Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
56 changes: 55 additions & 1 deletion src/services/supabaseService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ReturnType<typeof vi.fn>> = {};
chainMethods.forEach((method) => {
builder[method] = vi.fn(() => builder);
Expand Down Expand Up @@ -390,6 +390,60 @@ function callCheckNewSchema(instance: SupabaseService): Promise<boolean> {
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;

Expand Down
51 changes: 41 additions & 10 deletions src/services/supabaseService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, Task[]> = {};
(tasksData || []).forEach((task) => {
tasksData.forEach((task) => {
if (!task.day_record_id) return;

if (!tasksByDay[task.day_record_id]) {
Expand Down
Loading