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:
co-authored by
Claude Opus 4.8
parent
49e9dcda46
commit
73a7ea72ad
@@ -61,9 +61,9 @@ Components are dumb renderers that emit events; the shell wires them together.
|
||||
| `src/components/con-hub.js` | `<con-hub>` — the convention landing screen; deep-links into a scenario |
|
||||
| `src/scenarios/index.js` | Scenario registry (`getScenario(id)`) the hub deep-links through |
|
||||
| `src/con/` | Convention data (`continuum-2026.js`; `example-con.js` is a fully-timed template) |
|
||||
| `src/components/director-rail.js` | `<director-rail>` — the always-on pacing bar (🎲 👤 ✏️ 🔍 chips) |
|
||||
| `src/components/director-rail.js` | `<director-rail>` — the always-on pacing bar (🏠 🎲 👤 ✏️ 🔍 👥 ☕ chips; ☕ shows an on-break badge) |
|
||||
| `src/components/dice-tray.js` | `<dice-tray>` — pack selector + die picker + roll + verdict |
|
||||
| `src/components/npc-tray.js` · `art-tray.js` · `prop-viewer.js` · `clue-net.js` · `cast-tray.js` | The tray tools (`cast-tray` renders `scenario.cast` with tap-to-reveal secrets) |
|
||||
| `src/components/npc-tray.js` · `art-tray.js` · `prop-viewer.js` · `clue-net.js` · `cast-tray.js` · `break-timer.js` | The tray tools (`cast-tray` renders `scenario.cast` with tap-to-reveal secrets; `break-timer` pauses the session clock so breaks never poison drift) |
|
||||
| `src/components/gm-shell.js` | `<gm-shell>` — root wiring + persistence |
|
||||
| `src/scenarios/` | Scenario data files (six Continuum slots: `afterimage`, `day-one`, `vain-crown`, `silvery-moon`, `chopper`, `princes-bride`; `example-with-clues.js` is a template); `index.js` is the registry |
|
||||
| `public/` | `manifest.webmanifest`, `sw.js` (offline service worker) |
|
||||
@@ -97,7 +97,7 @@ compress if you reach a beat behind schedule.
|
||||
3. **Slice 3** — dice rule-packs (CoC d100, VANITY d6-pool, Panic & Glory, Dee Sanction) + tray pack selector. ✅
|
||||
4. **Slice 4** — the clue **safety-net** (essential-clue gap tracker + fallbacks). ✅
|
||||
5. **Slice 5** — the convention **hub** (all slots, live "live now / up next / done", deep-links into each scenario). ✅ All **six** Continuum 2026 slots ported and scheduled with real times.
|
||||
6. **Later** — remaining tray tools (break timer, parking-lot note, wake-lock); online art "Generate"; markdown → scenario-data generator; native iPad wrapper.
|
||||
6. **Later** — remaining tray tools (parking-lot note, wake-lock); online art "Generate"; markdown → scenario-data generator; native iPad wrapper.
|
||||
|
||||
## Docs
|
||||
|
||||
|
||||
@@ -74,6 +74,11 @@ jsdom. No new dependencies. Fully offline.
|
||||
≤ skill/20, special ≤ skill/5, success ≤ skill (bands transcribed from the Day One console). Day
|
||||
One's `meta.system` switched from `coc-d100` to `brp-d100`. `tests/dice/brp-d100.test.js` covers it.
|
||||
|
||||
- **Break timer built.** `<break-timer>` pauses the session clock for a break (reusing the clock's
|
||||
existing `pause`/`resume`), so break time never poisons drift. Shell owns the pause/resume +
|
||||
persistence (`break` store key); the rail shows a ☕ on-break badge; `fmtCountdown` (M:SS) added to
|
||||
`format.js`. Tests: `break-timer.test.js`, `gm-shell-break.test.js` (incl. the drift-honesty case).
|
||||
|
||||
## Still deferred (backlog)
|
||||
|
||||
- Break-timer / parking-lot / wake-lock tray tools; online art "Generate".
|
||||
- Parking-lot note + wake-lock tray tools; online art "Generate".
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import '../../src/components/break-timer.js';
|
||||
|
||||
describe('<break-timer>', () => {
|
||||
let el;
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
el = document.createElement('break-timer');
|
||||
document.body.appendChild(el);
|
||||
});
|
||||
|
||||
it('offers duration pills and a start button when not on break', () => {
|
||||
expect(el.querySelector('[data-role=durs]')).not.toBe(null);
|
||||
expect(el.querySelector('[data-role=start]')).not.toBe(null);
|
||||
expect(el.querySelector('[data-role=end]')).toBe(null);
|
||||
});
|
||||
|
||||
it('emits start-break with the selected duration', () => {
|
||||
let detail = null;
|
||||
el.addEventListener('start-break', (e) => { detail = e.detail; });
|
||||
el.querySelector('[data-min="15"]').click(); // select 15m
|
||||
el.querySelector('[data-role=start]').click();
|
||||
expect(detail).toEqual({ minutes: 15 });
|
||||
});
|
||||
|
||||
it('defaults to a 10-minute break', () => {
|
||||
let detail = null;
|
||||
el.addEventListener('start-break', (e) => { detail = e.detail; });
|
||||
el.querySelector('[data-role=start]').click();
|
||||
expect(detail).toEqual({ minutes: 10 });
|
||||
});
|
||||
|
||||
it('shows the countdown and an end button while on break', () => {
|
||||
el.state = { onBreak: true, remainingMs: 7 * 60000 + 5 * 1000 };
|
||||
expect(el.querySelector('[data-role=durs]')).toBe(null);
|
||||
expect(el.querySelector('[data-role=status]').textContent).toBe('Back in 7:05');
|
||||
expect(el.querySelector('[data-role=end]')).not.toBe(null);
|
||||
});
|
||||
|
||||
it('shows an overrun once the break runs over', () => {
|
||||
el.state = { onBreak: true, remainingMs: -90 * 1000 };
|
||||
const status = el.querySelector('[data-role=status]');
|
||||
expect(status.classList.contains('over')).toBe(true);
|
||||
expect(status.textContent).toContain('Over by 1:30');
|
||||
});
|
||||
|
||||
it('emits end-break when ending', () => {
|
||||
el.state = { onBreak: true, remainingMs: 60000 };
|
||||
let fired = 0;
|
||||
el.addEventListener('end-break', () => fired++);
|
||||
el.querySelector('[data-role=end]').click();
|
||||
expect(fired).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import '../../src/components/gm-shell.js';
|
||||
import afterimage from '../../src/scenarios/afterimage.js';
|
||||
|
||||
describe('<gm-shell> break timer', () => {
|
||||
let el, t;
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.body.innerHTML = '';
|
||||
t = 0;
|
||||
el = document.createElement('gm-shell');
|
||||
el.now = () => t;
|
||||
document.body.appendChild(el);
|
||||
el.loadScenario(afterimage);
|
||||
});
|
||||
|
||||
it('mounts a hidden break-timer, revealed by open-tool break', () => {
|
||||
expect(el.querySelector('break-timer').hidden).toBe(true);
|
||||
el.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'break' }, bubbles: true }));
|
||||
expect(el.querySelector('break-timer').hidden).toBe(false);
|
||||
});
|
||||
|
||||
it('a break pauses the clock so break time never poisons drift', () => {
|
||||
el.startSession(); // t=0
|
||||
t = 30 * 60000; // 30 min of play
|
||||
expect(Math.round(el.elapsedMs() / 60000)).toBe(30);
|
||||
|
||||
el.dispatchEvent(new CustomEvent('start-break', { detail: { minutes: 10 }, bubbles: true }));
|
||||
expect(el.querySelector('director-rail [data-role=break-badge]')).not.toBe(null);
|
||||
expect(el.querySelector('break-timer').state.onBreak).toBe(true);
|
||||
|
||||
t = 50 * 60000; // 20 min of wall-clock break
|
||||
expect(Math.round(el.elapsedMs() / 60000)).toBe(30); // frozen while on break
|
||||
|
||||
el.dispatchEvent(new CustomEvent('end-break', { bubbles: true }));
|
||||
expect(el.querySelector('director-rail [data-role=break-badge]')).toBe(null);
|
||||
|
||||
t = 60 * 60000; // 10 more min of play after the break
|
||||
expect(Math.round(el.elapsedMs() / 60000)).toBe(40); // 40 min play, break excluded
|
||||
});
|
||||
|
||||
it('persists the break so the paused clock survives a reload', () => {
|
||||
el.startSession();
|
||||
t = 10 * 60000;
|
||||
el.dispatchEvent(new CustomEvent('start-break', { detail: { minutes: 5 }, bubbles: true }));
|
||||
expect(JSON.parse(localStorage.getItem('gmd.afterimage.break'))).toBe(el.breakEndsAt);
|
||||
|
||||
// simulate reload
|
||||
document.body.innerHTML = '';
|
||||
const el2 = document.createElement('gm-shell');
|
||||
el2.now = () => t;
|
||||
document.body.appendChild(el2);
|
||||
el2.loadScenario(afterimage);
|
||||
expect(el2.querySelector('break-timer').state.onBreak).toBe(true);
|
||||
expect(el2.querySelector('director-rail [data-role=break-badge]')).not.toBe(null);
|
||||
});
|
||||
|
||||
it('ignores a start-break before the session has started', () => {
|
||||
el.dispatchEvent(new CustomEvent('start-break', { detail: { minutes: 10 }, bubbles: true }));
|
||||
expect(el.querySelector('break-timer').state.onBreak).toBe(false);
|
||||
expect(el.breakEndsAt).toBe(null);
|
||||
});
|
||||
|
||||
it('the rail exposes a break chip that emits open-tool', () => {
|
||||
const rail = el.querySelector('director-rail');
|
||||
let tool = null;
|
||||
el.addEventListener('open-tool', (e) => { tool = e.detail.tool; });
|
||||
rail.querySelector('[data-role=open-break]').click();
|
||||
expect(tool).toBe('break');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { fmtElapsed, fmtDrift } from '../../src/core/format.js';
|
||||
import { fmtElapsed, fmtDrift, fmtCountdown } from '../../src/core/format.js';
|
||||
|
||||
describe('formatting', () => {
|
||||
it('formats elapsed time', () => {
|
||||
@@ -13,4 +13,10 @@ describe('formatting', () => {
|
||||
expect(fmtDrift(6)).toBe('+6 behind');
|
||||
expect(fmtDrift(-3)).toBe('3 ahead');
|
||||
});
|
||||
it('formats a countdown as M:SS', () => {
|
||||
expect(fmtCountdown(0)).toBe('0:00');
|
||||
expect(fmtCountdown(9 * 60000 + 47 * 1000)).toBe('9:47');
|
||||
expect(fmtCountdown(10 * 60000)).toBe('10:00');
|
||||
expect(fmtCountdown(-5000)).toBe('0:00'); // clamps negatives
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user