Skip to content

Feat: Scrollbar Timeline with {Month-Year} Markers for Home page and AI Tagging page.#504

Closed
rohan-pandeyy wants to merge 11 commits intoAOSSIE-Org:mainfrom
rohan-pandeyy:feat/scrollbar-timeline
Closed

Feat: Scrollbar Timeline with {Month-Year} Markers for Home page and AI Tagging page.#504
rohan-pandeyy wants to merge 11 commits intoAOSSIE-Org:mainfrom
rohan-pandeyy:feat/scrollbar-timeline

Conversation

@rohan-pandeyy
Copy link
Contributor

@rohan-pandeyy rohan-pandeyy commented Sep 8, 2025

Fixes: #495

This PR is divided into 3 major parts:

  • Image metadata retrieval - fetch and organize image metadata needed for chronological grouping.
  • Chronological gallery view - update the gallery to display images in descending date order (newest first).
  • Timeline scrollbar integration - replace the traditional scrollbar with a custom timeline scrollbar for smoother navigation between months/years.

1. Image metadata retrieval:

Status: Done ✅
image

2. Chronological gallery view

Status: Done ✅
image
image

3. Timeline scrollbar integration

Status: Done ✅

Recording.2025-09-14.195728.mp4

Summary by CodeRabbit

  • New Features

    • Chronological image gallery with sticky month headers on Home and AI Tagging.
    • Timeline scrollbar with month markers, tooltips, drag/wheel support, and click-to-jump.
    • Automatic image metadata extraction; richer metadata exposed and galleries grouped by capture date.
    • New frontend utilities for grouping, indexing, and scroll behavior; ImageMetadata type added.
  • Bug Fixes

    • Media info panel now reliably displays metadata (string or object).
  • Style

    • Updated layout spacing; added utilities to hide scrollbars and disable text selection.
  • Tests

    • Jest ResizeObserver mock for test environments.
  • Documentation

    • API schema updated: image metadata is now an object, not a string.

Ashutoshx7 and others added 3 commits September 1, 2025 12:22
Refactored image metadata to use dictionaries instead of strings, added automatic extraction of image metadata using Pillow, and ensured metadata is stored as JSON in the database.
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 8, 2025

Walkthrough

Adds backend image metadata extraction and normalization (store JSON, expose objects) and frontend chronological gallery with a timeline scrollbar, related utilities/types, layout/CSS tweaks, and a Jest ResizeObserver polyfill.

Changes

Cohort / File(s) Summary
Backend: Metadata & DB helpers
backend/app/database/images.py
Add _connect() enabling foreign keys; add extract_image_metadata; change ImageRecord.metadata typing to allow dicts; normalize metadata in db_bulk_insert_images (parse strings/fill missing data, JSON-encode for DB) and parse JSON back to dicts in retrieval functions; improve error handling and use upsert.
Backend: Routes typing
backend/app/routes/images.py
Update response typing / ImageData.metadata from str to Dict[str, Any] and map image responses to include metadata objects.
Docs: OpenAPI
docs/backend/backend_python/openapi.json
Change ImageData.metadata schema type from string to object.
Frontend: Types
frontend/src/types/Media.ts
Add ImageMetadata interface and change Image.metadata from optional string to optional ImageMetadata.
Frontend: Gallery component
frontend/src/components/Media/ChronologicalGallery.tsx
New ChronologicalGallery component with month grouping, sticky month headers, month marker callbacks (onMonthOffsetsChange), optional scrollContainerRef, and exported MonthMarker type.
Frontend: Timeline scrollbar
frontend/src/components/Timeline/TimelineScrollbar.tsx
New TimelineScrollbar component (track, progress fill, markers, hover/drag/scrub tooltips, marker clicks, wheel/drag handling).
Frontend: Utilities
frontend/src/utils/dateUtils.ts, frontend/src/utils/timelineUtils.ts
Add grouping by year/month, image index map, useScroll, useWheel, marker lookup, clamp and tooltip-positioning helpers, and related types.
Frontend: Pages integration
frontend/src/pages/Home/Home.tsx, frontend/src/pages/AITagging/AITagging.tsx
Replace previous image grids with ChronologicalGallery, wire TimelineScrollbar via shared scrollableRef and monthMarkers, preserve MediaView modal flows.
Frontend: Info panel & layout tweaks
frontend/src/components/Media/MediaInfoPanel.tsx, frontend/src/layout/layout.tsx, frontend/src/App.css
Make metadata rendering resilient to string/object, adjust container spacing (outer margin), and add .hide-scrollbar and .no-select utilities.
Frontend: Tests / Jest
frontend/jest.setup.ts
Add minimal global ResizeObserver mock/polyfill for Jest.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor Client
  participant Routes as Backend Routes
  participant DB as DB Layer
  participant FS as Filesystem
  participant PIL as PIL (Pillow)

  Client->>Routes: db_bulk_insert_images(records)
  Routes->>DB: open connection via _connect()
  loop per record
    DB->>FS: stat file (size/mtime)
    DB->>PIL: open image for width/height/EXIF
    DB-->>Routes: metadata dict (filled/fallbacks)
  end
  Routes->>DB: INSERT ... ON CONFLICT(path) DO UPDATE (metadata JSON)
  DB-->>Client: insert result

  Client->>Routes: db_get_all_images()
  Routes->>DB: query rows via _connect()
  DB-->>Routes: rows with metadata JSON string
  Routes->>Routes: parse metadata JSON -> dict, dedupe tags, sort
  Routes-->>Client: images with metadata object
Loading
sequenceDiagram
  autonumber
  actor User
  participant Page as Home/AITagging
  participant Gallery as ChronologicalGallery
  participant TL as TimelineScrollbar
  participant Utils as dateUtils/timelineUtils

  Page->>Gallery: render(images, scrollContainerRef)
  Gallery->>Utils: groupImagesByYearMonthFromMetadata(images)
  Gallery-->>Page: onMonthOffsetsChange(markers)
  Page->>TL: render(scrollableRef, monthMarkers)
  User->>TL: hover / drag / click
  TL->>Utils: useScroll/useWheel/getMarkerForScrollPosition()
  TL->>Page: set scrollTop on scrollableRef (smooth on marker click)
  Page->>Gallery: scroll position updates (visual sync)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60–90 minutes

Possibly related PRs

Suggested labels

GSoC 2025

Poem

I hop through folders, sniff the light,
I count the months from dawn to night.
Dots on a rail, a draggable line,
I nudge the thumb — the months align.
Metadata bundled, the gallery's bright. 🥕📸

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 72.73% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title "Feat: Scrollbar Timeline with {Month-Year} Markers for Home page and AI Tagging page." succinctly and accurately summarizes the primary change, directly aligning with the PR objectives and linked issue; it is concise, focused, and free of extraneous file lists or emojis.
Linked Issues Check ✅ Passed The changes implement the core requirements from issue #495: a custom TimelineScrollbar with a draggable thumb and drag/scroll tooltips, hover preview tooltips, clickable month markers that jump to month sections, and dynamic updates driven by ChronologicalGallery/monthMarkers (grouped objectives: UI behavior, markers/navigation, and dynamic updates) [#495]. Supporting pieces required for correctness are present: ChronologicalGallery, dateUtils/timelineUtils hooks, TimelineScrollbar interactions (drag, wheel, hover), and backend/frontend metadata handling so markers derive from metadata.date_created. The PR appears to handle tooltip exclusivity (track/drag/scroll tooltips) and wires onMonthOffsetsChange to keep markers in sync when images change. Outstanding items to address before merge are adding unit/integration tests for the timeline and DB logic and ensuring consumers are informed of the breaking metadata schema change.
Out of Scope Changes Check ✅ Passed Most edits are directly related to implementing the timeline feature and its prerequisites (chronological grouping, metadata extraction, UI hooks), so they are in-scope for issue #495; however the PR also introduces cross-cutting changes—backend metadata shape change (string → object), OpenAPI update, layout spacing adjustments, Jest ResizeObserver mock, and CSS utilities—that increase surface area and constitute a breaking API change if not coordinated. These supporting changes are justifiable for the feature but should be treated as breaking and communicated; unit/integration tests for DB and timeline behavior are currently missing. No unrelated feature additions were detected.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

👮 Agentic pre-merge checks are now available in preview!

Pro plan users can now enable pre-merge checks in their settings to enforce checklists before merging PRs.

  • Built-in checks – Quickly apply ready-made checks to enforce title conventions, require pull request descriptions that follow templates, validate linked issues for compliance, and more.
  • Custom agentic checks – Define your own rules using CodeRabbit’s advanced agentic capabilities to enforce organization-specific policies and workflows. For example, you can instruct CodeRabbit’s agent to verify that API documentation is updated whenever API schema files are modified in a PR. Note: Upto 5 custom checks are currently allowed during the preview period. Pricing for this feature will be announced in a few weeks.

