From ec6be96c6a428438c88f30ac5f11c1852bfec4e6 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Mon, 8 Jun 2026 18:19:01 +0100 Subject: [PATCH] feat(timeline): add virtualised month-grouped timeline view - New Timeline view groups all media by YYYY-MM using taken_at ?? created_at - @tanstack/react-virtual (group-level virtualiser) keeps scroll smooth on large libraries; estimateSize is exact so no measureElement needed - ResizeObserver tracks container width; virtualizer.measure() is called on cols change to prevent stale position cache after window resize - setView("timeline") in store auto-sets sort:"taken_asc", resets images, and triggers a fresh load - Calendar nav item added to Sidebar between Explore and Duplicates - ContextMenu and ImageTile exported from Gallery for reuse in Timeline --- PLAN_watchdog_exif_timeline.md | 146 +++++++++++++++++++ src/App.tsx | 9 +- src/components/Gallery.tsx | 4 +- src/components/Sidebar.tsx | 17 +++ src/components/Timeline.tsx | 247 +++++++++++++++++++++++++++++++++ src/store.ts | 7 +- 6 files changed, 426 insertions(+), 4 deletions(-) create mode 100644 PLAN_watchdog_exif_timeline.md create mode 100644 src/components/Timeline.tsx diff --git a/PLAN_watchdog_exif_timeline.md b/PLAN_watchdog_exif_timeline.md new file mode 100644 index 0000000..a3f8519 --- /dev/null +++ b/PLAN_watchdog_exif_timeline.md @@ -0,0 +1,146 @@ +# Feature Plan: Watchdog + EXIF + Timeline + +Branch: `feat/watchdog-exif-timeline` +Base: merged PR #8 (discovery features) into `main` + +--- + +## Overview + +Three related features being implemented together: + +1. **Phase 1 — EXIF date extraction** ✅ DONE (commit `9ee5b08`) +2. **Phase 2 — Filesystem watchdog** ✅ DONE (commit `ae9e806`) +3. **Phase 3 — Timeline view** ✅ DONE + +--- + +## Phase 1: EXIF Date Extraction ✅ + +### What it does +Extracts the capture date from EXIF metadata during indexing and stores it as +`taken_at` (nullable TEXT, ISO 8601) on each image. Exposes "Taken: newest / +oldest" sort options. + +### Key files changed +- `src-tauri/Cargo.toml` — added `kamadak-exif = "0.5"` +- `src-tauri/src/db.rs`: + - Added `taken_at: Option` to `ImageRecord` struct (after `modified_at`) + - `ensure_column` migration + `idx_images_taken_at` index + - Updated `upsert_image`, `map_image_row` (all indices shifted +1 after taken_at), all 3 SELECT statements + - Added `taken_asc` / `taken_desc` sort cases using `COALESCE(taken_at, created_at)` +- `src-tauri/src/indexer.rs`: + - Added `extract_exif_date(path) -> Option` — tries DateTimeOriginal → DateTimeDigitized → DateTime; rejects all-zero sentinel dates ("0000:00:00 00:00:00") + - `build_record` now populates `taken_at: extract_exif_date(path)` +- `src/store.ts` — added `taken_at: string | null` to `ImageRecord`, `"taken_desc" | "taken_asc"` to `SortOrder`, sort cases in `compareImages` using `a.taken_at ?? a.created_at` +- `src/components/Toolbar.tsx` — added "Taken: newest" / "Taken: oldest" to sort dropdown + +### Notes +- Existing images won't have `taken_at` until re-indexed (file_size or mtime must change to trigger re-extraction) +- `upsert_image` uses `taken_at = excluded.taken_at` (not COALESCE) — if file content changes, fresh EXIF is used + +--- + +## Phase 2: Filesystem Watchdog ✅ + +### What it does +Watches all registered folders using OS-native events (`ReadDirectoryChangesW` +on Windows). New, modified, and deleted files are reflected automatically +without manual reindexing. Zero CPU when idle. + +### Key design +- **Adaptive blocking**: `recv()` when no events pending (truly idle), switches + to `recv_timeout(earliest_deadline)` only when debounce timers are running +- **500 ms per-path debounce** coalesces rapid OS event bursts +- **Change detection**: `build_record(path, folder_id, existing.as_ref())` skips + upsert if file_size + mtime unchanged → no thumbnail/embedding re-queues, no + metadata clobber +- `Access` events filtered out (reads don't change content) + +### Key files changed +- `src-tauri/Cargo.toml` — added `notify = "6"` +- `src-tauri/src/db.rs` — added `get_indexed_entry_by_path` and `get_image_id_by_path` +- `src-tauri/src/indexer.rs` — added `WatcherHandle`, `WatcherInner`, `start_watcher`, `process_watcher_path` +- `src-tauri/src/lib.rs` — starts watcher after other workers, manages `WatcherHandle` in app state +- `src-tauri/src/commands.rs` — `add_folder`, `remove_folder`, `update_folder_path` now accept `State<'_, WatcherHandle>` and call `watcher.add_folder / remove_folder / update_folder` +- `src/store.ts` — `subscribeToProgress` listens for `"watcher-deleted"` event (`number[]` payload), removes images from state, clears `selectedImage` if deleted + +--- + +## Phase 3: Timeline View ✅ DONE + +### What it should do +A new gallery view that groups images by date (year → month → day), virtualised +for performance, using the `taken_at` / `created_at` data from Phase 1. + +### Suggested approach + +**Backend** +- No new Tauri commands needed — images are already sorted by `taken_asc` / + `taken_desc` and carry `taken_at` / `created_at` +- Optionally: a `get_timeline_buckets` command that returns counts per + year/month for a navigation sidebar (nice-to-have, not essential for MVP) + +**Frontend — grouping logic** +- New view type: add `"timeline"` to `ActiveView` type in `store.ts` +- Grouping function: takes `ImageRecord[]`, returns `TimelineGroup[]`: + ```ts + interface TimelineGroup { + label: string; // e.g. "June 2023" + dateKey: string; // e.g. "2023-06" for keying + images: ImageRecord[]; + } + ``` + Use `taken_at ?? created_at` for the date. Group by `YYYY-MM` (month granularity works well). + +**Frontend — virtualised rendering** +- Use `@tanstack/react-virtual` (already a dependency) +- Virtualise at the *row* level (each row = one group header + a row of tiles, + or a row of tiles within a group) +- Simplest pattern: flatten groups into a mixed list of `{ type: 'header', label }` and `{ type: 'image', image }` items, then use `useVirtualizer` on that flat list +- Tile size uses the existing `tileSizeForZoom` / `zoomPreset` from the store + +**Frontend — navigation** +- Sidebar can list the groups (year/month) as anchor links — clicking jumps + `virtualizer.scrollToIndex(groupStartIndex)` +- Or keep it simple for MVP: just scroll, the headers are visible as you go + +**Frontend — state** +- `sort` should auto-switch to `"taken_desc"` when entering timeline view + (can be a `useEffect` in the Timeline component) +- When leaving timeline view, restore the previous sort + +**Suggested component structure** +``` +src/components/Timeline.tsx + - TimelineView (main component, uses useVirtualizer) + - TimelineGroupHeader (date label row) + - reuses existing Gallery tile/card components +``` + +**Toolbar** +- Add "Timeline" to the view switcher alongside the existing gallery/explore/duplicates tabs +- Or add it to the `Sidebar` nav + +### Existing hooks to reuse +- `tileSizeForZoom(zoomPreset)` for tile size +- `matchesFilters` for respecting active folder/media/favorites filters +- The existing `Gallery` grid tile component for rendering individual images +- `openImage` store action for lightbox + +--- + +## General notes for a new session + +- **Always run `feature-dev:code-reviewer` sweep before every commit** — see `MEMORY.md` +- **Use pnpm**, never npm +- **No `any` types** in TypeScript +- Hot reload is active during `pnpm dev:app` — don't restart for frontend changes +- CUDA `cargo check` failure is **pre-existing** (broken nvcc environment) and + unrelated to this feature work — filter it out with `grep -v candle` when checking +- The `map_image_row` in `db.rs` uses **positional column indices** — any new + column added to the SELECT must have its index maintained exactly +- `sqlite-vec` virtual table DML is unreliable inside transactions — vector + operations must happen outside `unchecked_transaction()` +- `ImageRecord` is mirrored in `db.rs` (Rust) and `store.ts` (TypeScript) — + both must stay in sync diff --git a/src/App.tsx b/src/App.tsx index ce2a339..84595a2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { Gallery } from "./components/Gallery"; import { Lightbox } from "./components/Lightbox"; import { TagCloud } from "./components/TagCloud"; import { DuplicateFinder } from "./components/DuplicateFinder"; +import { Timeline } from "./components/Timeline"; import { TitleBar } from "./components/TitleBar"; import { SettingsModal } from "./components/SettingsModal"; import { initializeNotifications } from "./notifications"; @@ -46,7 +47,13 @@ export default function App() {
- {activeView === "explore" ? ( + {activeView === "timeline" ? ( + <> + + + + + ) : activeView === "explore" ? ( <> diff --git a/src/components/Gallery.tsx b/src/components/Gallery.tsx index 0f820d4..bbfefac 100644 --- a/src/components/Gallery.tsx +++ b/src/components/Gallery.tsx @@ -16,7 +16,7 @@ function formatDuration(durationMs: number | null): string | null { return `${minutes}:${seconds.toString().padStart(2, "0")}`; } -function ContextMenu({ +export function ContextMenu({ x, y, image, @@ -104,7 +104,7 @@ function ContextMenu({ ); } -function ImageTile({ +export function ImageTile({ image, onClick, onContextMenu, diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 18f2fce..da84180 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -329,6 +329,23 @@ export function Sidebar() {
+
setView("timeline")} + > + + + + + Timeline + +
+
(); + for (const img of images) { + const ds = img.taken_at ?? img.created_at; + const key = ds ? ds.substring(0, 7) : "unknown"; + let bucket = map.get(key); + if (bucket === undefined) { + bucket = []; + map.set(key, bucket); + } + bucket.push(img); + } + return Array.from(map.entries()) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([key, imgs]) => ({ key, label: buildLabel(key), images: imgs })); +} + +export function Timeline() { + const images = useGalleryStore((s) => s.images); + const loadMoreImages = useGalleryStore((s) => s.loadMoreImages); + const openImage = useGalleryStore((s) => s.openImage); + const totalImages = useGalleryStore((s) => s.totalImages); + const loadingImages = useGalleryStore((s) => s.loadingImages); + const imageLoadError = useGalleryStore((s) => s.imageLoadError); + const zoomPreset = useGalleryStore((s) => s.zoomPreset); + + const parentRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(0); + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + image: ImageRecord; + } | null>(null); + + // Measure container width before first paint to avoid a single-column flash. + useLayoutEffect(() => { + const el = parentRef.current; + if (!el) return; + setContainerWidth(el.clientWidth); + const ro = new ResizeObserver((entries) => { + setContainerWidth(entries[0].contentRect.width); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const tileSize = tileSizeForZoom(zoomPreset); + const cols = useMemo( + () => Math.max(1, Math.floor((containerWidth + GAP) / (tileSize + GAP))), + [containerWidth, tileSize], + ); + + const groups = useMemo(() => groupImages(images), [images]); + + // estimateSize must be exact so virtualizer positions groups correctly. + // Each group height = header + rowCount * (tileSize + GAP) where the last row's + // GAP acts as spacing between this group and the next header. + const estimateSize = useCallback( + (index: number): number => { + const group = groups[index]; + if (!group) return HEADER_HEIGHT; + const rowCount = Math.ceil(group.images.length / cols); + return HEADER_HEIGHT + rowCount * (tileSize + GAP); + }, + [groups, cols, tileSize], + ); + + const virtualizer = useVirtualizer({ + count: groups.length, + getScrollElement: () => parentRef.current, + estimateSize, + overscan: 2, + }); + + // Re-measure all items when cols changes so virtualizer positions stay accurate + // after a window resize (react-virtual v3 doesn't invalidate cached sizes on its own). + useEffect(() => { + virtualizer.measure(); + }, [cols, virtualizer]); + + const handleScroll = useCallback(() => { + const el = parentRef.current; + if (!el) return; + if (el.scrollTop < 24) return; + const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 600; + if (nearBottom && !loadingImages && images.length < totalImages) { + void loadMoreImages(); + } + }, [images.length, loadMoreImages, loadingImages, totalImages]); + + useEffect(() => { + const el = parentRef.current; + if (!el) return; + el.addEventListener("scroll", handleScroll, { passive: true }); + return () => el.removeEventListener("scroll", handleScroll); + }, [handleScroll]); + + useEffect(() => { + const close = (e: PointerEvent) => { + if ((e.target as HTMLElement | null)?.closest("[data-gallery-context-menu]")) return; + setContextMenu(null); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setContextMenu(null); + }; + window.addEventListener("pointerdown", close); + window.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("pointerdown", close); + window.removeEventListener("keydown", onKey); + }; + }, []); + + return ( +
+ {images.length === 0 && loadingImages ? ( +
+
+
+

Loading timeline

+

Fetching results

+
+
+ ) : images.length === 0 ? ( +
+
+ + + +

+ {imageLoadError ? "Could not load timeline" : "No media found"} +

+

+ {imageLoadError ?? "Add a folder to see your timeline"} +

+
+
+ ) : ( +
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const group = groups[virtualItem.index]; + if (!group) return null; + return ( +
+ {/* Group header */} +
+ + {group.label} + + + {group.images.length} + +
+
+ + {/* Image grid — paddingBottom:GAP gives the gap below the last row, + matching the row-to-row gap and making estimateSize exact. */} +
+ {group.images.map((image) => ( + openImage(image)} + onContextMenu={(event) => { + event.preventDefault(); + setContextMenu({ x: event.clientX, y: event.clientY, image }); + }} + /> + ))} +
+
+ ); + })} +
+ )} + + {images.length > 0 && loadingImages ? ( +
+
+
+ ) : null} + + {contextMenu ? ( + setContextMenu(null)} + /> + ) : null} +
+ ); +} diff --git a/src/store.ts b/src/store.ts index 96fdd27..129ac4f 100644 --- a/src/store.ts +++ b/src/store.ts @@ -122,7 +122,7 @@ export interface ThumbnailBatch { images: ImageRecord[]; } -export type ActiveView = "gallery" | "explore" | "duplicates"; +export type ActiveView = "gallery" | "explore" | "duplicates" | "timeline"; export interface TagCloudEntry { count: number; @@ -905,6 +905,11 @@ export const useGalleryStore = create((set, get) => ({ closeImage: () => set({ selectedImage: null }), setView: (activeView) => { + if (activeView === "timeline") { + set({ activeView, sort: "taken_asc", images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null }); + void get().loadImages(true); + return; + } if (activeView === "duplicates") { const { selectedFolderId, duplicateScanFolderId } = get(); if (duplicateScanFolderId !== selectedFolderId) {