@artymclabin/qa-review 0.3.11 → 0.3.13

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/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.13 - 2026-10-07
4
+
5
+ - JOURNEY-WIDE TOTALS: in a cross-page journey the footer and the finish card
6
+ now show approve/reject totals across EVERY page (`whole journey: N approved
7
+ · M rejected · T items`), next to the page line (now labelled `this page,
8
+ all runs`). Previously the only ledger count was page-scoped and unlabelled,
9
+ so at the end of a ten-page journey with 12 verdicts it read "1 approved".
10
+ New exports: `fetchJourneyVerdicts`, `tallyJourney`, `VerdictTally`.
11
+ - RUN SNAPSHOT SAVES ITSELF: when `submitUrl` is set, the whole-run snapshot
12
+ is POSTed automatically once per finished round. The "Save session
13
+ snapshot (optional)" button is gone - it asked the reviewer for a decision
14
+ with no stake in it. A failed save shows as a warning line.
15
+
3
16
  ## 0.3.10 - 2026-09-19
4
17
 
5
18
  - OUTSIDE TAP MINIMIZES THE PANEL, ON MOBILE: the only way to collapse the
@@ -34,7 +34,7 @@ import { createPortal } from "react-dom";
34
34
  import { Check, X, ChevronLeft, ChevronRight, ClipboardCopy, PartyPopper, Database, Undo2, Crosshair, Locate, ExternalLink, Minimize2, Monitor, Smartphone, ClipboardCheck, } from "lucide-react";
35
35
  import { isTaskItem } from "./types.js";
36
36
  import { QAStore } from "./store.js";
37
- import { buildJourneyNavUrl, ensurePrefetchLink, fetchPendingCounts, isPrefetchFresh, journeyFinishView, journeyIndex, nextPendingPage, resolveFinishAction, shouldPrefetch, } from "./journey.js";
37
+ import { buildJourneyNavUrl, ensurePrefetchLink, fetchJourneyVerdicts, fetchPendingCounts, isPrefetchFresh, journeyFinishView, journeyIndex, nextPendingPage, resolveFinishAction, shouldPrefetch, tallyJourney, } from "./journey.js";
38
38
  import { fingerprintStatus, itemFingerprint } from "./fingerprint.js";
39
39
  import { DEVICE_LABEL, DEVICE_TOOLTIP, approvedDevicesOf, detectDevice, isFullyApproved, requiredDevices, toggleDevice, } from "./device.js";
40
40
  import { applySubHighlights } from "./highlight.js";
@@ -211,6 +211,8 @@ export function QAReviewOverlay({ items, target, gateParam, stateUrl, submitUrl,
211
211
  // Journey: pending counts (for the journey-complete panel only - page-level
212
212
  // completion navigates IMMEDIATELY with no interstitial).
213
213
  const [journeyCounts, setJourneyCounts] = React.useState(null);
214
+ // Ledger maps of every journey page, for journey-wide approve/reject totals.
215
+ const [journeyMaps, setJourneyMaps] = React.useState(null);
214
216
  const [journeyComplete, setJourneyComplete] = React.useState(false);
215
217
  // The journey page a full-page navigation is in flight to (loading label).
216
218
  const [navigatingTo, setNavigatingTo] = React.useState(null);
@@ -725,6 +727,21 @@ export function QAReviewOverlay({ items, target, gateParam, stateUrl, submitUrl,
725
727
  };
726
728
  // eslint-disable-next-line react-hooks/exhaustive-deps
727
729
  }, [active, finished, inJourney, index, roundTotal]);
730
+ // Journey-wide totals: load every page's ledger once on activation and
731
+ // again when the round finishes (other tabs/sessions may have written).
732
+ React.useEffect(() => {
733
+ if (!active || !inJourney)
734
+ return;
735
+ let cancelled = false;
736
+ void fetchJourneyVerdicts(stateUrl ?? DEFAULT_STATE_URL, journey.pages).then((maps) => {
737
+ if (!cancelled)
738
+ setJourneyMaps(maps);
739
+ });
740
+ return () => {
741
+ cancelled = true;
742
+ };
743
+ // eslint-disable-next-line react-hooks/exhaustive-deps
744
+ }, [active, inJourney, finished]);
728
745
  // Journey: the moment this page's round is done, resolve the next step and
729
746
  // navigate IMMEDIATELY (no interstitial, no success popup). Uses the
730
747
  // PREFETCHED counts when fresh (instant hop); falls back to an on-demand
@@ -851,6 +868,23 @@ export function QAReviewOverlay({ items, target, gateParam, stateUrl, submitUrl,
851
868
  setSave({ status: "error", message: "Network error - use Copy JSON instead." });
852
869
  }
853
870
  }, [submitUrl, buildPayload]);
