feat: add grades & averages

This commit is contained in:
Nathan Lamy 2025-08-20 16:09:06 +02:00
parent eb8bb8331f
commit dc5e10efd1
10 changed files with 1539 additions and 1 deletions

View file

@ -123,6 +123,24 @@
} }
} }
@layer base {
:root {
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
}
.dark {
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
}
}
html, html,
body { body {
@apply w-full h-full; @apply w-full h-full;

View file

@ -0,0 +1,732 @@
import { useState } from "react";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "~/components/ui/chart";
import {
LineChart,
Line,
BarChart,
Bar,
PieChart,
Pie,
Cell,
AreaChart,
Area,
RadarChart,
PolarGrid,
PolarAngleAxis,
PolarRadiusAxis,
Radar,
XAxis,
YAxis,
CartesianGrid,
ResponsiveContainer,
Legend,
Tooltip,
ReferenceLine,
} from "recharts";
import {
Maximize2,
X,
TrendingUp,
Award,
BookOpen,
Target,
} from "lucide-react";
export const gradesTrendData = {
"This Month": [
{
period: "Week 1",
math: 89,
science: 88,
english: 96,
history: 92,
average: 91.25,
},
{
period: "Week 2",
math: 91,
science: 89,
english: 97,
history: 93,
average: 92.5,
},
{
period: "Week 3",
math: 94,
science: 91,
english: 93,
history: 95,
average: 93.25,
},
{
period: "Week 4",
math: 92,
science: 90,
english: 98,
history: 94,
average: 93.5,
},
],
"3 Months": [
{
period: "Dec",
math: 89,
science: 88,
english: 96,
history: 92,
average: 91.25,
},
{
period: "Jan",
math: 94,
science: 91,
english: 93,
history: 95,
average: 93.25,
},
{
period: "Feb",
math: 91,
science: 89,
english: 97,
history: 93,
average: 92.5,
},
],
"Whole Year": [
{
period: "Sep",
math: 85,
science: 78,
english: 92,
history: 88,
average: 85.75,
},
{
period: "Oct",
math: 88,
science: 82,
english: 89,
history: 91,
average: 87.5,
},
{
period: "Nov",
math: 92,
science: 85,
english: 94,
history: 89,
average: 90,
},
{
period: "Dec",
math: 89,
science: 88,
english: 96,
history: 92,
average: 91.25,
},
{
period: "Jan",
math: 94,
science: 91,
english: 93,
history: 95,
average: 93.25,
},
{
period: "Feb",
math: 91,
science: 89,
english: 97,
history: 93,
average: 92.5,
},
],
};
const subjectPerformanceData = {
"This Month": [
{ subject: "Mathematics", grade: 94, target: 90, credits: 4 },
{ subject: "Science", grade: 91, target: 85, credits: 4 },
{ subject: "English", grade: 98, target: 92, credits: 3 },
{ subject: "History", grade: 95, target: 88, credits: 3 },
{ subject: "Art", grade: 96, target: 90, credits: 2 },
{ subject: "PE", grade: 90, target: 85, credits: 1 },
],
"3 Months": [
{ subject: "Mathematics", grade: 92, target: 90, credits: 4 },
{ subject: "Science", grade: 90, target: 85, credits: 4 },
{ subject: "English", grade: 96, target: 92, credits: 3 },
{ subject: "History", grade: 93, target: 88, credits: 3 },
{ subject: "Art", grade: 95, target: 90, credits: 2 },
{ subject: "PE", grade: 89, target: 85, credits: 1 },
],
"Whole Year": [
{ subject: "Mathematics", grade: 91, target: 90, credits: 4 },
{ subject: "Science", grade: 89, target: 85, credits: 4 },
{ subject: "English", grade: 97, target: 92, credits: 3 },
{ subject: "History", grade: 93, target: 88, credits: 3 },
{ subject: "Art", grade: 95, target: 90, credits: 2 },
{ subject: "PE", grade: 88, target: 85, credits: 1 },
],
};
const gradeDistributionData = {
"This Month": [
{ grade: "A+", count: 12, percentage: 35 },
{ grade: "A", count: 15, percentage: 44 },
{ grade: "A-", count: 5, percentage: 15 },
{ grade: "B+", count: 2, percentage: 6 },
{ grade: "B", count: 0, percentage: 0 },
],
"3 Months": [
{ grade: "A+", count: 10, percentage: 30 },
{ grade: "A", count: 14, percentage: 42 },
{ grade: "A-", count: 6, percentage: 18 },
{ grade: "B+", count: 3, percentage: 9 },
{ grade: "B", count: 1, percentage: 3 },
],
"Whole Year": [
{ grade: "A+", count: 8, percentage: 25 },
{ grade: "A", count: 12, percentage: 37.5 },
{ grade: "A-", count: 6, percentage: 18.75 },
{ grade: "B+", count: 4, percentage: 12.5 },
{ grade: "B", count: 2, percentage: 6.25 },
],
};
const skillsRadar = [
{ skill: "Problem Solving", score: 92 },
{ skill: "Critical Thinking", score: 88 },
{ skill: "Communication", score: 95 },
{ skill: "Creativity", score: 87 },
{ skill: "Collaboration", score: 91 },
{ skill: "Leadership", score: 85 },
];
const COLORS = [
"#8884d8",
"#82ca9d",
"#ffc658",
"#ff7300",
"#00ff00",
"#ff00ff",
];
export const chartConfig = {
math: { label: "Mathematics", color: "hsl(var(--chart-1))" },
science: { label: "Science", color: "hsl(var(--chart-2))" },
english: { label: "English", color: "hsl(var(--chart-3))" },
history: { label: "History", color: "hsl(var(--chart-4))" },
average: { label: "Average", color: "hsl(var(--chart-5))" },
};
export default function SchoolStatsPage() {
const [fullscreenChart, setFullscreenChart] = useState<string | null>(null);
const [selectedPeriod, setSelectedPeriod] = useState<
"This Month" | "3 Months" | "Whole Year"
>("3 Months");
const gradesTrend = gradesTrendData[selectedPeriod];
const subjectPerformance = subjectPerformanceData[selectedPeriod];
const gradeDistribution = gradeDistributionData[selectedPeriod];
const FullscreenModal = ({
chartId,
title,
children,
}: {
chartId: string;
title: string;
children: React.ReactNode;
}) => {
if (fullscreenChart !== chartId) return null;
return (
<div className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-2">
<div
className="bg-background rounded-lg w-full h-full flex flex-col
w-[100vh] h-[100vw] rotate-90 origin-center"
>
<div className="flex items-center justify-between p-3 border-b shrink-0">
<h2 className="text-lg md:text-xl lg:text-2xl font-bold rotate-0">
{title}
</h2>
<Button
variant="ghost"
size="icon"
onClick={() => setFullscreenChart(null)}
className="rotate-0"
>
<X className="h-5 w-5 md:h-6 md:w-6" />
</Button>
</div>
<div className="p-3 md:p-4">
{children}
</div>
</div>
</div>
);
};
const currentGPA = 3.7;
const totalCredits = 17;
const completedAssignments = 156;
const upcomingTests = 3;
return (
<div className="min-h-screen bg-background">
<div className="w-full max-w-7xl mx-auto px-4 py-6 space-y-8">
{/* Header */}
<div className="text-center space-y-3">
<h1 className="text-2xl md:text-4xl font-bold">Academic Dashboard</h1>
<p className="text-sm md:text-base text-muted-foreground">
Track your grades and progress
</p>
</div>
<div className="flex justify-center">
<div className="bg-muted/50 rounded-lg p-1 flex gap-1">
{(["This Month", "3 Months", "Whole Year"] as const).map(
(period) => (
<Button
key={period}
variant={selectedPeriod === period ? "default" : "ghost"}
size="sm"
onClick={() => setSelectedPeriod(period)}
className="text-xs md:text-sm"
>
{period}
</Button>
)
)}
</div>
</div>
{/* Key Stats - Mobile First Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-muted/50 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">
Current GPA
</span>
<Award className="h-4 w-4 text-muted-foreground" />
</div>
<div className="text-xl md:text-2xl font-bold">{currentGPA}</div>
<Badge variant="secondary" className="text-xs">
Dean's List
</Badge>
</div>
<div className="bg-muted/50 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">
Credits
</span>
<BookOpen className="h-4 w-4 text-muted-foreground" />
</div>
<div className="text-xl md:text-2xl font-bold">{totalCredits}</div>
<p className="text-xs text-muted-foreground">This semester</p>
</div>
<div className="bg-muted/50 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">
Assignments
</span>
<Target className="h-4 w-4 text-muted-foreground" />
</div>
<div className="text-xl md:text-2xl font-bold">
{completedAssignments}
</div>
<p className="text-xs text-muted-foreground">Completed</p>
</div>
<div className="bg-muted/50 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">
Tests
</span>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</div>
<div className="text-xl md:text-2xl font-bold">{upcomingTests}</div>
<p className="text-xs text-muted-foreground">Upcoming</p>
</div>
</div>
{/* Charts - Mobile First Layout */}
<div className="space-y-8">
{/* Grade Trends */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg md:text-xl font-semibold">
Grade Trends
</h2>
<p className="text-sm text-muted-foreground">
{selectedPeriod === "This Month"
? "Weekly"
: selectedPeriod === "3 Months"
? "Monthly"
: "Monthly"}{" "}
performance across subjects
</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setFullscreenChart("trends")}
>
<Maximize2 className="h-4 w-4" />
</Button>
</div>
<div className="bg-muted/30 rounded-lg">
<ChartContainer
config={chartConfig}
className="h-full w-full -ml-6"
>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={gradesTrend}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="period" />
<YAxis domain={[70, 100]} />
<ChartTooltip content={<ChartTooltipContent />} />
<Legend />
<Line type="monotone" dataKey="math" stroke="blue" />
<Area
type="monotone"
dataKey="science"
stackId="2"
stroke="var(--color-science)"
fill="var(--color-science)"
fillOpacity={0.3}
/>
<Area
type="monotone"
dataKey="english"
stackId="3"
stroke="var(--color-english)"
fill="var(--color-english)"
fillOpacity={0.3}
/>
<Area
type="monotone"
dataKey="history"
stackId="4"
stroke="var(--color-history)"
fill="var(--color-history)"
fillOpacity={0.3}
/>
<Line
type="monotone"
dataKey="average"
stroke="var(--color-average)"
strokeWidth={4}
/>
</LineChart>
</ResponsiveContainer>
</ChartContainer>
</div>
</div>
{/* Subject Performance */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg md:text-xl font-semibold">
Subject Performance
</h2>
<p className="text-sm text-muted-foreground">
Current grades vs targets
</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setFullscreenChart("performance")}
>
<Maximize2 className="h-4 w-4" />
</Button>
</div>
<div className="bg-muted/30 rounded-lg p-4">
<ChartContainer
config={chartConfig}
className="h-[250px] md:h-[300px] w-full"
>
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={subjectPerformance}
layout="horizontal"
margin={{ top: 5, right: 10, left: 60, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" domain={[0, 100]} />
<YAxis dataKey="subject" type="category" width={50} />
<ChartTooltip content={<ChartTooltipContent />} />
<Legend />
<Bar
dataKey="grade"
fill="hsl(var(--chart-1))"
name="Current"
/>
<Bar
dataKey="target"
fill="hsl(var(--chart-2))"
name="Target"
/>
</BarChart>
</ResponsiveContainer>
</ChartContainer>
</div>
</div>
{/* Grade Distribution & Skills - Side by Side on Desktop */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Grade Distribution */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg md:text-xl font-semibold">
Grade Distribution
</h2>
<p className="text-sm text-muted-foreground">
Breakdown of all grades
</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setFullscreenChart("distribution")}
>
<Maximize2 className="h-4 w-4" />
</Button>
</div>
<div className="bg-muted/30 rounded-lg p-4">
<ChartContainer
config={chartConfig}
className="h-[250px] md:h-[300px] w-full"
>
<ResponsiveContainer width="100%" height="100%">
<PieChart margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
<Pie
data={gradeDistribution}
cx="50%"
cy="50%"
labelLine={false}
label={({ grade, percentage }) =>
`${grade} (${percentage}%)`
}
outerRadius={80}
fill="#8884d8"
dataKey="count"
>
{gradeDistribution.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={COLORS[index % COLORS.length]}
/>
))}
</Pie>
<ChartTooltip content={<ChartTooltipContent />} />
</PieChart>
</ResponsiveContainer>
</ChartContainer>
</div>
</div>
{/* Skills Assessment */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg md:text-xl font-semibold">
Skills Assessment
</h2>
<p className="text-sm text-muted-foreground">
Competency across key areas
</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setFullscreenChart("skills")}
>
<Maximize2 className="h-4 w-4" />
</Button>
</div>
<div className="bg-muted/30 rounded-lg p-4">
<ChartContainer
config={chartConfig}
className="h-[250px] md:h-[300px] w-full"
>
<ResponsiveContainer width="100%" height="100%">
<RadarChart
data={skillsRadar}
margin={{ top: 20, right: 20, bottom: 20, left: 20 }}
>
<PolarGrid />
<PolarAngleAxis dataKey="skill" />
<PolarRadiusAxis angle={90} domain={[0, 100]} />
<Radar
name="Skills"
dataKey="score"
stroke="hsl(var(--chart-1))"
fill="hsl(var(--chart-1))"
fillOpacity={0.3}
/>
<ChartTooltip content={<ChartTooltipContent />} />
</RadarChart>
</ResponsiveContainer>
</ChartContainer>
</div>
</div>
</div>
</div>
{/* Fullscreen Modals */}
<FullscreenModal
chartId="trends"
title={`Grade Trends - ${selectedPeriod}`}
>
<ChartContainer config={chartConfig} className="h-full w-full z-100 ">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={gradesTrend}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="period" />
<YAxis domain={[70, 100]} />
<ChartTooltip content={<ChartTooltipContent />} />
<Legend />
<Area
type="monotone"
dataKey="math"
stackId="1"
stroke="blue"
fill="blue"
fillOpacity={0.3}
/>
<Area
type="monotone"
dataKey="science"
stackId="2"
stroke="var(--color-science)"
fill="var(--color-science)"
fillOpacity={0.3}
/>
<Area
type="monotone"
dataKey="english"
stackId="3"
stroke="var(--color-english)"
fill="var(--color-english)"
fillOpacity={0.3}
/>
<Area
type="monotone"
dataKey="history"
stackId="4"
stroke="var(--color-history)"
fill="var(--color-history)"
fillOpacity={0.3}
/>
<Line
type="monotone"
dataKey="average"
stroke="var(--color-average)"
strokeWidth={4}
/>
</AreaChart>
</ResponsiveContainer>
</ChartContainer>
</FullscreenModal>
<FullscreenModal
chartId="performance"
title={`Subject Performance - ${selectedPeriod}`}
>
<ChartContainer config={chartConfig} className="h-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={subjectPerformance}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="subject" />
<YAxis domain={[0, 100]} />
<ChartTooltip content={<ChartTooltipContent />} />
<Legend />
<Bar
dataKey="grade"
fill="hsl(var(--chart-1))"
name="Current Grade"
/>
<Bar
dataKey="target"
fill="hsl(var(--chart-2))"
name="Target Grade"
/>
</BarChart>
</ResponsiveContainer>
</ChartContainer>
</FullscreenModal>
<FullscreenModal
chartId="distribution"
title={`Grade Distribution - ${selectedPeriod}`}
>
<ChartContainer config={chartConfig} className="h-full">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={gradeDistribution}
cx="50%"
cy="50%"
labelLine={false}
label={({ grade, percentage }) => `${grade} (${percentage}%)`}
outerRadius={200}
fill="#8884d8"
dataKey="count"
>
{gradeDistribution.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={COLORS[index % COLORS.length]}
/>
))}
</Pie>
<ChartTooltip content={<ChartTooltipContent />} />
<Legend />
</PieChart>
</ResponsiveContainer>
</ChartContainer>
</FullscreenModal>
<FullscreenModal
chartId="skills"
title="Skills Assessment - Fullscreen"
>
<ChartContainer config={chartConfig} className="h-full">
<ResponsiveContainer width="100%" height="100%">
<RadarChart data={skillsRadar}>
<PolarGrid />
<PolarAngleAxis dataKey="skill" />
<PolarRadiusAxis angle={90} domain={[0, 100]} />
<Radar
name="Skills"
dataKey="score"
stroke="hsl(var(--chart-1))"
fill="hsl(var(--chart-1))"
fillOpacity={0.3}
strokeWidth={3}
/>
<ChartTooltip content={<ChartTooltipContent />} />
</RadarChart>
</ResponsiveContainer>
</ChartContainer>
</FullscreenModal>
</div>
</div>
);
}

