T7.10 - D5: one analytics implementation, and its report on the admin console

Usage analytics existed as five of the nine colliding globals creator-frame.md
counted (ANALYTICS_KEY, analyticsLoad, analyticsSave, downloadAnalytics,
showAnalytics), twice - and the wizard's copy had no caller, because the
button lived on the creator. The admin console had a THIRD private reader
(usageLoad/downloadUsage) that only saw the wizard's key.

Now: ONE core, html/wp-usage.js (window.WPUsage: load/save/track/download +
the two pre-move storage keys, verbatim). The creator and wizard keep only a
thin track() wrapper - page state like the creator's dev-mode pause belongs
to the page - and record exactly what they recorded before, under the same
keys, so everything captured before this task still reads (probe plants a
legacy-format event and finds it in the report). The "Usage data" button left
the creator toolbar; the report lives in admin.html's usage card, covering
BOTH tools with a download each, behind the same admin gate as the rest of
the console (a non-admin sees the denied card and nothing else), usable at
390px.

Two probes re-pointed, both with the reason in the code:
- cards_check pinned admin.js byte-identical to HEAD - right for T6.5, but as
  a standing probe it would fail every legitimate later edit; D5 targets
  admin.js by name. A7's localization is protected by the feature checks and
  the end-to-end drive, plus a wiring assertion on the block itself.
- frame_check listed "Usage data" among the toolbar buttons that must be
  visible; it now asserts the button is GONE, so the duplicate cannot quietly
  return.

Verification (each probe run alone): NEW tests/usage_check.py 15/15 (grep
half: WPUsage defined once, no page touches the keys directly, none of the
five globals survives anywhere). Regressions: cards_check ALL PASS,
frame_check 39/39.

Items: D5

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 11:29:27 -07:00
parent e3de3c7c00
commit 82a8f30074
11 changed files with 321 additions and 101 deletions

View File

@@ -178,10 +178,11 @@
<!-- USAGE LOGS -->
<div class="card">
<h2>Usage logs</h2>
<div class="sub">Engagement recorded by the suite — sessions, step views, and actions. Note: stored locally per browser, so this reflects activity on <strong>this</strong> machine.</div>
<div class="sub">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 <strong>this</strong> machine.</div>
<div class="toolbar">
<button onclick="loadUsage()">Refresh</button>
<button onclick="downloadUsage()">Download JSON</button>
</div>
<div id="usage-admin" class="note">Click refresh to load.</div>
</div>
@@ -214,6 +215,7 @@
</div>
</div>
<script src="wp-usage.js"></script>
<script src="console-util.js"></script>
<script src="admin.js"></script>
<!-- The app bar's project switcher reads ProjectData; without this the bar on this

View File

