feat: add grades chart
All checks were successful
Deploy to Netlify / Deploy to Netlify (push) Successful in 1m51s

This commit is contained in:
Nathan Lamy 2025-08-22 11:58:20 +02:00
parent 135058e2e1
commit b42236e730
4 changed files with 244 additions and 112 deletions

View file

@ -0,0 +1,110 @@
import { CartesianGrid, Legend, Line, LineChart, XAxis, YAxis } from "recharts";
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "../ui/chart";
import { getSubjectEmoji, getSubjectColor } from "~/lib/utils";
import type { PeriodResult, User } from "~/lib/api";
import { useEffect, useState } from "react";
export default function AverageChart({
averages,
subjects,
user,
}: {
averages: PeriodResult[];
subjects: string[];
user: User;
}) {
const [hiddenSubjects, setHiddenSubjects] = useState<string[]>([]);
const toggleSubjectVisibility = (subject: string) => {
setHiddenSubjects((prev) =>
prev.includes(subject)
? prev.filter((s) => s !== subject)
: [...prev, subject]
);
};
const [min, setMin] = useState(0);
const [max, setMax] = useState(20);
useEffect(() => {
if (averages.length > 0) {
const filteredGrades = averages.map((o) =>
Object.entries(o)
.filter(([key, value]) => {
return !hiddenSubjects.includes(key) && !isNaN(parseFloat(value));
})
.map(([_, v]) => parseFloat(v))
.filter(Boolean)
);
setMin(getMin(filteredGrades));
setMax(getMax(filteredGrades));
}
}, [averages, hiddenSubjects]);
return (
<ChartContainer config={{}} className="h-full w-full -ml-6 max-w-2xl">
<LineChart data={averages}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="period" />
<YAxis domain={[min, max]} />
<ChartTooltip content={<ChartTooltipContent />} />
<Legend
onClick={(e) => {
if (e.dataKey) {
toggleSubjectVisibility(e.dataKey as string);
}
}}
/>
{subjects.map((subject, index) => (
<Line
key={index}
type="monotone"
dataKey={subject}
name={subject + " " + getSubjectEmoji(subject, user.preferences)}
hide={hiddenSubjects.includes(subject)}
stroke={`var(--color-${getSubjectColor(
subject,
user.preferences
)}-800`}
dot={{
fill: `var(--color-${getSubjectColor(
subject,
user.preferences
)}-800`,
stroke: `var(--color-${getSubjectColor(
subject,
user.preferences
)}-800)`,
}}
/>
))}
<Line
type="monotone"
dataKey="average"
name="Moyenne"
stroke="var(--primary)"
strokeWidth={2}
dot={false}
strokeDasharray="5 5"
hide={hiddenSubjects.includes("average")}
/>
</LineChart>
</ChartContainer>
);
}
function getMin(grades: number[][]) {
return Math.max(
Math.round(
Math.min(...grades.map((g) => Math.min(...g)).filter(Boolean)) - 1
),
0
);
}
function getMax(grades: number[][]) {
return Math.min(
Math.round(
Math.max(...grades.map((g) => Math.max(...g)).filter(Boolean)) + 1
),
20
);
}

View file

@ -0,0 +1,97 @@
import { CartesianGrid, Legend, Line, LineChart, XAxis, YAxis } from "recharts";
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "../ui/chart";
import { getSubjectEmoji, getSubjectColor } from "~/lib/utils";
import type { Grade, User } from "~/lib/api";
import { useEffect, useState } from "react";
import { DateTime } from "luxon";
export default function GradesChart({
grades,
subjects,
user,
}: {
grades: Grade[];
subjects: string[];
user: User;
}) {
const [hiddenSubjects, setHiddenSubjects] = useState<string[]>([]);
const toggleSubjectVisibility = (subject: string) => {
setHiddenSubjects((prev) =>
prev.includes(subject)
? prev.filter((s) => s !== subject)
: [...prev, subject]
);
};
const [min, setMin] = useState(0);
const [max, setMax] = useState(20);
useEffect(() => {
if (grades.length > 0) {
const filteredGrades = grades
.filter(
(g) => !hiddenSubjects.includes(g.subject) && !isNaN(Number(g.grade))
)
.map((g) => Number(g.grade));
setMin(Math.max(0, Math.min(...filteredGrades) - 1));
setMax(Math.min(20, Math.max(...filteredGrades) + 1));
}
}, [grades, hiddenSubjects]);
return (
<ChartContainer config={{}} className="h-full w-full -ml-6 max-w-2xl">
<LineChart data={transformGrades(grades, subjects)}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis domain={[min, max]} />
<ChartTooltip content={<ChartTooltipContent />} />
<Legend
onClick={(e) => {
if (e.dataKey) {
toggleSubjectVisibility(e.dataKey as string);
}
}}
/>
{subjects.map((subject, index) => (
<Line
key={index}
type="linear"
dataKey={subject}
name={subject + " " + getSubjectEmoji(subject, user.preferences)}
hide={hiddenSubjects.includes(subject)}
stroke={`var(--color-${getSubjectColor(
subject,
user.preferences
)}-800`}
dot={{
fill: `var(--color-${getSubjectColor(
subject,
user.preferences
)}-800`,
stroke: `var(--color-${getSubjectColor(
subject,
user.preferences
)}-800)`,
}}
connectNulls
/>
))}
</LineChart>
</ChartContainer>
);
}
function transformGrades(data: Grade[], subjects: string[]) {
const grouped: Record<string, any> = {};
data.forEach((g) => {
const d = DateTime.fromMillis(g.date).toISODate()!;
if (!grouped[d]) {
grouped[d] = { date: d };
// initialize all subjects with null
subjects.forEach((s) => (grouped[d][s] = null));
}
grouped[d][g.subject] = Number(g.grade); // or keep as string
});
return Object.values(grouped);
}

