@startsimpli/ui 0.4.167 → 0.4.169
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.
- package/LICENSE +21 -0
- package/README.md +4 -2
- package/package.json +17 -5
- package/src/components/charts/PeriodBars.tsx +36 -0
- package/src/components/charts/index.ts +2 -0
- package/src/components/index.ts +3 -0
- package/src/components/reports/ExportButtons.tsx +28 -0
- package/src/components/reports/PeriodReport.tsx +124 -0
- package/src/components/reports/__tests__/PeriodReport.test.tsx +75 -0
- package/src/components/reports/index.ts +4 -0
- package/src/components/timer/ColorPicker.tsx +43 -0
- package/src/components/timer/EntryFormDialog.tsx +104 -0
- package/src/components/timer/EntryList.tsx +42 -0
- package/src/components/timer/IdlePromptDialog.tsx +65 -0
- package/src/components/timer/ProjectSelect.tsx +52 -0
- package/src/components/timer/ProjectSwatch.tsx +15 -0
- package/src/components/timer/ProjectsDialog.tsx +221 -0
- package/src/components/timer/TimerBar.tsx +74 -0
- package/src/components/timer/__tests__/interval-logic.test.ts +121 -0
- package/src/components/timer/__tests__/projects-dialog.test.tsx +113 -0
- package/src/components/timer/__tests__/timer-components.test.tsx +147 -0
- package/src/components/timer/__tests__/useIdleReturn.test.tsx +59 -0
- package/src/components/timer/__tests__/useRunningTimer.test.tsx +167 -0
- package/src/components/timer/index.ts +20 -0
- package/src/components/timer/interval-logic.ts +140 -0
- package/src/components/timer/useIdleReturn.ts +49 -0
- package/src/components/timer/useRunningTimer.ts +209 -0
- package/src/utils/__tests__/xlsx.test.ts +36 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/xlsx.ts +35 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { fireEvent, render, screen, within } from '@testing-library/react'
|
|
2
|
+
import { ColorPicker, PROJECT_COLORS } from '../ColorPicker'
|
|
3
|
+
import { ProjectSwatch } from '../ProjectSwatch'
|
|
4
|
+
import { TimerBar } from '../TimerBar'
|
|
5
|
+
import { IdlePromptDialog } from '../IdlePromptDialog'
|
|
6
|
+
import { EntryFormDialog } from '../EntryFormDialog'
|
|
7
|
+
import { EntryList } from '../EntryList'
|
|
8
|
+
|
|
9
|
+
const projects = [
|
|
10
|
+
{ id: 'p1', name: 'Alpha', color: '#3366ff' },
|
|
11
|
+
{ id: 'p2', name: 'Beta', color: '#e11d48' },
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
describe('ColorPicker', () => {
|
|
15
|
+
it('is a radiogroup that reports the picked colour', () => {
|
|
16
|
+
const onChange = jest.fn()
|
|
17
|
+
render(<ColorPicker value={PROJECT_COLORS[0]} onChange={onChange} />)
|
|
18
|
+
const group = screen.getByRole('radiogroup', { name: /colour/i })
|
|
19
|
+
const radios = within(group).getAllByRole('radio')
|
|
20
|
+
expect(radios).toHaveLength(PROJECT_COLORS.length)
|
|
21
|
+
expect(radios[0]).toHaveAttribute('aria-checked', 'true')
|
|
22
|
+
fireEvent.click(radios[2])
|
|
23
|
+
expect(onChange).toHaveBeenCalledWith(PROJECT_COLORS[2])
|
|
24
|
+
})
|
|
25
|
+
it('every preset is a 6-digit hex the schema regex accepts', () => {
|
|
26
|
+
for (const c of PROJECT_COLORS) expect(c).toMatch(/^#[0-9a-fA-F]{6}$/)
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('ProjectSwatch', () => {
|
|
31
|
+
it('paints the dot with the colour and falls back to grey', () => {
|
|
32
|
+
const { rerender } = render(<ProjectSwatch color="#3366ff" />)
|
|
33
|
+
expect(screen.getByTestId('swatch')).toHaveStyle({ backgroundColor: '#3366ff' })
|
|
34
|
+
rerender(<ProjectSwatch color={null} />)
|
|
35
|
+
expect(screen.getByTestId('swatch')).toHaveStyle({ backgroundColor: '#9ca3af' })
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('TimerBar', () => {
|
|
40
|
+
const base = { projects, projectId: 'p1', note: '', onProjectChange: jest.fn(), onNoteChange: jest.fn(), busy: false, notice: null }
|
|
41
|
+
it('stopped: one Start action, no elapsed ticker', () => {
|
|
42
|
+
const onStart = jest.fn()
|
|
43
|
+
render(<TimerBar {...base} status="stopped" elapsed={0} paused={false} onStart={onStart} onPause={jest.fn()} onStop={jest.fn()} onResume={jest.fn()} />)
|
|
44
|
+
fireEvent.click(screen.getByRole('button', { name: 'Start' }))
|
|
45
|
+
expect(onStart).toHaveBeenCalled()
|
|
46
|
+
expect(screen.queryByRole('button', { name: 'Stop' })).toBeNull()
|
|
47
|
+
})
|
|
48
|
+
it('running: shows elapsed with Pause and Stop', () => {
|
|
49
|
+
const onPause = jest.fn()
|
|
50
|
+
const onStop = jest.fn()
|
|
51
|
+
render(<TimerBar {...base} status="running" elapsed={3_661_000} paused={false} onStart={jest.fn()} onPause={onPause} onStop={onStop} onResume={jest.fn()} />)
|
|
52
|
+
expect(screen.getByRole('timer')).toHaveTextContent('1:01:01')
|
|
53
|
+
fireEvent.click(screen.getByRole('button', { name: 'Pause' }))
|
|
54
|
+
fireEvent.click(screen.getByRole('button', { name: 'Stop' }))
|
|
55
|
+
expect(onPause).toHaveBeenCalled()
|
|
56
|
+
expect(onStop).toHaveBeenCalled()
|
|
57
|
+
})
|
|
58
|
+
it('paused: Resume is the main action', () => {
|
|
59
|
+
const onResume = jest.fn()
|
|
60
|
+
render(<TimerBar {...base} status="stopped" elapsed={0} paused onStart={jest.fn()} onPause={jest.fn()} onStop={jest.fn()} onResume={onResume} />)
|
|
61
|
+
fireEvent.click(screen.getByRole('button', { name: 'Resume' }))
|
|
62
|
+
expect(onResume).toHaveBeenCalled()
|
|
63
|
+
})
|
|
64
|
+
it('says so when the timer was already running elsewhere', () => {
|
|
65
|
+
render(<TimerBar {...base} status="running" elapsed={1000} paused={false} notice="already-running" onStart={jest.fn()} onPause={jest.fn()} onStop={jest.fn()} onResume={jest.fn()} />)
|
|
66
|
+
expect(screen.getByRole('status')).toHaveTextContent(/already running/i)
|
|
67
|
+
})
|
|
68
|
+
it('changing project and note reports through the callbacks', () => {
|
|
69
|
+
const onProjectChange = jest.fn()
|
|
70
|
+
const onNoteChange = jest.fn()
|
|
71
|
+
render(<TimerBar {...base} onProjectChange={onProjectChange} onNoteChange={onNoteChange} status="stopped" elapsed={0} paused={false} onStart={jest.fn()} onPause={jest.fn()} onStop={jest.fn()} onResume={jest.fn()} />)
|
|
72
|
+
fireEvent.change(screen.getByLabelText('Project'), { target: { value: 'p2' } })
|
|
73
|
+
expect(onProjectChange).toHaveBeenCalledWith('p2')
|
|
74
|
+
fireEvent.change(screen.getByLabelText('What are you working on?'), { target: { value: 'x' } })
|
|
75
|
+
expect(onNoteChange).toHaveBeenCalledWith('x')
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('IdlePromptDialog', () => {
|
|
80
|
+
const props = { open: true, seconds: 1260, projects, currentProjectId: 'p1', busy: false, onKeep: jest.fn(), onDiscard: jest.fn(), onAssign: jest.fn() }
|
|
81
|
+
it('states the idle duration and offers keep, discard, assign', () => {
|
|
82
|
+
const onKeep = jest.fn()
|
|
83
|
+
const onDiscard = jest.fn()
|
|
84
|
+
const onAssign = jest.fn()
|
|
85
|
+
render(<IdlePromptDialog {...props} onKeep={onKeep} onDiscard={onDiscard} onAssign={onAssign} />)
|
|
86
|
+
expect(screen.getByText(/21 min/)).toBeInTheDocument()
|
|
87
|
+
fireEvent.click(screen.getByRole('button', { name: 'Keep' }))
|
|
88
|
+
fireEvent.click(screen.getByRole('button', { name: 'Discard' }))
|
|
89
|
+
expect(onKeep).toHaveBeenCalled()
|
|
90
|
+
expect(onDiscard).toHaveBeenCalled()
|
|
91
|
+
fireEvent.change(screen.getByLabelText('Assign to'), { target: { value: 'p2' } })
|
|
92
|
+
fireEvent.click(screen.getByRole('button', { name: 'Assign' }))
|
|
93
|
+
expect(onAssign).toHaveBeenCalledWith('p2')
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('EntryFormDialog', () => {
|
|
98
|
+
it('adds a manual entry from local datetimes', () => {
|
|
99
|
+
const onSubmit = jest.fn()
|
|
100
|
+
render(<EntryFormDialog open mode="add" projects={projects} busy={false} onSubmit={onSubmit} onClose={jest.fn()} />)
|
|
101
|
+
fireEvent.change(screen.getByLabelText('Start'), { target: { value: '2026-10-31T09:00' } })
|
|
102
|
+
fireEvent.change(screen.getByLabelText('End'), { target: { value: '2026-10-31T10:30' } })
|
|
103
|
+
fireEvent.change(screen.getByLabelText('Project'), { target: { value: 'p2' } })
|
|
104
|
+
fireEvent.change(screen.getByLabelText('Note'), { target: { value: 'review' } })
|
|
105
|
+
fireEvent.click(screen.getByRole('button', { name: 'Add entry' }))
|
|
106
|
+
const arg = onSubmit.mock.calls[0][0]
|
|
107
|
+
expect(new Date(arg.endedAt).getTime() - new Date(arg.startedAt).getTime()).toBe(90 * 60_000)
|
|
108
|
+
expect(arg).toMatchObject({ projectId: 'p2', note: 'review' })
|
|
109
|
+
})
|
|
110
|
+
it('refuses an end before the start', () => {
|
|
111
|
+
const onSubmit = jest.fn()
|
|
112
|
+
render(<EntryFormDialog open mode="add" projects={projects} busy={false} onSubmit={onSubmit} onClose={jest.fn()} />)
|
|
113
|
+
fireEvent.change(screen.getByLabelText('Start'), { target: { value: '2026-10-31T10:00' } })
|
|
114
|
+
fireEvent.change(screen.getByLabelText('End'), { target: { value: '2026-10-31T09:00' } })
|
|
115
|
+
fireEvent.click(screen.getByRole('button', { name: 'Add entry' }))
|
|
116
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
117
|
+
expect(screen.getByRole('alert')).toHaveTextContent(/after/i)
|
|
118
|
+
})
|
|
119
|
+
it('edit prefills and offers delete', () => {
|
|
120
|
+
const onDelete = jest.fn()
|
|
121
|
+
render(
|
|
122
|
+
<EntryFormDialog open mode="edit" projects={projects} busy={false} onSubmit={jest.fn()} onDelete={onDelete} onClose={jest.fn()}
|
|
123
|
+
initial={{ startedAt: '2026-10-31T09:00:00.000Z', endedAt: '2026-10-31T10:00:00.000Z', projectId: 'p1', note: 'old' }} />,
|
|
124
|
+
)
|
|
125
|
+
expect(screen.getByLabelText('Note')).toHaveValue('old')
|
|
126
|
+
expect(screen.getByLabelText('Project')).toHaveValue('p1')
|
|
127
|
+
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
|
128
|
+
expect(onDelete).toHaveBeenCalled()
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe('EntryList', () => {
|
|
133
|
+
it('shows colour, project, note, span and duration, and edits on click', () => {
|
|
134
|
+
const onEdit = jest.fn()
|
|
135
|
+
const entries = [{ id: 'e1', startedAt: '2026-10-31T09:00:00.000Z', endedAt: '2026-10-31T10:30:00.000Z', projectId: 'p1', note: 'spec' }]
|
|
136
|
+
render(<EntryList entries={entries} projects={projects} onEdit={onEdit} />)
|
|
137
|
+
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
|
138
|
+
expect(screen.getByText('spec')).toBeInTheDocument()
|
|
139
|
+
expect(screen.getByText('1:30:00')).toBeInTheDocument()
|
|
140
|
+
fireEvent.click(screen.getByRole('button', { name: /edit/i }))
|
|
141
|
+
expect(onEdit).toHaveBeenCalledWith(entries[0])
|
|
142
|
+
})
|
|
143
|
+
it('says when there is nothing yet', () => {
|
|
144
|
+
render(<EntryList entries={[]} projects={projects} onEdit={jest.fn()} />)
|
|
145
|
+
expect(screen.getByText(/no entries/i)).toBeInTheDocument()
|
|
146
|
+
})
|
|
147
|
+
})
|
|
@@ -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
|
+
}
|