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
32 changes: 32 additions & 0 deletions death-clock-core.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -820,6 +851,7 @@ const DeathClockCore = {
getRateAtDate,
RATE_GROWTH_PER_YEAR,
getDynamicRate,
computeExtinctionSecsRemaining,
calculateTipImpact,
generateEquivalences,
calculatePersonalFootprint,
Expand Down
12 changes: 11 additions & 1 deletion docs/LEARNINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). | #107 |

---

Expand Down Expand Up @@ -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 #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.
- **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`

---



- **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 `<details>/<summary>` collapse, localStorage date tracking, and a share button reusing `openSharePopup()`.
Expand Down
1 change: 1 addition & 0 deletions src/js/00-state.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
getRateAtDate,
RATE_GROWTH_PER_YEAR,
getDynamicRate,
computeExtinctionSecsRemaining,
calculateTipImpact,
generateEquivalences,
calculatePersonalFootprint,
Expand Down
7 changes: 2 additions & 5 deletions src/js/02-counter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
55 changes: 55 additions & 0 deletions tests/death-clock.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,11 @@ const {
SESSION_CHALLENGE_DEFS,
BASE_TOKENS,
TOKENS_PER_SECOND,
BASE_DATE_ISO,
getSimulatedViewerCount,
getDynamicRate,
RATE_GROWTH_PER_YEAR,
computeExtinctionSecsRemaining,
} = core;

// ============================================================
Expand Down Expand Up @@ -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, 'abc');
expect(typeof secs).toBe('number');
expect(isFinite(secs)).toBe(true);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});


// ============================================================
// TOKEN_TIPS
// ============================================================
Expand Down
Loading