From 7be0725bf860980d99156110647ef6c2f6721f53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Paj=C4=85k?= Date: Mon, 18 Nov 2024 12:18:17 +0100 Subject: [PATCH 01/13] feat: initialize statistics page charts --- apps/api/src/lessons/schemas/lesson.types.ts | 2 + .../statistics/api/statistics.controller.ts | 15 +- .../repositories/statistics.repository.ts | 156 +++++++- apps/api/src/statistics/statistics.module.ts | 4 +- apps/api/src/statistics/statistics.service.ts | 2 +- apps/api/src/swagger/api-schema.json | 130 +------ apps/web/app/api/queries/useUserStatistics.ts | 25 ++ apps/web/app/components/Gravatar.tsx | 8 +- apps/web/app/components/ui/CategoryChip.tsx | 4 +- apps/web/app/components/ui/calendar.tsx | 91 +++++ apps/web/app/components/ui/chart.tsx | 363 ++++++++++++++++++ apps/web/app/components/ui/skeleton.tsx | 9 + apps/web/app/config/navigationConfig.ts | 5 + apps/web/app/config/routeAccessConfig.ts | 1 + apps/web/app/index.css | 5 + .../modules/Statistics/Statistics.page.tsx | 202 ++++++++++ .../components/AvgPercentScoreChart.tsx | 96 +++++ .../components/ChartLegendBadge.tsx | 15 + .../components/ContinueLearningCard.tsx | 41 ++ .../components/ProfileWithCalendar.tsx | 115 ++++++ .../Statistics/components/RatesChart.tsx | 122 ++++++ apps/web/package.json | 2 + apps/web/routes.ts | 3 +- apps/web/tailwind.config.ts | 1 + pnpm-lock.yaml | 257 ++++++++++++- 25 files changed, 1518 insertions(+), 156 deletions(-) create mode 100644 apps/web/app/api/queries/useUserStatistics.ts create mode 100644 apps/web/app/components/ui/calendar.tsx create mode 100644 apps/web/app/components/ui/chart.tsx create mode 100644 apps/web/app/components/ui/skeleton.tsx create mode 100644 apps/web/app/modules/Statistics/Statistics.page.tsx create mode 100644 apps/web/app/modules/Statistics/components/AvgPercentScoreChart.tsx create mode 100644 apps/web/app/modules/Statistics/components/ChartLegendBadge.tsx create mode 100644 apps/web/app/modules/Statistics/components/ContinueLearningCard.tsx create mode 100644 apps/web/app/modules/Statistics/components/ProfileWithCalendar.tsx create mode 100644 apps/web/app/modules/Statistics/components/RatesChart.tsx diff --git a/apps/api/src/lessons/schemas/lesson.types.ts b/apps/api/src/lessons/schemas/lesson.types.ts index f4858a687..9cbaa6ac0 100644 --- a/apps/api/src/lessons/schemas/lesson.types.ts +++ b/apps/api/src/lessons/schemas/lesson.types.ts @@ -10,3 +10,5 @@ export const LessonProgress = { inProgress: "in_progress", completed: "completed", } as const; + +export type LessonProgressType = (typeof LessonProgress)[keyof typeof LessonProgress]; diff --git a/apps/api/src/statistics/api/statistics.controller.ts b/apps/api/src/statistics/api/statistics.controller.ts index 26c0159ad..05abd3749 100644 --- a/apps/api/src/statistics/api/statistics.controller.ts +++ b/apps/api/src/statistics/api/statistics.controller.ts @@ -1,25 +1,16 @@ import { Controller, Get } from "@nestjs/common"; -import { Validate } from "nestjs-typebox"; -import { baseResponse, BaseResponse, UUIDType } from "src/common"; +import { BaseResponse, UUIDType } from "src/common"; +import { CurrentUser } from "src/common/decorators/user.decorator"; -import { CurrentUser } from "../../common/decorators/user.decorator"; -import { UserStatsSchema } from "../schemas/userStats.schema"; import { StatisticsService } from "../statistics.service"; -import type { UserStats } from "../schemas/userStats.schema"; - @Controller("statistics") export class StatisticsController { constructor(private statisticsService: StatisticsService) {} @Get() - @Validate({ - response: baseResponse(UserStatsSchema), - }) - async getUserStatistics( - @CurrentUser("userId") currentUserId: UUIDType, - ): Promise> { + async getUserStatistics(@CurrentUser("userId") currentUserId: UUIDType) { return new BaseResponse(await this.statisticsService.getUserStats(currentUserId)); } } diff --git a/apps/api/src/statistics/repositories/statistics.repository.ts b/apps/api/src/statistics/repositories/statistics.repository.ts index f50bbc14b..15517a733 100644 --- a/apps/api/src/statistics/repositories/statistics.repository.ts +++ b/apps/api/src/statistics/repositories/statistics.repository.ts @@ -1,15 +1,21 @@ import { Inject, Injectable } from "@nestjs/common"; -import { startOfDay, differenceInDays, eachDayOfInterval, format } from "date-fns"; -import { and, eq, sql } from "drizzle-orm"; +import { differenceInDays, eachDayOfInterval, format, startOfDay } from "date-fns"; +import { and, desc, eq, sql } from "drizzle-orm"; import { DatabasePg } from "src/common"; - +import { LessonsRepository } from "src/lessons/repositories/lessons.repository"; +import { LessonProgress } from "src/lessons/schemas/lesson.types"; import { + lessonItems, + lessons, quizAttempts, + studentCompletedLessonItems, studentCourses, studentLessonsProgress, userStatistics, -} from "../../storage/schema"; +} from "src/storage/schema"; + +import type { LessonProgressType } from "src/lessons/schemas/lesson.types"; type Stats = { month: string; @@ -20,7 +26,10 @@ type Stats = { @Injectable() export class StatisticsRepository { - constructor(@Inject("DB") private readonly db: DatabasePg) {} + constructor( + @Inject("DB") private readonly db: DatabasePg, + private readonly lessonsRepository: LessonsRepository, + ) {} async getUserStats(userId: string) { const [quizStatsResult] = await this.db @@ -71,6 +80,29 @@ export class StatisticsRepository { .groupBy(sql`date_trunc('month', ${studentCourses.createdAt})`) .orderBy(sql`date_trunc('month', ${studentCourses.createdAt})`); + const [courseStats] = await this.db + .select({ + started: sql`count(*)::INTEGER`, + completed: sql`count(case when ${studentCourses.state} = 'completed' then 1 end)::INTEGER`, + completionRate: sql` + coalesce( + round( + (count(case when ${studentCourses.state} = 'completed' then 1 end)::numeric / + nullif(count(*)::numeric, 0)) * 100, + 2 + ), + 0 + )::INTEGER + `, + }) + .from(studentCourses) + .where( + and( + eq(studentCourses.studentId, userId), + sql`${studentCourses.createdAt} >= date_trunc('month', current_date) - interval '11 months'`, + ), + ); + const [activityStats] = await this.db .select() .from(userStatistics) @@ -99,10 +131,112 @@ export class StatisticsRepository { sql`${studentLessonsProgress.createdAt} >= date_trunc('month', current_date) - interval '11 months'`, ), ) - .groupBy(sql`date_trunc('month', ${studentLessonsProgress.createdAt})`) .orderBy(sql`date_trunc('month', ${studentLessonsProgress.createdAt})`); + const [lessonStats] = await this.db + .select({ + started: sql`count(distinct ${studentLessonsProgress.lessonId})::INTEGER`, + completed: sql`count(case when ${studentLessonsProgress.completedLessonItemCount} = ${studentLessonsProgress.lessonItemCount} then 1 end)::INTEGER`, + completionRate: sql` + coalesce( + round( + (count(case when ${studentLessonsProgress.completedLessonItemCount} = ${studentLessonsProgress.lessonItemCount} then 1 end)::numeric / + nullif(count(distinct ${studentLessonsProgress.lessonId})::numeric, 0)) * 100, + 2 + ), + 0 + )::INTEGER + `, + }) + .from(studentLessonsProgress) + .where( + and( + eq(studentLessonsProgress.studentId, userId), + sql`${studentLessonsProgress.createdAt} >= date_trunc('month', current_date) - interval '11 months'`, + ), + ); + + const [lastLessonItem] = await this.db + .select() + .from(studentCompletedLessonItems) + .where(and(eq(studentCompletedLessonItems.studentId, userId))) + .orderBy(desc(studentCompletedLessonItems.updatedAt)) + .limit(1); + + const lastLessonDetails = await this.lessonsRepository.getLessonForUser( + lastLessonItem.courseId, + lastLessonItem.lessonId, + userId, + ); + + const [lastLesson] = await this.db + .select({ + // TODO: Code below needs https://github.com/wielopolski love + lessonProgress: sql` + (CASE + WHEN ( + SELECT COUNT(*) + FROM ${lessonItems} + WHERE ${lessonItems.lessonId} = ${lastLessonItem.lessonId} + AND ${lessonItems.lessonItemType} != 'text_block' + ) = ( + SELECT COUNT(*) + FROM ${studentCompletedLessonItems} + WHERE ${studentCompletedLessonItems.lessonId} = ${lastLessonItem.lessonId} + AND ${studentCompletedLessonItems.courseId} = ${lastLessonItem.courseId} + AND ${studentCompletedLessonItems.studentId} = ${userId} + ) AND ( + SELECT COUNT(*) + FROM ${lessonItems} + WHERE ${lessonItems.lessonId} = ${lastLessonItem.lessonId} + AND ${lessonItems.lessonItemType} != 'text_block' + ) > 0 + THEN ${LessonProgress.completed} + WHEN ( + SELECT COUNT(*) + FROM ${studentCompletedLessonItems} + WHERE ${studentCompletedLessonItems.lessonId} = ${lastLessonItem.lessonId} + AND ${studentCompletedLessonItems.courseId} = ${lastLessonItem.courseId} + AND ${studentCompletedLessonItems.studentId} = ${userId} + ) > 0 + THEN ${LessonProgress.inProgress} + ELSE ${LessonProgress.notStarted} + END) + `, + itemsCount: sql` + (SELECT COUNT(*) + FROM ${lessonItems} + WHERE ${lessonItems.lessonId} = ${lastLessonItem.lessonId} + AND ${lessonItems.lessonItemType} != 'text_block')::INTEGER`, + itemsCompletedCount: sql` + (SELECT COUNT(*) + FROM ${studentCompletedLessonItems} + WHERE ${studentCompletedLessonItems.lessonId} = ${lastLessonItem.lessonId} + AND ${studentCompletedLessonItems.courseId} = ${lastLessonItem.courseId} + AND ${studentCompletedLessonItems.studentId} = ${userId})::INTEGER + `, + }) + .from(lessons) + .where(and(eq(lessons.id, lastLessonItem.lessonId))) + .leftJoin( + studentLessonsProgress, + and( + eq(studentLessonsProgress.studentId, userId), + eq(studentLessonsProgress.lessonId, lastLessonItem.lessonId), + eq(studentLessonsProgress.courseId, lastLessonItem.courseId), + ), + ) + .leftJoin(lessonItems, eq(studentLessonsProgress.lessonId, lessonItems.lessonId)) + .leftJoin( + studentCompletedLessonItems, + and( + eq(studentLessonsProgress.lessonId, studentCompletedLessonItems.lessonId), + eq(studentCompletedLessonItems.courseId, studentLessonsProgress.courseId), + ), + ); + + console.log(lastLesson); return { quizzes: { totalAttempts: Number(quizStats.totalAttempts), @@ -119,6 +253,14 @@ export class StatisticsRepository { activityHistory: activityStats?.activityHistory || {}, }, lessons: this.formatLessonStats(lessonStatsResult), + averageStats: { + lessonStats, + courseStats, + }, + lastLesson: { + ...lastLessonDetails, + ...lastLesson, + }, }; } @@ -130,7 +272,7 @@ export class StatisticsRepository { wrongAnswers: number; score: number; }) { - return await this.db.insert(quizAttempts).values(data); + return this.db.insert(quizAttempts).values(data); } async updateUserActivity(userId: string) { diff --git a/apps/api/src/statistics/statistics.module.ts b/apps/api/src/statistics/statistics.module.ts index de2ab9c03..50fba678c 100644 --- a/apps/api/src/statistics/statistics.module.ts +++ b/apps/api/src/statistics/statistics.module.ts @@ -1,6 +1,8 @@ import { Module } from "@nestjs/common"; import { CqrsModule } from "@nestjs/cqrs"; +import { LessonsRepository } from "src/lessons/repositories/lessons.repository"; + import { StatisticsController } from "./api/statistics.controller"; import { StatisticsHandler } from "./handlers/statistics.handler"; import { StatisticsRepository } from "./repositories/statistics.repository"; @@ -9,7 +11,7 @@ import { StatisticsService } from "./statistics.service"; @Module({ imports: [CqrsModule], controllers: [StatisticsController], - providers: [StatisticsHandler, StatisticsRepository, StatisticsService], + providers: [StatisticsHandler, StatisticsRepository, StatisticsService, LessonsRepository], exports: [StatisticsRepository], }) export class StatisticsModule {} diff --git a/apps/api/src/statistics/statistics.service.ts b/apps/api/src/statistics/statistics.service.ts index 5321fdabb..09a566549 100644 --- a/apps/api/src/statistics/statistics.service.ts +++ b/apps/api/src/statistics/statistics.service.ts @@ -7,6 +7,6 @@ export class StatisticsService { constructor(private statisticsRepository: StatisticsRepository) {} async getUserStats(userId: string) { - return this.statisticsRepository.getUserStats(userId); + return await this.statisticsRepository.getUserStats(userId); } } diff --git a/apps/api/src/swagger/api-schema.json b/apps/api/src/swagger/api-schema.json index 9c327bee2..0a00ad13e 100644 --- a/apps/api/src/swagger/api-schema.json +++ b/apps/api/src/swagger/api-schema.json @@ -2588,13 +2588,7 @@ "parameters": [], "responses": { "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GetUserStatisticsResponse" - } - } - } + "description": "" } } } @@ -6759,128 +6753,6 @@ "required": [ "data" ] - }, - "GetUserStatisticsResponse": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "quizzes": { - "type": "object", - "properties": { - "totalAttempts": { - "type": "number" - }, - "totalCorrectAnswers": { - "type": "number" - }, - "totalWrongAnswers": { - "type": "number" - }, - "totalQuestions": { - "type": "number" - }, - "averageScore": { - "type": "number" - }, - "uniqueQuizzesTaken": { - "type": "number" - } - }, - "required": [ - "totalAttempts", - "totalCorrectAnswers", - "totalWrongAnswers", - "totalQuestions", - "averageScore", - "uniqueQuizzesTaken" - ] - }, - "courses": { - "type": "object", - "patternProperties": { - "^(.*)$": { - "type": "object", - "properties": { - "started": { - "type": "number" - }, - "completed": { - "type": "number" - }, - "completionRate": { - "type": "number" - } - }, - "required": [ - "started", - "completed", - "completionRate" - ] - } - } - }, - "lessons": { - "type": "object", - "patternProperties": { - "^(.*)$": { - "type": "object", - "properties": { - "started": { - "type": "number" - }, - "completed": { - "type": "number" - }, - "completionRate": { - "type": "number" - } - }, - "required": [ - "started", - "completed", - "completionRate" - ] - } - } - }, - "streak": { - "type": "object", - "properties": { - "current": { - "type": "number" - }, - "longest": { - "type": "number" - }, - "activityHistory": { - "type": "object", - "patternProperties": { - "^(.*)$": { - "type": "boolean" - } - } - } - }, - "required": [ - "current", - "longest", - "activityHistory" - ] - } - }, - "required": [ - "quizzes", - "courses", - "lessons", - "streak" - ] - } - }, - "required": [ - "data" - ] } } } diff --git a/apps/web/app/api/queries/useUserStatistics.ts b/apps/web/app/api/queries/useUserStatistics.ts new file mode 100644 index 000000000..8291cbce2 --- /dev/null +++ b/apps/web/app/api/queries/useUserStatistics.ts @@ -0,0 +1,25 @@ +import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; + +import { ApiClient } from "../api-client"; + +import type { GetUserStatisticsResponse } from "../generated-api"; + +export const userStatistics = () => { + return { + queryKey: ["user-statistics"], + queryFn: async () => { + const response = await ApiClient.api.statisticsControllerGetUserStatistics(); + + return response.data; + }, + select: (data: GetUserStatisticsResponse) => data.data, + }; +}; + +export function useUserStatistics() { + return useQuery(userStatistics()); +} + +export function useUserStatisticsSuspense() { + return useSuspenseQuery(userStatistics()); +} diff --git a/apps/web/app/components/Gravatar.tsx b/apps/web/app/components/Gravatar.tsx index 7c6d8b593..c84f8a63e 100644 --- a/apps/web/app/components/Gravatar.tsx +++ b/apps/web/app/components/Gravatar.tsx @@ -3,19 +3,21 @@ import CryptoJS from "crypto-js"; import { AvatarImage } from "./ui/avatar"; type GravatarProps = { - email: string; + email: string | undefined; size?: number; className?: string; }; export const Gravatar = ({ email, size = 200, className = "" }: GravatarProps) => { - const hash = CryptoJS.MD5(email.toLowerCase().trim()).toString(); + const defaultGravatarHash = "27205e5c51cb03f862138b22bcb5dc20f94a342e744ff6df1b8dc8af3c865109"; + + const hash = email ? CryptoJS.MD5(email.toLowerCase().trim()).toString() : defaultGravatarHash; const gravatarUrl = `https://www.gravatar.com/avatar/${hash}?s=${size}&d=mp`; return ( & { + dates: Date[] | undefined; +}; + +type CustomDayContentProps = DayContentProps & { + dates: Date[] | undefined; +}; + +function CustomDayContent({ dates, ...props }: CustomDayContentProps) { + console.log(props.date); + if (dates?.includes(props.date)) { + return 🎉; + } + + return
{props.date.getDate()}
; +} + +function Calendar({ + className, + classNames, + showOutsideDays = true, + dates, + ...props +}: CalendarProps) { + return ( + , + PreviousMonthButton: ({ ...props }) =>
, + NextMonthButton: ({ ...props }) =>
, + }} + {...props} + /> + ); +} +Calendar.displayName = "Calendar"; + +export { Calendar }; diff --git a/apps/web/app/components/ui/chart.tsx b/apps/web/app/components/ui/chart.tsx new file mode 100644 index 000000000..6a21c68de --- /dev/null +++ b/apps/web/app/components/ui/chart.tsx @@ -0,0 +1,363 @@ +import * as React from "react" +import * as RechartsPrimitive from "recharts" + +import { cn } from "~/lib/utils" + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode + icon?: React.ComponentType + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ) +} + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a ") + } + + return context +} + +const ChartContainer = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + config: ChartConfig + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"] + } +>(({ id, className, children, config, ...props }, ref) => { + const uniqueId = React.useId() + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` + + return ( + +
+ + + {children} + +
+
+ ) +}) +ChartContainer.displayName = "Chart" + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([_, config]) => config.theme || config.color + ) + + if (!colorConfig.length) { + return null + } + + return ( +