From 1d1b0eee016f209f0ccace7e900a9c421f143371 Mon Sep 17 00:00:00 2001 From: Hugh Kaznowski Date: Sat, 5 Sep 2026 16:20:18 +0100 Subject: [PATCH] Add timeline start/end markers, cursor hover, and live drag readout (ticket 031) LogTimeline now always shows the first/last bucket's index and timestamp, a hover readout that tracks the cursor with the bucket at/before it, and a live-updating start/end readout while dragging a selection. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o --- tickets/031-timeline-markers-and-hover.md | 40 ----------------- webapp/src/components/LogTimeline.tsx | 54 +++++++++++++++++++++-- 2 files changed, 51 insertions(+), 43 deletions(-) delete mode 100644 tickets/031-timeline-markers-and-hover.md diff --git a/tickets/031-timeline-markers-and-hover.md b/tickets/031-timeline-markers-and-hover.md deleted file mode 100644 index 1dd650c..0000000 --- a/tickets/031-timeline-markers-and-hover.md +++ /dev/null @@ -1,40 +0,0 @@ -# 031 — Timeline start/end markers, cursor hover, and live drag readout - -## Summary - -`LogTimeline` (`webapp/src/components/LogTimeline.tsx`) shows a bar per -bucket with only a per-bar `title` tooltip on native hover, and a drag -selection that's silent until `handleMouseUp` fires `onSelectRange`. Add -always-visible start/end labels, a hover readout that tracks the cursor, and -a live-updating readout while dragging. - -## Scope - -- **Start/end markers**: render the sequence index and timestamp of the - first and last visible bucket at the two ends of the timeline (using - `domainStart`/`domainEnd`, already computed at - `LogTimeline.tsx:52-53`), always visible, not hover-only. -- **Cursor hover readout**: on `onMouseMove` (already wired for drag at - `handleMouseMove`, `LogTimeline.tsx:65`), when not dragging, compute the - bucket at or immediately before the cursor via `xToMs` and show its - timestamp/count in a readout near the cursor — replacing reliance on the - native per-bar `title` attribute (`LogTimeline.tsx:135`). -- **Live drag readout**: while `dragging` is true (`LogTimeline.tsx:78`), show - the resolved start/end (`xToMs(dragStartX)`/`xToMs(dragCurrentX)`) updating - on every `handleMouseMove`, not just after `handleMouseUp` resolves the - final range — the drag rectangle already updates live (`selRect`, - `LogTimeline.tsx:79-84`); this adds the numeric readout next to it. - -## Acceptance criteria - -- The timeline always shows the sequence/timestamp of its first and last - bucket, without needing to hover. -- Moving the mouse over the timeline (not dragging) shows the timestamp/count - of the bucket under (or just before) the cursor, updating as the mouse - moves. -- While dragging, the displayed start/end timestamps update continuously as - the drag extends, matching the already-live selection rectangle. - -## Depends on - -None — builds on the existing `LogTimeline` drag/hover plumbing. diff --git a/webapp/src/components/LogTimeline.tsx b/webapp/src/components/LogTimeline.tsx index 64d9232..79c825a 100644 --- a/webapp/src/components/LogTimeline.tsx +++ b/webapp/src/components/LogTimeline.tsx @@ -31,6 +31,10 @@ export function computePresetRange(id: TimeRangePresetId): TimeRange | null { return { startMs: now - preset.ms, endMs: now } } +function formatTimestamp(ms: number): string { + return new Date(ms).toLocaleTimeString() +} + export function LogTimeline({ buckets, range, @@ -47,11 +51,14 @@ export function LogTimeline({ const containerRef = useRef(null) const [dragStartX, setDragStartX] = useState(null) const [dragCurrentX, setDragCurrentX] = useState(null) + const [hoverX, setHoverX] = useState(null) const maxCount = Math.max(1, ...buckets.map((b) => b.count)) const domainStart = range?.startMs ?? buckets[0]?.start_ms ?? 0 const domainEnd = range?.endMs ?? Date.now() const domainSpan = Math.max(1, domainEnd - domainStart) + const firstBucket = buckets[0] ?? null + const lastBucket = buckets[buckets.length - 1] ?? null function xToMs(x: number) { const rect = containerRef.current?.getBoundingClientRect() @@ -60,12 +67,25 @@ export function LogTimeline({ return Math.round(domainStart + ratio * domainSpan) } + function bucketAtOrBefore(ms: number): TimelineBucket | null { + let result: TimelineBucket | null = null + for (const b of buckets) { + if (b.start_ms <= ms) result = b + else break + } + return result ?? firstBucket + } + function handleMouseDown(e: React.MouseEvent) { setDragStartX(e.clientX) setDragCurrentX(e.clientX) } function handleMouseMove(e: React.MouseEvent) { - if (dragStartX !== null) setDragCurrentX(e.clientX) + if (dragStartX !== null) { + setDragCurrentX(e.clientX) + } else { + setHoverX(e.clientX) + } } function handleMouseUp() { if (dragStartX !== null && dragCurrentX !== null && Math.abs(dragCurrentX - dragStartX) > 3) { @@ -84,6 +104,8 @@ export function LogTimeline({ width: Math.abs(dragCurrentX! - dragStartX!), } : null + const hoverBucket = !dragging && hoverX !== null ? bucketAtOrBefore(xToMs(hoverX)) : null + const containerLeft = containerRef.current?.getBoundingClientRect().left ?? 0 return (
@@ -128,6 +150,7 @@ export function LogTimeline({ onMouseLeave={() => { setDragStartX(null) setDragCurrentX(null) + setHoverX(null) }} >
@@ -136,16 +159,41 @@ export function LogTimeline({ key={b.start_ms} className="min-w-[2px] flex-1 rounded-t bg-accent/70" style={{ height: `${(b.count / maxCount) * 100}%` }} - title={`${new Date(b.start_ms).toLocaleTimeString()} — ${b.count}`} /> ))}
+ {firstBucket && ( +
+ #0 · {formatTimestamp(firstBucket.start_ms)} +
+ )} + {lastBucket && ( +
+ #{buckets.length - 1} · {formatTimestamp(lastBucket.start_ms)} +
+ )} {selRect && (
)} + {dragging && ( +
+ {formatTimestamp(xToMs(dragStartX!))} → {formatTimestamp(xToMs(dragCurrentX!))} +
+ )} + {!dragging && hoverBucket && hoverX !== null && ( +
+ {formatTimestamp(hoverBucket.start_ms)} — {hoverBucket.count} +
+ )}
)