Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion ui-services/history-ui-service/src/api/historyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,15 @@ const mapToHistoryEntry = (payload: HistoryEntryPayload): HistoryEntry => {
};
};

function getEntryTimestamp(entry: HistoryEntry): number {
return (
entry.sessionEndedAt?.getTime() ??
entry.updatedAt?.getTime() ??
entry.createdAt?.getTime() ??
0
);
}

export async function fetchHistoryEntries(
options: {
userId?: string;
Expand Down Expand Up @@ -139,7 +148,22 @@ export async function fetchHistoryEntries(
}

const items = Array.isArray(data.items) ? data.items : [];
return items.map(mapToHistoryEntry);
const entries = items.map(mapToHistoryEntry);

const latestByQuestionId = new Map<string, HistoryEntry>();
for (const entry of entries) {
if (!entry.questionId) {
continue;
}
const existing = latestByQuestionId.get(entry.questionId);
if (!existing || getEntryTimestamp(entry) > getEntryTimestamp(existing)) {
latestByQuestionId.set(entry.questionId, entry);
}
}

return Array.from(latestByQuestionId.values()).sort(
(a, b) => getEntryTimestamp(b) - getEntryTimestamp(a),
);
}

export { HISTORY_SERVICE_BASE_URL };
101 changes: 101 additions & 0 deletions ui-shell/src/api/historyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,107 @@ export async function fetchHistorySnapshot(
return normalised;
}

export interface HistoryListQuery {
sessionId?: string | null;
userId?: string | null;
questionId?: string | null;
limit?: number;
skip?: number;
}

export interface HistoryListResult {
items: HistorySnapshot[];
total: number;
limit: number;
skip: number;
}

export async function fetchHistorySnapshots(
query: HistoryListQuery = {},
signal?: AbortSignal,
): Promise<HistoryListResult> {
const url = new URL(`${HISTORY_SERVICE_BASE_URL}/history`);
const params = new URLSearchParams();

const appendStringParam = (key: keyof HistoryListQuery) => {
const value = query[key];
if (typeof value === "string" && value.trim().length > 0) {
params.set(key, value.trim());
}
};

appendStringParam("sessionId");
appendStringParam("userId");
appendStringParam("questionId");

if (
typeof query.limit === "number" &&
Number.isFinite(query.limit) &&
query.limit > 0
) {
params.set("limit", String(Math.floor(query.limit)));
}

if (
typeof query.skip === "number" &&
Number.isFinite(query.skip) &&
query.skip >= 0
) {
params.set("skip", String(Math.max(0, Math.floor(query.skip))));
}

const queryString = params.toString();
const endpoint = queryString
? `${url.toString()}?${queryString}`
: url.toString();

const response = await fetch(endpoint, {
signal,
headers: { "Content-Type": "application/json" },
});

if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(
`History request failed (${response.status}): ${
text || response.statusText
}`,
);
}

const data = await response.json();
if (!data || data.success === false) {
throw new Error(data?.error ?? "Failed to fetch history snapshots");
}

const items = Array.isArray(data.items)
? data.items
.map((item: unknown) =>
normaliseHistorySnapshot(item as HistorySnapshotInput),
)
.filter(
(snapshot: HistorySnapshot | null): snapshot is HistorySnapshot =>
snapshot !== null,
)
: [];

return {
items,
total:
typeof data.total === "number" && Number.isFinite(data.total)
? data.total
: items.length,
limit:
typeof data.limit === "number" && Number.isFinite(data.limit)
? data.limit
: (query.limit ?? items.length),
skip:
typeof data.skip === "number" && Number.isFinite(data.skip)
? data.skip
: (query.skip ?? 0),
};
}

