diff --git a/README.md b/README.md index 606187f..8bfc61a 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Free. No login. No downloads. No sound needed. - **One shared Daily Five.** Five different puzzle types, determined by your device’s date. Players on the same date get the same challenge. - **Close counts.** Every round is worth 100 points. See your guess and the target together, then inspect small comparisons in your final results. -- **Compare with friends.** Share a compact scorecard with the date, puzzle emojis, and your total. No answer spoilers. +- **Compare with friends.** Share your Daily Five results as a compact scorecard with the date, puzzle emojis, and your total. No answer spoilers. - **Practice when you feel like it.** Open the Practice menu for a fresh mix or an individual skill. Trivia practice draws from past Daily Fives, keeping upcoming questions out of the pool. - **Pick your atmosphere.** Night Lab or Focus. Your choice sticks between visits. - **Come back later.** Daily progress, streaks, and recent results stay in your browser. No account required; no cross-device sync. diff --git a/docs/ANALYTICS.md b/docs/ANALYTICS.md index a091993..b6e4567 100644 --- a/docs/ANALYTICS.md +++ b/docs/ANALYTICS.md @@ -15,10 +15,14 @@ requests are sent: | Pageview | First visit in a browser on a device-calendar day | None | | `daily_started` | First puzzle interaction in Daily Five | None | | `daily_completed` | Fifth answer committed in Daily Five | `total`, plus one score keyed by each of the five puzzle types | -| `results_share_clicked` | First Share results click per browser per calendar day, in any game mode | None | +| `results_share_clicked` | First Share results click per browser per calendar day, in Daily Five | None | +| `challenge_share_clicked` | First Challenge a friend click per browser per calendar day | None | +| `challenge_visit` | First visit through a valid challenge for today's Daily Five | None | +| `challenge_start` | First Daily Five interaction through that challenge | None | +| `challenge_complete` | First Daily Five completion through that challenge | None | -Individual mini games, practice and retries do not send playing events. Their -share clicks count toward the same single daily share event. Controls, rounds, +Individual mini games, practice and retries do not send playing events. Practice +results have no share or friend-challenge controls. Controls, rounds, theme changes, timing and performance are not tracked. Sharing measures intent (button click), including clipboard fallback, rather than proving an external post. Opening a completed saved game does not emit completion. Theme switches and @@ -29,7 +33,7 @@ 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. No growing event backlog is stored. Tracking failures never stop play. At most -four requests are held in memory while the script loads, with no retries or +eight 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 @@ -47,5 +51,19 @@ Umami's usage dashboard, especially if this account tracks other websites. References: https://umami.is/pricing and https://docs.umami.is/docs/tracker-configuration. +Friend challenges contain only a Central-calendar date and a self-reported total +in the URL. No answers, player ID, or individual referral identifier is included. +Query strings remain excluded from analytics. Challenge events have no custom +properties; an incoming completed-and-shared challenge can add four requests to +the existing ten-unit daily budget. Expired or invalid challenges do not contribute +challenge visit/start/completion events. A shared score is a friendly comparison, +not an authenticated leaderboard entry. + +Challenge events count browser days and share intent, not verified delivery or +unique invitees. Ratios across these counts are directional: repeat visitors, +cross-day arrivals, cancelled shares, lost parameters, tracker blocking and links +received after an earlier daily completion can affect them. Loading saved results +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. diff --git a/docs/GROWTH.md b/docs/GROWTH.md new file mode 100644 index 0000000..f52e1cc --- /dev/null +++ b/docs/GROWTH.md @@ -0,0 +1,78 @@ +# Perception growth experiment + +## Starting evidence + +Umami shared-dashboard aggregate snapshot, October 9, 2026 at approximately +5:35 p.m. Central, queried over the preceding 30 days: + +| Recorded activity | Count | +| --- | ---: | +| Visits / visitors / pageviews | 28 each | +| Daily starts | 19 | +| Daily completions | 17 | +| Share-button clicks | 13 | +| Mobile visits | 19 | +| Laptop / desktop visits | 7 / 2 | + +Start-to-completion is approximately 89%; visits-to-start is 68%. This is a very +small sample of deduplicated browser-day activity, not evidence of stable rates +or a causal funnel. Share clicks may include practice and cancelled native sheets. +No referrers were returned; messaging apps often remove referrer information. +There is no measured referral conversion in the existing data. + +## First experiment: a score worth challenging + +The current completion screen offers **Challenge a friend** above ordinary result +sharing. It produces a clickable HTTPS link carrying today's date and score. +Recipients go straight into the Daily Five with a score to beat. Completion shows +the difference, including a tie or loss, and invites a challenge back. A link from +an earlier day explains that the challenge has ended and offers today's game. +Practice results offer neither score sharing nor friend challenges. Existing daily puzzles, +scoring, saved first attempts and compact result cards remain intact. + +This tests one hypothesis: a concrete score to beat will turn sharing into more +completed daily games. It does not depend on accounts, a public leaderboard, +fabricated popularity, or promises of viral growth. + +## Launch material + +Short post, ready to adapt for an audience the owner already participates in: + +> You think you can remember a colour. You know what 10 seconds feels like. +> …Right? Perception tests that confidence with five daily puzzles and a score +> out of 500. Free, no login, and silent. Play today's five, then send me your +> challenge: https://perception.thedanktank.com/ + +For a group chat, play today's game and use its generated challenge message: +“I scored [your actual score]/500 on Perception. Can you beat me?” The generated +link includes the real date and total. Do not reuse an old challenge as today's. + +For a short video, record one surprising missed guess and its reveal; keep +today's answers out of public clips by recording practice. End with the actual +score and “Five puzzles. Can you beat me?” Direct viewers to the public game. + +Distribution order: start with an existing friend group; invite participants to +send their own challenges; then use one relevant puzzle community that permits +self-promotion. Tailor each post and follow its rules. None of this outreach has +been posted or sent by the agent. + +## Evaluate after seven days of real exposure + +Compare `challenge_share_clicked`, `challenge_visit`, `challenge_start`, and +`challenge_complete` in Umami over the same window. Also watch overall Daily Five +starts/completions so increased traffic does not hide a worse playing experience. + +- Few challenge clicks: improve the completion-screen invitation and share copy. +- Clicks but few arrivals: check native sharing and link visibility in the apps + players actually use; ask consenting players what recipients saw. +- Arrivals but few starts: simplify the first puzzle's instructions using player + feedback before adding another landing screen. +- Starts but few completions: inspect feedback about confusing puzzles; the + current analytics cannot locate abandonment by round. +- More completed challenges: keep the flow, expand exposure gradually, and check + returning-player retention before adding more features. + +Do not declare a winner from a handful of visits or divide these aggregate +browser-day counts into an asserted viral coefficient. Actual growth requires +continued distribution and repeated observations. Review Umami quota use if +traffic rises. Publishing and outbound promotion are separate next actions. diff --git a/public/focus.css b/public/focus.css index 4d60933..de12997 100644 --- a/public/focus.css +++ b/public/focus.css @@ -100,6 +100,8 @@ .colour-adjust{display:block;text-align:left;margin:12px auto;max-width:320px}.colour-adjust>span:first-child{display:block;font-size:12px;color:var(--muted);margin-bottom:6px}.colour-adjust .chosen-swatch{display:block;width:100%;height:56px;border-radius:9px;border:1px solid var(--line);background:var(--canvas)} .share-panel{margin:24px 0 12px}.share-panel>.primary{width:100%}.share-status{font-size:12px;color:var(--muted);min-height:18px;margin:8px 0 0;text-align:center}.share-fallback{display:block;width:100%;min-height:150px;padding:12px;margin-top:12px;background:var(--canvas);color:var(--ink);border:1px solid var(--line);border-radius:8px;font:13px/1.6 monospace;resize:vertical}.share-fallback[hidden]{display:none} @media(max-width:760px){.colour-adjust{margin:8px auto}.colour-adjust .chosen-swatch{height:44px}.wheel-wrap{width:min(100%,clamp(140px,calc(100svh - 510px),260px))}} + +.share-panel>button{display:block;width:100%;margin-top:10px}.share-panel>button[hidden]{display:none}.friend-challenge{padding:12px 16px;margin:0 0 16px;border:1px solid var(--accent);border-radius:10px;background:var(--canvas);font-size:14px}.friend-challenge[hidden]{display:none} .save-note{font-size:12px;color:var(--muted);margin:0 0 12px}.save-note[hidden]{display:none}.browser-history{margin:0;font-size:13px}.history-stats{color:var(--muted);line-height:1.6}.history-stats strong{color:var(--ink)}.browser-history summary{cursor:pointer}.history-row{display:flex;justify-content:space-between;gap:12px;padding:10px 0;border-bottom:1px solid var(--line);font-size:12px}.history-row strong{white-space:nowrap}.storage-caption{font-size:12px;color:var(--muted);line-height:1.6} .history-button{display:flex;align-items:center;justify-content:center;width:44px;height:44px;padding:0;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--muted);cursor:pointer;flex-shrink:0}.history-button:hover{color:var(--ink);border-color:var(--accent)} diff --git a/public/focus.html b/public/focus.html index c292f45..2ab70c3 100644 --- a/public/focus.html +++ b/public/focus.html @@ -1,5 +1,5 @@ -
p.PERCEPTION
A daily perception game
Practice
-
ROUND 01 / 05
0 / 500
+
ROUND 01 / 05
0 / 500

