@qoretechnologies/reqraft 0.10.56 → 0.10.57-pr.127.g0a792ea
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/CLAUDE.md +1 -0
- package/README.md +20 -0
- package/design/TRACKING.md +290 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/tracking/browser/attach.d.ts +8 -0
- package/dist/tracking/browser/attach.d.ts.map +1 -0
- package/dist/tracking/browser/attach.js +182 -0
- package/dist/tracking/browser/attach.js.map +1 -0
- package/dist/tracking/browser/env.d.ts +14 -0
- package/dist/tracking/browser/env.d.ts.map +1 -0
- package/dist/tracking/browser/env.js +150 -0
- package/dist/tracking/browser/env.js.map +1 -0
- package/dist/tracking/core/consent.d.ts +21 -0
- package/dist/tracking/core/consent.d.ts.map +1 -0
- package/dist/tracking/core/consent.js +39 -0
- package/dist/tracking/core/consent.js.map +1 -0
- package/dist/tracking/core/engagement.d.ts +20 -0
- package/dist/tracking/core/engagement.d.ts.map +1 -0
- package/dist/tracking/core/engagement.js +52 -0
- package/dist/tracking/core/engagement.js.map +1 -0
- package/dist/tracking/core/experiments.d.ts +109 -0
- package/dist/tracking/core/experiments.d.ts.map +1 -0
- package/dist/tracking/core/experiments.js +294 -0
- package/dist/tracking/core/experiments.js.map +1 -0
- package/dist/tracking/core/hash.d.ts +16 -0
- package/dist/tracking/core/hash.d.ts.map +1 -0
- package/dist/tracking/core/hash.js +31 -0
- package/dist/tracking/core/hash.js.map +1 -0
- package/dist/tracking/core/ids.d.ts +6 -0
- package/dist/tracking/core/ids.d.ts.map +1 -0
- package/dist/tracking/core/ids.js +31 -0
- package/dist/tracking/core/ids.js.map +1 -0
- package/dist/tracking/core/observable.d.ts +12 -0
- package/dist/tracking/core/observable.d.ts.map +1 -0
- package/dist/tracking/core/observable.js +32 -0
- package/dist/tracking/core/observable.js.map +1 -0
- package/dist/tracking/core/queue.d.ts +47 -0
- package/dist/tracking/core/queue.d.ts.map +1 -0
- package/dist/tracking/core/queue.js +205 -0
- package/dist/tracking/core/queue.js.map +1 -0
- package/dist/tracking/core/sections.d.ts +54 -0
- package/dist/tracking/core/sections.d.ts.map +1 -0
- package/dist/tracking/core/sections.js +156 -0
- package/dist/tracking/core/sections.js.map +1 -0
- package/dist/tracking/core/storage.d.ts +15 -0
- package/dist/tracking/core/storage.d.ts.map +1 -0
- package/dist/tracking/core/storage.js +72 -0
- package/dist/tracking/core/storage.js.map +1 -0
- package/dist/tracking/core/tracker.d.ts +122 -0
- package/dist/tracking/core/tracker.d.ts.map +1 -0
- package/dist/tracking/core/tracker.js +390 -0
- package/dist/tracking/core/tracker.js.map +1 -0
- package/dist/tracking/core/types.d.ts +130 -0
- package/dist/tracking/core/types.d.ts.map +1 -0
- package/dist/tracking/core/types.js +69 -0
- package/dist/tracking/core/types.js.map +1 -0
- package/dist/tracking/core/utm.d.ts +6 -0
- package/dist/tracking/core/utm.d.ts.map +1 -0
- package/dist/tracking/core/utm.js +30 -0
- package/dist/tracking/core/utm.js.map +1 -0
- package/dist/tracking/index.d.ts +46 -0
- package/dist/tracking/index.d.ts.map +1 -0
- package/dist/tracking/index.js +91 -0
- package/dist/tracking/index.js.map +1 -0
- package/dist/tracking/markup.d.ts +12 -0
- package/dist/tracking/markup.d.ts.map +1 -0
- package/dist/tracking/markup.js +19 -0
- package/dist/tracking/markup.js.map +1 -0
- package/dist/tracking/react/Experiment.d.ts +23 -0
- package/dist/tracking/react/Experiment.d.ts.map +1 -0
- package/dist/tracking/react/Experiment.js +28 -0
- package/dist/tracking/react/Experiment.js.map +1 -0
- package/dist/tracking/react/TrackingProvider.d.ts +26 -0
- package/dist/tracking/react/TrackingProvider.d.ts.map +1 -0
- package/dist/tracking/react/TrackingProvider.js +45 -0
- package/dist/tracking/react/TrackingProvider.js.map +1 -0
- package/dist/tracking/react/hooks.d.ts +39 -0
- package/dist/tracking/react/hooks.d.ts.map +1 -0
- package/dist/tracking/react/hooks.js +96 -0
- package/dist/tracking/react/hooks.js.map +1 -0
- package/dist/tracking/react/index.d.ts +7 -0
- package/dist/tracking/react/index.d.ts.map +1 -0
- package/dist/tracking/react/index.js +17 -0
- package/dist/tracking/react/index.js.map +1 -0
- package/dist/tracking/testing.d.ts +40 -0
- package/dist/tracking/testing.d.ts.map +1 -0
- package/dist/tracking/testing.js +140 -0
- package/dist/tracking/testing.js.map +1 -0
- package/dist/tracking/ui/ExperimentCard.d.ts +55 -0
- package/dist/tracking/ui/ExperimentCard.d.ts.map +1 -0
- package/dist/tracking/ui/ExperimentCard.js +340 -0
- package/dist/tracking/ui/ExperimentCard.js.map +1 -0
- package/dist/tracking/ui/ExperimentsOverlay.d.ts +59 -0
- package/dist/tracking/ui/ExperimentsOverlay.d.ts.map +1 -0
- package/dist/tracking/ui/ExperimentsOverlay.js +359 -0
- package/dist/tracking/ui/ExperimentsOverlay.js.map +1 -0
- package/dist/tracking/ui/anchor.d.ts +107 -0
- package/dist/tracking/ui/anchor.d.ts.map +1 -0
- package/dist/tracking/ui/anchor.js +108 -0
- package/dist/tracking/ui/anchor.js.map +1 -0
- package/dist/tracking/ui/card.d.ts +121 -0
- package/dist/tracking/ui/card.d.ts.map +1 -0
- package/dist/tracking/ui/card.js +417 -0
- package/dist/tracking/ui/card.js.map +1 -0
- package/dist/tracking/ui/client.d.ts +42 -0
- package/dist/tracking/ui/client.d.ts.map +1 -0
- package/dist/tracking/ui/client.js +123 -0
- package/dist/tracking/ui/client.js.map +1 -0
- package/dist/tracking/ui/index.d.ts +20 -0
- package/dist/tracking/ui/index.d.ts.map +1 -0
- package/dist/tracking/ui/index.js +60 -0
- package/dist/tracking/ui/index.js.map +1 -0
- package/dist/tracking/ui/types.d.ts +120 -0
- package/dist/tracking/ui/types.d.ts.map +1 -0
- package/dist/tracking/ui/types.js +8 -0
- package/dist/tracking/ui/types.js.map +1 -0
- package/dist/tracking/ui/useExperimentsAdmin.d.ts +31 -0
- package/dist/tracking/ui/useExperimentsAdmin.d.ts.map +1 -0
- package/dist/tracking/ui/useExperimentsAdmin.js +136 -0
- package/dist/tracking/ui/useExperimentsAdmin.js.map +1 -0
- package/package.json +1 -1
- package/src/index.tsx +5 -0
- package/src/stories/tracking.tsx +206 -0
- package/src/tracking/browser/attach.ts +186 -0
- package/src/tracking/browser/env.ts +78 -0
- package/src/tracking/core/consent.ts +37 -0
- package/src/tracking/core/engagement.ts +47 -0
- package/src/tracking/core/experiments.ts +258 -0
- package/src/tracking/core/hash.ts +26 -0
- package/src/tracking/core/ids.ts +25 -0
- package/src/tracking/core/observable.ts +23 -0
- package/src/tracking/core/queue.ts +131 -0
- package/src/tracking/core/sections.ts +166 -0
- package/src/tracking/core/storage.ts +69 -0
- package/src/tracking/core/tracker.ts +417 -0
- package/src/tracking/core/types.ts +162 -0
- package/src/tracking/core/utm.ts +23 -0
- package/src/tracking/index.ts +72 -0
- package/src/tracking/markup.ts +17 -0
- package/src/tracking/react/Experiment.tsx +44 -0
- package/src/tracking/react/TrackingProvider.tsx +56 -0
- package/src/tracking/react/hooks.ts +106 -0
- package/src/tracking/react/index.ts +13 -0
- package/src/tracking/testing.ts +108 -0
- package/src/tracking/ui/ExperimentCard.stories.tsx +290 -0
- package/src/tracking/ui/ExperimentCard.tsx +581 -0
- package/src/tracking/ui/ExperimentsOverlay.stories.tsx +155 -0
- package/src/tracking/ui/ExperimentsOverlay.tsx +437 -0
- package/src/tracking/ui/anchor.ts +180 -0
- package/src/tracking/ui/card.ts +464 -0
- package/src/tracking/ui/client.ts +117 -0
- package/src/tracking/ui/index.ts +77 -0
- package/src/tracking/ui/types.ts +110 -0
- package/src/tracking/ui/useExperimentsAdmin.ts +103 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { IBatch, ITrackedEvent } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How a batch leaves the page. `beacon` is used when the page is going away
|
|
5
|
+
* (`pagehide`, tab hidden): the browser delivers it after the page is gone.
|
|
6
|
+
* Resolves to `true` when the batch was handed over, `false` to keep it for later
|
|
7
|
+
* (network error), and never rejects.
|
|
8
|
+
*/
|
|
9
|
+
export interface ITransport {
|
|
10
|
+
send(body: string, mode: 'fetch' | 'beacon'): Promise<boolean>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Collector limits (§4): at most 100 events and 64 KB per batch. We stay under both. */
|
|
14
|
+
export const MAX_EVENTS_PER_BATCH = 100;
|
|
15
|
+
export const MAX_BATCH_BYTES = 60 * 1024;
|
|
16
|
+
/** Events kept while the collector is unreachable; the oldest are dropped past this. */
|
|
17
|
+
export const MAX_BACKLOG = 500;
|
|
18
|
+
|
|
19
|
+
export interface IQueueOptions {
|
|
20
|
+
flushIntervalMs: number;
|
|
21
|
+
maxQueued: number;
|
|
22
|
+
transport: ITransport;
|
|
23
|
+
/** The envelope for a batch; null → nothing may be sent (no consent) and the queue is dropped. */
|
|
24
|
+
envelope: () => Omit<IBatch, 'events' | 'sent_at'> | null;
|
|
25
|
+
now: () => number;
|
|
26
|
+
/** Timers are injected so tests can drive them. */
|
|
27
|
+
setTimer: (fn: () => void, ms: number) => unknown;
|
|
28
|
+
clearTimer: (handle: unknown) => void;
|
|
29
|
+
debug?: (batch: IBatch) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const size = (s: string) => (typeof TextEncoder !== 'undefined' ? new TextEncoder().encode(s).length : s.length * 2);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Batches events: sends every `flushIntervalMs`, as soon as `maxQueued` are waiting,
|
|
36
|
+
* and immediately (as a beacon) when the page is going away.
|
|
37
|
+
*/
|
|
38
|
+
export class EventQueue {
|
|
39
|
+
private events: ITrackedEvent[] = [];
|
|
40
|
+
private timer: unknown = null;
|
|
41
|
+
private sending = false;
|
|
42
|
+
|
|
43
|
+
constructor(private readonly o: IQueueOptions) {}
|
|
44
|
+
|
|
45
|
+
get length() {
|
|
46
|
+
return this.events.length;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
push(event: ITrackedEvent) {
|
|
50
|
+
this.events.push(event);
|
|
51
|
+
if (this.events.length > MAX_BACKLOG) this.events.splice(0, this.events.length - MAX_BACKLOG);
|
|
52
|
+
if (this.events.length >= this.o.maxQueued) void this.flush('fetch');
|
|
53
|
+
else this.schedule();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Forget everything unsent (consent withdrawn). */
|
|
57
|
+
clear() {
|
|
58
|
+
this.events = [];
|
|
59
|
+
if (this.timer != null) this.o.clearTimer(this.timer);
|
|
60
|
+
this.timer = null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private schedule() {
|
|
64
|
+
if (this.timer != null) return;
|
|
65
|
+
this.timer = this.o.setTimer(() => {
|
|
66
|
+
this.timer = null;
|
|
67
|
+
void this.flush('fetch');
|
|
68
|
+
}, this.o.flushIntervalMs);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Splits `events` into batches that fit the collector's limits. */
|
|
72
|
+
static chunk(events: ITrackedEvent[], envelope: Omit<IBatch, 'events' | 'sent_at'>, sentAt: string): IBatch[] {
|
|
73
|
+
const batches: IBatch[] = [];
|
|
74
|
+
let current: ITrackedEvent[] = [];
|
|
75
|
+
const bodyOf = (evts: ITrackedEvent[]) => JSON.stringify({ ...envelope, sent_at: sentAt, events: evts });
|
|
76
|
+
for (const e of events) {
|
|
77
|
+
const next = [...current, e];
|
|
78
|
+
if (current.length && (next.length > MAX_EVENTS_PER_BATCH || size(bodyOf(next)) > MAX_BATCH_BYTES)) {
|
|
79
|
+
batches.push({ ...envelope, sent_at: sentAt, events: current });
|
|
80
|
+
current = [e];
|
|
81
|
+
} else {
|
|
82
|
+
current = next;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (current.length) batches.push({ ...envelope, sent_at: sentAt, events: current });
|
|
86
|
+
// A single event larger than the limit is dropped rather than rejected forever.
|
|
87
|
+
return batches.filter((b) => b.events.length > 1 || size(JSON.stringify(b)) <= MAX_BATCH_BYTES);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async flush(mode: 'fetch' | 'beacon' = 'fetch'): Promise<void> {
|
|
91
|
+
if (this.timer != null) {
|
|
92
|
+
this.o.clearTimer(this.timer);
|
|
93
|
+
this.timer = null;
|
|
94
|
+
}
|
|
95
|
+
if (!this.events.length) return;
|
|
96
|
+
// A fetch in flight owns the queue; a beacon (page going away) sends what is left now.
|
|
97
|
+
if (this.sending && mode === 'fetch') {
|
|
98
|
+
this.schedule();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const envelope = this.o.envelope();
|
|
102
|
+
if (!envelope) {
|
|
103
|
+
this.events = [];
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const pending = this.events;
|
|
107
|
+
this.events = [];
|
|
108
|
+
const batches = EventQueue.chunk(pending, envelope, new Date(this.o.now()).toISOString());
|
|
109
|
+
this.sending = true;
|
|
110
|
+
const failed: ITrackedEvent[] = [];
|
|
111
|
+
try {
|
|
112
|
+
for (const batch of batches) {
|
|
113
|
+
this.o.debug?.(batch);
|
|
114
|
+
let ok = false;
|
|
115
|
+
try {
|
|
116
|
+
ok = await this.o.transport.send(JSON.stringify(batch), mode);
|
|
117
|
+
} catch {
|
|
118
|
+
ok = false;
|
|
119
|
+
}
|
|
120
|
+
if (!ok) failed.push(...batch.events);
|
|
121
|
+
}
|
|
122
|
+
} finally {
|
|
123
|
+
this.sending = false;
|
|
124
|
+
}
|
|
125
|
+
if (failed.length) {
|
|
126
|
+
// Kept for the next flush, which the next event schedules (no retry loop against a
|
|
127
|
+
// collector that is down); event ids make a double delivery harmless.
|
|
128
|
+
this.events = [...failed, ...this.events].slice(-MAX_BACKLOG);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attention per section, as plain arithmetic over time (no DOM): the browser layer
|
|
3
|
+
* feeds it visibility changes from an IntersectionObserver and tab visibility, and
|
|
4
|
+
* asks it for the page summary.
|
|
5
|
+
*
|
|
6
|
+
* - `visible_ms`: time any part of the section was on screen while the tab was visible.
|
|
7
|
+
* - `seen`: the section was at least 50% visible for at least 1 s in one stretch — the
|
|
8
|
+
* attention definition the contract uses everywhere (§4). "50% visible" is 50% of
|
|
9
|
+
* the section *or* 50% of the viewport covered by it: a section taller than two
|
|
10
|
+
* screens can never show half of itself, and it would otherwise never count.
|
|
11
|
+
* - `first_seen_ms`: when that stretch began, measured from the start of the page view.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const SEEN_RATIO = 0.5;
|
|
15
|
+
export const SEEN_MS = 1000;
|
|
16
|
+
|
|
17
|
+
export interface ISectionSummary {
|
|
18
|
+
id: string;
|
|
19
|
+
visible_ms: number;
|
|
20
|
+
seen: boolean;
|
|
21
|
+
first_seen_ms: number | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface ISectionState {
|
|
25
|
+
id: string;
|
|
26
|
+
/** Effective visibility 0..1 (see above). */
|
|
27
|
+
ratio: number;
|
|
28
|
+
visibleMs: number;
|
|
29
|
+
visibleSince: number | null;
|
|
30
|
+
halfSince: number | null;
|
|
31
|
+
seenAt: number | null;
|
|
32
|
+
firstSeenMs: number | null;
|
|
33
|
+
/** Reported in the page summary (false for synthetic experiment triggers). */
|
|
34
|
+
report: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class SectionClock {
|
|
38
|
+
private sections = new Map<string, ISectionState>();
|
|
39
|
+
private tabVisible = true;
|
|
40
|
+
private seenListeners = new Set<(id: string) => void>();
|
|
41
|
+
|
|
42
|
+
constructor(private start: number) {}
|
|
43
|
+
|
|
44
|
+
/** A new page view: everything starts from zero at `now`. */
|
|
45
|
+
reset(now: number) {
|
|
46
|
+
this.start = now;
|
|
47
|
+
const ids = Array.from(this.sections.values()).map((s) => [s.id, s.report] as const);
|
|
48
|
+
this.sections.clear();
|
|
49
|
+
ids.forEach(([id, report]) => this.ensure(id, report));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
onSeen(listener: (id: string) => void) {
|
|
53
|
+
this.seenListeners.add(listener);
|
|
54
|
+
return () => void this.seenListeners.delete(listener);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
isSeen = (id: string) => this.sections.get(id)?.seenAt != null;
|
|
58
|
+
|
|
59
|
+
private ensure(id: string, report = true): ISectionState {
|
|
60
|
+
let s = this.sections.get(id);
|
|
61
|
+
if (!s) {
|
|
62
|
+
s = { id, ratio: 0, visibleMs: 0, visibleSince: null, halfSince: null, seenAt: null, firstSeenMs: null, report };
|
|
63
|
+
this.sections.set(id, s);
|
|
64
|
+
}
|
|
65
|
+
return s;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Registers a section so it shows up in the summary even if it is never on screen. */
|
|
69
|
+
register(id: string, report = true) {
|
|
70
|
+
this.ensure(id, report).report ||= report;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The section's effective visibility changed. */
|
|
74
|
+
update(id: string, ratio: number, now: number) {
|
|
75
|
+
const s = this.ensure(id);
|
|
76
|
+
if (this.tabVisible) {
|
|
77
|
+
const wasVisible = s.ratio > 0;
|
|
78
|
+
const isVisible = ratio > 0;
|
|
79
|
+
if (wasVisible && !isVisible && s.visibleSince != null) {
|
|
80
|
+
s.visibleMs += Math.max(0, now - s.visibleSince);
|
|
81
|
+
s.visibleSince = null;
|
|
82
|
+
}
|
|
83
|
+
if (!wasVisible && isVisible) s.visibleSince = now;
|
|
84
|
+
// A stretch at >= 50% continues through ratio changes above the line.
|
|
85
|
+
const wasHalf = s.ratio >= SEEN_RATIO;
|
|
86
|
+
const isHalf = ratio >= SEEN_RATIO;
|
|
87
|
+
if (wasHalf && !isHalf) {
|
|
88
|
+
this.tickOne(s, now);
|
|
89
|
+
s.halfSince = null;
|
|
90
|
+
}
|
|
91
|
+
if (!wasHalf && isHalf) s.halfSince = now;
|
|
92
|
+
}
|
|
93
|
+
s.ratio = ratio;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The tab became visible or hidden: time stops counting while hidden. */
|
|
97
|
+
setTabVisible(visible: boolean, now: number) {
|
|
98
|
+
if (visible === this.tabVisible) return;
|
|
99
|
+
this.sections.forEach((s) => this.settle(s, now));
|
|
100
|
+
this.tabVisible = visible;
|
|
101
|
+
this.sections.forEach((s) => this.arm(s, now));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Promotes stretches that have lasted long enough to `seen`. Call ~every second. */
|
|
105
|
+
tick(now: number) {
|
|
106
|
+
this.sections.forEach((s) => this.tickOne(s, now));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Closes the open stretches up to `now` (visible time, seen). */
|
|
110
|
+
private settle(s: ISectionState, now: number) {
|
|
111
|
+
if (s.visibleSince != null) s.visibleMs += Math.max(0, now - s.visibleSince);
|
|
112
|
+
s.visibleSince = null;
|
|
113
|
+
this.tickOne(s, now);
|
|
114
|
+
s.halfSince = null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private tickOne(s: ISectionState, now: number) {
|
|
118
|
+
if (s.seenAt == null && s.halfSince != null && now - s.halfSince >= SEEN_MS) {
|
|
119
|
+
s.seenAt = now;
|
|
120
|
+
s.firstSeenMs = Math.max(0, s.halfSince - this.start);
|
|
121
|
+
this.seenListeners.forEach((l) => l(s.id));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Opens stretches for the current state. */
|
|
126
|
+
private arm(s: ISectionState, now: number) {
|
|
127
|
+
if (!this.tabVisible) return;
|
|
128
|
+
if (s.ratio > 0) s.visibleSince = now;
|
|
129
|
+
if (s.ratio >= SEEN_RATIO) s.halfSince = now;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The section most on screen now (ties: the lower one on the page, i.e. the later registered). */
|
|
133
|
+
current(): string | null {
|
|
134
|
+
let best: ISectionState | null = null;
|
|
135
|
+
for (const s of Array.from(this.sections.values())) {
|
|
136
|
+
if (s.report && s.ratio > 0 && (!best || s.ratio >= best.ratio)) best = s;
|
|
137
|
+
}
|
|
138
|
+
return best?.id ?? null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
summary(now: number): ISectionSummary[] {
|
|
142
|
+
this.tick(now);
|
|
143
|
+
return Array.from(this.sections.values())
|
|
144
|
+
.filter((s) => s.report)
|
|
145
|
+
.map((s) => ({
|
|
146
|
+
id: s.id,
|
|
147
|
+
visible_ms: Math.round(s.visibleMs + (s.visibleSince != null ? Math.max(0, now - s.visibleSince) : 0)),
|
|
148
|
+
seen: s.seenAt != null,
|
|
149
|
+
first_seen_ms: s.firstSeenMs,
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Effective visibility from an IntersectionObserver entry: the larger of the share
|
|
156
|
+
* of the section on screen and the share of the viewport the section fills.
|
|
157
|
+
*/
|
|
158
|
+
export const effectiveRatio = (
|
|
159
|
+
intersectionRatio: number,
|
|
160
|
+
intersectionHeight: number,
|
|
161
|
+
viewportHeight: number,
|
|
162
|
+
): number => {
|
|
163
|
+
if (intersectionRatio <= 0) return 0;
|
|
164
|
+
const cover = viewportHeight > 0 ? intersectionHeight / viewportHeight : 0;
|
|
165
|
+
return Math.min(1, Math.max(intersectionRatio, cover));
|
|
166
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage that never throws: private windows, blocked site data and sandboxed
|
|
3
|
+
* iframes make `localStorage` throw on access. The tracker then keeps its state in
|
|
4
|
+
* memory for the page's lifetime and the site behaves exactly as without consent.
|
|
5
|
+
*/
|
|
6
|
+
export interface IStorage {
|
|
7
|
+
get(key: string): string | null;
|
|
8
|
+
set(key: string, value: string): void;
|
|
9
|
+
remove(key: string): void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const memoryStorage = (): IStorage => {
|
|
13
|
+
const data = new Map<string, string>();
|
|
14
|
+
return {
|
|
15
|
+
get: (k) => data.get(k) ?? null,
|
|
16
|
+
set: (k, v) => void data.set(k, v),
|
|
17
|
+
remove: (k) => void data.delete(k),
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const safeStorage = (pick: () => Storage | undefined): IStorage => {
|
|
22
|
+
const fallback = memoryStorage();
|
|
23
|
+
const store = (): Storage | undefined => {
|
|
24
|
+
try {
|
|
25
|
+
return pick();
|
|
26
|
+
} catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
return {
|
|
31
|
+
get: (k) => {
|
|
32
|
+
try {
|
|
33
|
+
const s = store();
|
|
34
|
+
return s ? s.getItem(k) : fallback.get(k);
|
|
35
|
+
} catch {
|
|
36
|
+
return fallback.get(k);
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
set: (k, v) => {
|
|
40
|
+
try {
|
|
41
|
+
const s = store();
|
|
42
|
+
if (s) s.setItem(k, v);
|
|
43
|
+
else fallback.set(k, v);
|
|
44
|
+
} catch {
|
|
45
|
+
fallback.set(k, v);
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
remove: (k) => {
|
|
49
|
+
fallback.remove(k);
|
|
50
|
+
try {
|
|
51
|
+
store()?.removeItem(k);
|
|
52
|
+
} catch {
|
|
53
|
+
/* ignore */
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const readJson = <T>(storage: IStorage, key: string): T | null => {
|
|
60
|
+
const raw = storage.get(key);
|
|
61
|
+
if (!raw) return null;
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(raw) as T;
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const writeJson = (storage: IStorage, key: string, value: unknown) => storage.set(key, JSON.stringify(value));
|