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
@@ -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