WELCOME TO PERCEPTION

Guess. Reveal. Get sharper.

Daily Five is the shared daily challenge: five different puzzle types, worth 500 points. Open Practice to choose a skill or play a fresh mix. Individual games are practice-only.

3D view: remember the view, then drag the shape to turn it and drag the green handle around the outer ring to tilt it. Balance: move a support beneath a uniform shape. Line memory: look at a generated path for three seconds, then trace it in one stroke on the blank canvas. Perspective: place the vanishing point. Proportions: resize a rectangle to match the ratio. Each reveal shows the target. All games are silent.

Colour memory: look at a swatch for two seconds, then find it on the wheel. Hue goes around the wheel; saturation increases towards the edge. Arrow keys or the keyboard controls also work.

Scoring: every round is worth 100 points. We divide your error by the game’s zero-point bound, then square the remaining closeness. An error of 10% earns 81 points; 25% earns 56; 50% earns 25. Scores are rounded to whole points. Five rounds total 500 points.

Zero-point bounds

3D and angles: 180°. Balance: 50% of the shape’s width. Line memory: 20% of the canvas diagonal (100 units). Perspective: 40% of the diagonal (200 units). Time: 3 seconds early or late. Rhythm and proportions: 1.5 times or two-thirds of the target. Colour: straight-line distance of one wheel radius. When?: 50 years early or late. How long?: four times or one-quarter of the target duration.

