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]) {