rogasper commited on
Commit
b87cbc7
·
1 Parent(s): 36b41bf

Add analytics components including BreakdownCharts, OverviewCards, ScoreTrendChart, TimeAnalyticsPanel, and WeaknessPanel. Update routing to include analytics routes and enhance sidebar navigation. Add recharts dependency for data visualization.

Browse files
apps/web/package.json CHANGED
@@ -32,6 +32,7 @@
32
  "next-themes": "catalog:",
33
  "react": "^19.2.5",
34
  "react-dom": "^19.2.5",
 
35
  "sonner": "^2.0.7",
36
  "vite-plugin-pwa": "^1.2.0",
37
  "zod": "catalog:"
 
32
  "next-themes": "catalog:",
33
  "react": "^19.2.5",
34
  "react-dom": "^19.2.5",
35
+ "recharts": "^3.8.1",
36
  "sonner": "^2.0.7",
37
  "vite-plugin-pwa": "^1.2.0",
38
  "zod": "catalog:"
apps/web/src/components/analytics/BreakdownCharts.tsx ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ BarChart,
3
+ Bar,
4
+ XAxis,
5
+ YAxis,
6
+ CartesianGrid,
7
+ Tooltip,
8
+ ResponsiveContainer,
9
+ Cell,
10
+ } from "recharts";
11
+ import { Card, CardContent, CardHeader, CardTitle } from "@labas/ui/components/card";
12
+ import { formatLabel } from "@/lib/format";
13
+ import { formatTime } from "@/lib/time";
14
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
15
+
16
+ interface ExamTypeItem {
17
+ examTypeId: string;
18
+ examTypeName: string;
19
+ accuracyPct: number;
20
+ avgScorePct: number;
21
+ avgTimeSpentSec: number;
22
+ attempts: number;
23
+ }
24
+
25
+ interface SectionTypeItem {
26
+ sectionTypeId: string;
27
+ sectionTypeName: string;
28
+ accuracyPct: number;
29
+ avgScorePct: number;
30
+ avgTimeSpentSec: number;
31
+ attempts: number;
32
+ }
33
+
34
+ interface FormatItem {
35
+ format: string;
36
+ accuracyPct: number;
37
+ avgTimeSpentSec: number;
38
+ totalQuestions: number;
39
+ }
40
+
41
+ interface BreakdownChartsProps {
42
+ byExamType: ExamTypeItem[] | undefined;
43
+ bySectionType: SectionTypeItem[] | undefined;
44
+ byFormat: FormatItem[] | undefined;
45
+ }
46
+
47
+ const COLORS = [
48
+ "var(--matcha-600)",
49
+ "var(--slushie-600)",
50
+ "var(--lemon-700)",
51
+ "var(--ube-600)",
52
+ "var(--pomegranate-600)",
53
+ ];
54
+
55
+ function AccuracyBar({ pct }: { pct: number }) {
56
+ return (
57
+ <div className="w-full h-2 bg-[var(--oat-light)] rounded-full overflow-hidden">
58
+ <div
59
+ className="h-full rounded-full transition-all"
60
+ style={{
61
+ width: `${pct}%`,
62
+ backgroundColor:
63
+ pct >= 80
64
+ ? "var(--matcha-600)"
65
+ : pct >= 60
66
+ ? "var(--lemon-700)"
67
+ : "var(--pomegranate-600)",
68
+ }}
69
+ />
70
+ </div>
71
+ );
72
+ }
73
+
74
+ export function BreakdownCharts({ byExamType, bySectionType, byFormat }: BreakdownChartsProps) {
75
+ return (
76
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
77
+ <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
78
+ <CardHeader>
79
+ <CardTitle className="text-lg font-headline font-bold text-[var(--clay-black)]">
80
+ Performa per Jenis Ujian
81
+ </CardTitle>
82
+ </CardHeader>
83
+ <CardContent>
84
+ {byExamType && byExamType.length > 0 ? (
85
+ <ResponsiveContainer width="100%" height={220}>
86
+ <BarChart data={byExamType} margin={{ top: 5, right: 10, left: -10, bottom: 0 }}>
87
+ <CartesianGrid strokeDasharray="3 3" stroke="var(--oat-border)" />
88
+ <XAxis
89
+ dataKey="examTypeName"
90
+ tick={{ fontSize: 12, fill: "var(--warm-charcoal)" }}
91
+ axisLine={{ stroke: "var(--oat-border)" }}
92
+ tickLine={false}
93
+ />
94
+ <YAxis
95
+ tick={{ fontSize: 12, fill: "var(--warm-charcoal)" }}
96
+ axisLine={false}
97
+ tickLine={false}
98
+ domain={[0, 100]}
99
+ unit="%"
100
+ />
101
+ <Tooltip
102
+ contentStyle={{
103
+ backgroundColor: "var(--pure-white)",
104
+ border: "2px solid var(--oat-border)",
105
+ borderRadius: "var(--radius-lg)",
106
+ fontSize: 13,
107
+ }}
108
+ formatter={(value: unknown, name: unknown, _item: unknown, _index: unknown, _payload: unknown) => {
109
+ const num = typeof value === "number" ? value : 0;
110
+ const n = typeof name === "string" ? name : "";
111
+ if (n === "accuracyPct") return [`${num}%`, "Akurasi"];
112
+ if (n === "avgScorePct") return [`${num}%`, "Rata-rata Skor"];
113
+ return [num, n];
114
+ }}
115
+ />
116
+ <Bar dataKey="accuracyPct" radius={[6, 6, 0, 0]}>
117
+ {byExamType.map((_, i) => (
118
+ <Cell key={i} fill={COLORS[i % COLORS.length]} />
119
+ ))}
120
+ </Bar>
121
+ </BarChart>
122
+ </ResponsiveContainer>
123
+ ) : (
124
+ <div className="h-56 flex items-center justify-center text-[var(--warm-charcoal)]">
125
+ Belum ada data.
126
+ </div>
127
+ )}
128
+ </CardContent>
129
+ </Card>
130
+
131
+ <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
132
+ <CardHeader>
133
+ <CardTitle className="text-lg font-headline font-bold text-[var(--clay-black)]">
134
+ Performa per Section
135
+ </CardTitle>
136
+ </CardHeader>
137
+ <CardContent>
138
+ {bySectionType && bySectionType.length > 0 ? (
139
+ <ResponsiveContainer width="100%" height={220}>
140
+ <BarChart data={bySectionType} margin={{ top: 5, right: 10, left: -10, bottom: 0 }}>
141
+ <CartesianGrid strokeDasharray="3 3" stroke="var(--oat-border)" />
142
+ <XAxis
143
+ dataKey="sectionTypeName"
144
+ tick={{ fontSize: 12, fill: "var(--warm-charcoal)" }}
145
+ axisLine={{ stroke: "var(--oat-border)" }}
146
+ tickLine={false}
147
+ />
148
+ <YAxis
149
+ tick={{ fontSize: 12, fill: "var(--warm-charcoal)" }}
150
+ axisLine={false}
151
+ tickLine={false}
152
+ domain={[0, 100]}
153
+ unit="%"
154
+ />
155
+ <Tooltip
156
+ contentStyle={{
157
+ backgroundColor: "var(--pure-white)",
158
+ border: "2px solid var(--oat-border)",
159
+ borderRadius: "var(--radius-lg)",
160
+ fontSize: 13,
161
+ }}
162
+ formatter={(value: unknown, name: unknown, _item: unknown, _index: unknown, _payload: unknown) => {
163
+ const num = typeof value === "number" ? value : 0;
164
+ const n = typeof name === "string" ? name : "";
165
+ if (n === "accuracyPct") return [`${num}%`, "Akurasi"];
166
+ if (n === "avgScorePct") return [`${num}%`, "Rata-rata Skor"];
167
+ return [num, n];
168
+ }}
169
+ />
170
+ <Bar dataKey="accuracyPct" radius={[6, 6, 0, 0]}>
171
+ {bySectionType.map((_, i) => (
172
+ <Cell key={i} fill={COLORS[(i + 2) % COLORS.length]} />
173
+ ))}
174
+ </Bar>
175
+ </BarChart>
176
+ </ResponsiveContainer>
177
+ ) : (
178
+ <div className="h-56 flex items-center justify-center text-[var(--warm-charcoal)]">
179
+ Belum ada data.
180
+ </div>
181
+ )}
182
+ </CardContent>
183
+ </Card>
184
+
185
+ <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] lg:col-span-2">
186
+ <CardHeader>
187
+ <CardTitle className="text-lg font-headline font-bold text-[var(--clay-black)]">
188
+ Performa per Format Soal
189
+ </CardTitle>
190
+ </CardHeader>
191
+ <CardContent>
192
+ {byFormat && byFormat.length > 0 ? (
193
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
194
+ {byFormat.map((item) => (
195
+ <div
196
+ key={item.format}
197
+ className="p-4 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--warm-cream)]"
198
+ >
199
+ <div className="flex items-center justify-between mb-2">
200
+ <span className="text-sm font-semibold text-[var(--clay-black)]">
201
+ {formatLabel(item.format)}
202
+ </span>
203
+ <span className="text-xs text-[var(--warm-charcoal)]">
204
+ {item.totalQuestions} soal
205
+ </span>
206
+ </div>
207
+ <div className="flex items-center gap-2 mb-1">
208
+ <span className="text-lg font-bold text-[var(--clay-black)]">
209
+ {item.accuracyPct}%
210
+ </span>
211
+ <span className="text-xs text-[var(--warm-charcoal)]">
212
+ {formatTime(item.avgTimeSpentSec)}/soal
213
+ </span>
214
+ </div>
215
+ <AccuracyBar pct={item.accuracyPct} />
216
+ </div>
217
+ ))}
218
+ </div>
219
+ ) : (
220
+ <div className="h-20 flex items-center justify-center text-[var(--warm-charcoal)]">
221
+ Belum ada data.
222
+ </div>
223
+ )}
224
+ </CardContent>
225
+ </Card>
226
+ </div>
227
+ );
228
+ }
apps/web/src/components/analytics/OverviewCards.tsx ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
2
+ import { Card, CardContent } from "@labas/ui/components/card";
3
+ import { formatTime } from "@/lib/time";
4
+
5
+ interface OverviewCardsProps {
6
+ data: {
7
+ totalAttempts: number;
8
+ completedAttempts: number;
9
+ abandonedAttempts: number;
10
+ avgScorePct: number;
11
+ totalTimeSpentSec: number;
12
+ totalQuestionsAnswered: number;
13
+ totalCorrectAnswers: number;
14
+ overallAccuracyPct: number;
15
+ } | undefined;
16
+ }
17
+
18
+ export function OverviewCards({ data }: OverviewCardsProps) {
19
+ if (!data) return null;
20
+
21
+ const cards = [
22
+ {
23
+ label: "Total Latihan",
24
+ value: data.totalAttempts,
25
+ icon: "school",
26
+ color: "text-[var(--matcha-600)]",
27
+ bg: "bg-[var(--matcha-300)]/20",
28
+ },
29
+ {
30
+ label: "Selesai",
31
+ value: data.completedAttempts,
32
+ icon: "check_circle",
33
+ color: "text-[var(--matcha-600)]",
34
+ bg: "bg-[var(--matcha-300)]/20",
35
+ },
36
+ {
37
+ label: "Rata-rata Skor",
38
+ value: `${data.avgScorePct}%`,
39
+ icon: "trending_up",
40
+ color: "text-[var(--lemon-700)]",
41
+ bg: "bg-[var(--lemon-300)]/20",
42
+ },
43
+ {
44
+ label: "Akurasi",
45
+ value: `${data.overallAccuracyPct}%`,
46
+ icon: "target",
47
+ color: "text-[var(--ube-600)]",
48
+ bg: "bg-[var(--ube-300)]/20",
49
+ },
50
+ {
51
+ label: "Waktu Total",
52
+ value: formatTime(data.totalTimeSpentSec),
53
+ icon: "timer",
54
+ color: "text-[var(--slushie-600)]",
55
+ bg: "bg-[var(--slushie-300)]/20",
56
+ },
57
+ {
58
+ label: "Soal Dijawab",
59
+ value: data.totalQuestionsAnswered,
60
+ icon: "quiz",
61
+ color: "text-[var(--pomegranate-600)]",
62
+ bg: "bg-[var(--pomegranate-300)]/20",
63
+ },
64
+ ];
65
+
66
+ return (
67
+ <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
68
+ {cards.map((card) => (
69
+ <Card
70
+ key={card.label}
71
+ className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]"
72
+ >
73
+ <CardContent className="p-4">
74
+ <div className={`inline-flex items-center justify-center w-10 h-10 rounded-[var(--radius-lg)] ${card.bg} mb-3`}>
75
+ <MaterialIcon name={card.icon} className={card.color} />
76
+ </div>
77
+ <div className="text-2xl font-headline font-extrabold text-[var(--clay-black)]">
78
+ {card.value}
79
+ </div>
80
+ <div className="text-xs text-[var(--warm-charcoal)] mt-1">{card.label}</div>
81
+ </CardContent>
82
+ </Card>
83
+ ))}
84
+ </div>
85
+ );
86
+ }
apps/web/src/components/analytics/ScoreTrendChart.tsx ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ AreaChart,
3
+ Area,
4
+ XAxis,
5
+ YAxis,
6
+ CartesianGrid,
7
+ Tooltip,
8
+ ResponsiveContainer,
9
+ } from "recharts";
10
+ import { Card, CardContent, CardHeader, CardTitle } from "@labas/ui/components/card";
11
+
12
+ interface TrendPoint {
13
+ date: string;
14
+ attempts: number;
15
+ avgScorePct: number;
16
+ }
17
+
18
+ interface ScoreTrendChartProps {
19
+ data: TrendPoint[] | undefined;
20
+ }
21
+
22
+ function formatDateLabel(dateStr: string) {
23
+ const d = new Date(dateStr);
24
+ return `${d.getDate()}/${d.getMonth() + 1}`;
25
+ }
26
+
27
+ export function ScoreTrendChart({ data }: ScoreTrendChartProps) {
28
+ if (!data || data.length === 0) {
29
+ return (
30
+ <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
31
+ <CardHeader>
32
+ <CardTitle className="text-lg font-headline font-bold text-[var(--clay-black)]">
33
+ Tren Skor
34
+ </CardTitle>
35
+ </CardHeader>
36
+ <CardContent className="h-64 flex items-center justify-center text-[var(--warm-charcoal)]">
37
+ Belum ada data tren.
38
+ </CardContent>
39
+ </Card>
40
+ );
41
+ }
42
+
43
+ const chartData = data.map((d) => ({
44
+ ...d,
45
+ label: formatDateLabel(d.date),
46
+ }));
47
+
48
+ return (
49
+ <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
50
+ <CardHeader>
51
+ <CardTitle className="text-lg font-headline font-bold text-[var(--clay-black)]">
52
+ Tren Skor (30 Hari)
53
+ </CardTitle>
54
+ </CardHeader>
55
+ <CardContent>
56
+ <ResponsiveContainer width="100%" height={256}>
57
+ <AreaChart data={chartData} margin={{ top: 5, right: 10, left: -10, bottom: 0 }}>
58
+ <defs>
59
+ <linearGradient id="scoreGradient" x1="0" y1="0" x2="0" y2="1">
60
+ <stop offset="5%" stopColor="var(--matcha-600)" stopOpacity={0.2} />
61
+ <stop offset="95%" stopColor="var(--matcha-600)" stopOpacity={0} />
62
+ </linearGradient>
63
+ </defs>
64
+ <CartesianGrid strokeDasharray="3 3" stroke="var(--oat-border)" />
65
+ <XAxis
66
+ dataKey="label"
67
+ tick={{ fontSize: 12, fill: "var(--warm-charcoal)" }}
68
+ axisLine={{ stroke: "var(--oat-border)" }}
69
+ tickLine={false}
70
+ />
71
+ <YAxis
72
+ tick={{ fontSize: 12, fill: "var(--warm-charcoal)" }}
73
+ axisLine={false}
74
+ tickLine={false}
75
+ domain={[0, 100]}
76
+ unit="%"
77
+ />
78
+ <Tooltip
79
+ contentStyle={{
80
+ backgroundColor: "var(--pure-white)",
81
+ border: "2px solid var(--oat-border)",
82
+ borderRadius: "var(--radius-lg)",
83
+ fontSize: 13,
84
+ }}
85
+ formatter={(value: unknown, name: unknown, _item: unknown, _index: unknown, _payload: unknown) => {
86
+ const num = typeof value === "number" ? value : 0;
87
+ const n = typeof name === "string" ? name : "";
88
+ if (n === "avgScorePct") return [`${num}%`, "Rata-rata Skor"];
89
+ if (n === "attempts") return [num, "Latihan"];
90
+ return [num, n];
91
+ }}
92
+ />
93
+ <Area
94
+ type="monotone"
95
+ dataKey="avgScorePct"
96
+ stroke="var(--matcha-600)"
97
+ strokeWidth={2}
98
+ fill="url(#scoreGradient)"
99
+ />
100
+ </AreaChart>
101
+ </ResponsiveContainer>
102
+ </CardContent>
103
+ </Card>
104
+ );
105
+ }
apps/web/src/components/analytics/TimeAnalyticsPanel.tsx ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ BarChart,
3
+ Bar,
4
+ XAxis,
5
+ YAxis,
6
+ CartesianGrid,
7
+ Tooltip,
8
+ ResponsiveContainer,
9
+ } from "recharts";
10
+ import { Card, CardContent, CardHeader, CardTitle } from "@labas/ui/components/card";
11
+ import { formatTime } from "@/lib/time";
12
+ import { formatLabel } from "@/lib/format";
13
+
14
+ interface SectionTimeItem {
15
+ sectionTypeName: string;
16
+ avgTimeSpentSec: number;
17
+ totalTimeSpentSec: number;
18
+ }
19
+
20
+ interface FormatTimeItem {
21
+ format: string;
22
+ avgTimeSpentSec: number;
23
+ totalTimeSpentSec: number;
24
+ }
25
+
26
+ interface TimeTrendItem {
27
+ date: string;
28
+ avgTimeSpentSec: number;
29
+ }
30
+
31
+ interface TimeAnalyticsPanelProps {
32
+ sectionTime: SectionTimeItem[] | undefined;
33
+ formatTimeData: FormatTimeItem[] | undefined;
34
+ timeTrend: TimeTrendItem[] | undefined;
35
+ }
36
+
37
+ export function TimeAnalyticsPanel({ sectionTime, formatTimeData, timeTrend }: TimeAnalyticsPanelProps) {
38
+ return (
39
+ <div className="space-y-6">
40
+ <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
41
+ <CardHeader>
42
+ <CardTitle className="text-lg font-headline font-bold text-[var(--clay-black)]">
43
+ Waktu per Section
44
+ </CardTitle>
45
+ </CardHeader>
46
+ <CardContent>
47
+ {sectionTime && sectionTime.length > 0 ? (
48
+ <div className="space-y-3">
49
+ {sectionTime.map((item) => (
50
+ <div key={item.sectionTypeName} className="flex items-center gap-4">
51
+ <div className="w-28 shrink-0 text-sm font-medium text-[var(--clay-black)]">
52
+ {item.sectionTypeName}
53
+ </div>
54
+ <div className="flex-1">
55
+ <div className="w-full h-2.5 bg-[var(--oat-light)] rounded-full overflow-hidden">
56
+ <div
57
+ className="h-full rounded-full bg-[var(--slushie-600)]"
58
+ style={{
59
+ width: `${Math.min(100, (item.avgTimeSpentSec / 300) * 100)}%`,
60
+ }}
61
+ />
62
+ </div>
63
+ </div>
64
+ <div className="w-20 shrink-0 text-right text-sm text-[var(--warm-charcoal)]">
65
+ {formatTime(item.avgTimeSpentSec)}
66
+ </div>
67
+ </div>
68
+ ))}
69
+ </div>
70
+ ) : (
71
+ <div className="h-20 flex items-center justify-center text-[var(--warm-charcoal)]">
72
+ Belum ada data.
73
+ </div>
74
+ )}
75
+ </CardContent>
76
+ </Card>
77
+
78
+ <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
79
+ <CardHeader>
80
+ <CardTitle className="text-lg font-headline font-bold text-[var(--clay-black)]">
81
+ Waktu per Format Soal
82
+ </CardTitle>
83
+ </CardHeader>
84
+ <CardContent>
85
+ {formatTimeData && formatTimeData.length > 0 ? (
86
+ <div className="space-y-3">
87
+ {formatTimeData.map((item) => (
88
+ <div key={item.format} className="flex items-center gap-4">
89
+ <div className="w-32 shrink-0 text-sm font-medium text-[var(--clay-black)] truncate">
90
+ {formatLabel(item.format)}
91
+ </div>
92
+ <div className="flex-1">
93
+ <div className="w-full h-2.5 bg-[var(--oat-light)] rounded-full overflow-hidden">
94
+ <div
95
+ className="h-full rounded-full bg-[var(--ube-600)]"
96
+ style={{
97
+ width: `${Math.min(100, (item.avgTimeSpentSec / 120) * 100)}%`,
98
+ }}
99
+ />
100
+ </div>
101
+ </div>
102
+ <div className="w-20 shrink-0 text-right text-sm text-[var(--warm-charcoal)]">
103
+ {formatTime(item.avgTimeSpentSec)}
104
+ </div>
105
+ </div>
106
+ ))}
107
+ </div>
108
+ ) : (
109
+ <div className="h-20 flex items-center justify-center text-[var(--warm-charcoal)]">
110
+ Belum ada data.
111
+ </div>
112
+ )}
113
+ </CardContent>
114
+ </Card>
115
+
116
+ <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
117
+ <CardHeader>
118
+ <CardTitle className="text-lg font-headline font-bold text-[var(--clay-black)]">
119
+ Tren Waktu per Hari
120
+ </CardTitle>
121
+ </CardHeader>
122
+ <CardContent>
123
+ {timeTrend && timeTrend.length > 0 ? (
124
+ <ResponsiveContainer width="100%" height={200}>
125
+ <BarChart data={timeTrend} margin={{ top: 5, right: 10, left: -10, bottom: 0 }}>
126
+ <CartesianGrid strokeDasharray="3 3" stroke="var(--oat-border)" />
127
+ <XAxis
128
+ dataKey="date"
129
+ tickFormatter={(v: string) => {
130
+ const d = new Date(v);
131
+ return `${d.getDate()}/${d.getMonth() + 1}`;
132
+ }}
133
+ tick={{ fontSize: 12, fill: "var(--warm-charcoal)" }}
134
+ axisLine={{ stroke: "var(--oat-border)" }}
135
+ tickLine={false}
136
+ />
137
+ <YAxis
138
+ tick={{ fontSize: 12, fill: "var(--warm-charcoal)" }}
139
+ axisLine={false}
140
+ tickLine={false}
141
+ tickFormatter={(v: number) => `${Math.round(v / 60)}m`}
142
+ />
143
+ <Tooltip
144
+ contentStyle={{
145
+ backgroundColor: "var(--pure-white)",
146
+ border: "2px solid var(--oat-border)",
147
+ borderRadius: "var(--radius-lg)",
148
+ fontSize: 13,
149
+ }}
150
+ formatter={(value: unknown, _name: unknown, _item: unknown, _index: unknown, _payload: unknown) => [formatTime(typeof value === "number" ? value : 0), "Rata-rata Waktu"]}
151
+ />
152
+ <Bar dataKey="avgTimeSpentSec" fill="var(--slushie-600)" radius={[6, 6, 0, 0]} />
153
+ </BarChart>
154
+ </ResponsiveContainer>
155
+ ) : (
156
+ <div className="h-48 flex items-center justify-center text-[var(--warm-charcoal)]">
157
+ Belum ada data.
158
+ </div>
159
+ )}
160
+ </CardContent>
161
+ </Card>
162
+ </div>
163
+ );
164
+ }
apps/web/src/components/analytics/WeaknessPanel.tsx ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Card, CardContent, CardHeader, CardTitle } from "@labas/ui/components/card";
2
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
3
+ import { formatLabel } from "@/lib/format";
4
+
5
+ interface Weakness {
6
+ type: "format" | "section" | "skill";
7
+ name: string;
8
+ totalQuestions: number;
9
+ accuracyPct: number;
10
+ }
11
+
12
+ interface WeaknessPanelProps {
13
+ weaknesses: Weakness[] | undefined;
14
+ recommendations: string[] | undefined;
15
+ }
16
+
17
+ function getTypeIcon(type: Weakness["type"]) {
18
+ switch (type) {
19
+ case "format":
20
+ return "quiz";
21
+ case "section":
22
+ return "folder";
23
+ case "skill":
24
+ return "psychology";
25
+ }
26
+ }
27
+
28
+ function getTypeLabel(type: Weakness["type"]) {
29
+ switch (type) {
30
+ case "format":
31
+ return "Format";
32
+ case "section":
33
+ return "Section";
34
+ case "skill":
35
+ return "Skill";
36
+ }
37
+ }
38
+
39
+ function getSeverityColor(pct: number) {
40
+ if (pct < 40) return "text-[var(--pomegranate-600)]";
41
+ if (pct < 60) return "text-[var(--lemon-700)]";
42
+ return "text-[var(--matcha-600)]";
43
+ }
44
+
45
+ export function WeaknessPanel({ weaknesses, recommendations }: WeaknessPanelProps) {
46
+ const hasData = weaknesses && weaknesses.length > 0;
47
+
48
+ return (
49
+ <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
50
+ <CardHeader>
51
+ <CardTitle className="text-lg font-headline font-bold text-[var(--clay-black)] flex items-center gap-2">
52
+ <MaterialIcon name="analytics" className="text-[var(--pomegranate-600)]" />
53
+ Area untuk Ditingkatkan
54
+ </CardTitle>
55
+ </CardHeader>
56
+ <CardContent className="space-y-6">
57
+ {hasData ? (
58
+ <>
59
+ <div className="space-y-3">
60
+ {weaknesses.map((w, i) => (
61
+ <div
62
+ key={`${w.type}-${w.name}`}
63
+ className="flex items-center gap-4 p-3 rounded-[var(--radius-md)] bg-[var(--warm-cream)] border-2 border-[var(--oat-border)]"
64
+ >
65
+ <div className="flex items-center justify-center w-10 h-10 rounded-[var(--radius-lg)] bg-[var(--oat-light)] shrink-0">
66
+ <MaterialIcon name={getTypeIcon(w.type)} className="text-[var(--warm-charcoal)]" />
67
+ </div>
68
+ <div className="flex-1 min-w-0">
69
+ <div className="flex items-center gap-2">
70
+ <span className="text-xs font-medium text-[var(--warm-silver)] uppercase tracking-wide">
71
+ {getTypeLabel(w.type)}
72
+ </span>
73
+ </div>
74
+ <div className="text-sm font-semibold text-[var(--clay-black)] truncate">
75
+ {formatLabel(w.name)}
76
+ </div>
77
+ </div>
78
+ <div className="text-right shrink-0">
79
+ <div className={`text-xl font-headline font-extrabold ${getSeverityColor(w.accuracyPct)}`}>
80
+ {w.accuracyPct}%
81
+ </div>
82
+ <div className="text-xs text-[var(--warm-silver)]">{w.totalQuestions} soal</div>
83
+ </div>
84
+ </div>
85
+ ))}
86
+ </div>
87
+
88
+ {recommendations && recommendations.length > 0 && (
89
+ <div className="space-y-2">
90
+ <h4 className="text-sm font-semibold text-[var(--clay-black)]">Rekomendasi</h4>
91
+ <ul className="space-y-2">
92
+ {recommendations.map((rec, i) => (
93
+ <li key={i} className="flex items-start gap-2 text-sm text-[var(--warm-charcoal)]">
94
+ <MaterialIcon name="lightbulb" className="text-[var(--lemon-700)] shrink-0 mt-0.5" />
95
+ <span>{rec}</span>
96
+ </li>
97
+ ))}
98
+ </ul>
99
+ </div>
100
+ )}
101
+ </>
102
+ ) : (
103
+ <div className="text-center py-8 text-[var(--warm-charcoal)]">
104
+ <MaterialIcon name="insights" className="text-5xl text-[var(--warm-silver)] mx-auto mb-3" />
105
+ <p className="font-semibold">Belum cukup data</p>
106
+ <p className="text-sm mt-1">Selesaikan lebih banyak latihan untuk melihat analisis kelemahan.</p>
107
+ </div>
108
+ )}
109
+ </CardContent>
110
+ </Card>
111
+ );
112
+ }
apps/web/src/components/bank/QuestionDetailModal.tsx CHANGED
@@ -170,11 +170,10 @@ export function QuestionDetailModal({
170
  {isOwner && (
171
  <div className="flex gap-2 items-center">
172
  <span
173
- className={`px-3 py-1.5 rounded-full text-xs font-semibold ${
174
- question.isPublic
175
  ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
176
  : "bg-[var(--oat-light)] text-[var(--warm-charcoal)]"
177
- }`}
178
  >
179
  {question.isPublic ? "Publik" : "Privat"}
180
  </span>
@@ -188,13 +187,12 @@ export function QuestionDetailModal({
188
  variant="outline"
189
  onClick={onToggleSelect}
190
  disabled={!isSelectable}
191
- className={`rounded-[var(--radius-lg)] border-2 clay-hover ${
192
- !isSelectable
193
  ? "opacity-40 cursor-not-allowed border-[var(--oat-border)] text-[var(--warm-silver)]"
194
  : isSelected
195
  ? "border-[var(--pomegranate-400)] text-[var(--pomegranate-600)] bg-[var(--pomegranate-50)]"
196
  : "border-[var(--oat-border)] text-[var(--warm-charcoal)]"
197
- }`}
198
  >
199
  <MaterialIcon name={isSelected ? "remove" : "add"} className="mr-2" />
200
  {!isSelectable
 
170
  {isOwner && (
171
  <div className="flex gap-2 items-center">
172
  <span
173
+ className={`px-3 py-1.5 rounded-full text-xs font-semibold ${question.isPublic
 
174
  ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
175
  : "bg-[var(--oat-light)] text-[var(--warm-charcoal)]"
176
+ }`}
177
  >
178
  {question.isPublic ? "Publik" : "Privat"}
179
  </span>
 
187
  variant="outline"
188
  onClick={onToggleSelect}
189
  disabled={!isSelectable}
190
+ className={`rounded-[var(--radius-lg)] border-2 clay-hover ${!isSelectable
 
191
  ? "opacity-40 cursor-not-allowed border-[var(--oat-border)] text-[var(--warm-silver)]"
192
  : isSelected
193
  ? "border-[var(--pomegranate-400)] text-[var(--pomegranate-600)] bg-[var(--pomegranate-50)]"
194
  : "border-[var(--oat-border)] text-[var(--warm-charcoal)]"
195
+ }`}
196
  >
197
  <MaterialIcon name={isSelected ? "remove" : "add"} className="mr-2" />
198
  {!isSelectable
apps/web/src/components/sidebar.tsx CHANGED
@@ -30,9 +30,8 @@ export function Sidebar() {
30
  <>
31
  {/* Desktop Sidebar */}
32
  <aside
33
- className={`fixed left-0 top-0 h-full flex flex-col z-40 bg-[var(--warm-cream)] border-r border-[var(--oat-border)] hidden md:flex transition-all duration-300 ${
34
- collapsed ? "w-16 items-center px-2 py-4" : "w-64 p-4"
35
- }`}
36
  >
37
  <div className={`mb-8 ${collapsed ? "px-0 text-center" : "px-4"}`}>
38
  <h1 className="text-xl font-extrabold text-[var(--clay-black)] font-headline tracking-tight">
@@ -52,11 +51,10 @@ export function Sidebar() {
52
  <Link
53
  key={item.to}
54
  to={item.to}
55
- className={`flex items-center gap-3 rounded-[var(--radius-lg)] transition-all group clay-hover ${
56
- isActive
57
- ? "bg-[var(--matcha-300)] text-[var(--matcha-800)] font-semibold clay-shadow"
58
- : "text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] hover:text-[var(--clay-black)]"
59
- } ${collapsed ? "justify-center py-3 px-2" : "py-3 px-4"}`}
60
  title={collapsed ? item.label : undefined}
61
  >
62
  <NavIcon name={item.icon} />
@@ -73,11 +71,10 @@ export function Sidebar() {
73
  <Link
74
  key={item.to}
75
  to={item.to}
76
- className={`flex items-center gap-3 rounded-[var(--radius-lg)] transition-all clay-hover ${
77
- isActive
78
- ? "bg-[var(--matcha-300)] text-[var(--matcha-800)] font-semibold clay-shadow"
79
- : "text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] hover:text-[var(--clay-black)]"
80
- } ${collapsed ? "justify-center py-3 px-2" : "py-3 px-4"}`}
81
  title={collapsed ? item.label : undefined}
82
  >
83
  <NavIcon name={item.icon} />
@@ -88,9 +85,8 @@ export function Sidebar() {
88
  {isLoggedIn ? (
89
  <button
90
  onClick={() => authClient.signOut()}
91
- className={`flex items-center gap-3 rounded-[var(--radius-lg)] transition-all clay-hover text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] hover:text-[var(--clay-black)] w-full ${
92
- collapsed ? "justify-center py-3 px-2" : "py-3 px-4 text-left"
93
- }`}
94
  title={collapsed ? "Keluar" : undefined}
95
  >
96
  <NavIcon name="logout" />
@@ -99,9 +95,8 @@ export function Sidebar() {
99
  ) : (
100
  <Link
101
  to="/login"
102
- className={`flex items-center gap-3 rounded-[var(--radius-lg)] transition-all clay-hover text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] hover:text-[var(--clay-black)] ${
103
- collapsed ? "justify-center py-3 px-2" : "py-3 px-4"
104
- }`}
105
  title={collapsed ? "Masuk" : undefined}
106
  >
107
  <NavIcon name="login" />
@@ -116,25 +111,23 @@ export function Sidebar() {
116
  onClick={toggle}
117
  title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
118
  style={{ left: collapsed ? "48px" : "240px" }}
119
- className="hidden md:flex absolute top-6 z-50 items-center justify-center w-10 h-10 rounded-full bg-[var(--pure-white)] border-2 border-[var(--oat-border)] shadow-md hover:bg-[var(--oat-light)] transition-all duration-300 text-[var(--warm-charcoal)] clay-hover"
120
  >
121
  <span className="relative inline-flex size-6 shrink-0 items-center justify-center">
122
  <span
123
- className={`material-symbols-outlined pointer-events-none absolute inset-0 flex items-center justify-center text-[20px] leading-none transition-all duration-300 ease-out select-none ${
124
- collapsed
125
- ? "opacity-100 scale-100 rotate-0"
126
- : "opacity-0 scale-75 -rotate-90"
127
- }`}
128
  aria-hidden
129
  >
130
  right_panel_open
131
  </span>
132
  <span
133
- className={`material-symbols-outlined pointer-events-none absolute inset-0 flex items-center justify-center text-[20px] leading-none transition-all duration-300 ease-out select-none ${
134
- collapsed
135
- ? "opacity-0 scale-75 rotate-90"
136
- : "opacity-100 scale-100 rotate-0"
137
- }`}
138
  aria-hidden
139
  >
140
  right_panel_close
@@ -150,11 +143,10 @@ export function Sidebar() {
150
  <Link
151
  key={item.to}
152
  to={item.to}
153
- className={`flex flex-col items-center justify-center px-4 py-1.5 transition-all rounded-[var(--radius-lg)] ${
154
- isActive
155
- ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
156
- : "text-[var(--warm-charcoal)]"
157
- }`}
158
  >
159
  <NavIcon name={item.icon} />
160
  <span className="text-[10px] font-medium uppercase tracking-wider mt-1">{item.label}</span>
 
30
  <>
31
  {/* Desktop Sidebar */}
32
  <aside
33
+ className={`fixed left-0 top-0 h-full flex flex-col z-40 bg-[var(--warm-cream)] border-r border-[var(--oat-border)] hidden md:flex transition-all duration-300 ${collapsed ? "w-16 items-center px-2 py-4" : "w-64 p-4"
34
+ }`}
 
35
  >
36
  <div className={`mb-8 ${collapsed ? "px-0 text-center" : "px-4"}`}>
37
  <h1 className="text-xl font-extrabold text-[var(--clay-black)] font-headline tracking-tight">
 
51
  <Link
52
  key={item.to}
53
  to={item.to}
54
+ className={`flex items-center gap-3 rounded-[var(--radius-lg)] transition-all group clay-hover ${isActive
55
+ ? "bg-[var(--matcha-300)] text-[var(--matcha-800)] font-semibold clay-shadow"
56
+ : "text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] hover:text-[var(--clay-black)]"
57
+ } ${collapsed ? "justify-center py-3 px-2" : "py-3 px-4"}`}
 
58
  title={collapsed ? item.label : undefined}
59
  >
60
  <NavIcon name={item.icon} />
 
71
  <Link
72
  key={item.to}
73
  to={item.to}
74
+ className={`flex items-center gap-3 rounded-[var(--radius-lg)] transition-all clay-hover ${isActive
75
+ ? "bg-[var(--matcha-300)] text-[var(--matcha-800)] font-semibold clay-shadow"
76
+ : "text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] hover:text-[var(--clay-black)]"
77
+ } ${collapsed ? "justify-center py-3 px-2" : "py-3 px-4"}`}
 
78
  title={collapsed ? item.label : undefined}
79
  >
80
  <NavIcon name={item.icon} />
 
85
  {isLoggedIn ? (
86
  <button
87
  onClick={() => authClient.signOut()}
88
+ className={`flex items-center gap-3 rounded-[var(--radius-lg)] transition-all clay-hover text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] hover:text-[var(--clay-black)] w-full ${collapsed ? "justify-center py-3 px-2" : "py-3 px-4 text-left"
89
+ }`}
 
90
  title={collapsed ? "Keluar" : undefined}
91
  >
92
  <NavIcon name="logout" />
 
95
  ) : (
96
  <Link
97
  to="/login"
98
+ className={`flex items-center gap-3 rounded-[var(--radius-lg)] transition-all clay-hover text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] hover:text-[var(--clay-black)] ${collapsed ? "justify-center py-3 px-2" : "py-3 px-4"
99
+ }`}
 
100
  title={collapsed ? "Masuk" : undefined}
101
  >
102
  <NavIcon name="login" />
 
111
  onClick={toggle}
112
  title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
113
  style={{ left: collapsed ? "48px" : "240px" }}
114
+ className="hidden md:flex absolute top-6 z-40 items-center justify-center w-10 h-10 rounded-full bg-[var(--pure-white)] border-2 border-[var(--oat-border)] shadow-md hover:bg-[var(--oat-light)] transition-all duration-300 text-[var(--warm-charcoal)] clay-hover"
115
  >
116
  <span className="relative inline-flex size-6 shrink-0 items-center justify-center">
117
  <span
118
+ className={`material-symbols-outlined pointer-events-none absolute inset-0 flex items-center justify-center text-[20px] leading-none transition-all duration-300 ease-out select-none ${collapsed
119
+ ? "opacity-100 scale-100 rotate-0"
120
+ : "opacity-0 scale-75 -rotate-90"
121
+ }`}
 
122
  aria-hidden
123
  >
124
  right_panel_open
125
  </span>
126
  <span
127
+ className={`material-symbols-outlined pointer-events-none absolute inset-0 flex items-center justify-center text-[20px] leading-none transition-all duration-300 ease-out select-none ${collapsed
128
+ ? "opacity-0 scale-75 rotate-90"
129
+ : "opacity-100 scale-100 rotate-0"
130
+ }`}
 
131
  aria-hidden
132
  >
133
  right_panel_close
 
143
  <Link
144
  key={item.to}
145
  to={item.to}
146
+ className={`flex flex-col items-center justify-center px-4 py-1.5 transition-all rounded-[var(--radius-lg)] ${isActive
147
+ ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
148
+ : "text-[var(--warm-charcoal)]"
149
+ }`}
 
150
  >
151
  <NavIcon name={item.icon} />
152
  <span className="text-[10px] font-medium uppercase tracking-wider mt-1">{item.label}</span>
apps/web/src/components/test/AttemptTestView.tsx CHANGED
@@ -1,7 +1,7 @@
 
1
  import { Link } from "@tanstack/react-router";
2
  import { useQuery } from "@tanstack/react-query";
3
  import { Button } from "@labas/ui/components/button";
4
- import { Card, CardContent } from "@labas/ui/components/card";
5
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
6
  import { formatTime } from "@/lib/time";
7
  import { trpc } from "@/utils/trpc";
@@ -18,8 +18,12 @@ interface AttemptTestViewProps {
18
  answeredCount: number;
19
  totalQuestions: number;
20
  onFinish: () => void;
 
21
  isFinished: boolean;
22
  submittingQId: string | null;
 
 
 
23
  }
24
 
25
  export function AttemptTestView({
@@ -33,118 +37,164 @@ export function AttemptTestView({
33
  answeredCount,
34
  totalQuestions,
35
  onFinish,
 
36
  isFinished,
37
  submittingQId,
 
 
 
38
  }: AttemptTestViewProps) {
39
- const attemptQuery = useQuery(trpc.attempt.getById.queryOptions({ id: attemptId }));
 
 
 
 
 
 
 
 
 
 
40
  const attempt = attemptQuery.data;
41
  const currentSection = pkg.sections[currentSectionIdx];
42
  const sectionData = attempt?.sections?.[currentSectionIdx];
43
  const sectionResultId = sectionData?.sectionResultId;
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  return (
46
- <div className="min-h-screen bg-[var(--warm-cream)]">
47
- {/* Top Bar */}
48
- <div className="sticky top-0 z-50 bg-[var(--pure-white)] border-b-2 border-[var(--oat-border)] px-4 md:px-8 py-3">
49
- <div className="max-w-5xl mx-auto flex items-center justify-between gap-4">
50
- <div className="flex items-center gap-3">
51
- <Link to="/package/$id" params={{ id: pkg.id }} className="text-[var(--warm-charcoal)] hover:text-[var(--clay-black)]">
 
 
 
 
 
 
 
 
52
  <MaterialIcon name="close" />
53
- </Link>
54
- <h1 className="font-headline font-bold text-[var(--clay-black)] text-sm md:text-base truncate max-w-[200px] md:max-w-sm">
55
- {pkg.title}
56
- </h1>
 
57
  </div>
58
 
59
- <div className="flex items-center gap-4">
60
- <div className="flex items-center gap-1.5 bg-[var(--oat-light)] px-3 py-1.5 rounded-full text-sm font-mono text-[var(--clay-black)]">
61
- <MaterialIcon name="timer" className="text-sm" />
62
- {formatTime(timeElapsed)}
 
 
63
  </div>
64
- <div className="hidden md:flex items-center gap-1.5 text-sm text-[var(--warm-charcoal)]">
65
- <MaterialIcon name="check_circle" className="text-sm text-[var(--matcha-600)]" />
66
- {answeredCount}/{totalQuestions}
 
 
 
 
 
 
 
 
67
  </div>
 
 
 
68
  <Button
69
- onClick={onFinish}
70
  disabled={isFinished}
71
- className="bg-[var(--pomegranate-500)] text-[var(--pure-white)] hover:bg-[var(--pomegranate-700)] clay-hover rounded-[var(--radius-lg)] text-sm px-4 py-2"
72
  >
73
- Selesai
74
  </Button>
75
  </div>
76
- </div>
77
- </div>
78
 
79
- {/* Section Tabs */}
80
- <div className="max-w-5xl mx-auto px-4 md:px-8 py-4">
81
- <div className="flex gap-2 overflow-x-auto pb-2">
82
- {pkg.sections.map((sec: any, idx: number) => (
83
- <button
84
- key={sec.id}
85
- onClick={() => setCurrentSectionIdx(idx)}
86
- className={`px-4 py-2 rounded-[var(--radius-lg)] text-sm font-semibold whitespace-nowrap border-2 transition-colors ${
87
- idx === currentSectionIdx
88
- ? "bg-[var(--clay-black)] text-[var(--pure-white)] border-[var(--clay-black)]"
89
- : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-[var(--oat-border)] hover:border-[var(--matcha-400)]"
90
- }`}
91
- >
92
- {sec.title}
93
- </button>
94
- ))}
95
- </div>
96
- </div>
97
-
98
- {/* Main Content */}
99
- <div className="max-w-5xl mx-auto px-4 md:px-8 pb-32">
100
- <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
101
- {/* Left: Passage */}
102
- <div className="lg:col-span-1">
103
- <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] sticky top-24">
104
- <CardContent className="p-5">
105
- <h2 className="font-headline font-bold text-[var(--clay-black)] mb-3 flex items-center gap-2">
106
- <MaterialIcon name="menu_book" className="text-[var(--matcha-600)]" />
107
- Bacaan
108
- </h2>
109
- <div className="prose prose-sm max-w-none text-[var(--clay-black)] whitespace-pre-wrap text-sm leading-relaxed max-h-[60vh] overflow-y-auto pr-2">
110
- {currentSection.questions[0]?.passageText ?? "Tidak ada bacaan untuk section ini."}
111
  </div>
112
- </CardContent>
113
- </Card>
114
- </div>
 
 
 
115
 
116
- {/* Right: Questions */}
117
- <div className="lg:col-span-2 space-y-4">
118
- {currentSection.questions.map((q: any, idx: number) => {
119
- const answerValue = answers[q.id] ?? "";
120
- return (
121
- <Card
122
- key={q.id}
123
- className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]"
124
- >
125
- <CardContent className="p-5">
126
- <div className="flex items-start gap-3 mb-4">
127
- <span className="w-8 h-8 rounded-full bg-[var(--clay-black)] text-[var(--pure-white)] text-xs flex items-center justify-center font-bold shrink-0">
128
- {idx + 1}
129
- </span>
130
- <div>
131
- <p className="text-[var(--clay-black)] font-medium leading-relaxed">
132
- {q.questionText}
133
- </p>
134
- <div className="flex gap-2 mt-1">
135
- <span className="text-xs px-2 py-0.5 rounded bg-[var(--oat-light)] text-[var(--warm-charcoal)]">
136
- {q.format.replace(/_/g, " ")}
137
- </span>
138
- {q.difficulty && (
139
- <span className="text-xs px-2 py-0.5 rounded bg-[var(--oat-light)] text-[var(--warm-charcoal)]">
140
- Lv.{q.difficulty}
141
- </span>
142
- )}
143
- </div>
144
  </div>
 
 
 
 
 
 
 
 
 
 
 
145
  </div>
146
 
147
- <div className="pl-11">
 
 
 
 
148
  {submittingQId === q.id && (
149
  <div className="text-xs text-[var(--matcha-600)] mb-2 flex items-center gap-1">
150
  <MaterialIcon name="sync" className="text-xs animate-spin" />
@@ -162,44 +212,195 @@ export function AttemptTestView({
162
  disabled={isFinished || !sectionResultId}
163
  />
164
  </div>
165
- </CardContent>
166
- </Card>
167
- );
168
- })}
169
 
170
- {/* Section Navigation */}
171
- <div className="flex justify-between pt-4">
172
- <Button
173
- variant="outline"
174
- onClick={() => setCurrentSectionIdx(Math.max(0, currentSectionIdx - 1))}
175
- disabled={currentSectionIdx === 0}
176
- className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
177
- >
178
- <MaterialIcon name="arrow_back" />
179
- <span className="ml-2">Sebelumnya</span>
180
- </Button>
181
- {currentSectionIdx < pkg.sections.length - 1 ? (
182
- <Button
183
- onClick={() => setCurrentSectionIdx(currentSectionIdx + 1)}
184
- className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)]"
185
- >
186
- <span className="mr-2">Selanjutnya</span>
187
- <MaterialIcon name="arrow_forward" />
188
- </Button>
189
- ) : (
190
  <Button
191
- onClick={onFinish}
192
- disabled={isFinished}
193
- className="bg-[var(--pomegranate-500)] text-[var(--pure-white)] hover:bg-[var(--pomegranate-700)] clay-hover rounded-[var(--radius-lg)]"
 
194
  >
195
- <MaterialIcon name="check_circle" />
196
- <span className="ml-2">Selesaikan</span>
197
  </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  </div>
200
  </div>
201
  </div>
202
- </div>
203
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  );
205
  }
 
1
+ import { useState } from "react";
2
  import { Link } from "@tanstack/react-router";
3
  import { useQuery } from "@tanstack/react-query";
4
  import { Button } from "@labas/ui/components/button";
 
5
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
6
  import { formatTime } from "@/lib/time";
7
  import { trpc } from "@/utils/trpc";
 
18
  answeredCount: number;
19
  totalQuestions: number;
20
  onFinish: () => void;
21
+ onAbandon: () => void;
22
  isFinished: boolean;
23
  submittingQId: string | null;
24
+ markedQuestions: Set<string>;
25
+ toggleMarkQuestion: (questionId: string) => void;
26
+ startQuestionTimer: (questionId: string) => void;
27
  }
28
 
29
  export function AttemptTestView({
 
37
  answeredCount,
38
  totalQuestions,
39
  onFinish,
40
+ onAbandon,
41
  isFinished,
42
  submittingQId,
43
+ markedQuestions,
44
+ toggleMarkQuestion,
45
+ startQuestionTimer,
46
  }: AttemptTestViewProps) {
47
+ const [showFinishDialog, setShowFinishDialog] = useState(false);
48
+ const [showAbandonDialog, setShowAbandonDialog] = useState(false);
49
+
50
+ console.log("[AttemptTestView] render, attemptId:", attemptId, "sections:", pkg.sections?.length);
51
+
52
+ const attemptQuery = useQuery(
53
+ trpc.attempt.getById.queryOptions(
54
+ { id: attemptId },
55
+ { enabled: !!attemptId },
56
+ ),
57
+ );
58
  const attempt = attemptQuery.data;
59
  const currentSection = pkg.sections[currentSectionIdx];
60
  const sectionData = attempt?.sections?.[currentSectionIdx];
61
  const sectionResultId = sectionData?.sectionResultId;
62
 
63
+ if (!currentSection) {
64
+ return (
65
+ <div className="min-h-screen flex items-center justify-center bg-[var(--warm-cream)]">
66
+ <p className="text-[var(--warm-charcoal)]">Section tidak ditemukan.</p>
67
+ </div>
68
+ );
69
+ }
70
+
71
+ // Build global question index across all sections
72
+ const allQuestions: Array<{ id: string; sectionIdx: number; localIdx: number; passageText?: string }> = [];
73
+ pkg.sections.forEach((sec: any, sIdx: number) => {
74
+ sec.questions.forEach((q: any, qIdx: number) => {
75
+ allQuestions.push({ id: q.id, sectionIdx: sIdx, localIdx: qIdx, passageText: q.passageText });
76
+ });
77
+ });
78
+
79
+ const isAnswered = (qId: string) => !!answers[qId];
80
+ const isMarked = (qId: string) => markedQuestions.has(qId);
81
+
82
  return (
83
+ <>
84
+ <style>{`
85
+ .custom-scrollbar::-webkit-scrollbar { width: 6px; height: 6px; }
86
+ .custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
87
+ .custom-scrollbar::-webkit-scrollbar-thumb { background: var(--oat-border); border-radius: 10px; }
88
+ .hide-scrollbar::-webkit-scrollbar { display: none; }
89
+ .hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
90
+ `}</style>
91
+
92
+ <div className="h-full flex flex-col bg-[var(--warm-cream)] overflow-hidden">
93
+ {/* TopAppBar Shell */}
94
+ <header className="bg-[var(--pure-white)] border-b border-[var(--oat-border)] flex justify-between items-center w-full px-6 py-3 shrink-0 z-50">
95
+ <div className="flex items-center gap-4">
96
+ <button onClick={() => setShowAbandonDialog(true)} className="text-[var(--warm-charcoal)] hover:text-[var(--clay-black)] transition-colors flex items-center">
97
  <MaterialIcon name="close" />
98
+ </button>
99
+ <span className="text-xl font-bold tracking-tight text-[var(--clay-black)] truncate max-w-[200px] md:max-w-sm">{pkg.title}</span>
100
+ <span className="bg-[var(--oat-light)] px-3 py-1 rounded-full text-xs font-semibold text-[var(--warm-charcoal)] uppercase tracking-widest hidden md:inline-block">
101
+ {currentSection.title}
102
+ </span>
103
  </div>
104
 
105
+ {/* Timer Box */}
106
+ <div className="bg-[var(--pure-white)]/70 backdrop-blur-md rounded-xl hidden sm:flex items-center gap-3 px-6 py-2 shadow-sm border border-[var(--oat-border)]">
107
+ <MaterialIcon name="timer" className="text-[var(--matcha-600)]" />
108
+ <div className="flex flex-col">
109
+ <span className="text-[10px] leading-none uppercase font-bold text-[var(--warm-silver)] tracking-tighter">Waktu Berlalu</span>
110
+ <span className="text-xl font-bold font-headline tabular-nums text-[var(--clay-black)]">{formatTime(timeElapsed)}</span>
111
  </div>
112
+ </div>
113
+
114
+ <div className="flex items-center gap-6">
115
+ <div className="hidden md:flex items-center gap-2">
116
+ <div className="w-24 h-2 bg-[var(--oat-light)] rounded-full overflow-hidden">
117
+ <div
118
+ className="h-full bg-[var(--matcha-600)] transition-all rounded-full"
119
+ style={{ width: `${totalQuestions > 0 ? (answeredCount / totalQuestions) * 100 : 0}%` }}
120
+ />
121
+ </div>
122
+ <span className="text-xs font-bold text-[var(--clay-black)]">{answeredCount}/{totalQuestions} Dijawab</span>
123
  </div>
124
+ <button className="bg-[var(--oat-light)] p-2 rounded-xl text-[var(--warm-charcoal)] hover:text-[var(--clay-black)] transition-colors sm:hidden">
125
+ <MaterialIcon name="timer" />
126
+ </button>
127
  <Button
128
+ onClick={() => setShowFinishDialog(true)}
129
  disabled={isFinished}
130
+ className="bg-[var(--pomegranate-500)] text-[var(--pure-white)] hover:bg-[var(--pomegranate-600)] px-4 py-2 rounded-xl text-sm font-bold transition-opacity"
131
  >
132
+ Selesai Test
133
  </Button>
134
  </div>
135
+ </header>
 
136
 
137
+ {/* Main Exam Workspace */}
138
+ <main className="flex-1 flex overflow-hidden flex-col lg:flex-row">
139
+ {/* Left Column: Reading Passage */}
140
+ <section className="w-full lg:w-1/2 bg-[var(--pure-white)] overflow-y-auto custom-scrollbar p-6 md:p-12 border-b lg:border-b-0 lg:border-r border-[var(--oat-border)] h-1/2 lg:h-full">
141
+ <article className="max-w-2xl mx-auto">
142
+ <header className="mb-8">
143
+ <h1 className="text-3xl font-extrabold text-[var(--clay-black)] mb-4 leading-tight">
144
+ {currentSection.title}
145
+ </h1>
146
+ <div className="flex gap-4 text-sm text-[var(--warm-silver)] italic">
147
+ <span>Section {currentSectionIdx + 1} dari {pkg.sections.length}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  </div>
149
+ </header>
150
+ <div className="space-y-6 text-lg leading-relaxed text-[var(--warm-charcoal)] font-body whitespace-pre-wrap">
151
+ {currentSection.questions[0]?.passageText ?? "Tidak ada bacaan tambahan untuk section ini."}
152
+ </div>
153
+ </article>
154
+ </section>
155
 
156
+ {/* Right Column: Question Panel */}
157
+ <section className="w-full lg:w-1/2 bg-[var(--warm-cream)] overflow-y-auto custom-scrollbar p-6 md:p-10 relative h-1/2 lg:h-full">
158
+ <div className="max-w-2xl mx-auto space-y-8 pb-32">
159
+ {currentSection.questions.map((q: any) => {
160
+ const answerValue = answers[q.id] ?? "";
161
+ const globalIdx = allQuestions.findIndex((aq) => aq.id === q.id) + 1;
162
+
163
+ return (
164
+ <div
165
+ key={q.id}
166
+ id={`question-${q.id}`}
167
+ className={`bg-[var(--pure-white)] p-6 md:p-8 rounded-xl shadow-sm border-2 transition-all ${
168
+ isMarked(q.id) ? "border-[var(--pomegranate-400)]" : "border-transparent"
169
+ }`}
170
+ >
171
+ <div className="flex items-center justify-between mb-6">
172
+ <div className="flex items-center gap-3">
173
+ <span className="bg-[var(--clay-black)] text-[var(--pure-white)] w-8 h-8 flex items-center justify-center rounded-lg font-bold text-sm">
174
+ {globalIdx}
175
+ </span>
176
+ <h2 className="text-xl font-bold text-[var(--clay-black)] capitalize">
177
+ {q.format.replace(/_/g, " ")}
178
+ </h2>
 
 
 
 
 
179
  </div>
180
+ <button
181
+ onClick={() => toggleMarkQuestion(q.id)}
182
+ className={`shrink-0 w-10 h-10 flex items-center justify-center rounded-full transition-colors ${
183
+ isMarked(q.id)
184
+ ? "bg-[var(--pomegranate-400)]/20 text-[var(--pomegranate-400)]"
185
+ : "bg-[var(--oat-light)] text-[var(--warm-silver)] hover:text-[var(--pomegranate-400)]"
186
+ }`}
187
+ title={isMarked(q.id) ? "Hapus tanda" : "Tandai untuk review"}
188
+ >
189
+ <MaterialIcon name={isMarked(q.id) ? "bookmark" : "bookmark_border"} className="text-lg" />
190
+ </button>
191
  </div>
192
 
193
+ <p className="text-[var(--warm-charcoal)] mb-6 font-medium leading-relaxed">
194
+ {q.questionText}
195
+ </p>
196
+
197
+ <div className="pl-0">
198
  {submittingQId === q.id && (
199
  <div className="text-xs text-[var(--matcha-600)] mb-2 flex items-center gap-1">
200
  <MaterialIcon name="sync" className="text-xs animate-spin" />
 
212
  disabled={isFinished || !sectionResultId}
213
  />
214
  </div>
215
+ </div>
216
+ );
217
+ })}
 
218
 
219
+ {/* Bottom Navigation */}
220
+ <nav className="flex justify-between items-center pt-8 pb-12">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  <Button
222
+ variant="ghost"
223
+ onClick={() => setCurrentSectionIdx(Math.max(0, currentSectionIdx - 1))}
224
+ disabled={currentSectionIdx === 0}
225
+ className="flex items-center gap-2 text-[var(--clay-black)] font-bold px-6 py-3 rounded-xl hover:bg-[var(--clay-black)]/5 transition-all disabled:opacity-50"
226
  >
227
+ <MaterialIcon name="arrow_back" />
228
+ Sebelumnya
229
  </Button>
230
+
231
+ <div className="flex gap-2">
232
+ {pkg.sections.map((_: any, idx: number) => (
233
+ <div
234
+ key={idx}
235
+ className={`w-2 h-2 rounded-full ${idx === currentSectionIdx ? "bg-[var(--matcha-600)]" : "bg-[var(--oat-border)]"}`}
236
+ />
237
+ ))}
238
+ </div>
239
+
240
+ {currentSectionIdx < pkg.sections.length - 1 ? (
241
+ <Button
242
+ onClick={() => setCurrentSectionIdx(currentSectionIdx + 1)}
243
+ className="flex items-center gap-2 bg-[var(--clay-black)] text-[var(--pure-white)] font-bold px-8 py-3 rounded-xl shadow-lg hover:bg-[var(--warm-charcoal)] transition-all"
244
+ >
245
+ Selanjutnya
246
+ <MaterialIcon name="arrow_forward" />
247
+ </Button>
248
+ ) : (
249
+ <Button
250
+ onClick={() => setShowFinishDialog(true)}
251
+ disabled={isFinished}
252
+ className="flex items-center gap-2 bg-[var(--matcha-600)] text-[var(--pure-white)] font-bold px-8 py-3 rounded-xl shadow-lg hover:bg-[var(--matcha-700)] transition-all"
253
+ >
254
+ Selesai
255
+ <MaterialIcon name="check_circle" />
256
+ </Button>
257
+ )}
258
+ </nav>
259
+ </div>
260
+ </section>
261
+ </main>
262
+
263
+ {/* Floating Navigation Orb (Quick Question Jump) */}
264
+ <div className="fixed bottom-6 left-1/2 -translate-x-1/2 flex items-center gap-2 p-2 bg-[var(--clay-black)]/95 backdrop-blur-md rounded-full shadow-2xl border border-white/10 z-40 max-w-[90vw] overflow-x-auto hide-scrollbar">
265
+ {allQuestions.map((q, gIdx) => {
266
+ const answered = isAnswered(q.id);
267
+ const marked = isMarked(q.id);
268
+
269
+ return (
270
+ <button
271
+ key={q.id}
272
+ onClick={() => {
273
+ setCurrentSectionIdx(q.sectionIdx);
274
+ setTimeout(() => {
275
+ const el = document.getElementById(`question-${q.id}`);
276
+ el?.scrollIntoView({ behavior: "smooth", block: "center" });
277
+ }, 100);
278
+ }}
279
+ className={`relative w-10 h-10 shrink-0 flex items-center justify-center rounded-full font-bold text-xs transition-all ${
280
+ answered
281
+ ? "bg-[var(--matcha-600)] text-[var(--pure-white)]"
282
+ : "bg-white/10 text-white/60 hover:bg-white/20"
283
+ }`}
284
+ title={`Soal ${gIdx + 1}${marked ? " (ditandai)" : ""}`}
285
+ >
286
+ {gIdx + 1}
287
+ {marked && (
288
+ <span className="absolute top-0 right-0 w-2.5 h-2.5 bg-[var(--pomegranate-400)] rounded-full border border-[var(--clay-black)]" />
289
+ )}
290
+ </button>
291
+ );
292
+ })}
293
+ <div className="w-1 h-6 bg-white/10 mx-1 shrink-0"></div>
294
+ <button
295
+ onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
296
+ className="flex items-center gap-1 px-4 py-2 shrink-0 rounded-full text-white/80 font-semibold text-xs hover:text-white transition-all"
297
+ >
298
+ <MaterialIcon name="arrow_upward" className="text-sm" />
299
+ Top
300
+ </button>
301
+ </div>
302
+ </div>
303
+
304
+ {/* Finish Confirmation Dialog */}
305
+ {showFinishDialog && (
306
+ <div
307
+ className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
308
+ onClick={(e) => {
309
+ if (e.target === e.currentTarget) setShowFinishDialog(false);
310
+ }}
311
+ >
312
+ <div className="bg-[var(--warm-cream)] w-full max-w-md rounded-[var(--radius-xl)] border-2 border-[var(--oat-border)] shadow-xl p-6 md:p-8">
313
+ <div className="flex items-center gap-3 mb-4">
314
+ <div className="w-10 h-10 rounded-full bg-[var(--pomegranate-400)]/20 flex items-center justify-center">
315
+ <MaterialIcon name="help" className="text-[var(--pomegranate-500)]" />
316
+ </div>
317
+ <h2 className="text-xl font-headline font-bold text-[var(--clay-black)]">
318
+ Selesaikan Latihan?
319
+ </h2>
320
+ </div>
321
+
322
+ <div className="space-y-3 mb-6">
323
+ <p className="text-sm text-[var(--warm-charcoal)]">
324
+ Kamu sudah menjawab <strong className="text-[var(--clay-black)]">{answeredCount} dari {totalQuestions}</strong> soal.
325
+ </p>
326
+ {answeredCount < totalQuestions && (
327
+ <div className="p-3 rounded-[var(--radius-md)] bg-[var(--lemon-400)]/20 border-2 border-[var(--lemon-500)]/30 text-sm text-[var(--lemon-800)] flex items-start gap-2">
328
+ <MaterialIcon name="warning" className="text-sm mt-0.5 shrink-0" />
329
+ <span>Masih ada {totalQuestions - answeredCount} soal yang belum dijawab.</span>
330
+ </div>
331
  )}
332
+ <p className="text-sm text-[var(--warm-charcoal)]">
333
+ Setelah selesai, jawaban tidak bisa diubah dan hasil akan langsung terlihat.
334
+ </p>
335
+ </div>
336
+
337
+ <div className="flex gap-3">
338
+ <Button
339
+ variant="outline"
340
+ onClick={() => setShowFinishDialog(false)}
341
+ className="flex-1 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)]"
342
+ >
343
+ Lanjutkan
344
+ </Button>
345
+ <Button
346
+ onClick={() => {
347
+ setShowFinishDialog(false);
348
+ onFinish();
349
+ }}
350
+ className="flex-1 bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)]"
351
+ >
352
+ Selesaikan
353
+ </Button>
354
  </div>
355
  </div>
356
  </div>
357
+ )}
358
+
359
+ {/* Abandon Confirmation Dialog */}
360
+ {showAbandonDialog && (
361
+ <div
362
+ className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
363
+ onClick={(e) => {
364
+ if (e.target === e.currentTarget) setShowAbandonDialog(false);
365
+ }}
366
+ >
367
+ <div className="bg-[var(--warm-cream)] w-full max-w-md rounded-[var(--radius-xl)] border-2 border-[var(--oat-border)] shadow-xl p-6 md:p-8">
368
+ <div className="flex items-center gap-3 mb-4">
369
+ <div className="w-10 h-10 rounded-full bg-[var(--pomegranate-400)]/20 flex items-center justify-center">
370
+ <MaterialIcon name="warning" className="text-[var(--pomegranate-500)]" />
371
+ </div>
372
+ <h2 className="text-xl font-headline font-bold text-[var(--clay-black)]">
373
+ Keluar dari Latihan?
374
+ </h2>
375
+ </div>
376
+
377
+ <div className="space-y-3 mb-6">
378
+ <p className="text-sm text-[var(--warm-charcoal)]">
379
+ Apakah Anda yakin ingin meninggalkan sesi latihan ini? Progress pengerjaan Anda mungkin tidak tersimpan dan akan ditandai sebagai gagal atau dibatalkan.
380
+ </p>
381
+ </div>
382
+
383
+ <div className="flex gap-3">
384
+ <Button
385
+ variant="outline"
386
+ onClick={() => setShowAbandonDialog(false)}
387
+ className="flex-1 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)]"
388
+ >
389
+ Batal
390
+ </Button>
391
+ <Button
392
+ onClick={() => {
393
+ setShowAbandonDialog(false);
394
+ onAbandon();
395
+ }}
396
+ className="flex-1 bg-[var(--pomegranate-500)] text-[var(--pure-white)] hover:bg-[var(--pomegranate-600)] rounded-[var(--radius-lg)]"
397
+ >
398
+ Keluar
399
+ </Button>
400
+ </div>
401
+ </div>
402
+ </div>
403
+ )}
404
+ </>
405
  );
406
  }
apps/web/src/components/test/QuestionInput.tsx CHANGED
@@ -28,8 +28,8 @@ const AUTHOR_VIEW_CHOICES = [
28
  ];
29
 
30
  const radioClass =
31
- "flex items-center gap-3 p-3 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] cursor-pointer hover:border-[var(--matcha-400)] transition-colors";
32
- const radioSelected = "border-[var(--matcha-600)] bg-[var(--matcha-100)]";
33
  const radioDisabled = "opacity-60 cursor-not-allowed";
34
 
35
  export function QuestionInput({
@@ -70,7 +70,7 @@ export function QuestionInput({
70
  disabled={disabled}
71
  className="hidden"
72
  />
73
- <span className="w-8 h-8 rounded-full bg-[var(--oat-light)] text-[var(--clay-black)] text-sm font-bold flex items-center justify-center shrink-0">
74
  {opt.key}
75
  </span>
76
  <span className="text-sm text-[var(--clay-black)]">{opt.text}</span>
 
28
  ];
29
 
30
  const radioClass =
31
+ "flex items-center gap-3 p-3 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] cursor-pointer hover:border-[var(--matcha-300)] transition-colors";
32
+ const radioSelected = "border-[var(--matcha-600)] bg-[#e8f5ed]";
33
  const radioDisabled = "opacity-60 cursor-not-allowed";
34
 
35
  export function QuestionInput({
 
70
  disabled={disabled}
71
  className="hidden"
72
  />
73
+ <span className={`w-8 h-8 rounded-full text-sm font-bold flex items-center justify-center shrink-0 transition-colors ${value === opt.key ? "bg-[var(--matcha-600)] text-[var(--pure-white)]" : "bg-[var(--oat-light)] text-[var(--clay-black)]"}`}>
74
  {opt.key}
75
  </span>
76
  <span className="text-sm text-[var(--clay-black)]">{opt.text}</span>
apps/web/src/hooks/use-analytics.ts ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import { trpc } from "@/utils/trpc";
3
+
4
+ export function useAnalytics() {
5
+ const overview = useQuery(trpc.stats.overview.queryOptions());
6
+ const byExamType = useQuery(trpc.stats.byExamType.queryOptions());
7
+ const bySectionType = useQuery(trpc.stats.bySectionType.queryOptions());
8
+ const byFormat = useQuery(trpc.stats.byFormat.queryOptions());
9
+ const bySkillTag = useQuery(trpc.stats.bySkillTag.queryOptions());
10
+ const trend = useQuery(trpc.stats.trend.queryOptions());
11
+ const weaknesses = useQuery(trpc.stats.weaknesses.queryOptions());
12
+ const timeAnalytics = useQuery(trpc.stats.timeAnalytics.queryOptions());
13
+
14
+ const isLoading =
15
+ overview.isLoading ||
16
+ byExamType.isLoading ||
17
+ bySectionType.isLoading ||
18
+ byFormat.isLoading ||
19
+ bySkillTag.isLoading ||
20
+ trend.isLoading ||
21
+ weaknesses.isLoading ||
22
+ timeAnalytics.isLoading;
23
+
24
+ return {
25
+ overview,
26
+ byExamType,
27
+ bySectionType,
28
+ byFormat,
29
+ bySkillTag,
30
+ trend,
31
+ weaknesses,
32
+ timeAnalytics,
33
+ isLoading,
34
+ };
35
+ }
apps/web/src/hooks/use-test-session.ts CHANGED
@@ -1,8 +1,29 @@
1
- import { useState, useEffect, useCallback } from "react";
2
  import { useMutation } from "@tanstack/react-query";
3
  import { useNavigate } from "@tanstack/react-router";
4
  import { trpc } from "@/utils/trpc";
5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  export function useTestSession(packageId: string) {
7
  const navigate = useNavigate();
8
 
@@ -13,24 +34,48 @@ export function useTestSession(packageId: string) {
13
  const [isStarted, setIsStarted] = useState(false);
14
  const [isFinished, setIsFinished] = useState(false);
15
  const [submittingQId, setSubmittingQId] = useState<string | null>(null);
 
 
 
 
 
16
 
17
  const startMutation = useMutation(trpc.attempt.start.mutationOptions());
18
  const submitMutation = useMutation(trpc.attempt.submitAnswer.mutationOptions());
19
  const finishMutation = useMutation(trpc.attempt.finish.mutationOptions());
 
20
 
21
- // Timer
22
  useEffect(() => {
23
- if (!isStarted || isFinished) return;
24
  const interval = setInterval(() => {
25
- setTimeElapsed((t) => t + 1);
 
 
 
 
26
  }, 1000);
27
  return () => clearInterval(interval);
28
- }, [isStarted, isFinished]);
29
 
30
  const handleStart = useCallback(async () => {
31
- const res = await startMutation.mutateAsync({ packageId });
32
- setAttemptId(res.attemptId);
33
- setIsStarted(true);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  }, [packageId, startMutation]);
35
 
36
  const handleAnswerChange = useCallback(
@@ -38,12 +83,23 @@ export function useTestSession(packageId: string) {
38
  if (!attemptId || isFinished) return;
39
  setAnswers((prev) => ({ ...prev, [questionId]: value }));
40
  setSubmittingQId(questionId);
 
 
 
 
 
 
 
 
 
 
41
  try {
42
  await submitMutation.mutateAsync({
43
  attemptId,
44
  sectionResultId,
45
  questionId,
46
  userAnswer: value,
 
47
  });
48
  } finally {
49
  setSubmittingQId(null);
@@ -56,9 +112,33 @@ export function useTestSession(packageId: string) {
56
  if (!attemptId) return;
57
  setIsFinished(true);
58
  await finishMutation.mutateAsync({ attemptId });
 
59
  navigate({ to: "/attempt/$id", params: { id: attemptId } });
60
  }, [attemptId, finishMutation, navigate]);
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  return {
63
  attemptId,
64
  currentSectionIdx,
@@ -68,9 +148,14 @@ export function useTestSession(packageId: string) {
68
  isStarted,
69
  isFinished,
70
  submittingQId,
 
71
  startPending: startMutation.isPending,
 
72
  handleStart,
73
  handleAnswerChange,
74
  handleFinish,
 
 
 
75
  };
76
  }
 
1
+ import { useState, useEffect, useCallback, useRef } from "react";
2
  import { useMutation } from "@tanstack/react-query";
3
  import { useNavigate } from "@tanstack/react-router";
4
  import { trpc } from "@/utils/trpc";
5
 
6
+ function getTimerKey(attemptId: string | null) {
7
+ return attemptId ? `labas_attempt_timer_${attemptId}` : null;
8
+ }
9
+
10
+ function loadElapsedTime(attemptId: string | null): number {
11
+ const key = getTimerKey(attemptId);
12
+ if (!key) return 0;
13
+ const saved = localStorage.getItem(key);
14
+ return saved ? parseInt(saved, 10) || 0 : 0;
15
+ }
16
+
17
+ function saveElapsedTime(attemptId: string | null, seconds: number) {
18
+ const key = getTimerKey(attemptId);
19
+ if (key) localStorage.setItem(key, String(seconds));
20
+ }
21
+
22
+ function clearElapsedTime(attemptId: string | null) {
23
+ const key = getTimerKey(attemptId);
24
+ if (key) localStorage.removeItem(key);
25
+ }
26
+
27
  export function useTestSession(packageId: string) {
28
  const navigate = useNavigate();
29
 
 
34
  const [isStarted, setIsStarted] = useState(false);
35
  const [isFinished, setIsFinished] = useState(false);
36
  const [submittingQId, setSubmittingQId] = useState<string | null>(null);
37
+ const [markedQuestions, setMarkedQuestions] = useState<Set<string>>(new Set());
38
+ const [startError, setStartError] = useState<string | null>(null);
39
+
40
+ // Track when each question was first viewed (for timeSpentSec)
41
+ const questionStartTimes = useRef<Record<string, number>>({});
42
 
43
  const startMutation = useMutation(trpc.attempt.start.mutationOptions());
44
  const submitMutation = useMutation(trpc.attempt.submitAnswer.mutationOptions());
45
  const finishMutation = useMutation(trpc.attempt.finish.mutationOptions());
46
+ const abandonMutation = useMutation(trpc.attempt.abandon.mutationOptions());
47
 
48
+ // Timer with localStorage persistence
49
  useEffect(() => {
50
+ if (!isStarted || isFinished || !attemptId) return;
51
  const interval = setInterval(() => {
52
+ setTimeElapsed((t) => {
53
+ const next = t + 1;
54
+ saveElapsedTime(attemptId, next);
55
+ return next;
56
+ });
57
  }, 1000);
58
  return () => clearInterval(interval);
59
+ }, [isStarted, isFinished, attemptId]);
60
 
61
  const handleStart = useCallback(async () => {
62
+ setStartError(null);
63
+ console.log("[useTestSession] handleStart called, packageId:", packageId);
64
+ try {
65
+ const res = await startMutation.mutateAsync({ packageId });
66
+ console.log("[useTestSession] start mutation result:", res);
67
+ if (!res?.attemptId) {
68
+ throw new Error("Server tidak mengembalikan attempt ID.");
69
+ }
70
+ setAttemptId(res.attemptId);
71
+ setIsStarted(true);
72
+ // Load any saved timer (in case of refresh during attempt)
73
+ const saved = loadElapsedTime(res.attemptId);
74
+ if (saved > 0) setTimeElapsed(saved);
75
+ } catch (err: any) {
76
+ console.error("[useTestSession] Start attempt failed:", err);
77
+ setStartError(err.message ?? "Gagal memulai latihan. Coba lagi.");
78
+ }
79
  }, [packageId, startMutation]);
80
 
81
  const handleAnswerChange = useCallback(
 
83
  if (!attemptId || isFinished) return;
84
  setAnswers((prev) => ({ ...prev, [questionId]: value }));
85
  setSubmittingQId(questionId);
86
+
87
+ // Start timer if first interaction
88
+ if (!questionStartTimes.current[questionId]) {
89
+ questionStartTimes.current[questionId] = Date.now();
90
+ }
91
+
92
+ // Calculate time spent on this question
93
+ const startTime = questionStartTimes.current[questionId];
94
+ const timeSpentSec = startTime ? Math.round((Date.now() - startTime) / 1000) : undefined;
95
+
96
  try {
97
  await submitMutation.mutateAsync({
98
  attemptId,
99
  sectionResultId,
100
  questionId,
101
  userAnswer: value,
102
+ timeSpentSec,
103
  });
104
  } finally {
105
  setSubmittingQId(null);
 
112
  if (!attemptId) return;
113
  setIsFinished(true);
114
  await finishMutation.mutateAsync({ attemptId });
115
+ clearElapsedTime(attemptId);
116
  navigate({ to: "/attempt/$id", params: { id: attemptId } });
117
  }, [attemptId, finishMutation, navigate]);
118
 
119
+ const handleAbandon = useCallback(async () => {
120
+ if (!attemptId) return;
121
+ await abandonMutation.mutateAsync({ attemptId });
122
+ clearElapsedTime(attemptId);
123
+ navigate({ to: "/packages" });
124
+ }, [attemptId, abandonMutation, navigate]);
125
+
126
+ const toggleMarkQuestion = useCallback((questionId: string) => {
127
+ setMarkedQuestions((prev) => {
128
+ const next = new Set(prev);
129
+ if (next.has(questionId)) next.delete(questionId);
130
+ else next.add(questionId);
131
+ return next;
132
+ });
133
+ }, []);
134
+
135
+ // Track question start time when user navigates to a question
136
+ const startQuestionTimer = useCallback((questionId: string) => {
137
+ if (!questionStartTimes.current[questionId]) {
138
+ questionStartTimes.current[questionId] = Date.now();
139
+ }
140
+ }, []);
141
+
142
  return {
143
  attemptId,
144
  currentSectionIdx,
 
148
  isStarted,
149
  isFinished,
150
  submittingQId,
151
+ markedQuestions,
152
  startPending: startMutation.isPending,
153
+ startError,
154
  handleStart,
155
  handleAnswerChange,
156
  handleFinish,
157
+ handleAbandon,
158
+ toggleMarkQuestion,
159
+ startQuestionTimer,
160
  };
161
  }
apps/web/src/routeTree.gen.ts CHANGED
@@ -15,10 +15,12 @@ import { Route as LoginRouteImport } from './routes/login'
15
  import { Route as JobsRouteImport } from './routes/jobs'
16
  import { Route as GenerateRouteImport } from './routes/generate'
17
  import { Route as BankRouteImport } from './routes/bank'
 
18
  import { Route as IndexRouteImport } from './routes/index'
19
  import { Route as PackageIdRouteImport } from './routes/package.$id'
20
  import { Route as BuilderComboRouteImport } from './routes/builder.combo'
21
  import { Route as AttemptIdRouteImport } from './routes/attempt.$id'
 
22
  import { Route as PackageIdTakeRouteImport } from './routes/package.$id.take'
23
 
24
  const SettingsRoute = SettingsRouteImport.update({
@@ -51,6 +53,11 @@ const BankRoute = BankRouteImport.update({
51
  path: '/bank',
52
  getParentRoute: () => rootRouteImport,
53
  } as any)
 
 
 
 
 
54
  const IndexRoute = IndexRouteImport.update({
55
  id: '/',
56
  path: '/',
@@ -71,6 +78,11 @@ const AttemptIdRoute = AttemptIdRouteImport.update({
71
  path: '/attempt/$id',
72
  getParentRoute: () => rootRouteImport,
73
  } as any)
 
 
 
 
 
74
  const PackageIdTakeRoute = PackageIdTakeRouteImport.update({
75
  id: '/take',
76
  path: '/take',
@@ -79,6 +91,7 @@ const PackageIdTakeRoute = PackageIdTakeRouteImport.update({
79
 
80
  export interface FileRoutesByFullPath {
81
  '/': typeof IndexRoute
 
82
  '/bank': typeof BankRoute
83
  '/generate': typeof GenerateRoute
84
  '/jobs': typeof JobsRoute
@@ -89,9 +102,11 @@ export interface FileRoutesByFullPath {
89
  '/builder/combo': typeof BuilderComboRoute
90
  '/package/$id': typeof PackageIdRouteWithChildren
91
  '/package/$id/take': typeof PackageIdTakeRoute
 
92
  }
93
  export interface FileRoutesByTo {
94
  '/': typeof IndexRoute
 
95
  '/bank': typeof BankRoute
96
  '/generate': typeof GenerateRoute
97
  '/jobs': typeof JobsRoute
@@ -100,12 +115,13 @@ export interface FileRoutesByTo {
100
  '/settings': typeof SettingsRoute
101
  '/attempt/$id': typeof AttemptIdRoute
102
  '/builder/combo': typeof BuilderComboRoute
103
- '/package/$id': typeof PackageIdRouteWithChildren
104
  '/package/$id/take': typeof PackageIdTakeRoute
 
105
  }
106
  export interface FileRoutesById {
107
  __root__: typeof rootRouteImport
108
  '/': typeof IndexRoute
 
109
  '/bank': typeof BankRoute
110
  '/generate': typeof GenerateRoute
111
  '/jobs': typeof JobsRoute
@@ -116,11 +132,13 @@ export interface FileRoutesById {
116
  '/builder/combo': typeof BuilderComboRoute
117
  '/package/$id': typeof PackageIdRouteWithChildren
118
  '/package/$id/take': typeof PackageIdTakeRoute
 
119
  }
120
  export interface FileRouteTypes {
121
  fileRoutesByFullPath: FileRoutesByFullPath
122
  fullPaths:
123
  | '/'
 
124
  | '/bank'
125
  | '/generate'
126
  | '/jobs'
@@ -131,9 +149,11 @@ export interface FileRouteTypes {
131
  | '/builder/combo'
132
  | '/package/$id'
133
  | '/package/$id/take'
 
134
  fileRoutesByTo: FileRoutesByTo
135
  to:
136
  | '/'
 
137
  | '/bank'
138
  | '/generate'
139
  | '/jobs'
@@ -142,11 +162,12 @@ export interface FileRouteTypes {
142
  | '/settings'
143
  | '/attempt/$id'
144
  | '/builder/combo'
145
- | '/package/$id'
146
  | '/package/$id/take'
 
147
  id:
148
  | '__root__'
149
  | '/'
 
150
  | '/bank'
151
  | '/generate'
152
  | '/jobs'
@@ -157,10 +178,12 @@ export interface FileRouteTypes {
157
  | '/builder/combo'
158
  | '/package/$id'
159
  | '/package/$id/take'
 
160
  fileRoutesById: FileRoutesById
161
  }
162
  export interface RootRouteChildren {
163
  IndexRoute: typeof IndexRoute
 
164
  BankRoute: typeof BankRoute
165
  GenerateRoute: typeof GenerateRoute
166
  JobsRoute: typeof JobsRoute
@@ -216,6 +239,13 @@ declare module '@tanstack/react-router' {
216
  preLoaderRoute: typeof BankRouteImport
217
  parentRoute: typeof rootRouteImport
218
  }
 
 
 
 
 
 
 
219
  '/': {
220
  id: '/'
221
  path: '/'
@@ -244,6 +274,13 @@ declare module '@tanstack/react-router' {
244
  preLoaderRoute: typeof AttemptIdRouteImport
245
  parentRoute: typeof rootRouteImport
246
  }
 
 
 
 
 
 
 
247
  '/package/$id/take': {
248
  id: '/package/$id/take'
249
  path: '/take'
@@ -256,10 +293,12 @@ declare module '@tanstack/react-router' {
256
 
257
  interface PackageIdRouteChildren {
258
  PackageIdTakeRoute: typeof PackageIdTakeRoute
 
259
  }
260
 
261
  const PackageIdRouteChildren: PackageIdRouteChildren = {
262
  PackageIdTakeRoute: PackageIdTakeRoute,
 
263
  }
264
 
265
  const PackageIdRouteWithChildren = PackageIdRoute._addFileChildren(
@@ -268,6 +307,7 @@ const PackageIdRouteWithChildren = PackageIdRoute._addFileChildren(
268
 
269
  const rootRouteChildren: RootRouteChildren = {
270
  IndexRoute: IndexRoute,
 
271
  BankRoute: BankRoute,
272
  GenerateRoute: GenerateRoute,
273
  JobsRoute: JobsRoute,
 
15
  import { Route as JobsRouteImport } from './routes/jobs'
16
  import { Route as GenerateRouteImport } from './routes/generate'
17
  import { Route as BankRouteImport } from './routes/bank'
18
+ import { Route as AnalyticsRouteImport } from './routes/analytics'
19
  import { Route as IndexRouteImport } from './routes/index'
20
  import { Route as PackageIdRouteImport } from './routes/package.$id'
21
  import { Route as BuilderComboRouteImport } from './routes/builder.combo'
22
  import { Route as AttemptIdRouteImport } from './routes/attempt.$id'
23
+ import { Route as PackageIdIndexRouteImport } from './routes/package.$id.index'
24
  import { Route as PackageIdTakeRouteImport } from './routes/package.$id.take'
25
 
26
  const SettingsRoute = SettingsRouteImport.update({
 
53
  path: '/bank',
54
  getParentRoute: () => rootRouteImport,
55
  } as any)
56
+ const AnalyticsRoute = AnalyticsRouteImport.update({
57
+ id: '/analytics',
58
+ path: '/analytics',
59
+ getParentRoute: () => rootRouteImport,
60
+ } as any)
61
  const IndexRoute = IndexRouteImport.update({
62
  id: '/',
63
  path: '/',
 
78
  path: '/attempt/$id',
79
  getParentRoute: () => rootRouteImport,
80
  } as any)
81
+ const PackageIdIndexRoute = PackageIdIndexRouteImport.update({
82
+ id: '/',
83
+ path: '/',
84
+ getParentRoute: () => PackageIdRoute,
85
+ } as any)
86
  const PackageIdTakeRoute = PackageIdTakeRouteImport.update({
87
  id: '/take',
88
  path: '/take',
 
91
 
92
  export interface FileRoutesByFullPath {
93
  '/': typeof IndexRoute
94
+ '/analytics': typeof AnalyticsRoute
95
  '/bank': typeof BankRoute
96
  '/generate': typeof GenerateRoute
97
  '/jobs': typeof JobsRoute
 
102
  '/builder/combo': typeof BuilderComboRoute
103
  '/package/$id': typeof PackageIdRouteWithChildren
104
  '/package/$id/take': typeof PackageIdTakeRoute
105
+ '/package/$id/': typeof PackageIdIndexRoute
106
  }
107
  export interface FileRoutesByTo {
108
  '/': typeof IndexRoute
109
+ '/analytics': typeof AnalyticsRoute
110
  '/bank': typeof BankRoute
111
  '/generate': typeof GenerateRoute
112
  '/jobs': typeof JobsRoute
 
115
  '/settings': typeof SettingsRoute
116
  '/attempt/$id': typeof AttemptIdRoute
117
  '/builder/combo': typeof BuilderComboRoute
 
118
  '/package/$id/take': typeof PackageIdTakeRoute
119
+ '/package/$id': typeof PackageIdIndexRoute
120
  }
121
  export interface FileRoutesById {
122
  __root__: typeof rootRouteImport
123
  '/': typeof IndexRoute
124
+ '/analytics': typeof AnalyticsRoute
125
  '/bank': typeof BankRoute
126
  '/generate': typeof GenerateRoute
127
  '/jobs': typeof JobsRoute
 
132
  '/builder/combo': typeof BuilderComboRoute
133
  '/package/$id': typeof PackageIdRouteWithChildren
134
  '/package/$id/take': typeof PackageIdTakeRoute
135
+ '/package/$id/': typeof PackageIdIndexRoute
136
  }
137
  export interface FileRouteTypes {
138
  fileRoutesByFullPath: FileRoutesByFullPath
139
  fullPaths:
140
  | '/'
141
+ | '/analytics'
142
  | '/bank'
143
  | '/generate'
144
  | '/jobs'
 
149
  | '/builder/combo'
150
  | '/package/$id'
151
  | '/package/$id/take'
152
+ | '/package/$id/'
153
  fileRoutesByTo: FileRoutesByTo
154
  to:
155
  | '/'
156
+ | '/analytics'
157
  | '/bank'
158
  | '/generate'
159
  | '/jobs'
 
162
  | '/settings'
163
  | '/attempt/$id'
164
  | '/builder/combo'
 
165
  | '/package/$id/take'
166
+ | '/package/$id'
167
  id:
168
  | '__root__'
169
  | '/'
170
+ | '/analytics'
171
  | '/bank'
172
  | '/generate'
173
  | '/jobs'
 
178
  | '/builder/combo'
179
  | '/package/$id'
180
  | '/package/$id/take'
181
+ | '/package/$id/'
182
  fileRoutesById: FileRoutesById
183
  }
184
  export interface RootRouteChildren {
185
  IndexRoute: typeof IndexRoute
186
+ AnalyticsRoute: typeof AnalyticsRoute
187
  BankRoute: typeof BankRoute
188
  GenerateRoute: typeof GenerateRoute
189
  JobsRoute: typeof JobsRoute
 
239
  preLoaderRoute: typeof BankRouteImport
240
  parentRoute: typeof rootRouteImport
241
  }
242
+ '/analytics': {
243
+ id: '/analytics'
244
+ path: '/analytics'
245
+ fullPath: '/analytics'
246
+ preLoaderRoute: typeof AnalyticsRouteImport
247
+ parentRoute: typeof rootRouteImport
248
+ }
249
  '/': {
250
  id: '/'
251
  path: '/'
 
274
  preLoaderRoute: typeof AttemptIdRouteImport
275
  parentRoute: typeof rootRouteImport
276
  }
277
+ '/package/$id/': {
278
+ id: '/package/$id/'
279
+ path: '/'
280
+ fullPath: '/package/$id/'
281
+ preLoaderRoute: typeof PackageIdIndexRouteImport
282
+ parentRoute: typeof PackageIdRoute
283
+ }
284
  '/package/$id/take': {
285
  id: '/package/$id/take'
286
  path: '/take'
 
293
 
294
  interface PackageIdRouteChildren {
295
  PackageIdTakeRoute: typeof PackageIdTakeRoute
296
+ PackageIdIndexRoute: typeof PackageIdIndexRoute
297
  }
298
 
299
  const PackageIdRouteChildren: PackageIdRouteChildren = {
300
  PackageIdTakeRoute: PackageIdTakeRoute,
301
+ PackageIdIndexRoute: PackageIdIndexRoute,
302
  }
303
 
304
  const PackageIdRouteWithChildren = PackageIdRoute._addFileChildren(
 
307
 
308
  const rootRouteChildren: RootRouteChildren = {
309
  IndexRoute: IndexRoute,
310
+ AnalyticsRoute: AnalyticsRoute,
311
  BankRoute: BankRoute,
312
  GenerateRoute: GenerateRoute,
313
  JobsRoute: JobsRoute,
apps/web/src/routes/__root.tsx CHANGED
@@ -1,7 +1,7 @@
1
  import { Toaster } from "@labas/ui/components/sonner";
2
  import type { QueryClient } from "@tanstack/react-query";
3
  import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
4
- import { HeadContent, Outlet, createRootRouteWithContext } from "@tanstack/react-router";
5
  import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
6
 
7
  import { Sidebar } from "@/components/sidebar";
@@ -32,6 +32,8 @@ export const Route = createRootRouteWithContext<RouterAppContext>()({
32
 
33
  function RootComponent() {
34
  const { collapsed } = useSidebar();
 
 
35
 
36
  return (
37
  <>
@@ -43,14 +45,20 @@ function RootComponent() {
43
  disableTransitionOnChange
44
  storageKey="labas-theme"
45
  >
46
- <div className="min-h-screen bg-background relative">
47
- <Sidebar />
48
- <main
49
- className={`min-h-screen transition-all duration-300 relative z-0 ${collapsed ? "md:ml-16" : "md:ml-64"}`}
50
- >
51
  <Outlet />
52
- </main>
53
- </div>
 
 
 
 
 
 
 
 
 
54
  <Toaster richColors />
55
  </ThemeProvider>
56
  <TanStackRouterDevtools position="bottom-left" />
 
1
  import { Toaster } from "@labas/ui/components/sonner";
2
  import type { QueryClient } from "@tanstack/react-query";
3
  import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
4
+ import { HeadContent, Outlet, createRootRouteWithContext, useRouterState } from "@tanstack/react-router";
5
  import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
6
 
7
  import { Sidebar } from "@/components/sidebar";
 
32
 
33
  function RootComponent() {
34
  const { collapsed } = useSidebar();
35
+ const matches = useRouterState({ select: (s) => s.matches });
36
+ const isFullScreen = matches.some((m) => m.routeId === "/package/$id/take");
37
 
38
  return (
39
  <>
 
45
  disableTransitionOnChange
46
  storageKey="labas-theme"
47
  >
48
+ {isFullScreen ? (
49
+ <div className="h-screen bg-background text-on-surface flex flex-col overflow-hidden relative">
 
 
 
50
  <Outlet />
51
+ </div>
52
+ ) : (
53
+ <div className="min-h-screen bg-background relative">
54
+ <Sidebar />
55
+ <main
56
+ className={`min-h-screen transition-all duration-300 relative z-0 ${collapsed ? "md:ml-16" : "md:ml-64"}`}
57
+ >
58
+ <Outlet />
59
+ </main>
60
+ </div>
61
+ )}
62
  <Toaster richColors />
63
  </ThemeProvider>
64
  <TanStackRouterDevtools position="bottom-left" />
apps/web/src/routes/analytics.tsx ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createFileRoute, redirect, Link } from "@tanstack/react-router";
2
+ import { authClient } from "@/lib/auth-client";
3
+ import { useAnalytics } from "@/hooks/use-analytics";
4
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
5
+ import { OverviewCards } from "@/components/analytics/OverviewCards";
6
+ import { ScoreTrendChart } from "@/components/analytics/ScoreTrendChart";
7
+ import { BreakdownCharts } from "@/components/analytics/BreakdownCharts";
8
+ import { WeaknessPanel } from "@/components/analytics/WeaknessPanel";
9
+ import { TimeAnalyticsPanel } from "@/components/analytics/TimeAnalyticsPanel";
10
+
11
+ export const Route = createFileRoute("/analytics")({
12
+ component: AnalyticsComponent,
13
+ beforeLoad: async () => {
14
+ const session = await authClient.getSession();
15
+ if (!session.data) {
16
+ redirect({ to: "/login", throw: true });
17
+ }
18
+ return { session };
19
+ },
20
+ });
21
+
22
+ function AnalyticsComponent() {
23
+ const {
24
+ overview,
25
+ byExamType,
26
+ bySectionType,
27
+ byFormat,
28
+ trend,
29
+ weaknesses,
30
+ timeAnalytics,
31
+ isLoading,
32
+ } = useAnalytics();
33
+
34
+ if (isLoading) {
35
+ return (
36
+ <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-7xl mx-auto bg-[var(--warm-cream)]">
37
+ <div className="h-8 w-64 bg-[var(--oat-light)] animate-pulse rounded mb-8" />
38
+ <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-8">
39
+ {Array.from({ length: 6 }).map((_, i) => (
40
+ <div key={i} className="h-28 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-xl)]" />
41
+ ))}
42
+ </div>
43
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
44
+ <div className="h-80 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-xl)]" />
45
+ <div className="h-80 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-xl)]" />
46
+ </div>
47
+ </div>
48
+ );
49
+ }
50
+
51
+ return (
52
+ <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-7xl mx-auto bg-[var(--warm-cream)]">
53
+ {/* Header */}
54
+ <div className="mb-8">
55
+ <div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-4">
56
+ <Link to="/" className="hover:text-[var(--clay-black)] transition-colors">
57
+ Beranda
58
+ </Link>
59
+ <MaterialIcon name="chevron_right" className="text-xs" />
60
+ <span className="text-[var(--clay-black)] font-medium">Analitik</span>
61
+ </div>
62
+
63
+ <div className="flex items-center justify-between">
64
+ <div>
65
+ <h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
66
+ Analitik
67
+ </h1>
68
+ <p className="text-lg text-[var(--warm-charcoal)] mt-2">
69
+ Pantau perkembangan dan identifikasi area untuk ditingkatkan.
70
+ </p>
71
+ </div>
72
+ </div>
73
+ </div>
74
+
75
+ {/* Overview */}
76
+ <div className="mb-8">
77
+ <OverviewCards data={overview.data} />
78
+ </div>
79
+
80
+ {/* Score Trend */}
81
+ <div className="mb-8">
82
+ <ScoreTrendChart data={trend.data} />
83
+ </div>
84
+
85
+ {/* Breakdown Charts */}
86
+ <div className="mb-8">
87
+ <BreakdownCharts
88
+ byExamType={byExamType.data}
89
+ bySectionType={bySectionType.data}
90
+ byFormat={byFormat.data}
91
+ />
92
+ </div>
93
+
94
+ {/* Weaknesses + Time Analytics */}
95
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
96
+ <WeaknessPanel
97
+ weaknesses={weaknesses.data?.weaknesses}
98
+ recommendations={weaknesses.data?.recommendations}
99
+ />
100
+ <TimeAnalyticsPanel
101
+ sectionTime={timeAnalytics.data?.sectionTime}
102
+ formatTimeData={timeAnalytics.data?.formatTime}
103
+ timeTrend={timeAnalytics.data?.timeTrend}
104
+ />
105
+ </div>
106
+ </div>
107
+ );
108
+ }
apps/web/src/routes/attempt.$id.tsx CHANGED
@@ -137,6 +137,12 @@ function AttemptResultComponent() {
137
  <p className="text-sm text-[var(--warm-charcoal)]">
138
  {sec.score ?? 0}/{sec.maxScore ?? 0} benar
139
  </p>
 
 
 
 
 
 
140
  </div>
141
  <div className="w-16 h-16 rounded-full border-2 border-[var(--matcha-400)] flex items-center justify-center bg-[var(--matcha-50)]">
142
  <span className="text-lg font-bold text-[var(--matcha-700)]">{secPct}%</span>
@@ -217,6 +223,12 @@ function AttemptResultComponent() {
217
  </span>
218
  </div>
219
  )}
 
 
 
 
 
 
220
  </div>
221
 
222
  {q.explanation && (
 
137
  <p className="text-sm text-[var(--warm-charcoal)]">
138
  {sec.score ?? 0}/{sec.maxScore ?? 0} benar
139
  </p>
140
+ {sec.timeSpentSec !== null && sec.timeSpentSec !== undefined && (
141
+ <p className="text-xs text-[var(--warm-silver)] mt-1 flex items-center gap-1">
142
+ <MaterialIcon name="timer" className="text-xs" />
143
+ {formatTime(sec.timeSpentSec)}
144
+ </p>
145
+ )}
146
  </div>
147
  <div className="w-16 h-16 rounded-full border-2 border-[var(--matcha-400)] flex items-center justify-center bg-[var(--matcha-50)]">
148
  <span className="text-lg font-bold text-[var(--matcha-700)]">{secPct}%</span>
 
223
  </span>
224
  </div>
225
  )}
226
+ {ans?.timeSpentSec !== null && ans?.timeSpentSec !== undefined && (
227
+ <div className="flex items-center gap-1 text-[var(--warm-silver)]">
228
+ <MaterialIcon name="timer" className="text-xs" />
229
+ <span>{formatTime(ans.timeSpentSec)}</span>
230
+ </div>
231
+ )}
232
  </div>
233
 
234
  {q.explanation && (
apps/web/src/routes/package.$id.index.tsx ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import { createFileRoute, redirect, Link } from "@tanstack/react-router";
3
+ import { authClient } from "@/lib/auth-client";
4
+ import { trpc } from "@/utils/trpc";
5
+ import { Button } from "@labas/ui/components/button";
6
+ import { Card, CardContent } from "@labas/ui/components/card";
7
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
8
+ import { formatLabel } from "@/lib/format";
9
+
10
+ export const Route = createFileRoute("/package/$id/")({
11
+ component: PackageDetailComponent,
12
+ beforeLoad: async () => {
13
+ const session = await authClient.getSession();
14
+ if (!session.data) {
15
+ redirect({ to: "/login", throw: true });
16
+ }
17
+ return { session };
18
+ },
19
+ });
20
+
21
+ function PackageDetailComponent() {
22
+ const { id } = Route.useParams();
23
+ const { data: session } = authClient.useSession();
24
+
25
+ const packageQuery = useQuery(trpc.package.getById.queryOptions({ id }));
26
+
27
+ const pkg = packageQuery.data;
28
+
29
+ if (packageQuery.isLoading) {
30
+ return (
31
+ <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-4xl mx-auto bg-[var(--warm-cream)]">
32
+ <div className="h-8 w-48 bg-[var(--oat-light)] animate-pulse rounded mb-4" />
33
+ <div className="h-64 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-xl)]" />
34
+ </div>
35
+ );
36
+ }
37
+
38
+ if (!pkg) {
39
+ return (
40
+ <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-4xl mx-auto bg-[var(--warm-cream)]">
41
+ <div className="text-center py-20">
42
+ <MaterialIcon name="error_outline" className="text-6xl text-[var(--warm-silver)] mx-auto mb-4" />
43
+ <p className="text-lg text-[var(--warm-charcoal)] font-semibold">Paket tidak ditemukan</p>
44
+ <Link to="/bank" className="text-[var(--matcha-600)] font-semibold mt-4 inline-block">
45
+ Kembali ke Bank Soal
46
+ </Link>
47
+ </div>
48
+ </div>
49
+ );
50
+ }
51
+
52
+ const isOwner = pkg.creatorUserId === session?.user.id;
53
+ const totalQuestions = pkg.sections.reduce((sum, sec) => sum + sec.questions.length, 0);
54
+
55
+ return (
56
+ <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-4xl mx-auto bg-[var(--warm-cream)]">
57
+ {/* Breadcrumb */}
58
+ <div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-6">
59
+ <Link to="/bank" className="hover:text-[var(--clay-black)] transition-colors">Bank Soal</Link>
60
+ <MaterialIcon name="chevron_right" className="text-xs" />
61
+ <span className="text-[var(--clay-black)] font-medium">Detail Paket</span>
62
+ </div>
63
+
64
+ {/* Header */}
65
+ <div className="mb-8">
66
+ <div className="flex flex-wrap items-start justify-between gap-4 mb-4">
67
+ <div>
68
+ <h1 className="text-3xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
69
+ {pkg.title}
70
+ </h1>
71
+ {pkg.description && (
72
+ <p className="text-[var(--warm-charcoal)] mt-2">{pkg.description}</p>
73
+ )}
74
+ </div>
75
+ <div className="flex gap-2">
76
+ <span className="px-3 py-1.5 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-sm font-semibold">
77
+ {pkg.examTypeName}
78
+ </span>
79
+ {pkg.isPublic ? (
80
+ <span className="px-3 py-1.5 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] text-sm font-semibold">
81
+ Publik
82
+ </span>
83
+ ) : (
84
+ <span className="px-3 py-1.5 rounded-full bg-[var(--oat-light)] text-[var(--warm-charcoal)] text-sm font-semibold">
85
+ Privat
86
+ </span>
87
+ )}
88
+ </div>
89
+ </div>
90
+
91
+ <div className="flex flex-wrap gap-4 text-sm text-[var(--warm-charcoal)]">
92
+ <span className="flex items-center gap-1">
93
+ <MaterialIcon name="person" className="text-sm" />
94
+ {pkg.creatorName ?? "Anonim"}
95
+ </span>
96
+ <span className="flex items-center gap-1">
97
+ <MaterialIcon name="quiz" className="text-sm" />
98
+ {totalQuestions} soal
99
+ </span>
100
+ <span className="flex items-center gap-1">
101
+ <MaterialIcon name="folder" className="text-sm" />
102
+ {pkg.totalSections} section
103
+ </span>
104
+ {pkg.estimatedDurationMin && (
105
+ <span className="flex items-center gap-1">
106
+ <MaterialIcon name="timer" className="text-sm" />
107
+ {pkg.estimatedDurationMin} menit
108
+ </span>
109
+ )}
110
+ <span className="flex items-center gap-1">
111
+ <MaterialIcon name="trending_up" className="text-sm" />
112
+ {pkg.usageCount}x digunakan
113
+ </span>
114
+ </div>
115
+ </div>
116
+
117
+ {/* Sections */}
118
+ <div className="space-y-6">
119
+ {pkg.sections.map((section) => (
120
+ <Card key={section.id} className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
121
+ <CardContent className="p-6">
122
+ <div className="flex items-center gap-3 mb-4 pb-4 border-b border-[var(--oat-border)]">
123
+ <MaterialIcon name="folder_open" className="text-[var(--matcha-600)]" />
124
+ <h2 className="font-headline text-lg font-bold text-[var(--clay-black)]">
125
+ {section.title}
126
+ </h2>
127
+ <span className="px-2 py-0.5 rounded-full bg-[var(--oat-light)] text-[var(--warm-charcoal)] text-xs font-semibold ml-auto">
128
+ {section.sectionTypeName}
129
+ </span>
130
+ </div>
131
+
132
+ {section.questions.length === 0 ? (
133
+ <p className="text-sm text-[var(--warm-silver)] text-center py-4">Belum ada soal di section ini</p>
134
+ ) : (
135
+ <div className="space-y-3">
136
+ {section.questions.map((q: any, idx: number) => (
137
+ <div
138
+ key={q.id}
139
+ className="block p-4 rounded-[var(--radius-lg)] bg-[var(--oat-light)] hover:bg-[var(--matcha-300)]/10 transition-colors"
140
+ >
141
+ <div className="flex items-start gap-3">
142
+ <span className="w-6 h-6 rounded-full bg-[var(--clay-black)] text-[var(--pure-white)] text-xs flex items-center justify-center font-bold shrink-0 mt-0.5">
143
+ {idx + 1}
144
+ </span>
145
+ <div className="flex-1 min-w-0">
146
+ <p className="text-sm font-medium text-[var(--clay-black)] line-clamp-2">
147
+ {q.questionText}
148
+ </p>
149
+ <div className="flex gap-2 mt-2">
150
+ <span className="px-2 py-0.5 rounded bg-[var(--pure-white)] text-[var(--warm-charcoal)] text-xs">
151
+ {formatLabel(q.format)}
152
+ </span>
153
+ <span className="px-2 py-0.5 rounded bg-[var(--pure-white)] text-[var(--warm-charcoal)] text-xs">
154
+ Lv.{q.difficulty}
155
+ </span>
156
+ </div>
157
+ </div>
158
+ </div>
159
+ </div>
160
+ ))}
161
+ </div>
162
+ )}
163
+ </CardContent>
164
+ </Card>
165
+ ))}
166
+ </div>
167
+
168
+ {/* Actions */}
169
+ <div className="flex gap-3 mt-8">
170
+ <Link to="/package/$id/take" params={{ id }}>
171
+ <Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)]">
172
+ <MaterialIcon name="play_arrow" />
173
+ <span className="ml-2">Mulai Latihan</span>
174
+ </Button>
175
+ </Link>
176
+ {isOwner && (
177
+ <Button
178
+ variant="outline"
179
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
180
+ >
181
+ <MaterialIcon name="edit" />
182
+ <span className="ml-2">Edit Paket</span>
183
+ </Button>
184
+ )}
185
+ </div>
186
+ </div>
187
+ );
188
+ }
apps/web/src/routes/package.$id.take.tsx CHANGED
@@ -35,10 +35,15 @@ function TakeTestComponent() {
35
  isStarted,
36
  isFinished,
37
  submittingQId,
 
38
  startPending,
 
39
  handleStart,
40
  handleAnswerChange,
41
  handleFinish,
 
 
 
42
  } = useTestSession(packageId);
43
 
44
  if (packageQuery.isLoading) {
@@ -112,6 +117,12 @@ function TakeTestComponent() {
112
  </CardContent>
113
  </Card>
114
 
 
 
 
 
 
 
115
  <Button
116
  onClick={handleStart}
117
  disabled={startPending}
@@ -125,6 +136,17 @@ function TakeTestComponent() {
125
  );
126
  }
127
 
 
 
 
 
 
 
 
 
 
 
 
128
  const currentSection = pkg.sections[currentSectionIdx];
129
  if (!currentSection) {
130
  return (
@@ -142,7 +164,7 @@ function TakeTestComponent() {
142
 
143
  return (
144
  <AttemptTestView
145
- attemptId={attemptId!}
146
  pkg={pkg}
147
  currentSectionIdx={currentSectionIdx}
148
  setCurrentSectionIdx={setCurrentSectionIdx}
@@ -152,8 +174,12 @@ function TakeTestComponent() {
152
  answeredCount={answeredCount}
153
  totalQuestions={totalQuestions}
154
  onFinish={handleFinish}
 
155
  isFinished={isFinished}
156
  submittingQId={submittingQId}
 
 
 
157
  />
158
  );
159
  }
 
35
  isStarted,
36
  isFinished,
37
  submittingQId,
38
+ markedQuestions,
39
  startPending,
40
+ startError,
41
  handleStart,
42
  handleAnswerChange,
43
  handleFinish,
44
+ handleAbandon,
45
+ toggleMarkQuestion,
46
+ startQuestionTimer,
47
  } = useTestSession(packageId);
48
 
49
  if (packageQuery.isLoading) {
 
117
  </CardContent>
118
  </Card>
119
 
120
+ {startError && (
121
+ <div className="mb-4 p-4 rounded-[var(--radius-md)] bg-[var(--pomegranate-400)]/10 border-2 border-[var(--pomegranate-400)]/30 text-sm text-[var(--pomegranate-600)]">
122
+ {startError}
123
+ </div>
124
+ )}
125
+
126
  <Button
127
  onClick={handleStart}
128
  disabled={startPending}
 
136
  );
137
  }
138
 
139
+ if (!attemptId) {
140
+ return (
141
+ <div className="min-h-screen flex items-center justify-center bg-[var(--warm-cream)]">
142
+ <div className="text-center">
143
+ <div className="h-8 w-8 border-2 border-[var(--matcha-600)] border-t-transparent rounded-full animate-spin mx-auto mb-4" />
144
+ <p className="text-[var(--warm-charcoal)]">Menyiapkan latihan...</p>
145
+ </div>
146
+ </div>
147
+ );
148
+ }
149
+
150
  const currentSection = pkg.sections[currentSectionIdx];
151
  if (!currentSection) {
152
  return (
 
164
 
165
  return (
166
  <AttemptTestView
167
+ attemptId={attemptId}
168
  pkg={pkg}
169
  currentSectionIdx={currentSectionIdx}
170
  setCurrentSectionIdx={setCurrentSectionIdx}
 
174
  answeredCount={answeredCount}
175
  totalQuestions={totalQuestions}
176
  onFinish={handleFinish}
177
+ onAbandon={handleAbandon}
178
  isFinished={isFinished}
179
  submittingQId={submittingQId}
180
+ markedQuestions={markedQuestions}
181
+ toggleMarkQuestion={toggleMarkQuestion}
182
+ startQuestionTimer={startQuestionTimer}
183
  />
184
  );
185
  }
apps/web/src/routes/package.$id.tsx CHANGED
@@ -1,188 +1,5 @@
1
- import { useQuery } from "@tanstack/react-query";
2
- import { createFileRoute, redirect, Link } from "@tanstack/react-router";
3
- import { authClient } from "@/lib/auth-client";
4
- import { trpc } from "@/utils/trpc";
5
- import { Button } from "@labas/ui/components/button";
6
- import { Card, CardContent } from "@labas/ui/components/card";
7
- import { MaterialIcon } from "@/components/ui/MaterialIcon";
8
- import { formatLabel } from "@/lib/format";
9
 
10
  export const Route = createFileRoute("/package/$id")({
11
- component: PackageDetailComponent,
12
- beforeLoad: async () => {
13
- const session = await authClient.getSession();
14
- if (!session.data) {
15
- redirect({ to: "/login", throw: true });
16
- }
17
- return { session };
18
- },
19
  });
20
-
21
- function PackageDetailComponent() {
22
- const { id } = Route.useParams();
23
- const { data: session } = authClient.useSession();
24
-
25
- const packageQuery = useQuery(trpc.package.getById.queryOptions({ id }));
26
-
27
- const pkg = packageQuery.data;
28
-
29
- if (packageQuery.isLoading) {
30
- return (
31
- <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-4xl mx-auto bg-[var(--warm-cream)]">
32
- <div className="h-8 w-48 bg-[var(--oat-light)] animate-pulse rounded mb-4" />
33
- <div className="h-64 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-xl)]" />
34
- </div>
35
- );
36
- }
37
-
38
- if (!pkg) {
39
- return (
40
- <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-4xl mx-auto bg-[var(--warm-cream)]">
41
- <div className="text-center py-20">
42
- <MaterialIcon name="error_outline" className="text-6xl text-[var(--warm-silver)] mx-auto mb-4" />
43
- <p className="text-lg text-[var(--warm-charcoal)] font-semibold">Paket tidak ditemukan</p>
44
- <Link to="/bank" className="text-[var(--matcha-600)] font-semibold mt-4 inline-block">
45
- Kembali ke Bank Soal
46
- </Link>
47
- </div>
48
- </div>
49
- );
50
- }
51
-
52
- const isOwner = pkg.creatorUserId === session?.user.id;
53
- const totalQuestions = pkg.sections.reduce((sum, sec) => sum + sec.questions.length, 0);
54
-
55
- return (
56
- <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-4xl mx-auto bg-[var(--warm-cream)]">
57
- {/* Breadcrumb */}
58
- <div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-6">
59
- <Link to="/bank" className="hover:text-[var(--clay-black)] transition-colors">Bank Soal</Link>
60
- <MaterialIcon name="chevron_right" className="text-xs" />
61
- <span className="text-[var(--clay-black)] font-medium">Detail Paket</span>
62
- </div>
63
-
64
- {/* Header */}
65
- <div className="mb-8">
66
- <div className="flex flex-wrap items-start justify-between gap-4 mb-4">
67
- <div>
68
- <h1 className="text-3xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
69
- {pkg.title}
70
- </h1>
71
- {pkg.description && (
72
- <p className="text-[var(--warm-charcoal)] mt-2">{pkg.description}</p>
73
- )}
74
- </div>
75
- <div className="flex gap-2">
76
- <span className="px-3 py-1.5 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-sm font-semibold">
77
- {pkg.examTypeName}
78
- </span>
79
- {pkg.isPublic ? (
80
- <span className="px-3 py-1.5 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] text-sm font-semibold">
81
- Publik
82
- </span>
83
- ) : (
84
- <span className="px-3 py-1.5 rounded-full bg-[var(--oat-light)] text-[var(--warm-charcoal)] text-sm font-semibold">
85
- Privat
86
- </span>
87
- )}
88
- </div>
89
- </div>
90
-
91
- <div className="flex flex-wrap gap-4 text-sm text-[var(--warm-charcoal)]">
92
- <span className="flex items-center gap-1">
93
- <MaterialIcon name="person" className="text-sm" />
94
- {pkg.creatorName ?? "Anonim"}
95
- </span>
96
- <span className="flex items-center gap-1">
97
- <MaterialIcon name="quiz" className="text-sm" />
98
- {totalQuestions} soal
99
- </span>
100
- <span className="flex items-center gap-1">
101
- <MaterialIcon name="folder" className="text-sm" />
102
- {pkg.totalSections} section
103
- </span>
104
- {pkg.estimatedDurationMin && (
105
- <span className="flex items-center gap-1">
106
- <MaterialIcon name="timer" className="text-sm" />
107
- {pkg.estimatedDurationMin} menit
108
- </span>
109
- )}
110
- <span className="flex items-center gap-1">
111
- <MaterialIcon name="trending_up" className="text-sm" />
112
- {pkg.usageCount}x digunakan
113
- </span>
114
- </div>
115
- </div>
116
-
117
- {/* Sections */}
118
- <div className="space-y-6">
119
- {pkg.sections.map((section) => (
120
- <Card key={section.id} className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
121
- <CardContent className="p-6">
122
- <div className="flex items-center gap-3 mb-4 pb-4 border-b border-[var(--oat-border)]">
123
- <MaterialIcon name="folder_open" className="text-[var(--matcha-600)]" />
124
- <h2 className="font-headline text-lg font-bold text-[var(--clay-black)]">
125
- {section.title}
126
- </h2>
127
- <span className="px-2 py-0.5 rounded-full bg-[var(--oat-light)] text-[var(--warm-charcoal)] text-xs font-semibold ml-auto">
128
- {section.sectionTypeName}
129
- </span>
130
- </div>
131
-
132
- {section.questions.length === 0 ? (
133
- <p className="text-sm text-[var(--warm-silver)] text-center py-4">Belum ada soal di section ini</p>
134
- ) : (
135
- <div className="space-y-3">
136
- {section.questions.map((q: any, idx: number) => (
137
- <div
138
- key={q.id}
139
- className="block p-4 rounded-[var(--radius-lg)] bg-[var(--oat-light)] hover:bg-[var(--matcha-300)]/10 transition-colors"
140
- >
141
- <div className="flex items-start gap-3">
142
- <span className="w-6 h-6 rounded-full bg-[var(--clay-black)] text-[var(--pure-white)] text-xs flex items-center justify-center font-bold shrink-0 mt-0.5">
143
- {idx + 1}
144
- </span>
145
- <div className="flex-1 min-w-0">
146
- <p className="text-sm font-medium text-[var(--clay-black)] line-clamp-2">
147
- {q.questionText}
148
- </p>
149
- <div className="flex gap-2 mt-2">
150
- <span className="px-2 py-0.5 rounded bg-[var(--pure-white)] text-[var(--warm-charcoal)] text-xs">
151
- {formatLabel(q.format)}
152
- </span>
153
- <span className="px-2 py-0.5 rounded bg-[var(--pure-white)] text-[var(--warm-charcoal)] text-xs">
154
- Lv.{q.difficulty}
155
- </span>
156
- </div>
157
- </div>
158
- </div>
159
- </div>
160
- ))}
161
- </div>
162
- )}
163
- </CardContent>
164
- </Card>
165
- ))}
166
- </div>
167
-
168
- {/* Actions */}
169
- <div className="flex gap-3 mt-8">
170
- <Link to="/package/$id/take" params={{ id }}>
171
- <Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)]">
172
- <MaterialIcon name="play_arrow" />
173
- <span className="ml-2">Mulai Latihan</span>
174
- </Button>
175
- </Link>
176
- {isOwner && (
177
- <Button
178
- variant="outline"
179
- className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
180
- >
181
- <MaterialIcon name="edit" />
182
- <span className="ml-2">Edit Paket</span>
183
- </Button>
184
- )}
185
- </div>
186
- </div>
187
- );
188
- }
 
1
+ import { createFileRoute, Outlet } from "@tanstack/react-router";
 
 
 
 
 
 
 
2
 
3
  export const Route = createFileRoute("/package/$id")({
4
+ component: () => <Outlet />,
 
 
 
 
 
 
 
5
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/web/src/routes/packages.tsx CHANGED
@@ -1,6 +1,6 @@
1
  import { useState } from "react";
2
  import { useQuery } from "@tanstack/react-query";
3
- import { createFileRoute, redirect, Link } from "@tanstack/react-router";
4
  import { authClient } from "@/lib/auth-client";
5
  import { trpc } from "@/utils/trpc";
6
  import { Input } from "@labas/ui/components/input";
@@ -36,6 +36,7 @@ const EXAM_TYPES = [
36
  ];
37
 
38
  function PackagesComponent() {
 
39
  const [search, setSearch] = useState("");
40
  const [examType, setExamType] = useState<string>("");
41
  const [page, setPage] = useState(0);
@@ -126,9 +127,9 @@ function PackagesComponent() {
126
  <>
127
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
128
  {packages.map((pkg) => (
129
- <Link key={pkg.id} to="/package/$id" params={{ id: pkg.id }} className="block">
130
- <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full flex flex-col">
131
- <CardContent className="p-5 flex flex-col h-full">
132
  <div className="flex items-start justify-between mb-3">
133
  <span className="px-2.5 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold">
134
  {pkg.examTypeName}
@@ -146,7 +147,7 @@ function PackagesComponent() {
146
  </h3>
147
 
148
  {pkg.description && (
149
- <p className="text-sm text-[var(--warm-charcoal)] line-clamp-2 mb-4 flex-1">
150
  {pkg.description}
151
  </p>
152
  )}
@@ -172,9 +173,19 @@ function PackagesComponent() {
172
  {pkg.usageCount}x digunakan
173
  </span>
174
  </div>
175
- </CardContent>
176
- </Card>
177
- </Link>
 
 
 
 
 
 
 
 
 
 
178
  ))}
179
  </div>
180
 
 
1
  import { useState } from "react";
2
  import { useQuery } from "@tanstack/react-query";
3
+ import { createFileRoute, redirect, Link, useNavigate } from "@tanstack/react-router";
4
  import { authClient } from "@/lib/auth-client";
5
  import { trpc } from "@/utils/trpc";
6
  import { Input } from "@labas/ui/components/input";
 
36
  ];
37
 
38
  function PackagesComponent() {
39
+ const navigate = useNavigate();
40
  const [search, setSearch] = useState("");
41
  const [examType, setExamType] = useState<string>("");
42
  const [page, setPage] = useState(0);
 
127
  <>
128
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
129
  {packages.map((pkg) => (
130
+ <Card key={pkg.id} className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full flex flex-col">
131
+ <CardContent className="p-5 flex flex-col h-full">
132
+ <Link to="/package/$id" params={{ id: pkg.id }} className="block flex-1">
133
  <div className="flex items-start justify-between mb-3">
134
  <span className="px-2.5 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold">
135
  {pkg.examTypeName}
 
147
  </h3>
148
 
149
  {pkg.description && (
150
+ <p className="text-sm text-[var(--warm-charcoal)] line-clamp-2 mb-4">
151
  {pkg.description}
152
  </p>
153
  )}
 
173
  {pkg.usageCount}x digunakan
174
  </span>
175
  </div>
176
+ </Link>
177
+
178
+ <div className="mt-4 pt-3 border-t border-[var(--oat-border)]">
179
+ <Button
180
+ className="w-full bg-[var(--matcha-600)] text-[var(--pure-white)] hover:bg-[var(--matcha-800)] clay-hover rounded-[var(--radius-lg)]"
181
+ onClick={() => navigate({ to: '/package/$id/take', params: { id: pkg.id } })}
182
+ >
183
+ <MaterialIcon name="play_arrow" className="mr-2" />
184
+ Mulai Latihan
185
+ </Button>
186
+ </div>
187
+ </CardContent>
188
+ </Card>
189
  ))}
190
  </div>
191
 
bun.lock CHANGED
@@ -61,6 +61,7 @@
61
  "next-themes": "catalog:",
62
  "react": "^19.2.5",
63
  "react-dom": "^19.2.5",
 
64
  "sonner": "^2.0.7",
65
  "vite-plugin-pwa": "^1.2.0",
66
  "zod": "catalog:",
@@ -621,6 +622,8 @@
621
 
622
  "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="],
623
 
 
 
624
  "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.17", "", { "os": "android", "cpu": "arm64" }, "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ=="],
625
 
626
  "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw=="],
@@ -769,6 +772,24 @@
769
 
770
  "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
771
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
772
  "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
773
 
774
  "@types/jsesc": ["@types/jsesc@2.5.1", "", {}, "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw=="],
@@ -791,6 +812,8 @@
791
 
792
  "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
793
 
 
 
794
  "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="],
795
 
796
  "@types/webidl-conversions": ["@types/webidl-conversions@7.0.3", "", {}, "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="],
@@ -953,6 +976,28 @@
953
 
954
  "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
955
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
956
  "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
957
 
958
  "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
@@ -963,6 +1008,8 @@
963
 
964
  "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
965
 
 
 
966
  "decode-bmp": ["decode-bmp@0.2.1", "", { "dependencies": { "@canvas/image-data": "^1.0.0", "to-data-view": "^1.1.0" } }, "sha512-NiOaGe+GN0KJqi2STf24hfMkFitDUaIoUU3eKvP/wAbLe8o6FuW5n/x7MHPR0HKvBokp6MQY/j7w8lewEeVCIA=="],
967
 
968
  "decode-ico": ["decode-ico@0.4.1", "", { "dependencies": { "@canvas/image-data": "^1.0.0", "decode-bmp": "^0.2.0", "to-data-view": "^1.1.0" } }, "sha512-69NZfbKIzux1vBOd31al3XnMnH+2mqDhEgLdpygErm4d60N+UwA5Sq5WFjmEDQzumgB9fElojGwWG0vybVfFmA=="],
@@ -1035,6 +1082,8 @@
1035
 
1036
  "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="],
1037
 
 
 
1038
  "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
1039
 
1040
  "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
@@ -1049,6 +1098,8 @@
1049
 
1050
  "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
1051
 
 
 
1052
  "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
1053
 
1054
  "eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="],
@@ -1185,6 +1236,8 @@
1185
 
1186
  "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
1187
 
 
 
1188
  "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
1189
 
1190
  "import-without-cache": ["import-without-cache@0.3.3", "", {}, "sha512-bDxwDdF04gm550DfZHgffvlX+9kUlcz32UD0AeBTmVPFiWkrexF2XVmiuFFbDhiFuP8fQkrkvI2KdSNPYWAXkQ=="],
@@ -1193,6 +1246,8 @@
1193
 
1194
  "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
1195
 
 
 
1196
  "ioredis": ["ioredis@5.10.1", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA=="],
1197
 
1198
  "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
@@ -1553,16 +1608,26 @@
1553
 
1554
  "react-hook-form": ["react-hook-form@7.73.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-VAfVYOPcx3piiEVQy95vyFmBwbVUsP/AUIN+mpFG8h11yshDd444nn0VyfaGWSRnhOLVgiDu7HIuBtAIzxn9dA=="],
1555
 
 
 
 
 
1556
  "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
1557
 
1558
  "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
1559
 
1560
  "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
1561
 
 
 
1562
  "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
1563
 
1564
  "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
1565
 
 
 
 
 
1566
  "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
1567
 
1568
  "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="],
@@ -1833,6 +1898,8 @@
1833
 
1834
  "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
1835
 
 
 
1836
  "vite": ["vite@8.0.10", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.17", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw=="],
1837
 
1838
  "vite-plugin-pwa": ["vite-plugin-pwa@1.2.0", "", { "dependencies": { "debug": "^4.3.6", "pretty-bytes": "^6.1.1", "tinyglobby": "^0.2.10", "workbox-build": "^7.4.0", "workbox-window": "^7.4.0" }, "peerDependencies": { "@vite-pwa/assets-generator": "^1.0.0", "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@vite-pwa/assets-generator"] }, "sha512-a2xld+SJshT9Lgcv8Ji4+srFJL4k/1bVbd1x06JIkvecpQkwkvCncD1+gSzcdm3s+owWLpMJerG3aN5jupJEVw=="],
@@ -1947,6 +2014,8 @@
1947
 
1948
  "@noble/curves/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
1949
 
 
 
1950
  "@rollup/plugin-node-resolve/@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="],
1951
 
1952
  "@rollup/plugin-replace/magic-string": ["magic-string@0.25.9", "", { "dependencies": { "sourcemap-codec": "^1.4.8" } }, "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ=="],
 
61
  "next-themes": "catalog:",
62
  "react": "^19.2.5",
63
  "react-dom": "^19.2.5",
64
+ "recharts": "^3.8.1",
65
  "sonner": "^2.0.7",
66
  "vite-plugin-pwa": "^1.2.0",
67
  "zod": "catalog:",
 
622
 
623
  "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="],
624
 
625
+ "@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="],
626
+
627
  "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.17", "", { "os": "android", "cpu": "arm64" }, "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ=="],
628
 
629
  "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw=="],
 
772
 
773
  "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
774
 
775
+ "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
776
+
777
+ "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
778
+
779
+ "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
780
+
781
+ "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
782
+
783
+ "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
784
+
785
+ "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
786
+
787
+ "@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="],
788
+
789
+ "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
790
+
791
+ "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
792
+
793
  "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
794
 
795
  "@types/jsesc": ["@types/jsesc@2.5.1", "", {}, "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw=="],
 
812
 
813
  "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
814
 
815
+ "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
816
+
817
  "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="],
818
 
819
  "@types/webidl-conversions": ["@types/webidl-conversions@7.0.3", "", {}, "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="],
 
976
 
977
  "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
978
 
979
+ "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
980
+
981
+ "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
982
+
983
+ "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
984
+
985
+ "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
986
+
987
+ "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
988
+
989
+ "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
990
+
991
+ "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
992
+
993
+ "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
994
+
995
+ "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
996
+
997
+ "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
998
+
999
+ "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
1000
+
1001
  "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
1002
 
1003
  "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
 
1008
 
1009
  "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
1010
 
1011
+ "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
1012
+
1013
  "decode-bmp": ["decode-bmp@0.2.1", "", { "dependencies": { "@canvas/image-data": "^1.0.0", "to-data-view": "^1.1.0" } }, "sha512-NiOaGe+GN0KJqi2STf24hfMkFitDUaIoUU3eKvP/wAbLe8o6FuW5n/x7MHPR0HKvBokp6MQY/j7w8lewEeVCIA=="],
1014
 
1015
  "decode-ico": ["decode-ico@0.4.1", "", { "dependencies": { "@canvas/image-data": "^1.0.0", "decode-bmp": "^0.2.0", "to-data-view": "^1.1.0" } }, "sha512-69NZfbKIzux1vBOd31al3XnMnH+2mqDhEgLdpygErm4d60N+UwA5Sq5WFjmEDQzumgB9fElojGwWG0vybVfFmA=="],
 
1082
 
1083
  "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="],
1084
 
1085
+ "es-toolkit": ["es-toolkit@1.46.0", "", {}, "sha512-IToJ6ct9OLl5zz6WsC/1vZEwfSZ7Myil+ygl5Tf30Xjn9AEkzNB4kqp2G7VUJKF1DtTx/ra5M5KLlXvzOg51BA=="],
1086
+
1087
  "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
1088
 
1089
  "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
 
1098
 
1099
  "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
1100
 
1101
+ "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
1102
+
1103
  "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
1104
 
1105
  "eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="],
 
1236
 
1237
  "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
1238
 
1239
+ "immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
1240
+
1241
  "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
1242
 
1243
  "import-without-cache": ["import-without-cache@0.3.3", "", {}, "sha512-bDxwDdF04gm550DfZHgffvlX+9kUlcz32UD0AeBTmVPFiWkrexF2XVmiuFFbDhiFuP8fQkrkvI2KdSNPYWAXkQ=="],
 
1246
 
1247
  "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
1248
 
1249
+ "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
1250
+
1251
  "ioredis": ["ioredis@5.10.1", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA=="],
1252
 
1253
  "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
 
1608
 
1609
  "react-hook-form": ["react-hook-form@7.73.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-VAfVYOPcx3piiEVQy95vyFmBwbVUsP/AUIN+mpFG8h11yshDd444nn0VyfaGWSRnhOLVgiDu7HIuBtAIzxn9dA=="],
1610
 
1611
+ "react-is": ["react-is@19.2.5", "", {}, "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ=="],
1612
+
1613
+ "react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
1614
+
1615
  "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
1616
 
1617
  "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
1618
 
1619
  "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
1620
 
1621
+ "recharts": ["recharts@3.8.1", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "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" } }, "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg=="],
1622
+
1623
  "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
1624
 
1625
  "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
1626
 
1627
+ "redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
1628
+
1629
+ "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
1630
+
1631
  "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
1632
 
1633
  "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="],
 
1898
 
1899
  "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
1900
 
1901
+ "victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
1902
+
1903
  "vite": ["vite@8.0.10", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.17", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw=="],
1904
 
1905
  "vite-plugin-pwa": ["vite-plugin-pwa@1.2.0", "", { "dependencies": { "debug": "^4.3.6", "pretty-bytes": "^6.1.1", "tinyglobby": "^0.2.10", "workbox-build": "^7.4.0", "workbox-window": "^7.4.0" }, "peerDependencies": { "@vite-pwa/assets-generator": "^1.0.0", "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@vite-pwa/assets-generator"] }, "sha512-a2xld+SJshT9Lgcv8Ji4+srFJL4k/1bVbd1x06JIkvecpQkwkvCncD1+gSzcdm3s+owWLpMJerG3aN5jupJEVw=="],
 
2014
 
2015
  "@noble/curves/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
2016
 
2017
+ "@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
2018
+
2019
  "@rollup/plugin-node-resolve/@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="],
2020
 
2021
  "@rollup/plugin-replace/magic-string": ["magic-string@0.25.9", "", { "dependencies": { "sourcemap-codec": "^1.4.8" } }, "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ=="],
packages/api/src/queue.ts CHANGED
@@ -6,7 +6,7 @@ import { db } from "@labas/db";
6
  import { generationJob, question, testPackage, packageSection, sectionQuestion } from "@labas/db";
7
  import { and, eq, notInArray } from "drizzle-orm";
8
 
9
- const redisConnection = new IORedis(env.REDIS_URL, { maxRetriesPerRequest: null });
10
 
11
  /** Thrown when the job was cancelled (DB status or cooperative poll). */
12
  export class GenerationJobCancelledError extends Error {
@@ -93,7 +93,7 @@ export async function cancelGenerationJob(
93
  }
94
 
95
  export const generationQueue = new Queue("generation", {
96
- connection: redisConnection,
97
  });
98
 
99
  export const generationWorker = new Worker(
@@ -338,7 +338,7 @@ export const generationWorker = new Worker(
338
  }
339
  },
340
  {
341
- connection: redisConnection,
342
  concurrency: 2,
343
  },
344
  );
 
6
  import { generationJob, question, testPackage, packageSection, sectionQuestion } from "@labas/db";
7
  import { and, eq, notInArray } from "drizzle-orm";
8
 
9
+ const connectionOptions = { maxRetriesPerRequest: null };
10
 
11
  /** Thrown when the job was cancelled (DB status or cooperative poll). */
12
  export class GenerationJobCancelledError extends Error {
 
93
  }
94
 
95
  export const generationQueue = new Queue("generation", {
96
+ connection: new IORedis(env.REDIS_URL, connectionOptions),
97
  });
98
 
99
  export const generationWorker = new Worker(
 
338
  }
339
  },
340
  {
341
+ connection: new IORedis(env.REDIS_URL, connectionOptions),
342
  concurrency: 2,
343
  },
344
  );
packages/api/src/routers/attempt.ts CHANGED
@@ -331,12 +331,14 @@ export const attemptRouter = router({
331
 
332
  const sectionScore = secAnswers.filter((a) => a.isCorrect).length;
333
  const sectionMax = questionCounts.get(pkgSec.id) ?? secAnswers.length;
 
334
 
335
  await db
336
  .update(sectionResult)
337
  .set({
338
  score: sectionScore,
339
  maxScore: sectionMax,
 
340
  })
341
  .where(eq(sectionResult.id, secResult.id));
342
 
@@ -366,6 +368,54 @@ export const attemptRouter = router({
366
  };
367
  }),
368
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
369
  myAttempts: protectedProcedure
370
  .input(
371
  z
 
331
 
332
  const sectionScore = secAnswers.filter((a) => a.isCorrect).length;
333
  const sectionMax = questionCounts.get(pkgSec.id) ?? secAnswers.length;
334
+ const sectionTimeSpent = secAnswers.reduce((sum, a) => sum + (a.timeSpentSec ?? 0), 0);
335
 
336
  await db
337
  .update(sectionResult)
338
  .set({
339
  score: sectionScore,
340
  maxScore: sectionMax,
341
+ timeSpentSec: sectionTimeSpent,
342
  })
343
  .where(eq(sectionResult.id, secResult.id));
344
 
 
368
  };
369
  }),
370
 
371
+ getActiveAttempt: protectedProcedure
372
+ .input(z.object({ packageId: z.string().uuid() }))
373
+ .query(async ({ ctx, input }) => {
374
+ const userId = ctx.session.user.id;
375
+
376
+ const [attempt] = await db
377
+ .select({
378
+ id: testAttempt.id,
379
+ status: testAttempt.status,
380
+ startedAt: testAttempt.startedAt,
381
+ })
382
+ .from(testAttempt)
383
+ .where(
384
+ and(
385
+ eq(testAttempt.userId, userId),
386
+ eq(testAttempt.packageId, input.packageId),
387
+ eq(testAttempt.status, "in_progress"),
388
+ ),
389
+ )
390
+ .orderBy(desc(testAttempt.createdAt))
391
+ .limit(1);
392
+
393
+ return attempt ?? null;
394
+ }),
395
+
396
+ abandon: protectedProcedure
397
+ .input(z.object({ attemptId: z.string().uuid() }))
398
+ .mutation(async ({ ctx, input }) => {
399
+ const userId = ctx.session.user.id;
400
+
401
+ const [attempt] = await db
402
+ .select()
403
+ .from(testAttempt)
404
+ .where(eq(testAttempt.id, input.attemptId))
405
+ .limit(1);
406
+
407
+ if (!attempt) throw new Error("Attempt not found");
408
+ if (attempt.userId !== userId) throw new Error("Not authorized");
409
+ if (attempt.status !== "in_progress") throw new Error("Attempt not in progress");
410
+
411
+ await db
412
+ .update(testAttempt)
413
+ .set({ status: "abandoned" })
414
+ .where(eq(testAttempt.id, input.attemptId));
415
+
416
+ return { success: true };
417
+ }),
418
+
419
  myAttempts: protectedProcedure
420
  .input(
421
  z
packages/api/src/routers/index.ts CHANGED
@@ -6,6 +6,7 @@ import { questionRouter } from "./question";
6
  import { packageRouter } from "./package";
7
  import { ratingRouter } from "./rating";
8
  import { settingsRouter } from "./settings";
 
9
 
10
  export const appRouter = router({
11
  healthCheck: publicProcedure.query(() => {
@@ -24,6 +25,7 @@ export const appRouter = router({
24
  package: packageRouter,
25
  rating: ratingRouter,
26
  settings: settingsRouter,
 
27
  });
28
 
29
  export type AppRouter = typeof appRouter;
 
6
  import { packageRouter } from "./package";
7
  import { ratingRouter } from "./rating";
8
  import { settingsRouter } from "./settings";
9
+ import { statsRouter } from "./stats";
10
 
11
  export const appRouter = router({
12
  healthCheck: publicProcedure.query(() => {
 
25
  package: packageRouter,
26
  rating: ratingRouter,
27
  settings: settingsRouter,
28
+ stats: statsRouter,
29
  });
30
 
31
  export type AppRouter = typeof appRouter;
packages/api/src/routers/package.ts CHANGED
@@ -206,7 +206,7 @@ export const packageRouter = router({
206
  const sectionsWithQuestions = sections.map((section) => ({
207
  ...section,
208
  questions: sectionQuestions
209
- .filter((sq) => sq.sectionId === section.id)
210
  .sort((a, b) => a.orderIndex - b.orderIndex)
211
  .map((sq) => sq.question),
212
  }));
 
206
  const sectionsWithQuestions = sections.map((section) => ({
207
  ...section,
208
  questions: sectionQuestions
209
+ .filter((sq) => sq.sectionId === section.id && sq.question != null)
210
  .sort((a, b) => a.orderIndex - b.orderIndex)
211
  .map((sq) => sq.question),
212
  }));
packages/api/src/routers/stats.ts ADDED
@@ -0,0 +1,455 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { z } from "zod";
2
+ import { eq, and, desc, sql, gte } from "drizzle-orm";
3
+ import { router, protectedProcedure } from "../index";
4
+ import { db } from "@labas/db";
5
+ import {
6
+ testAttempt,
7
+ sectionResult,
8
+ answer,
9
+ testPackage,
10
+ question,
11
+ examType,
12
+ sectionType,
13
+ } from "@labas/db";
14
+
15
+ function clamp(n: number, min: number, max: number) {
16
+ return Math.max(min, Math.min(max, n));
17
+ }
18
+
19
+ export const statsRouter = router({
20
+ overview: protectedProcedure.query(async ({ ctx }) => {
21
+ const userId = ctx.session.user.id;
22
+
23
+ const [attemptAgg] = await db
24
+ .select({
25
+ totalAttempts: sql<number>`count(*)`,
26
+ completedAttempts: sql<number>`sum(case when ${testAttempt.status} = 'completed' then 1 else 0 end)`,
27
+ abandonedAttempts: sql<number>`sum(case when ${testAttempt.status} = 'abandoned' then 1 else 0 end)`,
28
+ avgScorePct:
29
+ sql<number>`round(avg(case when ${testAttempt.status} = 'completed' and ${testAttempt.maxScore} > 0 then (${testAttempt.totalScore}::float / ${testAttempt.maxScore}) * 100 end)::numeric, 1)`,
30
+ })
31
+ .from(testAttempt)
32
+ .where(eq(testAttempt.userId, userId));
33
+
34
+ const [timeAgg] = await db
35
+ .select({
36
+ totalTimeSpentSec: sql<number>`coalesce(sum(${sectionResult.timeSpentSec}), 0)`,
37
+ })
38
+ .from(sectionResult)
39
+ .innerJoin(testAttempt, eq(sectionResult.attemptId, testAttempt.id))
40
+ .where(eq(testAttempt.userId, userId));
41
+
42
+ const [answerAgg] = await db
43
+ .select({
44
+ totalQuestionsAnswered: sql<number>`count(*)`,
45
+ totalCorrectAnswers: sql<number>`sum(case when ${answer.isCorrect} = true then 1 else 0 end)`,
46
+ })
47
+ .from(answer)
48
+ .innerJoin(sectionResult, eq(answer.sectionResultId, sectionResult.id))
49
+ .innerJoin(testAttempt, eq(sectionResult.attemptId, testAttempt.id))
50
+ .where(eq(testAttempt.userId, userId));
51
+
52
+ const totalAttempts = Number(attemptAgg?.totalAttempts ?? 0);
53
+ const completedAttempts = Number(attemptAgg?.completedAttempts ?? 0);
54
+ const totalQuestionsAnswered = Number(answerAgg?.totalQuestionsAnswered ?? 0);
55
+ const totalCorrectAnswers = Number(answerAgg?.totalCorrectAnswers ?? 0);
56
+
57
+ return {
58
+ totalAttempts,
59
+ completedAttempts,
60
+ abandonedAttempts: Number(attemptAgg?.abandonedAttempts ?? 0),
61
+ avgScorePct: Number(attemptAgg?.avgScorePct ?? 0),
62
+ totalTimeSpentSec: Number(timeAgg?.totalTimeSpentSec ?? 0),
63
+ totalQuestionsAnswered,
64
+ totalCorrectAnswers,
65
+ overallAccuracyPct:
66
+ totalQuestionsAnswered > 0
67
+ ? Math.round((totalCorrectAnswers / totalQuestionsAnswered) * 100)
68
+ : 0,
69
+ };
70
+ }),
71
+
72
+ byExamType: protectedProcedure.query(async ({ ctx }) => {
73
+ const userId = ctx.session.user.id;
74
+
75
+ const rows = await db
76
+ .select({
77
+ examTypeId: testPackage.examTypeId,
78
+ examTypeName: examType.name,
79
+ attempts: sql<number>`count(distinct ${testAttempt.id})`,
80
+ avgScorePct:
81
+ sql<number>`round(avg(case when ${testAttempt.status} = 'completed' and ${testAttempt.maxScore} > 0 then (${testAttempt.totalScore}::float / ${testAttempt.maxScore}) * 100 end)::numeric, 1)`,
82
+ avgTimeSpentSec: sql<number>`round(avg(${sectionResult.timeSpentSec})::numeric, 0)`,
83
+ totalQuestions: sql<number>`count(${answer.id})`,
84
+ correctQuestions: sql<number>`sum(case when ${answer.isCorrect} = true then 1 else 0 end)`,
85
+ })
86
+ .from(testAttempt)
87
+ .innerJoin(testPackage, eq(testAttempt.packageId, testPackage.id))
88
+ .innerJoin(examType, eq(testPackage.examTypeId, examType.id))
89
+ .leftJoin(sectionResult, eq(sectionResult.attemptId, testAttempt.id))
90
+ .leftJoin(answer, eq(answer.sectionResultId, sectionResult.id))
91
+ .where(eq(testAttempt.userId, userId))
92
+ .groupBy(testPackage.examTypeId, examType.name);
93
+
94
+ return rows.map((r) => ({
95
+ ...r,
96
+ attempts: Number(r.attempts),
97
+ avgScorePct: Number(r.avgScorePct ?? 0),
98
+ avgTimeSpentSec: Number(r.avgTimeSpentSec ?? 0),
99
+ totalQuestions: Number(r.totalQuestions),
100
+ correctQuestions: Number(r.correctQuestions),
101
+ accuracyPct:
102
+ Number(r.totalQuestions) > 0
103
+ ? Math.round((Number(r.correctQuestions) / Number(r.totalQuestions)) * 100)
104
+ : 0,
105
+ }));
106
+ }),
107
+
108
+ bySectionType: protectedProcedure.query(async ({ ctx }) => {
109
+ const userId = ctx.session.user.id;
110
+
111
+ const rows = await db
112
+ .select({
113
+ sectionTypeId: sectionType.id,
114
+ sectionTypeName: sectionType.name,
115
+ attempts: sql<number>`count(distinct ${testAttempt.id})`,
116
+ avgScorePct:
117
+ sql<number>`round(avg(case when ${testAttempt.status} = 'completed' and ${sectionResult.maxScore} > 0 then (${sectionResult.score}::float / ${sectionResult.maxScore}) * 100 end)::numeric, 1)`,
118
+ avgTimeSpentSec: sql<number>`round(avg(${sectionResult.timeSpentSec})::numeric, 0)`,
119
+ totalQuestions: sql<number>`count(${answer.id})`,
120
+ correctQuestions: sql<number>`sum(case when ${answer.isCorrect} = true then 1 else 0 end)`,
121
+ })
122
+ .from(testAttempt)
123
+ .innerJoin(sectionResult, eq(sectionResult.attemptId, testAttempt.id))
124
+ .innerJoin(sectionType, eq(sectionResult.sectionTypeId, sectionType.id))
125
+ .leftJoin(answer, eq(answer.sectionResultId, sectionResult.id))
126
+ .where(eq(testAttempt.userId, userId))
127
+ .groupBy(sectionType.id, sectionType.name);
128
+
129
+ return rows.map((r) => ({
130
+ ...r,
131
+ attempts: Number(r.attempts),
132
+ avgScorePct: Number(r.avgScorePct ?? 0),
133
+ avgTimeSpentSec: Number(r.avgTimeSpentSec ?? 0),
134
+ totalQuestions: Number(r.totalQuestions),
135
+ correctQuestions: Number(r.correctQuestions),
136
+ accuracyPct:
137
+ Number(r.totalQuestions) > 0
138
+ ? Math.round((Number(r.correctQuestions) / Number(r.totalQuestions)) * 100)
139
+ : 0,
140
+ }));
141
+ }),
142
+
143
+ byFormat: protectedProcedure.query(async ({ ctx }) => {
144
+ const userId = ctx.session.user.id;
145
+
146
+ const rows = await db
147
+ .select({
148
+ format: question.format,
149
+ totalQuestions: sql<number>`count(${answer.id})`,
150
+ correctQuestions: sql<number>`sum(case when ${answer.isCorrect} = true then 1 else 0 end)`,
151
+ avgTimeSpentSec: sql<number>`round(avg(${answer.timeSpentSec})::numeric, 0)`,
152
+ })
153
+ .from(answer)
154
+ .innerJoin(sectionResult, eq(answer.sectionResultId, sectionResult.id))
155
+ .innerJoin(testAttempt, eq(sectionResult.attemptId, testAttempt.id))
156
+ .innerJoin(question, eq(answer.questionId, question.id))
157
+ .where(eq(testAttempt.userId, userId))
158
+ .groupBy(question.format);
159
+
160
+ return rows
161
+ .map((r) => ({
162
+ format: r.format,
163
+ totalQuestions: Number(r.totalQuestions),
164
+ correctQuestions: Number(r.correctQuestions),
165
+ avgTimeSpentSec: Number(r.avgTimeSpentSec ?? 0),
166
+ accuracyPct:
167
+ Number(r.totalQuestions) > 0
168
+ ? Math.round((Number(r.correctQuestions) / Number(r.totalQuestions)) * 100)
169
+ : 0,
170
+ }))
171
+ .sort((a, b) => b.totalQuestions - a.totalQuestions);
172
+ }),
173
+
174
+ bySkillTag: protectedProcedure.query(async ({ ctx }) => {
175
+ const userId = ctx.session.user.id;
176
+
177
+ const rows = await db
178
+ .select({
179
+ skillTags: question.skillTags,
180
+ isCorrect: answer.isCorrect,
181
+ timeSpentSec: answer.timeSpentSec,
182
+ })
183
+ .from(answer)
184
+ .innerJoin(sectionResult, eq(answer.sectionResultId, sectionResult.id))
185
+ .innerJoin(testAttempt, eq(sectionResult.attemptId, testAttempt.id))
186
+ .innerJoin(question, eq(answer.questionId, question.id))
187
+ .where(eq(testAttempt.userId, userId));
188
+
189
+ const tagMap = new Map<
190
+ string,
191
+ { total: number; correct: number; timeSpentSec: number }
192
+ >();
193
+
194
+ for (const row of rows) {
195
+ const tags = row.skillTags ?? [];
196
+ for (const tag of tags) {
197
+ const existing = tagMap.get(tag) ?? { total: 0, correct: 0, timeSpentSec: 0 };
198
+ existing.total += 1;
199
+ if (row.isCorrect) existing.correct += 1;
200
+ if (row.timeSpentSec) existing.timeSpentSec += row.timeSpentSec;
201
+ tagMap.set(tag, existing);
202
+ }
203
+ }
204
+
205
+ return Array.from(tagMap.entries())
206
+ .map(([tag, data]) => ({
207
+ tag,
208
+ totalQuestions: data.total,
209
+ correctQuestions: data.correct,
210
+ accuracyPct: data.total > 0 ? Math.round((data.correct / data.total) * 100) : 0,
211
+ avgTimeSpentSec: data.total > 0 ? Math.round(data.timeSpentSec / data.total) : 0,
212
+ }))
213
+ .sort((a, b) => b.totalQuestions - a.totalQuestions);
214
+ }),
215
+
216
+ trend: protectedProcedure
217
+ .input(
218
+ z
219
+ .object({
220
+ days: z.number().min(7).max(90).default(30),
221
+ })
222
+ .optional(),
223
+ )
224
+ .query(async ({ ctx, input }) => {
225
+ const userId = ctx.session.user.id;
226
+ const days = input?.days ?? 30;
227
+ const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
228
+
229
+ const rows = await db
230
+ .select({
231
+ date: sql<string>`date(${testAttempt.finishedAt})`,
232
+ attempts: sql<number>`count(*)`,
233
+ avgScorePct:
234
+ sql<number>`round(avg(case when ${testAttempt.maxScore} > 0 then (${testAttempt.totalScore}::float / ${testAttempt.maxScore}) * 100 end)::numeric, 1)`,
235
+ })
236
+ .from(testAttempt)
237
+ .where(
238
+ and(
239
+ eq(testAttempt.userId, userId),
240
+ eq(testAttempt.status, "completed"),
241
+ gte(testAttempt.finishedAt, since),
242
+ ),
243
+ )
244
+ .groupBy(sql`date(${testAttempt.finishedAt})`)
245
+ .orderBy(sql`date(${testAttempt.finishedAt})`);
246
+
247
+ // Fill in missing dates
248
+ const resultMap = new Map<string, { date: string; attempts: number; avgScorePct: number }>();
249
+ for (const row of rows) {
250
+ resultMap.set(row.date, {
251
+ date: row.date,
252
+ attempts: Number(row.attempts),
253
+ avgScorePct: Number(row.avgScorePct ?? 0),
254
+ });
255
+ }
256
+
257
+ const filled: { date: string; attempts: number; avgScorePct: number }[] = [];
258
+ for (let i = days - 1; i >= 0; i--) {
259
+ const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
260
+ const key = d.toISOString().split("T")[0];
261
+ filled.push(resultMap.get(key) ?? { date: key, attempts: 0, avgScorePct: 0 });
262
+ }
263
+
264
+ return filled;
265
+ }),
266
+
267
+ weaknesses: protectedProcedure.query(async ({ ctx }) => {
268
+ const userId = ctx.session.user.id;
269
+ const MIN_QUESTIONS = 5;
270
+
271
+ // Format weaknesses
272
+ const formatRows = await db
273
+ .select({
274
+ format: question.format,
275
+ totalQuestions: sql<number>`count(${answer.id})`,
276
+ correctQuestions: sql<number>`sum(case when ${answer.isCorrect} = true then 1 else 0 end)`,
277
+ })
278
+ .from(answer)
279
+ .innerJoin(sectionResult, eq(answer.sectionResultId, sectionResult.id))
280
+ .innerJoin(testAttempt, eq(sectionResult.attemptId, testAttempt.id))
281
+ .innerJoin(question, eq(answer.questionId, question.id))
282
+ .where(eq(testAttempt.userId, userId))
283
+ .groupBy(question.format);
284
+
285
+ const formatWeaknesses = formatRows
286
+ .map((r) => ({
287
+ type: "format" as const,
288
+ name: r.format,
289
+ totalQuestions: Number(r.totalQuestions),
290
+ accuracyPct:
291
+ Number(r.totalQuestions) > 0
292
+ ? Math.round((Number(r.correctQuestions) / Number(r.totalQuestions)) * 100)
293
+ : 0,
294
+ }))
295
+ .filter((w) => w.totalQuestions >= MIN_QUESTIONS)
296
+ .sort((a, b) => a.accuracyPct - b.accuracyPct)
297
+ .slice(0, 3);
298
+
299
+ // Section type weaknesses
300
+ const sectionRows = await db
301
+ .select({
302
+ sectionTypeId: sectionType.id,
303
+ sectionTypeName: sectionType.name,
304
+ totalQuestions: sql<number>`count(${answer.id})`,
305
+ correctQuestions: sql<number>`sum(case when ${answer.isCorrect} = true then 1 else 0 end)`,
306
+ })
307
+ .from(answer)
308
+ .innerJoin(sectionResult, eq(answer.sectionResultId, sectionResult.id))
309
+ .innerJoin(testAttempt, eq(sectionResult.attemptId, testAttempt.id))
310
+ .innerJoin(sectionType, eq(sectionResult.sectionTypeId, sectionType.id))
311
+ .where(eq(testAttempt.userId, userId))
312
+ .groupBy(sectionType.id, sectionType.name);
313
+
314
+ const sectionWeaknesses = sectionRows
315
+ .map((r) => ({
316
+ type: "section" as const,
317
+ name: r.sectionTypeName,
318
+ totalQuestions: Number(r.totalQuestions),
319
+ accuracyPct:
320
+ Number(r.totalQuestions) > 0
321
+ ? Math.round((Number(r.correctQuestions) / Number(r.totalQuestions)) * 100)
322
+ : 0,
323
+ }))
324
+ .filter((w) => w.totalQuestions >= MIN_QUESTIONS)
325
+ .sort((a, b) => a.accuracyPct - b.accuracyPct)
326
+ .slice(0, 3);
327
+
328
+ // Skill tag weaknesses
329
+ const tagRows = await db
330
+ .select({
331
+ skillTags: question.skillTags,
332
+ isCorrect: answer.isCorrect,
333
+ })
334
+ .from(answer)
335
+ .innerJoin(sectionResult, eq(answer.sectionResultId, sectionResult.id))
336
+ .innerJoin(testAttempt, eq(sectionResult.attemptId, testAttempt.id))
337
+ .innerJoin(question, eq(answer.questionId, question.id))
338
+ .where(eq(testAttempt.userId, userId));
339
+
340
+ const tagMap = new Map<string, { total: number; correct: number }>();
341
+ for (const row of tagRows) {
342
+ const tags = row.skillTags ?? [];
343
+ for (const tag of tags) {
344
+ const existing = tagMap.get(tag) ?? { total: 0, correct: 0 };
345
+ existing.total += 1;
346
+ if (row.isCorrect) existing.correct += 1;
347
+ tagMap.set(tag, existing);
348
+ }
349
+ }
350
+
351
+ const tagWeaknesses = Array.from(tagMap.entries())
352
+ .map(([name, data]) => ({
353
+ type: "skill" as const,
354
+ name,
355
+ totalQuestions: data.total,
356
+ accuracyPct: data.total > 0 ? Math.round((data.correct / data.total) * 100) : 0,
357
+ }))
358
+ .filter((w) => w.totalQuestions >= MIN_QUESTIONS)
359
+ .sort((a, b) => a.accuracyPct - b.accuracyPct)
360
+ .slice(0, 3);
361
+
362
+ // Combine and get top overall weaknesses
363
+ const allWeaknesses = [...formatWeaknesses, ...sectionWeaknesses, ...tagWeaknesses]
364
+ .sort((a, b) => a.accuracyPct - b.accuracyPct)
365
+ .slice(0, 5);
366
+
367
+ // Generate recommendations
368
+ const recommendations: string[] = [];
369
+ for (const w of allWeaknesses) {
370
+ if (w.accuracyPct < 40) {
371
+ recommendations.push(
372
+ `Fokus pada ${w.name}: akurasi hanya ${w.accuracyPct}%. Cobalah latihan intensif untuk tipe ini.`,
373
+ );
374
+ } else if (w.accuracyPct < 60) {
375
+ recommendations.push(
376
+ `Perbanyak latihan ${w.name}: akurasi ${w.accuracyPct}%. Analisis jawaban salah Anda untuk memahami pola kesalahan.`,
377
+ );
378
+ } else {
379
+ recommendations.push(
380
+ `Tingkatkan konsistensi ${w.name}: akurasi ${w.accuracyPct}%. Latihan rutin akan membantu mencapai 80%+.`,
381
+ );
382
+ }
383
+ }
384
+
385
+ if (allWeaknesses.length === 0) {
386
+ recommendations.push(
387
+ "Belum cukup data untuk mengidentifikasi kelemahan. Selesaikan lebih banyak latihan!",
388
+ );
389
+ }
390
+
391
+ return { weaknesses: allWeaknesses, recommendations };
392
+ }),
393
+
394
+ timeAnalytics: protectedProcedure.query(async ({ ctx }) => {
395
+ const userId = ctx.session.user.id;
396
+
397
+ // Time by section type
398
+ const sectionTime = await db
399
+ .select({
400
+ sectionTypeName: sectionType.name,
401
+ avgTimeSpentSec: sql<number>`round(avg(${sectionResult.timeSpentSec})::numeric, 0)`,
402
+ totalTimeSpentSec: sql<number>`sum(${sectionResult.timeSpentSec})`,
403
+ })
404
+ .from(testAttempt)
405
+ .innerJoin(sectionResult, eq(sectionResult.attemptId, testAttempt.id))
406
+ .innerJoin(sectionType, eq(sectionResult.sectionTypeId, sectionType.id))
407
+ .where(eq(testAttempt.userId, userId))
408
+ .groupBy(sectionType.name);
409
+
410
+ // Time by format
411
+ const formatTime = await db
412
+ .select({
413
+ format: question.format,
414
+ avgTimeSpentSec: sql<number>`round(avg(${answer.timeSpentSec})::numeric, 0)`,
415
+ totalTimeSpentSec: sql<number>`sum(${answer.timeSpentSec})`,
416
+ })
417
+ .from(answer)
418
+ .innerJoin(sectionResult, eq(answer.sectionResultId, sectionResult.id))
419
+ .innerJoin(testAttempt, eq(sectionResult.attemptId, testAttempt.id))
420
+ .innerJoin(question, eq(answer.questionId, question.id))
421
+ .where(eq(testAttempt.userId, userId))
422
+ .groupBy(question.format);
423
+
424
+ // Time trend (avg time per completed attempt)
425
+ const timeTrend = await db
426
+ .select({
427
+ date: sql<string>`date(${testAttempt.finishedAt})`,
428
+ avgTimeSpentSec: sql<number>`round(avg(${sectionResult.timeSpentSec})::numeric, 0)`,
429
+ })
430
+ .from(testAttempt)
431
+ .innerJoin(sectionResult, eq(sectionResult.attemptId, testAttempt.id))
432
+ .where(and(eq(testAttempt.userId, userId), eq(testAttempt.status, "completed")))
433
+ .groupBy(sql`date(${testAttempt.finishedAt})`)
434
+ .orderBy(sql`date(${testAttempt.finishedAt})`);
435
+
436
+ return {
437
+ sectionTime: sectionTime.map((r) => ({
438
+ sectionTypeName: r.sectionTypeName,
439
+ avgTimeSpentSec: Number(r.avgTimeSpentSec ?? 0),
440
+ totalTimeSpentSec: Number(r.totalTimeSpentSec ?? 0),
441
+ })),
442
+ formatTime: formatTime
443
+ .map((r) => ({
444
+ format: r.format,
445
+ avgTimeSpentSec: Number(r.avgTimeSpentSec ?? 0),
446
+ totalTimeSpentSec: Number(r.totalTimeSpentSec ?? 0),
447
+ }))
448
+ .sort((a, b) => b.totalTimeSpentSec - a.totalTimeSpentSec),
449
+ timeTrend: timeTrend.map((r) => ({
450
+ date: r.date,
451
+ avgTimeSpentSec: Number(r.avgTimeSpentSec ?? 0),
452
+ })),
453
+ };
454
+ }),
455
+ });