@artymclabin/qa-review 0.3.12 → 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
  }
@@ -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.12",
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": {