View file

@ -0,0 +1,60 @@
import {
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
XAxis,
YAxis,
} from "recharts";
import { useGrades, type User } from "~/lib/api";
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "../ui/chart";
import { chartConfig, gradesTrendData } from "./inde";
export default function GradesPage({ user }: { user: User }) {
// TODO: Error handling, loading state
const { grades, subjects, isLoading, isError } = useGrades("YEAR")
return (
<div>
{/* Tabs */}
{/* Charts - Mobile First Layout */}
<div className="space-y-8">
{/* Grade Trends */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg md:text-xl font-semibold">Grade Trends</h2>
<p className="text-sm text-muted-foreground">
performance across subjects
</p>
</div>
</div>
<div className="bg-muted/30 rounded-lg">
<ChartContainer config={chartConfig} className="h-full w-full -ml-6">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={grades}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="period" />
{/* TODO: Custom domain */}
<YAxis domain={[0, 20]} />
<ChartTooltip content={<ChartTooltipContent />} />
<Legend />
{subjects.map((subject, index) => (
<Line
key={index}
type="monotone"
dataKey={subject}
name={subject}
/>
))}
</LineChart>
</ResponsiveContainer>
</ChartContainer>
</div>
</div>
</div>
</div>
);
}

353
app/components/ui/chart.tsx Normal file
View file

@ -0,0 +1,353 @@
"use client";
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<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
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 (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};

View file

@ -383,3 +383,43 @@ export const testNotification = async (id: string) => {
"Échec de l'envoi de la notification de test" "Échec de l'envoi de la notification de test"
); );
}; };
/**
* === GRADES API ===
*/
export const getGrades = async (period: string) => {
return makeRequest(
`/grades?period=${encodeURIComponent(period)}`,
"Échec de la récupération des notes"
);
};
export const useGrades = (period: string) => {
const { data, ...props } = useQuery({
queryKey: ["grades", period],
queryFn: () => getGrades(period),
staleTime: Duration.fromObject({
hours: 0, // 1 hour
}).toMillis(),
gcTime: Duration.fromObject({
days: 3, // 3 days
}).toMillis(),
});
const grades = data?.grades as PeriodResult[] || [];
const subjects = data?.subjects as string[] || [];
return {
grades,
subjects,
...props,
};
}
interface SubjectPerformance {
subject: string
average: number
}
interface PeriodResult {
period: string
average: number
subjectAverages: SubjectPerformance[]
}

