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 7c4cb0dba..a5923f533 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": "" } } } @@ -6753,128 +6747,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/ui/calendar.tsx b/apps/web/app/components/ui/calendar.tsx index 956130ae0..9a657693f 100644 --- a/apps/web/app/components/ui/calendar.tsx +++ b/apps/web/app/components/ui/calendar.tsx @@ -4,10 +4,32 @@ import { buttonVariants } from "~/components/ui/button"; import { cn } from "~/lib/utils"; import type { ComponentProps } from "react"; +import type { DayContentProps } from "react-day-picker"; -export type CalendarProps = ComponentProps; +export type CalendarProps = ComponentProps & { + dates: Date[] | undefined; +}; -function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) { +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 }) =>
, }} diff --git a/apps/web/app/modules/Statistics/Statistics.page.tsx b/apps/web/app/modules/Statistics/Statistics.page.tsx index beb5190a2..69bfef545 100644 --- a/apps/web/app/modules/Statistics/Statistics.page.tsx +++ b/apps/web/app/modules/Statistics/Statistics.page.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { useCurrentUser } from "~/api/queries"; +import { useUserStatistics } from "~/api/queries/useUserStatistics"; import { Gravatar } from "~/components/Gravatar"; import { Avatar } from "~/components/ui/avatar"; import { AvgPercentScoreChart } from "~/modules/Statistics/components/AvgPercentScoreChart"; @@ -10,25 +11,9 @@ import { RatesChart } from "~/modules/Statistics/components/RatesChart"; import type { ChartConfig } from "~/components/ui/chart"; -const chartData1 = [ - { state: "Correct", percentage: 72, fill: "var(--primary-700)" }, - { state: "Wrong", percentage: 28, fill: "var(--primary-300)" }, -]; - -const chartConfig1 = { - completed: { - label: "Correct answers", - color: "var(--primary-700)", - }, - notCompleted: { - label: "Wrong answers", - color: "var(--primary-300)", - }, -} satisfies ChartConfig; - const chartData2 = [ - { state: "Completed", percentage: 92, fill: "var(--primary-700)" }, - { state: "Started", percentage: 8, fill: "var(--primary-300)" }, + { state: "Completed", percentage: 0, fill: "var(--primary-700)" }, + { state: "Started", percentage: 1, fill: "var(--primary-300)" }, ]; const chartConfig2 = { @@ -43,28 +28,125 @@ const chartConfig2 = { } satisfies ChartConfig; const chartData = [ - { month: "January", completed: 1, started: 2 }, - { month: "February", completed: 2, started: 3 }, - { month: "March", completed: 3, started: 5 }, - { month: "April", completed: 4, started: 6 }, - { month: "May", completed: 5, started: 6 }, - { month: "June", completed: 6, started: 7 }, - { month: "July", completed: 7, started: 8 }, - { month: "August", completed: 8, started: 8 }, - { month: "September", completed: 8, started: 9 }, - { month: "October", completed: 8, started: 9 }, - { month: "November", completed: 9, started: 9 }, - { month: "December", completed: 9, started: 9 }, + { month: "January", completed: 0, started: 0 }, + { month: "February", completed: 0, started: 0 }, + { month: "March", completed: 0, started: 0 }, + { month: "April", completed: 0, started: 0 }, + { month: "May", completed: 0, started: 0 }, + { month: "June", completed: 0, started: 0 }, + { month: "July", completed: 0, started: 0 }, + { month: "August", completed: 0, started: 0 }, + { month: "September", completed: 0, started: 0 }, + { month: "October", completed: 0, started: 0 }, + { month: "November", completed: 0, started: 0 }, + { month: "December", completed: 0, started: 0 }, ]; export default function StatisticsPage() { const { data: user } = useCurrentUser(); + const { data: userStatistics } = useUserStatistics(); + + console.log({ userStatistics }); + + const chartData1 = [ + { state: "Correct", percentage: 0, fill: "var(--primary-700)" }, + { state: "Wrong", percentage: 1, fill: "var(--primary-300)" }, + ]; + + const chartConfig1 = { + completed: { + label: "Correct answers", + color: "var(--primary-700)", + }, + notCompleted: { + label: "Wrong answers", + color: "var(--primary-300)", + }, + } satisfies ChartConfig; + + const coursesChartData = [ + { + state: "Completed Courses", + percentage: userStatistics?.averageStats.courseStats.completed, + fill: "var(--primary-700)", + }, + { + state: "Started Courses", + percentage: userStatistics?.averageStats.courseStats.started, + fill: "var(--primary-700)", + }, + ]; + + const coursesChartConfig = { + completed: { + label: "Completed", + color: "var(--primary-700)", + }, + notCompleted: { + label: "Started", + color: "var(--primary-300)", + }, + } satisfies ChartConfig; + + const quizesChartData = [ + { + state: "Correct Answers", + percentage: userStatistics?.quizzes.totalCorrectAnswers, + fill: "var(--primary-700)", + }, + { + state: "Wrong Answers", + percentage: userStatistics?.quizzes.totalWrongAnswers, + fill: "var(--primary-700)", + }, + ]; + + const quizesChartConfig = { + completed: { + label: "Correct answers", + color: "var(--primary-700)", + }, + notCompleted: { + label: "Wrong answers", + color: "var(--primary-300)", + }, + } satisfies ChartConfig; + + function transformData(data) { + const months = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ]; + + return months.map((month) => { + return { + month: month, + completed: data?.[month]?.completed, + started: data?.[month]?.started, + }; + }); + } + + const lessonRatesChartData = transformData(userStatistics?.lessons); + const coursesRatesChartData = transformData(userStatistics?.courses); + const [isLoading, setIsLoading] = useState(true); useEffect(() => { setTimeout(() => { setIsLoading(false); }, 1000); }, []); + return (
@@ -76,32 +158,44 @@ export default function StatisticsPage() {
- +
- +
- +
- +
); diff --git a/apps/web/app/modules/Statistics/components/AvgCoursePercentCompletition.tsx b/apps/web/app/modules/Statistics/components/AvgCoursePercentCompletition.tsx deleted file mode 100644 index d185e872c..000000000 --- a/apps/web/app/modules/Statistics/components/AvgCoursePercentCompletition.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { Label, Pie, PieChart } from "recharts"; - -import { CategoryChip } from "~/components/ui/CategoryChip"; -import { ChartContainer, ChartTooltip, ChartTooltipContent } from "~/components/ui/chart"; - -import type { ChartConfig } from "~/components/ui/chart"; - -const chartData = [ - { state: "correct", percentage: 92, fill: "var(--primary-700)" }, - { state: "wrong", percentage: 8, fill: "var(--neutral-100)" }, -]; - -const chartConfig = { - completed: { - label: "Correct answers", - color: "var(--primary-700)", - }, - notCompleted: { - label: "Wrong answers", - color: "var(--neutral-300)", - }, -} satisfies ChartConfig; - -export const AvgCoursePercentCompletition = () => { - return ( -
-

- Avg. Percent of Course Completition -

-
- - - } /> - - - - -
-
- - -
-
- ); -}; diff --git a/apps/web/app/modules/Statistics/components/AvgQuizzesPercentScore.tsx b/apps/web/app/modules/Statistics/components/AvgQuizzesPercentScore.tsx deleted file mode 100644 index 66a9a0f16..000000000 --- a/apps/web/app/modules/Statistics/components/AvgQuizzesPercentScore.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { Label, Pie, PieChart } from "recharts"; - -import { CategoryChip } from "~/components/ui/CategoryChip"; -import { ChartContainer, ChartTooltip, ChartTooltipContent } from "~/components/ui/chart"; - -import type { ChartConfig } from "~/components/ui/chart"; - -const chartData = [ - { answers: "correct", percentage: 74, fill: "var(--primary-700)" }, - { answers: "wrong", percentage: 26, fill: "var(--neutral-100)" }, -]; - -const chartConfig = { - correct: { - label: "Correct answers", - color: "var(--primary-700)", - }, - wrong: { - label: "Wrong answers", - color: "var(--neutral-300)", - }, -} satisfies ChartConfig; - -export const AvgQuizzesPercentScore = () => { - return ( -
-

Avg. Percent Score of Quizzes

-
- - - } /> - - - - -
-
- - -
-
- ); -}; diff --git a/apps/web/app/modules/Statistics/components/ContinueLearningCard.tsx b/apps/web/app/modules/Statistics/components/ContinueLearningCard.tsx index 2bdad97a0..4d8c458a0 100644 --- a/apps/web/app/modules/Statistics/components/ContinueLearningCard.tsx +++ b/apps/web/app/modules/Statistics/components/ContinueLearningCard.tsx @@ -1,11 +1,14 @@ import { Skeleton } from "~/components/ui/skeleton"; import { LessonCard } from "~/modules/Courses/CourseView/LessonCard"; +import type { GetUserStatisticsResponse } from "~/api/generated-api"; + type ContinueLearningCardProps = { isLoading: boolean; + lesson: GetUserStatisticsResponse["data"]["lastLesson"]; }; -export const ContinueLearningCard = ({ isLoading = false }: ContinueLearningCardProps) => { +export const ContinueLearningCard = ({ isLoading = false, lesson }: ContinueLearningCardProps) => { if (isLoading) { return (
@@ -27,15 +30,11 @@ export const ContinueLearningCard = ({ isLoading = false }: ContinueLearningCard
); diff --git a/apps/web/app/modules/Statistics/components/ProfileWithCalendar.tsx b/apps/web/app/modules/Statistics/components/ProfileWithCalendar.tsx index 3dfadd817..be106f1b3 100644 --- a/apps/web/app/modules/Statistics/components/ProfileWithCalendar.tsx +++ b/apps/web/app/modules/Statistics/components/ProfileWithCalendar.tsx @@ -6,11 +6,12 @@ import { Button } from "~/components/ui/button"; import { Calendar } from "~/components/ui/calendar"; import { Skeleton } from "~/components/ui/skeleton"; -import type { CurrentUserResponse } from "~/api/generated-api"; +import type { CurrentUserResponse, GetUserStatisticsResponse } from "~/api/generated-api"; type ProfileWithCalendar = { user: CurrentUserResponse["data"] | undefined; isLoading: boolean; + streakStatistics: GetUserStatisticsResponse["data"]["streak"]; }; export const ProfileWithCalendar = ({ user, isLoading = true }: ProfileWithCalendar) => { @@ -101,11 +102,11 @@ export const ProfileWithCalendar = ({ user, isLoading = true }: ProfileWithCalen
diff --git a/apps/web/app/modules/Statistics/components/RatesChart.tsx b/apps/web/app/modules/Statistics/components/RatesChart.tsx index c8f97dc1e..338eb2a1c 100644 --- a/apps/web/app/modules/Statistics/components/RatesChart.tsx +++ b/apps/web/app/modules/Statistics/components/RatesChart.tsx @@ -56,7 +56,7 @@ export const RatesChart = ({ isLoading = false, resourceName, chartData }: Rates ))}
{Array.from({ length: 12 }).map((_, index) => ( -
+
{Array.from({ length: 2 }).map((_, index) => ( ))} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6072b3600..92238d4d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -401,6 +401,9 @@ importers: react: specifier: ^18.3.1 version: 18.3.1 + react-day-picker: + specifier: 8.10.1 + version: 8.10.1(date-fns@3.6.0)(react@18.3.1) react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) @@ -413,6 +416,9 @@ importers: react-use: specifier: ^17.5.1 version: 17.5.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + recharts: + specifier: ^2.13.3 + version: 2.13.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) socket.io-client: specifier: ^4.8.0 version: 4.8.0 @@ -4451,6 +4457,33 @@ packages: '@types/crypto-js@4.2.2': resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==} + '@types/d3-array@3.2.1': + resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.0': + resolution: {integrity: sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==} + + '@types/d3-scale@4.0.8': + resolution: {integrity: sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==} + + '@types/d3-shape@3.1.6': + resolution: {integrity: sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==} + + '@types/d3-time@3.0.3': + resolution: {integrity: sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -5910,6 +5943,50 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.0: + resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -5986,6 +6063,9 @@ packages: resolution: {integrity: sha512-Fv96DCsdOgB6mdGl67MT5JaTNKRzrzill5OH5s8bjYJXVlcXyPYGyPsUkWyGV5p1TXI5esYIYMMeDJL0hEIwaA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.4.3: resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} @@ -6144,6 +6224,9 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -6685,6 +6768,9 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} @@ -6745,6 +6831,10 @@ packages: fast-diff@1.3.0: resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + fast-equals@5.0.1: + resolution: {integrity: sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==} + engines: {node: '>=6.0.0'} + fast-fifo@1.3.2: resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} @@ -7298,6 +7388,10 @@ packages: resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==} engines: {node: '>= 0.4'} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + interpret@1.4.0: resolution: {integrity: sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==} engines: {node: '>= 0.10'} @@ -9424,6 +9518,12 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true + react-day-picker@8.10.1: + resolution: {integrity: sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==} + peerDependencies: + date-fns: ^2.28.0 || ^3.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: @@ -9527,6 +9627,12 @@ packages: peerDependencies: react: '>=16.8' + react-smooth@4.0.1: + resolution: {integrity: sha512-OE4hm7XqR0jNOq3Qmk9mFLyd6p2+j6bvbPJ7qlB7+oo0eNcL2l7WQzG6MBnT3EXY6xzkLMUBec3AfewJdA0J8w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-style-singleton@2.2.1: resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==} engines: {node: '>=10'} @@ -9537,6 +9643,12 @@ packages: '@types/react': optional: true + react-transition-group@4.4.5: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + react-universal-interface@0.6.2: resolution: {integrity: sha512-dg8yXdcQmvgR13RIlZbTRQOoUrDciFVoSBZILwjE2LFISxZZ8loVJKAkuzswl5js8BHda79bIb2b84ehU8IjXw==} peerDependencies: @@ -9574,6 +9686,16 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + recharts-scale@0.4.5: + resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} + + recharts@2.13.3: + resolution: {integrity: sha512-YDZ9dOfK9t3ycwxgKbrnDlRC4BHdjlY73fet3a0C1+qGMjXVZe6+VXmpOIIhzkje5MMEL8AN4hLIe4AMskBzlA==} + engines: {node: '>=14'} + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 + rechoir@0.6.2: resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==} engines: {node: '>= 0.10'} @@ -10890,6 +11012,9 @@ packages: vfile@5.3.7: resolution: {integrity: sha512-r7qlzkgErKjobAmyNIkkSpizsFPYiUPuJb5pNW1RB4JcYVZhs4lIbVqk8XPk033CV/1z8ss5pkax8SuhGpcG8g==} + victory-vendor@36.9.2: + resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + vite-node@1.6.0: resolution: {integrity: sha512-de6HJgzC+TFzOu0NTC4RAIsyf/DY/ibWDYQUcuEA84EMHhcefTUGkjFHKKEJhQN4A+6I0u++kr3l36ZF2d7XRw==} engines: {node: ^18.0.0 || >=20.0.0} @@ -12501,7 +12626,6 @@ snapshots: '@babel/runtime@7.26.0': dependencies: regenerator-runtime: 0.14.1 - optional: true '@babel/template@7.25.0': dependencies: @@ -15948,6 +16072,30 @@ snapshots: '@types/crypto-js@4.2.2': {} + '@types/d3-array@3.2.1': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.0': {} + + '@types/d3-scale@4.0.8': + dependencies: + '@types/d3-time': 3.0.3 + + '@types/d3-shape@3.1.6': + dependencies: + '@types/d3-path': 3.1.0 + + '@types/d3-time@3.0.3': {} + + '@types/d3-timer@3.0.2': {} + '@types/debug@4.1.12': dependencies: '@types/ms': 0.7.34 @@ -17862,6 +18010,44 @@ snapshots: csstype@3.1.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.0: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.0 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + damerau-levenshtein@1.0.8: {} data-uri-to-buffer@3.0.1: {} @@ -17915,6 +18101,8 @@ snapshots: decamelize@6.0.0: {} + decimal.js-light@2.5.1: {} + decimal.js@10.4.3: {} decode-named-character-reference@1.0.2: @@ -18056,6 +18244,11 @@ snapshots: dom-accessibility-api@0.6.3: {} + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.26.0 + csstype: 3.1.3 + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -18829,6 +19022,8 @@ snapshots: event-target-shim@5.0.1: {} + eventemitter3@4.0.7: {} + eventemitter3@5.0.1: {} events@1.1.1: {} @@ -18943,6 +19138,8 @@ snapshots: fast-diff@1.3.0: {} + fast-equals@5.0.1: {} + fast-fifo@1.3.2: {} fast-glob@3.3.2: @@ -19614,6 +19811,8 @@ snapshots: hasown: 2.0.2 side-channel: 1.0.6 + internmap@2.0.3: {} + interpret@1.4.0: {} interpret@2.2.0: @@ -22206,6 +22405,11 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 + react-day-picker@8.10.1(date-fns@3.6.0)(react@18.3.1): + dependencies: + date-fns: 3.6.0 + react: 18.3.1 + react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 @@ -22371,6 +22575,14 @@ snapshots: '@remix-run/router': 1.18.0 react: 18.3.1 + react-smooth@4.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + fast-equals: 5.0.1 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-style-singleton@2.2.1(@types/react@18.2.61)(react@18.3.1): dependencies: get-nonce: 1.0.1 @@ -22389,6 +22601,15 @@ snapshots: optionalDependencies: '@types/react': 18.3.4 + react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.26.0 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-universal-interface@0.6.2(react@18.3.1)(tslib@2.6.3): dependencies: react: 18.3.1 @@ -22453,6 +22674,23 @@ snapshots: dependencies: picomatch: 2.3.1 + recharts-scale@0.4.5: + dependencies: + decimal.js-light: 2.5.1 + + recharts@2.13.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + clsx: 2.1.1 + eventemitter3: 4.0.7 + lodash: 4.17.21 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-is: 18.3.1 + react-smooth: 4.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + recharts-scale: 0.4.5 + tiny-invariant: 1.3.3 + victory-vendor: 36.9.2 + rechoir@0.6.2: dependencies: resolve: 1.22.8 @@ -24037,6 +24275,23 @@ snapshots: unist-util-stringify-position: 3.0.3 vfile-message: 3.1.4 + victory-vendor@36.9.2: + dependencies: + '@types/d3-array': 3.2.1 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.8 + '@types/d3-shape': 3.1.6 + '@types/d3-time': 3.0.3 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite-node@1.6.0(@types/node@20.14.14)(terser@5.31.4): dependencies: cac: 6.7.14