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
36 changes: 36 additions & 0 deletions death-clock-core.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ============================================================
Expand Down Expand Up @@ -865,6 +899,8 @@ const DeathClockCore = {
getSimulatedViewerCount,
HOROSCOPE_TEMPLATES,
getDailyHoroscope,
GUILT_LABELS,
getGuiltLabel,
};

/* istanbul ignore else */
Expand Down
11 changes: 10 additions & 1 deletion docs/LEARNINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<progress>` 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.
Expand All @@ -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 `<details>/<summary>` collapse, localStorage date tracking, and a share button reusing `openSharePopup()`.
Expand Down
26 changes: 26 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,32 @@ <h2>Global Token Counter</h2>
</div>
</section>

<!-- ── AI Guilt-O-Meter (Phase 3 PRD #2) ───────────────────── -->
<section id="guilt-meter-section">
<div class="container">
<div class="guilt-meter-wrap">
<div class="guilt-meter-header">
<span class="guilt-meter-title">😬 Your Guilt Level</span>
<span class="guilt-meter-hint">filling up the longer you stay…</span>
</div>
<progress
id="guiltMeterBar"
class="guilt-meter-bar"
value="0"
max="100"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="0"
aria-label="Mildly Aware"
></progress>
<div class="guilt-meter-label-row">
<span id="guiltMeterLabel" class="guilt-meter-label" aria-live="polite" aria-atomic="true">😐 Mildly Aware</span>
</div>
<button id="guiltShareBtn" class="btn-sm guilt-share-btn" hidden>📤 Share My Guilt</button>
</div>
</div>
</section>

<!-- ── Daily AI Horoscope ────────────────────────────────── -->
<section id="horoscope-section">
<div class="container">
Expand Down
3 changes: 2 additions & 1 deletion scripts/build-js.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
2 changes: 2 additions & 0 deletions src/js/00-state.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
getSimulatedViewerCount,
HOROSCOPE_TEMPLATES,
getDailyHoroscope,
GUILT_LABELS,
getGuiltLabel,
} = window.DeathClockCore;

// ---- Unpack changelog data ----------------------------------
Expand Down
2 changes: 2 additions & 0 deletions src/js/14-badges.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
54 changes: 54 additions & 0 deletions src/js/22-guilt-meter.js
Original file line number Diff line number Diff line change
@@ -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();
}
2 changes: 2 additions & 0 deletions src/js/22-boot.js → src/js/23-boot.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
initCalculator();
initAccelerator();
initHoroscope();
initGuiltMeter();
// Engagement features
initPresenceStrip();
initEventLog();
Expand Down Expand Up @@ -78,6 +79,7 @@
checkTimeBadges();
checkMilestoneAlert();
updateExtinctionCountdown();
updateGuiltMeter();
}, 1000);
}

Expand Down
80 changes: 80 additions & 0 deletions styles/features.css
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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; }
}

87 changes: 87 additions & 0 deletions tests/death-clock.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
Loading