rogasper commited on
Commit
46d8323
·
1 Parent(s): 94ac16a

feat: implement HistoryPage component with lazy loading and session validation. Introduce new route for history, enhancing user experience by allowing users to view their training attempts. Update routeTree to support lazy loading of the HistoryComponent, improving performance. Refactor existing history route to streamline code and enhance maintainability.

Browse files
apps/web/src/components/routes/HistoryPage.tsx ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useRef, useState, useEffect } from "react";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { createFileRoute, redirect, Link } from "@tanstack/react-router";
4
+ import { z } from "zod";
5
+ import { authClient } from "@/lib/auth-client";
6
+ import { trpc } from "@/utils/trpc";
7
+ import { Button } from "@labas/ui/components/button";
8
+ import { Card, CardContent } from "@labas/ui/components/card";
9
+ import { Input } from "@labas/ui/components/input";
10
+ import {
11
+ Select,
12
+ SelectContent,
13
+ SelectGroup,
14
+ SelectItem,
15
+ SelectTrigger,
16
+ SelectValue,
17
+ } from "@labas/ui/components/select";
18
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
19
+ import { EXAM_TYPES } from "@/lib/exam-constants";
20
+
21
+ export const Route = createFileRoute("/history")({
22
+ validateSearch: z.object({
23
+ page: z.coerce.number().optional(),
24
+ examTypeId: z.string().optional(),
25
+ search: z.string().optional(),
26
+ }).parse,
27
+ beforeLoad: async () => {
28
+ const session = await authClient.getSession();
29
+ if (!session.data) {
30
+ redirect({ to: "/login", throw: true });
31
+ }
32
+ return { session };
33
+ },
34
+ });
35
+
36
+ function formatDate(dateStr: string | Date | null) {
37
+ if (!dateStr) return "-";
38
+ const d = new Date(dateStr);
39
+ return d.toLocaleDateString("id-ID", {
40
+ day: "numeric",
41
+ month: "short",
42
+ year: "numeric",
43
+ hour: "2-digit",
44
+ minute: "2-digit",
45
+ });
46
+ }
47
+
48
+ function getStatusBadge(status: string) {
49
+ switch (status) {
50
+ case "completed":
51
+ return (
52
+ <span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold">
53
+ <MaterialIcon name="check_circle" className="text-xs" />
54
+ Selesai
55
+ </span>
56
+ );
57
+ case "abandoned":
58
+ return (
59
+ <span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-[var(--oat-light)] text-[var(--warm-charcoal)] text-xs font-semibold">
60
+ <MaterialIcon name="cancel" className="text-xs" />
61
+ Ditinggalkan
62
+ </span>
63
+ );
64
+ default:
65
+ return (
66
+ <span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] text-xs font-semibold">
67
+ <MaterialIcon name="hourglass_top" className="text-xs" />
68
+ Berlangsung
69
+ </span>
70
+ );
71
+ }
72
+ }
73
+
74
+ export function HistoryComponent() {
75
+ const search = Route.useSearch();
76
+ const navigate = Route.useNavigate();
77
+ const page = search.page ?? 1;
78
+ const examTypeId = search.examTypeId ?? "";
79
+ const searchQuery = search.search ?? "";
80
+ const limit = 12;
81
+
82
+ const [localSearch, setLocalSearch] = useState(searchQuery);
83
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
84
+
85
+ useEffect(() => {
86
+ setLocalSearch(searchQuery);
87
+ }, [searchQuery]);
88
+
89
+ useEffect(() => {
90
+ if (debounceRef.current) clearTimeout(debounceRef.current);
91
+ debounceRef.current = setTimeout(() => {
92
+ if (localSearch !== searchQuery) {
93
+ navigate({ search: (prev) => ({ ...prev, search: localSearch || undefined, page: 1 }) });
94
+ }
95
+ }, 300);
96
+ return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
97
+ }, [localSearch]);
98
+
99
+ const setExamTypeFilter = useCallback((value: string) => {
100
+ navigate({ search: (prev) => ({ ...prev, examTypeId: value || undefined, page: 1 }) });
101
+ }, [navigate]);
102
+
103
+ const query = useQuery(
104
+ trpc.attempt.myAttempts.queryOptions({
105
+ limit,
106
+ offset: (page - 1) * limit,
107
+ examTypeId: examTypeId || undefined,
108
+ search: searchQuery || undefined,
109
+ }),
110
+ );
111
+
112
+ const attempts = query.data?.attempts ?? [];
113
+ const total = query.data?.total ?? 0;
114
+ const totalPages = Math.ceil(total / limit);
115
+
116
+ return (
117
+ <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-5xl mx-auto bg-[var(--warm-cream)]">
118
+ {/* Header */}
119
+ <div className="mb-8">
120
+ <div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-4">
121
+ <Link to="/" className="hover:text-[var(--clay-black)] transition-colors">
122
+ Beranda
123
+ </Link>
124
+ <MaterialIcon name="chevron_right" className="text-xs" />
125
+ <span className="text-[var(--clay-black)] font-medium">Riwayat Latihan</span>
126
+ </div>
127
+
128
+ <div className="flex items-center justify-between">
129
+ <div>
130
+ <h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
131
+ Riwayat Latihan
132
+ </h1>
133
+ <p className="text-lg text-[var(--warm-charcoal)] mt-2">
134
+ Semua attempt latihan Anda.
135
+ </p>
136
+ </div>
137
+ </div>
138
+
139
+ {/* Filters */}
140
+ <div className="flex flex-col md:flex-row gap-3 mt-6">
141
+ <div className="relative flex-1 max-w-md">
142
+ <MaterialIcon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--warm-charcoal)]" />
143
+ <Input
144
+ placeholder="Cari paket..."
145
+ value={localSearch}
146
+ onChange={(e) => setLocalSearch(e.target.value)}
147
+ aria-label="Cari paket"
148
+ className="pl-10 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] h-11"
149
+ />
150
+ </div>
151
+ <Select value={examTypeId} onValueChange={(v) => setExamTypeFilter(v ?? "")}>
152
+ <SelectTrigger className="w-36">
153
+ <SelectValue placeholder="Semua Ujian" />
154
+ </SelectTrigger>
155
+ <SelectContent>
156
+ <SelectGroup>
157
+ <SelectItem value="">Semua Ujian</SelectItem>
158
+ {EXAM_TYPES.map((t) => (
159
+ <SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>
160
+ ))}
161
+ </SelectGroup>
162
+ </SelectContent>
163
+ </Select>
164
+ </div>
165
+ </div>
166
+
167
+ {/* Results */}
168
+ {query.isLoading ? (
169
+ <div className="space-y-4">
170
+ {Array.from({ length: 5 }).map((_, i) => (
171
+ <Card key={i} className="h-24 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-xl)]" />
172
+ ))}
173
+ </div>
174
+ ) : attempts.length === 0 ? (
175
+ <div className="text-center py-20">
176
+ <MaterialIcon name="history" className="text-6xl text-[var(--warm-silver)] mx-auto mb-4" />
177
+ <p className="text-lg text-[var(--warm-charcoal)] font-semibold">Belum ada riwayat latihan</p>
178
+ <p className="text-sm text-[var(--warm-silver)] mt-1">
179
+ Mulai latihan dari paket soal untuk melihat riwayat di sini.
180
+ </p>
181
+ <Link to="/packages" className="inline-block mt-6">
182
+ <Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)]">
183
+ <MaterialIcon name="folder" className="mr-2" />
184
+ Lihat Paket Soal
185
+ </Button>
186
+ </Link>
187
+ </div>
188
+ ) : (
189
+ <>
190
+ <div className="space-y-4">
191
+ {attempts.map((attempt) => {
192
+ const pct =
193
+ attempt.maxScore && attempt.maxScore > 0 && attempt.totalScore != null
194
+ ? Math.round((attempt.totalScore / attempt.maxScore) * 100)
195
+ : null;
196
+
197
+ return (
198
+ <Card
199
+ key={attempt.id}
200
+ className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] hover:border-[var(--matcha-400)] transition-colors"
201
+ >
202
+ <CardContent className="p-5">
203
+ <div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
204
+ <div className="flex-1 min-w-0">
205
+ <div className="flex items-center gap-2 mb-2 flex-wrap">
206
+ {getStatusBadge(attempt.status)}
207
+ {attempt.examTypeName && (
208
+ <span className="px-2.5 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold">
209
+ {attempt.examTypeName}
210
+ </span>
211
+ )}
212
+ </div>
213
+
214
+ <h2 className="font-headline text-lg font-bold text-[var(--clay-black)] truncate">
215
+ {attempt.packageTitle ?? "Paket tidak diketahui"}
216
+ </h2>
217
+
218
+ <div className="flex items-center gap-4 mt-1 text-xs text-[var(--warm-charcoal)]">
219
+ <span className="flex items-center gap-1">
220
+ <MaterialIcon name="event" className="text-xs" />
221
+ {formatDate(attempt.startedAt)}
222
+ </span>
223
+ {attempt.finishedAt && (
224
+ <span className="flex items-center gap-1">
225
+ <MaterialIcon name="check" className="text-xs" />
226
+ {formatDate(attempt.finishedAt)}
227
+ </span>
228
+ )}
229
+ </div>
230
+ </div>
231
+
232
+ <div className="flex items-center gap-6 shrink-0">
233
+ {pct != null && (
234
+ <div className="text-center">
235
+ <div className="text-2xl font-headline font-extrabold text-[var(--clay-black)]">
236
+ {pct}%
237
+ </div>
238
+ <div className="text-xs text-[var(--warm-charcoal)]">
239
+ {attempt.totalScore}/{attempt.maxScore}
240
+ </div>
241
+ </div>
242
+ )}
243
+
244
+ <div className="flex gap-2">
245
+ {attempt.status === "completed" && (
246
+ <Link to="/attempt/$id" params={{ id: attempt.id }}>
247
+ <Button
248
+ variant="outline"
249
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover text-sm"
250
+ >
251
+ <MaterialIcon name="visibility" className="mr-1" />
252
+ Hasil
253
+ </Button>
254
+ </Link>
255
+ )}
256
+ {attempt.packageId && (
257
+ <Link to="/package/$id" params={{ id: attempt.packageId }}>
258
+ <Button
259
+ variant="outline"
260
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover text-sm"
261
+ >
262
+ <MaterialIcon name="folder" className="mr-1" />
263
+ Paket
264
+ </Button>
265
+ </Link>
266
+ )}
267
+ </div>
268
+ </div>
269
+ </div>
270
+ </CardContent>
271
+ </Card>
272
+ );
273
+ })}
274
+ </div>
275
+
276
+ {totalPages > 1 && (
277
+ <div className="flex items-center justify-center gap-2 mt-10">
278
+ <Button
279
+ variant="outline"
280
+ onClick={() => navigate({ search: (prev) => ({ ...prev, page: Math.max(1, page - 1) }) })}
281
+ disabled={page <= 1}
282
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
283
+ >
284
+ <MaterialIcon name="chevron_left" />
285
+ </Button>
286
+ <span className="text-sm text-[var(--warm-charcoal)] px-4">
287
+ Halaman {page} dari {totalPages}
288
+ </span>
289
+ <Button
290
+ variant="outline"
291
+ onClick={() => navigate({ search: (prev) => ({ ...prev, page: Math.min(totalPages, page + 1) }) })}
292
+ disabled={page >= totalPages}
293
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
294
+ >
295
+ <MaterialIcon name="chevron_right" />
296
+ </Button>
297
+ </div>
298
+ )}
299
+ </>
300
+ )}
301
+ </div>
302
+ );
303
+ }
apps/web/src/hooks/use-generation-jobs.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { useEffect, useCallback } from "react";
2
  import { useQuery } from "@tanstack/react-query";
