diff --git a/dashboard/src/components/Sidebar.tsx b/dashboard/src/components/Sidebar.tsx index 1dc2ec41..8e0b6c20 100644 --- a/dashboard/src/components/Sidebar.tsx +++ b/dashboard/src/components/Sidebar.tsx @@ -8,14 +8,13 @@ import { useVehicleList, } from "@/lib/store"; import { + Activity, Briefcase, Bug, CarFront, ChevronsUpDown, LayoutDashboard, LucideIcon, - MapPinned, - SearchCode, Settings, } from "lucide-react"; import { @@ -285,29 +284,15 @@ const Sidebar = (props: SidebarProps) => { isSidebarExpanded={props.isSidebarExpanded} /> -
- {
+ -
-
- {selected ? ( - - ) : ( - - )} -
-
-
{signal.name == "" ? signal.id : signal.name}
-
{signal.id}
-
-
- - ); -} diff --git a/dashboard/src/components/query/TripCard.tsx b/dashboard/src/components/query/TripCard.tsx deleted file mode 100644 index 8144a47d..00000000 --- a/dashboard/src/components/query/TripCard.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Trip } from "@/models/trip"; -import { Card } from "@/components/ui/card"; -import { CheckCircle2, Circle } from "lucide-react"; - -interface TripCardProps { - trip: Trip; - selected: boolean; -} - -export function TripCard({ trip, selected }: TripCardProps) { - return ( - -
-
- {selected ? ( - - ) : ( - - )} -
-
-
{trip.name}
-
- {new Date(trip.start_time).toLocaleString()} -{" "} - {new Date(trip.end_time).toLocaleTimeString()} -
-
-
-
- ); -} diff --git a/dashboard/src/components/signals/ChartTypeToggle.tsx b/dashboard/src/components/signals/ChartTypeToggle.tsx new file mode 100644 index 00000000..6c3a642d --- /dev/null +++ b/dashboard/src/components/signals/ChartTypeToggle.tsx @@ -0,0 +1,52 @@ +import { cn } from "@/lib/utils"; +import { AreaChart, BarChart3, LineChart } from "lucide-react"; + +export type ChartType = "bar" | "line" | "area"; + +const OPTIONS: { type: ChartType; icon: typeof BarChart3; title: string }[] = [ + { type: "bar", icon: BarChart3, title: "Bar" }, + { type: "line", icon: LineChart, title: "Line" }, + { type: "area", icon: AreaChart, title: "Area" }, +]; + +interface ChartTypeToggleProps { + value: ChartType; + onChange: (next: ChartType) => void; + className?: string; +} + +export function ChartTypeToggle({ + value, + onChange, + className, +}: ChartTypeToggleProps) { + return ( +
+ {OPTIONS.map(({ type, icon: Icon, title }) => { + const active = value === type; + return ( + + ); + })} +
+ ); +} diff --git a/dashboard/src/components/signals/QueryBuilder.tsx b/dashboard/src/components/signals/QueryBuilder.tsx new file mode 100644 index 00000000..c157cf13 --- /dev/null +++ b/dashboard/src/components/signals/QueryBuilder.tsx @@ -0,0 +1,526 @@ +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + AGGREGATORS, + type Aggregator, + COUNT_FIELD, + defaultFieldFor, + type FieldName, + type FilterColumn, + FILTERABLE_COLUMNS, + type GroupColumn, + GROUPABLE_COLUMNS, + NUMERIC_FIELDS, + type Predicate, + type Query, + type Rollup, + ROLLUP_INTERVALS, + ROW_COUNT_AGGS, + serializeQuery, +} from "@/lib/query"; +import { cn } from "@/lib/utils"; +import Fuse from "fuse.js"; +import { ChevronDown, Plus, X } from "lucide-react"; +import { useMemo, useState } from "react"; + +interface QueryBuilderProps { + value: Query; + onChange: (next: Query) => void; + /** Available signal names for the filter-value autocomplete. Pulled from + * the page's existing `/query/signals` fetch so the picker and the + * table never disagree about what exists. */ + signalNames: string[]; + /** Parser/execution error from the most recent run. Surfaced under the + * serialized preview; the builder itself stays interactive so the user + * can keep iterating. */ + error?: { message: string; position?: number } | null; +} + +export function QueryBuilder({ + value, + onChange, + signalNames, + error, +}: QueryBuilderProps) { + const fieldOptions: FieldName[] = ROW_COUNT_AGGS.has(value.fn) + ? [COUNT_FIELD] + : NUMERIC_FIELDS; + + function setFn(fn: Aggregator) { + // Swapping aggregator classes (count ↔ avg/sum/...) invalidates the + // current field. Reset to the canonical default to keep the AST + // valid without a separate "field is wrong" error state. + const fieldClassChanged = + ROW_COUNT_AGGS.has(fn) !== ROW_COUNT_AGGS.has(value.fn); + onChange({ + ...value, + fn, + field: fieldClassChanged ? defaultFieldFor(fn) : value.field, + }); + } + + function setField(field: FieldName) { + onChange({ ...value, field }); + } + + function addFilter() { + onChange({ + ...value, + filters: [ + ...value.filters, + { column: "name", op: "=", value: "" }, + ], + }); + } + + function updateFilter(i: number, next: Predicate) { + const filters = [...value.filters]; + filters[i] = next; + onChange({ ...value, filters }); + } + + function removeFilter(i: number) { + onChange({ + ...value, + filters: value.filters.filter((_, idx) => idx !== i), + }); + } + + function addGroup() { + // Only one groupable column today; if it's already in the list bail + // out instead of creating a duplicate the SQL would reject anyway. + const next = GROUPABLE_COLUMNS.find((c) => !value.groupBy.includes(c)); + if (!next) return; + onChange({ ...value, groupBy: [...value.groupBy, next] }); + } + + function removeGroup(col: GroupColumn) { + onChange({ + ...value, + groupBy: value.groupBy.filter((c) => c !== col), + }); + } + + function setRollup(next: Rollup | undefined) { + // Pass an explicit undefined to clear instead of leaving rollup + // hanging around as an empty string in the AST. + const { rollup: _drop, ...rest } = value; + onChange(next ? { ...rest, rollup: next } : rest); + } + + return ( +
+
+ ({ + value: a.value, + label: a.label, + }))} + onSelect={(v) => setFn(v as Aggregator)} + /> + ({ value: f, label: f }))} + onSelect={(v) => setField(v as FieldName)} + disabled={fieldOptions.length === 1} + /> + + + from + + {value.filters.map((pred, i) => { + // Adjacent filters on the same column union (OR semantics); + // show a tiny "or" between them so the user sees this rather + // than reading the visual sequence as AND. + const prev = i > 0 ? value.filters[i - 1] : null; + const sameColAsPrev = prev !== null && prev.column === pred.column; + return ( + + {sameColAsPrev ? ( + + or + + ) : null} + updateFilter(i, next)} + onRemove={() => removeFilter(i)} + signalNames={signalNames} + /> + + ); + })} + + + + by + + {value.groupBy.map((col) => ( + removeGroup(col)} /> + ))} + {value.groupBy.length < GROUPABLE_COLUMNS.length ? ( + + ) : null} + + + rollup + + +
+ + + {serializeQuery(value)} + + {error ? ( +

+ {error.message} + {typeof error.position === "number" + ? ` (col ${error.position + 1})` + : ""} +

+ ) : null} +
+ ); +} + +// --------------------------------------------------------------------------- +// Chip primitives +// --------------------------------------------------------------------------- + +const CHIP_BASE = + "inline-flex h-7 items-center gap-1 rounded-md border bg-background px-2 text-xs font-mono transition-colors"; + +function SelectChip({ + label, + options, + onSelect, + disabled, +}: { + label: string; + options: { value: string; label: string }[]; + onSelect: (value: string) => void; + disabled?: boolean; +}) { + const [open, setOpen] = useState(false); + if (disabled) { + return ( + + {label} + + ); + } + return ( + + + + + + {options.map((o) => ( + + ))} + + + ); +} + +function AddChip({ label, onClick }: { label: string; onClick: () => void }) { + return ( + + ); +} + +function RollupChip({ + value, + onChange, +}: { + value: Rollup | undefined; + onChange: (next: Rollup | undefined) => void; +}) { + const [open, setOpen] = useState(false); + const label = value ?? "auto"; + const isAuto = !value; + return ( + + + + + + +
+ {ROLLUP_INTERVALS.map((iv) => ( + + ))} + + + ); +} + +function GroupChip({ + column, + onRemove, +}: { + column: GroupColumn; + onRemove: () => void; +}) { + return ( + + {column} + + + ); +} + +function FilterChip({ + value, + onChange, + onRemove, + signalNames, +}: { + value: Predicate; + onChange: (next: Predicate) => void; + onRemove: () => void; + signalNames: string[]; +}) { + // Open the popover automatically when the chip is freshly added (empty + // value) so the user doesn't have to click again to start typing. + const [open, setOpen] = useState(value.value === ""); + + const display = value.value + ? `${value.column} = "${value.value}"` + : `${value.column} = …`; + + return ( + + + + + + setOpen(false)} + /> + + + ); +} + +function FilterEditor({ + value, + onChange, + signalNames, + onCommit, +}: { + value: Predicate; + onChange: (next: Predicate) => void; + signalNames: string[]; + onCommit: () => void; +}) { + const [search, setSearch] = useState(value.value); + + const fuse = useMemo( + () => + new Fuse(signalNames, { + threshold: 0.3, + ignoreLocation: true, + }), + [signalNames], + ); + + const hasWildcard = search.includes("*"); + + const matches = useMemo(() => { + const q = search.trim(); + if (!q) return signalNames.slice(0, 50); + if (hasWildcard) { + // Compile the wildcard pattern to a regex so the preview list + // shows what would actually match on the backend (`*` ⇒ any run + // of characters). Anchor with ^…$ to mirror LIKE's full-string + // semantics — `bcu_*_temp` shouldn't match `prefix_bcu_x_temp`. + const escaped = q + .replace(/[.+?^${}()|[\]\\]/g, "\\$&") // escape regex metachars + .replace(/\*/g, ".*"); + try { + const rx = new RegExp(`^${escaped}$`, "i"); + return signalNames.filter((n) => rx.test(n)).slice(0, 50); + } catch { + return []; + } + } + return fuse.search(q).slice(0, 50).map((r) => r.item); + }, [search, signalNames, fuse, hasWildcard]); + + return ( +
+
+ ({ value: c, label: c }))} + onSelect={(c) => + onChange({ ...value, column: c as FilterColumn }) + } + /> + = + setSearch(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + // Wildcards commit the literal pattern (we want LIKE + // semantics, not "pick the first match"). Otherwise prefer + // the top fuzzy match — saves a click when the user typed + // an exact-ish prefix. + const pick = hasWildcard + ? search.trim() + : (matches[0] ?? search.trim()); + if (!pick) return; + onChange({ ...value, value: pick }); + onCommit(); + } else if (e.key === "Escape") { + onCommit(); + } + }} + placeholder="signal name (use * for wildcards)" + className="h-8 font-mono text-xs" + /> + {hasWildcard ? ( + + {matches.length} match + {matches.length === 1 ? "" : "es"} + + ) : null} +
+
+ {matches.length === 0 ? ( +
+ No matches +
+ ) : ( + matches.map((name) => ( + + )) + )} +
+
+ ); +} diff --git a/dashboard/src/components/signals/QueryChart.tsx b/dashboard/src/components/signals/QueryChart.tsx new file mode 100644 index 00000000..a5b20965 --- /dev/null +++ b/dashboard/src/components/signals/QueryChart.tsx @@ -0,0 +1,378 @@ +import { Button } from "@/components/ui/button"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from "@/components/ui/chart"; +import { AlertTriangle } from "lucide-react"; +import { useMemo, useState } from "react"; +import { + Area, + AreaChart, + Bar, + BarChart, + CartesianGrid, + Line, + LineChart, + ReferenceArea, + XAxis, + YAxis, +} from "recharts"; +import type { ChartType } from "./ChartTypeToggle"; + +export interface SeriesPoint { + bucket: string; + value: number | null; +} + +export interface Series { + tags: Record; + points: SeriesPoint[]; +} + +interface QueryChartProps { + series: Series[]; + type: ChartType; + /** Number of seconds per bucket — drives the x-axis tick formatting. */ + intervalSec: number; + /** Max series shown before everything beyond gets rolled into "other". + * Past ~12 the legend stops being useful. */ + maxSeries?: number; + /** If set, click-and-drag on the chart highlights a range and commits + * it on mouse-up. Receives the [start, end) of the brushed window. */ + onBrushSelect?: (start: Date, end: Date) => void; +} + +// Stable, high-contrast palette. Datadog-ish saturated colors that survive +// dark backgrounds — first slot deliberately matches our existing gr-pink +// so single-series bars don't change color when you flip into multi-series. +const PALETTE = [ + "#e105a3", + "#8412fc", + "#10b981", + "#f59e0b", + "#3b82f6", + "#ef4444", + "#06b6d4", + "#84cc16", + "#a855f7", + "#ec4899", + "#14b8a6", + "#f97316", +]; + +const OTHER_KEY = "__other__"; + +function formatBucketTick(iso: string, intervalSec: number): string { + const d = new Date(iso); + if (intervalSec >= 24 * 60 * 60) { + return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + } + if (intervalSec >= 60 * 60) { + return d.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + }); + } + if (intervalSec >= 60) { + return d.toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + }); + } + // Sub-minute buckets need seconds — otherwise every tick reads the + // same "1:23 PM" and the user can't tell them apart. + return d.toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + second: "2-digit", + }); +} + +function formatCount(n: number): string { + const abs = Math.abs(n); + if (abs < 1_000) return Number.isInteger(n) ? n.toString() : n.toFixed(2); + if (abs < 1_000_000) return `${(n / 1_000).toFixed(1)}k`; + return `${(n / 1_000_000).toFixed(2)}M`; +} + +/** Make a stable label for a series from its tag values. Empty tags → + * "value" so the single-series legend reads naturally. */ +function seriesLabel(tags: Record): string { + const entries = Object.entries(tags); + if (entries.length === 0) return "value"; + return entries.map(([, v]) => v ?? "—").join(" · "); +} + +/** Sum every point in a series — for top-K ranking. Null values are 0. */ +function seriesTotal(s: Series): number { + let acc = 0; + for (const p of s.points) acc += p.value ?? 0; + return acc; +} + +/** Roll any series past `max` into a single "other" bucket so the chart + * stays readable when a query produces hundreds of groups. */ +function topK(series: Series[], max: number): { kept: Series[]; otherCount: number } { + if (series.length <= max) return { kept: series, otherCount: 0 }; + const sorted = [...series].sort((a, b) => seriesTotal(b) - seriesTotal(a)); + const kept = sorted.slice(0, max); + const tail = sorted.slice(max); + // Build the "other" series by summing point-by-point across the tail. + // Every series shares the same bucket axis (server zero-fills), so a + // simple index walk is correct. + const refBuckets = sorted[0]?.points ?? []; + const otherPoints: SeriesPoint[] = refBuckets.map((p, i) => { + let sum = 0; + for (const s of tail) sum += s.points[i]?.value ?? 0; + return { bucket: p.bucket, value: sum }; + }); + kept.push({ tags: { [OTHER_KEY]: `+${tail.length} other` }, points: otherPoints }); + return { kept, otherCount: tail.length }; +} + +export function QueryChart({ + series, + type, + intervalSec, + maxSeries = 10, + onBrushSelect, +}: QueryChartProps) { + // Brush state. `start` is fixed on mousedown; `current` follows the + // mouse and renders the live highlight. We commit on mouseup when the + // two are distinct buckets — a same-bucket release is treated as a + // click (tooltip), not a selection, so single clicks keep working. + const [brushStart, setBrushStart] = useState(null); + const [brushCurrent, setBrushCurrent] = useState(null); + + type ChartMouseEvent = { activeLabel?: string | number | null }; + const handleMouseDown = onBrushSelect + ? (e: ChartMouseEvent | null) => { + const label = e?.activeLabel; + if (typeof label === "string") { + setBrushStart(label); + setBrushCurrent(label); + } + } + : undefined; + const handleMouseMove = onBrushSelect + ? (e: ChartMouseEvent | null) => { + if (brushStart === null) return; + const label = e?.activeLabel; + if (typeof label === "string") setBrushCurrent(label); + } + : undefined; + const handleMouseUp = onBrushSelect + ? () => { + if (brushStart !== null && brushCurrent !== null && brushStart !== brushCurrent) { + const a = new Date(brushStart); + const b = new Date(brushCurrent); + const [start, end] = a < b ? [a, b] : [b, a]; + // Extend `end` to the *end* of its bucket so a brush that + // visually covers a bar actually includes that bar's data. + // intervalSec is the bucket width in seconds. + onBrushSelect( + start, + new Date(end.getTime() + intervalSec * 1000), + ); + } + setBrushStart(null); + setBrushCurrent(null); + } + : undefined; + // Cancel an in-progress drag if the cursor leaves the chart — avoids + // a stuck highlight when the user releases the button off-chart. + const handleMouseLeave = onBrushSelect + ? () => { + setBrushStart(null); + setBrushCurrent(null); + } + : undefined; + // Top-K rollup before any other shaping; bar/area would stack hundreds + // of slivers otherwise. + const { kept } = useMemo(() => topK(series, maxSeries), [series, maxSeries]); + + // Pivot tall → wide so recharts can render multiple series from one + // dataset. Each row: { bucket, [seriesKey1]: value1, [seriesKey2]: ... }. + // Series keys are array indices ("s0", "s1", ...) so we never collide on + // user-provided values like "name". + const { data, seriesKeys, config } = useMemo(() => { + const seriesKeys: { key: string; label: string; color: string }[] = kept.map( + (s, i) => ({ + key: `s${i}`, + label: seriesLabel(s.tags), + color: PALETTE[i % PALETTE.length], + }), + ); + const config: ChartConfig = Object.fromEntries( + seriesKeys.map(({ key, label, color }) => [key, { label, color }]), + ); + const buckets = kept[0]?.points.map((p) => p.bucket) ?? []; + const data = buckets.map((bucket, i) => { + const row: Record = { bucket }; + kept.forEach((s, sIdx) => { + row[`s${sIdx}`] = s.points[i]?.value ?? 0; + }); + return row; + }); + return { data, seriesKeys, config }; + }, [kept]); + + const isMulti = seriesKeys.length > 1; + // Bar/area stack by default in multi-series; line doesn't (lines stacked + // on top of each other are unreadable). Single-series ignores stackId. + const stackId = isMulti && type !== "line" ? "stack" : undefined; + + // Safety rail. Recharts is SVG-based — each bucket × series renders a + // DOM element, and the browser starts choking past ~20k of them. Bar + // charts hit this hardest (one per bar); line charts only render + // one per series so they're cheap. We treat any chart type the + // same here for simplicity — if 20k is too conservative for line later, + // we can split the threshold by type. + const RENDER_LIMIT = 20_000; + const renderElementCount = data.length * Math.max(1, seriesKeys.length); + const renderSig = `${data.length}x${seriesKeys.length}`; + const [confirmedSig, setConfirmedSig] = useState(null); + const needsConfirm = + renderElementCount > RENDER_LIMIT && confirmedSig !== renderSig; + + if (data.length === 0) { + return ( +
+ No data in this window +
+ ); + } + + if (needsConfirm) { + return ( +
+ +
+

+ Large render —{" "} + {data.length.toLocaleString()} buckets ×{" "} + {seriesKeys.length} series ={" "} + {renderElementCount.toLocaleString()} elements +

+

+ Past about {RENDER_LIMIT.toLocaleString()} SVG elements the browser + tab can hang. Pick a coarser rollup or a narrower timeframe, or + render anyway. +

+
+ +
+ ); + } + + const commonAxes = ( + <> + + formatBucketTick(v, intervalSec)} + tickLine={false} + axisLine={false} + minTickGap={32} + /> + + new Date(v as string).toLocaleString()} + /> + } + /> + + ); + + // Shared props for whichever chart variant we render below. + const chartProps = { + data, + margin: { top: 8, right: 8, left: -16, bottom: 0 }, + onMouseDown: handleMouseDown, + onMouseMove: handleMouseMove, + onMouseUp: handleMouseUp, + onMouseLeave: handleMouseLeave, + // Drag-to-select feels wrong with text selection happening underneath. + style: onBrushSelect ? { userSelect: "none" as const, cursor: "crosshair" as const } : undefined, + }; + + const brushHighlight = + brushStart !== null && brushCurrent !== null && brushStart !== brushCurrent ? ( + + ) : null; + + return ( + + {type === "bar" ? ( + + {commonAxes} + {seriesKeys.map(({ key }) => ( + + ))} + {brushHighlight} + + ) : type === "line" ? ( + + {commonAxes} + {seriesKeys.map(({ key }) => ( + + ))} + {brushHighlight} + + ) : ( + + {commonAxes} + {seriesKeys.map(({ key }) => ( + + ))} + {brushHighlight} + + )} + + ); +} diff --git a/dashboard/src/components/signals/TimeframePicker.tsx b/dashboard/src/components/signals/TimeframePicker.tsx new file mode 100644 index 00000000..257b48b4 --- /dev/null +++ b/dashboard/src/components/signals/TimeframePicker.tsx @@ -0,0 +1,317 @@ +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import { Clock } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +/** The page-level time window. Always absolute under the hood — presets + * just snap start/end to a "now-anchored" pair at the moment they're + * picked. `label` is metadata for the chip display ("Past 1 week", + * "Past 45 minutes", or "Custom" when the user dragged on the chart). */ +export interface Timeframe { + start: Date; + end: Date; + label: string; +} + +export interface TimeframePreset { + label: string; + shortcut: string; + rangeSeconds: number; +} + +export const TIMEFRAME_PRESETS: TimeframePreset[] = [ + { label: "Past 1 minute", shortcut: "1m", rangeSeconds: 60 }, + { label: "Past 15 minutes", shortcut: "15m", rangeSeconds: 15 * 60 }, + { label: "Past 30 minutes", shortcut: "30m", rangeSeconds: 30 * 60 }, + { label: "Past 1 hour", shortcut: "1h", rangeSeconds: 60 * 60 }, + { label: "Past 4 hours", shortcut: "4h", rangeSeconds: 4 * 60 * 60 }, + { label: "Past 1 day", shortcut: "1d", rangeSeconds: 24 * 60 * 60 }, + { label: "Past 2 days", shortcut: "2d", rangeSeconds: 2 * 24 * 60 * 60 }, + { label: "Past 1 week", shortcut: "1w", rangeSeconds: 7 * 24 * 60 * 60 }, +]; + +// Aliases mapped to seconds-per-unit. Plural / abbreviated variants all +// collapse to the same multiplier so users can type whatever feels natural. +const UNIT_SECONDS: Record = { + s: 1, sec: 1, secs: 1, second: 1, seconds: 1, + m: 60, min: 60, mins: 60, minute: 60, minutes: 60, + h: 3600, hr: 3600, hrs: 3600, hour: 3600, hours: 3600, + d: 86400, day: 86400, days: 86400, + w: 604800, wk: 604800, week: 604800, weeks: 604800, +}; + +const SHORTCUT_RX = /^(\d+)\s*([a-z]+)$/; + +// Absolute-range parser. Canonical separator is " - " (space-dash-space) +// since it's easy to type and not conflicting with the date format's +// internal dashes. We also accept `→`, `->`, and ` to ` as input tolerance +// — output always uses " - ". Times are interpreted in the user's local +// timezone — that's what `new Date(y, m, d, h, min)` does by default and +// matches the chip's display. +const RANGE_SEPARATOR_RX = /\s+(?:-|→|->|to)\s+/i; +const ABS_DT_RX = /^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}):(\d{2})(?::(\d{2}))?)?$/; + +function parseAbsoluteDatetime(s: string): Date | null { + const m = s.trim().match(ABS_DT_RX); + if (!m) return null; + const [, y, mo, d, h, mi, se] = m; + const dt = new Date( + Number(y), + Number(mo) - 1, + Number(d), + Number(h ?? "0"), + Number(mi ?? "0"), + Number(se ?? "0"), + ); + return isNaN(dt.getTime()) ? null : dt; +} + +function parseAbsoluteRange(input: string): Timeframe | null { + // Split on the first separator match; if there's no separator we're + // not looking at an absolute range and fall through to shortcuts. + const parts = input.split(RANGE_SEPARATOR_RX); + if (parts.length !== 2) return null; + const start = parseAbsoluteDatetime(parts[0]); + const end = parseAbsoluteDatetime(parts[1]); + if (!start || !end || start >= end) return null; + return { start, end, label: "Custom" }; +} + +/** Build a now-anchored relative timeframe with the given preset label. */ +export function relativeTimeframe( + rangeSeconds: number, + label: string, +): Timeframe { + const end = new Date(); + const start = new Date(end.getTime() - rangeSeconds * 1000); + return { start, end, label }; +} + +export function defaultTimeframe(): Timeframe { + return relativeTimeframe(7 * 24 * 60 * 60, "Past 1 week"); +} + +/** Parse user input into a Timeframe. Accepts three forms, tried in + * order: absolute range (`YYYY-MM-DD HH:MM - YYYY-MM-DD HH:MM`), preset + * label or shortcut (`Past 1 week`, `1w`), and ad-hoc shortcut (`45m`). + * Returns null if none match. */ +export function parseTimeframeInput(input: string): Timeframe | null { + const raw = input.trim(); + if (!raw) return null; + + // Absolute range first — it's the only form that can contain + // whitespace internally, so the cheap match is unambiguous. + const abs = parseAbsoluteRange(raw); + if (abs) return abs; + + const s = raw.toLowerCase(); + for (const p of TIMEFRAME_PRESETS) { + if (s === p.label.toLowerCase() || s === p.shortcut) { + return relativeTimeframe(p.rangeSeconds, p.label); + } + } + const m = s.match(SHORTCUT_RX); + if (m) { + const n = parseInt(m[1], 10); + const mult = UNIT_SECONDS[m[2]]; + if (n > 0 && mult) { + const seconds = n * mult; + const label = `Past ${formatDuration(seconds)}`; + return relativeTimeframe(seconds, label); + } + } + return null; +} + +function formatDuration(s: number): string { + if (s % 604800 === 0) return plural(s / 604800, "week"); + if (s % 86400 === 0) return plural(s / 86400, "day"); + if (s % 3600 === 0) return plural(s / 3600, "hour"); + if (s % 60 === 0) return plural(s / 60, "minute"); + return plural(s, "second"); +} + +function plural(n: number, unit: string): string { + return `${n} ${unit}${n === 1 ? "" : "s"}`; +} + +/** Compact local-time formatting for the chip's range display. Shows the + * date prefix only when the range crosses a day boundary, so a 1-hour + * range reads "1:00 PM - 2:00 PM" without redundant "Jun 10" on both + * sides. */ +function formatRange(start: Date, end: Date): string { + const sameDay = + start.getFullYear() === end.getFullYear() && + start.getMonth() === end.getMonth() && + start.getDate() === end.getDate(); + const time: Intl.DateTimeFormatOptions = { + hour: "numeric", + minute: "2-digit", + }; + const dateTime: Intl.DateTimeFormatOptions = { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }; + if (sameDay) { + const date = start.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); + return `${date}, ${start.toLocaleTimeString(undefined, time)} - ${end.toLocaleTimeString(undefined, time)}`; + } + return `${start.toLocaleString(undefined, dateTime)} - ${end.toLocaleString(undefined, dateTime)}`; +} + +/** Editable form: `YYYY-MM-DD HH:MM - YYYY-MM-DD HH:MM`, local time. Round + * trips through `parseAbsoluteRange` so what the user sees in the input + * is exactly what they'd type to reproduce it. */ +function formatRangeForInput(start: Date, end: Date): string { + return `${formatLocalDT(start)} - ${formatLocalDT(end)}`; +} + +function formatLocalDT(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + const h = String(d.getHours()).padStart(2, "0"); + const min = String(d.getMinutes()).padStart(2, "0"); + return `${y}-${m}-${day} ${h}:${min}`; +} + +interface TimeframePickerProps { + value: Timeframe; + onChange: (next: Timeframe) => void; + className?: string; +} + +export function TimeframePicker({ + value, + onChange, + className, +}: TimeframePickerProps) { + const [editing, setEditing] = useState(false); + const [input, setInput] = useState(""); + const [error, setError] = useState(false); + const containerRef = useRef(null); + const inputRef = useRef(null); + + useEffect(() => { + if (!editing) return; + // Always seed with the absolute range so the user can tweak either + // side directly. Typing a shortcut like `1h` still works — the + // parser tries the absolute form first, then falls back to presets + // and shortcuts. + setInput(formatRangeForInput(value.start, value.end)); + setError(false); + const t = setTimeout(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, 0); + return () => clearTimeout(t); + }, [editing, value]); + + useEffect(() => { + if (!editing) return; + function onMouseDown(e: MouseEvent) { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + setEditing(false); + setError(false); + } + } + document.addEventListener("mousedown", onMouseDown); + return () => document.removeEventListener("mousedown", onMouseDown); + }, [editing]); + + function commit(tf: Timeframe) { + onChange(tf); + setEditing(false); + setError(false); + } + + function tryCommit() { + const parsed = parseTimeframeInput(input); + if (parsed === null) { + setError(true); + return; + } + commit(parsed); + } + + if (!editing) { + return ( + + ); + } + + return ( +
+ { + setInput(e.target.value); + setError(false); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + tryCommit(); + } else if (e.key === "Escape") { + setEditing(false); + setError(false); + } + }} + placeholder="1h, 2d, or 2026-06-03 14:00 - 2026-06-08 09:00" + className={cn( + "h-12 font-mono text-xs", + error && "border-destructive focus-visible:ring-destructive", + )} + /> +
+ {TIMEFRAME_PRESETS.map((p) => ( + + ))} +
+
+ ); +} diff --git a/dashboard/src/components/trips/TripDetailsDialog.tsx b/dashboard/src/components/trips/TripDetailsDialog.tsx deleted file mode 100644 index 254a0fd1..00000000 --- a/dashboard/src/components/trips/TripDetailsDialog.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Trip, initTrip } from "@/models/trip"; -import { formatTime } from "@/lib/utils"; -import { Clock, MapPin, Hash, Edit2 } from "lucide-react"; -import { Separator } from "@/components/ui/separator"; -import { Dialog, DialogContent, DialogTitle } from "../ui/dialog"; -import { notify } from "@/lib/notify"; -import { BACKEND_URL } from "@/consts/config"; -import axios from "axios"; -import { getAxiosErrorMessage } from "@/lib/axios-error-handler"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; -import { useState, ChangeEvent } from "react"; -import { OutlineButton } from "@/components/ui/outline-button"; -import { useVehicle } from "@/lib/store"; - -interface TripDetailsDialogProps { - trip: Trip; - tripDetailsOpen: boolean; - setTripDetailsOpen: (open: boolean) => void; -} - -export function TripDetailsDialog({ - trip, - tripDetailsOpen, - setTripDetailsOpen, -}: TripDetailsDialogProps) { - const vehicle = useVehicle(); - - const [isEditing, setIsEditing] = useState(false); - const [editedTrip, setEditedTrip] = useState(initTrip); - - // Calculate duration in milliseconds - const duration = () => { - const start = new Date(trip.start_time).getTime(); - const end = new Date(trip.end_time).getTime(); - return end - start; - }; - - const updateTrip = async () => { - try { - const response = await axios.post( - `${BACKEND_URL}/sessions/${trip.id}`, - editedTrip, - { - headers: { - Authorization: `Bearer ${localStorage.getItem("sentinel_access_token")}`, - }, - }, - ); - if (response.status == 200) { - notify.success("Updated trip successfully"); - setIsEditing(false); - window.location.reload(); - } - } catch (error) { - notify.error(getAxiosErrorMessage(error)); - } - }; - - const handleEdit = () => { - setEditedTrip(trip); - setIsEditing(true); - }; - - const handleSave = () => { - if (!editedTrip.name.toString().trim()) { - notify.error("Trip name cannot be empty"); - return; - } - updateTrip(); - }; - - const handleNameChange = (e: ChangeEvent) => { - setEditedTrip({ ...editedTrip, name: e.target.value }); - }; - - const handleDescriptionChange = (e: ChangeEvent) => { - setEditedTrip({ ...editedTrip, description: e.target.value }); - }; - - const handleOpenChange = (open: boolean) => { - if (!open && isEditing) { - setIsEditing(false); - setEditedTrip(initTrip); - } - setTripDetailsOpen(open); - }; - - return ( - - Trip Details Dialog - - - - {isEditing ? ( - - ) : ( - {trip.name} - )} -
- {isEditing ? ( - <> - Save - - ) : ( - - )} -
-
- -
-

Trip Vehicle

- -
-
- - {vehicle?.type} - -
-
-
- -
-
-

{vehicle?.name}

-

{vehicle?.id}

- -

- {vehicle?.description} -

-
-
-
-
-
-
-
-

Trip Information

-
-
- - - Trip ID: - - {trip.id} -
-
- - - Duration: - - - {formatTime(duration())} ({duration()} ms) - -
-
- - - Start Time: - - - {new Date(trip.start_time).toLocaleString()} - -
-
- - - End Time: - - - {new Date(trip.end_time).toLocaleString()} - -
-
-

- Description: -

- {isEditing ? ( -