Repository navigation
Expand file tree
/
Copy pathtimeline.ts
More file actions
108 lines (91 loc) · 3.58 KB
/
Copy pathtimeline.ts
File metadata and controls
108 lines (91 loc) · 3.58 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
import {
compileTimeline,
createBuiltInDetectors,
MaxEventDetailLength,
MaxEventNameLength,
runDetectors,
scrubPath,
scrubText,
type AdaptifEvent,
type CompiledTimeline,
type Signal,
} from "@adaptif/core";
/** Cases are written in minutes and seconds from the start of the session; this anchors them. */
const SessionStartedAt = Date.UTC(2026, 9, 1, 14);
type UntimedEvent = AdaptifEvent extends infer Event
? Event extends AdaptifEvent
? Omit<Event, "occurredAt">
: never
: never;
export interface BuiltTimeline {
events: AdaptifEvent[];
signalNames: string[];
compiled: CompiledTimeline;
}
/** `"1:05"` → 65 000 ms. */
export function parseClockTime(clockTime: string): number {
const match = /^(\d+):([0-5]\d)$/.exec(clockTime);
if (!match) throw new Error(`Expected a time like "1:05", got ${JSON.stringify(clockTime)}.`);
return (Number(match[1]) * 60 + Number(match[2])) * 1000;
}
export function at(clockTime: string, event: UntimedEvent): AdaptifEvent {
return { ...event, occurredAt: SessionStartedAt + parseClockTime(clockTime) };
}
export const navigate = (path: string): UntimedEvent => ({ kind: "navigate", path });
export const custom = (name: string, detail?: string): UntimedEvent =>
detail === undefined ? { kind: "custom", name } : { kind: "custom", name, detail };
export const click = (name: string): UntimedEvent => ({ kind: "click", name });
export const deadClick = (name: string): UntimedEvent => ({ kind: "click", name, noEffect: true });
export const fieldInput = (form: string, field: string, detail?: string): UntimedEvent =>
detail === undefined
? { kind: "field_input", form, field }
: { kind: "field_input", form, field, detail };
export const invalid = (form: string, field: string): UntimedEvent => ({
kind: "invalid",
form,
field,
});
export const submit = (form: string): UntimedEvent => ({ kind: "submit", form });
export const idle = (seconds: number): UntimedEvent => ({ kind: "idle", ms: seconds * 1000 });
/**
* Builds the timeline the way the browser does: the engine's scrubbing for
* emitted events and paths, then detectors event by event in time order
* (as `TimelineStore.record` runs them), then `compileTimeline`.
*/
export function buildTimeline(events: readonly AdaptifEvent[], endsAt: string): BuiltTimeline {
const recorded = events.map(scrubEventLikeTheEngine).sort((a, b) => a.occurredAt - b.occurredAt);
const signals = detectSignals(recorded);
const now = SessionStartedAt + parseClockTime(endsAt);
return {
events: recorded,
signalNames: signals.map((signal) => signal.name),
compiled: compileTimeline(recorded, signals, now),
};
}
function detectSignals(recorded: readonly AdaptifEvent[]): Signal[] {
const detectors = createBuiltInDetectors();
const signals: Signal[] = [];
recorded.forEach((event, index) => {
signals.push(...runDetectors(event, recorded.slice(0, index + 1), detectors, signals));
});
return signals;
}
function scrubEventLikeTheEngine(event: AdaptifEvent): AdaptifEvent {
switch (event.kind) {
case "navigate":
return { ...event, path: scrubPath(event.path) };
case "custom":
return {
...event,
name: scrubText(event.name, MaxEventNameLength),
...(event.detail === undefined ? {} : { detail: scrubDetail(event.detail) }),
};
case "field_input":
return event.detail === undefined ? event : { ...event, detail: scrubDetail(event.detail) };
default:
return event;
}
}
function scrubDetail(detail: string): string {
return scrubText(detail, MaxEventDetailLength).replaceAll('"', "'");
}