From 64e4ea8083477c12ba4c259dc921158b9f3eacc2 Mon Sep 17 00:00:00 2001 From: dtoro Date: Sat, 11 Apr 2026 12:01:52 +0200 Subject: [PATCH] feat: floating capture-date chip on timeline scrollbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tracks the scroll position and shows a small date chip pinned to the right edge of the timeline, fading in while the user scrolls and out 700ms after they stop. Only active in date-sorted views — other sort modes hide it since the label would be meaningless. A cached row-offset/date index keeps the lookup to a single binary search per scroll frame. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/components/timeline/Timeline.tsx | 148 +++++++++++++++++- 1 file changed, 141 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/timeline/Timeline.tsx b/frontend/src/components/timeline/Timeline.tsx index ccf4438..8838b4f 100644 --- a/frontend/src/components/timeline/Timeline.tsx +++ b/frontend/src/components/timeline/Timeline.tsx @@ -1,6 +1,7 @@ import { useRef, useEffect, useMemo, useState, useCallback } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' import { format, parseISO } from 'date-fns' +import clsx from 'clsx' import { usePhotoStore } from '../../store/photoStore' import { useFilterStore } from '../../store/filterStore' import { PhotoThumbnail } from './PhotoThumbnail' @@ -204,6 +205,10 @@ export function Timeline() { [photos, columns, cellSize, sortBy, groupBy] ) + // Used by the sticky header, the row-date index, and the floating + // scrollbar chip — all three only make sense in date-sorted views. + const isDateSort = sortBy === 'taken_at' || sortBy === 'added_at' + // Pre-computed offset of every header in the virtualizer's coordinate // space, used to drive the sticky-header overlay below. const headerOffsets = useMemo(() => { @@ -231,19 +236,132 @@ export function Timeline() { virtualizer.measure() }, [items, virtualizer]) - // Track scroll position so we can show the current group label as a - // pinned overlay at the top of the scroll container. The virtualizer's + // Track scroll position so we can (a) show the current group label as + // a pinned overlay at the top of the scroll container and (b) drive + // the floating "date next to the scrollbar" indicator. The virtualizer's // items use transform translateY (so CSS position: sticky doesn't work - // on the inline headers); the overlay sidesteps that by living outside + // on the inline headers); the overlays sidestep that by living outside // the virtualizer's positioned children. - const [scrollTop, setScrollTop] = useState(0) + // + // We capture clientHeight + scrollHeight alongside top so the floating + // indicator can position itself proportionally to scroll progress + // without needing a second observer to react to viewport resizes. + // `isScrolling` is a short-lived flag reset by a debounced timer — + // drives the fade in/out of the date chip so it only appears while + // the user is actively dragging the scrollbar / wheel-scrolling. + const [scrollMetrics, setScrollMetrics] = useState({ + top: 0, + clientHeight: 0, + scrollHeight: 0, + }) + const [isScrolling, setIsScrolling] = useState(false) + const scrollIdleTimerRef = useRef(null) useEffect(() => { const el = parentRef.current if (!el) return - const onScroll = () => setScrollTop(el.scrollTop) + const capture = () => { + setScrollMetrics({ + top: el.scrollTop, + clientHeight: el.clientHeight, + scrollHeight: el.scrollHeight, + }) + } + capture() + const onScroll = () => { + capture() + setIsScrolling(true) + if (scrollIdleTimerRef.current !== null) { + window.clearTimeout(scrollIdleTimerRef.current) + } + scrollIdleTimerRef.current = window.setTimeout(() => { + setIsScrolling(false) + scrollIdleTimerRef.current = null + }, 700) + } el.addEventListener('scroll', onScroll, { passive: true }) - return () => el.removeEventListener('scroll', onScroll) - }, []) + return () => { + el.removeEventListener('scroll', onScroll) + if (scrollIdleTimerRef.current !== null) { + window.clearTimeout(scrollIdleTimerRef.current) + scrollIdleTimerRef.current = null + } + } + // Depend on isLoading + photo presence so the effect re-attaches + // after the early-return loading/empty JSX gives way to the real + // scroll container — on first mount parentRef.current is null and + // an empty-deps effect would never bind the listener. + }, [isLoading, photos.length]) + + // Alias for the sticky-header math below, which only needs the scroll + // offset. Kept as a local so the existing logic reads the same as + // before the metrics refactor. + const scrollTop = scrollMetrics.top + + // Parallel index of every row's (cumulative offset, raw date string), + // built from the same flat items array the virtualizer reads. Used by + // the floating scrollbar-date chip to answer "what date is the user + // currently looking at" in O(log n) without reaching into the + // virtualizer's internals. Rebuilt whenever items or sort field change. + const rowDateIndex = useMemo(() => { + if (!isDateSort) return [] as { offset: number; raw: string | null }[] + const out: { offset: number; raw: string | null }[] = [] + let cum = 0 + for (const item of items) { + if (item.type === 'row') { + const first = item.cells[0]?.photo + const raw = + sortBy === 'added_at' + ? first?.added_at ?? first?.taken_at ?? null + : first?.taken_at ?? null + out.push({ offset: cum, raw }) + } + cum += item.height + } + return out + }, [items, sortBy, isDateSort]) + + // Date shown in the floating chip next to the scrollbar. Finds the + // deepest row whose offset is at/above the viewport top (plus a + // small lookahead so fast flicks feel responsive) and formats its + // photo's capture date. Null when not date-sorted, not scrolling, or + // when the topmost row has no date — we'd rather hide the chip than + // show an unhelpful "Unknown". + const scrollDateLabel = useMemo(() => { + if (!isDateSort || rowDateIndex.length === 0) return null + const target = scrollMetrics.top + 20 + let raw: string | null = null + // Binary search: largest offset ≤ target. + let lo = 0 + let hi = rowDateIndex.length - 1 + while (lo <= hi) { + const mid = (lo + hi) >> 1 + if (rowDateIndex[mid].offset <= target) { + raw = rowDateIndex[mid].raw + lo = mid + 1 + } else { + hi = mid - 1 + } + } + if (!raw) return null + try { + return format(parseISO(raw), 'MMM d, yyyy') + } catch { + return null + } + }, [rowDateIndex, scrollMetrics.top, isDateSort]) + + // Vertical position of the floating chip in scroll-viewport pixels. + // Tracks the scrollbar thumb's position by mapping scroll progress + // linearly onto the viewport height. Clamped so the chip never + // overflows the top/bottom padding even at scroll extremes. + const scrollIndicatorTop = useMemo(() => { + const { top, clientHeight, scrollHeight } = scrollMetrics + if (scrollHeight <= clientHeight) return 8 + const ratio = top / (scrollHeight - clientHeight) + const chipHeight = 28 + const usable = clientHeight - chipHeight - 16 + return 8 + Math.max(0, Math.min(usable, ratio * usable)) + }, [scrollMetrics]) // Find the latest header whose BOTTOM is above the viewport top. That's // the group whose natural in-grid header has scrolled out of view — @@ -557,6 +675,22 @@ export function Timeline() { )} + {/* Floating scrollbar date chip. Sits just inside the right edge, + * vertically tracking the scrollbar thumb's position, and fades + * out 700ms after the user stops scrolling. Read-only/decorative, + * so pointer-events-none — it must never steal scrollbar clicks. */} + {isDateSort && scrollDateLabel && ( +
+ {scrollDateLabel} +
+ )} +