diff --git a/death-clock-core.js b/death-clock-core.js index cd4da34..a59fba8 100644 --- a/death-clock-core.js +++ b/death-clock-core.js @@ -823,6 +823,40 @@ function getDailyHoroscope(nowMs, templates) { return templates[day % templates.length]; } +// ============================================================ +// AI GUILT-O-METER (Phase 3 PRD #2) +// ============================================================ + +/** + * Label thresholds for the Guilt-O-Meter progress bar. + * Each entry defines the minimum fill percentage at which that label is shown. + * Sorted ascending by `min`; the highest matching entry wins. + */ +const GUILT_LABELS = [ + { min: 0, icon: '\uD83D\uDE10', text: 'Mildly Aware' }, + { min: 20, icon: '\uD83D\uDE1F', text: 'Mild Regret' }, + { min: 40, icon: '\uD83D\uDE2C', text: 'Full Doomscroller' }, + { min: 60, icon: '\uD83D\uDE30', text: 'Carbon Hypocrite in Training' }, + { min: 80, icon: '\uD83D\uDE31', text: 'Fully Complicit' }, + { min: 100, icon: '\uD83D\uDC80', text: 'Certified Hypocrite' }, +]; + +/** + * Return the guilt label entry for a given fill percentage (0–100). + * Returns the entry with the highest `min` value that is ≤ pct. + * + * @param {number} pct - Fill percentage, clamped to [0, 100] + * @returns {{ min: number, icon: string, text: string }} + */ +function getGuiltLabel(pct) { + const clamped = Math.max(0, Math.min(100, pct)); + let result = GUILT_LABELS[0]; + for (const label of GUILT_LABELS) { + if (clamped >= label.min) result = label; + } + return result; +} + // ============================================================ // EXPORTS — CommonJS for Jest; window global for the browser // ============================================================ @@ -865,6 +899,8 @@ const DeathClockCore = { getSimulatedViewerCount, HOROSCOPE_TEMPLATES, getDailyHoroscope, + GUILT_LABELS, + getGuiltLabel, }; /* istanbul ignore else */ diff --git a/docs/LEARNINGS.md b/docs/LEARNINGS.md index df6a4d9..cc94dfc 100644 --- a/docs/LEARNINGS.md +++ b/docs/LEARNINGS.md @@ -144,6 +144,15 @@ Entries are grouped by release. Add new entries at the top of the appropriate re ### v1.7.x +#### PR #106 — feat: implement AI Guilt-O-Meter (Phase 3 PRD #2) + +- **Problem:** Phase 3 PRD #2 (AI Guilt-O-Meter) was the next unimplemented low-effort high-impact feature; the site lacked a persistent emotional hook to keep sessions engaged past the initial counter shock. +- **Approach:** Added `GUILT_LABELS` constant and `getGuiltLabel(pct)` pure function to `death-clock-core.js` for unit-testability; created `src/js/22-guilt-meter.js` with `initGuiltMeter()` / `updateGuiltMeter()` updating on the existing 1s interval; added HTML `` element with ARIA attributes and a share button; renamed `22-boot.js` → `23-boot.js` to maintain strict sequential file ordering; added the `certified_hypocrite` badge to `BADGE_DEFS`. +- **Learning:** Progress bar fill transitions in CSS require `appearance: none` plus browser-prefixed pseudo-elements (`::-webkit-progress-value`, `::-moz-progress-bar`) to render correctly across Chromium and Firefox. Always add both. (→ CSS) +- **Key files:** `death-clock-core.js`, `src/js/22-guilt-meter.js`, `src/js/23-boot.js`, `src/js/14-badges.js`, `scripts/build-js.js`, `index.html`, `styles/features.css`, `tests/death-clock.test.js` + +--- + #### 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. @@ -153,7 +162,7 @@ Entries are grouped by release. Add new entries at the top of the appropriate re --- - +#### PR #103 — feat: implement Token Horoscope daily satirical AI horoscope (Phase 3 PRD #1) - **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/index.html b/index.html index a719fbd..01af037 100644 --- a/index.html +++ b/index.html @@ -203,6 +203,32 @@

Global Token Counter

