diff --git a/src/core/timeline.js b/src/core/timeline.js new file mode 100644 index 0000000..7b49d6f --- /dev/null +++ b/src/core/timeline.js @@ -0,0 +1,20 @@ +export function sortedTimeline(timeline) { + return [...timeline].sort((a, b) => a.targetMin - b.targetMin); +} + +export function nextUnreachedBeat(timeline, stamps) { + const t = sortedTimeline(timeline); + return t.find((b) => !(b.id in stamps)) || null; +} + +export function analyze(timeline, elapsedMin, stamps) { + const t = sortedTimeline(timeline); + const reached = t.filter((b) => b.id in stamps); + const currentBeat = reached.length ? reached[reached.length - 1] : null; + const currentIndex = currentBeat ? t.findIndex((b) => b.id === currentBeat.id) : -1; + const nextBeat = t[currentIndex + 1] || null; + const nextHardTrigger = t.slice(currentIndex + 1).find((b) => b.hardTrigger) || null; + const minutesToNextHard = nextHardTrigger ? nextHardTrigger.targetMin - elapsedMin : null; + const driftMin = currentBeat ? stamps[currentBeat.id] - currentBeat.targetMin : null; + return { currentBeat, nextBeat, nextHardTrigger, minutesToNextHard, driftMin, cutHint: nextBeat?.cutHint ?? null }; +} diff --git a/tests/core/timeline.test.js b/tests/core/timeline.test.js new file mode 100644 index 0000000..e85fa0a --- /dev/null +++ b/tests/core/timeline.test.js @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { sortedTimeline, nextUnreachedBeat, analyze } from '../../src/core/timeline.js'; + +const T = [ + { id: 'a', act: 1, label: 'Open', targetMin: 0, hardTrigger: true }, + { id: 'b', act: 1, label: 'Parlor', targetMin: 35, cutHint: 'skip the tea' }, + { id: 'c', act: 2, label: 'Door-cam', targetMin: 120, hardTrigger: true }, +]; + +describe('timeline analysis', () => { + it('sorts by targetMin without mutating input', () => { + const shuffled = [T[2], T[0], T[1]]; + expect(sortedTimeline(shuffled).map((b) => b.id)).toEqual(['a', 'b', 'c']); + expect(shuffled[0].id).toBe('c'); + }); + + it('finds the next unreached beat', () => { + expect(nextUnreachedBeat(T, {}).id).toBe('a'); + expect(nextUnreachedBeat(T, { a: 0 }).id).toBe('b'); + }); + + it('before any stamp: no current, next hard is the first hard beat', () => { + const m = analyze(T, 5, {}); + expect(m.currentBeat).toBe(null); + expect(m.nextBeat.id).toBe('a'); + expect(m.driftMin).toBe(null); + expect(m.nextHardTrigger.id).toBe('a'); + }); + + it('after stamping "a" late: drift is behind, next hard is "c"', () => { + const m = analyze(T, 40, { a: 6 }); // reached Open at 6 min (target 0) => 6 behind + expect(m.currentBeat.id).toBe('a'); + expect(m.driftMin).toBe(6); + expect(m.nextBeat.id).toBe('b'); + expect(m.cutHint).toBe('skip the tea'); + expect(m.nextHardTrigger.id).toBe('c'); + expect(m.minutesToNextHard).toBe(80); // 120 - 40 + }); +});