@jorgerdz/timeview 0.1.0
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/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +263 -0
- package/dist/cli/timeview.js +6710 -0
- package/dist/timeview.cjs +1 -0
- package/dist/timeview.js +5667 -0
- package/dist/tokens.css +67 -0
- package/dist/types/timeview/BandedTimeline.d.ts +11 -0
- package/dist/types/timeview/BandedTimeline.d.ts.map +1 -0
- package/dist/types/timeview/DensityHeatmap.d.ts +11 -0
- package/dist/types/timeview/DensityHeatmap.d.ts.map +1 -0
- package/dist/types/timeview/LaneCalendar.d.ts +11 -0
- package/dist/types/timeview/LaneCalendar.d.ts.map +1 -0
- package/dist/types/timeview/MetricTimeline.d.ts +8 -0
- package/dist/types/timeview/MetricTimeline.d.ts.map +1 -0
- package/dist/types/timeview/SpanMatrix.d.ts +8 -0
- package/dist/types/timeview/SpanMatrix.d.ts.map +1 -0
- package/dist/types/timeview/config.d.ts +22 -0
- package/dist/types/timeview/config.d.ts.map +1 -0
- package/dist/types/timeview/core/aggregate.d.ts +113 -0
- package/dist/types/timeview/core/aggregate.d.ts.map +1 -0
- package/dist/types/timeview/core/calendar.d.ts +27 -0
- package/dist/types/timeview/core/calendar.d.ts.map +1 -0
- package/dist/types/timeview/core/intervals.d.ts +8 -0
- package/dist/types/timeview/core/intervals.d.ts.map +1 -0
- package/dist/types/timeview/core/labels.d.ts +5 -0
- package/dist/types/timeview/core/labels.d.ts.map +1 -0
- package/dist/types/timeview/core/metric.d.ts +58 -0
- package/dist/types/timeview/core/metric.d.ts.map +1 -0
- package/dist/types/timeview/core/time.d.ts +22 -0
- package/dist/types/timeview/core/time.d.ts.map +1 -0
- package/dist/types/timeview/dashboard.d.ts +17 -0
- package/dist/types/timeview/dashboard.d.ts.map +1 -0
- package/dist/types/timeview/data.d.ts +21 -0
- package/dist/types/timeview/data.d.ts.map +1 -0
- package/dist/types/timeview/export.d.ts +14 -0
- package/dist/types/timeview/export.d.ts.map +1 -0
- package/dist/types/timeview/index.d.ts +28 -0
- package/dist/types/timeview/index.d.ts.map +1 -0
- package/dist/types/timeview/registry.d.ts +285 -0
- package/dist/types/timeview/registry.d.ts.map +1 -0
- package/dist/types/timeview/shared/Caption.d.ts +9 -0
- package/dist/types/timeview/shared/Caption.d.ts.map +1 -0
- package/dist/types/timeview/shared/EmptyState.d.ts +16 -0
- package/dist/types/timeview/shared/EmptyState.d.ts.map +1 -0
- package/dist/types/timeview/shared/Legend.d.ts +10 -0
- package/dist/types/timeview/shared/Legend.d.ts.map +1 -0
- package/dist/types/timeview/shared/Tooltip.d.ts +15 -0
- package/dist/types/timeview/shared/Tooltip.d.ts.map +1 -0
- package/dist/types/timeview/shared/useMeasuredWidth.d.ts +2 -0
- package/dist/types/timeview/shared/useMeasuredWidth.d.ts.map +1 -0
- package/dist/types/timeview/types.d.ts +158 -0
- package/dist/types/timeview/types.d.ts.map +1 -0
- package/docs/AGENT-USAGE.md +93 -0
- package/docs/COMPATIBILITY.md +134 -0
- package/docs/STUDIO.md +41 -0
- package/examples/README.md +21 -0
- package/examples/configs/bandedTimeline.json +31 -0
- package/examples/configs/densityHeatmap.json +33 -0
- package/examples/configs/laneCalendar.json +31 -0
- package/examples/configs/metricTimeline.json +51 -0
- package/examples/configs/spanMatrix.json +31 -0
- package/package.json +94 -0
- package/render.html +12 -0
- package/src/render.tsx +67 -0
- package/src/styles/tokens.css +67 -0
- package/src/timeview/BandedTimeline.tsx +620 -0
- package/src/timeview/DensityHeatmap.tsx +513 -0
- package/src/timeview/LaneCalendar.tsx +496 -0
- package/src/timeview/MetricTimeline.tsx +993 -0
- package/src/timeview/SpanMatrix.tsx +721 -0
- package/src/timeview/config.ts +399 -0
- package/src/timeview/core/aggregate.ts +317 -0
- package/src/timeview/core/calendar.ts +81 -0
- package/src/timeview/core/intervals.ts +52 -0
- package/src/timeview/core/labels.ts +19 -0
- package/src/timeview/core/metric.ts +263 -0
- package/src/timeview/core/time.ts +103 -0
- package/src/timeview/dashboard.ts +80 -0
- package/src/timeview/data.ts +242 -0
- package/src/timeview/export.ts +48 -0
- package/src/timeview/index.ts +106 -0
- package/src/timeview/registry.ts +207 -0
- package/src/timeview/shared/Caption.tsx +40 -0
- package/src/timeview/shared/EmptyState.tsx +90 -0
- package/src/timeview/shared/Legend.tsx +67 -0
- package/src/timeview/shared/Tooltip.tsx +59 -0
- package/src/timeview/shared/useMeasuredWidth.ts +21 -0
- package/src/timeview/types.ts +159 -0
- package/vite.config.ts +11 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// metric.ts - pure data-space helpers for MetricTimeline.
|
|
2
|
+
|
|
3
|
+
import { DAY_MS, fmt } from "./time";
|
|
4
|
+
import type { TimeDataset, TimeInterval } from "../types";
|
|
5
|
+
|
|
6
|
+
export interface MetricPoint {
|
|
7
|
+
t: number;
|
|
8
|
+
at: string;
|
|
9
|
+
value: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface MetricState extends TimeInterval {
|
|
13
|
+
_s: number;
|
|
14
|
+
_e: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface MetricEvent {
|
|
18
|
+
id: string;
|
|
19
|
+
title: string;
|
|
20
|
+
labelIds?: string[];
|
|
21
|
+
_t: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface MetricTarget {
|
|
25
|
+
value: number;
|
|
26
|
+
label: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface MetricModel {
|
|
30
|
+
samples: MetricPoint[];
|
|
31
|
+
states: MetricState[];
|
|
32
|
+
events: MetricEvent[];
|
|
33
|
+
T0: number;
|
|
34
|
+
T1: number;
|
|
35
|
+
unit: string;
|
|
36
|
+
name: string;
|
|
37
|
+
target: MetricTarget | null;
|
|
38
|
+
vMin: number;
|
|
39
|
+
vMax: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface NiceTicks {
|
|
43
|
+
lo: number;
|
|
44
|
+
hi: number;
|
|
45
|
+
step: number;
|
|
46
|
+
ticks: number[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface MetricTimeTick {
|
|
50
|
+
t: number;
|
|
51
|
+
label: string;
|
|
52
|
+
strong: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface MetricLineSegment {
|
|
56
|
+
a: MetricPoint;
|
|
57
|
+
b: MetricPoint;
|
|
58
|
+
labelId: string | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const METRIC_MIN_SPAN = 5 * DAY_MS;
|
|
62
|
+
|
|
63
|
+
function startOfDayUTC(value: number | string | Date): number {
|
|
64
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
65
|
+
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function tvNiceTicks(min: number, max: number, target = 5): NiceTicks {
|
|
69
|
+
if (!(max > min)) {
|
|
70
|
+
const value = Number.isFinite(min) ? min : 0;
|
|
71
|
+
return { lo: value - 1, hi: value + 1, step: 1, ticks: [value - 1, value, value + 1] };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const raw = (max - min) / Math.max(1, target);
|
|
75
|
+
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
|
|
76
|
+
const n = raw / mag;
|
|
77
|
+
const step = (n >= 5 ? 5 : n >= 2.5 ? 2.5 : n >= 2 ? 2 : n >= 1 ? 1 : 0.5) * mag;
|
|
78
|
+
const lo = Math.floor(min / step) * step;
|
|
79
|
+
const hi = Math.ceil(max / step) * step;
|
|
80
|
+
const ticks: number[] = [];
|
|
81
|
+
for (let value = lo; value <= hi + step * 1e-6; value += step) {
|
|
82
|
+
ticks.push(Math.round(value * 1000) / 1000);
|
|
83
|
+
}
|
|
84
|
+
return { lo, hi, step, ticks };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function tvMetricTimeTicks(v0: number, v1: number): MetricTimeTick[] {
|
|
88
|
+
const days = (v1 - v0) / DAY_MS;
|
|
89
|
+
const ticks: MetricTimeTick[] = [];
|
|
90
|
+
const push = (t: number, label: string, strong: boolean) => {
|
|
91
|
+
if (t >= v0 - 1 && t <= v1 + 1) ticks.push({ t, label, strong });
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
if (days <= 16) {
|
|
95
|
+
const step = days <= 8 ? 1 : 2;
|
|
96
|
+
const base = startOfDayUTC(v0);
|
|
97
|
+
let d = base;
|
|
98
|
+
if (d < v0) d += DAY_MS;
|
|
99
|
+
for (; d <= v1; d += DAY_MS) {
|
|
100
|
+
if (Math.round((d - base) / DAY_MS) % step !== 0) continue;
|
|
101
|
+
const date = new Date(d);
|
|
102
|
+
push(d, fmt.monDay(date), date.getUTCDate() === 1);
|
|
103
|
+
}
|
|
104
|
+
} else if (days <= 70) {
|
|
105
|
+
let d = startOfDayUTC(v0);
|
|
106
|
+
const dow = (new Date(d).getUTCDay() + 6) % 7;
|
|
107
|
+
d += ((7 - dow) % 7) * DAY_MS;
|
|
108
|
+
for (; d <= v1; d += 7 * DAY_MS) {
|
|
109
|
+
const date = new Date(d);
|
|
110
|
+
push(d, fmt.monDay(date), date.getUTCDate() <= 7);
|
|
111
|
+
}
|
|
112
|
+
} else if (days <= 240) {
|
|
113
|
+
let d = Date.UTC(new Date(v0).getUTCFullYear(), new Date(v0).getUTCMonth(), 1);
|
|
114
|
+
while (d <= v1) {
|
|
115
|
+
const date = new Date(d);
|
|
116
|
+
push(d, fmt.mon(date), true);
|
|
117
|
+
push(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 15), "15", false);
|
|
118
|
+
d = Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1);
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
let d = Date.UTC(new Date(v0).getUTCFullYear(), new Date(v0).getUTCMonth(), 1);
|
|
122
|
+
if (d < v0) {
|
|
123
|
+
const date = new Date(d);
|
|
124
|
+
d = Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1);
|
|
125
|
+
}
|
|
126
|
+
while (d <= v1) {
|
|
127
|
+
const date = new Date(d);
|
|
128
|
+
push(d, fmt.mon(date), date.getUTCMonth() === 0);
|
|
129
|
+
d = Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return ticks;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function tvStateAt(states: MetricState[], t: number): MetricState | null {
|
|
136
|
+
return states.find((state) => t >= state._s && t < state._e) || null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function tvMetricModel(dataset: TimeDataset): MetricModel {
|
|
140
|
+
const series = dataset.series;
|
|
141
|
+
const samples = (series?.samples || [])
|
|
142
|
+
.map((point) => ({ t: Date.parse(point.at), at: point.at, value: point.value }))
|
|
143
|
+
.filter((point) => Number.isFinite(point.t) && Number.isFinite(point.value))
|
|
144
|
+
.sort((a, b) => a.t - b.t);
|
|
145
|
+
|
|
146
|
+
const states = (dataset.intervals || [])
|
|
147
|
+
.map((interval) => ({
|
|
148
|
+
...interval,
|
|
149
|
+
_s: Date.parse(interval.range.start),
|
|
150
|
+
_e: startOfDayUTC(interval.range.end) + DAY_MS,
|
|
151
|
+
}))
|
|
152
|
+
.filter((state) => Number.isFinite(state._s) && Number.isFinite(state._e) && state._e > state._s)
|
|
153
|
+
.sort((a, b) => a._s - b._s);
|
|
154
|
+
|
|
155
|
+
const events = (dataset.events || [])
|
|
156
|
+
.map((event) => ({ id: event.id, title: event.title, labelIds: event.labelIds, _t: Date.parse(event.at) }))
|
|
157
|
+
.filter((event) => Number.isFinite(event._t))
|
|
158
|
+
.sort((a, b) => a._t - b._t);
|
|
159
|
+
|
|
160
|
+
const times: number[] = [];
|
|
161
|
+
samples.forEach((point) => times.push(point.t));
|
|
162
|
+
states.forEach((state) => times.push(state._s, state._e));
|
|
163
|
+
events.forEach((event) => times.push(event._t));
|
|
164
|
+
|
|
165
|
+
let T0 = times.length ? Math.min(...times) : Date.UTC(2026, 0, 1);
|
|
166
|
+
let T1 = times.length ? Math.max(...times) : T0 + 30 * DAY_MS;
|
|
167
|
+
if (T1 <= T0) T1 = T0 + DAY_MS;
|
|
168
|
+
|
|
169
|
+
const values = samples.map((point) => point.value);
|
|
170
|
+
return {
|
|
171
|
+
samples,
|
|
172
|
+
states,
|
|
173
|
+
events,
|
|
174
|
+
T0,
|
|
175
|
+
T1,
|
|
176
|
+
unit: series?.unit || "",
|
|
177
|
+
name: series?.name || "Value",
|
|
178
|
+
target: typeof series?.target?.value === "number" ? { value: series.target.value, label: series.target.label || "goal" } : null,
|
|
179
|
+
vMin: values.length ? Math.min(...values) : 0,
|
|
180
|
+
vMax: values.length ? Math.max(...values) : 1,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function tvDefaultViewport(model: MetricModel, days = 90, today: string | null | undefined = null): [number, number] {
|
|
185
|
+
const parsedToday = today ? Date.parse(today) : Number.NaN;
|
|
186
|
+
const end = Number.isFinite(parsedToday) ? Math.min(model.T1, parsedToday) : model.T1;
|
|
187
|
+
const span = Math.min(model.T1 - model.T0, days * DAY_MS);
|
|
188
|
+
let v1 = end;
|
|
189
|
+
let v0 = end - span;
|
|
190
|
+
if (v0 < model.T0) {
|
|
191
|
+
v0 = model.T0;
|
|
192
|
+
v1 = Math.min(model.T1, v0 + span);
|
|
193
|
+
}
|
|
194
|
+
if (v1 > model.T1) {
|
|
195
|
+
v1 = model.T1;
|
|
196
|
+
v0 = Math.max(model.T0, v1 - span);
|
|
197
|
+
}
|
|
198
|
+
if (v1 - v0 < METRIC_MIN_SPAN) v1 = v0 + METRIC_MIN_SPAN;
|
|
199
|
+
return [v0, v1];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function tvClampViewport(v0: number, v1: number, model: MetricModel, mode: "pan" | "zoom" = "pan"): [number, number] {
|
|
203
|
+
const full = model.T1 - model.T0;
|
|
204
|
+
let span = Math.max(METRIC_MIN_SPAN, Math.min(full, v1 - v0));
|
|
205
|
+
if (mode === "pan") span = Math.min(full, v1 - v0);
|
|
206
|
+
let next0 = v0;
|
|
207
|
+
let next1 = next0 + span;
|
|
208
|
+
if (next0 < model.T0) {
|
|
209
|
+
next0 = model.T0;
|
|
210
|
+
next1 = next0 + span;
|
|
211
|
+
}
|
|
212
|
+
if (next1 > model.T1) {
|
|
213
|
+
next1 = model.T1;
|
|
214
|
+
next0 = next1 - span;
|
|
215
|
+
}
|
|
216
|
+
if (next0 < model.T0) next0 = model.T0;
|
|
217
|
+
return [next0, next1];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function tvNearest(samples: MetricPoint[], t: number | Date): MetricPoint | null {
|
|
221
|
+
if (!samples.length) return null;
|
|
222
|
+
const time = t instanceof Date ? t.getTime() : t;
|
|
223
|
+
let lo = 0;
|
|
224
|
+
let hi = samples.length - 1;
|
|
225
|
+
while (lo < hi) {
|
|
226
|
+
const mid = (lo + hi) >> 1;
|
|
227
|
+
if (samples[mid].t < time) lo = mid + 1;
|
|
228
|
+
else hi = mid;
|
|
229
|
+
}
|
|
230
|
+
const a = samples[Math.max(0, lo - 1)];
|
|
231
|
+
const b = samples[lo];
|
|
232
|
+
return Math.abs(a.t - time) <= Math.abs(b.t - time) ? a : b;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function tvLineSegments(samples: MetricPoint[], states: MetricState[]): MetricLineSegment[] {
|
|
236
|
+
const segments: MetricLineSegment[] = [];
|
|
237
|
+
for (let i = 0; i < samples.length - 1; i += 1) {
|
|
238
|
+
const a = samples[i];
|
|
239
|
+
const b = samples[i + 1];
|
|
240
|
+
const boundaries: number[] = [];
|
|
241
|
+
states.forEach((state) => {
|
|
242
|
+
[state._s, state._e].forEach((boundary) => {
|
|
243
|
+
if (boundary > a.t && boundary < b.t) boundaries.push(boundary);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
boundaries.sort((x, y) => x - y);
|
|
247
|
+
let prev = a;
|
|
248
|
+
[...boundaries, b.t].forEach((boundary) => {
|
|
249
|
+
const end =
|
|
250
|
+
boundary === b.t
|
|
251
|
+
? b
|
|
252
|
+
: {
|
|
253
|
+
t: boundary,
|
|
254
|
+
at: new Date(boundary).toISOString(),
|
|
255
|
+
value: a.value + (b.value - a.value) * ((boundary - a.t) / (b.t - a.t)),
|
|
256
|
+
};
|
|
257
|
+
const state = tvStateAt(states, (prev.t + end.t) / 2);
|
|
258
|
+
segments.push({ a: prev, b: end, labelId: state?.labelIds?.[0] || null });
|
|
259
|
+
prev = end;
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return segments;
|
|
263
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// time.ts — shared time-domain helpers for Timeview visualizers.
|
|
2
|
+
|
|
3
|
+
import type { TimeDataset, TimeInput, TvScale } from "../types";
|
|
4
|
+
|
|
5
|
+
export const DAY_MS = 86400000;
|
|
6
|
+
|
|
7
|
+
function asDate(d: TimeInput): Date {
|
|
8
|
+
return d instanceof Date ? d : new Date(d);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function startOfDayUTC(d: TimeInput): number {
|
|
12
|
+
const x = asDate(d);
|
|
13
|
+
return Date.UTC(x.getUTCFullYear(), x.getUTCMonth(), x.getUTCDate());
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function domainOf(dataset: TimeDataset): [number, number] {
|
|
17
|
+
const ts: number[] = [];
|
|
18
|
+
(dataset.events || []).forEach((e) => ts.push(new Date(e.at).getTime()));
|
|
19
|
+
(dataset.intervals || []).forEach((iv) => {
|
|
20
|
+
ts.push(new Date(iv.range.start).getTime());
|
|
21
|
+
ts.push(new Date(iv.range.end).getTime());
|
|
22
|
+
});
|
|
23
|
+
(dataset.series?.samples || []).forEach((sample) => {
|
|
24
|
+
ts.push(new Date(sample.at).getTime());
|
|
25
|
+
});
|
|
26
|
+
if (!ts.length) {
|
|
27
|
+
const now = Date.UTC(2026, 0, 5);
|
|
28
|
+
return [now, now + 24 * DAY_MS];
|
|
29
|
+
}
|
|
30
|
+
const min = Math.min(...ts);
|
|
31
|
+
const max = Math.max(...ts);
|
|
32
|
+
const floor = (t: number) => {
|
|
33
|
+
const d = new Date(t);
|
|
34
|
+
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
|
|
35
|
+
};
|
|
36
|
+
const t0 = floor(min);
|
|
37
|
+
const t1 = floor(max) + DAY_MS;
|
|
38
|
+
return [t0, t1];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Linear scale: maps a time domain [t0,t1] to pixels [0,width]. */
|
|
42
|
+
export function tvScale(t0: TimeInput, t1: TimeInput, width: number): TvScale {
|
|
43
|
+
const a = asDate(t0).getTime();
|
|
44
|
+
const b = asDate(t1).getTime();
|
|
45
|
+
const span = Math.max(1, b - a);
|
|
46
|
+
const fn = ((t: TimeInput) => ((asDate(t).getTime() - a) / span) * width) as TvScale;
|
|
47
|
+
fn.invert = (px: number) => new Date(a + (px / width) * span);
|
|
48
|
+
fn.domain = [a, b];
|
|
49
|
+
fn.width = width;
|
|
50
|
+
return fn;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Every day-tick boundary within [t0,t1] (UTC midnights). */
|
|
54
|
+
export function tvDays(t0: TimeInput, t1: TimeInput): Date[] {
|
|
55
|
+
const out: Date[] = [];
|
|
56
|
+
let d = startOfDayUTC(t0);
|
|
57
|
+
const end = asDate(t1).getTime();
|
|
58
|
+
while (d <= end) {
|
|
59
|
+
out.push(new Date(d));
|
|
60
|
+
d += DAY_MS;
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Week groupings (Mon-start) covering the domain, for the secondary axis. */
|
|
66
|
+
export function tvWeeks(t0: TimeInput, t1: TimeInput): { start: Date; end: Date }[] {
|
|
67
|
+
const out: { start: Date; end: Date }[] = [];
|
|
68
|
+
let d = new Date(startOfDayUTC(t0));
|
|
69
|
+
const dow = (d.getUTCDay() + 6) % 7; // 0 = Monday
|
|
70
|
+
d = new Date(d.getTime() - dow * DAY_MS);
|
|
71
|
+
const end = asDate(t1).getTime();
|
|
72
|
+
while (d.getTime() <= end) {
|
|
73
|
+
const ws = new Date(d.getTime());
|
|
74
|
+
const we = new Date(d.getTime() + 7 * DAY_MS);
|
|
75
|
+
out.push({ start: ws, end: we });
|
|
76
|
+
d = we;
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const MON = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
82
|
+
const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
83
|
+
|
|
84
|
+
export const fmt = {
|
|
85
|
+
day: (d: TimeInput) => asDate(d).getUTCDate(),
|
|
86
|
+
dow: (d: TimeInput) => DOW[asDate(d).getUTCDay()],
|
|
87
|
+
mon: (d: TimeInput) => MON[asDate(d).getUTCMonth()],
|
|
88
|
+
monDay: (d: TimeInput) => `${MON[asDate(d).getUTCMonth()]} ${asDate(d).getUTCDate()}`,
|
|
89
|
+
full: (d: TimeInput) => `${DOW[asDate(d).getUTCDay()]}, ${MON[asDate(d).getUTCMonth()]} ${asDate(d).getUTCDate()}`,
|
|
90
|
+
time: (d: TimeInput): string | null => {
|
|
91
|
+
const x = asDate(d);
|
|
92
|
+
const h = x.getUTCHours();
|
|
93
|
+
const m = x.getUTCMinutes();
|
|
94
|
+
if (h === 0 && m === 0) return null;
|
|
95
|
+
const ap = h >= 12 ? "PM" : "AM";
|
|
96
|
+
const hh = ((h + 11) % 12) + 1;
|
|
97
|
+
return `${hh}:${String(m).padStart(2, "0")} ${ap} UTC`;
|
|
98
|
+
},
|
|
99
|
+
span: (a: TimeInput, b: TimeInput) => {
|
|
100
|
+
const days = Math.max(1, Math.round((startOfDayUTC(b) - startOfDayUTC(a)) / DAY_MS));
|
|
101
|
+
return days === 1 ? "1 day" : `${days} days`;
|
|
102
|
+
},
|
|
103
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { EXPORT_PRESETS, type ExportFormat, type ExportPresetId } from "./export";
|
|
2
|
+
import { normalizeTimeviewConfig, type TimeviewConfigV1, type ValidationResult } from "./config";
|
|
3
|
+
|
|
4
|
+
export interface TimeviewDashboardPanelV1 {
|
|
5
|
+
id: string;
|
|
6
|
+
title: string;
|
|
7
|
+
config: TimeviewConfigV1;
|
|
8
|
+
format: ExportFormat;
|
|
9
|
+
preset: ExportPresetId;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface TimeviewDashboardV1 {
|
|
13
|
+
v: 1;
|
|
14
|
+
title: string;
|
|
15
|
+
generatedAt: string;
|
|
16
|
+
panels: TimeviewDashboardPanelV1[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
20
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isExportFormat(value: unknown): value is ExportFormat {
|
|
24
|
+
return value === "html" || value === "png";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isExportPreset(value: unknown): value is ExportPresetId {
|
|
28
|
+
return typeof value === "string" && value in EXPORT_PRESETS;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function normalizeTimeviewDashboard(raw: unknown): ValidationResult<TimeviewDashboardV1> {
|
|
32
|
+
const errors: string[] = [];
|
|
33
|
+
if (!isRecord(raw)) return { value: null, errors: ["Dashboard root must be a JSON object."] };
|
|
34
|
+
|
|
35
|
+
if (raw.v !== 1) errors.push("Dashboard version must be 1.");
|
|
36
|
+
if (typeof raw.title !== "string" || !raw.title.trim()) errors.push("Dashboard title must be a non-empty string.");
|
|
37
|
+
if (typeof raw.generatedAt !== "string" || !raw.generatedAt.trim()) errors.push("Dashboard generatedAt must be a non-empty string.");
|
|
38
|
+
if (!Array.isArray(raw.panels) || raw.panels.length === 0) errors.push("Dashboard panels must be a non-empty array.");
|
|
39
|
+
|
|
40
|
+
const panels: TimeviewDashboardPanelV1[] = [];
|
|
41
|
+
|
|
42
|
+
if (Array.isArray(raw.panels)) {
|
|
43
|
+
raw.panels.forEach((panel, index) => {
|
|
44
|
+
if (!isRecord(panel)) {
|
|
45
|
+
errors.push(`panels[${index}] must be a JSON object.`);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (typeof panel.id !== "string" || !panel.id.trim()) errors.push(`panels[${index}].id must be a non-empty string.`);
|
|
50
|
+
if (typeof panel.title !== "string" || !panel.title.trim()) errors.push(`panels[${index}].title must be a non-empty string.`);
|
|
51
|
+
if (!isExportFormat(panel.format)) errors.push(`panels[${index}].format must be "png" or "html".`);
|
|
52
|
+
if (!isExportPreset(panel.preset)) errors.push(`panels[${index}].preset must be one of ${Object.keys(EXPORT_PRESETS).join(", ")}.`);
|
|
53
|
+
|
|
54
|
+
const config = normalizeTimeviewConfig(panel.config);
|
|
55
|
+
errors.push(...config.errors.map((error) => `panels[${index}].config: ${error}`));
|
|
56
|
+
|
|
57
|
+
if (config.value && typeof panel.id === "string" && typeof panel.title === "string" && isExportFormat(panel.format) && isExportPreset(panel.preset)) {
|
|
58
|
+
panels.push({
|
|
59
|
+
id: panel.id,
|
|
60
|
+
title: panel.title,
|
|
61
|
+
config: config.value,
|
|
62
|
+
format: panel.format,
|
|
63
|
+
preset: panel.preset,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (errors.length) return { value: null, errors };
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
value: {
|
|
73
|
+
v: 1,
|
|
74
|
+
title: raw.title as string,
|
|
75
|
+
generatedAt: raw.generatedAt as string,
|
|
76
|
+
panels,
|
|
77
|
+
},
|
|
78
|
+
errors: [],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// data.ts — Timeview datasets, palettes, and scale helper re-exports.
|
|
2
|
+
//
|
|
3
|
+
// A TimeDataset is renderer-agnostic: the same data feeds any visualizer.
|
|
4
|
+
// Ported from the design handoff's `data.jsx` (window globals → ES modules).
|
|
5
|
+
|
|
6
|
+
import type { TimeDataset } from "./types";
|
|
7
|
+
import { DAY_MS } from "./core/time";
|
|
8
|
+
export { DAY_MS, domainOf, tvScale, tvDays, tvWeeks, fmt } from "./core/time";
|
|
9
|
+
|
|
10
|
+
// ── Categorical palettes (the label-color tweak picks one) ──────────
|
|
11
|
+
// 8 hues each, ordered. Labels map to a palette slot by index, so the
|
|
12
|
+
// design never bakes one fixture's literal hex into a band.
|
|
13
|
+
export const TV_PALETTES = {
|
|
14
|
+
Studio: ["#2f6fed", "#d97706", "#0f9f6e", "#c2410c", "#7c3aed", "#0891b2", "#db2777", "#475569"],
|
|
15
|
+
Muted: ["#5b7aa8", "#b08147", "#5a8f73", "#a96a4f", "#7e6ba8", "#5f8a96", "#a96d86", "#6b7280"],
|
|
16
|
+
Vivid: ["#3b82f6", "#f59e0b", "#10b981", "#ef4444", "#8b5cf6", "#06b6d4", "#ec4899", "#64748b"],
|
|
17
|
+
Cool: ["#2563eb", "#0ea5e9", "#0d9488", "#7c3aed", "#4f46e5", "#0891b2", "#6366f1", "#475569"],
|
|
18
|
+
} satisfies Record<string, string[]>;
|
|
19
|
+
|
|
20
|
+
// ── Fixtures ────────────────────────────────────────────────────────
|
|
21
|
+
// Each fixture is a full TimeDataset (schema timeview.dataset.v1).
|
|
22
|
+
|
|
23
|
+
const LABELS_DEFAULT = [
|
|
24
|
+
{ id: "focus", name: "Focus" },
|
|
25
|
+
{ id: "travel", name: "Travel" },
|
|
26
|
+
{ id: "qa", name: "QA" },
|
|
27
|
+
{ id: "launch", name: "Launch" },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const FIXTURE_DEFAULT: TimeDataset = {
|
|
31
|
+
schemaVersion: "timeview.dataset.v1",
|
|
32
|
+
timezone: "UTC",
|
|
33
|
+
meta: {
|
|
34
|
+
title: "Launch preparation timeline",
|
|
35
|
+
description: "Focus work, travel, QA, and launch windows.",
|
|
36
|
+
},
|
|
37
|
+
labels: LABELS_DEFAULT,
|
|
38
|
+
events: [
|
|
39
|
+
{ id: "kickoff", at: "2026-01-06T09:00:00Z", title: "Kickoff", labelIds: ["focus"] },
|
|
40
|
+
{ id: "design-freeze", at: "2026-01-16T18:00:00Z", title: "Design freeze", labelIds: ["qa"] },
|
|
41
|
+
{ id: "launch", at: "2026-01-27T15:00:00Z", title: "Launch", labelIds: ["launch"] },
|
|
42
|
+
],
|
|
43
|
+
intervals: [
|
|
44
|
+
{ id: "deep-work", range: { start: "2026-01-06T00:00:00Z", end: "2026-01-18T23:59:59Z" }, title: "Deep work", labelIds: ["focus"] },
|
|
45
|
+
{ id: "travel-week", range: { start: "2026-01-12T00:00:00Z", end: "2026-01-15T23:59:59Z" }, title: "Travel", labelIds: ["travel"] },
|
|
46
|
+
{ id: "qa-window", range: { start: "2026-01-16T00:00:00Z", end: "2026-01-24T23:59:59Z" }, title: "QA window", labelIds: ["qa"] },
|
|
47
|
+
{ id: "launch-window", range: { start: "2026-01-24T00:00:00Z", end: "2026-01-29T23:59:59Z" }, title: "Launch window", labelIds: ["launch"] },
|
|
48
|
+
],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Overlap-heavy: many intervals crossing the same days.
|
|
52
|
+
const FIXTURE_OVERLAP: TimeDataset = {
|
|
53
|
+
schemaVersion: "timeview.dataset.v1",
|
|
54
|
+
timezone: "UTC",
|
|
55
|
+
meta: { title: "Overlapping work streams", description: "Several streams running in parallel across the same days." },
|
|
56
|
+
labels: [
|
|
57
|
+
{ id: "focus", name: "Engineering" },
|
|
58
|
+
{ id: "travel", name: "Design" },
|
|
59
|
+
{ id: "qa", name: "Research" },
|
|
60
|
+
{ id: "launch", name: "Marketing" },
|
|
61
|
+
{ id: "ops", name: "Ops" },
|
|
62
|
+
],
|
|
63
|
+
events: [
|
|
64
|
+
{ id: "e1", at: "2026-01-09T12:00:00Z", title: "Spec sign-off", labelIds: ["focus"] },
|
|
65
|
+
{ id: "e2", at: "2026-01-20T12:00:00Z", title: "Beta cut", labelIds: ["qa"] },
|
|
66
|
+
],
|
|
67
|
+
intervals: [
|
|
68
|
+
{ id: "o1", range: { start: "2026-01-05", end: "2026-01-22" }, title: "Build core", labelIds: ["focus"] },
|
|
69
|
+
{ id: "o2", range: { start: "2026-01-07", end: "2026-01-17" }, title: "Visual design", labelIds: ["travel"] },
|
|
70
|
+
{ id: "o3", range: { start: "2026-01-06", end: "2026-01-14" }, title: "User research", labelIds: ["qa"] },
|
|
71
|
+
{ id: "o4", range: { start: "2026-01-12", end: "2026-01-26" }, title: "Campaign prep", labelIds: ["launch"] },
|
|
72
|
+
{ id: "o5", range: { start: "2026-01-10", end: "2026-01-24" }, title: "Infra hardening", labelIds: ["ops"] },
|
|
73
|
+
{ id: "o6", range: { start: "2026-01-18", end: "2026-01-28" }, title: "Polish & QA", labelIds: ["qa"] },
|
|
74
|
+
],
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Dense: long titles, many short intervals, tight packing.
|
|
78
|
+
const FIXTURE_DENSE: TimeDataset = {
|
|
79
|
+
schemaVersion: "timeview.dataset.v1",
|
|
80
|
+
timezone: "UTC",
|
|
81
|
+
meta: { title: "Dense sprint board", description: "Many short tasks with long names competing for space." },
|
|
82
|
+
labels: [
|
|
83
|
+
{ id: "focus", name: "Engineering" },
|
|
84
|
+
{ id: "travel", name: "Design review" },
|
|
85
|
+
{ id: "qa", name: "Quality assurance" },
|
|
86
|
+
{ id: "launch", name: "Release management" },
|
|
87
|
+
{ id: "ops", name: "Platform operations" },
|
|
88
|
+
{ id: "data", name: "Data & analytics" },
|
|
89
|
+
],
|
|
90
|
+
events: [
|
|
91
|
+
{ id: "d1", at: "2026-01-07", title: "Sprint 1 demo", labelIds: ["focus"] },
|
|
92
|
+
{ id: "d2", at: "2026-01-13", title: "Mid-cycle review", labelIds: ["travel"] },
|
|
93
|
+
{ id: "d3", at: "2026-01-19", title: "Code freeze", labelIds: ["qa"] },
|
|
94
|
+
{ id: "d4", at: "2026-01-26", title: "Go / no-go", labelIds: ["launch"] },
|
|
95
|
+
],
|
|
96
|
+
intervals: [
|
|
97
|
+
{ id: "i1", range: { start: "2026-01-05", end: "2026-01-08" }, title: "Authentication refactor", labelIds: ["focus"] },
|
|
98
|
+
{ id: "i2", range: { start: "2026-01-06", end: "2026-01-09" }, title: "Onboarding redesign exploration", labelIds: ["travel"] },
|
|
99
|
+
{ id: "i3", range: { start: "2026-01-08", end: "2026-01-11" }, title: "Accessibility audit pass", labelIds: ["qa"] },
|
|
100
|
+
{ id: "i4", range: { start: "2026-01-09", end: "2026-01-13" }, title: "Billing migration", labelIds: ["focus"] },
|
|
101
|
+
{ id: "i5", range: { start: "2026-01-10", end: "2026-01-12" }, title: "Marketing site copy", labelIds: ["launch"] },
|
|
102
|
+
{ id: "i6", range: { start: "2026-01-11", end: "2026-01-16" }, title: "Search infrastructure", labelIds: ["ops"] },
|
|
103
|
+
{ id: "i7", range: { start: "2026-01-12", end: "2026-01-15" }, title: "Event tracking instrumentation", labelIds: ["data"] },
|
|
104
|
+
{ id: "i8", range: { start: "2026-01-14", end: "2026-01-18" }, title: "Component library polish", labelIds: ["travel"] },
|
|
105
|
+
{ id: "i9", range: { start: "2026-01-15", end: "2026-01-20" }, title: "Load & performance testing", labelIds: ["qa"] },
|
|
106
|
+
{ id: "i10", range: { start: "2026-01-17", end: "2026-01-22" }, title: "Feature flag rollout", labelIds: ["focus"] },
|
|
107
|
+
{ id: "i11", range: { start: "2026-01-19", end: "2026-01-23" }, title: "Localization sweep", labelIds: ["ops"] },
|
|
108
|
+
{ id: "i12", range: { start: "2026-01-21", end: "2026-01-27" }, title: "Release candidate hardening", labelIds: ["launch"] },
|
|
109
|
+
{ id: "i13", range: { start: "2026-01-22", end: "2026-01-25" }, title: "Analytics dashboards", labelIds: ["data"] },
|
|
110
|
+
{ id: "i14", range: { start: "2026-01-24", end: "2026-01-29" }, title: "Launch comms", labelIds: ["launch"] },
|
|
111
|
+
],
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const FIXTURE_STACKED: TimeDataset = {
|
|
115
|
+
schemaVersion: "timeview.dataset.v1",
|
|
116
|
+
timezone: "UTC",
|
|
117
|
+
meta: { title: "Team coverage & assignments", description: "Overlapping assignments stacking within each team." },
|
|
118
|
+
labels: [
|
|
119
|
+
{ id: "focus", name: "Platform" },
|
|
120
|
+
{ id: "travel", name: "Mobile" },
|
|
121
|
+
{ id: "qa", name: "Support" },
|
|
122
|
+
{ id: "launch", name: "Growth" },
|
|
123
|
+
],
|
|
124
|
+
events: [
|
|
125
|
+
{ id: "s-e1", at: "2026-01-14T12:00:00Z", title: "Coverage checkpoint", labelIds: ["focus"] },
|
|
126
|
+
{ id: "s-e2", at: "2026-01-22T12:00:00Z", title: "Campaign live", labelIds: ["launch"] },
|
|
127
|
+
],
|
|
128
|
+
intervals: [
|
|
129
|
+
{ id: "s1", range: { start: "2026-01-05T00:00:00Z", end: "2026-01-16T23:59:59Z" }, title: "Data migration", labelIds: ["focus"] },
|
|
130
|
+
{ id: "s2", range: { start: "2026-01-10T00:00:00Z", end: "2026-01-20T23:59:59Z" }, title: "On-call rotation", labelIds: ["focus"] },
|
|
131
|
+
{ id: "s3", range: { start: "2026-01-06T00:00:00Z", end: "2026-01-12T23:59:59Z" }, title: "iOS release", labelIds: ["travel"] },
|
|
132
|
+
{ id: "s4", range: { start: "2026-01-13T00:00:00Z", end: "2026-01-19T23:59:59Z" }, title: "Android release", labelIds: ["travel"] },
|
|
133
|
+
{ id: "s5", range: { start: "2026-01-08T00:00:00Z", end: "2026-01-18T23:59:59Z" }, title: "Tier 1 desk", labelIds: ["qa"] },
|
|
134
|
+
{ id: "s6", range: { start: "2026-01-11T00:00:00Z", end: "2026-01-15T23:59:59Z" }, title: "Tier 2 desk", labelIds: ["qa"] },
|
|
135
|
+
{ id: "s7", range: { start: "2026-01-12T00:00:00Z", end: "2026-01-14T23:59:59Z" }, title: "Escalations", labelIds: ["qa"] },
|
|
136
|
+
{ id: "s8", range: { start: "2026-01-16T00:00:00Z", end: "2026-01-24T23:59:59Z" }, title: "Launch campaign", labelIds: ["launch"] },
|
|
137
|
+
],
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const FIXTURE_EMPTY: TimeDataset = {
|
|
141
|
+
schemaVersion: "timeview.dataset.v1",
|
|
142
|
+
timezone: "UTC",
|
|
143
|
+
meta: { title: "Launch preparation timeline", description: "No events or intervals fall in this window yet." },
|
|
144
|
+
labels: LABELS_DEFAULT,
|
|
145
|
+
events: [],
|
|
146
|
+
intervals: [],
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const METRIC_LABELS = [
|
|
150
|
+
{ id: "diet", name: "Diet" },
|
|
151
|
+
{ id: "maintenance", name: "Maintenance" },
|
|
152
|
+
{ id: "off", name: "Off plan" },
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
function seeded(seed: number) {
|
|
156
|
+
let value = seed;
|
|
157
|
+
return () => {
|
|
158
|
+
value = (value * 1664525 + 1013904223) % 4294967296;
|
|
159
|
+
return value / 4294967296;
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function metricSamples(stepDays: number, noise: number, start = "2025-06-10", end = "2026-06-09") {
|
|
164
|
+
const random = seeded(stepDays * 1009 + Math.round(noise * 100));
|
|
165
|
+
const out: { at: string; value: number }[] = [];
|
|
166
|
+
const startMs = Date.parse(start + "T08:00:00Z");
|
|
167
|
+
const endMs = Date.parse(end + "T08:00:00Z");
|
|
168
|
+
const totalDays = Math.max(1, Math.round((endMs - startMs) / DAY_MS));
|
|
169
|
+
|
|
170
|
+
for (let t = startMs; t <= endMs; t += stepDays * DAY_MS) {
|
|
171
|
+
const day = Math.round((t - startMs) / DAY_MS);
|
|
172
|
+
let base = 204 - day * 0.055;
|
|
173
|
+
if (day > 95) base += (day - 95) * 0.032;
|
|
174
|
+
if (day > 164) base -= (day - 164) * 0.07;
|
|
175
|
+
if (day > 270) base += (day - 270) * 0.018;
|
|
176
|
+
const seasonal = Math.sin((day / totalDays) * Math.PI * 4) * 0.9;
|
|
177
|
+
const jitter = (random() - 0.5) * noise;
|
|
178
|
+
out.push({ at: new Date(t).toISOString(), value: Math.round((base + seasonal + jitter) * 10) / 10 });
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const METRIC_INTERVALS = [
|
|
184
|
+
{ id: "phase-1", range: { start: "2025-06-10T00:00:00Z", end: "2025-09-12T23:59:59Z" }, title: "Diet", labelIds: ["diet"] },
|
|
185
|
+
{ id: "phase-2", range: { start: "2025-09-13T00:00:00Z", end: "2025-11-24T23:59:59Z" }, title: "Off plan", labelIds: ["off"] },
|
|
186
|
+
{ id: "phase-3", range: { start: "2025-11-25T00:00:00Z", end: "2026-03-15T23:59:59Z" }, title: "Diet", labelIds: ["diet"] },
|
|
187
|
+
{ id: "phase-4", range: { start: "2026-03-16T00:00:00Z", end: "2026-06-09T23:59:59Z" }, title: "Maintenance", labelIds: ["maintenance"] },
|
|
188
|
+
];
|
|
189
|
+
|
|
190
|
+
const FIXTURE_METRIC: TimeDataset = {
|
|
191
|
+
schemaVersion: "timeview.dataset.v1",
|
|
192
|
+
timezone: "UTC",
|
|
193
|
+
meta: { title: "Weight trend & diet phases", description: "Body weight logs with diet, maintenance, and off-plan periods." },
|
|
194
|
+
labels: METRIC_LABELS,
|
|
195
|
+
events: [
|
|
196
|
+
{ id: "m-goal-start", at: "2025-06-10T08:00:00Z", title: "Cut started", labelIds: ["diet"] },
|
|
197
|
+
{ id: "m-off-plan", at: "2025-09-13T08:00:00Z", title: "Travel break", labelIds: ["off"] },
|
|
198
|
+
{ id: "m-maintenance", at: "2026-03-16T08:00:00Z", title: "Maintenance", labelIds: ["maintenance"] },
|
|
199
|
+
],
|
|
200
|
+
intervals: METRIC_INTERVALS,
|
|
201
|
+
series: {
|
|
202
|
+
id: "weight",
|
|
203
|
+
name: "Weight",
|
|
204
|
+
unit: "lb",
|
|
205
|
+
target: { value: 185, label: "goal" },
|
|
206
|
+
samples: metricSamples(2, 1.8),
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const FIXTURE_METRIC_DENSE: TimeDataset = {
|
|
211
|
+
...FIXTURE_METRIC,
|
|
212
|
+
meta: { title: "Daily weight trend", description: "Dense daily logs across the full history." },
|
|
213
|
+
series: { ...FIXTURE_METRIC.series!, samples: metricSamples(1, 1.5) },
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const FIXTURE_METRIC_SPARSE: TimeDataset = {
|
|
217
|
+
...FIXTURE_METRIC,
|
|
218
|
+
meta: { title: "Weekly weight check-ins", description: "Sparse weekly logs where value labels are useful." },
|
|
219
|
+
series: { ...FIXTURE_METRIC.series!, samples: metricSamples(7, 1.1, "2026-01-06", "2026-06-09") },
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const FIXTURE_METRIC_EMPTY: TimeDataset = {
|
|
223
|
+
...FIXTURE_METRIC,
|
|
224
|
+
meta: { title: "Weight trend & diet phases", description: "No measurements have been logged yet." },
|
|
225
|
+
events: [],
|
|
226
|
+
intervals: [],
|
|
227
|
+
series: { ...FIXTURE_METRIC.series!, samples: [] },
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
export const TV_DATA = {
|
|
231
|
+
default: FIXTURE_DEFAULT,
|
|
232
|
+
overlap: FIXTURE_OVERLAP,
|
|
233
|
+
dense: FIXTURE_DENSE,
|
|
234
|
+
stacked: FIXTURE_STACKED,
|
|
235
|
+
empty: FIXTURE_EMPTY,
|
|
236
|
+
metric: FIXTURE_METRIC,
|
|
237
|
+
metricDense: FIXTURE_METRIC_DENSE,
|
|
238
|
+
metricSparse: FIXTURE_METRIC_SPARSE,
|
|
239
|
+
metricEmpty: FIXTURE_METRIC_EMPTY,
|
|
240
|
+
} satisfies Record<string, TimeDataset>;
|
|
241
|
+
|
|
242
|
+
export type FixtureKey = keyof typeof TV_DATA;
|