From 4b68d65a1c7807c29db6a95abc4bab469a7634a6 Mon Sep 17 00:00:00 2001 From: Chip Date: Fri, 9 Oct 2026 20:43:26 -0500 Subject: [PATCH] Measure Daily Five acquisition by fixed campaign source --- docs/ANALYTICS.md | 26 ++++++++++++++++++++++++-- src/design-options/analytics.js | 19 ++++++++++++++++--- tests/verify-analytics.cjs | 11 +++++++++-- 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/docs/ANALYTICS.md b/docs/ANALYTICS.md index b6e4567..618e8fc 100644 --- a/docs/ANALYTICS.md +++ b/docs/ANALYTICS.md @@ -31,9 +31,9 @@ days*, rather than every page load. Use event counts to compare starts and completions. Use completion property breakdowns to inspect total and per-type scores. This minimal schema cannot show abandonment by round or time per puzzle. -The browser ledger keeps only seven days of four flags, capped on read at 4 KB. +The browser ledger keeps only seven days of bounded event flags, capped on read at 4 KB. No growing event backlog is stored. Tracking failures never stop play. At most -eight requests are held in memory while the script loads, with no retries or +eleven requests are held in memory while the script loads, with no retries or historical uploads. A blocked or failed request can therefore be undercounted. Storage clearing, unavailable storage across reloads and simultaneous tabs can also affect deduplication; these are approximate analytics, not authoritative @@ -67,3 +67,25 @@ never re-emits completion. Use the same date window when comparing counts. Run `node tests/verify-analytics.cjs` after building. The regular game checks also cover analytics integration without contacting Umami. + +## Acquisition campaign counts + +Links may include `?source=x`, `listdle`, `playlin`, `dledirectory`, `webgames`, +or `showhn`. Only those six fixed values are accepted; arbitrary query values +are never uploaded. For Daily Five, the app sends `acquisition__visit`, +`acquisition__start`, and `acquisition__complete`. These events +have no custom properties and deduplicate per source, browser and Central day. +Practice links do not send acquisition events. The query string remains excluded +from ordinary analytics. + +A campaign-tagged daily play adds at most three quota units. A tagged incoming +challenge that is completed and shared uses at most seventeen units including +the existing play and challenge events. The loading queue is capped at eleven +requests; failures remain isolated from play. + +These are campaign-attributed browser days, not new-user counts or causal +conversion cohorts. The same browser can count for multiple sources; changing +links mid-play, tracker blocking, untagged return visits, and removed parameters +limit attribution. Source tags are neither authenticated nor persisted across +untagged visits. Read starts and completions as directional evidence and pair +them with real player feedback. diff --git a/src/design-options/analytics.js b/src/design-options/analytics.js index ee4177c..6611065 100644 --- a/src/design-options/analytics.js +++ b/src/design-options/analytics.js @@ -1,6 +1,7 @@ // First-attempt Daily Five play and one daily share click are measured. const ANALYTICS_STORAGE_KEY='perception-metrics-v1'; -const ANALYTICS_EVENTS=['visit','start','complete','share','challenge_share_clicked','challenge_visit','challenge_start','challenge_complete']; +const ACQUISITION_SOURCES=['x','listdle','playlin','dledirectory','webgames','showhn']; +const ANALYTICS_EVENTS=['visit','start','complete','share','challenge_share_clicked','challenge_visit','challenge_start','challenge_complete',...ACQUISITION_SOURCES.flatMap(source=>['visit','start','complete'].map(stage=>'acquisition_'+source+'_'+stage))]; let analyticsReady=false,analyticsQueue=[],analyticsLedger={}; function analyticsAllowed(){return !!ANALYTICS_CONFIG.websiteId&&location.hostname===ANALYTICS_CONFIG.hostname&&navigator.doNotTrack!=='1';} function analyticsDaily(){return analyticsAllowed()&&mode==='daily'&&mini==='mix'&&!review;} @@ -18,12 +19,14 @@ function analyticsClaim(day,event){ analyticsLedger[day]=[...previous,event]; // A fixed-size ledger survives reloads and theme switches without growing history. analyticsLedger=Object.fromEntries(Object.entries(analyticsLedger).filter(([d])=>/^\d{4}-\d{1,2}-\d{1,2}$/.test(d)).sort(([a],[b])=>dayNumber(b)-dayNumber(a)).slice(0,7)); - try{localStorage.setItem(ANALYTICS_STORAGE_KEY,JSON.stringify(analyticsLedger));}catch{} + let ledgerText=JSON.stringify(analyticsLedger); + while(ledgerText.length>4096){delete analyticsLedger[Object.keys(analyticsLedger).at(-1)];ledgerText=JSON.stringify(analyticsLedger);} + try{localStorage.setItem(ANALYTICS_STORAGE_KEY,ledgerText);}catch{} return true; } function analyticsSend(name,data){ if(!analyticsAllowed())return; - if(!analyticsReady){if(analyticsQueue.length<8)analyticsQueue.push([name,data]);return;} + if(!analyticsReady){if(analyticsQueue.length<11)analyticsQueue.push([name,data]);return;} try{ const sent=name?window.umami.track(name,data):window.umami.track(p=>({...p,url:'/',title:'Perception'})); if(sent&&typeof sent.catch==='function')sent.catch(()=>{}); @@ -33,6 +36,7 @@ function analyticsStarted(){ if(!analyticsDaily()||results.length===5)return; if(analyticsClaim(sessionDay,'start'))analyticsSend('daily_started'); analyticsChallenge('start'); + analyticsAcquisition('start'); } function analyticsCompleted(){ if(!analyticsDaily()||results.length!==5)return; @@ -41,6 +45,14 @@ function analyticsCompleted(){ for(const r of results)data[r.q.type]=r.score; analyticsSend('daily_completed',data); analyticsChallenge('complete'); + analyticsAcquisition('complete'); +} +function analyticsAcquisition(stage){ + if(!analyticsDaily()||!['visit','start','complete'].includes(stage)||typeof URLSearchParams==='undefined')return; + const source=new URLSearchParams(location.search||'').get('source'); + if(!ACQUISITION_SOURCES.includes(source))return; + const event='acquisition_'+source+'_'+stage; + if(analyticsClaim(calendarDay(),event))analyticsSend(event); } function analyticsChallenge(stage){ if(!analyticsAllowed()||!['share_clicked','visit','start','complete'].includes(stage))return; @@ -75,6 +87,7 @@ function setupAnalytics(){ document.head.appendChild(script); if(analyticsClaim(calendarDay(),'visit'))analyticsSend(null); analyticsChallenge('visit'); + analyticsAcquisition('visit'); const interaction=e=>{ if(e.type==='keydown'&&!['Enter',' ','ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key))return; if(e.target.closest?.('#playarea,#actionarea'))analyticsStarted(); diff --git a/tests/verify-analytics.cjs b/tests/verify-analytics.cjs index 7ac3a8b..f5713f0 100644 --- a/tests/verify-analytics.cjs +++ b/tests/verify-analytics.cjs @@ -1,8 +1,8 @@ const fs=require('fs'),vm=require('vm'),assert=require('assert'); const source=fs.readFileSync('src/design-options/analytics.js','utf8'); -function harness({host='perception.thedanktank.com',dnt='0',configured=true,storage,broken=false}={}){ +function harness({host='perception.thedanktank.com',dnt='0',configured=true,storage,broken=false,search=''}={}){ const calls=[],scripts=[],listeners={},store=storage||new Map(); - const context=vm.createContext({ANALYTICS_CONFIG:{websiteId:configured?'test-id':'',scriptUrl:'https://cloud.umami.is/script.js',hostname:'perception.thedanktank.com'},location:{hostname:host},navigator:{doNotTrack:dnt},window:{umami:{track(name,data){calls.push(typeof name==='function'?['visit',name({url:'/?game=mix'})]:[name,data]);return Promise.resolve();}}},document:{createElement(){return{dataset:{},addEventListener(name,fn){this[name]=fn;}}},head:{appendChild(s){scripts.push(s)}},addEventListener(name,fn){listeners[name]=fn}},localStorage:{getItem(k){if(broken)throw Error();return store.get(k)||null;},setItem(k,v){if(broken)throw Error();store.set(k,v)}},calendarDay(){return '2026-10-8'},dayNumber(s){const [y,m,d]=s.split('-').map(Number);return Date.UTC(y,m-1,d)/86400000;},playerData:{id:'existing-browser-id'},mode:'daily',mini:'mix',review:false,sessionDay:'2026-10-8',results:[]}); + const context=vm.createContext({ANALYTICS_CONFIG:{websiteId:configured?'test-id':'',scriptUrl:'https://cloud.umami.is/script.js',hostname:'perception.thedanktank.com'},URLSearchParams,location:{hostname:host,search},navigator:{doNotTrack:dnt},window:{umami:{track(name,data){calls.push(typeof name==='function'?['visit',name({url:'/?game=mix'})]:[name,data]);return Promise.resolve();}}},document:{createElement(){return{dataset:{},addEventListener(name,fn){this[name]=fn;}}},head:{appendChild(s){scripts.push(s)}},addEventListener(name,fn){listeners[name]=fn}},localStorage:{getItem(k){if(broken)throw Error();return store.get(k)||null;},setItem(k,v){if(broken)throw Error();store.set(k,v)}},calendarDay(){return '2026-10-8'},dayNumber(s){const [y,m,d]=s.split('-').map(Number);return Date.UTC(y,m-1,d)/86400000;},playerData:{id:'existing-browser-id'},mode:'daily',mini:'mix',review:false,sessionDay:'2026-10-8',results:[]}); vm.runInContext(source,context);const run=s=>vm.runInContext(s,context); return {run,calls,scripts,listeners,store}; } @@ -28,6 +28,13 @@ const retention=harness();for(let d=1;d<=30;d++)retention.run(`analyticsClaim('2 const challenge=harness();challenge.run("const friendChallenge={day:'2026-10-8',score:300};function currentFriendChallenge(){return friendChallenge.day===calendarDay()&&mode==='daily'&&mini==='mix'&&!review;}setupAnalytics();analyticsStarted();results="+JSON.stringify(['angle','time','colour','timeline','duration'].map(type=>({q:{type},score:80})))+";analyticsCompleted();analyticsShareClicked();analyticsChallenge('share_clicked');analyticsChallenge('visit');analyticsChallenge('start');analyticsChallenge('complete');analyticsChallenge('share_clicked');"); challenge.scripts[0].load();assert.deepEqual(challenge.calls.map(c=>c[0]),['visit','challenge_visit','daily_started','challenge_start','daily_completed','challenge_complete','results_share_clicked','challenge_share_clicked']); const stale=harness();stale.run("const friendChallenge={day:'2026-10-7',score:300};function currentFriendChallenge(){return false;}setupAnalytics();analyticsChallenge('start');analyticsChallenge('complete');");stale.scripts[0].load();assert.deepEqual(stale.calls.map(c=>c[0]),['visit']); +const acquired=harness({search:'?source=x'});acquired.run('setupAnalytics();analyticsStarted();results='+JSON.stringify(['angle','time','colour','timeline','duration'].map(type=>({q:{type},score:80})))+';analyticsCompleted();analyticsAcquisition("visit");analyticsAcquisition("start");analyticsAcquisition("complete")');acquired.scripts[0].load();assert.deepEqual(acquired.calls.map(c=>c[0]),['visit','acquisition_x_visit','daily_started','acquisition_x_start','daily_completed','acquisition_x_complete']); +const acquiredReload=harness({search:'?source=x',storage:acquired.store});acquiredReload.run('setupAnalytics();analyticsStarted();analyticsAcquisition("complete")');acquiredReload.scripts[0].load();assert.equal(acquiredReload.calls.length,0); +for(const search of ['?source=unknown','?source=private@example.com','?source=X']){const x=harness({search});x.run('setupAnalytics();analyticsStarted()');x.scripts[0].load();assert.deepEqual(x.calls.map(c=>c[0]),['visit','daily_started']);} +for(const config of [{dnt:'1'},{host:'localhost'}]){const x=harness({search:'?source=x',...config});x.run('setupAnalytics();analyticsStarted();analyticsAcquisition("complete")');assert.equal(x.calls.length,0);assert.equal(x.store.size,0);} +const acquiredPractice=harness({search:'?source=x'});acquiredPractice.run('mode="practice";setupAnalytics();analyticsStarted();analyticsAcquisition("complete")');acquiredPractice.scripts[0].load();assert.deepEqual(acquiredPractice.calls.map(c=>c[0]),['visit']); +const allQueued=harness({search:'?source=x'});allQueued.run("const friendChallenge={};function currentFriendChallenge(){return true;}setupAnalytics();analyticsStarted();results="+JSON.stringify(['angle','time','colour','timeline','duration'].map(type=>({q:{type},score:80})))+";analyticsCompleted();analyticsShareClicked();analyticsChallenge('share_clicked');");allQueued.scripts[0].load();assert.equal(allQueued.calls.length,11); +const fullLedger=harness();fullLedger.run("for(let d=1;d<=7;d++)for(const event of ANALYTICS_EVENTS)analyticsClaim('2026-10-'+d,event)");const fullText=fullLedger.store.get('perception-metrics-v1');assert(fullText.length<=4096);assert(JSON.parse(fullText)['2026-10-7']); const generated=fs.readFileSync('src/design-options/game-themed.js','utf8');assert(generated.includes('analyticsStarted();results.push({q,guess:value,score});saveDailyGame();analyticsCompleted();')); const shareHandler=generated.slice(generated.indexOf('function bindShareResults()'),generated.indexOf('function bindShareResults()')+2200); assert(shareHandler.indexOf('analyticsShareClicked();')