export interface UpdateHistorySnapshotPayload {
code?: string;
language?: string;
Expand Down
168 changes: 130 additions & 38 deletions ui-shell/src/pages/history/HistoryDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { Attempt } from "@/types/Attempt";
import type { Question } from "@/types/Question";
import {
fetchHistorySnapshot,
fetchHistorySnapshots,
normaliseHistorySnapshot,
} from "@/api/historyService";

Expand Down Expand Up @@ -42,6 +43,11 @@ const HistoryDetailPage: React.FC = () => {
const [entry, setEntry] = useState<HistorySnapshot | null>(initialEntry);
const [loading, setLoading] = useState(!initialEntry);
const [error, setError] = useState<string | null>(null);
const [attemptSnapshots, setAttemptSnapshots] = useState<HistorySnapshot[]>(
initialEntry ? [initialEntry] : [],
);
const [attemptsLoading, setAttemptsLoading] = useState(false);
const [attemptsError, setAttemptsError] = useState<string | null>(null);

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

const attemptEntries = useMemo<Attempt[]>(() => {
useEffect(() => {
if (!entry) {
return [];
setAttemptSnapshots([]);
return;
}
setAttemptSnapshots([entry]);
setAttemptsError(null);
}, [entry]);

useEffect(() => {
if (!entry?.userId || !entry?.questionId) {
return;
}

const controller = new AbortController();
setAttemptsLoading(true);
fetchHistorySnapshots(
{
userId: entry.userId,
questionId: entry.questionId,
limit: 100,
},
controller.signal,
)
.then((result) => {
if (controller.signal.aborted) {
return;
}

if (!result.items.length) {
setAttemptSnapshots(entry ? [entry] : []);
} else {
const map = new Map<string, HistorySnapshot>();
result.items.forEach((snapshot) => map.set(snapshot.id, snapshot));
if (entry && !map.has(entry.id)) {
map.set(entry.id, entry);
}
setAttemptSnapshots(Array.from(map.values()));
}
setAttemptsError(null);
})
.catch((err) => {
if (!controller.signal.aborted) {
setAttemptsError(
err instanceof Error ? err.message : "Failed to load attempts",
);
setAttemptSnapshots(entry ? [entry] : []);
}
})
.finally(() => {
if (!controller.signal.aborted) {
setAttemptsLoading(false);
}
});

const attemptTimestamp =
entry.sessionEndedAt ?? entry.updatedAt ?? entry.createdAt ?? new Date();

const timeTakenLabel = formatDuration(entry);

const baseQuestion: Question = {
title: entry.questionTitle || "Untitled Question",
body: "",
topics: entry.topics ?? [],
hints: [],
answer: "",
difficulty: entry.difficulty ?? "Unknown",
timeLimit:
typeof entry.timeLimit === "number"
? `${entry.timeLimit} min`
: (entry.timeLimit ?? "—"),
};

const partners = entry.participants.filter(
(participant) => participant !== entry.userId,
);
const targets = partners.length > 0 ? partners : [entry.userId];

return targets.map((partner, index) => ({
id: `${entry.id}-${partner}-${index}`,
question: baseQuestion,
date: attemptTimestamp,
partner,
timeTaken: timeTakenLabel,
}));
return () => controller.abort();
}, [entry]);

const attemptEntries = useMemo<Attempt[]>(() => {
if (!attemptSnapshots.length) {
return [];
}

const sortedSnapshots = [...attemptSnapshots].sort((a, b) => {
const timeA =
(a.sessionEndedAt ?? a.updatedAt ?? a.createdAt)?.getTime() ?? 0;
const timeB =
(b.sessionEndedAt ?? b.updatedAt ?? b.createdAt)?.getTime() ?? 0;
return timeB - timeA;
});

return sortedSnapshots.map((snapshot) => {
const attemptTimestamp =
snapshot.sessionEndedAt ??
snapshot.updatedAt ??
snapshot.createdAt ??
new Date();

const timeTakenLabel = formatDuration(snapshot);

const baseQuestion: Question = {
title: snapshot.questionTitle || "Untitled Question",
body: "",
topics: snapshot.topics ?? [],
hints: [],
answer: "",
difficulty: snapshot.difficulty ?? "Unknown",
timeLimit:
typeof snapshot.timeLimit === "number"
? `${snapshot.timeLimit} min`
: snapshot.timeLimit !== undefined
? String(snapshot.timeLimit)
: "—",
};

const partner =
snapshot.participants.find(
(participant) => participant !== snapshot.userId,
) ?? snapshot.userId;

return {
id: snapshot.id,
question: baseQuestion,
date: attemptTimestamp,
partner,
timeTaken: timeTakenLabel,
};
});
}, [attemptSnapshots]);

const handleAttemptSelect = (attempt: Attempt) => {
if (!entry) {
if (!attempt?.id) {
return;
}
navigate(`/history/${entry.id}/attempt`, {

const snapshot = attemptSnapshots.find((item) => item.id === attempt.id);
if (!snapshot) {
return;
}

const attemptPartner =
attempt.partner ??
snapshot.participants.find(
(participant) => participant !== snapshot.userId,
) ??
snapshot.userId;

navigate(`/history/${snapshot.id}/attempt`, {
state: {
entry,
attemptPartner: attempt.partner ?? entry.userId,
entry: snapshot,
attemptPartner,
},
});
};
Expand Down Expand Up @@ -157,8 +249,8 @@ const HistoryDetailPage: React.FC = () => {
errorMessage="Attempt history unavailable."
remoteProps={{
items: attemptEntries,
isLoading: loading,
error,
isLoading: loading || attemptsLoading,
error: error ?? attemptsError,
emptyMessage: "No attempt history recorded.",
loadingMessage: "Loading attempt history…",
onSelect: handleAttemptSelect,
Expand Down