Skip to content

Commit abfd665

Browse files
committed
Merge branch 'master' into chat-user-doc-update
2 parents aa2d0a7 + d694309 commit abfd665

File tree

3 files changed

+256
-39
lines changed

3 files changed

+256
-39
lines changed

ui-services/history-ui-service/src/api/historyService.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,15 @@ const mapToHistoryEntry = (payload: HistoryEntryPayload): HistoryEntry => {
102102
};
103103
};
104104

105+
function getEntryTimestamp(entry: HistoryEntry): number {
106+
return (
107+
entry.sessionEndedAt?.getTime() ??
108+
entry.updatedAt?.getTime() ??
109+
entry.createdAt?.getTime() ??
110+
0
111+
);
112+
}
113+
105114
export async function fetchHistoryEntries(
106115
options: {
107116
userId?: string;
@@ -139,7 +148,22 @@ export async function fetchHistoryEntries(
139148
}
140149

141150
const items = Array.isArray(data.items) ? data.items : [];
142-
return items.map(mapToHistoryEntry);
151+
const entries = items.map(mapToHistoryEntry);
152+
153+
const latestByQuestionId = new Map<string, HistoryEntry>();
154+
for (const entry of entries) {
155+
if (!entry.questionId) {
156+
continue;
157+
}
158+
const existing = latestByQuestionId.get(entry.questionId);
159+
if (!existing || getEntryTimestamp(entry) > getEntryTimestamp(existing)) {
160+
latestByQuestionId.set(entry.questionId, entry);
161+
}
162+
}
163+
164+
return Array.from(latestByQuestionId.values()).sort(
165+
(a, b) => getEntryTimestamp(b) - getEntryTimestamp(a),
166+
);
143167
}
144168

145169
export { HISTORY_SERVICE_BASE_URL };

ui-shell/src/api/historyService.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,107 @@ export async function fetchHistorySnapshot(
154154
return normalised;
155155
}
156156

157+
export interface HistoryListQuery {
158+
sessionId?: string | null;
159+
userId?: string | null;
160+
questionId?: string | null;
161+
limit?: number;
162+
skip?: number;
163+
}
164+
165+
export interface HistoryListResult {
166+
items: HistorySnapshot[];
167+
total: number;
168+
limit: number;
169+
skip: number;
170+
}
171+
172+
export async function fetchHistorySnapshots(
173+
query: HistoryListQuery = {},
174+
signal?: AbortSignal,
175+
): Promise<HistoryListResult> {
176+
const url = new URL(`${HISTORY_SERVICE_BASE_URL}/history`);
177+
const params = new URLSearchParams();
178+
179+
const appendStringParam = (key: keyof HistoryListQuery) => {
180+
const value = query[key];
181+
if (typeof value === "string" && value.trim().length > 0) {
182+
params.set(key, value.trim());
183+
}
184+
};
185+
186+
appendStringParam("sessionId");
187+
appendStringParam("userId");
188+
appendStringParam("questionId");
189+
190+
if (
191+
typeof query.limit === "number" &&
192+
Number.isFinite(query.limit) &&
193+
query.limit > 0
194+
) {
195+
params.set("limit", String(Math.floor(query.limit)));
196+
}
197+
198+
if (
199+
typeof query.skip === "number" &&
200+
Number.isFinite(query.skip) &&
201+
query.skip >= 0
202+
) {
203+
params.set("skip", String(Math.max(0, Math.floor(query.skip))));
204+
}
205+
206+
const queryString = params.toString();
207+
const endpoint = queryString
208+
? `${url.toString()}?${queryString}`
209+
: url.toString();
210+
211+
const response = await fetch(endpoint, {
212+
signal,
213+
headers: { "Content-Type": "application/json" },
214+
});
215+
216+
if (!response.ok) {
217+
const text = await response.text().catch(() => "");
218+
throw new Error(
219+
`History request failed (${response.status}): ${
220+
text || response.statusText
221+
}`,
222+
);
223+
}
224+
225+
const data = await response.json();
226+
if (!data || data.success === false) {
227+
throw new Error(data?.error ?? "Failed to fetch history snapshots");
228+
}
229+
230+
const items = Array.isArray(data.items)
231+
? data.items
232+
.map((item: unknown) =>
233+
normaliseHistorySnapshot(item as HistorySnapshotInput),
234+
)
235+
.filter(
236+
(snapshot: HistorySnapshot | null): snapshot is HistorySnapshot =>
237+
snapshot !== null,
238+
)
239+
: [];
240+
241+
return {
242+
items,
243+
total:
244+
typeof data.total === "number" && Number.isFinite(data.total)
245+
? data.total
246+
: items.length,
247+
limit:
248+
typeof data.limit === "number" && Number.isFinite(data.limit)
249+
? data.limit
250+
: (query.limit ?? items.length),
251+
skip:
252+
typeof data.skip === "number" && Number.isFinite(data.skip)
253+
? data.skip
254+
: (query.skip ?? 0),
255+
};
256+
}
257+
157258
export interface UpdateHistorySnapshotPayload {
158259
code?: string;
159260
language?: string;

ui-shell/src/pages/history/HistoryDetailPage.tsx

Lines changed: 130 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { Attempt } from "@/types/Attempt";
77
import type { Question } from "@/types/Question";
88
import {
99
fetchHistorySnapshot,
10+
fetchHistorySnapshots,
1011
normaliseHistorySnapshot,
1112
} from "@/api/historyService";
1213

@@ -42,6 +43,11 @@ const HistoryDetailPage: React.FC = () => {
4243
const [entry, setEntry] = useState<HistorySnapshot | null>(initialEntry);
4344
const [loading, setLoading] = useState(!initialEntry);
4445
const [error, setError] = useState<string | null>(null);
46+
const [attemptSnapshots, setAttemptSnapshots] = useState<HistorySnapshot[]>(
47+
initialEntry ? [initialEntry] : [],
48+
);
49+
const [attemptsLoading, setAttemptsLoading] = useState(false);
50+
const [attemptsError, setAttemptsError] = useState<string | null>(null);
4551

4652
useEffect(() => {
4753
if (entry || !historyId) {
@@ -71,51 +77,137 @@ const HistoryDetailPage: React.FC = () => {
7177
return () => controller.abort();
7278
}, [entry, historyId]);
7379

74-
const attemptEntries = useMemo<Attempt[]>(() => {
80+
useEffect(() => {
7581
if (!entry) {
76-
return [];
82+
setAttemptSnapshots([]);
83+
return;
7784
}
85+
setAttemptSnapshots([entry]);
86+
setAttemptsError(null);
87+
}, [entry]);
88+
89+
useEffect(() => {
90+
if (!entry?.userId || !entry?.questionId) {
91+
return;
92+
}
93+
94+
const controller = new AbortController();
95+
setAttemptsLoading(true);
96+
fetchHistorySnapshots(
97+
{
98+
userId: entry.userId,
99+
questionId: entry.questionId,
100+
limit: 100,
101+
},
102+
controller.signal,
103+
)
104+
.then((result) => {
105+
if (controller.signal.aborted) {
106+
return;
107+
}
108+
109+
if (!result.items.length) {
110+
setAttemptSnapshots(entry ? [entry] : []);
111+
} else {
112+
const map = new Map<string, HistorySnapshot>();
113+
result.items.forEach((snapshot) => map.set(snapshot.id, snapshot));
114+
if (entry && !map.has(entry.id)) {
115+
map.set(entry.id, entry);
116+
}
117+
setAttemptSnapshots(Array.from(map.values()));
118+
}
119+
setAttemptsError(null);
120+
})
121+
.catch((err) => {
122+
if (!controller.signal.aborted) {
123+
setAttemptsError(
124+
err instanceof Error ? err.message : "Failed to load attempts",
125+
);
126+
setAttemptSnapshots(entry ? [entry] : []);
127+
}
128+
})
129+
.finally(() => {
130+
if (!controller.signal.aborted) {
131+
setAttemptsLoading(false);
132+
}
133+
});
78134

79-
const attemptTimestamp =
80-
entry.sessionEndedAt ?? entry.updatedAt ?? entry.createdAt ?? new Date();
81-
82-
const timeTakenLabel = formatDuration(entry);
83-
84-
const baseQuestion: Question = {
85-
title: entry.questionTitle || "Untitled Question",
86-
body: "",
87-
topics: entry.topics ?? [],
88-
hints: [],
89-
answer: "",
90-
difficulty: entry.difficulty ?? "Unknown",
91-
timeLimit:
92-
typeof entry.timeLimit === "number"
93-
? `${entry.timeLimit} min`
94-
: (entry.timeLimit ?? "—"),
95-
};
96-
97-
const partners = entry.participants.filter(
98-
(participant) => participant !== entry.userId,
99-
);
100-
const targets = partners.length > 0 ? partners : [entry.userId];
101-
102-
return targets.map((partner, index) => ({
103-
id: `${entry.id}-${partner}-${index}`,
104-
question: baseQuestion,
105-
date: attemptTimestamp,
106-
partner,
107-
timeTaken: timeTakenLabel,
108-
}));
135+
return () => controller.abort();
109136
}, [entry]);
110137

138+
const attemptEntries = useMemo<Attempt[]>(() => {
139+
if (!attemptSnapshots.length) {
140+
return [];
141+
}
142+
143+
const sortedSnapshots = [...attemptSnapshots].sort((a, b) => {
144+
const timeA =
145+
(a.sessionEndedAt ?? a.updatedAt ?? a.createdAt)?.getTime() ?? 0;
146+
const timeB =
147+
(b.sessionEndedAt ?? b.updatedAt ?? b.createdAt)?.getTime() ?? 0;
148+
return timeB - timeA;
149+
});
150+
151+
return sortedSnapshots.map((snapshot) => {
152+
const attemptTimestamp =
153+
snapshot.sessionEndedAt ??
154+
snapshot.updatedAt ??
155+
snapshot.createdAt ??
156+
new Date();
157+
158+
const timeTakenLabel = formatDuration(snapshot);
159+
160+
const baseQuestion: Question = {
161+
title: snapshot.questionTitle || "Untitled Question",
162+
body: "",
163+
topics: snapshot.topics ?? [],
164+
hints: [],
165+
answer: "",
166+
difficulty: snapshot.difficulty ?? "Unknown",
167+
timeLimit:
168+
typeof snapshot.timeLimit === "number"
169+
? `${snapshot.timeLimit} min`
170+
: snapshot.timeLimit !== undefined
171+
? String(snapshot.timeLimit)
172+
: "—",
173+
};
174+
175+
const partner =
176+
snapshot.participants.find(
177+
(participant) => participant !== snapshot.userId,
178+
) ?? snapshot.userId;
179+
180+
return {
181+
id: snapshot.id,
182+
question: baseQuestion,
183+
date: attemptTimestamp,
184+
partner,
185+
timeTaken: timeTakenLabel,
186+
};
187+
});
188+
}, [attemptSnapshots]);
189+
111190
const handleAttemptSelect = (attempt: Attempt) => {
112-
if (!entry) {
191+
if (!attempt?.id) {
113192
return;
114193
}
115-
navigate(`/history/${entry.id}/attempt`, {
194+
195+
const snapshot = attemptSnapshots.find((item) => item.id === attempt.id);
196+
if (!snapshot) {
197+
return;
198+
}
199+
200+
const attemptPartner =
201+
attempt.partner ??
202+
snapshot.participants.find(
203+
(participant) => participant !== snapshot.userId,
204+
) ??
205+
snapshot.userId;
206+
207+
navigate(`/history/${snapshot.id}/attempt`, {
116208
state: {
117-
entry,
118-
attemptPartner: attempt.partner ?? entry.userId,
209+
entry: snapshot,
210+
attemptPartner,
119211
},
120212
});
121213
};
@@ -157,8 +249,8 @@ const HistoryDetailPage: React.FC = () => {
157249
errorMessage="Attempt history unavailable."
158250
remoteProps={{
159251
items: attemptEntries,
160-
isLoading: loading,
161-
error,
252+
isLoading: loading || attemptsLoading,
253+
error: error ?? attemptsError,
162254
emptyMessage: "No attempt history recorded.",
163255
loadingMessage: "Loading attempt history…",
164256
onSelect: handleAttemptSelect,

0 commit comments

Comments
 (0)