3
  import { trpc, trpcClient } from "@/utils/trpc";
4
  import { authClient } from "@/lib/auth-client";
@@ -36,66 +36,73 @@ export function useGenerationJobs() {
36
  });
37
 
38
  /* Transport layer — polling now, swappable for WebSocket later */
39
- const transport = usePollingTransport({ isAuthenticated });
40
-
41
- useEffect(() => {
42
- transport.subscribe(jobIds);
43
- return () => transport.unsubscribe();
44
- }, [jobIds, transport]);
45
 
46
  useEffect(() => {
47
- transport.onUpdate((event) => {
48
- const { jobId, status, data } = event;
49
- const prevStatus = processedStates[jobId];
50
- if (prevStatus === status) return;
51
- trackStatus(jobId, status);
52
-
53
- const extractPkgId = (): string | null => {
54
- const rj = data.resultJson as Record<string, unknown> | null | undefined;
55
- if (!rj || typeof rj !== "object") return null;
56
- const id = rj.generatedPackageId;
57
- return typeof id === "string" ? id : null;
58
- };
59
-
60
- const baseResult = {
61
- jobId,
62
- result: data.resultJson as GenerationResult,
63
- generatedPackageId: extractPkgId(),
64
- mode: (data.mode as string) ?? "quick",
65
- timestamp: Date.now(),
66
- };
67
-
68
- const alreadyCompleted = completedResults.some(
69
- (r) => r.jobId === jobId && r.timestamp > 0,
70
- );
71
-
72
- if (alreadyCompleted) {
73
- if (status === "completed" && data.resultJson) {
74
- setResult(baseResult);
75
- }
76
- return;
77
- }
78
-
79
- if (status === "partial_ready" && data.resultJson) {
80
- if (!completedResults.some((r) => r.jobId === jobId)) {
81
- setResult(baseResult);
82
- }
83
- }
84
-
85
  if (status === "completed" && data.resultJson) {
86
  setResult(baseResult);
87
  }
 
 
88
 
89
- if (status === "failed") {
90
- setError((data.errorMessage as string) ?? "Generation failed");
91
- clearResult(jobId);
92
  }
 
93
 
94
- if (status === "cancelled") {
95
- clearResult(jobId);
96
- }
97
- });
98
- }, [transport, processedStates, completedResults, trackStatus, setResult, setError, clearResult]);
 
 
 
 
 
 
 
 
 
 
 
 
99
 
