diff --git a/src/components/director-rail.js b/src/components/director-rail.js new file mode 100644 index 0000000..94422d8 --- /dev/null +++ b/src/components/director-rail.js @@ -0,0 +1,40 @@ +import { analyze } from '../core/timeline.js'; +import { fmtElapsed, fmtDrift } from '../core/format.js'; + +export class DirectorRail extends HTMLElement { + constructor() { + super(); + this._state = { scenario: null, elapsedMs: 0, stamps: {} }; + } + connectedCallback() { this.render(); } + update(patch) { Object.assign(this._state, patch); this.render(); } + + render() { + const { scenario, elapsedMs, stamps } = this._state; + if (!scenario) { this.innerHTML = `
No scenario loaded
`; return; } + const m = analyze(scenario.timeline, elapsedMs / 60000, stamps); + const here = m.currentBeat ? m.currentBeat.label : (scenario.timeline[0]?.label ?? 'โ€”'); + const drift = fmtDrift(m.driftMin); + const driftClass = m.driftMin > 0 ? 'behind' : 'ahead'; + const nh = m.nextHardTrigger; + const nhText = nh ? `${nh.label} ยท ~${Math.max(0, Math.round(m.minutesToNextHard))}m` : 'โ€”'; + + this.innerHTML = ` +
+
Session${fmtElapsed(elapsedMs)}
+
You are here + ${here}${drift ? ` ${drift}` : ''}
+
Next hard trigger${nhText}
+ + +
`; + + this.querySelector('[data-role=reached]').addEventListener('click', () => { + this.dispatchEvent(new CustomEvent('reached', { bubbles: true })); + }); + this.querySelector('[data-role=open-dice]').addEventListener('click', () => { + this.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'dice' }, bubbles: true })); + }); + } +} +customElements.define('director-rail', DirectorRail); diff --git a/tests/components/director-rail.test.js b/tests/components/director-rail.test.js new file mode 100644 index 0000000..cdca223 --- /dev/null +++ b/tests/components/director-rail.test.js @@ -0,0 +1,34 @@ +// tests/components/director-rail.test.js +import { describe, it, expect, beforeEach } from 'vitest'; +import '../../src/components/director-rail.js'; +import afterimage from '../../src/scenarios/afterimage.js'; + +describe('', () => { + let el; + beforeEach(() => { + document.body.innerHTML = ''; + el = document.createElement('director-rail'); + document.body.appendChild(el); + }); + + it('shows the first beat and no drift before any stamp', () => { + el.update({ scenario: afterimage, elapsedMs: 0, stamps: {} }); + expect(el.querySelector('[data-role=here]').textContent).toContain('The pier'); + expect(el.querySelector('.drift')).toBe(null); + expect(el.querySelector('[data-role=clock]').textContent).toBe('0:00'); + }); + + it('shows drift and the next hard trigger after a late stamp', () => { + el.update({ scenario: afterimage, elapsedMs: 40 * 60000, stamps: { 'a1-open': 6 } }); + expect(el.querySelector('.drift').textContent).toBe('+6 behind'); + expect(el.querySelector('[data-role=next]').textContent).toContain('Door-cam'); + }); + + it('emits "reached" when the button is clicked', () => { + el.update({ scenario: afterimage, elapsedMs: 0, stamps: {} }); + let fired = 0; + el.addEventListener('reached', () => fired++); + el.querySelector('[data-role=reached]').click(); + expect(fired).toBe(1); + }); +});