+ +
+
+
+
+ 😬 Your Guilt Level + filling up the longer you stay… +
+ +
+ 😐 Mildly Aware +
+ +
+
+
+
diff --git a/scripts/build-js.js b/scripts/build-js.js index b6feda7..f74bb6d 100644 --- a/scripts/build-js.js +++ b/scripts/build-js.js @@ -44,7 +44,8 @@ const PARTS = [ '19-milestone-alert.js', '20-tabs.js', '21-horoscope.js', - '22-boot.js', + '22-guilt-meter.js', + '23-boot.js', ]; const HEADER = [ diff --git a/src/js/00-state.js b/src/js/00-state.js index 1dce8b5..1926d25 100644 --- a/src/js/00-state.js +++ b/src/js/00-state.js @@ -37,6 +37,8 @@ getSimulatedViewerCount, HOROSCOPE_TEMPLATES, getDailyHoroscope, + GUILT_LABELS, + getGuiltLabel, } = window.DeathClockCore; // ---- Unpack changelog data ---------------------------------- diff --git a/src/js/14-badges.js b/src/js/14-badges.js index d327f35..79b7b83 100644 --- a/src/js/14-badges.js +++ b/src/js/14-badges.js @@ -29,6 +29,8 @@ { id: 'lights_out', icon: '☠️', name: 'Lights Out', desc: 'Replaced every human worker. Fully automated.', type: 'manual' }, // Witness badges { id: 'witness', icon: '👁️', name: 'Witness', desc: 'Stayed to watch a milestone get crossed in real time.', type: 'manual' }, + // Guilt-O-Meter badge + { id: 'certified_hypocrite', icon: '\uD83D\uDE2C', name: 'Certified Hypocrite', desc: 'Watched the apocalypse for 5 minutes without doing anything about it.', type: 'manual' }, ]; const LS_BADGES_KEY = 'tokenDeathclockBadges'; diff --git a/src/js/22-guilt-meter.js b/src/js/22-guilt-meter.js new file mode 100644 index 0000000..05354d0 --- /dev/null +++ b/src/js/22-guilt-meter.js @@ -0,0 +1,54 @@ + // ---- AI Guilt-O-Meter (Phase 3 PRD #2) ---------------------- + + const GUILT_DURATION_MS = 300000; // 5 minutes → 100 % + + let _guiltCertified = false; + + function updateGuiltMeter() { + const bar = document.getElementById('guiltMeterBar'); + const labelEl = document.getElementById('guiltMeterLabel'); + const shareBtn = document.getElementById('guiltShareBtn'); + if (!bar || !labelEl) return; + + const elapsed = Math.max(0, Date.now() - pageLoadTime); + const pct = Math.min(100, Math.floor((elapsed / GUILT_DURATION_MS) * 100)); + const label = getGuiltLabel(pct); + const labelText = label.icon + '\u00A0' + label.text; + + bar.value = pct; + bar.setAttribute('aria-valuenow', pct); + bar.setAttribute('aria-label', labelText); + labelEl.textContent = labelText; + + if (shareBtn) shareBtn.hidden = pct < 20; + + if (pct >= 100 && !_guiltCertified) { + _guiltCertified = true; + awardBadge('certified_hypocrite'); + } + } + + function initGuiltMeter() { + const shareBtn = document.getElementById('guiltShareBtn'); + + if (shareBtn) { + shareBtn.addEventListener('click', () => { + const elapsed = Math.max(0, Date.now() - pageLoadTime); + const mins = Math.floor(elapsed / 60000) || 1; + const labelEl = document.getElementById('guiltMeterLabel'); + const labelText = labelEl ? labelEl.textContent : ''; + const shareText = + '\uD83D\uDE2C I\u2019ve been watching AI consume tokens for ' + + mins + ' minute' + (mins !== 1 ? 's' : '') + + ' and done absolutely nothing about it.' + + ' My guilt level: ' + labelText + + '. Are you as bad as me?\n\u2192 ' + SITE_URL + + ' #TokenDeathClock #CertifiedHypocrite'; + openSharePopup(shareText); + awardBadge('spreading_doom'); + }); + } + + // Render initial state + updateGuiltMeter(); + } diff --git a/src/js/22-boot.js b/src/js/23-boot.js similarity index 98% rename from src/js/22-boot.js rename to src/js/23-boot.js index aad9156..2518518 100644 --- a/src/js/22-boot.js +++ b/src/js/23-boot.js @@ -49,6 +49,7 @@ initCalculator(); initAccelerator(); initHoroscope(); + initGuiltMeter(); // Engagement features initPresenceStrip(); initEventLog(); @@ -78,6 +79,7 @@ checkTimeBadges(); checkMilestoneAlert(); updateExtinctionCountdown(); + updateGuiltMeter(); }, 1000); } diff --git a/styles/features.css b/styles/features.css index b6775bf..5542fe8 100644 --- a/styles/features.css +++ b/styles/features.css @@ -725,6 +725,83 @@ .calc-result-grid { grid-template-columns: 1fr 1fr; } } +/* ---- AI Guilt-O-Meter ---- */ +.guilt-meter-wrap { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 0.75rem; + padding: 1rem 1.25rem; + margin-top: 1.5rem; +} + +.guilt-meter-header { + display: flex; + align-items: baseline; + gap: 0.6rem; + margin-bottom: 0.6rem; + flex-wrap: wrap; +} + +.guilt-meter-title { + font-size: 0.9rem; + font-weight: 700; + color: var(--text); +} + +.guilt-meter-hint { + font-size: 0.78rem; + color: var(--text-muted); + font-style: italic; +} + +.guilt-meter-bar { + width: 100%; + height: 1.1rem; + border-radius: 0.55rem; + overflow: hidden; + appearance: none; + -webkit-appearance: none; + background: var(--surface-3, var(--surface-2)); + border: 1px solid var(--border); + display: block; + transition: width 0.6s ease; +} + +/* WebKit / Blink */ +.guilt-meter-bar::-webkit-progress-bar { + background: var(--surface-3, var(--surface-2)); + border-radius: 0.55rem; +} +.guilt-meter-bar::-webkit-progress-value { + background: linear-gradient(90deg, var(--accent, #38d9a9) 0%, var(--danger, #ff6b6b) 100%); + border-radius: 0.55rem; + transition: width 0.6s ease; +} + +/* Firefox */ +.guilt-meter-bar::-moz-progress-bar { + background: linear-gradient(90deg, var(--accent, #38d9a9) 0%, var(--danger, #ff6b6b) 100%); + border-radius: 0.55rem; + transition: width 0.6s ease; +} + +.guilt-meter-label-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 0.45rem; +} + +.guilt-meter-label { + font-size: 0.88rem; + font-weight: 600; + color: var(--text); +} + +.guilt-share-btn { + margin-top: 0.75rem; +} + /* ---- Reduced motion ---- */ @media (prefers-reduced-motion: reduce) { .toast, .toast-in, .toast-out, @@ -733,5 +810,8 @@ .ai-ticker-text { transition: none; animation: none; } .badge-item.badge-anim-out, .badge-item.badge-anim-in { animation: none; opacity: 1; transform: none; filter: none; } + .guilt-meter-bar, + .guilt-meter-bar::-webkit-progress-value, + .guilt-meter-bar::-moz-progress-bar { transition: none; } } diff --git a/tests/death-clock.test.js b/tests/death-clock.test.js index 5ddf05f..ae311dd 100644 --- a/tests/death-clock.test.js +++ b/tests/death-clock.test.js @@ -1686,3 +1686,90 @@ describe('getDailyHoroscope', () => { expect(HOROSCOPE_TEMPLATES).toContain(result); }); }); + +// ============================================================ +// GUILT_LABELS +// ============================================================ +const { GUILT_LABELS, getGuiltLabel } = core; + +describe('GUILT_LABELS', () => { + test('is a non-empty array', () => { + expect(Array.isArray(GUILT_LABELS)).toBe(true); + expect(GUILT_LABELS.length).toBeGreaterThan(0); + }); + + test('every entry has min, icon, and text properties', () => { + GUILT_LABELS.forEach((label) => { + expect(typeof label.min).toBe('number'); + expect(typeof label.icon).toBe('string'); + expect(label.icon.length).toBeGreaterThan(0); + expect(typeof label.text).toBe('string'); + expect(label.text.length).toBeGreaterThan(0); + }); + }); + + test('first entry has min === 0', () => { + expect(GUILT_LABELS[0].min).toBe(0); + }); + + test('entries are sorted ascending by min', () => { + for (let i = 1; i < GUILT_LABELS.length; i++) { + expect(GUILT_LABELS[i].min).toBeGreaterThan(GUILT_LABELS[i - 1].min); + } + }); + + test('includes a 100% Certified Hypocrite entry', () => { + const full = GUILT_LABELS.find((l) => l.min === 100); + expect(full).toBeDefined(); + expect(full.text).toContain('Certified Hypocrite'); + }); +}); + +// ============================================================ +// getGuiltLabel +// ============================================================ +describe('getGuiltLabel', () => { + test('returns the first label for pct === 0', () => { + const result = getGuiltLabel(0); + expect(result).toBe(GUILT_LABELS[0]); + }); + + test('returns the correct label at each threshold boundary', () => { + GUILT_LABELS.forEach((label) => { + const result = getGuiltLabel(label.min); + expect(result).toBe(label); + }); + }); + + test('returns the last label for pct === 100', () => { + const last = GUILT_LABELS[GUILT_LABELS.length - 1]; + expect(getGuiltLabel(100)).toBe(last); + }); + + test('returns the Certified Hypocrite label for pct > 100 (clamped)', () => { + const last = GUILT_LABELS[GUILT_LABELS.length - 1]; + expect(getGuiltLabel(200)).toBe(last); + }); + + test('returns the first label for negative pct (clamped)', () => { + expect(getGuiltLabel(-10)).toBe(GUILT_LABELS[0]); + }); + + test('returns the correct label for mid-range values', () => { + // pct = 50 should be 'Full Doomscroller' (min 40) but not 'Carbon Hypocrite in Training' (min 60) + const result = getGuiltLabel(50); + expect(result.text).toBe('Full Doomscroller'); + }); + + test('returns an object with icon and text properties', () => { + const result = getGuiltLabel(30); + expect(typeof result.icon).toBe('string'); + expect(typeof result.text).toBe('string'); + }); + + test('boundary just below a threshold returns the previous label', () => { + // pct = 39 should be 'Mild Regret' (min 20), not 'Full Doomscroller' (min 40) + const result = getGuiltLabel(39); + expect(result.text).toBe('Mild Regret'); + }); +});