100
  /* Merge discovered jobs from myJobs fallback */
101
  useEffect(() => {
@@ -127,7 +134,6 @@ export function useGenerationJobs() {
127
  return () => timers.forEach(clearTimeout);
128
  }, [processedStates, jobIds, removeJob]);
129
 
130
- const activeJobs = transport.activeJobs;
131
  const activeCount = activeJobs.length;
132
  const canAddMore = activeCount < MAX_PARALLEL;
133
  const isGenerating = activeCount > 0;
 
1
+ import { useEffect, useCallback, useRef } from "react";
2
  import { useQuery } from "@tanstack/react-query";
3
  import { trpc, trpcClient } from "@/utils/trpc";
4
  import { authClient } from "@/lib/auth-client";
 
36
  });
37
 
38
  /* Transport layer — polling now, swappable for WebSocket later */
39
+ const { subscribe, unsubscribe, onUpdate, activeJobs } = usePollingTransport({
40
+ isAuthenticated,
41
+ });
 
 
 
42
 
43
  useEffect(() => {
44
+ subscribe(jobIds);
45
+ return () => unsubscribe();
46
+ }, [jobIds, subscribe, unsubscribe]);
47
+
48
+ const onUpdateHandlerRef = useRef<
49
+ (event: { jobId: string; status: string; data: Record<string, unknown> }) => void
50
+ >(() => {});
51
+ onUpdateHandlerRef.current = (event) => {
52
+ const { jobId, status, data } = event;
53
+ const prevStatus = processedStates[jobId];
54
+ if (prevStatus === status) return;
55
+ trackStatus(jobId, status);
56
+
57
+ const extractPkgId = (): string | null => {
58
+ const rj = data.resultJson as Record<string, unknown> | null | undefined;
59
+ if (!rj || typeof rj !== "object") return null;
60
+ const id = rj.generatedPackageId;
61
+ return typeof id === "string" ? id : null;
62
+ };
63
+
64
+ const baseResult = {
65
+ jobId,
66
+ result: data.resultJson as GenerationResult,
67
+ generatedPackageId: extractPkgId(),
68
+ mode: (data.mode as string) ?? "quick",
69
+ timestamp: Date.now(),
70
+ };
71
+
72
+ const alreadyCompleted = completedResults.some(
73
+ (r) => r.jobId === jobId && r.timestamp > 0,
74
+ );
75
+
76
+ if (alreadyCompleted) {
 
 
 
 
 
77
  if (status === "completed" && data.resultJson) {
78
  setResult(baseResult);
79
  }
80
+ return;
81
+ }
82
 
83
+ if (status === "partial_ready" && data.resultJson) {
84
+ if (!completedResults.some((r) => r.jobId === jobId)) {
85
+ setResult(baseResult);
86
  }
87
+ }
88
 
89
+ if (status === "completed" && data.resultJson) {
90
+ setResult(baseResult);
91
+ }
92
+
93
+ if (status === "failed") {
94
+ setError((data.errorMessage as string) ?? "Generation failed");
95
+ clearResult(jobId);
96
+ }
97
+
98
+ if (status === "cancelled") {
99
+ clearResult(jobId);
100
+ }
101
+ };
102
+
103
+ useEffect(() => {
104
+ onUpdate((event) => onUpdateHandlerRef.current(event));
105
+ }, [onUpdate]);
106
 
