Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions apps/admin/src/features/dashboard/courseEnrollmentChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { CourseEnrollmentDistribution } from "@opencircle/core";
import { useId } from "react";
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";

import { ChartSkeleton } from "./chartSkeleton";

interface CourseEnrollmentChartProps {
data: CourseEnrollmentDistribution[];
isLoading?: boolean;
}

export const CourseEnrollmentChart = ({
data,
isLoading,
}: CourseEnrollmentChartProps) => {
const gradientId = useId();

if (isLoading) {
return <ChartSkeleton height={300} />;
}

const chartData = data.map((item) => ({
name:
item.courseTitle.length > 12
? `${item.courseTitle.slice(0, 12)}...`
: item.courseTitle,
enrollments: item.enrollmentCount,
}));

const hasData = chartData.some((item) => item.enrollments > 0);

return (
<div className="rounded-lg bg-background-secondary p-6 shadow">
<h3 className="mb-4 font-semibold text-foreground text-lg">
Course Enrollment Distribution
</h3>
{hasData ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart
data={chartData}
margin={{ top: 8, right: 12, bottom: 8, left: 0 }}
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#6366f1" stopOpacity={0.8} />
<stop offset="100%" stopColor="#6366f1" stopOpacity={0.3} />
</linearGradient>
</defs>
<CartesianGrid
stroke="currentColor"
strokeOpacity={0.08}
vertical={false}
/>
<XAxis dataKey="name" tickLine={false} axisLine={false} />
<YAxis tickLine={false} axisLine={false} />
<Tooltip
cursor={{ fill: "var(--background)", opacity: 0.5 }}
contentStyle={{
backgroundColor: "var(--background)",
border: "1px solid var(--border)",
borderRadius: 8,
}}
labelStyle={{ color: "var(--foreground)" }}
itemStyle={{ color: "var(--foreground)" }}
/>
<Bar
dataKey="enrollments"
name="Enrollments"
fill={`url(#${gradientId})`}
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
) : (
<div className="flex h-[300px] items-center justify-center text-foreground-secondary">
No enrollment data available
</div>
)}
</div>
);
};
116 changes: 116 additions & 0 deletions apps/admin/src/features/dashboard/userGrowthChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import type { UserGrowthData } from "@opencircle/core";
import { format } from "date-fns";
import { useId } from "react";
import {
Area,
AreaChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";

import { ChartSkeleton } from "./chartSkeleton";

interface UserGrowthChartProps {
data: UserGrowthData[];
isLoading?: boolean;
}

export const UserGrowthChart = ({ data, isLoading }: UserGrowthChartProps) => {
const newUsersGradientId = useId();
const cumulativeGradientId = useId();

if (isLoading) {
return <ChartSkeleton height={300} />;
}

const chartData = data.map((item) => ({
date: format(new Date(item.date), "MMM dd"),
"New Users": item.newUsers,
"Total Users": item.cumulativeUsers,
}));

const hasData = chartData.length > 0;

return (
<div className="rounded-lg bg-background-secondary p-6 shadow">
<h3 className="mb-4 font-semibold text-foreground text-lg">
User Growth Over Time
</h3>
{hasData ? (
<ResponsiveContainer width="100%" height={300}>
<AreaChart
data={chartData}
margin={{ top: 8, right: 12, bottom: 8, left: 0 }}
>
<defs>
<linearGradient
id={newUsersGradientId}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="0%" stopColor="#6366f1" stopOpacity={0.25} />
<stop offset="100%" stopColor="#6366f1" stopOpacity={0} />
</linearGradient>
<linearGradient
id={cumulativeGradientId}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="0%" stopColor="#22c55e" stopOpacity={0.25} />
<stop offset="100%" stopColor="#22c55e" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid
stroke="currentColor"
strokeOpacity={0.08}
vertical={false}
/>
<XAxis dataKey="date" tickLine={false} axisLine={false} />
<YAxis tickLine={false} axisLine={false} />
<Tooltip
cursor={{ stroke: "#4b5563", strokeDasharray: "3 3" }}
contentStyle={{
backgroundColor: "var(--background)",
border: "1px solid var(--border)",
borderRadius: 8,
}}
labelStyle={{ color: "var(--foreground)" }}
itemStyle={{ color: "var(--foreground)" }}
/>
<Legend />
<Area
type="linear"
dataKey="New Users"
stroke="#6366f1"
strokeWidth={2}
fill={`url(#${newUsersGradientId})`}
dot={false}
activeDot={{ r: 4 }}
/>
<Area
type="linear"
dataKey="Total Users"
stroke="#22c55e"
strokeWidth={2}
fill={`url(#${cumulativeGradientId})`}
dot={false}
activeDot={{ r: 4 }}
/>
</AreaChart>
</ResponsiveContainer>
) : (
<div className="flex h-[300px] items-center justify-center text-foreground-secondary">
No user growth data available
</div>
)}
</div>
);
};
35 changes: 34 additions & 1 deletion apps/admin/src/routes/_dashboardLayout/dashboard/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { DashboardStats } from "@opencircle/core";
import type {
CourseEnrollmentDistribution,
DashboardStats,
UserGrowthData,
} from "@opencircle/core";
import { Button } from "@opencircle/ui";
import { useQuery } from "@tanstack/react-query";
import { createFileRoute, Link } from "@tanstack/react-router";
Expand All @@ -23,6 +27,8 @@ import {
YAxis,
} from "recharts";
import { METADATA } from "../../../constants/metadata";
import { CourseEnrollmentChart } from "../../../features/dashboard/courseEnrollmentChart";
import { UserGrowthChart } from "../../../features/dashboard/userGrowthChart";
import { api } from "../../../utils/api";

export const Route = createFileRoute("/_dashboardLayout/dashboard/")({
Expand Down Expand Up @@ -99,6 +105,21 @@ function RouteComponent() {
staleTime: 5 * 60 * 1000,
});

const { data: courseEnrollmentData, isLoading: courseEnrollmentLoading } =
useQuery<CourseEnrollmentDistribution[]>({
queryKey: ["dashboard", "course-enrollment-distribution"],
queryFn: () => api.courses.getCourseEnrollmentDistribution(5),
staleTime: 5 * 60 * 1000,
});

const { data: userGrowthData, isLoading: userGrowthLoading } = useQuery<
UserGrowthData[]
>({
queryKey: ["dashboard", "user-growth"],
queryFn: () => api.courses.getUserGrowthData(30),
staleTime: 5 * 60 * 1000,
});

const isLoading =
statsLoading || channelsLoading || articlesLoading || resourcesLoading;

Expand Down Expand Up @@ -314,6 +335,18 @@ function RouteComponent() {
)}
</div>

{/* Course Enrollment & User Growth Charts */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<CourseEnrollmentChart
data={courseEnrollmentData || []}
isLoading={courseEnrollmentLoading}
/>
<UserGrowthChart
data={userGrowthData || []}
isLoading={userGrowthLoading}
/>
</div>

{/* Quick Stats & Links */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Activity Stats */}
Expand Down
2 changes: 1 addition & 1 deletion apps/admin/src/utils/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export const api = createApi(API_URL, {
if (response.status === 401 && !request.url.includes("/refresh")) {
const newToken = await getValidAccessToken();
if (newToken) {
const url = request.url.replace(API_URL + "/", "");
const url = request.url.replace(`${API_URL}/`, "");
return kyClient(url, {
method: request.method,
headers: {
Expand Down
2 changes: 1 addition & 1 deletion apps/platform/src/utils/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export const api = createApi(API_URL, {
if (response.status === 401 && !request.url.includes("/refresh")) {
const newToken = await getValidAccessToken();
if (newToken) {
const url = request.url.replace(API_URL + "/", "");
const url = request.url.replace(`${API_URL}/`, "");
return kyClient(url, {
method: request.method,
headers: {
Expand Down
91 changes: 90 additions & 1 deletion packages/core/src/services/routers/courses/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { BaseRouter } from "../../../utils/baseRouter";
import type {
Course,
CourseCreate,
CourseEnrollmentDistribution,
CourseUpdate,
DashboardStats,
EnrolledCourse,
Expand All @@ -14,6 +15,7 @@ import type {
Section,
SectionCreate,
SectionUpdate,
UserGrowthData,
} from "../../types";

export class CoursesRouter extends BaseRouter {
Expand Down Expand Up @@ -267,6 +269,93 @@ export class CoursesRouter extends BaseRouter {
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
) as EnrollmentChartData[];
}

async getCourseEnrollmentDistribution(
limit = 5,
): Promise<CourseEnrollmentDistribution[]> {
const [courses, enrollments] = await Promise.all([
this.getAllCourses(0, 1000),
this.getAllEnrollments(undefined, undefined, undefined, 0, 10000),
]);

const courseEnrollmentMap = new Map<
string,
{ title: string; count: number }
>();
courses.forEach((course) => {
courseEnrollmentMap.set(course.id, { title: course.title, count: 0 });
});

enrollments.forEach((enrollment) => {
const courseData = courseEnrollmentMap.get(enrollment.course_id);
if (courseData) {
courseData.count += 1;
}
});

return Array.from(courseEnrollmentMap.entries())
.map(([courseId, data]) => ({
courseId,
courseTitle: data.title,
enrollmentCount: data.count,
}))
.sort((a, b) => b.enrollmentCount - a.enrollmentCount)
.slice(0, limit);
}

async getUserGrowthData(days = 30): Promise<UserGrowthData[]> {
const users =
await this.client.get<{ id: string; created_at: string }[]>(
"users/?limit=10000",
);

const endDate = new Date();
const startDate = new Date();
startDate.setDate(endDate.getDate() - days);

const dateMap = new Map<string, number>();
for (
let d = new Date(startDate);
d <= endDate;
d.setDate(d.getDate() + 1)
) {
const dateStr = d.toISOString().split("T")[0];
dateMap.set(dateStr, 0);
}

users.forEach((user) => {
if (user.created_at) {
const createdDate = new Date(user.created_at)
.toISOString()
.split("T")[0];
if (dateMap.has(createdDate)) {
const currentCount = dateMap.get(createdDate) || 0;
dateMap.set(createdDate, currentCount + 1);
}
}
});

let cumulative = users.filter((u) => {
const createdDate = new Date(u.created_at);
return createdDate < startDate;
}).length;

return Array.from(dateMap.entries())
.sort(([a], [b]) => new Date(a).getTime() - new Date(b).getTime())
.map(([date, newUsers]) => {
cumulative += newUsers;
return {
date,
newUsers,
cumulativeUsers: cumulative,
};
});
}
}

export type { DashboardStats, EnrollmentChartData } from "./types";
export type {
CourseEnrollmentDistribution,
DashboardStats,
EnrollmentChartData,
UserGrowthData,
} from "./types";
Loading