View file

@ -1,23 +1,17 @@
import {
CartesianGrid,
Legend,
Line,
LineChart,
PolarAngleAxis,
PolarGrid,
PolarRadiusAxis,
Radar,
RadarChart,
ResponsiveContainer,
XAxis,
YAxis,
} from "recharts";
import { useAverages, useGrades, type User } from "~/lib/api";
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "../ui/chart";
import { Award } from "lucide-react";
import { getSubjectColor, getSubjectEmoji } from "~/lib/utils";
import { useEffect, useState } from "react";
import { useState } from "react";
import { Tabs, TabsList, tabsStyle, TabsTrigger } from "../ui/tabs";
import AverageChart from "./average-chart";
import GradesChart from "./grades-chart";
const periods = [
{
@ -37,7 +31,7 @@ const periods = [
export default function GradesPage({ user }: { user: User }) {
const [period, setPeriod] = useState(periods[0].id);
const { grades, subjects, isLoading, isError } = useGrades(period);
const { grades, averages, subjects, isLoading, isError } = useGrades(period);
const {
subjectAverages,
globalAverage,
@ -45,32 +39,6 @@ export default function GradesPage({ user }: { user: User }) {
isError: error,
} = useAverages(period);
const [hiddenSubjects, setHiddenSubjects] = useState<string[]>([]);
const toggleSubjectVisibility = (subject: string) => {
setHiddenSubjects((prev) =>
prev.includes(subject)
? prev.filter((s) => s !== subject)
: [...prev, subject]
);
};
const [min, setMin] = useState(0);
const [max, setMax] = useState(20);
useEffect(() => {
if (grades.length > 0) {
const filteredGrades = grades.map((o) =>
Object.entries(o)
.filter(([key, value]) => {
return !hiddenSubjects.includes(key) && !isNaN(parseFloat(value));
})
.map(([_, v]) => parseFloat(v))
.filter(Boolean)
);
setMin(getMin(filteredGrades));
setMax(getMax(filteredGrades));
}
}, [grades, hiddenSubjects]);
return (
<div className="space-y-6 pb-20 md:pb-0">
{/* Tabs */}
@ -135,61 +103,28 @@ export default function GradesPage({ user }: { user: User }) {
</div>
</div>
<div className="bg-muted/50 rounded-lg py-4">
<ChartContainer
config={{}}
className="h-full w-full -ml-6 max-w-2xl"
>
<LineChart data={grades}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="period" />
<YAxis domain={[min, max]} />
<ChartTooltip content={<ChartTooltipContent />} />
<Legend
onClick={(e) => {
if (e.dataKey) {
toggleSubjectVisibility(e.dataKey as string);
}
}}
/>
{subjects.map((subject, index) => (
<Line
key={index}
type="monotone"
dataKey={subject}
name={
subject +
" " +
getSubjectEmoji(subject, user.preferences)
}
hide={hiddenSubjects.includes(subject)}
stroke={`var(--color-${getSubjectColor(
subject,
user.preferences
)}-800`}
dot={{
fill: `var(--color-${getSubjectColor(
subject,
user.preferences
)}-800`,
stroke: `var(--color-${getSubjectColor(
subject,
user.preferences
)}-800)`,
}}
/>
))}
<Line
type="monotone"
dataKey="average"
name="Moyenne"
stroke="var(--primary)"
strokeWidth={2}
dot={false}
strokeDasharray="5 5"
hide={hiddenSubjects.includes("average")}
/>
</LineChart>
</ChartContainer>
<GradesChart grades={grades} subjects={subjects} user={user} />
</div>
</div>
{/* Grade Trends */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg md:text-xl font-semibold">
Vos moyennes par matière
</h2>
<p className="text-sm text-muted-foreground">
Comparez vos moyennes par matière
</p>
</div>
</div>
<div className="bg-muted/50 rounded-lg py-4">
<AverageChart
averages={averages}
subjects={subjects}
user={user}
/>
</div>
</div>
@ -235,21 +170,3 @@ export default function GradesPage({ user }: { user: User }) {
</div>
);
}
function getMin(grades: number[][]) {
return Math.max(
Math.round(
Math.min(...grades.map((g) => Math.min(...g)).filter(Boolean)) - 1
),
0
);
}
function getMax(grades: number[][]) {
return Math.min(
Math.round(
Math.max(...grades.map((g) => Math.max(...g)).filter(Boolean)) + 1
),
20
);
}

View file

@ -405,11 +405,13 @@ export const useGrades = (period: string) => {
days: 3, // 3 days
}).toMillis(),
});
const grades = (data?.grades as PeriodResult[]) || [];
const averages = (data?.averages as PeriodResult[]) || [];
const subjects = (data?.subjects as string[]) || [];
const grades = (data?.grades as Grade[]) || [];
return {
grades,
averages,
subjects,
grades,
...props,
};
};
@ -419,11 +421,17 @@ interface SubjectPerformance {
average: number;
}
interface PeriodResult {
export interface PeriodResult {
period: string;
average: number;
}
export interface Grade {
subject: string;
date: number; // timestamp
grade: string;
}
export const getAverages = async (period: string) => {
return makeRequest(
`/averages?period=${encodeURIComponent(period)}`,