Skip to content

Implement dynamic HTML page titles (Issue #394)#395

Merged
masonfox merged 27 commits intodevelopfrom
feature/394-dynamic-page-titles
Mar 4, 2026
Merged

Implement dynamic HTML page titles (Issue #394)#395
masonfox merged 27 commits intodevelopfrom
feature/394-dynamic-page-titles

Conversation

@masonfox
Copy link
Owner

@masonfox masonfox commented Mar 3, 2026

Summary

Implements dynamic HTML page titles throughout the app to improve tab distinguishability when multiple Tome tabs are open. All titles follow the format Tome - {detail} where detail is context-specific.

BONUS: Also implements a centralized query key factory to fix critical React Query bugs discovered during this work.

Changes

Dynamic Page Titles

New Hook

  • lib/hooks/usePageTitle.ts: Reusable client-side hook for managing page titles
    • Automatically prefixes with "Tome - "
    • Cleans up on unmount
    • Handles undefined/null gracefully

Static Page Titles (12 pages)

Updated all static pages with appropriate titles:

  • Dashboard: Tome - Dashboard
  • Library: Tome - Library
  • Series List: Tome - Series
  • Stats: Tome - Stats
  • Journal: Tome - Journal
  • Goals: Tome - Goals
  • Read Next: Tome - Read Next
  • Shelves: Tome - Shelves
  • Tags: Tome - Tags
  • Streak: Tome - Streak
  • Settings: Tome - Settings
  • Login: Tome - Login

Dynamic Page Titles (3 pages)

Updated all detail pages with data-driven titles:

  • Book Detail: Tome - {bookTitle} by {authors}
    • Example: Tome - The Fellowship of the Ring by J.R.R. Tolkien
  • Series Detail: Tome - Series / {seriesName}
    • Example: Tome - Series / The Lord of the Rings
  • Shelf Detail: Tome - Shelf / {shelfName}
    • Example: Tome - Shelf / My Favorites

Implementation Details

  • All pages: Use usePageTitle() hook
  • Note: Goals, Streak, and Settings were converted to client components because Next.js metadata exports don't work during client-side navigation (only SSR). Using usePageTitle() ensures titles update correctly during both direct navigation and client-side routing.
  • Loading behavior: Shows "Tome" during data loading, then updates to full title
  • Cleanup: Titles automatically reset to "Tome" when navigating away

Query Key Factory (Bonus Fix)

Problem Discovered

While working on dynamic titles, discovered critical React Query bugs:

  1. Invalidation Bug: useStreak.ts was invalidating ['streak-analytics'] but actual key was ['streak-analytics-full', 7] - invalidation did nothing!
  2. Query Key Collision: useStreakQuery and StreakChartSection both used 'streak-analytics' with different data structures, causing cache confusion
  3. Maintenance Risk: 139 hardcoded query key strings across 20+ files with no type safety

Solution: Centralized Query Key Factory

Created lib/query-keys.ts with type-safe, hierarchical query key factory:

import { queryKeys } from '@/lib/query-keys';

// Type-safe query keys
useQuery({ queryKey: queryKeys.book.detail(bookId), ... });
useQuery({ queryKey: queryKeys.streak.analytics(7), ... });

// Wildcard invalidation
queryClient.invalidateQueries({ queryKey: queryKeys.streak.base() });

Benefits

  • Prevents collisions: Hierarchical keys like ['streak', 'analytics', 7] vs ['streak', 'analytics', 'heatmap', 365]
  • Type safety: TypeScript catches typos at compile time
  • Easier refactoring: Change key structure in one place
  • Consistent patterns: Base keys enable wildcard invalidation

Migration Scope

  • 28 files changed: 1 new factory, 17 hooks, 8 components, 4 pages
  • 139 hardcoded strings → Centralized factory
  • 2 critical bugs fixed: Invalidation failure and key collision
  • Documentation added: Full pattern guide in docs/AI_CODING_PATTERNS.md

Files Migrated

Hooks (17):

  • useStreak.ts (FIXED INVALIDATION BUG)
  • useStreakQuery.ts (FIXED COLLISION)
  • useBookDetail.ts, useBookProgress.ts, useBookStatus.ts, useBookRating.ts
  • useShelfBooks.ts, useReadNextBooks.ts, useReadingGoals.ts
  • useDashboard.ts, useStats.ts, useSessionProgress.ts
  • useLibraryData.ts, useTagManagement.ts, useTagBooks.ts
  • usePullToRefreshLogic.ts, useVersion.ts

Components (8):

  • StreakChartSection.tsx, GoalsPagePanel.tsx
  • CurrentlyReadingList.tsx, ReadingHistoryTab.tsx
  • LogProgressModal.tsx

Pages (4):

  • app/books/[id]/page.tsx, app/journal/page.tsx
  • app/series/[name]/page.tsx, app/series/page.tsx

Testing

✅ All 3937 tests pass (43 new tests added)
✅ Build succeeds with no errors or warnings
✅ Manual testing confirmed:

  • Multiple tabs are now easily distinguishable
  • Titles update correctly when data loads
  • No console errors
  • Proper cleanup on navigation
  • Streak analytics invalidation now works correctly
  • No more query key collisions

Documentation

  • Added comprehensive Query Key Factory Pattern section to docs/AI_CODING_PATTERNS.md
  • Enhanced JSDoc in lib/query-keys.ts with usage examples
  • Updated implementation plan in docs/plans/query-key-factory-implementation.md

Closes

Closes #394

masonfox added 2 commits March 3, 2026 16:13
- Create reusable usePageTitle hook for client-side title management
- Add static titles to all 12 static pages (Dashboard, Library, Series, Stats, Journal, Goals, Read Next Queue, Shelves, Tags, Reading Streak, Settings, Login)
- Use metadata export for server components (Goals, Streak, Settings)
- Use usePageTitle hook for client components
- All titles follow format: Tome - {PageName}

Part of issue #394 - improve tab distinguishability
- Book detail: Tome - {Title} by {Authors}
- Series detail: Tome - {Series Name}
- Shelf detail: Tome - {Shelf Name}
- Titles update dynamically when data loads
- Shows 'Tome' during loading state

Completes Phase 3 of issue #394
Copilot AI review requested due to automatic review settings March 3, 2026 21:17
- Add 17 unit tests for usePageTitle hook
  - Test title setting, updates, cleanup, edge cases
  - Test loading states and multiple instances
  - Test special characters and unicode

- Add 26 integration tests for page titles
  - Test all 12 static page titles
  - Test dynamic page titles (book, series, shelf)
  - Test navigation between pages
  - Test edge cases (special chars, long titles, unicode)

Total: 43 new tests
All 3937 tests pass
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Implements dynamic, route-specific HTML document titles to improve tab distinguishability across Tome, with consistent Tome - {detail} formatting for both static and data-driven pages.

Changes:

  • Added a reusable usePageTitle() client hook to set/reset document.title.
  • Updated multiple client-rendered pages to set static titles via usePageTitle().
  • Updated server-rendered pages (Goals, Streak, Settings) to set titles via Next.js metadata.

Reviewed changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
lib/hooks/usePageTitle.ts Adds reusable hook for setting/resetting document.title.
app/page.tsx Sets Dashboard page title via usePageTitle.
app/library/page.tsx Sets Library page title via usePageTitle.
app/series/page.tsx Sets Series list page title via usePageTitle.
app/series/[name]/page.tsx Sets Series detail page title dynamically from fetched series name.
app/books/[id]/page.tsx Sets Book detail page title dynamically from fetched book title/authors.
app/shelves/page.tsx Sets Shelves page title via usePageTitle.
app/shelves/[id]/page.tsx Sets Shelf detail page title dynamically from fetched shelf name.
app/stats/page.tsx Sets Reading Statistics page title via usePageTitle.
app/journal/page.tsx Sets Reading Journal page title via usePageTitle.
app/read-next/page.tsx Sets Read Next Queue page title via usePageTitle.
app/tags/page.tsx Sets Tags page title via usePageTitle.
app/login/page.tsx Sets Login page title via usePageTitle.
app/goals/page.tsx Sets Reading Goals title via Next.js metadata.
app/streak/page.tsx Sets Reading Streak title via Next.js metadata.
app/settings/page.tsx Sets Settings title via Next.js metadata.

You can also share your feedback on Copilot code review. Take the survey.

masonfox added 3 commits March 3, 2026 16:42
- Create separate query hook for fetching streak data
- Separates queries from mutations (useStreak remains mutation-only)
- Includes queries for streak settings and analytics
- Analytics query conditionally enabled based on streak status
- Interface matches actual API response structure (userTimezone, etc.)
- TimezoneSettings: Remove initialTimezone prop, fetch data via useStreakQuery
- StreakPagePanel: Remove props, fetch data internally with loading/error states
- GoalsPagePanel: Remove initialGoalData/allGoals props, use useReadingGoals hook
- All components now fully self-contained with skeleton loaders
- Improves component reusability and separation of concerns
…or dynamic titles

Fixes bug where page titles don't update during client-side navigation.

Root cause: Next.js metadata exports only work during SSR, not client-side
navigation. Converting to client components with usePageTitle hook fixes this.

Changes:
- Add 'use client' directive to all three pages
- Remove metadata, dynamic, and revalidate exports
- Remove server-side imports and data fetching
- Add usePageTitle() hook for dynamic title management
- Remove props passed to child components (now self-contained)

All pages now update titles correctly during both direct navigation and
client-side routing.

Tested: All 3937 tests passing
@masonfox masonfox marked this pull request as draft March 3, 2026 21:43
masonfox added 4 commits March 3, 2026 16:43
Changed query key from 'streak-analytics' to 'streak-analytics-full' in
useStreakQuery to avoid collision with StreakChartSection's query.

This was causing the Daily Reading Activity graph to not display because
the cached data structure mismatch (full analytics object vs array).

Fixes rendering issue on /streak page.
Add placeholderData to React Query to keep previous chart data visible
while fetching new data, eliminating the flash/unmount that occurred
when changing timeframes in the Daily Reading Activity chart.
- Replace basic skeleton with colorful gradient-based cards matching actual layout
- Add skeletons for chart and heatmap sections with proper spacing
- Reduce chart bottom margin from 40 to 5 pixels
- Reduce XAxis height from 60 to 50 pixels for tighter layout
Add placeholderData to React Query to keep previous chart data visible
while fetching new data, eliminating the flash/unmount that occurred
when changing years in the Monthly Breakdown chart.
@masonfox masonfox marked this pull request as ready for review March 3, 2026 21:50
Copilot AI review requested due to automatic review settings March 3, 2026 21:50
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 24 out of 24 changed files in this pull request and generated 5 comments.


You can also share your feedback on Copilot code review. Take the survey.

@masonfox masonfox marked this pull request as draft March 3, 2026 22:01
@codecov
Copy link

codecov bot commented Mar 3, 2026

Codecov Report

❌ Patch coverage is 94.61538% with 7 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
lib/hooks/usePageTitle.ts 70.58% 2 Missing and 3 partials ⚠️
hooks/useBookRating.ts 80.00% 1 Missing ⚠️
hooks/useShelfBooks.ts 95.00% 0 Missing and 1 partial ⚠️

Impacted file tree graph

@@             Coverage Diff             @@
##           develop     #395      +/-   ##
===========================================
+ Coverage    77.71%   78.55%   +0.84%     
===========================================
  Files          165      167       +2     
  Lines         7458     7509      +51     
  Branches      1809     1829      +20     
===========================================
+ Hits          5796     5899     +103     
+ Misses        1187     1129      -58     
- Partials       475      481       +6     
Flag Coverage Δ
unittests 78.55% <94.61%> (+0.84%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
hooks/useBookDetail.ts 90.69% <100.00%> (+5.33%) ⬆️
hooks/useBookProgress.ts 74.30% <100.00%> (+0.54%) ⬆️
hooks/useBookStatus.ts 83.91% <100.00%> (ø)
hooks/useDashboard.ts 100.00% <ø> (ø)
hooks/useLibraryData.ts 78.84% <100.00%> (ø)
hooks/useStats.ts 100.00% <ø> (ø)
hooks/useStreak.ts 90.62% <100.00%> (-1.05%) ⬇️
lib/query-keys.ts 100.00% <100.00%> (ø)
hooks/useBookRating.ts 83.87% <80.00%> (ø)
hooks/useShelfBooks.ts 96.36% <95.00%> (+33.55%) ⬆️
... and 1 more
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

masonfox added 3 commits March 4, 2026 09:21
… bugs

- Create lib/query-keys.ts with type-safe, hierarchical query key factory
- Fix critical invalidation bug in useStreak.ts (was invalidating wrong key)
- Fix query key collision from PR #395 (streak-analytics used with different data)
- Migrate 17 hooks, 8 components, and 4 pages to use centralized keys
- Add comprehensive documentation to AI_CODING_PATTERNS.md
- Replace 139 hardcoded query key strings with centralized factory

Technical changes:
- Add NumericPeriod type in StreakChartSection for type safety
- Add sessions.progress() key for session progress queries
- Add null guards in useShelfBooks for nullable shelf IDs
- Update streak keys to accept numeric union types

Fixes:
- useStreak.ts invalidating ['streak-analytics'] but key was ['streak-analytics-full', 7]
- useBookProgress.ts invalidating wrong streak analytics key
- Query key collision between useStreakQuery and StreakChartSection

All 3937 tests pass, build succeeds with no warnings
- Fix TimezoneSettings to use updateTimezoneAsync instead of updateTimezone
  Ensures proper async/await handling and error catching

- Disable refetchOnWindowFocus in useStreakQuery to prevent excessive DB writes
  The /api/streak endpoint performs writes (checkAndResetStreakIfNeeded)
  staleTime of 60s provides sufficient freshness

- Update usePageTitle JSDoc to accurately document empty string handling
  Clarifies that both undefined and empty strings fall back to 'Tome'

- Remove trailing whitespace in app/books/[id]/page.tsx

Addresses feedback from GitHub Copilot reviews:
- #395 (review)
- #395 (review)

All 3937 tests passing
moved to global
@masonfox masonfox marked this pull request as ready for review March 4, 2026 14:32
Copilot AI review requested due to automatic review settings March 4, 2026 14:32
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 47 out of 47 changed files in this pull request and generated 7 comments.

Comments suppressed due to low confidence (1)

hooks/useBookRating.ts:58

  • This mutation now cancels/invalidates using queryKeys.book.detail(parseInt(bookId)), but the optimistic update/rollback still reads/writes ['book', bookId]. That leaves the actual cached book detail (keyed by number) untouched, so UI may not update optimistically and rollbacks may not work. Use the same queryKeys.book.detail(bookIdNum) key for getQueryData/setQueryData/rollback to keep cache operations consistent.

You can also share your feedback on Copilot code review. Take the survey.

masonfox added 3 commits March 4, 2026 09:41
The usePageTitle hook was failing to update the document title on initial page load/refresh, causing the title to remain as 'Tome' instead of showing the page-specific title (e.g., 'Tome - Dashboard').

Root cause: useEffect runs after hydration, allowing the SSR'd default title from layout metadata to persist.

Solution: Set document.title immediately during component render (outside effects) combined with useLayoutEffect for updates. This ensures the title is set as early as possible during hydration, before the user sees the default title.

Changes:
- Add immediate title setting using synchronous code during render
- Change useEffect to useLayoutEffect for better timing
- Add proper guards for SSR (typeof window !== 'undefined')
- Update documentation to explain the hydration timing fix

All existing tests continue to pass (17/17 hook tests, 26/26 integration tests).
Copilot AI review requested due to automatic review settings March 4, 2026 14:50
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 47 out of 47 changed files in this pull request and generated 6 comments.

Comments suppressed due to low confidence (3)

components/Streaks/StreakChartSection.tsx:90

  • TimePeriodFilter includes string options ("this-year", "all-time"), but this component now hard-restricts state to numeric periods and ignores non-numeric selections. As a result, the dropdown shows options the user can’t actually select (the controlled value will snap back). Consider filtering the options for this use-case (or providing a dedicated filter component/config) so only supported numeric periods are presented.
  const handlePeriodChange = useCallback(
    (period: TimePeriod) => {
      // Only numeric periods are supported for streak analytics
      if (typeof period === 'number') {
        setSelectedPeriod(period);
      }
    },
    []
  );

  return (
    <div>
      {/* Header with Filter */}
      <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
        <h2 className="text-2xl font-serif font-bold text-[var(--heading-text)]">
          Daily Reading Activity
        </h2>
        <TimePeriodFilter
          selected={selectedPeriod}
          onChange={handlePeriodChange}
        />

hooks/useBookRating.ts:58

  • useBookRating is still reading/writing optimistic state under the old query key ['book', bookId], but the query itself now uses queryKeys.book.detail(parseInt(bookId)). This means previousBook will likely be undefined and the optimistic update/rollback won’t affect the active cache entry. Use the same queryKeys.book.detail(...) key consistently for getQueryData/setQueryData/rollback.
    hooks/useVersion.ts:30
  • queryKeys is imported here but never used, and the query key is still hardcoded (['version']). Either remove the unused import or add a queryKeys.version... entry and use it here (to match the new “use the factory for all keys” pattern).

You can also share your feedback on Copilot code review. Take the survey.

masonfox added 5 commits March 4, 2026 10:02
The daily reading activity chart was not rendering on initial page load
due to improper initialData handling between parent and child components.
This caused race conditions where the chart would only appear when
switching to non-default time periods.

Changes:
- Lift selectedPeriod state to StreakPagePanel (parent manages data)
- Remove initialData prop coordination between parent/child
- Let StreakChartSection receive data as props instead of querying
- Add support for string periods (this-year, all-time) in query keys
- Use placeholderData to keep previous chart visible during transitions
- Remove window.location.reload() on threshold updates (use React Query invalidation)

Benefits:
- Chart renders immediately on page load (fixes #394 chart issue)
- All time periods work (7, 30, 90, 180, this-year, all-time)
- TanStack Query deduplicates requests when parent and child need same data
- Smoother UX with no page reloads on settings updates
- Stats cards always show overall data (not affected by period selection)
- daysOfData always uses 7-day check for encouraging message

Resolves chart rendering bug where switching to 30 days worked but
7 days (default) did not display.
Implement code review improvements from PR #395:

- Optimize usePageTitle hook to deduplicate title computation
- Add strict AnalyticsDays type for type safety
- Fix duplicate streak.settings/base query keys (critical fix)
- Improve loading state UX by showing 'Tome' instead of placeholder

Changes:
- lib/hooks/usePageTitle.ts: Compute title once, reuse in both render and effect
- lib/query-keys.ts: Add AnalyticsDays type, fix settings key hierarchy
- app/series/[name]/page.tsx: Use undefined for loading state
- app/shelves/[id]/page.tsx: Use undefined for loading state

All 3937 tests passing, build succeeds with no errors.
Copilot AI review requested due to automatic review settings March 4, 2026 21:27
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 48 out of 48 changed files in this pull request and generated 11 comments.

Comments suppressed due to low confidence (1)

hooks/useVersion.ts:30

  • queryKeys is imported but not used, and the query key remains hardcoded (['version']). This will trip unused-import linting and undermines the new key factory pattern. Either remove the import, or add a queryKeys.version entry and use it here.

You can also share your feedback on Copilot code review. Take the survey.

masonfox added 2 commits March 4, 2026 16:57
Critical fixes:
- Fix usePageTitle cleanup to prevent title clobbering during navigation
- Fix shelf query key structure to resolve invalidation bugs
- Migrate useStats to new streak key structure (eliminates legacy key)
- Add queryKeys.version() to factory for consistency

Code cleanup:
- Remove unused analyticsError variable from StreakPagePanel
- Remove duplicate section header in query-keys.ts
- Document usePageTitle render-time side effect tradeoff

Documentation:
- Update PR description to reflect hierarchical title format
- Add comprehensive JSDoc explaining usePageTitle implementation

All changes maintain backward compatibility except shelf query keys,
which will trigger one cache refresh on deploy (acceptable tradeoff).

Addresses Copilot review feedback from:
- Review #3889852066 (2026-03-03)
- Review #3892171749 (2026-03-04)

All 3937 tests passing.
Add MutationObserver to usePageTitle hook to detect and restore titles
when React hydration overwrites client-side changes with server-rendered
metadata. This fixes the issue where hard refresh caused titles to
revert to just 'Tome' on /settings, /streak, and /library pages.

The observer watches the <title> element and immediately restores the
correct title whenever external changes are detected, making the fix
imperceptible to users.
Copilot AI review requested due to automatic review settings March 4, 2026 22:39
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 48 out of 48 changed files in this pull request and generated 6 comments.

Comments suppressed due to low confidence (2)

hooks/useBookRating.ts:58

  • useBookRating now cancels/invalidates using queryKeys.book.detail(...), but the optimistic update + rollback still read/write ['book', bookId]. That means the optimistic cache update won't affect the active query cache anymore, and rollback will also target the wrong key. Update getQueryData/setQueryData to use the same queryKeys.book.detail(parseInt(bookId)) key (ideally compute once).
    hooks/useStreak.ts:27
  • This file uses console.error(...) in multiple mutation error handlers, but the repo ESLint config disallows all console.* usage outside tests (no-console: error). Replace these with structured logging via getLogger() (or remove logging if not needed) to avoid failing next lint.

You can also share your feedback on Copilot code review. Take the survey.

Comment on lines +81 to +96
book: {
/** Base key for all book queries: ['book'] */
base: () => ['book'] as const,

/** Book detail by ID: ['book', id] */
detail: (id: number) => ['book', id] as const,

/** Available tags for books: ['availableTags'] */
availableTags: () => ['availableTags'] as const,

/** Available shelves for books: ['availableShelves'] */
availableShelves: () => ['availableShelves'] as const,

/** Shelves for a specific book: ['bookShelves', bookId] */
shelves: (bookId: number) => ['bookShelves', bookId] as const,
},
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

queryKeys.book.base() implies wildcard invalidation for “all book queries”, but several book-related keys here are not children of ['book'] (e.g. availableTags, availableShelves, shelves uses ['bookShelves', ...]). As a result, invalidateQueries({ queryKey: queryKeys.book.base() }) will not invalidate those caches. Consider either nesting these under the book prefix (e.g. ['book', 'availableTags'], ['book', bookId, 'shelves']) or renaming/moving them so the JSDoc and wildcard invalidation semantics remain accurate.

Copilot uses AI. Check for mistakes.
Comment on lines +145 to +157
goals: {
/** Base key for all goal queries: ['goals'] */
base: () => ['goals'] as const,

/** Reading goal for specific year: ['reading-goal', year] */
byYear: (year: number) => ['reading-goal', year] as const,

/** Monthly breakdown for specific year: ['monthly-breakdown', year] */
monthlyBreakdown: (year: number) => ['monthly-breakdown', year] as const,

/** Completed books for specific year: ['completed-books', year] */
completedBooks: (year: number) => ['completed-books', year] as const,
},
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

The Goals domain mixes a goals.base() key of ['goals'] with other goal queries that are not children of that prefix (['reading-goal', year], ['monthly-breakdown', year], ['completed-books', year]). This means wildcard invalidation via queryKeys.goals.base() won’t refresh the actual goal detail queries. Consider restructuring these keys under a single hierarchical prefix (e.g. ['goals', 'byYear', year], etc.) or adjusting the naming/docs to avoid implying wildcard support that isn’t there.

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,76 @@
import { useLayoutEffect, useRef, useEffect } from "react";
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

useRef is imported but never used. This will be flagged by linting (and is dead code). Remove the unused import.

Copilot uses AI. Check for mistakes.
Comment on lines 86 to 101
@@ -93,7 +97,7 @@ export function useShelfBooks(
.map((book, index) => ({ ...book, sortOrder: index } as BookWithStatus));

queryClient.setQueryData(
["shelf", shelfId, { orderBy, direction }],
["shelf", shelfId, "books", { orderBy, direction }],
{
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

Even after introducing queryKeys.shelf.detail(...), the optimistic cache reads/writes here still hardcode the query key array (e.g. ["shelf", shelfId, "books", { orderBy, direction }]). This risks drift if the factory changes and makes it harder to audit key consistency. Use the factory-returned key for getQueryData/setQueryData/rollback as well (compute it once per render/mutation).

Copilot uses AI. Check for mistakes.
Comment on lines +36 to +47
// Map pages to their query key invalidation
const invalidationsByPath: Record<string, readonly unknown[]> = {
"/": queryKeys.dashboard.all(),
"/library": queryKeys.library.books(),
"/read-next": queryKeys.readNext.base(),
"/series": queryKeys.series.all(),
"/stats": queryKeys.stats.all(),
"/goals": queryKeys.goals.base(),
"/streak": queryKeys.streak.base(),
"/shelves": queryKeys.shelf.base(),
"/tags": queryKeys.tags.base(),
};
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

The invalidationsByPath mapping looks incomplete/inaccurate for current queries: (1) /goals only invalidates queryKeys.goals.base() (['goals']), but the Goals UI queries use reading-goal / monthly-breakdown / completed-books keys, so pull-to-refresh won’t refresh the displayed goal data; and (2) routes like /journal and /settings are no longer mapped, causing the fallback to invalidate all queries (potentially expensive). Consider adding explicit invalidations for these pages and/or introducing proper goal/journal/settings base keys so wildcard invalidation works as intended.

Copilot uses AI. Check for mistakes.
Comment on lines +38 to +71
it("should render Reading Statistics title", () => {
render(<TestPage title="Reading Statistics" />);
expect(document.title).toBe("Tome - Reading Statistics");
});

it("should render Reading Journal title", () => {
render(<TestPage title="Reading Journal" />);
expect(document.title).toBe("Tome - Reading Journal");
});

it("should render Reading Goals title", () => {
render(<TestPage title="Reading Goals" />);
expect(document.title).toBe("Tome - Reading Goals");
});

it("should render Read Next Queue title", () => {
render(<TestPage title="Read Next Queue" />);
expect(document.title).toBe("Tome - Read Next Queue");
});

it("should render Shelves title", () => {
render(<TestPage title="Shelves" />);
expect(document.title).toBe("Tome - Shelves");
});

it("should render Tags title", () => {
render(<TestPage title="Tags" />);
expect(document.title).toBe("Tome - Tags");
});

it("should render Reading Streak title", () => {
render(<TestPage title="Reading Streak" />);
expect(document.title).toBe("Tome - Reading Streak");
});
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

These integration tests use page title strings that don’t match the ones actually set in the app (e.g. the app sets usePageTitle("Stats"), but the test asserts "Reading Statistics"; similarly for Journal/Goals/Streak/Read Next). Since this test suite is named like it validates real page titles, consider aligning the strings with the actual page implementations (or renaming/simplifying the cases) to avoid confusion when titles change.

Copilot uses AI. Check for mistakes.
masonfox added 2 commits March 4, 2026 17:49
Add comprehensive tests for dynamic page titles and query key factory:

- lib/query-keys.ts: 47 new tests achieving 100% coverage
  - Query key factory functions for all entities
  - Type safety verification with const assertions
  - Hierarchical structure validation
  - Key collision prevention
  - Edge cases (negative IDs, empty strings, special characters)

- hooks/useShelfBooks.ts: 20 new tests for moveToTop/moveToBottom
  - API call verification
  - Optimistic updates and error rollback
  - State tracking across mutations
  - Sort order handling (ascending/descending)

- hooks/useBookDetail.ts: 3 new tests for updateBookPartial
  - Single and multiple field updates
  - Null safety when book data not loaded

- hooks/usePageTitle.ts: Document MutationObserver limitation
  - Explained why lines 46-48 cannot be reliably tested
  - Browser-specific behavior not reproducible in happy-dom

All 4007 tests pass. Ready for review.
Replace remaining hardcoded query key arrays with factory methods in
getQueryData/setQueryData calls across three hooks. This completes the
migration to the centralized query key factory pattern.

Fixes 30 instances of hardcoded keys in optimistic update patterns:
- useBookRating: 3 instances (getQueryData/setQueryData)
- useReadNextBooks: 9 instances across 3 mutations
- useShelfBooks: 18 instances across 6 mutations

This ensures consistency where all query operations (useQuery,
invalidateQueries, getQueryData, setQueryData) now use the same
factory-generated keys, preventing potential cache mismatches.
Copilot AI review requested due to automatic review settings March 4, 2026 22:59
they weren't working
@masonfox masonfox merged commit 4a5722b into develop Mar 4, 2026
4 checks passed
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 50 out of 50 changed files in this pull request and generated 9 comments.


You can also share your feedback on Copilot code review. Take the survey.

Comment on lines +146 to +150
/** Base key for all goal queries: ['goals'] */
base: () => ['goals'] as const,

/** Reading goal for specific year: ['reading-goal', year] */
byYear: (year: number) => ['reading-goal', year] as const,
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

queryKeys.goals.base() returns ['goals'], but byYear uses ['reading-goal', year] (and related queries use other top-level prefixes). As a result, wildcard invalidation via goals.base() won’t invalidate most goals data. Consider namespacing goals keys under ['goals', ...] or adding goal-specific base keys so invalidation is predictable.

Copilot uses AI. Check for mistakes.
Comment on lines +39 to +43
} = useQuery<StreakAnalyticsData>({
queryKey: queryKeys.streak.analytics(selectedPeriod),
queryFn: async () => {
const response = await fetch(`/api/streak/analytics?days=${String(selectedPeriod)}`);
if (!response.ok) throw new Error('Failed to fetch analytics');
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

When selectedPeriod is 7, this useQuery uses the same key as useStreakQuery’s analytics query (queryKeys.streak.analytics(7)), but with a different queryFn/options. Sharing a key across different queryFns is fragile (mount order decides which queryFn runs) and can lead to inconsistent refetch behavior. Consider consolidating analytics fetching into a single hook/source (e.g. parameterize useStreakQuery, or skip this query when selectedPeriod===7 and reuse analytics).

Copilot uses AI. Check for mistakes.
Comment on lines 21 to 25
const { data: streak, isLoading: isLoadingStreak, error: streakError } = useQuery<StreakData>({
queryKey: ['streaks'],
queryKey: queryKeys.streak.settings(),
queryFn: fetchStreak,
staleTime: 30000, // 30 seconds
refetchOnWindowFocus: true,
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

queryKeys.streak.settings() is also used by useStreakQuery() for /api/streak, but here it backs statsApi.getStreak() which calls /api/streaks (different endpoint/response shape). Sharing the same query key across different queryFns will cause cache collisions and type mismatches. Introduce a distinct query key for the /api/streaks stats endpoint (or switch this hook to the same /api/streak source) so each endpoint has a unique, stable key.

Copilot uses AI. Check for mistakes.
Comment on lines +26 to 30
? queryKeys.shelf.detail(shelfId, { orderBy, direction })
: ['shelf-empty'],
queryFn: async () => {
if (!shelfId) return null;

Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

The query is enabled when shelfId !== null, but the queryFn uses if (!shelfId) which treats 0 as missing. This makes the nullability checks inconsistent and can lead to returning null/throwing even though the query is enabled. Prefer explicit shelfId === null checks throughout for consistency with number | null.

Copilot uses AI. Check for mistakes.
Comment on lines +88 to +92
/** Available tags for books: ['availableTags'] */
availableTags: () => ['availableTags'] as const,

/** Available shelves for books: ['availableShelves'] */
availableShelves: () => ['availableShelves'] as const,
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

These keys live under queryKeys.book, but they don’t share the ['book', ...] prefix (availableTags / availableShelves / bookShelves). That undermines hierarchical/wildcard invalidation via queryKeys.book.base(). Consider namespacing them under ['book', ...] (or moving them out of the book domain if they’re intentionally global).

Copilot uses AI. Check for mistakes.
Comment on lines +37 to +41
const invalidationsByPath: Record<string, readonly unknown[]> = {
"/": queryKeys.dashboard.all(),
"/library": queryKeys.library.books(),
"/read-next": queryKeys.readNext.base(),
"/series": queryKeys.series.all(),
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

invalidationsByPath no longer has explicit entries for routes like /journal and /settings (so they’ll fall back to queryClient.invalidateQueries() and invalidate everything). That can cause unnecessary refetching on mobile pull-to-refresh. Consider adding explicit invalidations for those routes using the appropriate queryKeys.* prefixes.

Copilot uses AI. Check for mistakes.
Comment on lines +571 to +575
onSuccess: () => {
if (shelfId !== null) {
queryClient.invalidateQueries({
queryKey: queryKeys.shelf.detail(shelfId)
});
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

The example invalidation uses queryKeys.shelf.detail(shelfId), but queryKeys.shelf.detail currently requires a second options argument. As written this example won’t type-check and may confuse readers; update it to use queryKeys.shelf.byId(shelfId) for wildcard invalidation (or pass the required options object).

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +2
import { useLayoutEffect, useRef, useEffect } from "react";

Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

useRef is imported but never used. Removing the unused import will avoid lint/TS warnings and keep the hook minimal.

Copilot uses AI. Check for mistakes.
Comment on lines +39 to +42
* // Invalidate ALL book queries (wildcard)
* queryClient.invalidateQueries({
* queryKey: queryKeys.book.base()
* });
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

This example says queryKeys.book.base() can invalidate “ALL book queries”, but several book-related keys in this same factory are not children of ['book'] (e.g. availableTags, availableShelves, bookShelves). Either namespace those under ['book', ...] or adjust the docs/examples so callers don’t rely on book.base() for broader invalidation than it provides.

Copilot uses AI. Check for mistakes.
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.

Use HTML title

2 participants