Skip to content

Commit 2bbb898

Browse files
committed
improve the timeline design
1 parent 08bfa4c commit 2bbb898

2 files changed

Lines changed: 244 additions & 39 deletions

File tree

apps/learner-ux/src/App.js

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,11 +143,42 @@ function UserCredentialsTimelinePage() {
143143
return b.eventTime.localeCompare(a.eventTime);
144144
});
145145
}, [selectedCredentialIds, credentialDetailsById]);
146-
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) => (_jsxs("div", { className: "flex cursor-pointer items-start gap-3 rounded border border-slate-200 bg-white p-3 hover:bg-slate-50", children: [_jsx("input", { type: "checkbox", className: "mt-1", "aria-label": `Select snapshot ${credential.credentialId}`, checked: selectedCredentialIds.includes(credential.credentialId), onChange: (event) => toggleCredentialSelection(credential.credentialId, event.target.checked) }), _jsxs("div", { children: [_jsx("p", { className: "font-medium", children: credential.credentialId }), _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)))] }), _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." })), mergedEvents.length > 0 && (_jsx("div", { className: "space-y-0", children: mergedEvents.map((timelineEvent, index) => (_jsx(EventTimelineItem, { event: timelineEvent, isLast: index === mergedEvents.length - 1 }, timelineEvent.eventKey))) }))] })] })) : null] }) }));
146+
const groupedEvents = useMemo(() => groupEventsBySecond(mergedEvents), [mergedEvents]);
147+
const credentialColorsById = useMemo(() => {
148+
const map = {};
149+
selectedCredentialIds.forEach((credentialId, index) => {
150+
map[credentialId] = pickCredentialColor(index);
151+
});
152+
return map;
153+
}, [selectedCredentialIds]);
154+
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) => ((() => {
155+
const selected = selectedCredentialIds.includes(credential.credentialId);
156+
const color = credentialColorsById[credential.credentialId];
157+
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 ? {
158+
borderLeftWidth: 6,
159+
borderLeftColor: color.border,
160+
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));
162+
})()))] }), _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] }) }));
147163
}
148-
function EventTimelineItem(props) {
149-
const { event, isLast } = props;
150-
return (_jsxs("div", { className: "relative pl-8", children: [!isLast && _jsx("span", { className: "absolute left-[11px] top-6 h-[calc(100%-8px)] w-px bg-slate-300", "aria-hidden": true }), _jsx("span", { className: "absolute left-2 top-3 h-2 w-2 rounded-full bg-blue-600", "aria-hidden": true }), _jsxs("div", { className: "mb-4 rounded border border-slate-200 bg-slate-50 p-3", children: [_jsxs("p", { className: "font-medium", children: [event.actorLabel, " ", event.actionLabel, " ", event.objectLabel] }), _jsxs("p", { className: "text-xs text-slate-600", children: ["When: ", formatTimestamp(event.eventTime)] }), _jsxs("p", { className: "text-xs text-slate-600", children: ["Category: ", toDisplayEventType(event.eventType), " | Snapshot: ", toDisplayRef(event.credentialId)] })] })] }));
164+
const CREDENTIAL_COLORS = [
165+
{ border: "#7c9cf5", bg: "#f5f7ff", chip: "#dbe5ff" },
166+
{ border: "#5aa4b1", bg: "#f2fbfd", chip: "#d3eff4" },
167+
{ border: "#a786d9", bg: "#faf6ff", chip: "#eadcfb" },
168+
{ border: "#69a86f", bg: "#f3fbf4", chip: "#d9f1dc" },
169+
{ border: "#c58b5c", bg: "#fdf7f2", chip: "#f4e1d2" },
170+
{ border: "#c67a9b", bg: "#fef5f9", chip: "#f8d8e6" }
171+
];
172+
function TimelineSecondGroup(props) {
173+
const { group, isLast, credentialColorsById } = props;
174+
return (_jsxs("div", { className: "relative pb-2 pl-8", children: [!isLast && _jsx("span", { className: "absolute left-[11px] top-7 h-[calc(100%-6px)] w-px bg-slate-300", "aria-hidden": true }), _jsx("span", { className: "absolute left-2 top-4 h-2 w-2 rounded-full bg-blue-600", "aria-hidden": true }), _jsx("p", { className: "mb-2 pt-1 text-xs font-semibold text-slate-700", children: formatTimestampToSecond(group.minuteKey) }), _jsx("div", { className: "space-y-2 pb-2", children: group.events.map((event) => {
175+
const color = credentialColorsById[event.credentialId];
176+
return (_jsxs("div", { className: "rounded border border-slate-200 p-3", style: color ? {
177+
borderLeftWidth: 6,
178+
borderLeftColor: color.border,
179+
backgroundColor: color.bg
180+
} : undefined, children: [_jsxs("p", { className: "font-medium", children: [event.actorLabel, " ", event.actionLabel, " ", event.objectLabel] }), _jsxs("p", { className: "text-xs text-slate-600", children: ["Category: ", toDisplayEventType(event.eventType), " | Snapshot: ", toDisplayRef(event.credentialId)] })] }, event.eventKey));
181+
}) })] }));
151182
}
152183
function extractCredentialEvents(detail) {
153184
const payload = detail.rawPayload;
@@ -251,6 +282,55 @@ function formatTimestamp(timestamp) {
251282
}
252283
return asDate.toLocaleString();
253284
}
285+
function formatTimestampToSecond(timestamp) {
286+
if (!timestamp) {
287+
return "unknown time";
288+
}
289+
const asDate = new Date(timestamp);
290+
if (Number.isNaN(asDate.getTime())) {
291+
return timestamp;
292+
}
293+
return asDate.toLocaleString(undefined, {
294+
year: "numeric",
295+
month: "short",
296+
day: "numeric",
297+
hour: "numeric",
298+
minute: "2-digit",
299+
second: "2-digit"
300+
});
301+
}
302+
function toMinuteKey(timestamp) {
303+
const asDate = new Date(timestamp);
304+
if (Number.isNaN(asDate.getTime())) {
305+
return timestamp;
306+
}
307+
return new Date(Math.floor(asDate.getTime() / 60000) * 60000).toISOString();
308+
}
309+
function groupEventsBySecond(events) {
310+
const groupsByMinute = new Map();
311+
const orderedMinuteKeys = [];
312+
for (const event of events) {
313+
const minuteKey = toMinuteKey(event.eventTime);
314+
const existing = groupsByMinute.get(minuteKey);
315+
if (existing) {
316+
existing.push(event);
317+
continue;
318+
}
319+
groupsByMinute.set(minuteKey, [event]);
320+
orderedMinuteKeys.push(minuteKey);
321+
}
322+
return orderedMinuteKeys.map((minuteKey) => ({
323+
minuteKey,
324+
events: groupsByMinute.get(minuteKey) ?? []
325+
}));
326+
}
327+
function pickCredentialColor(index) {
328+
const color = CREDENTIAL_COLORS[index % CREDENTIAL_COLORS.length];
329+
if (color) {
330+
return color;
331+
}
332+
return { border: "#7c9cf5", bg: "#f5f7ff", chip: "#dbe5ff" };
333+
}
254334
function LearnerPage() {
255335
const { learnerId = "demo-learner" } = useParams();
256336
const [context, setContext] = useState(null);

0 commit comments

Comments
 (0)