Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions docs/ANALYTICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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_<source>_visit`,
`acquisition_<source>_start`, and `acquisition_<source>_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.
19 changes: 16 additions & 3 deletions src/design-options/analytics.js
Original file line number Diff line number Diff line change
@@ -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;}
Expand All @@ -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(()=>{});
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
11 changes: 9 additions & 2 deletions tests/verify-analytics.cjs
Original file line number Diff line number Diff line change
@@ -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};
}
Expand All @@ -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();')<shareHandler.indexOf('await navigator.share('),'Share intent must be counted before native sharing');
Expand Down
Loading