View file

@ -7,4 +7,5 @@ export default [
route("/register", "routes/register.tsx"), route("/register", "routes/register.tsx"),
route("/colles/:colleId", "routes/colles.tsx"), route("/colles/:colleId", "routes/colles.tsx"),
route("/settings", "routes/settings.tsx"), route("/settings", "routes/settings.tsx"),
route("/grades", "routes/grades.tsx"),
] satisfies RouteConfig; ] satisfies RouteConfig;

35
app/routes/grades.tsx Normal file
View file

@ -0,0 +1,35 @@
import { Navigate } from "react-router";
import BottomNavigation from "~/components/bottom-nav";
import Error from "~/components/error";
import GradesPage from "~/components/grades";
import Loader from "~/components/loader";
import { MainLayout } from "~/layout";
import { AUTH_ERROR, useUser } from "~/lib/api";
import { forceReload } from "~/lib/utils";
export default function Grade() {
const { user, isLoading, error } = useUser();
if (isLoading) {
return <Loader />;
}
if (error?.message === AUTH_ERROR) {
return <Navigate to="/login" replace />;
}
if (error) {
return <Error message={error.message} />;
}
return (
<MainLayout
header={
<h1 className="text-2xl font-bold" onClick={forceReload}>
Khollis&eacute; - {user.className}
</h1>
}
>
<GradesPage user={user} />
<BottomNavigation activeId="grades" />
</MainLayout>
);
}

