Skip to content

Commit 108c4af

Browse files
committed
improved data
1 parent 2bbb898 commit 108c4af

9 files changed

Lines changed: 378 additions & 69 deletions

File tree

apps/learner-ux/src/App.js

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -154,11 +154,15 @@ function UserCredentialsTimelinePage() {
154154
return (_jsx("div", { className: "space-y-4", children: _jsxs(Card, { children: [_jsxs("div", { className: "mb-3 flex items-center justify-between gap-2", children: [_jsxs("div", { children: [_jsx("h2", { className: "font-semibold", children: "Credential Timeline" }), _jsxs("p", { className: "text-sm text-slate-600", children: ["User: ", userId || "unknown"] })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Link, { to: "/users", className: "text-sm text-blue-700 underline", children: "Back to users" }), _jsx(Button, { onClick: loadTimeline, disabled: loading, children: loading ? "Loading..." : "Refresh" })] })] }), error && _jsx("p", { className: "mb-3 text-sm text-red-700", children: error }), !loading && !error && payload?.caliperCredentials.length === 0 && (_jsx("p", { className: "text-sm text-slate-600", children: "No credentials found for this user." })), payload?.caliperCredentials.length ? (_jsxs("div", { className: "grid gap-4 lg:grid-cols-[1fr_1.4fr]", children: [_jsxs("div", { className: "space-y-2", children: [_jsx("h3", { className: "font-semibold", children: "Snapshots" }), _jsx("p", { className: "text-xs text-slate-600", children: "Select zero, one, or multiple snapshots." }), payload.caliperCredentials.map((credential) => ((() => {
155155
const selected = selectedCredentialIds.includes(credential.credentialId);
156156
const color = credentialColorsById[credential.credentialId];
157+
const detail = credentialDetailsById[credential.credentialId];
158+
const story = detail
159+
? buildCredentialStory(detail)
160+
: buildPlaceholderStory(credential);
157161
return (_jsxs("div", { className: "flex cursor-pointer items-start gap-3 rounded border border-slate-200 bg-white p-3 hover:bg-slate-50", style: selected && color ? {
158162
borderLeftWidth: 6,
159163
borderLeftColor: color.border,
160164
backgroundColor: color.bg
161-
} : undefined, children: [_jsx("input", { type: "checkbox", className: "mt-1", "aria-label": `Select snapshot ${credential.credentialId}`, checked: selected, onChange: (event) => toggleCredentialSelection(credential.credentialId, event.target.checked) }), _jsxs("div", { children: [_jsxs("p", { className: "font-medium", children: [credential.credentialId, selected && color && (_jsx("span", { className: "ml-2 inline-block h-2.5 w-2.5 rounded-full align-middle", style: { backgroundColor: color.chip }, "aria-hidden": true }))] }), _jsxs("p", { className: "text-xs text-slate-600", children: ["Issued: ", formatTimestamp(credential.issuedAt)] }), _jsxs("p", { className: "text-xs text-slate-600", children: ["Issuer: ", credential.issuerId] }), _jsxs("p", { className: "text-xs text-slate-600", children: ["Events: ", credential.eventCount] })] })] }, credential.credentialId));
165+
} : undefined, children: [_jsx("input", { type: "checkbox", className: "mt-1", "aria-label": `Select snapshot ${credential.credentialId}`, checked: selected, onChange: (event) => toggleCredentialSelection(credential.credentialId, event.target.checked) }), _jsxs("div", { children: [_jsxs("p", { className: "font-medium", children: [credential.credentialId, selected && color && (_jsx("span", { className: "ml-2 inline-block h-2.5 w-2.5 rounded-full align-middle", style: { backgroundColor: color.chip }, "aria-hidden": true }))] }), _jsxs("div", { className: "mb-1 mt-1 flex items-center gap-2", children: [_jsx("span", { className: "rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-700", children: story.tag }), _jsx("span", { className: "text-xs text-slate-700", children: story.title })] }), _jsx("p", { className: "text-xs text-slate-600", children: story.subtitle }), _jsxs("p", { className: "text-xs text-slate-600", children: ["Issued: ", formatTimestamp(credential.issuedAt)] }), _jsxs("p", { className: "text-xs text-slate-600", children: ["Issuer: ", credential.issuerId] }), _jsxs("p", { className: "text-xs text-slate-600", children: ["Events: ", credential.eventCount] })] })] }, credential.credentialId));
162166
})()))] }), _jsxs("div", { className: "space-y-2", children: [_jsx("h3", { className: "font-semibold", children: "Event Timeline" }), _jsxs("p", { className: "text-xs text-slate-600", children: ["Selected snapshots: ", selectedCredentialIds.length, " | Events: ", mergedEvents.length] }), detailsError && _jsx("p", { className: "text-sm text-red-700", children: detailsError }), detailsLoading && _jsx("p", { className: "text-sm text-slate-600", children: "Loading selected snapshot events..." }), selectedCredentialIds.length === 0 && (_jsx("p", { className: "rounded border border-dashed border-slate-300 p-3 text-sm text-slate-600", children: "Select one or more snapshots to render the timeline." })), selectedCredentialIds.length > 0 && !detailsLoading && mergedEvents.length === 0 && (_jsx("p", { className: "rounded border border-dashed border-slate-300 p-3 text-sm text-slate-600", children: "No events were found in the selected snapshots." })), groupedEvents.length > 0 && (_jsx("div", { className: "space-y-0", children: groupedEvents.map((group, index) => (_jsx(TimelineSecondGroup, { group: group, isLast: index === groupedEvents.length - 1, credentialColorsById: credentialColorsById }, group.minuteKey))) }))] })] })) : null] }) }));
163167
}
164168
const CREDENTIAL_COLORS = [
@@ -181,12 +185,7 @@ function TimelineSecondGroup(props) {
181185
}) })] }));
182186
}
183187
function extractCredentialEvents(detail) {
184-
const payload = detail.rawPayload;
185-
if (!payload || typeof payload !== "object") {
186-
return [];
187-
}
188-
const credentialSubject = asRecord(payload.credentialSubject);
189-
const events = Array.isArray(credentialSubject?.events) ? credentialSubject.events : [];
188+
const events = readCredentialSubjectEvents(detail);
190189
return events
191190
.map((item, index) => {
192191
const event = asRecord(item);
@@ -213,6 +212,64 @@ function extractCredentialEvents(detail) {
213212
})
214213
.filter((event) => Boolean(event));
215214
}
215+
function buildCredentialStory(detail) {
216+
const events = readCredentialSubjectEvents(detail)
217+
.map((item) => asRecord(item))
218+
.filter((event) => Boolean(event));
219+
if (events.length === 0) {
220+
return {
221+
tag: "Learning Story",
222+
title: "No event detail",
223+
subtitle: "This snapshot does not include event-level activity."
224+
};
225+
}
226+
const typeSet = new Set();
227+
const actionSet = new Set();
228+
for (const event of events) {
229+
if (typeof event.type === "string") {
230+
typeSet.add(event.type);
231+
}
232+
if (typeof event.action === "string") {
233+
actionSet.add(event.action);
234+
}
235+
}
236+
const types = [...typeSet];
237+
const actions = [...actionSet];
238+
const hasType = (value) => types.some((type) => type.toLowerCase().includes(value));
239+
let tag = "Learning Story";
240+
if (hasType("assessment"))
241+
tag = "Assessment Story";
242+
else if (hasType("assignable"))
243+
tag = "Assignment Story";
244+
else if (hasType("session"))
245+
tag = "Session Story";
246+
else if (hasType("navigation") || hasType("view"))
247+
tag = "Engagement Story";
248+
const primaryType = types[0] ? toDisplayEventType(types[0]) : "activity";
249+
const actionSummary = actions.length > 0
250+
? actions.slice(0, 3).map((action) => action.toLowerCase()).join(", ")
251+
: "captured activity";
252+
return {
253+
tag,
254+
title: `${events.length} events in ${primaryType}`,
255+
subtitle: `Story includes ${actionSummary}${actions.length > 3 ? ", and more" : ""}.`
256+
};
257+
}
258+
function buildPlaceholderStory(credential) {
259+
return {
260+
tag: "Learning Story",
261+
title: `${credential.eventCount} events captured`,
262+
subtitle: "Select this snapshot to load detailed activity story."
263+
};
264+
}
265+
function readCredentialSubjectEvents(detail) {
266+
const payload = detail.rawPayload;
267+
if (!payload || typeof payload !== "object") {
268+
return [];
269+
}
270+
const credentialSubject = asRecord(payload.credentialSubject);
271+
return Array.isArray(credentialSubject?.events) ? credentialSubject.events : [];
272+
}
216273
function asRecord(value) {
217274
if (!value || typeof value !== "object") {
218275
return undefined;

apps/learner-ux/src/App.tsx

Lines changed: 108 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { Card } from "./components/ui/card";
1414
import { Input } from "./components/ui/input";
1515
import type {
1616
LearnerContextResponse,
17+
UserCaliperCredential,
1718
UserCaliperCredentialDetail,
1819
UserCaliperCredentialsResponse,
1920
UserListItem,
@@ -106,7 +107,10 @@ function UsersPage() {
106107
className="w-full rounded border border-slate-200 bg-white p-3 text-left hover:bg-slate-50"
107108
onClick={() => navigate(`/users/${encodeURIComponent(user.userId)}/credentials`)}
108109
>
109-
<p className="font-medium">{user.userId}</p>
110+
<p className="font-medium">{user.userDisplayName || user.userId}</p>
111+
{user.userDisplayName && user.userDisplayName !== user.userId && (
112+
<p className="text-xs text-slate-500">ID: {user.userId}</p>
113+
)}
110114
<p className="text-xs text-slate-600">
111115
Snapshots: {user.snapshotCount} | Credentials: {user.credentialCount} | Latest: {formatTimestamp(user.latestSnapshotAt)}
112116
</p>
@@ -242,7 +246,10 @@ function UserCredentialsTimelinePage() {
242246
<div className="mb-3 flex items-center justify-between gap-2">
243247
<div>
244248
<h2 className="font-semibold">Credential Timeline</h2>
245-
<p className="text-sm text-slate-600">User: {userId || "unknown"}</p>
249+
<p className="text-sm text-slate-600">
250+
User: {payload?.userDisplayName || userId || "unknown"}
251+
{payload?.userDisplayName && payload.userDisplayName !== userId ? ` (${userId})` : ""}
252+
</p>
246253
</div>
247254
<div className="flex items-center gap-2">
248255
<Link to="/users" className="text-sm text-blue-700 underline">Back to users</Link>
@@ -266,6 +273,10 @@ function UserCredentialsTimelinePage() {
266273
(() => {
267274
const selected = selectedCredentialIds.includes(credential.credentialId);
268275
const color = credentialColorsById[credential.credentialId];
276+
const detail = credentialDetailsById[credential.credentialId];
277+
const story = detail
278+
? buildCredentialStory(detail)
279+
: buildPlaceholderStory(credential);
269280
return (
270281
<div
271282
key={credential.credentialId}
@@ -294,6 +305,17 @@ function UserCredentialsTimelinePage() {
294305
/>
295306
)}
296307
</p>
308+
<div className="mb-1 mt-1 flex items-center gap-2">
309+
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-700">
310+
{story.tag}
311+
</span>
312+
<span className="text-xs text-slate-700">
313+
{story.title}
314+
</span>
315+
</div>
316+
<p className="text-xs text-slate-600">
317+
{story.subtitle}
318+
</p>
297319
<p className="text-xs text-slate-600">Issued: {formatTimestamp(credential.issuedAt)}</p>
298320
<p className="text-xs text-slate-600">Issuer: {credential.issuerId}</p>
299321
<p className="text-xs text-slate-600">Events: {credential.eventCount}</p>
@@ -353,6 +375,17 @@ type CredentialTimelineEvent = {
353375
objectRef: string;
354376
};
355377

378+
type NormalizedRef = {
379+
id: string;
380+
name?: string;
381+
};
382+
383+
type CredentialStorySummary = {
384+
tag: string;
385+
title: string;
386+
subtitle: string;
387+
};
388+
356389
type CredentialColor = {
357390
border: string;
358391
bg: string;
@@ -412,12 +445,7 @@ function TimelineSecondGroup(props: Readonly<{
412445
}
413446

414447
function extractCredentialEvents(detail: UserCaliperCredentialDetail): CredentialTimelineEvent[] {
415-
const payload = detail.rawPayload;
416-
if (!payload || typeof payload !== "object") {
417-
return [];
418-
}
419-
const credentialSubject = asRecord(payload.credentialSubject);
420-
const events = Array.isArray(credentialSubject?.events) ? credentialSubject.events : [];
448+
const events = readCredentialSubjectEvents(detail);
421449
return events
422450
.map((item, index) => {
423451
const event = asRecord(item);
@@ -437,30 +465,91 @@ function extractCredentialEvents(detail: UserCaliperCredentialDetail): Credentia
437465
eventType,
438466
eventTime,
439467
actionLabel,
440-
actorLabel: toDisplayRef(actorRef),
441-
objectLabel: toDisplayRef(objectRef),
442-
objectRef
468+
actorLabel: actorRef.name ?? toDisplayRef(actorRef.id),
469+
objectLabel: objectRef.name ?? toDisplayRef(objectRef.id),
470+
objectRef: objectRef.id
443471
} satisfies CredentialTimelineEvent;
444472
})
445473
.filter((event): event is CredentialTimelineEvent => Boolean(event));
446474
}
447475

476+
function buildCredentialStory(detail: UserCaliperCredentialDetail): CredentialStorySummary {
477+
const events = readCredentialSubjectEvents(detail)
478+
.map((item) => asRecord(item))
479+
.filter((event): event is Record<string, unknown> => Boolean(event));
480+
if (events.length === 0) {
481+
return {
482+
tag: "Learning Story",
483+
title: "No event detail",
484+
subtitle: "This snapshot does not include event-level activity."
485+
};
486+
}
487+
const typeSet = new Set<string>();
488+
const actionSet = new Set<string>();
489+
for (const event of events) {
490+
if (typeof event.type === "string") {
491+
typeSet.add(event.type);
492+
}
493+
if (typeof event.action === "string") {
494+
actionSet.add(event.action);
495+
}
496+
}
497+
const types = [...typeSet];
498+
const actions = [...actionSet];
499+
const hasType = (value: string): boolean => types.some((type) => type.toLowerCase().includes(value));
500+
let tag = "Learning Story";
501+
if (hasType("assessment")) tag = "Assessment Story";
502+
else if (hasType("assignable")) tag = "Assignment Story";
503+
else if (hasType("session")) tag = "Session Story";
504+
else if (hasType("navigation") || hasType("view")) tag = "Engagement Story";
505+
506+
const primaryType = types[0] ? toDisplayEventType(types[0]) : "activity";
507+
const actionSummary = actions.length > 0
508+
? actions.slice(0, 3).map((action) => action.toLowerCase()).join(", ")
509+
: "captured activity";
510+
return {
511+
tag,
512+
title: `${events.length} events in ${primaryType}`,
513+
subtitle: `Story includes ${actionSummary}${actions.length > 3 ? ", and more" : ""}.`
514+
};
515+
}
516+
517+
function buildPlaceholderStory(credential: UserCaliperCredential): CredentialStorySummary {
518+
return {
519+
tag: "Learning Story",
520+
title: `${credential.eventCount} events captured`,
521+
subtitle: "Select this snapshot to load detailed activity story."
522+
};
523+
}
524+
525+
function readCredentialSubjectEvents(detail: UserCaliperCredentialDetail): unknown[] {
526+
const payload = detail.rawPayload;
527+
if (!payload || typeof payload !== "object") {
528+
return [];
529+
}
530+
const credentialSubject = asRecord(payload.credentialSubject);
531+
return Array.isArray(credentialSubject?.events) ? credentialSubject.events : [];
532+
}
533+
448534
function asRecord(value: unknown): Record<string, unknown> | undefined {
449535
if (!value || typeof value !== "object") {
450536
return undefined;
451537
}
452538
return value as Record<string, unknown>;
453539
}
454540

455-
function normalizeRef(value: unknown): string {
541+
function normalizeRef(value: unknown): NormalizedRef {
456542
if (typeof value === "string") {
457-
return value;
543+
return { id: value };
458544
}
459545
const record = asRecord(value);
460546
if (record && typeof record.id === "string") {
461-
return record.id;
547+
return {
548+
id: record.id,
549+
...(typeof record.name === "string" && record.name.trim().length > 0 ? { name: record.name } : {})
550+
};
462551
}
463-
return "unknown";
552+
return { id: "unknown" };
464553
}
465554

466555
function toDisplayRef(value: string): string {
@@ -669,7 +758,10 @@ function LearnerPage() {
669758
<div className="flex items-center justify-between">
670759
<div>
671760
<h2 className="font-semibold">Learner Context</h2>
672-
<p className="text-sm text-slate-600">Learner ID: {learnerId}</p>
761+
<p className="text-sm text-slate-600">
762+
Learner: {context?.learnerDisplayName || learnerId}
763+
{context?.learnerDisplayName && context.learnerDisplayName !== learnerId ? ` (${learnerId})` : ""}
764+
</p>
673765
</div>
674766
<div className="flex items-center gap-2">
675767
<label className="text-xs text-slate-600" htmlFor="stackBy">Stack by</label>

apps/learner-ux/src/api.js

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,9 @@ export async function fetchUserCaliperCredentials(userId) {
5050
return await response.json();
5151
}
5252
export async function fetchCaliperCredentialById(credentialId) {
53-
const response = await fetch(buildUrl(`/calipercredentials/${encodeURIComponent(credentialId)}`), {
54-
headers: await authHeaders()
53+
const headers = await authHeaders();
54+
const response = await fetchWithRetry(buildUrl(`/calipercredentials/${encodeURIComponent(credentialId)}`), {
55+
headers
5556
});
5657
if (!response.ok)
5758
throw new Error("Failed to load credential details");
@@ -124,3 +125,22 @@ async function resolveToken() {
124125
};
125126
return tokenCache.accessToken;
126127
}
128+
async function fetchWithRetry(input, init) {
129+
const maxAttempts = 3;
130+
let response;
131+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
132+
response = await fetch(input, init);
133+
if (response.ok) {
134+
return response;
135+
}
136+
const retryable = response.status === 429 || response.status === 503 || response.status === 504;
137+
if (!retryable || attempt === maxAttempts) {
138+
return response;
139+
}
140+
const delayMs = attempt * 250;
141+
await new Promise((resolve) => {
142+
setTimeout(resolve, delayMs);
143+
});
144+
}
145+
return response;
146+
}

apps/learner-ux/src/api.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,13 @@ export async function fetchUserCaliperCredentials(userId: string): Promise<UserC
6868
}
6969

7070
export async function fetchCaliperCredentialById(credentialId: string): Promise<UserCaliperCredentialDetail> {
71-
const response = await fetch(buildUrl(`/calipercredentials/${encodeURIComponent(credentialId)}`), {
72-
headers: await authHeaders()
73-
});
71+
const headers = await authHeaders();
72+
const response = await fetchWithRetry(
73+
buildUrl(`/calipercredentials/${encodeURIComponent(credentialId)}`),
74+
{
75+
headers
76+
}
77+
);
7478
if (!response.ok) throw new Error("Failed to load credential details");
7579
return await response.json() as UserCaliperCredentialDetail;
7680
}
@@ -145,3 +149,26 @@ async function resolveToken(): Promise<string | undefined> {
145149
};
146150
return tokenCache.accessToken;
147151
}
152+
153+
async function fetchWithRetry(
154+
input: RequestInfo | URL,
155+
init?: RequestInit
156+
): Promise<Response> {
157+
const maxAttempts = 3;
158+
let response: Response | undefined;
159+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
160+
response = await fetch(input, init);
161+
if (response.ok) {
162+
return response;
163+
}
164+
const retryable = response.status === 429 || response.status === 503 || response.status === 504;
165+
if (!retryable || attempt === maxAttempts) {
166+
return response;
167+
}
168+
const delayMs = attempt * 250;
169+
await new Promise((resolve) => {
170+
setTimeout(resolve, delayMs);
171+
});
172+
}
173+
return response as Response;
174+
}

0 commit comments

Comments
 (0)