Please see the documentation for more information.

Example:

reviews:
  pre_merge_checks:
    custom_checks:
      - name: "Undocumented Breaking Changes"
        mode: "warning"
        instructions: |
          Pass/fail criteria: All breaking changes to public APIs, CLI flags, environment variables, configuration keys, database schemas, or HTTP/GraphQL endpoints must be documented in the "Breaking Change" section of the PR description and in CHANGELOG.md. Exclude purely internal or private changes (e.g., code not exported from package entry points or explicitly marked as internal).

Please share your feedback with us on this Discord post.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Introduces ChronologicalGallery for displaying images grouped by year and month using metadata. Refactors Home and AI Tagging pages to use the new gallery component. Updates Image type to include structured metadata and adds dateUtils helpers for grouping and indexing images.
Simplifies Gallery by removing two scroll areas - inner and outter. Updates usage in Home and AITagging pages to match new props and layout, including gallery section styling and title changes.
@rohan-pandeyy
Copy link
Contributor Author

No more double scroll !

Improved Gallery layout by removing inner/outer scroll area conflict.
image

Introduces TimelineScrollbar which marks and shows where a new month begins, on the scrollbar itself. Adds a new timelineUtils with the states, hooks and helpers for scroll behavior.
@rohan-pandeyy rohan-pandeyy marked this pull request as ready for review September 14, 2025 14:43
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
docs/backend/backend_python/openapi.json (1)

1933-1942: Inconsistent metadata type across endpoints (object vs string).

ImageInCluster.metadata remains string|null, while ImageData.metadata is now object. This inconsistency will force conditional handling in clients and can cause subtle bugs.

Apply this schema adjustment for consistency:

-          "metadata": {
-            "anyOf": [
-              { "type": "string" },
-              { "type": "null" }
-            ],
-            "title": "Metadata"
-          },
+          "metadata": {
+            "anyOf": [
+              { "type": "object" },
+              { "type": "null" }
+            ],
+            "title": "Metadata"
+          },

Also bump docs/changelog to call out this breaking change.

frontend/src/components/Media/MediaInfoPanel.tsx (1)

27-33: Date should come from image metadata, not “now”.

Using the current date is misleading in an info panel. Prefer currentImage?.metadata?.date_created with a safe fallback.

