initial code commit

This commit is contained in:
Deven Thiel 2026-03-04 21:21:59 -05:00
commit 27bb45f7df
56 changed files with 15106 additions and 0 deletions

View file

@ -0,0 +1,245 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { useTimerStore } from '@/store/timerStore';
describe('timerStore', () => {
beforeEach(() => {
vi.useFakeTimers();
useTimerStore.setState({
currentRate: 50,
running: false,
runStartedAt: null,
accumulatedMs: 0,
splits: [],
lastHeartbeat: Date.now(),
elapsedMs: 0,
crashRecovery: null,
});
});
afterEach(() => {
vi.useRealTimers();
});
it('starts and tracks elapsed time', () => {
const s = useTimerStore.getState();
s.start();
expect(useTimerStore.getState().running).toBe(true);
vi.advanceTimersByTime(5000);
useTimerStore.getState()._tick();
expect(useTimerStore.getState().elapsedMs).toBeGreaterThanOrEqual(5000);
});
it('pause freezes elapsed and stops ticking', () => {
useTimerStore.getState().start();
vi.advanceTimersByTime(3000);
useTimerStore.getState().pause();
const frozen = useTimerStore.getState().elapsedMs;
expect(useTimerStore.getState().running).toBe(false);
vi.advanceTimersByTime(10000);
expect(useTimerStore.getState().elapsedMs).toBe(frozen);
});
it('resume after pause accumulates correctly', () => {
useTimerStore.getState().start();
vi.advanceTimersByTime(2000);
useTimerStore.getState().pause();
vi.advanceTimersByTime(5000); // paused time doesn't count
useTimerStore.getState().start();
vi.advanceTimersByTime(3000);
useTimerStore.getState()._tick();
const elapsed = useTimerStore.getState().elapsedMs;
expect(elapsed).toBeGreaterThanOrEqual(5000);
expect(elapsed).toBeLessThan(7000); // not 10000
});
it('split records current segment and resets clock', () => {
useTimerStore.getState().start();
vi.advanceTimersByTime(4000);
const split = useTimerStore.getState().split('task A');
expect(split.elapsedMs).toBeGreaterThanOrEqual(4000);
expect(split.rate).toBe(50);
expect(split.label).toBe('task A');
expect(split.recorded).toBe(false);
expect(useTimerStore.getState().splits).toHaveLength(1);
// Clock resets but keeps running
expect(useTimerStore.getState().accumulatedMs).toBe(0);
expect(useTimerStore.getState().running).toBe(true);
});
it('split preserves rate at time of split (rate change after doesn\'t affect)', () => {
useTimerStore.getState().start();
vi.advanceTimersByTime(1000);
const split = useTimerStore.getState().split();
expect(split.rate).toBe(50);
useTimerStore.getState().setRate(100);
expect(useTimerStore.getState().splits[0].rate).toBe(50);
expect(useTimerStore.getState().currentRate).toBe(100);
});
it('setRate only affects live clock, not existing splits', () => {
useTimerStore.getState().start();
vi.advanceTimersByTime(1000);
useTimerStore.getState().split();
useTimerStore.getState().split();
useTimerStore.getState().setRate(999);
expect(useTimerStore.getState().splits.every((s) => s.rate === 50)).toBe(true);
});
it('markRecorded sets flag and stores work entry id', () => {
useTimerStore.getState().start();
vi.advanceTimersByTime(1000);
const split = useTimerStore.getState().split();
useTimerStore.getState().markRecorded(split.id, 'we-123');
const updated = useTimerStore.getState().splits.find((s) => s.id === split.id)!;
expect(updated.recorded).toBe(true);
expect(updated.recordedWorkEntryId).toBe('we-123');
});
it('updateSplit modifies fields', () => {
useTimerStore.getState().start();
vi.advanceTimersByTime(1000);
const split = useTimerStore.getState().split();
useTimerStore.getState().updateSplit(split.id, { label: 'new label', elapsedMs: 9999 });
const updated = useTimerStore.getState().splits.find((s) => s.id === split.id)!;
expect(updated.label).toBe('new label');
expect(updated.elapsedMs).toBe(9999);
});
it('deleteSplit removes from table', () => {
useTimerStore.getState().start();
vi.advanceTimersByTime(1000);
const split = useTimerStore.getState().split();
useTimerStore.getState().deleteSplit(split.id);
expect(useTimerStore.getState().splits).toHaveLength(0);
});
it('reset clears everything', () => {
useTimerStore.getState().start();
vi.advanceTimersByTime(1000);
useTimerStore.getState().split();
useTimerStore.getState().reset();
expect(useTimerStore.getState().running).toBe(false);
expect(useTimerStore.getState().elapsedMs).toBe(0);
expect(useTimerStore.getState().splits).toHaveLength(0);
});
it('persists state to cookie on tick', () => {
useTimerStore.getState().start();
vi.advanceTimersByTime(1500);
useTimerStore.getState()._tick();
expect(document.cookie).toContain('t99_timer');
});
});
describe('timerStore crash recovery', () => {
beforeEach(() => {
vi.useRealTimers();
});
it('detects crash when heartbeat stale + running', () => {
const now = Date.now();
const staleSnapshot = {
currentRate: 75,
running: true,
runStartedAt: now - 60000, // started 1 min ago
accumulatedMs: 0,
splits: [],
lastHeartbeat: now - 30000, // last seen 30s ago (> 5s threshold)
};
document.cookie = `t99_timer=${encodeURIComponent(JSON.stringify(staleSnapshot))}; path=/`;
useTimerStore.setState({ crashRecovery: null });
useTimerStore.getState()._restoreFromCookie();
const cr = useTimerStore.getState().crashRecovery;
expect(cr).not.toBeNull();
expect(cr!.gapMs).toBeGreaterThan(25000);
expect(cr!.crashTime).toBe(now - 30000);
});
it('does NOT flag crash when paused', () => {
const now = Date.now();
const snapshot = {
currentRate: 50,
running: false,
runStartedAt: null,
accumulatedMs: 120000,
splits: [],
lastHeartbeat: now - 60000,
};
document.cookie = `t99_timer=${encodeURIComponent(JSON.stringify(snapshot))}; path=/`;
useTimerStore.setState({ crashRecovery: null });
useTimerStore.getState()._restoreFromCookie();
expect(useTimerStore.getState().crashRecovery).toBeNull();
});
it('restores elapsed time from original start (includes gap)', () => {
const now = Date.now();
const snapshot = {
currentRate: 50,
running: true,
runStartedAt: now - 120000, // 2 min ago
accumulatedMs: 0,
splits: [],
lastHeartbeat: now - 60000,
};
document.cookie = `t99_timer=${encodeURIComponent(JSON.stringify(snapshot))}; path=/`;
useTimerStore.getState()._restoreFromCookie();
// Clock should show ~2 minutes (original start was preserved)
expect(useTimerStore.getState().elapsedMs).toBeGreaterThanOrEqual(120000);
});
it('subtractCrashGap removes gap from accumulated time', () => {
useTimerStore.setState({
running: false,
runStartedAt: null,
accumulatedMs: 100000,
crashRecovery: { crashTime: 0, reloadTime: 30000, gapMs: 30000 },
elapsedMs: 100000,
});
useTimerStore.getState().subtractCrashGap();
expect(useTimerStore.getState().accumulatedMs).toBe(70000);
expect(useTimerStore.getState().crashRecovery).toBeNull();
});
it('subtractCrashGap clamps to zero', () => {
useTimerStore.setState({
accumulatedMs: 5000,
crashRecovery: { crashTime: 0, reloadTime: 0, gapMs: 99999 },
});
useTimerStore.getState().subtractCrashGap();
expect(useTimerStore.getState().accumulatedMs).toBe(0);
});
it('dismissCrashBanner keeps time but removes banner', () => {
useTimerStore.setState({
accumulatedMs: 100000,
crashRecovery: { crashTime: 0, reloadTime: 0, gapMs: 30000 },
});
useTimerStore.getState().dismissCrashBanner();
expect(useTimerStore.getState().crashRecovery).toBeNull();
expect(useTimerStore.getState().accumulatedMs).toBe(100000); // unchanged
});
it('restores splits from cookie', () => {
const snapshot = {
currentRate: 50,
running: false,
runStartedAt: null,
accumulatedMs: 0,
splits: [
{ id: 's1', startedAt: 0, elapsedMs: 60000, rate: 50, recorded: false },
{ id: 's2', startedAt: 0, elapsedMs: 30000, rate: 75, recorded: true, recordedWorkEntryId: 'we-1' },
],
lastHeartbeat: Date.now(),
};
document.cookie = `t99_timer=${encodeURIComponent(JSON.stringify(snapshot))}; path=/`;
useTimerStore.getState()._restoreFromCookie();
expect(useTimerStore.getState().splits).toHaveLength(2);
expect(useTimerStore.getState().splits[1].recorded).toBe(true);
});
});