871
+ // 🚨 The run snapshot saves ITSELF when a round finishes (0.3.13). It used to
872
+ // be a "Save session snapshot (optional)" button, which asked the reviewer to
873
+ // make a decision with no stake in it: Arty 2026-10-07, "Either save it or
874
+ // don't save it. If it's useful, save it... Why did you ask me to click that
875
+ // button instead?" Once per finish; a failure surfaces as a warning only.
876
+ const snapshotDoneRef = React.useRef(false);
877
+ React.useEffect(() => {
878
+ if (!finished) {
879
+ snapshotDoneRef.current = false;
880
+ return;
881
+ }
882
+ // Nothing decided this round = nothing to snapshot.
883
+ if (!submitUrl || snapshotDoneRef.current || Object.keys(sessionVerdicts).length === 0)
884
+ return;
885
+ snapshotDoneRef.current = true;
886
+ void saveToDb();
887
+ }, [finished, submitUrl, saveToDb, sessionVerdicts]);
854
888
  if (!active || !hydrated || typeof document === "undefined")
855
889
  return null;
856
890
  // Inline CSS-variable overrides for the injected default theme.
@@ -863,6 +897,8 @@ export function QAReviewOverlay({ items, target, gateParam, stateUrl, submitUrl,
863
897
  // LEDGER (all-time on this page) vs THIS ROUND (acted on in this run).
864
898
  const approved = Object.values(results).filter((r) => r.verdict === "approve").length;
865
899
  const rejected = Object.values(results).filter((r) => r.verdict === "reject").length;
900
+ const liveMap = Object.fromEntries(Object.values(results).map((r) => [r.id, { verdict: r.verdict }]));
901
+ const journeyTally = inJourney && journeyMaps ? tallyJourney(journey.pages, journeyMaps, { [target]: liveMap }) : null;
866
902
  const sessionApproved = Object.values(sessionVerdicts).filter((v) => v === "approve").length;
867
903
  const sessionRejected = Object.values(sessionVerdicts).filter((v) => v === "reject").length;
868
904
  /* ------------------------------ finish panel ------------------------------ */
@@ -876,14 +912,10 @@ export function QAReviewOverlay({ items, target, gateParam, stateUrl, submitUrl,
876
912
  ? `Loading ${navigatingTo.label ?? navigatingTo.path}…`
877
913
  : "Checking remaining pages…", _jsx("span", { className: "qar-muted", style: { fontSize: 11, fontWeight: 400 }, children: "The review continues on the next page - do not close this tab." })] }) }), document.body);
878
914
  }