@@ -649,39 +649,46 @@ async function loadNotifications(){
}
// ── usage logs (read from this browser's localStorage) ──────────────────────────
const USAGE_KEY = 'wp_suite_analytics_v1';
function usageLoad(){ try { return JSON.parse(localStorage.getItem(USAGE_KEY)) || {events:[]}; } catch(e){ return {events:[]}; } }
// D5 / T7.10: the report for BOTH tools' recorded usage, in the one place an
// operator-facing readout belongs - behind the same admin gate as this whole
// page (gateByRole() below shows nothing else either). Data comes from
// wp-usage.js, the single implementation; the keys predate the move, so
// everything recorded before it is still here.
function loadUsage(){
const box = document.getElementById('usage-admin');
const evs = (usageLoad().events) || [];
if(!evs.length){ box.innerHTML = '<div class="note">No usage recorded in this browser yet.</div>'; return; }
const byEvent = {}, byStep = {}, sessions = new Set();
let first = evs[0].ts, last = evs[0].ts;
evs.forEach(e => {
byEvent[e.event] = (byEvent[e.event]||0)+1;
if(e.session) sessions.add(e.session);
if(e.event==='step_view' && e.detail) byStep[e.detail.step] = (byStep[e.detail.step]||0)+1;
if(e.ts < first) first = e.ts; if(e.ts > last) last = e.ts;
if(!box) return;
const tools = [
['Work package creator', WPUsage.KEYS.creator, 'wp-iwp-usage'],
['SOP wizard', WPUsage.KEYS.wizard, 'wp-suite-usage'],
];
let html = '';
tools.forEach(([label, key, prefix]) => {
const evs = (WPUsage.load(key).events) || [];
html += '<h2 style="margin-top:16px">' + uesc(label) + '</h2>';
if(!evs.length){
html += '<div class="note">No usage recorded in this browser yet.</div>';
return;
}
const byEvent = {}, sessions = new Set();
let first = evs[0].ts, last = evs[0].ts;
evs.forEach(e => {
byEvent[e.event] = (byEvent[e.event]||0)+1;
if(e.session) sessions.add(e.session);
if(e.ts < first) first = e.ts; if(e.ts > last) last = e.ts;
});
const fmt = v => v ? wpFormatDateTime(v) : '—';
html += '<table class="kv">'+
'<tr><th>Sessions</th><td>'+sessions.size+'</td></tr>'+
'<tr><th>Events</th><td>'+evs.length+'</td></tr>'+
'<tr><th>Range</th><td style="font-weight:600">'+fmt(first)+' → '+fmt(last)+'</td></tr></table>';
html += '<table class="users"><thead><tr><th>Event</th><th>Count</th></tr></thead><tbody>';
Object.keys(byEvent).sort().forEach(k => html += '<tr><td>'+uesc(k)+'</td><td>'+byEvent[k]+'</td></tr>');
html += '</tbody></table>';
html += '<div class="toolbar" style="margin-top:8px"><button onclick="WPUsage.download(WPUsage.KEYS.'+
(key === WPUsage.KEYS.creator ? 'creator' : 'wizard')+', ' + jsq(prefix) + ')">Download the full event log</button></div>';
});
const fmt = s => s ? wpFormatDateTime(s) : '—';
let html = '<table class="kv">'+
'<tr><th>Sessions</th><td>'+sessions.size+'</td></tr>'+
'<tr><th>Events</th><td>'+evs.length+'</td></tr>'+
'<tr><th>Range</th><td style="font-weight:600">'+fmt(first)+' → '+fmt(last)+'</td></tr></table>';
html += '<h2 style="margin-top:16px">Step views</h2><table class="users"><thead><tr><th>Step</th><th>Views</th></tr></thead><tbody>';
for(let i=1;i<=10;i++) html += '<tr><td>Step '+i+'</td><td>'+(byStep[i]||0)+'</td></tr>';
html += '</tbody></table>';
html += '<h2 style="margin-top:16px">Actions</h2><table class="users"><thead><tr><th>Event</th><th>Count</th></tr></thead><tbody>';
Object.keys(byEvent).sort().forEach(k => html += '<tr><td>'+uesc(k)+'</td><td>'+byEvent[k]+'</td></tr>');
html += '</tbody></table>';
box.innerHTML = html;
}
function downloadUsage(){
const blob = new Blob([JSON.stringify(usageLoad(),null,2)], {type:'application/json'});
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
a.download = 'wp-suite-usage-' + new Date().toISOString().slice(0,10) + '.json';
a.click(); setTimeout(()=>URL.revokeObjectURL(a.href), 1000);
}
// ── access control: admins only ─────────────────────────────────────────────────
// auth-guard.js requires a login and sets window.WP_USER (firing 'wp-auth-ready').

View File

@@ -2337,69 +2337,19 @@ function loadStepComments(){
// Lightweight usage analytics stored in localStorage so the tool owner can review
// engagement over time. No field VALUES are stored (field-edit events record only
// the field id), keeping captured data non-sensitive.
const ANALYTICS_KEY = 'wp_suite_analytics_v1';
// D5 / T7.10: the analytics implementation lives in wp-usage.js and the report
// on the admin console. The wizard's own copy of showAnalytics() never had a
// caller here - the button lived on the creator - and once B7 dissolved the
// frame the duplicate sat in the same document as five colliding globals.
// This page only records; dwell tracking keeps its page-local state below.
let _stepEnter = Date.now();
const _session = 's_' + Date.now().toString(36) + Math.random().toString(36).slice(2,6);
function analyticsLoad(){
try { return JSON.parse(localStorage.getItem(ANALYTICS_KEY)) || {events:[]}; }
catch(e){ return {events:[]}; }
}
function analyticsSave(data){
try { localStorage.setItem(ANALYTICS_KEY, JSON.stringify(data)); }
catch(e){ /* storage unavailable — degrade silently */ }
}
function track(event, detail){
try {
const data = analyticsLoad();
data.events.push({ ts: new Date().toISOString(), session: _session, event, detail: detail||null });
if(data.events.length > 5000) data.events = data.events.slice(-5000);
analyticsSave(data);
} catch(e){}
WPUsage.track(WPUsage.KEYS.wizard, event, detail);
}
function trackStepDwell(){
const ms = Date.now() - _stepEnter;
if(ms > 400 && ms < 1000*60*60) track('step_dwell', {step: currentStep, ms});
_stepEnter = Date.now();
}
function analyticsSummary(){
const data = analyticsLoad();
const byEvent = {}, byStep = {}, dwell = {}, sessions = new Set();
data.events.forEach(e=>{
byEvent[e.event] = (byEvent[e.event]||0)+1;
sessions.add(e.session);
if(e.event==='step_view' && e.detail) byStep[e.detail.step]=(byStep[e.detail.step]||0)+1;
if(e.event==='step_dwell' && e.detail){ dwell[e.detail.step]=(dwell[e.detail.step]||0)+e.detail.ms; }
});
return {total:data.events.length, sessions:sessions.size, byEvent, byStep, dwell, first:data.events[0]?.ts, last:data.events[data.events.length-1]?.ts};
}
function showAnalytics(){
const s = analyticsSummary();
const fmtMin = ms => (ms/60000).toFixed(1)+' min';
let txt = `USAGE LOGS\n\nSessions: ${s.sessions} Events: ${s.total}\nRange: ${s.first?new Date(s.first).toLocaleString():'—'}${s.last?new Date(s.last).toLocaleString():'—'}\n\nStep views:\n`;
for(let i=1;i<=LAST_STEP;i++) txt += ` Step ${i}: ${s.byStep[i]||0} views` + (s.dwell[i]?`, ${fmtMin(s.dwell[i])} total`:'') + `\n`;
txt += `\nActions:\n`;
Object.keys(s.byEvent).filter(k=>!['step_view','step_dwell','field_edit'].includes(k)).forEach(k=> txt += ` ${k}: ${s.byEvent[k]}\n`);
txt += ` field edits: ${s.byEvent['field_edit']||0}\n`;
// Was a native confirmation carrying the whole summary as its body — a wall of
// text in a dialog with one OK button. The summary is the useful part, so it is
// shown, and the download is offered as an action beside it rather than as the
// only way to dismiss the message.
//
// Note this function has no caller in the wizard's markup: the "Usage data"
// button lives on the creator, which has its own showAnalytics(). Converted
// rather than deleted because deleting a feature is not what T5.8 was asked to
// do, and its dialog counts toward the number this task has to drive to zero.
wizardToast(txt.replace(/\n+/g, ' · ').trim(),
{action: {label: 'Download the full event log', fn: downloadAnalytics}});
}
function downloadAnalytics(){
const data = analyticsLoad();
const blob = new Blob([JSON.stringify(data,null,2)], {type:'application/json'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'wp-suite-usage-' + new Date().toISOString().slice(0,10) + '.json';
a.click();
URL.revokeObjectURL(a.href);
track('analytics_exported');
}

View File

@@ -11,6 +11,7 @@
<!-- Addressable state (S3). Parses before the app scripts, which read the URL
during their own boot. -->
<script src="wp-url.js"></script>
<script src="wp-usage.js"></script>
<!-- Autosave, unsaved-work guard, draft recovery (S2). -->
<script src="wp-autosave.js"></script>
<!-- Which work package sections this project uses (CR-006). Shared with the

View File

@@ -3173,14 +3173,11 @@ function openSopModal(){
document.getElementById('sop-modal').classList.add('open'); track('view_sop');
}
function closeSopModal(){ document.getElementById('sop-modal').classList.remove('open'); }
const ANALYTICS_KEY='wp_iwp_analytics_v1'; const _session='s_'+Date.now().toString(36)+Math.random().toString(36).slice(2,6);
function analyticsLoad(){ try{ return JSON.parse(localStorage.getItem(ANALYTICS_KEY))||{events:[]}; }catch(e){ return {events:[]}; } }
function analyticsSave(d){ try{ localStorage.setItem(ANALYTICS_KEY, JSON.stringify(d)); }catch(e){} }
function track(event,detail){ if(devMode) return; try{ const d=analyticsLoad(); d.events.push({ts:new Date().toISOString(),session:_session,event,detail:detail||null}); if(d.events.length>5000)d.events=d.events.slice(-5000); analyticsSave(d); }catch(e){} }
function showAnalytics(){ const d=analyticsLoad(); const by={}; const ses=new Set(); d.events.forEach(e=>{by[e.event]=(by[e.event]||0)+1;ses.add(e.session);});
let t=`USAGE ANALYTICS\n\nSessions: ${ses.size} Events: ${d.events.length}\n\nActions:\n`; Object.keys(by).forEach(k=>t+=` ${k}: ${by[k]}\n`); t+=`\nSaved packages (this device): ${savedPackages.length}\n\nDownload full log as JSON?`;
if(confirm(t)) downloadAnalytics(); }
function downloadAnalytics(){ const d=analyticsLoad(); const blob=new Blob([JSON.stringify(d,null,2)],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='wp-iwp-usage-'+new Date().toISOString().slice(0,10)+'.json'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); track('analytics_exported'); }
// D5 / T7.10: the analytics implementation lives in wp-usage.js - ONE copy for
// the whole suite - and its report lives on the admin console, where an
// operator-facing readout belongs. This page only records. Same key, same
// event shape: everything recorded before the move is still readable after it.
function track(event,detail){ if(devMode) return; WPUsage.track(WPUsage.KEYS.creator, event, detail); }
// ── COMMENTS ─────────────────────────────────────────────────────────────────
const COMMENTS_KEY='wp_iwp_comments_v1';

View File

@@ -11,6 +11,7 @@
<!-- Addressable state (S3). Parses before the app scripts, which read the URL
during their own boot. -->
<script src="wp-url.js"></script>
<script src="wp-usage.js"></script>
<!-- Autosave, unsaved-work guard, draft recovery (S2). -->
<script src="wp-autosave.js"></script>
<!-- Which sections this project uses (CR-006). The same file the SOP wizard
@@ -89,7 +90,6 @@
<!-- T7.10 moves this to the admin console and deletes one of the two
implementations. It stays visible here until then rather than being
deleted by the task that dissolved the frame. -->
<button class="btn btn-ghost" onclick="showAnalytics()">Usage data</button>
</div>
<div class="dev-banner" id="dev-banner" style="display:none">⚙ DEV MODE — usage tracking paused. This session's actions are not being recorded.</div>

58
html/wp-usage.js Normal file
View File

@@ -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' },
};
})();