diff --git a/docs/reference/file-map.md b/docs/reference/file-map.md
index 88452c5..aa0f608 100644
--- a/docs/reference/file-map.md
+++ b/docs/reference/file-map.md
@@ -283,6 +283,7 @@ python tests/triage_check.py # A6 - the sidebar answers the stand-up
python tests/qa_gate_check.py # CR-014/D2/D9/D10 - QA gate + capture sink 40 checks
python tests/files_check.py # CR-007/D8 - drawings upload + real offline 36 checks
python tests/sticky_bar_check.py # B6 - save reachable on every wizard step 12 checks
+python tests/usage_check.py # D5 - one analytics core, admin report 15 checks
```
**Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live
diff --git a/html/admin.html b/html/admin.html
index e03d5a9..7a23417 100644
--- a/html/admin.html
+++ b/html/admin.html
@@ -178,10 +178,11 @@
Usage logs
-
Engagement recorded by the suite — sessions, step views, and actions. Note: stored locally per browser, so this reflects activity on this machine.
+
Engagement recorded by both tools — the work package creator and the SOP wizard —
+ sessions, actions and counts, with a download per tool (D5). Note: stored locally per browser,
+ so this reflects activity on this machine.
-
Click refresh to load.
@@ -214,6 +215,7 @@
+
+
+
-
⚙ DEV MODE — usage tracking paused. This session's actions are not being recorded.
diff --git a/html/wp-usage.js b/html/wp-usage.js
new file mode 100644
index 0000000..b4ee13a
--- /dev/null
+++ b/html/wp-usage.js
@@ -0,0 +1,58 @@
+/* Usage analytics core — the ONE implementation (D5 / T7.10).
+
+ This existed three times: the creator's copy, the wizard's copy (which had no
+ caller — the button lived on the creator), and the admin console's own reader.
+ Once the creator stopped being an iframe (B7/T7.1) the first two sat in one
+ document as five colliding globals; an unreferenced duplicate is exactly what
+ produced D5. One core now; the pages keep only a thin track() wrapper because
+ page state (the creator's dev-mode pause) belongs to the page.
+
+ The storage KEYS are unchanged on purpose: everything recorded before this
+ file existed is still readable through it. No field VALUES are ever stored —
+ a field-edit event records the field id, nothing else.
+
+ Classic script, no modules: exposes window.WPUsage. */
+'use strict';
+
+(function () {
+ var SESSION = 's_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
+
+ function load(key) {
+ try { return JSON.parse(localStorage.getItem(key)) || { events: [] }; }
+ catch (e) { return { events: [] }; }
+ }
+
+ function save(key, data) {
+ try { localStorage.setItem(key, JSON.stringify(data)); }
+ catch (e) { /* storage unavailable — degrade silently */ }
+ }
+
+ function track(key, event, detail) {
+ try {
+ var d = load(key);
+ d.events.push({ ts: new Date().toISOString(), session: SESSION, event: event, detail: detail || null });
+ if (d.events.length > 5000) d.events = d.events.slice(-5000);
+ save(key, d);
+ } catch (e) { /* never let telemetry break the tool it watches */ }
+ }
+
+ function download(key, prefix) {
+ var blob = new Blob([JSON.stringify(load(key), null, 2)], { type: 'application/json' });
+ var a = document.createElement('a');
+ a.href = URL.createObjectURL(blob);
+ a.download = (prefix || 'wp-usage') + '-' + new Date().toISOString().slice(0, 10) + '.json';
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000);
+ }
+
+ window.WPUsage = {
+ load: load,
+ save: save,
+ track: track,
+ download: download,
+ // The pre-D5 keys, verbatim — continuity of the recorded data is a done-when.
+ KEYS: { creator: 'wp_iwp_analytics_v1', wizard: 'wp_suite_analytics_v1' },
+ };
+})();
diff --git a/tests/cards_check.py b/tests/cards_check.py
index 57815bf..2139b10 100644
--- a/tests/cards_check.py
+++ b/tests/cards_check.py
@@ -235,10 +235,17 @@ def run(page, base, tok, db_path):
for needle in ("Localization defaults", "L10N_LOCALES", "L10N_ZONES",
"fillLocalization", "saveLocalization", "set-locale", "set-tz"):
chk("admin.js still has %s" % needle, needle in src)
- changed = subprocess.run(
- ["git", "diff", "--stat", "HEAD", "--", "html/admin.js"],
- cwd=ROOT, capture_output=True, text=True).stdout.strip()
- chk("...and this task changed nothing in it at all", not changed, changed)
+ # Re-pointed at T7.10, not relaxed. This asserted admin.js was byte-identical
+ # to HEAD - right for T6.5, whose task touched nothing there, but as a
+ # standing probe it failed every LEGITIMATE later edit (D5 moved the usage
+ # report into admin.js by name). A7's protection is the feature checks above
+ # plus the end-to-end localization drive - so pin the localization BLOCK
+ # instead: its functions must not merely exist, they must be uncalled by
+ # nothing, i.e. still wired to the controls that ship the feature.
+ chk("...and the localization block is still wired to its controls",
+ "fillLocalization()" in src
+ and 'onclick="saveLocalization()"' in src
+ and "set-locale" in src)
def main():
diff --git a/tests/frame_check.py b/tests/frame_check.py
index 4a6d5e6..2c9054c 100644
--- a/tests/frame_check.py
+++ b/tests/frame_check.py
@@ -244,8 +244,13 @@ def page_checks(page, base, tok):
});
return JSON.stringify(out);
})()"""))
- for label in ("Sample SOP", "Load example", "View SOP", "Import SOP", "Usage data"):
+ # "Usage data" left this list at T7.10: D5 moved the report to the admin
+ # console. Its absence HERE is asserted, so the button cannot quietly return
+ # and recreate the duplicate D5 existed to remove.
+ for label in ("Sample SOP", "Load example", "View SOP", "Import SOP"):
chk("%-13s is visible on the unframed page" % label, vis.get(label) is True, vis)
+ chk("Usage data is GONE from the creator toolbar (D5)",
+ "Usage data" not in vis, vis)
chk("...and every one of them has a handler that exists", page.eval("""(() => {
return [...document.querySelectorAll('#wp-toolbar button')].every(b => {
const m = (b.getAttribute('onclick') || '').match(/^([A-Za-z_$][\\w$]*)\\(/);
diff --git a/tests/usage_check.py b/tests/usage_check.py
new file mode 100644
index 0000000..b99ca8f
--- /dev/null
+++ b/tests/usage_check.py
@@ -0,0 +1,192 @@
+#!/usr/bin/env python3
+"""Is there exactly one analytics implementation, reported from admin? — D5, T7.10.
+
+Usage analytics existed twice (creator + wizard), five of the nine colliding
+globals `creator-frame.md` counted, and the wizard's copy had no caller. One
+core survives in wp-usage.js; the pages keep thin track() wrappers; the report
+and its downloads live on the admin console behind the same role gate as the
+rest of that page. Keys are unchanged, so pre-move data still reads.
+
+Self-contained: throwaway SQLite, its own uvicorn, headless Edge or Chrome.
+Exit 0 all passed, 1 a failure, 2 could not run.
+"""
+import json
+import os
+import re
+import sys
+import tempfile
+import time
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+import cdp # noqa: E402
+from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402
+from sections_check import set_sop # noqa: E402
+from stepper_check import dismiss_dialogs # noqa: E402
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+HTML = os.path.join(ROOT, "html")
+
+
+def ascii_(v, n=280):
+ return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
+
+
+def settle(seconds=0.6):
+ time.sleep(seconds)
+
+
+def strip_js(src):
+ src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
+ return "\n".join(re.sub(r"(? {
+ const d = JSON.parse(localStorage.getItem('wp_iwp_analytics_v1')) || {events: []};
+ d.events.unshift({ts: '2026-07-01T10:00:00Z', session: 's_legacy',
+ event: 'legacy_probe_event', detail: null});
+ localStorage.setItem('wp_iwp_analytics_v1', JSON.stringify(d));
+ })()""")
+ n0 = page.eval("WPUsage.load(WPUsage.KEYS.creator).events.length")
+ page.eval("track('probe_event')")
+ chk("the creator's track() still lands events under the old key",
+ page.eval("WPUsage.load(WPUsage.KEYS.creator).events.length") == n0 + 1)
+
+ page.goto(base + "/work-package-suite.html?tab=sop")
+ dismiss_dialogs(page)
+ settle(2.0)
+ n0 = page.eval("WPUsage.load(WPUsage.KEYS.wizard).events.length")
+ page.eval("track('probe_event_wizard')")
+ chk("the wizard's track() still lands events under its old key",
+ page.eval("WPUsage.load(WPUsage.KEYS.wizard).events.length") == n0 + 1)
+ wiz_errors = [e for e in page.js_errors() if "beforeunload" not in e]
+ chk("...and the wizard page throws no errors without its old globals "
+ "(the blocked-beforeunload console line is BL-020, filtered not hidden)",
+ not wiz_errors, ascii_(wiz_errors[:2]))
+
+ # ── 3. the report, on admin, behind the admin gate ───────────────────
+ print("\n3. the admin report")
+ page.goto(base + "/admin.html")
+ dismiss_dialogs(page)
+ settle(2.0)
+ page.eval("loadUsage()")
+ settle(0.5)
+ report = page.eval("(document.getElementById('usage-admin')||{textContent:''}).textContent")
+ chk("usage data is reachable from admin.html, both tools reported",
+ "Work package creator" in report and "SOP wizard" in report, ascii_(report, 160))
+ chk("the pre-move legacy event is readable in the report",
+ "legacy_probe_event" in report)
+ chk("this session's fresh events are in it too",
+ "probe_event" in report and "probe_event_wizard" in report)
+ chk("each tool offers its download from the report",
+ page.eval("[...document.querySelectorAll('#usage-admin button')].length") >= 2)
+
+ page.viewport(390, 844, mobile=True)
+ settle(0.6)
+ fits = page.eval("""(() => {
+ const box = document.getElementById('usage-admin');
+ return box && box.scrollWidth <= box.clientWidth + 2
+ && document.documentElement.scrollWidth <= 392;
+ })()""")
+ chk("the report is usable at 390px - no sideways scrolling", bool(fits))
+ page.viewport(1440, 900)
+ settle(0.4)
+
+ # The same role gate as the rest of the console: a non-admin sees the
+ # denied card and no cards, this one included.
+ page.clear_cookies()
+ page.set_cookie("wp_session", tok["pat"])
+ page.goto(base + "/admin.html")
+ dismiss_dialogs(page)
+ settle(2.0)
+ chk("a non-administrator gets the denied notice, not the usage report",
+ page.eval("""(() => {
+ const denied = document.getElementById('admin-denied');
+ const usage = document.getElementById('usage-admin');
+ const visible = el => !!el && el.offsetParent !== null;
+ return visible(denied) && !visible(usage);
+ })()"""))
+
+ js_errors = [e for e in page.js_errors() if "beforeunload" not in e]
+ chk("no JavaScript errors anywhere in this run", not js_errors,
+ ascii_(js_errors[:2]))
+
+ finally:
+ if browser is not None:
+ try:
+ browser.close()
+ except Exception:
+ pass
+ if server is not None:
+ try:
+ server.terminate()
+ except Exception:
+ pass
+
+ print("\n" + "-" * 54)
+ print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
+ for f in _FAIL:
+ print(" - " + f)
+ return 1 if _FAIL else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())