@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 StartSimpli
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -591,6 +591,8 @@ widget to an app's `src/`, ask whether another app could plausibly want it.
591
591
  If yes — or if you're unsure — it goes here. See the monorepo
592
592
  `CLAUDE.md` rule 9 and `.claude/docs/conventions.md#shared-packages-policy`.
593
593
 
594
- ## License
594
+ ## Links
595
595
 
596
- Proprietary — StartSimpli.
596
+ - Source: https://github.com/qosha1/start-simpli/tree/main/packages/ui
597
+ - Homepage: https://startsimpli.com
598
+ - License: MIT, see [LICENSE](./LICENSE)
package/package.json CHANGED
@@ -1,7 +1,17 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.167",
3
+ "version": "0.4.169",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
+ "license": "MIT",
6
+ "homepage": "https://startsimpli.com",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/qosha1/start-simpli.git",
10
+ "directory": "packages/ui"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/qosha1/start-simpli/issues"
14
+ },
5
15
  "main": "./src/index.ts",
6
16
  "types": "./src/index.ts",
7
17
  "exports": {
@@ -34,12 +44,14 @@
34
44
  "./session": "./src/components/session/index.ts",
35
45
  "./session-console": "./src/components/session/AgentSessionConsole.tsx",
36
46
  "./tailwind": "./tailwind.preset.js",
37
- "./track-changes": "./src/components/track-changes/index.ts"
47
+ "./track-changes": "./src/components/track-changes/index.ts",
48
+ "./timer": "./src/components/timer/index.ts"
38
49
  },
39
50
  "files": [
40
51
  "src",
41
52
  "tailwind.preset.js",
42
- "README.md"
53
+ "README.md",
54
+ "LICENSE"
43
55
  ],
44
56
  "publishConfig": {
45
57
  "access": "public"
@@ -55,9 +67,9 @@
55
67
  "@codemirror/language": "^6.0.0",
56
68
  "@codemirror/state": "^6.0.0",
57
69
  "@codemirror/view": "^6.0.0",
58
- "@startsimpli/i18n": "^0.1.3",
59
70
  "@startsimpli/auth": "^0.4.47",
60
- "@startsimpli/api": "^0.5.69"
71
+ "@startsimpli/api": "^0.5.69",
72
+ "@startsimpli/i18n": "^0.1.3"
61
73
  },
62
74
  "peerDependenciesMeta": {
63
75
  "@startsimpli/api": {
@@ -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
+ }