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 +1 -0
- apps/web/src/components/analytics/BreakdownCharts.tsx +228 -0
- apps/web/src/components/analytics/OverviewCards.tsx +86 -0
- apps/web/src/components/analytics/ScoreTrendChart.tsx +105 -0
- apps/web/src/components/analytics/TimeAnalyticsPanel.tsx +164 -0
- apps/web/src/components/analytics/WeaknessPanel.tsx +112 -0
- apps/web/src/components/bank/QuestionDetailModal.tsx +4 -6
- apps/web/src/components/sidebar.tsx +27 -35
- apps/web/src/components/test/AttemptTestView.tsx +320 -119
- apps/web/src/components/test/QuestionInput.tsx +3 -3
- apps/web/src/hooks/use-analytics.ts +35 -0
- apps/web/src/hooks/use-test-session.ts +93 -8
- apps/web/src/routeTree.gen.ts +42 -2
- apps/web/src/routes/__root.tsx +16 -8
- apps/web/src/routes/analytics.tsx +108 -0
- apps/web/src/routes/attempt.$id.tsx +12 -0
- apps/web/src/routes/package.$id.index.tsx +188 -0
- apps/web/src/routes/package.$id.take.tsx +27 -1
- apps/web/src/routes/package.$id.tsx +2 -185
- apps/web/src/routes/packages.tsx +19 -8
- bun.lock +69 -0
- packages/api/src/queue.ts +3 -3
- packages/api/src/routers/attempt.ts +50 -0
- packages/api/src/routers/index.ts +2 -0
- packages/api/src/routers/package.ts +1 -1
- packages/api/src/routers/stats.ts +455 -0
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 |
-
|
| 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 |
-
|
| 57 |
-
|
| 58 |
-
|
| 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 |
-
|
| 78 |
-
|
| 79 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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-
|
| 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 |
-
|
| 125 |
-
|
| 126 |
-
|
| 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 |
-
|
| 135 |
-
|
| 136 |
-
|
| 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 |
-
|
| 155 |
-
|
| 156 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 47 |
-
{
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
<MaterialIcon name="close" />
|
| 53 |
-
</
|
| 54 |
-
<
|
| 55 |
-
|
| 56 |
-
|
|
|
|
| 57 |
</div>
|
| 58 |
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
| 63 |
</div>
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
</div>
|
|
|
|
|
|
|
|
|
|
| 68 |
<Button
|
| 69 |
-
onClick={
|
| 70 |
disabled={isFinished}
|
| 71 |
-
className="bg-[var(--pomegranate-500)] text-[var(--pure-white)] hover:bg-[var(--pomegranate-
|
| 72 |
>
|
| 73 |
-
Selesai
|
| 74 |
</Button>
|
| 75 |
</div>
|
| 76 |
-
</
|
| 77 |
-
</div>
|
| 78 |
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
<
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 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 |
-
</
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
-
{/* Right:
|
| 117 |
-
<
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
<
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
</
|
| 166 |
-
|
| 167 |
-
)
|
| 168 |
-
})}
|
| 169 |
|
| 170 |
-
|
| 171 |
-
|
| 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 |
-
|
| 192 |
-
|
| 193 |
-
|
|
|
|
| 194 |
>
|
| 195 |
-
<MaterialIcon name="
|
| 196 |
-
|
| 197 |
</Button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
</div>
|
| 200 |
</div>
|
| 201 |
</div>
|
| 202 |
-
|
| 203 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 32 |
-
const radioSelected = "border-[var(--matcha-600)] bg-[
|
| 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=
|
| 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) =>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
}, 1000);
|
| 27 |
return () => clearInterval(interval);
|
| 28 |
-
}, [isStarted, isFinished]);
|
| 29 |
|
| 30 |
const handleStart = useCallback(async () => {
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 47 |
-
<
|
| 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 |
-
</
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
| 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:
|
| 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 |
-
<
|
| 130 |
-
<
|
| 131 |
-
<
|
| 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
|
| 150 |
{pkg.description}
|
| 151 |
</p>
|
| 152 |
)}
|
|
@@ -172,9 +173,19 @@ function PackagesComponent() {
|
|
| 172 |
{pkg.usageCount}x digunakan
|
| 173 |
</span>
|
| 174 |
</div>
|
| 175 |
-
</
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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:
|
| 97 |
});
|
| 98 |
|
| 99 |
export const generationWorker = new Worker(
|
|
@@ -338,7 +338,7 @@ export const generationWorker = new Worker(
|
|
| 338 |
}
|
| 339 |
},
|
| 340 |
{
|
| 341 |
-
connection:
|
| 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 |
+
});
|