feat: add grades chart
All checks were successful
Deploy to Netlify / Deploy to Netlify (push) Successful in 1m51s
All checks were successful
Deploy to Netlify / Deploy to Netlify (push) Successful in 1m51s
This commit is contained in:
parent
135058e2e1
commit
b42236e730
4 changed files with 244 additions and 112 deletions
110
app/components/grades/average-chart.tsx
Normal file
110
app/components/grades/average-chart.tsx
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
97
app/components/grades/grades-chart.tsx
Normal file
97
app/components/grades/grades-chart.tsx
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -1,23 +1,17 @@
|
||||||
import {
|
import {
|
||||||
CartesianGrid,
|
|
||||||
Legend,
|
|
||||||
Line,
|
|
||||||
LineChart,
|
|
||||||
PolarAngleAxis,
|
PolarAngleAxis,
|
||||||
PolarGrid,
|
PolarGrid,
|
||||||
PolarRadiusAxis,
|
PolarRadiusAxis,
|
||||||
Radar,
|
Radar,
|
||||||
RadarChart,
|
RadarChart,
|
||||||
ResponsiveContainer,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { useAverages, useGrades, type User } from "~/lib/api";
|
import { useAverages, useGrades, type User } from "~/lib/api";
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "../ui/chart";
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "../ui/chart";
|
||||||
import { Award } from "lucide-react";
|
import { Award } from "lucide-react";
|
||||||
import { getSubjectColor, getSubjectEmoji } from "~/lib/utils";
|
import { useState } from "react";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Tabs, TabsList, tabsStyle, TabsTrigger } from "../ui/tabs";
|
import { Tabs, TabsList, tabsStyle, TabsTrigger } from "../ui/tabs";
|
||||||
|
import AverageChart from "./average-chart";
|
||||||
|
import GradesChart from "./grades-chart";
|
||||||
|
|
||||||
const periods = [
|
const periods = [
|
||||||
{
|
{
|
||||||
|
|
@ -37,7 +31,7 @@ const periods = [
|
||||||
export default function GradesPage({ user }: { user: User }) {
|
export default function GradesPage({ user }: { user: User }) {
|
||||||
const [period, setPeriod] = useState(periods[0].id);
|
const [period, setPeriod] = useState(periods[0].id);
|
||||||
|
|
||||||
const { grades, subjects, isLoading, isError } = useGrades(period);
|
const { grades, averages, subjects, isLoading, isError } = useGrades(period);
|
||||||
const {
|
const {
|
||||||
subjectAverages,
|
subjectAverages,
|
||||||
globalAverage,
|
globalAverage,
|
||||||
|
|
@ -45,32 +39,6 @@ export default function GradesPage({ user }: { user: User }) {
|
||||||
isError: error,
|
isError: error,
|
||||||
} = useAverages(period);
|
} = 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 (
|
return (
|
||||||
<div className="space-y-6 pb-20 md:pb-0">
|
<div className="space-y-6 pb-20 md:pb-0">
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
|
|
@ -135,61 +103,28 @@ export default function GradesPage({ user }: { user: User }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-muted/50 rounded-lg py-4">
|
<div className="bg-muted/50 rounded-lg py-4">
|
||||||
<ChartContainer
|
<GradesChart grades={grades} subjects={subjects} user={user} />
|
||||||
config={{}}
|
</div>
|
||||||
className="h-full w-full -ml-6 max-w-2xl"
|
</div>
|
||||||
>
|
|
||||||
<LineChart data={grades}>
|
{/* Grade Trends */}
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<div className="space-y-4">
|
||||||
<XAxis dataKey="period" />
|
<div className="flex items-center justify-between">
|
||||||
<YAxis domain={[min, max]} />
|
<div>
|
||||||
<ChartTooltip content={<ChartTooltipContent />} />
|
<h2 className="text-lg md:text-xl font-semibold">
|
||||||
<Legend
|
Vos moyennes par matière
|
||||||
onClick={(e) => {
|
</h2>
|
||||||
if (e.dataKey) {
|
<p className="text-sm text-muted-foreground">
|
||||||
toggleSubjectVisibility(e.dataKey as string);
|
Comparez vos moyennes par matière
|
||||||
}
|
</p>
|
||||||
}}
|
</div>
|
||||||
/>
|
</div>
|
||||||
{subjects.map((subject, index) => (
|
<div className="bg-muted/50 rounded-lg py-4">
|
||||||
<Line
|
<AverageChart
|
||||||
key={index}
|
averages={averages}
|
||||||
type="monotone"
|
subjects={subjects}
|
||||||
dataKey={subject}
|
user={user}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -235,21 +170,3 @@ export default function GradesPage({ user }: { user: User }) {
|
||||||
</div>
|
</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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -405,11 +405,13 @@ export const useGrades = (period: string) => {
|
||||||
days: 3, // 3 days
|
days: 3, // 3 days
|
||||||
}).toMillis(),
|
}).toMillis(),
|
||||||
});
|
});
|
||||||
const grades = (data?.grades as PeriodResult[]) || [];
|
const averages = (data?.averages as PeriodResult[]) || [];
|
||||||
const subjects = (data?.subjects as string[]) || [];
|
const subjects = (data?.subjects as string[]) || [];
|
||||||
|
const grades = (data?.grades as Grade[]) || [];
|
||||||
return {
|
return {
|
||||||
grades,
|
averages,
|
||||||
subjects,
|
subjects,
|
||||||
|
grades,
|
||||||
...props,
|
...props,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -419,11 +421,17 @@ interface SubjectPerformance {
|
||||||
average: number;
|
average: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PeriodResult {
|
export interface PeriodResult {
|
||||||
period: string;
|
period: string;
|
||||||
average: number;
|
average: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Grade {
|
||||||
|
subject: string;
|
||||||
|
date: number; // timestamp
|
||||||
|
grade: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const getAverages = async (period: string) => {
|
export const getAverages = async (period: string) => {
|
||||||
return makeRequest(
|
return makeRequest(
|
||||||
`/averages?period=${encodeURIComponent(period)}`,
|
`/averages?period=${encodeURIComponent(period)}`,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue