@startsimpli/ui 0.4.168 → 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 (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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.168",
3
+ "version": "0.4.169",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "license": "MIT",
6
6
  "homepage": "https://startsimpli.com",
@@ -44,7 +44,8 @@
44
44
  "./session": "./src/components/session/index.ts",
45
45
  "./session-console": "./src/components/session/AgentSessionConsole.tsx",
46
46
  "./tailwind": "./tailwind.preset.js",
47
- "./track-changes": "./src/components/track-changes/index.ts"
47
+ "./track-changes": "./src/components/track-changes/index.ts",
48
+ "./timer": "./src/components/timer/index.ts"
48
49
  },
49
50
  "files": [
50
51
  "src",
@@ -66,9 +67,9 @@
66
67
  "@codemirror/language": "^6.0.0",
67
68
  "@codemirror/state": "^6.0.0",
68
69
  "@codemirror/view": "^6.0.0",
69
- "@startsimpli/auth": "^0.4.48",
70
- "@startsimpli/api": "^0.5.70",
71
- "@startsimpli/i18n": "^0.1.4"
70
+ "@startsimpli/auth": "^0.4.47",
71
+ "@startsimpli/api": "^0.5.69",
72
+ "@startsimpli/i18n": "^0.1.3"
72
73
  },
73
74
  "peerDependenciesMeta": {
74
75
  "@startsimpli/api": {
@@ -163,8 +164,8 @@
163
164
  "tailwindcss": "^3.4.19",
164
165
  "tsup": "^8.5.1",
165
166
  "typescript": "^6.0.3",
166
- "@startsimpli/realtime": "0.0.3",
167
- "@startsimpli/i18n": "0.1.4"
167
+ "@startsimpli/i18n": "0.1.3",
168
+ "@startsimpli/realtime": "0.0.2"
168
169
  },
169
170
  "keywords": [
170
171
  "ui",
@@ -0,0 +1,36 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
5
+ import { SERIES_COLORS } from './TimeSeriesChart'
6
+
7
+ export interface PeriodBarsDatum {
8
+ /** X axis label for the period. */
9
+ label: string
10
+ value: number
11
+ }
12
+
13
+ export interface PeriodBarsProps {
14
+ data: PeriodBarsDatum[]
15
+ /** What the bars measure, for the tooltip and the accessible name. */
16
+ unit: string
17
+ height?: number
18
+ ariaLabel?: string
19
+ }
20
+
21
+ /** One bar per period. Categorical axis, so a gap period is simply absent. */
22
+ export function PeriodBars({ data, unit, height = 220, ariaLabel }: PeriodBarsProps) {
23
+ return (
24
+ <div role="img" aria-label={ariaLabel ?? `${unit} per period`} style={{ width: '100%', height }}>
25
+ <ResponsiveContainer width="100%" height="100%">
26
+ <BarChart data={data} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
27
+ <CartesianGrid vertical={false} strokeOpacity={0.3} />
28
+ <XAxis dataKey="label" tick={{ fontSize: 12 }} interval="preserveStartEnd" />
29
+ <YAxis tick={{ fontSize: 12 }} width={44} />
30
+ <Tooltip formatter={(v) => [String(v), unit]} />
31
+ <Bar dataKey="value" fill={SERIES_COLORS[0]} radius={[3, 3, 0, 0]} isAnimationActive={false} />
32
+ </BarChart>
33
+ </ResponsiveContainer>
34
+ </div>
35
+ )
36
+ }
@@ -1,6 +1,8 @@
1
1
  // Charts live behind their own subpath (`@startsimpli/ui/charts`) so recharts
2
2
  // is only bundled by apps that draw one.
3
3
  export { TimeSeriesChart, SERIES_COLORS, SERIES_DASHES, seriesStroke } from './TimeSeriesChart'
4
+ export { PeriodBars } from './PeriodBars'
5
+ export type { PeriodBarsProps, PeriodBarsDatum } from './PeriodBars'
4
6
  export type { TimeSeriesChartProps, TimeSeriesReferenceLine } from './TimeSeriesChart'
5
7
  export type {
6
8
  TimeSeries,
@@ -120,6 +120,9 @@ export * from './email-dialogs'
120
120
  // Dashboard components (MetricCard, PeriodSelector, SparklineTrend, DashboardGrid, DashboardSection)
121
121
  export * from './dashboard'
122
122
 
123
+ // Period reports (PeriodReport, ExportButtons) - grouped table + totals, caller owns data and export
124
+ export * from './reports'
125
+
123
126
  // SystemHealth blocks (HealthCard, PipelineHealth, SourceFreshness, DeliveryHealth, AttentionFeed)
124
127
  export * from './health'
125
128
 
@@ -0,0 +1,28 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { Button } from '../ui/button'
5
+
6
+ export type ReportExportFormat = 'csv' | 'xlsx'
7
+
8
+ export interface ExportButtonsProps {
9
+ onExport: (format: ReportExportFormat) => void
10
+ /** Disables both while a file is being built. */
11
+ busy?: boolean
12
+ formats?: ReportExportFormat[]
13
+ }
14
+
15
+ const LABEL: Record<ReportExportFormat, string> = { csv: 'Export CSV', xlsx: 'Export Excel' }
16
+
17
+ /** One button per format. The caller builds and saves the file. */
18
+ export function ExportButtons({ onExport, busy, formats = ['csv', 'xlsx'] }: ExportButtonsProps) {
19
+ return (
20
+ <>
21
+ {formats.map((f) => (
22
+ <Button key={f} type="button" variant="outline" size="sm" disabled={busy} onClick={() => onExport(f)}>
23
+ {LABEL[f]}
24
+ </Button>
25
+ ))}
26
+ </>
27
+ )
28
+ }
@@ -0,0 +1,124 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { cn } from '../../lib/utils'
5
+ import { PeriodSelector, type PeriodOption } from '../dashboard/PeriodSelector'
6
+
7
+ export interface PeriodReportColumn<R> {
8
+ key: string
9
+ label: string
10
+ numeric?: boolean
11
+ render: (row: R) => React.ReactNode
12
+ }
13
+
14
+ export interface PeriodReportProps<R extends { key: string }> {
15
+ groupBy: string
16
+ groupOptions: PeriodOption[]
17
+ onGroupByChange: (value: string) => void
18
+ rows: R[]
19
+ /** Rendered as the last row. Derived by the caller from the same data as `rows`. */
20
+ totals: R
21
+ columns: PeriodReportColumn<R>[]
22
+ /** Headline figures above the table (hours, earnings, entries...), pre-formatted. */
23
+ totalsBar: { label: string; value: React.ReactNode }[]
24
+ /** Filters and search, wired by the caller. */
25
+ toolbar?: React.ReactNode
26
+ /** Export buttons and the like. */
27
+ actions?: React.ReactNode
28
+ /** A chart of `rows`; a slot so this file never pulls in a chart library. */
29
+ chart?: React.ReactNode
30
+ loading?: boolean
31
+ emptyMessage?: string
32
+ className?: string
33
+ }
34
+
35
+ /**
36
+ * A report grouped by period: a toolbar, a totals bar, a chart slot and one
37
+ * table row per period plus a totals row. Presentational - the caller owns the
38
+ * data, the filters and the export, so the screen and the file are built from
39
+ * the same rows.
40
+ */
41
+ export function PeriodReport<R extends { key: string }>({
42
+ groupBy,
43
+ groupOptions,
44
+ onGroupByChange,
45
+ rows,
46
+ totals,
47
+ columns,
48
+ totalsBar,
49
+ toolbar,
50
+ actions,
51
+ chart,
52
+ loading,
53
+ emptyMessage = 'No entries match these filters.',
54
+ className,
55
+ }: PeriodReportProps<R>) {
56
+ const cell = (c: PeriodReportColumn<R>) =>
57
+ cn('px-3 py-2 text-sm', c.numeric ? 'text-right tabular-nums' : 'text-left')
58
+
59
+ return (
60
+ <div className={cn('space-y-4', className)}>
61
+ <div className="flex flex-wrap items-center gap-3">
62
+ <PeriodSelector periods={groupOptions} selected={groupBy} onChange={onGroupByChange} />
63
+ {toolbar}
64
+ {actions ? <div className="ml-auto flex items-center gap-2">{actions}</div> : null}
65
+ </div>
66
+
67
+ <div data-testid="report-totals" className="grid grid-cols-2 gap-3 sm:grid-cols-4">
68
+ {totalsBar.map((t) => (
69
+ <div key={t.label} className="rounded-lg border border-gray-200 bg-white p-3">
70
+ <div className="text-xs uppercase tracking-wide text-gray-500">{t.label}</div>
71
+ <div className="mt-1 text-2xl font-semibold tabular-nums text-gray-900">{t.value}</div>
72
+ </div>
73
+ ))}
74
+ </div>
75
+
76
+ {loading ? (
77
+ <p className="text-sm text-gray-500" role="status">
78
+ Loading report
79
+ </p>
80
+ ) : (
81
+ <>
82
+ {chart && rows.length > 0 ? <div>{chart}</div> : null}
83
+ {rows.length === 0 ? (
84
+ <p className="text-sm text-gray-500">{emptyMessage}</p>
85
+ ) : (
86
+ <div className="overflow-auto rounded-lg border border-gray-200 bg-white">
87
+ <table className="w-full">
88
+ <thead className="bg-gray-50">
89
+ <tr>
90
+ {columns.map((c) => (
91
+ <th key={c.key} className={cn(cell(c), 'font-medium text-gray-600')}>
92
+ {c.label}
93
+ </th>
94
+ ))}
95
+ </tr>
96
+ </thead>
97
+ <tbody>
98
+ {rows.map((r) => (
99
+ <tr key={r.key} className="border-t border-gray-100">
100
+ {columns.map((c) => (
101
+ <td key={c.key} className={cell(c)}>
102
+ {c.render(r)}
103
+ </td>
104
+ ))}
105
+ </tr>
106
+ ))}
107
+ </tbody>
108
+ <tfoot>
109
+ <tr data-testid="report-total-row" className="border-t-2 border-gray-300 bg-gray-50 font-semibold">
110
+ {columns.map((c, i) => (
111
+ <td key={c.key} className={cell(c)}>
112
+ {i === 0 ? 'Total' : c.render(totals)}
113
+ </td>
114
+ ))}
115
+ </tr>
116
+ </tfoot>
117
+ </table>
118
+ </div>
119
+ )}
120
+ </>
121
+ )}
122
+ </div>
123
+ )
124
+ }
@@ -0,0 +1,75 @@
1
+ import { render, screen, fireEvent, within } from '@testing-library/react'
2
+ import { PeriodReport, type PeriodReportColumn } from '../PeriodReport'
3
+ import { ExportButtons } from '../ExportButtons'
4
+
5
+ interface Row { key: string; entries: number; hours: number }
6
+ const rows: Row[] = [
7
+ { key: '2026-07', entries: 3, hours: 4.25 },
8
+ { key: '2026-08', entries: 1, hours: 1 },
9
+ ]
10
+ const totals: Row = { key: 'total', entries: 4, hours: 5.25 }
11
+ const columns: PeriodReportColumn<Row>[] = [
12
+ { key: 'key', label: 'Period', render: (r) => r.key },
13
+ { key: 'entries', label: 'Entries', numeric: true, render: (r) => String(r.entries) },
14
+ { key: 'hours', label: 'Hours', numeric: true, render: (r) => r.hours.toFixed(2) },
15
+ ]
16
+ const base = {
17
+ groupBy: 'month',
18
+ groupOptions: [
19
+ { value: 'day', label: 'Day' },
20
+ { value: 'month', label: 'Month' },
21
+ ],
22
+ onGroupByChange: () => {},
23
+ rows,
24
+ totals,
25
+ columns,
26
+ totalsBar: [{ label: 'Hours', value: '5.25' }, { label: 'Entries', value: '4' }],
27
+ }
28
+
29
+ describe('PeriodReport', () => {
30
+ it('shows a totals bar, one row per period and a totals row', () => {
31
+ render(<PeriodReport {...base} />)
32
+ expect(screen.getByTestId('report-totals')).toHaveTextContent('5.25')
33
+ expect(screen.getAllByRole('row')).toHaveLength(1 + 2 + 1)
34
+ const foot = screen.getByTestId('report-total-row')
35
+ expect(within(foot).getByText('5.25')).toBeInTheDocument()
36
+ })
37
+
38
+ it('reports a grouping change', () => {
39
+ const onGroupByChange = jest.fn()
40
+ render(<PeriodReport {...base} onGroupByChange={onGroupByChange} />)
41
+ fireEvent.click(screen.getByRole('button', { name: 'Day' }))
42
+ expect(onGroupByChange).toHaveBeenCalledWith('day')
43
+ expect(screen.getByRole('button', { name: 'Month' })).toHaveAttribute('aria-pressed', 'true')
44
+ })
45
+
46
+ it('renders toolbar, actions and chart slots, and an empty state', () => {
47
+ const { rerender } = render(
48
+ <PeriodReport {...base} toolbar={<span>filters</span>} actions={<span>export</span>} chart={<div>chart</div>} />,
49
+ )
50
+ expect(screen.getByText('filters')).toBeInTheDocument()
51
+ expect(screen.getByText('export')).toBeInTheDocument()
52
+ expect(screen.getByText('chart')).toBeInTheDocument()
53
+ rerender(<PeriodReport {...base} rows={[]} chart={<div>chart</div>} emptyMessage="Nothing here" />)
54
+ expect(screen.getByText('Nothing here')).toBeInTheDocument()
55
+ expect(screen.queryByText('chart')).toBeNull()
56
+ })
57
+
58
+ it('shows a loading state instead of stale rows', () => {
59
+ render(<PeriodReport {...base} loading />)
60
+ expect(screen.getByText('Loading report')).toBeInTheDocument()
61
+ expect(screen.queryByTestId('report-total-row')).toBeNull()
62
+ })
63
+ })
64
+
65
+ describe('ExportButtons', () => {
66
+ it('calls back with the chosen format and disables while busy', () => {
67
+ const onExport = jest.fn()
68
+ const { rerender } = render(<ExportButtons onExport={onExport} />)
69
+ fireEvent.click(screen.getByRole('button', { name: /CSV/ }))
70
+ fireEvent.click(screen.getByRole('button', { name: /Excel/ }))
71
+ expect(onExport.mock.calls).toEqual([['csv'], ['xlsx']])
72
+ rerender(<ExportButtons onExport={onExport} busy />)
73
+ expect(screen.getByRole('button', { name: /CSV/ })).toBeDisabled()
74
+ })
75
+ })
@@ -0,0 +1,4 @@
1
+ export { PeriodReport } from './PeriodReport'
2
+ export type { PeriodReportProps, PeriodReportColumn } from './PeriodReport'
3
+ export { ExportButtons } from './ExportButtons'
4
+ export type { ExportButtonsProps, ReportExportFormat } from './ExportButtons'
@@ -0,0 +1,43 @@
1
+ 'use client'
2
+
3
+ /** Presets, all `#rrggbb` so they satisfy a 6-digit-hex colour attribute. */
4
+ export const PROJECT_COLORS = [
5
+ '#3366ff',
6
+ '#0ea5e9',
7
+ '#14b8a6',
8
+ '#22c55e',
9
+ '#eab308',
10
+ '#f97316',
11
+ '#e11d48',
12
+ '#a855f7',
13
+ '#64748b',
14
+ ] as const
15
+
16
+ export interface ColorPickerProps {
17
+ value: string | null | undefined
18
+ onChange: (color: string) => void
19
+ colors?: readonly string[]
20
+ label?: string
21
+ }
22
+
23
+ export function ColorPicker({ value, onChange, colors = PROJECT_COLORS, label = 'Colour' }: ColorPickerProps) {
24
+ return (
25
+ <div role="radiogroup" aria-label={label} className="flex flex-wrap gap-2">
26
+ {colors.map((c) => {
27
+ const checked = value?.toLowerCase() === c.toLowerCase()
28
+ return (
29
+ <button
30
+ key={c}
31
+ type="button"
32
+ role="radio"
33
+ aria-checked={checked}
34
+ aria-label={c}
35
+ onClick={() => onChange(c)}
36
+ className={`h-6 w-6 rounded-full border-2 ${checked ? 'border-gray-900' : 'border-transparent'} focus:outline-none focus:ring-2 focus:ring-indigo-500`}
37
+ style={{ backgroundColor: c }}
38
+ />
39
+ )
40
+ })}
41
+ </div>
42
+ )
43
+ }
@@ -0,0 +1,104 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { BaseDialog } from '../dialog/BaseDialog'
5
+ import { Button } from '../ui/button'
6
+ import { fromLocalInput, toLocalInput, validateRange, type EntryId, type NewEntry } from './interval-logic'
7
+ import { ProjectSelect, type ProjectOption } from './ProjectSelect'
8
+
9
+ export interface EntryFormValue {
10
+ startedAt: string
11
+ endedAt: string | null
12
+ projectId: EntryId | null
13
+ note: string
14
+ }
15
+
16
+ export interface EntryFormDialogProps {
17
+ open: boolean
18
+ mode: 'add' | 'edit'
19
+ projects: ProjectOption[]
20
+ busy: boolean
21
+ initial?: Partial<EntryFormValue>
22
+ onSubmit: (value: EntryFormValue & NewEntry) => void
23
+ onDelete?: () => void
24
+ onClose: () => void
25
+ }
26
+
27
+ /** Manual entry and edit share one form. Times are local datetimes, stored as UTC ISO. */
28
+ export function EntryFormDialog(p: EntryFormDialogProps) {
29
+ const [start, setStart] = useState('')
30
+ const [end, setEnd] = useState('')
31
+ const [projectId, setProjectId] = useState<EntryId | null>(null)
32
+ const [note, setNote] = useState('')
33
+ const [problem, setProblem] = useState<string | null>(null)
34
+
35
+ useEffect(() => {
36
+ if (!p.open) return
37
+ setStart(p.initial?.startedAt ? toLocalInput(p.initial.startedAt) : '')
38
+ setEnd(p.initial?.endedAt ? toLocalInput(p.initial.endedAt) : '')
39
+ setProjectId(p.initial?.projectId ?? null)
40
+ setNote(p.initial?.note ?? '')
41
+ setProblem(null)
42
+ // eslint-disable-next-line react-hooks/exhaustive-deps
43
+ }, [p.open])
44
+
45
+ function submit(e: React.FormEvent) {
46
+ e.preventDefault()
47
+ const startedAt = fromLocalInput(start)
48
+ const endedAt = fromLocalInput(end)
49
+ const bad = validateRange(startedAt, endedAt)
50
+ if (bad) return setProblem(bad)
51
+ if (p.mode === 'add' && !endedAt) return setProblem('Enter an end time, or use the timer.')
52
+ setProblem(null)
53
+ p.onSubmit({ startedAt: startedAt!, endedAt, projectId, note })
54
+ }
55
+
56
+ const field = 'h-9 w-full rounded-md border border-gray-300 px-2 text-sm'
57
+ return (
58
+ <BaseDialog open={p.open} onOpenChange={(o) => !o && p.onClose()} size="md" loading={p.busy}>
59
+ <form onSubmit={submit}>
60
+ <BaseDialog.Header>
61
+ <BaseDialog.Title>{p.mode === 'add' ? 'Add time' : 'Edit entry'}</BaseDialog.Title>
62
+ </BaseDialog.Header>
63
+ <BaseDialog.Body>
64
+ <div className="space-y-3">
65
+ <label className="block text-sm">
66
+ <span className="mb-1 block text-gray-700">Start</span>
67
+ <input aria-label="Start" type="datetime-local" value={start} onChange={(e) => setStart(e.target.value)} className={field} />
68
+ </label>
69
+ <label className="block text-sm">
70
+ <span className="mb-1 block text-gray-700">End</span>
71
+ <input aria-label="End" type="datetime-local" value={end} onChange={(e) => setEnd(e.target.value)} className={field} />
72
+ </label>
73
+ <div className="text-sm">
74
+ <span className="mb-1 block text-gray-700">Project</span>
75
+ <ProjectSelect projects={p.projects} value={projectId} onChange={setProjectId} />
76
+ </div>
77
+ <label className="block text-sm">
78
+ <span className="mb-1 block text-gray-700">Note</span>
79
+ <input aria-label="Note" value={note} onChange={(e) => setNote(e.target.value)} className={field} />
80
+ </label>
81
+ {problem ? (
82
+ <p role="alert" className="text-sm text-red-600">
83
+ {problem}
84
+ </p>
85
+ ) : null}
86
+ </div>
87
+ </BaseDialog.Body>
88
+ <BaseDialog.Footer>
89
+ {p.mode === 'edit' && p.onDelete ? (
90
+ <Button type="button" variant="outline" disabled={p.busy} onClick={p.onDelete}>
91
+ Delete
92
+ </Button>
93
+ ) : null}
94
+ <Button type="button" variant="outline" disabled={p.busy} onClick={p.onClose}>
95
+ Cancel
96
+ </Button>
97
+ <Button type="submit" disabled={p.busy}>
98
+ {p.mode === 'add' ? 'Add entry' : 'Save'}
99
+ </Button>
100
+ </BaseDialog.Footer>
101
+ </form>
102
+ </BaseDialog>
103
+ )
104
+ }
@@ -0,0 +1,42 @@
1
+ 'use client'
2
+
3
+ import { elapsedMs, formatElapsed, type TimedEntry } from './interval-logic'
4
+ import { ProjectSwatch } from './ProjectSwatch'
5
+ import type { ProjectOption } from './ProjectSelect'
6
+
7
+ export interface EntryListProps {
8
+ entries: TimedEntry[]
9
+ projects: ProjectOption[]
10
+ onEdit: (entry: TimedEntry) => void
11
+ }
12
+
13
+ const clock = (iso: string) => new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
14
+
15
+ /** Closed intervals, newest first as given. Duration is end minus start; rounding is a report concern. */
16
+ export function EntryList({ entries, projects, onEdit }: EntryListProps) {
17
+ if (entries.length === 0) return <p className="text-sm text-gray-500">No entries yet.</p>
18
+ return (
19
+ <ul className="divide-y divide-gray-100 rounded-lg border border-gray-200 bg-white">
20
+ {entries.map((e) => {
21
+ const project = projects.find((p) => String(p.id) === String(e.projectId ?? ''))
22
+ const end = e.endedAt ? new Date(e.endedAt).getTime() : Date.now()
23
+ return (
24
+ <li key={String(e.id)} className="flex items-center gap-3 px-4 py-2 text-sm">
25
+ <ProjectSwatch color={project?.color} />
26
+ <span className="w-32 truncate font-medium text-gray-900">{project?.name ?? 'No project'}</span>
27
+ <span className="flex-1 truncate text-gray-600">{e.note}</span>
28
+ <span className="text-gray-500">
29
+ {clock(e.startedAt)} to {e.endedAt ? clock(e.endedAt) : 'now'}
30
+ </span>
31
+ <span className="w-16 text-right font-mono tabular-nums text-gray-900">
32
+ {formatElapsed(end - new Date(e.startedAt).getTime())}
33
+ </span>
34
+ <button type="button" className="text-indigo-600 hover:underline" aria-label={`Edit entry ${e.note || ''}`.trim()} onClick={() => onEdit(e)}>
35
+ Edit
36
+ </button>
37
+ </li>
38
+ )
39
+ })}
40
+ </ul>
41
+ )
42
+ }
@@ -0,0 +1,65 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { BaseDialog } from '../dialog/BaseDialog'
5
+ import { Button } from '../ui/button'
6
+ import type { EntryId } from './interval-logic'
7
+ import { ProjectSelect, type ProjectOption } from './ProjectSelect'
8
+
9
+ export interface IdlePromptDialogProps {
10
+ open: boolean
11
+ seconds: number
12
+ projects: ProjectOption[]
13
+ currentProjectId: EntryId | null
14
+ busy: boolean
15
+ onKeep: () => void
16
+ onDiscard: () => void
17
+ onAssign: (projectId: EntryId) => void
18
+ }
19
+
20
+ export function formatIdle(seconds: number): string {
21
+ const min = Math.max(1, Math.round(seconds / 60))
22
+ if (min < 60) return `${min} min`
23
+ const h = Math.floor(min / 60)
24
+ const m = min % 60
25
+ return m === 0 ? `${h} h` : `${h} h ${m} min`
26
+ }
27
+
28
+ /** On return from idle: keep the time, discard it, or book it to another project. */
29
+ export function IdlePromptDialog(p: IdlePromptDialogProps) {
30
+ const other = p.projects.find((x) => String(x.id) !== String(p.currentProjectId ?? ''))
31
+ const [target, setTarget] = useState<EntryId | null>(null)
32
+ const chosen = target ?? other?.id ?? null
33
+ return (
34
+ <BaseDialog open={p.open} onOpenChange={(o) => !o && !p.busy && p.onKeep()} size="md" loading={p.busy}>
35
+ <BaseDialog.Header>
36
+ <BaseDialog.Title>You were away</BaseDialog.Title>
37
+ </BaseDialog.Header>
38
+ <BaseDialog.Body>
39
+ <p className="text-sm text-gray-700">
40
+ The timer kept running while you were idle for {formatIdle(p.seconds)}. What should happen to that time?
41
+ </p>
42
+ <div className="mt-4 flex items-center gap-2">
43
+ <ProjectSelect
44
+ label="Assign to"
45
+ projects={p.projects.filter((x) => String(x.id) !== String(p.currentProjectId ?? ''))}
46
+ value={chosen}
47
+ onChange={setTarget}
48
+ noneLabel="Choose a project"
49
+ />
50
+ <Button variant="outline" disabled={p.busy || chosen === null} onClick={() => chosen !== null && p.onAssign(chosen)}>
51
+ Assign
52
+ </Button>
53
+ </div>
54
+ </BaseDialog.Body>
55
+ <BaseDialog.Footer>
56
+ <Button variant="outline" disabled={p.busy} onClick={p.onDiscard}>
57
+ Discard
58
+ </Button>
59
+ <Button disabled={p.busy} onClick={p.onKeep}>
60
+ Keep
61
+ </Button>
62
+ </BaseDialog.Footer>
63
+ </BaseDialog>
64
+ )
65
+ }
@@ -0,0 +1,52 @@
1
+ 'use client'
2
+
3
+ import { ProjectSwatch } from './ProjectSwatch'
4
+ import type { EntryId } from './interval-logic'
5
+
6
+ export interface ProjectOption {
7
+ id: EntryId
8
+ name: string
9
+ color?: string | null
10
+ /** The client this project bills to, when the host models clients. */
11
+ clientId?: EntryId | null
12
+ /** Per-hour rate new time on this project defaults to. */
13
+ defaultRate?: number | null
14
+ /** Store-owned passthrough (the record's other attributes), so a whole-record update can send them back. */
15
+ extra?: Record<string, unknown>
16
+ }
17
+
18
+ export interface ProjectSelectProps {
19
+ projects: ProjectOption[]
20
+ value: EntryId | null | undefined
21
+ onChange: (id: EntryId | null) => void
22
+ label?: string
23
+ id?: string
24
+ noneLabel?: string
25
+ }
26
+
27
+ /** A native select (reliable in every shell) with the chosen project's colour beside it. */
28
+ export function ProjectSelect({ projects, value, onChange, label = 'Project', id, noneLabel = 'No project' }: ProjectSelectProps) {
29
+ const chosen = projects.find((p) => String(p.id) === String(value ?? ''))
30
+ return (
31
+ <span className="inline-flex items-center gap-2">
32
+ <ProjectSwatch color={chosen?.color} />
33
+ <select
34
+ id={id}
35
+ aria-label={label}
36
+ value={value == null ? '' : String(value)}
37
+ onChange={(e) => {
38
+ const v = e.target.value
39
+ onChange(v === '' ? null : (projects.find((p) => String(p.id) === v)?.id ?? v))
40
+ }}
41
+ className="h-9 rounded-md border border-gray-300 bg-white px-2 text-sm"
42
+ >
43
+ <option value="">{noneLabel}</option>
44
+ {projects.map((p) => (
45
+ <option key={String(p.id)} value={String(p.id)}>
46
+ {p.name}
47
+ </option>
48
+ ))}
49
+ </select>
50
+ </span>
51
+ )
52
+ }
@@ -0,0 +1,15 @@
1
+ 'use client'
2
+
3
+ export const NO_COLOR = '#9ca3af'
4
+
5
+ /** A project's colour as a dot. Decorative: the project name always sits beside it. */
6
+ export function ProjectSwatch({ color, size = 10 }: { color?: string | null; size?: number }) {
7
+ return (
8
+ <span
9
+ data-testid="swatch"
10
+ aria-hidden="true"
11
+ className="inline-block shrink-0 rounded-full"
12
+ style={{ backgroundColor: color || NO_COLOR, width: size, height: size }}
13
+ />
14
+ )
15
+ }