-  const getFormattedDate = () => {
-    return new Date().toLocaleDateString('en-US', {
+  const getFormattedDate = () => {
+    const iso = currentImage?.metadata && (currentImage.metadata as any).date_created;
+    const d = iso ? new Date(iso) : null;
+    return (d ?? new Date(0)).toLocaleDateString('en-US', {
       year: 'numeric',
       month: 'long',
       day: 'numeric',
     });
   };
backend/app/database/images.py (1)

259-296: Return parsed metadata here too for consistency with the API.

db_get_untagged_images currently returns raw metadata (likely a JSON string), diverging from db_get_all_images and the OpenAPI. Parse to dict before returning.

-        for image_id, path, folder_id, thumbnail_path, metadata in results:
+        for image_id, path, folder_id, thumbnail_path, metadata in results:
+            md: dict = {}
+            if metadata:
+                try:
+                    md = json.loads(metadata) if isinstance(metadata, str) else (metadata or {})
+                except Exception:
+                    md = {}
             untagged_images.append(
                 {
                     "id": image_id,
                     "path": path,
                     "folder_id": folder_id,
                     "thumbnailPath": thumbnail_path,
-                    "metadata": metadata,
+                    "metadata": md,
                 }
             )
🧹 Nitpick comments (20)
frontend/src/App.css (2)

199-207: Scrollbar hiding: OK, but watch a11y discoverability.

The utility is fine. Ensure the timeline remains keyboard-accessible (arrow/PageUp/PageDown/Home/End) wherever .hide-scrollbar is applied, since hidden scrollbars can impair discoverability of scrollable regions.


209-214: Consider adding touch-action for smoother drag.

For drag handles/tracks that use .no-select, consider also applying touch-action: none on the draggable element (not globally) to prevent browser scroll interference during drag.

+.no-select.touch-none {
+  touch-action: none;
+}
frontend/jest.setup.ts (1)

11-18: Strengthen ResizeObserver mock to accept a callback (constructor).

Some components instantiate new ResizeObserver(cb) and expect a callback signature. Make the mock accept and optionally invoke the callback to avoid brittle tests. Also guard assignment to avoid overriding an existing polyfill.

-class ResizeObserver {
-  observe() {}
-  unobserve() {}
-  disconnect() {}
-}
-
-(global as any).ResizeObserver = ResizeObserver;
+if (typeof (global as any).ResizeObserver === 'undefined') {
+  class ResizeObserver {
+    private _cb: any;
+    constructor(cb?: any) {
+      this._cb = cb;
+    }
+    observe(target?: Element) {
+      // Optionally fire once for tests that rely on an initial measurement
+      if (typeof this._cb === 'function') {
+        this._cb([], this);
+      }
+    }
+    unobserve() {}
+    disconnect() {}
+  }
+  (global as any).ResizeObserver = ResizeObserver;
+}
frontend/src/components/Media/MediaInfoPanel.tsx (1)

83-87: “Location” renders the entire metadata JSON. Show a specific field.

Display a meaningful location/path (e.g., metadata.file_location) and keep raw JSON out of the primary UI.

-            <p className="font-medium text-white">
-              {typeof currentImage?.metadata === 'string'
-                ? currentImage.metadata
-                : currentImage?.metadata
-                  ? JSON.stringify(currentImage.metadata)
-                  : 'No location data'}
-            </p>
+            <p className="font-medium break-all text-white">
+              {(() => {
+                const md: any = currentImage?.metadata;
+                if (!md) return 'No location data';
+                if (typeof md === 'string') return md;
+                return md.file_location ?? 'No location data';
+              })()}
+            </p>

Optional: add a collapsible “Raw metadata” section if you still want to expose the full JSON.

frontend/src/types/Media.ts (2)

1-9: Type aligns with backend; consider making fields optional for resilience.

extract_image_metadata can return {}; making ImageMetadata fields optional avoids unsafe access in the UI code.

-export interface ImageMetadata {
-  name: string;
-  date_created: string;
-  width: number;
-  height: number;
-  file_location: string;
-  file_size: number;
-  item_type: string;
-}
+export interface ImageMetadata {
+  name?: string;
+  date_created?: string; // ISO 8601
+  width?: number;
+  height?: number;
+  file_location?: string;
+  file_size?: number;
+  item_type?: string;
+}

17-17: metadata optionality vs API contract.

OpenAPI requires metadata, but backend may fill {}. Keeping metadata? is okay for UI ergonomics, but consider documenting that API always returns an object (possibly empty).

backend/app/database/images.py (1)

84-118: Consider indexes to speed joins/filters.

Add indexes on images(folder_id), image_classes(image_id), and image_classes(class_id) to improve query performance in larger libraries.

backend/app/routes/images.py (1)

52-56: Message uses count of images: good; consider pagination later.

For large libraries this endpoint may become heavy; consider adding pagination and date-based filters in a follow-up.

frontend/src/layout/layout.tsx (1)

12-12: Spacing change: verify it doesn’t reintroduce double-scroll on small viewports.

Replacing p-4 with outer m-4 changes available height; confirm Home and AI Tagging pages with the timeline don’t overflow/clip at height: calc(100vh - 56px).

frontend/src/pages/Home/Home.tsx (2)

58-64: Render TimelineScrollbar only when markers exist and match its width to the track.

Prevents a floating empty control and avoids width mismatch (wrapper w-4 vs internal track w-6).

Apply this diff:

-      {/* Timeline Scrollbar */}
-      <TimelineScrollbar
-        scrollableRef={scrollableRef}
-        monthMarkers={monthMarkers}
-        className="absolute top-0 right-0 h-full w-4"
-      />
+      {/* Timeline Scrollbar */}
+      {monthMarkers.length > 0 && (
+        <TimelineScrollbar
+          scrollableRef={scrollableRef}
+          monthMarkers={monthMarkers}
+          className="absolute top-0 right-0 h-full w-6"
+        />
+      )}

66-66: Drop no‑op onClose prop to MediaView (or wire it properly).

The handler is empty and comment says Redux handles closing. Remove for clarity.

Apply this diff:

-      {isImageViewOpen && <MediaView onClose={handleCloseMediaView} />}
+      {isImageViewOpen && <MediaView />}
frontend/src/utils/dateUtils.ts (1)

31-43: Be defensive when parsing metadata.date_created.

Date strings can be invalid/locale‑dependent. Normalize parsing and bail early on invalid/empty.

Apply this diff:

-export const groupImagesByYearMonthFromMetadata = (images: Image[]) => {
+export const groupImagesByYearMonthFromMetadata = (images: Image[]) => {
   const grouped: Record<string, Record<string, Image[]>> = {};
 
   images.forEach((image) => {
-    const dateStr = image.metadata?.date_created; // extract date from metadata.date_created
-    if (!dateStr) return;
-
-    const date = new Date(dateStr);
+    const dateStr = image.metadata?.date_created;
+    if (!dateStr) return;
+    // Accept ISO strings or epoch millis
+    const epoch = /^\d+$/.test(String(dateStr)) ? Number(dateStr) : dateStr;
+    const date = new Date(epoch);
     if (isNaN(date.getTime())) return;
frontend/src/pages/AITagging/AITagging.tsx (2)

75-79: Guard TimelineScrollbar render and align width.

Avoids drawing a timeline with no data and fixes width mismatch with track.

Apply this diff:

-      <TimelineScrollbar
-        scrollableRef={scrollableRef}
-        monthMarkers={monthMarkers}
-        className="absolute top-0 right-0 h-full w-4"
-      />
+      {monthMarkers.length > 0 && (
+        <TimelineScrollbar
+          scrollableRef={scrollableRef}
+          monthMarkers={monthMarkers}
+          className="absolute top-0 right-0 h-full w-6"
+        />
+      )}

72-74: Optional: mirror MediaView usage with Home or remove onClose there for consistency.

Minor consistency nit only.

frontend/src/components/Media/ChronologicalGallery.tsx (1)

66-79: Minor: Clear refs when the month set changes.

Avoids stale entries if months are removed.

Apply this diff:

   if (!images.length) {
+    monthHeaderRefs.current.clear();
     return (
frontend/src/components/Timeline/TimelineScrollbar.tsx (5)

48-61: Consider using a const assertion or enum for month abbreviations.

The month abbreviations mapping could be more type-safe and maintainable using a const assertion to ensure immutability and better type inference.

-  const monthAbbreviations: Record<string, string> = {
+  const monthAbbreviations = {
     January: 'Jan',
     February: 'Feb',
     March: 'Mar',
     April: 'Apr',
     May: 'May',
     June: 'Jun',
     July: 'Jul',
     August: 'Aug',
     September: 'Sept',
     October: 'Oct',
     November: 'Nov',
     December: 'Dec',
-  };
+  } as const;

66-113: Potential memory leak with timer cleanup.

The scroll tooltip timer might not be properly cleaned up if the component unmounts while a timer is active, since the cleanup in the effect dependency array changes could leave orphaned timers.

Consider moving the timer cleanup to a dedicated cleanup function:

   useEffect(() => {
     if (isDragging || isMarkerHovered || !scrollableRef.current) {
       setIsScrollTooltipVisible(false);
+      if (scrollTooltipTimer.current) {
+        clearTimeout(scrollTooltipTimer.current);
+        scrollTooltipTimer.current = null;
+      }
       return;
     }

84-87: Extract magic number for tooltip half height.

The tooltip half height value (14) is repeated multiple times. Consider extracting it as a named constant for better maintainability.

+ const TOOLTIP_HALF_HEIGHT = 14;

  // Line 83-87
- const tooltipHalfHeight = 14;
  const top = Math.max(
-   tooltipHalfHeight,
-   Math.min(scrollProgress * trackHeight, trackHeight - tooltipHalfHeight),
+   TOOLTIP_HALF_HEIGHT,
+   Math.min(scrollProgress * trackHeight, trackHeight - TOOLTIP_HALF_HEIGHT),
  );

  // Similar changes for lines 169-173 and 240-244

Also applies to: 170-173, 241-244


115-132: ResizeObserver cleanup might not handle all edge cases.

The ResizeObserver setup looks good, but consider handling the case where the parent element changes during the component lifecycle.

Add a dependency on the parent element reference:

  useEffect(() => {
-   if (!containerRef.current?.parentElement) return;
+   const container = containerRef.current;
+   const parent = container?.parentElement;
+   if (!parent) return;

-   const parent = containerRef.current.parentElement;
    const measure = () => setTrackHeight(parent.clientHeight);
    
    // ... rest of the code
- }, []);
+ }, [containerRef.current?.parentElement]);

260-262: Extract tooltip styles to a constant or CSS class.

The tooltip base class string is quite long and could be extracted for better maintainability and potential reuse.

- const tooltipBaseClass =
-   'text-primary-foreground bg-primary absolute left-[-75px] rounded-md px-2 py-1 text-xs shadow-md';
+ const TOOLTIP_BASE_CLASS = 'text-primary-foreground bg-primary absolute left-[-75px] rounded-md px-2 py-1 text-xs shadow-md';

Or better yet, consider moving this to a CSS module or Tailwind component class.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5b10b16 and c033b95.

📒 Files selected for processing (14)
  • backend/app/database/images.py (4 hunks)
  • backend/app/routes/images.py (2 hunks)
  • docs/backend/backend_python/openapi.json (1 hunks)
  • frontend/jest.setup.ts (1 hunks)
  • frontend/src/App.css (1 hunks)
  • frontend/src/components/Media/ChronologicalGallery.tsx (1 hunks)
  • frontend/src/components/Media/MediaInfoPanel.tsx (1 hunks)
  • frontend/src/components/Timeline/TimelineScrollbar.tsx (1 hunks)
  • frontend/src/layout/layout.tsx (1 hunks)
  • frontend/src/pages/AITagging/AITagging.tsx (3 hunks)
  • frontend/src/pages/Home/Home.tsx (3 hunks)
  • frontend/src/types/Media.ts (1 hunks)
  • frontend/src/utils/dateUtils.ts (2 hunks)
  • frontend/src/utils/timelineUtils.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (7)
frontend/src/components/Media/ChronologicalGallery.tsx (4)
frontend/src/types/Media.ts (1)
  • Image (11-20)
frontend/src/features/imageSelectors.ts (1)
  • selectImages (5-5)
frontend/src/utils/dateUtils.ts (2)
  • groupImagesByYearMonthFromMetadata (31-55)
  • createImageIndexMap (58-66)
frontend/src/components/Media/ImageCard.tsx (1)
  • ImageCard (20-111)
frontend/src/utils/timelineUtils.ts (1)
frontend/src/components/Media/ChronologicalGallery.tsx (1)
  • MonthMarker (11-15)
frontend/src/utils/dateUtils.ts (1)
frontend/src/types/Media.ts (1)
  • Image (11-20)
frontend/src/components/Timeline/TimelineScrollbar.tsx (2)
frontend/src/components/Media/ChronologicalGallery.tsx (1)
  • MonthMarker (11-15)
frontend/src/utils/timelineUtils.ts (4)
  • TooltipState (4-8)
  • useScroll (10-31)
  • useWheel (33-52)
  • getMarkerForScrollPosition (54-61)
backend/app/database/images.py (1)
frontend/src/types/Media.ts (1)
  • Image (11-20)
frontend/src/pages/Home/Home.tsx (2)
frontend/src/components/Media/ChronologicalGallery.tsx (2)
  • MonthMarker (11-15)
  • ChronologicalGallery (25-148)
frontend/src/components/Timeline/TimelineScrollbar.tsx (1)
  • TimelineScrollbar (22-376)
frontend/src/pages/AITagging/AITagging.tsx (3)
frontend/src/features/imageSelectors.ts (2)
  • selectIsImageViewOpen (22-25)
  • selectTaggedImages (37-39)
frontend/src/components/Media/ChronologicalGallery.tsx (2)
  • MonthMarker (11-15)
  • ChronologicalGallery (25-148)
frontend/src/components/Timeline/TimelineScrollbar.tsx (1)
  • TimelineScrollbar (22-376)
🔇 Additional comments (7)
docs/backend/backend_python/openapi.json (1)

1862-1864: Schema update to object looks good; flagging downstream consistency.

Changing ImageData.metadata to object matches the backend response and frontend types.

Please confirm all endpoints that return images now use object metadata for consistency.

backend/app/database/images.py (2)

213-225: Robust JSON parsing: LGTM.

Safe parsing with fallbacks to {} avoids crashes and matches the new API contract.

Also applies to: 231-231


236-238: Good de-duplication of tags.

Avoiding duplicate tag aggregation prevents noisy payloads.

backend/app/routes/images.py (1)

16-16: Type change to Dict[str, Any] matches new schema.

This aligns route models with the updated metadata shape.

frontend/src/utils/dateUtils.ts (1)

58-66: LGTM: fast id→index map.

Straightforward and efficient for frequent lookups.

frontend/src/components/Timeline/TimelineScrollbar.tsx (2)

1-376: Overall: Well-implemented timeline scrollbar with minor improvements needed.

The component successfully implements the timeline scrollbar functionality with month markers, tooltips, and drag interactions. The integration with the timeline utilities and the chronological gallery is clean and follows React best practices.

Key strengths:

  • Good separation of concerns with utilities
  • Proper event handler cleanup
  • Responsive height measurement
  • Smooth scrolling behavior

The main areas for improvement are around error handling during drag operations and some performance optimizations for the marker rendering.


42-46: Verify wheel event handling doesn't block native inertial scrolling

  • Inspect useWheel in frontend/src/components/Timeline/TimelineScrollbar.tsx (around lines 42–46): confirm it does not call e.preventDefault() or attach non-passive wheel listeners (passive: false). If it does, remove or narrowly scope preventDefault so native touchpad momentum isn't lost.
  • Search the repo for other "wheel" / "onWheel" listeners that might also call preventDefault or update the same container's scrollTop.
  • Manually test two-finger touchpad/precision-scroll behavior (macOS and Windows) to ensure inertial scrolling and smoothness are preserved.

Comment on lines +33 to +34
const monthHeaderRefs = useRef<(HTMLDivElement | null)[]>([]);

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Compute marker offsets relative to the scroll container; filter/sort refs; clear stale refs.

offsetTop may be relative to a different offsetParent and produce mismatched offsets vs scrollTop. Also, stale/null refs should be filtered and markers sorted by DOM position.

Apply this diff:

-  const monthHeaderRefs = useRef<(HTMLDivElement | null)[]>([]);
+  // Map of 'YYYY-MM' -> header element
+  const monthHeaderRefs = useRef<Map<string, HTMLDivElement | null>>(new Map());
   useEffect(() => {
-    if (onMonthOffsetsChange) {
-      const markers = monthHeaderRefs.current.map((ref) => {
-        const year = ref?.parentElement?.parentElement?.dataset.year || '';
-        const month =
-          ref?.parentElement?.dataset.timelineMonth?.split('-')[1] || '';
-        return {
-          offset: ref?.offsetTop ?? 0,
-          month: new Date(Number(year), Number(month) - 1).toLocaleString(
-            'default',
-            { month: 'long' },
-          ),
-          year,
-        };
-      });
-      onMonthOffsetsChange(markers);
-    }
-  }, [images, onMonthOffsetsChange]);
+    if (!onMonthOffsetsChange) return;
+    // Rebuild markers from current refs
+    const entries = Array.from(monthHeaderRefs.current.entries()).flatMap(
+      ([key, el]) => {
+        if (!el) return [];
+        const [y, m] = key.split('-');
+        const monthName = new Date(Number(y), Number(m) - 1).toLocaleString(
+          'default',
+          { month: 'long' },
+        );
+        // Compute offset relative to provided scroll container when available
+        // @ts-ignore: prop added in this PR; see props diff below
+        const scroller = scrollContainerRef?.current as HTMLElement | undefined;
+        const offset = scroller
+          ? el.getBoundingClientRect().top -
+            scroller.getBoundingClientRect().top +
+            scroller.scrollTop
+          : el.offsetTop;
+        return [{ offset, month: monthName, year: y }];
+      },
+    );
+    // Sort by offset ascending to match scroll coordinates
+    entries.sort((a, b) => a.offset - b.offset);
+    onMonthOffsetsChange(entries);
+  }, [images, onMonthOffsetsChange /* , scrollContainerRef */]);

And update props to accept the scroller:

 type ChronologicalGalleryProps = {
   images: Image[];
   showTitle?: boolean;
   title?: string;
   className?: string;
   onMonthOffsetsChange?: (markers: MonthMarker[]) => void;
+  scrollContainerRef?: React.RefObject<HTMLElement | null>;
 };
 export const ChronologicalGallery = ({
   images,
   showTitle = false,
   title = 'Image Gallery',
   className = '',
   onMonthOffsetsChange,
+  scrollContainerRef,
 }: ChronologicalGalleryProps) => {

Also applies to: 47-64

🤖 Prompt for AI Agents
frontend/src/components/Media/ChronologicalGallery.tsx around lines 33-34 and
47-64: monthHeaderRefs is currently an unsanitized array of div refs and offsets
are computed with offsetTop (which may use a different offsetParent); update the
component to accept a scroller prop (HTMLElement | null) and compute marker
offsets relative to that scroller by using getBoundingClientRect() on each
header and the scroller and subtracting their tops, filter out null/stale refs
before using them, sort the refs by DOM order (e.g., compareDocumentPosition or
by the computed top) so markers are ordered, and ensure you clear stale refs
(e.g., set monthHeaderRefs.current = filteredRefs) whenever refs change or on
cleanup so future calculations only use valid elements.

Comment on lines +17 to +23
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = scrollable;
const progress = scrollTop / (scrollHeight - clientHeight);
setScrollProgress(progress);
};

scrollable.addEventListener('scroll', handleScroll, { passive: true });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Guard division by zero and clamp progress to [0,1].

When content fits the viewport, (scrollHeight − clientHeight) is 0, yielding NaN and breaking styles.

Apply this diff:

-    const handleScroll = () => {
-      const { scrollTop, scrollHeight, clientHeight } = scrollable;
-      const progress = scrollTop / (scrollHeight - clientHeight);
-      setScrollProgress(progress);
-    };
+    const handleScroll = () => {
+      const { scrollTop, scrollHeight, clientHeight } = scrollable;
+      const denom = Math.max(scrollHeight - clientHeight, 1);
+      const progress = Math.min(1, Math.max(0, scrollTop / denom));
+      setScrollProgress(progress);
+    };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = scrollable;
const progress = scrollTop / (scrollHeight - clientHeight);
setScrollProgress(progress);
};
scrollable.addEventListener('scroll', handleScroll, { passive: true });
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = scrollable;
const denom = Math.max(scrollHeight - clientHeight, 1);
const progress = Math.min(1, Math.max(0, scrollTop / denom));
setScrollProgress(progress);
};
scrollable.addEventListener('scroll', handleScroll, { passive: true });
🤖 Prompt for AI Agents
In frontend/src/utils/timelineUtils.ts around lines 17 to 23, the scroll
progress calculation can divide by zero when scrollHeight − clientHeight is 0
and produce NaN; change the handler to compute denominator = Math.max(1,
scrollHeight - clientHeight) or explicitly check if denominator <= 0 and set
progress = 0, then compute progress = scrollTop / denominator and clamp the
result to the [0,1] range (e.g., progress = Math.min(1, Math.max(0, progress)))
before calling setScrollProgress.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/app/database/images.py (1)

289-299: Return type mismatch: db_get_untagged_images claims ImageRecord but omits isTagged.

Either add isTagged: False to each row or relax the return type.

-def db_get_untagged_images() -> List[ImageRecord]:
+def db_get_untagged_images() -> List[ImageRecord]:
@@
             untagged_images.append(
                 {
                     "id": image_id,
                     "path": path,
                     "folder_id": folder_id,
                     "thumbnailPath": thumbnail_path,
-                    "metadata": md,
+                    "metadata": md,
+                    "isTagged": False,
                 }
             )

Also applies to: 327-334

♻️ Duplicate comments (4)
frontend/src/pages/Home/Home.tsx (1)

50-56: Resolved: offsets now computed in the correct coordinate space.

Passing scrollContainerRef to ChronologicalGallery fixes the earlier offsetParent mismatch for month markers. Looks good.

frontend/src/pages/AITagging/AITagging.tsx (1)

64-70: Resolved: scroll container passed to ChronologicalGallery.

This addresses the earlier offset mismatch for month markers.

backend/app/database/images.py (2)

96-105: Consistent minimal object on outer exceptions — nice.

This addresses the earlier feedback to never return {}.


180-190: Switched from OR IGNORE to UPSERT — good upgrade.

This will keep timeline markers fresh as metadata changes.

🧹 Nitpick comments (9)
frontend/src/pages/Home/Home.tsx (1)

60-64: Width mismatch: container w-4 vs track w-6 leads to clipping/overflow.

TimelineScrollbar’s internal track uses w-6; here the wrapper is w-4. Align widths (either bump wrapper to w-6 or reduce the track to w-4) to avoid cropping and stray padding.

Apply either change:

-      <TimelineScrollbar
+      <TimelineScrollbar
         scrollableRef={scrollableRef}
         monthMarkers={monthMarkers}
-        className="absolute top-0 right-0 h-full w-4"
+        className="absolute top-0 right-0 h-full w-6"
       />

—or adjust the track width inside TimelineScrollbar (see separate comment).

Also applies to: 44-44

frontend/src/components/Media/ChronologicalGallery.tsx (2)

118-121: Avoid retaining null refs; delete on unmount to prevent stale entries.

When a section unmounts, React calls the ref with null. Delete the key to keep the map clean.

  ref={(el) => {
    const key = `${year}-${month}`;
-   monthHeaderRefs.current.set(key, el);
+   if (el) {
+     monthHeaderRefs.current.set(key, el);
+   } else {
+     monthHeaderRefs.current.delete(key);
+   }
  }}

11-15: Prefer locale‑agnostic payloads for MonthMarker; format in the view.

Storing a localized month name ties consumers to one locale. Consider adding monthIndex (0–11) or a timestamp and format in TimelineScrollbar via Intl.DateTimeFormat('default', { month: 'short' }).

frontend/src/pages/AITagging/AITagging.tsx (2)

76-80: Width mismatch with internal track (w-6).

Same as Home: update wrapper width to avoid clipping or adjust the track width.

-      <TimelineScrollbar
+      <TimelineScrollbar
         scrollableRef={scrollableRef}
         monthMarkers={monthMarkers}
-        className="absolute top-0 right-0 h-full w-4"
+        className="absolute top-0 right-0 h-full w-6"
       />

76-80: Minor a11y: add role and keyboard support in TimelineScrollbar.

Consider role="scrollbar", tabIndex=0, and Arrow/Page/Home/End key handling to move the scrollableRef for keyboard users; set aria-controls to the scroll container id.

backend/app/database/images.py (4)

16-18: Type alias mismatch: FolderId should be int.

DB schema defines folder_id INTEGER; code uses List[int] elsewhere. Align the alias.

Apply:

-FolderId = str
+FolderId = int

39-49: Differentiate missing files in metadata.

For non-existent paths, set item_type to "missing" (not "unknown") so callers can distinguish.

-            "item_type": "unknown",
+            "item_type": "missing",

153-175: Avoid mutating input records; build DB rows explicitly.

Prevents surprising side effects upstream and ensures stable JSON encoding.

-            # Make sure it's stored as a JSON string in DB
-            record["metadata"] = json.dumps(metadata)
-
-            prepared_records.append(record)
+            # Make sure it's stored as a JSON string in DB
+            prepared_records.append(
+                {
+                    **record,
+                    "metadata": json.dumps(metadata, ensure_ascii=False),
+                }
+            )

214-227: Optional — add server-side chronological sort (SQLite JSON1 verified)

File: backend/app/database/images.py
Lines: 214-227

Verified sqlite3 JSON1 support in Python (script returned 1). Returning date-sorted rows reduces client work and stabilizes pagination.

-            ORDER BY i.path, m.name
+            ORDER BY
+              COALESCE(json_extract(i.metadata, '$.date_created'), '') DESC,
+              i.path,
+              m.name
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c033b95 and 393c0d7.

📒 Files selected for processing (6)
  • backend/app/database/images.py (5 hunks)
  • frontend/src/components/Media/ChronologicalGallery.tsx (1 hunks)
  • frontend/src/components/Timeline/TimelineScrollbar.tsx (1 hunks)
  • frontend/src/pages/AITagging/AITagging.tsx (3 hunks)
  • frontend/src/pages/Home/Home.tsx (3 hunks)
  • frontend/src/utils/timelineUtils.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • frontend/src/utils/timelineUtils.ts
  • frontend/src/components/Timeline/TimelineScrollbar.tsx
🧰 Additional context used
🧬 Code graph analysis (4)
backend/app/database/images.py (1)
frontend/src/types/Media.ts (1)
  • Image (11-20)
frontend/src/components/Media/ChronologicalGallery.tsx (4)
frontend/src/types/Media.ts (1)
  • Image (11-20)
frontend/src/features/imageSelectors.ts (1)
  • selectImages (5-5)
frontend/src/utils/dateUtils.ts (2)
  • groupImagesByYearMonthFromMetadata (31-55)
  • createImageIndexMap (58-66)
frontend/src/components/Media/ImageCard.tsx (1)
  • ImageCard (20-111)
frontend/src/pages/AITagging/AITagging.tsx (3)
frontend/src/features/imageSelectors.ts (2)
  • selectIsImageViewOpen (22-25)
  • selectTaggedImages (37-39)
frontend/src/components/Media/ChronologicalGallery.tsx (2)
  • MonthMarker (11-15)
  • ChronologicalGallery (26-155)
frontend/src/components/Timeline/TimelineScrollbar.tsx (1)
  • TimelineScrollbar (22-385)
frontend/src/pages/Home/Home.tsx (2)
frontend/src/components/Media/ChronologicalGallery.tsx (2)
  • MonthMarker (11-15)
  • ChronologicalGallery (26-155)
frontend/src/components/Timeline/TimelineScrollbar.tsx (1)
  • TimelineScrollbar (22-385)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Tauri Build Check (windows-latest)
  • GitHub Check: Tauri Build Check (ubuntu-22.04)
  • GitHub Check: Tauri Tests
🔇 Additional comments (2)
frontend/src/components/Media/ChronologicalGallery.tsx (1)

37-47: LGTM: efficient grouping and index map.

Good use of useMemo and shared Redux index mapping to keep ImageCard selection consistent across filtered views.

backend/app/database/images.py (1)

3-6: Imports look good for EXIF work.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (1)
frontend/src/components/Media/ChronologicalGallery.tsx (1)

143-146: Avoid stale refs accumulating in the Map.

Delete the key when the element unmounts (el === null).

-                ref={(el) => {
-                  const key = `${year}-${month}`;
-                  monthHeaderRefs.current.set(key, el);
-                }}
+                ref={(el) => {
+                  const key = `${year}-${month}`;
+                  if (el) {
+                    monthHeaderRefs.current.set(key, el);
+                  } else {
+                    monthHeaderRefs.current.delete(key);
+                  }
+                }}
🧹 Nitpick comments (13)
frontend/src/pages/Home/Home.tsx (2)

60-66: Render the timeline only when markers exist.

Prevents showing an empty scrollbar when images lack valid dates.

-      {images.length > 0 && (
+      {monthMarkers.length > 0 && (
         <TimelineScrollbar
           scrollableRef={scrollableRef}
           monthMarkers={monthMarkers}
           className="absolute top-0 right-0 h-full w-4"
         />
       )}

40-42: Remove no-op onClose for MediaView (keep consistent with AITagging).

Slight cleanup; MediaView closes via Redux.

-  const handleCloseMediaView = () => {
-    // MediaView will handle closing via Redux
-  };
...
-      {isImageViewOpen && <MediaView onClose={handleCloseMediaView} />}
+      {isImageViewOpen && <MediaView />}

Also applies to: 69-69

frontend/src/components/Media/ChronologicalGallery.tsx (2)

97-99: Use disconnect() for ResizeObserver cleanup.

Safer if the observed element changes.

-    return () => {
-      observer.unobserve(elementToObserve);
-    };
+    return () => {
+      observer.disconnect();
+    };

61-83: Minor: skip callback when no headers.

Saves a redundant state update when the Map is empty.

   const recomputeMarkers = useCallback(() => {
     if (!onMonthOffsetsChange) return;
+    if (monthHeaderRefs.current.size === 0) {
+      onMonthOffsetsChange([]);
+      return;
+    }
frontend/src/pages/AITagging/AITagging.tsx (1)

76-82: Gate TimelineScrollbar on markers, not just images.

Matches Home and hides empty track when no dated images.

-      {taggedImages.length > 0 && (
+      {monthMarkers.length > 0 && (
         <TimelineScrollbar
           scrollableRef={scrollableRef}
           monthMarkers={monthMarkers}
           className="absolute top-0 right-0 h-full w-4"
         />
       )}
frontend/src/components/Timeline/TimelineScrollbar.tsx (3)

154-161: Avoid re-sorting markers on every lookup.

Pre-sort once and reuse for tooltip/drag lookups.

+  const sortedMarkers = useMemo(
+    () => monthMarkers.slice().sort((a, b) => a.offset - b.offset),
+    [monthMarkers],
+  );
...
-    const currentMarker = getMarkerForScrollPosition(
-      scrollable.scrollTop,
-      monthMarkers,
-    );
+    const currentMarker = getMarkerForScrollPosition(scrollable.scrollTop, sortedMarkers);
...
-    const draggedMarker = getMarkerForScrollPosition(
-      correspondingScrollTop,
-      monthMarkers,
-    );
+    const draggedMarker = getMarkerForScrollPosition(correspondingScrollTop, sortedMarkers);
...
-    const hoveredMarker = getMarkerForScrollPosition(
-      correspondingScrollTop,
-      monthMarkers,
-    );
+    const hoveredMarker = getMarkerForScrollPosition(correspondingScrollTop, sortedMarkers);

Also applies to: 211-214, 288-291


284-287: Clamp hover scroll percentage.

Protects against minor float errors and edge math.

-    const scrollPercentage = hoverY / height;
+    const scrollPercentage = Math.min(Math.max(hoverY / height, 0), 1);

107-111: Wheel handler: guard against detached ref.

Tiny safety to avoid rare NPE if the node unmounts mid-wheel.

-  useWheel(containerRef, (deltaY) => {
-    if (scrollableRef.current) {
-      scrollableRef.current.scrollTop += deltaY;
-    }
-  });
+  useWheel(containerRef, (deltaY) => {
+    const scroller = scrollableRef.current;
+    if (scroller) scroller.scrollTop += deltaY;
+  });
backend/app/database/images.py (5)

35-39: Enable a sane busy timeout to reduce “database is locked” errors.

Foreign keys ON is great. Consider adding a small busy timeout.

Apply:

 def _connect() -> sqlite3.Connection:
     conn = sqlite3.connect(DATABASE_PATH)
     # Ensure ON DELETE CASCADE and other FKs are enforced
     conn.execute("PRAGMA foreign_keys = ON")
+    # Reduce contention errors under concurrent access
+    conn.execute("PRAGMA busy_timeout = 5000")
     return conn

46-56: Nonexistent file sentinel: consider item_type="missing".

Returning a stable minimal object is good. Labeling missing files distinctly can aid debugging and cleanup tooling.

-            "item_type": "unknown",
+            "item_type": "missing",

88-103: Timezone semantics for date_created are ambiguous.

EXIF times are naïve; mtime is from local clock. For consistent month bucketing across hosts/timezones, consider normalizing mtime to UTC and documenting that EXIF is naïve local time.

Example tweak:

-                        date_created = datetime.datetime.fromtimestamp(
-                            stats.st_mtime
-                        ).isoformat()
+                        date_created = datetime.datetime.fromtimestamp(
+                            stats.st_mtime, tz=datetime.timezone.utc
+                        ).isoformat()

Optionally add a metadata flag like date_source = "exif" | "mtime" to help clients disambiguate.


181-206: Avoid mutating caller-provided records when JSON-stringifying metadata.

You overwrite record["metadata"] in place. Prefer building a DB-only dict to prevent surprising callers.

Apply:

-        prepared_records = []
+        prepared_records = []
         for record in image_records:
             metadata = record.get("metadata")
@@
-            # Make sure it's stored as a JSON string in DB
-            record["metadata"] = json.dumps(metadata)
-
-            prepared_records.append(record)
+            # Build DB payload without mutating the input record
+            prepared_records.append({
+                "id": record["id"],
+                "path": record["path"],
+                "folder_id": record["folder_id"],
+                "thumbnailPath": record["thumbnailPath"],
+                "metadata": json.dumps(metadata),
+                "isTagged": record.get("isTagged", False),
+            })

274-286: Parsing metadata and de-duping tags: OK; minor perf nit.

Logic is correct. If tag lists can be long, consider a set during aggregation and convert to list at the end.

Also applies to: 297-299, 309-309

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 393c0d7 and 0328f95.

📒 Files selected for processing (5)
  • backend/app/database/images.py (11 hunks)
  • frontend/src/components/Media/ChronologicalGallery.tsx (1 hunks)
  • frontend/src/components/Timeline/TimelineScrollbar.tsx (1 hunks)
  • frontend/src/pages/AITagging/AITagging.tsx (3 hunks)
  • frontend/src/pages/Home/Home.tsx (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (5)
frontend/src/components/Media/ChronologicalGallery.tsx (4)
frontend/src/types/Media.ts (1)
  • Image (11-20)
frontend/src/features/imageSelectors.ts (1)
  • selectImages (5-5)
frontend/src/utils/dateUtils.ts (2)
  • groupImagesByYearMonthFromMetadata (31-55)
  • createImageIndexMap (58-66)
frontend/src/components/Media/ImageCard.tsx (1)
  • ImageCard (20-111)
backend/app/database/images.py (1)
frontend/src/types/Media.ts (1)
  • Image (11-20)
frontend/src/pages/Home/Home.tsx (2)
frontend/src/components/Media/ChronologicalGallery.tsx (2)
  • MonthMarker (11-15)
  • ChronologicalGallery (26-182)
frontend/src/components/Timeline/TimelineScrollbar.tsx (1)
  • TimelineScrollbar (39-422)
frontend/src/components/Timeline/TimelineScrollbar.tsx (2)
frontend/src/components/Media/ChronologicalGallery.tsx (1)
  • MonthMarker (11-15)
frontend/src/utils/timelineUtils.ts (4)
  • TooltipState (4-8)
  • useScroll (10-32)
  • useWheel (34-53)
  • getMarkerForScrollPosition (55-67)
frontend/src/pages/AITagging/AITagging.tsx (3)
frontend/src/features/imageSelectors.ts (2)
  • selectIsImageViewOpen (22-25)
  • selectTaggedImages (37-39)
frontend/src/components/Media/ChronologicalGallery.tsx (2)
  • MonthMarker (11-15)
  • ChronologicalGallery (26-182)
frontend/src/components/Timeline/TimelineScrollbar.tsx (1)
  • TimelineScrollbar (39-422)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
  • GitHub Check: Backend Tests
  • GitHub Check: Tauri Build Check (ubuntu-22.04)
  • GitHub Check: Tauri Build Check (windows-latest)
  • GitHub Check: Tauri Build Check (macos-latest, --target aarch64-apple-darwin)
  • GitHub Check: Tauri Tests
🔇 Additional comments (7)
backend/app/database/images.py (7)

3-7: Imports look correct and minimal.

The new dependencies align with the added functionality.


28-28: Good: metadata type now matches DB TEXT serialization.

Union[Mapping[str, Any], str] prevents mypy drift between in‑memory dicts and stored JSON.


67-87: EXIF handling is robust.

Using getexif with a safe fallback and defensive decoding looks solid.


114-126: Graceful fallback on Pillow open failure.

Keeps file stats and avoids empty metadata; good.


239-239: Consistent connection helper usage.

Good centralization of FK enforcement.


416-425: INSERT OR IGNORE is fine here.

For a pure junction table with no extra columns, ignoring duplicates is acceptable.


488-507: Deletion path looks good.

FKs enabled per-connection ensures CASCADE on image_classes.

Comment on lines +212 to +219
INSERT INTO images (id, path, folder_id, thumbnailPath, metadata, isTagged)
VALUES (:id, :path, :folder_id, :thumbnailPath, :metadata, :isTagged)
""",
image_records,
ON CONFLICT(path) DO UPDATE SET
folder_id=excluded.folder_id,
thumbnailPath=excluded.thumbnailPath,
metadata=excluded.metadata,
isTagged=excluded.isTagged
""",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Don’t downgrade isTagged on upsert.

Re-ingest flows often pass isTagged=False; this would wipe a previously tagged image. Preserve True once set; only escalate to True.

Apply:

             ON CONFLICT(path) DO UPDATE SET
               folder_id=excluded.folder_id,
               thumbnailPath=excluded.thumbnailPath,
-              metadata=excluded.metadata,
-              isTagged=excluded.isTagged
+              metadata=excluded.metadata,
+              isTagged=CASE
+                 WHEN excluded.isTagged THEN 1
+                 ELSE images.isTagged
+              END

Please verify call sites don’t rely on resetting isTagged to False unintentionally:


🏁 Script executed:

#!/bin/bash
# Inspect builders/callers of db_bulk_insert_images for isTagged usage
rg -n -C2 "db_bulk_insert_images\(|isTagged\s*[:=]" --type=py --type=ts --type=tsx

Length of output: 27


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Searching for calls to db_bulk_insert_images and usages of isTagged..."
rg -n -C2 "db_bulk_insert_images\(" || true
rg -n -C2 "\bisTagged\b" || true

echo
echo "Searching for images table schema, INSERTs, and ON CONFLICT upserts..."
rg -n -C2 "CREATE TABLE images" || true
rg -n -C2 "INSERT INTO images" || true
rg -n -C2 "ON CONFLICT\(path\) DO UPDATE SET" || true

echo
echo "Showing backend/app/database/images.py (if present) up to 500 lines for manual inspection..."
if [ -f backend/app/database/images.py ]; then
  sed -n '1,500p' backend/app/database/images.py || true
else
  echo "backend/app/database/images.py not found"
fi

Length of output: 23414


Don't downgrade isTagged on upsert — preserve True.

ON CONFLICT currently sets isTagged = excluded.isTagged; a re-ingest with isTagged=False will clear a previously-tagged image. Change the upsert to only escalate to True.

Apply:

            ON CONFLICT(path) DO UPDATE SET
              folder_id=excluded.folder_id,
              thumbnailPath=excluded.thumbnailPath,
-              metadata=excluded.metadata,
-              isTagged=excluded.isTagged
+              metadata=excluded.metadata,
+              isTagged=CASE
+                 WHEN excluded.isTagged THEN 1
+                 ELSE images.isTagged
+              END

Verify callers that might intentionally rely on resetting isTagged to False:

  • backend/app/database/images.py (current upsert at ~lines 212-219)
  • backend/app/utils/images.py (db_bulk_insert_images call ~line 67; prepared records set "isTagged": False around ~lines 160-164)
  • backend/app/routes/images.py (routes pass isTagged around ~lines 44-47)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
INSERT INTO images (id, path, folder_id, thumbnailPath, metadata, isTagged)
VALUES (:id, :path, :folder_id, :thumbnailPath, :metadata, :isTagged)
""",
image_records,
ON CONFLICT(path) DO UPDATE SET
folder_id=excluded.folder_id,
thumbnailPath=excluded.thumbnailPath,
metadata=excluded.metadata,
isTagged=excluded.isTagged
""",
INSERT INTO images (id, path, folder_id, thumbnailPath, metadata, isTagged)
VALUES (:id, :path, :folder_id, :thumbnailPath, :metadata, :isTagged)
ON CONFLICT(path) DO UPDATE SET
folder_id=excluded.folder_id,
thumbnailPath=excluded.thumbnailPath,
metadata=excluded.metadata,
isTagged=CASE
WHEN excluded.isTagged THEN 1
ELSE isTagged
END
""",
🤖 Prompt for AI Agents
In backend/app/database/images.py around lines 212 to 219 the ON CONFLICT upsert
unconditionally sets isTagged = excluded.isTagged which allows a re-ingest with
isTagged=False to clear a previously-tagged image; change the upsert so it only
escalates to True (e.g., set isTagged = isTagged OR excluded.isTagged, or use
CASE to preserve True) so existing True values cannot be downgraded, and then
audit the listed callers (backend/app/database/images.py upsert,
backend/app/utils/images.py where records are prepared for db_bulk_insert_images
around lines ~160-164, and backend/app/routes/images.py where isTagged is passed
~lines 44-47) to ensure no code intentionally relies on resetting isTagged to
False and adjust those callers if needed.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/app/database/images.py (1)

333-385: Possible None metadata leaks to clients; coerce to dict.

json.loads("null") returns None; current branch can return metadata=None, violating the TypedDict and risking UI errors.

Apply:

-            md: dict = {}
-            if metadata:
-                try:
-                    md = (
-                        json.loads(metadata)
-                        if isinstance(metadata, str)
-                        else (metadata or {})
-                    )
-                except Exception:
-                    md = {}
+            md: dict = {}
+            if metadata:
+                try:
+                    parsed = json.loads(metadata) if isinstance(metadata, str) else metadata
+                    md = parsed if isinstance(parsed, dict) else {}
+                except Exception:
+                    md = {}
♻️ Duplicate comments (1)
frontend/src/pages/AITagging/AITagging.tsx (1)

62-70: Great: Passing scrollContainerRef keeps marker offsets in the correct space.

This addresses the earlier concern about mismatched coordinate systems.

🧹 Nitpick comments (14)
frontend/src/pages/Home/Home.tsx (3)

59-66: Fix width/position duplication; align container width with track width

TimelineScrollbar internally renders an absolute container and a track of width w-6. Here the outer className repeats absolute/top/right and sets w-4, causing the track (w-6) to overflow the container and potentially misalign pointer hit‑targets. Keep positioning inside the component and match container width to the track.

Apply this diff:

-        <TimelineScrollbar
-          scrollableRef={scrollableRef}
-          monthMarkers={monthMarkers}
-          className="absolute top-0 right-0 h-full w-4"
-        />
+        <TimelineScrollbar
+          scrollableRef={scrollableRef}
+          monthMarkers={monthMarkers}
+          className="w-6"
+        />

46-49: A11y: label the primary scroll region

Add an accessible label so screen readers can identify the main scrollable gallery area.

-      <div
-        ref={scrollableRef}
-        className="hide-scrollbar flex-1 overflow-x-hidden overflow-y-auto"
-      >
+      <div
+        ref={scrollableRef}
+        className="hide-scrollbar flex-1 overflow-x-hidden overflow-y-auto"
+        role="region"
+        aria-label="Chronological image gallery"
+      >

44-44: Keep padding in sync with timeline width

You use pr-6 on the container while the timeline track is w-6. If the track width changes later, remember to update this padding to avoid content overlap. Consider extracting a shared variable or utility class for the timeline gutter to prevent drift.

backend/app/database/images.py (6)

45-49: Set row_factory on connections for safer column access.

Optional, but returning sqlite3.Row reduces dict unpacking errors and eases future refactors.

Apply:

 def _connect() -> sqlite3.Connection:
     conn = sqlite3.connect(DATABASE_PATH)
+    conn.row_factory = sqlite3.Row
     # Ensure ON DELETE CASCADE and other FKs are enforced
     conn.execute("PRAGMA foreign_keys = ON")
     return conn

52-148: Normalize date_created to timezone-aware UTC; keep EXIF fallbacks.

Naive datetimes can sort inconsistently across hosts/timezones, which risks wrong month bucketing in the timeline. Emit ISO-8601 with UTC tz.

Apply:

-                        date_created = datetime.datetime.strptime(
-                            dt_original.strip().split("\x00", 1)[0],
-                            "%Y:%m:%d %H:%M:%S",
-                        ).isoformat()
+                        date_created = datetime.datetime.strptime(
+                            dt_original.strip().split("\x00", 1)[0],
+                            "%Y:%m:%d %H:%M:%S",
+                        ).replace(tzinfo=datetime.timezone.utc).isoformat()
                     except ValueError:
-                        date_created = datetime.datetime.fromtimestamp(
-                            stats.st_mtime
-                        ).isoformat()
+                        date_created = datetime.datetime.fromtimestamp(
+                            stats.st_mtime, tz=datetime.timezone.utc
+                        ).isoformat()
                 else:
-                    date_created = datetime.datetime.fromtimestamp(
-                        stats.st_mtime
-                    ).isoformat()
+                    date_created = datetime.datetime.fromtimestamp(
+                        stats.st_mtime, tz=datetime.timezone.utc
+                    ).isoformat()

Optional: also check EXIF "DateTimeDigitized" and "DateTime" when "DateTimeOriginal" is missing.


150-183: Add indexes to match query patterns (folder scans, untagged queries).

Will speed up joins and the AI-tagging selection.

Apply after table creation:

     cursor.execute(
         """
         CREATE TABLE IF NOT EXISTS image_classes (
@@
         )
     """
     )
+
+    # Helpful indexes
+    cursor.execute("CREATE INDEX IF NOT EXISTS idx_images_folder ON images(folder_id)")
+    cursor.execute("CREATE INDEX IF NOT EXISTS idx_images_isTagged ON images(isTagged)")
+    cursor.execute("CREATE INDEX IF NOT EXISTS idx_ic_image ON image_classes(image_id)")
+    cursor.execute("CREATE INDEX IF NOT EXISTS idx_ic_class ON image_classes(class_id)")
+    cursor.execute("CREATE INDEX IF NOT EXISTS idx_folders_ai ON folders(AI_Tagging)")

186-234: Make metadata JSON stable; avoid downgrading thumbnailPath to NULL; confirm path-as-key invariant.

  • Preserve non-ASCII and smaller payloads when serializing.
  • Prevent accidental NULL overwrite of thumbnailPath on upsert.
  • Double-check that “path” is the canonical key and renames are treated as new images.

Apply:

-            record["metadata"] = json.dumps(metadata)
+            record["metadata"] = json.dumps(metadata, ensure_ascii=False, separators=(",", ":"))
             ON CONFLICT(path) DO UPDATE SET
               folder_id=excluded.folder_id,
-              thumbnailPath=excluded.thumbnailPath,
+              thumbnailPath=COALESCE(excluded.thumbnailPath, images.thumbnailPath),
               metadata=excluded.metadata,
               isTagged=CASE
                  WHEN excluded.isTagged THEN 1
                  ELSE images.isTagged
               END

Please confirm:

  • Ingest treats “path” as the stable identity; if a file moves/renames, do we intentionally create a new row?
  • No code relies on clearing thumbnailPath by passing null.

245-331: Tag de-dup can be O(n²); use a set internally.

Minor micro-optimization: build tags in a set, then convert to list once at the end.


468-479: Deterministic ordering for folder_id queries.

Add ORDER BY for stable pagination and tests.

Apply:

         cursor.execute(
             f"""
             SELECT id, path, thumbnailPath
             FROM images
             WHERE folder_id IN ({placeholders})
+            ORDER BY path
             """,
             folder_ids,
         )
frontend/src/pages/AITagging/AITagging.tsx (5)

24-26: Fix ref typing to allow null (TS strict).

useRef should include null in the generic to match the initial value and avoid downstream type casting.

-  const scrollableRef = useRef<HTMLDivElement>(null);
+  const scrollableRef = useRef<HTMLDivElement | null>(null);

14-17: Import MonthMarker as a type-only import.

Slight tree‑shaking/clarity win and avoids accidental value import.

-import {
-  ChronologicalGallery,
-  MonthMarker,
-} from '@/components/Media/ChronologicalGallery';
+import { ChronologicalGallery } from '@/components/Media/ChronologicalGallery';
+import type { MonthMarker } from '@/components/Media/ChronologicalGallery';

37-47: Ensure global loader always hides on unmount and avoids flicker.

If the component unmounts mid‑load, the loader may remain shown. Track local show state and hide in cleanup.

   useEffect(() => {
-    if (imagesLoading) {
-      dispatch(showLoader('Loading AI tagging data'));
-    } else if (imagesError) {
-      dispatch(hideLoader());
-    } else if (imagesSuccess) {
-      const images = imagesData?.data as Image[];
-      dispatch(setImages(images));
-      dispatch(hideLoader());
-    }
-  }, [imagesData, imagesSuccess, imagesError, imagesLoading, dispatch]);
+    let didShow = false;
+    if (imagesLoading) {
+      dispatch(showLoader('Loading AI tagging data'));
+      didShow = true;
+    } else {
+      // Always clear loader when not loading
+      dispatch(hideLoader());
+      if (imagesSuccess) {
+        const images = imagesData?.data as Image[];
+        dispatch(setImages(images));
+      }
+      // (optional) surface error UI when imagesError is true
+    }
+    return () => {
+      if (didShow) dispatch(hideLoader());
+    };
+  }, [imagesData, imagesSuccess, imagesError, imagesLoading, dispatch]);

43-45: Avoid unsafe cast; validate response shape before dispatch.

Casting imagesData?.data as Image[] can mask server/schema drift. Prefer a narrow mapper/validator before setImages.

I can provide a minimal zod schema and mapper if helpful.


50-55: Spacing parity confirmed — optional refactor recommended.

Both frontend/src/pages/AITagging/AITagging.tsx and frontend/src/pages/Home/Home.tsx use pr-6 while TimelineScrollbar is w-4, so there’s no immediate mismatch; content won’t be obscured. Optional: centralize the spacing or add responsive parity (e.g., pr-4 md:pr-6 + w-3 md:w-4) to prevent future drift.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0328f95 and 0548607.

📒 Files selected for processing (5)
  • backend/app/database/images.py (12 hunks)
  • frontend/src/components/Media/ChronologicalGallery.tsx (1 hunks)
  • frontend/src/components/Timeline/TimelineScrollbar.tsx (1 hunks)
  • frontend/src/pages/AITagging/AITagging.tsx (3 hunks)
  • frontend/src/pages/Home/Home.tsx (3 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • frontend/src/components/Timeline/TimelineScrollbar.tsx
  • frontend/src/components/Media/ChronologicalGallery.tsx
🧰 Additional context used
🧬 Code graph analysis (3)
frontend/src/pages/Home/Home.tsx (2)
frontend/src/components/Media/ChronologicalGallery.tsx (2)
  • MonthMarker (11-15)
  • ChronologicalGallery (26-190)
frontend/src/components/Timeline/TimelineScrollbar.tsx (1)
  • TimelineScrollbar (39-421)
frontend/src/pages/AITagging/AITagging.tsx (3)
frontend/src/features/imageSelectors.ts (2)
  • selectIsImageViewOpen (22-25)
  • selectTaggedImages (37-39)
frontend/src/components/Media/ChronologicalGallery.tsx (2)
  • MonthMarker (11-15)
  • ChronologicalGallery (26-190)
frontend/src/components/Timeline/TimelineScrollbar.tsx (1)
  • TimelineScrollbar (39-421)
backend/app/database/images.py (1)
frontend/src/types/Media.ts (1)
  • Image (11-20)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
  • GitHub Check: Tauri Build Check (windows-latest)
  • GitHub Check: Tauri Build Check (ubuntu-22.04)
  • GitHub Check: Tauri Tests
  • GitHub Check: Tauri Build Check (macos-latest, --target aarch64-apple-darwin)
  • GitHub Check: Backend Tests
🔇 Additional comments (5)
frontend/src/pages/Home/Home.tsx (2)

50-56: Good: Gallery wired to timeline source of truth

Passing both onMonthOffsetsChange and scrollContainerRef into ChronologicalGallery ensures markers are computed in the same scroll coordinate space. This addresses the prior offsetParent issue.


20-22: State wiring for timeline is clean

Local state for monthMarkers and a stable scroll ref keep the page concerns minimal and composable.

backend/app/database/images.py (2)

3-7: Type widening and imports look good.

Using Union[Mapping[str, Any], str] for metadata aligns with DB TEXT storage; Pillow/EXIF imports are appropriate for the new extraction logic.

Also applies to: 28-28


32-41: UntaggedImageRecord type fits the API surface.

Clear separation from the full ImageRecord and matches the untagged query payload.

frontend/src/pages/AITagging/AITagging.tsx (1)

62-70: Confirmed — all ChronologicalGallery usages pass scrollContainerRef and onMonthOffsetsChange.

Found in frontend/src/pages/AITagging/AITagging.tsx (lines 64–70) and frontend/src/pages/Home/Home.tsx (lines 50–56); both include scrollContainerRef and onMonthOffsetsChange.

Comment on lines 300 to 306
images_dict[image_id] = {
"id": image_id,
"path": path,
"folder_id": folder_id,
"thumbnailPath": thumbnail_path,
"metadata": metadata,
"metadata": metadata_dict,
"isTagged": bool(is_tagged),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

folder_id type mismatch between DB (INTEGER) and frontend (string). Cast on return.

Frontend types (Media.ts) expect folder_id: string. Backend currently returns int. Cast to str to avoid subtle client bugs.

Apply:

-                    "folder_id": folder_id,
+                    "folder_id": str(folder_id),
-                    "folder_id": folder_id,
+                    "folder_id": str(folder_id),

Run to confirm frontend expects string:

Also applies to: 373-378


🏁 Script executed:

#!/bin/bash
rg -nC2 'folder_id\s*:\s*string' --type=ts --type=tsx

Length of output: 27


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Searching for 'folder_id: string' in .ts/.tsx files..."
rg -nC2 "folder_id\s*:\s*string" -g '!**/node_modules/**' -g '**/*.ts' -g '**/*.tsx' || true

echo "Searching for 'folderId: string' in .ts/.tsx files..."
rg -nC2 "folderId\s*:\s*string" -g '!**/node_modules/**' -g '**/*.ts' -g '**/*.tsx' || true

echo "Searching for any occurrence of 'folder_id' or 'folderId' in .ts/.tsx files..."
rg -nC2 "\b(folder_id|folderId)\b" -g '!**/node_modules/**' -g '**/*.ts' -g '**/*.tsx' || true

echo "Searching for interface/type named Media in .ts/.tsx files..."
rg -nC2 "(interface|type)\s+Media" -g '!**/node_modules/**' -g '**/*.ts' -g '**/*.tsx' || true

Length of output: 12108


Cast folder_id to string in image responses

Frontend types (frontend/src/types/Media.ts, frontend/src/types/Folder.ts) declare folder_id as string; backend returns integers — cast to string before returning. Location: backend/app/database/images.py lines 300-306 and 373-378.

Apply:

-                    "folder_id": folder_id,
+                    "folder_id": str(folder_id),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
images_dict[image_id] = {
"id": image_id,
"path": path,
"folder_id": folder_id,
"thumbnailPath": thumbnail_path,
"metadata": metadata,
"metadata": metadata_dict,
"isTagged": bool(is_tagged),
images_dict[image_id] = {
"id": image_id,
"path": path,
"folder_id": str(folder_id),
"thumbnailPath": thumbnail_path,
"metadata": metadata_dict,
"isTagged": bool(is_tagged),
🤖 Prompt for AI Agents
In backend/app/database/images.py around lines 300-306 and 373-378 the image
response currently returns folder_id as an integer while frontend types expect a
string; update the response objects to cast folder_id to a string (e.g.,
folder_id = str(folder_id) or wrap the value with str(...) where building the
dicts) so both places return folder_id as a string.

@rahulharpal1603
Copy link
Contributor

@rohan-pandeyy I need some time to properly review this. Kindly wait for one more week :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feat: Scrollbar Timeline with {Month-Year} Markers for Home page and AI Tagging page.

3 participants