feat: add line chart
This commit is contained in:
parent
dc5e10efd1
commit
e6ba7e0d9a
5 changed files with 182 additions and 38 deletions
23
app/app.css
23
app/app.css
|
|
@ -153,3 +153,26 @@ body {
|
||||||
[data-sonner-toaster][data-sonner-theme='dark'] [data-description] {
|
[data-sonner-toaster][data-sonner-theme='dark'] [data-description] {
|
||||||
color: var(--primary) !important;
|
color: var(--primary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--color-red-800: theme('colors.red.800');
|
||||||
|
--color-orange-800: theme('colors.orange.800');
|
||||||
|
--color-amber-800: theme('colors.amber.800');
|
||||||
|
--color-yellow-800: theme('colors.yellow.800');
|
||||||
|
--color-lime-800: theme('colors.lime.800');
|
||||||
|
--color-green-800: theme('colors.green.800');
|
||||||
|
--color-emerald-800: theme('colors.emerald.800');
|
||||||
|
--color-teal-800: theme('colors.teal.800');
|
||||||
|
--color-cyan-800: theme('colors.cyan.800');
|
||||||
|
--color-sky-800: theme('colors.sky.800');
|
||||||
|
--color-blue-800: theme('colors.blue.800');
|
||||||
|
--color-indigo-800: theme('colors.indigo.800');
|
||||||
|
--color-violet-800: theme('colors.violet.800');
|
||||||
|
--color-purple-800: theme('colors.purple.800');
|
||||||
|
--color-fuchsia-800: theme('colors.fuchsia.800');
|
||||||
|
--color-pink-800: theme('colors.pink.800');
|
||||||
|
--color-rose-800: theme('colors.rose.800');
|
||||||
|
--color-slate-800: theme('colors.slate.800');
|
||||||
|
--color-gray-800: theme('colors.gray.800');
|
||||||
|
--color-zinc-800: theme('colors.zinc.800');
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ export default function AttachmentItem({ attachment }: { attachment: Attachment
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="flex items-center gap-2 p-3 border rounded-md hover:bg-muted transition-colors cursor-pointer"
|
className="flex items-center gap-2 p-3 border rounded-md hover:bg-muted transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
{getIcon(attachment.name)}
|
{getIcon(attachment.path)}
|
||||||
<span className="font-medium truncate">
|
<span className="font-medium truncate">
|
||||||
{attachment.name}
|
{attachment.name}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -320,9 +320,6 @@ export default function SchoolStatsPage() {
|
||||||
<Award className="h-4 w-4 text-muted-foreground" />
|
<Award className="h-4 w-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xl md:text-2xl font-bold">{currentGPA}</div>
|
<div className="text-xl md:text-2xl font-bold">{currentGPA}</div>
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
Dean's List
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-muted/50 rounded-lg p-4 space-y-2">
|
<div className="bg-muted/50 rounded-lg p-4 space-y-2">
|
||||||
|
|
|
||||||
|
|
@ -7,50 +7,131 @@ import {
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { 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 { chartConfig, gradesTrendData } from "./inde";
|
import { Award } from "lucide-react";
|
||||||
|
import { getSubjectColor, getSubjectEmoji } from "~/lib/utils";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
export default function GradesPage({ user }: { user: User }) {
|
export default function GradesPage({ user }: { user: User }) {
|
||||||
// TODO: Error handling, loading state
|
// TODO: Error handling, loading state
|
||||||
const { grades, subjects, isLoading, isError } = useGrades("YEAR")
|
const { grades, subjects, isLoading, isError } = useGrades("YEAR");
|
||||||
|
const {
|
||||||
|
subjectAverages,
|
||||||
|
globalAverage,
|
||||||
|
isLoading: loading,
|
||||||
|
isError: error,
|
||||||
|
} = useAverages("YEAR");
|
||||||
|
|
||||||
|
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>
|
<div className="space-y-6 pb-20 md:pb-0">
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
|
|
||||||
|
{/* Global average */}
|
||||||
|
<div className="bg-muted/80 dark:bg-muted/30 rounded-lg p-4 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs md:text-sm font-medium text-muted-foreground">
|
||||||
|
Moyenne générale
|
||||||
|
</span>
|
||||||
|
<Award className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="text-xl md:text-2xl font-bold">{globalAverage} / 20</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Charts - Mobile First Layout */}
|
{/* Charts - Mobile First Layout */}
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Grade Trends */}
|
{/* Grade Trends */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg md:text-xl font-semibold">Grade Trends</h2>
|
<h2 className="text-lg md:text-xl font-semibold">
|
||||||
|
Votre progression
|
||||||
|
</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
performance across subjects
|
Suivez vos notes au fil du temps
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-muted/30 rounded-lg">
|
<div className="bg-muted/80 dark:bg-muted/30 rounded-lg py-4">
|
||||||
<ChartContainer config={chartConfig} className="h-full w-full -ml-6">
|
<ChartContainer
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
config={{}}
|
||||||
|
className="h-full w-full -ml-6 max-w-2xl"
|
||||||
|
>
|
||||||
<LineChart data={grades}>
|
<LineChart data={grades}>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
<XAxis dataKey="period" />
|
<XAxis dataKey="period" />
|
||||||
{/* TODO: Custom domain */}
|
{/* TODO: Custom domain */}
|
||||||
<YAxis domain={[0, 20]} />
|
<YAxis domain={[min, max]} />
|
||||||
<ChartTooltip content={<ChartTooltipContent />} />
|
<ChartTooltip content={<ChartTooltipContent />} />
|
||||||
<Legend />
|
<Legend
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.dataKey) {
|
||||||
|
toggleSubjectVisibility(e.dataKey as string);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{subjects.map((subject, index) => (
|
{subjects.map((subject, index) => (
|
||||||
<Line
|
<Line
|
||||||
key={index}
|
key={index}
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey={subject}
|
dataKey={subject}
|
||||||
name={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>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -58,3 +139,21 @@ 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -404,22 +404,47 @@ export const useGrades = (period: string) => {
|
||||||
days: 3, // 3 days
|
days: 3, // 3 days
|
||||||
}).toMillis(),
|
}).toMillis(),
|
||||||
});
|
});
|
||||||
const grades = data?.grades as PeriodResult[] || [];
|
const grades = (data?.grades as PeriodResult[]) || [];
|
||||||
const subjects = data?.subjects as string[] || [];
|
const subjects = (data?.subjects as string[]) || [];
|
||||||
return {
|
return {
|
||||||
grades,
|
grades,
|
||||||
subjects,
|
subjects,
|
||||||
...props,
|
...props,
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
interface SubjectPerformance {
|
interface SubjectPerformance {
|
||||||
subject: string
|
subject: string;
|
||||||
average: number
|
average: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PeriodResult {
|
interface PeriodResult {
|
||||||
period: string
|
period: string;
|
||||||
average: number
|
average: number;
|
||||||
subjectAverages: SubjectPerformance[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getAverages = async (period: string) => {
|
||||||
|
return makeRequest(
|
||||||
|
`/averages?period=${encodeURIComponent(period)}`,
|
||||||
|
"Échec de la récupération des moyennes"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export const useAverages = (period: string) => {
|
||||||
|
const { data, ...props } = useQuery({
|
||||||
|
queryKey: ["averages", period],
|
||||||
|
queryFn: () => getAverages(period),
|
||||||
|
staleTime: Duration.fromObject({
|
||||||
|
hours: 0, // 1 hour
|
||||||
|
}).toMillis(),
|
||||||
|
gcTime: Duration.fromObject({
|
||||||
|
days: 3, // 3 days
|
||||||
|
}).toMillis(),
|
||||||
|
});
|
||||||
|
const subjectAverages = (data?.subjectAverages as SubjectPerformance[]) || [];
|
||||||
|
const globalAverage = data?.globalAverage || 0;
|
||||||
|
return {
|
||||||
|
subjectAverages,
|
||||||
|
globalAverage,
|
||||||
|
...props,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue