@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,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
|
+
})
|
package/src/utils/index.ts
CHANGED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import * as XLSX from 'xlsx'
|
|
2
|
+
import { downloadBlob } from './csv'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A spreadsheet from rows, written so a spreadsheet can use it.
|
|
6
|
+
*
|
|
7
|
+
* Numbers stay numbers (a total column can be summed), strings stay strings -
|
|
8
|
+
* a `=...` string is stored as text, never as a formula, so no guard prefix is
|
|
9
|
+
* needed here (the CSV writer needs one; this format has a cell type for text).
|
|
10
|
+
* Returns the bytes so a test, a server or a download can all consume it;
|
|
11
|
+
* `downloadXlsx` is the thin browser wrapper. Pairs with `csvFromRows`: give
|
|
12
|
+
* both the same matrix and the two files hold the same values.
|
|
13
|
+
*
|
|
14
|
+
* Uses `xlsx` (SheetJS), already a dependency of this package for the table
|
|
15
|
+
* export. Only the write path is used; the parse path with the published
|
|
16
|
+
* advisories is not reachable from here.
|
|
17
|
+
*/
|
|
18
|
+
export type XlsxCell = string | number | boolean | null
|
|
19
|
+
|
|
20
|
+
export function xlsxFromRows(rows: XlsxCell[][], opts: { sheetName?: string } = {}): Uint8Array {
|
|
21
|
+
const ws = XLSX.utils.aoa_to_sheet(rows)
|
|
22
|
+
const widths = rows[0]?.map((_, i) => ({
|
|
23
|
+
wch: Math.min(50, Math.max(...rows.map((r) => String(r[i] ?? '').length)) + 2),
|
|
24
|
+
}))
|
|
25
|
+
if (widths) ws['!cols'] = widths
|
|
26
|
+
const wb = XLSX.utils.book_new()
|
|
27
|
+
XLSX.utils.book_append_sheet(wb, ws, (opts.sheetName ?? 'Sheet1').slice(0, 31))
|
|
28
|
+
return new Uint8Array(XLSX.write(wb, { bookType: 'xlsx', type: 'array' }))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
|
32
|
+
|
|
33
|
+
export function downloadXlsx(filename: string, rows: XlsxCell[][], opts: { sheetName?: string } = {}): void {
|
|
34
|
+
downloadBlob(filename, new Blob([xlsxFromRows(rows, opts) as BlobPart], { type: XLSX_MIME }))
|
|
35
|
+
}
|