@startsimpli/ui 0.4.167 → 0.4.169
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +4 -2
- package/package.json +17 -5
- package/src/components/charts/PeriodBars.tsx +36 -0
- package/src/components/charts/index.ts +2 -0
- package/src/components/index.ts +3 -0
- package/src/components/reports/ExportButtons.tsx +28 -0
- package/src/components/reports/PeriodReport.tsx +124 -0
- package/src/components/reports/__tests__/PeriodReport.test.tsx +75 -0
- package/src/components/reports/index.ts +4 -0
- package/src/components/timer/ColorPicker.tsx +43 -0
- package/src/components/timer/EntryFormDialog.tsx +104 -0
- package/src/components/timer/EntryList.tsx +42 -0
- package/src/components/timer/IdlePromptDialog.tsx +65 -0
- package/src/components/timer/ProjectSelect.tsx +52 -0
- package/src/components/timer/ProjectSwatch.tsx +15 -0
- package/src/components/timer/ProjectsDialog.tsx +221 -0
- package/src/components/timer/TimerBar.tsx +74 -0
- package/src/components/timer/__tests__/interval-logic.test.ts +121 -0
- package/src/components/timer/__tests__/projects-dialog.test.tsx +113 -0
- package/src/components/timer/__tests__/timer-components.test.tsx +147 -0
- package/src/components/timer/__tests__/useIdleReturn.test.tsx +59 -0
- package/src/components/timer/__tests__/useRunningTimer.test.tsx +167 -0
- package/src/components/timer/index.ts +20 -0
- package/src/components/timer/interval-logic.ts +140 -0
- package/src/components/timer/useIdleReturn.ts +49 -0
- package/src/components/timer/useRunningTimer.ts +209 -0
- package/src/utils/__tests__/xlsx.test.ts +36 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/xlsx.ts +35 -0
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
|
-
##
|
|
594
|
+
## Links
|
|
595
595
|
|
|
596
|
-
|
|
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.
|
|
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,
|
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
|
+
}
|