Rhythm: watch five silent flashes, then tap five beats at the same pace. Angles: rotate a line to the target angle. Both reward closeness.

When? Slide the timeline beneath the centre marker to choose an event’s year. The minus and plus buttons adjust by one year. How long? Turn the dial from seconds to days. Use minus, plus or arrow keys to fine tune; Shift and arrows adjust more broadly. Both award up to 100 points for closeness, with the answer and source shown after you commit. Daily five uses each trivia fact once. Trivia practice draws only from past Daily Fives, with up to five different questions per session. Today’s and future questions stay out of the practice pool.

Time: Start, wait, Stop. Points fall with your timing error using the same curve as every other game.

Switching away resets an active memory flash or timer.

Recent results

p.PERCEPTION
A daily perception game
Practice
-
ROUND 01 / 05
0 / 500
+
ROUND 01 / 05
0 / 500

WELCOME TO PERCEPTION

Guess. Reveal. Get sharper.

Daily Five is the shared daily challenge: five different puzzle types, worth 500 points. Open Practice to choose a skill or play a fresh mix. Individual games are practice-only.

3D view: remember the view, then drag the shape to turn it and drag the green handle around the outer ring to tilt it. Balance: move a support beneath a uniform shape. Line memory: look at a generated path for three seconds, then trace it in one stroke on the blank canvas. Perspective: place the vanishing point. Proportions: resize a rectangle to match the ratio. Each reveal shows the target. All games are silent.

Colour memory: look at a swatch for two seconds, then find it on the wheel. Hue goes around the wheel; saturation increases towards the edge. Arrow keys or the keyboard controls also work.

Scoring: every round is worth 100 points. We divide your error by the game’s zero-point bound, then square the remaining closeness. An error of 10% earns 81 points; 25% earns 56; 50% earns 25. Scores are rounded to whole points. Five rounds total 500 points.

Zero-point bounds

3D and angles: 180°. Balance: 50% of the shape’s width. Line memory: 20% of the canvas diagonal (100 units). Perspective: 40% of the diagonal (200 units). Time: 3 seconds early or late. Rhythm and proportions: 1.5 times or two-thirds of the target. Colour: straight-line distance of one wheel radius. When?: 50 years early or late. How long?: four times or one-quarter of the target duration.

Rhythm: watch five silent flashes, then tap five beats at the same pace. Angles: rotate a line to the target angle. Both reward closeness.

