import { afterEach, describe, expect, it, vi } from 'vitest'; import { createMemberActivityTimerId, deriveReviewActivityTimerAnchor, deriveWorkActivityTimerAnchor, formatMemberActivityElapsed, readMemberActivityTimerElapsed, resetMemberActivityTimerStoreForTests, syncMemberActivityTimer, } from '@renderer/utils/memberActivityTimer'; import type { TeamTaskWithKanban } from '@shared/types'; const baseTask: TeamTaskWithKanban = { id: 'task-1', displayId: 'abc12345', subject: 'Build feature', status: 'in_progress', createdAt: '2026-05-07T09:00:00.000Z', reviewState: 'none', }; describe('memberActivityTimer', () => { afterEach(() => { vi.useRealTimers(); resetMemberActivityTimerStoreForTests(); globalThis.localStorage?.clear(); }); it('anchors work timers to the active work interval', () => { const task: TeamTaskWithKanban = { ...baseTask, workIntervals: [ { startedAt: '2026-05-07T09:10:00.000Z', completedAt: '2026-05-07T09:15:00.000Z', }, { startedAt: '2026-05-07T09:20:00.000Z' }, ], }; const anchor = deriveWorkActivityTimerAnchor(task, { teamName: 'alpha', memberName: 'bob', }); expect(anchor?.startedAt).toBe('2026-05-07T09:20:00.000Z'); expect(anchor?.baseElapsedMs).toBe(300_000); expect(anchor?.timerId).toContain('task-1'); }); it('adds completed work intervals to the active timer elapsed value', () => { const task: TeamTaskWithKanban = { ...baseTask, workIntervals: [ { startedAt: '2026-05-07T09:10:00.000Z', completedAt: '2026-05-07T09:15:00.000Z', }, { startedAt: '2026-05-07T09:20:00.000Z' }, ], }; const anchor = deriveWorkActivityTimerAnchor(task, { teamName: 'alpha', memberName: 'bob', }); expect(anchor).not.toBeNull(); expect( readMemberActivityTimerElapsed({ timerId: anchor!.timerId, startedAtMs: anchor!.startedAtMs, baseElapsedMs: anchor!.baseElapsedMs, running: true, runId: 'run-1', nowMs: Date.parse('2026-05-07T09:21:00.000Z'), }) ).toBe(360_000); }); it('does not invent a work timer when task start evidence is missing', () => { expect( deriveWorkActivityTimerAnchor(baseTask, { teamName: 'alpha', memberName: 'bob', }) ).toBeNull(); }); it('treats closed work intervals without an active interval as paused', () => { const task: TeamTaskWithKanban = { ...baseTask, workIntervals: [ { startedAt: '2026-05-07T09:10:00.000Z', completedAt: '2026-05-07T09:15:00.000Z', }, ], historyEvents: [ { id: 'evt-1', type: 'status_changed', from: 'pending', to: 'in_progress', timestamp: '2026-05-07T09:10:00.000Z', }, ], }; expect( deriveWorkActivityTimerAnchor(task, { teamName: 'alpha', memberName: 'bob', }) ).toBeNull(); }); it('does not treat invalid empty completedAt values as active work or review intervals', () => { const workTask: TeamTaskWithKanban = { ...baseTask, workIntervals: [{ startedAt: '2026-05-07T09:10:00.000Z', completedAt: '' }], }; expect( deriveWorkActivityTimerAnchor(workTask, { teamName: 'alpha', memberName: 'bob', }) ).toBeNull(); const reviewTask: TeamTaskWithKanban = { ...baseTask, status: 'completed', reviewState: 'review', kanbanColumn: 'review', reviewer: 'alice', reviewIntervals: [ { reviewer: 'alice', startedAt: '2026-05-07T09:30:00.000Z', completedAt: '' }, ], }; expect( deriveReviewActivityTimerAnchor(reviewTask, { teamName: 'alpha', memberName: 'alice', }) ).toBeNull(); }); it('anchors review timers only after the reviewer actually starts review', () => { const assignedOnly: TeamTaskWithKanban = { ...baseTask, status: 'completed', reviewState: 'review', kanbanColumn: 'review', reviewer: 'alice', historyEvents: [ { id: 'evt-1', type: 'review_requested', from: 'none', to: 'review', reviewer: 'alice', timestamp: '2026-05-07T09:30:00.000Z', }, ], }; expect( deriveReviewActivityTimerAnchor(assignedOnly, { teamName: 'alpha', memberName: 'alice', }) ).toBeNull(); const started: TeamTaskWithKanban = { ...assignedOnly, historyEvents: [ ...(assignedOnly.historyEvents ?? []), { id: 'evt-2', type: 'review_started', from: 'review', to: 'review', actor: 'alice', timestamp: '2026-05-07T09:35:00.000Z', }, ], }; expect( deriveReviewActivityTimerAnchor(started, { teamName: 'alpha', memberName: 'alice', })?.startedAt ).toBe('2026-05-07T09:35:00.000Z'); }); it('anchors review timers to persisted review intervals and adds paused review time', () => { const task: TeamTaskWithKanban = { ...baseTask, status: 'completed', reviewState: 'review', kanbanColumn: 'review', reviewer: 'alice', historyEvents: [ { id: 'evt-1', type: 'review_started', from: 'review', to: 'review', actor: 'alice', timestamp: '2026-05-07T09:30:00.000Z', }, ], reviewIntervals: [ { reviewer: 'alice', startedAt: '2026-05-07T09:30:00.000Z', completedAt: '2026-05-07T09:35:00.000Z', }, { reviewer: 'alice', startedAt: '2026-05-07T09:40:00.000Z' }, ], }; const anchor = deriveReviewActivityTimerAnchor(task, { teamName: 'alpha', memberName: 'alice', }); expect(anchor?.startedAt).toBe('2026-05-07T09:40:00.000Z'); expect(anchor?.baseElapsedMs).toBe(300_000); }); it('pauses elapsed time while the activity is not running and resumes from the frozen value', () => { const timerId = createMemberActivityTimerId({ teamName: 'alpha', memberName: 'bob', phase: 'work', taskId: 'task-1', startedAt: '2026-05-07T09:00:00.000Z', }); const startedAtMs = Date.parse('2026-05-07T09:00:00.000Z'); syncMemberActivityTimer({ timerId, startedAtMs, baseElapsedMs: 0, running: true, runId: 'run-1', nowMs: Date.parse('2026-05-07T09:01:00.000Z'), }); expect( readMemberActivityTimerElapsed({ timerId, startedAtMs, baseElapsedMs: 0, running: true, runId: 'run-1', nowMs: Date.parse('2026-05-07T09:02:00.000Z'), }) ).toBe(120_000); syncMemberActivityTimer({ timerId, startedAtMs, baseElapsedMs: 0, running: false, runId: 'run-1', nowMs: Date.parse('2026-05-07T09:02:00.000Z'), }); expect( readMemberActivityTimerElapsed({ timerId, startedAtMs, baseElapsedMs: 0, running: false, runId: 'run-1', nowMs: Date.parse('2026-05-07T09:05:00.000Z'), }) ).toBe(120_000); syncMemberActivityTimer({ timerId, startedAtMs, baseElapsedMs: 0, running: true, runId: 'run-1', nowMs: Date.parse('2026-05-07T09:05:00.000Z'), }); expect( readMemberActivityTimerElapsed({ timerId, startedAtMs, baseElapsedMs: 0, running: true, runId: 'run-1', nowMs: Date.parse('2026-05-07T09:06:00.000Z'), }) ).toBe(180_000); }); it('caps elapsed time across unobserved runtime run transitions', () => { const timerId = createMemberActivityTimerId({ teamName: 'alpha', memberName: 'bob', phase: 'work', taskId: 'task-1', startedAt: '2026-05-07T09:00:00.000Z', }); const startedAtMs = Date.parse('2026-05-07T09:00:00.000Z'); syncMemberActivityTimer({ timerId, startedAtMs, baseElapsedMs: 0, running: true, runId: 'run-1', nowMs: Date.parse('2026-05-07T09:01:00.000Z'), }); syncMemberActivityTimer({ timerId, startedAtMs, baseElapsedMs: 0, running: true, runId: 'run-2', nowMs: Date.parse('2026-05-07T10:00:00.000Z'), }); expect( readMemberActivityTimerElapsed({ timerId, startedAtMs, baseElapsedMs: 0, running: true, runId: 'run-2', nowMs: Date.parse('2026-05-07T10:00:00.000Z'), }) ).toBe(65_000); }); it('formats seconds, minutes, and hours compactly', () => { expect(formatMemberActivityElapsed(9_000)).toBe('9s'); expect(formatMemberActivityElapsed(65_000)).toBe('1m 05s'); expect(formatMemberActivityElapsed(3_780_000)).toBe('1h 03m'); }); });