@analitikisler/ai-tag-debugger 0.16.0 → 0.18.0

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/src/history.js ADDED
@@ -0,0 +1,302 @@
1
+ // Run history: what the site's saved runs say over time. changes.js compares a run with the one
2
+ // before it; this module reads every run in the window and turns it into views the report, the CLI
3
+ // and the alert can show: a timeline per finding, a trend series for sparklines and the completion
4
+ // rate of every journey. Only a small summary of each run is kept, so thirty runs cost a few KB.
5
+ import { readFile, writeFile } from "node:fs/promises";
6
+ import path from "node:path";
7
+ import { groupFindings } from "./checks.js";
8
+ import { LANGUAGES } from "./i18n.js";
9
+ import { PROVIDERS } from "./parsers.js";
10
+ import { listRunIds, runsDir, siteDir } from "./workspace.js";
11
+
12
+ /** The per-site index of run summaries, next to the plans and runs folders. */
13
+ export const HISTORY_FILE = "history.json";
14
+ export const historyFile = (dataDir, site) => path.join(siteDir(dataDir, site), HISTORY_FILE);
15
+
16
+ const INDEX_VERSION = 1;
17
+ const PROBLEMS = new Set(["broken", "risk", "warning"]);
18
+ const SEVERITY_RANK = { broken: 0, risk: 1, warning: 2 };
19
+ // Problems are weighed so one broken event outranks a few warnings when runs are compared.
20
+ const WEIGHT = { broken: 3, risk: 2, warning: 1 };
21
+ // "Recently" and the trend direction both look at the last few runs, not just the last one.
22
+ const RECENT = 3;
23
+
24
+ /**
25
+ * The identity of a finding across runs: the same line replay.js writes to site.json ("severity check
26
+ * journey viewport platform event"), so a replay and the history agree on what "the same finding" is.
27
+ * Kept inline rather than imported because replay.js loads the browser and the audit, and the report
28
+ * must stay importable without them. test/history.test.js pins the two to the same output.
29
+ */
30
+ export const findingKey = (f) => [f.severity, f.check, f.journey, f.viewport, f.platform ?? "-", f.event ?? "-"].join(" ");
31
+
32
+ /** The id's local time as "YYYY-MM-DDTHH:MM"; the seconds suffix of a same-minute id is dropped. */
33
+ export const dateOfId = (id) => {
34
+ const m = /^(\d{4}-\d{2}-\d{2})_(\d{2})(\d{2})/.exec(id);
35
+ return m ? `${m[1]}T${m[2]}:${m[3]}` : null;
36
+ };
37
+
38
+ /**
39
+ * The summary of one run that history keeps: its journeys, its counts and its problems, a few hundred
40
+ * bytes instead of a capture. Fields a capture may lack (older shapes, or ones audit.js does not write
41
+ * yet such as version and ai) come out null, never throw. Findings a baseline accepted are left out,
42
+ * as the counts and the verdict leave them out, so an accepted problem is not "still open" in the trend.
43
+ */
44
+ export function runSummary(id, capture = {}, { version = null, ai = null } = {}) {
45
+ const findings = Array.isArray(capture.findings) ? capture.findings : [];
46
+ const counts = { broken: 0, risk: 0, warning: 0, info: 0, ok: 0 };
47
+ for (const f of groupFindings(findings.filter((f) => f.severity in counts))) if (!f.accepted) counts[f.severity] += 1;
48
+ const seen = new Set();
49
+ const problems = [];
50
+ for (const f of findings) {
51
+ if (!PROBLEMS.has(f.severity) || !f.check || f.accepted === true) continue;
52
+ const key = findingKey(f);
53
+ if (seen.has(key)) continue;
54
+ seen.add(key);
55
+ problems.push({ key, check: f.check, severity: f.severity, platform: f.platform ?? null, event: f.event ?? null, journey: f.journey ?? null, viewport: f.viewport ?? null });
56
+ }
57
+ const journeys = (Array.isArray(capture.runs) ? capture.runs : [])
58
+ .filter((r) => r && r.journeyId)
59
+ .map((r) => ({ id: r.journeyId, viewport: r.viewport ?? null, completed: Boolean(r.completed), hits: Array.isArray(r.hits) ? r.hits.length : 0 }));
60
+ return { id, date: dateOfId(id), version: capture.version ?? version, ai: capture.ai ?? ai, journeys, counts, findings: problems };
61
+ }
62
+
63
+ const readJson = (file) => readFile(file, "utf8").then(JSON.parse).catch(() => null);
64
+
65
+ /** The summaries the index holds, by run id; empty when there is no index or it is unreadable. */
66
+ async function readIndex(dataDir, site) {
67
+ const index = await readJson(historyFile(dataDir, site));
68
+ const runs = index?.version === INDEX_VERSION && Array.isArray(index.runs) ? index.runs : [];
69
+ return new Map(runs.filter((r) => r && typeof r.id === "string").map((r) => [r.id, r]));
70
+ }
71
+
72
+ /**
73
+ * The site's most recent `limit` runs, oldest first and newest last, each as a runSummary. Runs with a
74
+ * missing or unreadable capture.json are skipped. A run already in history.json is taken from there;
75
+ * the others are read from their capture.json, one at a time so only one capture is ever in memory.
76
+ * Cost without an index: one read and parse per run (a capture is 100 KB to a few MB, roughly 1-10 ms
77
+ * per 100 KB), then the parsed object is dropped. With the index, one small file.
78
+ */
79
+ export async function loadHistory(dataDir, site, { limit = 30 } = {}) {
80
+ const ids = (await listRunIds(dataDir, site)).slice(-Math.max(0, limit));
81
+ const indexed = await readIndex(dataDir, site);
82
+ const out = [];
83
+ for (const id of ids) {
84
+ const known = indexed.get(id);
85
+ if (known) {
86
+ out.push(known);
87
+ continue;
88
+ }
89
+ const capture = await readJson(path.join(runsDir(dataDir, site), id, "capture.json"));
90
+ if (capture && typeof capture === "object") out.push(runSummary(id, capture));
91
+ }
92
+ return out;
93
+ }
94
+
95
+ /**
96
+ * Adds one run to the site's history.json (or replaces its entry) so later reports and CLI calls do not
97
+ * re-read every capture. `run` is { id, capture, version?, ai? } or an existing runSummary. Entries whose
98
+ * run folder is gone are dropped. Never throws on a missing or corrupt index: it is rebuilt from the run.
99
+ */
100
+ export async function updateHistoryIndex(dataDir, site, run) {
101
+ const entry = run.capture ? runSummary(run.id, run.capture, { version: run.version ?? null, ai: run.ai ?? null }) : run;
102
+ const indexed = await readIndex(dataDir, site);
103
+ indexed.set(entry.id, entry);
104
+ const onDisk = new Set(await listRunIds(dataDir, site));
105
+ const runs = [...indexed.values()].filter((r) => r.id === entry.id || onDisk.has(r.id)).sort((a, b) => (a.id < b.id ? -1 : 1));
106
+ const file = historyFile(dataDir, site);
107
+ await writeFile(file, JSON.stringify({ version: INDEX_VERSION, site, updated: new Date().toISOString(), runs }, null, 2));
108
+ return { file, runs: runs.length };
109
+ }
110
+
111
+ /** Whether a run gives evidence about a journey: it ran and completed. An incomplete journey proves nothing by absence. */
112
+ const observed = (run) => new Set(run.journeys.filter((j) => j.completed).map((j) => `${j.id}|${j.viewport}`));
113
+
114
+ /**
115
+ * One entry per finding seen in the window, oldest open first:
116
+ * { key, check, severity, platform, event, journey, viewport, firstSeen, lastSeen, seenIn, openNow, openSince,
117
+ * flapping, fixedIn }. A run where the finding's journey did not complete says nothing: it is neither "seen"
118
+ * nor "gone", so a crashed checkout does not count as a fix. flapping: seen, gone, then seen again.
119
+ * fixedIn: the first run after lastSeen where the journey completed without it; null while open.
120
+ */
121
+ export function findingTimeline(history) {
122
+ const entries = new Map();
123
+ for (const run of history) {
124
+ for (const f of run.findings) {
125
+ if (!entries.has(f.key)) entries.set(f.key, { ...f, states: [] });
126
+ }
127
+ }
128
+ for (const run of history) {
129
+ const present = new Set(run.findings.map((f) => f.key));
130
+ const ok = observed(run);
131
+ for (const e of entries.values()) {
132
+ // A finding from an older capture without a journey has no completion to check: absence means gone.
133
+ const state = present.has(e.key) ? "seen" : !e.journey || ok.has(`${e.journey}|${e.viewport}`) ? "gone" : null;
134
+ e.states.push({ id: run.id, state });
135
+ }
136
+ }
137
+ const out = [];
138
+ for (const e of entries.values()) {
139
+ const { states, ...rest } = e;
140
+ const known = states.filter((s) => s.state);
141
+ const seen = known.filter((s) => s.state === "seen");
142
+ const firstSeen = seen[0].id;
143
+ const lastSeen = seen.at(-1).id;
144
+ const openNow = known.at(-1).state === "seen";
145
+ // Open since the start of the current unbroken streak, which is firstSeen unless it flapped.
146
+ let openSince = null;
147
+ for (let i = known.length - 1; i >= 0 && known[i].state === "seen"; i--) openSince = known[i].id;
148
+ const between = known.filter((s) => s.id > firstSeen && s.id < lastSeen);
149
+ const flapping = between.some((s) => s.state === "gone");
150
+ const fixedIn = openNow ? null : known.find((s) => s.id > lastSeen && s.state === "gone").id;
151
+ out.push({ ...rest, firstSeen, lastSeen, seenIn: seen.length, openNow, openSince, flapping, fixedIn });
152
+ }
153
+ return out.sort(byOpenLongest);
154
+ }
155
+
156
+ /** Open findings first, the one open longest on top; then the fixed ones, the most recently fixed on top. */
157
+ function byOpenLongest(a, b) {
158
+ if (a.openNow !== b.openNow) return a.openNow ? -1 : 1;
159
+ if (a.openNow) return a.openSince < b.openSince ? -1 : a.openSince > b.openSince ? 1 : bySeverity(a, b);
160
+ return a.fixedIn > b.fixedIn ? -1 : a.fixedIn < b.fixedIn ? 1 : bySeverity(a, b);
161
+ }
162
+
163
+ const bySeverity = (a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
164
+
165
+ /**
166
+ * The same problem on several journeys or viewports as one entry with its places, as the report groups
167
+ * findings. The first entry in timeline order speaks for the group, so its dates are the longest open.
168
+ */
169
+ function grouped(entries) {
170
+ const out = new Map();
171
+ for (const { journey, viewport, ...e } of entries) {
172
+ const k = [e.severity, e.check, e.platform, e.event].join("|");
173
+ if (out.has(k)) out.get(k).places.push({ journey, viewport });
174
+ else out.set(k, { ...e, places: [{ journey, viewport }] });
175
+ }
176
+ return [...out.values()];
177
+ }
178
+
179
+ const score = (c) => c.broken * WEIGHT.broken + c.risk * WEIGHT.risk + c.warning * WEIGHT.warning;
180
+ const mean = (xs) => xs.reduce((a, b) => a + b, 0) / xs.length;
181
+
182
+ /**
183
+ * The series for sparklines plus a summary of the window.
184
+ * series: per run { id, date, broken, risk, warning, score, complete }, oldest first. score is
185
+ * 3·broken + 2·risk + warning (lower is better); complete is false when a journey did not finish.
186
+ * direction: the mean score of the last 3 complete runs against the 3 complete runs before them
187
+ * ("improving" when lower, "worsening" when higher, else "flat"); with fewer than 4 complete runs the
188
+ * two halves are compared, and with fewer than 2 it is "flat". Incomplete runs are left out because a
189
+ * journey that stopped early hides findings rather than fixing them.
190
+ * openLongest: the 5 problems open longest; fixedRecently / newRecently: fixed or first seen in the
191
+ * last 3 runs; flapping: seen, gone and seen again in the window. Each list has one entry per problem
192
+ * with its `places` (journey and viewport, as the report groups findings) and its `runsOpen` count.
193
+ */
194
+ export function trend(history) {
195
+ const series = history.map((r) => ({ id: r.id, date: r.date, broken: r.counts.broken, risk: r.counts.risk, warning: r.counts.warning, score: score(r.counts), complete: r.journeys.every((j) => j.completed) }));
196
+ const timeline = findingTimeline(history);
197
+ const ids = history.map((r) => r.id);
198
+ const recentIds = new Set(ids.slice(-RECENT));
199
+ const withAge = (e) => ({ ...e, runsOpen: e.openNow ? ids.length - ids.indexOf(e.openSince) : 0 });
200
+ return {
201
+ runs: ids.length,
202
+ first: ids[0] ?? null,
203
+ last: ids.at(-1) ?? null,
204
+ series,
205
+ direction: direction(series.filter((p) => p.complete).map((p) => p.score)),
206
+ openLongest: grouped(timeline.filter((e) => e.openNow)).slice(0, 5).map(withAge),
207
+ fixedRecently: grouped(timeline.filter((e) => e.fixedIn && recentIds.has(e.fixedIn))).map(withAge),
208
+ newRecently: grouped(timeline.filter((e) => e.openNow && recentIds.has(e.firstSeen))).map(withAge),
209
+ flapping: grouped(timeline.filter((e) => e.flapping)).map(withAge),
210
+ };
211
+ }
212
+
213
+ function direction(scores) {
214
+ if (scores.length < 2) return "flat";
215
+ const n = Math.min(RECENT, Math.floor(scores.length / 2));
216
+ const recent = mean(scores.slice(-n));
217
+ const earlier = mean(scores.slice(-2 * n, -n));
218
+ return recent < earlier ? "improving" : recent > earlier ? "worsening" : "flat";
219
+ }
220
+
221
+ /**
222
+ * How reliably each journey finishes on this site, least reliable first: per journey and viewport
223
+ * { id, viewport, runs, completed, completionRate, lastFailure, avgHits }. A journey that keeps
224
+ * failing says the AI or the script cannot get through the site, not that the tracking is bad.
225
+ */
226
+ export function journeyStability(history) {
227
+ const out = new Map();
228
+ for (const run of history) {
229
+ for (const j of run.journeys) {
230
+ const k = `${j.id}|${j.viewport}`;
231
+ const s = out.get(k) ?? { id: j.id, viewport: j.viewport, runs: 0, completed: 0, lastFailure: null, hits: 0 };
232
+ s.runs += 1;
233
+ s.hits += j.hits;
234
+ if (j.completed) s.completed += 1;
235
+ else s.lastFailure = run.id;
236
+ out.set(k, s);
237
+ }
238
+ }
239
+ return [...out.values()]
240
+ .map(({ hits, ...s }) => ({ ...s, completionRate: s.runs ? s.completed / s.runs : 0, avgHits: s.runs ? Math.round((hits / s.runs) * 10) / 10 : 0 }))
241
+ .sort((a, b) => a.completionRate - b.completionRate || a.id.localeCompare(b.id) || String(a.viewport).localeCompare(String(b.viewport)));
242
+ }
243
+
244
+ const day = (id) => (id ? String(id).slice(0, 10) : "");
245
+ const platformLabel = (p) => (p ? PROVIDERS[p] ?? String(p) : "");
246
+ /** "GA4 purchase missing_params" or, for a finding without an event, the check name. */
247
+ const label = (e) => `${platformLabel(e.platform)} ${e.event ?? ""}`.trim().concat(` ${e.check}`).trim();
248
+ /** The strings of ui.history in the report's language; an unknown language falls back to English rather than throwing. */
249
+ const strings = (lang) => (LANGUAGES[lang] ?? LANGUAGES.en).ui.history;
250
+ /** "GA4 purchase missing_params in 4 places": the problem with its place count when it is seen in more than one. */
251
+ export const problemLabel = (e, lang = "en") => `${label(e)}${e.places?.length > 1 ? strings(lang).places(e.places.length) : ""}`;
252
+
253
+ const SPARK = "▁▂▃▄▅▆▇█";
254
+
255
+ /**
256
+ * The trend series as a text sparkline for the CLI and the alert: one character per run, the score
257
+ * mapped onto eight levels over 0..max, "·" for a run that did not complete (its lower counts are not
258
+ * a fix). The scale starts at 3 so one warning does not draw a full bar. Empty under two runs.
259
+ */
260
+ export function sparkline(t) {
261
+ const series = t?.series ?? [];
262
+ if (series.length < 2) return "";
263
+ const max = Math.max(3, ...series.map((p) => p.score));
264
+ return series.map((p) => (p.complete ? SPARK[Math.min(SPARK.length - 1, Math.round((p.score / max) * (SPARK.length - 1)))] : "·")).join("");
265
+ }
266
+
267
+ /**
268
+ * The trend as 2-3 plain lines for the CLI after a run and for the alert: the direction with the counts
269
+ * now and three runs ago, the findings open longest (up to 3), and the flapping ones when there are any.
270
+ * Pass the stability list to add a line on journeys that do not always finish.
271
+ */
272
+ export function historySummaryLines(t, lang = "en", stability = []) {
273
+ const s = strings(lang);
274
+ if (!t.runs) return [s.noRuns];
275
+ if (t.runs === 1) return [s.oneRun(t.last)];
276
+ const now = t.series.at(-1);
277
+ const was = t.series.at(-1 - RECENT);
278
+ const dir = s.direction[t.direction];
279
+ const lines = [was ? s.headline(t.runs, day(t.first), day(t.last), dir, s.counts(now), s.counts(was), RECENT) : s.headlineShort(t.runs, day(t.first), day(t.last), dir, s.counts(now))];
280
+ if (t.openLongest.length) lines.push(s.openLongest(t.openLongest.slice(0, 3).map((e) => s.since(problemLabel(e, lang), day(e.openSince), e.runsOpen))));
281
+ if (t.flapping.length) lines.push(s.flapping(t.flapping.slice(0, 3).map((e) => s.seenIn(problemLabel(e, lang), e.seenIn, t.runs))));
282
+ const unstable = stability.filter((j) => j.completionRate < 1);
283
+ if (unstable.length) lines.push(s.unstable(unstable.slice(0, 3).map((j) => `${j.id} (${j.viewport}) ${Math.round(j.completionRate * 100)}%`)));
284
+ return lines;
285
+ }
286
+
287
+ /**
288
+ * The trend as the alert shows it: the direction line with the text sparkline, the flapping line, and
289
+ * "still open after N runs" only when a problem has just reached a round number of runs (5, 10, 20, …),
290
+ * so the open-longest list does not repeat in every message and train readers to skip it.
291
+ */
292
+ export function historyAlertLines(t, lang = "en") {
293
+ const s = strings(lang);
294
+ if (!t || t.runs < 2) return [];
295
+ const lines = [`${historySummaryLines(t, lang)[0]} \`${sparkline(t)}\``];
296
+ if (t.flapping.length) lines.push(s.flapping(t.flapping.slice(0, 3).map((e) => s.seenIn(problemLabel(e, lang), e.seenIn, t.runs))));
297
+ // A round number of runs open (5, 10, 20, then every 10) is worth one line; every other week it is noise.
298
+ const milestone = (n) => [5, 10, 20].includes(n) || (n > 20 && n % 10 === 0);
299
+ const ages = [...new Set(t.openLongest.filter((e) => milestone(e.runsOpen)).map((e) => e.runsOpen))].sort((a, b) => b - a);
300
+ for (const n of ages) lines.push(s.stillOpen(n, t.openLongest.filter((e) => e.runsOpen === n).map((e) => problemLabel(e, lang))));
301
+ return lines;
302
+ }
@@ -0,0 +1,152 @@
1
+ // The Consent section: one status line per platform, the Consent Mode card and the scenarios card. Denied is red,
2
+ // granted green and "no signal" grey, all by token through chip("consent", ...).
3
+ import { consentTable } from "../consent.js";
4
+ import { isConsentEntry } from "../report-shared.js";
5
+ import { PLATFORM_ART, chip, esc, platformLabel, safeKey, sectionBlock, sortPlatforms, stepTitle, secs, use, badge } from "./lib.js";
6
+
7
+ // Google tags carry Consent Mode signals on the hit; every other platform sends none, so its state is "none".
8
+ const GOOGLE = new Set(["ga4", "google_ads"]);
9
+ const SCENARIOS = ["before", "accept", "reject", "analytics"];
10
+ const LINE_CHECKS = new Set(["before_consent", "after_reject", "consent_default_missing"]);
11
+ const MODE_CHECKS = new Set(["consent_default_missing", "consent_default_late", "consent_v2_missing", "consent_v2_update"]);
12
+ const SEVERITY_RANK = { broken: 0, risk: 1, warning: 2, info: 3 };
13
+
14
+ /** A platform's artwork, or its initials when it has none. Decorative: the chip's text names it once for screen readers. */
15
+ function platArt(key) {
16
+ const k = safeKey(key);
17
+ if (PLATFORM_ART[k]) return use(`p-${k}`, 13);
18
+ const initials = platformLabel(key).split(/\s+/).slice(0, 2).map((w) => w[0]).join("").toUpperCase();
19
+ return `<span class="plat-fb s14" aria-hidden="true">${esc(initials)}</span>`;
20
+ }
21
+
22
+ /** The worst problem row among these, or null. Accepted rows (the baseline's) do not count, as everywhere else. */
23
+ const worstRow = (rows) => rows.filter((f) => !f.accepted && f.severity in SEVERITY_RANK).sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity])[0] ?? null;
24
+
25
+ /** denied / granted for a Google platform, none for the rest; null when the tag sent nothing in that scenario. */
26
+ function stateOf(platform, c) {
27
+ if (!c?.n) return null;
28
+ if (!GOOGLE.has(platform)) return "none";
29
+ return c.cookieless >= c.n || c.adDenied >= c.n ? "denied" : "granted";
30
+ }
31
+
32
+ /** What was sent after a scenario's choice (before: with no choice yet); undefined when the scenario did not run. */
33
+ const afterOf = (sc, s) => (s === "before" ? sc.before : sc.states?.[s]?.after);
34
+
35
+ /** The consent signals of a Consent Mode command, as the chips a hit carries: key and value, coloured by value. */
36
+ function signalChips(m, signals) {
37
+ return Object.entries(signals ?? {}).filter(([, v]) => typeof v === "string")
38
+ .map(([k, v]) => {
39
+ const state = v === "granted" || v === "denied" ? v : "none";
40
+ return chip("consent", state, `<span class="k">${esc(k)}</span>${esc(v)}`);
41
+ }).join("");
42
+ }
43
+
44
+ /** A neutral outlined chip for a plain label or number. */
45
+ const plain = (html, cls = "") => `<span class="chip plain${cls ? ` ${cls}` : ""}">${html}</span>`;
46
+
47
+ /** @param {import("./shell.js").Ctx} ctx */
48
+ export function render(ctx) {
49
+ const { m, runs, rows, ids, metrics, consentScenarios: sc } = ctx;
50
+ const t = m.html.consent;
51
+ const sevChip = (sev, label) => chip("sev", sev, `${use(`sev-${sev}`, 13)}${esc(label)}`);
52
+ const stateWord = (state) => t.state[state];
53
+ const stateChip = (state, label = stateWord(state)) => chip("consent", state, esc(label));
54
+ const platChip = (key) => chip("plat", key, `${platArt(key)}${esc(platformLabel(key))}`);
55
+ const findingLink = (row, cls = "") => (row && ids.get(row) ? `<a${cls ? ` class="${cls}"` : ""} href="#${ids.get(row)}">${esc(m.html.findingLink)}</a>` : "");
56
+
57
+ /* ---- one status line per platform ---- */
58
+ const total = (platform, s) => {
59
+ const after = sc ? afterOf(sc, s) : null;
60
+ if (!after) return "";
61
+ const byEvent = after[platform] ?? {};
62
+ const count = Object.values(byEvent).reduce((a, c) => a + c.n, 0);
63
+ if (!count) return `<span class="chip zero">0</span>`;
64
+ const states = new Set(Object.values(byEvent).map((c) => stateOf(platform, c)).filter(Boolean));
65
+ // One state for every event, else the strictest: a mix that holds a denied event reads denied.
66
+ const state = states.size === 1 ? [...states][0] : states.has("denied") ? "denied" : "none";
67
+ return stateChip(state, t.tally(count, stateWord(state)));
68
+ };
69
+ const platforms = sortPlatforms(new Set(metrics.consent.platforms.map((p) => p.platform)));
70
+ const lines = platforms.map((platform) => {
71
+ const own = rows.filter((f) => LINE_CHECKS.has(f.check) && f.platform === platform);
72
+ const row = worstRow(own);
73
+ // The scenarios can show a tag sent after Reject with no finding row for it; with no row at all (not even an
74
+ // accepted one) the metric decides, so the line agrees with the flagged cell below.
75
+ const sev = row?.severity ?? (!own.length && metrics.consent.platforms.find((p) => p.platform === platform)?.status !== "pass" ? "risk" : "ok");
76
+ const cells = SCENARIOS.slice(0, 3).filter((s) => sc && afterOf(sc, s))
77
+ .map((s) => `<span class="cs-p"><span class="cs-k">${esc(t.cols[s])}</span>${total(platform, s)}</span>`).join("");
78
+ return `<li class="card sev-${sev} cs-row" id="consent-${safeKey(platform)}">${platChip(platform)}${sevChip(sev, m.severity[sev])}${cells ? `<span class="cs-cells">${cells}</span>` : ""}${findingLink(row, "cs-go")}</li>`;
79
+ }).join("");
80
+ const list = lines ? `<ul class="cs-list" aria-label="${esc(t.statusLabel)}">${lines}</ul>` : "";
81
+
82
+ /* ---- Consent Mode ---- */
83
+ const mode = metrics.consent.consentMode;
84
+ const perRun = runs.map((run, ri) => {
85
+ const cmds = (run.dataLayerLog ?? []).filter((e) => isConsentEntry(e.value) && (e.value[1] === "default" || e.value[1] === "update"));
86
+ const first = cmds.find((e) => e.value[1] === "default");
87
+ const update = cmds.find((e) => e.value[1] === "update" && (!first || e.t >= first.t));
88
+ return { run, info: mode.runs[ri], first, update, resent: cmds.filter((e) => e !== first && e !== update).length };
89
+ });
90
+ const runChip = (run) => chip("vp", run.viewport, `${run.viewport === "desktop" || run.viewport === "mobile" ? use(`ui-${run.viewport}`, 13) : ""}${esc(run.journeyId)}<span class="sr"> ${esc(run.viewport)}</span>`);
91
+ const modeFinding = worstRow(rows.filter((f) => MODE_CHECKS.has(f.check)));
92
+ const withDefault = perRun.filter((p) => p.info?.present);
93
+ const ms = withDefault.map((p) => p.info.ms).filter((x) => x !== null);
94
+ const late = mode.defaultOnTime === false;
95
+ let modeHead;
96
+ if (!runs.length) modeHead = chip("consent", "none", esc(m.ui.notChecked));
97
+ else if (!mode.defaultPresent) modeHead = modeFinding ? sevChip(modeFinding.severity, t.noDefault) : chip("consent", "none", esc(t.noDefault));
98
+ else modeHead = late ? sevChip(modeFinding?.severity ?? "risk", t.defaultLate) : sevChip("ok", t.defaultFirst);
99
+ const modeChips = `${ms.length ? plain(`${use("ui-clock", 13)}<b>${esc(t.timing(Math.min(...ms), Math.max(...ms)))}</b>`, "mono") : ""}${withDefault.length && mode.v2Signals.present ? sevChip("ok", t.v2) : withDefault.length ? sevChip("warning", t.v2Missing) : ""}`;
100
+
101
+ // Runs that pushed the same commands on the same steps share one pair of rows; a run that differs gets its own.
102
+ const groups = new Map();
103
+ for (const p of withDefault) {
104
+ const key = JSON.stringify([p.first?.value[2], p.update?.value[2], p.info.stepLabel, p.update?.step]);
105
+ if (!groups.has(key)) groups.set(key, []);
106
+ groups.get(key).push(p);
107
+ }
108
+ const commandRow = (label, p, e) => {
109
+ if (!e) return "";
110
+ const step = p.run.steps.find((s) => s.index === e.step);
111
+ const where = `${step ? `${m.ui.step} ${step.index} · ${esc(stepTitle(p.run, step))} · ` : ""}${esc(secs(p.run, e.t))}`;
112
+ return `<li><b class="cm-l">${esc(label)}</b><span class="cm-w">${where}</span><span class="cm-s">${signalChips(m, e.value[2])}</span></li>`;
113
+ };
114
+ const groupHtml = [...groups.values()].map((g) => `${groups.size > 1 ? `<div class="chips cm-runs">${g.map((p) => runChip(p.run)).join("")}</div>` : ""}<ol class="cm-list">${commandRow(m.ui.consentDefault, g[0], g[0].first)}${commandRow(m.ui.consentUpdate, g[0], g[0].update)}</ol>`).join("");
115
+ const timing = withDefault.map((p) => `<li>${runChip(p.run)}<span class="xs">${esc(m.html.consentTiming(p.info.ms, p.info.beforeFirstGoogleHit, p.info.v2Signals.present, p.info.v2Signals.missing))}</span></li>`).join("");
116
+ const resent = Math.max(0, ...withDefault.map((p) => p.resent));
117
+ const modeFindings = rows.filter((f) => MODE_CHECKS.has(f.check) && !f.accepted && ids.get(f)).map((f) => findingLink(f)).join(" ");
118
+ const none = !runs.length ? m.ui.md.consentNotChecked : m.ui.md.consentModeNone;
119
+ const modeBody = withDefault.length
120
+ ? `${timing ? `<ul class="cm-timing">${timing}</ul>` : ""}${groupHtml}${resent ? `<p class="xs muted cm-rep">${use("ui-clock", 12)} ${esc(t.resent(resent))}</p>` : ""}${modeFindings ? `<p class="xs cm-find">${modeFindings}</p>` : ""}`
121
+ : `<p class="muted xs">${esc(none)}</p>${modeFindings ? `<p class="xs cm-find">${modeFindings}</p>` : ""}`;
122
+ const modeCard = `<details class="card" id="consent-mode"><summary><span class="t">${esc(m.html.consentMode)}</span>${modeHead}${modeChips}<span class="end muted xs">${esc(t.runsWith(withDefault.length, runs.length))}</span></summary><div class="body">${modeBody}</div></details>`;
123
+
124
+ /* ---- scenarios ---- */
125
+ if (!sc) return sectionBlock("consent", esc(m.html.shell.sections.consent), `${list}\n${modeCard}\n<p class="muted xs cs-none" id="consent-scenarios">${esc(m.ui.md.noScenarios)}</p>`);
126
+ const table = consentTable(sc);
127
+ const cols = table.cols.filter((s) => s !== "analytics" || sc.states?.analytics?.via);
128
+ const before = rows.filter((f) => f.check === "before_consent" && !f.accepted);
129
+ const problemRow = (platform, event) => rows.find((f) => f.platform === platform && f.event === event && !f.accepted && /^(after_reject|before_consent)$/.test(f.check));
130
+ const reasonOf = (c) => (c.cookieless >= c.n ? m.html.cellCookieless : c.adDenied >= c.n ? m.html.cellAdDenied : m.html.cellWithCookies);
131
+ let problemCount = 0;
132
+ const cellHtml = (r, cell) => {
133
+ const after = afterOf(sc, cell.state);
134
+ if (!after || cell.count === null) return `<td class="muted">–<span class="sr"> ${esc(m.ui.scenarioNotRun)}</span></td>`;
135
+ const c = after[r.platform]?.[r.event];
136
+ if (!c?.n) return `<td class="muted">0</td>`;
137
+ const state = stateOf(r.platform, c);
138
+ const reason = reasonOf(c);
139
+ const flagged = cell.problem || (cell.state === "before" && before.some((f) => f.platform === r.platform && f.event === r.event));
140
+ const body = `<b class="num">${c.n}</b><span title="${esc(reason)}">${stateChip(state)}</span><span class="sr"> ${esc(reason)}</span>`;
141
+ if (!flagged) return `<td><span class="cs-c">${body}</span></td>`;
142
+ problemCount++;
143
+ const f = problemRow(r.platform, r.event);
144
+ return `<td class="cs-bad"><span class="cs-c">${body}${badge(f?.severity ?? "risk", t.sent)}<span class="sr"> ${esc(m.html.cellProblem)} ${esc(m.html.cellShouldNot)}</span>${findingLink(f)}</span></td>`;
145
+ };
146
+ const body = table.rows.map((r) => `<tr><th scope="row"><span class="cs-tag">${platChip(r.platform)}<code>${esc(r.event)}</code></span></th>${cols.map((s) => cellHtml(r, r.cells.find((c) => c.state === s))).join("")}</tr>`).join("");
147
+ const supported = (s) => (s === "before" ? true : Boolean(sc.states?.[s]?.after));
148
+ const summaryChips = SCENARIOS.map((s) => (supported(s) ? plain(esc(t.cols[s])) : chip("consent", "none", esc(`${t.cols[s]} · ${t.notSupported}`)))).join("");
149
+ const probChip = problemCount ? sevChip("risk", t.problems(problemCount)) : sevChip("ok", t.noProblems);
150
+ const scenarios = `<details class="card" id="consent-scenarios"><summary><span class="t">${esc(t.scenarios)}</span>${summaryChips}<span class="end">${probChip}</span></summary><div class="body"><div class="tscroll"><table class="tbl cs-tbl"><caption class="sr">${esc(m.ui.scenarios)}</caption><thead><tr><th scope="col">${esc(m.ui.scenarioTag)}</th>${cols.map((s) => `<th scope="col">${esc(t.cols[s])}</th>`).join("")}</tr></thead><tbody>${body}</tbody></table></div></div></details>`;
151
+ return sectionBlock("consent", esc(m.html.shell.sections.consent), `${list}\n${modeCard}\n${scenarios}`);
152
+ }