diff --git a/.opencode/package-lock.json b/.opencode/package-lock.json new file mode 100644 index 00000000..86bbf564 --- /dev/null +++ b/.opencode/package-lock.json @@ -0,0 +1,115 @@ +{ + "name": ".opencode", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@opencode-ai/plugin": "1.3.17" + } + }, + "node_modules/@opencode-ai/plugin": { + "version": "1.3.17", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.3.17.tgz", + "integrity": "sha512-N5lckFtYvEu2R8K1um//MIOTHsJHniF2kHoPIWPCrxKG5Jpismt1ISGzIiU3aKI2ht/9VgcqKPC5oZFLdmpxPw==", + "license": "MIT", + "dependencies": { + "@opencode-ai/sdk": "1.3.17", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.1.96", + "@opentui/solid": ">=0.1.96" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "node_modules/@opencode-ai/sdk": { + "version": "1.3.17", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.3.17.tgz", + "integrity": "sha512-2+MGgu7wynqTBwxezR01VAGhILXlpcHDY/pF7SWB87WOgLt3kD55HjKHNj6PWxyY8n575AZolR95VUC3gtwfmA==", + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/zod": { + "version": "4.1.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/__tests__/hooks/useLongPress.test.ts b/__tests__/hooks/useLongPress.test.ts new file mode 100644 index 00000000..8a82408f --- /dev/null +++ b/__tests__/hooks/useLongPress.test.ts @@ -0,0 +1,636 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useLongPress } from "@/hooks/useLongPress"; + +describe("useLongPress", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + describe("Basic Functionality", () => { + it("should return event handlers", () => { + const onLongPress = vi.fn(); + const { result } = renderHook(() => useLongPress(onLongPress)); + + expect(result.current).toHaveProperty("onMouseDown"); + expect(result.current).toHaveProperty("onMouseUp"); + expect(result.current).toHaveProperty("onMouseLeave"); + expect(result.current).toHaveProperty("onMouseMove"); + expect(result.current).toHaveProperty("onTouchStart"); + expect(result.current).toHaveProperty("onTouchEnd"); + expect(result.current).toHaveProperty("onTouchCancel"); + expect(result.current).toHaveProperty("onTouchMove"); + }); + + it("should use default delay and tolerance", () => { + const onLongPress = vi.fn(); + const { result } = renderHook(() => useLongPress(onLongPress)); + + const mockEvent = { + button: 0, + clientX: 100, + clientY: 100, + } as React.MouseEvent; + + act(() => { + result.current.onMouseDown(mockEvent); + }); + + act(() => { + vi.advanceTimersByTime(499); + }); + expect(onLongPress).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(onLongPress).toHaveBeenCalledTimes(1); + }); + + it("should accept custom delay and tolerance", () => { + const onLongPress = vi.fn(); + const { result } = renderHook(() => + useLongPress(onLongPress, { delay: 1000, tolerance: 20 }) + ); + + const mockEvent = { + button: 0, + clientX: 100, + clientY: 100, + } as React.MouseEvent; + + act(() => { + result.current.onMouseDown(mockEvent); + }); + + act(() => { + vi.advanceTimersByTime(999); + }); + expect(onLongPress).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(onLongPress).toHaveBeenCalledTimes(1); + }); + }); + + describe("Mouse Events", () => { + it("should trigger callback after delay on mouse down", () => { + const onLongPress = vi.fn(); + const { result } = renderHook(() => useLongPress(onLongPress, { delay: 500 })); + + const mockEvent = { + button: 0, + clientX: 100, + clientY: 100, + } as React.MouseEvent; + + act(() => { + result.current.onMouseDown(mockEvent); + }); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(onLongPress).toHaveBeenCalledTimes(1); + }); + + it("should only respond to left mouse button", () => { + const onLongPress = vi.fn(); + const { result } = renderHook(() => useLongPress(onLongPress, { delay: 500 })); + + // Right-click (button = 2) + const rightClickEvent = { + button: 2, + clientX: 100, + clientY: 100, + } as React.MouseEvent; + + act(() => { + result.current.onMouseDown(rightClickEvent); + }); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(onLongPress).not.toHaveBeenCalled(); + }); + + it("should cancel on mouse up before delay", () => { + const onLongPress = vi.fn(); + const { result } = renderHook(() => useLongPress(onLongPress, { delay: 500 })); + + const mockEvent = { + button: 0, + clientX: 100, + clientY: 100, + } as React.MouseEvent; + + act(() => { + result.current.onMouseDown(mockEvent); + }); + + act(() => { + vi.advanceTimersByTime(250); + }); + + act(() => { + result.current.onMouseUp(); + }); + + act(() => { + vi.advanceTimersByTime(250); + }); + + expect(onLongPress).not.toHaveBeenCalled(); + }); + + it("should cancel on mouse leave", () => { + const onLongPress = vi.fn(); + const { result } = renderHook(() => useLongPress(onLongPress, { delay: 500 })); + + const mockEvent = { + button: 0, + clientX: 100, + clientY: 100, + } as React.MouseEvent; + + act(() => { + result.current.onMouseDown(mockEvent); + }); + + act(() => { + vi.advanceTimersByTime(250); + }); + + act(() => { + result.current.onMouseLeave(); + }); + + act(() => { + vi.advanceTimersByTime(250); + }); + + expect(onLongPress).not.toHaveBeenCalled(); + }); + + it("should cancel on movement beyond tolerance", () => { + const onLongPress = vi.fn(); + const { result } = renderHook(() => + useLongPress(onLongPress, { delay: 500, tolerance: 10 }) + ); + + const startEvent = { + button: 0, + clientX: 100, + clientY: 100, + } as React.MouseEvent; + + act(() => { + result.current.onMouseDown(startEvent); + }); + + // Move beyond tolerance (> 10px) + const moveEvent = { + clientX: 112, + clientY: 100, + } as React.MouseEvent; + + act(() => { + result.current.onMouseMove(moveEvent); + }); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(onLongPress).not.toHaveBeenCalled(); + }); + + it("should NOT cancel on movement within tolerance", () => { + const onLongPress = vi.fn(); + const { result } = renderHook(() => + useLongPress(onLongPress, { delay: 500, tolerance: 10 }) + ); + + const startEvent = { + button: 0, + clientX: 100, + clientY: 100, + } as React.MouseEvent; + + act(() => { + result.current.onMouseDown(startEvent); + }); + + // Move within tolerance (< 10px) + const moveEvent = { + clientX: 105, + clientY: 103, + } as React.MouseEvent; + + act(() => { + result.current.onMouseMove(moveEvent); + }); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(onLongPress).toHaveBeenCalledTimes(1); + }); + }); + + describe("Touch Events", () => { + it("should trigger callback after delay on touch start", () => { + const onLongPress = vi.fn(); + const { result } = renderHook(() => useLongPress(onLongPress, { delay: 500 })); + + const mockEvent = { + touches: [{ clientX: 100, clientY: 100 }], + } as unknown as React.TouchEvent; + + act(() => { + result.current.onTouchStart(mockEvent); + }); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(onLongPress).toHaveBeenCalledTimes(1); + }); + + it("should ignore multi-touch events", () => { + const onLongPress = vi.fn(); + const { result } = renderHook(() => useLongPress(onLongPress, { delay: 500 })); + + const mockEvent = { + touches: [ + { clientX: 100, clientY: 100 }, + { clientX: 200, clientY: 200 }, + ], + } as unknown as React.TouchEvent; + + act(() => { + result.current.onTouchStart(mockEvent); + }); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(onLongPress).not.toHaveBeenCalled(); + }); + + it("should cancel on touch end before delay", () => { + const onLongPress = vi.fn(); + const { result } = renderHook(() => useLongPress(onLongPress, { delay: 500 })); + + const mockEvent = { + touches: [{ clientX: 100, clientY: 100 }], + } as unknown as React.TouchEvent; + + act(() => { + result.current.onTouchStart(mockEvent); + }); + + act(() => { + vi.advanceTimersByTime(250); + }); + + act(() => { + result.current.onTouchEnd(); + }); + + act(() => { + vi.advanceTimersByTime(250); + }); + + expect(onLongPress).not.toHaveBeenCalled(); + }); + + it("should cancel on touch cancel (browser-interrupted touch)", () => { + const onLongPress = vi.fn(); + const { result } = renderHook(() => useLongPress(onLongPress, { delay: 500 })); + + const mockEvent = { + touches: [{ clientX: 100, clientY: 100 }], + } as unknown as React.TouchEvent; + + act(() => { + result.current.onTouchStart(mockEvent); + }); + + act(() => { + vi.advanceTimersByTime(250); + }); + + act(() => { + result.current.onTouchCancel(); + }); + + act(() => { + vi.advanceTimersByTime(250); + }); + + expect(onLongPress).not.toHaveBeenCalled(); + }); + + it("should cancel on touch move beyond tolerance", () => { + const onLongPress = vi.fn(); + const { result } = renderHook(() => + useLongPress(onLongPress, { delay: 500, tolerance: 10 }) + ); + + const startEvent = { + touches: [{ clientX: 100, clientY: 100 }], + } as unknown as React.TouchEvent; + + act(() => { + result.current.onTouchStart(startEvent); + }); + + // Move beyond tolerance (> 10px) + const moveEvent = { + touches: [{ clientX: 100, clientY: 112 }], + } as unknown as React.TouchEvent; + + act(() => { + result.current.onTouchMove(moveEvent); + }); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(onLongPress).not.toHaveBeenCalled(); + }); + + it("should NOT cancel on touch move within tolerance", () => { + const onLongPress = vi.fn(); + const { result } = renderHook(() => + useLongPress(onLongPress, { delay: 500, tolerance: 10 }) + ); + + const startEvent = { + touches: [{ clientX: 100, clientY: 100 }], + } as unknown as React.TouchEvent; + + act(() => { + result.current.onTouchStart(startEvent); + }); + + // Move within tolerance (< 10px) + const moveEvent = { + touches: [{ clientX: 105, clientY: 103 }], + } as unknown as React.TouchEvent; + + act(() => { + result.current.onTouchMove(moveEvent); + }); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(onLongPress).toHaveBeenCalledTimes(1); + }); + + it("should ignore touch move with multiple touches", () => { + const onLongPress = vi.fn(); + const { result } = renderHook(() => + useLongPress(onLongPress, { delay: 500, tolerance: 10 }) + ); + + const startEvent = { + touches: [{ clientX: 100, clientY: 100 }], + } as unknown as React.TouchEvent; + + act(() => { + result.current.onTouchStart(startEvent); + }); + + // Multi-touch move + const moveEvent = { + touches: [ + { clientX: 105, clientY: 103 }, + { clientX: 200, clientY: 200 }, + ], + } as unknown as React.TouchEvent; + + act(() => { + result.current.onTouchMove(moveEvent); + }); + + // Should still trigger because multi-touch move was ignored + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(onLongPress).toHaveBeenCalledTimes(1); + }); + }); + + describe("Memory Management", () => { + it("should clear timeout on unmount", () => { + const onLongPress = vi.fn(); + const { result, unmount } = renderHook(() => + useLongPress(onLongPress, { delay: 500 }) + ); + + const mockEvent = { + button: 0, + clientX: 100, + clientY: 100, + } as React.MouseEvent; + + act(() => { + result.current.onMouseDown(mockEvent); + }); + + act(() => { + vi.advanceTimersByTime(250); + }); + + // Unmount before timeout completes + unmount(); + + act(() => { + vi.advanceTimersByTime(250); + }); + + // Callback should NOT fire after unmount + expect(onLongPress).not.toHaveBeenCalled(); + }); + + it("should clear existing timeout when starting new long-press", () => { + const onLongPress = vi.fn(); + const { result } = renderHook(() => useLongPress(onLongPress, { delay: 500 })); + + const firstEvent = { + button: 0, + clientX: 100, + clientY: 100, + } as React.MouseEvent; + + act(() => { + result.current.onMouseDown(firstEvent); + }); + + act(() => { + vi.advanceTimersByTime(250); + }); + + // Start a new long-press before first one completes + const secondEvent = { + button: 0, + clientX: 200, + clientY: 200, + } as React.MouseEvent; + + act(() => { + result.current.onMouseDown(secondEvent); + }); + + act(() => { + vi.advanceTimersByTime(250); + }); + + // First timeout should be cleared, so callback not fired yet + expect(onLongPress).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(250); + }); + + // Second timeout completes (total 500ms since second start) + expect(onLongPress).toHaveBeenCalledTimes(1); + }); + }); + + describe("Edge Cases", () => { + it("should handle rapid press and release", () => { + const onLongPress = vi.fn(); + const { result } = renderHook(() => useLongPress(onLongPress, { delay: 500 })); + + const mockEvent = { + button: 0, + clientX: 100, + clientY: 100, + } as React.MouseEvent; + + // Rapid press and release 10 times + for (let i = 0; i < 10; i++) { + act(() => { + result.current.onMouseDown(mockEvent); + }); + + act(() => { + vi.advanceTimersByTime(50); + }); + + act(() => { + result.current.onMouseUp(); + }); + } + + // No callbacks should fire + expect(onLongPress).not.toHaveBeenCalled(); + }); + + it("should not fire if no position set on move", () => { + const onLongPress = vi.fn(); + const { result } = renderHook(() => useLongPress(onLongPress, { delay: 500 })); + + // Call onMouseMove without calling onMouseDown first + const moveEvent = { + clientX: 100, + clientY: 100, + } as React.MouseEvent; + + act(() => { + result.current.onMouseMove(moveEvent); + }); + + // Should not crash or cause issues + expect(onLongPress).not.toHaveBeenCalled(); + }); + + it("should handle movement exactly at tolerance boundary", () => { + const onLongPress = vi.fn(); + const { result } = renderHook(() => + useLongPress(onLongPress, { delay: 500, tolerance: 10 }) + ); + + const startEvent = { + button: 0, + clientX: 100, + clientY: 100, + } as React.MouseEvent; + + act(() => { + result.current.onMouseDown(startEvent); + }); + + // Move exactly 10px (at boundary) + const moveEvent = { + clientX: 110, + clientY: 100, + } as React.MouseEvent; + + act(() => { + result.current.onMouseMove(moveEvent); + }); + + act(() => { + vi.advanceTimersByTime(500); + }); + + // Should NOT cancel (tolerance is exclusive) + expect(onLongPress).toHaveBeenCalledTimes(1); + }); + + it("should handle diagonal movement correctly", () => { + const onLongPress = vi.fn(); + const { result } = renderHook(() => + useLongPress(onLongPress, { delay: 500, tolerance: 10 }) + ); + + const startEvent = { + button: 0, + clientX: 100, + clientY: 100, + } as React.MouseEvent; + + act(() => { + result.current.onMouseDown(startEvent); + }); + + // Diagonal move: 8px X, 8px Y (within tolerance individually but not combined) + const moveEvent = { + clientX: 108, + clientY: 108, + } as React.MouseEvent; + + act(() => { + result.current.onMouseMove(moveEvent); + }); + + act(() => { + vi.advanceTimersByTime(500); + }); + + // Should still trigger (both deltas < 10) + expect(onLongPress).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/__tests__/integration/issues/issue-413-session-number-after-deletion.test.ts b/__tests__/integration/issues/issue-413-session-number-after-deletion.test.ts new file mode 100644 index 00000000..d605e64b --- /dev/null +++ b/__tests__/integration/issues/issue-413-session-number-after-deletion.test.ts @@ -0,0 +1,341 @@ +/** + * Integration test for Issue #413: + * Session numbers should display correctly after deletion + * + * Bug: When a session is deleted and a new one is created, the session number + * increments incorrectly (shows "Read #2" when it should be "Read #1") + * + * Fix: + * 1. Use getNextSessionNumber() instead of hardcoded 1 in deleteSession() + * 2. Calculate displayNumber based on array index from chronologically ordered sessions + */ + +import { describe, test, expect, beforeAll, beforeEach, afterAll } from "vitest"; +import { setupTestDatabase, clearTestDatabase, teardownTestDatabase, type TestDatabaseInstance } from "@/__tests__/helpers/db-setup"; +import { sessionService } from "@/lib/services/session.service"; +import { sessionRepository, bookRepository } from "@/lib/repositories"; + +const TEST_FILE_PATH = __filename; +let testDbInstance: TestDatabaseInstance; +let bookId: number; + +beforeAll(async () => { + testDbInstance = await setupTestDatabase(TEST_FILE_PATH); +}); + +beforeEach(async () => { + await clearTestDatabase(testDbInstance); + + // Create a test book + const book = await bookRepository.create({ + calibreId: 1, + title: "Test Book", + authors: ["Test Author"], + path: "/test/path", + totalPages: 100, + }); + bookId = book.id; +}); + +afterAll(async () => { + await teardownTestDatabase(testDbInstance); +}); + +describe("Issue #413 - Session Number After Deletion", () => { + + test("should use getNextSessionNumber instead of hardcoded 1 when creating session after deletion", async () => { + // Step 1: Create a "read" session + const session1 = await sessionRepository.create({ + bookId, + sessionNumber: 1, + status: "read", + isActive: false, + startedDate: "2024-01-01", + completedDate: "2024-01-15", + }); + + // Verify session exists + let sessions = await sessionRepository.findAllByBookId(bookId); + expect(sessions).toHaveLength(1); + expect(sessions[0].sessionNumber).toBe(1); + + // Step 2: Delete the session - deleteSession should create new "to-read" session + await sessionService.deleteSession(bookId, session1.id); + + // Verify deleteSession created a new "to-read" session + sessions = await sessionRepository.findAllByBookId(bookId); + expect(sessions).toHaveLength(1); + expect(sessions[0].status).toBe("to-read"); + + // CRITICAL FIX: After deleting session #1, the new "to-read" session gets the NEXT number + // Since we deleted session #1, getNextSessionNumber() looks at remaining sessions (none), + // so it returns 1. Before the fix, it was hardcoded to 1, now it's calculated. + // The key is that it's using getNextSessionNumber() to avoid conflicts. + expect(sessions[0].sessionNumber).toBe(1); + + // Step 3: Archive the "to-read" session and create another "read" session + await sessionRepository.update(sessions[0].id, { isActive: false }); + + const session2 = await sessionRepository.create({ + bookId, + sessionNumber: await sessionRepository.getNextSessionNumber(bookId), + status: "read", + isActive: true, + startedDate: "2024-01-20", + completedDate: "2024-02-01", + }); + + // The new session should get sessionNumber 2 (getNextSessionNumber finds 1, returns 2) + // This is the correct behavior - continuous numbering + expect(session2.sessionNumber).toBe(2); + }); + + test("should assign next session number correctly when other sessions exist after deletion", async () => { + // Create multiple sessions + const session1 = await sessionRepository.create({ + bookId, + sessionNumber: 1, + status: "read", + isActive: false, + startedDate: "2024-01-01", + completedDate: "2024-01-15", + }); + + const session2 = await sessionRepository.create({ + bookId, + sessionNumber: 2, + status: "read", + isActive: false, + startedDate: "2024-02-01", + completedDate: "2024-02-15", + }); + + // Verify both exist + let sessions = await sessionRepository.findAllByBookId(bookId); + expect(sessions).toHaveLength(2); + + // Delete session #1 + await sessionService.deleteSession(bookId, session1.id); + + // Verify to-read session was created with sessionNumber=3 (not 1!) + // This proves getNextSessionNumber() is being used, not hardcoded 1 + sessions = await sessionRepository.findAllByBookId(bookId); + const toReadSession = sessions.find(s => s.status === "to-read"); + expect(toReadSession).toBeDefined(); + expect(toReadSession!.sessionNumber).toBe(3); // CRITICAL: Must be 3, not 1 + + // Should still have session2 with sessionNumber=2 + const readSession = sessions.find(s => s.sessionNumber === 2); + expect(readSession).toBeDefined(); + }); + + test("should calculate display numbers based on chronological order", async () => { + // Create 3 sessions at different times + const session1 = await sessionRepository.create({ + bookId, + sessionNumber: 1, + status: "read", + isActive: false, + startedDate: "2024-01-01", + completedDate: "2024-01-15", + }); + + const session2 = await sessionRepository.create({ + bookId, + sessionNumber: 2, + status: "read", + isActive: false, + startedDate: "2024-02-01", + completedDate: "2024-02-15", + }); + + const session3 = await sessionRepository.create({ + bookId, + sessionNumber: 3, + status: "read", + isActive: true, + startedDate: "2024-03-01", + completedDate: "2024-03-15", + }); + + // Get ordered sessions + const orderedSessions = await sessionRepository.findAllByBookIdOrdered(bookId); + + // Should have 3 sessions ordered chronologically + expect(orderedSessions).toHaveLength(3); + expect(orderedSessions[0].startedDate).toBe("2024-01-01"); + expect(orderedSessions[1].startedDate).toBe("2024-02-01"); + expect(orderedSessions[2].startedDate).toBe("2024-03-01"); + + // Get sessions with display numbers + const sessionsWithDisplay = await sessionService.getSessionsWithDisplayNumbers(bookId); + + // Display numbers should be 1, 2, 3 (based on array position) + expect(sessionsWithDisplay[0].displayNumber).toBe(1); + expect(sessionsWithDisplay[1].displayNumber).toBe(2); + expect(sessionsWithDisplay[2].displayNumber).toBe(3); + }); + + test("should renumber display numbers after deleting a session", async () => { + // Create 3 sessions + const session1 = await sessionRepository.create({ + bookId, + sessionNumber: 1, + status: "read", + isActive: false, + startedDate: "2024-01-01", + completedDate: "2024-01-15", + }); + + const session2 = await sessionRepository.create({ + bookId, + sessionNumber: 2, + status: "read", + isActive: false, + startedDate: "2024-02-01", + completedDate: "2024-02-15", + }); + + const session3 = await sessionRepository.create({ + bookId, + sessionNumber: 3, + status: "read", + isActive: true, + startedDate: "2024-03-01", + completedDate: "2024-03-15", + }); + + // Verify display numbers before deletion + let sessionsWithDisplay = await sessionService.getSessionsWithDisplayNumbers(bookId); + expect(sessionsWithDisplay.filter(s => s.status === "read")).toHaveLength(3); + + // Delete the second session + await sessionService.deleteSession(bookId, session2.id); + + // Get updated sessions + sessionsWithDisplay = await sessionService.getSessionsWithDisplayNumbers(bookId); + const completedSessions = sessionsWithDisplay.filter(s => s.status === "read"); + + // Should now have 2 "read" sessions (1st and 3rd) + expect(completedSessions).toHaveLength(2); + + // Display numbers should be renumbered to 1 and 2 (no gaps!) + expect(completedSessions[0].displayNumber).toBe(1); + expect(completedSessions[0].startedDate).toBe("2024-01-01"); + + expect(completedSessions[1].displayNumber).toBe(2); + expect(completedSessions[1].startedDate).toBe("2024-03-01"); + }); + + test("should handle sessions with null startedDate using createdAt fallback", async () => { + // Create session1 with startedDate + const session1 = await sessionRepository.create({ + bookId, + sessionNumber: 1, + status: "read", + isActive: false, + startedDate: "2024-02-01", + completedDate: "2024-02-15", + }); + + // Create session2 without startedDate + const session2 = await sessionRepository.create({ + bookId, + sessionNumber: 2, + status: "read", + isActive: false, + startedDate: null, + completedDate: "2024-01-15", + }); + + // Get ordered sessions - should use COALESCE(startedDate, createdAt) + const orderedSessions = await sessionRepository.findAllByBookIdOrdered(bookId); + expect(orderedSessions).toHaveLength(2); + + // Verify displayNumbers are calculated AND ordering is correct + // Session2 (null startedDate) was created second, so createdAt is later + // Therefore session1 should be first chronologically + const sessionsWithDisplay = await sessionService.getSessionsWithDisplayNumbers(bookId); + expect(sessionsWithDisplay).toHaveLength(2); + expect(sessionsWithDisplay[0].sessionNumber).toBe(1); // session1 comes first + expect(sessionsWithDisplay[0].displayNumber).toBe(1); + expect(sessionsWithDisplay[1].sessionNumber).toBe(2); // session2 comes second + expect(sessionsWithDisplay[1].displayNumber).toBe(2); + }); + + test("should only assign displayNumber to sessions that match display filter", async () => { + // Create an archived "to-read" session without startedDate (will use createdAt ~ today) + const toReadSession = await sessionRepository.create({ + bookId, + sessionNumber: 1, + status: "to-read", + isActive: false, // Archived immediately + }); + + // Create an archived "reading" session with startedDate in 2024 + const readingSession = await sessionRepository.create({ + bookId, + sessionNumber: 2, + status: "reading", + isActive: false, // Archived immediately + startedDate: "2024-02-01", + }); + + // Create a completed "read" session + const readSession1 = await sessionRepository.create({ + bookId, + sessionNumber: 3, + status: "read", + isActive: false, + startedDate: "2024-03-01", + completedDate: "2024-03-15", + }); + + // Create a "dnf" session + const dnfSession = await sessionRepository.create({ + bookId, + sessionNumber: 4, + status: "dnf", + isActive: false, + startedDate: "2024-04-01", + completedDate: "2024-04-10", + }); + + // Create another completed "read" session that's active + const readSession2 = await sessionRepository.create({ + bookId, + sessionNumber: 5, + status: "read", + isActive: true, + startedDate: "2024-05-01", + completedDate: "2024-05-15", + }); + + // Get sessions with display numbers + const sessionsWithDisplay = await sessionService.getSessionsWithDisplayNumbers(bookId); + + // Should have 5 total sessions + expect(sessionsWithDisplay).toHaveLength(5); + + // Find each session by sessionNumber + const toRead = sessionsWithDisplay.find(s => s.sessionNumber === 1); + const reading = sessionsWithDisplay.find(s => s.sessionNumber === 2); + const read1 = sessionsWithDisplay.find(s => s.sessionNumber === 3); + const dnf = sessionsWithDisplay.find(s => s.sessionNumber === 4); + const read2 = sessionsWithDisplay.find(s => s.sessionNumber === 5); + + // Expected chronological order based on startedDate (or createdAt fallback): + // 1. reading: 2024-02-01 + // 2. read1: 2024-03-01 + // 3. dnf: 2024-04-01 + // 4. read2: 2024-05-01 + // 5. toRead: ~2026-04-14 (createdAt converted to YYYY-MM-DD, created today) + + // All sessions match the display filter (!isActive || status=='read' || status=='dnf') + expect(reading?.displayNumber).toBe(1); + expect(read1?.displayNumber).toBe(2); + expect(dnf?.displayNumber).toBe(3); + expect(read2?.displayNumber).toBe(4); + expect(toRead?.displayNumber).toBe(5); + }); +}); diff --git a/__tests__/integration/repositories/books/book-status-filtering.test.ts b/__tests__/integration/repositories/books/book-status-filtering.test.ts new file mode 100644 index 00000000..71c222d6 --- /dev/null +++ b/__tests__/integration/repositories/books/book-status-filtering.test.ts @@ -0,0 +1,343 @@ +import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { bookRepository } from "@/lib/repositories"; +import { sessionRepository } from "@/lib/repositories/session.repository"; +import { setupTestDatabase, teardownTestDatabase, clearTestDatabase } from "@/__tests__/helpers/db-setup"; +import { createTestBook } from "../../../fixtures/test-data"; + +/** + * BookRepository Status Filtering Tests + * + * Tests status filtering in findWithFilters() and findWithFiltersAndRelations() methods. + * + * Key behaviors: + * - "read" and "dnf" statuses are terminal states - should include all sessions (is_active can be 0 or 1) + * - "to-read", "read-next", "reading" statuses should only include active sessions (is_active = 1) + * + * This tests the fix for the DNF filter bug where DNF books were not showing up + * because the code was requiring is_active = 1 for DNF status. + */ + +beforeAll(async () => { + await setupTestDatabase(__filename); +}); + +afterAll(async () => { + await teardownTestDatabase(__filename); +}); + +beforeEach(async () => { + await clearTestDatabase(__filename); +}); + +describe("BookRepository Status Filter - Terminal States", () => { + test("should filter books by 'read' status (terminal state)", async () => { + // Arrange: Create books with different statuses + const readBook = await bookRepository.create(createTestBook({ + calibreId: 1, + title: "Finished Book", + authors: ["Author 1"], + path: "Author 1/Finished Book (1)", + })); + + const readingBook = await bookRepository.create(createTestBook({ + calibreId: 2, + title: "Currently Reading", + authors: ["Author 2"], + path: "Author 2/Currently Reading (2)", + })); + + // Create read session (inactive) + await sessionRepository.create({ + bookId: readBook.id, + userId: null, + sessionNumber: 1, + status: "read", + isActive: false, // Read sessions are inactive + startedDate: "2026-01-01", + completedDate: "2026-01-15", + }); + + // Create reading session (active) + await sessionRepository.create({ + bookId: readingBook.id, + userId: null, + sessionNumber: 1, + status: "reading", + isActive: true, + }); + + // Act: Filter by 'read' status + const result = await bookRepository.findWithFilters({ status: "read" }, 50, 0); + + // Assert: Should only return the read book + expect(result.books).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.books[0].id).toBe(readBook.id); + }); + + test("should filter books by 'dnf' status (terminal state)", async () => { + // Arrange: Create books with different statuses + const dnfBook = await bookRepository.create(createTestBook({ + calibreId: 1, + title: "DNF Book", + authors: ["Author 1"], + path: "Author 1/DNF Book (1)", + })); + + const readingBook = await bookRepository.create(createTestBook({ + calibreId: 2, + title: "Currently Reading", + authors: ["Author 2"], + path: "Author 2/Currently Reading (2)", + })); + + // Create DNF session (inactive) + await sessionRepository.create({ + bookId: dnfBook.id, + userId: null, + sessionNumber: 1, + status: "dnf", + isActive: false, // DNF sessions are inactive + startedDate: "2026-01-01", + }); + + // Create reading session (active) + await sessionRepository.create({ + bookId: readingBook.id, + userId: null, + sessionNumber: 1, + status: "reading", + isActive: true, + }); + + // Act: Filter by 'dnf' status + const result = await bookRepository.findWithFilters({ status: "dnf" }, 50, 0); + + // Assert: Should only return the DNF book + expect(result.books).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.books[0].id).toBe(dnfBook.id); + }); + + test("should include DNF books even when is_active = 0", async () => { + // Arrange: Create a DNF book with inactive session + const dnfBook = await bookRepository.create(createTestBook({ + calibreId: 1, + title: "DNF Book Inactive", + authors: ["Author 1"], + path: "Author 1/DNF Book Inactive (1)", + })); + + // Create DNF session with is_active = 0 (archived) + await sessionRepository.create({ + bookId: dnfBook.id, + userId: null, + sessionNumber: 1, + status: "dnf", + isActive: false, // This is the critical test - DNF sessions should be inactive + startedDate: "2026-01-01", + }); + + // Act: Filter by 'dnf' status + const result = await bookRepository.findWithFilters({ status: "dnf" }, 50, 0); + + // Assert: Should return the DNF book + expect(result.books).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.books[0].id).toBe(dnfBook.id); + }); + + test("should handle book with both DNF and active session", async () => { + // Arrange: Create a book that was DNF'd and then added back + const book = await bookRepository.create(createTestBook({ + calibreId: 1, + title: "Book with Multiple Sessions", + authors: ["Author 1"], + path: "Author 1/Book with Multiple Sessions (1)", + })); + + // First session: DNF (inactive) + await sessionRepository.create({ + bookId: book.id, + userId: null, + sessionNumber: 1, + status: "dnf", + isActive: false, + startedDate: "2026-01-01", + }); + + // Second session: read-next (active) + await sessionRepository.create({ + bookId: book.id, + userId: null, + sessionNumber: 2, + status: "read-next", + isActive: true, + }); + + // Act: Filter by 'dnf' status + const dnfResult = await bookRepository.findWithFilters({ status: "dnf" }, 50, 0); + + // Assert: Should return the book (it has a DNF session) + expect(dnfResult.books).toHaveLength(1); + expect(dnfResult.total).toBe(1); + expect(dnfResult.books[0].id).toBe(book.id); + + // Also verify it appears in read-next filter + const readNextResult = await bookRepository.findWithFilters({ status: "read-next" }, 50, 0); + expect(readNextResult.books).toHaveLength(1); + expect(readNextResult.books[0].id).toBe(book.id); + }); +}); + +describe("BookRepository Status Filter - Active States", () => { + test("should filter books by 'reading' status (only active sessions)", async () => { + // Arrange: Create books + const activeBook = await bookRepository.create(createTestBook({ + calibreId: 1, + title: "Active Reading", + authors: ["Author 1"], + path: "Author 1/Active Reading (1)", + })); + + const inactiveBook = await bookRepository.create(createTestBook({ + calibreId: 2, + title: "Inactive Reading", + authors: ["Author 2"], + path: "Author 2/Inactive Reading (2)", + })); + + // Create active reading session + await sessionRepository.create({ + bookId: activeBook.id, + userId: null, + sessionNumber: 1, + status: "reading", + isActive: true, + }); + + // Create inactive reading session (edge case - shouldn't happen in practice) + await sessionRepository.create({ + bookId: inactiveBook.id, + userId: null, + sessionNumber: 1, + status: "reading", + isActive: false, + }); + + // Act: Filter by 'reading' status + const result = await bookRepository.findWithFilters({ status: "reading" }, 50, 0); + + // Assert: Should only return the active reading book + expect(result.books).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.books[0].id).toBe(activeBook.id); + }); + + test("should filter books by 'to-read' status (only active sessions)", async () => { + // Arrange + const activeBook = await bookRepository.create(createTestBook({ + calibreId: 1, + title: "Active To-Read", + authors: ["Author 1"], + path: "Author 1/Active To-Read (1)", + })); + + await sessionRepository.create({ + bookId: activeBook.id, + userId: null, + sessionNumber: 1, + status: "to-read", + isActive: true, + }); + + // Act + const result = await bookRepository.findWithFilters({ status: "to-read" }, 50, 0); + + // Assert + expect(result.books).toHaveLength(1); + expect(result.books[0].id).toBe(activeBook.id); + }); + + test("should filter books by 'read-next' status (only active sessions)", async () => { + // Arrange + const activeBook = await bookRepository.create(createTestBook({ + calibreId: 1, + title: "Active Read-Next", + authors: ["Author 1"], + path: "Author 1/Active Read-Next (1)", + })); + + await sessionRepository.create({ + bookId: activeBook.id, + userId: null, + sessionNumber: 1, + status: "read-next", + isActive: true, + }); + + // Act + const result = await bookRepository.findWithFilters({ status: "read-next" }, 50, 0); + + // Assert + expect(result.books).toHaveLength(1); + expect(result.books[0].id).toBe(activeBook.id); + }); +}); + +describe("BookRepository Status Filter - findWithFiltersAndRelations()", () => { + test("should filter books by 'dnf' status using findWithFiltersAndRelations", async () => { + // Arrange: Create DNF book + const dnfBook = await bookRepository.create(createTestBook({ + calibreId: 1, + title: "DNF Book", + authors: ["Author 1"], + path: "Author 1/DNF Book (1)", + })); + + await sessionRepository.create({ + bookId: dnfBook.id, + userId: null, + sessionNumber: 1, + status: "dnf", + isActive: false, + startedDate: "2026-01-01", + }); + + // Act: Filter using findWithFiltersAndRelations + const result = await bookRepository.findWithFiltersAndRelations({ status: "dnf" }, 50, 0); + + // Assert + expect(result.books).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.books[0].id).toBe(dnfBook.id); + }); + + test("should filter books by 'read' status using findWithFiltersAndRelations", async () => { + // Arrange: Create read book + const readBook = await bookRepository.create(createTestBook({ + calibreId: 1, + title: "Read Book", + authors: ["Author 1"], + path: "Author 1/Read Book (1)", + })); + + await sessionRepository.create({ + bookId: readBook.id, + userId: null, + sessionNumber: 1, + status: "read", + isActive: false, + startedDate: "2026-01-01", + completedDate: "2026-01-15", + }); + + // Act + const result = await bookRepository.findWithFiltersAndRelations({ status: "read" }, 50, 0); + + // Assert + expect(result.books).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.books[0].id).toBe(readBook.id); + }); +}); diff --git a/__tests__/integration/services/streaks-coverage.test.ts b/__tests__/integration/services/streaks-coverage.test.ts index 7294bf41..91861622 100644 --- a/__tests__/integration/services/streaks-coverage.test.ts +++ b/__tests__/integration/services/streaks-coverage.test.ts @@ -2,8 +2,6 @@ import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'vitest' import { streakService } from "@/lib/services/streak.service"; import { bookRepository, sessionRepository, progressRepository, streakRepository } from "@/lib/repositories"; import { setupTestDatabase, teardownTestDatabase, clearTestDatabase } from "@/__tests__/helpers/db-setup"; -import { startOfDay } from "date-fns"; -import { toZonedTime, fromZonedTime } from "date-fns-tz"; import { toProgressDate, toSessionDate } from "../../test-utils"; import { toDateString } from "@/utils/dateHelpers.server"; diff --git a/app/api/books/[id]/sessions/route.ts b/app/api/books/[id]/sessions/route.ts index ae43f5cc..c87c04ad 100644 --- a/app/api/books/[id]/sessions/route.ts +++ b/app/api/books/[id]/sessions/route.ts @@ -1,6 +1,7 @@ import { getLogger } from "@/lib/logger"; import { NextRequest, NextResponse } from "next/server"; import { bookRepository, sessionRepository } from "@/lib/repositories"; +import { sessionService } from "@/lib/services/session.service"; export const dynamic = 'force-dynamic'; @@ -22,7 +23,21 @@ export async function GET(request: NextRequest, props: { params: Promise<{ id: s // OPTIMIZED: Get all reading sessions with progress summaries in a single query const sessionsWithProgress = await sessionRepository.findAllByBookIdWithProgress(bookId); - return NextResponse.json(sessionsWithProgress, { + // Get display numbers using service layer (single source of truth) + const sessionsWithDisplayNumbers = await sessionService.getSessionsWithDisplayNumbers(bookId); + + // Create a map of sessionId -> displayNumber + const displayNumberMap = new Map( + sessionsWithDisplayNumbers.map(s => [s.id, s.displayNumber]) + ); + + // Add displayNumber to sessions (preserving original sort order from findAllByBookIdWithProgress) + const result = sessionsWithProgress.map(session => ({ + ...session, + displayNumber: displayNumberMap.get(session.id), + })); + + return NextResponse.json(result, { headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache', diff --git a/app/shelves/[id]/page.tsx b/app/shelves/[id]/page.tsx index 4619e93f..ec4b47f1 100644 --- a/app/shelves/[id]/page.tsx +++ b/app/shelves/[id]/page.tsx @@ -375,6 +375,7 @@ export default function ShelfDetailPage() { isSelectMode={listView.isSelectMode} selectedBookIds={listView.selectedBookIds} onToggleSelection={listView.toggleBookSelection} + onEnterSelectModeWithSelection={listView.enterSelectModeWithSelection} renderActions={!listView.isSelectMode ? (book, index) => ( listView.toggleBookSelection(book.id)} + onLongPress={() => listView.enterSelectModeWithSelection(book.id)} actions={ !listView.isSelectMode ? ( void; + onLongPress?: () => void; } export const BookListItem = memo(function BookListItem({ @@ -41,6 +43,7 @@ export const BookListItem = memo(function BookListItem({ isSelectMode = false, isSelected = false, onToggleSelection, + onLongPress, }: BookListItemProps) { const [imageError, setImageError] = useState(false); @@ -60,8 +63,30 @@ export const BookListItem = memo(function BookListItem({ } }; + const handleKeyDown = (e: React.KeyboardEvent) => { + // Allow Space and Enter keys to trigger long-press selection when not in select mode + // role="button" per ARIA spec requires both Space and Enter to activate + if ((e.key === ' ' || e.key === 'Enter') && !isSelectMode && onLongPress) { + e.preventDefault(); + onLongPress(); + } + }; + + // Long-press handlers (only active when NOT in select mode) + const longPressHandlers = useLongPress( + () => { + if (!isSelectMode && onLongPress) { + onLongPress(); + } + }, + { delay: 500, tolerance: 10 } + ); + return (
{/* Checkbox for select mode */} diff --git a/components/Books/DraggableBookList.tsx b/components/Books/DraggableBookList.tsx index e6059170..bf948dbb 100644 --- a/components/Books/DraggableBookList.tsx +++ b/components/Books/DraggableBookList.tsx @@ -46,6 +46,7 @@ interface DraggableBookListProps { isSelectMode?: boolean; selectedBookIds?: Set; onToggleSelection?: (bookId: number) => void; + onEnterSelectModeWithSelection?: (bookId: number) => void; } interface SortableBookItemProps { @@ -54,9 +55,10 @@ interface SortableBookItemProps { isSelectMode?: boolean; isSelected?: boolean; onToggleSelection?: () => void; + onLongPress?: () => void; } -function SortableBookItem({ book, actions, isSelectMode = false, isSelected = false, onToggleSelection }: SortableBookItemProps) { +function SortableBookItem({ book, actions, isSelectMode = false, isSelected = false, onToggleSelection, onLongPress }: SortableBookItemProps) { const { attributes, listeners, @@ -101,6 +103,7 @@ function SortableBookItem({ book, actions, isSelectMode = false, isSelected = fa isSelectMode={isSelectMode} isSelected={isSelected} onToggleSelection={onToggleSelection} + onLongPress={onLongPress} />
@@ -116,6 +119,7 @@ export function DraggableBookList({ isSelectMode = false, selectedBookIds = new Set(), onToggleSelection, + onEnterSelectModeWithSelection, }: DraggableBookListProps) { const [activeId, setActiveId] = useState(null); const [localBooks, setLocalBooks] = useState(books); @@ -190,6 +194,7 @@ export function DraggableBookList({ isSelectMode={isSelectMode} isSelected={selectedBookIds.has(book.id)} onToggleSelection={() => onToggleSelection?.(book.id)} + onLongPress={() => onEnterSelectModeWithSelection?.(book.id)} /> ))} @@ -214,6 +219,7 @@ export function DraggableBookList({ isSelectMode={isSelectMode} isSelected={selectedBookIds.has(book.id)} onToggleSelection={() => onToggleSelection?.(book.id)} + onLongPress={() => onEnterSelectModeWithSelection?.(book.id)} /> ))} diff --git a/components/CurrentlyReading/ReadingHistoryTab.tsx b/components/CurrentlyReading/ReadingHistoryTab.tsx index 485d4b48..56f702c8 100644 --- a/components/CurrentlyReading/ReadingHistoryTab.tsx +++ b/components/CurrentlyReading/ReadingHistoryTab.tsx @@ -15,6 +15,7 @@ import MarkdownRenderer from "@/components/Markdown/MarkdownRenderer"; interface ReadingSession { id: number; sessionNumber: number; + displayNumber?: number; // Calculated display number (1st read, 2nd read, etc.) status: string; startedDate?: string; completedDate?: string; @@ -47,6 +48,7 @@ export default function ReadingHistoryTab({ bookId, bookTitle = "this book" }: R const [viewProgressModal, setViewProgressModal] = useState<{ sessionId: number; sessionNumber: number; + displayNumber?: number; } | null>(null); // Fetch sessions using TanStack Query - automatic caching and background refetching @@ -165,11 +167,11 @@ export default function ReadingHistoryTab({ bookId, bookTitle = "this book" }: R key={session.id} className="p-5 bg-[var(--background)] border border-[var(--border-color)] rounded-lg" > -
+

- Read #{session.sessionNumber} + Read #{session.displayNumber ?? session.sessionNumber}

{session.status === 'dnf' ? ( @@ -221,9 +223,10 @@ export default function ReadingHistoryTab({ bookId, bookTitle = "this book" }: R onClick={() => setViewProgressModal({ sessionId: session.id, sessionNumber: session.sessionNumber, + displayNumber: session.displayNumber, })} className="w-full grid grid-cols-2 gap-4 mb-4 p-3 bg-[var(--card-bg)] rounded border border-[var(--border-color)] hover:border-[var(--accent)] hover:bg-[var(--accent)]/5 transition-colors duration-200 cursor-pointer group" - aria-label={`View progress logs for Read #${session.sessionNumber}`} + aria-label={`View progress logs for Read #${session.displayNumber ?? session.sessionNumber}`} >

@@ -275,6 +278,7 @@ export default function ReadingHistoryTab({ bookId, bookTitle = "this book" }: R onConfirm={handleSaveSession} bookTitle={bookTitle} sessionNumber={editingSession?.sessionNumber ?? 0} + displayNumber={editingSession?.displayNumber} sessionId={editingSession?.id ?? 0} bookId={bookId} currentStartedDate={editingSession?.startedDate ?? null} @@ -287,6 +291,7 @@ export default function ReadingHistoryTab({ bookId, bookTitle = "this book" }: R onClose={handleCloseDeleteModal} onConfirm={handleConfirmDelete} sessionNumber={deletingSession?.sessionNumber ?? 0} + displayNumber={deletingSession?.displayNumber} progressCount={deletingSession?.progressSummary.totalEntries ?? 0} bookTitle={bookTitle} isActive={deletingSession?.isActive ?? false} @@ -300,6 +305,7 @@ export default function ReadingHistoryTab({ bookId, bookTitle = "this book" }: R bookId={bookId} bookTitle={bookTitle} sessionNumber={viewProgressModal.sessionNumber} + displayNumber={viewProgressModal.displayNumber} /> )}

diff --git a/components/Modals/DeleteSessionModal.tsx b/components/Modals/DeleteSessionModal.tsx index 18200c30..8ae7cf43 100644 --- a/components/Modals/DeleteSessionModal.tsx +++ b/components/Modals/DeleteSessionModal.tsx @@ -2,13 +2,14 @@ import { X, Trash2 } from "lucide-react"; import { Button } from "@/components/Utilities/Button"; -import { useState } from "react"; +import { useState, useEffect } from "react"; interface DeleteSessionModalProps { isOpen: boolean; onClose: () => void; onConfirm: () => Promise; sessionNumber: number; + displayNumber?: number; // Calculated display number (optional for backward compat) progressCount: number; bookTitle: string; isActive: boolean; @@ -19,12 +20,20 @@ export default function DeleteSessionModal({ onClose, onConfirm, sessionNumber, + displayNumber, progressCount, bookTitle, isActive, }: DeleteSessionModalProps) { const [submitting, setSubmitting] = useState(false); + // Reset submitting state when modal closes to prevent stuck "Deleting..." button + useEffect(() => { + if (!isOpen) { + setSubmitting(false); + } + }, [isOpen]); + if (!isOpen) return null; async function handleConfirm() { @@ -49,7 +58,7 @@ export default function DeleteSessionModal({

- Delete Read #{sessionNumber}? + Delete Read #{displayNumber ?? sessionNumber}?

diff --git a/components/Modals/SessionEditModal.tsx b/components/Modals/SessionEditModal.tsx index 79246115..6a4dc9d0 100644 --- a/components/Modals/SessionEditModal.tsx +++ b/components/Modals/SessionEditModal.tsx @@ -17,6 +17,7 @@ interface SessionEditModalProps { }) => void; bookTitle: string; sessionNumber: number; + displayNumber?: number; // Calculated display number (optional for backward compat) sessionId: number; bookId: string; currentStartedDate?: string | null; @@ -30,6 +31,7 @@ export default function SessionEditModal({ onConfirm, bookTitle, sessionNumber, + displayNumber, sessionId, bookId, currentStartedDate, @@ -108,7 +110,7 @@ export default function SessionEditModal({ { + setIsSelectMode(true); + setSelectedBookIds(new Set([bookId])); + }, []); + return { // Mobile detection isMobile, @@ -111,5 +117,6 @@ export function useBookListView({ books, initialFilter = "" }: UseBookListViewOp toggleSelectAll, clearSelection, exitSelectMode, + enterSelectModeWithSelection, }; } diff --git a/hooks/useLongPress.ts b/hooks/useLongPress.ts new file mode 100644 index 00000000..9ed3696c --- /dev/null +++ b/hooks/useLongPress.ts @@ -0,0 +1,147 @@ +/** + * useLongPress Hook + * + * Detects long-press gestures on touch and mouse events. + * Used for entering select mode when user long-presses on a book list item. + * + * @param onLongPress - Callback fired when long-press is detected + * @param options - Configuration options + * @returns Event handlers to spread onto target element + */ + +import { useRef, useCallback, useEffect } from "react"; + +interface UseLongPressOptions { + /** Delay in milliseconds before triggering long-press (default: 500) */ + delay?: number; + /** Movement tolerance in pixels - if exceeded, cancels long-press (default: 10) */ + tolerance?: number; +} + +interface Position { + x: number; + y: number; +} + +export function useLongPress( + onLongPress: () => void, + options: UseLongPressOptions = {} +) { + const { delay = 500, tolerance = 10 } = options; + + const timeoutRef = useRef(null); + const startPosRef = useRef(null); + + const start = useCallback( + (x: number, y: number) => { + startPosRef.current = { x, y }; + + // Clear any existing timeout + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + // Set new timeout for long-press + timeoutRef.current = setTimeout(() => { + onLongPress(); + startPosRef.current = null; + }, delay); + }, + [onLongPress, delay] + ); + + const cancel = useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + startPosRef.current = null; + }, []); + + const checkMovement = useCallback( + (x: number, y: number) => { + if (!startPosRef.current) return; + + const deltaX = Math.abs(x - startPosRef.current.x); + const deltaY = Math.abs(y - startPosRef.current.y); + + // If movement exceeds tolerance, cancel long-press + if (deltaX > tolerance || deltaY > tolerance) { + cancel(); + } + }, + [tolerance, cancel] + ); + + // Mouse event handlers + const onMouseDown = useCallback( + (e: React.MouseEvent) => { + // Only respond to left mouse button + if (e.button !== 0) return; + start(e.clientX, e.clientY); + }, + [start] + ); + + const onMouseUp = useCallback(() => { + cancel(); + }, [cancel]); + + const onMouseLeave = useCallback(() => { + cancel(); + }, [cancel]); + + const onMouseMove = useCallback( + (e: React.MouseEvent) => { + checkMovement(e.clientX, e.clientY); + }, + [checkMovement] + ); + + // Touch event handlers + const onTouchStart = useCallback( + (e: React.TouchEvent) => { + if (e.touches.length !== 1) return; // Only single-touch + const touch = e.touches[0]; + start(touch.clientX, touch.clientY); + }, + [start] + ); + + const onTouchEnd = useCallback(() => { + cancel(); + }, [cancel]); + + const onTouchCancel = useCallback(() => { + cancel(); + }, [cancel]); + + const onTouchMove = useCallback( + (e: React.TouchEvent) => { + if (e.touches.length !== 1) return; + const touch = e.touches[0]; + checkMovement(touch.clientX, touch.clientY); + }, + [checkMovement] + ); + + // Cleanup timeout on unmount to prevent memory leaks + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + return { + onMouseDown, + onMouseUp, + onMouseLeave, + onMouseMove, + onTouchStart, + onTouchEnd, + onTouchCancel, + onTouchMove, + }; +} diff --git a/lib/repositories/book.repository.ts b/lib/repositories/book.repository.ts index b7187aff..c513d77e 100644 --- a/lib/repositories/book.repository.ts +++ b/lib/repositories/book.repository.ts @@ -375,8 +375,8 @@ export class BookRepository extends BaseRepository if (filters.status) { const statusCondition = eq(readingSessions.status, filters.status as any); const activeCondition = - filters.status === "read" - ? undefined // For "read", include all sessions + filters.status === "read" || filters.status === "dnf" + ? undefined // For terminal states (read, dnf), include all sessions : eq(readingSessions.isActive, true); const sessionQuery = this.getDatabase() @@ -752,8 +752,8 @@ export class BookRepository extends BaseRepository if (filters.status) { const statusCondition = eq(readingSessions.status, filters.status as any); const activeCondition = - filters.status === "read" - ? undefined // For "read", include all sessions + filters.status === "read" || filters.status === "dnf" + ? undefined // For terminal states (read, dnf), include all sessions : eq(readingSessions.isActive, true); const sessionQuery = this.getDatabase() diff --git a/lib/repositories/session.repository.ts b/lib/repositories/session.repository.ts index e9ef7e83..c2683d31 100644 --- a/lib/repositories/session.repository.ts +++ b/lib/repositories/session.repository.ts @@ -85,6 +85,31 @@ export class SessionRepository extends BaseRepository< .all(); } + /** + * Find all sessions for a book ordered chronologically for display + * Sorts by startedDate (with createdAt fallback) to show sessions in reading order + * Used for calculating display numbers (1st read, 2nd read, etc.) + * + * Note: Uses strftime to convert createdAt (INTEGER unix timestamp) to YYYY-MM-DD TEXT + * format before COALESCE to ensure consistent sorting with startedDate (TEXT). + */ + async findAllByBookIdOrdered(bookId: number, tx?: any): Promise { + const database = tx || this.getDatabase(); + return database + .select() + .from(readingSessions) + .where(eq(readingSessions.bookId, bookId)) + .orderBy( + asc( + sql`COALESCE( + ${readingSessions.startedDate}, + strftime('%Y-%m-%d', ${readingSessions.createdAt}, 'unixepoch') + )` + ) + ) + .all(); + } + /** * Find all sessions for a book with progress summaries - OPTIMIZED * Uses a single query with aggregations instead of N+1 queries diff --git a/lib/services/session.service.ts b/lib/services/session.service.ts index 946a14b8..5dd0b395 100644 --- a/lib/services/session.service.ts +++ b/lib/services/session.service.ts @@ -195,6 +195,38 @@ export class SessionService { return sessionRepository.findAllByBookId(bookId); } + /** + * Get all sessions for a book with calculated display numbers + * Display numbers represent sequential read position (1st read, 2nd read, etc.) + * Sessions are ordered chronologically by startedDate (or createdAt if no startedDate) + * + * @param bookId - The book ID + * @returns Sessions with displayNumber field added + */ + async getSessionsWithDisplayNumbers( + bookId: number + ): Promise> { + // Get sessions ordered chronologically + const sessions = await sessionRepository.findAllByBookIdOrdered(bookId); + + // Filter to only sessions that will be displayed in Reading History + // Matches filter in ReadingHistoryTab.tsx:77-79 + const displayedSessions = sessions.filter( + session => !session.isActive || session.status === 'read' || session.status === 'dnf' + ); + + // Create a map of sessionId -> displayNumber (only for displayed sessions) + const displayNumberMap = new Map( + displayedSessions.map((session, index) => [session.id, index + 1]) + ); + + // Add displayNumber to sessions (only displayed sessions get a number) + return sessions.map((session) => ({ + ...session, + displayNumber: displayNumberMap.get(session.id), + })); + } + /** * Update book reading status (primary workflow for status changes) * @@ -1294,16 +1326,19 @@ export class SessionService { if (!remainingActiveSession) { logger.info({ bookId }, "Creating new 'to-read' session - no active session remains"); + // Calculate next session number to avoid numbering conflicts (fix for issue #413) + const nextSessionNumber = await sessionRepository.getNextSessionNumber(bookId); + await sessionRepository.create({ bookId, - sessionNumber: 1, + sessionNumber: nextSessionNumber, status: "to-read", isActive: true, userId: session.userId, }); newSessionCreated = true; - logger.info({ bookId }, "New 'to-read' session created"); + logger.info({ bookId, sessionNumber: nextSessionNumber }, "New 'to-read' session created"); } else { logger.info({ bookId }, "Book still has active session - no new session needed"); } diff --git a/package.json b/package.json index 6dea0f35..0861ff8e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tome", - "version": "0.6.8", + "version": "0.6.9", "description": "A Calibre-integrated book tracker", "homepage": "https://github.com/masonfox/tome", "author": "Mason Fox (https://github.com/masonfox)",