From bc5f2b553068dd9561e62e349f41f5c7aed8119a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 04:01:03 +0000 Subject: [PATCH 1/3] fix: extinction countdown ticks down by 2 seconds instead of 1 Agent-Logs-Url: https://github.com/nitrocode/token-deathclock/sessions/7fd5f569-36ed-4fc9-83e4-c1ea9f0bd26a Co-authored-by: nitrocode <7775707+nitrocode@users.noreply.github.com> --- death-clock-core.js | 32 +++++++++++++++++++++++ docs/LEARNINGS.md | 12 ++++++++- src/js/00-state.js | 1 + src/js/02-counter.js | 7 ++--- tests/death-clock.test.js | 55 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 101 insertions(+), 6 deletions(-) diff --git a/death-clock-core.js b/death-clock-core.js index d1477dd..cd4da34 100644 --- a/death-clock-core.js +++ b/death-clock-core.js @@ -388,6 +388,37 @@ function getDynamicRate(date) { return Math.round(TOKENS_PER_SECOND * Math.pow(1 + RATE_GROWTH_PER_YEAR, elapsedYears)); } +/** + * Compute the seconds remaining until a target token count will be reached, + * using the same exponentially-growing integral model that drives the live + * counter (getCurrentTokens in 00-state.js). + * + * This is the inverse of the cumulative-token integral: + * getCurrentTokens(t) = BASE_TOKENS + (R0/k) * (e^(k*t) - 1) + * Solving for t gives: + * tExtinction = ln(1 + (targetTokens − BASE_TOKENS) * k / R0) / k + * and secsRemaining = tExtinction − tNow. + * + * Because tExtinction is a constant and tNow advances by 1 each second, + * the returned value decreases by EXACTLY 1 per second — unlike the + * naïve linear approximation (tokensRemaining / currentRate) which ticks + * down by ~2 per second when the extinction milestone is several years away. + * + * @param {number} targetTokens - token count to reach + * @param {number} [nowMs] - epoch milliseconds (defaults to Date.now()) + * @returns {number} - seconds remaining; negative if already passed + */ +function computeExtinctionSecsRemaining(targetTokens, nowMs) { + if (typeof targetTokens !== 'number' || targetTokens <= BASE_TOKENS) return 0; + const now = typeof nowMs === 'number' ? nowMs : Date.now(); + const baseMs = new Date(BASE_DATE_ISO).getTime(); + const SECS_PER_YEAR = 365.25 * 24 * 3600; + const k = Math.log(1 + RATE_GROWTH_PER_YEAR) / SECS_PER_YEAR; + const tExtinction = Math.log(1 + (targetTokens - BASE_TOKENS) * k / TOKENS_PER_SECOND) / k; + const tNow = (now - baseMs) / 1000; + return tExtinction - tNow; +} + /** * Calculate the collective daily environmental impact if a fraction of global users * consistently applies a token-saving tip. @@ -820,6 +851,7 @@ const DeathClockCore = { getRateAtDate, RATE_GROWTH_PER_YEAR, getDynamicRate, + computeExtinctionSecsRemaining, calculateTipImpact, generateEquivalences, calculatePersonalFootprint, diff --git a/docs/LEARNINGS.md b/docs/LEARNINGS.md index 75b4ea4..9b37f2e 100644 --- a/docs/LEARNINGS.md +++ b/docs/LEARNINGS.md @@ -102,6 +102,7 @@ Every PR description (written by a human or agent) must follow this structure: | A2 | Enable TypeScript type-checking via `checkJs: true` in `tsconfig.json` with JSDoc annotations. This catches type errors in plain `.js` files without requiring a full TS migration. | #54 | | A3 | `death-clock-core.js` must never reference the DOM (`document`, `window`, `getElementById`, etc.). All DOM wiring belongs in `src/js/`. This boundary keeps the core unit-testable. | AGENTS.md | | A4 | The CommonJS + browser dual-export pattern (`module.exports` for Jest, `window.DeathClockCore` for the browser) must be maintained. Do not convert to ES modules without updating all consumers. | AGENTS.md | +| A5 | When displaying a countdown to a future token threshold, invert the integral accumulation formula (`tExtinction = ln(1+(target-BASE_TOKENS)*k/R0)/k`) rather than using `tokensRemaining / currentRate`. The naïve linear formula ticks down by `1 + secsRemaining*k` per second (~2 at typical extinction distances). | #104 | --- @@ -143,7 +144,16 @@ Entries are grouped by release. Add new entries at the top of the appropriate re ### v1.7.x -#### PR #103 — feat: implement Token Horoscope daily satirical AI horoscope (Phase 3 PRD #1) +#### PR #104 — fix: extinction countdown ticks down by 2 seconds instead of 1 + +- **Problem:** The extinction countdown header displayed the seconds decreasing by ~2 per tick instead of 1, because `updateExtinctionCountdown` used `tokensRemaining / currentRate` (linear approximation) while `getCurrentTokens` uses an exponentially-growing integral model. +- **Approach:** Added `computeExtinctionSecsRemaining(targetTokens, nowMs)` pure function to `death-clock-core.js` that solves the inverse of the cumulative-token integral (`tExtinction = ln(1 + (target - BASE_TOKENS)*k/R0)/k`). Since `tExtinction` is a constant and `tNow` advances by 1 s/s, the result decreases by exactly 1 per second. Updated `updateExtinctionCountdown` to use it. Added 6 unit tests including the key 1-second-per-tick invariant. +- **Learning:** When displaying a countdown to a future token threshold, always invert the integral accumulation formula rather than using `tokensRemaining / currentRate`. The naïve linear formula ticks down by `(1 + secsRemaining * k)` per second, which is ~2× at typical extinction distances. (→ A3) +- **Key files:** `death-clock-core.js`, `src/js/02-counter.js`, `src/js/00-state.js`, `tests/death-clock.test.js` + +--- + + - **Problem:** The site had no daily-rotating content to drive return visits; Phase 3 PRD #1 (Token Horoscope) was the highest-impact lowest-effort unimplemented feature. - **Approach:** Added `HOROSCOPE_TEMPLATES` (30 entries) and `getDailyHoroscope(nowMs, templates)` pure function to `death-clock-core.js`; wired up a new `src/js/21-horoscope.js` DOM module with `
/` collapse, localStorage date tracking, and a share button reusing `openSharePopup()`. diff --git a/src/js/00-state.js b/src/js/00-state.js index 6b86a4c..1dce8b5 100644 --- a/src/js/00-state.js +++ b/src/js/00-state.js @@ -23,6 +23,7 @@ getRateAtDate, RATE_GROWTH_PER_YEAR, getDynamicRate, + computeExtinctionSecsRemaining, calculateTipImpact, generateEquivalences, calculatePersonalFootprint, diff --git a/src/js/02-counter.js b/src/js/02-counter.js index b71765c..e97b611 100644 --- a/src/js/02-counter.js +++ b/src/js/02-counter.js @@ -176,11 +176,9 @@ const extinctionMilestone = MILESTONES.find(m => m.extinctionMarker); if (!extinctionMilestone) return; - const tokens = getCurrentTokens(); - const currentRate = getDynamicRate(new Date()); - const tokensRemaining = extinctionMilestone.tokens - tokens; + const secsRemaining = computeExtinctionSecsRemaining(extinctionMilestone.tokens, Date.now()); - if (tokensRemaining <= 0) { + if (secsRemaining <= 0) { // Extinction threshold reached — show zeroed-out timer _EXT_UNIT_IDS.forEach(id => { const el = document.getElementById(id); @@ -189,7 +187,6 @@ return; } - const secsRemaining = tokensRemaining / currentRate; const years = Math.floor(secsRemaining / _EXT_SECS_PER_YEAR); const remAfterY = secsRemaining % _EXT_SECS_PER_YEAR; const days = Math.floor(remAfterY / _EXT_SECS_PER_DAY); diff --git a/tests/death-clock.test.js b/tests/death-clock.test.js index 4f7633f..2e93d62 100644 --- a/tests/death-clock.test.js +++ b/tests/death-clock.test.js @@ -36,9 +36,11 @@ const { SESSION_CHALLENGE_DEFS, BASE_TOKENS, TOKENS_PER_SECOND, + BASE_DATE_ISO, getSimulatedViewerCount, getDynamicRate, RATE_GROWTH_PER_YEAR, + computeExtinctionSecsRemaining, } = core; // ============================================================ @@ -858,6 +860,59 @@ describe('calculateTipImpact', () => { }); }); +// ============================================================ +// computeExtinctionSecsRemaining — 1-second-per-tick invariant +// ============================================================ +describe('computeExtinctionSecsRemaining', () => { + // A token target safely beyond BASE_TOKENS (10 quadrillion above baseline) + const TARGET = BASE_TOKENS + 10_000_000_000_000_000; // 10 quadrillion above base + // Anchor "now" to BASE_DATE_ISO so results are deterministic + const baseMs = new Date(BASE_DATE_ISO).getTime(); + + test('returns a positive number when target is in the future', () => { + const secs = computeExtinctionSecsRemaining(TARGET, baseMs); + expect(secs).toBeGreaterThan(0); + }); + + test('decreases by exactly 1 for every 1 second that elapses', () => { + // This is the core invariant — the old linear approximation fails this test + // because it ticked down by ~2 seconds per second when the rate was growing. + const t0 = baseMs; + const t1 = baseMs + 1000; + const secs0 = computeExtinctionSecsRemaining(TARGET, t0); + const secs1 = computeExtinctionSecsRemaining(TARGET, t1); + expect(secs0 - secs1).toBeCloseTo(1, 9); + }); + + test('decreases by exactly 60 over a 60-second window', () => { + const t0 = baseMs; + const t1 = baseMs + 60_000; + const secs0 = computeExtinctionSecsRemaining(TARGET, t0); + const secs1 = computeExtinctionSecsRemaining(TARGET, t1); + expect(secs0 - secs1).toBeCloseTo(60, 9); + }); + + test('returns 0 or negative when target equals or is below BASE_TOKENS', () => { + expect(computeExtinctionSecsRemaining(BASE_TOKENS, baseMs)).toBe(0); + expect(computeExtinctionSecsRemaining(BASE_TOKENS - 1, baseMs)).toBe(0); + }); + + test('returns a negative number when target was passed in the past', () => { + // Advance far into the future so that target has already been passed + const farFuture = baseMs + 1_000_000_000 * 1000; // ~31 years from base + const secs = computeExtinctionSecsRemaining(TARGET, farFuture); + expect(secs).toBeLessThan(0); + }); + + test('handles non-numeric nowMs by defaulting to Date.now()', () => { + // Should not throw; result will be a finite number + const secs = computeExtinctionSecsRemaining(TARGET); + expect(typeof secs).toBe('number'); + expect(isFinite(secs)).toBe(true); + }); +}); + + // ============================================================ // TOKEN_TIPS // ============================================================ From 3fa51b66bbd06501126ac3e06364c84816dcb337 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 04:02:04 +0000 Subject: [PATCH 2/3] =?UTF-8?q?docs:=20fix=20learning=20cross-reference=20?= =?UTF-8?q?A3=E2=86=92A5=20in=20LEARNINGS.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/nitrocode/token-deathclock/sessions/7fd5f569-36ed-4fc9-83e4-c1ea9f0bd26a Co-authored-by: nitrocode <7775707+nitrocode@users.noreply.github.com> --- docs/LEARNINGS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/LEARNINGS.md b/docs/LEARNINGS.md index 9b37f2e..3378a72 100644 --- a/docs/LEARNINGS.md +++ b/docs/LEARNINGS.md @@ -148,7 +148,7 @@ Entries are grouped by release. Add new entries at the top of the appropriate re - **Problem:** The extinction countdown header displayed the seconds decreasing by ~2 per tick instead of 1, because `updateExtinctionCountdown` used `tokensRemaining / currentRate` (linear approximation) while `getCurrentTokens` uses an exponentially-growing integral model. - **Approach:** Added `computeExtinctionSecsRemaining(targetTokens, nowMs)` pure function to `death-clock-core.js` that solves the inverse of the cumulative-token integral (`tExtinction = ln(1 + (target - BASE_TOKENS)*k/R0)/k`). Since `tExtinction` is a constant and `tNow` advances by 1 s/s, the result decreases by exactly 1 per second. Updated `updateExtinctionCountdown` to use it. Added 6 unit tests including the key 1-second-per-tick invariant. -- **Learning:** When displaying a countdown to a future token threshold, always invert the integral accumulation formula rather than using `tokensRemaining / currentRate`. The naïve linear formula ticks down by `(1 + secsRemaining * k)` per second, which is ~2× at typical extinction distances. (→ A3) +- **Learning:** When displaying a countdown to a future token threshold, always invert the integral accumulation formula rather than using `tokensRemaining / currentRate`. The naïve linear formula ticks down by `(1 + secsRemaining * k)` per second, which is ~2× at typical extinction distances. (→ A5) - **Key files:** `death-clock-core.js`, `src/js/02-counter.js`, `src/js/00-state.js`, `tests/death-clock.test.js` --- From a1f38cb4a6236c45b77d2b254ddc64ee551160cd Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:43:56 +0000 Subject: [PATCH 3/3] fix: apply CodeRabbit auto-fixes Fixed 2 file(s) based on 2 unresolved review comments. Co-authored-by: CodeRabbit --- docs/LEARNINGS.md | 4 ++-- tests/death-clock.test.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/LEARNINGS.md b/docs/LEARNINGS.md index 3378a72..df6a4d9 100644 --- a/docs/LEARNINGS.md +++ b/docs/LEARNINGS.md @@ -102,7 +102,7 @@ Every PR description (written by a human or agent) must follow this structure: | A2 | Enable TypeScript type-checking via `checkJs: true` in `tsconfig.json` with JSDoc annotations. This catches type errors in plain `.js` files without requiring a full TS migration. | #54 | | A3 | `death-clock-core.js` must never reference the DOM (`document`, `window`, `getElementById`, etc.). All DOM wiring belongs in `src/js/`. This boundary keeps the core unit-testable. | AGENTS.md | | A4 | The CommonJS + browser dual-export pattern (`module.exports` for Jest, `window.DeathClockCore` for the browser) must be maintained. Do not convert to ES modules without updating all consumers. | AGENTS.md | -| A5 | When displaying a countdown to a future token threshold, invert the integral accumulation formula (`tExtinction = ln(1+(target-BASE_TOKENS)*k/R0)/k`) rather than using `tokensRemaining / currentRate`. The naïve linear formula ticks down by `1 + secsRemaining*k` per second (~2 at typical extinction distances). | #104 | +| A5 | When displaying a countdown to a future token threshold, invert the integral accumulation formula (`tExtinction = ln(1+(target-BASE_TOKENS)*k/R0)/k`) rather than using `tokensRemaining / currentRate`. The naïve linear formula ticks down by `1 + secsRemaining*k` per second (~2 at typical extinction distances). | #107 | --- @@ -144,7 +144,7 @@ Entries are grouped by release. Add new entries at the top of the appropriate re ### v1.7.x -#### PR #104 — fix: extinction countdown ticks down by 2 seconds instead of 1 +#### PR #107 — fix: extinction countdown ticks down by 2 seconds instead of 1 - **Problem:** The extinction countdown header displayed the seconds decreasing by ~2 per tick instead of 1, because `updateExtinctionCountdown` used `tokensRemaining / currentRate` (linear approximation) while `getCurrentTokens` uses an exponentially-growing integral model. - **Approach:** Added `computeExtinctionSecsRemaining(targetTokens, nowMs)` pure function to `death-clock-core.js` that solves the inverse of the cumulative-token integral (`tExtinction = ln(1 + (target - BASE_TOKENS)*k/R0)/k`). Since `tExtinction` is a constant and `tNow` advances by 1 s/s, the result decreases by exactly 1 per second. Updated `updateExtinctionCountdown` to use it. Added 6 unit tests including the key 1-second-per-tick invariant. diff --git a/tests/death-clock.test.js b/tests/death-clock.test.js index 2e93d62..5ddf05f 100644 --- a/tests/death-clock.test.js +++ b/tests/death-clock.test.js @@ -906,7 +906,7 @@ describe('computeExtinctionSecsRemaining', () => { test('handles non-numeric nowMs by defaulting to Date.now()', () => { // Should not throw; result will be a finite number - const secs = computeExtinctionSecsRemaining(TARGET); + const secs = computeExtinctionSecsRemaining(TARGET, 'abc'); expect(typeof secs).toBe('number'); expect(isFinite(secs)).toBe(true); });