0.3.32
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user