@startsimpli/ui 0.4.168 → 0.4.170
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/package.json +8 -7
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.170",
|
|
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.
|
|
70
|
-
"@startsimpli/
|
|
71
|
-
"@startsimpli/
|
|
70
|
+
"@startsimpli/auth": "^0.4.49",
|
|
71
|
+
"@startsimpli/i18n": "^0.1.3",
|
|
72
|
+
"@startsimpli/api": "^0.5.69"
|
|
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.
|
|
167
|
-
"@startsimpli/i18n": "0.1.
|
|
167
|
+
"@startsimpli/realtime": "0.0.2",
|
|
168
|
+
"@startsimpli/i18n": "0.1.3"
|
|
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,
|
package/src/components/index.ts
CHANGED
|
@@ -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,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
|
+
}
|