When? Slide the timeline beneath the centre marker to choose an event’s year. The minus and plus buttons adjust by one year. How long? Turn the dial from seconds to days. Use minus, plus or arrow keys to fine tune; Shift and arrows adjust more broadly. Both award up to 100 points for closeness, with the answer and source shown after you commit. Daily five uses each trivia fact once. Trivia practice draws only from past Daily Fives, with up to five different questions per session. Today’s and future questions stay out of the practice pool.

Time: Start, wait, Stop. Points fall with your timing error using the same curve as every other game.

Switching away resets an active memory flash or timer.

Recent results

p.PERCEPTION
A daily perception game
Practice
-
ROUND 01 / 05
0 / 500
+
ROUND 01 / 05
0 / 500

WELCOME TO PERCEPTION

Guess. Reveal. Get sharper.

Daily Five is the shared daily challenge: five different puzzle types, worth 500 points. Open Practice to choose a skill or play a fresh mix. Individual games are practice-only.

3D view: remember the view, then drag the shape to turn it and drag the green handle around the outer ring to tilt it. Balance: move a support beneath a uniform shape. Line memory: look at a generated path for three seconds, then trace it in one stroke on the blank canvas. Perspective: place the vanishing point. Proportions: resize a rectangle to match the ratio. Each reveal shows the target. All games are silent.

Colour memory: look at a swatch for two seconds, then find it on the wheel. Hue goes around the wheel; saturation increases towards the edge. Arrow keys or the keyboard controls also work.

Scoring: every round is worth 100 points. We divide your error by the game’s zero-point bound, then square the remaining closeness. An error of 10% earns 81 points; 25% earns 56; 50% earns 25. Scores are rounded to whole points. Five rounds total 500 points.

Zero-point bounds

3D and angles: 180°. Balance: 50% of the shape’s width. Line memory: 20% of the canvas diagonal (100 units). Perspective: 40% of the diagonal (200 units). Time: 3 seconds early or late. Rhythm and proportions: 1.5 times or two-thirds of the target. Colour: straight-line distance of one wheel radius. When?: 50 years early or late. How long?: four times or one-quarter of the target duration.

Rhythm: watch five silent flashes, then tap five beats at the same pace. Angles: rotate a line to the target angle. Both reward closeness.

When? Slide the timeline beneath the centre marker to choose an event’s year. The minus and plus buttons adjust by one year. How long? Turn the dial from seconds to days. Use minus, plus or arrow keys to fine tune; Shift and arrows adjust more broadly. Both award up to 100 points for closeness, with the answer and source shown after you commit. Daily five uses each trivia fact once. Trivia practice draws only from past Daily Fives, with up to five different questions per session. Today’s and future questions stay out of the practice pool.

Time: Start, wait, Stop. Points fall with your timing error using the same curve as every other game.

Switching away resets an active memory flash or timer.

Recent results

p.PERCEPTION
A daily perception game
Practice
-
ROUND 01 / 05
0 / 500
+
ROUND 01 / 05
0 / 500

WELCOME TO PERCEPTION

Guess. Reveal. Get sharper.

Daily Five is the shared daily challenge: five different puzzle types, worth 500 points. Open Practice to choose a skill or play a fresh mix. Individual games are practice-only.

3D view: remember the view, then drag the shape to turn it and drag the green handle around the outer ring to tilt it. Balance: move a support beneath a uniform shape. Line memory: look at a generated path for three seconds, then trace it in one stroke on the blank canvas. Perspective: place the vanishing point. Proportions: resize a rectangle to match the ratio. Each reveal shows the target. All games are silent.

Colour memory: look at a swatch for two seconds, then find it on the wheel. Hue goes around the wheel; saturation increases towards the edge. Arrow keys or the keyboard controls also work.

Scoring: every round is worth 100 points. We divide your error by the game’s zero-point bound, then square the remaining closeness. An error of 10% earns 81 points; 25% earns 56; 50% earns 25. Scores are rounded to whole points. Five rounds total 500 points.

Zero-point bounds

3D and angles: 180°. Balance: 50% of the shape’s width. Line memory: 20% of the canvas diagonal (100 units). Perspective: 40% of the diagonal (200 units). Time: 3 seconds early or late. Rhythm and proportions: 1.5 times or two-thirds of the target. Colour: straight-line distance of one wheel radius. When?: 50 years early or late. How long?: four times or one-quarter of the target duration.

Rhythm: watch five silent flashes, then tap five beats at the same pace. Angles: rotate a line to the target angle. Both reward closeness.

