From 4f552c7914ccaa08e72a980e101485fb6067c919 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 20:41:14 +0000 Subject: [PATCH 1/3] perf(tests): speed up E2E test suite Agent-Logs-Url: https://github.com/nitrocode/token-deathclock/sessions/50c7ed30-3b70-4e1d-b2b0-6887115a948a Co-authored-by: nitrocode <7775707+nitrocode@users.noreply.github.com> --- .github/workflows/e2e-tests.yml | 12 ++++++ playwright.config.js | 2 + tests/e2e/death-clock.spec.js | 69 ++++++++++++++++++--------------- 3 files changed, 52 insertions(+), 31 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 450d97b..1673093 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -48,9 +48,21 @@ jobs: - name: Install dependencies run: npm ci + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf # v4.2.2 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' run: npx playwright install --with-deps chromium + - name: Install Playwright system dependencies + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: npx playwright install-deps chromium + - name: Build generated files run: npm run build diff --git a/playwright.config.js b/playwright.config.js index cebe37a..1a78064 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -6,6 +6,8 @@ module.exports = defineConfig({ testDir: './tests/e2e', timeout: 30_000, retries: 0, + fullyParallel: true, + workers: process.env.CI ? 4 : undefined, use: { baseURL: 'http://localhost:3000', headless: true, diff --git a/tests/e2e/death-clock.spec.js b/tests/e2e/death-clock.spec.js index 014829c..254fd9c 100644 --- a/tests/e2e/death-clock.spec.js +++ b/tests/e2e/death-clock.spec.js @@ -29,10 +29,12 @@ async function waitForCounter(page, selector, timeout = 3000) { test.describe('AI Death Clock — end-to-end', () => { - test.beforeEach(async ({ page }) => { + test.beforeEach(async ({ page }, testInfo) => { + // Desktop-only tests — skip on the mobile-chrome project to avoid doubling run time + test.skip(testInfo.project.name !== 'chromium', 'Desktop-only test suite'); await page.goto('/'); - // Give the page time to initialise (RAF loop, Chart.js, etc.) - await page.waitForLoadState('networkidle'); + // Wait for DOM+resources; networkidle is unnecessary for a static site. + await page.waitForLoadState('load'); }); // ── Page structure ──────────────────────────────────────────────────────── @@ -56,12 +58,12 @@ test.describe('AI Death Clock — end-to-end', () => { }); test('session counter populates after a moment', async ({ page }) => { - // Wait up to 3 s for at least one tick - await page.waitForTimeout(1100); - const text = await page.locator('#sessionCounter').textContent(); - expect(text.trim()).not.toBe(''); - // Should be a formatted number (contains digits) - expect(text).toMatch(/\d/); + // Poll until the counter has at least one tick + await expect(async () => { + const text = await page.locator('#sessionCounter').textContent(); + expect(text.trim()).not.toBe(''); + expect(text).toMatch(/\d/); + }).toPass({ timeout: 3000 }); }); test('current rate counter shows a dynamic rate', async ({ page }) => { @@ -73,21 +75,25 @@ test.describe('AI Death Clock — end-to-end', () => { }); test('rate event subtitle is populated', async ({ page }) => { - await page.waitForTimeout(500); - const text = await page.locator('#rateEvent').textContent(); - expect(text.trim()).not.toBe(''); - expect(text.toLowerCase()).toContain('tokens'); + await expect(async () => { + const text = await page.locator('#rateEvent').textContent(); + expect(text.trim()).not.toBe(''); + expect(text.toLowerCase()).toContain('tokens'); + }).toPass({ timeout: 3000 }); }); test('total counter grows over time', async ({ page }) => { await waitForCounter(page, '#totalCounter'); const first = await page.locator('#totalCounter').textContent(); - await page.waitForTimeout(2000); - const second = await page.locator('#totalCounter').textContent(); - // Both should be truthy; after 2 s the numeric part should advance - // (They may format the same string if growth is tiny — at minimum they must be non-empty) expect(first).toBeTruthy(); + // The formatted display rounds to 3 decimal places of a quadrillion, so the + // visible text does not change on a sub-second timescale. A short pause is + // enough to confirm the counter stays live and non-empty. + await page.waitForTimeout(200); + const second = await page.locator('#totalCounter').textContent(); expect(second).toBeTruthy(); + expect(second.trim()).not.toBe(''); + expect(second).not.toContain('Loading'); }); // ── Environmental impact strip ──────────────────────────────────────────── @@ -169,12 +175,13 @@ test.describe('AI Death Clock — end-to-end', () => { }); test('chart canvas has non-zero dimensions after render', async ({ page }) => { - // Give Chart.js time to paint - await page.waitForTimeout(1000); - const box = await page.locator('#tokenChart').boundingBox(); - expect(box).not.toBeNull(); - expect(box.width).toBeGreaterThan(0); - expect(box.height).toBeGreaterThan(0); + // Poll until Chart.js has painted and the canvas has non-zero dimensions + await expect(async () => { + const box = await page.locator('#tokenChart').boundingBox(); + expect(box).not.toBeNull(); + expect(box.width).toBeGreaterThan(0); + expect(box.height).toBeGreaterThan(0); + }).toPass({ timeout: 3000 }); }); // ── Theme toggle ────────────────────────────────────────────────────────── @@ -260,7 +267,7 @@ test.describe('AI Death Clock — end-to-end', () => { const errors = []; page.on('pageerror', (err) => errors.push(err.message)); await page.reload(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); expect(errors).toHaveLength(0); }); @@ -277,7 +284,7 @@ test.describe('AI Death Clock — end-to-end', () => { } }); await page.reload(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); expect(messages).toHaveLength(0); }); @@ -305,7 +312,7 @@ test.describe('AI Death Clock — end-to-end', () => { test('navigating to a section anchor deep-link activates the correct tab', async ({ page }) => { await page.goto('/#milestones-section'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); // milestones-section lives in the dashboard tab; it should be visible (not hidden) const section = page.locator('#milestones-section'); await expect(section).toBeVisible(); @@ -325,7 +332,7 @@ test.describe('mobile layout — fixed elements within viewport', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); }); test('GitHub corner banner is fully within the viewport on mobile', async ({ page }) => { @@ -381,14 +388,14 @@ test.describe('mobile layout — fixed elements within viewport', () => { await reaper.click(); await expect(bubble).toHaveClass(/visible/, { timeout: 1000 }); - // After ~4 s the bubble should disappear (default duration is 3.5 s) - await expect(bubble).not.toHaveClass(/visible/, { timeout: 5000 }); + // After ~4 s the bubble should disappear (app default is 3.5 s + 700 ms buffer = 4200 ms) + await expect(bubble).not.toHaveClass(/visible/, { timeout: 4200 }); }); test('Share Your Doom button is fully within the viewport on mobile', async ({ page }) => { // Reveal the panel immediately via the ?share=true query param await page.goto('/?share=true'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const vp = page.viewportSize(); const btn = page.locator('#shareDoomBtn'); @@ -411,7 +418,7 @@ test.describe('Mobile tab bar — 375 px viewport', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); }); test('all four tab buttons are present in the DOM at mobile width', async ({ page }) => { From 47cc6c64f49eef8b9003787e793f7309404318f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:00:20 +0000 Subject: [PATCH 2/3] test(e2e): address review findings in death-clock spec Agent-Logs-Url: https://github.com/nitrocode/token-deathclock/sessions/c940ec65-4b4c-4e96-ab02-1aa38a66c704 Co-authored-by: nitrocode <7775707+nitrocode@users.noreply.github.com> --- tests/e2e/death-clock.spec.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/e2e/death-clock.spec.js b/tests/e2e/death-clock.spec.js index 254fd9c..93066a9 100644 --- a/tests/e2e/death-clock.spec.js +++ b/tests/e2e/death-clock.spec.js @@ -61,6 +61,7 @@ test.describe('AI Death Clock — end-to-end', () => { // Poll until the counter has at least one tick await expect(async () => { const text = await page.locator('#sessionCounter').textContent(); + expect(text).toBeTruthy(); expect(text.trim()).not.toBe(''); expect(text).toMatch(/\d/); }).toPass({ timeout: 3000 }); @@ -77,12 +78,13 @@ test.describe('AI Death Clock — end-to-end', () => { test('rate event subtitle is populated', async ({ page }) => { await expect(async () => { const text = await page.locator('#rateEvent').textContent(); + expect(text).toBeTruthy(); expect(text.trim()).not.toBe(''); expect(text.toLowerCase()).toContain('tokens'); }).toPass({ timeout: 3000 }); }); - test('total counter grows over time', async ({ page }) => { + test('total counter stays non-empty over time', async ({ page }) => { await waitForCounter(page, '#totalCounter'); const first = await page.locator('#totalCounter').textContent(); expect(first).toBeTruthy(); @@ -388,8 +390,8 @@ test.describe('mobile layout — fixed elements within viewport', () => { await reaper.click(); await expect(bubble).toHaveClass(/visible/, { timeout: 1000 }); - // After ~4 s the bubble should disappear (app default is 3.5 s + 700 ms buffer = 4200 ms) - await expect(bubble).not.toHaveClass(/visible/, { timeout: 4200 }); + // After ~4 s the bubble should disappear (app default is 3.5 s + 2.5 s CI buffer = 6000 ms) + await expect(bubble).not.toHaveClass(/visible/, { timeout: 6000 }); }); test('Share Your Doom button is fully within the viewport on mobile', async ({ page }) => { From bf3f1f96c0d4a82c0dd16d4f0a0779efdd5898b4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:03:30 +0000 Subject: [PATCH 3/3] chore: merge main into branch, resolve conflicts Agent-Logs-Url: https://github.com/nitrocode/token-deathclock/sessions/9658237a-bfeb-4951-ada4-2b39190ebf67 Co-authored-by: nitrocode <7775707+nitrocode@users.noreply.github.com> --- .github/actions/setup/action.yml | 15 +++ .github/workflows/{unit-tests.yml => ci.yml} | 52 +++++++--- .github/workflows/deploy.yml | 29 +----- .github/workflows/e2e-tests.yml | 83 --------------- .github/workflows/preview.yml | 17 +--- .github/workflows/weekly-stats-check.yml | 100 +++++++++++++++++++ index.html | 30 +++++- scripts/build-bundle.js | 67 +++++++++++++ scripts/build-css.js | 34 ++----- scripts/build-js.js | 52 +++------- src/js/02-counter.js | 54 ++++++++++ src/js/21-boot.js | 4 + styles/hero-tabs.css | 77 +++++++++++++- tests/e2e/death-clock.spec.js | 11 +- 14 files changed, 418 insertions(+), 207 deletions(-) create mode 100644 .github/actions/setup/action.yml rename .github/workflows/{unit-tests.yml => ci.yml} (56%) delete mode 100644 .github/workflows/e2e-tests.yml create mode 100644 .github/workflows/weekly-stats-check.yml create mode 100644 scripts/build-bundle.js diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 0000000..6bd6bcb --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,15 @@ +name: 'Setup Node.js environment' +description: 'Configure Node.js 22 with npm cache and install dependencies' + +runs: + using: composite + steps: + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + shell: bash diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/ci.yml similarity index 56% rename from .github/workflows/unit-tests.yml rename to .github/workflows/ci.yml index 1f4fd11..7f4d83a 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,8 @@ -name: Unit Tests +name: CI on: + schedule: + - cron: '0 9 * * 1' # Every Monday at 09:00 UTC push: branches: - main @@ -14,13 +16,16 @@ jobs: changes: runs-on: ubuntu-latest outputs: - code: ${{ steps.filter.outputs.code }} + # On scheduled runs there is no diff to compare — always run tests. + # On push/PR, only run when relevant files changed. + code: ${{ github.event_name == 'schedule' || steps.filter.outputs.code == 'true' }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Detect relevant file changes uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + if: github.event_name != 'schedule' id: filter with: filters: | @@ -36,17 +41,8 @@ jobs: if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: '22' - cache: 'npm' - - - name: Install dependencies - run: npm ci + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/setup - name: Build generated files needed for tests run: npm run build:milestones && npm run build:js @@ -65,6 +61,23 @@ jobs: verbose: true fail_ci_if_error: false + test-e2e: + needs: changes + if: needs.changes.outputs.code == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/setup + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Build generated files + run: npm run build + + - name: Run E2E tests + run: npm run test:e2e + unit-test-status: needs: [changes, test] if: always() @@ -77,3 +90,16 @@ jobs: exit 1 fi echo "Unit tests passed or were skipped (no relevant files changed)" + + e2e-test-status: + needs: [changes, test-e2e] + if: always() + runs-on: ubuntu-latest + steps: + - name: Report status + run: | + if [[ "${{ needs.test-e2e.result }}" == "failure" ]]; then + echo "E2E tests failed" + exit 1 + fi + echo "E2E tests passed or were skipped (no relevant files changed)" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 94cc553..fdde754 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -20,32 +20,11 @@ jobs: name: github-pages url: https://nitrocode.github.io/token-deathclock/ steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/setup - - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: '22' - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Regenerate milestones-data.js from YAML - run: npm run build:milestones - - - name: Regenerate changelog-data.js from CHANGELOG.md - run: npm run build:changelog - - - name: Regenerate project-stats-data.js from YAML - run: npm run build:project-stats - - - name: Rebuild script.js from src/js/ source files - run: npm run build:js - - - name: Rebuild styles.css from styles/ source files - run: npm run build:css + - name: Build all generated files + run: npm run build - name: Deploy to GitHub Pages (gh-pages branch) uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4.0.0 diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml deleted file mode 100644 index 1673093..0000000 --- a/.github/workflows/e2e-tests.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: E2E Tests - -on: - push: - branches: - - main - pull_request: - merge_group: - -permissions: - contents: read - -jobs: - changes: - runs-on: ubuntu-latest - outputs: - code: ${{ steps.filter.outputs.code }} - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Detect relevant file changes - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 - id: filter - with: - filters: | - code: - - '**/*.js' - - '**/*.ts' - - '**/*.css' - - 'tests/**' - - 'package*.json' - - test-e2e: - needs: changes - if: needs.changes.outputs.code == 'true' - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: '22' - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Cache Playwright browsers - id: playwright-cache - uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf # v4.2.2 - with: - path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} - - - name: Install Playwright browsers - if: steps.playwright-cache.outputs.cache-hit != 'true' - run: npx playwright install --with-deps chromium - - - name: Install Playwright system dependencies - if: steps.playwright-cache.outputs.cache-hit == 'true' - run: npx playwright install-deps chromium - - - name: Build generated files - run: npm run build - - - name: Run E2E tests - run: npm run test:e2e - - e2e-test-status: - needs: [changes, test-e2e] - if: always() - runs-on: ubuntu-latest - steps: - - name: Report status - run: | - if [[ "${{ needs.test-e2e.result }}" == "failure" ]]; then - echo "E2E tests failed" - exit 1 - fi - echo "E2E tests passed or were skipped (no relevant files changed)" diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 1596bfb..1522793 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -12,17 +12,8 @@ jobs: preview: runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: '22' - cache: 'npm' - - - name: Install dependencies - run: npm ci + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/setup - name: Build all generated files run: npm run build @@ -35,8 +26,8 @@ jobs: destination_dir: previews/pr-${{ github.event.number }} # Preserve existing previews and production files keep_files: true - # Exclude non-site files - exclude_assets: '.github,node_modules,tests,scripts,package-lock.json,package.json,milestones.yaml' + # Exclude non-site files (keep in sync with deploy.yml) + exclude_assets: '.github,node_modules,tests,scripts,package-lock.json,package.json,milestones.yaml,project-stats.yaml' - name: Post or update preview URL comment uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 diff --git a/.github/workflows/weekly-stats-check.yml b/.github/workflows/weekly-stats-check.yml new file mode 100644 index 0000000..3220fce --- /dev/null +++ b/.github/workflows/weekly-stats-check.yml @@ -0,0 +1,100 @@ +name: Weekly Project Stats Check + +# Opens a reminder issue when the recorded pr_count in project-stats.yaml +# is lower than the actual number of merged PRs in the repository. + +on: + schedule: + - cron: '0 9 * * 1' # Every Monday at 09:00 UTC + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + check-stats: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Check project-stats.yaml against actual merged PR count + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const fs = require('fs'); + + // Read the recorded pr_count from project-stats.yaml + const yaml = fs.readFileSync('project-stats.yaml', 'utf8'); + const match = yaml.match(/^pr_count:\s*(\d+)/m); + if (!match) { + throw new Error('Invalid or missing pr_count in project-stats.yaml'); + } + const recordedCount = parseInt(match[1], 10); + if (!Number.isInteger(recordedCount)) { + throw new Error('Invalid or missing pr_count in project-stats.yaml'); + } + + // Count actual merged PRs via the GitHub API + let page = 1; + let totalMerged = 0; + while (true) { + const { data } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'closed', + per_page: 100, + page, + }); + if (data.length === 0) break; + totalMerged += data.filter(pr => pr.merged_at !== null).length; + if (data.length < 100) break; + page++; + } + + console.log(`Recorded pr_count: ${recordedCount}, actual merged PRs: ${totalMerged}`); + + if (recordedCount >= totalMerged) { + console.log('project-stats.yaml is up to date — no action needed.'); + return; + } + + const title = `chore: update project-stats.yaml (recorded ${recordedCount}, actual ${totalMerged} merged PRs)`; + const body = [ + '## 📊 Project Stats Are Out of Date', + '', + `**Recorded \`pr_count\`:** ${recordedCount}`, + `**Actual merged PRs:** ${totalMerged}`, + `**Difference:** ${totalMerged - recordedCount} untracked PR(s)`, + '', + 'After the next agent session, update `project-stats.yaml`:', + '', + '```yaml', + `pr_count: ${totalMerged}`, + '```', + '', + 'Then run `npm run build:project-stats` to regenerate `project-stats-data.js`, or let the deploy workflow handle it.', + ].join('\n'); + + // Avoid duplicate open issues (paginate to cover all open items, exclude PRs) + const openIssues = await github.paginate(github.rest.issues.listForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + }); + const duplicate = openIssues.find(i => + i.title.startsWith('chore: update project-stats.yaml') && !i.pull_request + ); + if (duplicate) { + console.log(`Reminder issue already open: #${duplicate.number} — skipping.`); + return; + } + + const { data: issue } = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title, + body, + }); + console.log(`Created reminder issue: #${issue.number}`); diff --git a/index.html b/index.html index 9e53332..2847df4 100644 --- a/index.html +++ b/index.html @@ -55,8 +55,34 @@
-

