@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.
Files changed (30) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +4 -2
  3. package/package.json +17 -5
  4. package/src/components/charts/PeriodBars.tsx +36 -0
  5. package/src/components/charts/index.ts +2 -0
  6. package/src/components/index.ts +3 -0
  7. package/src/components/reports/ExportButtons.tsx +28 -0
  8. package/src/components/reports/PeriodReport.tsx +124 -0
  9. package/src/components/reports/__tests__/PeriodReport.test.tsx +75 -0
  10. package/src/components/reports/index.ts +4 -0
  11. package/src/components/timer/ColorPicker.tsx +43 -0
  12. package/src/components/timer/EntryFormDialog.tsx +104 -0
  13. package/src/components/timer/EntryList.tsx +42 -0
  14. package/src/components/timer/IdlePromptDialog.tsx +65 -0
  15. package/src/components/timer/ProjectSelect.tsx +52 -0
  16. package/src/components/timer/ProjectSwatch.tsx +15 -0
  17. package/src/components/timer/ProjectsDialog.tsx +221 -0
  18. package/src/components/timer/TimerBar.tsx +74 -0
  19. package/src/components/timer/__tests__/interval-logic.test.ts +121 -0
  20. package/src/components/timer/__tests__/projects-dialog.test.tsx +113 -0
  21. package/src/components/timer/__tests__/timer-components.test.tsx +147 -0
  22. package/src/components/timer/__tests__/useIdleReturn.test.tsx +59 -0
  23. package/src/components/timer/__tests__/useRunningTimer.test.tsx +167 -0
  24. package/src/components/timer/index.ts +20 -0
  25. package/src/components/timer/interval-logic.ts +140 -0
  26. package/src/components/timer/useIdleReturn.ts +49 -0
  27. package/src/components/timer/useRunningTimer.ts +209 -0
  28. package/src/utils/__tests__/xlsx.test.ts +36 -0
  29. package/src/utils/index.ts +1 -0
  30. 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
+ })
@@ -8,6 +8,7 @@ export {
8
8
  downloadBlob,
9
9
  type CsvColumn,
10
10
  } from './csv'
11
+ export { xlsxFromRows, downloadXlsx, XLSX_MIME, type XlsxCell } from './xlsx'
11
12
 
12
13
  /**
13
14
  * Generate a random ID with optional prefix
@@ -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
+ }