feat: break timer — pause the session clock so breaks don't poison drift

Add <break-timer> tray: pick a duration, Start break pauses the session
clock (reusing clock.pause/resume), End break resumes it — so meal/comfort
break time is excluded from elapsed and drift stays honest. The shell owns
pause/resume + persistence (break store key); the rail shows a on-break
badge and gains a coffee chip. fmtCountdown (M:SS) added to format.js.

tests/components/break-timer.test.js + gm-shell-break.test.js (incl. the
30-play/20-break/10-play drift-honesty case) + fmtCountdown. 159 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-07-20 23:00:17 +01:00
co-authored by Claude Opus 4.8
parent 49e9dcda46
commit 73a7ea72ad
10 changed files with 253 additions and 9 deletions
+52
View File
@@ -0,0 +1,52 @@
import { fmtCountdown } from '../core/format.js';
// Break timer tray. Pauses the session clock for a meal/comfort break so the
// break time never poisons drift. The shell owns the clock; this component just
// picks a duration and emits start-break / end-break, and renders the countdown
// fed back to it via `state` ({ onBreak, remainingMs }).
const DURATIONS = [5, 10, 15, 20];
export class BreakTimer extends HTMLElement {
constructor() {
super();
this._state = { onBreak: false, remainingMs: null };
this._minutes = 10;
}
connectedCallback() { this.render(); }
set state(v) { this._state = v || { onBreak: false, remainingMs: null }; this.render(); }
get state() { return this._state; }
render() {
const { onBreak, remainingMs } = this._state;
if (onBreak) {
const over = remainingMs != null && remainingMs < 0;
const clock = remainingMs != null ? fmtCountdown(Math.abs(remainingMs)) : '';
const line = remainingMs == null
? 'On break — clock paused'
: over ? `Over by ${clock} — resume when ready` : `Back in ${clock}`;
this.innerHTML = `
<div class="break-timer">
<div class="break-status ${over ? 'over' : ''}" data-role="status">${line}</div>
<button class="break-end" data-role="end">▶ End break — resume clock</button>
</div>`;
this.querySelector('[data-role=end]').addEventListener('click', () =>
this.dispatchEvent(new CustomEvent('end-break', { bubbles: true })));
return;
}
const pills = DURATIONS.map((m) =>
`<button class="break-dur ${m === this._minutes ? 'on' : ''}" data-min="${m}">${m}m</button>`).join('');
this.innerHTML = `
<div class="break-timer">
<div class="break-durs" data-role="durs">${pills}</div>
<button class="break-start" data-role="start">⏸ Start break — pause clock</button>
</div>`;
this.querySelectorAll('[data-min]').forEach((b) =>
b.addEventListener('click', () => { this._minutes = Number(b.dataset.min); this.render(); }));
this.querySelector('[data-role=start]').addEventListener('click', () =>
this.dispatchEvent(new CustomEvent('start-break', { detail: { minutes: this._minutes }, bubbles: true })));
}
}
customElements.define('break-timer', BreakTimer);
+6 -2
View File
@@ -10,7 +10,7 @@ export class DirectorRail extends HTMLElement {
update(patch) { Object.assign(this._state, patch); this.render(); }
render() {
const { scenario, elapsedMs, stamps } = this._state;
const { scenario, elapsedMs, stamps, paused } = this._state;
if (!scenario) { this.innerHTML = `<div class="rail rail--empty">No scenario loaded</div>`; return; }
const m = analyze(scenario.timeline, elapsedMs / 60000, stamps);
const here = m.currentBeat ? m.currentBeat.label : (scenario.timeline[0]?.label ?? '—');
@@ -21,7 +21,7 @@ export class DirectorRail extends HTMLElement {
this.innerHTML = `
<div class="rail">
<div class="cell"><span class="k">Session</span><span class="v" data-role="clock">${fmtElapsed(elapsedMs)}</span></div>
<div class="cell${paused ? ' on-break' : ''}"><span class="k">Session</span><span class="v" data-role="clock">${fmtElapsed(elapsedMs)}${paused ? ' <span class="break-badge" data-role="break-badge">☕ BREAK</span>' : ''}</span></div>
<div class="cell here"><span class="k">You are here</span>
<span class="v"><span data-role="here">${here}</span>${drift ? ` <span class="drift ${driftClass}">${drift}</span>` : ''}</span></div>
<div class="cell trig"><span class="k">Next hard trigger</span><span class="v" data-role="next">${nhText}</span></div>
@@ -32,6 +32,7 @@ export class DirectorRail extends HTMLElement {
<button class="tray-btn" data-role="open-art" aria-label="Art">✏️</button>
<button class="tray-btn" data-role="open-clues" aria-label="Clues">🔍</button>
<button class="tray-btn" data-role="open-cast" aria-label="Cast">👥</button>
<button class="tray-btn" data-role="open-break" aria-label="Break timer">☕</button>
</div>`;
this.querySelector('[data-role=reached]').addEventListener('click', () => {
@@ -55,6 +56,9 @@ export class DirectorRail extends HTMLElement {
this.querySelector('[data-role=open-cast]').addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'cast' }, bubbles: true }));
});
this.querySelector('[data-role=open-break]').addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'break' }, bubbles: true }));
});
}
}
customElements.define('director-rail', DirectorRail);
+34 -2
View File
@@ -1,5 +1,5 @@
import { createStore } from '../core/store.js';
import { initialClock, start, elapsedMs } from '../core/clock.js';
import { initialClock, start, pause, resume, elapsedMs, isRunning, isPaused } from '../core/clock.js';
import { nextUnreachedBeat } from '../core/timeline.js';
import './director-rail.js';
import './dice-tray.js';
@@ -8,6 +8,7 @@ import './art-tray.js';
import './prop-viewer.js';
import './clue-net.js';
import './cast-tray.js';
import './break-timer.js';
import './con-hub.js';
import { getScenario } from '../scenarios/index.js';
@@ -22,6 +23,7 @@ export class GmShell extends HTMLElement {
this.stamps = {};
this.cast = [];
this.revealedClues = [];
this.breakEndsAt = null;
this._tick = null;
}
@@ -35,6 +37,7 @@ export class GmShell extends HTMLElement {
<art-tray hidden></art-tray>
<clue-net hidden></clue-net>
<cast-tray hidden></cast-tray>
<break-timer hidden></break-timer>
</main>
<prop-viewer></prop-viewer>`;
this.addEventListener('reached', () => this.onReached());
@@ -42,6 +45,8 @@ export class GmShell extends HTMLElement {
this.addEventListener('keep-npc', (e) => this.onKeepNpc(e));
this.addEventListener('show-prop', (e) => this.onShowProp(e));
this.addEventListener('toggle-clue', (e) => this.onToggleClue(e));
this.addEventListener('start-break', (e) => this.onStartBreak(e));
this.addEventListener('end-break', () => this.onEndBreak());
this.addEventListener('open-scenario', (e) => this.onOpenScenario(e));
this.addEventListener('open-hub', () => this.showHub());
}
@@ -85,6 +90,7 @@ export class GmShell extends HTMLElement {
net.clues = this.scenario.clues || [];
net.revealed = this.revealedClues;
this.querySelector('cast-tray').cast = this.scenario.cast || [];
this.breakEndsAt = this.store.get('break', null);
this.refresh();
}
@@ -107,7 +113,7 @@ export class GmShell extends HTMLElement {
onOpenTool(e) {
const tool = e.detail?.tool;
const trays = { dice: 'dice-tray', npc: 'npc-tray', art: 'art-tray', clues: 'clue-net', cast: 'cast-tray' };
const trays = { dice: 'dice-tray', npc: 'npc-tray', art: 'art-tray', clues: 'clue-net', cast: 'cast-tray', break: 'break-timer' };
if (!(tool in trays)) return;
const target = this.querySelector(trays[tool]);
const willShow = target.hidden;
@@ -133,12 +139,38 @@ export class GmShell extends HTMLElement {
this.querySelector('prop-viewer').show(e.detail.src, e.detail.label);
}
onStartBreak(e) {
if (!isRunning(this.clockState)) return; // a break only makes sense mid-session
const now = this.now();
this.clockState = pause(this.clockState, now);
this.breakEndsAt = now + (e.detail?.minutes ?? 10) * 60000;
this.store.set('clock', this.clockState);
this.store.set('break', this.breakEndsAt);
if (!this._tick) this._tick = setInterval(() => this.refresh(), 1000);
this.refresh();
}
onEndBreak() {
if (!isPaused(this.clockState)) return;
this.clockState = resume(this.clockState, this.now());
this.breakEndsAt = null;
this.store.set('clock', this.clockState);
this.store.set('break', null);
this.refresh();
}
refresh() {
const paused = isPaused(this.clockState);
this.querySelector('director-rail').update({
scenario: this.scenario,
elapsedMs: this.elapsedMs(),
stamps: this.stamps,
paused,
});
this.querySelector('break-timer').state = {
onBreak: paused,
remainingMs: paused && this.breakEndsAt != null ? this.breakEndsAt - this.now() : null,
};
}
}
customElements.define('gm-shell', GmShell);
+7
View File
@@ -5,6 +5,13 @@ export function fmtElapsed(ms) {
return `${h}:${String(m).padStart(2, '0')}`;
}
export function fmtCountdown(ms) {
const total = Math.max(0, Math.floor(ms / 1000));
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
export function fmtDrift(driftMin) {
if (driftMin == null) return '';
const r = Math.round(driftMin);
+13
View File
@@ -120,3 +120,16 @@ cast-tray[hidden] { display: none; }
.cast-secret { font-size: 12px; color: #ffd479; margin-top: 6px; padding-top: 6px; border-top: 1px dashed #2b3644; }
.cast-reveal { flex: 0 0 auto; border: 1px solid #2a3542; background: #161d27; color: #cfe0f0; font-size: 11px; font-weight: 700; border-radius: 8px; padding: 6px 12px; }
.cast-empty { color: #6b7a8d; font-size: 12px; }
/* Break timer tray + rail break badge */
.break-timer { position: absolute; top: 0; left: 0; right: 0; background: #0f141b; border-bottom: 1px solid #2b3644; padding: 14px; display: flex; flex-direction: column; gap: 12px; }
break-timer[hidden] { display: none; }
.break-durs { display: flex; gap: 8px; flex-wrap: wrap; }
.break-dur { min-width: 54px; padding: 9px 14px; border-radius: 9px; background: #161d27; border: 1px solid #2a3542; color: #c6d5e6; font-weight: 600; font-size: 14px; }
.break-dur.on { background: #2a2013; border-color: #7a651f; color: #ffd479; }
.break-start { border: none; padding: 12px; border-radius: 9px; background: #2a2013; color: #ffd479; font-weight: 700; font-size: 14px; border: 1px solid #7a651f; }
.break-status { font-size: 20px; font-weight: 800; color: #ffd479; padding: 6px 2px; }
.break-status.over { color: #ff9c9c; }
.break-end { border: none; padding: 12px; border-radius: 9px; background: #1c8a5a; color: #eafff4; font-weight: 700; font-size: 14px; }
.break-badge { margin-left: 8px; font-size: 10px; font-weight: 800; letter-spacing: .1em; color: #ffd479; border: 1px solid #7a651f; border-radius: 12px; padding: 1px 7px; vertical-align: middle; }
.rail .cell.on-break .v { color: #ffd479; }