View file

@ -6,7 +6,7 @@ import { MainLayout } from "~/layout";
import { AUTH_ERROR, useUser } from "~/lib/api"; import { AUTH_ERROR, useUser } from "~/lib/api";
import { forceReload } from "~/lib/utils"; import { forceReload } from "~/lib/utils";
export default function Home() { export default function Settings() {
const { user, isLoading, error } = useUser(); const { user, isLoading, error } = useUser();
if (isLoading) { if (isLoading) {

View file

@ -46,6 +46,7 @@
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-latex-next": "^3.0.0", "react-latex-next": "^3.0.0",
"react-router": "^7.5.3", "react-router": "^7.5.3",
"recharts": "^3.1.2",
"sonner": "^2.0.6", "sonner": "^2.0.6",
"tailwind-merge": "^3.3.0", "tailwind-merge": "^3.3.0",
"tw-animate-css": "^1.3.0" "tw-animate-css": "^1.3.0"

298
pnpm-lock.yaml generated
View file

@ -116,6 +116,9 @@ importers:
react-router: react-router:
specifier: ^7.5.3 specifier: ^7.5.3
version: 7.7.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) version: 7.7.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
recharts:
specifier: ^3.1.2
version: 3.1.2(@types/react@19.1.8)(react-dom@19.1.1(react@19.1.1))(react-is@19.1.1)(react@19.1.1)(redux@5.0.1)
sonner: sonner:
specifier: ^2.0.6 specifier: ^2.0.6
version: 2.0.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1) version: 2.0.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
@ -2173,6 +2176,17 @@ packages:
peerDependencies: peerDependencies:
react-router: 7.7.1 react-router: 7.7.1
'@reduxjs/toolkit@2.8.2':
resolution: {integrity: sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A==}
peerDependencies:
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
peerDependenciesMeta:
react:
optional: true
react-redux:
optional: true
'@rollup/plugin-babel@5.3.1': '@rollup/plugin-babel@5.3.1':
resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==} resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
@ -2341,6 +2355,12 @@ packages:
resolution: {integrity: sha512-doH1gimEu3A46VX6aVxpHTeHrytJAG6HgdxntYnCFiIFHEM/ZGpG8KiZGBChchjQmG0XFIBL552kBTjVcMZXwQ==} resolution: {integrity: sha512-doH1gimEu3A46VX6aVxpHTeHrytJAG6HgdxntYnCFiIFHEM/ZGpG8KiZGBChchjQmG0XFIBL552kBTjVcMZXwQ==}
engines: {node: '>=12'} engines: {node: '>=12'}
'@standard-schema/spec@1.0.0':
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@surma/rollup-plugin-off-main-thread@2.2.3': '@surma/rollup-plugin-off-main-thread@2.2.3':
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==} resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
@ -2473,6 +2493,33 @@ packages:
'@tsconfig/node16@1.0.4': '@tsconfig/node16@1.0.4':
resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
'@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.1':
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
'@types/d3-scale@4.0.9':
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
'@types/d3-shape@3.1.7':
resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==}
'@types/d3-time@3.0.4':
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/estree@0.0.39': '@types/estree@0.0.39':
resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==} resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==}
@ -2514,6 +2561,9 @@ packages:
'@types/trusted-types@2.0.7': '@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@types/yauzl@2.10.3': '@types/yauzl@2.10.3':
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
@ -3197,6 +3247,50 @@ packages:
cyclist@1.0.2: cyclist@1.0.2:
resolution: {integrity: sha512-0sVXIohTfLqVIW3kb/0n6IiWF3Ifj5nm2XaSrLq2DI6fKIGa2fYAZdk917rUneaeLVpYfFcyXE2ft0fe3remsA==} resolution: {integrity: sha512-0sVXIohTfLqVIW3kb/0n6IiWF3Ifj5nm2XaSrLq2DI6fKIGa2fYAZdk917rUneaeLVpYfFcyXE2ft0fe3remsA==}
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'}
data-uri-to-buffer@4.0.1: data-uri-to-buffer@4.0.1:
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
engines: {node: '>= 12'} engines: {node: '>= 12'}
@ -3239,6 +3333,9 @@ packages:
decache@4.6.2: decache@4.6.2:
resolution: {integrity: sha512-2LPqkLeu8XWHU8qNCS3kcF6sCcb5zIzvWaAHYSvPfwhdd7mHuah29NssMzrTYyHN4F5oFy2ko9OBYxegtU0FEw==} resolution: {integrity: sha512-2LPqkLeu8XWHU8qNCS3kcF6sCcb5zIzvWaAHYSvPfwhdd7mHuah29NssMzrTYyHN4F5oFy2ko9OBYxegtU0FEw==}
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
decompress-response@6.0.0: decompress-response@6.0.0:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -3493,6 +3590,9 @@ packages:
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
es-toolkit@1.39.10:
resolution: {integrity: sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w==}
esbuild@0.25.6: esbuild@0.25.6:
resolution: {integrity: sha512-GVuzuUwtdsghE3ocJ9Bs8PNoF13HNQ5TXbEi2AhvVb8xU1Iwt9Fos9FEamfoee+u/TOsn7GUWc04lz46n2bbTg==} resolution: {integrity: sha512-GVuzuUwtdsghE3ocJ9Bs8PNoF13HNQ5TXbEi2AhvVb8xU1Iwt9Fos9FEamfoee+u/TOsn7GUWc04lz46n2bbTg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -3561,6 +3661,9 @@ packages:
eventemitter3@4.0.7: eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
eventemitter3@5.0.1:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
events@3.3.0: events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'} engines: {node: '>=0.8.x'}
@ -4044,6 +4147,9 @@ packages:
engines: {node: '>=16.x'} engines: {node: '>=16.x'}
hasBin: true hasBin: true
immer@10.1.1:
resolution: {integrity: sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==}
imurmurhash@0.1.4: imurmurhash@0.1.4:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'} engines: {node: '>=0.8.19'}
@ -4093,6 +4199,10 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
ipaddr.js@1.9.1: ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
@ -5300,6 +5410,9 @@ packages:
peerDependencies: peerDependencies:
react: ^19.1.1 react: ^19.1.1
react-is@19.1.1:
resolution: {integrity: sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==}
react-latex-next@3.0.0: react-latex-next@3.0.0:
resolution: {integrity: sha512-x70f1b1G7TronVigsRgKHKYYVUNfZk/3bciFyYX1lYLQH2y3/TXku3+5Vap8MDbJhtopePSYBsYWS6jhzIdz+g==} resolution: {integrity: sha512-x70f1b1G7TronVigsRgKHKYYVUNfZk/3bciFyYX1lYLQH2y3/TXku3+5Vap8MDbJhtopePSYBsYWS6jhzIdz+g==}
engines: {node: '>=12', npm: '>=5'} engines: {node: '>=12', npm: '>=5'}
@ -5307,6 +5420,18 @@ packages:
react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
react-redux@9.2.0:
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
peerDependencies:
'@types/react': ^18.2.25 || ^19
react: ^18.0 || ^19
redux: ^5.0.0
peerDependenciesMeta:
'@types/react':
optional: true
redux:
optional: true
react-refresh@0.14.2: react-refresh@0.14.2:
resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -5389,6 +5514,22 @@ packages:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'} engines: {node: '>= 12.13.0'}
recharts@3.1.2:
resolution: {integrity: sha512-vhNbYwaxNbk/IATK0Ki29k3qvTkGqwvCgyQAQ9MavvvBwjvKnMTswdbklJpcOAoMPN/qxF3Lyqob0zO+ZXkZ4g==}
engines: {node: '>=18'}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
redux-thunk@3.1.0:
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
redux: ^5.0.0
redux@5.0.1:
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
reflect.getprototypeof@1.0.10: reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -5444,6 +5585,9 @@ packages:
requires-port@1.0.0: requires-port@1.0.0:
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
resolve-alpn@1.2.1: resolve-alpn@1.2.1:
resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==}
@ -5890,6 +6034,9 @@ packages:
through@2.3.8: through@2.3.8:
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tinyglobby@0.2.14: tinyglobby@0.2.14:
resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@ -6236,6 +6383,9 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
vite-node@3.2.4: vite-node@3.2.4:
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@ -8780,6 +8930,18 @@ snapshots:
- supports-color - supports-color
- typescript - typescript
'@reduxjs/toolkit@2.8.2(react-redux@9.2.0(@types/react@19.1.8)(react@19.1.1)(redux@5.0.1))(react@19.1.1)':
dependencies:
'@standard-schema/spec': 1.0.0
'@standard-schema/utils': 0.3.0
immer: 10.1.1
redux: 5.0.1
redux-thunk: 3.1.0(redux@5.0.1)
reselect: 5.1.1
optionalDependencies:
react: 19.1.1
react-redux: 9.2.0(@types/react@19.1.8)(react@19.1.1)(redux@5.0.1)
'@rollup/plugin-babel@5.3.1(@babel/core@7.28.0)(rollup@2.79.2)': '@rollup/plugin-babel@5.3.1(@babel/core@7.28.0)(rollup@2.79.2)':
dependencies: dependencies:
'@babel/core': 7.28.0 '@babel/core': 7.28.0
@ -8903,6 +9065,10 @@ snapshots:
dependencies: dependencies:
escape-string-regexp: 5.0.0 escape-string-regexp: 5.0.0
'@standard-schema/spec@1.0.0': {}
'@standard-schema/utils@0.3.0': {}
'@surma/rollup-plugin-off-main-thread@2.2.3': '@surma/rollup-plugin-off-main-thread@2.2.3':
dependencies: dependencies:
ejs: 3.1.10 ejs: 3.1.10
@ -9016,6 +9182,30 @@ snapshots:
'@tsconfig/node16@1.0.4': {} '@tsconfig/node16@1.0.4': {}
'@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.1': {}
'@types/d3-scale@4.0.9':
dependencies:
'@types/d3-time': 3.0.4
'@types/d3-shape@3.1.7':
dependencies:
'@types/d3-path': 3.1.1
'@types/d3-time@3.0.4': {}
'@types/d3-timer@3.0.2': {}
'@types/estree@0.0.39': {} '@types/estree@0.0.39': {}
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
@ -9050,6 +9240,8 @@ snapshots:
'@types/trusted-types@2.0.7': {} '@types/trusted-types@2.0.7': {}
'@types/use-sync-external-store@0.0.6': {}
'@types/yauzl@2.10.3': '@types/yauzl@2.10.3':
dependencies: dependencies:
'@types/node': 20.19.9 '@types/node': 20.19.9
@ -9834,6 +10026,44 @@ snapshots:
cyclist@1.0.2: {} cyclist@1.0.2: {}
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: {}
data-uri-to-buffer@4.0.1: {} data-uri-to-buffer@4.0.1: {}
data-view-buffer@1.0.2: data-view-buffer@1.0.2:
@ -9872,6 +10102,8 @@ snapshots:
dependencies: dependencies:
callsite: 1.0.0 callsite: 1.0.0
decimal.js-light@2.5.1: {}
decompress-response@6.0.0: decompress-response@6.0.0:
dependencies: dependencies:
mimic-response: 3.1.0 mimic-response: 3.1.0
@ -10151,6 +10383,8 @@ snapshots:
is-date-object: 1.1.0 is-date-object: 1.1.0
is-symbol: 1.1.1 is-symbol: 1.1.1
es-toolkit@1.39.10: {}
esbuild@0.25.6: esbuild@0.25.6:
optionalDependencies: optionalDependencies:
'@esbuild/aix-ppc64': 0.25.6 '@esbuild/aix-ppc64': 0.25.6
@ -10245,6 +10479,8 @@ snapshots:
eventemitter3@4.0.7: {} eventemitter3@4.0.7: {}
eventemitter3@5.0.1: {}
events@3.3.0: {} events@3.3.0: {}
execa@5.1.1: execa@5.1.1:
@ -10816,6 +11052,8 @@ snapshots:
image-size@2.0.2: {} image-size@2.0.2: {}
immer@10.1.1: {}
imurmurhash@0.1.4: {} imurmurhash@0.1.4: {}
indent-string@5.0.0: {} indent-string@5.0.0: {}
@ -10875,6 +11113,8 @@ snapshots:
hasown: 2.0.2 hasown: 2.0.2
side-channel: 1.1.0 side-channel: 1.1.0
internmap@2.0.3: {}
ipaddr.js@1.9.1: {} ipaddr.js@1.9.1: {}
ipx@3.1.1(@netlify/blobs@10.0.7)(idb-keyval@6.2.2): ipx@3.1.1(@netlify/blobs@10.0.7)(idb-keyval@6.2.2):
@ -12128,12 +12368,23 @@ snapshots:
react: 19.1.1 react: 19.1.1
scheduler: 0.26.0 scheduler: 0.26.0
react-is@19.1.1: {}
react-latex-next@3.0.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1): react-latex-next@3.0.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
dependencies: dependencies:
katex: 0.16.22 katex: 0.16.22
react: 19.1.1 react: 19.1.1
react-dom: 19.1.1(react@19.1.1) react-dom: 19.1.1(react@19.1.1)
react-redux@9.2.0(@types/react@19.1.8)(react@19.1.1)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
react: 19.1.1
use-sync-external-store: 1.5.0(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.8
redux: 5.0.1
react-refresh@0.14.2: {} react-refresh@0.14.2: {}
react-remove-scroll-bar@2.3.8(@types/react@19.1.8)(react@19.1.1): react-remove-scroll-bar@2.3.8(@types/react@19.1.8)(react@19.1.1):
@ -12223,6 +12474,32 @@ snapshots:
real-require@0.2.0: {} real-require@0.2.0: {}
recharts@3.1.2(@types/react@19.1.8)(react-dom@19.1.1(react@19.1.1))(react-is@19.1.1)(react@19.1.1)(redux@5.0.1):
dependencies:
'@reduxjs/toolkit': 2.8.2(react-redux@9.2.0(@types/react@19.1.8)(react@19.1.1)(redux@5.0.1))(react@19.1.1)
clsx: 2.1.1
decimal.js-light: 2.5.1
es-toolkit: 1.39.10
eventemitter3: 5.0.1
immer: 10.1.1
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
react-is: 19.1.1
react-redux: 9.2.0(@types/react@19.1.8)(react@19.1.1)(redux@5.0.1)
reselect: 5.1.1
tiny-invariant: 1.3.3
use-sync-external-store: 1.5.0(react@19.1.1)
victory-vendor: 37.3.6
transitivePeerDependencies:
- '@types/react'
- redux
redux-thunk@3.1.0(redux@5.0.1):
dependencies:
redux: 5.0.1
redux@5.0.1: {}
reflect.getprototypeof@1.0.10: reflect.getprototypeof@1.0.10:
dependencies: dependencies:
call-bind: 1.0.8 call-bind: 1.0.8
@ -12284,6 +12561,8 @@ snapshots:
requires-port@1.0.0: {} requires-port@1.0.0: {}
reselect@5.1.1: {}
resolve-alpn@1.2.1: {} resolve-alpn@1.2.1: {}
resolve-from@5.0.0: {} resolve-from@5.0.0: {}
@ -12823,6 +13102,8 @@ snapshots:
through@2.3.8: {} through@2.3.8: {}
tiny-invariant@1.3.3: {}
tinyglobby@0.2.14: tinyglobby@0.2.14:
dependencies: dependencies:
fdir: 6.4.6(picomatch@4.0.3) fdir: 6.4.6(picomatch@4.0.3)
@ -13093,6 +13374,23 @@ snapshots:
vary@1.1.2: {} vary@1.1.2: {}
victory-vendor@37.3.6:
dependencies:
'@types/d3-array': 3.2.1
'@types/d3-ease': 3.0.2
'@types/d3-interpolate': 3.0.4
'@types/d3-scale': 4.0.9
'@types/d3-shape': 3.1.7
'@types/d3-time': 3.0.4
'@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@3.2.4(@types/node@20.19.9)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.0): vite-node@3.2.4(@types/node@20.19.9)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.0):
dependencies: dependencies:
cac: 6.7.14 cac: 6.7.14