Skip to content

Release v0.6.8#401

Open
masonfox wants to merge 375 commits intomainfrom
develop
Open

Release v0.6.8#401
masonfox wants to merge 375 commits intomainfrom
develop

Conversation

@masonfox
Copy link
Copy Markdown
Owner

@masonfox masonfox commented Mar 11, 2026

Release v0.6.8

This release includes bug fixes, UX improvements, and technical enhancements.

Bug Fixes

Fix: DNF status transitions now create new sessions (#391)

  • Fixed: Transitioning from DNF back to "Read Next" or "Want To Read" now properly creates a new session instead of overwriting the DNF session
  • Behavior: DNF is now treated as a fully terminal state (like "read")
  • Validation: Direct DNF → read transitions are now blocked with validation error
  • Impact: DNF history is preserved when users restart a book
  • Tests: +10 new tests (5 unit, 5 E2E)

Fix: Stable sort for journal entries with same date (#400)

  • Fixed: Journal entry date changes now produce correct, consistent "pages read" calculations when multiple entries exist on the same date
  • Root cause: Sorting by progressDate alone caused non-deterministic ordering
  • Solution: Implemented stable multi-column sort (progressDate → createdAt → id)
  • Impact: All journal calculations are now idempotent and deterministic
  • Tests: +4 new tests (3 integration, 1 E2E)

UX Improvements

Add mobile back button to book detail page (#397)

  • Added: Mobile-only back button on book detail pages
  • Behavior: Shows when navigating from another page, preserves scroll position
  • Display: Circular background with arrow icon, hidden on desktop
  • Implementation: Uses browser history for universal navigation support

Implement dynamic HTML page titles (#395)

  • Added: Dynamic page titles for better tab distinguishability
  • Format: All titles follow Tome - {detail} pattern
  • Static pages (12): Dashboard, Library, Series, Stats, Journal, Goals, Read Next, Shelves, Tags, Streak, Settings, Login
  • Dynamic pages (3): Book detail, Series detail, Shelf detail
  • Example: Tome - The Fellowship of the Ring by J.R.R. Tolkien

Technical Improvements

Query Key Factory (#395)

  • Added: Centralized, type-safe query key factory (lib/query-keys.ts)
  • Fixed: Critical React Query invalidation bug in streak analytics
  • Fixed: Query key collision between useStreakQuery and StreakChartSection
  • Migrated: 139 hardcoded query key strings across 28 files
  • Benefits: Prevents collisions, adds type safety, enables wildcard invalidation
  • Documentation: Added comprehensive pattern guide to docs/AI_CODING_PATTERNS.md

Test Coverage

  • All tests passing: 4003/4003 tests (100%)
  • New tests added: 14 tests (9 integration, 5 E2E)
  • No breaking changes, backward compatible

Files Changed

masonfox and others added 30 commits January 3, 2026 08:14
Strengthen code coverage requirements to maintain quality bar:
- Reduce project threshold from 1% to 0.3% to catch coverage regressions
- Set patch target to 80% (matching current project baseline of 81%)
- Set patch threshold to 0% to prevent coverage degradation in new code

Rationale: At 81% project coverage, the previous 1% threshold was too
permissive and could mask significant coverage drops. New code should
meet or exceed the existing quality standard.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
## Summary

- Adds a "Check All" / "Uncheck All" button when filtering tags via
search query
- Button layout splits 2/3 "Select Multiple" and 1/3 "Check All" when
actively filtering
- Check All button adds remaining filtered tags to existing selections
(allows building selections across queries)
- Button text dynamically toggles between "Check All" and "Uncheck All"
based on current state
- Auto-enters checkbox mode when Check All is clicked
- Responsive design: shows "All" on small screens, full text on larger
screens

## Changes

- Updated `TagList` component to conditionally render split buttons when
search query is active
- Added `allFilteredChecked` computed value to track if all filtered
tags are currently selected
- Added `handleToggleAllFiltered` function to toggle all filtered tags
on/off
- Import `CheckCheck` icon from lucide-react for the new button
- Maintains existing functionality when not filtering (single "Select
Multiple" button)

## Testing

- Manually tested filtering + check all functionality
- Tested responsive behavior on different screen sizes
- Verified selection building across multiple queries
- Confirmed auto-enter checkbox mode behavior
## Summary

- Upgrades Next.js from 14 to 16.1.1 and React from 18 to 19
- Migrates all route handlers to use async params API (Next.js 15+
requirement)
- Converts next.config.js to TypeScript and removes deprecated options
- Adds Turbopack support and fixes CSS import order
- Updates React 19 type compatibility for refs

This major framework upgrade modernizes the application with the latest
stable versions, enabling improved performance, Turbopack bundling, and
access to new React 19 features.

Closes #192

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
## Summary

Fixes production error where Next.js fails to load the pino logger
module during the instrumentation phase with error: `EISDIR reading
"/app/.next/node_modules/pino-28069d5257187539"`

## Changes

- **lib/logger.ts**: Changed pino initialization to lazy-load at runtime
instead of at module import time
- **next.config.ts**: Added pino and pino-pretty to
serverExternalPackages and webpack externals to prevent bundling

## Technical Details

Next.js 16 with Turbopack has issues bundling certain Node.js packages
during the instrumentation phase. This fix:
1. Uses `require('pino')` inside a lazy initialization function instead
of top-level import
2. Marks pino packages as external so they're not bundled by
Next.js/Turbopack
3. Maintains full logging functionality - logger initializes on first
use

## Testing

- Build completes successfully without pino module errors
- Logging functionality preserved at runtime
…ck bundling error

- Replace pino logger with console.log in instrumentation.ts
- Prevents Turbopack from trying to bundle pino during instrumentation phase
- Resolves EISDIR error: BuildMessage: EISDIR reading pino module
- Maintains structured logging for API routes and application code
…g error

Root cause: Turbopack performs static analysis at build time and attempts to
bundle pino-pretty when it encounters the string literal 'pino-pretty', even
inside conditional blocks that evaluate to false at runtime.

Changes:
- Remove pino-pretty transport configuration from logger.ts
- Logs now output as structured JSON (pino's default format)
- Multi-stream logging (stdout + file) preserved and unaffected
- For pretty logs in dev, pipe through pino-pretty externally

This fixes the production error:
  Failed to load external module pino-28069d5257187539:
  BuildMessage: EISDIR reading "/app/.next/node_modules/pino-28069d5257187539"
…tion phase loading

Critical fix: lib/db/calibre.ts had module-level code (lines 9-12) that called
getLogger() immediately when the module was imported. This caused pino to load
during the instrumentation phase, triggering Turbopack bundling errors.

The import chain was:
  instrumentation.ts → sync-service.ts → calibre.ts → logger → pino

Changes:
- Move logger call from module-level into getCalibreDB() function
- Add hasLoggedWarning flag to log warning only once
- Warning now logs when function is called, not at module load time

This completes the fix for:
  Failed to load external module pino-28069d5257187539:
  BuildMessage: EISDIR reading "/app/.next/node_modules/pino-28069d5257187539"
…g during instrumentation

The instrumentation hook was failing in production with EISDIR error because:
- lib/db/sqlite.ts had module-level getLogger() call (line 6)
- lib/db/migrate.ts had module-level getLogger() call (line 11)
- lib/db/seeders/index.ts had module-level getLogger() call (line 22)

Import chain during instrumentation phase:
instrumentation.ts → sync-service.ts → repositories → lib/db/sqlite.ts → lib/logger.ts → pino

This caused Turbopack to attempt bundling pino during the instrumentation phase,
creating a symlink that was incorrectly read as a directory.

Solution: Replace all module-level logger initialization with lazy getLoggerSafe()
functions that only load pino when actually called, not at import time.

Files modified:
- lib/db/sqlite.ts: Replaced const logger with lazy getLoggerSafe()
- lib/db/migrate.ts: Replaced const logger with lazy getLoggerSafe()
- lib/db/seeders/index.ts: Replaced const logger with lazy getLoggerSafe()

This completes the fix chain from commits 224d4a6, 3587203, 433d498, and 118f6dd.
…tibility issue

Next.js 16 Turbopack has compatibility issues with pino logger when running
with Bun runtime, causing 'Failed to load external module pino' EISDIR errors
during instrumentation phase.

Root cause: Turbopack cannot handle pino's dynamic worker thread requires.
Upstream issue: vercel/next.js#86099 (still open)

Changes:
- package.json: Add --webpack flag to dev and build scripts
- tailwind.config.ts: Convert require() to ESM import for webpack compatibility
- docs/TURBOPACK_PINO_WORKAROUND.md: Document issue and future migration plan

This is a temporary workaround. Phase 2 will migrate to Consola logger which
has native Turbopack compatibility and is used by Vercel internally.

Testing:
✅ Development server starts without errors
✅ Production build completes successfully
✅ Production server runs without pino/instrumentation errors
discovered that, in production, these fonts are not loading and applying
and we're falling back to serif. Maintain serif, for now.
masonfox and others added 19 commits February 23, 2026 18:14
Implements a two-agent review automation system:
- /review-loop: Automated iteration between @review agent and GitHub Copilot
- /review: Single-pass review for quick feedback
- /review-status: Check PR status without triggering reviews

Features:
- Max 3 iterations to prevent infinite loops
- Auto-implements recommended changes
- Runs tests after each change
- Both agents must approve for completion
- Full documentation and usage examples

Files added:
- .opencode/command/review-loop.md (main automation)
- .opencode/command/review.md (single review)
- .opencode/command/review-status.md (status checker)
- .opencode/command/README.md (command docs)
- docs/REVIEW_LOOP.md (workflow guide)

Updated:
- AGENTS.md (added review commands and docs reference)
## Summary

Fixes critical transaction handling bug in `shelf.repository.ts`
identified during review of PR #381.

## Changes

### 🔴 Critical Fix: Transaction Handling

**Problem**: The `moveBookToBottom` method in `shelf.repository.ts` was
not using the transaction parameter correctly:

```typescript
// Before (INCORRECT):
db.transaction(() => {
  db.update(bookShelves)  // ❌ Uses outer db instance
    .set({ sortOrder: sql`...` })
    .run();
});
```

**Impact**: Updates were not atomic, defeating the purpose of the
transaction and potentially causing race conditions.

**Fix**: Now correctly uses the transaction parameter:

```typescript
// After (CORRECT):
await db.transaction((tx) => {  // ✅ Uses tx parameter
  tx.update(bookShelves)        // ✅ Uses transaction instance
    .set({ sortOrder: sql`...` })
    .run();
});
```

### Additional Improvements

- Added `await` keyword for consistency with `session.repository.ts`
pattern
- Aligns transaction pattern across both repositories

## Testing

✅ All 3,891 tests pass
- 33 shelf repository tests
- 18 move-to-bottom API tests (sessions + shelves)
- 38 session repository tests
- All other test suites

## Related

- Fixes issue identified in PR #381 review
- Follows pattern established in `session.repository.ts:699`
- Maintains consistency across repository layer

## Review Notes

This change ensures atomic database updates when moving books to the
bottom of shelves. Without this fix, concurrent operations could result
in corrupted sort orders.
…384)

## Summary

Fixes critical optimistic update logic bugs in "Move to Bottom"
functionality identified by GitHub Copilot in PR #381.

The optimistic updates were causing UI flickers because they didn't
match the server-side logic, creating inconsistent cache state.

## Problem

Both `useShelfBooks` and `useReadNextBooks` hooks had optimistic updates
that reassigned **ALL** `sortOrder`/`readNextOrder` values sequentially
(0, 1, 2...), but the server-side logic in the repositories only:
- Decrements items **above** the moved item
- Sets the moved item to `maxOrder`
- Leaves items **below** unchanged

This mismatch between optimistic state and actual server response caused
visual flickers when the cache was invalidated and real data was
fetched.

## Changes

### 1. Fixed `hooks/useShelfBooks.ts` (moveToBottom mutation)
- Updated optimistic update to mirror server-side logic from
`shelf.repository.ts`
- Keep books below moved book unchanged
- Decrement `sortOrder` only for books above moved book
- Set moved book to `maxOrder`

### 2. Fixed `hooks/useReadNextBooks.ts` (moveToBottom mutation)
- Updated optimistic update to mirror server-side logic from
`session.repository.ts`
- Keep sessions below moved session unchanged
- Decrement `readNextOrder` only for sessions above moved session
- Set moved session to `maxOrder`

## Code Changes

**Before (incorrect):**
```typescript
// ❌ Reassigns ALL sortOrder values
const otherBooks = previousShelf.books.filter((b) => b.id !== bookId);
const optimisticBooks = [
  ...otherBooks.map((book, index) => ({
    ...book,
    sortOrder: index, // All books get new sequential values
  })),
  { ...bookToMove, sortOrder: maxOrder },
];
```

**After (correct):**
```typescript
// ✅ Only updates books above moved book
const optimisticBooks = previousShelf.books.map((book) => {
  if (book.id === bookId) {
    return { ...book, sortOrder: maxOrder };
  }

  const bookOrder = book.sortOrder ?? 0;
  if (bookOrder > currentOrder) {
    // Only decrement books above the moved book
    return { ...book, sortOrder: bookOrder - 1 };
  }

  // Books below keep their sortOrder unchanged
  return book;
});
```

## Testing

✅ All **3891 tests pass** with the new logic
- No test changes required (existing tests validate the behavior)
- Logic now matches server-side implementation exactly

## References

- Addresses GitHub Copilot PR review feedback on PR #381:
  - `hooks/useShelfBooks.ts:451`
  - `hooks/useReadNextBooks.ts:261`
- Original PR: #381 (Release v0.6.6)

## Impact

- **User Experience:** Eliminates UI flickers when moving books/sessions
to bottom
- **Correctness:** Optimistic updates now correctly predict server
response
- **Performance:** No performance changes (same number of operations)
- **Breaking Changes:** None

## Verification

To verify the fix manually:
1. Open a shelf or Read Next queue with multiple books/sessions
2. Move an item to the bottom
3. Observe smooth UI update without flicker
4. Check that all items retain correct order after cache refresh

---------

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
useDropdownPosition now reads menuRef.current.offsetWidth at calculation
time, falling back to the configured menuWidth option (default 192) only
when the element is not measurable. Prevents silent positioning bugs if
menu CSS width changes.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Resolves Copilot review comments from PR #381.

### Changes
- **JSDoc**: Added documentation to `ShelfService.moveBookToBottom` to
match `moveBookToTop` pattern
- **Performance**: Replaced O(n) `findIndex`/`find` per row with
pre-computed `Map` lookup in read-next page (eliminates O(n^2) render
cost)
- **Horizontal overflow**: Clamped dropdown `left` position within
viewport bounds in `useDropdownPosition` hook
- **Test corrections**: Fixed swapped test names in
`useDropdownPosition` edge case tests ("position above" vs "position
below" were inverted)

### Notes
- Copilot comments 1 & 2 (optimistic update mismatch) were already
resolved in the current code
- All 3894 tests pass, build succeeds

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
#386)

`isAtTop`/`isAtBottom` flags in filtered/sorted views were computed from
the filtered list index rather than the book's actual position in the
underlying data, causing "Move to Top/Bottom" actions to be incorrectly
disabled or enabled when a filter or non-default sort was active.

## Changes

- **`read-next` BookTable branch**: Use `entry.index` (from
`sessionLookup`) and `sessions.length - 1` instead of the filtered array
index
- **`shelves` mobile non-draggable list**: Use `book.sortOrder === 0` /
`book.sortOrder === books.length - 1` instead of filtered index
- **`shelves` desktop `BookTable` branch**: Same `sortOrder`-based check
for `isAtBottom`

```tsx
// Before — wrong when filter is active
isAtTop={index === 0}
isAtBottom={index === listView.filteredBooks.length - 1}

// After — reflects actual queue/shelf position
isAtTop={entry.index === 0}                        // read-next
isAtBottom={entry.index === sessions.length - 1}   // read-next

isAtTop={book.sortOrder === 0}                     // shelves
isAtBottom={book.sortOrder === books.length - 1}   // shelves
```

Draggable variants are unaffected — they only render when no filter is
applied, so index and position are equivalent there.

<!-- START COPILOT CODING AGENT TIPS -->
---

💬 We'd love your input! Share your thoughts on Copilot coding agent in
our [2 minute survey](https://gh.io/copilot-coding-agent-survey).

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
## Summary

Fixes incorrect Move to Top/Bottom button states in shelf pages when
filtering or sorting is applied.

## Problem

In PR #381, GitHub Copilot identified that the shelf page was using
**reference equality** (`book === books[0]`) to determine if a book is
at the top/bottom of a shelf. This was updated to use ID-based
comparison (`book.id === books[0]?.id`), but this still had a
fundamental issue:

**The buttons were checking position in the filtered/sorted display
view, not the canonical shelf order.**

### Example Issue

Consider a shelf with books having sortOrders [0, 1, 2, 3, 4]:
- User sorts by title (alphabetically)
- Book with sortOrder=2 appears first in the display
- "Move to Top" button becomes disabled (incorrectly thinks it's already
at top)
- But the book isn't actually at sortOrder=0 in the canonical shelf
order

## Solution

Replace array position checks with **sortOrder-based comparison** using
a lookup map pattern:

1. **Calculate canonical position** from all books' sortOrder values:
   ```tsx
const minSortOrder = Math.min(...books.map(b => b.sortOrder ??
Infinity));
const maxSortOrder = Math.max(...books.map(b => b.sortOrder ??
-Infinity));
   ```

2. **Create lookup map** for O(1) access to each book's sortOrder:
   ```tsx
const sortOrderLookup = new Map(books.map(b => [b.id, b.sortOrder ??
Infinity]));
   ```

3. **Compare against canonical position** instead of display position:
   ```tsx
   isAtTop={sortOrderLookup.get(book.id) === minSortOrder}
   isAtBottom={sortOrderLookup.get(book.id) === maxSortOrder}
   ```

### Why This Works

- Move to Top/Bottom operations always work with **canonical shelf
order** (sortOrder field)
- The buttons now check against **canonical position**, matching the
operation behavior
- Filtering/sorting only affects display, not the underlying shelf order
- Buttons remain semantically correct regardless of current view
settings

## Benefits

- ✅ Buttons reflect **actual shelf position** (sortOrder), not display
position
- ✅ Correct behavior regardless of filtering/sorting
- ✅ Type-safe (avoids runtime property access issues)
- ✅ O(1) lookups after initial map creation
- ✅ Mirrors pattern used in `/read-next` page
- ✅ Semantically consistent with Move operations

## Changes

**File:** `app/shelves/[id]/page.tsx`

- Added canonical position calculation (minSortOrder/maxSortOrder)
- Created sortOrderLookup map for type-safe access
- Updated all 4 button locations to use canonical position checks:
  1. Mobile draggable list (line ~377-378)
  2. Mobile filtered list (line ~399-400)
  3. Desktop draggable table (line ~429-430)
  4. Desktop filtered table (line ~452-453)

## Testing

- ✅ All 3,894 tests pass
- ✅ Move to Top/Bottom buttons correctly disabled at extremes
- ✅ Buttons remain accurate when filtering/sorting applied
- ✅ Buttons now reflect canonical shelf order, not display order
- ✅ No performance regression (O(1) lookups)

## Related

- Addresses feedback from PR #381 review
- Uses lookup map pattern consistent with `app/read-next/page.tsx`
- Properly implements concept identified in original PR #381 discussion
## Summary

Fixes a bug where editing an existing review in the Session Edit modal
would not persist. User edits were silently discarded and the PATCH
request payload did not include the updated review value.

### Root Cause

The initialization effect included `draftReview` in its dependency
array, causing it to re-run whenever the user edited the review. This
created a loop:
1. User edits review → auto-save updates `draftReview`
2. `draftReview` change triggers init effect to re-run
3. Init effect resets review back to `currentReview` (original value)
4. User's edits are lost

### Solution

Refactored `SessionEditModal` to use the standard two-effect pattern
with `hasRestoredDraft` ref, aligning it with the pattern already used
by:
- `CompleteBookModal`
- `FinishBookModal`
- `DNFBookModal`
- `ProgressEditModal`

This standardizes draft management across all 5 modals that use the
`useDraftField` hook.

### Changes

- Added `hasRestoredDraft` ref to track draft restoration state
- Split mega-effect into separate initialization and draft restoration
effects
- Removed `draftReview` from initialization effect dependencies
- Updated comments to explain the pattern

### Testing

✅ All 3894 tests passing
✅ No regressions detected

### Files Changed

- `components/Modals/SessionEditModal.tsx` (42 lines changed)

### Related

- Plan document: `.opencode/plans/fix-session-edit-review-bug.md`
- User reported bug: review edits not saving in Session Edit modal
## 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:

```typescript
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
## Summary

Adds a mobile-only back button to the book detail page that appears when
users navigate from another page within the app. The button uses browser
history to navigate back, preserving scroll position and page state.

## Changes

- **Mobile-only display**: Hidden on desktop (>= 768px)
- **Smart detection**: Shows when `window.history.length > 1`
- **Browser history navigation**: Uses `router.back()` to preserve state
- **Circular background**: Arrow icon in a subtle card-bg circle
- **Visual polish**: Improved vertical alignment and bolder arrow

## Behavior

✅ Internal navigation (Library → Book): Button shows, navigates back  
✅ Direct URL (bookmark/typed): No button shown  
⚠️ Pasted from Google: Button shows, goes back to Google (acceptable
edge case)

## Technical Details

- Uses simple `window.history.length > 1` check (opted for simplicity
over complex referrer detection)
- Works universally from any page (Library, Series, Shelves, Journal,
Dashboard, etc.)
- No URL query parameters or link modifications needed
- Consistent with existing mobile navigation patterns

## Testing

- ✅ Build successful
- ✅ All 3,999 tests passing
- ✅ Manual testing on mobile viewport

Resolves #392
## Summary

Fixes #399 - Journal entry date changes now produce correct, consistent
"pages read" calculations when multiple entries exist on the same date.

### Problem

When changing a journal entry's date, the "pages read" calculation
became incorrect and non-idempotent:
- **Initial**: Mar 9 shows "Read 65 pages" (122 - 57) ✓
- **After moving to Mar 8**: Shows "Read 122 pages" 
- **After moving back to Mar 9**: Shows "Read 79 pages" ❌ (should be 65)

**Root Cause**: Sorting by `progressDate` alone (YYYY-MM-DD) doesn't
provide stable ordering when multiple entries share the same date. The
sort became unpredictable, causing `findLast()` to return arbitrary
entries.

### Solution

Implemented stable multi-column sort using:
1. **progressDate** (primary - YYYY-MM-DD calendar day)
2. **createdAt** (secondary - chronological order within same day)  
3. **id** (tertiary - database insertion order as final tiebreaker)

This ensures deterministic, repeatable results regardless of database
retrieval order.

### Changes

- ✅ **progress.service.ts**: Updated `updateProgress()` with stable sort
- ✅ **progress.repository.ts**: Added stable `orderBy` to all query
methods
- ✅ **journal.service.ts**: Included `id` in sort order
- ✅ **Integration tests**: 3 new tests for same-date entry scenarios  
- ✅ **E2E test**: Reproduces exact issue #399 scenario

### Testing

- **All tests passing**: 4003/4003 tests (100%)
- **New integration tests**: 3 tests covering same-date edge cases
- **New E2E test**: Verifies issue #399 fix with exact reproduction
- **Regression tested**: All existing progress/journal tests still pass

### Verification

Reproduction of issue #399 now produces correct, idempotent results:
- Moving entry Mar 9 → Mar 8 → Mar 9 returns to original 65 pages ✓
- Multiple entries on same date are sorted deterministically ✓
- No breaking changes to existing functionality ✓

---

**Impact**: Low-risk bug fix, no API changes, backward compatible
@codecov
Copy link
Copy Markdown

codecov bot commented Mar 11, 2026

Codecov Report

❌ Patch coverage is 91.82692% with 17 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 ⚠️
lib/services/session.service.ts 87.17% 2 Missing and 3 partials ⚠️
hooks/useShelfBooks.ts 92.85% 0 Missing and 2 partials ⚠️
lib/query-keys.ts 94.11% 2 Missing ⚠️
lib/services/progress.service.ts 71.42% 0 Missing and 2 partials ⚠️
hooks/useBookRating.ts 80.00% 1 Missing ⚠️

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #401      +/-   ##
==========================================
+ Coverage   77.71%   78.67%   +0.95%     
==========================================
  Files         165      167       +2     
  Lines        7458     7559     +101     
  Branches     1809     1850      +41     
==========================================
+ Hits         5796     5947     +151     
+ Misses       1187     1127      -60     
- Partials      475      485      +10     
Flag Coverage Δ
unittests 78.67% <91.82%> (+0.95%) ⬆️

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

Files with missing lines Coverage Δ
app/api/books/[id]/status/route.ts 92.10% <100.00%> (-0.21%) ⬇️
hooks/useBookDetail.ts 90.69% <100.00%> (+5.33%) ⬆️
hooks/useBookProgress.ts 74.30% <100.00%> (+0.54%) ⬆️
hooks/useBookStatus.ts 85.62% <100.00%> (+1.70%) ⬆️
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/repositories/progress.repository.ts 78.20% <ø> (ø)
lib/repositories/session.repository.ts 78.89% <100.00%> (+0.19%) ⬆️
... and 8 more

... and 2 files with indirect coverage changes

🚀 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.

This comment was marked as outdated.

masonfox added a commit that referenced this pull request Mar 16, 2026
Implements 4 code quality improvements from PR review feedback:

1. Remove unused import in usePageTitle.ts
   - Removed unused useRef import that would fail lint checks

2. Fix null check bug in useShelfBooks.ts (critical)
   - Changed '!shelfId' to 'shelfId === null' to properly handle shelf ID 0
   - Prevents incorrectly skipping shelf with ID 0

3. Add targeted query invalidations for journal/settings
   - Added specific invalidation keys for /journal and /settings routes
   - Prevents expensive fallback that invalidates all queries
   - Improves pull-to-refresh performance on these pages

4. Use query key factory consistently in useLibraryData.ts
   - Changed from hardcoded 'library-books' to queryKeys.library.books()
   - Prevents query key drift between invalidation and query construction
   - Ensures refresh and invalidation stay coupled to factory

All fixes verified with test suite (4006/4007 tests passing, 1 pre-existing failure unrelated to changes).

Resolves review feedback from: #401 (review)
## Summary

Addresses 4 code quality improvements identified in [PR #401 review
feedback](#401 (review)).

## Changes

### 1. Remove unused import (hooks/usePageTitle.ts)
- **Issue**: `useRef` was imported but never used
- **Fix**: Removed unused import to prevent lint failures
- **Impact**: Clean code, no functional change

### 2. Fix shelf ID 0 handling bug (hooks/useShelfBooks.ts) 🐛
- **Issue**: `if (!shelfId)` treats `0` as falsy, incorrectly skipping
shelf ID 0
- **Fix**: Changed to `if (shelfId === null)` for strict null checking
- **Impact**: **Critical bug fix** - shelf with ID 0 now works correctly
- **Type**: `shelfId` is typed as `number | null`, so this aligns with
the type system

### 3. Add targeted query invalidations (hooks/usePullToRefreshLogic.ts)
- **Issue**: Unmapped paths like `/journal` and `/settings` fell back to
invalidating **all queries**
- **Fix**: Added specific invalidation keys for these routes:
  - `/journal` → `["journal-entries", "journal-archive"]`
  - `/settings` → `["user-preferences"]`
- **Impact**: Prevents expensive full-cache invalidation, improves
pull-to-refresh performance

### 4. Use query key factory consistently (hooks/useLibraryData.ts)
- **Issue**: Query key used hardcoded `'library-books'` string while
refresh used `queryKeys.library.books()`
- **Fix**: Changed to `[...queryKeys.library.books(), ...]` for
consistent factory usage
- **Impact**: Prevents query key drift, ensures invalidation and query
construction stay coupled

## Testing

- ✅ All tests passing: **4006/4007 tests** (99.98%)
- ✅ 1 pre-existing failure in `streaks-coverage.test.ts` (unrelated,
already failing on develop)
- ✅ No new test failures introduced
- ✅ All 4 fixes verified with existing test coverage

## Files Changed

- `lib/hooks/usePageTitle.ts` - Removed unused import
- `hooks/useShelfBooks.ts` - Fixed null check for shelf ID 0
- `hooks/usePullToRefreshLogic.ts` - Added targeted invalidations
- `hooks/useLibraryData.ts` - Use query key factory consistently

## Review Feedback Source

[PR #401 Review Comment
Thread](#401 (review))
@masonfox
Copy link
Copy Markdown
Owner Author

masonfox commented Mar 19, 2026

Given that the cache invalidation improved in this release - from magic strings to traversable object methods - I really want to make sure we don't regress on cache invalidation. Therefore, while I've love to release this ASAP, I'd like to make sure I clean up any cache invalidation bugs! Just found one for the /shelves/:id page and am working to fix that, but need to take another thorough look at all of the core mutations!

## Summary

Fixes a cache invalidation bug where changing a book's status on
`/books/:id` doesn't invalidate the cache for `/shelves/:id`, causing
shelves to show stale book status for up to 30 seconds.

## Problem

When a user changes a book's status on the book detail page, the
`invalidateBookQueries()` helper function invalidates several React
Query caches (book, sessions, progress, dashboard, library, read-next),
but **does not invalidate shelf queries**. This means:

- Shelf pages continue showing the old status until the 30-second
`staleTime` expires
- Users see inconsistent data between the book detail page and shelf
pages
- Manual refresh or waiting 30s is required to see updated status on
shelves

## Solution

Modified `invalidateBookQueries()` in `hooks/useBookStatus.ts` to also
invalidate shelf caches:

### Cache-Based Approach (Option A)

1. **Check cache first**: Query React Query cache for the book's shelves
using `queryKeys.book.shelves(bookId)`
2. **Surgical invalidation**: If cached shelves exist, invalidate only
those specific shelf queries using `queryKeys.shelf.byId(shelfId)`
3. **Nuclear fallback**: If no cached shelves, invalidate all shelf
queries using `queryKeys.shelf.base()` to ensure correctness

This approach balances performance (surgical when possible) with
correctness (nuclear when cache unavailable).

## Changes

### Core Changes

- **`hooks/useBookStatus.ts`** (lines 42-74):
  - Enhanced `invalidateBookQueries()` to check cached book shelves
- Invalidate affected shelf queries (surgical) or all shelves (nuclear)
  - Added JSDoc comments documenting the shelf invalidation logic
  
- **`app/api/books/[id]/status/route.ts`** (line 69):
- Removed misleading comment about SessionService handling cache
invalidation

## Testing

- ✅ All 4007 existing tests pass
- ✅ No breaking changes to function signature
- ✅ Compatible with all existing uses of `invalidateBookQueries()`
- ✅ Server running and responsive during testing

## Implementation Pattern

This follows the same pattern already used in `app/books/[id]/page.tsx`
(lines 245-261) for the `updateShelves()` function, which demonstrates
proper shelf invalidation when books are added/removed from shelves.

## Related Files

- Investigation details: `docs/plans/fix-shelf-cache-invalidation.md`
- Query keys definition: `lib/query-keys.ts` (lines 162-172)
- Book detail page: `app/books/[id]/page.tsx` (lines 229-242 for shelf
caching)

---------

Co-authored-by: GitHub Copilot <copilot@github.com>
@masonfox
Copy link
Copy Markdown
Owner Author

Okay... I'm back at it all! Over the past month, I was strongly committed to something that required multiple hours of investment each day, taking place of my focus on development and maintenance of Tome. At this point, while I'm easing back in and will work to get this release cinched and shipped!

## Summary

Fixes a cache invalidation bug where changing a book's status on
`/books/:id` doesn't refresh the cache on the `/series/:name` page,
causing stale status badges and ratings to be displayed.

## Root Cause

The cache invalidation bug existed at two levels:

1. **Client-Side (React Query)**: `invalidateBookQueries()` in
`useBookStatus.ts` was not invalidating series queries
2. **Server-Side (Next.js)**: `SessionService` and `ProgressService`
were not calling `revalidatePath('/series')`

## Changes

**3 files modified, 3 lines added:**

- `hooks/useBookStatus.ts:57` - Added series query invalidation using
`queryKeys.series.all()`
- `lib/services/session.service.ts:1365` - Added
`revalidatePath('/series')`
- `lib/services/progress.service.ts:524` - Added
`revalidatePath('/series')`

## Implementation Details

The fix uses "nuclear" invalidation (invalidate all series queries) for
simplicity and reliability, consistent with the existing shelf
invalidation pattern when specific targeting is complex.

- `queryKeys.series.all()` returns `['series']`, which matches both
series list and all series detail pages
- `revalidatePath('/series')` revalidates both `/series` (list) and
`/series/:name` (detail) pages

## Testing

- ✅ All 4,013 tests pass (188 test files)
- ✅ No regressions detected
- ✅ Changes follow existing patterns

## Manual Testing

To verify the fix:
1. Open a series detail page (e.g., `/series/Harry%20Potter`)
2. Note a book's status badge
3. Change that book's status on `/books/:id`
4. Return to the series page - status should now update immediately
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.

3 participants