107
  /* Merge discovered jobs from myJobs fallback */
108
  useEffect(() => {
 
134
  return () => timers.forEach(clearTimeout);
135
  }, [processedStates, jobIds, removeJob]);
136
 
 
137
  const activeCount = activeJobs.length;
138
  const canAddMore = activeCount < MAX_PARALLEL;
139
  const isGenerating = activeCount > 0;
apps/web/src/hooks/use-polling-transport.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useCallback, useRef } from "react";
2
  import { useQueries } from "@tanstack/react-query";
3
  import { trpc } from "@/utils/trpc";
4
  import { type ActiveJob, type JobTransport, type JobTransportEvent, isTerminal } from "./use-job-shared";
@@ -11,6 +11,11 @@ function jobStatusRefetchInterval(query: unknown): number | false {
11
  return 1000;
12
  }
13
 
 
 
 
 
 
14
  /** Polling-based implementation of JobTransport.
15
  * Uses tRPC useQueries to poll each tracked job individually. */
16
  export function usePollingTransport({
@@ -20,9 +25,14 @@ export function usePollingTransport({
20
  }): JobTransport {
21
  const [jobIds, setJobIds] = useState<string[]>([]);
22
  const [activeJobs, setActiveJobs] = useState<ActiveJob[]>([]);
 
 
23
  const onUpdateRef = useRef<(event: JobTransportEvent) => void>(undefined);
24
  const onErrorRef = useRef<(error: Error) => void>(undefined);
25
 
 
 
 
26
  const jobQueries = useQueries({
27
  queries: jobIds.map((jobId) => ({
28
  ...trpc.ai.getJobStatus.queryOptions({ jobId }),
@@ -35,24 +45,36 @@ export function usePollingTransport({
35
  const nextActive = jobQueries
36
  .filter((q) => q.data && !isTerminal((q.data as { status: string }).status))
37
  .map((q) => q.data as unknown as ActiveJob);
38
- setActiveJobs(nextActive);
39
 
40
- // Emit events for status changes
 
 
 
 
 
41
  for (let i = 0; i < jobQueries.length; i++) {
42
  const query = jobQueries[i];
43
  const jobId = jobIds[i];
44
  if (!jobId || !query.data) continue;
45
  const data = query.data as Record<string, unknown>;
 
 
 
46
  onUpdateRef.current?.({
47
  jobId,
48
- status: data.status as string,
49
  data,
50
  });
51
  }
52
  }, [jobQueries, jobIds]);
53
 
54
  const subscribe = useCallback((ids: string[]) => {
55
- setJobIds(ids);
 
 
 
 
 
56
  }, []);
57
 
58
  const unsubscribe = useCallback(() => {
@@ -68,13 +90,16 @@ export function usePollingTransport({
68
  onErrorRef.current = callback;
69
  }, []);
70
 
71
- return {
72
- subscribe,
73
- unsubscribe,
74
- onUpdate,
75
- onError,
76
- get activeJobs() {
77
- return activeJobs;
78
- },
79
- };
 
 
 
80
  }
 
1
+ import { useState, useEffect, useCallback, useMemo, useRef } from "react";
2
  import { useQueries } from "@tanstack/react-query";
3
  import { trpc } from "@/utils/trpc";
4
  import { type ActiveJob, type JobTransport, type JobTransportEvent, isTerminal } from "./use-job-shared";
 
11
  return 1000;
12
  }
13
 
14
+ /** Track previous active job IDs and per-job statuses to avoid redundant state updates. */
15
+ function serializeJobIds(jobs: ActiveJob[]): string {
16
+ return jobs.map((j) => j.id).sort().join(",");
17
+ }
18
+
19
  /** Polling-based implementation of JobTransport.
20
  * Uses tRPC useQueries to poll each tracked job individually. */
21
  export function usePollingTransport({
 
25
  }): JobTransport {
26
  const [jobIds, setJobIds] = useState<string[]>([]);
27
  const [activeJobs, setActiveJobs] = useState<ActiveJob[]>([]);
28
+ const activeJobsRef = useRef(activeJobs);
29
+ activeJobsRef.current = activeJobs;
30
  const onUpdateRef = useRef<(event: JobTransportEvent) => void>(undefined);
31
  const onErrorRef = useRef<(error: Error) => void>(undefined);
32
 
33
+ const prevActiveIdsRef = useRef("");
34
+ const prevStatusesRef = useRef<Record<string, string>>({});
35
+
36
  const jobQueries = useQueries({
37
  queries: jobIds.map((jobId) => ({
38
  ...trpc.ai.getJobStatus.queryOptions({ jobId }),
 
45
  const nextActive = jobQueries
46
  .filter((q) => q.data && !isTerminal((q.data as { status: string }).status))
47
  .map((q) => q.data as unknown as ActiveJob);
 
48
 
49
+ const nextIds = serializeJobIds(nextActive);
50
+ if (nextIds !== prevActiveIdsRef.current) {
51
+ prevActiveIdsRef.current = nextIds;
52
+ setActiveJobs(nextActive);
53
+ }
54
+
55
  for (let i = 0; i < jobQueries.length; i++) {
56
  const query = jobQueries[i];
57
  const jobId = jobIds[i];
58
  if (!jobId || !query.data) continue;
59
  const data = query.data as Record<string, unknown>;
60
+ const status = data.status as string;
61
+ if (prevStatusesRef.current[jobId] === status) continue;
62
+ prevStatusesRef.current[jobId] = status;
63
  onUpdateRef.current?.({
64
  jobId,
65
+ status,
66
  data,
67
  });
68
  }
69
  }, [jobQueries, jobIds]);
70
 
71
  const subscribe = useCallback((ids: string[]) => {
72
+ setJobIds((prev) => {
73
+ if (prev.length === ids.length && prev.every((id, i) => id === ids[i])) {
74
+ return prev;
75
+ }
76
+ return ids;
77
+ });
78
  }, []);
79
 
80
  const unsubscribe = useCallback(() => {
 
90
  onErrorRef.current = callback;
91
  }, []);
92
 
93
+ return useMemo(
94
+ () => ({
95
+ subscribe,
96
+ unsubscribe,
97
+ onUpdate,
98
+ onError,
99
+ get activeJobs() {
100
+ return activeJobsRef.current;
101
+ },
102
+ }),
103
+ [subscribe, unsubscribe, onUpdate, onError],
104
+ );
105
  }
apps/web/src/routeTree.gen.ts CHANGED
@@ -87,7 +87,7 @@ const HistoryRoute = HistoryRouteImport.update({
87
  id: '/history',
88
  path: '/history',
89
  getParentRoute: () => rootRouteImport,
90
- } as any)
91
  const GenerateRoute = GenerateRouteImport.update({
92
  id: '/generate',
93
  path: '/generate',
 
87
  id: '/history',
88
  path: '/history',
89
  getParentRoute: () => rootRouteImport,
90
+ } as any).lazy(() => import('./routes/history.lazy').then((d) => d.Route))
91
  const GenerateRoute = GenerateRouteImport.update({
92
  id: '/generate',
93
  path: '/generate',
apps/web/src/routes/__root.tsx CHANGED
@@ -94,12 +94,12 @@ function RootComponent() {
94
  <GlobalGenerationProgress />
95
  <Toaster richColors />
96
  </ThemeProvider>
97
- {import.meta.env.DEV && (
98
  <>
99
  <TanStackRouterDevtools position="bottom-left" />
100
  <ReactQueryDevtools position="bottom" buttonPosition="bottom-right" />
101
  </>
102
- )}
103
  </>
104
  );
105
  }
 
94
  <GlobalGenerationProgress />
95
  <Toaster richColors />
96
  </ThemeProvider>
97
+ {/* {import.meta.env.DEV && (
98
  <>
99
  <TanStackRouterDevtools position="bottom-left" />
100
  <ReactQueryDevtools position="bottom" buttonPosition="bottom-right" />
101
  </>
102
+ )} */}
103
  </>
104
  );
105
  }
apps/web/src/routes/history.lazy.tsx ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import { createLazyFileRoute } from "@tanstack/react-router";
2
+ import { HistoryComponent } from "@/components/routes/HistoryPage";
3
+
4
+ export const Route = createLazyFileRoute("/history")({
5
+ component: HistoryComponent,
6
+ });
apps/web/src/routes/history.tsx CHANGED
@@ -1,25 +1,8 @@
1
- import { useCallback, useRef, useState, useEffect } from "react";
2
- import { useQuery } from "@tanstack/react-query";
3
- import { createFileRoute, redirect, Link } from "@tanstack/react-router";
4
  import { z } from "zod";
5
  import { authClient } from "@/lib/auth-client";
6
- import { trpc } from "@/utils/trpc";
7
- import { Button } from "@labas/ui/components/button";
8
- import { Card, CardContent } from "@labas/ui/components/card";
9
- import { Input } from "@labas/ui/components/input";
10
- import {
11
- Select,
12
- SelectContent,
13
- SelectGroup,
14
- SelectItem,
15
- SelectTrigger,
16
- SelectValue,
17
- } from "@labas/ui/components/select";
18
- import { MaterialIcon } from "@/components/ui/MaterialIcon";
19
- import { EXAM_TYPES } from "@/lib/exam-constants";
20
 
21
  export const Route = createFileRoute("/history")({
22
- component: HistoryComponent,
23
  validateSearch: z.object({
24
  page: z.coerce.number().optional(),
25
  examTypeId: z.string().optional(),
@@ -33,272 +16,3 @@ export const Route = createFileRoute("/history")({
33
  return { session };
34
  },
35
  });
36
-
37
- function formatDate(dateStr: string | Date | null) {
38
- if (!dateStr) return "-";
39
- const d = new Date(dateStr);
40
- return d.toLocaleDateString("id-ID", {
41
- day: "numeric",
42
- month: "short",
43
- year: "numeric",
44
- hour: "2-digit",
45
- minute: "2-digit",
46
- });
47
- }
48
-
49
- function getStatusBadge(status: string) {
50
- switch (status) {
51
- case "completed":
52
- return (
53
- <span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold">
54
- <MaterialIcon name="check_circle" className="text-xs" />
55
- Selesai
56
- </span>
57
- );
58
- case "abandoned":
59
- return (
60
- <span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-[var(--oat-light)] text-[var(--warm-charcoal)] text-xs font-semibold">
61
- <MaterialIcon name="cancel" className="text-xs" />
62
- Ditinggalkan
63
- </span>
64
- );
65
- default:
66
- return (
67
- <span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] text-xs font-semibold">
68
- <MaterialIcon name="hourglass_top" className="text-xs" />
69
- Berlangsung
70
- </span>
71
- );
72
- }
73
- }
74
-
75
- export function HistoryComponent() {
76
- const search = Route.useSearch();
77
- const navigate = Route.useNavigate();
78
- const page = search.page ?? 1;
79
- const examTypeId = search.examTypeId ?? "";
80
- const searchQuery = search.search ?? "";
81
- const limit = 12;
82
-
83
- const [localSearch, setLocalSearch] = useState(searchQuery);
84
- const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
85
-
86
- useEffect(() => {
87
- setLocalSearch(searchQuery);
88
- }, [searchQuery]);
89
-
90
- useEffect(() => {
91
- if (debounceRef.current) clearTimeout(debounceRef.current);
92
- debounceRef.current = setTimeout(() => {
93
- if (localSearch !== searchQuery) {
94
- navigate({ search: (prev) => ({ ...prev, search: localSearch || undefined, page: 1 }) });
95
- }
96
- }, 300);
97
- return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
98
- }, [localSearch]);
99
-
100
- const setExamTypeFilter = useCallback((value: string) => {
101
- navigate({ search: (prev) => ({ ...prev, examTypeId: value || undefined, page: 1 }) });
102
- }, [navigate]);
103
-
104
- const query = useQuery(
105
- trpc.attempt.myAttempts.queryOptions({
106
- limit,
107
- offset: (page - 1) * limit,
108
- examTypeId: examTypeId || undefined,
109
- search: searchQuery || undefined,
110
- }),
111
- );
112
-
113
- const attempts = query.data?.attempts ?? [];
114
- const total = query.data?.total ?? 0;
115
- const totalPages = Math.ceil(total / limit);
116
-
117
- return (
118
- <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-5xl mx-auto bg-[var(--warm-cream)]">
119
- {/* Header */}
120
- <div className="mb-8">
121
- <div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-4">
122
- <Link to="/" className="hover:text-[var(--clay-black)] transition-colors">
123
- Beranda
124
- </Link>
125
- <MaterialIcon name="chevron_right" className="text-xs" />
126
- <span className="text-[var(--clay-black)] font-medium">Riwayat Latihan</span>
127
- </div>
128
-
129
- <div className="flex items-center justify-between">
130
- <div>
131
- <h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
132
- Riwayat Latihan
133
- </h1>
134
- <p className="text-lg text-[var(--warm-charcoal)] mt-2">
135
- Semua attempt latihan Anda.
136
- </p>
137
- </div>
138
- </div>
139
-
140
- {/* Filters */}
141
- <div className="flex flex-col md:flex-row gap-3 mt-6">
142
- <div className="relative flex-1 max-w-md">
143
- <MaterialIcon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--warm-charcoal)]" />
144
- <Input
145
- placeholder="Cari paket..."
146
- value={localSearch}
147
- onChange={(e) => setLocalSearch(e.target.value)}
148
- aria-label="Cari paket"
149
- className="pl-10 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] h-11"
150
- />
151
- </div>
152
- <Select value={examTypeId} onValueChange={(v) => setExamTypeFilter(v ?? "")}>
153
- <SelectTrigger className="w-36">
154
- <SelectValue placeholder="Semua Ujian" />
155
- </SelectTrigger>
156
- <SelectContent>
157
- <SelectGroup>
158
- <SelectItem value="">Semua Ujian</SelectItem>
159
- {EXAM_TYPES.map((t) => (
160
- <SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>
161
- ))}
162
- </SelectGroup>
163
- </SelectContent>
164
- </Select>
165
- </div>
166
- </div>
167
-
168
- {/* Results */}
169
- {query.isLoading ? (
170
- <div className="space-y-4">
171
- {Array.from({ length: 5 }).map((_, i) => (
172
- <Card key={i} className="h-24 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-xl)]" />
173
- ))}
174
- </div>
175
- ) : attempts.length === 0 ? (
176
- <div className="text-center py-20">
177
- <MaterialIcon name="history" className="text-6xl text-[var(--warm-silver)] mx-auto mb-4" />
178
- <p className="text-lg text-[var(--warm-charcoal)] font-semibold">Belum ada riwayat latihan</p>
179
- <p className="text-sm text-[var(--warm-silver)] mt-1">
180
- Mulai latihan dari paket soal untuk melihat riwayat di sini.
181
- </p>
182
- <Link to="/packages" className="inline-block mt-6">
183
- <Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)]">
184
- <MaterialIcon name="folder" className="mr-2" />
185
- Lihat Paket Soal
186
- </Button>
187
- </Link>
188
- </div>
189
- ) : (
190
- <>
191
- <div className="space-y-4">
192
- {attempts.map((attempt) => {
193
- const pct =
194
- attempt.maxScore && attempt.maxScore > 0 && attempt.totalScore != null
195
- ? Math.round((attempt.totalScore / attempt.maxScore) * 100)
196
- : null;
197
-
198
- return (
199
- <Card
200
- key={attempt.id}
201
- className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] hover:border-[var(--matcha-400)] transition-colors"
202
- >
203
- <CardContent className="p-5">
204
- <div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
205
- <div className="flex-1 min-w-0">
206
- <div className="flex items-center gap-2 mb-2 flex-wrap">
207
- {getStatusBadge(attempt.status)}
208
- {attempt.examTypeName && (
209
- <span className="px-2.5 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold">
210
- {attempt.examTypeName}
211
- </span>
212
- )}
213
- </div>
214
-
215
- <h2 className="font-headline text-lg font-bold text-[var(--clay-black)] truncate">
216
- {attempt.packageTitle ?? "Paket tidak diketahui"}
217
- </h2>
218
-
219
- <div className="flex items-center gap-4 mt-1 text-xs text-[var(--warm-charcoal)]">
220
- <span className="flex items-center gap-1">
221
- <MaterialIcon name="event" className="text-xs" />
222
- {formatDate(attempt.startedAt)}
223
- </span>
224
- {attempt.finishedAt && (
225
- <span className="flex items-center gap-1">
226
- <MaterialIcon name="check" className="text-xs" />
227
- {formatDate(attempt.finishedAt)}
228
- </span>
229
- )}
230
- </div>
231
- </div>
232
-
233
- <div className="flex items-center gap-6 shrink-0">
234
- {pct != null && (
235
- <div className="text-center">
236
- <div className="text-2xl font-headline font-extrabold text-[var(--clay-black)]">
237
- {pct}%
238
- </div>
239
- <div className="text-xs text-[var(--warm-charcoal)]">
240
- {attempt.totalScore}/{attempt.maxScore}
241
- </div>
242
- </div>
243
- )}
244
-
245
- <div className="flex gap-2">
246
- {attempt.status === "completed" && (
247
- <Link to="/attempt/$id" params={{ id: attempt.id }}>
248
- <Button
249
- variant="outline"
250
- className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover text-sm"
251
- >
252
- <MaterialIcon name="visibility" className="mr-1" />
253
- Hasil
254
- </Button>
255
- </Link>
256
- )}
257
- {attempt.packageId && (
258
- <Link to="/package/$id" params={{ id: attempt.packageId }}>
259
- <Button
260
- variant="outline"
261
- className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover text-sm"
262
- >
263
- <MaterialIcon name="folder" className="mr-1" />
264
- Paket
265
- </Button>
266
- </Link>
267
- )}
268
- </div>
269
- </div>
270
- </div>
271
- </CardContent>
272
- </Card>
273
- );
274
- })}
275
- </div>
276
-
277
- {totalPages > 1 && (
278
- <div className="flex items-center justify-center gap-2 mt-10">
279
- <Button
280
- variant="outline"
281
- onClick={() => navigate({ search: (prev) => ({ ...prev, page: Math.max(1, page - 1) }) })}
282
- disabled={page <= 1}
283
- className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
284
- >
285
- <MaterialIcon name="chevron_left" />
286
- </Button>
287
- <span className="text-sm text-[var(--warm-charcoal)] px-4">
288
- Halaman {page} dari {totalPages}
289
- </span>
290
- <Button
291
- variant="outline"
292
- onClick={() => navigate({ search: (prev) => ({ ...prev, page: Math.min(totalPages, page + 1) }) })}
293
- disabled={page >= totalPages}
294
- className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
295
- >
296
- <MaterialIcon name="chevron_right" />
297
- </Button>
298
- </div>
299
- )}
300
- </>
301
- )}
302
- </div>
303
- );
304
- }
 
1
+ import { createFileRoute, redirect } from "@tanstack/react-router";
 
 
2
  import { z } from "zod";
3
  import { authClient } from "@/lib/auth-client";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  export const Route = createFileRoute("/history")({
 
6
  validateSearch: z.object({
7
  page: z.coerce.number().optional(),
8
  examTypeId: z.string().optional(),
 
16
  return { session };
17
  },
18
  });