879
- return createPortal(_jsx("div", { className: "qar-theme qar-finish-backdrop", style: themeStyle, children: _jsxs("div", { className: "qar-finish-card", children: [_jsx(PartyPopper, { className: "qar-finish-icon", size: 40, "aria-hidden": true }), _jsx("h2", { className: "qar-finish-title", children: inJourney ? "Journey complete" : "QA review complete" }), _jsxs("p", { className: "qar-finish-stats", "data-qatip": "Verdicts you recorded in THIS review run", children: [_jsxs("span", { className: "qar-green", children: [sessionApproved, " approved"] }), " \u00B7", " ", _jsxs("span", { className: "qar-red", children: [sessionRejected, " rejected"] }), " \u00B7 this round"] }), _jsxs("p", { className: "qar-finish-alltime", "data-qatip": "Ledger totals for this page across all runs (durable review database)", children: ["all-time on this page: ", approved, " approved \u00B7 ", rejected, " rejected \u00B7 ", total, " items"] }), _jsxs("p", { className: "qar-finish-autosaved", "data-qatip": "Every verdict was written to the server ledger the moment you decided it", children: [_jsx(Database, { size: 14, "aria-hidden": true }), " Auto-saved to the review database as you go."] }), inJourney && journeyCounts && (_jsx("div", { className: "qar-journey", children: journey.pages.map((p, i) => {
915
+ return createPortal(_jsx("div", { className: "qar-theme qar-finish-backdrop", style: themeStyle, children: _jsxs("div", { className: "qar-finish-card", children: [_jsx(PartyPopper, { className: "qar-finish-icon", size: 40, "aria-hidden": true }), _jsx("h2", { className: "qar-finish-title", children: inJourney ? "Journey complete" : "QA review complete" }), _jsxs("p", { className: "qar-finish-stats", "data-qatip": "Verdicts you recorded in THIS review run", children: [_jsxs("span", { className: "qar-green", children: [sessionApproved, " approved"] }), " \u00B7", " ", _jsxs("span", { className: "qar-red", children: [sessionRejected, " rejected"] }), " \u00B7 this round"] }), _jsxs("p", { className: "qar-finish-alltime", "data-qatip": "Ledger totals for this page across all runs (durable review database)", children: ["this page, all runs: ", approved, " approved \u00B7 ", rejected, " rejected \u00B7 ", total, " items"] }), journeyTally && (_jsxs("p", { className: "qar-finish-alltime", "data-qatip": "Ledger totals across every page of this journey", children: ["whole journey: ", journeyTally.approved, " approved \u00B7 ", journeyTally.rejected, " rejected \u00B7", " ", journeyTally.total, " items"] })), _jsxs("p", { className: "qar-finish-autosaved", "data-qatip": "Every verdict was written to the server ledger the moment you decided it", children: [_jsx(Database, { size: 14, "aria-hidden": true }), " Auto-saved to the review database as you go."] }), inJourney && journeyCounts && (_jsx("div", { className: "qar-journey", children: journey.pages.map((p, i) => {
880
916
  const left = journeyCounts[p.target] ?? 0;
881
917
  return (_jsxs("div", { className: `qar-journey-row${i === journeyIdx ? " qar-current" : ""}`, children: [_jsxs("span", { children: [p.label ?? p.path, i === journeyIdx ? " (here)" : ""] }), _jsx("span", { className: left === 0 ? "qar-journey-done" : "qar-journey-left", children: left === 0 ? "✓ done" : `${left} left` })] }, p.target));
882
- }) })), _jsxs("div", { className: "qar-finish-actions", children: [submitUrl && (_jsxs(_Fragment, { children: [_jsxs("button", { type: "button", onClick: saveToDb, disabled: save.status === "saving" || save.status === "ok", className: "qar-btn-primary", "data-qatip": "Store a snapshot of this whole run (verdicts are already saved individually)", children: [_jsx(Database, { size: 16, "aria-hidden": true }), save.status === "saving"
883
- ? "Saving…"
884
- : save.status === "ok"
885
- ? "Snapshot saved ✓"
886
- : "Save session snapshot (optional)"] }), save.status !== "ok" && (_jsx("p", { className: "qar-finish-autosaved", style: { marginTop: 4 }, children: "Your verdicts above are already saved - this only logs a snapshot of the whole run." })), save.status === "error" && _jsx("p", { className: "qar-warn", children: save.message })] })), _jsxs("button", { type: "button", onClick: copyResults, className: "qar-btn-outline-accent", "data-qatip": "Copy all verdicts of this page as JSON", children: [_jsx(ClipboardCopy, { size: 16, "aria-hidden": true }), copied ? "Copied!" : "Copy JSON"] }), roundTotal > 0 && (_jsx("button", { type: "button", onClick: () => {
918
+ }) })), _jsxs("div", { className: "qar-finish-actions", children: [save.status === "error" && (_jsxs("p", { className: "qar-warn", children: ["Run snapshot not saved: ", save.message] })), _jsxs("button", { type: "button", onClick: copyResults, className: "qar-btn-outline-accent", "data-qatip": "Copy all verdicts of this page as JSON", children: [_jsx(ClipboardCopy, { size: 16, "aria-hidden": true }), copied ? "Copied!" : "Copy JSON"] }), roundTotal > 0 && (_jsx("button", { type: "button", onClick: () => {
887
919
  setFinished(false);
888
920
  // 🚨 THE LAST ITEM, NOT THE FIRST (Arty 2026-08-09: "Clicking
889
921
  // on 'Back to review' in the QA interactive panel should go to
@@ -988,5 +1020,5 @@ export function QAReviewOverlay({ items, target, gateParam, stateUrl, submitUrl,
988
1020
  return (_jsxs("button", { type: "button", onClick: () => approveDevice(d), className: cls, "data-qatip": `${DEVICE_TOOLTIP[d]}${isHint ? " (A)" : ""}${done ? " - already approved" : ""}`, children: [multiDevice ? (d === "pc" ? (_jsx(Monitor, { size: 16, "aria-hidden": true })) : (_jsx(Smartphone, { size: 16, "aria-hidden": true }))) : (_jsx(Check, { size: 16, "aria-hidden": true })), multiDevice ? `${done ? "✓ " : ""}${DEVICE_LABEL[d]}` : "Approve"] }, d));
989
1021
  }), _jsx("button", { type: "button", onClick: next, disabled: !hasNext, "aria-label": "Next item", "data-qatip": index >= roundTotal - 1 && inJourney
990
1022
  ? "Finish this page and continue the journey (→)"
991
- : "Next item (→)", className: "qar-nav-btn", children: _jsx(ChevronRight, { size: 16 }) })] }), _jsxs("div", { className: "qar-footer", children: [_jsx("span", { "data-qatip": "Keyboard shortcuts", children: "keys: A approve \u00B7 R reject \u00B7 \u2190/\u2192 \u00B7 U undo \u00B7 M minimize \u00B7 Esc" }), _jsxs("span", { children: [_jsxs("span", { className: "qar-footer-green", "data-qatip": "Items decided in this round", children: [decidedInRound, "/", roundTotal, " this round"] }), " ", _jsxs("span", { "data-qatip": "Fully approved items on this page across all runs (ledger)", children: ["\u00B7 ", approvedCount, " approved all-time"] })] })] })] })] }), document.body);
1023
+ : "Next item (→)", className: "qar-nav-btn", children: _jsx(ChevronRight, { size: 16 }) })] }), _jsxs("div", { className: "qar-footer", children: [_jsx("span", { "data-qatip": "Keyboard shortcuts", children: "keys: A approve \u00B7 R reject \u00B7 \u2190/\u2192 \u00B7 U undo \u00B7 M minimize \u00B7 Esc" }), _jsxs("span", { children: [_jsxs("span", { className: "qar-footer-green", "data-qatip": "Items decided in this round", children: [decidedInRound, "/", roundTotal, " this round"] }), " ", journeyTally ? (_jsxs("span", { "data-qatip": "Ledger totals across every page of this journey", children: ["\u00B7 journey: ", journeyTally.approved, " approved \u00B7 ", journeyTally.rejected, " rejected"] })) : (_jsxs("span", { "data-qatip": "Items on this page across all runs (ledger)", children: ["\u00B7 this page: ", approvedCount, " approved \u00B7 ", rejected, " rejected"] }))] })] })] })] }), document.body);
992
1024
  }
@@ -5,9 +5,24 @@ export declare function normalizeText(text: string): string;
5
5
  export declare function fingerprintText(text: string): string;
6
6
  /**
7
7
  * Fingerprint the CURRENT content of an item: anchored items hash the
8
- * element's rendered innerText; off-DOM task items hash their question text
9
- * (sub, falling back to title). Returns null when the anchor is not present
10
- * (e.g. other-viewport element) - no judgement is made then.
8
+ * element's textContent; off-DOM task items hash their question text (sub,
9
+ * falling back to title). Returns null when the anchor is not present (e.g.
10
+ * other-viewport element) - no judgement is made then.
11
+ *
12
+ * 🚨 textContent, NEVER innerText (Arty 2026-09-24, GameReady fundraise FAQ).
13
+ * innerText is spec'd to approximate what a user currently SEES, so it
14
+ * excludes anything not laid out - which includes the body of a closed
15
+ * native `<details>`. Every accordion-style FAQ defaults to collapsed, so an
16
+ * anchor on the answer paragraph inside it fingerprinted ONLY the always-
17
+ * visible summary/question text forever, regardless of how the answer was
18
+ * edited. "NOT ALTERED since your rejection" then showed on an item that
19
+ * had, in fact, been fixed the same day it was rejected - the exact
20
+ * regression this poka-yoke exists to prevent, silently defeated by the one
21
+ * DOM shape (collapsed accordion) FAQ content overwhelmingly uses. textContent
22
+ * does not care about layout/visibility, which is what "did the words
23
+ * change" actually needs - this was invisible in tests because jsdom does
24
+ * not implement `<details>` collapse or innerText's visibility semantics at
25
+ * all, so the bug could only ever surface against a real rendered page.
11
26
  */
12
27
  export declare function itemFingerprint(item: QAReviewItem, doc: Document): string | null;
13
28
  export type FingerprintStatus = "unchanged" | "changed";
@@ -16,9 +16,24 @@ export function fingerprintText(text) {
16
16
  }
17
17
  /**
18
18
  * Fingerprint the CURRENT content of an item: anchored items hash the
19
- * element's rendered innerText; off-DOM task items hash their question text
20
- * (sub, falling back to title). Returns null when the anchor is not present
21
- * (e.g. other-viewport element) - no judgement is made then.
19
+ * element's textContent; off-DOM task items hash their question text (sub,
20
+ * falling back to title). Returns null when the anchor is not present (e.g.
21
+ * other-viewport element) - no judgement is made then.
22
+ *
23
+ * 🚨 textContent, NEVER innerText (Arty 2026-09-24, GameReady fundraise FAQ).
24
+ * innerText is spec'd to approximate what a user currently SEES, so it
25
+ * excludes anything not laid out - which includes the body of a closed
26
+ * native `<details>`. Every accordion-style FAQ defaults to collapsed, so an
27
+ * anchor on the answer paragraph inside it fingerprinted ONLY the always-
28
+ * visible summary/question text forever, regardless of how the answer was
29
+ * edited. "NOT ALTERED since your rejection" then showed on an item that
30
+ * had, in fact, been fixed the same day it was rejected - the exact
31
+ * regression this poka-yoke exists to prevent, silently defeated by the one
32
+ * DOM shape (collapsed accordion) FAQ content overwhelmingly uses. textContent
33
+ * does not care about layout/visibility, which is what "did the words
34
+ * change" actually needs - this was invisible in tests because jsdom does
35
+ * not implement `<details>` collapse or innerText's visibility semantics at
36
+ * all, so the bug could only ever surface against a real rendered page.
22
37
  */
23
38
  export function itemFingerprint(item, doc) {
24
39
  if (!item.selector)
@@ -26,7 +41,7 @@ export function itemFingerprint(item, doc) {
26
41
  const el = doc.querySelector(item.selector);
27
42
  if (!el)
28
43
  return null;
29
- return fingerprintText(el.innerText ?? el.textContent ?? "");
44
+ return fingerprintText(el.textContent ?? "");
30
45
  }
31
46
  /**
32
47
  * Compare the fingerprint STORED with the last verdict against the current
@@ -1,6 +1,6 @@
1
1
  export { QAReviewOverlay, isClickGesture, nextMinimized, AUTO_BUBBLE_MAX_WIDTH_PX, type BubbleEvent, type QAReviewOverlayProps, } from "./QAReviewOverlay.js";
2
2
  export { QAStore, createQAStore, targetFromLocation, type QAStoreOptions, type PersistedVerdict, type PendingOp, type VerdictMap, } from "./store.js";
3
- export { journeyIndex, countPending, countUnverdicted, nextPendingPage, buildJourneyNavUrl, fetchPendingCounts, resolveFinishAction, journeyFinishView, shouldPrefetch, isPrefetchFresh, ensurePrefetchLink, PREFETCH_WINDOW_ITEMS, PREFETCH_MAX_AGE_MS, type FinishAction, type QAJourneyConfig, type QAJourneyPage, } from "./journey.js";
3
+ export { journeyIndex, countPending, countUnverdicted, nextPendingPage, buildJourneyNavUrl, fetchPendingCounts, fetchJourneyVerdicts, tallyJourney, resolveFinishAction, journeyFinishView, shouldPrefetch, isPrefetchFresh, ensurePrefetchLink, PREFETCH_WINDOW_ITEMS, PREFETCH_MAX_AGE_MS, type FinishAction, type QAJourneyConfig, type QAJourneyPage, type VerdictTally, } from "./journey.js";
4
4
  export { normalizeText, fingerprintText, itemFingerprint, fingerprintStatus, type FingerprintStatus, } from "./fingerprint.js";
5
5
  export { requiredDevices, approvedDevicesOf, isFullyApproved, nextApprovedDevices, toggleDevice, detectDevice, DEVICE_LABEL, DEVICE_TOOLTIP, type QADevice, } from "./device.js";
6
6
  export { findMatchRanges, applySubHighlights, type MatchRange } from "./highlight.js";
@@ -1,6 +1,6 @@
1
1
  export { QAReviewOverlay, isClickGesture, nextMinimized, AUTO_BUBBLE_MAX_WIDTH_PX, } from "./QAReviewOverlay.js";
2
2
  export { QAStore, createQAStore, targetFromLocation, } from "./store.js";
3
- export { journeyIndex, countPending, countUnverdicted, nextPendingPage, buildJourneyNavUrl, fetchPendingCounts, resolveFinishAction, journeyFinishView, shouldPrefetch, isPrefetchFresh, ensurePrefetchLink, PREFETCH_WINDOW_ITEMS, PREFETCH_MAX_AGE_MS, } from "./journey.js";
3
+ export { journeyIndex, countPending, countUnverdicted, nextPendingPage, buildJourneyNavUrl, fetchPendingCounts, fetchJourneyVerdicts, tallyJourney, resolveFinishAction, journeyFinishView, shouldPrefetch, isPrefetchFresh, ensurePrefetchLink, PREFETCH_WINDOW_ITEMS, PREFETCH_MAX_AGE_MS, } from "./journey.js";
4
4
  export { normalizeText, fingerprintText, itemFingerprint, fingerprintStatus, } from "./fingerprint.js";
5
5
  export { requiredDevices, approvedDevicesOf, isFullyApproved, nextApprovedDevices, toggleDevice, detectDevice, DEVICE_LABEL, DEVICE_TOOLTIP, } from "./device.js";
6
6
  export { findMatchRanges, applySubHighlights } from "./highlight.js";
@@ -87,3 +87,21 @@ export declare function ensurePrefetchLink(doc: Document, href: string): void;
87
87
  * a page silently).
88
88
  */
89
89
  export declare function fetchPendingCounts(stateUrl: string, pages: readonly QAJourneyPage[]): Promise<Record<string, number>>;
90
+ /** Ledger verdict map per journey page target (null = fetch failed). */
91
+ export declare function fetchJourneyVerdicts(stateUrl: string, pages: readonly QAJourneyPage[]): Promise<Record<string, VerdictMap | null>>;
92
+ export interface VerdictTally {
93
+ approved: number;
94
+ rejected: number;
95
+ total: number;
96
+ }
97
+ /**
98
+ * Approve / reject / item totals over a whole journey (0.3.13). Counts only the
99
+ * page's declared `itemIds`, so stale ledger rows for retired items never
100
+ * inflate the numbers. `overrides` replaces a page's ledger map with live state
101
+ * (the current page's in-memory verdicts are fresher than any fetch).
102
+ *
103
+ * 🚨 Arty 2026-10-07: the panel said "1 approved, 0 rejected" at the end of a
104
+ * ten-page journey he had just walked through with 12 verdicts - the count was
105
+ * page-scoped and unlabeled, so it read as the whole review.
106
+ */
107
+ export declare function tallyJourney(pages: readonly QAJourneyPage[], maps: Readonly<Record<string, VerdictMap | null | undefined>>, overrides?: Readonly<Record<string, VerdictMap>>): VerdictTally;
@@ -112,6 +112,14 @@ export function ensurePrefetchLink(doc, href) {
112
112
  * a page silently).
113
113
  */
114
114
  export async function fetchPendingCounts(stateUrl, pages) {
115
+ const maps = await fetchJourneyVerdicts(stateUrl, pages);
116
+ const out = {};
117
+ for (const p of pages)
118
+ out[p.target] = countUnverdicted(p.itemIds, maps[p.target]);
119
+ return out;
120
+ }
121
+ /** Ledger verdict map per journey page target (null = fetch failed). */
122
+ export async function fetchJourneyVerdicts(stateUrl, pages) {
115
123
  const out = {};
116
124
  await Promise.all(pages.map(async (p) => {
117
125
  let map = null;
@@ -128,7 +136,32 @@ export async function fetchPendingCounts(stateUrl, pages) {
128
136
  catch {
129
137
  map = null;
130
138
  }
131
- out[p.target] = countUnverdicted(p.itemIds, map);
139
+ out[p.target] = map;
132
140
  }));
133
141
  return out;
134
142
  }
143
+ /**
144
+ * Approve / reject / item totals over a whole journey (0.3.13). Counts only the
145
+ * page's declared `itemIds`, so stale ledger rows for retired items never
146
+ * inflate the numbers. `overrides` replaces a page's ledger map with live state
147
+ * (the current page's in-memory verdicts are fresher than any fetch).
148
+ *
149
+ * 🚨 Arty 2026-10-07: the panel said "1 approved, 0 rejected" at the end of a
150
+ * ten-page journey he had just walked through with 12 verdicts - the count was
151
+ * page-scoped and unlabeled, so it read as the whole review.
152
+ */
153
+ export function tallyJourney(pages, maps, overrides = {}) {
154
+ const t = { approved: 0, rejected: 0, total: 0 };
155
+ for (const p of pages) {
156
+ const map = overrides[p.target] ?? maps[p.target];
157
+ t.total += p.itemIds.length;
158
+ for (const id of p.itemIds) {
159
+ const v = map?.[id]?.verdict;
160
+ if (v === "approve")
161
+ t.approved++;
162
+ else if (v === "reject")
163
+ t.rejected++;
164
+ }
165
+ }
166
+ return t;
167
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@artymclabin/qa-review",
3
- "version": "0.3.11",
3
+ "version": "0.3.13",
4
4
  "description": "Interactive on-page QA review overlay (spotlight walkthrough, approve/reject verdict ledger) with a framework-agnostic server handler factory and a self-provisioning Postgres store.",
5
5
  "license": "MIT",
6
6
  "repository": {