@startsimpli/ui 0.4.168 → 0.4.170

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/package.json +8 -7
  2. package/src/components/charts/PeriodBars.tsx +36 -0
  3. package/src/components/charts/index.ts +2 -0
  4. package/src/components/index.ts +3 -0
  5. package/src/components/reports/ExportButtons.tsx +28 -0
  6. package/src/components/reports/PeriodReport.tsx +124 -0
  7. package/src/components/reports/__tests__/PeriodReport.test.tsx +75 -0
  8. package/src/components/reports/index.ts +4 -0
  9. package/src/components/timer/ColorPicker.tsx +43 -0
  10. package/src/components/timer/EntryFormDialog.tsx +104 -0
  11. package/src/components/timer/EntryList.tsx +42 -0
  12. package/src/components/timer/IdlePromptDialog.tsx +65 -0
  13. package/src/components/timer/ProjectSelect.tsx +52 -0
  14. package/src/components/timer/ProjectSwatch.tsx +15 -0
  15. package/src/components/timer/ProjectsDialog.tsx +221 -0
  16. package/src/components/timer/TimerBar.tsx +74 -0
  17. package/src/components/timer/__tests__/interval-logic.test.ts +121 -0
  18. package/src/components/timer/__tests__/projects-dialog.test.tsx +113 -0
  19. package/src/components/timer/__tests__/timer-components.test.tsx +147 -0
  20. package/src/components/timer/__tests__/useIdleReturn.test.tsx +59 -0
  21. package/src/components/timer/__tests__/useRunningTimer.test.tsx +167 -0
  22. package/src/components/timer/index.ts +20 -0
  23. package/src/components/timer/interval-logic.ts +140 -0
  24. package/src/components/timer/useIdleReturn.ts +49 -0
  25. package/src/components/timer/useRunningTimer.ts +209 -0
  26. package/src/utils/__tests__/xlsx.test.ts +36 -0
  27. package/src/utils/index.ts +1 -0
  28. package/src/utils/xlsx.ts +35 -0
