@startsimpli/ui 0.4.137 → 0.4.139

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.137",
3
+ "version": "0.4.139",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -11,6 +11,7 @@
11
11
  "./foundry": "./src/foundry/index.ts",
12
12
  "./gantt": "./src/components/gantt/index.ts",
13
13
  "./gantt/styles": "./src/components/gantt/gantt.css",
14
+ "./charts": "./src/components/charts/index.ts",
14
15
  "./components": "./src/components/index.ts",
15
16
  "./error": "./src/components/error/index.ts",
16
17
  "./i18n": "./src/components/i18n/index.ts",
@@ -48,9 +49,9 @@
48
49
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
49
50
  "react": "^18.0.0 || ^19.0.0",
50
51
  "react-dom": "^18.0.0 || ^19.0.0",
52
+ "@startsimpli/api": "^0.5.66",
51
53
  "@startsimpli/auth": "^0.4.38",
52
- "@startsimpli/i18n": "^0.1.3",
53
- "@startsimpli/api": "^0.5.66"
54
+ "@startsimpli/i18n": "^0.1.3"
54
55
  },
55
56
  "peerDependenciesMeta": {
56
57
  "@startsimpli/api": {
@@ -101,6 +102,7 @@
101
102
  "react-day-picker": "^9.14.0",
102
103
  "react-markdown": "^10.1.0",
103
104
  "react-syntax-highlighter": "^16.1.0",
105
+ "recharts": "^3.10.1",
104
106
  "remark-gfm": "^4.0.1",
105
107
  "tailwind-merge": "^2.6.1",
106
108
  "tailwindcss-animate": "^1.0.7",
@@ -124,8 +126,8 @@
124
126
  "tailwindcss": "^3.4.19",
125
127
  "tsup": "^8.5.1",
126
128
  "typescript": "^6.0.3",
127
- "@startsimpli/i18n": "0.1.3",
128
- "@startsimpli/realtime": "0.0.2"
129
+ "@startsimpli/realtime": "0.0.2",
130
+ "@startsimpli/i18n": "0.1.3"
129
131
  },
130
132
  "keywords": [
131
133
  "ui",
@@ -0,0 +1,305 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import {
5
+ CartesianGrid,
6
+ Line,
7
+ LineChart,
8
+ ReferenceLine,
9
+ ResponsiveContainer,
10
+ Tooltip,
11
+ XAxis,
12
+ YAxis,
13
+ } from 'recharts'
14
+ import { cn } from '../../lib/utils'
15
+ import {
16
+ buildChartModel,
17
+ valueKey,
18
+ type ChartModel,
19
+ type ChartRow,
20
+ type TimeSeries,
21
+ type TimeSeriesMode,
22
+ type TimeSeriesUnit,
23
+ } from './lib/series'
24
+ import {
25
+ axisValueFormatter,
26
+ formatTick,
27
+ formatTooltipTime,
28
+ formatValue,
29
+ isDateOnly,
30
+ timeTicks,
31
+ valueTicks,
32
+ } from './lib/format'
33
+
34
+ export interface TimeSeriesReferenceLine {
35
+ /** Where the line sits, in the plotted unit (a drawdown % in drawdown mode). */
36
+ y: number
37
+ label?: string
38
+ }
39
+
40
+ export interface TimeSeriesChartProps {
41
+ series: TimeSeries[]
42
+ unit: TimeSeriesUnit
43
+ referenceLine?: TimeSeriesReferenceLine
44
+ /** `'drawdown'` plots each series as the % below its running peak. */
45
+ mode?: TimeSeriesMode
46
+ /** Plot height in px. The legend, when there is one, sits below it. */
47
+ height?: number
48
+ /** Overrides the generated description. */
49
+ ariaLabel?: string
50
+ className?: string
51
+ }
52
+
53
+ /**
54
+ * Series colours, in order, from the theme contract (theme/contract.css) — so
55
+ * they follow the app's brand and flip with `.dark` like every other surface.
56
+ * `--success` carries the same fallback channels as the Tailwind preset, for
57
+ * apps that predate the token.
58
+ */
59
+ export const SERIES_COLORS = [
60
+ 'hsl(var(--primary))',
61
+ 'hsl(var(--muted-foreground))',
62
+ 'hsl(var(--success, 142.1 76.2% 36.3%))',
63
+ 'hsl(var(--destructive))',
64
+ 'hsl(var(--foreground))',
65
+ ] as const
66
+
67
+ /** After the palette runs out, series repeat its colours with a dash. */
68
+ const DASHES = [undefined, '6 4', '2 3'] as const
69
+
70
+ export function seriesStroke(index: number): { color: string; dash: string | undefined } {
71
+ const n = SERIES_COLORS.length
72
+ return {
73
+ color: SERIES_COLORS[index % n],
74
+ dash: DASHES[Math.floor(index / n) % DASHES.length],
75
+ }
76
+ }
77
+
78
+ const AXIS_TICK = { fill: 'hsl(var(--muted-foreground))', fontSize: 12 }
79
+ const GRID_STROKE = 'hsl(var(--border))'
80
+
81
+ function describe(model: ChartModel, mode: TimeSeriesMode, utc: boolean): string {
82
+ const kind = mode === 'drawdown' ? 'Drawdown chart' : 'Line chart'
83
+ if (!model.hasData) return `${kind}: no data`
84
+ const first = formatTooltipTime(model.times[0], utc, false)
85
+ const last = formatTooltipTime(model.times[model.times.length - 1], utc, false)
86
+ return `${kind} of ${model.names.join(', ')} from ${first} to ${last}`
87
+ }
88
+
89
+ interface TooltipBodyProps {
90
+ active?: boolean
91
+ label?: number | string
92
+ rowsByTime: Map<number, ChartRow>
93
+ names: string[]
94
+ unit: TimeSeriesUnit
95
+ utc: boolean
96
+ withTime: boolean
97
+ }
98
+
99
+ function TooltipBody({ active, label, rowsByTime, names, unit, utc, withTime }: TooltipBodyProps) {
100
+ if (!active || label === undefined) return null
101
+ const t = Number(label)
102
+ const row = rowsByTime.get(t)
103
+ if (!row) return null
104
+
105
+ // Every series with a sample here — a gap reads as a dash, a series with no
106
+ // sample at this instant is left out rather than shown as missing.
107
+ const entries = names
108
+ .map((name, i) => ({ name, i, v: row[valueKey(i)] }))
109
+ .filter((e) => e.v !== undefined)
110
+
111
+ return (
112
+ <div className="rounded-md border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md">
113
+ <div className="mb-1 font-medium">{formatTooltipTime(t, utc, withTime)}</div>
114
+ <ul className="space-y-0.5">
115
+ {entries.map((e) => (
116
+ <li key={e.i} className="flex items-center justify-between gap-4">
117
+ <span className="flex min-w-0 items-center gap-1.5">
118
+ <span
119
+ className="h-2 w-2 shrink-0 rounded-full"
120
+ style={{ backgroundColor: seriesStroke(e.i).color }}
121
+ aria-hidden="true"
122
+ />
123
+ <span className="truncate text-muted-foreground">{e.name}</span>
124
+ </span>
125
+ <span className="font-medium tabular-nums">
126
+ {typeof e.v === 'number' ? formatValue(e.v, unit) : '—'}
127
+ </span>
128
+ </li>
129
+ ))}
130
+ </ul>
131
+ </div>
132
+ )
133
+ }
134
+
135
+ function SeriesLegend({ names }: { names: string[] }) {
136
+ return (
137
+ <ul className="flex flex-wrap items-center gap-x-4 gap-y-1 px-2 pt-2 text-xs text-muted-foreground">
138
+ {names.map((name, i) => {
139
+ const { color, dash } = seriesStroke(i)
140
+ return (
141
+ <li key={`${name}-${i}`} className="flex items-center gap-1.5">
142
+ <svg width="16" height="8" aria-hidden="true" className="shrink-0">
143
+ <line x1="0" y1="4" x2="16" y2="4" stroke={color} strokeWidth="2" strokeDasharray={dash} />
144
+ </svg>
145
+ <span>{name}</span>
146
+ </li>
147
+ )
148
+ })}
149
+ </ul>
150
+ )
151
+ }
152
+
153
+ /**
154
+ * One or more time series on a date axis. Values are formatted for `unit`;
155
+ * `null` points break the line instead of being joined across. With
156
+ * `mode="drawdown"` every series is shown as the % below its own running peak.
157
+ * Colours come from the theme tokens, so it follows light/dark with the app.
158
+ */
159
+ export function TimeSeriesChart({
160
+ series,
161
+ unit,
162
+ referenceLine,
163
+ mode = 'line',
164
+ height = 300,
165
+ ariaLabel,
166
+ className,
167
+ }: TimeSeriesChartProps) {
168
+ const model = React.useMemo(() => buildChartModel(series, mode, unit), [series, mode, unit])
169
+ const rowsByTime = React.useMemo(
170
+ () => new Map(model.rows.map((r) => [r.t, r] as const)),
171
+ [model],
172
+ )
173
+ const utc = isDateOnly(model.times)
174
+ const withTime = !utc && model.times.some((t) => {
175
+ const d = new Date(t)
176
+ return d.getHours() !== 0 || d.getMinutes() !== 0
177
+ })
178
+ const minT = model.times[0]
179
+ const maxT = model.times[model.times.length - 1]
180
+ const { ticks, step } = React.useMemo(
181
+ () => timeTicks(minT, maxT, utc),
182
+ [minT, maxT, utc],
183
+ )
184
+ const refY = referenceLine?.y
185
+ const yAxis = React.useMemo(() => {
186
+ let lo = model.min
187
+ let hi = model.max
188
+ if (typeof refY === 'number' && Number.isFinite(refY)) {
189
+ lo = Math.min(lo, refY)
190
+ hi = Math.max(hi, refY)
191
+ }
192
+ // A drawdown axis always tops out at 0 — "at the peak".
193
+ if (mode === 'drawdown') hi = 0
194
+ const { ticks: yTicks, step: yStep } = valueTicks(lo, hi)
195
+ return { ticks: yTicks, format: axisValueFormatter(model.unit, yTicks, yStep) }
196
+ }, [model, refY, mode])
197
+ const showLegend = series.length >= 2
198
+ const safeHeight = Math.max(height, 80)
199
+ const label = ariaLabel ?? describe(model, mode, utc)
200
+
201
+ if (!model.hasData) {
202
+ return (
203
+ <div
204
+ role="img"
205
+ aria-label={label}
206
+ className={cn(
207
+ 'flex w-full items-center justify-center rounded-md border border-dashed text-sm text-muted-foreground',
208
+ className,
209
+ )}
210
+ style={{ height: safeHeight }}
211
+ >
212
+ No data
213
+ </div>
214
+ )
215
+ }
216
+
217
+ return (
218
+ <div role="figure" aria-label={label} className={cn('w-full', className)}>
219
+ <ResponsiveContainer width="100%" height={safeHeight}>
220
+ <LineChart data={model.rows} margin={{ top: 8, right: 16, bottom: 0, left: 0 }}>
221
+ <CartesianGrid stroke={GRID_STROKE} strokeDasharray="3 3" vertical={false} />
222
+ <XAxis
223
+ dataKey="t"
224
+ type="number"
225
+ scale="time"
226
+ domain={[minT, maxT]}
227
+ ticks={ticks}
228
+ tickFormatter={(t: number) => formatTick(t, step, utc)}
229
+ tick={AXIS_TICK}
230
+ stroke={GRID_STROKE}
231
+ tickLine={false}
232
+ minTickGap={16}
233
+ />
234
+ <YAxis
235
+ type="number"
236
+ ticks={yAxis.ticks}
237
+ domain={[yAxis.ticks[0], yAxis.ticks[yAxis.ticks.length - 1]]}
238
+ tickFormatter={(v: number) => yAxis.format(v)}
239
+ tick={AXIS_TICK}
240
+ stroke={GRID_STROKE}
241
+ tickLine={false}
242
+ axisLine={false}
243
+ width={64}
244
+ interval={0}
245
+ />
246
+ <Tooltip
247
+ cursor={{ stroke: GRID_STROKE }}
248
+ isAnimationActive={false}
249
+ content={(props) => (
250
+ <TooltipBody
251
+ active={props.active}
252
+ label={props.label as number | string | undefined}
253
+ rowsByTime={rowsByTime}
254
+ names={model.names}
255
+ unit={model.unit}
256
+ utc={utc}
257
+ withTime={withTime}
258
+ />
259
+ )}
260
+ />
261
+ {referenceLine && (
262
+ <ReferenceLine
263
+ y={referenceLine.y}
264
+ stroke="hsl(var(--muted-foreground))"
265
+ strokeDasharray="4 4"
266
+ ifOverflow="extendDomain"
267
+ label={
268
+ referenceLine.label
269
+ ? {
270
+ value: referenceLine.label,
271
+ position: 'insideTopRight',
272
+ fill: 'hsl(var(--muted-foreground))',
273
+ fontSize: 12,
274
+ }
275
+ : undefined
276
+ }
277
+ />
278
+ )}
279
+ {model.segments.map((seg) => {
280
+ const { color, dash } = seriesStroke(seg.seriesIndex)
281
+ return (
282
+ <Line
283
+ key={seg.dataKey}
284
+ dataKey={seg.dataKey}
285
+ name={model.names[seg.seriesIndex]}
286
+ type="linear"
287
+ stroke={color}
288
+ strokeWidth={2}
289
+ strokeDasharray={dash}
290
+ // Within a run, step over instants this series has no sample
291
+ // at; runs themselves never join, which is what makes a null a gap.
292
+ connectNulls
293
+ dot={seg.single ? { r: 2.5, fill: color, stroke: color } : false}
294
+ activeDot={{ r: 3.5, fill: color, stroke: 'hsl(var(--background))' }}
295
+ isAnimationActive={false}
296
+ legendType="none"
297
+ />
298
+ )
299
+ })}
300
+ </LineChart>
301
+ </ResponsiveContainer>
302
+ {showLegend && <SeriesLegend names={model.names} />}
303
+ </div>
304
+ )
305
+ }
@@ -0,0 +1,141 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { TimeSeriesChart, SERIES_COLORS, seriesStroke } from '../TimeSeriesChart'
3
+ import type { TimeSeries } from '../lib/series'
4
+
5
+ // jsdom lays nothing out: recharts' ResponsiveContainer renders no chart at a
6
+ // 0x0 size, and every tick label would measure 0 wide. Give the container a
7
+ // real box and text a plausible one (8px a character), so recharts' own
8
+ // overlap culling behaves as it does in a browser.
9
+ function rect(width: number, height: number): DOMRect {
10
+ return { width, height, top: 0, left: 0, right: width, bottom: height, x: 0, y: 0, toJSON: () => ({}) } as DOMRect
11
+ }
12
+ let rectSpy: jest.SpyInstance
13
+ beforeAll(() => {
14
+ rectSpy = jest
15
+ .spyOn(HTMLElement.prototype, 'getBoundingClientRect')
16
+ .mockImplementation(function (this: HTMLElement) {
17
+ if (this.classList.contains('recharts-responsive-container')) return rect(800, 300)
18
+ return rect((this.textContent ?? '').length * 8, 14)
19
+ })
20
+ })
21
+ afterAll(() => rectSpy.mockRestore())
22
+
23
+ /** Tick labels on one axis; recharts splits a label into one tspan per word. */
24
+ function tickLabels(container: HTMLElement, axis: 'x' | 'y'): string[] {
25
+ return Array.from(
26
+ container.querySelectorAll(`.recharts-${axis}Axis-tick-labels .recharts-cartesian-axis-tick-value`),
27
+ ).map((n) =>
28
+ Array.from(n.querySelectorAll('tspan'))
29
+ .map((s) => s.textContent)
30
+ .join(' '),
31
+ )
32
+ }
33
+
34
+ const strategy: TimeSeries = {
35
+ name: 'Strategy',
36
+ points: [
37
+ { t: '2026-01-05', v: 100_000 },
38
+ { t: '2026-01-06', v: 104_000 },
39
+ { t: '2026-01-07', v: null },
40
+ { t: '2026-01-08', v: 98_000 },
41
+ { t: '2026-01-09', v: 110_000 },
42
+ ],
43
+ }
44
+
45
+ const holding: TimeSeries = {
46
+ name: 'Buy & hold',
47
+ points: [
48
+ { t: '2026-01-05', v: 100_000 },
49
+ { t: '2026-01-06', v: 101_000 },
50
+ { t: '2026-01-07', v: 99_000 },
51
+ { t: '2026-01-08', v: 97_000 },
52
+ { t: '2026-01-09', v: 102_000 },
53
+ ],
54
+ }
55
+
56
+ function linePaths(container: HTMLElement) {
57
+ return Array.from(container.querySelectorAll('path.recharts-line-curve'))
58
+ }
59
+
60
+ describe('TimeSeriesChart', () => {
61
+ it('draws one stroke per unbroken run, so a null leaves a gap', () => {
62
+ const { container } = render(<TimeSeriesChart series={[strategy]} unit="$" />)
63
+ // Strategy breaks at Jan 7: two runs, two strokes, nothing joining them.
64
+ expect(linePaths(container)).toHaveLength(2)
65
+ })
66
+
67
+ it('colours series from theme tokens, never a fixed palette', () => {
68
+ const { container } = render(<TimeSeriesChart series={[strategy, holding]} unit="$" />)
69
+ const strokes = linePaths(container).map((p) => p.getAttribute('stroke'))
70
+ expect(strokes.length).toBeGreaterThan(0)
71
+ for (const stroke of strokes) {
72
+ expect(stroke).toMatch(/^hsl\(var\(--[a-z-]+/)
73
+ }
74
+ expect(new Set(strokes)).toEqual(new Set([SERIES_COLORS[0], SERIES_COLORS[1]]))
75
+ })
76
+
77
+ it('shows a legend for two or more series and none for one', () => {
78
+ const { rerender } = render(<TimeSeriesChart series={[strategy, holding]} unit="$" />)
79
+ expect(screen.getByText('Strategy')).toBeInTheDocument()
80
+ expect(screen.getByText('Buy & hold')).toBeInTheDocument()
81
+
82
+ rerender(<TimeSeriesChart series={[strategy]} unit="$" />)
83
+ expect(screen.queryByText('Strategy')).not.toBeInTheDocument()
84
+ })
85
+
86
+ it('labels the date axis in calendar terms and the value axis in the unit', () => {
87
+ const { container } = render(<TimeSeriesChart series={[strategy]} unit="$" />)
88
+ expect(tickLabels(container, 'x')).toContain('Jan 5')
89
+ const y = tickLabels(container, 'y')
90
+ expect(y.length).toBeGreaterThan(1)
91
+ expect(y.every((t) => /^\$\d+(\.\d+)?K$/.test(t))).toBe(true)
92
+ })
93
+
94
+ it('draws a labelled reference line', () => {
95
+ const { container } = render(
96
+ <TimeSeriesChart
97
+ series={[strategy]}
98
+ unit="$"
99
+ referenceLine={{ y: 100_000, label: 'Start' }}
100
+ />,
101
+ )
102
+ expect(container.querySelector('.recharts-reference-line')).not.toBeNull()
103
+ expect(screen.getByText('Start')).toBeInTheDocument()
104
+ })
105
+
106
+ it('plots drawdown in % on an axis that tops out at 0', () => {
107
+ const { container } = render(
108
+ <TimeSeriesChart series={[strategy]} unit="$" mode="drawdown" />,
109
+ )
110
+ expect(screen.getByRole('figure')).toHaveAccessibleName(/^Drawdown chart of Strategy/)
111
+ const yTicks = tickLabels(container, 'y')
112
+
113
+ expect(yTicks.length).toBeGreaterThan(0)
114
+ expect(yTicks.every((t) => t.endsWith('%'))).toBe(true)
115
+ expect(yTicks).toContain('0%')
116
+ })
117
+
118
+ it('describes itself for assistive tech', () => {
119
+ render(<TimeSeriesChart series={[strategy, holding]} unit="$" />)
120
+ expect(screen.getByRole('figure')).toHaveAccessibleName(
121
+ 'Line chart of Strategy, Buy & hold from Jan 5, 2026 to Jan 9, 2026',
122
+ )
123
+ })
124
+
125
+ it('renders an empty state when there is nothing to plot', () => {
126
+ render(
127
+ <TimeSeriesChart series={[{ name: 'Empty', points: [{ t: '2026-01-05', v: null }] }]} unit="%" />,
128
+ )
129
+ expect(screen.getByRole('img')).toHaveAccessibleName('Line chart: no data')
130
+ expect(screen.getByText('No data')).toBeInTheDocument()
131
+ })
132
+ })
133
+
134
+ describe('seriesStroke', () => {
135
+ it('cycles the token palette and dashes the repeats', () => {
136
+ const n = SERIES_COLORS.length
137
+ expect(seriesStroke(0)).toEqual({ color: SERIES_COLORS[0], dash: undefined })
138
+ expect(seriesStroke(n)).toEqual({ color: SERIES_COLORS[0], dash: '6 4' })
139
+ expect(seriesStroke(n + 1).color).toBe(SERIES_COLORS[1])
140
+ })
141
+ })
@@ -0,0 +1,159 @@
1
+ import {
2
+ axisValueFormatter,
3
+ formatTick,
4
+ formatTooltipTime,
5
+ formatValue,
6
+ isDateOnly,
7
+ timeTicks,
8
+ valueTicks,
9
+ } from '../lib/format'
10
+
11
+ describe('formatValue', () => {
12
+ it('formats dollars with cents below $1,000 and whole dollars above', () => {
13
+ expect(formatValue(12.5, '$')).toBe('$12.50')
14
+ expect(formatValue(319517.4, '$')).toBe('$319,517')
15
+ expect(formatValue(-1234, '$')).toBe('-$1,234')
16
+ })
17
+
18
+ it('formats % values as percentage points, not fractions', () => {
19
+ expect(formatValue(12.345, '%')).toBe('12.3%')
20
+ expect(formatValue(-3.14159, '%')).toBe('-3.14%')
21
+ expect(formatValue(250.4, '%')).toBe('250%')
22
+ })
23
+
24
+ it('formats plain numbers with grouping', () => {
25
+ expect(formatValue(1234.567, 'number')).toBe('1,235')
26
+ expect(formatValue(0.12345, 'number')).toBe('0.12')
27
+ })
28
+ })
29
+
30
+ describe('formatValue at zero', () => {
31
+ it('never renders negative zero', () => {
32
+ expect(formatValue(-0.0001, '%')).toBe('0%')
33
+ expect(formatValue(-0.001, '$')).toBe('$0.00')
34
+ })
35
+ })
36
+
37
+ describe('valueTicks', () => {
38
+ it('covers the range with round multiples of a nice step', () => {
39
+ const { ticks, step } = valueTicks(-8, 190)
40
+ expect(step.value).toBe(50)
41
+ expect(ticks).toEqual([-50, 0, 50, 100, 150, 200])
42
+ })
43
+
44
+ it('keeps fractional steps exact', () => {
45
+ const { ticks, step } = valueTicks(0.1, 0.9)
46
+ expect(step).toEqual({ value: 0.2, decimals: 1 })
47
+ expect(ticks).toEqual([0, 0.2, 0.4, 0.6, 0.8, 1])
48
+ })
49
+
50
+ it('needs one more decimal for a 2.5 step', () => {
51
+ expect(valueTicks(30.3, 31.3).step).toEqual({ value: 0.25, decimals: 2 })
52
+ })
53
+
54
+ it('opens a band around a flat series', () => {
55
+ const { ticks } = valueTicks(100, 100)
56
+ expect(ticks[0]).toBeLessThan(100)
57
+ expect(ticks[ticks.length - 1]).toBeGreaterThan(100)
58
+ })
59
+ })
60
+
61
+ describe('axisValueFormatter', () => {
62
+ const labels = (unit: '$' | '%' | 'number', min: number, max: number) => {
63
+ const { ticks, step } = valueTicks(min, max)
64
+ const format = axisValueFormatter(unit, ticks, step)
65
+ return ticks.map(format)
66
+ }
67
+
68
+ it('compacts by the largest tick and keeps one precision across labels', () => {
69
+ expect(labels('$', 60_000, 300_000)).toEqual(['$0', '$100K', '$200K', '$300K'])
70
+ expect(labels('$', 30.3, 31.3)).toEqual(['$30.25', '$30.50', '$30.75', '$31.00', '$31.25', '$31.50'])
71
+ expect(labels('number', 0, 2_500_000)).toEqual(['0', '1M', '2M', '3M'])
72
+ expect(labels('number', 0, 1_800_000)).toEqual(['0', '0.5M', '1.0M', '1.5M', '2.0M'])
73
+ })
74
+
75
+ it('keeps cents on small dollar axes', () => {
76
+ expect(labels('$', 1, 5)).toEqual(['$1.00', '$2.00', '$3.00', '$4.00', '$5.00'])
77
+ })
78
+
79
+ it('suffixes percentages', () => {
80
+ expect(labels('%', -24, 0)).toEqual(['-30%', '-20%', '-10%', '0%'])
81
+ expect(labels('%', 0, 25_000)).toEqual(['0%', '10K%', '20K%', '30K%'])
82
+ })
83
+ })
84
+
85
+ describe('isDateOnly', () => {
86
+ it('is true only when every timestamp is midnight UTC', () => {
87
+ expect(isDateOnly([Date.UTC(2026, 0, 1), Date.UTC(2026, 0, 2)])).toBe(true)
88
+ expect(isDateOnly([Date.UTC(2026, 0, 1), Date.UTC(2026, 0, 2, 14, 30)])).toBe(false)
89
+ expect(isDateOnly([])).toBe(false)
90
+ })
91
+ })
92
+
93
+ describe('timeTicks', () => {
94
+ it('puts a month of daily data on Mondays', () => {
95
+ const { ticks, step } = timeTicks(Date.UTC(2026, 0, 1), Date.UTC(2026, 0, 31), true)
96
+ expect(step).toEqual({ unit: 'day', count: 7 })
97
+ // 2026-01-05 is a Monday.
98
+ expect(ticks[0]).toBe(Date.UTC(2026, 0, 5))
99
+ expect(ticks.every((t) => new Date(t).getUTCDay() === 1)).toBe(true)
100
+ })
101
+
102
+ it('puts two years on quarter starts', () => {
103
+ const { ticks, step } = timeTicks(Date.UTC(2024, 1, 10), Date.UTC(2026, 1, 10), true)
104
+ expect(step.unit).toBe('month')
105
+ expect(ticks.length).toBeLessThanOrEqual(6)
106
+ for (const t of ticks) {
107
+ const d = new Date(t)
108
+ expect(d.getUTCDate()).toBe(1)
109
+ expect(d.getUTCMonth() % step.count).toBe(0)
110
+ }
111
+ })
112
+
113
+ it('puts a decade on year starts', () => {
114
+ const { ticks, step } = timeTicks(Date.UTC(2016, 3, 1), Date.UTC(2026, 3, 1), true)
115
+ expect(step.unit).toBe('month')
116
+ expect(step.count % 12).toBe(0)
117
+ expect(ticks.every((t) => new Date(t).getUTCMonth() === 0)).toBe(true)
118
+ })
119
+
120
+ it('puts an intraday session on round local times', () => {
121
+ const open = new Date(2026, 0, 5, 9, 30).getTime()
122
+ const close = new Date(2026, 0, 5, 16, 0).getTime()
123
+ const { ticks, step } = timeTicks(open, close, false)
124
+ expect(step.unit).toBe('minute')
125
+ expect(ticks.length).toBeGreaterThan(1)
126
+ expect(ticks.length).toBeLessThanOrEqual(6)
127
+ for (const t of ticks) {
128
+ const d = new Date(t)
129
+ expect((d.getHours() * 60 + d.getMinutes()) % step.count).toBe(0)
130
+ expect(t).toBeGreaterThanOrEqual(open)
131
+ expect(t).toBeLessThanOrEqual(close)
132
+ }
133
+ })
134
+
135
+ it('handles a single instant', () => {
136
+ expect(timeTicks(5, 5, true).ticks).toEqual([5])
137
+ })
138
+
139
+ it('respects maxTicks', () => {
140
+ const { ticks } = timeTicks(Date.UTC(2020, 0, 1), Date.UTC(2026, 0, 1), true, 4)
141
+ expect(ticks.length).toBeLessThanOrEqual(4)
142
+ expect(ticks.length).toBeGreaterThan(0)
143
+ })
144
+ })
145
+
146
+ describe('formatTick / formatTooltipTime', () => {
147
+ it('labels a tick as finely as its step', () => {
148
+ const t = Date.UTC(2026, 2, 1)
149
+ expect(formatTick(t, { unit: 'day', count: 7 }, true)).toBe('Mar 1')
150
+ expect(formatTick(t, { unit: 'month', count: 3 }, true)).toBe('Mar 2026')
151
+ expect(formatTick(t, { unit: 'month', count: 12 }, true)).toBe('2026')
152
+ expect(formatTick(t, { unit: 'minute', count: 60 }, true)).toBe('Mar 1')
153
+ expect(formatTick(Date.UTC(2026, 2, 1, 14, 30), { unit: 'minute', count: 30 }, true)).toBe('2:30 PM')
154
+ })
155
+
156
+ it('labels date-only data in UTC so it never slips to the previous day', () => {
157
+ expect(formatTooltipTime(Date.UTC(2026, 0, 5), true, false)).toBe('Jan 5, 2026')
158
+ })
159
+ })
@@ -0,0 +1,168 @@
1
+ import {
2
+ buildChartModel,
3
+ normalizePoints,
4
+ toDrawdown,
5
+ toEpochMs,
6
+ valueKey,
7
+ } from '../lib/series'
8
+
9
+ const day = (d: number) => Date.UTC(2026, 0, d)
10
+
11
+ describe('toEpochMs', () => {
12
+ it('reads Dates, epoch ms and ISO strings to the same instant', () => {
13
+ const ms = Date.UTC(2026, 0, 5)
14
+ expect(toEpochMs(new Date(ms))).toBe(ms)
15
+ expect(toEpochMs(ms)).toBe(ms)
16
+ expect(toEpochMs('2026-01-05')).toBe(ms)
17
+ })
18
+
19
+ it('returns null for an unreadable timestamp', () => {
20
+ expect(toEpochMs('not a date')).toBeNull()
21
+ expect(toEpochMs(Number.NaN)).toBeNull()
22
+ expect(toEpochMs(new Date('nope'))).toBeNull()
23
+ })
24
+ })
25
+
26
+ describe('normalizePoints', () => {
27
+ it('sorts by time, drops unreadable timestamps, and lets the later duplicate win', () => {
28
+ const out = normalizePoints([
29
+ { t: day(3), v: 3 },
30
+ { t: 'garbage', v: 99 },
31
+ { t: day(1), v: 1 },
32
+ { t: day(3), v: 30 },
33
+ ])
34
+ expect(out).toEqual([
35
+ { t: day(1), v: 1 },
36
+ { t: day(3), v: 30 },
37
+ ])
38
+ })
39
+
40
+ it('treats a non-finite value as a gap', () => {
41
+ expect(normalizePoints([{ t: day(1), v: Number.NaN }])).toEqual([{ t: day(1), v: null }])
42
+ })
43
+ })
44
+
45
+ describe('toDrawdown', () => {
46
+ it('is 0 at each new high and the % below the running peak otherwise', () => {
47
+ const out = toDrawdown(
48
+ [
49
+ { t: 1, v: 100 },
50
+ { t: 2, v: 120 },
51
+ { t: 3, v: 90 },
52
+ { t: 4, v: 130 },
53
+ ],
54
+ '$',
55
+ )
56
+ expect(out.map((p) => p.v)).toEqual([0, 0, -25, 0])
57
+ })
58
+
59
+ it('never yields -0 at a new high', () => {
60
+ const [p] = toDrawdown([{ t: 1, v: 50 }], 'number')
61
+ expect(Object.is(p.v, -0)).toBe(false)
62
+ })
63
+
64
+ it('reads % values as cumulative returns: +50% -> +20% is a 20% drawdown', () => {
65
+ const out = toDrawdown(
66
+ [
67
+ { t: 1, v: 50 },
68
+ { t: 2, v: 20 },
69
+ ],
70
+ '%',
71
+ )
72
+ expect(out[1].v).toBeCloseTo(-20, 10)
73
+ })
74
+
75
+ it('keeps a gap a gap and does not reset the peak across it', () => {
76
+ const out = toDrawdown(
77
+ [
78
+ { t: 1, v: 200 },
79
+ { t: 2, v: null },
80
+ { t: 3, v: 100 },
81
+ ],
82
+ '$',
83
+ )
84
+ expect(out.map((p) => p.v)).toEqual([0, null, -50])
85
+ })
86
+
87
+ it('has no % below a non-positive peak, so those points become gaps', () => {
88
+ const out = toDrawdown(
89
+ [
90
+ { t: 1, v: -5 },
91
+ { t: 2, v: -10 },
92
+ { t: 3, v: 10 },
93
+ { t: 4, v: 5 },
94
+ ],
95
+ 'number',
96
+ )
97
+ expect(out.map((p) => p.v)).toEqual([null, null, 0, -50])
98
+ })
99
+ })
100
+
101
+ describe('buildChartModel', () => {
102
+ it('merges series onto one row per distinct timestamp', () => {
103
+ const model = buildChartModel(
104
+ [
105
+ { name: 'A', points: [{ t: day(1), v: 1 }, { t: day(2), v: 2 }] },
106
+ { name: 'B', points: [{ t: day(2), v: 20 }, { t: day(3), v: 30 }] },
107
+ ],
108
+ 'line',
109
+ 'number',
110
+ )
111
+ expect(model.times).toEqual([day(1), day(2), day(3)])
112
+ expect(model.rows[1][valueKey(0)]).toBe(2)
113
+ expect(model.rows[1][valueKey(1)]).toBe(20)
114
+ // A has no sample on day 3 — absent, not a gap.
115
+ expect(model.rows[2][valueKey(0)]).toBeUndefined()
116
+ expect(model.names).toEqual(['A', 'B'])
117
+ expect(model.hasData).toBe(true)
118
+ })
119
+
120
+ it('splits a series into separate runs at every null so the gap is not bridged', () => {
121
+ const model = buildChartModel(
122
+ [
123
+ {
124
+ name: 'A',
125
+ points: [
126
+ { t: day(1), v: 1 },
127
+ { t: day(2), v: 2 },
128
+ { t: day(3), v: null },
129
+ { t: day(4), v: 4 },
130
+ { t: day(5), v: 5 },
131
+ ],
132
+ },
133
+ ],
134
+ 'line',
135
+ 'number',
136
+ )
137
+ expect(model.segments.map((s) => s.dataKey)).toEqual(['s0_0', 's0_1'])
138
+ // The first run holds no value past the gap and the second none before it.
139
+ expect(model.rows.map((r) => r.s0_0)).toEqual([1, 2, undefined, undefined, undefined])
140
+ expect(model.rows.map((r) => r.s0_1)).toEqual([undefined, undefined, undefined, 4, 5])
141
+ expect(model.rows[2][valueKey(0)]).toBeNull()
142
+ })
143
+
144
+ it('marks a one-point run so it can be drawn as a dot', () => {
145
+ const model = buildChartModel(
146
+ [{ name: 'A', points: [{ t: day(1), v: null }, { t: day(2), v: 7 }, { t: day(3), v: null }] }],
147
+ 'line',
148
+ '$',
149
+ )
150
+ expect(model.segments).toEqual([{ dataKey: 's0_0', seriesIndex: 0, single: true }])
151
+ })
152
+
153
+ it('reports no data when nothing is finite', () => {
154
+ const model = buildChartModel([{ name: 'A', points: [{ t: day(1), v: null }] }], 'line', '$')
155
+ expect(model.hasData).toBe(false)
156
+ expect(model.segments).toEqual([])
157
+ })
158
+
159
+ it('shows drawdown in % whatever the input unit', () => {
160
+ const model = buildChartModel(
161
+ [{ name: 'Equity', points: [{ t: day(1), v: 100 }, { t: day(2), v: 80 }] }],
162
+ 'drawdown',
163
+ '$',
164
+ )
165
+ expect(model.unit).toBe('%')
166
+ expect(model.rows[1][valueKey(0)]).toBeCloseTo(-20, 10)
167
+ })
168
+ })
@@ -0,0 +1,13 @@
1
+ // Charts live behind their own subpath (`@startsimpli/ui/charts`) so recharts
2
+ // is only bundled by apps that draw one.
3
+ export { TimeSeriesChart, SERIES_COLORS, seriesStroke } from './TimeSeriesChart'
4
+ export type { TimeSeriesChartProps, TimeSeriesReferenceLine } from './TimeSeriesChart'
5
+ export type {
6
+ TimeSeries,
7
+ TimeSeriesPoint,
8
+ TimeSeriesUnit,
9
+ TimeSeriesMode,
10
+ TimeValue,
11
+ } from './lib/series'
12
+ export { buildChartModel, normalizePoints, toDrawdown, toEpochMs } from './lib/series'
13
+ export { formatValue } from './lib/format'
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Value + time formatting for TimeSeriesChart. Pure, en-US like the rest of the
3
+ * package's formatters (utils/index.ts), so output is the same on every
4
+ * machine.
5
+ */
6
+ import type { TimeSeriesUnit } from './series'
7
+
8
+ const LOCALE = 'en-US'
9
+
10
+ /**
11
+ * `v` in the unit with exactly `digits` decimals. A value that rounds to zero
12
+ * prints as zero — never `-0` or `-$0.00`.
13
+ */
14
+ function fixed(v: number, unit: TimeSeriesUnit, digits: number, minDigits = digits): string {
15
+ const value = Math.abs(v) < 0.5 * 10 ** -digits ? 0 : v
16
+ const text = new Intl.NumberFormat(LOCALE, {
17
+ minimumFractionDigits: minDigits,
18
+ maximumFractionDigits: digits,
19
+ ...(unit === '$' ? { style: 'currency', currency: 'USD' } : {}),
20
+ }).format(value)
21
+ return unit === '%' ? `${text}%` : text
22
+ }
23
+
24
+ /** Full value, for the tooltip: `$1,234`, `$12.50`, `12.5%`, `1,234.57`. */
25
+ export function formatValue(v: number, unit: TimeSeriesUnit): string {
26
+ const abs = Math.abs(v)
27
+ if (unit === '$') return fixed(v, unit, abs >= 1000 ? 0 : 2)
28
+ if (unit === '%') return fixed(v, unit, abs >= 100 ? 0 : abs >= 10 ? 1 : 2, 0)
29
+ return fixed(v, unit, abs >= 1000 ? 0 : 2, 0)
30
+ }
31
+
32
+ /** A "nice" tick step: 1, 2, 2.5 or 5 times a power of ten. */
33
+ interface NiceStep {
34
+ value: number
35
+ /** Decimals needed to print every multiple of `value` exactly. */
36
+ decimals: number
37
+ }
38
+
39
+ function niceStep(rough: number): NiceStep {
40
+ const exp = Math.floor(Math.log10(rough))
41
+ const base = 10 ** exp
42
+ const m = [1, 2, 2.5, 5, 10].find((c) => c * base >= rough * (1 - 1e-9)) ?? 10
43
+ const k = m === 10 ? exp + 1 : exp
44
+ return {
45
+ value: m * base,
46
+ decimals: Math.max(0, -k + (m === 2.5 ? 1 : 0)),
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Value-axis ticks over `[min, max]`: round multiples of a nice step, about
52
+ * `count` of them, covering the range and no more. Done here rather than by
53
+ * recharts so the labels can share one precision (`$30.80` / `$31.00`, not
54
+ * `$30.8` / `$31`).
55
+ */
56
+ export function valueTicks(
57
+ min: number,
58
+ max: number,
59
+ count = 5,
60
+ ): { ticks: number[]; step: NiceStep } {
61
+ let lo = min
62
+ let hi = max
63
+ if (!(hi > lo)) {
64
+ // One value (or a flat line): open a band around it so it has an axis.
65
+ const pad = Math.abs(lo) > 0 ? Math.abs(lo) * 0.1 : 1
66
+ lo -= pad
67
+ hi += pad
68
+ }
69
+ const step = niceStep((hi - lo) / Math.max(count - 1, 1))
70
+ const first = Math.floor(lo / step.value + 1e-9)
71
+ const last = Math.ceil(hi / step.value - 1e-9)
72
+ const ticks: number[] = []
73
+ for (let i = first; i <= last; i += 1) {
74
+ // Multiply rather than accumulate, so 0.1-steps don't drift to 0.30000000000000004.
75
+ ticks.push(Number((i * step.value).toFixed(step.decimals)))
76
+ }
77
+ return { ticks, step }
78
+ }
79
+
80
+ const COMPACT = [
81
+ { at: 1e9, suffix: 'B' },
82
+ { at: 1e6, suffix: 'M' },
83
+ { at: 1e3, suffix: 'K' },
84
+ ] as const
85
+
86
+ /**
87
+ * Formatter for the value-axis ticks from `valueTicks`: compacted to K/M/B by
88
+ * the largest tick, every label at the precision the step needs —
89
+ * `$300K`, `$30.80`, `-20%`, `12.5K`.
90
+ */
91
+ export function axisValueFormatter(
92
+ unit: TimeSeriesUnit,
93
+ ticks: number[],
94
+ step: NiceStep,
95
+ ): (v: number) => string {
96
+ const maxAbs = Math.max(0, ...ticks.map((t) => Math.abs(t)))
97
+ const scale = COMPACT.find((c) => maxAbs >= c.at)
98
+ if (!scale) {
99
+ // Uncompacted dollars below $10 keep their cents.
100
+ const digits = unit === '$' && maxAbs < 10 ? Math.max(step.decimals, 2) : step.decimals
101
+ return (v) => fixed(v, unit, digits)
102
+ }
103
+ const digits = Math.max(0, niceStep(step.value / scale.at).decimals)
104
+ return (v) => {
105
+ // Zero is zero in any scale: `$0`, not `$0K`.
106
+ if (v === 0) return fixed(0, unit, 0)
107
+ const text = fixed(v / scale.at, unit === '%' ? 'number' : unit, digits)
108
+ return `${text}${scale.suffix}${unit === '%' ? '%' : ''}`
109
+ }
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Time
114
+ // ---------------------------------------------------------------------------
115
+
116
+ const MINUTE = 60_000
117
+ const HOUR = 60 * MINUTE
118
+ const DAY = 24 * HOUR
119
+
120
+ /** A tick interval: a fixed number of minutes/days, or calendar months. */
121
+ export type TickStep =
122
+ | { unit: 'minute'; count: number }
123
+ | { unit: 'day'; count: number }
124
+ | { unit: 'month'; count: number }
125
+
126
+ const STEPS: TickStep[] = [
127
+ ...[1, 5, 15, 30, 60, 180, 360, 720].map((count) => ({ unit: 'minute' as const, count })),
128
+ ...[1, 2, 7, 14].map((count) => ({ unit: 'day' as const, count })),
129
+ ...[1, 3, 6, 12, 24, 60, 120, 240, 600].map((count) => ({ unit: 'month' as const, count })),
130
+ ]
131
+
132
+ function approxMs(step: TickStep): number {
133
+ if (step.unit === 'minute') return step.count * MINUTE
134
+ if (step.unit === 'day') return step.count * DAY
135
+ return step.count * 30.44 * DAY
136
+ }
137
+
138
+ /**
139
+ * Whether every timestamp is midnight UTC — i.e. the data is date-only
140
+ * (`'2026-01-05'` parses to midnight UTC). Such data is labelled in UTC; in
141
+ * local time a US viewer would see every bar under the previous day.
142
+ */
143
+ export function isDateOnly(times: number[]): boolean {
144
+ return times.length > 0 && times.every((t) => t % DAY === 0)
145
+ }
146
+
147
+ /** Calendar accessors in either UTC or the viewer's zone. */
148
+ function calendar(utc: boolean) {
149
+ return {
150
+ parts(t: number) {
151
+ const d = new Date(t)
152
+ return utc
153
+ ? { y: d.getUTCFullYear(), mo: d.getUTCMonth(), d: d.getUTCDate(), h: d.getUTCHours(), mi: d.getUTCMinutes(), dow: d.getUTCDay() }
154
+ : { y: d.getFullYear(), mo: d.getMonth(), d: d.getDate(), h: d.getHours(), mi: d.getMinutes(), dow: d.getDay() }
155
+ },
156
+ make(y: number, mo: number, d: number, h = 0, mi = 0): number {
157
+ return utc ? Date.UTC(y, mo, d, h, mi) : new Date(y, mo, d, h, mi).getTime()
158
+ },
159
+ }
160
+ }
161
+
162
+ /** Tick instants for one step, aligned to round calendar boundaries. */
163
+ function ticksForStep(min: number, max: number, step: TickStep, utc: boolean, limit: number): number[] {
164
+ const cal = calendar(utc)
165
+ const p = cal.parts(min)
166
+ const out: number[] = []
167
+ const push = (t: number) => {
168
+ if (t >= min && t <= max) out.push(t)
169
+ return out.length <= limit && t <= max
170
+ }
171
+
172
+ if (step.unit === 'minute') {
173
+ // Minutes since local midnight, rounded up to a multiple of the step.
174
+ const sinceMidnight = p.h * 60 + p.mi
175
+ const first = Math.ceil(sinceMidnight / step.count) * step.count
176
+ for (let m = first; ; m += step.count) {
177
+ if (!push(cal.make(p.y, p.mo, p.d, 0, m))) break
178
+ }
179
+ return out
180
+ }
181
+
182
+ if (step.unit === 'day') {
183
+ let day = p.d
184
+ if (cal.make(p.y, p.mo, day) < min) day += 1
185
+ // Weekly steps start on a Monday so the labels line up week to week.
186
+ if (step.count >= 7) {
187
+ const dow = cal.parts(cal.make(p.y, p.mo, day)).dow
188
+ day += (8 - dow) % 7
189
+ }
190
+ for (let d = day; ; d += step.count) {
191
+ if (!push(cal.make(p.y, p.mo, d))) break
192
+ }
193
+ return out
194
+ }
195
+
196
+ // Months: start on a month whose index is a multiple of the step, so a
197
+ // quarterly axis reads Jan/Apr/Jul/Oct and a 5-year axis 2020/2025/2030.
198
+ let month = p.y * 12 + p.mo
199
+ if (cal.make(p.y, p.mo, 1) < min) month += 1
200
+ month = Math.ceil(month / step.count) * step.count
201
+ for (let m = month; ; m += step.count) {
202
+ if (!push(cal.make(Math.floor(m / 12), m % 12, 1))) break
203
+ }
204
+ return out
205
+ }
206
+
207
+ /**
208
+ * Evenly spaced, calendar-aligned ticks across `[min, max]`: the finest step
209
+ * that yields at most `maxTicks` of them (midnights, Mondays, month or year
210
+ * starts — never "every 4.3 days from the first sample").
211
+ */
212
+ export function timeTicks(
213
+ min: number,
214
+ max: number,
215
+ utc: boolean,
216
+ maxTicks = 6,
217
+ ): { ticks: number[]; step: TickStep } {
218
+ const cap = Math.max(maxTicks, 2)
219
+ if (!(max > min)) return { ticks: Number.isFinite(min) ? [min] : [], step: STEPS[0] }
220
+ const span = max - min
221
+ for (const step of STEPS) {
222
+ if (span / approxMs(step) > cap + 1) continue
223
+ const ticks = ticksForStep(min, max, step, utc, cap)
224
+ if (ticks.length <= cap && ticks.length > 0) return { ticks, step }
225
+ }
226
+ const step = STEPS[STEPS.length - 1]
227
+ return { ticks: ticksForStep(min, max, step, utc, cap), step }
228
+ }
229
+
230
+ /** Label for an axis tick, as fine as the step it sits on. */
231
+ export function formatTick(t: number, step: TickStep, utc: boolean): string {
232
+ const timeZone = utc ? 'UTC' : undefined
233
+ if (step.unit === 'minute') {
234
+ const p = calendar(utc).parts(t)
235
+ // A tick on midnight names the day, so a multi-day intraday axis stays readable.
236
+ if (p.h === 0 && p.mi === 0) {
237
+ return new Intl.DateTimeFormat(LOCALE, { month: 'short', day: 'numeric', timeZone }).format(t)
238
+ }
239
+ return new Intl.DateTimeFormat(LOCALE, { hour: 'numeric', minute: '2-digit', timeZone }).format(t)
240
+ }
241
+ if (step.unit === 'day') {
242
+ return new Intl.DateTimeFormat(LOCALE, { month: 'short', day: 'numeric', timeZone }).format(t)
243
+ }
244
+ if (step.count < 12) {
245
+ return new Intl.DateTimeFormat(LOCALE, { month: 'short', year: 'numeric', timeZone }).format(t)
246
+ }
247
+ return new Intl.DateTimeFormat(LOCALE, { year: 'numeric', timeZone }).format(t)
248
+ }
249
+
250
+ /** Label for the tooltip: the full date, plus the time when the data has one. */
251
+ export function formatTooltipTime(t: number, utc: boolean, withTime: boolean): string {
252
+ return new Intl.DateTimeFormat(LOCALE, {
253
+ year: 'numeric',
254
+ month: 'short',
255
+ day: 'numeric',
256
+ ...(withTime ? { hour: 'numeric', minute: '2-digit' } : {}),
257
+ timeZone: utc ? 'UTC' : undefined,
258
+ }).format(t)
259
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Pure data shaping for TimeSeriesChart — no React, no recharts, so every rule
3
+ * here is unit-tested on its own.
4
+ */
5
+
6
+ /** A timestamp: a Date, epoch milliseconds, or any string `Date.parse` reads. */
7
+ export type TimeValue = string | number | Date
8
+
9
+ export interface TimeSeriesPoint {
10
+ t: TimeValue
11
+ /** `null` is a gap: the line breaks there instead of joining its neighbours. */
12
+ v: number | null
13
+ }
14
+
15
+ export interface TimeSeries {
16
+ name: string
17
+ points: TimeSeriesPoint[]
18
+ }
19
+
20
+ /**
21
+ * How a value reads. `'%'` values are percentage POINTS — pass `12.5` for
22
+ * 12.5%, not `0.125`. `'$'` is US dollars.
23
+ */
24
+ export type TimeSeriesUnit = '%' | '$' | 'number'
25
+
26
+ /**
27
+ * `'line'` plots the values as given. `'drawdown'` plots each series as the
28
+ * percentage it sits below its own running peak (0 at a new high, negative
29
+ * otherwise).
30
+ */
31
+ export type TimeSeriesMode = 'line' | 'drawdown'
32
+
33
+ /** A point after parsing: epoch ms + a finite value or a gap. */
34
+ export interface NormalizedPoint {
35
+ t: number
36
+ v: number | null
37
+ }
38
+
39
+ /** Epoch ms for a timestamp, or null when it cannot be read. */
40
+ export function toEpochMs(t: TimeValue): number | null {
41
+ const ms = t instanceof Date ? t.getTime() : typeof t === 'number' ? t : Date.parse(t)
42
+ return Number.isFinite(ms) ? ms : null
43
+ }
44
+
45
+ /**
46
+ * Parse, sort and de-duplicate one series. Points whose timestamp cannot be
47
+ * read are dropped (there is nowhere to put them); a non-finite value becomes a
48
+ * gap. Two points at the same instant: the later one in the input wins.
49
+ */
50
+ export function normalizePoints(points: TimeSeriesPoint[]): NormalizedPoint[] {
51
+ const byTime = new Map<number, number | null>()
52
+ for (const p of points) {
53
+ const t = toEpochMs(p.t)
54
+ if (t === null) continue
55
+ byTime.set(t, typeof p.v === 'number' && Number.isFinite(p.v) ? p.v : null)
56
+ }
57
+ return [...byTime.entries()].sort((a, b) => a[0] - b[0]).map(([t, v]) => ({ t, v }))
58
+ }
59
+
60
+ /**
61
+ * Each point as the percentage below the running peak of everything before it.
62
+ *
63
+ * For `'$'` and `'number'` the peak is taken over the raw values. For `'%'`
64
+ * the values are read as cumulative returns and the peak is taken over the
65
+ * growth they imply (`1 + v/100`): falling from +50% to +20% is a 20%
66
+ * drawdown, not a 60% one.
67
+ *
68
+ * A gap stays a gap and does not reset the peak. While the peak is not
69
+ * positive there is no meaningful "% below it", so those points become gaps.
70
+ */
71
+ export function toDrawdown(points: NormalizedPoint[], unit: TimeSeriesUnit): NormalizedPoint[] {
72
+ let peak: number | null = null
73
+ return points.map(({ t, v }) => {
74
+ if (v === null) return { t, v: null }
75
+ const level = unit === '%' ? 1 + v / 100 : v
76
+ if (peak === null || level > peak) peak = level
77
+ if (!(peak > 0)) return { t, v: null }
78
+ const dd = (level / peak - 1) * 100
79
+ // A new high is exactly 0, never -0 (which would format as "-0%").
80
+ return { t, v: dd === 0 ? 0 : dd }
81
+ })
82
+ }
83
+
84
+ /** One recharts row per distinct timestamp across all series. */
85
+ export interface ChartRow {
86
+ t: number
87
+ [key: string]: number | null | undefined
88
+ }
89
+
90
+ /**
91
+ * One drawn line: a contiguous run of a series between gaps. A series with
92
+ * gaps is several of these, so a gap is a real break in the stroke while a
93
+ * timestamp the series simply has no sample at (because another series does)
94
+ * is stepped over.
95
+ */
96
+ export interface ChartSegment {
97
+ dataKey: string
98
+ seriesIndex: number
99
+ /** A run of one point — there is no stroke to draw, so it needs a dot. */
100
+ single: boolean
101
+ }
102
+
103
+ export interface ChartModel {
104
+ rows: ChartRow[]
105
+ segments: ChartSegment[]
106
+ names: string[]
107
+ /** The unit values are DISPLAYED in — always `'%'` in drawdown mode. */
108
+ unit: TimeSeriesUnit
109
+ /** False when there is not one finite value to plot. */
110
+ hasData: boolean
111
+ /** Every timestamp in the chart, ascending. */
112
+ times: number[]
113
+ /** Smallest and largest plotted value (both 0 when there is no data). */
114
+ min: number
115
+ max: number
116
+ }
117
+
118
+ /** Row key holding series `i`'s value: number, null (gap) or absent. */
119
+ export function valueKey(seriesIndex: number): string {
120
+ return `v${seriesIndex}`
121
+ }
122
+
123
+ export function buildChartModel(
124
+ series: TimeSeries[],
125
+ mode: TimeSeriesMode,
126
+ unit: TimeSeriesUnit,
127
+ ): ChartModel {
128
+ const rowsByTime = new Map<number, ChartRow>()
129
+ const rowAt = (t: number): ChartRow => {
130
+ let row = rowsByTime.get(t)
131
+ if (!row) {
132
+ row = { t }
133
+ rowsByTime.set(t, row)
134
+ }
135
+ return row
136
+ }
137
+
138
+ const segments: ChartSegment[] = []
139
+ let hasData = false
140
+ let min = Infinity
141
+ let max = -Infinity
142
+
143
+ series.forEach((s, i) => {
144
+ const normalized = normalizePoints(s.points)
145
+ const points = mode === 'drawdown' ? toDrawdown(normalized, unit) : normalized
146
+
147
+ let segIndex = 0
148
+ let segLength = 0
149
+ const closeSegment = () => {
150
+ if (segLength === 0) return
151
+ segments.push({ dataKey: `s${i}_${segIndex}`, seriesIndex: i, single: segLength === 1 })
152
+ segIndex += 1
153
+ segLength = 0
154
+ }
155
+
156
+ for (const p of points) {
157
+ const row = rowAt(p.t)
158
+ row[valueKey(i)] = p.v
159
+ if (p.v === null) {
160
+ closeSegment()
161
+ continue
162
+ }
163
+ row[`s${i}_${segIndex}`] = p.v
164
+ segLength += 1
165
+ hasData = true
166
+ if (p.v < min) min = p.v
167
+ if (p.v > max) max = p.v
168
+ }
169
+ closeSegment()
170
+ })
171
+
172
+ const rows = [...rowsByTime.values()].sort((a, b) => a.t - b.t)
173
+ return {
174
+ rows,
175
+ segments,
176
+ names: series.map((s) => s.name),
177
+ unit: mode === 'drawdown' ? '%' : unit,
178
+ hasData,
179
+ times: rows.map((r) => r.t),
180
+ min: hasData ? min : 0,
181
+ max: hasData ? max : 0,
182
+ }
183
+ }
@@ -385,3 +385,63 @@ describe('resolving is a deliberate act', () => {
385
385
  expect(result.current.version).toBe(2);
386
386
  });
387
387
  });
388
+
389
+ describe('a resolution cannot be raced', () => {
390
+ // The dialog disables these while busy, but the HOOK is exported on its own
391
+ // and a consumer wires its own controls (bd startsim-j19hf does exactly that).
392
+ it('ignores acceptTheirs mid-keepMine so the two cannot interleave', async () => {
393
+ let release: (v: ConditionalWriteOutcome) => void = () => {};
394
+ const second = new Promise<ConditionalWriteOutcome>((r) => {
395
+ release = r;
396
+ });
397
+ let call = 0;
398
+ const write = jest.fn(async () => {
399
+ call += 1;
400
+ return call === 1 ? REFUSAL : second;
401
+ });
402
+ const { result } = renderHook(() => useConditionalSave({ client: { write }, version: 2 }));
403
+
404
+ await act(async () => {
405
+ await result.current.save({ data: { blog: 'mine' } });
406
+ });
407
+
408
+ let inflight!: Promise<unknown>;
409
+ act(() => {
410
+ inflight = result.current.keepMine();
411
+ });
412
+ expect(result.current.resolving).toBe(true);
413
+
414
+ // The user hits "discard mine" while the overwrite is still in the air.
415
+ act(() => result.current.acceptTheirs());
416
+ // IGNORED, so the two never interleave: the conflict is still the one
417
+ // keepMine is resolving, not a half-cleared state.
418
+ expect(result.current.conflict?.currentVersion).toBe(3);
419
+ expect(result.current.version).toBe(2);
420
+
421
+ await act(async () => {
422
+ release({ status: 412, body: { detail: 'again', current_version: 9 } });
423
+ await inflight;
424
+ });
425
+
426
+ // One writer decided the outcome — keepMine's — rather than two racing.
427
+ expect(result.current.conflict?.currentVersion).toBe(9);
428
+ expect(result.current.conflict?.refusedBody).toEqual({ data: { blog: 'mine' } });
429
+ expect(result.current.paused).toBe(true);
430
+ expect(result.current.version).toBe(2);
431
+ });
432
+
433
+ it('does not fire two overwrites on a double click', async () => {
434
+ const { client, sent } = writer([REFUSAL, { status: 200, body: { version: 4 } }]);
435
+ const { result } = renderHook(() => useConditionalSave({ client, version: 2 }));
436
+
437
+ await act(async () => {
438
+ await result.current.save({ data: { blog: 'mine' } });
439
+ });
440
+ await act(async () => {
441
+ await Promise.all([result.current.keepMine(), result.current.keepMine()]);
442
+ });
443
+
444
+ // One refused save + exactly one overwrite.
445
+ expect(sent).toHaveLength(2);
446
+ });
447
+ });
@@ -171,6 +171,9 @@ export function useConditionalSave({
171
171
  const [conflict, setConflict] = useState<StaleSaveConflict | null>(null);
172
172
  const [dismissed, setDismissed] = useState(false);
173
173
  const [resolving, setResolving] = useState(false);
174
+ /** The synchronous twin of {@link resolving}. State lands a render too late to
175
+ * stop a second click; this does not. */
176
+ const resolvingRef = useRef(false);
174
177
  const [lastError, setLastError] = useState<unknown>(null);
175
178
 
176
179
  // Read inside `save` so a stale closure cannot send a version the hook has
@@ -301,6 +304,13 @@ export function useConditionalSave({
301
304
  const keepMine = useCallback(async (): Promise<ConditionalSaveResult> => {
302
305
  const current = conflictRef.current;
303
306
  if (!current) return { status: 'paused' };
307
+ // RE-ENTRANCY IS GUARDED ON A REF, NOT ON `resolving`, AND THAT IS THE WHOLE
308
+ // POINT. `setResolving(true)` only reaches the DOM after a re-render, so a
309
+ // double click on "Save mine" lands both handlers before the button is
310
+ // disabled — and sends the overwrite TWICE. Measured, not theorised: the
311
+ // test below counted three writes where two were correct.
312
+ if (resolvingRef.current) return { status: 'paused' };
313
+ resolvingRef.current = true;
304
314
  // Adopting `current_version` is legitimate HERE and nowhere else: a person
305
315
  // has been told what changed and has chosen to replace it.
306
316
  const target = isVersion(current.currentVersion) ? current.currentVersion : undefined;
@@ -311,11 +321,17 @@ export function useConditionalSave({
311
321
  try {
312
322
  return await attempt(current.refusedBody, target);
313
323
  } finally {
324
+ resolvingRef.current = false;
314
325
  if (alive.current) setResolving(false);
315
326
  }
316
327
  }, [attempt]);
317
328
 
318
329
  const acceptTheirs = useCallback(() => {
330
+ // IGNORED MID-OVERWRITE, same posture as `save`. Discarding while a
331
+ // `keepMine` is in the air lets the two interleave, and the refusal of a
332
+ // save the reviewer has ALREADY ABANDONED then raises a fresh dialog about
333
+ // it — a conflict about text that no longer exists anywhere.
334
+ if (resolvingRef.current) return;
319
335
  const current = conflictRef.current;
320
336
  setVersionState(isVersion(current?.currentVersion) ? current!.currentVersion : undefined);
321
337
  setConflict(null);