From 0ffd42426003d3ab63dd92dbe7a6f32e282717c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 13:20:43 +0000 Subject: [PATCH] fix: paginate archived task fetch past the 1000-row cap getArchivedDays loaded all archived tasks in one query, which PostgREST truncates at 1000 rows. Once the tasks table passed that, the newest archived days rendered with no tasks. Page through with a stable (start_time, id) order, and add regression tests. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01GhvAYaQY83fW4raUET9yj5 --- AGENTS.md | 2 +- src/services/supabaseService.test.ts | 56 +++++++++++++++++++++++++++- src/services/supabaseService.ts | 51 ++++++++++++++++++++----- 3 files changed, 97 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f1c8417..118e581 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ Timetraked is a React 18 + TypeScript time tracking PWA for freelancers and cons | `src/contexts/TimeTracking.test.tsx` | Full context: day/task/archive management, todos, planned tasks, project CRUD, category CRUD, `discardDay`, `adjustTaskTime`, `addBackdatedDay`, client archive/restore | | `src/contexts/TimeTrackingContext.forceSync.test.tsx` | `forceSyncToDatabase` behavior | | `src/services/dataService.test.ts` | `LocalStorageService` save/load round-trips | -| `src/services/supabaseService.test.ts` | Upsert-only bulk saves and single-row deletes (`saveProjects`, `deleteProject`, `saveCategories`, `deleteCategory`, `saveTodos`), plus `getCurrentDay`/`saveCurrentDay`, `checkNewSchema` schema detection, and `migrateFromLocalStorage` (no-op/no-existing-data/client-name-reconcile/error-swallow paths) | +| `src/services/supabaseService.test.ts` | Upsert-only bulk saves and single-row deletes (`saveProjects`, `deleteProject`, `saveCategories`, `deleteCategory`, `saveTodos`), plus `getCurrentDay`/`saveCurrentDay`, `getArchivedDays` task pagination past the 1000-row PostgREST cap, `checkNewSchema` schema detection, and `migrateFromLocalStorage` (no-op/no-existing-data/client-name-reconcile/error-swallow paths) | | `src/services/localStorageService/recovery.test.ts` | Listing/summarizing/restoring localStorage schema-mismatch backups and full-snapshot disk backups | | `src/services/localStorageService/utils.test.ts` | Versioned read/write helpers, stale-key backup, write-failure notification | | `src/hooks/useReportStorage.test.ts` | Report storage hook | diff --git a/src/services/supabaseService.test.ts b/src/services/supabaseService.test.ts index 986c7df..e9ab2a2 100644 --- a/src/services/supabaseService.test.ts +++ b/src/services/supabaseService.test.ts @@ -18,7 +18,7 @@ const { trackAuthCallMock, localServiceMocks } = vi.hoisted(() => { - const chainMethods = ["eq", "in", "order", "limit", "select", "upsert", "delete", "update"]; + const chainMethods = ["eq", "in", "order", "limit", "range", "select", "upsert", "delete", "update"]; const builder: Record> = {}; chainMethods.forEach((method) => { builder[method] = vi.fn(() => builder); @@ -390,6 +390,60 @@ function callCheckNewSchema(instance: SupabaseService): Promise { return (instance as any).checkNewSchema(); } +describe("SupabaseService — getArchivedDays pagination", () => { + let service: SupabaseService; + + const makeTask = (n: number) => ({ + id: `t${String(n).padStart(5, "0")}`, + title: `Task ${n}`, + start_time: "2026-10-01T09:00:00.000Z", + end_time: null, + day_record_id: "day-1" + }); + + beforeEach(() => { + vi.clearAllMocks(); + getCachedUserMock.mockResolvedValue({ id: "user-1" }); + service = new SupabaseService(); + }); + + // Regression: PostgREST caps a response at 1000 rows, so the unpaginated + // tasks query dropped the newest archived tasks once the table exceeded it. + it("pages through tasks so archived days keep tasks beyond the 1000-row cap", async () => { + const pages = [ + { data: [{ id: "day-1", date: "Thu Oct 01 2026", total_duration: 0, start_time: "2026-10-01T09:00:00.000Z", end_time: "2026-10-01T17:00:00.000Z" }], error: null }, + { data: Array.from({ length: 1000 }, (_, i) => makeTask(i)), error: null }, + { data: Array.from({ length: 29 }, (_, i) => makeTask(1000 + i)), error: null } + ]; + let call = 0; + (builder as unknown as { then: typeof Promise.prototype.then }).then = (resolve, reject) => + Promise.resolve(pages[call++]).then(resolve, reject); + + const result = await service.getArchivedDays(); + + expect(result).toHaveLength(1); + expect(result[0].tasks).toHaveLength(1029); + expect(builder.range).toHaveBeenCalledWith(0, 999); + expect(builder.range).toHaveBeenCalledWith(1000, 1999); + expect(builder.range).toHaveBeenCalledTimes(2); + }); + + it("stops after a single request when the first page is not full", async () => { + const pages = [ + { data: [{ id: "day-1", date: "Thu Oct 01 2026", total_duration: 0, start_time: "2026-10-01T09:00:00.000Z", end_time: "2026-10-01T17:00:00.000Z" }], error: null }, + { data: [makeTask(1)], error: null } + ]; + let call = 0; + (builder as unknown as { then: typeof Promise.prototype.then }).then = (resolve, reject) => + Promise.resolve(pages[call++]).then(resolve, reject); + + const result = await service.getArchivedDays(); + + expect(result[0].tasks).toHaveLength(1); + expect(builder.range).toHaveBeenCalledTimes(1); + }); +}); + describe("SupabaseService — schema detection (checkNewSchema)", () => { let service: SupabaseService; diff --git a/src/services/supabaseService.ts b/src/services/supabaseService.ts index 615aae6..4f767aa 100644 --- a/src/services/supabaseService.ts +++ b/src/services/supabaseService.ts @@ -15,6 +15,25 @@ import { TaskCategory } from "@/config/categories"; import { DataService, CurrentDayData } from "@/services/dataService"; import { LocalStorageService } from "@/services/localStorageService"; +// Rows per request when paging through `tasks`; matches PostgREST's default +// max-rows cap, so a full page means there may be more. +const TASKS_PAGE_SIZE = 1000; + +interface TaskRow { + id: string; + title: string; + description: string | null; + start_time: string; + end_time: string | null; + duration: number | null; + project_name: string | null; + client: string | null; + category_id: string | null; + day_record_id: string | null; + inserted_at?: string | null; + updated_at?: string | null; +} + export class SupabaseService implements DataService { // Schema detection with permanent caching private hasNewSchema: boolean | null = null; @@ -365,20 +384,32 @@ export class SupabaseService implements DataService { return []; } - const { data: tasksData, error: tasksError } = await supabase - .from("tasks") - .select("*") - .eq("user_id", user.id) - .eq("is_current", false) - .order("start_time", { ascending: true }); + // PostgREST caps a single response at 1000 rows (Supabase default), which + // silently truncated the newest archived tasks once the table outgrew it. + // Page through with a deterministic order (id breaks start_time ties) so + // no row is skipped or repeated across pages. + const tasksData: TaskRow[] = []; + for (let from = 0; ; from += TASKS_PAGE_SIZE) { + const { data: page, error: tasksError } = await supabase + .from("tasks") + .select("*") + .eq("user_id", user.id) + .eq("is_current", false) + .order("start_time", { ascending: true }) + .order("id", { ascending: true }) + .range(from, from + TASKS_PAGE_SIZE - 1); + + if (tasksError) { + console.error("❌ Error loading archived tasks:", tasksError); + throw tasksError; + } - if (tasksError) { - console.error("❌ Error loading archived tasks:", tasksError); - throw tasksError; + tasksData.push(...(page ?? [])); + if (!page || page.length < TASKS_PAGE_SIZE) break; } const tasksByDay: Record = {}; - (tasksData || []).forEach((task) => { + tasksData.forEach((task) => { if (!task.day_record_id) return; if (!tasksByDay[task.day_record_id]) {