@@ -0,0 +1,59 @@
1
+ import { renderHook, act } from '@testing-library/react'
2
+ import { useIdleReturn, type IdleSignal } from '../useIdleReturn'
3
+
4
+ function bus() {
5
+ const subs = new Set<(e: IdleSignal) => void>()
6
+ return {
7
+ subscribe: (cb: (e: IdleSignal) => void) => {
8
+ subs.add(cb)
9
+ return () => subs.delete(cb)
10
+ },
11
+ emit: (e: IdleSignal) => subs.forEach((s) => s(e)),
12
+ }
13
+ }
14
+
15
+ const t = (s: string) => new Date(s).getTime()
16
+ const idleSince = t('2026-10-31T10:00:00.000Z')
17
+ const ts = t('2026-10-31T10:20:00.000Z')
18
+
19
+ describe('useIdleReturn', () => {
20
+ it('opens a prompt on idle-end while a timer runs, with the idle duration', () => {
21
+ const b = bus()
22
+ const { result } = renderHook(() => useIdleReturn({ subscribe: b.subscribe, runningSince: t('2026-10-31T09:00:00.000Z') }))
23
+ expect(result.current.prompt).toBeNull()
24
+ act(() => b.emit({ type: 'idle-start', ts: idleSince, idleSince }))
25
+ expect(result.current.prompt).toBeNull()
26
+ act(() => b.emit({ type: 'idle-end', ts, idleSince }))
27
+ expect(result.current.prompt).toEqual({ idleSince, until: ts, seconds: 1200 })
28
+ })
29
+
30
+ it('ignores a return when no timer is running', () => {
31
+ const b = bus()
32
+ const { result } = renderHook(() => useIdleReturn({ subscribe: b.subscribe, runningSince: null }))
33
+ act(() => b.emit({ type: 'idle-end', ts, idleSince }))
34
+ expect(result.current.prompt).toBeNull()
35
+ })
36
+
37
+ it('ignores idle shorter than the minimum', () => {
38
+ const b = bus()
39
+ const { result } = renderHook(() => useIdleReturn({ subscribe: b.subscribe, runningSince: 0, minSeconds: 600 }))
40
+ act(() => b.emit({ type: 'idle-end', ts: idleSince + 120_000, idleSince }))
41
+ expect(result.current.prompt).toBeNull()
42
+ })
43
+
44
+ it('dismiss clears it, and non-idle events never open it', () => {
45
+ const b = bus()
46
+ const { result } = renderHook(() => useIdleReturn({ subscribe: b.subscribe, runningSince: 0 }))
47
+ act(() => b.emit({ type: 'lock', ts }))
48
+ expect(result.current.prompt).toBeNull()
49
+ act(() => b.emit({ type: 'idle-end', ts, idleSince }))
50
+ expect(result.current.prompt).not.toBeNull()
51
+ act(() => result.current.dismiss())
52
+ expect(result.current.prompt).toBeNull()
53
+ })
54
+
55
+ it('works without a subscription (browser build)', () => {
56
+ const { result } = renderHook(() => useIdleReturn({ subscribe: undefined, runningSince: 0 }))
57
+ expect(result.current.prompt).toBeNull()
58
+ })
59
+ })
@@ -0,0 +1,167 @@
1
+ import { renderHook, act, waitFor } from '@testing-library/react'
2
+ import { useRunningTimer } from '../useRunningTimer'
3
+ import type { IntervalStore, TimedEntry } from '../interval-logic'
4
+
5
+ const conflict = () => Object.assign(new Error('conflict'), { status: 409 })
6
+ const stale = () => Object.assign(new Error('stale'), { status: 412 })
7
+
8
+ function fakeStore(initial: TimedEntry | null = null) {
9
+ let running = initial
10
+ let seq = 0
11
+ const calls: string[] = []
12
+ const store: IntervalStore & { calls: string[]; setRunning(e: TimedEntry | null): void; failNext: { create?: Error; update?: Error } } = {
13
+ calls,
14
+ failNext: {},
15
+ setRunning(e) {
16
+ running = e
17
+ },
18
+ async findRunning() {
19
+ calls.push('find')
20
+ return running
21
+ },
22
+ async create(input) {
23
+ calls.push(`create:${input.endedAt ?? 'open'}`)
24
+ if (store.failNext.create) {
25
+ const e = store.failNext.create
26
+ store.failNext.create = undefined
27
+ throw e
28
+ }
29
+ const e: TimedEntry = { id: `n${++seq}`, version: 1, startedAt: input.startedAt, endedAt: input.endedAt, projectId: input.projectId ?? null, note: input.note ?? '' }
30
+ if (!input.endedAt) running = e
31
+ return e
32
+ },
33
+ async update(entry, patch) {
34
+ calls.push(`update:${JSON.stringify(patch)}`)
35
+ if (store.failNext.update) {
36
+ const e = store.failNext.update
37
+ store.failNext.update = undefined
38
+ throw e
39
+ }
40
+ const next = { ...entry, ...patch, version: (entry.version ?? 0) + 1 } as TimedEntry
41
+ if (running?.id === entry.id) running = next.endedAt ? null : next
42
+ return next
43
+ },
44
+ async remove() {},
45
+ }
46
+ return store
47
+ }
48
+
49
+ const open = (over: Partial<TimedEntry> = {}): TimedEntry => ({
50
+ id: 'r1', version: 1, startedAt: '2026-10-31T09:00:00.000Z', endedAt: null, projectId: 'p1', note: 'n', ...over,
51
+ })
52
+
53
+ describe('useRunningTimer', () => {
54
+ it('loads an already running entry and counts elapsed from now()', async () => {
55
+ const store = fakeStore(open())
56
+ const now = jest.fn(() => new Date('2026-10-31T09:00:10.000Z').getTime())
57
+ const { result } = renderHook(() => useRunningTimer({ store, now, tickMs: 0 }))
58
+ await waitFor(() => expect(result.current.status).toBe('running'))
59
+ expect(result.current.elapsed).toBe(10_000)
60
+ })
61
+
62
+ it('starts with the chosen project and note', async () => {
63
+ const store = fakeStore()
64
+ const now = () => new Date('2026-10-31T09:00:00.000Z').getTime()
65
+ const { result } = renderHook(() => useRunningTimer({ store, now, tickMs: 0 }))
66
+ await waitFor(() => expect(result.current.status).toBe('stopped'))
67
+ await act(() => result.current.start({ projectId: 'p2', note: 'hi' }))
68
+ expect(result.current.status).toBe('running')
69
+ expect(result.current.entry).toMatchObject({ projectId: 'p2', note: 'hi', startedAt: '2026-10-31T09:00:00.000Z' })
70
+ })
71
+
72
+ it('on 409 adopts the entry running elsewhere and says so', async () => {
73
+ const store = fakeStore()
74
+ const { result } = renderHook(() => useRunningTimer({ store, tickMs: 0 }))
75
+ await waitFor(() => expect(result.current.status).toBe('stopped'))
76
+ store.failNext.create = conflict()
77
+ store.setRunning(open({ id: 'other' }))
78
+ await act(() => result.current.start({}))
79
+ expect(result.current.status).toBe('running')
80
+ expect(result.current.entry?.id).toBe('other')
81
+ expect(result.current.notice).toBe('already-running')
82
+ expect(result.current.error).toBeNull()
83
+ })
84
+
85
+ it('pause closes the entry; resume reopens with the same project and note', async () => {
86
+ const store = fakeStore(open())
87
+ const { result } = renderHook(() => useRunningTimer({ store, tickMs: 0 }))
88
+ await waitFor(() => expect(result.current.status).toBe('running'))
89
+ await act(() => result.current.pause())
90
+ expect(result.current.status).toBe('stopped')
91
+ expect(result.current.paused).toMatchObject({ projectId: 'p1', note: 'n' })
92
+ await act(() => result.current.resume())
93
+ expect(result.current.status).toBe('running')
94
+ expect(result.current.entry).toMatchObject({ projectId: 'p1', note: 'n' })
95
+ expect(result.current.entry?.id).not.toBe('r1')
96
+ })
97
+
98
+ it('stop forgets the paused interval, so there is nothing to resume', async () => {
99
+ const store = fakeStore(open())
100
+ const { result } = renderHook(() => useRunningTimer({ store, tickMs: 0 }))
101
+ await waitFor(() => expect(result.current.status).toBe('running'))
102
+ await act(() => result.current.stop())
103
+ expect(result.current.paused).toBeNull()
104
+ })
105
+
106
+ it('stop on a stale version re-reads and closes the fresh row once', async () => {
107
+ const store = fakeStore(open())
108
+ const { result } = renderHook(() => useRunningTimer({ store, tickMs: 0 }))
109
+ await waitFor(() => expect(result.current.status).toBe('running'))
110
+ store.failNext.update = stale()
111
+ store.setRunning(open({ version: 2 }))
112
+ await act(() => result.current.stop())
113
+ expect(result.current.status).toBe('stopped')
114
+ expect(result.current.error).toBeNull()
115
+ })
116
+
117
+ it('stop when another window already stopped it just refreshes to stopped', async () => {
118
+ const store = fakeStore(open())
119
+ const { result } = renderHook(() => useRunningTimer({ store, tickMs: 0 }))
120
+ await waitFor(() => expect(result.current.status).toBe('running'))
121
+ store.failNext.update = stale()
122
+ store.setRunning(null)
123
+ await act(() => result.current.stop())
124
+ expect(result.current.status).toBe('stopped')
125
+ })
126
+
127
+ it('surfaces other errors without losing the running entry', async () => {
128
+ const store = fakeStore(open())
129
+ const { result } = renderHook(() => useRunningTimer({ store, tickMs: 0 }))
130
+ await waitFor(() => expect(result.current.status).toBe('running'))
131
+ store.failNext.update = new Error('boom')
132
+ await act(() => result.current.stop())
133
+ expect(result.current.error?.message).toBe('boom')
134
+ expect(result.current.status).toBe('running')
135
+ })
136
+
137
+ it('reports each state change through onChange (tray sync)', async () => {
138
+ const store = fakeStore()
139
+ const onChange = jest.fn()
140
+ const { result } = renderHook(() => useRunningTimer({ store, tickMs: 0, onChange }))
141
+ await waitFor(() => expect(result.current.status).toBe('stopped'))
142
+ await act(() => result.current.start({ note: 'x' }))
143
+ expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ id: expect.any(String), note: 'x' }))
144
+ await act(() => result.current.stop())
145
+ expect(onChange).toHaveBeenLastCalledWith(null)
146
+ })
147
+
148
+ it('resolves idle time through the planned writes, close before create', async () => {
149
+ const store = fakeStore(open())
150
+ const { result } = renderHook(() => useRunningTimer({ store, tickMs: 0 }))
151
+ await waitFor(() => expect(result.current.status).toBe('running'))
152
+ store.calls.length = 0
153
+ await act(() =>
154
+ result.current.resolveIdle({
155
+ idleSince: new Date('2026-10-31T10:00:00.000Z').getTime(),
156
+ now: new Date('2026-10-31T10:20:00.000Z').getTime(),
157
+ choice: { kind: 'assign', projectId: 'p2' },
158
+ }),
159
+ )
160
+ expect(store.calls.filter((c) => c !== 'find')).toEqual([
161
+ 'update:{"endedAt":"2026-10-31T10:00:00.000Z"}',
162
+ 'create:2026-10-31T10:20:00.000Z',
163
+ 'create:open',
164
+ ])
165
+ expect(result.current.entry?.startedAt).toBe('2026-10-31T10:20:00.000Z')
166
+ })
167
+ })
@@ -0,0 +1,20 @@
1
+ export * from './interval-logic'
2
+ export { useRunningTimer } from './useRunningTimer'
3
+ export type { RunningTimer, StartInput, UseRunningTimerOptions } from './useRunningTimer'
4
+ export { useIdleReturn } from './useIdleReturn'
5
+ export type { IdlePrompt, IdleSignal, UseIdleReturnOptions } from './useIdleReturn'
6
+ export { ProjectSwatch, NO_COLOR } from './ProjectSwatch'
7
+ export { ColorPicker, PROJECT_COLORS } from './ColorPicker'
8
+ export type { ColorPickerProps } from './ColorPicker'
9
+ export { ProjectSelect } from './ProjectSelect'
10
+ export type { ProjectOption, ProjectSelectProps } from './ProjectSelect'
11
+ export { TimerBar } from './TimerBar'
12
+ export type { TimerBarProps } from './TimerBar'
13
+ export { IdlePromptDialog, formatIdle } from './IdlePromptDialog'
14
+ export type { IdlePromptDialogProps } from './IdlePromptDialog'
15
+ export { EntryFormDialog } from './EntryFormDialog'
16
+ export type { EntryFormDialogProps, EntryFormValue } from './EntryFormDialog'
17
+ export { EntryList } from './EntryList'
18
+ export type { EntryListProps } from './EntryList'
19
+ export { ProjectsDialog } from './ProjectsDialog'
20
+ export type { ProjectsDialogProps, ProjectSave, ClientOption } from './ProjectsDialog'
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Pure logic for a running-interval timer: one open interval per owner,
3
+ * closed intervals everywhere else. Nothing here names time tracking beyond
4
+ * "started" and "ended"; a shift clock or a checkout ledger fits the same shape.
5
+ *
6
+ * PAUSE IS NOT A STATE. Pausing closes the interval; resuming opens a new one
7
+ * with the same project and note. Every row stays one closed interval, so a
8
+ * report is a sum of rows, and the store needs no attribute beyond the two
9
+ * timestamps (bd startsim-i2t09).
10
+ */
11
+
12
+ export type EntryId = string | number
13
+
14
+ /** One interval. `endedAt` null means it is running. Timestamps are UTC ISO-8601. */
15
+ export interface TimedEntry {
16
+ id: EntryId
17
+ /** Row version for the store's optimistic write; opaque here. */
18
+ version?: number
19
+ startedAt: string
20
+ endedAt: string | null
21
+ projectId: EntryId | null
22
+ note: string
23
+ /**
24
+ * Store-owned passthrough for attributes the timer does not model. A backend whose
25
+ * update replaces the whole record must send them back or they are lost.
26
+ */
27
+ extra?: Record<string, unknown>
28
+ }
29
+
30
+ export interface NewEntry {
31
+ startedAt: string
32
+ endedAt: string | null
33
+ projectId?: EntryId | null
34
+ note?: string
35
+ }
36
+
37
+ export interface EntryPatch {
38
+ startedAt?: string
39
+ endedAt?: string | null
40
+ projectId?: EntryId | null
41
+ note?: string
42
+ }
43
+
44
+ /**
45
+ * What a backend must offer. `create` with `endedAt: null` is "start"; an
46
+ * `update` with `endedAt` set is "stop". A second open interval for the same
47
+ * owner must be refused with an error carrying `status: 409`.
48
+ */
49
+ export interface IntervalStore {
50
+ findRunning(): Promise<TimedEntry | null>
51
+ create(input: NewEntry): Promise<TimedEntry>
52
+ update(entry: TimedEntry, patch: EntryPatch): Promise<TimedEntry>
53
+ remove(entry: TimedEntry): Promise<void>
54
+ }
55
+
56
+ export function isStatus(err: unknown, status: number): boolean {
57
+ return typeof err === 'object' && err !== null && (err as { status?: unknown }).status === status
58
+ }
59
+
60
+ export function elapsedMs(startedAt: string, nowMs: number): number {
61
+ return Math.max(0, nowMs - new Date(startedAt).getTime())
62
+ }
63
+
64
+ /** `h:mm:ss`, hours unbounded (a 100 hour interval reads `100:00:00`). */
65
+ export function formatElapsed(ms: number): string {
66
+ const total = Math.max(0, Math.floor(ms / 1000))
67
+ const h = Math.floor(total / 3600)
68
+ const m = Math.floor((total % 3600) / 60)
69
+ const s = total % 60
70
+ return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
71
+ }
72
+
73
+ const iso = (ms: number) => new Date(ms).toISOString()
74
+
75
+ export type IdleChoice = { kind: 'keep' } | { kind: 'discard' } | { kind: 'assign'; projectId: EntryId }
76
+
77
+ export type IdleOp =
78
+ | { kind: 'update'; patch: EntryPatch }
79
+ | { kind: 'create'; input: Required<Pick<NewEntry, 'startedAt' | 'endedAt'>> & { projectId: EntryId | null; note: string } }
80
+
81
+ /**
82
+ * The ordered writes that resolve an idle window `[idleSince, now]` on the
83
+ * running `entry`. Order matters: the open-interval rule refuses a second open
84
+ * row, so the running row is always closed before a new one is created.
85
+ *
86
+ * `update` ops target `entry`. All timestamps are UTC ISO strings: the server
87
+ * compares them as text, so mixed offsets would sort wrong.
88
+ */
89
+ export function planIdleResolution(input: {
90
+ entry: TimedEntry
91
+ idleSince: number
92
+ now: number
93
+ choice: IdleChoice
94
+ }): IdleOp[] {
95
+ const { entry, idleSince, now, choice } = input
96
+ if (choice.kind === 'keep') return []
97
+ const startedInsideIdle = new Date(entry.startedAt).getTime() >= idleSince
98
+ const resumed: IdleOp = {
99
+ kind: 'create',
100
+ input: { startedAt: iso(now), endedAt: null, projectId: entry.projectId, note: entry.note },
101
+ }
102
+
103
+ if (startedInsideIdle) {
104
+ // Nothing before the idle window to keep: the entry itself is the idle time.
105
+ if (choice.kind === 'discard') return [{ kind: 'update', patch: { startedAt: iso(now) } }]
106
+ return [{ kind: 'update', patch: { endedAt: iso(now), projectId: choice.projectId } }, resumed]
107
+ }
108
+
109
+ const close: IdleOp = { kind: 'update', patch: { endedAt: iso(idleSince) } }
110
+ if (choice.kind === 'discard') return [close, resumed]
111
+ return [
112
+ close,
113
+ { kind: 'create', input: { startedAt: iso(idleSince), endedAt: iso(now), projectId: choice.projectId, note: '' } },
114
+ resumed,
115
+ ]
116
+ }
117
+
118
+ const pad = (n: number) => String(n).padStart(2, '0')
119
+
120
+ /** ISO instant -> the `yyyy-mm-ddThh:mm` a datetime-local input shows (local zone). */
121
+ export function toLocalInput(isoString: string): string {
122
+ const d = new Date(isoString)
123
+ if (Number.isNaN(d.getTime())) return ''
124
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
125
+ }
126
+
127
+ /** A datetime-local value -> UTC ISO, or null when blank or unparsable. */
128
+ export function fromLocalInput(value: string): string | null {
129
+ if (!value) return null
130
+ const d = new Date(value)
131
+ return Number.isNaN(d.getTime()) ? null : d.toISOString()
132
+ }
133
+
134
+ export function validateRange(startedAt: string | null, endedAt: string | null): string | null {
135
+ if (!startedAt) return 'Enter a start time.'
136
+ if (endedAt && new Date(endedAt).getTime() <= new Date(startedAt).getTime()) {
137
+ return 'The end must be after the start.'
138
+ }
139
+ return null
140
+ }
@@ -0,0 +1,49 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useState } from 'react'
4
+
5
+ /** The slice of the shell's idle events this hook reads (matches @startsimpli/desktop). */
6
+ export type IdleSignal =
7
+ | { type: 'idle-start'; ts: number; idleSince: number; idleSeconds?: number }
8
+ | { type: 'idle-end'; ts: number; idleSince: number; idleSeconds?: number }
9
+ | { type: 'lock' | 'unlock' | 'suspend' | 'resume'; ts: number }
10
+
11
+ export interface IdlePrompt {
12
+ /** When the idle stretch began (epoch ms). */
13
+ idleSince: number
14
+ /** When the user came back (epoch ms). */
15
+ until: number
16
+ seconds: number
17
+ }
18
+
19
+ export interface UseIdleReturnOptions {
20
+ /** Subscribe to idle events; undefined outside the desktop shell. */
21
+ subscribe: ((cb: (e: IdleSignal) => void) => () => void) | undefined
22
+ /** Epoch ms the running interval started; null when nothing is running. */
23
+ runningSince: number | null
24
+ /** Idle shorter than this is not worth asking about. */
25
+ minSeconds?: number
26
+ }
27
+
28
+ /**
29
+ * Turns the shell's idle-end event into a prompt, but only when an interval was
30
+ * running before the idle stretch began. The timer itself keeps running
31
+ * through idle; policy (keep, discard, assign) is applied by the caller.
32
+ */
33
+ export function useIdleReturn({ subscribe, runningSince, minSeconds = 0 }: UseIdleReturnOptions) {
34
+ const [prompt, setPrompt] = useState<IdlePrompt | null>(null)
35
+
36
+ useEffect(() => {
37
+ if (!subscribe) return
38
+ return subscribe((e) => {
39
+ if (e.type !== 'idle-end') return
40
+ if (runningSince === null) return
41
+ const seconds = Math.round((e.ts - e.idleSince) / 1000)
42
+ if (seconds < minSeconds) return
43
+ setPrompt({ idleSince: e.idleSince, until: e.ts, seconds })
44
+ })
45
+ }, [subscribe, runningSince, minSeconds])
46
+
47
+ const dismiss = useCallback(() => setPrompt(null), [])
48
+ return { prompt, dismiss }
49
+ }
@@ -0,0 +1,209 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react'
4
+ import {
5
+ elapsedMs,
6
+ isStatus,
7
+ planIdleResolution,
8
+ type EntryId,
9
+ type IdleChoice,
10
+ type IntervalStore,
11
+ type TimedEntry,
12
+ } from './interval-logic'
13
+
14
+ export interface StartInput {
15
+ projectId?: EntryId | null
16
+ note?: string
17
+ }
18
+
19
+ export interface UseRunningTimerOptions {
20
+ store: IntervalStore
21
+ /** Clock, injectable for tests. */
22
+ now?: () => number
23
+ /** Re-render period while running; 0 disables the interval (tests). */
24
+ tickMs?: number
25
+ /** Called with the running entry (or null) whenever it changes, once loaded. Drives the tray. */
26
+ onChange?: (entry: TimedEntry | null) => void
27
+ }
28
+
29
+ export interface RunningTimer {
30
+ status: 'loading' | 'stopped' | 'running'
31
+ entry: TimedEntry | null
32
+ /** Milliseconds since the running entry started; 0 when stopped. */
33
+ elapsed: number
34
+ /** Set by pause(), cleared by stop() and resume(): what Resume would reopen. */
35
+ paused: TimedEntry | null
36
+ busy: boolean
37
+ error: Error | null
38
+ /** 'already-running': a start lost to an interval running elsewhere, which is now shown. */
39
+ notice: 'already-running' | null
40
+ start(input?: StartInput): Promise<void>
41
+ /** Closes the interval and keeps its project and note for resume(). */
42
+ pause(): Promise<void>
43
+ /** Closes the interval. */
44
+ stop(): Promise<void>
45
+ resume(): Promise<void>
46
+ refresh(): Promise<void>
47
+ resolveIdle(input: { idleSince: number; now: number; choice: IdleChoice }): Promise<void>
48
+ clearNotice(): void
49
+ }
50
+
51
+ /**
52
+ * The running interval, as the store sees it. The server is the authority: a
53
+ * start that loses the one-open-interval race (409) adopts the winner, a write
54
+ * against a moved row (412) re-reads once, and the tray/other windows are
55
+ * reconciled by refreshing on focus.
56
+ */
57
+ export function useRunningTimer({ store, now = Date.now, tickMs = 1000, onChange }: UseRunningTimerOptions): RunningTimer {
58
+ const [entry, setEntry] = useState<TimedEntry | null>(null)
59
+ const [loaded, setLoaded] = useState(false)
60
+ const [paused, setPaused] = useState<TimedEntry | null>(null)
61
+ const [busy, setBusy] = useState(false)
62
+ const [error, setError] = useState<Error | null>(null)
63
+ const [notice, setNotice] = useState<'already-running' | null>(null)
64
+ const [, setTick] = useState(0)
65
+ const entryRef = useRef(entry)
66
+ entryRef.current = entry
67
+ const onChangeRef = useRef(onChange)
68
+ onChangeRef.current = onChange
69
+
70
+ const refresh = useCallback(async () => {
71
+ try {
72
+ setEntry(await store.findRunning())
73
+ } catch (e) {
74
+ setError(e as Error)
75
+ } finally {
76
+ setLoaded(true)
77
+ }
78
+ }, [store])
79
+
80
+ useEffect(() => {
81
+ void refresh()
82
+ }, [refresh])
83
+
84
+ useEffect(() => {
85
+ if (!loaded) return
86
+ onChangeRef.current?.(entry)
87
+ }, [entry, loaded])
88
+
89
+ useEffect(() => {
90
+ if (!entry || tickMs <= 0) return
91
+ const id = setInterval(() => setTick((n) => n + 1), tickMs)
92
+ return () => clearInterval(id)
93
+ }, [entry, tickMs])
94
+
95
+ useEffect(() => {
96
+ if (typeof window === 'undefined') return
97
+ const onFocus = () => void refresh()
98
+ window.addEventListener('focus', onFocus)
99
+ return () => window.removeEventListener('focus', onFocus)
100
+ }, [refresh])
101
+
102
+ const guarded = useCallback(async (fn: () => Promise<void>) => {
103
+ setBusy(true)
104
+ setError(null)
105
+ try {
106
+ await fn()
107
+ } catch (e) {
108
+ setError(e as Error)
109
+ } finally {
110
+ setBusy(false)
111
+ }
112
+ }, [])
113
+
114
+ /** Runs `fn` against the held row; on a stale version re-reads the running row and tries once more. */
115
+ const withFresh = useCallback(
116
+ async <T,>(fn: (e: TimedEntry) => Promise<T>): Promise<T | undefined> => {
117
+ const current = entryRef.current
118
+ if (!current) return undefined
119
+ try {
120
+ return await fn(current)
121
+ } catch (err) {
122
+ if (!isStatus(err, 412)) throw err
123
+ const fresh = await store.findRunning()
124
+ setEntry(fresh)
125
+ if (!fresh) return undefined
126
+ return fn(fresh)
127
+ }
128
+ },
129
+ [store],
130
+ )
131
+
132
+ const start = useCallback(
133
+ (input: StartInput = {}) =>
134
+ guarded(async () => {
135
+ setNotice(null)
136
+ try {
137
+ const created = await store.create({
138
+ startedAt: new Date(now()).toISOString(),
139
+ endedAt: null,
140
+ projectId: input.projectId ?? null,
141
+ note: input.note ?? '',
142
+ })
143
+ setEntry(created)
144
+ } catch (err) {
145
+ if (!isStatus(err, 409)) throw err
146
+ // Another window or the tray won the race: show what is running.
147
+ const winner = await store.findRunning()
148
+ setEntry(winner)
149
+ if (winner) setNotice('already-running')
150
+ else throw err
151
+ }
152
+ }),
153
+ [guarded, store, now],
154
+ )
155
+
156
+ const close = useCallback(
157
+ (keep: boolean) =>
158
+ guarded(async () => {
159
+ const closed = await withFresh((e) => store.update(e, { endedAt: new Date(now()).toISOString() }))
160
+ setPaused(keep && closed ? closed : null)
161
+ setEntry(null)
162
+ }),
163
+ [guarded, withFresh, store, now],
164
+ )
165
+ const stop = useCallback(() => close(false), [close])
166
+ const pause = useCallback(() => close(true), [close])
167
+
168
+ const resume = useCallback(async () => {
169
+ const from = paused
170
+ await start({ projectId: from?.projectId ?? null, note: from?.note ?? '' })
171
+ setPaused(null)
172
+ }, [start, paused])
173
+
174
+ const resolveIdle = useCallback(
175
+ (input: { idleSince: number; now: number; choice: IdleChoice }) =>
176
+ guarded(async () => {
177
+ const current = entryRef.current
178
+ if (!current) return
179
+ const ops = planIdleResolution({ entry: current, ...input })
180
+ try {
181
+ for (const op of ops) {
182
+ if (op.kind === 'update') await withFresh((e) => store.update(e, op.patch))
183
+ else await store.create(op.input)
184
+ }
185
+ } finally {
186
+ // Whatever landed, the server's view is the truth.
187
+ await refresh()
188
+ }
189
+ }),
190
+ [guarded, withFresh, store, refresh],
191
+ )
192
+
193
+ return {
194
+ status: !loaded ? 'loading' : entry ? 'running' : 'stopped',
195
+ entry,
196
+ elapsed: entry ? elapsedMs(entry.startedAt, now()) : 0,
197
+ paused,
198
+ busy,
199
+ error,
200
+ notice,
201
+ start,
202
+ pause,
203
+ stop,
204
+ resume,
205
+ refresh,
206
+ resolveIdle,
207
+ clearNotice: () => setNotice(null),
208
+ }
209
+ }
@@ -0,0 +1,36 @@
1
+ import * as XLSX from 'xlsx'
2
+ import { csvFromRows } from '../csv'
3
+ import { xlsxFromRows } from '../xlsx'
4
+
5
+ const rows: (string | number)[][] = [
6
+ ['Period', 'Entries', 'Minutes', 'Earnings'],
7
+ ['2026-07', 3, 255, 195],
8
+ ['2026-08', 1, 60, 50],
9
+ ['Total', 4, 315, 245],
10
+ ]
11
+
12
+ describe('xlsxFromRows', () => {
13
+ it('writes numbers as numbers so a spreadsheet can sum them', () => {
14
+ const wb = XLSX.read(xlsxFromRows(rows, { sheetName: 'Hours' }), { type: 'array' })
15
+ expect(wb.SheetNames).toEqual(['Hours'])
16
+ const back = XLSX.utils.sheet_to_json<(string | number)[]>(wb.Sheets.Hours, { header: 1 })
17
+ expect(back).toEqual(rows)
18
+ expect(typeof back[1][2]).toBe('number')
19
+ })
20
+
21
+ it('does not turn a formula-looking string into a formula', () => {
22
+ const wb = XLSX.read(xlsxFromRows([['=1+1', '@x']]), { type: 'array' })
23
+ const cell = wb.Sheets[wb.SheetNames[0]].A1
24
+ expect(cell.t).toBe('s')
25
+ expect(cell.f).toBeUndefined()
26
+ expect(cell.v).toBe('=1+1')
27
+ })
28
+
29
+ it('agrees with the CSV writer on every cell', () => {
30
+ const wb = XLSX.read(xlsxFromRows(rows), { type: 'array' })
31
+ const fromXlsx = XLSX.utils.sheet_to_csv(wb.Sheets[wb.SheetNames[0]], { rawNumbers: true })
32
+ expect(fromXlsx.trim().split('\n').map((l) => l.split(','))).toEqual(
33
+ csvFromRows(rows.map((r) => r.map(String))).trim().split('\r\n').map((l) => l.split(',')),
34
+ )
35
+ })
36
+ })