AI DEATH CLOCK

-

The True Environmental Cost of Global AI Token Consumption

+

HUMAN EXTINCTION COUNTDOWN

+
+
+ + YRS +
+ +
+ + DAYS +
+ +
+ + HRS +
+ +
+ + MINS +
+ +
+ + SECS +
+
+

at current global AI token consumption rate · see milestones ↓

diff --git a/scripts/build-bundle.js b/scripts/build-bundle.js new file mode 100644 index 0000000..4b16c52 --- /dev/null +++ b/scripts/build-bundle.js @@ -0,0 +1,67 @@ +#!/usr/bin/env node +/** + * build-bundle.js + * + * Shared helper used by build-js.js and build-css.js. + * Concatenates an ordered list of source files, optionally wraps them with a + * header and footer string, then minifies the result with esbuild. + * + * This module is not a standalone script — import it via require(). + * + * @example + * const { buildBundle } = require('./build-bundle'); + * buildBundle({ parts, srcDir, outPath, loader: 'css' }); + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const esbuild = require('esbuild'); + +/** + * Build a minified bundle from an ordered list of source files. + * + * @param {object} opts + * @param {string[]} opts.parts - Ordered source file names + * @param {string} opts.srcDir - Directory that contains the source files + * @param {string} opts.outPath - Absolute path for the output file + * @param {'js'|'css'} opts.loader - esbuild loader type + * @param {string} [opts.header] - Text prepended before minification + * @param {string} [opts.footer] - Text appended before minification + * @param {object} [opts.esbuildOptions]- Extra options merged into the esbuild call + */ +function buildBundle(opts) { + const chunks = opts.parts.map((file) => { + const fullPath = path.join(opts.srcDir, file); + if (!fs.existsSync(fullPath)) { + throw new Error(`Missing source file: ${file}`); + } + return fs.readFileSync(fullPath, 'utf8'); + }); + + // Concatenate with explicit newline separators so files without trailing + // newlines don't accidentally merge tokens across boundaries. + let unminified = chunks.join('\n'); + if (opts.header) unminified = opts.header + '\n' + unminified; + if (opts.footer) unminified = unminified + '\n' + opts.footer; + + const esbuildOpts = Object.assign( + { minify: true, loader: opts.loader }, + opts.esbuildOptions || {}, + ); + + const result = esbuild.transformSync(unminified, esbuildOpts); + fs.writeFileSync(opts.outPath, result.code); + + const outName = path.basename(opts.outPath); + const ratio = unminified.length === 0 + ? '0.0' + : ((1 - result.code.length / unminified.length) * 100).toFixed(1); + console.log( + `${outName} rebuilt from ${opts.parts.length} source files ` + + `(${unminified ? unminified.split('\n').length : 0} lines → ${result.code.length} bytes, −${ratio}% via esbuild minification)`, + ); +} + +module.exports = { buildBundle }; diff --git a/scripts/build-css.js b/scripts/build-css.js index c68867a..2e82384 100644 --- a/scripts/build-css.js +++ b/scripts/build-css.js @@ -14,9 +14,8 @@ 'use strict'; -const fs = require('fs'); -const path = require('path'); -const esbuild = require('esbuild'); +const path = require('path'); +const { buildBundle } = require('./build-bundle'); const ROOT = path.resolve(__dirname, '..'); @@ -36,30 +35,9 @@ const PARTS = [ 'scary-features.css', ]; -const chunks = PARTS.map((file) => { - const fullPath = path.join(ROOT, 'styles', file); - if (!fs.existsSync(fullPath)) { - throw new Error(`Missing source file: styles/${file}`); - } - return fs.readFileSync(fullPath, 'utf8'); -}); - -// Concatenate directly — each source file preserves its own trailing blank lines -// so no additional separator is needed. -const unminified = chunks.join(''); - -const outPath = path.join(ROOT, 'styles.css'); - -// Minify with esbuild (synchronous transform API — no temp files needed). -const result = esbuild.transformSync(unminified, { - minify: true, +buildBundle({ + parts: PARTS, + srcDir: path.join(ROOT, 'styles'), + outPath: path.join(ROOT, 'styles.css'), loader: 'css', }); - -fs.writeFileSync(outPath, result.code); - -const ratio = ((1 - result.code.length / unminified.length) * 100).toFixed(1); -console.log( - `styles.css rebuilt from ${PARTS.length} source files ` + - `(${unminified.split('\n').length - 1} lines → ${result.code.length} bytes, −${ratio}% via esbuild minification)`, -); diff --git a/scripts/build-js.js b/scripts/build-js.js index 71551db..dd8ae90 100644 --- a/scripts/build-js.js +++ b/scripts/build-js.js @@ -15,9 +15,8 @@ 'use strict'; -const fs = require('fs'); -const path = require('path'); -const esbuild = require('esbuild'); +const path = require('path'); +const { buildBundle } = require('./build-bundle'); const ROOT = path.resolve(__dirname, '..'); @@ -59,38 +58,17 @@ const HEADER = [ '(function () {', ].join('\n'); -const FOOTER = '})();'; - -const chunks = PARTS.map((file) => { - const fullPath = path.join(ROOT, 'src', 'js', file); - if (!fs.existsSync(fullPath)) { - throw new Error(`Missing source file: src/js/${file}`); - } - return fs.readFileSync(fullPath, 'utf8'); -}); - -// Concatenate inner body directly — each source file preserves its own -// trailing blank lines so no additional separator is needed. -const innerBody = chunks.join(''); - -const unminified = HEADER + '\n' + innerBody + FOOTER + '\n'; - -const outPath = path.join(ROOT, 'script.js'); - -// Minify with esbuild (synchronous transform API — no temp files needed). -const result = esbuild.transformSync(unminified, { - minify: true, - // Preserve the leading banner comment so tools can still identify the file. - banner: '/* AI DEATH CLOCK — browser/DOM layer (minified) */', - // Target all modern browsers; no transpilation needed. - target: ['es2018'], - loader: 'js', +buildBundle({ + parts: PARTS, + srcDir: path.join(ROOT, 'src', 'js'), + outPath: path.join(ROOT, 'script.js'), + loader: 'js', + header: HEADER, + footer: '})();', + esbuildOptions: { + // Preserve the leading banner comment so tools can still identify the file. + banner: '/* AI DEATH CLOCK — browser/DOM layer (minified) */', + // Target all modern browsers; no transpilation needed. + target: ['es2018'], + }, }); - -fs.writeFileSync(outPath, result.code); - -const ratio = ((1 - result.code.length / unminified.length) * 100).toFixed(1); -console.log( - `script.js rebuilt from ${PARTS.length} source files ` + - `(${unminified.split('\n').length - 1} lines → ${result.code.length} bytes, −${ratio}% via esbuild minification)`, -); diff --git a/src/js/02-counter.js b/src/js/02-counter.js index b326c3f..b71765c 100644 --- a/src/js/02-counter.js +++ b/src/js/02-counter.js @@ -158,3 +158,57 @@ if (el) el.textContent = value; } + // ---- Extinction countdown (header) ----------------------- + // Module-level constants and helpers so they are created once, not on every + // call to updateExtinctionCountdown() (which runs every second). + const _EXT_SECS_PER_YEAR = 365.25 * 24 * 3600; + const _EXT_SECS_PER_DAY = 24 * 3600; + const _EXT_SECS_PER_HOUR = 3600; + const _EXT_SECS_PER_MIN = 60; + const _EXT_UNIT_IDS = ['extYears', 'extDays', 'extHours', 'extMins', 'extSecs']; + const _extPad2 = (n) => String(Math.floor(n)).padStart(2, '0'); + const _extPad3 = (n) => String(Math.floor(n)).padStart(3, '0'); + + // Finds the milestone flagged `extinctionMarker: true`, computes the + // remaining seconds at the current dynamic rate, and updates the header + // countdown display once per second. + function updateExtinctionCountdown() { + const extinctionMilestone = MILESTONES.find(m => m.extinctionMarker); + if (!extinctionMilestone) return; + + const tokens = getCurrentTokens(); + const currentRate = getDynamicRate(new Date()); + const tokensRemaining = extinctionMilestone.tokens - tokens; + + if (tokensRemaining <= 0) { + // Extinction threshold reached — show zeroed-out timer + _EXT_UNIT_IDS.forEach(id => { + const el = document.getElementById(id); + if (el) el.textContent = '00'; + }); + 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); + const remAfterD = remAfterY % _EXT_SECS_PER_DAY; + const hours = Math.floor(remAfterD / _EXT_SECS_PER_HOUR); + const remAfterH = remAfterD % _EXT_SECS_PER_HOUR; + const mins = Math.floor(remAfterH / _EXT_SECS_PER_MIN); + const secs = Math.floor(remAfterH % _EXT_SECS_PER_MIN); + + const yrsEl = document.getElementById('extYears'); + const daysEl = document.getElementById('extDays'); + const hrsEl = document.getElementById('extHours'); + const minsEl = document.getElementById('extMins'); + const secsEl = document.getElementById('extSecs'); + + if (yrsEl) yrsEl.textContent = String(years); + if (daysEl) daysEl.textContent = _extPad3(days); + if (hrsEl) hrsEl.textContent = _extPad2(hours); + if (minsEl) minsEl.textContent = _extPad2(mins); + if (secsEl) secsEl.textContent = _extPad2(secs); + } + diff --git a/src/js/21-boot.js b/src/js/21-boot.js index 2de1dac..be5a48c 100644 --- a/src/js/21-boot.js +++ b/src/js/21-boot.js @@ -69,10 +69,14 @@ // Kick off the live counter RAF loop requestAnimationFrame(updateCounters); + // Populate extinction countdown immediately, then refresh every second + updateExtinctionCountdown(); + // Check time-based badges and milestone alert every second setInterval(() => { checkTimeBadges(); checkMilestoneAlert(); + updateExtinctionCountdown(); }, 1000); } diff --git a/styles/hero-tabs.css b/styles/hero-tabs.css index 198c094..af2cc20 100644 --- a/styles/hero-tabs.css +++ b/styles/hero-tabs.css @@ -33,11 +33,80 @@ header::after { text-shadow: 0 0 20px var(--accent-glow); } -.site-subtitle { +/* ---- Extinction Countdown Timer ---- */ +.extinction-timer { + display: flex; + justify-content: center; + align-items: flex-start; + gap: 0.15rem; + margin-top: 0.75rem; + flex-wrap: wrap; +} + +.extinction-unit { + display: flex; + flex-direction: column; + align-items: center; + min-width: 3rem; +} + +.extinction-value { + font-family: 'Orbitron', monospace; + font-size: clamp(1.8rem, 5vw, 3rem); + font-weight: 900; + color: var(--accent); + text-shadow: 0 0 18px var(--accent-glow); + line-height: 1; + font-variant-numeric: tabular-nums; + letter-spacing: -0.02em; +} + +.extinction-unit-label { + font-family: 'Orbitron', monospace; + font-size: 0.55rem; + letter-spacing: 0.18em; color: var(--text-dim); - font-size: 1rem; - margin-top: 0.5rem; - letter-spacing: 0.1em; + text-transform: uppercase; + margin-top: 0.25rem; +} + +.extinction-sep { + font-family: 'Orbitron', monospace; + font-size: clamp(1.4rem, 4vw, 2.4rem); + font-weight: 900; + color: var(--accent); + opacity: 0.35; + line-height: 1; + padding-top: 0.05em; + align-self: flex-start; +} + +.extinction-caption { + font-size: 0.7rem; + color: var(--text-dim); + letter-spacing: 0.08em; + margin-top: 0.75rem; + text-transform: uppercase; +} + +.extinction-link { + color: var(--accent); + text-decoration: none; + opacity: 0.7; +} + +.extinction-link:hover { + opacity: 1; + text-decoration: underline; +} + +@media (max-width: 480px) { + .extinction-unit { + min-width: 2.25rem; + } + .extinction-unit-label { + font-size: 0.48rem; + } } /* ---- Tab Bar ---- */ diff --git a/tests/e2e/death-clock.spec.js b/tests/e2e/death-clock.spec.js index 93066a9..51dbbf5 100644 --- a/tests/e2e/death-clock.spec.js +++ b/tests/e2e/death-clock.spec.js @@ -43,9 +43,16 @@ test.describe('AI Death Clock — end-to-end', () => { await expect(page).toHaveTitle(/Token Deathclock/i); }); - test('renders main header', async ({ page }) => { + test('renders main header with extinction countdown', async ({ page }) => { await expect(page.locator('h1.site-title')).toBeVisible(); - await expect(page.locator('h1.site-title')).toContainText('AI DEATH CLOCK'); + await expect(page.locator('h1.site-title')).toContainText('HUMAN EXTINCTION COUNTDOWN'); + // Countdown timer should be present and populated after init + await page.waitForFunction(() => { + const el = document.getElementById('extYears'); + return el && el.textContent.trim() !== '—' && el.textContent.trim() !== ''; + }, { timeout: 3000 }); + const years = await page.locator('#extYears').textContent(); + expect(parseInt(years, 10)).toBeGreaterThan(0); }); // ── Live counters ─────────────────────────────────────────────────────────