This commit is contained in:
ponzischeme89
2026-08-26 21:31:05 +12:00
parent a5f9a91832
commit 3d42c98947
33 changed files with 1172 additions and 69 deletions
+68 -5
View File
@@ -21,6 +21,7 @@ interface JourneyEvent {
itemType?: string;
playSessionId?: string;
outcome?: string;
positionMs?: number;
}
interface JourneyResponse {
@@ -43,6 +44,36 @@ const label = (value: string | undefined | null) => {
const place = (event: JourneyEvent | undefined) => label(event?.target || event?.screen || event?.source || event?.feature);
/* "15m 22s" / "1h 04m" — where playback was, for a pause or resume step. Seconds are
* dropped past an hour; a viewing session that long has no use for that precision. */
const formatPosition = (ms: number | undefined) => {
const totalSeconds = Math.max(0, Math.round((ms ?? 0) / 1000));
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) return `${hours}h ${String(minutes).padStart(2, '0')}m`;
if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, '0')}s`;
return `${seconds}s`;
};
/* "4m" / "48s" — how long a pause lasted, from the gap between its timestamp and the
* matching resume's. Coarser than formatPosition: nobody needs their pause timed to the
* second, only roughly how long they were away. */
const formatDuration = (ms: number) => {
const totalSeconds = Math.max(0, Math.round(ms / 1000));
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
if (hours > 0) return `${hours}h ${minutes}m`;
if (minutes > 0) return `${minutes}m`;
return `${totalSeconds}s`;
};
/* A Hero is a featured card, not a poster in a shelf — the same "content"/"open" step a
* poster press writes, wearing `feature: "hero"` instead of the shelf's own kind. Nothing
* else in the event says that: `source` is still the hero's row id, which reads exactly
* like any other row id next to it. This is the one place that distinction is read back. */
const isHeroOrigin = (event: JourneyEvent | undefined) => event?.feature === 'hero';
/* Where a viewing journey began.
*
* The journey is cut at the playback request, so the first event's `target` is "player" for
@@ -54,9 +85,14 @@ const entryPoint = (events: JourneyEvent[]) => {
const request = events.find((event) => event.category === 'playback' && event.action === 'request');
return request?.source ? label(request.source) : place(events[0]);
};
const detail = (event: JourneyEvent) => event.itemName
? `${label(event.itemType)} · ${event.itemName}`
: event.source && event.target ? `${label(event.source)} ${label(event.target)}` : place(event);
const detail = (event: JourneyEvent) => {
const base = event.itemName
? `${label(event.itemType)} · ${event.itemName}`
: event.source && event.target ? `${label(event.source)}${label(event.target)}` : place(event);
// The page/section the Hero was shown on rides `screen` already, on every event — this
// is the one place it is worth restating, since it is what says *which* Hero this was.
return isHeroOrigin(event) ? `${base} · from the Hero${event.screen ? ` on ${label(event.screen)}` : ''}` : base;
};
const verb = (event: JourneyEvent) => ({
journey_start: 'Opened Memby', home_open: 'Opened Memby', journey_end: 'Finished session',
screen_view: 'Viewed', select: 'Selected', open: 'Opened', close: 'Closed',
@@ -68,8 +104,34 @@ const verb = (event: JourneyEvent) => ({
complete: event.category === 'playback'
? (event.outcome === 'completed' ? 'Finished watching' : 'Stopped watching')
: 'Completed',
pause: 'Paused',
resume: 'Resumed',
}[event.action] ?? label(event.action));
/* Pause and resume replace the ordinary itemName/place detail with where playback was and,
* for a resume, how long the pause it closes lasted — computed from the two steps'
* timestamps rather than sent as a field of its own, so it can never disagree with them.
* Matched by playSessionId and position within the sequence: a pause is "open" until the
* next resume carrying the same stream closes it, which is what keeps a second pause/resume
* pair in the same viewing journey from pairing across each other. */
function playbackDetail(events: JourneyEvent[]) {
const detailByStep = new Map<JourneyEvent, string>();
const openPauseAt = new Map<string, string>();
for (const event of events) {
if (event.category !== 'playback') continue;
if (event.action === 'pause') {
openPauseAt.set(event.playSessionId ?? '', event.occurredAt);
detailByStep.set(event, `at ${formatPosition(event.positionMs)}`);
} else if (event.action === 'resume') {
const pausedAt = openPauseAt.get(event.playSessionId ?? '');
openPauseAt.delete(event.playSessionId ?? '');
const gapMs = pausedAt ? new Date(event.occurredAt).getTime() - new Date(pausedAt).getTime() : NaN;
detailByStep.set(event, Number.isFinite(gapMs) && gapMs >= 0 ? `after ${formatDuration(gapMs)}` : `at ${formatPosition(event.positionMs)}`);
}
}
return detailByStep;
}
function outcome(events: JourneyEvent[]) {
/* A playback step is the verdict on a viewing journey, so it outranks whatever incidental
* outcome a favourite toggle or a settings change left behind on the way in. */
@@ -131,14 +193,15 @@ export function JourneyViewerPage() {
const entry = events[0];
const selection = [...events].reverse().find((event) => event.itemName || event.action === 'select' || (event.category === 'playback' && event.action === 'request'));
const result = outcome(events);
const pauseResumeDetail = playbackDetail(events);
return <article className="visit" key={journey.key}>
<header><div><b>{when(entry?.occurredAt)}</b><span>Journey {index + 1} · {events.length} recorded steps</span></div><Tag tone={result.tone}>{result.label}</Tag></header>
<div className="journey-answers">
<div className="journey-answer" data-kind="entry"><Icon name="journey" /><span>Entered from</span><b>{entryPoint(events)}</b></div>
<div className="journey-answer" data-kind="selection"><Icon name="play" /><span>Selected</span><b>{selection ? detail(selection) : 'Nothing selected'}</b></div>
<div className="journey-answer" data-kind="selection"><Icon name={isHeroOrigin(selection) ? 'star' : 'play'} /><span>Selected</span><b>{selection ? detail(selection) : 'Nothing selected'}</b></div>
<div className="journey-answer" data-kind="outcome"><Icon name={result.tone === 'ok' ? 'check' : 'clock'} /><span>Outcome</span><b>{result.label}</b></div>
</div>
<ol className="journey-timeline">{events.map((event) => <li key={`${event.journeyId}:${event.sequence}`}><span className="timeline-dot" data-action={event.action} /><div><b>{verb(event)}</b><span>{detail(event)}</span></div><time>{when(event.occurredAt)}</time></li>)}</ol>
<ol className="journey-timeline">{events.map((event) => <li key={`${event.journeyId}:${event.sequence}`}><span className="timeline-dot" data-action={event.action} data-hero={isHeroOrigin(event) ? '' : undefined} /><div><b>{verb(event)}</b><span>{pauseResumeDetail.get(event) ?? detail(event)}</span></div><time>{when(event.occurredAt)}</time></li>)}</ol>
</article>;
})}
</div>
+8
View File
@@ -2911,6 +2911,14 @@ pre.code {
.timeline-dot[data-action="select"],
.timeline-dot[data-action="open"] { background: var(--note-ink); }
.timeline-dot[data-action="close"] { background: var(--quiet); }
/* Paused is amber (look at this — playback stopped); resumed keeps the default accent, the
same "still going" colour a step that moves the journey forward already wears. */
.timeline-dot[data-action="pause"] { background: var(--warn-ink); }
/* A step that came from the Hero rather than a poster in a shelf — same dot position,
a ring around it so it reads at a glance without having to read the step's text. */
.timeline-dot[data-hero] {
box-shadow: 0 0 0 2px var(--note-wash), 0 0 0 3px var(--note-ink);
}
.journey-timeline li > div { min-width: 0; }
.journey-timeline li b {
display: inline;