When? Slide the timeline beneath the centre marker to choose an event’s year. The minus and plus buttons adjust by one year. How long? Turn the dial from seconds to days. Use minus, plus or arrow keys to fine tune; Shift and arrows adjust more broadly. Both award up to 100 points for closeness, with the answer and source shown after you commit. Daily five uses each trivia fact once. Trivia practice draws only from past Daily Fives, with up to five different questions per session. Today’s and future questions stay out of the practice pool.

Time: Start, wait, Stop. Points fall with your timing error using the same curve as every other game.

Switching away resets an active memory flash or timer.

Recent results

', '

Recent results

') themed+='\nbindHistoryNavigation();\n' -html=html.replace('
','
') +html=html.replace('
','
') common_css=mobile_play+Path('src/design-options/trivia.css').read_text()+Path('src/design-options/theme-switcher.css').read_text()+Path('src/design-options/summary-comparisons.css').read_text()+Path('src/design-options/share-results.css').read_text()+Path('src/design-options/player-storage.css').read_text()+Path('src/design-options/play-navigation.css').read_text() theme_assets={key:key+'.css?v='+hashlib.sha256((base+css+common_css).encode()).hexdigest()[:12] for key,css in styles.items()} theme_preference='window.perceptionThemeAssets='+json.dumps(theme_assets)+';\n'+theme_preference diff --git a/src/design-options/friend-challenge.js b/src/design-options/friend-challenge.js new file mode 100644 index 0000000..c76efab --- /dev/null +++ b/src/design-options/friend-challenge.js @@ -0,0 +1,50 @@ +// A challenge contains only a date and total, never answers or player identifiers. +function readFriendChallenge(search){ + const params=new URLSearchParams(search),day=params.get('day'),score=params.get('beat'); + if(params.get('from')!=='challenge'||!validDay(day)||!/^\d{1,3}$/.test(score||'')||Number(score)>500)return null; + return {day:day.split('-').map(Number).join('-'),score:Number(score)}; +} +const friendChallenge=typeof location!=='undefined'&&typeof URLSearchParams!=='undefined'?readFriendChallenge(location.search):null; +function currentFriendChallenge(){return friendChallenge&&friendChallenge.day===calendarDay()&&mode==='daily'&&mini==='mix'&&!review;} +function friendChallengeURL(total,day){ + const url=new URL('https://perception.thedanktank.com/'); + url.search=new URLSearchParams({from:'challenge',day,beat:String(total)}).toString(); + return url.href; +} +function friendChallengeText(){ + const total=results.reduce((sum,r)=>sum+r.score,0); + return `I scored ${total}/500 on Perception. Can you beat me?\nFive daily puzzles. No login.\n${friendChallengeURL(total,sessionDay)}`; +} +function renderFriendChallenge(){ + const banner=$('friend-challenge');if(!banner)return; + banner.hidden=!friendChallenge||mode!=='daily'||mini!=='mix'||review; + if(banner.hidden)return; + if(!currentFriendChallenge()){ + banner.textContent='That daily challenge has ended. Play today’s five and send a fresh challenge.';return; + } + if(results.length===5){ + const total=results.reduce((sum,r)=>sum+r.score,0),delta=total-friendChallenge.score; + banner.textContent=delta>0?`You beat your friend by ${delta} ${delta===1?'point':'points'}! Send a challenge back.`:delta===0?`A tie at ${total}/500. Challenge someone else to break it.`:`Your friend scored ${friendChallenge.score}/500. You were ${-delta} points away. Try again tomorrow.`; + }else banner.textContent=`Your friend scored ${friendChallenge.score}/500. Can you beat them? Same five puzzles. Your own first attempt.`; +} +function bindFriendChallenge(){ + renderFriendChallenge(); + const button=$('challenge-friend');if(!button)return; + button.hidden=mode!=='daily'||mini!=='mix'||review||sessionDay!==calendarDay()||results.length!==5; + if(button.hidden)return; + button.addEventListener('click',async()=>{ + if(button.disabled)return; + analyticsShareClicked();analyticsChallenge('share_clicked'); + button.disabled=true; + const text=friendChallengeText(),status=$('share-status'),fallback=$('share-fallback'); + status.textContent='';fallback.hidden=true; + try{ + if(typeof navigator.share==='function'){ + try{await navigator.share({text});status.textContent='Challenge shared.';return;} + catch(error){if(error?.name==='AbortError')return;} + } + try{await navigator.clipboard.writeText(text);status.textContent='Challenge copied. Send it to a friend.';} + catch{fallback.hidden=false;fallback.value=text;fallback.focus();fallback.select();status.textContent='Select and copy your challenge below.';} + }finally{button.disabled=false;} + }); +} diff --git a/src/design-options/share-results.css b/src/design-options/share-results.css index bb21225..9da49e4 100644 --- a/src/design-options/share-results.css +++ b/src/design-options/share-results.css @@ -1,3 +1,5 @@ .colour-adjust{display:block;text-align:left;margin:12px auto;max-width:320px}.colour-adjust>span:first-child{display:block;font-size:12px;color:var(--muted);margin-bottom:6px}.colour-adjust .chosen-swatch{display:block;width:100%;height:56px;border-radius:9px;border:1px solid var(--line);background:var(--canvas)} .share-panel{margin:24px 0 12px}.share-panel>.primary{width:100%}.share-status{font-size:12px;color:var(--muted);min-height:18px;margin:8px 0 0;text-align:center}.share-fallback{display:block;width:100%;min-height:150px;padding:12px;margin-top:12px;background:var(--canvas);color:var(--ink);border:1px solid var(--line);border-radius:8px;font:13px/1.6 monospace;resize:vertical}.share-fallback[hidden]{display:none} @media(max-width:760px){.colour-adjust{margin:8px auto}.colour-adjust .chosen-swatch{height:44px}.wheel-wrap{width:min(100%,clamp(140px,calc(100svh - 510px),260px))}} + +.share-panel>button{display:block;width:100%;margin-top:10px}.share-panel>button[hidden]{display:none}.friend-challenge{padding:12px 16px;margin:0 0 16px;border:1px solid var(--accent);border-radius:10px;background:var(--canvas);font-size:14px}.friend-challenge[hidden]{display:none} diff --git a/src/design-options/share-results.js b/src/design-options/share-results.js index 1d6b0f4..57f8c9f 100644 --- a/src/design-options/share-results.js +++ b/src/design-options/share-results.js @@ -8,6 +8,10 @@ function shareResultsText(rounds,day,game='mix',daily=true){ return `perception.thedanktank.com ${dateLabel}${daily?'':' · Practice '+gameLabel}\n${rounds.map(r=>(icons[r.q.type]||'•')+r.score).join(' ')}\nFinal score: ${total}/${rounds.length*100}`; } function bindShareResults(){ + const panel=$('share-panel'); + const allowed=mode==='daily'&&mini==='mix'&&!review; + if(panel)panel.hidden=!allowed; + if(!allowed)return; const button=$('share-results'),status=$('share-status'),fallback=$('share-fallback'); const text=shareResultsText(results,sessionDay,mini,mode==='daily'&&!review); button.addEventListener('click',async()=>{ diff --git a/tests/verify-analytics.cjs b/tests/verify-analytics.cjs index c707bdd..7ac3a8b 100644 --- a/tests/verify-analytics.cjs +++ b/tests/verify-analytics.cjs @@ -15,8 +15,8 @@ assert.equal(h.calls.reduce((sum,c)=>sum+1+(c[0]==='visit'?0:Object.keys(c[1]||{ h.run('analyticsShareClicked();analyticsShareClicked()');assert.equal(h.calls.length,4);assert.equal(h.calls[3][0],'results_share_clicked');assert.equal(h.calls[3][1],undefined,'Sharing must use no quota-consuming properties'); const reload=harness({storage:h.store});reload.run('setupAnalytics();analyticsStarted();results='+JSON.stringify(['angle','time','colour','timeline','duration'].map(type=>({q:{type},score:80})))+';analyticsCompleted()');reload.scripts[0].load();assert.equal(reload.calls.length,0,'Reload/theme changes must not duplicate events'); reload.run("mode='practice';analyticsShareClicked();mini='angle';analyticsShareClicked()");assert.equal(reload.calls.length,0,'Sharing across modes/reloads must deduplicate'); -reload.run("calendarDay=()=> '2026-10-9';analyticsShareClicked();analyticsShareClicked()");assert.equal(reload.calls.length,1,'Next day may share again, even an older puzzle'); -const practiceShare=harness();practiceShare.run("setupAnalytics();mode='practice';analyticsShareClicked();analyticsShareClicked()");practiceShare.scripts[0].load();assert.deepEqual(practiceShare.calls.map(c=>c[0]),['visit','results_share_clicked']); +reload.run("mode='daily';mini='mix';calendarDay=()=> '2026-10-9';analyticsShareClicked();analyticsShareClicked()");assert.equal(reload.calls.length,1,'Next day may share again, even an older puzzle'); +const practiceShare=harness();practiceShare.run("setupAnalytics();mode='practice';analyticsShareClicked();analyticsShareClicked()");practiceShare.scripts[0].load();assert.deepEqual(practiceShare.calls.map(c=>c[0]),['visit']); for(const config of [{host:'localhost'},{host:'chipgpt.github.io'},{dnt:'1'},{configured:false}]){const x=harness(config);x.run('setupAnalytics();analyticsStarted();analyticsShareClicked()');assert.equal(x.scripts.length,0);assert.equal(x.calls.length,0);assert.equal(x.store.size,0);} for(const mode of ["mode='practice'","mini='angle'","review=true"]){const x=harness();x.run('setupAnalytics();'+mode+';analyticsStarted()');x.scripts[0].load();assert.equal(x.calls.length,1,'Practice only contributes the deduplicated visit');} const unavailable=harness({broken:true});unavailable.run('setupAnalytics();analyticsStarted();analyticsStarted()');unavailable.scripts[0].load();assert.equal(unavailable.calls.length,2,'Storage failure must be safe and deduplicate in memory'); @@ -25,6 +25,9 @@ const slow=harness();slow.run('setupAnalytics();analyticsStarted();');slow.scrip const failed=harness();failed.run('setupAnalytics();analyticsStarted()');failed.scripts[0].error();assert.equal(failed.calls.length,0); const queuedShare=harness();queuedShare.run('setupAnalytics();analyticsStarted();results='+JSON.stringify(['angle','time','colour','timeline','duration'].map(type=>({q:{type},score:80})))+';analyticsCompleted();analyticsShareClicked()');queuedShare.scripts[0].load();assert.equal(queuedShare.calls.length,4,'Share must survive loading alongside the other three requests'); const retention=harness();for(let d=1;d<=30;d++)retention.run(`analyticsClaim('2026-10-${d}','visit')`);assert.equal(Object.keys(JSON.parse(retention.store.get('perception-metrics-v1'))).length,7); +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 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();') '2026-10-9',validDay(day){if(!/^\d{4}-\d{1,2}-\d{1,2}$/.test(day||''))return false;const [y,m,d]=day.split('-').map(Number),v=new Date(Date.UTC(y,m-1,d));return v.getUTCFullYear()===y&&v.getUTCMonth()+1===m&&v.getUTCDate()===d},mode:'daily',mini:'mix',review:false,sessionDay:'2026-10-9',results:[],navigator,$:id=>elements[id],analyticsShareClicked(){events.push('share')},analyticsChallenge(stage){events.push(stage)}}); + vm.runInContext(source,c);return {elements,events,run:s=>vm.runInContext(s,c)}; +} +(async()=>{ + for(const query of ['?from=challenge&day=2026-2-30&beat=300','?from=challenge&day=2026-10-9&beat=501','?from=challenge&day=2026-10-9&beat=-1','?from=challenge&day=2026-10-9&beat=','?day=2026-10-9&beat=300'])assert.equal(harness(query).run('friendChallenge'),null); + const c=harness('?from=challenge&day=2026-10-09&beat=300'); + c.run('renderFriendChallenge()');assert(c.elements['friend-challenge'].textContent.includes('300/500')); + c.run('results=Array.from({length:5},()=>({score:80}));bindFriendChallenge()');assert(c.elements['friend-challenge'].textContent.includes('beat your friend by 100'));assert(!c.elements['challenge-friend'].hidden); + const text=c.run('friendChallengeText()'),url=text.split('\n').at(-1);assert.equal(new URL(url).searchParams.get('beat'),'400');assert.equal(harness(new URL(url).search).run('friendChallenge.score'),400); + c.run('results=Array.from({length:5},()=>({score:60}));renderFriendChallenge()');assert(c.elements['friend-challenge'].textContent.includes('tie')); + c.run('results=Array.from({length:5},()=>({score:40}));renderFriendChallenge()');assert(c.elements['friend-challenge'].textContent.includes('100 points away')); + c.run("mode='practice';bindFriendChallenge()");assert(c.elements['challenge-friend'].hidden);assert(c.elements['friend-challenge'].hidden); + const old=harness('?from=challenge&day=2026-10-8&beat=300');old.run('renderFriendChallenge()');assert(old.elements['friend-challenge'].textContent.includes('has ended'));assert(!old.run('currentFriendChallenge()')); + let payload;const native=harness('',{share:async data=>{payload=data}});native.run('results=Array.from({length:5},()=>({score:80}));bindFriendChallenge()');await native.elements['challenge-friend'].click();assert(payload.text.includes('400/500'));assert.deepEqual(native.events,['share','share_clicked']); + const cancel=harness('',{share:async()=>{throw {name:'AbortError'}}});cancel.run('results=Array.from({length:5},()=>({score:80}));bindFriendChallenge()');await cancel.elements['challenge-friend'].click();assert(cancel.elements['share-fallback'].hidden);assert.equal(cancel.elements['share-status'].textContent,''); + const fallback=harness();fallback.run('results=Array.from({length:5},()=>({score:80}));bindFriendChallenge()');await fallback.elements['challenge-friend'].click();assert(fallback.elements['share-fallback'].value.includes('beat=400'));assert(fallback.elements['share-fallback'].selected); + console.log('Verified challenge round trip, invalid dates/scores, expiry, win/tie/loss, practice isolation, native sharing, cancellation and manual copying.'); +})().catch(error=>{console.error(error);process.exitCode=1}); diff --git a/tests/verify-sharing.cjs b/tests/verify-sharing.cjs index d47b86a..915fbe8 100644 --- a/tests/verify-sharing.cjs +++ b/tests/verify-sharing.cjs @@ -16,19 +16,21 @@ console.log('Verified share date, totals, game labels, practice labels, public l (async()=>{ const shareSource=fs.readFileSync('src/design-options/share-results.js','utf8'); - function setup(nav){ - const elements={};for(const id of ['share-results','share-status','share-fallback'])elements[id]={textContent:'',hidden:true,disabled:false,addEventListener(type,fn){this.click=fn},focus(){this.focused=true},select(){this.selected=true}}; + function setup(nav,state={}){ + const elements={};for(const id of ['share-panel','share-results','share-status','share-fallback'])elements[id]={textContent:'',hidden:true,disabled:false,addEventListener(type,fn){this.click=fn},focus(){this.focused=true},select(){this.selected=true}}; let clicks=0;const copied=[]; const navigator={clipboard:{async writeText(t){copied.push(t)}},...nav}; - const c=vm.createContext({Date,calendarDay:()=> '2026-10-8',navigator,$:id=>elements[id],results:sample,mini:'mix',mode:'daily',review:false,analyticsShareClicked:()=>clicks++}); + const c=vm.createContext({Date,calendarDay:()=> '2026-10-8',navigator,$:id=>elements[id],results:sample,mini:'mix',mode:'daily',review:false,analyticsShareClicked:()=>clicks++,...state}); vm.runInContext(shareSource+'\nbindShareResults();',c); - return {button:elements['share-results'],status:elements['share-status'],fallback:elements['share-fallback'],copied,clicks:()=>clicks}; + return {panel:elements['share-panel'],button:elements['share-results'],status:elements['share-status'],fallback:elements['share-fallback'],copied,clicks:()=>clicks}; } let payload,resolve;const native=setup({share:data=>{payload=data;return new Promise(r=>resolve=r)}}); const pending=native.button.click();assert(payload,'Native share must be invoked immediately from the click');assert.equal(payload.text,run(`shareResultsText(${JSON.stringify(sample)},'2026-10-8')`));assert.equal(Object.keys(payload).length,1,'Avoid duplicating the URL in shared messages');assert(native.button.disabled); await native.button.click();assert.equal(native.clicks(),1);resolve();await pending;assert(!native.button.disabled);assert.equal(native.copied.length,0); const cancel=setup({share:async()=>{throw {name:'AbortError'}}});await cancel.button.click();assert.equal(cancel.copied.length,0);assert(cancel.fallback.hidden);assert.equal(cancel.status.textContent,'');assert(!cancel.button.disabled); const desktop=setup({});await desktop.button.click();assert.equal(desktop.copied.length,1);assert.equal(desktop.button.textContent,'Copied!'); + assert(!desktop.panel.hidden); + for(const state of [{mode:'practice'},{mode:'practice',mini:'angle'},{review:true}]){const practice=setup({},state);assert(practice.panel.hidden);assert.equal(practice.button.click,undefined);assert.equal(practice.clicks(),0);} const rejected=setup({share:async()=>{throw {name:'NotAllowedError'}}});await rejected.button.click();assert.equal(rejected.copied.length,1); const denied=setup({clipboard:{async writeText(){throw new Error('blocked')}}});await denied.button.click();assert(!denied.fallback.hidden);assert(denied.fallback.focused&&denied.fallback.selected);assert.equal(denied.fallback.value,payload.text);assert(!denied.button.disabled); console.log('Verified native sharing on tap, cancellation, repeated taps, clipboard fallback, and manual copy when permissions fail.');