diff --git a/e2e/tasks/delete-restore-task.spec.ts b/e2e/tasks/delete-restore-task.spec.ts new file mode 100644 index 0000000000..e7cb1361d3 --- /dev/null +++ b/e2e/tasks/delete-restore-task.spec.ts @@ -0,0 +1,40 @@ +import { test } from "@playwright/test"; +import { + createTask, + deleteTaskWithKeyboard, + expectTaskMissing, + expectTaskSavedToIndexedDB, + expectTaskVisible, + prepareTaskPage, + restoreDeletedTaskFromUndoToast, +} from "../utils/task-test-utils"; + +test.describe("Task Delete + Restore", () => { + test.skip( + ({ isMobile }) => isMobile, + "Tasks are not available in the current mobile experience.", + ); + + test("should restore a deleted task from the undo toast", async ({ + page, + }) => { + await prepareTaskPage(page); + + const taskTitle = `Delete Restore Task ${Date.now()}`; + await createTask(page, taskTitle); + + await expectTaskVisible(page, taskTitle); + await expectTaskSavedToIndexedDB(page, taskTitle); + + await deleteTaskWithKeyboard(page, taskTitle); + await expectTaskMissing(page, taskTitle); + + await restoreDeletedTaskFromUndoToast(page); + await expectTaskVisible(page, taskTitle); + await expectTaskSavedToIndexedDB(page, taskTitle); + + await page.reload(); + await expectTaskVisible(page, taskTitle, 10000); + await expectTaskSavedToIndexedDB(page, taskTitle); + }); +}); diff --git a/e2e/utils/task-test-utils.ts b/e2e/utils/task-test-utils.ts index 5c70769345..0e5ad23784 100644 --- a/e2e/utils/task-test-utils.ts +++ b/e2e/utils/task-test-utils.ts @@ -51,12 +51,44 @@ export const expectTaskVisible = async ( title: string, timeout = 10000, ) => { - // Tasks are rendered as inputs - await expect(page.locator(`input[value="${title}"]`)).toBeVisible({ + await expect( + page.getByRole("textbox", { name: `Edit ${title}` }), + ).toBeVisible({ timeout, }); }; +export const expectTaskMissing = async ( + page: Page, + title: string, + timeout = 10000, +) => { + await expect( + page.getByRole("textbox", { name: `Edit ${title}` }), + ).toHaveCount(0, { + timeout, + }); +}; + +export const deleteTaskWithKeyboard = async (page: Page, title: string) => { + const taskCheckbox = page.getByRole("checkbox", { + name: `Toggle ${title}`, + }); + + await expect(taskCheckbox).toBeVisible(); + await taskCheckbox.focus(); + await page.keyboard.press("Delete"); +}; + +export const restoreDeletedTaskFromUndoToast = async (page: Page) => { + const undoDeleteText = page.getByText("Deleted").first(); + + await expect(undoDeleteText).toBeVisible(); + await undoDeleteText.click(); + await page.keyboard.press("Meta+z"); + await page.keyboard.press("Control+z"); +}; + export const expectTaskSavedToIndexedDB = async (page: Page, title: string) => { let lastSnapshot: unknown = null; diff --git a/packages/web/src/common/repositories/event/local.event.repository.test.ts b/packages/web/src/common/repositories/event/local.event.repository.test.ts index fabf59d485..1916dcf739 100644 --- a/packages/web/src/common/repositories/event/local.event.repository.test.ts +++ b/packages/web/src/common/repositories/event/local.event.repository.test.ts @@ -4,23 +4,29 @@ import { Payload_Order, Schema_Event, } from "@core/types/event.types"; +import { isDateRangeOverlapping } from "@core/util/date/date.util"; import dayjs from "@core/util/date/dayjs"; import { createTestCompassEvent, createTestEvent, } from "@web/__tests__/utils/repositories/repository.test.factory"; +import * as storageAdapter from "@web/common/storage/adapter/adapter"; import { LocalEventRepository } from "./local.event.repository"; const mockEvents = new Map(); -jest.mock("@web/common/utils/storage/event.storage.util", () => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { isDateRangeOverlapping } = require("@core/util/date/date.util"); +function createMockAdapter() { return { - saveEventToIndexedDB: jest.fn(async (event: Event_Core) => { - mockEvents.set(event._id!, event); - }), - loadEventsFromIndexedDB: jest.fn( + initialize: jest.fn().mockResolvedValue(undefined), + isReady: jest.fn().mockReturnValue(true), + getTasks: jest.fn().mockResolvedValue([]), + getAllTasks: jest.fn().mockResolvedValue([]), + putTasks: jest.fn().mockResolvedValue(undefined), + putTask: jest.fn().mockResolvedValue(undefined), + deleteTask: jest.fn().mockResolvedValue(undefined), + moveTask: jest.fn().mockResolvedValue(undefined), + clearAllTasks: jest.fn().mockResolvedValue(undefined), + getEvents: jest.fn( async (startDate: string, endDate: string, isSomeday?: boolean) => { const allEvents = Array.from(mockEvents.values()); let filtered = allEvents.filter((event) => { @@ -39,31 +45,36 @@ jest.mock("@web/common/utils/storage/event.storage.util", () => { return filtered; }, ), - deleteEventFromIndexedDB: jest.fn(async (id: string) => { + getAllEvents: jest.fn(async () => Array.from(mockEvents.values())), + putEvent: jest.fn(async (event: Event_Core) => { + mockEvents.set(event._id!, event); + }), + putEvents: jest.fn().mockResolvedValue(undefined), + deleteEvent: jest.fn(async (id: string) => { mockEvents.delete(id); }), + clearAllEvents: jest.fn().mockResolvedValue(undefined), + getMigrationRecords: jest.fn().mockResolvedValue([]), + setMigrationRecord: jest.fn().mockResolvedValue(undefined), }; -}); +} -jest.mock("@web/common/utils/storage/compass-local.db", () => { - return { - compassLocalDB: { - events: { - toArray: jest.fn(async () => Array.from(mockEvents.values())), - }, - }, - }; -}); +jest.mock("@web/common/storage/adapter/adapter"); describe("LocalEventRepository", () => { let repository: LocalEventRepository; + let mockAdapter: ReturnType; beforeEach(async () => { - repository = new LocalEventRepository(); mockEvents.clear(); + mockAdapter = createMockAdapter(); + (storageAdapter.getStorageAdapter as jest.Mock).mockReturnValue( + mockAdapter, + ); + repository = new LocalEventRepository(); }); - afterEach(async () => { + afterEach(() => { mockEvents.clear(); }); diff --git a/packages/web/src/common/repositories/event/local.event.repository.ts b/packages/web/src/common/repositories/event/local.event.repository.ts index 49f300013a..e8a6a7fad1 100644 --- a/packages/web/src/common/repositories/event/local.event.repository.ts +++ b/packages/web/src/common/repositories/event/local.event.repository.ts @@ -5,15 +5,21 @@ import { RecurringEventUpdateScope, Schema_Event, } from "@core/types/event.types"; -import { - deleteEventFromIndexedDB, - loadEventsFromIndexedDB, - saveEventToIndexedDB, -} from "@web/common/utils/storage/event.storage.util"; +import { getStorageAdapter } from "@web/common/storage/adapter/adapter"; import { Response_GetEventsSuccess } from "@web/ducks/events/event.types"; import { EventRepository } from "./event.repository.interface"; +/** + * Local event repository implementation using the storage adapter. + * + * This repository delegates all storage operations to the StorageAdapter, + * making it independent of the underlying storage technology. + */ export class LocalEventRepository implements EventRepository { + private get adapter() { + return getStorageAdapter(); + } + async create(event: Schema_Event | Schema_Event[]): Promise { const events = Array.isArray(event) ? event : [event]; @@ -22,7 +28,7 @@ export class LocalEventRepository implements EventRepository { for (const e of events) { try { - await saveEventToIndexedDB(e as Event_Core); + await this.adapter.putEvent(e as Event_Core); } catch (error) { errors.push({ event: e, error }); } @@ -37,7 +43,7 @@ export class LocalEventRepository implements EventRepository { } async get(params: Params_Events): Promise { - const events = await loadEventsFromIndexedDB( + const events = await this.adapter.getEvents( params.startDate, params.endDate, params.someday, @@ -57,27 +63,25 @@ export class LocalEventRepository implements EventRepository { async edit( _id: string, event: Schema_Event, - params: { applyTo?: RecurringEventUpdateScope }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _params: { applyTo?: RecurringEventUpdateScope }, ): Promise { // For local repository, we just save the updated event // The applyTo parameter is not relevant for local storage - await saveEventToIndexedDB(event as Event_Core); + await this.adapter.putEvent(event as Event_Core); } async delete( _id: string, - applyTo?: RecurringEventUpdateScope, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _applyTo?: RecurringEventUpdateScope, ): Promise { // For local repository, applyTo is not relevant - await deleteEventFromIndexedDB(_id); + await this.adapter.deleteEvent(_id); } async reorder(order: Payload_Order[]): Promise { - // Load all events from IndexedDB, update their order, and save back - const { compassLocalDB } = await import( - "@web/common/utils/storage/compass-local.db" - ); - const allEvents = await compassLocalDB.events.toArray(); + const allEvents = await this.adapter.getAllEvents(); const orderMap = new Map(order.map((o) => [o._id, o.order])); // Track errors for individual event saves @@ -86,9 +90,11 @@ export class LocalEventRepository implements EventRepository { for (const event of allEvents) { const eventId = event._id; if (eventId && orderMap.has(eventId)) { - event.order = orderMap.get(eventId); + // Cast to Schema_Event which includes order property + const eventWithOrder = event as unknown as Schema_Event; + eventWithOrder.order = orderMap.get(eventId); try { - await saveEventToIndexedDB(event); + await this.adapter.putEvent(event); } catch (error) { errors.push({ eventId, error }); } diff --git a/packages/web/src/common/repositories/task/cloud.task.repository.ts b/packages/web/src/common/repositories/task/cloud.task.repository.ts index 16cf1989d1..1fe68866ab 100644 --- a/packages/web/src/common/repositories/task/cloud.task.repository.ts +++ b/packages/web/src/common/repositories/task/cloud.task.repository.ts @@ -10,7 +10,7 @@ export class CloudTaskRepository implements TaskRepository { throw this.createNotImplementedError("get"); } - async save(_dateKey: string, _tasks: Task[]): Promise { + async save(_dateKey: string, _taskOrTasks: Task | Task[]): Promise { throw this.createNotImplementedError("save"); } diff --git a/packages/web/src/common/repositories/task/local.task.repository.test.ts b/packages/web/src/common/repositories/task/local.task.repository.test.ts index e062a570f1..c635930eff 100644 --- a/packages/web/src/common/repositories/task/local.task.repository.test.ts +++ b/packages/web/src/common/repositories/task/local.task.repository.test.ts @@ -2,42 +2,46 @@ import { createTestTask, createTestTasks, } from "@web/__tests__/utils/repositories/repository.test.factory"; -import { - deleteTaskFromIndexedDB, - loadTasksFromIndexedDB, - moveTaskBetweenDates, - saveTasksToIndexedDB, -} from "@web/common/utils/storage/task.storage.util"; +import * as storageAdapter from "@web/common/storage/adapter/adapter"; import { LocalTaskRepository } from "./local.task.repository"; -jest.mock("@web/common/utils/storage/task.storage.util"); +// Mock the storage adapter module +jest.mock("@web/common/storage/adapter/adapter"); describe("LocalTaskRepository", () => { let repository: LocalTaskRepository; - const mockLoadTasks = loadTasksFromIndexedDB as jest.MockedFunction< - typeof loadTasksFromIndexedDB - >; - const mockSaveTasks = saveTasksToIndexedDB as jest.MockedFunction< - typeof saveTasksToIndexedDB - >; - const mockDeleteTask = deleteTaskFromIndexedDB as jest.MockedFunction< - typeof deleteTaskFromIndexedDB - >; - const mockMoveTask = moveTaskBetweenDates as jest.MockedFunction< - typeof moveTaskBetweenDates - >; + let mockAdapter: { + getTasks: jest.Mock; + putTasks: jest.Mock; + putTask: jest.Mock; + deleteTask: jest.Mock; + moveTask: jest.Mock; + }; beforeEach(() => { + mockAdapter = { + getTasks: jest.fn().mockResolvedValue([]), + putTasks: jest.fn().mockResolvedValue(undefined), + putTask: jest.fn().mockResolvedValue(undefined), + deleteTask: jest.fn().mockResolvedValue(undefined), + moveTask: jest.fn().mockResolvedValue(undefined), + }; + + (storageAdapter.getStorageAdapter as jest.Mock).mockReturnValue( + mockAdapter, + ); + repository = new LocalTaskRepository(); jest.clearAllMocks(); - mockLoadTasks.mockResolvedValue([]); - mockSaveTasks.mockResolvedValue(undefined); - mockDeleteTask.mockResolvedValue(undefined); - mockMoveTask.mockResolvedValue(undefined); + + // Re-mock after clearing + (storageAdapter.getStorageAdapter as jest.Mock).mockReturnValue( + mockAdapter, + ); }); describe("get", () => { - it("should load tasks from IndexedDB", async () => { + it("should load tasks from adapter", async () => { const dateKey = "2024-01-01"; const mockTasks = [ createTestTask({ @@ -46,28 +50,28 @@ describe("LocalTaskRepository", () => { }), ]; - mockLoadTasks.mockResolvedValue(mockTasks); + mockAdapter.getTasks.mockResolvedValue(mockTasks); const result = await repository.get(dateKey); - expect(mockLoadTasks).toHaveBeenCalledWith(dateKey); - expect(mockLoadTasks).toHaveBeenCalledTimes(1); + expect(mockAdapter.getTasks).toHaveBeenCalledWith(dateKey); + expect(mockAdapter.getTasks).toHaveBeenCalledTimes(1); expect(result).toEqual(mockTasks); }); it("should return empty array when no tasks exist", async () => { const dateKey = "2024-01-01"; - mockLoadTasks.mockResolvedValue([]); + mockAdapter.getTasks.mockResolvedValue([]); const result = await repository.get(dateKey); - expect(mockLoadTasks).toHaveBeenCalledWith(dateKey); + expect(mockAdapter.getTasks).toHaveBeenCalledWith(dateKey); expect(result).toEqual([]); }); }); describe("save", () => { - it("should save tasks to IndexedDB", async () => { + it("should save tasks to adapter", async () => { const dateKey = "2024-01-01"; const tasks = [ createTestTask({ @@ -78,8 +82,8 @@ describe("LocalTaskRepository", () => { await repository.save(dateKey, tasks); - expect(mockSaveTasks).toHaveBeenCalledWith(dateKey, tasks); - expect(mockSaveTasks).toHaveBeenCalledTimes(1); + expect(mockAdapter.putTasks).toHaveBeenCalledWith(dateKey, tasks); + expect(mockAdapter.putTasks).toHaveBeenCalledTimes(1); }); it("should save empty array", async () => { @@ -88,14 +92,28 @@ describe("LocalTaskRepository", () => { await repository.save(dateKey, tasks); - expect(mockSaveTasks).toHaveBeenCalledWith(dateKey, tasks); + expect(mockAdapter.putTasks).toHaveBeenCalledWith(dateKey, tasks); + }); + + it("should save single task using putTask", async () => { + const dateKey = "2024-01-01"; + const task = createTestTask({ + _id: "task-1", + title: "Single Task", + }); + + await repository.save(dateKey, task); + + expect(mockAdapter.putTask).toHaveBeenCalledWith(dateKey, task); + expect(mockAdapter.putTask).toHaveBeenCalledTimes(1); + expect(mockAdapter.putTasks).not.toHaveBeenCalled(); }); }); describe("delete", () => { it("should delete a task by id when it belongs to the date", async () => { const dateKey = "2024-01-01"; - mockLoadTasks.mockResolvedValue([ + mockAdapter.getTasks.mockResolvedValue([ createTestTask({ _id: "task-1", }), @@ -103,18 +121,18 @@ describe("LocalTaskRepository", () => { await repository.delete(dateKey, "task-1"); - expect(mockLoadTasks).toHaveBeenCalledWith(dateKey); - expect(mockDeleteTask).toHaveBeenCalledWith("task-1"); + expect(mockAdapter.getTasks).toHaveBeenCalledWith(dateKey); + expect(mockAdapter.deleteTask).toHaveBeenCalledWith("task-1"); }); it("should handle deleting non-existent task", async () => { const dateKey = "2024-01-01"; - mockLoadTasks.mockResolvedValue([]); + mockAdapter.getTasks.mockResolvedValue([]); await repository.delete(dateKey, "non-existent"); - expect(mockLoadTasks).toHaveBeenCalledWith(dateKey); - expect(mockDeleteTask).not.toHaveBeenCalled(); + expect(mockAdapter.getTasks).toHaveBeenCalledWith(dateKey); + expect(mockAdapter.deleteTask).not.toHaveBeenCalled(); }); }); @@ -130,13 +148,13 @@ describe("LocalTaskRepository", () => { order: index, })); - mockLoadTasks.mockResolvedValue(tasks); + mockAdapter.getTasks.mockResolvedValue(tasks); await repository.reorder(dateKey, 0, 2); - expect(mockLoadTasks).toHaveBeenCalledWith(dateKey); - expect(mockSaveTasks).toHaveBeenCalled(); - const savedCall = mockSaveTasks.mock.calls[0]; + expect(mockAdapter.getTasks).toHaveBeenCalledWith(dateKey); + expect(mockAdapter.putTasks).toHaveBeenCalled(); + const savedCall = mockAdapter.putTasks.mock.calls[0]; expect(savedCall[0]).toBe(dateKey); const savedTasks = savedCall[1]; @@ -173,13 +191,13 @@ describe("LocalTaskRepository", () => { const tasks = [...todoTasks, ...completedTasks]; - mockLoadTasks.mockResolvedValue(tasks); + mockAdapter.getTasks.mockResolvedValue(tasks); await repository.reorder(dateKey, 0, 1); - expect(mockLoadTasks).toHaveBeenCalledWith(dateKey); - expect(mockSaveTasks).toHaveBeenCalled(); - const savedCall = mockSaveTasks.mock.calls[0]; + expect(mockAdapter.getTasks).toHaveBeenCalledWith(dateKey); + expect(mockAdapter.putTasks).toHaveBeenCalled(); + const savedCall = mockAdapter.putTasks.mock.calls[0]; const savedTasks = savedCall[1]; // Todo tasks should have order 0, 1 @@ -208,11 +226,11 @@ describe("LocalTaskRepository", () => { order: index, })); - mockLoadTasks.mockResolvedValue(tasks); + mockAdapter.getTasks.mockResolvedValue(tasks); await repository.reorder(dateKey, 0, 1); - const savedCall = mockSaveTasks.mock.calls[0]; + const savedCall = mockAdapter.putTasks.mock.calls[0]; const savedTasks = savedCall[1]; // Task 2 should now be first @@ -222,6 +240,29 @@ describe("LocalTaskRepository", () => { expect(savedTasks[1]._id).toBe("task-1"); expect(savedTasks[1].order).toBe(1); }); + + it("should no-op when source index is out of bounds", async () => { + const dateKey = "2024-01-01"; + mockAdapter.getTasks.mockResolvedValue([ + createTestTask({ _id: "task-1", status: "todo", order: 0 }), + ]); + + await repository.reorder(dateKey, 5, 0); + + expect(mockAdapter.putTasks).not.toHaveBeenCalled(); + }); + + it("should no-op when source and destination indexes are equal", async () => { + const dateKey = "2024-01-01"; + mockAdapter.getTasks.mockResolvedValue([ + createTestTask({ _id: "task-1", status: "todo", order: 0 }), + createTestTask({ _id: "task-2", status: "todo", order: 1 }), + ]); + + await repository.reorder(dateKey, 1, 1); + + expect(mockAdapter.putTasks).not.toHaveBeenCalled(); + }); }); describe("move", () => { @@ -232,12 +273,12 @@ describe("LocalTaskRepository", () => { await repository.move(task, "2024-01-01", "2024-01-02"); - expect(mockMoveTask).toHaveBeenCalledWith( + expect(mockAdapter.moveTask).toHaveBeenCalledWith( task, "2024-01-01", "2024-01-02", ); - expect(mockMoveTask).toHaveBeenCalledTimes(1); + expect(mockAdapter.moveTask).toHaveBeenCalledTimes(1); }); }); }); diff --git a/packages/web/src/common/repositories/task/local.task.repository.ts b/packages/web/src/common/repositories/task/local.task.repository.ts index dbce4cb520..3019ece4d6 100644 --- a/packages/web/src/common/repositories/task/local.task.repository.ts +++ b/packages/web/src/common/repositories/task/local.task.repository.ts @@ -1,19 +1,32 @@ +import { getStorageAdapter } from "@web/common/storage/adapter/adapter"; import { Task } from "@web/common/types/task.types"; -import { - deleteTaskFromIndexedDB, - loadTasksFromIndexedDB, - moveTaskBetweenDates, - saveTasksToIndexedDB, -} from "@web/common/utils/storage/task.storage.util"; +import { dispatchTasksSavedEvent } from "@web/common/utils/storage/storage.util"; import { TaskRepository } from "./task.repository"; +/** + * Local task repository implementation using the storage adapter. + * + * This repository delegates all storage operations to the StorageAdapter, + * making it independent of the underlying storage technology. The adapter + * can be IndexedDB, SQLite, or any other implementation. + */ export class LocalTaskRepository implements TaskRepository { + private get adapter() { + return getStorageAdapter(); + } + async get(dateKey: string): Promise { - return loadTasksFromIndexedDB(dateKey); + return this.adapter.getTasks(dateKey); } - async save(dateKey: string, tasks: Task[]): Promise { - await saveTasksToIndexedDB(dateKey, tasks); + async save(dateKey: string, taskOrTasks: Task | Task[]): Promise { + const tasks = Array.isArray(taskOrTasks) ? taskOrTasks : [taskOrTasks]; + if (tasks.length === 1 && !Array.isArray(taskOrTasks)) { + await this.adapter.putTask(dateKey, tasks[0]); + } else { + await this.adapter.putTasks(dateKey, tasks); + } + dispatchTasksSavedEvent(dateKey); } async delete(dateKey: string, taskId: string): Promise { @@ -24,7 +37,7 @@ export class LocalTaskRepository implements TaskRepository { return; } - await deleteTaskFromIndexedDB(taskId); + await this.adapter.deleteTask(taskId); } async move( @@ -32,7 +45,7 @@ export class LocalTaskRepository implements TaskRepository { fromDateKey: string, toDateKey: string, ): Promise { - await moveTaskBetweenDates(task, fromDateKey, toDateKey); + await this.adapter.moveTask(task, fromDateKey, toDateKey); } async reorder( @@ -41,9 +54,27 @@ export class LocalTaskRepository implements TaskRepository { destinationIndex: number, ): Promise { const tasks = await this.get(dateKey); + if (tasks.length <= 1) { + return; + } + + const isSourceIndexValid = sourceIndex >= 0 && sourceIndex < tasks.length; + if (!isSourceIndexValid) { + return; + } + + const boundedDestinationIndex = Math.max( + 0, + Math.min(destinationIndex, tasks.length - 1), + ); + + if (sourceIndex === boundedDestinationIndex) { + return; + } + const newTasks = Array.from(tasks); const [moved] = newTasks.splice(sourceIndex, 1); - newTasks.splice(destinationIndex, 0, moved); + newTasks.splice(boundedDestinationIndex, 0, moved); // Update order for todo and completed tasks separately const todoTasks = newTasks.filter((t) => t.status === "todo"); diff --git a/packages/web/src/common/repositories/task/task.repository.ts b/packages/web/src/common/repositories/task/task.repository.ts index 85147cac3f..16b27e44e9 100644 --- a/packages/web/src/common/repositories/task/task.repository.ts +++ b/packages/web/src/common/repositories/task/task.repository.ts @@ -2,7 +2,7 @@ import { Task } from "@web/common/types/task.types"; export interface TaskRepository { get(dateKey: string): Promise; - save(dateKey: string, tasks: Task[]): Promise; + save(dateKey: string, taskOrTasks: Task | Task[]): Promise; delete(dateKey: string, taskId: string): Promise; move(task: Task, fromDateKey: string, toDateKey: string): Promise; reorder( diff --git a/packages/web/src/common/storage/adapter/adapter.test.ts b/packages/web/src/common/storage/adapter/adapter.test.ts new file mode 100644 index 0000000000..de4bc06cac --- /dev/null +++ b/packages/web/src/common/storage/adapter/adapter.test.ts @@ -0,0 +1,130 @@ +/** + * Tests for the storage adapter factory and initialization. + */ +import { + ensureStorageReady, + getStorageAdapter, + initializeStorage, + isStorageReady, + resetStorage, +} from "@web/common/storage/adapter/adapter"; +import { IndexedDBAdapter } from "@web/common/storage/adapter/indexeddb.adapter"; + +describe("storage adapter index", () => { + beforeEach(() => { + resetStorage(); + jest.clearAllMocks(); + }); + + afterEach(() => { + resetStorage(); + }); + + describe("getStorageAdapter", () => { + it("returns the same adapter instance on multiple calls", () => { + const adapter1 = getStorageAdapter(); + const adapter2 = getStorageAdapter(); + + expect(adapter1).toBe(adapter2); + }); + + it("returns a new adapter after resetStorage", () => { + const adapter1 = getStorageAdapter(); + resetStorage(); + const adapter2 = getStorageAdapter(); + + expect(adapter1).not.toBe(adapter2); + }); + }); + + describe("isStorageReady", () => { + it("returns false before initialization", () => { + expect(isStorageReady()).toBe(false); + }); + + it("returns true after initializeStorage", async () => { + await initializeStorage(); + + expect(isStorageReady()).toBe(true); + }); + + it("returns false after resetStorage", async () => { + await initializeStorage(); + resetStorage(); + + expect(isStorageReady()).toBe(false); + }); + }); + + describe("initializeStorage", () => { + it("initializes the adapter and runs migrations", async () => { + await initializeStorage(); + + expect(isStorageReady()).toBe(true); + }); + + it("returns same promise when called multiple times before resolve", async () => { + const [result1, result2] = await Promise.all([ + initializeStorage(), + initializeStorage(), + ]); + + expect(result1).toBeUndefined(); + expect(result2).toBeUndefined(); + expect(isStorageReady()).toBe(true); + }); + + it("does not re-initialize when already ready", async () => { + await initializeStorage(); + const adapterBefore = getStorageAdapter(); + await initializeStorage(); + const adapterAfter = getStorageAdapter(); + + expect(adapterBefore).toBe(adapterAfter); + }); + + it("allows retry after initialization failure", async () => { + const initializeSpy = jest + .spyOn(IndexedDBAdapter.prototype, "initialize") + .mockRejectedValueOnce(new Error("init failed")) + .mockResolvedValueOnce(undefined); + + try { + await expect(initializeStorage()).rejects.toThrow("init failed"); + await expect(initializeStorage()).resolves.toBeUndefined(); + + expect(initializeSpy).toHaveBeenCalledTimes(2); + } finally { + initializeSpy.mockRestore(); + } + }); + }); + + describe("ensureStorageReady", () => { + it("triggers initialization when storage is not ready", async () => { + await ensureStorageReady(); + + expect(isStorageReady()).toBe(true); + }); + + it("resolves when storage is already ready", async () => { + await initializeStorage(); + + await expect(ensureStorageReady()).resolves.toBeUndefined(); + expect(isStorageReady()).toBe(true); + }); + }); + + describe("resetStorage", () => { + it("clears adapter and init promise", async () => { + await initializeStorage(); + expect(isStorageReady()).toBe(true); + + resetStorage(); + + expect(isStorageReady()).toBe(false); + const newAdapter = getStorageAdapter(); + expect(newAdapter).toBeDefined(); + }); + }); +}); diff --git a/packages/web/src/common/storage/adapter/adapter.ts b/packages/web/src/common/storage/adapter/adapter.ts new file mode 100644 index 0000000000..f8a01db74e --- /dev/null +++ b/packages/web/src/common/storage/adapter/adapter.ts @@ -0,0 +1,82 @@ +import { IndexedDBAdapter } from "./indexeddb.adapter"; +import { StorageAdapter } from "./storage.adapter"; + +let adapter: StorageAdapter | null = null; +let initPromise: Promise | null = null; + +/** + * Get the storage adapter singleton. + * + * The adapter is lazily instantiated on first call. To switch storage + * implementations (e.g., IndexedDB → SQLite), change the instantiation + * here. + */ +export function getStorageAdapter(): StorageAdapter { + if (!adapter) { + // Switch this line to use a different adapter implementation + adapter = new IndexedDBAdapter(); + } + return adapter; +} + +/** + * Initialize storage and run all migrations. + * + * This should be called once at app startup. It: + * 1. Initializes the storage adapter (runs schema migrations internally) + * 2. Runs data migrations (storage-agnostic transformations) + * 3. Runs external migrations (imports from localStorage, etc.) + * + * Safe to call multiple times - subsequent calls return the same promise. + */ +export async function initializeStorage(): Promise { + if (initPromise) { + return initPromise; + } + + initPromise = (async () => { + const storage = getStorageAdapter(); + await storage.initialize(); + + // Import migrations dynamically to avoid circular dependencies + const { runAllMigrations } = await import("../migrations/migrations"); + await runAllMigrations(storage); + })().catch((error) => { + // Allow retry if initialization fails. + initPromise = null; + throw error; + }); + + return initPromise; +} + +/** + * Check if storage is ready for operations. + */ +export function isStorageReady(): boolean { + return adapter?.isReady() ?? false; +} + +/** + * Ensure storage is initialized before performing operations. + * If not initialized, triggers initialization. + */ +export async function ensureStorageReady(): Promise { + if (!isStorageReady()) { + await initializeStorage(); + } +} + +/** + * Reset storage state. Used for testing only. + */ +export function resetStorage(): void { + adapter = null; + initPromise = null; +} + +export type { + MigrationRecord, + StorageAdapter, + StoredTask, +} from "./storage.adapter"; diff --git a/packages/web/src/common/storage/adapter/indexeddb.adapter.test.ts b/packages/web/src/common/storage/adapter/indexeddb.adapter.test.ts new file mode 100644 index 0000000000..18d9bd05ef --- /dev/null +++ b/packages/web/src/common/storage/adapter/indexeddb.adapter.test.ts @@ -0,0 +1,293 @@ +/** + * Tests for the IndexedDB storage adapter. + * + * Uses fake-indexeddb (see web.test.start.ts) for in-memory IndexedDB support. + */ +import { Event_Core } from "@core/types/event.types"; +import { createMockTask } from "@web/__tests__/utils/factories/task.factory"; +import { createTestEvent } from "@web/__tests__/utils/repositories/repository.test.factory"; +import { clearCompassLocalDb } from "@web/__tests__/utils/storage/indexeddb.test.util"; +import { IndexedDBAdapter } from "./indexeddb.adapter"; + +describe("IndexedDBAdapter", () => { + let adapter: IndexedDBAdapter; + + beforeEach(async () => { + await clearCompassLocalDb(); + adapter = new IndexedDBAdapter(); + }); + + afterEach(async () => { + await clearCompassLocalDb(); + }); + + describe("initialize", () => { + it("opens the database and sets ready state", async () => { + await adapter.initialize(); + + expect(adapter.isReady()).toBe(true); + }); + + it("is idempotent when already initialized", async () => { + await adapter.initialize(); + await adapter.initialize(); + + expect(adapter.isReady()).toBe(true); + }); + }); + + describe("task operations", () => { + beforeEach(async () => { + await adapter.initialize(); + }); + + it("puts and gets tasks for a date", async () => { + const dateKey = "2025-01-15"; + const tasks = [ + createMockTask({ _id: "task-1", title: "Task 1" }), + createMockTask({ _id: "task-2", title: "Task 2" }), + ]; + + await adapter.putTasks(dateKey, tasks); + const result = await adapter.getTasks(dateKey); + + expect(result).toHaveLength(2); + expect(result.map((t) => t._id)).toEqual(["task-1", "task-2"]); + expect(result.map((t) => t.title)).toEqual(["Task 1", "Task 2"]); + }); + + it("putTask inserts new task", async () => { + const dateKey = "2025-01-15"; + const task = createMockTask({ _id: "task-1", title: "New task" }); + + await adapter.putTask(dateKey, task); + const result = await adapter.getTasks(dateKey); + + expect(result).toHaveLength(1); + expect(result[0]._id).toBe("task-1"); + expect(result[0].title).toBe("New task"); + }); + + it("putTask updates existing task", async () => { + const dateKey = "2025-01-15"; + await adapter.putTasks(dateKey, [ + createMockTask({ _id: "task-1", title: "Original" }), + ]); + + await adapter.putTask(dateKey, { + ...createMockTask({ _id: "task-1" }), + title: "Updated", + }); + const result = await adapter.getTasks(dateKey); + + expect(result).toHaveLength(1); + expect(result[0].title).toBe("Updated"); + }); + + it("putTask works alongside putTasks", async () => { + const dateKeyA = "2025-01-15"; + const dateKeyB = "2025-01-16"; + await adapter.putTasks(dateKeyA, [ + createMockTask({ _id: "task-1", title: "Date A" }), + ]); + + await adapter.putTask( + dateKeyB, + createMockTask({ _id: "task-2", title: "Date B" }), + ); + + const tasksA = await adapter.getTasks(dateKeyA); + const tasksB = await adapter.getTasks(dateKeyB); + + expect(tasksA).toHaveLength(1); + expect(tasksA[0].title).toBe("Date A"); + expect(tasksB).toHaveLength(1); + expect(tasksB[0].title).toBe("Date B"); + }); + + it("replaces tasks when putting for same date", async () => { + const dateKey = "2025-01-15"; + await adapter.putTasks(dateKey, [ + createMockTask({ _id: "task-1", title: "Original" }), + ]); + await adapter.putTasks(dateKey, [ + createMockTask({ _id: "task-2", title: "Replaced" }), + ]); + + const result = await adapter.getTasks(dateKey); + + expect(result).toHaveLength(1); + expect(result[0]._id).toBe("task-2"); + expect(result[0].title).toBe("Replaced"); + }); + + it("returns empty array for date with no tasks", async () => { + const result = await adapter.getTasks("2025-01-01"); + + expect(result).toEqual([]); + }); + + it("deletes a task by id", async () => { + const dateKey = "2025-01-15"; + await adapter.putTasks(dateKey, [ + createMockTask({ _id: "task-1" }), + createMockTask({ _id: "task-2" }), + ]); + + await adapter.deleteTask("task-1"); + + const result = await adapter.getTasks(dateKey); + expect(result).toHaveLength(1); + expect(result[0]._id).toBe("task-2"); + }); + + it("moves task between dates", async () => { + const task = createMockTask({ _id: "task-1", title: "Move me" }); + await adapter.putTasks("2025-01-15", [task]); + + await adapter.moveTask(task, "2025-01-15", "2025-01-16"); + + const fromDate = await adapter.getTasks("2025-01-15"); + const toDate = await adapter.getTasks("2025-01-16"); + + expect(fromDate).toHaveLength(0); + expect(toDate).toHaveLength(1); + expect(toDate[0]._id).toBe("task-1"); + }); + + it("clears all tasks", async () => { + await adapter.putTasks("2025-01-15", [createMockTask({ _id: "task-1" })]); + await adapter.putTasks("2025-01-16", [createMockTask({ _id: "task-2" })]); + + await adapter.clearAllTasks(); + + expect(await adapter.getTasks("2025-01-15")).toHaveLength(0); + expect(await adapter.getTasks("2025-01-16")).toHaveLength(0); + }); + }); + + describe("event operations", () => { + beforeEach(async () => { + await adapter.initialize(); + }); + + it("puts and gets events", async () => { + const event = createTestEvent({ + _id: "event-1", + title: "Test Event", + startDate: "2025-01-15", + endDate: "2025-01-15", + }); + + await adapter.putEvent(event); + const events = await adapter.getEvents("2025-01-15", "2025-01-15"); + + expect(events).toHaveLength(1); + expect(events[0]._id).toBe("event-1"); + expect(events[0].title).toBe("Test Event"); + }); + + it("filters events by date range", async () => { + await adapter.putEvent( + createTestEvent({ + _id: "e1", + startDate: "2025-01-15", + endDate: "2025-01-15", + }), + ); + await adapter.putEvent( + createTestEvent({ + _id: "e2", + startDate: "2025-01-20", + endDate: "2025-01-20", + }), + ); + + const events = await adapter.getEvents("2025-01-15", "2025-01-16"); + + expect(events).toHaveLength(1); + expect(events[0]._id).toBe("e1"); + }); + + it("filters by isSomeday when specified", async () => { + const somedayEvent = createTestEvent({ + _id: "e1", + startDate: "2025-01-15", + endDate: "2025-01-15", + isSomeday: true, + }); + const regularEvent = createTestEvent({ + _id: "e2", + startDate: "2025-01-15", + endDate: "2025-01-15", + isSomeday: false, + }); + await adapter.putEvent(somedayEvent); + await adapter.putEvent(regularEvent); + + const somedayEvents = await adapter.getEvents( + "2025-01-15", + "2025-01-15", + true, + ); + const regularEvents = await adapter.getEvents( + "2025-01-15", + "2025-01-15", + false, + ); + + expect(somedayEvents).toHaveLength(1); + expect(somedayEvents[0]._id).toBe("e1"); + expect(regularEvents).toHaveLength(1); + expect(regularEvents[0]._id).toBe("e2"); + }); + + it("throws when putting event without _id", async () => { + const event = createTestEvent({ title: "No ID" }) as Event_Core; + delete event._id; + + await expect(adapter.putEvent(event)).rejects.toThrow( + "Event must have an _id to save", + ); + }); + + it("deletes event by id", async () => { + const event = createTestEvent({ _id: "event-1" }); + await adapter.putEvent(event); + + await adapter.deleteEvent("event-1"); + + const events = await adapter.getAllEvents(); + expect(events).toHaveLength(0); + }); + + it("clears all events", async () => { + await adapter.putEvent(createTestEvent({ _id: "e1" })); + await adapter.putEvent(createTestEvent({ _id: "e2" })); + + await adapter.clearAllEvents(); + + expect(await adapter.getAllEvents()).toHaveLength(0); + }); + }); + + describe("migration tracking", () => { + beforeEach(async () => { + await adapter.initialize(); + }); + + it("records and retrieves migration records", async () => { + await adapter.setMigrationRecord("migration-1"); + await adapter.setMigrationRecord("migration-2"); + + const records = await adapter.getMigrationRecords(); + + expect(records).toHaveLength(2); + expect(records.map((r) => r.id).sort()).toEqual([ + "migration-1", + "migration-2", + ]); + expect(records[0].completedAt).toBeDefined(); + }); + }); +}); diff --git a/packages/web/src/common/storage/adapter/indexeddb.adapter.ts b/packages/web/src/common/storage/adapter/indexeddb.adapter.ts new file mode 100644 index 0000000000..57dcebcf34 --- /dev/null +++ b/packages/web/src/common/storage/adapter/indexeddb.adapter.ts @@ -0,0 +1,201 @@ +import Dexie, { Table } from "dexie"; +import { Event_Core } from "@core/types/event.types"; +import { isDateRangeOverlapping } from "@core/util/date/date.util"; +import { + Task, + normalizeTask, + normalizeTasks, +} from "@web/common/types/task.types"; +import { MigrationRecord, StorageAdapter, StoredTask } from "./storage.adapter"; + +/** + * Dexie database schema for Compass local storage. + * + * Schema versioning is handled by Dexie's built-in version() method. + */ +class CompassDB extends Dexie { + events!: Table; + tasks!: Table; + _migrations!: Table; + + constructor() { + super("compass-local"); + + // Version 1: events table only + this.version(1).stores({ + events: "_id, startDate, endDate, isSomeday", + }); + + // Version 2: add tasks table + this.version(2).stores({ + events: "_id, startDate, endDate, isSomeday", + tasks: "_id, dateKey, status, order", + }); + + // Version 3: add migrations tracking table + this.version(3).stores({ + events: "_id, startDate, endDate, isSomeday", + tasks: "_id, dateKey, status, order", + _migrations: "id", + }); + } +} + +/** + * IndexedDB implementation of StorageAdapter using Dexie. + * + * This adapter encapsulates all IndexedDB-specific logic, including: + * - Schema versioning via Dexie + * - Dexie transaction management + * - Query optimization using indexes + */ +export class IndexedDBAdapter implements StorageAdapter { + private db = new CompassDB(); + private initialized = false; + + async initialize(): Promise { + if (this.initialized && this.db.isOpen()) { + return; + } + + await this.db.open(); + this.initialized = true; + } + + isReady(): boolean { + return this.initialized && this.db.isOpen(); + } + + // ─── Task Operations ─────────────────────────────────────────────────────── + + async getTasks(dateKey: string): Promise { + const storedTasks = await this.db.tasks + .where("dateKey") + .equals(dateKey) + .toArray(); + + // Remove dateKey and normalize (ensures defaults like user are applied) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + return storedTasks.map(({ dateKey: _, ...task }) => normalizeTask(task)); + } + + async getAllTasks(): Promise { + return this.db.tasks.toArray(); + } + + async putTasks(dateKey: string, tasks: Task[]): Promise { + const storedTasks: StoredTask[] = normalizeTasks(tasks).map((task) => ({ + ...task, + dateKey, + })); + + await this.db.transaction("rw", this.db.tasks, async () => { + // Replace all tasks for this date atomically + await this.db.tasks.where("dateKey").equals(dateKey).delete(); + if (storedTasks.length > 0) { + await this.db.tasks.bulkPut(storedTasks); + } + }); + } + + async putTask(dateKey: string, task: Task): Promise { + const normalizedTask = normalizeTask(task); + const storedTask: StoredTask = { ...normalizedTask, dateKey }; + await this.db.tasks.put(storedTask); + } + + async deleteTask(taskId: string): Promise { + await this.db.tasks.delete(taskId); + } + + async moveTask( + task: Task, + fromDateKey: string, + toDateKey: string, + ): Promise { + const normalizedTask = normalizeTask(task); + + await this.db.transaction("rw", this.db.tasks, async () => { + const existingTask = await this.db.tasks.get(normalizedTask._id); + + // If the task exists for a different date, don't move it + if (existingTask && existingTask.dateKey !== fromDateKey) { + return; + } + + // Remove from source date (task id stays the same) + await this.db.tasks.delete(normalizedTask._id); + + // Add to target date + const storedTask: StoredTask = { ...normalizedTask, dateKey: toDateKey }; + await this.db.tasks.put(storedTask); + }); + } + + async clearAllTasks(): Promise { + await this.db.tasks.clear(); + } + + // ─── Event Operations ────────────────────────────────────────────────────── + + async getEvents( + startDate: string, + endDate: string, + isSomeday?: boolean, + ): Promise { + const allEvents = await this.db.events.toArray(); + + return allEvents.filter((event) => { + if (!event.startDate || !event.endDate) return false; + if (isSomeday !== undefined && event.isSomeday !== isSomeday) { + return false; + } + return isDateRangeOverlapping( + event.startDate, + event.endDate, + startDate, + endDate, + "day", + ); + }); + } + + async getAllEvents(): Promise { + return this.db.events.toArray(); + } + + async putEvent(event: Event_Core): Promise { + if (!event._id) { + throw new Error("Event must have an _id to save"); + } + await this.db.events.put(event); + } + + async putEvents(events: Event_Core[]): Promise { + const validEvents = events.filter((e) => e._id); + if (validEvents.length > 0) { + await this.db.events.bulkPut(validEvents); + } + } + + async deleteEvent(eventId: string): Promise { + await this.db.events.delete(eventId); + } + + async clearAllEvents(): Promise { + await this.db.events.clear(); + } + + // ─── Migration Tracking ──────────────────────────────────────────────────── + + async getMigrationRecords(): Promise { + return this.db._migrations.toArray(); + } + + async setMigrationRecord(id: string): Promise { + await this.db._migrations.put({ + id, + completedAt: new Date().toISOString(), + }); + } +} diff --git a/packages/web/src/common/storage/adapter/storage.adapter.ts b/packages/web/src/common/storage/adapter/storage.adapter.ts new file mode 100644 index 0000000000..e8fa380f14 --- /dev/null +++ b/packages/web/src/common/storage/adapter/storage.adapter.ts @@ -0,0 +1,128 @@ +import { Event_Core } from "@core/types/event.types"; +import { Task } from "@web/common/types/task.types"; + +/** + * Record of a completed migration. + */ +export interface MigrationRecord { + id: string; + completedAt: string; +} + +/** + * Task stored with its associated date key. + */ +export interface StoredTask extends Task { + dateKey: string; +} + +/** + * Abstract storage adapter interface. + * + * This interface defines storage operations independently of the underlying + * storage technology (IndexedDB, SQLite, etc.). Implementations handle + * storage-specific details like schema migrations internally. + * + * Benefits: + * - Storage-agnostic application code + * - Easy to swap implementations (IndexedDB → SQLite) + * - Testable via mock adapters + */ +export interface StorageAdapter { + /** + * Initialize storage and run internal schema migrations. + * Must be called before any other operations. + */ + initialize(): Promise; + + /** + * Check if storage has been initialized and is ready for operations. + */ + isReady(): boolean; + + // ─── Task Operations ─────────────────────────────────────────────────────── + + /** + * Get all tasks for a specific date. + */ + getTasks(dateKey: string): Promise; + + /** + * Get all tasks across all dates. + */ + getAllTasks(): Promise; + + /** + * Save tasks for a specific date, replacing any existing tasks for that date. + */ + putTasks(dateKey: string, tasks: Task[]): Promise; + + /** + * Save or update a single task for a specific date. + * Uses upsert semantics - inserts or updates by task _id. + */ + putTask(dateKey: string, task: Task): Promise; + + /** + * Delete a single task by ID. + */ + deleteTask(taskId: string): Promise; + + /** + * Move a task from one date to another. + */ + moveTask(task: Task, fromDateKey: string, toDateKey: string): Promise; + + /** + * Clear all tasks from storage. + */ + clearAllTasks(): Promise; + + // ─── Event Operations ────────────────────────────────────────────────────── + + /** + * Get events overlapping a date range. + */ + getEvents( + startDate: string, + endDate: string, + isSomeday?: boolean, + ): Promise; + + /** + * Get all events without filtering. + */ + getAllEvents(): Promise; + + /** + * Save or update a single event. + */ + putEvent(event: Event_Core): Promise; + + /** + * Save or update multiple events. + */ + putEvents(events: Event_Core[]): Promise; + + /** + * Delete an event by ID. + */ + deleteEvent(eventId: string): Promise; + + /** + * Clear all events from storage. + */ + clearAllEvents(): Promise; + + // ─── Migration Tracking ──────────────────────────────────────────────────── + + /** + * Get all completed migration records. + */ + getMigrationRecords(): Promise; + + /** + * Record a migration as completed. + */ + setMigrationRecord(id: string): Promise; +} diff --git a/packages/web/src/common/storage/migrations/external/localstorage-tasks.test.ts b/packages/web/src/common/storage/migrations/external/localstorage-tasks.test.ts new file mode 100644 index 0000000000..031a99adad --- /dev/null +++ b/packages/web/src/common/storage/migrations/external/localstorage-tasks.test.ts @@ -0,0 +1,221 @@ +/** + * Tests for the localStorage tasks migration. + */ +import { createMockTask } from "@web/__tests__/utils/factories/task.factory"; +import { StorageAdapter } from "@web/common/storage/adapter/storage.adapter"; +import { localStorageTasksMigration } from "./localstorage-tasks"; + +const TASK_KEY_PREFIX = "compass.today.tasks."; + +function createMockAdapter(): jest.Mocked { + const tasksByDate = new Map[]>(); + + return { + initialize: jest.fn().mockResolvedValue(undefined), + isReady: jest.fn().mockReturnValue(true), + getTasks: jest.fn().mockImplementation(async (dateKey: string) => { + return tasksByDate.get(dateKey) ?? []; + }), + getAllTasks: jest.fn().mockResolvedValue([]), + putTasks: jest.fn().mockImplementation(async (dateKey: string, tasks) => { + tasksByDate.set(dateKey, tasks); + }), + putTask: jest.fn().mockImplementation(async (dateKey: string, task) => { + const existing = tasksByDate.get(dateKey) ?? []; + const index = existing.findIndex((t) => t._id === task._id); + const updated = + index >= 0 + ? existing.map((t, i) => (i === index ? task : t)) + : [...existing, task]; + tasksByDate.set(dateKey, updated); + }), + deleteTask: jest.fn().mockResolvedValue(undefined), + moveTask: jest.fn().mockResolvedValue(undefined), + clearAllTasks: jest.fn().mockResolvedValue(undefined), + getEvents: jest.fn().mockResolvedValue([]), + getAllEvents: jest.fn().mockResolvedValue([]), + putEvent: jest.fn().mockResolvedValue(undefined), + putEvents: jest.fn().mockResolvedValue(undefined), + deleteEvent: jest.fn().mockResolvedValue(undefined), + clearAllEvents: jest.fn().mockResolvedValue(undefined), + getMigrationRecords: jest.fn().mockResolvedValue([]), + setMigrationRecord: jest.fn().mockResolvedValue(undefined), + }; +} + +describe("localStorageTasksMigration", () => { + beforeEach(() => { + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key?.startsWith(TASK_KEY_PREFIX)) { + localStorage.removeItem(key); + } + } + jest.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("skips when no task keys exist in localStorage", async () => { + const adapter = createMockAdapter(); + + await localStorageTasksMigration.migrate(adapter); + + expect(adapter.getTasks).not.toHaveBeenCalled(); + expect(adapter.putTasks).not.toHaveBeenCalled(); + }); + + it("imports tasks from localStorage to adapter", async () => { + const task = createMockTask({ _id: "task-1", title: "Test Task" }); + const dateKey = "2025-01-15"; + localStorage.setItem( + `${TASK_KEY_PREFIX}${dateKey}`, + JSON.stringify([task]), + ); + + const adapter = createMockAdapter(); + adapter.getTasks.mockResolvedValue([]); + + await localStorageTasksMigration.migrate(adapter); + + expect(adapter.getTasks).toHaveBeenCalledWith(dateKey); + expect(adapter.putTasks).toHaveBeenCalledWith(dateKey, [task]); + }); + + it("removes successfully migrated keys from localStorage", async () => { + const task = createMockTask({ _id: "task-1" }); + const dateKey = "2025-01-15"; + const storageKey = `${TASK_KEY_PREFIX}${dateKey}`; + localStorage.setItem(storageKey, JSON.stringify([task])); + + const adapter = createMockAdapter(); + adapter.getTasks.mockResolvedValue([]); + + await localStorageTasksMigration.migrate(adapter); + + expect(localStorage.getItem(storageKey)).toBeNull(); + }); + + it("maps legacy id field to _id", async () => { + const legacyTask = { + id: "legacy-task-1", + title: "Legacy Task", + status: "todo", + order: 0, + createdAt: "2025-01-15T00:00:00.000Z", + user: "user-1", + }; + const dateKey = "2025-01-15"; + localStorage.setItem( + `${TASK_KEY_PREFIX}${dateKey}`, + JSON.stringify([legacyTask]), + ); + + const adapter = createMockAdapter(); + adapter.getTasks.mockResolvedValue([]); + + await localStorageTasksMigration.migrate(adapter); + + expect(adapter.putTasks).toHaveBeenCalledWith( + dateKey, + expect.arrayContaining([ + expect.objectContaining({ _id: "legacy-task-1", title: "Legacy Task" }), + ]), + ); + }); + + it("does not duplicate tasks that already exist in adapter", async () => { + const task = createMockTask({ _id: "task-1", title: "Existing" }); + const dateKey = "2025-01-15"; + localStorage.setItem( + `${TASK_KEY_PREFIX}${dateKey}`, + JSON.stringify([task]), + ); + + const adapter = createMockAdapter(); + adapter.getTasks.mockResolvedValue([task]); + + await localStorageTasksMigration.migrate(adapter); + + expect(adapter.putTasks).not.toHaveBeenCalled(); + }); + + it("merges new tasks with existing tasks for same date", async () => { + const existingTask = createMockTask({ _id: "task-1", title: "Existing" }); + const newTask = createMockTask({ _id: "task-2", title: "New" }); + const dateKey = "2025-01-15"; + localStorage.setItem( + `${TASK_KEY_PREFIX}${dateKey}`, + JSON.stringify([newTask]), + ); + + const adapter = createMockAdapter(); + adapter.getTasks.mockResolvedValue([existingTask]); + + await localStorageTasksMigration.migrate(adapter); + + expect(adapter.putTasks).toHaveBeenCalledWith(dateKey, [ + existingTask, + newTask, + ]); + }); + + it("skips invalid JSON entries and keeps them for retry", async () => { + const validTask = createMockTask({ _id: "task-1" }); + const dateKey = "2025-01-15"; + const validKey = `${TASK_KEY_PREFIX}${dateKey}`; + const invalidKey = `${TASK_KEY_PREFIX}2025-01-16`; + + localStorage.setItem(validKey, JSON.stringify([validTask])); + localStorage.setItem(invalidKey, "invalid json {{{"); + + const adapter = createMockAdapter(); + adapter.getTasks.mockResolvedValue([]); + + await localStorageTasksMigration.migrate(adapter); + + expect(localStorage.getItem(validKey)).toBeNull(); + expect(localStorage.getItem(invalidKey)).toBe("invalid json {{{"); + }); + + it("skips non-array parsed values and does not remove key", async () => { + const dateKey = "2025-01-15"; + const storageKey = `${TASK_KEY_PREFIX}${dateKey}`; + localStorage.setItem(storageKey, JSON.stringify("not an array")); + + const adapter = createMockAdapter(); + + await localStorageTasksMigration.migrate(adapter); + + expect(adapter.putTasks).not.toHaveBeenCalled(); + expect(localStorage.getItem(storageKey)).toBeTruthy(); + }); + + it("reports incomplete when task keys remain in localStorage", async () => { + const dateKey = "2025-01-15"; + localStorage.setItem( + `${TASK_KEY_PREFIX}${dateKey}`, + JSON.stringify("not an array"), + ); + + expect(localStorageTasksMigration.isComplete?.()).toBe(false); + }); + + it("reports complete after all task keys are migrated", async () => { + const task = createMockTask({ _id: "task-1", title: "Test Task" }); + const dateKey = "2025-01-15"; + localStorage.setItem( + `${TASK_KEY_PREFIX}${dateKey}`, + JSON.stringify([task]), + ); + + const adapter = createMockAdapter(); + adapter.getTasks.mockResolvedValue([]); + + await localStorageTasksMigration.migrate(adapter); + + expect(localStorageTasksMigration.isComplete?.()).toBe(true); + }); +}); diff --git a/packages/web/src/common/storage/migrations/external/localstorage-tasks.ts b/packages/web/src/common/storage/migrations/external/localstorage-tasks.ts new file mode 100644 index 0000000000..02cc5338db --- /dev/null +++ b/packages/web/src/common/storage/migrations/external/localstorage-tasks.ts @@ -0,0 +1,138 @@ +import { Task, isTask } from "@web/common/types/task.types"; +import { StorageAdapter } from "../../adapter/storage.adapter"; +import { ExternalMigration } from "../migration.types"; + +const TASK_KEY_PREFIX = "compass.today.tasks."; + +/** + * Normalize a task, handling legacy format with 'id' instead of '_id'. + */ +function normalizeTaskWithLegacyId(item: unknown): Task | null { + // Already valid task + if (isTask(item)) { + return item; + } + + // Check if it's an object we can work with + if (!item || typeof item !== "object") { + return null; + } + + // Check for legacy 'id' field + const legacy = item as Record; + if (typeof legacy.id !== "string") { + return null; + } + + // Map 'id' to '_id' + const { id, ...rest } = legacy; + const mapped = { ...rest, _id: id }; + + return isTask(mapped) ? mapped : null; +} + +/** + * Get all localStorage keys that contain task data. + */ +function getTaskStorageKeys(): string[] { + const keys: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key?.startsWith(TASK_KEY_PREFIX)) { + keys.push(key); + } + } + return keys; +} + +/** + * Migration to import tasks from localStorage to the storage adapter. + * + * This handles the transition from the original localStorage-based task + * storage to IndexedDB. It: + * + * 1. Finds all task entries in localStorage (compass.today.tasks.YYYY-MM-DD) + * 2. Parses and validates each task (including legacy 'id' → '_id' mapping) + * 3. Saves valid tasks to the storage adapter + * 4. Removes successfully migrated entries from localStorage + * + * Partial failures are handled gracefully - only successfully migrated + * entries are removed from localStorage, allowing retry on next startup. + */ +export const localStorageTasksMigration: ExternalMigration = { + id: "localstorage-tasks-v1", + description: "Migrate tasks from localStorage to storage adapter", + + async migrate(adapter: StorageAdapter): Promise { + // Skip when localStorage is unavailable + if (typeof localStorage === "undefined") { + return; + } + + const keys = getTaskStorageKeys(); + if (keys.length === 0) { + return; + } + + const keysToRemove: string[] = []; + let totalMigrated = 0; + + for (const key of keys) { + const dateKey = key.replace(TASK_KEY_PREFIX, ""); + const raw = localStorage.getItem(key); + + if (!raw) { + continue; + } + + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + continue; + } + + const tasks = parsed + .map(normalizeTaskWithLegacyId) + .filter((t): t is Task => t !== null); + + if (tasks.length > 0) { + // Get existing tasks for this date to merge + const existingTasks = await adapter.getTasks(dateKey); + const existingIds = new Set(existingTasks.map((t) => t._id)); + + // Only add tasks that don't already exist + const newTasks = tasks.filter((t) => !existingIds.has(t._id)); + + if (newTasks.length > 0) { + await adapter.putTasks(dateKey, [...existingTasks, ...newTasks]); + totalMigrated += newTasks.length; + } + } + + keysToRemove.push(key); + } catch { + // Skip invalid entries - don't remove from localStorage for retry + console.error(`[Migration] Failed to parse tasks from: ${key}`); + } + } + + // Remove successfully migrated entries from localStorage + for (const key of keysToRemove) { + localStorage.removeItem(key); + } + + if (totalMigrated > 0) { + console.log( + `[Migration] Migrated ${totalMigrated} tasks from localStorage`, + ); + } + }, + + isComplete(): boolean { + if (typeof localStorage === "undefined") { + return true; + } + + return getTaskStorageKeys().length === 0; + }, +}; diff --git a/packages/web/src/common/storage/migrations/migration.types.ts b/packages/web/src/common/storage/migrations/migration.types.ts new file mode 100644 index 0000000000..ecdc224362 --- /dev/null +++ b/packages/web/src/common/storage/migrations/migration.types.ts @@ -0,0 +1,76 @@ +import { StorageAdapter } from "../adapter/storage.adapter"; + +/** + * Data migration - transforms existing data within storage. + * + * Data migrations work through the abstract StorageAdapter interface, + * making them storage-agnostic. They're tracked in the storage's + * _migrations table and only run once. + * + * Use cases: + * - Renaming fields + * - Computing derived values + * - Restructuring data + * + * Example: + * ```typescript + * const addUserIdMigration: DataMigration = { + * id: "add-user-id-v1", + * description: "Add user field to tasks missing it", + * async migrate(adapter) { + * const tasks = await adapter.getAllTasks(); + * // transform and save tasks... + * } + * }; + * ``` + */ +export interface DataMigration { + /** Unique identifier for this migration */ + id: string; + /** Human-readable description */ + description: string; + /** Migration function that transforms data */ + migrate: (adapter: StorageAdapter) => Promise; +} + +/** + * External migration - imports data from external sources. + * + * External migrations import data from sources outside the storage + * adapter (localStorage, files, APIs, etc.). They're tracked via + * localStorage flags since the source may not be the storage adapter. + * + * Use cases: + * - Migrating from localStorage to IndexedDB + * - Importing from legacy storage formats + * - One-time data imports + * + * Example: + * ```typescript + * const importSettingsMigration: ExternalMigration = { + * id: "import-settings-v1", + * description: "Import user settings from localStorage", + * async migrate(adapter) { + * const settings = localStorage.getItem("settings"); + * if (settings) { + * await adapter.putSettings(JSON.parse(settings)); + * } + * } + * }; + * ``` + */ +export interface ExternalMigration { + /** Unique identifier for this migration */ + id: string; + /** Human-readable description */ + description: string; + /** Migration function that imports external data */ + migrate: (adapter: StorageAdapter) => Promise; + /** + * Optional completion check. + * + * When provided, migration completion is only recorded if this returns true. + * Useful for partial migrations that should retry on next startup. + */ + isComplete?: () => boolean | Promise; +} diff --git a/packages/web/src/common/storage/migrations/migrations.test.ts b/packages/web/src/common/storage/migrations/migrations.test.ts new file mode 100644 index 0000000000..58e5f24723 --- /dev/null +++ b/packages/web/src/common/storage/migrations/migrations.test.ts @@ -0,0 +1,115 @@ +/** + * Tests for the migration runners. + */ +import { StorageAdapter } from "@web/common/storage/adapter/storage.adapter"; +import { + runAllMigrations, + runDataMigrations, + runExternalMigrations, +} from "@web/common/storage/migrations/migrations"; + +function createMockAdapter(): jest.Mocked { + return { + initialize: jest.fn().mockResolvedValue(undefined), + isReady: jest.fn().mockReturnValue(true), + getTasks: jest.fn().mockResolvedValue([]), + getAllTasks: jest.fn().mockResolvedValue([]), + putTasks: jest.fn().mockResolvedValue(undefined), + putTask: jest.fn().mockResolvedValue(undefined), + deleteTask: jest.fn().mockResolvedValue(undefined), + moveTask: jest.fn().mockResolvedValue(undefined), + clearAllTasks: jest.fn().mockResolvedValue(undefined), + getEvents: jest.fn().mockResolvedValue([]), + getAllEvents: jest.fn().mockResolvedValue([]), + putEvent: jest.fn().mockResolvedValue(undefined), + putEvents: jest.fn().mockResolvedValue(undefined), + deleteEvent: jest.fn().mockResolvedValue(undefined), + clearAllEvents: jest.fn().mockResolvedValue(undefined), + getMigrationRecords: jest.fn().mockResolvedValue([]), + setMigrationRecord: jest.fn().mockResolvedValue(undefined), + }; +} + +describe("storage migrations", () => { + const externalMigrationFlagKey = "compass.migration.localstorage-tasks-v1"; + const taskStoragePrefix = "compass.today.tasks."; + + function clearTaskStorageKeys(): void { + const keysToRemove: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key?.startsWith(taskStoragePrefix)) { + keysToRemove.push(key); + } + } + keysToRemove.forEach((key) => localStorage.removeItem(key)); + } + + beforeEach(() => { + localStorage.removeItem(externalMigrationFlagKey); + clearTaskStorageKeys(); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + localStorage.removeItem(externalMigrationFlagKey); + clearTaskStorageKeys(); + jest.restoreAllMocks(); + }); + + describe("runDataMigrations", () => { + it("returns early when dataMigrations array is empty", async () => { + const adapter = createMockAdapter(); + + await runDataMigrations(adapter); + + expect(adapter.getMigrationRecords).not.toHaveBeenCalled(); + }); + }); + + describe("runExternalMigrations", () => { + it("skips migration when localStorage flag is already set", async () => { + localStorage.setItem(externalMigrationFlagKey, "completed"); + + const adapter = createMockAdapter(); + + await runExternalMigrations(adapter); + + expect(adapter.putTasks).not.toHaveBeenCalled(); + }); + + it("runs migration and sets flag when not previously completed", async () => { + const adapter = createMockAdapter(); + adapter.getTasks.mockResolvedValue([]); + adapter.putTasks.mockResolvedValue(undefined); + + await runExternalMigrations(adapter); + + expect(localStorage.getItem(externalMigrationFlagKey)).toBe("completed"); + }); + + it("does not throw when migration fails (non-blocking)", async () => { + localStorage.setItem( + "compass.today.tasks.2025-01-01", + "invalid json {{{", + ); + + const adapter = createMockAdapter(); + + await expect(runExternalMigrations(adapter)).resolves.not.toThrow(); + expect(localStorage.getItem(externalMigrationFlagKey)).toBeNull(); + }); + }); + + describe("runAllMigrations", () => { + it("runs data then external migrations without error", async () => { + const adapter = createMockAdapter(); + adapter.getTasks.mockResolvedValue([]); + + await expect(runAllMigrations(adapter)).resolves.toBeUndefined(); + expect(localStorage.getItem(externalMigrationFlagKey)).toBe("completed"); + }); + }); +}); diff --git a/packages/web/src/common/storage/migrations/migrations.ts b/packages/web/src/common/storage/migrations/migrations.ts new file mode 100644 index 0000000000..9444e42989 --- /dev/null +++ b/packages/web/src/common/storage/migrations/migrations.ts @@ -0,0 +1,117 @@ +import { StorageAdapter } from "../adapter/storage.adapter"; +import { localStorageTasksMigration } from "./external/localstorage-tasks"; +import { DataMigration, ExternalMigration } from "./migration.types"; + +// ─── Migration Registry ────────────────────────────────────────────────────── +// Add new migrations to these arrays. They run in order. + +/** + * Data migrations transform existing data within storage. + * Tracked via the storage adapter's migration records. + */ +export const dataMigrations: DataMigration[] = [ + // Add data migrations here as needed. Examples: + // addUserFieldMigration, + // renameStatusFieldMigration, +]; + +/** + * External migrations import data from outside storage. + * Tracked via localStorage flags. + */ +export const externalMigrations: ExternalMigration[] = [ + localStorageTasksMigration, +]; + +// ─── Migration Runners ─────────────────────────────────────────────────────── + +/** + * Run all pending data migrations. + * + * Data migrations are tracked in the storage adapter's _migrations table. + * Each migration only runs once - its ID is recorded after completion. + */ +export async function runDataMigrations( + adapter: StorageAdapter, +): Promise { + if (dataMigrations.length === 0) return; + + const completedRecords = await adapter.getMigrationRecords(); + const completedIds = new Set(completedRecords.map((r) => r.id)); + + for (const migration of dataMigrations) { + if (completedIds.has(migration.id)) { + continue; + } + + console.log(`[Migration] Running data migration: ${migration.id}`); + try { + await migration.migrate(adapter); + await adapter.setMigrationRecord(migration.id); + console.log(`[Migration] Completed: ${migration.id}`); + } catch (error) { + console.error(`[Migration] Failed: ${migration.id}`, error); + throw error; // Data migrations are critical - fail fast + } + } +} + +/** + * Run all pending external migrations. + * + * External migrations are tracked via localStorage flags since they + * import data from sources outside the storage adapter. Failures are + * logged but don't block app startup - data stays in source for retry. + */ +export async function runExternalMigrations( + adapter: StorageAdapter, +): Promise { + if (typeof localStorage === "undefined") { + return; + } + + for (const migration of externalMigrations) { + const flagKey = `compass.migration.${migration.id}`; + + if (localStorage.getItem(flagKey) === "completed") { + continue; + } + + console.log(`[Migration] Running external migration: ${migration.id}`); + try { + await migration.migrate(adapter); + const isComplete = migration.isComplete + ? await migration.isComplete() + : true; + + if (!isComplete) { + console.warn(`[Migration] Incomplete (will retry): ${migration.id}`); + continue; + } + + localStorage.setItem(flagKey, "completed"); + console.log(`[Migration] Completed: ${migration.id}`); + } catch (error) { + // External migrations are non-blocking - data stays in source for retry + console.error( + `[Migration] Failed (non-blocking): ${migration.id}`, + error, + ); + } + } +} + +/** + * Run all migrations in the correct order. + * + * Order: + * 1. Data migrations (storage-agnostic transformations) + * 2. External migrations (imports from localStorage, etc.) + */ +export async function runAllMigrations(adapter: StorageAdapter): Promise { + await runDataMigrations(adapter); + await runExternalMigrations(adapter); +} + +// Re-export types for convenience +export type { DataMigration, ExternalMigration } from "./migration.types"; diff --git a/packages/web/src/common/utils/app-init.util.test.ts b/packages/web/src/common/utils/app-init.util.test.ts index f6544ebc54..147dbf4d7b 100644 --- a/packages/web/src/common/utils/app-init.util.test.ts +++ b/packages/web/src/common/utils/app-init.util.test.ts @@ -1,30 +1,14 @@ import { toast } from "react-toastify"; -import { - DatabaseInitError, - initializeDatabase, -} from "@web/common/utils/storage/db-init.util"; -import { migrateTasksFromLocalStorageToIndexedDB } from "@web/common/utils/storage/task-migration.util"; +import { initializeStorage } from "@web/common/storage/adapter/adapter"; +import { DatabaseInitError } from "@web/common/utils/storage/db-errors.util"; import { initializeDatabaseWithErrorHandling, showDbInitErrorToast, } from "./app-init.util"; -// Mock the db-init module -jest.mock("@web/common/utils/storage/db-init.util", () => { - const actual = jest.requireActual("@web/common/utils/storage/db-init.util"); - const { DatabaseInitError } = jest.requireActual( - "@web/common/utils/storage/db-errors.util", - ); - return { - ...actual, - DatabaseInitError, - initializeDatabase: jest.fn(), - }; -}); - -// Mock the task-migration module -jest.mock("@web/common/utils/storage/task-migration.util", () => ({ - migrateTasksFromLocalStorageToIndexedDB: jest.fn(), +// Mock the storage adapter +jest.mock("@web/common/storage/adapter/adapter", () => ({ + initializeStorage: jest.fn(), })); // Mock react-toastify @@ -34,14 +18,12 @@ jest.mock("react-toastify", () => ({ }, })); -const mockInitializeDatabase = initializeDatabase as jest.Mock; -const mockMigrateTasks = migrateTasksFromLocalStorageToIndexedDB as jest.Mock; +const mockInitializeStorage = initializeStorage as jest.Mock; describe("app-init.util", () => { beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); - mockMigrateTasks.mockResolvedValue(0); }); afterEach(() => { @@ -49,28 +31,18 @@ describe("app-init.util", () => { }); describe("initializeDatabaseWithErrorHandling", () => { - it("should return null error when database initializes successfully", async () => { - mockInitializeDatabase.mockResolvedValue(undefined); + it("should return null error when storage initializes successfully", async () => { + mockInitializeStorage.mockResolvedValue(undefined); const result = await initializeDatabaseWithErrorHandling(); expect(result.dbInitError).toBeNull(); - expect(mockInitializeDatabase).toHaveBeenCalledTimes(1); - }); - - it("should migrate tasks after database initializes", async () => { - mockInitializeDatabase.mockResolvedValue(undefined); - mockMigrateTasks.mockResolvedValue(5); - - const result = await initializeDatabaseWithErrorHandling(); - - expect(mockMigrateTasks).toHaveBeenCalledTimes(1); - expect(result.tasksMigrated).toBe(5); + expect(mockInitializeStorage).toHaveBeenCalledTimes(1); }); it("should catch DatabaseInitError and return it", async () => { const dbError = new DatabaseInitError("Storage quota exceeded"); - mockInitializeDatabase.mockRejectedValue(dbError); + mockInitializeStorage.mockRejectedValue(dbError); const result = await initializeDatabaseWithErrorHandling(); @@ -80,32 +52,22 @@ describe("app-init.util", () => { it("should ignore non-DatabaseInitError errors and return null", async () => { const genericError = new Error("Some other error"); - mockInitializeDatabase.mockRejectedValue(genericError); + mockInitializeStorage.mockRejectedValue(genericError); const result = await initializeDatabaseWithErrorHandling(); expect(result.dbInitError).toBeNull(); }); - it("should not throw when database initialization fails", async () => { + it("should not throw when storage initialization fails", async () => { const dbError = new DatabaseInitError("Database version mismatch"); - mockInitializeDatabase.mockRejectedValue(dbError); + mockInitializeStorage.mockRejectedValue(dbError); // Should not throw - just return the error await expect( initializeDatabaseWithErrorHandling(), ).resolves.not.toThrow(); }); - - it("should not migrate tasks when database initialization fails", async () => { - const dbError = new DatabaseInitError("Database version mismatch"); - mockInitializeDatabase.mockRejectedValue(dbError); - - const result = await initializeDatabaseWithErrorHandling(); - - expect(mockMigrateTasks).not.toHaveBeenCalled(); - expect(result.tasksMigrated).toBe(0); - }); }); describe("showDbInitErrorToast", () => { @@ -160,7 +122,7 @@ describe("app-init.util", () => { const dbError = new DatabaseInitError( "Failed to initialize IndexedDB after 3 attempts", ); - mockInitializeDatabase.mockRejectedValue(dbError); + mockInitializeStorage.mockRejectedValue(dbError); // Simulate what index.tsx does const { dbInitError } = await initializeDatabaseWithErrorHandling(); @@ -182,14 +144,11 @@ describe("app-init.util", () => { }); it("should handle full initialization flow without error", async () => { - mockInitializeDatabase.mockResolvedValue(undefined); - mockMigrateTasks.mockResolvedValue(3); + mockInitializeStorage.mockResolvedValue(undefined); - const { dbInitError, tasksMigrated } = - await initializeDatabaseWithErrorHandling(); + const { dbInitError } = await initializeDatabaseWithErrorHandling(); expect(dbInitError).toBeNull(); - expect(tasksMigrated).toBe(3); // No toast should be shown jest.advanceTimersByTime(100); diff --git a/packages/web/src/common/utils/app-init.util.ts b/packages/web/src/common/utils/app-init.util.ts index c15df16fdb..bd348d89fd 100644 --- a/packages/web/src/common/utils/app-init.util.ts +++ b/packages/web/src/common/utils/app-init.util.ts @@ -1,26 +1,26 @@ import { toast } from "react-toastify"; -import { initializeDatabase } from "@web/common/utils/storage/db-init.util"; -import { migrateTasksFromLocalStorageToIndexedDB } from "@web/common/utils/storage/task-migration.util"; +import { initializeStorage } from "@web/common/storage/adapter/adapter"; import { DatabaseInitError } from "./storage/db-errors.util"; export interface AppInitResult { dbInitError: DatabaseInitError | null; - tasksMigrated: number; } /** - * Initialize the database for the application. + * Initialize storage for the application. + * + * This: + * 1. Initializes the storage adapter (IndexedDB with Dexie schema migrations) + * 2. Runs data migrations (storage-agnostic transformations) + * 3. Runs external migrations (imports from localStorage, etc.) + * * Returns any initialization error so the caller can handle it appropriately. */ export async function initializeDatabaseWithErrorHandling(): Promise { let dbInitError: DatabaseInitError | null = null; - let tasksMigrated = 0; try { - await initializeDatabase(); - - // After database is ready, migrate tasks from localStorage to IndexedDB - tasksMigrated = await migrateTasksFromLocalStorageToIndexedDB(); + await initializeStorage(); } catch (error) { if (error instanceof DatabaseInitError) { dbInitError = error; @@ -29,7 +29,7 @@ export async function initializeDatabaseWithErrorHandling(): Promise; // string is the key type (_id) - tasks!: Table; // string is the key type (id) - - constructor() { - super("compass-local"); - - // Version 1: events table only - this.version(1).stores({ - events: "_id, startDate, endDate, isSomeday", - }); - - // Version 2: add tasks table - this.version(2).stores({ - events: "_id, startDate, endDate, isSomeday", - tasks: "_id, dateKey, status, order", - }); - } -} - -export const compassLocalDB = new CompassLocalDB(); diff --git a/packages/web/src/common/utils/storage/db-init.util.test.ts b/packages/web/src/common/utils/storage/db-init.util.test.ts deleted file mode 100644 index d7f82f323e..0000000000 --- a/packages/web/src/common/utils/storage/db-init.util.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -import Dexie from "dexie"; -import { compassLocalDB } from "./compass-local.db"; -import { - DatabaseInitError, - ensureDatabaseReady, - initializeDatabase, - isDatabaseReady, - resetDatabaseInitialization, -} from "./db-init.util"; - -// Mock the compassLocalDB -jest.mock("./compass-local.db", () => ({ - compassLocalDB: { - open: jest.fn(), - isOpen: jest.fn(), - name: "compass-local", - verno: 1, - tables: [{ name: "events" }], - }, -})); - -describe("db-init.util", () => { - beforeEach(() => { - // Reset initialization state before each test - resetDatabaseInitialization(); - jest.clearAllMocks(); - }); - - afterEach(() => { - resetDatabaseInitialization(); - jest.clearAllMocks(); - }); - - describe("initializeDatabase", () => { - it("should successfully initialize the database", async () => { - (compassLocalDB.open as jest.Mock).mockResolvedValue(undefined); - (compassLocalDB.isOpen as jest.Mock).mockReturnValue(true); - - await initializeDatabase(); - - expect(compassLocalDB.open).toHaveBeenCalled(); - expect(compassLocalDB.isOpen).toHaveBeenCalled(); - }); - - it("should only initialize once when called multiple times concurrently", async () => { - (compassLocalDB.open as jest.Mock).mockResolvedValue(undefined); - (compassLocalDB.isOpen as jest.Mock).mockReturnValue(true); - - // Call multiple times concurrently - await Promise.all([ - initializeDatabase(), - initializeDatabase(), - initializeDatabase(), - ]); - - // Should only call open once - expect(compassLocalDB.open).toHaveBeenCalledTimes(1); - }); - - it("should return immediately if already initialized", async () => { - (compassLocalDB.open as jest.Mock).mockResolvedValue(undefined); - (compassLocalDB.isOpen as jest.Mock).mockReturnValue(true); - - // Initialize once - await initializeDatabase(); - expect(compassLocalDB.open).toHaveBeenCalledTimes(1); - - // Call again - await initializeDatabase(); - - // Should not call open again - expect(compassLocalDB.open).toHaveBeenCalledTimes(1); - }); - - it("should throw DatabaseInitError if database fails to open", async () => { - (compassLocalDB.open as jest.Mock).mockResolvedValue(undefined); - (compassLocalDB.isOpen as jest.Mock).mockReturnValue(false); - - const promise = initializeDatabase(); - - await expect(promise).rejects.toThrow(DatabaseInitError); - await expect(promise).rejects.toThrow( - "Failed to initialize IndexedDB after 3 attempts", - ); - }); - - it("should retry on transient errors", async () => { - (compassLocalDB.open as jest.Mock) - .mockRejectedValueOnce(new Error("Transient error")) - .mockResolvedValueOnce(undefined); - (compassLocalDB.isOpen as jest.Mock).mockReturnValue(true); - - await initializeDatabase(); - - // Should have retried and succeeded - expect(compassLocalDB.open).toHaveBeenCalledTimes(2); - }); - - it("should throw DatabaseInitError after max retries", async () => { - (compassLocalDB.open as jest.Mock).mockRejectedValue( - new Error("Persistent error"), - ); - - const promise = initializeDatabase(); - - await expect(promise).rejects.toThrow(DatabaseInitError); - await expect(promise).rejects.toThrow( - "Failed to initialize IndexedDB after 3 attempts", - ); - - // Should have retried 3 times - expect(compassLocalDB.open).toHaveBeenCalledTimes(3); - }); - - it("should handle QuotaExceededError", async () => { - const quotaError = new Dexie.QuotaExceededError(); - (compassLocalDB.open as jest.Mock).mockRejectedValue(quotaError); - - const promise = initializeDatabase(); - - await expect(promise).rejects.toThrow(DatabaseInitError); - await expect(promise).rejects.toThrow("Storage quota exceeded"); - - // Should not retry on quota errors - expect(compassLocalDB.open).toHaveBeenCalledTimes(1); - }); - - it("should handle VersionError", async () => { - const versionError = new Dexie.VersionError(); - (compassLocalDB.open as jest.Mock).mockRejectedValue(versionError); - - const promise = initializeDatabase(); - - await expect(promise).rejects.toThrow(DatabaseInitError); - await expect(promise).rejects.toThrow("Database version mismatch"); - - // Should not retry on version errors - expect(compassLocalDB.open).toHaveBeenCalledTimes(1); - }); - }); - - describe("isDatabaseReady", () => { - it("should return false before initialization", () => { - expect(isDatabaseReady()).toBe(false); - }); - - it("should return true after successful initialization", async () => { - (compassLocalDB.open as jest.Mock).mockResolvedValue(undefined); - (compassLocalDB.isOpen as jest.Mock).mockReturnValue(true); - - await initializeDatabase(); - - expect(isDatabaseReady()).toBe(true); - }); - - it("should return false if database is not open", async () => { - (compassLocalDB.open as jest.Mock).mockResolvedValue(undefined); - (compassLocalDB.isOpen as jest.Mock).mockReturnValue(false); - - expect(isDatabaseReady()).toBe(false); - }); - }); - - describe("ensureDatabaseReady", () => { - it("should initialize database if not ready", async () => { - (compassLocalDB.open as jest.Mock).mockResolvedValue(undefined); - (compassLocalDB.isOpen as jest.Mock).mockReturnValue(true); - - await ensureDatabaseReady(); - - expect(compassLocalDB.open).toHaveBeenCalled(); - }); - - it("should not reinitialize if already ready", async () => { - (compassLocalDB.open as jest.Mock).mockResolvedValue(undefined); - (compassLocalDB.isOpen as jest.Mock).mockReturnValue(true); - - // Initialize first - await initializeDatabase(); - expect(compassLocalDB.open).toHaveBeenCalledTimes(1); - - // Ensure ready again - await ensureDatabaseReady(); - - // Should not call open again - expect(compassLocalDB.open).toHaveBeenCalledTimes(1); - }); - }); - - describe("resetDatabaseInitialization", () => { - it("should reset initialization state", async () => { - (compassLocalDB.open as jest.Mock).mockResolvedValue(undefined); - (compassLocalDB.isOpen as jest.Mock).mockReturnValue(true); - - // Initialize - await initializeDatabase(); - expect(isDatabaseReady()).toBe(true); - - // Reset - resetDatabaseInitialization(); - expect(isDatabaseReady()).toBe(false); - }); - - it("should allow reinitialization after reset", async () => { - (compassLocalDB.open as jest.Mock).mockResolvedValue(undefined); - (compassLocalDB.isOpen as jest.Mock).mockReturnValue(true); - - // Initialize - await initializeDatabase(); - expect(compassLocalDB.open).toHaveBeenCalledTimes(1); - - // Reset and initialize again - resetDatabaseInitialization(); - await initializeDatabase(); - - expect(compassLocalDB.open).toHaveBeenCalledTimes(2); - }); - }); -}); diff --git a/packages/web/src/common/utils/storage/db-init.util.ts b/packages/web/src/common/utils/storage/db-init.util.ts deleted file mode 100644 index 9755d90c93..0000000000 --- a/packages/web/src/common/utils/storage/db-init.util.ts +++ /dev/null @@ -1,118 +0,0 @@ -import Dexie from "dexie"; -import { compassLocalDB } from "./compass-local.db"; -import { DatabaseInitError } from "./db-errors.util"; - -/** - * Database initialization state tracking - */ -let dbInitPromise: Promise | null = null; -let isInitialized = false; - -/** - * Initializes the IndexedDB database with explicit .open() call. - * Uses singleton pattern to ensure only one initialization happens. - * Implements retry logic for transient failures. - * - * @returns Promise that resolves when database is ready - * @throws DatabaseInitError if initialization fails after retries - */ -export async function initializeDatabase(): Promise { - // Return existing promise if initialization is in progress - if (dbInitPromise) { - return dbInitPromise; - } - - // Return immediately if already initialized - if (isInitialized) { - return Promise.resolve(); - } - - dbInitPromise = (async () => { - const maxRetries = 3; - let lastError: unknown; - - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - // Critical: Explicitly open the database - await compassLocalDB.open(); - - // Verify database is ready - const isOpen = compassLocalDB.isOpen(); - - if (!isOpen) { - throw new DatabaseInitError( - "Database opened but isOpen() returned false", - ); - } - - isInitialized = true; - return; - } catch (error) { - lastError = error; - - // Handle specific Dexie errors - don't retry these - if (error instanceof Dexie.VersionError) { - throw new DatabaseInitError( - "Database version mismatch. Please reload the page.", - error, - ); - } - - if (error instanceof Dexie.QuotaExceededError) { - throw new DatabaseInitError( - "Storage quota exceeded. Please free up space.", - error, - ); - } - - // For other errors, retry with exponential backoff - if (attempt < maxRetries) { - const backoffMs = Math.pow(2, attempt) * 100; // 200ms, 400ms, 800ms - await new Promise((resolve) => setTimeout(resolve, backoffMs)); - } - } - } - - // All retries failed - dbInitPromise = null; // Reset to allow future retry - throw new DatabaseInitError( - `Failed to initialize IndexedDB after ${maxRetries} attempts`, - lastError, - ); - })(); - - return dbInitPromise; -} - -/** - * Checks if the database is ready without triggering initialization. - * Synchronous check for performance. - * - * @returns true if database is initialized and open - */ -export function isDatabaseReady(): boolean { - return isInitialized && compassLocalDB.isOpen(); -} - -/** - * Ensures the database is ready before performing operations. - * Should be called at the start of all database operations. - * If database is not ready, triggers initialization. - * - * @returns Promise that resolves when database is ready - * @throws DatabaseInitError if initialization fails - */ -export async function ensureDatabaseReady(): Promise { - if (!isDatabaseReady()) { - await initializeDatabase(); - } -} - -/** - * Resets the initialization state. Useful for testing. - * WARNING: Only use in tests, never in production code. - */ -export function resetDatabaseInitialization(): void { - dbInitPromise = null; - isInitialized = false; -} diff --git a/packages/web/src/common/utils/storage/event.storage.util.test.ts b/packages/web/src/common/utils/storage/event.storage.util.test.ts deleted file mode 100644 index 0a093ed754..0000000000 --- a/packages/web/src/common/utils/storage/event.storage.util.test.ts +++ /dev/null @@ -1,313 +0,0 @@ -import { Event_Core } from "@core/types/event.types"; -import dayjs from "@core/util/date/dayjs"; -import { createMockStandaloneEvent } from "@core/util/test/ccal.event.factory"; -import { compassLocalDB } from "./compass-local.db"; -import { - clearEventsFromIndexedDB, - deleteEventFromIndexedDB, - loadAllEventsFromIndexedDB, - loadEventsFromIndexedDB, - saveEventToIndexedDB, -} from "./event.storage.util"; - -describe("event.storage.util", () => { - beforeEach(async () => { - await clearEventsFromIndexedDB(); - }); - - const createMockEvent = ( - overrides?: Partial, - dateDiff?: { value: number; unit: "days" | "hours" | "minutes" }, - ) => { - return createMockStandaloneEvent(overrides, false, dateDiff) as Event_Core; - }; - - describe("saveEventToIndexedDB", () => { - it("should save an event to IndexedDB", async () => { - const event = createMockEvent(); - await saveEventToIndexedDB(event); - - const savedEvent = await compassLocalDB.events.get(event._id!); - expect(savedEvent).toBeDefined(); - expect(savedEvent?._id).toBe(event._id); - expect(savedEvent?.title).toBe(event.title); - }); - - it("should update an existing event when saving with same _id", async () => { - const event = createMockEvent({ title: "Original Title" }); - await saveEventToIndexedDB(event); - - const updatedEvent = { - ...event, - title: "Updated Title", - }; - await saveEventToIndexedDB(updatedEvent); - - const savedEvent = await compassLocalDB.events.get(event._id!); - expect(savedEvent?.title).toBe("Updated Title"); - }); - - it("should throw an error if event does not have an _id", async () => { - const event = createMockEvent(); - delete event._id; - - await expect(saveEventToIndexedDB(event as Event_Core)).rejects.toThrow( - "Event must have an _id to save to IndexedDB", - ); - }); - }); - - describe("loadEventsFromIndexedDB", () => { - it("should load events within date range", async () => { - const today = dayjs(); - const tomorrow = today.add(1, "day"); - const nextWeek = today.add(7, "days"); - - const event1 = createMockEvent({ - startDate: today.toISOString(), - endDate: today.add(1, "hour").toISOString(), - }); - const event2 = createMockEvent({ - startDate: tomorrow.toISOString(), - endDate: tomorrow.add(1, "hour").toISOString(), - }); - const event3 = createMockEvent({ - startDate: nextWeek.toISOString(), - endDate: nextWeek.add(1, "hour").toISOString(), - }); - - await saveEventToIndexedDB(event1); - await saveEventToIndexedDB(event2); - await saveEventToIndexedDB(event3); - - const startDate = today.startOf("day").toISOString(); - const endDate = tomorrow.endOf("day").toISOString(); - - const events = await loadEventsFromIndexedDB(startDate, endDate); - - expect(events).toHaveLength(2); - expect(events.map((e) => e._id)).toContain(event1._id); - expect(events.map((e) => e._id)).toContain(event2._id); - expect(events.map((e) => e._id)).not.toContain(event3._id); - }); - - it("should filter by isSomeday when specified", async () => { - const today = dayjs(); - - const somedayEvent = createMockEvent({ - startDate: today.toISOString(), - endDate: today.toISOString(), - isSomeday: true, - }); - const regularEvent = createMockEvent({ - startDate: today.toISOString(), - endDate: today.add(1, "hour").toISOString(), - isSomeday: false, - }); - - await saveEventToIndexedDB(somedayEvent); - await saveEventToIndexedDB(regularEvent); - - const startDate = today.startOf("day").toISOString(); - const endDate = today.endOf("day").toISOString(); - - const somedayEvents = await loadEventsFromIndexedDB( - startDate, - endDate, - true, - ); - const regularEvents = await loadEventsFromIndexedDB( - startDate, - endDate, - false, - ); - - expect(somedayEvents).toHaveLength(1); - expect(somedayEvents[0]._id).toBe(somedayEvent._id); - expect(regularEvents).toHaveLength(1); - expect(regularEvents[0]._id).toBe(regularEvent._id); - }); - - it("should return empty array when no events match date range", async () => { - const today = dayjs(); - const nextMonth = today.add(1, "month"); - - const event = createMockEvent({ - startDate: nextMonth.toISOString(), - endDate: nextMonth.add(1, "hour").toISOString(), - }); - - await saveEventToIndexedDB(event); - - const startDate = today.startOf("day").toISOString(); - const endDate = today.endOf("day").toISOString(); - - const events = await loadEventsFromIndexedDB(startDate, endDate); - - expect(events).toHaveLength(0); - }); - - it("should include events on boundary dates", async () => { - const today = dayjs(); - const startOfDay = today.startOf("day"); - const endOfDay = today.endOf("day"); - - const event1 = createMockEvent({ - startDate: startOfDay.toISOString(), - endDate: startOfDay.add(1, "hour").toISOString(), - }); - const event2 = createMockEvent({ - startDate: endOfDay.toISOString(), - endDate: endOfDay.add(1, "hour").toISOString(), - }); - - await saveEventToIndexedDB(event1); - await saveEventToIndexedDB(event2); - - const events = await loadEventsFromIndexedDB( - startOfDay.toISOString(), - endOfDay.toISOString(), - ); - - expect(events).toHaveLength(2); - }); - - it("should include events that start before range but end within it", async () => { - // Example: Event from Jan 1-5 should be included when querying Jan 3-10 - const jan1 = dayjs("2024-01-01").startOf("day"); - const jan3 = dayjs("2024-01-03").startOf("day"); - const jan10 = dayjs("2024-01-10").endOf("day"); - - // Create event starting Jan 1, lasting 4 days (ends Jan 5) - const multiDayEvent = createMockEvent( - { startDate: jan1.toISOString() }, - { value: 4, unit: "days" }, - ); - - await saveEventToIndexedDB(multiDayEvent); - - const events = await loadEventsFromIndexedDB( - jan3.toISOString(), - jan10.toISOString(), - ); - - expect(events).toHaveLength(1); - expect(events[0]._id).toBe(multiDayEvent._id); - }); - - it("should include events that span the entire range", async () => { - // Event that starts before and ends after the query range - const jan1 = dayjs("2024-01-01").startOf("day"); - const jan3 = dayjs("2024-01-03").startOf("day"); - const jan5 = dayjs("2024-01-05").endOf("day"); - - // Create event starting Jan 1, lasting 9 days (ends Jan 10) - const spanningEvent = createMockEvent( - { startDate: jan1.toISOString() }, - { value: 9, unit: "days" }, - ); - - await saveEventToIndexedDB(spanningEvent); - - const events = await loadEventsFromIndexedDB( - jan3.toISOString(), - jan5.toISOString(), - ); - - expect(events).toHaveLength(1); - expect(events[0]._id).toBe(spanningEvent._id); - }); - - it("should include events that start within range but end after it", async () => { - const jan3 = dayjs("2024-01-03").startOf("day"); - const jan5 = dayjs("2024-01-05").startOf("day"); - - // Create event starting Jan 5, lasting 5 days (ends Jan 10) - const event = createMockEvent( - { startDate: jan5.toISOString() }, - { value: 5, unit: "days" }, - ); - - await saveEventToIndexedDB(event); - - const events = await loadEventsFromIndexedDB( - jan3.toISOString(), - jan5.endOf("day").toISOString(), - ); - - expect(events).toHaveLength(1); - expect(events[0]._id).toBe(event._id); - }); - - it("should exclude events that are completely outside the range", async () => { - const jan1 = dayjs("2024-01-01").startOf("day"); - const jan3 = dayjs("2024-01-03").startOf("day"); - const jan5 = dayjs("2024-01-05").endOf("day"); - - // Create event starting Jan 1, lasting 1 day (ends Jan 2) - const eventBefore = createMockEvent( - { startDate: jan1.toISOString() }, - { value: 1, unit: "days" }, - ); - - await saveEventToIndexedDB(eventBefore); - - const events = await loadEventsFromIndexedDB( - jan3.toISOString(), - jan5.toISOString(), - ); - - expect(events).toHaveLength(0); - }); - }); - - describe("loadAllEventsFromIndexedDB", () => { - it("should load all events from IndexedDB", async () => { - const event1 = createMockEvent(); - const event2 = createMockEvent(); - - await saveEventToIndexedDB(event1); - await saveEventToIndexedDB(event2); - - const events = await loadAllEventsFromIndexedDB(); - - expect(events).toHaveLength(2); - expect(events.map((event) => event._id)).toEqual( - expect.arrayContaining([event1._id, event2._id]), - ); - }); - }); - - describe("deleteEventFromIndexedDB", () => { - it("should delete an event by ID", async () => { - const event = createMockEvent(); - await saveEventToIndexedDB(event); - - await deleteEventFromIndexedDB(event._id!); - - const deletedEvent = await compassLocalDB.events.get(event._id!); - expect(deletedEvent).toBeUndefined(); - }); - - it("should not throw error when deleting non-existent event", async () => { - await expect( - deleteEventFromIndexedDB("non-existent-id"), - ).resolves.not.toThrow(); - }); - }); - - describe("clearEventsFromIndexedDB", () => { - it("should clear all events from IndexedDB", async () => { - const event1 = createMockEvent(); - const event2 = createMockEvent(); - - await saveEventToIndexedDB(event1); - await saveEventToIndexedDB(event2); - - await clearEventsFromIndexedDB(); - - const allEvents = await compassLocalDB.events.toArray(); - expect(allEvents).toHaveLength(0); - }); - }); -}); diff --git a/packages/web/src/common/utils/storage/event.storage.util.ts b/packages/web/src/common/utils/storage/event.storage.util.ts deleted file mode 100644 index 9e196011cf..0000000000 --- a/packages/web/src/common/utils/storage/event.storage.util.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { Event_Core } from "@core/types/event.types"; -import { isDateRangeOverlapping } from "@core/util/date/date.util"; -import { compassLocalDB } from "./compass-local.db"; -import { handleDatabaseError } from "./db-errors.util"; -import { ensureDatabaseReady } from "./db-init.util"; - -/** - * Saves an event to IndexedDB. Uses put() to handle both new and existing events. - */ -export async function saveEventToIndexedDB(event: Event_Core): Promise { - if (!event._id) { - throw new Error("Event must have an _id to save to IndexedDB"); - } - - try { - // Ensure database is ready before operation - await ensureDatabaseReady(); - - await compassLocalDB.events.put(event); - } catch (error) { - handleDatabaseError(error, "save"); - } -} - -/** - * Loads events from IndexedDB filtered by date range and optionally by isSomeday flag. - */ -export async function loadEventsFromIndexedDB( - startDate: string, - endDate: string, - isSomeday?: boolean, -): Promise { - try { - await ensureDatabaseReady(); - - const allEvents = await compassLocalDB.events.toArray(); - - return allEvents.filter((event) => { - if (!event.startDate || !event.endDate) return false; - if (isSomeday !== undefined && event.isSomeday !== isSomeday) { - return false; - } - return isDateRangeOverlapping( - event.startDate, - event.endDate, - startDate, - endDate, - "day", - ); - }); - } catch (error) { - handleDatabaseError(error, "load"); - } -} - -/** - * Loads all events from IndexedDB without filtering. - */ -export async function loadAllEventsFromIndexedDB(): Promise { - try { - // Ensure database is ready before operation - await ensureDatabaseReady(); - - const events = await compassLocalDB.events.toArray(); - - return events; - } catch (error) { - handleDatabaseError(error, "load"); - } -} - -/** - * Deletes an event from IndexedDB by its ID. - */ -export async function deleteEventFromIndexedDB(eventId: string): Promise { - try { - // Ensure database is ready before operation - await ensureDatabaseReady(); - - await compassLocalDB.events.delete(eventId); - } catch (error) { - handleDatabaseError(error, "delete"); - } -} - -/** - * Clears all events from IndexedDB. Used for migration cleanup. - */ -export async function clearEventsFromIndexedDB(): Promise { - try { - // Ensure database is ready before operation - await ensureDatabaseReady(); - - await compassLocalDB.events.clear(); - } catch (error) { - handleDatabaseError(error, "clear"); - } -} diff --git a/packages/web/src/common/utils/storage/storage.util.ts b/packages/web/src/common/utils/storage/storage.util.ts index b525fbe14f..898791af52 100644 --- a/packages/web/src/common/utils/storage/storage.util.ts +++ b/packages/web/src/common/utils/storage/storage.util.ts @@ -1,10 +1,7 @@ import dayjs from "@core/util/date/dayjs"; -import { Task, normalizeTasks } from "@web/common/types/task.types"; -import { - loadTasksFromIndexedDB, - moveTaskBetweenDates, - saveTasksToIndexedDB, -} from "@web/common/utils/storage/task.storage.util"; +import { getTaskRepository } from "@web/common/repositories/task/task.repository.util"; +import { ensureStorageReady } from "@web/common/storage/adapter/adapter"; +import { Task } from "@web/common/types/task.types"; import { CompassTasksSavedEventDetail } from "./storage.types"; export const TODAY_TASKS_STORAGE_KEY_PREFIX = "compass.today.tasks"; @@ -14,86 +11,33 @@ export function getDateKey(date: Date = new Date()): string { return dayjs(date).format(dayjs.DateFormat.YEAR_MONTH_DAY_FORMAT); } -function dispatchTasksSavedEvent(dateKey: string): void { - if (typeof window === "undefined") { - return; - } - - const eventDetail: CompassTasksSavedEventDetail = { dateKey }; - window.dispatchEvent( - new CustomEvent(COMPASS_TASKS_SAVED_EVENT_NAME, { - detail: eventDetail, - }), - ); -} - +/** + * Load tasks for a date from storage via TaskRepository. + * Kept for compatibility with useAvailableTasks and tests. + */ export async function loadTasksFromStorage(dateKey: string): Promise { if (typeof window === "undefined") { return []; } try { - return await loadTasksFromIndexedDB(dateKey); + await ensureStorageReady(); + return await getTaskRepository("local").get(dateKey); } catch (error) { - console.error("Error loading tasks from IndexedDB:", error); + console.error("Error loading tasks from storage:", error); return []; } } -export async function saveTasksToStorage( - dateKey: string, - tasks: Task[], -): Promise { - if (typeof window === "undefined") { - return; - } - - try { - await saveTasksToIndexedDB(dateKey, normalizeTasks(tasks)); - dispatchTasksSavedEvent(dateKey); - } catch (error) { - console.error("Error saving tasks to IndexedDB:", error); - } -} - -export async function loadTodayTasks(): Promise { - const dateKey = getDateKey(); - return await loadTasksFromStorage(dateKey); -} - -export async function updateTasksForDate( - dateKey: string, - updater: (tasks: Task[]) => Task[], -): Promise { - const loadedTasks = await loadTasksFromStorage(dateKey); - const updatedTasks = updater(loadedTasks); - await saveTasksToStorage(dateKey, updatedTasks); - return updatedTasks; -} - -export async function updateTodayTasks( - updater: (tasks: Task[]) => Task[], -): Promise { - const dateKey = getDateKey(); - return await updateTasksForDate(dateKey, updater); -} - -export async function moveTaskToDate( - task: Task, - fromDateKey: string, - toDateKey: string, -): Promise { +export function dispatchTasksSavedEvent(dateKey: string): void { if (typeof window === "undefined") { return; } - try { - await moveTaskBetweenDates(task, fromDateKey, toDateKey); - dispatchTasksSavedEvent(fromDateKey); - if (toDateKey !== fromDateKey) { - dispatchTasksSavedEvent(toDateKey); - } - } catch (error) { - console.error("Error moving task in IndexedDB:", error); - } + const eventDetail: CompassTasksSavedEventDetail = { dateKey }; + window.dispatchEvent( + new CustomEvent(COMPASS_TASKS_SAVED_EVENT_NAME, { + detail: eventDetail, + }), + ); } diff --git a/packages/web/src/common/utils/storage/task-migration.util.test.ts b/packages/web/src/common/utils/storage/task-migration.util.test.ts deleted file mode 100644 index e258c1eb46..0000000000 --- a/packages/web/src/common/utils/storage/task-migration.util.test.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { createMockTask } from "@web/__tests__/utils/factories/task.factory"; -import { clearCompassLocalDb } from "@web/__tests__/utils/storage/indexeddb.test.util"; -import { compassLocalDB } from "./compass-local.db"; -import { resetDatabaseInitialization } from "./db-init.util"; -import { - hasTaskMigrationCompleted, - migrateTasksFromLocalStorageToIndexedDB, - resetTaskMigrationFlag, -} from "./task-migration.util"; -import { - clearAllTasksFromIndexedDB, - loadAllTasksFromIndexedDB, - loadTasksFromIndexedDB, - saveTasksToIndexedDB, -} from "./task.storage.util"; - -describe("task-migration.util", () => { - const TASK_STORAGE_KEY_PREFIX = "compass.today.tasks."; - const MIGRATION_FLAG_KEY = "compass.tasks.migrated-to-indexeddb"; - const COMPASS_LOCAL_DB_NAME = "compass-local"; - - async function createLegacySchemaDatabase( - tasks: Array> = [], - ): Promise { - await new Promise((resolve, reject) => { - const request = indexedDB.open(COMPASS_LOCAL_DB_NAME, 2); - - request.onupgradeneeded = () => { - const db = request.result; - - if (!db.objectStoreNames.contains("events")) { - const eventsStore = db.createObjectStore("events", { - keyPath: "_id", - }); - eventsStore.createIndex("startDate", "startDate", { unique: false }); - eventsStore.createIndex("endDate", "endDate", { unique: false }); - eventsStore.createIndex("isSomeday", "isSomeday", { unique: false }); - } - - if (!db.objectStoreNames.contains("tasks")) { - const tasksStore = db.createObjectStore("tasks", { keyPath: "id" }); - tasksStore.createIndex("dateKey", "dateKey", { unique: false }); - tasksStore.createIndex("status", "status", { unique: false }); - tasksStore.createIndex("order", "order", { unique: false }); - } - }; - - request.onsuccess = () => { - const db = request.result; - const transaction = db.transaction("tasks", "readwrite"); - const tasksStore = transaction.objectStore("tasks"); - - tasks.forEach((task) => tasksStore.put(task)); - - transaction.oncomplete = () => { - db.close(); - resolve(); - }; - transaction.onerror = () => reject(transaction.error); - }; - request.onerror = () => reject(request.error); - }); - } - - beforeEach(async () => { - // Clear IndexedDB - await clearAllTasksFromIndexedDB(); - - // Clear localStorage - const keysToRemove: string[] = []; - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if ( - key?.startsWith(TASK_STORAGE_KEY_PREFIX) || - key === MIGRATION_FLAG_KEY - ) { - keysToRemove.push(key); - } - } - keysToRemove.forEach((key) => localStorage.removeItem(key)); - - // Reset migration flag - resetTaskMigrationFlag(); - }); - - describe("hasTaskMigrationCompleted", () => { - it("should return false when migration has not been completed", () => { - expect(hasTaskMigrationCompleted()).toBe(false); - }); - - it("should return true when migration flag is set", () => { - localStorage.setItem(MIGRATION_FLAG_KEY, "true"); - expect(hasTaskMigrationCompleted()).toBe(true); - }); - }); - - describe("migrateTasksFromLocalStorageToIndexedDB", () => { - it("should migrate tasks from localStorage to IndexedDB", async () => { - const task1 = createMockTask({ title: "Task 1" }); - const task2 = createMockTask({ title: "Task 2" }); - - // Store tasks in localStorage - localStorage.setItem( - `${TASK_STORAGE_KEY_PREFIX}2024-01-15`, - JSON.stringify([task1]), - ); - localStorage.setItem( - `${TASK_STORAGE_KEY_PREFIX}2024-01-16`, - JSON.stringify([task2]), - ); - - const migratedCount = await migrateTasksFromLocalStorageToIndexedDB(); - - expect(migratedCount).toBe(2); - - // Verify tasks are in IndexedDB - const allTasks = await loadAllTasksFromIndexedDB(); - expect(allTasks).toHaveLength(2); - expect(allTasks.map((t) => t.title)).toContain("Task 1"); - expect(allTasks.map((t) => t.title)).toContain("Task 2"); - }); - - it("should associate tasks with correct dateKey", async () => { - const task = createMockTask({ title: "Task for specific date" }); - - localStorage.setItem( - `${TASK_STORAGE_KEY_PREFIX}2024-01-15`, - JSON.stringify([task]), - ); - - await migrateTasksFromLocalStorageToIndexedDB(); - - const allTasks = await loadAllTasksFromIndexedDB(); - expect(allTasks).toHaveLength(1); - expect(allTasks[0].dateKey).toBe("2024-01-15"); - }); - - it("should remove localStorage entries after successful migration", async () => { - const task = createMockTask(); - const storageKey = `${TASK_STORAGE_KEY_PREFIX}2024-01-15`; - - localStorage.setItem(storageKey, JSON.stringify([task])); - - await migrateTasksFromLocalStorageToIndexedDB(); - - expect(localStorage.getItem(storageKey)).toBeNull(); - }); - - it("should set migration flag after completion", async () => { - const task = createMockTask(); - localStorage.setItem( - `${TASK_STORAGE_KEY_PREFIX}2024-01-15`, - JSON.stringify([task]), - ); - - await migrateTasksFromLocalStorageToIndexedDB(); - - expect(hasTaskMigrationCompleted()).toBe(true); - }); - - it("should return 0 and skip migration if already completed", async () => { - const task = createMockTask(); - localStorage.setItem( - `${TASK_STORAGE_KEY_PREFIX}2024-01-15`, - JSON.stringify([task]), - ); - - // First migration - await migrateTasksFromLocalStorageToIndexedDB(); - - // Add another task to localStorage - const task2 = createMockTask({ title: "New Task" }); - localStorage.setItem( - `${TASK_STORAGE_KEY_PREFIX}2024-01-16`, - JSON.stringify([task2]), - ); - - // Second migration should be skipped - const migratedCount = await migrateTasksFromLocalStorageToIndexedDB(); - - expect(migratedCount).toBe(0); - }); - - it("should handle empty localStorage", async () => { - const migratedCount = await migrateTasksFromLocalStorageToIndexedDB(); - expect(migratedCount).toBe(0); - expect(hasTaskMigrationCompleted()).toBe(true); - }); - - it("should skip invalid JSON entries", async () => { - const validTask = createMockTask({ title: "Valid Task" }); - - // Valid entry - localStorage.setItem( - `${TASK_STORAGE_KEY_PREFIX}2024-01-15`, - JSON.stringify([validTask]), - ); - // Invalid JSON - localStorage.setItem(`${TASK_STORAGE_KEY_PREFIX}2024-01-16`, "not json"); - - const migratedCount = await migrateTasksFromLocalStorageToIndexedDB(); - - // Should still migrate the valid task - expect(migratedCount).toBe(1); - - const allTasks = await loadAllTasksFromIndexedDB(); - expect(allTasks).toHaveLength(1); - expect(allTasks[0].title).toBe("Valid Task"); - }); - - it("should skip non-array entries", async () => { - const validTask = createMockTask({ title: "Valid Task" }); - - localStorage.setItem( - `${TASK_STORAGE_KEY_PREFIX}2024-01-15`, - JSON.stringify([validTask]), - ); - // Object instead of array - localStorage.setItem( - `${TASK_STORAGE_KEY_PREFIX}2024-01-16`, - JSON.stringify({ notAnArray: true }), - ); - - const migratedCount = await migrateTasksFromLocalStorageToIndexedDB(); - - expect(migratedCount).toBe(1); - }); - - it("should skip invalid task objects", async () => { - const validTask = createMockTask({ title: "Valid Task" }); - const invalidTask = { notATask: true }; - - localStorage.setItem( - `${TASK_STORAGE_KEY_PREFIX}2024-01-15`, - JSON.stringify([validTask, invalidTask]), - ); - - const migratedCount = await migrateTasksFromLocalStorageToIndexedDB(); - - // Only the valid task should be migrated - expect(migratedCount).toBe(1); - - const allTasks = await loadAllTasksFromIndexedDB(); - expect(allTasks).toHaveLength(1); - expect(allTasks[0].title).toBe("Valid Task"); - }); - - it("should migrate legacy tasks with id mapped to _id", async () => { - const task = createMockTask({ - _id: "legacy-task-id", - title: "Legacy Task", - }); - const { _id, ...rest } = task; - const legacyTask = { ...rest, id: _id }; - - localStorage.setItem( - `${TASK_STORAGE_KEY_PREFIX}2024-01-15`, - JSON.stringify([legacyTask]), - ); - - const migratedCount = await migrateTasksFromLocalStorageToIndexedDB(); - - expect(migratedCount).toBe(1); - - const allTasks = await loadAllTasksFromIndexedDB(); - expect(allTasks).toHaveLength(1); - expect(allTasks[0]._id).toBe("legacy-task-id"); - expect(allTasks[0].title).toBe("Legacy Task"); - }); - - it("should migrate legacy IndexedDB schema and allow saving new tasks", async () => { - const legacyTask = { - id: "legacy-task-id", - title: "Legacy Task", - status: "todo", - order: 0, - createdAt: new Date().toISOString(), - user: "user-1", - dateKey: "2024-01-15", - }; - - compassLocalDB.close(); - resetDatabaseInitialization(); - await clearCompassLocalDb(); - await createLegacySchemaDatabase([legacyTask]); - - localStorage.setItem(MIGRATION_FLAG_KEY, "true"); - - const migratedCount = await migrateTasksFromLocalStorageToIndexedDB(); - expect(migratedCount).toBe(0); - - const restoredLegacyTasks = await loadTasksFromIndexedDB("2024-01-15"); - expect(restoredLegacyTasks).toHaveLength(1); - expect(restoredLegacyTasks[0]._id).toBe("legacy-task-id"); - - const newTask = createMockTask({ - _id: "new-task-id", - title: "New Task", - }); - await saveTasksToIndexedDB("2024-01-16", [newTask]); - - const persistedNewTasks = await loadTasksFromIndexedDB("2024-01-16"); - expect(persistedNewTasks).toHaveLength(1); - expect(persistedNewTasks[0]._id).toBe("new-task-id"); - expect(persistedNewTasks[0].title).toBe("New Task"); - }); - }); - - describe("resetTaskMigrationFlag", () => { - it("should remove the migration flag", () => { - localStorage.setItem(MIGRATION_FLAG_KEY, "true"); - expect(hasTaskMigrationCompleted()).toBe(true); - - resetTaskMigrationFlag(); - - expect(hasTaskMigrationCompleted()).toBe(false); - }); - }); -}); diff --git a/packages/web/src/common/utils/storage/task-migration.util.ts b/packages/web/src/common/utils/storage/task-migration.util.ts deleted file mode 100644 index 9874fb5038..0000000000 --- a/packages/web/src/common/utils/storage/task-migration.util.ts +++ /dev/null @@ -1,340 +0,0 @@ -import { Event_Core } from "@core/types/event.types"; -import { Task, isTask, normalizeTask } from "@web/common/types/task.types"; -import { StoredTask, compassLocalDB } from "./compass-local.db"; -import { - ensureDatabaseReady, - resetDatabaseInitialization, -} from "./db-init.util"; -import { saveTaskToIndexedDB } from "./task.storage.util"; - -const MIGRATION_FLAG_KEY = "compass.tasks.migrated-to-indexeddb"; -const TASK_STORAGE_KEY_PREFIX = "compass.today.tasks."; -const COMPASS_LOCAL_DB_NAME = "compass-local"; -const EVENTS_STORE_NAME = "events"; -const TASKS_STORE_NAME = "tasks"; - -interface LegacyDbSnapshot { - events: Event_Core[]; - tasks: unknown[]; -} - -function normalizeTaskWithLegacyId(item: unknown): Task | null { - if (isTask(item)) { - return item; - } - - if (!item || typeof item !== "object") { - return null; - } - - const legacyTask = item as Record & { id?: unknown }; - if (typeof legacyTask.id !== "string") { - return null; - } - - const { id, ...rest } = legacyTask; - const mappedTask = { - ...rest, - _id: id, - }; - - return isTask(mappedTask) ? mappedTask : null; -} - -function requestAsPromise( - request: IDBRequest, - blockedErrorMessage?: string, -): Promise { - return new Promise((resolve, reject) => { - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - if (blockedErrorMessage && "onblocked" in request) { - (request as IDBOpenDBRequest).onblocked = () => - reject(new Error(blockedErrorMessage)); - } - }); -} - -function transactionDone(transaction: IDBTransaction): Promise { - return new Promise((resolve, reject) => { - transaction.oncomplete = () => resolve(); - transaction.onerror = () => reject(transaction.error); - transaction.onabort = () => reject(transaction.error); - }); -} - -function openLocalDatabaseRaw(): Promise { - return requestAsPromise(indexedDB.open(COMPASS_LOCAL_DB_NAME)); -} - -function deleteLocalDatabaseRaw(): Promise { - return requestAsPromise( - indexedDB.deleteDatabase(COMPASS_LOCAL_DB_NAME), - "Delete database request blocked", - ).then(() => undefined); -} - -async function withLocalDatabase( - callback: (database: IDBDatabase) => Promise, -): Promise { - const database = await openLocalDatabaseRaw(); - try { - return await callback(database); - } finally { - database.close(); - } -} - -async function runTransaction( - database: IDBDatabase, - storeNames: string | string[], - mode: IDBTransactionMode, - callback: (transaction: IDBTransaction) => Promise | T, -): Promise { - const transaction = database.transaction(storeNames, mode); - const [result] = await Promise.all([ - Promise.resolve(callback(transaction)), - transactionDone(transaction), - ]); - return result; -} - -async function readLegacyDbSnapshotIfNeeded(): Promise { - return withLocalDatabase(async (database) => { - if (!database.objectStoreNames.contains(TASKS_STORE_NAME)) { - return null; - } - - const taskStoreKeyPath = await runTransaction( - database, - TASKS_STORE_NAME, - "readonly", - (transaction) => { - const taskStore = transaction.objectStore(TASKS_STORE_NAME); - return taskStore.keyPath; - }, - ); - - if (taskStoreKeyPath !== "id") { - return null; - } - - const hasEventsStore = - database.objectStoreNames.contains(EVENTS_STORE_NAME); - const storesToRead = hasEventsStore - ? [EVENTS_STORE_NAME, TASKS_STORE_NAME] - : [TASKS_STORE_NAME]; - const { events, tasks } = await runTransaction( - database, - storesToRead, - "readonly", - async (transaction) => { - const tasks = await requestAsPromise( - transaction.objectStore(TASKS_STORE_NAME).getAll(), - ); - const events = hasEventsStore - ? await requestAsPromise( - transaction.objectStore(EVENTS_STORE_NAME).getAll(), - ) - : []; - return { events, tasks }; - }, - ); - - return { - events: events as Event_Core[], - tasks: tasks as unknown[], - }; - }); -} - -function mapLegacyIndexedDbTask(task: unknown): StoredTask | null { - if (!task || typeof task !== "object") { - return null; - } - - const legacyTask = task as Record & { dateKey?: unknown }; - if (typeof legacyTask.dateKey !== "string") { - return null; - } - - const normalizedTask = normalizeTaskWithLegacyId(legacyTask); - if (!normalizedTask) { - return null; - } - - try { - return { - ...normalizeTask(normalizedTask), - dateKey: legacyTask.dateKey, - }; - } catch { - return null; - } -} - -async function migrateLegacyTaskStoreSchemaIfNeeded(): Promise { - if (typeof window === "undefined") return; - if (typeof indexedDB === "undefined") return; - - const legacySnapshot = await readLegacyDbSnapshotIfNeeded(); - if (!legacySnapshot) { - return; - } - - compassLocalDB.close(); - resetDatabaseInitialization(); - await deleteLocalDatabaseRaw(); - await ensureDatabaseReady(); - - const migratedTasks = legacySnapshot.tasks - .map(mapLegacyIndexedDbTask) - .filter((task): task is StoredTask => task !== null); - - await compassLocalDB.transaction( - "rw", - compassLocalDB.events, - compassLocalDB.tasks, - async () => { - if (legacySnapshot.events.length > 0) { - await compassLocalDB.events.bulkPut(legacySnapshot.events); - } - if (migratedTasks.length > 0) { - await compassLocalDB.tasks.bulkPut(migratedTasks); - } - }, - ); -} - -/** - * Checks if task migration from localStorage to IndexedDB has been completed. - */ -export function hasTaskMigrationCompleted(): boolean { - if (typeof window === "undefined") return true; - return localStorage.getItem(MIGRATION_FLAG_KEY) === "true"; -} - -/** - * Marks task migration as completed. - */ -function markMigrationCompleted(): void { - if (typeof window === "undefined") return; - localStorage.setItem(MIGRATION_FLAG_KEY, "true"); -} - -/** - * Migrates all tasks from localStorage to IndexedDB. - * - * This is a one-time migration that: - * 1. Finds all task entries in localStorage (compass.today.tasks.YYYY-MM-DD) - * 2. Parses and validates each task - * 3. Saves valid tasks to IndexedDB - * 4. Removes the localStorage entries - * 5. Sets a flag to prevent re-migration - * - * @returns The number of tasks migrated - */ -export async function migrateTasksFromLocalStorageToIndexedDB(): Promise { - // Skip if running on server or already migrated - if (typeof window === "undefined") return 0; - try { - await migrateLegacyTaskStoreSchemaIfNeeded(); - } catch (error) { - console.error("Failed to migrate legacy IndexedDB task schema:", error); - } - if (hasTaskMigrationCompleted()) return 0; - - try { - await ensureDatabaseReady(); - - let migratedCount = 0; - let hasSaveFailures = false; - const keysToRemove: string[] = []; - - // Collect all task keys first (avoid modifying localStorage while iterating) - const taskKeys: string[] = []; - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key?.startsWith(TASK_STORAGE_KEY_PREFIX)) { - taskKeys.push(key); - } - } - - // Process each task key - for (const key of taskKeys) { - const dateKey = key.replace(TASK_STORAGE_KEY_PREFIX, ""); - const rawValue = localStorage.getItem(key); - - if (!rawValue) continue; - - try { - const parsed = JSON.parse(rawValue); - if (!Array.isArray(parsed)) continue; - - // Validate and save each task. If any save fails, keep only failed tasks - // in localStorage so successful migrations are not duplicated on retry. - const remainingTasks: Task[] = []; - for (const item of parsed) { - const normalizedTask = normalizeTaskWithLegacyId(item); - - if (normalizedTask) { - try { - await saveTaskToIndexedDB(normalizedTask, dateKey); - migratedCount++; - } catch (saveError) { - hasSaveFailures = true; - remainingTasks.push(normalizedTask); - console.error( - `Failed to save task to IndexedDB for dateKey: ${dateKey}`, - saveError, - ); - } - } - } - - if (remainingTasks.length === 0) { - // Mark key for removal after successful migration - keysToRemove.push(key); - } else { - localStorage.setItem(key, JSON.stringify(remainingTasks)); - } - } catch (parseError) { - // Skip invalid entries, don't fail the whole migration - console.warn( - `Failed to parse tasks from localStorage key: ${key}`, - parseError, - ); - } - } - - // Remove migrated entries from localStorage - for (const key of keysToRemove) { - localStorage.removeItem(key); - } - - // Mark migration as complete only when all saves succeeded. - if (!hasSaveFailures) { - markMigrationCompleted(); - } - - if (migratedCount > 0) { - console.log( - `Migrated ${migratedCount} tasks from localStorage to IndexedDB`, - ); - } - - return migratedCount; - } catch (error) { - console.error("Failed to migrate tasks to IndexedDB:", error); - // Don't mark as completed on error, so we can retry - return 0; - } -} - -/** - * Resets the migration flag. Used for testing. - */ -export function resetTaskMigrationFlag(): void { - if (typeof window === "undefined") return; - localStorage.removeItem(MIGRATION_FLAG_KEY); -} diff --git a/packages/web/src/common/utils/storage/task.storage.util.test.ts b/packages/web/src/common/utils/storage/task.storage.util.test.ts deleted file mode 100644 index f47dd985ea..0000000000 --- a/packages/web/src/common/utils/storage/task.storage.util.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { createMockTask } from "@web/__tests__/utils/factories/task.factory"; -import { UNAUTHENTICATED_USER } from "@web/common/constants/auth.constants"; -import { Task } from "@web/common/types/task.types"; -import { compassLocalDB } from "./compass-local.db"; -import { - clearAllTasksFromIndexedDB, - clearTasksForDateKey, - deleteTaskFromIndexedDB, - loadAllTasksFromIndexedDB, - loadTasksFromIndexedDB, - moveTaskBetweenDates, - saveTaskToIndexedDB, - saveTasksToIndexedDB, -} from "./task.storage.util"; - -describe("task.storage.util", () => { - beforeEach(async () => { - await clearAllTasksFromIndexedDB(); - }); - - describe("saveTaskToIndexedDB", () => { - it("should save a task to IndexedDB with dateKey", async () => { - const task = createMockTask(); - const dateKey = "2024-01-15"; - - await saveTaskToIndexedDB(task, dateKey); - - const savedTask = await compassLocalDB.tasks.get(task._id); - expect(savedTask).toBeDefined(); - expect(savedTask?._id).toBe(task._id); - expect(savedTask?.title).toBe(task.title); - expect(savedTask?.dateKey).toBe(dateKey); - }); - - it("should update an existing task when saving with same id", async () => { - const task = createMockTask({ title: "Original Title" }); - const dateKey = "2024-01-15"; - - await saveTaskToIndexedDB(task, dateKey); - - const updatedTask = { ...task, title: "Updated Title" }; - await saveTaskToIndexedDB(updatedTask, dateKey); - - const savedTask = await compassLocalDB.tasks.get(task._id); - expect(savedTask?.title).toBe("Updated Title"); - }); - - it("should default user when saving a task without user", async () => { - const task = createMockTask({ user: undefined }); - delete (task as Partial).user; - const dateKey = "2024-01-15"; - - await saveTaskToIndexedDB(task, dateKey); - - const savedTask = await compassLocalDB.tasks.get(task._id); - expect(savedTask?.user).toBe(UNAUTHENTICATED_USER); - }); - }); - - describe("saveTasksToIndexedDB", () => { - it("should save multiple tasks to IndexedDB", async () => { - const task1 = createMockTask({ title: "Task 1" }); - const task2 = createMockTask({ title: "Task 2" }); - const dateKey = "2024-01-15"; - - await saveTasksToIndexedDB(dateKey, [task1, task2]); - - const tasks = await loadTasksFromIndexedDB(dateKey); - expect(tasks).toHaveLength(2); - expect(tasks.map((t) => t.title)).toContain("Task 1"); - expect(tasks.map((t) => t.title)).toContain("Task 2"); - }); - - it("should replace existing tasks for the dateKey", async () => { - const task1 = createMockTask({ title: "Task 1" }); - const task2 = createMockTask({ title: "Task 2" }); - const dateKey = "2024-01-15"; - - await saveTasksToIndexedDB(dateKey, [task1]); - await saveTasksToIndexedDB(dateKey, [task2]); - - const tasks = await loadTasksFromIndexedDB(dateKey); - expect(tasks).toHaveLength(1); - expect(tasks[0].title).toBe("Task 2"); - }); - - it("should handle empty array", async () => { - const dateKey = "2024-01-15"; - await saveTasksToIndexedDB(dateKey, []); - - const tasks = await loadTasksFromIndexedDB(dateKey); - expect(tasks).toHaveLength(0); - }); - }); - - describe("loadTasksFromIndexedDB", () => { - it("should load tasks for a specific dateKey", async () => { - const task1 = createMockTask({ title: "Task for Day 1" }); - const task2 = createMockTask({ title: "Task for Day 2" }); - - await saveTaskToIndexedDB(task1, "2024-01-15"); - await saveTaskToIndexedDB(task2, "2024-01-16"); - - const tasksDay1 = await loadTasksFromIndexedDB("2024-01-15"); - const tasksDay2 = await loadTasksFromIndexedDB("2024-01-16"); - - expect(tasksDay1).toHaveLength(1); - expect(tasksDay1[0].title).toBe("Task for Day 1"); - expect(tasksDay2).toHaveLength(1); - expect(tasksDay2[0].title).toBe("Task for Day 2"); - }); - - it("should return tasks without dateKey property", async () => { - const task = createMockTask(); - await saveTaskToIndexedDB(task, "2024-01-15"); - - const tasks = await loadTasksFromIndexedDB("2024-01-15"); - - expect(tasks).toHaveLength(1); - expect(tasks[0]).not.toHaveProperty("dateKey"); - }); - - it("should return empty array when no tasks exist for dateKey", async () => { - const tasks = await loadTasksFromIndexedDB("2024-01-15"); - expect(tasks).toHaveLength(0); - }); - - it("should normalize legacy tasks without a user", async () => { - const task = createMockTask(); - delete (task as Partial).user; - - await compassLocalDB.tasks.put({ - ...(task as unknown as Task), - dateKey: "2024-01-15", - }); - - const tasks = await loadTasksFromIndexedDB("2024-01-15"); - - expect(tasks).toHaveLength(1); - expect(tasks[0].user).toBe(UNAUTHENTICATED_USER); - }); - }); - - describe("deleteTaskFromIndexedDB", () => { - it("should delete a task by ID", async () => { - const task = createMockTask(); - await saveTaskToIndexedDB(task, "2024-01-15"); - - await deleteTaskFromIndexedDB(task._id); - - const deletedTask = await compassLocalDB.tasks.get(task._id); - expect(deletedTask).toBeUndefined(); - }); - - it("should not throw error when deleting non-existent task", async () => { - await expect( - deleteTaskFromIndexedDB("non-existent-id"), - ).resolves.not.toThrow(); - }); - }); - - describe("clearTasksForDateKey", () => { - it("should clear all tasks for a specific dateKey", async () => { - const task1 = createMockTask({ title: "Task 1" }); - const task2 = createMockTask({ title: "Task 2" }); - const task3 = createMockTask({ title: "Task 3 - other day" }); - - await saveTaskToIndexedDB(task1, "2024-01-15"); - await saveTaskToIndexedDB(task2, "2024-01-15"); - await saveTaskToIndexedDB(task3, "2024-01-16"); - - await clearTasksForDateKey("2024-01-15"); - - const tasksDay1 = await loadTasksFromIndexedDB("2024-01-15"); - const tasksDay2 = await loadTasksFromIndexedDB("2024-01-16"); - - expect(tasksDay1).toHaveLength(0); - expect(tasksDay2).toHaveLength(1); - }); - }); - - describe("clearAllTasksFromIndexedDB", () => { - it("should clear all tasks from IndexedDB", async () => { - const task1 = createMockTask(); - const task2 = createMockTask(); - - await saveTaskToIndexedDB(task1, "2024-01-15"); - await saveTaskToIndexedDB(task2, "2024-01-16"); - - await clearAllTasksFromIndexedDB(); - - const allTasks = await compassLocalDB.tasks.toArray(); - expect(allTasks).toHaveLength(0); - }); - }); - - describe("moveTaskBetweenDates", () => { - it("should move a task from one date to another", async () => { - const task = createMockTask({ title: "Moving Task" }); - await saveTaskToIndexedDB(task, "2024-01-15"); - - await moveTaskBetweenDates(task, "2024-01-15", "2024-01-16"); - - const tasksDay1 = await loadTasksFromIndexedDB("2024-01-15"); - const tasksDay2 = await loadTasksFromIndexedDB("2024-01-16"); - - expect(tasksDay1).toHaveLength(0); - expect(tasksDay2).toHaveLength(1); - expect(tasksDay2[0].title).toBe("Moving Task"); - }); - - it("should update the dateKey on the moved task", async () => { - const task = createMockTask(); - await saveTaskToIndexedDB(task, "2024-01-15"); - - await moveTaskBetweenDates(task, "2024-01-15", "2024-01-16"); - - const savedTask = await compassLocalDB.tasks.get(task._id); - expect(savedTask?.dateKey).toBe("2024-01-16"); - }); - }); - - describe("loadAllTasksFromIndexedDB", () => { - it("should load all tasks from IndexedDB with dateKey", async () => { - const task1 = createMockTask({ title: "Task 1" }); - const task2 = createMockTask({ title: "Task 2" }); - - await saveTaskToIndexedDB(task1, "2024-01-15"); - await saveTaskToIndexedDB(task2, "2024-01-16"); - - const allTasks = await loadAllTasksFromIndexedDB(); - - expect(allTasks).toHaveLength(2); - expect(allTasks.map((t) => t.title)).toContain("Task 1"); - expect(allTasks.map((t) => t.title)).toContain("Task 2"); - // Should include dateKey for each task - expect(allTasks.find((t) => t.title === "Task 1")?.dateKey).toBe( - "2024-01-15", - ); - expect(allTasks.find((t) => t.title === "Task 2")?.dateKey).toBe( - "2024-01-16", - ); - }); - - it("should return empty array when no tasks exist", async () => { - const allTasks = await loadAllTasksFromIndexedDB(); - expect(allTasks).toHaveLength(0); - }); - }); -}); diff --git a/packages/web/src/common/utils/storage/task.storage.util.ts b/packages/web/src/common/utils/storage/task.storage.util.ts deleted file mode 100644 index ea24145d56..0000000000 --- a/packages/web/src/common/utils/storage/task.storage.util.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { - Task, - normalizeTask, - normalizeTasks, -} from "@web/common/types/task.types"; -import { StoredTask, compassLocalDB } from "./compass-local.db"; -import { handleDatabaseError } from "./db-errors.util"; -import { ensureDatabaseReady } from "./db-init.util"; - -/** - * Saves a single task to IndexedDB with its associated dateKey. - * Uses put() to handle both new and existing tasks. - */ -export async function saveTaskToIndexedDB( - task: Task, - dateKey: string, -): Promise { - const normalizedTask = normalizeTask(task); - - try { - await ensureDatabaseReady(); - const storedTask: StoredTask = { ...normalizedTask, dateKey }; - await compassLocalDB.tasks.put(storedTask); - } catch (error) { - handleDatabaseError(error, "save"); - } -} - -/** - * Saves multiple tasks to IndexedDB for a specific dateKey. - * Replaces all existing tasks for that dateKey. - */ -export async function saveTasksToIndexedDB( - dateKey: string, - tasks: Task[], -): Promise { - try { - await ensureDatabaseReady(); - - const storedTasks: StoredTask[] = normalizeTasks(tasks).map((task) => ({ - ...task, - dateKey, - })); - - await compassLocalDB.transaction("rw", compassLocalDB.tasks, async () => { - // Replace all tasks for this date atomically - await compassLocalDB.tasks.where("dateKey").equals(dateKey).delete(); - - if (storedTasks.length > 0) { - await compassLocalDB.tasks.bulkPut(storedTasks); - } - }); - } catch (error) { - handleDatabaseError(error, "save"); - } -} - -/** - * Loads all tasks from IndexedDB for a specific dateKey. - */ -export async function loadTasksFromIndexedDB(dateKey: string): Promise { - try { - await ensureDatabaseReady(); - - const storedTasks = await compassLocalDB.tasks - .where("dateKey") - .equals(dateKey) - .toArray(); - - // Remove dateKey and normalize legacy records (e.g. missing user). - return storedTasks.map(({ dateKey: _, ...task }) => normalizeTask(task)); - } catch (error) { - handleDatabaseError(error, "load"); - return []; - } -} - -/** - * Deletes a task from IndexedDB by its ID. - */ -export async function deleteTaskFromIndexedDB(taskId: string): Promise { - try { - await ensureDatabaseReady(); - await compassLocalDB.tasks.delete(taskId); - } catch (error) { - handleDatabaseError(error, "delete"); - } -} - -/** - * Clears all tasks for a specific dateKey from IndexedDB. - */ -export async function clearTasksForDateKey(dateKey: string): Promise { - try { - await ensureDatabaseReady(); - await compassLocalDB.tasks.where("dateKey").equals(dateKey).delete(); - } catch (error) { - handleDatabaseError(error, "clear"); - } -} - -/** - * Clears all tasks from IndexedDB. Used for cleanup or testing. - */ -export async function clearAllTasksFromIndexedDB(): Promise { - try { - await ensureDatabaseReady(); - await compassLocalDB.tasks.clear(); - } catch (error) { - handleDatabaseError(error, "clear"); - } -} - -/** - * Moves a task from one date to another. - */ -export async function moveTaskBetweenDates( - task: Task, - fromDateKey: string, - toDateKey: string, -): Promise { - try { - await ensureDatabaseReady(); - const normalizedTask = normalizeTask(task); - - await compassLocalDB.transaction("rw", compassLocalDB.tasks, async () => { - const existingTask = await compassLocalDB.tasks.get(normalizedTask._id); - - // If the task exists for a different date, don't move it. - if (existingTask && existingTask.dateKey !== fromDateKey) { - return; - } - - // Remove from source date (task id stays the same) - await compassLocalDB.tasks.delete(normalizedTask._id); - - // Add to target date - const storedTask: StoredTask = { ...normalizedTask, dateKey: toDateKey }; - await compassLocalDB.tasks.put(storedTask); - }); - } catch (error) { - handleDatabaseError(error, "move"); - } -} - -/** - * Loads all tasks from IndexedDB. Used for migration or bulk operations. - */ -export async function loadAllTasksFromIndexedDB(): Promise { - try { - await ensureDatabaseReady(); - return await compassLocalDB.tasks.toArray(); - } catch (error) { - handleDatabaseError(error, "load"); - return []; - } -} diff --git a/packages/web/src/common/utils/sync/local-event-sync.util.test.ts b/packages/web/src/common/utils/sync/local-event-sync.util.test.ts index f169500940..f971806e4a 100644 --- a/packages/web/src/common/utils/sync/local-event-sync.util.test.ts +++ b/packages/web/src/common/utils/sync/local-event-sync.util.test.ts @@ -1,7 +1,9 @@ import { Event_Core } from "@core/types/event.types"; import { createMockStandaloneEvent } from "@core/util/test/ccal.event.factory"; -import { compassLocalDB } from "@web/common/utils/storage/compass-local.db"; -import { saveEventToIndexedDB } from "@web/common/utils/storage/event.storage.util"; +import { + ensureStorageReady, + getStorageAdapter, +} from "@web/common/storage/adapter/adapter"; import { EventApi } from "@web/ducks/events/event.api"; import { syncLocalEventsToCloud } from "./local-event-sync.util"; @@ -13,7 +15,8 @@ describe("syncLocalEventsToCloud", () => { beforeEach(async () => { mockCreate.mockResolvedValue({} as never); - await compassLocalDB.events.clear(); + await ensureStorageReady(); + await getStorageAdapter().clearAllEvents(); }); afterEach(() => { @@ -24,8 +27,8 @@ describe("syncLocalEventsToCloud", () => { const event1 = createMockEvent(); const event2 = createMockEvent(); - await saveEventToIndexedDB(event1); - await saveEventToIndexedDB(event2); + await getStorageAdapter().putEvent(event1); + await getStorageAdapter().putEvent(event2); const count = await syncLocalEventsToCloud(); @@ -38,7 +41,7 @@ describe("syncLocalEventsToCloud", () => { ); expect(count).toBe(2); - const remainingEvents = await compassLocalDB.events.toArray(); + const remainingEvents = await getStorageAdapter().getAllEvents(); expect(remainingEvents).toHaveLength(0); }); diff --git a/packages/web/src/common/utils/sync/local-event-sync.util.ts b/packages/web/src/common/utils/sync/local-event-sync.util.ts index b51d0e611b..fb136d0da5 100644 --- a/packages/web/src/common/utils/sync/local-event-sync.util.ts +++ b/packages/web/src/common/utils/sync/local-event-sync.util.ts @@ -1,19 +1,21 @@ import { Event_Core } from "@core/types/event.types"; import { - clearEventsFromIndexedDB, - loadAllEventsFromIndexedDB, -} from "@web/common/utils/storage/event.storage.util"; + ensureStorageReady, + getStorageAdapter, +} from "@web/common/storage/adapter/adapter"; import { EventApi } from "@web/ducks/events/event.api"; export async function syncLocalEventsToCloud(): Promise { - const events = await loadAllEventsFromIndexedDB(); + await ensureStorageReady(); + const adapter = getStorageAdapter(); + const events = await adapter.getAllEvents(); if (events.length === 0) { return 0; } await EventApi.create(events as Event_Core[]); - await clearEventsFromIndexedDB(); + await adapter.clearAllEvents(); return events.length; } diff --git a/packages/web/src/ducks/events/sagas/event.sagas.test.ts b/packages/web/src/ducks/events/sagas/event.sagas.test.ts index 93401d86d6..64548a7f59 100644 --- a/packages/web/src/ducks/events/sagas/event.sagas.test.ts +++ b/packages/web/src/ducks/events/sagas/event.sagas.test.ts @@ -3,12 +3,15 @@ import { Schema_Event } from "@core/types/event.types"; import { createMockStandaloneEvent } from "@core/util/test/ccal.event.factory"; import { createStoreWithEvents } from "@web/__tests__/utils/state/store.test.util"; import { session } from "@web/common/classes/Session"; +import { + ensureStorageReady, + getStorageAdapter, +} from "@web/common/storage/adapter/adapter"; import { sagaMiddleware } from "@web/common/store/middlewares"; import { Schema_GridEvent, Schema_WebEvent, } from "@web/common/types/web.event.types"; -import { compassLocalDB } from "@web/common/utils/storage/compass-local.db"; import { EventApi } from "@web/ducks/events/event.api"; import { selectEventById } from "@web/ducks/events/selectors/event.selectors"; import { selectIsEventPending } from "@web/ducks/events/selectors/pending.selectors"; @@ -462,7 +465,8 @@ describe("createEvent saga - unauthenticated users", () => { beforeEach(async () => { jest.clearAllMocks(); try { - await compassLocalDB.events.clear(); + await ensureStorageReady(); + await getStorageAdapter().clearAllEvents(); } catch (error) { console.error(error); // Expect errors if database doesn't exist yet @@ -476,7 +480,7 @@ describe("createEvent saga - unauthenticated users", () => { afterEach(async () => { try { - await compassLocalDB.events.clear(); + await getStorageAdapter().clearAllEvents(); } catch (error) { console.error(error); // Expect errors if database doesn't exist yet @@ -503,7 +507,8 @@ describe("createEvent saga - unauthenticated users", () => { expect(eventIds).toHaveLength(1); const eventId = eventIds[0]; - const savedEvent = await compassLocalDB.events.get(eventId); + const allEvents = await getStorageAdapter().getAllEvents(); + const savedEvent = allEvents.find((e) => e._id === eventId); expect(savedEvent).toBeDefined(); expect(savedEvent?._id).toBe(eventId); }); diff --git a/packages/web/src/ducks/events/sagas/someday.sagas.test.ts b/packages/web/src/ducks/events/sagas/someday.sagas.test.ts index 4e5d3065eb..33da4c675d 100644 --- a/packages/web/src/ducks/events/sagas/someday.sagas.test.ts +++ b/packages/web/src/ducks/events/sagas/someday.sagas.test.ts @@ -4,10 +4,12 @@ import { Schema_Event } from "@core/types/event.types"; import dayjs from "@core/util/date/dayjs"; import { createStoreWithEvents } from "@web/__tests__/utils/state/store.test.util"; import { session } from "@web/common/classes/Session"; +import { + ensureStorageReady, + getStorageAdapter, +} from "@web/common/storage/adapter/adapter"; import { sagaMiddleware } from "@web/common/store/middlewares"; import { Response_HttpPaginatedSuccess } from "@web/common/types/api.types"; -import { compassLocalDB } from "@web/common/utils/storage/compass-local.db"; -import { saveEventToIndexedDB } from "@web/common/utils/storage/event.storage.util"; import { EventApi } from "@web/ducks/events/event.api"; import { getSomedayEventsSlice } from "@web/ducks/events/slices/someday.slice"; import { sagas } from "@web/store/sagas"; @@ -25,7 +27,8 @@ describe("getSomedayEvents saga", () => { beforeEach(async () => { jest.clearAllMocks(); try { - await compassLocalDB.events.clear(); + await ensureStorageReady(); + await getStorageAdapter().clearAllEvents(); } catch (error) { console.error(error); // Expect errors if database doesn't exist yet @@ -38,7 +41,7 @@ describe("getSomedayEvents saga", () => { afterEach(async () => { try { - await compassLocalDB.events.clear(); + await getStorageAdapter().clearAllEvents(); } catch (error) { console.error(error); // Expect errors if database doesn't exist yet @@ -132,7 +135,7 @@ describe("getSomedayEvents saga", () => { user: "UNAUTHENTICATED_USER", }; - await saveEventToIndexedDB(somedayEvent); + await getStorageAdapter().putEvent(somedayEvent); const action = getSomedayEventsSlice.actions.request({ startDate, @@ -210,8 +213,8 @@ describe("getSomedayEvents saga", () => { user: "UNAUTHENTICATED_USER", }; - await saveEventToIndexedDB(thisMonthEvent); - await saveEventToIndexedDB(nextMonthEvent); + await getStorageAdapter().putEvent(thisMonthEvent); + await getStorageAdapter().putEvent(nextMonthEvent); const action = getSomedayEventsSlice.actions.request({ startDate, @@ -259,8 +262,8 @@ describe("getSomedayEvents saga", () => { user: "UNAUTHENTICATED_USER", }; - await saveEventToIndexedDB(somedayEvent); - await saveEventToIndexedDB(regularEvent); + await getStorageAdapter().putEvent(somedayEvent); + await getStorageAdapter().putEvent(regularEvent); const action = getSomedayEventsSlice.actions.request({ startDate, diff --git a/packages/web/src/views/Day/context/TaskContext.tsx b/packages/web/src/views/Day/context/TaskContext.tsx index 8c03fa8ec4..4a09e49bcf 100644 --- a/packages/web/src/views/Day/context/TaskContext.tsx +++ b/packages/web/src/views/Day/context/TaskContext.tsx @@ -52,7 +52,10 @@ interface TaskProviderProps { export function TaskProvider({ children }: TaskProviderProps) { const { dateInView, navigateToNextDay, navigateToPreviousDay } = useDateNavigation(); - const state = useTaskState({ currentDate: dateInView.toDate() }); + const state = useTaskState({ + currentDate: dateInView.toDate(), + taskRepository: localTaskRepository, + }); const actions = useTaskActions({ setTasks: state.setTasks, tasks: state.tasks, diff --git a/packages/web/src/views/Day/context/__tests__/TaskContext.test.tsx b/packages/web/src/views/Day/context/__tests__/TaskContext.test.tsx index d0d2f4bb8f..f185e6b099 100644 --- a/packages/web/src/views/Day/context/__tests__/TaskContext.test.tsx +++ b/packages/web/src/views/Day/context/__tests__/TaskContext.test.tsx @@ -5,11 +5,11 @@ import dayjs from "@core/util/date/dayjs"; import { renderHook, waitFor } from "@web/__tests__/__mocks__/mock.render"; import { createMockTask } from "@web/__tests__/utils/factories/task.factory"; import { clearCompassLocalDb } from "@web/__tests__/utils/storage/indexeddb.test.util"; -import { Task } from "@web/common/types/task.types"; import { - loadTasksFromIndexedDB, - saveTasksToIndexedDB, -} from "@web/common/utils/storage/task.storage.util"; + ensureStorageReady, + getStorageAdapter, +} from "@web/common/storage/adapter/adapter"; +import { Task } from "@web/common/types/task.types"; import { useTasks } from "@web/views/Day/hooks/tasks/useTasks"; import { TaskProviderWrapper } from "@web/views/Day/util/day.test-util"; @@ -164,11 +164,13 @@ describe("TaskProvider", () => { const dateKey = today.format(dayjs.DateFormat.YEAR_MONTH_DAY_FORMAT); await waitFor(async () => { - const stored = await loadTasksFromIndexedDB(dateKey); + await ensureStorageReady(); + const stored = await getStorageAdapter().getTasks(dateKey); expect(stored).toHaveLength(1); }); - const stored = await loadTasksFromIndexedDB(dateKey); + await ensureStorageReady(); + const stored = await getStorageAdapter().getTasks(dateKey); expect(stored[0].title).toBe("Persisted task"); expect(stored[0]._id).toBe(createdTaskId); }); @@ -180,7 +182,8 @@ describe("TaskProvider", () => { createMockTask({ _id: "task-1", title: "Loaded task" }), ]; - await saveTasksToIndexedDB(dateKey, mockTasks); + await ensureStorageReady(); + await getStorageAdapter().putTasks(dateKey, mockTasks); const { result } = renderHook(useTasks, { wrapper: Wrapper }); @@ -197,7 +200,8 @@ describe("TaskProvider", () => { const mockTasks: Task[] = [ createMockTask({ _id: "task-1", title: "Existing task" }), ]; - await saveTasksToIndexedDB(dateKey, mockTasks); + await ensureStorageReady(); + await getStorageAdapter().putTasks(dateKey, mockTasks); const { result } = renderHook(useTasks, { wrapper: Wrapper }); @@ -205,7 +209,7 @@ describe("TaskProvider", () => { expect(result.current.tasks).toHaveLength(1); }); - const stored = await loadTasksFromIndexedDB(dateKey); + const stored = await getStorageAdapter().getTasks(dateKey); expect(stored).toHaveLength(1); expect(stored[0].title).toBe("Existing task"); }); @@ -219,7 +223,8 @@ describe("TaskProvider", () => { createMockTask({ _id: "task-3", status: "todo" }), ]; - await saveTasksToIndexedDB(dateKey, mockTasks); + await ensureStorageReady(); + await getStorageAdapter().putTasks(dateKey, mockTasks); const { result } = renderHook(useTasks, { wrapper: Wrapper }); diff --git a/packages/web/src/views/Day/hooks/tasks/useLoadTasksByDateEffect.test.ts b/packages/web/src/views/Day/hooks/tasks/useLoadTasksByDateEffect.test.ts index 6ab3dafe29..89c35ec1b3 100644 --- a/packages/web/src/views/Day/hooks/tasks/useLoadTasksByDateEffect.test.ts +++ b/packages/web/src/views/Day/hooks/tasks/useLoadTasksByDateEffect.test.ts @@ -2,12 +2,21 @@ import { act } from "react"; import { useRef, useState } from "react"; import { renderHook, waitFor } from "@testing-library/react"; import { createMockTask } from "@web/__tests__/utils/factories/task.factory"; +import { TaskRepository } from "@web/common/repositories/task/task.repository"; import { Task } from "@web/common/types/task.types"; -import * as taskStorageUtil from "@web/common/utils/storage/task.storage.util"; import { useLoadTasksByDateEffect } from "@web/views/Day/hooks/tasks/useLoadTasksByDateEffect"; -jest.mock("@web/common/utils/storage/task.storage.util", () => ({ - loadTasksFromIndexedDB: jest.fn(), +const mockGet = jest.fn(); +const mockTaskRepository: TaskRepository = { + get: mockGet, + save: jest.fn().mockResolvedValue(undefined), + delete: jest.fn().mockResolvedValue(undefined), + move: jest.fn().mockResolvedValue(undefined), + reorder: jest.fn().mockResolvedValue(undefined), +}; + +jest.mock("@web/common/storage/adapter/adapter", () => ({ + ensureStorageReady: jest.fn().mockResolvedValue(undefined), })); interface Deferred { @@ -34,6 +43,7 @@ function useLoadHarness(dateKey: string) { useLoadTasksByDateEffect({ dateKey, + taskRepository: mockTaskRepository, setTasksState, setIsLoadingTasks, setDidLoadFail, @@ -52,17 +62,13 @@ function useLoadHarness(dateKey: string) { } describe("useLoadTasksByDateEffect", () => { - const loadTasksMock = - taskStorageUtil.loadTasksFromIndexedDB as jest.MockedFunction< - typeof taskStorageUtil.loadTasksFromIndexedDB - >; - beforeEach(() => { jest.clearAllMocks(); + mockGet.mockReset(); }); it("loads tasks for date and sorts by status/order", async () => { - loadTasksMock.mockResolvedValueOnce([ + mockGet.mockResolvedValueOnce([ createMockTask({ _id: "completed-1", status: "completed", order: 0 }), createMockTask({ _id: "todo-2", status: "todo", order: 1 }), createMockTask({ _id: "todo-1", status: "todo", order: 0 }), @@ -86,7 +92,7 @@ describe("useLoadTasksByDateEffect", () => { it("clears tasks while loading a new date and ignores stale results", async () => { const firstLoad = createDeferred(); const secondLoad = createDeferred(); - loadTasksMock + mockGet .mockReturnValueOnce(firstLoad.promise) .mockReturnValueOnce(secondLoad.promise); @@ -128,7 +134,7 @@ describe("useLoadTasksByDateEffect", () => { }); it("sets failed state when load throws", async () => { - loadTasksMock.mockRejectedValueOnce(new Error("load failed")); + mockGet.mockRejectedValueOnce(new Error("load failed")); const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); const { result } = renderHook(() => useLoadHarness("2025-10-27")); diff --git a/packages/web/src/views/Day/hooks/tasks/useLoadTasksByDateEffect.ts b/packages/web/src/views/Day/hooks/tasks/useLoadTasksByDateEffect.ts index c2a7c48683..49080d06bb 100644 --- a/packages/web/src/views/Day/hooks/tasks/useLoadTasksByDateEffect.ts +++ b/packages/web/src/views/Day/hooks/tasks/useLoadTasksByDateEffect.ts @@ -1,10 +1,12 @@ import { Dispatch, MutableRefObject, SetStateAction, useEffect } from "react"; +import { TaskRepository } from "@web/common/repositories/task/task.repository"; +import { ensureStorageReady } from "@web/common/storage/adapter/adapter"; import { Task } from "@web/common/types/task.types"; -import { loadTasksFromIndexedDB } from "@web/common/utils/storage/task.storage.util"; import { sortTasksByStatus } from "@web/common/utils/task/sort.task"; interface UseLoadTasksByDateEffectProps { dateKey: string; + taskRepository: TaskRepository; setTasksState: Dispatch>; setIsLoadingTasks: Dispatch>; setDidLoadFail: Dispatch>; @@ -15,6 +17,7 @@ interface UseLoadTasksByDateEffectProps { export function useLoadTasksByDateEffect({ dateKey, + taskRepository, setTasksState, setIsLoadingTasks, setDidLoadFail, @@ -33,7 +36,10 @@ export function useLoadTasksByDateEffect({ setDidLoadFail(false); setIsLoadingTasks(true); - void loadTasksFromIndexedDB(dateKey) + void (async () => { + await ensureStorageReady(); + return taskRepository.get(dateKey); + })() .then((loadedTasks) => { if (isCancelled || requestId !== loadRequestIdRef.current) return; @@ -45,7 +51,7 @@ export function useLoadTasksByDateEffect({ .catch((error) => { if (isCancelled || requestId !== loadRequestIdRef.current) return; - console.error("Failed to load tasks from IndexedDB:", error); + console.error("Failed to load tasks from storage:", error); setTasksState([]); setLoadedDateKey(dateKey); setDidLoadFail(true); @@ -57,6 +63,7 @@ export function useLoadTasksByDateEffect({ }; }, [ dateKey, + taskRepository, isDirtyRef, loadRequestIdRef, setDidLoadFail, diff --git a/packages/web/src/views/Day/hooks/tasks/useSaveTasksByDateEffect.test.ts b/packages/web/src/views/Day/hooks/tasks/useSaveTasksByDateEffect.test.ts index d3f56f6821..54c8816430 100644 --- a/packages/web/src/views/Day/hooks/tasks/useSaveTasksByDateEffect.test.ts +++ b/packages/web/src/views/Day/hooks/tasks/useSaveTasksByDateEffect.test.ts @@ -1,12 +1,21 @@ import { useEffect, useRef } from "react"; import { renderHook, waitFor } from "@testing-library/react"; import { createMockTask } from "@web/__tests__/utils/factories/task.factory"; +import { TaskRepository } from "@web/common/repositories/task/task.repository"; import { Task } from "@web/common/types/task.types"; -import * as taskStorageUtil from "@web/common/utils/storage/task.storage.util"; import { useSaveTasksByDateEffect } from "@web/views/Day/hooks/tasks/useSaveTasksByDateEffect"; -jest.mock("@web/common/utils/storage/task.storage.util", () => ({ - saveTasksToIndexedDB: jest.fn(), +const mockSave = jest.fn(); +const mockTaskRepository: TaskRepository = { + get: jest.fn().mockResolvedValue([]), + save: mockSave, + delete: jest.fn().mockResolvedValue(undefined), + move: jest.fn().mockResolvedValue(undefined), + reorder: jest.fn().mockResolvedValue(undefined), +}; + +jest.mock("@web/common/storage/adapter/adapter", () => ({ + ensureStorageReady: jest.fn().mockResolvedValue(undefined), })); interface SaveHarnessProps { @@ -36,6 +45,7 @@ function useSaveHarness({ useSaveTasksByDateEffect({ dateKey, tasks, + taskRepository: mockTaskRepository, isLoadingTasks, didLoadFail, loadedDateKey, @@ -47,14 +57,9 @@ function useSaveHarness({ } describe("useSaveTasksByDateEffect", () => { - const saveTasksMock = - taskStorageUtil.saveTasksToIndexedDB as jest.MockedFunction< - typeof taskStorageUtil.saveTasksToIndexedDB - >; - beforeEach(() => { jest.clearAllMocks(); - saveTasksMock.mockResolvedValue(undefined); + mockSave.mockResolvedValue(undefined); }); it("saves tasks and clears dirty flag when all guards pass", async () => { @@ -72,7 +77,7 @@ describe("useSaveTasksByDateEffect", () => { ); await waitFor(() => { - expect(saveTasksMock).toHaveBeenCalledWith("2025-10-27", tasks); + expect(mockSave).toHaveBeenCalledWith("2025-10-27", tasks); expect(result.current.isDirtyRef.current).toBe(false); }); }); @@ -125,13 +130,13 @@ describe("useSaveTasksByDateEffect", () => { ); await waitFor(() => { - expect(saveTasksMock).not.toHaveBeenCalled(); + expect(mockSave).not.toHaveBeenCalled(); }); }); it("keeps dirty flag when save fails", async () => { const tasks = [createMockTask({ _id: "task-1" })]; - saveTasksMock.mockRejectedValueOnce(new Error("save failed")); + mockSave.mockRejectedValueOnce(new Error("save failed")); const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); const { result } = renderHook(() => @@ -146,7 +151,7 @@ describe("useSaveTasksByDateEffect", () => { ); await waitFor(() => { - expect(saveTasksMock).toHaveBeenCalledWith("2025-10-27", tasks); + expect(mockSave).toHaveBeenCalledWith("2025-10-27", tasks); expect(consoleErrorSpy).toHaveBeenCalled(); }); diff --git a/packages/web/src/views/Day/hooks/tasks/useSaveTasksByDateEffect.ts b/packages/web/src/views/Day/hooks/tasks/useSaveTasksByDateEffect.ts index 7998be11a5..42dbdbfba3 100644 --- a/packages/web/src/views/Day/hooks/tasks/useSaveTasksByDateEffect.ts +++ b/packages/web/src/views/Day/hooks/tasks/useSaveTasksByDateEffect.ts @@ -1,10 +1,12 @@ import { MutableRefObject, useEffect } from "react"; +import { TaskRepository } from "@web/common/repositories/task/task.repository"; +import { ensureStorageReady } from "@web/common/storage/adapter/adapter"; import { Task } from "@web/common/types/task.types"; -import { saveTasksToIndexedDB } from "@web/common/utils/storage/task.storage.util"; interface UseSaveTasksByDateEffectProps { dateKey: string; tasks: Task[]; + taskRepository: TaskRepository; isLoadingTasks: boolean; didLoadFail: boolean; loadedDateKey: string | null; @@ -15,6 +17,7 @@ interface UseSaveTasksByDateEffectProps { export function useSaveTasksByDateEffect({ dateKey, tasks, + taskRepository, isLoadingTasks, didLoadFail, loadedDateKey, @@ -31,13 +34,16 @@ export function useSaveTasksByDateEffect({ const requestId = saveRequestIdRef.current + 1; saveRequestIdRef.current = requestId; - void saveTasksToIndexedDB(dateKey, tasks) + void (async () => { + await ensureStorageReady(); + await taskRepository.save(dateKey, tasks); + })() .then(() => { if (isCancelled || requestId !== saveRequestIdRef.current) return; isDirtyRef.current = false; }) .catch((error) => { - console.error("Failed to save tasks to IndexedDB:", error); + console.error("Failed to save tasks to storage:", error); }); return () => { @@ -50,6 +56,7 @@ export function useSaveTasksByDateEffect({ isLoadingTasks, loadedDateKey, saveRequestIdRef, + taskRepository, tasks, ]); } diff --git a/packages/web/src/views/Day/hooks/tasks/useTaskActions.test.ts b/packages/web/src/views/Day/hooks/tasks/useTaskActions.test.ts index 1269db5fa8..e0949482ea 100644 --- a/packages/web/src/views/Day/hooks/tasks/useTaskActions.test.ts +++ b/packages/web/src/views/Day/hooks/tasks/useTaskActions.test.ts @@ -183,9 +183,10 @@ describe("useTaskActions - migration", () => { await waitFor(() => { expect(mockTaskRepository.save).toHaveBeenCalledWith("2025-10-28", []); }); - expect(mockTaskRepository.save).toHaveBeenCalledWith("2025-10-27", [ + expect(mockTaskRepository.save).toHaveBeenCalledWith( + "2025-10-27", mockTask, - ]); + ); // Should clear undo state expect(mockSetUndoState).toHaveBeenCalledWith(null); @@ -223,9 +224,10 @@ describe("useTaskActions - migration", () => { await waitFor(() => { expect(mockTaskRepository.save).toHaveBeenCalledWith("2025-10-26", []); }); - expect(mockTaskRepository.save).toHaveBeenCalledWith("2025-10-27", [ + expect(mockTaskRepository.save).toHaveBeenCalledWith( + "2025-10-27", mockTask, - ]); + ); }); it("does not restore if dateInView has changed", () => { diff --git a/packages/web/src/views/Day/hooks/tasks/useTaskActions.ts b/packages/web/src/views/Day/hooks/tasks/useTaskActions.ts index a8cc7777e4..3bbee5126a 100644 --- a/packages/web/src/views/Day/hooks/tasks/useTaskActions.ts +++ b/packages/web/src/views/Day/hooks/tasks/useTaskActions.ts @@ -98,76 +98,69 @@ export function useTaskActions({ }); }; - const restoreTask = useCallback(() => { - if (isEditingBlocked) return; - if (!undoState) return; - - if (undoState.type === "delete") { - // Restore deleted task - setTasks((prev) => sortTasksByStatus([...prev, undoState.task])); - } else if ( - undoState.type === "migrate" && - undoState.fromDate && - undoState.direction && - dateInView - ) { - const currentDateKey = getDateKey(dateInView.toDate()); - - // Only restore if we're still on the same date where the migration happened - if (currentDateKey === undoState.fromDate) { - // Add the task back to the list - setTasks((prev) => sortTasksByStatus([...prev, undoState.task])); - - // Calculate the target date (where the task was migrated to) - const targetDate = dateInView.add( - undoState.direction === "forward" ? 1 : -1, - "day", - ); - const targetDateKey = getDateKey(targetDate.toDate()); - - // Async storage operations (fire and forget since we're in a callback) - const restoreInStorage = async () => { - try { - // Remove the task from the target date in storage - const targetDateTasks = await taskRepository.get(targetDateKey); - const updatedTargetTasks = targetDateTasks.filter( - (t: Task) => t._id !== undoState.task._id, - ); - await taskRepository.save(targetDateKey, updatedTargetTasks); - - // Restore the task to the original date in storage. - const originalDateTasks = await taskRepository.get( - undoState.fromDate, - ); - const alreadyExists = originalDateTasks.some( - (task) => task._id === undoState.task._id, - ); - if (!alreadyExists) { - await taskRepository.save(undoState.fromDate, [ - ...originalDateTasks, - undoState.task, - ]); + const restoreTask = useCallback( + (operation: UndoOperation | null = undoState ?? null) => { + if (isEditingBlocked) return; + if (!operation) return; + + if (operation.type === "delete") { + // Restore deleted task + setTasks((prev) => sortTasksByStatus([...prev, operation.task])); + } else if ( + operation.type === "migrate" && + operation.fromDate && + operation.direction && + dateInView + ) { + const currentDateKey = getDateKey(dateInView.toDate()); + + const fromDate = operation.fromDate; + // Only restore if we're still on the same date where the migration happened + if (currentDateKey === fromDate) { + // Add the task back to the list + setTasks((prev) => sortTasksByStatus([...prev, operation.task])); + + // Calculate the target date (where the task was migrated to) + const targetDate = dateInView.add( + operation.direction === "forward" ? 1 : -1, + "day", + ); + const targetDateKey = getDateKey(targetDate.toDate()); + + // Async storage operations (fire and forget since we're in a callback) + const restoreInStorage = async () => { + try { + // Remove the task from the target date in storage + const targetDateTasks = await taskRepository.get(targetDateKey); + const updatedTargetTasks = targetDateTasks.filter( + (t: Task) => t._id !== operation.task._id, + ); + await taskRepository.save(targetDateKey, updatedTargetTasks); + + // Restore the task to the original date in storage (single write, no read) + await taskRepository.save(fromDate, operation.task); + } catch (error) { + console.error("Failed to restore task in repository:", error); } - } catch (error) { - console.error("Failed to restore task in repository:", error); - } - }; - restoreInStorage(); + }; + restoreInStorage(); + } } - } - // Clear the undo state - setUndoState?.(null); - setUndoToastId?.(null); - }, [ - undoState, - dateInView, - setUndoState, - setUndoToastId, - setTasks, - taskRepository, - isEditingBlocked, - ]); + // Clear the undo state + setUndoState?.(null); + setUndoToastId?.(null); + }, + [ + undoState, + dateInView, + setUndoState, + setUndoToastId, + setTasks, + taskRepository, + isEditingBlocked, + ], + ); const deleteTask = (taskId: string) => { if (isEditingBlocked) return; @@ -180,13 +173,18 @@ export function useTaskActions({ toast.dismiss(undoToastId); } + const deleteUndoOperation: UndoOperation = { + type: "delete", + task: taskToDelete, + }; + // Store the deleted task in unified undo state - setUndoState?.({ type: "delete", task: taskToDelete }); + setUndoState?.(deleteUndoOperation); // Remove task from the list setTasks((prev) => prev.filter((task) => task._id !== taskId)); - const toastId = showUndoDeleteToast(restoreTask); + const toastId = showUndoDeleteToast(() => restoreTask(deleteUndoOperation)); // Store the toast ID for potential dismissal setUndoToastId?.(toastId); @@ -350,13 +348,15 @@ export function useTaskActions({ : dateInView.subtract(1, "day"); const targetDateKey = getDateKey(targetDate.toDate()); - // Store the migrated task operation - setUndoState?.({ + const migrationUndoOperation: UndoOperation = { type: "migrate", task: taskToMigrate, fromDate: currentDateKey, direction, - }); + }; + + // Store the migrated task operation + setUndoState?.(migrationUndoOperation); // Move task in storage (async, fire and forget) taskRepository @@ -372,7 +372,9 @@ export function useTaskActions({ const onNavigate = direction === "forward" ? navigateToNextDay : navigateToPreviousDay; if (onNavigate) { - const toastId = showMigrationToast(direction, onNavigate, restoreTask); + const toastId = showMigrationToast(direction, onNavigate, () => + restoreTask(migrationUndoOperation), + ); setUndoToastId?.(toastId); } }, diff --git a/packages/web/src/views/Day/hooks/tasks/useTaskState.test.ts b/packages/web/src/views/Day/hooks/tasks/useTaskState.test.ts index 7de26bfbc8..b00429e3ac 100644 --- a/packages/web/src/views/Day/hooks/tasks/useTaskState.test.ts +++ b/packages/web/src/views/Day/hooks/tasks/useTaskState.test.ts @@ -1,14 +1,23 @@ import { act } from "react"; import { renderHook, waitFor } from "@testing-library/react"; import { createMockTask } from "@web/__tests__/utils/factories/task.factory"; +import { TaskRepository } from "@web/common/repositories/task/task.repository"; import { Task } from "@web/common/types/task.types"; import { getDateKey } from "@web/common/utils/storage/storage.util"; -import * as taskStorageUtil from "@web/common/utils/storage/task.storage.util"; import { useTaskState } from "@web/views/Day/hooks/tasks/useTaskState"; -jest.mock("@web/common/utils/storage/task.storage.util", () => ({ - loadTasksFromIndexedDB: jest.fn(), - saveTasksToIndexedDB: jest.fn().mockResolvedValue(undefined), +const mockGet = jest.fn(); +const mockSave = jest.fn().mockResolvedValue(undefined); +const mockTaskRepository: TaskRepository = { + get: mockGet, + save: mockSave, + delete: jest.fn().mockResolvedValue(undefined), + move: jest.fn().mockResolvedValue(undefined), + reorder: jest.fn().mockResolvedValue(undefined), +}; + +jest.mock("@web/common/storage/adapter/adapter", () => ({ + ensureStorageReady: jest.fn().mockResolvedValue(undefined), })); interface Deferred { @@ -25,15 +34,6 @@ function createDeferred(): Deferred { } describe("useTaskState", () => { - const loadTasksMock = - taskStorageUtil.loadTasksFromIndexedDB as jest.MockedFunction< - typeof taskStorageUtil.loadTasksFromIndexedDB - >; - const saveTasksMock = - taskStorageUtil.saveTasksToIndexedDB as jest.MockedFunction< - typeof taskStorageUtil.saveTasksToIndexedDB - >; - const dayOneDate = new Date("2025-10-27T12:00:00.000Z"); const dayTwoDate = new Date("2025-10-28T12:00:00.000Z"); const dayOneKey = getDateKey(dayOneDate); @@ -41,12 +41,13 @@ describe("useTaskState", () => { beforeEach(() => { jest.clearAllMocks(); - saveTasksMock.mockResolvedValue(undefined); + mockSave.mockResolvedValue(undefined); + mockGet.mockReset(); }); it("clears tasks and enters loading state when date changes", async () => { const dayTwoLoad = createDeferred(); - loadTasksMock + mockGet .mockResolvedValueOnce([ createMockTask({ _id: "task-1", @@ -60,7 +61,8 @@ describe("useTaskState", () => { .mockReturnValueOnce(dayTwoLoad.promise); const { result, rerender } = renderHook( - ({ currentDate }) => useTaskState({ currentDate }), + ({ currentDate }) => + useTaskState({ currentDate, taskRepository: mockTaskRepository }), { initialProps: { currentDate: dayOneDate } }, ); @@ -86,13 +88,17 @@ describe("useTaskState", () => { it("does not save previous-day tasks under the next date key", async () => { const dayTwoLoad = createDeferred(); - loadTasksMock - .mockResolvedValueOnce([]) - .mockReturnValueOnce(dayTwoLoad.promise); + mockGet.mockResolvedValueOnce([]).mockReturnValueOnce(dayTwoLoad.promise); const { result, rerender } = renderHook( - ({ currentDate }) => useTaskState({ currentDate }), - { initialProps: { currentDate: dayOneDate } }, + ({ currentDate, taskRepository }) => + useTaskState({ currentDate, taskRepository }), + { + initialProps: { + currentDate: dayOneDate, + taskRepository: mockTaskRepository, + }, + }, ); await waitFor(() => { @@ -106,22 +112,22 @@ describe("useTaskState", () => { }); await waitFor(() => { - expect(saveTasksMock).toHaveBeenCalledWith(dayOneKey, [localTask]); + expect(mockSave).toHaveBeenCalledWith(dayOneKey, [localTask]); }); - saveTasksMock.mockClear(); + mockSave.mockClear(); - rerender({ currentDate: dayTwoDate }); + rerender({ + currentDate: dayTwoDate, + taskRepository: mockTaskRepository, + }); await act(async () => { await Promise.resolve(); }); - expect(saveTasksMock).not.toHaveBeenCalledWith( - dayTwoKey, - expect.any(Array), - ); - expect(saveTasksMock).not.toHaveBeenCalled(); + expect(mockSave).not.toHaveBeenCalledWith(dayTwoKey, expect.any(Array)); + expect(mockSave).not.toHaveBeenCalled(); await act(async () => { dayTwoLoad.resolve([]); @@ -130,11 +136,14 @@ describe("useTaskState", () => { }); it("does not save empty tasks after a load failure", async () => { - loadTasksMock.mockRejectedValue(new Error("load failed")); + mockGet.mockRejectedValue(new Error("load failed")); const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); const { result } = renderHook(() => - useTaskState({ currentDate: dayOneDate }), + useTaskState({ + currentDate: dayOneDate, + taskRepository: mockTaskRepository, + }), ); await waitFor(() => { @@ -143,7 +152,7 @@ describe("useTaskState", () => { expect(result.current.tasks).toEqual([]); }); - expect(saveTasksMock).not.toHaveBeenCalled(); + expect(mockSave).not.toHaveBeenCalled(); consoleErrorSpy.mockRestore(); }); }); diff --git a/packages/web/src/views/Day/hooks/tasks/useTaskState.ts b/packages/web/src/views/Day/hooks/tasks/useTaskState.ts index e14cc775ba..1b517efe17 100644 --- a/packages/web/src/views/Day/hooks/tasks/useTaskState.ts +++ b/packages/web/src/views/Day/hooks/tasks/useTaskState.ts @@ -1,4 +1,5 @@ import { useCallback, useRef, useState } from "react"; +import { TaskRepository } from "@web/common/repositories/task/task.repository"; import { Task, UndoOperation } from "@web/common/types/task.types"; import { getDateKey } from "@web/common/utils/storage/storage.util"; import { useLoadTasksByDateEffect } from "@web/views/Day/hooks/tasks/useLoadTasksByDateEffect"; @@ -6,11 +7,13 @@ import { useSaveTasksByDateEffect } from "@web/views/Day/hooks/tasks/useSaveTask interface UseTaskStateProps { currentDate?: Date; + taskRepository: TaskRepository; } export function useTaskState({ currentDate = new Date(), -}: UseTaskStateProps = {}) { + taskRepository, +}: UseTaskStateProps) { const [tasks, setTasksState] = useState([]); const [isLoadingTasks, setIsLoadingTasks] = useState(true); const [didLoadFail, setDidLoadFail] = useState(false); @@ -39,6 +42,7 @@ export function useTaskState({ useLoadTasksByDateEffect({ dateKey, + taskRepository, setTasksState, setIsLoadingTasks, setDidLoadFail, @@ -49,6 +53,7 @@ export function useTaskState({ useSaveTasksByDateEffect({ dateKey, tasks, + taskRepository, isLoadingTasks, didLoadFail, loadedDateKey, diff --git a/packages/web/src/views/Now/components/TaskSelector/TaskSelector.test.tsx b/packages/web/src/views/Now/components/TaskSelector/TaskSelector.test.tsx index 017f2f7581..2bc8d04fde 100644 --- a/packages/web/src/views/Now/components/TaskSelector/TaskSelector.test.tsx +++ b/packages/web/src/views/Now/components/TaskSelector/TaskSelector.test.tsx @@ -4,6 +4,7 @@ import { RenderOptions, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import dayjs from "@core/util/date/dayjs"; import { createMockTask } from "@web/__tests__/utils/factories/task.factory"; +import { getTaskRepository } from "@web/common/repositories/task/task.repository.util"; import { Task } from "@web/common/types/task.types"; import * as storageUtil from "@web/common/utils/storage/storage.util"; import { CompassRequiredProviders } from "@web/components/CompassProvider/CompassProvider"; @@ -24,11 +25,24 @@ jest.mock("@web/views/Now/hooks/useAvailableTasks"); jest.mock("@web/common/utils/storage/storage.util", () => ({ ...jest.requireActual("@web/common/utils/storage/storage.util"), getDateKey: jest.fn(), - loadTasksFromStorage: jest.fn(), - saveTasksToStorage: jest.fn(), - updateTodayTasks: jest.fn(), })); +jest.mock("@web/common/repositories/task/task.repository.util", () => { + const mockSave = jest.fn().mockResolvedValue(undefined); + return { + getTaskRepository: jest.fn(() => ({ + save: mockSave, + })), + }; +}); + +jest.mock("@web/common/storage/adapter/adapter", () => ({ + ensureStorageReady: jest.fn().mockResolvedValue(undefined), +})); + +/** Get the save mock from the mocked getTaskRepository return value */ +const getMockSave = () => (getTaskRepository as jest.Mock)().save as jest.Mock; + const mockUseFocusedTask = useFocusedTask as jest.MockedFunction< typeof useFocusedTask >; @@ -86,12 +100,8 @@ describe("TaskSelector", () => { jest.clearAllMocks(); mockNavigate.mockClear(); mockSetFocusedTask.mockClear(); + getMockSave().mockResolvedValue(undefined); (storageUtil.getDateKey as jest.Mock).mockReturnValue(mockDateKey); - (storageUtil.loadTasksFromStorage as jest.Mock).mockResolvedValue( - mockTasks, - ); - (storageUtil.saveTasksToStorage as jest.Mock).mockResolvedValue(undefined); - (storageUtil.updateTodayTasks as jest.Mock).mockResolvedValue(mockTasks); // Use fake timers to control the current time jest.useFakeTimers(); @@ -305,14 +315,6 @@ describe("TaskSelector", () => { allTasks: tasks, hasCompletedTasks: false, }); - let lastUpdatedTasks: Task[] = []; - (storageUtil.updateTodayTasks as jest.Mock).mockImplementation( - async (updater: (taskList: Task[]) => Task[]) => { - lastUpdatedTasks = updater(tasks); - return lastUpdatedTasks; - }, - ); - renderWithNowProvider(); const checkButton = screen.getByRole("button", { @@ -321,10 +323,11 @@ describe("TaskSelector", () => { await user.click(checkButton); await waitFor(() => { - expect(storageUtil.updateTodayTasks).toHaveBeenCalledTimes(1); - expect( - lastUpdatedTasks.find((task) => task._id === "task-1")?.status, - ).toBe("completed"); + expect(getMockSave()).toHaveBeenCalledTimes(1); + expect(getMockSave()).toHaveBeenCalledWith( + mockDateKey, + expect.objectContaining({ _id: "task-1", status: "completed" }), + ); expect(mockSetFocusedTask).toHaveBeenCalledWith("task-2"); }); }); @@ -359,13 +362,6 @@ describe("TaskSelector", () => { allTasks: tasks, hasCompletedTasks: false, }); - let lastUpdatedTasks: Task[] = []; - (storageUtil.updateTodayTasks as jest.Mock).mockImplementation( - async (updater: (taskList: Task[]) => Task[]) => { - lastUpdatedTasks = updater(tasks); - return lastUpdatedTasks; - }, - ); renderWithNowProvider(); @@ -375,10 +371,11 @@ describe("TaskSelector", () => { await user.click(checkButton); await waitFor(() => { - expect(storageUtil.updateTodayTasks).toHaveBeenCalledTimes(1); - expect( - lastUpdatedTasks.find((task) => task._id === "task-2")?.status, - ).toBe("completed"); + expect(getMockSave()).toHaveBeenCalledTimes(1); + expect(getMockSave()).toHaveBeenCalledWith( + mockDateKey, + expect.objectContaining({ _id: "task-2", status: "completed" }), + ); expect(mockSetFocusedTask).toHaveBeenCalledWith("task-1"); }); }); @@ -405,13 +402,6 @@ describe("TaskSelector", () => { allTasks: tasks, hasCompletedTasks: false, }); - let lastUpdatedTasks: Task[] = []; - (storageUtil.updateTodayTasks as jest.Mock).mockImplementation( - async (updater: (taskList: Task[]) => Task[]) => { - lastUpdatedTasks = updater(tasks); - return lastUpdatedTasks; - }, - ); renderWithNowProvider(); @@ -421,10 +411,11 @@ describe("TaskSelector", () => { await user.click(checkButton); await waitFor(() => { - expect(storageUtil.updateTodayTasks).toHaveBeenCalledTimes(1); - expect( - lastUpdatedTasks.find((task) => task._id === "task-1")?.status, - ).toBe("completed"); + expect(getMockSave()).toHaveBeenCalledTimes(1); + expect(getMockSave()).toHaveBeenCalledWith( + mockDateKey, + expect.objectContaining({ _id: "task-1", status: "completed" }), + ); expect(mockNavigate).toHaveBeenCalledWith("/day"); }); }); diff --git a/packages/web/src/views/Now/components/TaskSelector/TaskSelector.tsx b/packages/web/src/views/Now/components/TaskSelector/TaskSelector.tsx index 2e72e7a068..1e415ebfb1 100644 --- a/packages/web/src/views/Now/components/TaskSelector/TaskSelector.tsx +++ b/packages/web/src/views/Now/components/TaskSelector/TaskSelector.tsx @@ -23,7 +23,7 @@ export const TaskSelector = () => { onPreviousTask={handlePreviousTask} onNextTask={handleNextTask} onUpdateDescription={(description: string) => - updateTaskDescription(focusedTask._id, description) + updateTaskDescription(focusedTask, description) } /> ); diff --git a/packages/web/src/views/Now/context/NowViewProvider.tsx b/packages/web/src/views/Now/context/NowViewProvider.tsx index 54f3872e87..b5954d6153 100644 --- a/packages/web/src/views/Now/context/NowViewProvider.tsx +++ b/packages/web/src/views/Now/context/NowViewProvider.tsx @@ -1,8 +1,10 @@ import React, { createContext, useCallback } from "react"; import { useNavigate } from "react-router-dom"; import { ROOT_ROUTES } from "@web/common/constants/routes"; +import { getTaskRepository } from "@web/common/repositories/task/task.repository.util"; +import { ensureStorageReady } from "@web/common/storage/adapter/adapter"; import { Task } from "@web/common/types/task.types"; -import { updateTodayTasks } from "@web/common/utils/storage/storage.util"; +import { getDateKey } from "@web/common/utils/storage/storage.util"; import { getIncompleteTasksSorted } from "@web/common/utils/task/sort.task"; import { useAvailableTasks } from "../hooks/useAvailableTasks"; import { useFocusedTask } from "../hooks/useFocusedTask"; @@ -16,7 +18,7 @@ interface NowViewContextValue { handlePreviousTask: () => void; handleNextTask: () => void; handleCompleteTask: () => void; - updateTaskDescription: (taskId: string, description: string) => void; + updateTaskDescription: (task: Task, description: string) => void; } export const NowViewContext = createContext( @@ -29,23 +31,28 @@ interface NowViewProviderProps { export function NowViewProvider({ children }: NowViewProviderProps) { const navigate = useNavigate(); - const { availableTasks, hasCompletedTasks } = useAvailableTasks(); + const { availableTasks, allTasks, hasCompletedTasks } = useAvailableTasks(); const { focusedTask, setFocusedTask } = useFocusedTask({ availableTasks }); - const completeFocusedTask = useCallback(async (taskId: string) => { - return await updateTodayTasks((tasks) => - tasks.map((task) => - task._id === taskId ? { ...task, status: "completed" as const } : task, - ), - ); - }, []); + const completeFocusedTask = useCallback( + async (task: Task) => { + const completedTask = { ...task, status: "completed" as const }; + const dateKey = getDateKey(); + await ensureStorageReady(); + await getTaskRepository("local").save(dateKey, completedTask); + return allTasks.map((t) => (t._id === task._id ? completedTask : t)); + }, + [allTasks], + ); const updateTaskDescription = useCallback( - (taskId: string, description: string) => { - void updateTodayTasks((tasks) => - tasks.map((task) => - task._id === taskId ? { ...task, description } : task, - ), - ).catch((error) => { + (task: Task, description: string) => { + void (async () => { + await ensureStorageReady(); + await getTaskRepository("local").save(getDateKey(), { + ...task, + description, + }); + })().catch((error) => { console.error("Failed to update task description:", error); }); }, @@ -90,7 +97,7 @@ export function NowViewProvider({ children }: NowViewProviderProps) { const currentTaskIndex = availableTasks.findIndex( (task) => task._id === focusedTask._id, ); - void completeFocusedTask(focusedTask._id) + void completeFocusedTask(focusedTask) .then((updatedTasks) => { const incompleteTasks = getIncompleteTasksSorted(updatedTasks); diff --git a/packages/web/src/views/Now/hooks/useAvailableTasks.test.ts b/packages/web/src/views/Now/hooks/useAvailableTasks.test.ts index ee5e41a66f..4e984477c8 100644 --- a/packages/web/src/views/Now/hooks/useAvailableTasks.test.ts +++ b/packages/web/src/views/Now/hooks/useAvailableTasks.test.ts @@ -9,8 +9,8 @@ import { useAvailableTasks } from "./useAvailableTasks"; jest.mock("@web/common/utils/storage/storage.util", () => ({ ...jest.requireActual("@web/common/utils/storage/storage.util"), - loadTasksFromStorage: jest.fn(), getDateKey: jest.fn(), + loadTasksFromStorage: jest.fn(), })); describe("useAvailableTasks", () => { @@ -29,7 +29,6 @@ describe("useAvailableTasks", () => { afterEach(() => { jest.useRealTimers(); - jest.restoreAllMocks(); localStorage.clear(); }); @@ -158,8 +157,10 @@ describe("useAvailableTasks", () => { }); act(() => { - dispatchStorageEvent( - `${storageUtil.TODAY_TASKS_STORAGE_KEY_PREFIX}.${mockDateKey}`, + window.dispatchEvent( + new CustomEvent(storageUtil.COMPASS_TASKS_SAVED_EVENT_NAME, { + detail: { dateKey: mockDateKey }, + }), ); }); @@ -206,7 +207,11 @@ describe("useAvailableTasks", () => { }); act(() => { - dispatchStorageEvent(null); + window.dispatchEvent( + new CustomEvent(storageUtil.COMPASS_TASKS_SAVED_EVENT_NAME, { + detail: { dateKey: mockDateKey }, + }), + ); }); await waitFor(() => { diff --git a/packages/web/src/views/Now/hooks/useAvailableTasks.ts b/packages/web/src/views/Now/hooks/useAvailableTasks.ts index f087644e2c..654006b7ad 100644 --- a/packages/web/src/views/Now/hooks/useAvailableTasks.ts +++ b/packages/web/src/views/Now/hooks/useAvailableTasks.ts @@ -34,7 +34,7 @@ export function useAvailableTasks() { }) .catch((error) => { if (isCancelled) return; - console.error("Failed to load tasks from IndexedDB:", error); + console.error("Failed to load tasks from storage:", error); setStoredTasks([]); }); };