From 2e7ae2041fbdda7915029077d08ea794a5c43f4a Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 19 Feb 2026 09:16:34 -0800 Subject: [PATCH 01/13] refactor(storage): implement StorageAdapter for task and event repositories - Refactored LocalTaskRepository and LocalEventRepository to utilize a new StorageAdapter, enhancing abstraction and flexibility in storage operations. - Updated methods to delegate storage actions to the adapter, allowing for easier integration with different storage technologies (e.g., IndexedDB). - Introduced a new storage adapter implementation using Dexie for managing tasks and events, including schema migrations and data handling. - Added migration utilities to facilitate the transition from localStorage to the new storage system, ensuring data integrity and improved performance. - Updated tests to reflect changes in storage handling and ensure compatibility with the new adapter structure. --- .../event/local.event.repository.ts | 42 ++- .../task/local.task.repository.test.ts | 104 +++--- .../task/local.task.repository.ts | 26 +- .../web/src/common/storage/adapter/index.ts | 78 ++++ .../storage/adapter/indexeddb.adapter.ts | 196 ++++++++++ .../common/storage/adapter/storage.adapter.ts | 122 +++++++ .../migrations/external/localstorage-tasks.ts | 130 +++++++ .../src/common/storage/migrations/index.ts | 104 ++++++ .../storage/migrations/migration.types.ts | 69 ++++ .../src/common/utils/app-init.util.test.ts | 73 +--- .../web/src/common/utils/app-init.util.ts | 20 +- .../common/utils/storage/compass-local.db.ts | 25 +- .../common/utils/storage/db-init.util.test.ts | 209 ++--------- .../src/common/utils/storage/db-init.util.ts | 114 ++---- .../utils/storage/event.storage.util.ts | 69 ++-- .../utils/storage/task-migration.util.test.ts | 317 ++-------------- .../utils/storage/task-migration.util.ts | 341 ++---------------- .../common/utils/storage/task.storage.util.ts | 124 +++---- 18 files changed, 1046 insertions(+), 1117 deletions(-) create mode 100644 packages/web/src/common/storage/adapter/index.ts create mode 100644 packages/web/src/common/storage/adapter/indexeddb.adapter.ts create mode 100644 packages/web/src/common/storage/adapter/storage.adapter.ts create mode 100644 packages/web/src/common/storage/migrations/external/localstorage-tasks.ts create mode 100644 packages/web/src/common/storage/migrations/index.ts create mode 100644 packages/web/src/common/storage/migrations/migration.types.ts 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..44ceec7e7b 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"; 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/local.task.repository.test.ts b/packages/web/src/common/repositories/task/local.task.repository.test.ts index e062a570f1..58938c6161 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,44 @@ 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"; 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"); 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; + deleteTask: jest.Mock; + moveTask: jest.Mock; + }; beforeEach(() => { + mockAdapter = { + getTasks: jest.fn().mockResolvedValue([]), + putTasks: 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 +48,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 +80,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 +90,14 @@ describe("LocalTaskRepository", () => { await repository.save(dateKey, tasks); - expect(mockSaveTasks).toHaveBeenCalledWith(dateKey, tasks); + expect(mockAdapter.putTasks).toHaveBeenCalledWith(dateKey, tasks); }); }); 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 +105,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 +132,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 +175,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 +210,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 @@ -232,12 +234,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..28a770f77a 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,25 @@ +import { getStorageAdapter } from "@web/common/storage/adapter"; import { Task } from "@web/common/types/task.types"; -import { - deleteTaskFromIndexedDB, - loadTasksFromIndexedDB, - moveTaskBetweenDates, - saveTasksToIndexedDB, -} from "@web/common/utils/storage/task.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); + await this.adapter.putTasks(dateKey, tasks); } async delete(dateKey: string, taskId: string): Promise { @@ -24,7 +30,7 @@ export class LocalTaskRepository implements TaskRepository { return; } - await deleteTaskFromIndexedDB(taskId); + await this.adapter.deleteTask(taskId); } async move( @@ -32,7 +38,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( diff --git a/packages/web/src/common/storage/adapter/index.ts b/packages/web/src/common/storage/adapter/index.ts new file mode 100644 index 0000000000..378598dad1 --- /dev/null +++ b/packages/web/src/common/storage/adapter/index.ts @@ -0,0 +1,78 @@ +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"); + await runAllMigrations(storage); + })(); + + 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.ts b/packages/web/src/common/storage/adapter/indexeddb.adapter.ts new file mode 100644 index 0000000000..fcf00fc6b9 --- /dev/null +++ b/packages/web/src/common/storage/adapter/indexeddb.adapter.ts @@ -0,0 +1,196 @@ +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. + * When switching to SQLite, the equivalent would be ALTER TABLE migrations. + */ +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 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..57f538078a --- /dev/null +++ b/packages/web/src/common/storage/adapter/storage.adapter.ts @@ -0,0 +1,122 @@ +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; + + /** + * 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.ts b/packages/web/src/common/storage/migrations/external/localstorage-tasks.ts new file mode 100644 index 0000000000..e75cc45e96 --- /dev/null +++ b/packages/web/src/common/storage/migrations/external/localstorage-tasks.ts @@ -0,0 +1,130 @@ +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 if not in browser + if (typeof window === "undefined" || 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.warn(`[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`, + ); + } + }, +}; diff --git a/packages/web/src/common/storage/migrations/index.ts b/packages/web/src/common/storage/migrations/index.ts new file mode 100644 index 0000000000..4551a4968d --- /dev/null +++ b/packages/web/src/common/storage/migrations/index.ts @@ -0,0 +1,104 @@ +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: + // 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 { + 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); + 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/storage/migrations/migration.types.ts b/packages/web/src/common/storage/migrations/migration.types.ts new file mode 100644 index 0000000000..8ce5d8c67e --- /dev/null +++ b/packages/web/src/common/storage/migrations/migration.types.ts @@ -0,0 +1,69 @@ +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; +} 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..50970239ff 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"; +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", () => ({ + 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..8787580f0b 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"; 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) + events!: Table; + tasks!: Table; constructor() { super("compass-local"); @@ -26,7 +41,13 @@ class CompassLocalDB extends Dexie { events: "_id, startDate, endDate, isSomeday", tasks: "_id, dateKey, status, order", }); + + // Note: Version 3 adds _migrations table, defined in IndexedDBAdapter } } +/** + * Legacy database instance for backward compatibility. + * @deprecated Use getStorageAdapter() instead + */ 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 index d7f82f323e..1c46c699ef 100644 --- a/packages/web/src/common/utils/storage/db-init.util.test.ts +++ b/packages/web/src/common/utils/storage/db-init.util.test.ts @@ -1,219 +1,82 @@ -import Dexie from "dexie"; -import { compassLocalDB } from "./compass-local.db"; +/** + * Tests for the database initialization compatibility layer. + * + * The db-init.util module now delegates to the storage adapter. + * These tests verify the delegation works correctly. + */ +import { + ensureStorageReady, + initializeStorage, + isStorageReady, + resetStorage, +} from "@web/common/storage/adapter"; 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" }], - }, +// Mock the storage adapter +jest.mock("@web/common/storage/adapter", () => ({ + initializeStorage: jest.fn(), + ensureStorageReady: jest.fn(), + isStorageReady: jest.fn(), + resetStorage: jest.fn(), })); -describe("db-init.util", () => { +describe("db-init.util (compatibility layer)", () => { 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); + it("should delegate to initializeStorage", async () => { + (initializeStorage as jest.Mock).mockResolvedValue(undefined); - // 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); + expect(initializeStorage).toHaveBeenCalledTimes(1); }); - it("should handle VersionError", async () => { - const versionError = new Dexie.VersionError(); - (compassLocalDB.open as jest.Mock).mockRejectedValue(versionError); + it("should propagate errors from initializeStorage", async () => { + const error = new Error("Storage init failed"); + (initializeStorage as jest.Mock).mockRejectedValue(error); - 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); + await expect(initializeDatabase()).rejects.toThrow("Storage init failed"); }); }); 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(); + it("should delegate to isStorageReady", () => { + (isStorageReady as jest.Mock).mockReturnValue(true); expect(isDatabaseReady()).toBe(true); + expect(isStorageReady).toHaveBeenCalledTimes(1); }); - it("should return false if database is not open", async () => { - (compassLocalDB.open as jest.Mock).mockResolvedValue(undefined); - (compassLocalDB.isOpen as jest.Mock).mockReturnValue(false); + it("should return false when storage is not ready", () => { + (isStorageReady 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 delegate to ensureStorageReady", async () => { + (ensureStorageReady as jest.Mock).mockResolvedValue(undefined); - 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); + expect(ensureStorageReady).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 + it("should delegate to resetStorage", () => { 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); + expect(resetStorage).toHaveBeenCalledTimes(1); }); }); }); diff --git a/packages/web/src/common/utils/storage/db-init.util.ts b/packages/web/src/common/utils/storage/db-init.util.ts index 9755d90c93..ff50fc301e 100644 --- a/packages/web/src/common/utils/storage/db-init.util.ts +++ b/packages/web/src/common/utils/storage/db-init.util.ts @@ -1,118 +1,46 @@ -import Dexie from "dexie"; -import { compassLocalDB } from "./compass-local.db"; -import { DatabaseInitError } from "./db-errors.util"; - /** - * Database initialization state tracking + * Database initialization utilities - compatibility layer. + * + * @deprecated These functions delegate to the StorageAdapter. + * New code should use initializeStorage() from @web/common/storage/adapter. + * + * @see {@link @web/common/storage/adapter} */ -let dbInitPromise: Promise | null = null; -let isInitialized = false; +import { + ensureStorageReady, + initializeStorage, + isStorageReady, + resetStorage, +} from "@web/common/storage/adapter"; /** - * 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 + * Initializes the IndexedDB database. + * @deprecated Use initializeStorage() from @web/common/storage/adapter instead */ 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; + await initializeStorage(); } /** * Checks if the database is ready without triggering initialization. - * Synchronous check for performance. - * - * @returns true if database is initialized and open + * @deprecated Use isStorageReady() from @web/common/storage/adapter instead */ export function isDatabaseReady(): boolean { - return isInitialized && compassLocalDB.isOpen(); + return isStorageReady(); } /** * 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 + * @deprecated Use ensureStorageReady() from @web/common/storage/adapter instead */ export async function ensureDatabaseReady(): Promise { - if (!isDatabaseReady()) { - await initializeDatabase(); - } + await ensureStorageReady(); } /** * Resets the initialization state. Useful for testing. - * WARNING: Only use in tests, never in production code. + * @deprecated Use resetStorage() from @web/common/storage/adapter instead */ export function resetDatabaseInitialization(): void { - dbInitPromise = null; - isInitialized = false; + resetStorage(); } diff --git a/packages/web/src/common/utils/storage/event.storage.util.ts b/packages/web/src/common/utils/storage/event.storage.util.ts index 9e196011cf..5d93b2b423 100644 --- a/packages/web/src/common/utils/storage/event.storage.util.ts +++ b/packages/web/src/common/utils/storage/event.storage.util.ts @@ -1,11 +1,21 @@ +/** + * Event storage utilities - compatibility layer. + * + * @deprecated These functions delegate to the StorageAdapter. + * New code should use getStorageAdapter() directly. + * + * @see {@link @web/common/storage/adapter} + */ import { Event_Core } from "@core/types/event.types"; -import { isDateRangeOverlapping } from "@core/util/date/date.util"; -import { compassLocalDB } from "./compass-local.db"; +import { + ensureStorageReady, + getStorageAdapter, +} from "@web/common/storage/adapter"; 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. + * Saves an event to IndexedDB. + * @deprecated Use getStorageAdapter().putEvent() instead */ export async function saveEventToIndexedDB(event: Event_Core): Promise { if (!event._id) { @@ -13,10 +23,8 @@ export async function saveEventToIndexedDB(event: Event_Core): Promise { } try { - // Ensure database is ready before operation - await ensureDatabaseReady(); - - await compassLocalDB.events.put(event); + await ensureStorageReady(); + await getStorageAdapter().putEvent(event); } catch (error) { handleDatabaseError(error, "save"); } @@ -24,6 +32,7 @@ export async function saveEventToIndexedDB(event: Event_Core): Promise { /** * Loads events from IndexedDB filtered by date range and optionally by isSomeday flag. + * @deprecated Use getStorageAdapter().getEvents() instead */ export async function loadEventsFromIndexedDB( startDate: string, @@ -31,23 +40,8 @@ export async function loadEventsFromIndexedDB( 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", - ); - }); + await ensureStorageReady(); + return await getStorageAdapter().getEvents(startDate, endDate, isSomeday); } catch (error) { handleDatabaseError(error, "load"); } @@ -55,15 +49,12 @@ export async function loadEventsFromIndexedDB( /** * Loads all events from IndexedDB without filtering. + * @deprecated Use getStorageAdapter().getAllEvents() instead */ export async function loadAllEventsFromIndexedDB(): Promise { try { - // Ensure database is ready before operation - await ensureDatabaseReady(); - - const events = await compassLocalDB.events.toArray(); - - return events; + await ensureStorageReady(); + return await getStorageAdapter().getAllEvents(); } catch (error) { handleDatabaseError(error, "load"); } @@ -71,27 +62,25 @@ export async function loadAllEventsFromIndexedDB(): Promise { /** * Deletes an event from IndexedDB by its ID. + * @deprecated Use getStorageAdapter().deleteEvent() instead */ export async function deleteEventFromIndexedDB(eventId: string): Promise { try { - // Ensure database is ready before operation - await ensureDatabaseReady(); - - await compassLocalDB.events.delete(eventId); + await ensureStorageReady(); + await getStorageAdapter().deleteEvent(eventId); } catch (error) { handleDatabaseError(error, "delete"); } } /** - * Clears all events from IndexedDB. Used for migration cleanup. + * Clears all events from IndexedDB. + * @deprecated Use getStorageAdapter().clearAllEvents() instead */ export async function clearEventsFromIndexedDB(): Promise { try { - // Ensure database is ready before operation - await ensureDatabaseReady(); - - await compassLocalDB.events.clear(); + await ensureStorageReady(); + await getStorageAdapter().clearAllEvents(); } catch (error) { handleDatabaseError(error, "clear"); } 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 index e258c1eb46..bfc80fd0fd 100644 --- a/packages/web/src/common/utils/storage/task-migration.util.test.ts +++ b/packages/web/src/common/utils/storage/task-migration.util.test.ts @@ -1,313 +1,68 @@ -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"; +/** + * Tests for the task migration compatibility layer. + * + * The actual migration logic has been moved to the new storage migration system: + * @see {@link @web/common/storage/migrations/external/localstorage-tasks.ts} + * + * These tests verify the compatibility layer's behavior. + */ 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."; +describe("task-migration.util (compatibility layer)", () => { 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); - }); - } + const NEW_MIGRATION_FLAG_KEY = "compass.migration.localstorage-tasks-v1"; - 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)); + beforeEach(() => { + // Clear migration flags + localStorage.removeItem(MIGRATION_FLAG_KEY); + localStorage.removeItem(NEW_MIGRATION_FLAG_KEY); + }); - // Reset migration flag - resetTaskMigrationFlag(); + afterEach(() => { + // Clean up + localStorage.removeItem(MIGRATION_FLAG_KEY); + localStorage.removeItem(NEW_MIGRATION_FLAG_KEY); }); describe("hasTaskMigrationCompleted", () => { - it("should return false when migration has not been completed", () => { + it("should return false when no migration flag is set", () => { expect(hasTaskMigrationCompleted()).toBe(false); }); - it("should return true when migration flag is set", () => { + it("should return true when old 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); + it("should return true when new migration flag is set", () => { + localStorage.setItem(NEW_MIGRATION_FLAG_KEY, "completed"); 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"); + describe("migrateTasksFromLocalStorageToIndexedDB", () => { + it("should always return 0 (migration handled by new system)", async () => { + const result = await migrateTasksFromLocalStorageToIndexedDB(); + expect(result).toBe(0); }); + }); - 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]); - + describe("resetTaskMigrationFlag", () => { + it("should remove both old and new migration flags", () => { localStorage.setItem(MIGRATION_FLAG_KEY, "true"); + localStorage.setItem(NEW_MIGRATION_FLAG_KEY, "completed"); - 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]); + resetTaskMigrationFlag(); - 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"); + expect(localStorage.getItem(MIGRATION_FLAG_KEY)).toBeNull(); + expect(localStorage.getItem(NEW_MIGRATION_FLAG_KEY)).toBeNull(); }); - }); - describe("resetTaskMigrationFlag", () => { - it("should remove the migration flag", () => { + it("should result in hasTaskMigrationCompleted returning false", () => { localStorage.setItem(MIGRATION_FLAG_KEY, "true"); expect(hasTaskMigrationCompleted()).toBe(true); diff --git a/packages/web/src/common/utils/storage/task-migration.util.ts b/packages/web/src/common/utils/storage/task-migration.util.ts index 9874fb5038..efedc769e0 100644 --- a/packages/web/src/common/utils/storage/task-migration.util.ts +++ b/packages/web/src/common/utils/storage/task-migration.util.ts @@ -1,340 +1,51 @@ -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"; +/** + * Task migration utilities - compatibility layer. + * + * @deprecated The migration system has been refactored. + * Migrations are now handled automatically by initializeStorage() from + * @web/common/storage/adapter. This file is kept for backward compatibility. + * + * @see {@link @web/common/storage/migrations} + */ 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); - } - }, - ); -} +const NEW_MIGRATION_FLAG_KEY = "compass.migration.localstorage-tasks-v1"; /** * Checks if task migration from localStorage to IndexedDB has been completed. + * @deprecated Migrations are now handled automatically by initializeStorage() */ 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"); + // Check both old and new migration flags + return ( + localStorage.getItem(MIGRATION_FLAG_KEY) === "true" || + localStorage.getItem(NEW_MIGRATION_FLAG_KEY) === "completed" + ); } /** * 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 + * @deprecated This migration is now handled automatically by initializeStorage(). + * The new migration system runs the localstorage-tasks-v1 external migration + * when storage is initialized. This function is kept for backward compatibility + * and now returns 0 since the migration runs elsewhere. * - * @returns The number of tasks migrated + * @returns Always returns 0 (migration runs automatically during init) */ 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; - } + // Migration is now handled by the external migrations system + // See: @web/common/storage/migrations/external/localstorage-tasks.ts + return 0; } /** * Resets the migration flag. Used for testing. + * @deprecated Use localStorage.removeItem() directly for testing */ export function resetTaskMigrationFlag(): void { if (typeof window === "undefined") return; localStorage.removeItem(MIGRATION_FLAG_KEY); + localStorage.removeItem(NEW_MIGRATION_FLAG_KEY); } diff --git a/packages/web/src/common/utils/storage/task.storage.util.ts b/packages/web/src/common/utils/storage/task.storage.util.ts index ea24145d56..37da27fd59 100644 --- a/packages/web/src/common/utils/storage/task.storage.util.ts +++ b/packages/web/src/common/utils/storage/task.storage.util.ts @@ -1,26 +1,48 @@ +/** + * Task storage utilities - compatibility layer. + * + * @deprecated These functions delegate to the StorageAdapter. + * New code should use getStorageAdapter() directly. + * + * @see {@link @web/common/storage/adapter} + */ import { - Task, - normalizeTask, - normalizeTasks, -} from "@web/common/types/task.types"; -import { StoredTask, compassLocalDB } from "./compass-local.db"; + StoredTask, + ensureStorageReady, + getStorageAdapter, +} from "@web/common/storage/adapter"; +import { Task, normalizeTask } from "@web/common/types/task.types"; import { handleDatabaseError } from "./db-errors.util"; -import { ensureDatabaseReady } from "./db-init.util"; + +// Re-export StoredTask for backward compatibility +export type { StoredTask }; /** * Saves a single task to IndexedDB with its associated dateKey. - * Uses put() to handle both new and existing tasks. + * @deprecated Use getStorageAdapter().putTasks() instead */ 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); + await ensureStorageReady(); + const adapter = getStorageAdapter(); + + // Get existing tasks, add/update this one, save all + const existingTasks = await adapter.getTasks(dateKey); + const normalizedTask = normalizeTask(task); + const taskIndex = existingTasks.findIndex( + (t) => t._id === normalizedTask._id, + ); + + if (taskIndex >= 0) { + existingTasks[taskIndex] = normalizedTask; + } else { + existingTasks.push(normalizedTask); + } + + await adapter.putTasks(dateKey, existingTasks); } catch (error) { handleDatabaseError(error, "save"); } @@ -28,28 +50,15 @@ export async function saveTaskToIndexedDB( /** * Saves multiple tasks to IndexedDB for a specific dateKey. - * Replaces all existing tasks for that dateKey. + * @deprecated Use getStorageAdapter().putTasks() instead */ 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); - } - }); + await ensureStorageReady(); + await getStorageAdapter().putTasks(dateKey, tasks); } catch (error) { handleDatabaseError(error, "save"); } @@ -57,31 +66,25 @@ export async function saveTasksToIndexedDB( /** * Loads all tasks from IndexedDB for a specific dateKey. + * @deprecated Use getStorageAdapter().getTasks() instead */ 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)); + await ensureStorageReady(); + return await getStorageAdapter().getTasks(dateKey); } catch (error) { handleDatabaseError(error, "load"); - return []; } } /** * Deletes a task from IndexedDB by its ID. + * @deprecated Use getStorageAdapter().deleteTask() instead */ export async function deleteTaskFromIndexedDB(taskId: string): Promise { try { - await ensureDatabaseReady(); - await compassLocalDB.tasks.delete(taskId); + await ensureStorageReady(); + await getStorageAdapter().deleteTask(taskId); } catch (error) { handleDatabaseError(error, "delete"); } @@ -89,23 +92,25 @@ export async function deleteTaskFromIndexedDB(taskId: string): Promise { /** * Clears all tasks for a specific dateKey from IndexedDB. + * @deprecated Use getStorageAdapter().putTasks(dateKey, []) instead */ export async function clearTasksForDateKey(dateKey: string): Promise { try { - await ensureDatabaseReady(); - await compassLocalDB.tasks.where("dateKey").equals(dateKey).delete(); + await ensureStorageReady(); + await getStorageAdapter().putTasks(dateKey, []); } catch (error) { handleDatabaseError(error, "clear"); } } /** - * Clears all tasks from IndexedDB. Used for cleanup or testing. + * Clears all tasks from IndexedDB. + * @deprecated Use getStorageAdapter().clearAllTasks() instead */ export async function clearAllTasksFromIndexedDB(): Promise { try { - await ensureDatabaseReady(); - await compassLocalDB.tasks.clear(); + await ensureStorageReady(); + await getStorageAdapter().clearAllTasks(); } catch (error) { handleDatabaseError(error, "clear"); } @@ -113,6 +118,7 @@ export async function clearAllTasksFromIndexedDB(): Promise { /** * Moves a task from one date to another. + * @deprecated Use getStorageAdapter().moveTask() instead */ export async function moveTaskBetweenDates( task: Task, @@ -120,38 +126,22 @@ export async function moveTaskBetweenDates( 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); - }); + await ensureStorageReady(); + await getStorageAdapter().moveTask(task, fromDateKey, toDateKey); } catch (error) { handleDatabaseError(error, "move"); } } /** - * Loads all tasks from IndexedDB. Used for migration or bulk operations. + * Loads all tasks from IndexedDB. + * @deprecated Use getStorageAdapter().getAllTasks() instead */ export async function loadAllTasksFromIndexedDB(): Promise { try { - await ensureDatabaseReady(); - return await compassLocalDB.tasks.toArray(); + await ensureStorageReady(); + return await getStorageAdapter().getAllTasks(); } catch (error) { handleDatabaseError(error, "load"); - return []; } } From 4f5c0aa7fadfabf2191b4836551cdbd3d4f2b2e4 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 19 Feb 2026 09:24:11 -0800 Subject: [PATCH 02/13] test(storage): add comprehensive tests for storage adapter and migrations - Introduced new test files for the storage adapter, including tests for IndexedDB operations and migration functionalities. - Implemented tests for the LocalEventRepository to ensure proper integration with the new storage adapter. - Added tests for migration processes, verifying the correct handling of tasks and events during transitions from localStorage to IndexedDB. - Enhanced existing tests for task management to reflect changes in storage handling and ensure data integrity. - Updated utility functions for creating mock tasks and events to streamline test setup and improve maintainability. --- .../event/local.event.repository.test.ts | 50 ++-- .../src/common/storage/adapter/index.test.ts | 113 ++++++++ .../storage/adapter/indexeddb.adapter.test.ts | 244 ++++++++++++++++++ .../external/localstorage-tasks.test.ts | 186 +++++++++++++ .../common/storage/migrations/index.test.ts | 99 +++++++ .../views/Now/hooks/useAvailableTasks.test.ts | 12 +- 6 files changed, 681 insertions(+), 23 deletions(-) create mode 100644 packages/web/src/common/storage/adapter/index.test.ts create mode 100644 packages/web/src/common/storage/adapter/indexeddb.adapter.test.ts create mode 100644 packages/web/src/common/storage/migrations/external/localstorage-tasks.test.ts create mode 100644 packages/web/src/common/storage/migrations/index.test.ts 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..cada12a7ab 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,28 @@ 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"; 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), + 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 +44,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"); 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/storage/adapter/index.test.ts b/packages/web/src/common/storage/adapter/index.test.ts new file mode 100644 index 0000000000..f432161a52 --- /dev/null +++ b/packages/web/src/common/storage/adapter/index.test.ts @@ -0,0 +1,113 @@ +/** + * Tests for the storage adapter factory and initialization. + */ +import { + ensureStorageReady, + getStorageAdapter, + initializeStorage, + isStorageReady, + resetStorage, +} from "@web/common/storage/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); + }); + }); + + 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/indexeddb.adapter.test.ts b/packages/web/src/common/storage/adapter/indexeddb.adapter.test.ts new file mode 100644 index 0000000000..e7496ee252 --- /dev/null +++ b/packages/web/src/common/storage/adapter/indexeddb.adapter.test.ts @@ -0,0 +1,244 @@ +/** + * 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("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/migrations/external/localstorage-tasks.test.ts b/packages/web/src/common/storage/migrations/external/localstorage-tasks.test.ts new file mode 100644 index 0000000000..fd0da4efc4 --- /dev/null +++ b/packages/web/src/common/storage/migrations/external/localstorage-tasks.test.ts @@ -0,0 +1,186 @@ +/** + * 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); + }), + 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(); + }); +}); diff --git a/packages/web/src/common/storage/migrations/index.test.ts b/packages/web/src/common/storage/migrations/index.test.ts new file mode 100644 index 0000000000..2731f5e69f --- /dev/null +++ b/packages/web/src/common/storage/migrations/index.test.ts @@ -0,0 +1,99 @@ +/** + * Tests for the migration runners. + */ +import { StorageAdapter } from "@web/common/storage/adapter/storage.adapter"; +import { + runAllMigrations, + runDataMigrations, + runExternalMigrations, +} from "@web/common/storage/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), + 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"; + + beforeEach(() => { + localStorage.removeItem(externalMigrationFlagKey); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + localStorage.removeItem(externalMigrationFlagKey); + 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(); + }); + }); + + 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/views/Now/hooks/useAvailableTasks.test.ts b/packages/web/src/views/Now/hooks/useAvailableTasks.test.ts index ee5e41a66f..7e52c985ea 100644 --- a/packages/web/src/views/Now/hooks/useAvailableTasks.test.ts +++ b/packages/web/src/views/Now/hooks/useAvailableTasks.test.ts @@ -158,8 +158,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 +208,11 @@ describe("useAvailableTasks", () => { }); act(() => { - dispatchStorageEvent(null); + window.dispatchEvent( + new CustomEvent(storageUtil.COMPASS_TASKS_SAVED_EVENT_NAME, { + detail: { dateKey: mockDateKey }, + }), + ); }); await waitFor(() => { From e61bd4aef9dc65003680dd9a273ddaf8dd4327e0 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 19 Feb 2026 09:28:36 -0800 Subject: [PATCH 03/13] refactor(storage): update storage adapter imports and add new tests - Refactored imports for the storage adapter to point to the new adapter structure. - Updated LocalEventRepository and LocalTaskRepository to utilize the new import paths. - Introduced comprehensive tests for the new storage adapter, covering initialization, migration processes, and ensuring proper functionality with both tasks and events. - Enhanced existing utility functions and tests to align with the updated storage architecture, improving maintainability and clarity. --- .../common/repositories/event/local.event.repository.test.ts | 4 ++-- .../src/common/repositories/event/local.event.repository.ts | 2 +- .../common/repositories/task/local.task.repository.test.ts | 4 ++-- .../web/src/common/repositories/task/local.task.repository.ts | 2 +- .../common/storage/adapter/{index.test.ts => adapter.test.ts} | 2 +- .../web/src/common/storage/adapter/{index.ts => adapter.ts} | 2 +- .../storage/migrations/{index.test.ts => migrations.test.ts} | 2 +- .../src/common/storage/migrations/{index.ts => migrations.ts} | 0 packages/web/src/common/utils/app-init.util.test.ts | 4 ++-- packages/web/src/common/utils/app-init.util.ts | 2 +- packages/web/src/common/utils/storage/db-init.util.test.ts | 4 ++-- packages/web/src/common/utils/storage/db-init.util.ts | 2 +- packages/web/src/common/utils/storage/event.storage.util.ts | 2 +- packages/web/src/common/utils/storage/task.storage.util.ts | 2 +- 14 files changed, 17 insertions(+), 17 deletions(-) rename packages/web/src/common/storage/adapter/{index.test.ts => adapter.test.ts} (98%) rename packages/web/src/common/storage/adapter/{index.ts => adapter.ts} (96%) rename packages/web/src/common/storage/migrations/{index.test.ts => migrations.test.ts} (98%) rename packages/web/src/common/storage/migrations/{index.ts => migrations.ts} (100%) 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 cada12a7ab..17b1b8e40e 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 @@ -10,7 +10,7 @@ import { createTestCompassEvent, createTestEvent, } from "@web/__tests__/utils/repositories/repository.test.factory"; -import * as storageAdapter from "@web/common/storage/adapter"; +import * as storageAdapter from "@web/common/storage/adapter/adapter"; import { LocalEventRepository } from "./local.event.repository"; const mockEvents = new Map(); @@ -58,7 +58,7 @@ function createMockAdapter() { }; } -jest.mock("@web/common/storage/adapter"); +jest.mock("@web/common/storage/adapter/adapter"); describe("LocalEventRepository", () => { let repository: LocalEventRepository; 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 44ceec7e7b..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,7 +5,7 @@ import { RecurringEventUpdateScope, Schema_Event, } from "@core/types/event.types"; -import { getStorageAdapter } from "@web/common/storage/adapter"; +import { getStorageAdapter } from "@web/common/storage/adapter/adapter"; import { Response_GetEventsSuccess } from "@web/ducks/events/event.types"; import { EventRepository } from "./event.repository.interface"; 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 58938c6161..fd5c924aaf 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,11 +2,11 @@ import { createTestTask, createTestTasks, } from "@web/__tests__/utils/repositories/repository.test.factory"; -import * as storageAdapter from "@web/common/storage/adapter"; +import * as storageAdapter from "@web/common/storage/adapter/adapter"; import { LocalTaskRepository } from "./local.task.repository"; // Mock the storage adapter module -jest.mock("@web/common/storage/adapter"); +jest.mock("@web/common/storage/adapter/adapter"); describe("LocalTaskRepository", () => { let repository: LocalTaskRepository; 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 28a770f77a..7811f28c9e 100644 --- a/packages/web/src/common/repositories/task/local.task.repository.ts +++ b/packages/web/src/common/repositories/task/local.task.repository.ts @@ -1,4 +1,4 @@ -import { getStorageAdapter } from "@web/common/storage/adapter"; +import { getStorageAdapter } from "@web/common/storage/adapter/adapter"; import { Task } from "@web/common/types/task.types"; import { TaskRepository } from "./task.repository"; diff --git a/packages/web/src/common/storage/adapter/index.test.ts b/packages/web/src/common/storage/adapter/adapter.test.ts similarity index 98% rename from packages/web/src/common/storage/adapter/index.test.ts rename to packages/web/src/common/storage/adapter/adapter.test.ts index f432161a52..2963610203 100644 --- a/packages/web/src/common/storage/adapter/index.test.ts +++ b/packages/web/src/common/storage/adapter/adapter.test.ts @@ -7,7 +7,7 @@ import { initializeStorage, isStorageReady, resetStorage, -} from "@web/common/storage/adapter"; +} from "@web/common/storage/adapter/adapter"; describe("storage adapter index", () => { beforeEach(() => { diff --git a/packages/web/src/common/storage/adapter/index.ts b/packages/web/src/common/storage/adapter/adapter.ts similarity index 96% rename from packages/web/src/common/storage/adapter/index.ts rename to packages/web/src/common/storage/adapter/adapter.ts index 378598dad1..df71320730 100644 --- a/packages/web/src/common/storage/adapter/index.ts +++ b/packages/web/src/common/storage/adapter/adapter.ts @@ -39,7 +39,7 @@ export async function initializeStorage(): Promise { await storage.initialize(); // Import migrations dynamically to avoid circular dependencies - const { runAllMigrations } = await import("../migrations"); + const { runAllMigrations } = await import("../migrations/migrations"); await runAllMigrations(storage); })(); diff --git a/packages/web/src/common/storage/migrations/index.test.ts b/packages/web/src/common/storage/migrations/migrations.test.ts similarity index 98% rename from packages/web/src/common/storage/migrations/index.test.ts rename to packages/web/src/common/storage/migrations/migrations.test.ts index 2731f5e69f..6f7164ae3c 100644 --- a/packages/web/src/common/storage/migrations/index.test.ts +++ b/packages/web/src/common/storage/migrations/migrations.test.ts @@ -6,7 +6,7 @@ import { runAllMigrations, runDataMigrations, runExternalMigrations, -} from "@web/common/storage/migrations"; +} from "@web/common/storage/migrations/migrations"; function createMockAdapter(): jest.Mocked { return { diff --git a/packages/web/src/common/storage/migrations/index.ts b/packages/web/src/common/storage/migrations/migrations.ts similarity index 100% rename from packages/web/src/common/storage/migrations/index.ts rename to packages/web/src/common/storage/migrations/migrations.ts 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 50970239ff..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,5 +1,5 @@ import { toast } from "react-toastify"; -import { initializeStorage } from "@web/common/storage/adapter"; +import { initializeStorage } from "@web/common/storage/adapter/adapter"; import { DatabaseInitError } from "@web/common/utils/storage/db-errors.util"; import { initializeDatabaseWithErrorHandling, @@ -7,7 +7,7 @@ import { } from "./app-init.util"; // Mock the storage adapter -jest.mock("@web/common/storage/adapter", () => ({ +jest.mock("@web/common/storage/adapter/adapter", () => ({ initializeStorage: jest.fn(), })); diff --git a/packages/web/src/common/utils/app-init.util.ts b/packages/web/src/common/utils/app-init.util.ts index 8787580f0b..bd348d89fd 100644 --- a/packages/web/src/common/utils/app-init.util.ts +++ b/packages/web/src/common/utils/app-init.util.ts @@ -1,5 +1,5 @@ import { toast } from "react-toastify"; -import { initializeStorage } from "@web/common/storage/adapter"; +import { initializeStorage } from "@web/common/storage/adapter/adapter"; import { DatabaseInitError } from "./storage/db-errors.util"; export interface AppInitResult { 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 index 1c46c699ef..2ae5f2c388 100644 --- a/packages/web/src/common/utils/storage/db-init.util.test.ts +++ b/packages/web/src/common/utils/storage/db-init.util.test.ts @@ -9,7 +9,7 @@ import { initializeStorage, isStorageReady, resetStorage, -} from "@web/common/storage/adapter"; +} from "@web/common/storage/adapter/adapter"; import { ensureDatabaseReady, initializeDatabase, @@ -18,7 +18,7 @@ import { } from "./db-init.util"; // Mock the storage adapter -jest.mock("@web/common/storage/adapter", () => ({ +jest.mock("@web/common/storage/adapter/adapter", () => ({ initializeStorage: jest.fn(), ensureStorageReady: jest.fn(), isStorageReady: jest.fn(), diff --git a/packages/web/src/common/utils/storage/db-init.util.ts b/packages/web/src/common/utils/storage/db-init.util.ts index ff50fc301e..efbac54da9 100644 --- a/packages/web/src/common/utils/storage/db-init.util.ts +++ b/packages/web/src/common/utils/storage/db-init.util.ts @@ -11,7 +11,7 @@ import { initializeStorage, isStorageReady, resetStorage, -} from "@web/common/storage/adapter"; +} from "@web/common/storage/adapter/adapter"; /** * Initializes the IndexedDB database. diff --git a/packages/web/src/common/utils/storage/event.storage.util.ts b/packages/web/src/common/utils/storage/event.storage.util.ts index 5d93b2b423..c32b0a3b4d 100644 --- a/packages/web/src/common/utils/storage/event.storage.util.ts +++ b/packages/web/src/common/utils/storage/event.storage.util.ts @@ -10,7 +10,7 @@ import { Event_Core } from "@core/types/event.types"; import { ensureStorageReady, getStorageAdapter, -} from "@web/common/storage/adapter"; +} from "@web/common/storage/adapter/adapter"; import { handleDatabaseError } from "./db-errors.util"; /** diff --git a/packages/web/src/common/utils/storage/task.storage.util.ts b/packages/web/src/common/utils/storage/task.storage.util.ts index 37da27fd59..4d8e305001 100644 --- a/packages/web/src/common/utils/storage/task.storage.util.ts +++ b/packages/web/src/common/utils/storage/task.storage.util.ts @@ -10,7 +10,7 @@ import { StoredTask, ensureStorageReady, getStorageAdapter, -} from "@web/common/storage/adapter"; +} from "@web/common/storage/adapter/adapter"; import { Task, normalizeTask } from "@web/common/types/task.types"; import { handleDatabaseError } from "./db-errors.util"; From 9879bcfcd9bd87fea419aed8ad1f223aecf90551 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 19 Feb 2026 09:30:19 -0800 Subject: [PATCH 04/13] refactor(storage): remove deprecated db-init utility and associated tests - Deleted the db-init utility functions and their corresponding test file as they are now deprecated and delegate to the StorageAdapter. - This cleanup aligns with the recent refactor to utilize the new storage architecture, enhancing maintainability and clarity in the codebase. --- .../common/utils/storage/db-init.util.test.ts | 82 ------------------- .../src/common/utils/storage/db-init.util.ts | 46 ----------- 2 files changed, 128 deletions(-) delete mode 100644 packages/web/src/common/utils/storage/db-init.util.test.ts delete mode 100644 packages/web/src/common/utils/storage/db-init.util.ts 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 2ae5f2c388..0000000000 --- a/packages/web/src/common/utils/storage/db-init.util.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Tests for the database initialization compatibility layer. - * - * The db-init.util module now delegates to the storage adapter. - * These tests verify the delegation works correctly. - */ -import { - ensureStorageReady, - initializeStorage, - isStorageReady, - resetStorage, -} from "@web/common/storage/adapter/adapter"; -import { - ensureDatabaseReady, - initializeDatabase, - isDatabaseReady, - resetDatabaseInitialization, -} from "./db-init.util"; - -// Mock the storage adapter -jest.mock("@web/common/storage/adapter/adapter", () => ({ - initializeStorage: jest.fn(), - ensureStorageReady: jest.fn(), - isStorageReady: jest.fn(), - resetStorage: jest.fn(), -})); - -describe("db-init.util (compatibility layer)", () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe("initializeDatabase", () => { - it("should delegate to initializeStorage", async () => { - (initializeStorage as jest.Mock).mockResolvedValue(undefined); - - await initializeDatabase(); - - expect(initializeStorage).toHaveBeenCalledTimes(1); - }); - - it("should propagate errors from initializeStorage", async () => { - const error = new Error("Storage init failed"); - (initializeStorage as jest.Mock).mockRejectedValue(error); - - await expect(initializeDatabase()).rejects.toThrow("Storage init failed"); - }); - }); - - describe("isDatabaseReady", () => { - it("should delegate to isStorageReady", () => { - (isStorageReady as jest.Mock).mockReturnValue(true); - - expect(isDatabaseReady()).toBe(true); - expect(isStorageReady).toHaveBeenCalledTimes(1); - }); - - it("should return false when storage is not ready", () => { - (isStorageReady as jest.Mock).mockReturnValue(false); - - expect(isDatabaseReady()).toBe(false); - }); - }); - - describe("ensureDatabaseReady", () => { - it("should delegate to ensureStorageReady", async () => { - (ensureStorageReady as jest.Mock).mockResolvedValue(undefined); - - await ensureDatabaseReady(); - - expect(ensureStorageReady).toHaveBeenCalledTimes(1); - }); - }); - - describe("resetDatabaseInitialization", () => { - it("should delegate to resetStorage", () => { - resetDatabaseInitialization(); - - expect(resetStorage).toHaveBeenCalledTimes(1); - }); - }); -}); 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 efbac54da9..0000000000 --- a/packages/web/src/common/utils/storage/db-init.util.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Database initialization utilities - compatibility layer. - * - * @deprecated These functions delegate to the StorageAdapter. - * New code should use initializeStorage() from @web/common/storage/adapter. - * - * @see {@link @web/common/storage/adapter} - */ -import { - ensureStorageReady, - initializeStorage, - isStorageReady, - resetStorage, -} from "@web/common/storage/adapter/adapter"; - -/** - * Initializes the IndexedDB database. - * @deprecated Use initializeStorage() from @web/common/storage/adapter instead - */ -export async function initializeDatabase(): Promise { - await initializeStorage(); -} - -/** - * Checks if the database is ready without triggering initialization. - * @deprecated Use isStorageReady() from @web/common/storage/adapter instead - */ -export function isDatabaseReady(): boolean { - return isStorageReady(); -} - -/** - * Ensures the database is ready before performing operations. - * @deprecated Use ensureStorageReady() from @web/common/storage/adapter instead - */ -export async function ensureDatabaseReady(): Promise { - await ensureStorageReady(); -} - -/** - * Resets the initialization state. Useful for testing. - * @deprecated Use resetStorage() from @web/common/storage/adapter instead - */ -export function resetDatabaseInitialization(): void { - resetStorage(); -} From f95afdf889234d0f786bea504bb53169bc01d0ce Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 19 Feb 2026 09:40:48 -0800 Subject: [PATCH 05/13] fix(storage): add bounds checking to reorder and retry support for incomplete migrations --- .../task/local.task.repository.test.ts | 23 ++++++++++++++++ .../task/local.task.repository.ts | 20 +++++++++++++- .../common/storage/adapter/adapter.test.ts | 17 ++++++++++++ .../web/src/common/storage/adapter/adapter.ts | 6 ++++- .../external/localstorage-tasks.test.ts | 26 +++++++++++++++++++ .../migrations/external/localstorage-tasks.ts | 12 +++++++-- .../storage/migrations/migration.types.ts | 7 +++++ .../storage/migrations/migrations.test.ts | 15 +++++++++++ .../common/storage/migrations/migrations.ts | 13 ++++++++++ 9 files changed, 135 insertions(+), 4 deletions(-) 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 fd5c924aaf..ee053ba606 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 @@ -224,6 +224,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", () => { 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 7811f28c9e..ff9b1bf0d5 100644 --- a/packages/web/src/common/repositories/task/local.task.repository.ts +++ b/packages/web/src/common/repositories/task/local.task.repository.ts @@ -47,9 +47,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/storage/adapter/adapter.test.ts b/packages/web/src/common/storage/adapter/adapter.test.ts index 2963610203..de4bc06cac 100644 --- a/packages/web/src/common/storage/adapter/adapter.test.ts +++ b/packages/web/src/common/storage/adapter/adapter.test.ts @@ -8,6 +8,7 @@ import { isStorageReady, resetStorage, } from "@web/common/storage/adapter/adapter"; +import { IndexedDBAdapter } from "@web/common/storage/adapter/indexeddb.adapter"; describe("storage adapter index", () => { beforeEach(() => { @@ -81,6 +82,22 @@ describe("storage adapter index", () => { 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", () => { diff --git a/packages/web/src/common/storage/adapter/adapter.ts b/packages/web/src/common/storage/adapter/adapter.ts index df71320730..f8a01db74e 100644 --- a/packages/web/src/common/storage/adapter/adapter.ts +++ b/packages/web/src/common/storage/adapter/adapter.ts @@ -41,7 +41,11 @@ export async function initializeStorage(): Promise { // 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; } 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 index fd0da4efc4..0f4aeb697e 100644 --- a/packages/web/src/common/storage/migrations/external/localstorage-tasks.test.ts +++ b/packages/web/src/common/storage/migrations/external/localstorage-tasks.test.ts @@ -183,4 +183,30 @@ describe("localStorageTasksMigration", () => { 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 index e75cc45e96..1347545048 100644 --- a/packages/web/src/common/storage/migrations/external/localstorage-tasks.ts +++ b/packages/web/src/common/storage/migrations/external/localstorage-tasks.ts @@ -64,8 +64,8 @@ export const localStorageTasksMigration: ExternalMigration = { description: "Migrate tasks from localStorage to storage adapter", async migrate(adapter: StorageAdapter): Promise { - // Skip if not in browser - if (typeof window === "undefined" || typeof localStorage === "undefined") { + // Skip when localStorage is unavailable + if (typeof localStorage === "undefined") { return; } @@ -127,4 +127,12 @@ export const localStorageTasksMigration: ExternalMigration = { ); } }, + + 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 index 8ce5d8c67e..ecdc224362 100644 --- a/packages/web/src/common/storage/migrations/migration.types.ts +++ b/packages/web/src/common/storage/migrations/migration.types.ts @@ -66,4 +66,11 @@ export interface ExternalMigration { 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 index 6f7164ae3c..05cfbbf3e1 100644 --- a/packages/web/src/common/storage/migrations/migrations.test.ts +++ b/packages/web/src/common/storage/migrations/migrations.test.ts @@ -31,9 +31,22 @@ function createMockAdapter(): jest.Mocked { 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(() => {}); @@ -41,6 +54,7 @@ describe("storage migrations", () => { afterEach(() => { localStorage.removeItem(externalMigrationFlagKey); + clearTaskStorageKeys(); jest.restoreAllMocks(); }); @@ -84,6 +98,7 @@ describe("storage migrations", () => { const adapter = createMockAdapter(); await expect(runExternalMigrations(adapter)).resolves.not.toThrow(); + expect(localStorage.getItem(externalMigrationFlagKey)).toBeNull(); }); }); diff --git a/packages/web/src/common/storage/migrations/migrations.ts b/packages/web/src/common/storage/migrations/migrations.ts index 4551a4968d..087b08955c 100644 --- a/packages/web/src/common/storage/migrations/migrations.ts +++ b/packages/web/src/common/storage/migrations/migrations.ts @@ -66,6 +66,10 @@ export async function runDataMigrations( export async function runExternalMigrations( adapter: StorageAdapter, ): Promise { + if (typeof localStorage === "undefined") { + return; + } + for (const migration of externalMigrations) { const flagKey = `compass.migration.${migration.id}`; @@ -76,6 +80,15 @@ export async function runExternalMigrations( 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) { From 31b78ad0d7373061a0ad238cd291edb317abf5a4 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 19 Feb 2026 09:56:12 -0800 Subject: [PATCH 06/13] refactor(storage): remove deprecated task-migration compatibility layer --- .../utils/storage/task-migration.util.test.ts | 74 ------------------- .../utils/storage/task-migration.util.ts | 51 ------------- 2 files changed, 125 deletions(-) delete mode 100644 packages/web/src/common/utils/storage/task-migration.util.test.ts delete mode 100644 packages/web/src/common/utils/storage/task-migration.util.ts 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 bfc80fd0fd..0000000000 --- a/packages/web/src/common/utils/storage/task-migration.util.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Tests for the task migration compatibility layer. - * - * The actual migration logic has been moved to the new storage migration system: - * @see {@link @web/common/storage/migrations/external/localstorage-tasks.ts} - * - * These tests verify the compatibility layer's behavior. - */ -import { - hasTaskMigrationCompleted, - migrateTasksFromLocalStorageToIndexedDB, - resetTaskMigrationFlag, -} from "./task-migration.util"; - -describe("task-migration.util (compatibility layer)", () => { - const MIGRATION_FLAG_KEY = "compass.tasks.migrated-to-indexeddb"; - const NEW_MIGRATION_FLAG_KEY = "compass.migration.localstorage-tasks-v1"; - - beforeEach(() => { - // Clear migration flags - localStorage.removeItem(MIGRATION_FLAG_KEY); - localStorage.removeItem(NEW_MIGRATION_FLAG_KEY); - }); - - afterEach(() => { - // Clean up - localStorage.removeItem(MIGRATION_FLAG_KEY); - localStorage.removeItem(NEW_MIGRATION_FLAG_KEY); - }); - - describe("hasTaskMigrationCompleted", () => { - it("should return false when no migration flag is set", () => { - expect(hasTaskMigrationCompleted()).toBe(false); - }); - - it("should return true when old migration flag is set", () => { - localStorage.setItem(MIGRATION_FLAG_KEY, "true"); - expect(hasTaskMigrationCompleted()).toBe(true); - }); - - it("should return true when new migration flag is set", () => { - localStorage.setItem(NEW_MIGRATION_FLAG_KEY, "completed"); - expect(hasTaskMigrationCompleted()).toBe(true); - }); - }); - - describe("migrateTasksFromLocalStorageToIndexedDB", () => { - it("should always return 0 (migration handled by new system)", async () => { - const result = await migrateTasksFromLocalStorageToIndexedDB(); - expect(result).toBe(0); - }); - }); - - describe("resetTaskMigrationFlag", () => { - it("should remove both old and new migration flags", () => { - localStorage.setItem(MIGRATION_FLAG_KEY, "true"); - localStorage.setItem(NEW_MIGRATION_FLAG_KEY, "completed"); - - resetTaskMigrationFlag(); - - expect(localStorage.getItem(MIGRATION_FLAG_KEY)).toBeNull(); - expect(localStorage.getItem(NEW_MIGRATION_FLAG_KEY)).toBeNull(); - }); - - it("should result in hasTaskMigrationCompleted returning false", () => { - 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 efedc769e0..0000000000 --- a/packages/web/src/common/utils/storage/task-migration.util.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Task migration utilities - compatibility layer. - * - * @deprecated The migration system has been refactored. - * Migrations are now handled automatically by initializeStorage() from - * @web/common/storage/adapter. This file is kept for backward compatibility. - * - * @see {@link @web/common/storage/migrations} - */ - -const MIGRATION_FLAG_KEY = "compass.tasks.migrated-to-indexeddb"; -const NEW_MIGRATION_FLAG_KEY = "compass.migration.localstorage-tasks-v1"; - -/** - * Checks if task migration from localStorage to IndexedDB has been completed. - * @deprecated Migrations are now handled automatically by initializeStorage() - */ -export function hasTaskMigrationCompleted(): boolean { - if (typeof window === "undefined") return true; - // Check both old and new migration flags - return ( - localStorage.getItem(MIGRATION_FLAG_KEY) === "true" || - localStorage.getItem(NEW_MIGRATION_FLAG_KEY) === "completed" - ); -} - -/** - * Migrates all tasks from localStorage to IndexedDB. - * - * @deprecated This migration is now handled automatically by initializeStorage(). - * The new migration system runs the localstorage-tasks-v1 external migration - * when storage is initialized. This function is kept for backward compatibility - * and now returns 0 since the migration runs elsewhere. - * - * @returns Always returns 0 (migration runs automatically during init) - */ -export async function migrateTasksFromLocalStorageToIndexedDB(): Promise { - // Migration is now handled by the external migrations system - // See: @web/common/storage/migrations/external/localstorage-tasks.ts - return 0; -} - -/** - * Resets the migration flag. Used for testing. - * @deprecated Use localStorage.removeItem() directly for testing - */ -export function resetTaskMigrationFlag(): void { - if (typeof window === "undefined") return; - localStorage.removeItem(MIGRATION_FLAG_KEY); - localStorage.removeItem(NEW_MIGRATION_FLAG_KEY); -} From d59e7c8bc329e6c1565b0d7f27efd3cc101a96bf Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 19 Feb 2026 10:21:20 -0800 Subject: [PATCH 07/13] refactor(storage): remove deprecated CompassLocalDB and update tests to use StorageAdapter - Deleted the deprecated CompassLocalDB utility, which was previously used for direct database access. - Updated tests to utilize the new StorageAdapter for event and task management, ensuring compatibility with the latest storage architecture. - Introduced a new method for testing raw stored tasks, enhancing the testing framework for migration and normalization processes. --- .../storage/adapter/indexeddb.adapter.ts | 9 +++- .../common/storage/migrations/migrations.ts | 2 +- .../common/utils/storage/compass-local.db.ts | 53 ------------------- .../utils/storage/event.storage.util.test.ts | 17 ++++-- .../utils/storage/task.storage.util.test.ts | 27 +++++++--- .../utils/sync/local-event-sync.util.test.ts | 10 ++-- .../ducks/events/sagas/event.sagas.test.ts | 13 +++-- .../ducks/events/sagas/someday.sagas.test.ts | 10 ++-- 8 files changed, 63 insertions(+), 78 deletions(-) delete mode 100644 packages/web/src/common/utils/storage/compass-local.db.ts diff --git a/packages/web/src/common/storage/adapter/indexeddb.adapter.ts b/packages/web/src/common/storage/adapter/indexeddb.adapter.ts index fcf00fc6b9..168f6621db 100644 --- a/packages/web/src/common/storage/adapter/indexeddb.adapter.ts +++ b/packages/web/src/common/storage/adapter/indexeddb.adapter.ts @@ -12,7 +12,6 @@ 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. - * When switching to SQLite, the equivalent would be ALTER TABLE migrations. */ class CompassDB extends Dexie { events!: Table; @@ -131,6 +130,14 @@ export class IndexedDBAdapter implements StorageAdapter { await this.db.tasks.clear(); } + /** + * For use in tests only. Puts a raw stored task without normalization. + * Use when testing migration/normalization of legacy data. + */ + async putRawStoredTaskForTesting(storedTask: StoredTask): Promise { + await this.db.tasks.put(storedTask); + } + // ─── Event Operations ────────────────────────────────────────────────────── async getEvents( diff --git a/packages/web/src/common/storage/migrations/migrations.ts b/packages/web/src/common/storage/migrations/migrations.ts index 087b08955c..9444e42989 100644 --- a/packages/web/src/common/storage/migrations/migrations.ts +++ b/packages/web/src/common/storage/migrations/migrations.ts @@ -10,7 +10,7 @@ import { DataMigration, ExternalMigration } from "./migration.types"; * Tracked via the storage adapter's migration records. */ export const dataMigrations: DataMigration[] = [ - // Add data migrations here as needed: + // Add data migrations here as needed. Examples: // addUserFieldMigration, // renameStatusFieldMigration, ]; diff --git a/packages/web/src/common/utils/storage/compass-local.db.ts b/packages/web/src/common/utils/storage/compass-local.db.ts deleted file mode 100644 index f4ab86b869..0000000000 --- a/packages/web/src/common/utils/storage/compass-local.db.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Compass local database - compatibility layer. - * - * @deprecated This module is deprecated. Use the StorageAdapter directly. - * - * For direct database access in tests, you can still import compassLocalDB, - * but new code should use getStorageAdapter() from @web/common/storage/adapter. - * - * @see {@link @web/common/storage/adapter} - */ -import Dexie, { Table } from "dexie"; -import { Event_Core } from "@core/types/event.types"; -import { Task } from "@web/common/types/task.types"; - -/** - * Task stored in IndexedDB with associated dateKey - * @deprecated Use StoredTask from @web/common/storage/adapter instead - */ -export type StoredTask = Task & { - dateKey: string; -}; - -/** - * Legacy database class for backward compatibility. - * @deprecated Use StorageAdapter instead - */ -class CompassLocalDB extends Dexie { - events!: Table; - tasks!: 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", - }); - - // Note: Version 3 adds _migrations table, defined in IndexedDBAdapter - } -} - -/** - * Legacy database instance for backward compatibility. - * @deprecated Use getStorageAdapter() instead - */ -export const compassLocalDB = new CompassLocalDB(); 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 index 0a093ed754..f77adca3d0 100644 --- a/packages/web/src/common/utils/storage/event.storage.util.test.ts +++ b/packages/web/src/common/utils/storage/event.storage.util.test.ts @@ -1,7 +1,10 @@ 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 { + ensureStorageReady, + getStorageAdapter, +} from "@web/common/storage/adapter/adapter"; import { clearEventsFromIndexedDB, deleteEventFromIndexedDB, @@ -12,6 +15,7 @@ import { describe("event.storage.util", () => { beforeEach(async () => { + await ensureStorageReady(); await clearEventsFromIndexedDB(); }); @@ -27,7 +31,8 @@ describe("event.storage.util", () => { const event = createMockEvent(); await saveEventToIndexedDB(event); - const savedEvent = await compassLocalDB.events.get(event._id!); + const allEvents = await getStorageAdapter().getAllEvents(); + const savedEvent = allEvents.find((e) => e._id === event._id); expect(savedEvent).toBeDefined(); expect(savedEvent?._id).toBe(event._id); expect(savedEvent?.title).toBe(event.title); @@ -43,7 +48,8 @@ describe("event.storage.util", () => { }; await saveEventToIndexedDB(updatedEvent); - const savedEvent = await compassLocalDB.events.get(event._id!); + const allEvents = await getStorageAdapter().getAllEvents(); + const savedEvent = allEvents.find((e) => e._id === event._id); expect(savedEvent?.title).toBe("Updated Title"); }); @@ -285,7 +291,8 @@ describe("event.storage.util", () => { await deleteEventFromIndexedDB(event._id!); - const deletedEvent = await compassLocalDB.events.get(event._id!); + const allEvents = await getStorageAdapter().getAllEvents(); + const deletedEvent = allEvents.find((e) => e._id === event._id); expect(deletedEvent).toBeUndefined(); }); @@ -306,7 +313,7 @@ describe("event.storage.util", () => { await clearEventsFromIndexedDB(); - const allEvents = await compassLocalDB.events.toArray(); + const allEvents = await getStorageAdapter().getAllEvents(); expect(allEvents).toHaveLength(0); }); }); 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 index f47dd985ea..a1b10adbe4 100644 --- a/packages/web/src/common/utils/storage/task.storage.util.test.ts +++ b/packages/web/src/common/utils/storage/task.storage.util.test.ts @@ -1,7 +1,11 @@ import { createMockTask } from "@web/__tests__/utils/factories/task.factory"; import { UNAUTHENTICATED_USER } from "@web/common/constants/auth.constants"; +import { + ensureStorageReady, + getStorageAdapter, +} from "@web/common/storage/adapter/adapter"; +import { IndexedDBAdapter } from "@web/common/storage/adapter/indexeddb.adapter"; import { Task } from "@web/common/types/task.types"; -import { compassLocalDB } from "./compass-local.db"; import { clearAllTasksFromIndexedDB, clearTasksForDateKey, @@ -15,6 +19,7 @@ import { describe("task.storage.util", () => { beforeEach(async () => { + await ensureStorageReady(); await clearAllTasksFromIndexedDB(); }); @@ -25,7 +30,8 @@ describe("task.storage.util", () => { await saveTaskToIndexedDB(task, dateKey); - const savedTask = await compassLocalDB.tasks.get(task._id); + const allTasks = await getStorageAdapter().getAllTasks(); + const savedTask = allTasks.find((t) => t._id === task._id); expect(savedTask).toBeDefined(); expect(savedTask?._id).toBe(task._id); expect(savedTask?.title).toBe(task.title); @@ -41,7 +47,8 @@ describe("task.storage.util", () => { const updatedTask = { ...task, title: "Updated Title" }; await saveTaskToIndexedDB(updatedTask, dateKey); - const savedTask = await compassLocalDB.tasks.get(task._id); + const allTasks = await getStorageAdapter().getAllTasks(); + const savedTask = allTasks.find((t) => t._id === task._id); expect(savedTask?.title).toBe("Updated Title"); }); @@ -52,7 +59,8 @@ describe("task.storage.util", () => { await saveTaskToIndexedDB(task, dateKey); - const savedTask = await compassLocalDB.tasks.get(task._id); + const allTasks = await getStorageAdapter().getAllTasks(); + const savedTask = allTasks.find((t) => t._id === task._id); expect(savedTask?.user).toBe(UNAUTHENTICATED_USER); }); }); @@ -129,7 +137,8 @@ describe("task.storage.util", () => { const task = createMockTask(); delete (task as Partial).user; - await compassLocalDB.tasks.put({ + const adapter = getStorageAdapter() as IndexedDBAdapter; + await adapter.putRawStoredTaskForTesting({ ...(task as unknown as Task), dateKey: "2024-01-15", }); @@ -148,7 +157,8 @@ describe("task.storage.util", () => { await deleteTaskFromIndexedDB(task._id); - const deletedTask = await compassLocalDB.tasks.get(task._id); + const allTasks = await getStorageAdapter().getAllTasks(); + const deletedTask = allTasks.find((t) => t._id === task._id); expect(deletedTask).toBeUndefined(); }); @@ -189,7 +199,7 @@ describe("task.storage.util", () => { await clearAllTasksFromIndexedDB(); - const allTasks = await compassLocalDB.tasks.toArray(); + const allTasks = await getStorageAdapter().getAllTasks(); expect(allTasks).toHaveLength(0); }); }); @@ -215,7 +225,8 @@ describe("task.storage.util", () => { await moveTaskBetweenDates(task, "2024-01-15", "2024-01-16"); - const savedTask = await compassLocalDB.tasks.get(task._id); + const allTasks = await getStorageAdapter().getAllTasks(); + const savedTask = allTasks.find((t) => t._id === task._id); expect(savedTask?.dateKey).toBe("2024-01-16"); }); }); 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..3a7c815d1c 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,6 +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 { + ensureStorageReady, + getStorageAdapter, +} from "@web/common/storage/adapter/adapter"; import { saveEventToIndexedDB } from "@web/common/utils/storage/event.storage.util"; import { EventApi } from "@web/ducks/events/event.api"; import { syncLocalEventsToCloud } from "./local-event-sync.util"; @@ -13,7 +16,8 @@ describe("syncLocalEventsToCloud", () => { beforeEach(async () => { mockCreate.mockResolvedValue({} as never); - await compassLocalDB.events.clear(); + await ensureStorageReady(); + await getStorageAdapter().clearAllEvents(); }); afterEach(() => { @@ -38,7 +42,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/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..00409656fd 100644 --- a/packages/web/src/ducks/events/sagas/someday.sagas.test.ts +++ b/packages/web/src/ducks/events/sagas/someday.sagas.test.ts @@ -4,9 +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"; @@ -25,7 +28,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 +42,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 From ac8b2a65323890f52a0e7455c8dc774cfcefd6e8 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 19 Feb 2026 10:24:24 -0800 Subject: [PATCH 08/13] refactor(storage): remove deprecated event storage utilities and update tests - Deleted deprecated event storage utility functions that delegated to the StorageAdapter. - Updated tests to utilize the StorageAdapter directly for event management, ensuring compatibility with the latest storage architecture. - This cleanup enhances maintainability and aligns with the recent refactor to streamline storage operations. --- .../utils/storage/event.storage.util.test.ts | 320 ------------------ .../utils/storage/event.storage.util.ts | 87 ----- .../utils/sync/local-event-sync.util.test.ts | 5 +- .../utils/sync/local-event-sync.util.ts | 12 +- .../ducks/events/sagas/someday.sagas.test.ts | 11 +- 5 files changed, 14 insertions(+), 421 deletions(-) delete mode 100644 packages/web/src/common/utils/storage/event.storage.util.test.ts delete mode 100644 packages/web/src/common/utils/storage/event.storage.util.ts 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 f77adca3d0..0000000000 --- a/packages/web/src/common/utils/storage/event.storage.util.test.ts +++ /dev/null @@ -1,320 +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 { - ensureStorageReady, - getStorageAdapter, -} from "@web/common/storage/adapter/adapter"; -import { - clearEventsFromIndexedDB, - deleteEventFromIndexedDB, - loadAllEventsFromIndexedDB, - loadEventsFromIndexedDB, - saveEventToIndexedDB, -} from "./event.storage.util"; - -describe("event.storage.util", () => { - beforeEach(async () => { - await ensureStorageReady(); - 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 allEvents = await getStorageAdapter().getAllEvents(); - const savedEvent = allEvents.find((e) => e._id === 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 allEvents = await getStorageAdapter().getAllEvents(); - const savedEvent = allEvents.find((e) => e._id === 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 allEvents = await getStorageAdapter().getAllEvents(); - const deletedEvent = allEvents.find((e) => e._id === 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 getStorageAdapter().getAllEvents(); - 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 c32b0a3b4d..0000000000 --- a/packages/web/src/common/utils/storage/event.storage.util.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Event storage utilities - compatibility layer. - * - * @deprecated These functions delegate to the StorageAdapter. - * New code should use getStorageAdapter() directly. - * - * @see {@link @web/common/storage/adapter} - */ -import { Event_Core } from "@core/types/event.types"; -import { - ensureStorageReady, - getStorageAdapter, -} from "@web/common/storage/adapter/adapter"; -import { handleDatabaseError } from "./db-errors.util"; - -/** - * Saves an event to IndexedDB. - * @deprecated Use getStorageAdapter().putEvent() instead - */ -export async function saveEventToIndexedDB(event: Event_Core): Promise { - if (!event._id) { - throw new Error("Event must have an _id to save to IndexedDB"); - } - - try { - await ensureStorageReady(); - await getStorageAdapter().putEvent(event); - } catch (error) { - handleDatabaseError(error, "save"); - } -} - -/** - * Loads events from IndexedDB filtered by date range and optionally by isSomeday flag. - * @deprecated Use getStorageAdapter().getEvents() instead - */ -export async function loadEventsFromIndexedDB( - startDate: string, - endDate: string, - isSomeday?: boolean, -): Promise { - try { - await ensureStorageReady(); - return await getStorageAdapter().getEvents(startDate, endDate, isSomeday); - } catch (error) { - handleDatabaseError(error, "load"); - } -} - -/** - * Loads all events from IndexedDB without filtering. - * @deprecated Use getStorageAdapter().getAllEvents() instead - */ -export async function loadAllEventsFromIndexedDB(): Promise { - try { - await ensureStorageReady(); - return await getStorageAdapter().getAllEvents(); - } catch (error) { - handleDatabaseError(error, "load"); - } -} - -/** - * Deletes an event from IndexedDB by its ID. - * @deprecated Use getStorageAdapter().deleteEvent() instead - */ -export async function deleteEventFromIndexedDB(eventId: string): Promise { - try { - await ensureStorageReady(); - await getStorageAdapter().deleteEvent(eventId); - } catch (error) { - handleDatabaseError(error, "delete"); - } -} - -/** - * Clears all events from IndexedDB. - * @deprecated Use getStorageAdapter().clearAllEvents() instead - */ -export async function clearEventsFromIndexedDB(): Promise { - try { - await ensureStorageReady(); - await getStorageAdapter().clearAllEvents(); - } catch (error) { - handleDatabaseError(error, "clear"); - } -} 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 3a7c815d1c..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 @@ -4,7 +4,6 @@ import { ensureStorageReady, getStorageAdapter, } from "@web/common/storage/adapter/adapter"; -import { saveEventToIndexedDB } from "@web/common/utils/storage/event.storage.util"; import { EventApi } from "@web/ducks/events/event.api"; import { syncLocalEventsToCloud } from "./local-event-sync.util"; @@ -28,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(); 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/someday.sagas.test.ts b/packages/web/src/ducks/events/sagas/someday.sagas.test.ts index 00409656fd..33da4c675d 100644 --- a/packages/web/src/ducks/events/sagas/someday.sagas.test.ts +++ b/packages/web/src/ducks/events/sagas/someday.sagas.test.ts @@ -10,7 +10,6 @@ import { } from "@web/common/storage/adapter/adapter"; import { sagaMiddleware } from "@web/common/store/middlewares"; import { Response_HttpPaginatedSuccess } from "@web/common/types/api.types"; -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"; @@ -136,7 +135,7 @@ describe("getSomedayEvents saga", () => { user: "UNAUTHENTICATED_USER", }; - await saveEventToIndexedDB(somedayEvent); + await getStorageAdapter().putEvent(somedayEvent); const action = getSomedayEventsSlice.actions.request({ startDate, @@ -214,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, @@ -263,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, From 4e3a2c4cdb08dcc22fe43dd7ff1728f8cd4a38e8 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 19 Feb 2026 10:28:51 -0800 Subject: [PATCH 09/13] refactor(storage): migrate task storage utilities to use StorageAdapter - Removed deprecated task storage utility functions and their associated tests, transitioning to the new StorageAdapter for all task-related operations. - Updated all relevant code and tests to utilize the StorageAdapter directly, ensuring compatibility with the latest storage architecture. - This refactor enhances maintainability and aligns with the ongoing improvements in the storage system. --- .../src/common/utils/storage/storage.util.ts | 18 +- .../utils/storage/task.storage.util.test.ts | 261 ------------------ .../common/utils/storage/task.storage.util.ts | 147 ---------- .../context/__tests__/TaskContext.test.tsx | 25 +- .../tasks/useLoadTasksByDateEffect.test.ts | 19 +- .../hooks/tasks/useLoadTasksByDateEffect.ts | 10 +- .../tasks/useSaveTasksByDateEffect.test.ts | 22 +- .../hooks/tasks/useSaveTasksByDateEffect.ts | 10 +- .../Day/hooks/tasks/useTaskState.test.ts | 43 ++- 9 files changed, 76 insertions(+), 479 deletions(-) delete mode 100644 packages/web/src/common/utils/storage/task.storage.util.test.ts delete mode 100644 packages/web/src/common/utils/storage/task.storage.util.ts diff --git a/packages/web/src/common/utils/storage/storage.util.ts b/packages/web/src/common/utils/storage/storage.util.ts index b525fbe14f..ff02bef3f5 100644 --- a/packages/web/src/common/utils/storage/storage.util.ts +++ b/packages/web/src/common/utils/storage/storage.util.ts @@ -1,10 +1,9 @@ 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"; + ensureStorageReady, + getStorageAdapter, +} from "@web/common/storage/adapter/adapter"; +import { Task, normalizeTasks } from "@web/common/types/task.types"; import { CompassTasksSavedEventDetail } from "./storage.types"; export const TODAY_TASKS_STORAGE_KEY_PREFIX = "compass.today.tasks"; @@ -33,7 +32,8 @@ export async function loadTasksFromStorage(dateKey: string): Promise { } try { - return await loadTasksFromIndexedDB(dateKey); + await ensureStorageReady(); + return await getStorageAdapter().getTasks(dateKey); } catch (error) { console.error("Error loading tasks from IndexedDB:", error); return []; @@ -49,7 +49,8 @@ export async function saveTasksToStorage( } try { - await saveTasksToIndexedDB(dateKey, normalizeTasks(tasks)); + await ensureStorageReady(); + await getStorageAdapter().putTasks(dateKey, normalizeTasks(tasks)); dispatchTasksSavedEvent(dateKey); } catch (error) { console.error("Error saving tasks to IndexedDB:", error); @@ -88,7 +89,8 @@ export async function moveTaskToDate( } try { - await moveTaskBetweenDates(task, fromDateKey, toDateKey); + await ensureStorageReady(); + await getStorageAdapter().moveTask(task, fromDateKey, toDateKey); dispatchTasksSavedEvent(fromDateKey); if (toDateKey !== fromDateKey) { dispatchTasksSavedEvent(toDateKey); 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 a1b10adbe4..0000000000 --- a/packages/web/src/common/utils/storage/task.storage.util.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { createMockTask } from "@web/__tests__/utils/factories/task.factory"; -import { UNAUTHENTICATED_USER } from "@web/common/constants/auth.constants"; -import { - ensureStorageReady, - getStorageAdapter, -} from "@web/common/storage/adapter/adapter"; -import { IndexedDBAdapter } from "@web/common/storage/adapter/indexeddb.adapter"; -import { Task } from "@web/common/types/task.types"; -import { - clearAllTasksFromIndexedDB, - clearTasksForDateKey, - deleteTaskFromIndexedDB, - loadAllTasksFromIndexedDB, - loadTasksFromIndexedDB, - moveTaskBetweenDates, - saveTaskToIndexedDB, - saveTasksToIndexedDB, -} from "./task.storage.util"; - -describe("task.storage.util", () => { - beforeEach(async () => { - await ensureStorageReady(); - 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 allTasks = await getStorageAdapter().getAllTasks(); - const savedTask = allTasks.find((t) => t._id === 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 allTasks = await getStorageAdapter().getAllTasks(); - const savedTask = allTasks.find((t) => t._id === 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 allTasks = await getStorageAdapter().getAllTasks(); - const savedTask = allTasks.find((t) => t._id === 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; - - const adapter = getStorageAdapter() as IndexedDBAdapter; - await adapter.putRawStoredTaskForTesting({ - ...(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 allTasks = await getStorageAdapter().getAllTasks(); - const deletedTask = allTasks.find((t) => t._id === 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 getStorageAdapter().getAllTasks(); - 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 allTasks = await getStorageAdapter().getAllTasks(); - const savedTask = allTasks.find((t) => t._id === 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 4d8e305001..0000000000 --- a/packages/web/src/common/utils/storage/task.storage.util.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Task storage utilities - compatibility layer. - * - * @deprecated These functions delegate to the StorageAdapter. - * New code should use getStorageAdapter() directly. - * - * @see {@link @web/common/storage/adapter} - */ -import { - StoredTask, - ensureStorageReady, - getStorageAdapter, -} from "@web/common/storage/adapter/adapter"; -import { Task, normalizeTask } from "@web/common/types/task.types"; -import { handleDatabaseError } from "./db-errors.util"; - -// Re-export StoredTask for backward compatibility -export type { StoredTask }; - -/** - * Saves a single task to IndexedDB with its associated dateKey. - * @deprecated Use getStorageAdapter().putTasks() instead - */ -export async function saveTaskToIndexedDB( - task: Task, - dateKey: string, -): Promise { - try { - await ensureStorageReady(); - const adapter = getStorageAdapter(); - - // Get existing tasks, add/update this one, save all - const existingTasks = await adapter.getTasks(dateKey); - const normalizedTask = normalizeTask(task); - const taskIndex = existingTasks.findIndex( - (t) => t._id === normalizedTask._id, - ); - - if (taskIndex >= 0) { - existingTasks[taskIndex] = normalizedTask; - } else { - existingTasks.push(normalizedTask); - } - - await adapter.putTasks(dateKey, existingTasks); - } catch (error) { - handleDatabaseError(error, "save"); - } -} - -/** - * Saves multiple tasks to IndexedDB for a specific dateKey. - * @deprecated Use getStorageAdapter().putTasks() instead - */ -export async function saveTasksToIndexedDB( - dateKey: string, - tasks: Task[], -): Promise { - try { - await ensureStorageReady(); - await getStorageAdapter().putTasks(dateKey, tasks); - } catch (error) { - handleDatabaseError(error, "save"); - } -} - -/** - * Loads all tasks from IndexedDB for a specific dateKey. - * @deprecated Use getStorageAdapter().getTasks() instead - */ -export async function loadTasksFromIndexedDB(dateKey: string): Promise { - try { - await ensureStorageReady(); - return await getStorageAdapter().getTasks(dateKey); - } catch (error) { - handleDatabaseError(error, "load"); - } -} - -/** - * Deletes a task from IndexedDB by its ID. - * @deprecated Use getStorageAdapter().deleteTask() instead - */ -export async function deleteTaskFromIndexedDB(taskId: string): Promise { - try { - await ensureStorageReady(); - await getStorageAdapter().deleteTask(taskId); - } catch (error) { - handleDatabaseError(error, "delete"); - } -} - -/** - * Clears all tasks for a specific dateKey from IndexedDB. - * @deprecated Use getStorageAdapter().putTasks(dateKey, []) instead - */ -export async function clearTasksForDateKey(dateKey: string): Promise { - try { - await ensureStorageReady(); - await getStorageAdapter().putTasks(dateKey, []); - } catch (error) { - handleDatabaseError(error, "clear"); - } -} - -/** - * Clears all tasks from IndexedDB. - * @deprecated Use getStorageAdapter().clearAllTasks() instead - */ -export async function clearAllTasksFromIndexedDB(): Promise { - try { - await ensureStorageReady(); - await getStorageAdapter().clearAllTasks(); - } catch (error) { - handleDatabaseError(error, "clear"); - } -} - -/** - * Moves a task from one date to another. - * @deprecated Use getStorageAdapter().moveTask() instead - */ -export async function moveTaskBetweenDates( - task: Task, - fromDateKey: string, - toDateKey: string, -): Promise { - try { - await ensureStorageReady(); - await getStorageAdapter().moveTask(task, fromDateKey, toDateKey); - } catch (error) { - handleDatabaseError(error, "move"); - } -} - -/** - * Loads all tasks from IndexedDB. - * @deprecated Use getStorageAdapter().getAllTasks() instead - */ -export async function loadAllTasksFromIndexedDB(): Promise { - try { - await ensureStorageReady(); - return await getStorageAdapter().getAllTasks(); - } catch (error) { - handleDatabaseError(error, "load"); - } -} 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..0497eebb6e 100644 --- a/packages/web/src/views/Day/hooks/tasks/useLoadTasksByDateEffect.test.ts +++ b/packages/web/src/views/Day/hooks/tasks/useLoadTasksByDateEffect.test.ts @@ -3,11 +3,12 @@ import { useRef, useState } from "react"; import { renderHook, waitFor } from "@testing-library/react"; import { createMockTask } from "@web/__tests__/utils/factories/task.factory"; 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 mockGetTasks = jest.fn(); +jest.mock("@web/common/storage/adapter/adapter", () => ({ + ensureStorageReady: jest.fn().mockResolvedValue(undefined), + getStorageAdapter: jest.fn(() => ({ getTasks: mockGetTasks })), })); interface Deferred { @@ -52,17 +53,13 @@ function useLoadHarness(dateKey: string) { } describe("useLoadTasksByDateEffect", () => { - const loadTasksMock = - taskStorageUtil.loadTasksFromIndexedDB as jest.MockedFunction< - typeof taskStorageUtil.loadTasksFromIndexedDB - >; - beforeEach(() => { jest.clearAllMocks(); + mockGetTasks.mockReset(); }); it("loads tasks for date and sorts by status/order", async () => { - loadTasksMock.mockResolvedValueOnce([ + mockGetTasks.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 +83,7 @@ describe("useLoadTasksByDateEffect", () => { it("clears tasks while loading a new date and ignores stale results", async () => { const firstLoad = createDeferred(); const secondLoad = createDeferred(); - loadTasksMock + mockGetTasks .mockReturnValueOnce(firstLoad.promise) .mockReturnValueOnce(secondLoad.promise); @@ -128,7 +125,7 @@ describe("useLoadTasksByDateEffect", () => { }); it("sets failed state when load throws", async () => { - loadTasksMock.mockRejectedValueOnce(new Error("load failed")); + mockGetTasks.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..561efd35ce 100644 --- a/packages/web/src/views/Day/hooks/tasks/useLoadTasksByDateEffect.ts +++ b/packages/web/src/views/Day/hooks/tasks/useLoadTasksByDateEffect.ts @@ -1,6 +1,9 @@ import { Dispatch, MutableRefObject, SetStateAction, useEffect } from "react"; +import { + ensureStorageReady, + getStorageAdapter, +} 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 { @@ -33,7 +36,10 @@ export function useLoadTasksByDateEffect({ setDidLoadFail(false); setIsLoadingTasks(true); - void loadTasksFromIndexedDB(dateKey) + void (async () => { + await ensureStorageReady(); + return getStorageAdapter().getTasks(dateKey); + })() .then((loadedTasks) => { if (isCancelled || requestId !== loadRequestIdRef.current) return; 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..2df317813c 100644 --- a/packages/web/src/views/Day/hooks/tasks/useSaveTasksByDateEffect.test.ts +++ b/packages/web/src/views/Day/hooks/tasks/useSaveTasksByDateEffect.test.ts @@ -2,11 +2,12 @@ import { useEffect, useRef } from "react"; import { renderHook, waitFor } from "@testing-library/react"; import { createMockTask } from "@web/__tests__/utils/factories/task.factory"; 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 mockPutTasks = jest.fn(); +jest.mock("@web/common/storage/adapter/adapter", () => ({ + ensureStorageReady: jest.fn().mockResolvedValue(undefined), + getStorageAdapter: jest.fn(() => ({ putTasks: mockPutTasks })), })); interface SaveHarnessProps { @@ -47,14 +48,9 @@ function useSaveHarness({ } describe("useSaveTasksByDateEffect", () => { - const saveTasksMock = - taskStorageUtil.saveTasksToIndexedDB as jest.MockedFunction< - typeof taskStorageUtil.saveTasksToIndexedDB - >; - beforeEach(() => { jest.clearAllMocks(); - saveTasksMock.mockResolvedValue(undefined); + mockPutTasks.mockResolvedValue(undefined); }); it("saves tasks and clears dirty flag when all guards pass", async () => { @@ -72,7 +68,7 @@ describe("useSaveTasksByDateEffect", () => { ); await waitFor(() => { - expect(saveTasksMock).toHaveBeenCalledWith("2025-10-27", tasks); + expect(mockPutTasks).toHaveBeenCalledWith("2025-10-27", tasks); expect(result.current.isDirtyRef.current).toBe(false); }); }); @@ -125,13 +121,13 @@ describe("useSaveTasksByDateEffect", () => { ); await waitFor(() => { - expect(saveTasksMock).not.toHaveBeenCalled(); + expect(mockPutTasks).not.toHaveBeenCalled(); }); }); it("keeps dirty flag when save fails", async () => { const tasks = [createMockTask({ _id: "task-1" })]; - saveTasksMock.mockRejectedValueOnce(new Error("save failed")); + mockPutTasks.mockRejectedValueOnce(new Error("save failed")); const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); const { result } = renderHook(() => @@ -146,7 +142,7 @@ describe("useSaveTasksByDateEffect", () => { ); await waitFor(() => { - expect(saveTasksMock).toHaveBeenCalledWith("2025-10-27", tasks); + expect(mockPutTasks).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..24b1b5d390 100644 --- a/packages/web/src/views/Day/hooks/tasks/useSaveTasksByDateEffect.ts +++ b/packages/web/src/views/Day/hooks/tasks/useSaveTasksByDateEffect.ts @@ -1,6 +1,9 @@ import { MutableRefObject, useEffect } from "react"; +import { + ensureStorageReady, + getStorageAdapter, +} 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; @@ -31,7 +34,10 @@ export function useSaveTasksByDateEffect({ const requestId = saveRequestIdRef.current + 1; saveRequestIdRef.current = requestId; - void saveTasksToIndexedDB(dateKey, tasks) + void (async () => { + await ensureStorageReady(); + await getStorageAdapter().putTasks(dateKey, tasks); + })() .then(() => { if (isCancelled || requestId !== saveRequestIdRef.current) return; isDirtyRef.current = false; 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..8922fd8204 100644 --- a/packages/web/src/views/Day/hooks/tasks/useTaskState.test.ts +++ b/packages/web/src/views/Day/hooks/tasks/useTaskState.test.ts @@ -3,12 +3,16 @@ import { renderHook, waitFor } from "@testing-library/react"; import { createMockTask } from "@web/__tests__/utils/factories/task.factory"; 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 mockGetTasks = jest.fn(); +const mockPutTasks = jest.fn().mockResolvedValue(undefined); +jest.mock("@web/common/storage/adapter/adapter", () => ({ + ensureStorageReady: jest.fn().mockResolvedValue(undefined), + getStorageAdapter: jest.fn(() => ({ + getTasks: mockGetTasks, + putTasks: mockPutTasks, + })), })); interface Deferred { @@ -25,15 +29,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 +36,13 @@ describe("useTaskState", () => { beforeEach(() => { jest.clearAllMocks(); - saveTasksMock.mockResolvedValue(undefined); + mockPutTasks.mockResolvedValue(undefined); + mockGetTasks.mockReset(); }); it("clears tasks and enters loading state when date changes", async () => { const dayTwoLoad = createDeferred(); - loadTasksMock + mockGetTasks .mockResolvedValueOnce([ createMockTask({ _id: "task-1", @@ -86,7 +82,7 @@ describe("useTaskState", () => { it("does not save previous-day tasks under the next date key", async () => { const dayTwoLoad = createDeferred(); - loadTasksMock + mockGetTasks .mockResolvedValueOnce([]) .mockReturnValueOnce(dayTwoLoad.promise); @@ -106,10 +102,10 @@ describe("useTaskState", () => { }); await waitFor(() => { - expect(saveTasksMock).toHaveBeenCalledWith(dayOneKey, [localTask]); + expect(mockPutTasks).toHaveBeenCalledWith(dayOneKey, [localTask]); }); - saveTasksMock.mockClear(); + mockPutTasks.mockClear(); rerender({ currentDate: dayTwoDate }); @@ -117,11 +113,8 @@ describe("useTaskState", () => { await Promise.resolve(); }); - expect(saveTasksMock).not.toHaveBeenCalledWith( - dayTwoKey, - expect.any(Array), - ); - expect(saveTasksMock).not.toHaveBeenCalled(); + expect(mockPutTasks).not.toHaveBeenCalledWith(dayTwoKey, expect.any(Array)); + expect(mockPutTasks).not.toHaveBeenCalled(); await act(async () => { dayTwoLoad.resolve([]); @@ -130,7 +123,7 @@ describe("useTaskState", () => { }); it("does not save empty tasks after a load failure", async () => { - loadTasksMock.mockRejectedValue(new Error("load failed")); + mockGetTasks.mockRejectedValue(new Error("load failed")); const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); const { result } = renderHook(() => @@ -143,7 +136,7 @@ describe("useTaskState", () => { expect(result.current.tasks).toEqual([]); }); - expect(saveTasksMock).not.toHaveBeenCalled(); + expect(mockPutTasks).not.toHaveBeenCalled(); consoleErrorSpy.mockRestore(); }); }); From 3cb1509c572eff4a11997a1b9ef71e428a062742 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 19 Feb 2026 10:37:49 -0800 Subject: [PATCH 10/13] feat(storage): add putTask method for single task management - Introduced a new `putTask` method in the `StorageAdapter` to handle saving or updating individual tasks based on their `_id`. - Updated the `IndexedDBAdapter` to implement the `putTask` functionality, ensuring tasks can be inserted or updated efficiently. - Enhanced tests across various components to validate the new `putTask` method, ensuring it integrates seamlessly with existing task management workflows. - Refactored related utility functions to utilize the new `putTask` method, improving code maintainability and clarity. --- .../event/local.event.repository.test.ts | 1 + .../storage/adapter/indexeddb.adapter.test.ts | 49 +++++++++++++++++ .../storage/adapter/indexeddb.adapter.ts | 6 ++ .../common/storage/adapter/storage.adapter.ts | 6 ++ .../external/localstorage-tasks.test.ts | 9 +++ .../storage/migrations/migrations.test.ts | 1 + .../src/common/utils/storage/storage.util.ts | 23 +++++++- .../tasks/useSaveTasksByDateEffect.test.ts | 5 +- .../Day/hooks/tasks/useTaskActions.test.ts | 12 ++-- .../views/Day/hooks/tasks/useTaskActions.ts | 23 +++----- .../Day/hooks/tasks/useTaskState.test.ts | 1 + .../TaskSelector/TaskSelector.test.tsx | 55 +++++++------------ .../components/TaskSelector/TaskSelector.tsx | 2 +- .../src/views/Now/context/NowViewProvider.tsx | 40 +++++++------- 14 files changed, 157 insertions(+), 76 deletions(-) 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 17b1b8e40e..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 @@ -22,6 +22,7 @@ function createMockAdapter() { 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), diff --git a/packages/web/src/common/storage/adapter/indexeddb.adapter.test.ts b/packages/web/src/common/storage/adapter/indexeddb.adapter.test.ts index e7496ee252..18d9bd05ef 100644 --- a/packages/web/src/common/storage/adapter/indexeddb.adapter.test.ts +++ b/packages/web/src/common/storage/adapter/indexeddb.adapter.test.ts @@ -56,6 +56,55 @@ describe("IndexedDBAdapter", () => { 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, [ diff --git a/packages/web/src/common/storage/adapter/indexeddb.adapter.ts b/packages/web/src/common/storage/adapter/indexeddb.adapter.ts index 168f6621db..188585c438 100644 --- a/packages/web/src/common/storage/adapter/indexeddb.adapter.ts +++ b/packages/web/src/common/storage/adapter/indexeddb.adapter.ts @@ -98,6 +98,12 @@ export class IndexedDBAdapter implements StorageAdapter { }); } + 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); } diff --git a/packages/web/src/common/storage/adapter/storage.adapter.ts b/packages/web/src/common/storage/adapter/storage.adapter.ts index 57f538078a..e8fa380f14 100644 --- a/packages/web/src/common/storage/adapter/storage.adapter.ts +++ b/packages/web/src/common/storage/adapter/storage.adapter.ts @@ -57,6 +57,12 @@ export interface StorageAdapter { */ 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. */ 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 index 0f4aeb697e..031a99adad 100644 --- a/packages/web/src/common/storage/migrations/external/localstorage-tasks.test.ts +++ b/packages/web/src/common/storage/migrations/external/localstorage-tasks.test.ts @@ -20,6 +20,15 @@ function createMockAdapter(): jest.Mocked { 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), diff --git a/packages/web/src/common/storage/migrations/migrations.test.ts b/packages/web/src/common/storage/migrations/migrations.test.ts index 05cfbbf3e1..58e5f24723 100644 --- a/packages/web/src/common/storage/migrations/migrations.test.ts +++ b/packages/web/src/common/storage/migrations/migrations.test.ts @@ -15,6 +15,7 @@ function createMockAdapter(): jest.Mocked { 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), diff --git a/packages/web/src/common/utils/storage/storage.util.ts b/packages/web/src/common/utils/storage/storage.util.ts index ff02bef3f5..4d57d63fc1 100644 --- a/packages/web/src/common/utils/storage/storage.util.ts +++ b/packages/web/src/common/utils/storage/storage.util.ts @@ -3,7 +3,11 @@ import { ensureStorageReady, getStorageAdapter, } from "@web/common/storage/adapter/adapter"; -import { Task, normalizeTasks } from "@web/common/types/task.types"; +import { + Task, + normalizeTask, + normalizeTasks, +} from "@web/common/types/task.types"; import { CompassTasksSavedEventDetail } from "./storage.types"; export const TODAY_TASKS_STORAGE_KEY_PREFIX = "compass.today.tasks"; @@ -57,6 +61,23 @@ export async function saveTasksToStorage( } } +export async function saveTaskToStorage( + dateKey: string, + task: Task, +): Promise { + if (typeof window === "undefined") { + return; + } + + try { + await ensureStorageReady(); + await getStorageAdapter().putTask(dateKey, normalizeTask(task)); + dispatchTasksSavedEvent(dateKey); + } catch (error) { + console.error("Error saving task to storage:", error); + } +} + export async function loadTodayTasks(): Promise { const dateKey = getDateKey(); return await loadTasksFromStorage(dateKey); 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 2df317813c..f0c1c3d289 100644 --- a/packages/web/src/views/Day/hooks/tasks/useSaveTasksByDateEffect.test.ts +++ b/packages/web/src/views/Day/hooks/tasks/useSaveTasksByDateEffect.test.ts @@ -7,7 +7,10 @@ import { useSaveTasksByDateEffect } from "@web/views/Day/hooks/tasks/useSaveTask const mockPutTasks = jest.fn(); jest.mock("@web/common/storage/adapter/adapter", () => ({ ensureStorageReady: jest.fn().mockResolvedValue(undefined), - getStorageAdapter: jest.fn(() => ({ putTasks: mockPutTasks })), + getStorageAdapter: jest.fn(() => ({ + putTasks: mockPutTasks, + putTask: jest.fn().mockResolvedValue(undefined), + })), })); interface SaveHarnessProps { 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..c685be5ebe 100644 --- a/packages/web/src/views/Day/hooks/tasks/useTaskActions.test.ts +++ b/packages/web/src/views/Day/hooks/tasks/useTaskActions.test.ts @@ -4,6 +4,7 @@ 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 storageUtil from "@web/common/utils/storage/storage.util"; import { showMigrationToast } from "@web/views/Day/components/Toasts/MigrationToast/MigrationToast"; import { useTaskActions } from "./useTaskActions"; @@ -33,6 +34,7 @@ describe("useTaskActions - migration", () => { beforeEach(() => { jest.clearAllMocks(); + jest.spyOn(storageUtil, "saveTaskToStorage").mockResolvedValue(undefined); mockTaskRepository.get.mockResolvedValue([]); mockTaskRepository.save.mockResolvedValue(undefined); mockTaskRepository.delete.mockResolvedValue(undefined); @@ -183,9 +185,10 @@ describe("useTaskActions - migration", () => { await waitFor(() => { expect(mockTaskRepository.save).toHaveBeenCalledWith("2025-10-28", []); }); - expect(mockTaskRepository.save).toHaveBeenCalledWith("2025-10-27", [ + expect(storageUtil.saveTaskToStorage).toHaveBeenCalledWith( + "2025-10-27", mockTask, - ]); + ); // Should clear undo state expect(mockSetUndoState).toHaveBeenCalledWith(null); @@ -223,9 +226,10 @@ describe("useTaskActions - migration", () => { await waitFor(() => { expect(mockTaskRepository.save).toHaveBeenCalledWith("2025-10-26", []); }); - expect(mockTaskRepository.save).toHaveBeenCalledWith("2025-10-27", [ + expect(storageUtil.saveTaskToStorage).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..b055c15dcf 100644 --- a/packages/web/src/views/Day/hooks/tasks/useTaskActions.ts +++ b/packages/web/src/views/Day/hooks/tasks/useTaskActions.ts @@ -5,7 +5,10 @@ import { UNAUTHENTICATED_USER } from "@web/common/constants/auth.constants"; import { TaskRepository } from "@web/common/repositories/task/task.repository"; import { Task, UndoOperation } from "@web/common/types/task.types"; import { createObjectIdString } from "@web/common/utils/id/object-id.util"; -import { getDateKey } from "@web/common/utils/storage/storage.util"; +import { + getDateKey, + saveTaskToStorage, +} from "@web/common/utils/storage/storage.util"; import { sortTasksByStatus } from "@web/common/utils/task/sort.task"; import { showMigrationToast } from "@web/views/Day/components/Toasts/MigrationToast/MigrationToast"; import { showUndoDeleteToast } from "@web/views/Day/components/Toasts/UndoToast/UndoDeleteToast"; @@ -113,8 +116,9 @@ export function useTaskActions({ ) { const currentDateKey = getDateKey(dateInView.toDate()); + const fromDate = undoState.fromDate; // Only restore if we're still on the same date where the migration happened - if (currentDateKey === undoState.fromDate) { + if (currentDateKey === fromDate) { // Add the task back to the list setTasks((prev) => sortTasksByStatus([...prev, undoState.task])); @@ -135,19 +139,8 @@ export function useTaskActions({ ); 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, - ]); - } + // Restore the task to the original date in storage (single write, no read) + await saveTaskToStorage(fromDate, undoState.task); } catch (error) { console.error("Failed to restore task in repository:", error); } 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 8922fd8204..4590f49544 100644 --- a/packages/web/src/views/Day/hooks/tasks/useTaskState.test.ts +++ b/packages/web/src/views/Day/hooks/tasks/useTaskState.test.ts @@ -12,6 +12,7 @@ jest.mock("@web/common/storage/adapter/adapter", () => ({ getStorageAdapter: jest.fn(() => ({ getTasks: mockGetTasks, putTasks: mockPutTasks, + putTask: jest.fn().mockResolvedValue(undefined), })), })); 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..1fb884888a 100644 --- a/packages/web/src/views/Now/components/TaskSelector/TaskSelector.test.tsx +++ b/packages/web/src/views/Now/components/TaskSelector/TaskSelector.test.tsx @@ -26,7 +26,7 @@ jest.mock("@web/common/utils/storage/storage.util", () => ({ getDateKey: jest.fn(), loadTasksFromStorage: jest.fn(), saveTasksToStorage: jest.fn(), - updateTodayTasks: jest.fn(), + saveTaskToStorage: jest.fn(), })); const mockUseFocusedTask = useFocusedTask as jest.MockedFunction< @@ -91,7 +91,7 @@ describe("TaskSelector", () => { mockTasks, ); (storageUtil.saveTasksToStorage as jest.Mock).mockResolvedValue(undefined); - (storageUtil.updateTodayTasks as jest.Mock).mockResolvedValue(mockTasks); + (storageUtil.saveTaskToStorage as jest.Mock).mockResolvedValue(undefined); // Use fake timers to control the current time jest.useFakeTimers(); @@ -305,13 +305,7 @@ 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; - }, - ); + (storageUtil.saveTaskToStorage as jest.Mock).mockResolvedValue(undefined); renderWithNowProvider(); @@ -321,10 +315,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(storageUtil.saveTaskToStorage).toHaveBeenCalledTimes(1); + expect(storageUtil.saveTaskToStorage).toHaveBeenCalledWith( + mockDateKey, + expect.objectContaining({ _id: "task-1", status: "completed" }), + ); expect(mockSetFocusedTask).toHaveBeenCalledWith("task-2"); }); }); @@ -359,13 +354,7 @@ 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; - }, - ); + (storageUtil.saveTaskToStorage as jest.Mock).mockResolvedValue(undefined); renderWithNowProvider(); @@ -375,10 +364,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(storageUtil.saveTaskToStorage).toHaveBeenCalledTimes(1); + expect(storageUtil.saveTaskToStorage).toHaveBeenCalledWith( + mockDateKey, + expect.objectContaining({ _id: "task-2", status: "completed" }), + ); expect(mockSetFocusedTask).toHaveBeenCalledWith("task-1"); }); }); @@ -405,13 +395,7 @@ 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; - }, - ); + (storageUtil.saveTaskToStorage as jest.Mock).mockResolvedValue(undefined); renderWithNowProvider(); @@ -421,10 +405,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(storageUtil.saveTaskToStorage).toHaveBeenCalledTimes(1); + expect(storageUtil.saveTaskToStorage).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..04407a7e80 100644 --- a/packages/web/src/views/Now/context/NowViewProvider.tsx +++ b/packages/web/src/views/Now/context/NowViewProvider.tsx @@ -2,7 +2,10 @@ import React, { createContext, useCallback } from "react"; import { useNavigate } from "react-router-dom"; import { ROOT_ROUTES } from "@web/common/constants/routes"; import { Task } from "@web/common/types/task.types"; -import { updateTodayTasks } from "@web/common/utils/storage/storage.util"; +import { + getDateKey, + saveTaskToStorage, +} 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 +19,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,25 +32,24 @@ 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 }; + await saveTaskToStorage(getDateKey(), 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) => { - console.error("Failed to update task description:", error); - }); + (task: Task, description: string) => { + void saveTaskToStorage(getDateKey(), { ...task, description }).catch( + (error) => { + console.error("Failed to update task description:", error); + }, + ); }, [], ); @@ -90,7 +92,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); From 4f2e5d1ca723caf4542584ea89c6355d1b9e04b8 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 19 Feb 2026 10:48:40 -0800 Subject: [PATCH 11/13] feat(tests): add end-to-end tests for task deletion and restoration - Introduced a new test suite for verifying the delete and restore functionality of tasks using Playwright. - Implemented tests to ensure tasks can be deleted and subsequently restored from the undo toast. - Enhanced utility functions for task visibility and deletion, improving test reliability and maintainability. - Updated existing task utility functions to support new test scenarios, ensuring comprehensive coverage of task management features. --- e2e/tasks/delete-restore-task.spec.ts | 40 +++++ e2e/utils/task-test-utils.ts | 36 ++++- .../migrations/external/localstorage-tasks.ts | 2 +- .../views/Day/hooks/tasks/useTaskActions.ts | 140 ++++++++++-------- 4 files changed, 151 insertions(+), 67 deletions(-) create mode 100644 e2e/tasks/delete-restore-task.spec.ts 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/storage/migrations/external/localstorage-tasks.ts b/packages/web/src/common/storage/migrations/external/localstorage-tasks.ts index 1347545048..02cc5338db 100644 --- a/packages/web/src/common/storage/migrations/external/localstorage-tasks.ts +++ b/packages/web/src/common/storage/migrations/external/localstorage-tasks.ts @@ -112,7 +112,7 @@ export const localStorageTasksMigration: ExternalMigration = { keysToRemove.push(key); } catch { // Skip invalid entries - don't remove from localStorage for retry - console.warn(`[Migration] Failed to parse tasks from: ${key}`); + console.error(`[Migration] Failed to parse tasks from: ${key}`); } } diff --git a/packages/web/src/views/Day/hooks/tasks/useTaskActions.ts b/packages/web/src/views/Day/hooks/tasks/useTaskActions.ts index b055c15dcf..b0ce887d9e 100644 --- a/packages/web/src/views/Day/hooks/tasks/useTaskActions.ts +++ b/packages/web/src/views/Day/hooks/tasks/useTaskActions.ts @@ -101,66 +101,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()); - - const fromDate = undoState.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, 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 (single write, no read) - await saveTaskToStorage(fromDate, undoState.task); - } catch (error) { - console.error("Failed to restore task in repository:", error); - } - }; - restoreInStorage(); + 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 saveTaskToStorage(fromDate, operation.task); + } catch (error) { + console.error("Failed to restore task in repository:", error); + } + }; + 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; @@ -173,13 +176,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); @@ -343,13 +351,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 @@ -365,7 +375,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); } }, From 998f9b99cab600f2afdc6149d3a8dca56245214e Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 19 Feb 2026 11:22:27 -0800 Subject: [PATCH 12/13] feat(task-repository): implement saveTask method and update task management - Added `saveTask` method to `LocalTaskRepository` for saving individual tasks, enhancing task management capabilities. - Introduced `saveTask` method in `TaskRepository` interface to standardize task saving operations. - Updated `CloudTaskRepository` to throw a not implemented error for `saveTask`, maintaining interface consistency. - Refactored task loading and saving hooks to utilize the new `taskRepository` structure, improving code clarity and maintainability. - Enhanced tests to cover the new `saveTask` functionality, ensuring robust task management workflows. --- .../task/cloud.task.repository.ts | 4 + .../task/local.task.repository.ts | 7 ++ .../repositories/task/task.repository.ts | 1 + .../storage/adapter/indexeddb.adapter.ts | 8 -- .../src/common/utils/storage/storage.util.ts | 111 +++--------------- .../web/src/views/Day/context/TaskContext.tsx | 5 +- .../tasks/useLoadTasksByDateEffect.test.ts | 22 +++- .../hooks/tasks/useLoadTasksByDateEffect.ts | 13 +- .../tasks/useSaveTasksByDateEffect.test.ts | 27 +++-- .../hooks/tasks/useSaveTasksByDateEffect.ts | 13 +- .../Day/hooks/tasks/useTaskActions.test.ts | 9 +- .../views/Day/hooks/tasks/useTaskActions.ts | 7 +- .../Day/hooks/tasks/useTaskState.test.ts | 64 ++++++---- .../src/views/Day/hooks/tasks/useTaskState.ts | 7 +- .../TaskSelector/TaskSelector.test.tsx | 43 ++++--- .../src/views/Now/context/NowViewProvider.tsx | 25 ++-- .../views/Now/hooks/useAvailableTasks.test.ts | 3 +- .../src/views/Now/hooks/useAvailableTasks.ts | 2 +- 18 files changed, 174 insertions(+), 197 deletions(-) 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..33c0c8a7e6 100644 --- a/packages/web/src/common/repositories/task/cloud.task.repository.ts +++ b/packages/web/src/common/repositories/task/cloud.task.repository.ts @@ -14,6 +14,10 @@ export class CloudTaskRepository implements TaskRepository { throw this.createNotImplementedError("save"); } + async saveTask(_dateKey: string, _task: Task): Promise { + throw this.createNotImplementedError("saveTask"); + } + async delete(_dateKey: string, _taskId: string): Promise { throw this.createNotImplementedError("delete"); } 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 ff9b1bf0d5..f904a2fc31 100644 --- a/packages/web/src/common/repositories/task/local.task.repository.ts +++ b/packages/web/src/common/repositories/task/local.task.repository.ts @@ -1,5 +1,6 @@ import { getStorageAdapter } from "@web/common/storage/adapter/adapter"; import { Task } from "@web/common/types/task.types"; +import { dispatchTasksSavedEvent } from "@web/common/utils/storage/storage.util"; import { TaskRepository } from "./task.repository"; /** @@ -20,6 +21,12 @@ export class LocalTaskRepository implements TaskRepository { async save(dateKey: string, tasks: Task[]): Promise { await this.adapter.putTasks(dateKey, tasks); + dispatchTasksSavedEvent(dateKey); + } + + async saveTask(dateKey: string, task: Task): Promise { + await this.adapter.putTask(dateKey, task); + dispatchTasksSavedEvent(dateKey); } async delete(dateKey: string, taskId: string): Promise { diff --git a/packages/web/src/common/repositories/task/task.repository.ts b/packages/web/src/common/repositories/task/task.repository.ts index 85147cac3f..131876bc90 100644 --- a/packages/web/src/common/repositories/task/task.repository.ts +++ b/packages/web/src/common/repositories/task/task.repository.ts @@ -3,6 +3,7 @@ import { Task } from "@web/common/types/task.types"; export interface TaskRepository { get(dateKey: string): Promise; save(dateKey: string, tasks: Task[]): Promise; + saveTask(dateKey: string, 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/indexeddb.adapter.ts b/packages/web/src/common/storage/adapter/indexeddb.adapter.ts index 188585c438..57dcebcf34 100644 --- a/packages/web/src/common/storage/adapter/indexeddb.adapter.ts +++ b/packages/web/src/common/storage/adapter/indexeddb.adapter.ts @@ -136,14 +136,6 @@ export class IndexedDBAdapter implements StorageAdapter { await this.db.tasks.clear(); } - /** - * For use in tests only. Puts a raw stored task without normalization. - * Use when testing migration/normalization of legacy data. - */ - async putRawStoredTaskForTesting(storedTask: StoredTask): Promise { - await this.db.tasks.put(storedTask); - } - // ─── Event Operations ────────────────────────────────────────────────────── async getEvents( diff --git a/packages/web/src/common/utils/storage/storage.util.ts b/packages/web/src/common/utils/storage/storage.util.ts index 4d57d63fc1..898791af52 100644 --- a/packages/web/src/common/utils/storage/storage.util.ts +++ b/packages/web/src/common/utils/storage/storage.util.ts @@ -1,13 +1,7 @@ import dayjs from "@core/util/date/dayjs"; -import { - ensureStorageReady, - getStorageAdapter, -} from "@web/common/storage/adapter/adapter"; -import { - Task, - normalizeTask, - normalizeTasks, -} from "@web/common/types/task.types"; +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"; @@ -17,19 +11,10 @@ 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 []; @@ -37,86 +22,22 @@ export async function loadTasksFromStorage(dateKey: string): Promise { try { await ensureStorageReady(); - return await getStorageAdapter().getTasks(dateKey); + 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 ensureStorageReady(); - await getStorageAdapter().putTasks(dateKey, normalizeTasks(tasks)); - dispatchTasksSavedEvent(dateKey); - } catch (error) { - console.error("Error saving tasks to IndexedDB:", error); - } -} - -export async function saveTaskToStorage( - dateKey: string, - task: Task, -): Promise { - if (typeof window === "undefined") { - return; - } - - try { - await ensureStorageReady(); - await getStorageAdapter().putTask(dateKey, normalizeTask(task)); - dispatchTasksSavedEvent(dateKey); - } catch (error) { - console.error("Error saving task to storage:", 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 ensureStorageReady(); - await getStorageAdapter().moveTask(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/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/hooks/tasks/useLoadTasksByDateEffect.test.ts b/packages/web/src/views/Day/hooks/tasks/useLoadTasksByDateEffect.test.ts index 0497eebb6e..f62cca1aa6 100644 --- a/packages/web/src/views/Day/hooks/tasks/useLoadTasksByDateEffect.test.ts +++ b/packages/web/src/views/Day/hooks/tasks/useLoadTasksByDateEffect.test.ts @@ -2,13 +2,22 @@ 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 { useLoadTasksByDateEffect } from "@web/views/Day/hooks/tasks/useLoadTasksByDateEffect"; -const mockGetTasks = jest.fn(); +const mockGet = jest.fn(); +const mockTaskRepository: TaskRepository = { + get: mockGet, + save: jest.fn().mockResolvedValue(undefined), + saveTask: 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), - getStorageAdapter: jest.fn(() => ({ getTasks: mockGetTasks })), })); interface Deferred { @@ -35,6 +44,7 @@ function useLoadHarness(dateKey: string) { useLoadTasksByDateEffect({ dateKey, + taskRepository: mockTaskRepository, setTasksState, setIsLoadingTasks, setDidLoadFail, @@ -55,11 +65,11 @@ function useLoadHarness(dateKey: string) { describe("useLoadTasksByDateEffect", () => { beforeEach(() => { jest.clearAllMocks(); - mockGetTasks.mockReset(); + mockGet.mockReset(); }); it("loads tasks for date and sorts by status/order", async () => { - mockGetTasks.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 }), @@ -83,7 +93,7 @@ describe("useLoadTasksByDateEffect", () => { it("clears tasks while loading a new date and ignores stale results", async () => { const firstLoad = createDeferred(); const secondLoad = createDeferred(); - mockGetTasks + mockGet .mockReturnValueOnce(firstLoad.promise) .mockReturnValueOnce(secondLoad.promise); @@ -125,7 +135,7 @@ describe("useLoadTasksByDateEffect", () => { }); it("sets failed state when load throws", async () => { - mockGetTasks.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 561efd35ce..49080d06bb 100644 --- a/packages/web/src/views/Day/hooks/tasks/useLoadTasksByDateEffect.ts +++ b/packages/web/src/views/Day/hooks/tasks/useLoadTasksByDateEffect.ts @@ -1,13 +1,12 @@ import { Dispatch, MutableRefObject, SetStateAction, useEffect } from "react"; -import { - ensureStorageReady, - getStorageAdapter, -} from "@web/common/storage/adapter/adapter"; +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 { sortTasksByStatus } from "@web/common/utils/task/sort.task"; interface UseLoadTasksByDateEffectProps { dateKey: string; + taskRepository: TaskRepository; setTasksState: Dispatch>; setIsLoadingTasks: Dispatch>; setDidLoadFail: Dispatch>; @@ -18,6 +17,7 @@ interface UseLoadTasksByDateEffectProps { export function useLoadTasksByDateEffect({ dateKey, + taskRepository, setTasksState, setIsLoadingTasks, setDidLoadFail, @@ -38,7 +38,7 @@ export function useLoadTasksByDateEffect({ void (async () => { await ensureStorageReady(); - return getStorageAdapter().getTasks(dateKey); + return taskRepository.get(dateKey); })() .then((loadedTasks) => { if (isCancelled || requestId !== loadRequestIdRef.current) return; @@ -51,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); @@ -63,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 f0c1c3d289..deb548ff48 100644 --- a/packages/web/src/views/Day/hooks/tasks/useSaveTasksByDateEffect.test.ts +++ b/packages/web/src/views/Day/hooks/tasks/useSaveTasksByDateEffect.test.ts @@ -1,16 +1,22 @@ 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 { useSaveTasksByDateEffect } from "@web/views/Day/hooks/tasks/useSaveTasksByDateEffect"; -const mockPutTasks = jest.fn(); +const mockSave = jest.fn(); +const mockTaskRepository: TaskRepository = { + get: jest.fn().mockResolvedValue([]), + save: mockSave, + saveTask: 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), - getStorageAdapter: jest.fn(() => ({ - putTasks: mockPutTasks, - putTask: jest.fn().mockResolvedValue(undefined), - })), })); interface SaveHarnessProps { @@ -40,6 +46,7 @@ function useSaveHarness({ useSaveTasksByDateEffect({ dateKey, tasks, + taskRepository: mockTaskRepository, isLoadingTasks, didLoadFail, loadedDateKey, @@ -53,7 +60,7 @@ function useSaveHarness({ describe("useSaveTasksByDateEffect", () => { beforeEach(() => { jest.clearAllMocks(); - mockPutTasks.mockResolvedValue(undefined); + mockSave.mockResolvedValue(undefined); }); it("saves tasks and clears dirty flag when all guards pass", async () => { @@ -71,7 +78,7 @@ describe("useSaveTasksByDateEffect", () => { ); await waitFor(() => { - expect(mockPutTasks).toHaveBeenCalledWith("2025-10-27", tasks); + expect(mockSave).toHaveBeenCalledWith("2025-10-27", tasks); expect(result.current.isDirtyRef.current).toBe(false); }); }); @@ -124,13 +131,13 @@ describe("useSaveTasksByDateEffect", () => { ); await waitFor(() => { - expect(mockPutTasks).not.toHaveBeenCalled(); + expect(mockSave).not.toHaveBeenCalled(); }); }); it("keeps dirty flag when save fails", async () => { const tasks = [createMockTask({ _id: "task-1" })]; - mockPutTasks.mockRejectedValueOnce(new Error("save failed")); + mockSave.mockRejectedValueOnce(new Error("save failed")); const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); const { result } = renderHook(() => @@ -145,7 +152,7 @@ describe("useSaveTasksByDateEffect", () => { ); await waitFor(() => { - expect(mockPutTasks).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 24b1b5d390..42dbdbfba3 100644 --- a/packages/web/src/views/Day/hooks/tasks/useSaveTasksByDateEffect.ts +++ b/packages/web/src/views/Day/hooks/tasks/useSaveTasksByDateEffect.ts @@ -1,13 +1,12 @@ import { MutableRefObject, useEffect } from "react"; -import { - ensureStorageReady, - getStorageAdapter, -} from "@web/common/storage/adapter/adapter"; +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"; interface UseSaveTasksByDateEffectProps { dateKey: string; tasks: Task[]; + taskRepository: TaskRepository; isLoadingTasks: boolean; didLoadFail: boolean; loadedDateKey: string | null; @@ -18,6 +17,7 @@ interface UseSaveTasksByDateEffectProps { export function useSaveTasksByDateEffect({ dateKey, tasks, + taskRepository, isLoadingTasks, didLoadFail, loadedDateKey, @@ -36,14 +36,14 @@ export function useSaveTasksByDateEffect({ void (async () => { await ensureStorageReady(); - await getStorageAdapter().putTasks(dateKey, tasks); + 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 () => { @@ -56,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 c685be5ebe..3ba1648ec1 100644 --- a/packages/web/src/views/Day/hooks/tasks/useTaskActions.test.ts +++ b/packages/web/src/views/Day/hooks/tasks/useTaskActions.test.ts @@ -4,7 +4,6 @@ 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 storageUtil from "@web/common/utils/storage/storage.util"; import { showMigrationToast } from "@web/views/Day/components/Toasts/MigrationToast/MigrationToast"; import { useTaskActions } from "./useTaskActions"; @@ -25,6 +24,7 @@ describe("useTaskActions - migration", () => { const mockTaskRepository: jest.Mocked = { get: jest.fn().mockResolvedValue([]), save: jest.fn().mockResolvedValue(undefined), + saveTask: jest.fn().mockResolvedValue(undefined), delete: jest.fn().mockResolvedValue(undefined), move: jest.fn().mockResolvedValue(undefined), reorder: jest.fn().mockResolvedValue(undefined), @@ -34,9 +34,9 @@ describe("useTaskActions - migration", () => { beforeEach(() => { jest.clearAllMocks(); - jest.spyOn(storageUtil, "saveTaskToStorage").mockResolvedValue(undefined); mockTaskRepository.get.mockResolvedValue([]); mockTaskRepository.save.mockResolvedValue(undefined); + mockTaskRepository.saveTask.mockResolvedValue(undefined); mockTaskRepository.delete.mockResolvedValue(undefined); mockTaskRepository.move.mockResolvedValue(undefined); mockTaskRepository.reorder.mockResolvedValue(undefined); @@ -185,7 +185,7 @@ describe("useTaskActions - migration", () => { await waitFor(() => { expect(mockTaskRepository.save).toHaveBeenCalledWith("2025-10-28", []); }); - expect(storageUtil.saveTaskToStorage).toHaveBeenCalledWith( + expect(mockTaskRepository.saveTask).toHaveBeenCalledWith( "2025-10-27", mockTask, ); @@ -226,7 +226,7 @@ describe("useTaskActions - migration", () => { await waitFor(() => { expect(mockTaskRepository.save).toHaveBeenCalledWith("2025-10-26", []); }); - expect(storageUtil.saveTaskToStorage).toHaveBeenCalledWith( + expect(mockTaskRepository.saveTask).toHaveBeenCalledWith( "2025-10-27", mockTask, ); @@ -314,6 +314,7 @@ describe("useTaskActions - reorderTasks", () => { const mockTaskRepository: jest.Mocked = { get: jest.fn().mockResolvedValue([]), save: jest.fn().mockResolvedValue(undefined), + saveTask: jest.fn().mockResolvedValue(undefined), delete: jest.fn().mockResolvedValue(undefined), move: jest.fn().mockResolvedValue(undefined), reorder: jest.fn().mockResolvedValue(undefined), diff --git a/packages/web/src/views/Day/hooks/tasks/useTaskActions.ts b/packages/web/src/views/Day/hooks/tasks/useTaskActions.ts index b0ce887d9e..244ef47107 100644 --- a/packages/web/src/views/Day/hooks/tasks/useTaskActions.ts +++ b/packages/web/src/views/Day/hooks/tasks/useTaskActions.ts @@ -5,10 +5,7 @@ import { UNAUTHENTICATED_USER } from "@web/common/constants/auth.constants"; import { TaskRepository } from "@web/common/repositories/task/task.repository"; import { Task, UndoOperation } from "@web/common/types/task.types"; import { createObjectIdString } from "@web/common/utils/id/object-id.util"; -import { - getDateKey, - saveTaskToStorage, -} from "@web/common/utils/storage/storage.util"; +import { getDateKey } from "@web/common/utils/storage/storage.util"; import { sortTasksByStatus } from "@web/common/utils/task/sort.task"; import { showMigrationToast } from "@web/views/Day/components/Toasts/MigrationToast/MigrationToast"; import { showUndoDeleteToast } from "@web/views/Day/components/Toasts/UndoToast/UndoDeleteToast"; @@ -141,7 +138,7 @@ export function useTaskActions({ await taskRepository.save(targetDateKey, updatedTargetTasks); // Restore the task to the original date in storage (single write, no read) - await saveTaskToStorage(fromDate, operation.task); + await taskRepository.saveTask(fromDate, operation.task); } catch (error) { console.error("Failed to restore task in repository:", error); } 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 4590f49544..5acc692436 100644 --- a/packages/web/src/views/Day/hooks/tasks/useTaskState.test.ts +++ b/packages/web/src/views/Day/hooks/tasks/useTaskState.test.ts @@ -1,19 +1,24 @@ 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 { useTaskState } from "@web/views/Day/hooks/tasks/useTaskState"; -const mockGetTasks = jest.fn(); -const mockPutTasks = jest.fn().mockResolvedValue(undefined); +const mockGet = jest.fn(); +const mockSave = jest.fn().mockResolvedValue(undefined); +const mockTaskRepository: TaskRepository = { + get: mockGet, + save: mockSave, + saveTask: 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), - getStorageAdapter: jest.fn(() => ({ - getTasks: mockGetTasks, - putTasks: mockPutTasks, - putTask: jest.fn().mockResolvedValue(undefined), - })), })); interface Deferred { @@ -37,13 +42,13 @@ describe("useTaskState", () => { beforeEach(() => { jest.clearAllMocks(); - mockPutTasks.mockResolvedValue(undefined); - mockGetTasks.mockReset(); + mockSave.mockResolvedValue(undefined); + mockGet.mockReset(); }); it("clears tasks and enters loading state when date changes", async () => { const dayTwoLoad = createDeferred(); - mockGetTasks + mockGet .mockResolvedValueOnce([ createMockTask({ _id: "task-1", @@ -57,7 +62,8 @@ describe("useTaskState", () => { .mockReturnValueOnce(dayTwoLoad.promise); const { result, rerender } = renderHook( - ({ currentDate }) => useTaskState({ currentDate }), + ({ currentDate }) => + useTaskState({ currentDate, taskRepository: mockTaskRepository }), { initialProps: { currentDate: dayOneDate } }, ); @@ -83,13 +89,17 @@ describe("useTaskState", () => { it("does not save previous-day tasks under the next date key", async () => { const dayTwoLoad = createDeferred(); - mockGetTasks - .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(() => { @@ -103,19 +113,22 @@ describe("useTaskState", () => { }); await waitFor(() => { - expect(mockPutTasks).toHaveBeenCalledWith(dayOneKey, [localTask]); + expect(mockSave).toHaveBeenCalledWith(dayOneKey, [localTask]); }); - mockPutTasks.mockClear(); + mockSave.mockClear(); - rerender({ currentDate: dayTwoDate }); + rerender({ + currentDate: dayTwoDate, + taskRepository: mockTaskRepository, + }); await act(async () => { await Promise.resolve(); }); - expect(mockPutTasks).not.toHaveBeenCalledWith(dayTwoKey, expect.any(Array)); - expect(mockPutTasks).not.toHaveBeenCalled(); + expect(mockSave).not.toHaveBeenCalledWith(dayTwoKey, expect.any(Array)); + expect(mockSave).not.toHaveBeenCalled(); await act(async () => { dayTwoLoad.resolve([]); @@ -124,11 +137,14 @@ describe("useTaskState", () => { }); it("does not save empty tasks after a load failure", async () => { - mockGetTasks.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(() => { @@ -137,7 +153,7 @@ describe("useTaskState", () => { expect(result.current.tasks).toEqual([]); }); - expect(mockPutTasks).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 1fb884888a..d44dc6ef12 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,25 @@ 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(), - saveTaskToStorage: jest.fn(), })); +jest.mock("@web/common/repositories/task/task.repository.util", () => { + const mockSaveTask = jest.fn().mockResolvedValue(undefined); + return { + getTaskRepository: jest.fn(() => ({ + saveTask: mockSaveTask, + })), + }; +}); + +jest.mock("@web/common/storage/adapter/adapter", () => ({ + ensureStorageReady: jest.fn().mockResolvedValue(undefined), +})); + +/** Get the saveTask mock from the mocked getTaskRepository return value */ +const getMockSaveTask = () => + (getTaskRepository as jest.Mock)().saveTask as jest.Mock; + const mockUseFocusedTask = useFocusedTask as jest.MockedFunction< typeof useFocusedTask >; @@ -86,12 +101,8 @@ describe("TaskSelector", () => { jest.clearAllMocks(); mockNavigate.mockClear(); mockSetFocusedTask.mockClear(); + getMockSaveTask().mockResolvedValue(undefined); (storageUtil.getDateKey as jest.Mock).mockReturnValue(mockDateKey); - (storageUtil.loadTasksFromStorage as jest.Mock).mockResolvedValue( - mockTasks, - ); - (storageUtil.saveTasksToStorage as jest.Mock).mockResolvedValue(undefined); - (storageUtil.saveTaskToStorage as jest.Mock).mockResolvedValue(undefined); // Use fake timers to control the current time jest.useFakeTimers(); @@ -305,8 +316,6 @@ describe("TaskSelector", () => { allTasks: tasks, hasCompletedTasks: false, }); - (storageUtil.saveTaskToStorage as jest.Mock).mockResolvedValue(undefined); - renderWithNowProvider(); const checkButton = screen.getByRole("button", { @@ -315,8 +324,8 @@ describe("TaskSelector", () => { await user.click(checkButton); await waitFor(() => { - expect(storageUtil.saveTaskToStorage).toHaveBeenCalledTimes(1); - expect(storageUtil.saveTaskToStorage).toHaveBeenCalledWith( + expect(getMockSaveTask()).toHaveBeenCalledTimes(1); + expect(getMockSaveTask()).toHaveBeenCalledWith( mockDateKey, expect.objectContaining({ _id: "task-1", status: "completed" }), ); @@ -354,7 +363,6 @@ describe("TaskSelector", () => { allTasks: tasks, hasCompletedTasks: false, }); - (storageUtil.saveTaskToStorage as jest.Mock).mockResolvedValue(undefined); renderWithNowProvider(); @@ -364,8 +372,8 @@ describe("TaskSelector", () => { await user.click(checkButton); await waitFor(() => { - expect(storageUtil.saveTaskToStorage).toHaveBeenCalledTimes(1); - expect(storageUtil.saveTaskToStorage).toHaveBeenCalledWith( + expect(getMockSaveTask()).toHaveBeenCalledTimes(1); + expect(getMockSaveTask()).toHaveBeenCalledWith( mockDateKey, expect.objectContaining({ _id: "task-2", status: "completed" }), ); @@ -395,7 +403,6 @@ describe("TaskSelector", () => { allTasks: tasks, hasCompletedTasks: false, }); - (storageUtil.saveTaskToStorage as jest.Mock).mockResolvedValue(undefined); renderWithNowProvider(); @@ -405,8 +412,8 @@ describe("TaskSelector", () => { await user.click(checkButton); await waitFor(() => { - expect(storageUtil.saveTaskToStorage).toHaveBeenCalledTimes(1); - expect(storageUtil.saveTaskToStorage).toHaveBeenCalledWith( + expect(getMockSaveTask()).toHaveBeenCalledTimes(1); + expect(getMockSaveTask()).toHaveBeenCalledWith( mockDateKey, expect.objectContaining({ _id: "task-1", status: "completed" }), ); diff --git a/packages/web/src/views/Now/context/NowViewProvider.tsx b/packages/web/src/views/Now/context/NowViewProvider.tsx index 04407a7e80..5a12a1505f 100644 --- a/packages/web/src/views/Now/context/NowViewProvider.tsx +++ b/packages/web/src/views/Now/context/NowViewProvider.tsx @@ -1,11 +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 { - getDateKey, - saveTaskToStorage, -} 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"; @@ -37,7 +36,9 @@ export function NowViewProvider({ children }: NowViewProviderProps) { const completeFocusedTask = useCallback( async (task: Task) => { const completedTask = { ...task, status: "completed" as const }; - await saveTaskToStorage(getDateKey(), completedTask); + const dateKey = getDateKey(); + await ensureStorageReady(); + await getTaskRepository("local").saveTask(dateKey, completedTask); return allTasks.map((t) => (t._id === task._id ? completedTask : t)); }, [allTasks], @@ -45,11 +46,15 @@ export function NowViewProvider({ children }: NowViewProviderProps) { const updateTaskDescription = useCallback( (task: Task, description: string) => { - void saveTaskToStorage(getDateKey(), { ...task, description }).catch( - (error) => { - console.error("Failed to update task description:", error); - }, - ); + void (async () => { + await ensureStorageReady(); + await getTaskRepository("local").saveTask(getDateKey(), { + ...task, + description, + }); + })().catch((error) => { + console.error("Failed to update task description:", error); + }); }, [], ); diff --git a/packages/web/src/views/Now/hooks/useAvailableTasks.test.ts b/packages/web/src/views/Now/hooks/useAvailableTasks.test.ts index 7e52c985ea..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(); }); 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([]); }); }; From 4c13e788ad22e36e97a1d27d15a99d2ef5799dd2 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 19 Feb 2026 11:29:45 -0800 Subject: [PATCH 13/13] refactor(task-repository): consolidate task saving methods and update tests - Refactored `save` method in `LocalTaskRepository` to handle both single and multiple tasks, replacing the deprecated `saveTask` method. - Updated the `TaskRepository` interface to reflect the new `save` method signature. - Modified tests across various components to remove references to `saveTask` and ensure compatibility with the updated `save` method. - Enhanced test coverage for the new saving logic, ensuring robust task management functionality. --- .../task/cloud.task.repository.ts | 6 +---- .../task/local.task.repository.test.ts | 16 +++++++++++++ .../task/local.task.repository.ts | 14 +++++------ .../repositories/task/task.repository.ts | 3 +-- .../tasks/useLoadTasksByDateEffect.test.ts | 1 - .../tasks/useSaveTasksByDateEffect.test.ts | 1 - .../Day/hooks/tasks/useTaskActions.test.ts | 7 ++---- .../views/Day/hooks/tasks/useTaskActions.ts | 2 +- .../Day/hooks/tasks/useTaskState.test.ts | 1 - .../TaskSelector/TaskSelector.test.tsx | 23 +++++++++---------- .../src/views/Now/context/NowViewProvider.tsx | 4 ++-- 11 files changed, 41 insertions(+), 37 deletions(-) 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 33c0c8a7e6..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,14 +10,10 @@ 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"); } - async saveTask(_dateKey: string, _task: Task): Promise { - throw this.createNotImplementedError("saveTask"); - } - async delete(_dateKey: string, _taskId: string): Promise { throw this.createNotImplementedError("delete"); } 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 ee053ba606..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 @@ -13,6 +13,7 @@ describe("LocalTaskRepository", () => { let mockAdapter: { getTasks: jest.Mock; putTasks: jest.Mock; + putTask: jest.Mock; deleteTask: jest.Mock; moveTask: jest.Mock; }; @@ -21,6 +22,7 @@ describe("LocalTaskRepository", () => { 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), }; @@ -92,6 +94,20 @@ describe("LocalTaskRepository", () => { 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", () => { 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 f904a2fc31..3019ece4d6 100644 --- a/packages/web/src/common/repositories/task/local.task.repository.ts +++ b/packages/web/src/common/repositories/task/local.task.repository.ts @@ -19,13 +19,13 @@ export class LocalTaskRepository implements TaskRepository { return this.adapter.getTasks(dateKey); } - async save(dateKey: string, tasks: Task[]): Promise { - await this.adapter.putTasks(dateKey, tasks); - dispatchTasksSavedEvent(dateKey); - } - - async saveTask(dateKey: string, task: Task): Promise { - await this.adapter.putTask(dateKey, task); + 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); } diff --git a/packages/web/src/common/repositories/task/task.repository.ts b/packages/web/src/common/repositories/task/task.repository.ts index 131876bc90..16b27e44e9 100644 --- a/packages/web/src/common/repositories/task/task.repository.ts +++ b/packages/web/src/common/repositories/task/task.repository.ts @@ -2,8 +2,7 @@ import { Task } from "@web/common/types/task.types"; export interface TaskRepository { get(dateKey: string): Promise; - save(dateKey: string, tasks: Task[]): Promise; - saveTask(dateKey: string, task: 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/views/Day/hooks/tasks/useLoadTasksByDateEffect.test.ts b/packages/web/src/views/Day/hooks/tasks/useLoadTasksByDateEffect.test.ts index f62cca1aa6..89c35ec1b3 100644 --- a/packages/web/src/views/Day/hooks/tasks/useLoadTasksByDateEffect.test.ts +++ b/packages/web/src/views/Day/hooks/tasks/useLoadTasksByDateEffect.test.ts @@ -10,7 +10,6 @@ const mockGet = jest.fn(); const mockTaskRepository: TaskRepository = { get: mockGet, save: jest.fn().mockResolvedValue(undefined), - saveTask: jest.fn().mockResolvedValue(undefined), delete: jest.fn().mockResolvedValue(undefined), move: jest.fn().mockResolvedValue(undefined), reorder: jest.fn().mockResolvedValue(undefined), 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 deb548ff48..54c8816430 100644 --- a/packages/web/src/views/Day/hooks/tasks/useSaveTasksByDateEffect.test.ts +++ b/packages/web/src/views/Day/hooks/tasks/useSaveTasksByDateEffect.test.ts @@ -9,7 +9,6 @@ const mockSave = jest.fn(); const mockTaskRepository: TaskRepository = { get: jest.fn().mockResolvedValue([]), save: mockSave, - saveTask: jest.fn().mockResolvedValue(undefined), delete: jest.fn().mockResolvedValue(undefined), move: jest.fn().mockResolvedValue(undefined), reorder: jest.fn().mockResolvedValue(undefined), 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 3ba1648ec1..e0949482ea 100644 --- a/packages/web/src/views/Day/hooks/tasks/useTaskActions.test.ts +++ b/packages/web/src/views/Day/hooks/tasks/useTaskActions.test.ts @@ -24,7 +24,6 @@ describe("useTaskActions - migration", () => { const mockTaskRepository: jest.Mocked = { get: jest.fn().mockResolvedValue([]), save: jest.fn().mockResolvedValue(undefined), - saveTask: jest.fn().mockResolvedValue(undefined), delete: jest.fn().mockResolvedValue(undefined), move: jest.fn().mockResolvedValue(undefined), reorder: jest.fn().mockResolvedValue(undefined), @@ -36,7 +35,6 @@ describe("useTaskActions - migration", () => { jest.clearAllMocks(); mockTaskRepository.get.mockResolvedValue([]); mockTaskRepository.save.mockResolvedValue(undefined); - mockTaskRepository.saveTask.mockResolvedValue(undefined); mockTaskRepository.delete.mockResolvedValue(undefined); mockTaskRepository.move.mockResolvedValue(undefined); mockTaskRepository.reorder.mockResolvedValue(undefined); @@ -185,7 +183,7 @@ describe("useTaskActions - migration", () => { await waitFor(() => { expect(mockTaskRepository.save).toHaveBeenCalledWith("2025-10-28", []); }); - expect(mockTaskRepository.saveTask).toHaveBeenCalledWith( + expect(mockTaskRepository.save).toHaveBeenCalledWith( "2025-10-27", mockTask, ); @@ -226,7 +224,7 @@ describe("useTaskActions - migration", () => { await waitFor(() => { expect(mockTaskRepository.save).toHaveBeenCalledWith("2025-10-26", []); }); - expect(mockTaskRepository.saveTask).toHaveBeenCalledWith( + expect(mockTaskRepository.save).toHaveBeenCalledWith( "2025-10-27", mockTask, ); @@ -314,7 +312,6 @@ describe("useTaskActions - reorderTasks", () => { const mockTaskRepository: jest.Mocked = { get: jest.fn().mockResolvedValue([]), save: jest.fn().mockResolvedValue(undefined), - saveTask: jest.fn().mockResolvedValue(undefined), delete: jest.fn().mockResolvedValue(undefined), move: jest.fn().mockResolvedValue(undefined), reorder: jest.fn().mockResolvedValue(undefined), diff --git a/packages/web/src/views/Day/hooks/tasks/useTaskActions.ts b/packages/web/src/views/Day/hooks/tasks/useTaskActions.ts index 244ef47107..3bbee5126a 100644 --- a/packages/web/src/views/Day/hooks/tasks/useTaskActions.ts +++ b/packages/web/src/views/Day/hooks/tasks/useTaskActions.ts @@ -138,7 +138,7 @@ export function useTaskActions({ await taskRepository.save(targetDateKey, updatedTargetTasks); // Restore the task to the original date in storage (single write, no read) - await taskRepository.saveTask(fromDate, operation.task); + await taskRepository.save(fromDate, operation.task); } catch (error) { console.error("Failed to restore task in repository:", error); } 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 5acc692436..b00429e3ac 100644 --- a/packages/web/src/views/Day/hooks/tasks/useTaskState.test.ts +++ b/packages/web/src/views/Day/hooks/tasks/useTaskState.test.ts @@ -11,7 +11,6 @@ const mockSave = jest.fn().mockResolvedValue(undefined); const mockTaskRepository: TaskRepository = { get: mockGet, save: mockSave, - saveTask: jest.fn().mockResolvedValue(undefined), delete: jest.fn().mockResolvedValue(undefined), move: jest.fn().mockResolvedValue(undefined), reorder: jest.fn().mockResolvedValue(undefined), 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 d44dc6ef12..2bc8d04fde 100644 --- a/packages/web/src/views/Now/components/TaskSelector/TaskSelector.test.tsx +++ b/packages/web/src/views/Now/components/TaskSelector/TaskSelector.test.tsx @@ -28,10 +28,10 @@ jest.mock("@web/common/utils/storage/storage.util", () => ({ })); jest.mock("@web/common/repositories/task/task.repository.util", () => { - const mockSaveTask = jest.fn().mockResolvedValue(undefined); + const mockSave = jest.fn().mockResolvedValue(undefined); return { getTaskRepository: jest.fn(() => ({ - saveTask: mockSaveTask, + save: mockSave, })), }; }); @@ -40,9 +40,8 @@ jest.mock("@web/common/storage/adapter/adapter", () => ({ ensureStorageReady: jest.fn().mockResolvedValue(undefined), })); -/** Get the saveTask mock from the mocked getTaskRepository return value */ -const getMockSaveTask = () => - (getTaskRepository as jest.Mock)().saveTask as jest.Mock; +/** 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 @@ -101,7 +100,7 @@ describe("TaskSelector", () => { jest.clearAllMocks(); mockNavigate.mockClear(); mockSetFocusedTask.mockClear(); - getMockSaveTask().mockResolvedValue(undefined); + getMockSave().mockResolvedValue(undefined); (storageUtil.getDateKey as jest.Mock).mockReturnValue(mockDateKey); // Use fake timers to control the current time @@ -324,8 +323,8 @@ describe("TaskSelector", () => { await user.click(checkButton); await waitFor(() => { - expect(getMockSaveTask()).toHaveBeenCalledTimes(1); - expect(getMockSaveTask()).toHaveBeenCalledWith( + expect(getMockSave()).toHaveBeenCalledTimes(1); + expect(getMockSave()).toHaveBeenCalledWith( mockDateKey, expect.objectContaining({ _id: "task-1", status: "completed" }), ); @@ -372,8 +371,8 @@ describe("TaskSelector", () => { await user.click(checkButton); await waitFor(() => { - expect(getMockSaveTask()).toHaveBeenCalledTimes(1); - expect(getMockSaveTask()).toHaveBeenCalledWith( + expect(getMockSave()).toHaveBeenCalledTimes(1); + expect(getMockSave()).toHaveBeenCalledWith( mockDateKey, expect.objectContaining({ _id: "task-2", status: "completed" }), ); @@ -412,8 +411,8 @@ describe("TaskSelector", () => { await user.click(checkButton); await waitFor(() => { - expect(getMockSaveTask()).toHaveBeenCalledTimes(1); - expect(getMockSaveTask()).toHaveBeenCalledWith( + expect(getMockSave()).toHaveBeenCalledTimes(1); + expect(getMockSave()).toHaveBeenCalledWith( mockDateKey, expect.objectContaining({ _id: "task-1", status: "completed" }), ); diff --git a/packages/web/src/views/Now/context/NowViewProvider.tsx b/packages/web/src/views/Now/context/NowViewProvider.tsx index 5a12a1505f..b5954d6153 100644 --- a/packages/web/src/views/Now/context/NowViewProvider.tsx +++ b/packages/web/src/views/Now/context/NowViewProvider.tsx @@ -38,7 +38,7 @@ export function NowViewProvider({ children }: NowViewProviderProps) { const completedTask = { ...task, status: "completed" as const }; const dateKey = getDateKey(); await ensureStorageReady(); - await getTaskRepository("local").saveTask(dateKey, completedTask); + await getTaskRepository("local").save(dateKey, completedTask); return allTasks.map((t) => (t._id === task._id ? completedTask : t)); }, [allTasks], @@ -48,7 +48,7 @@ export function NowViewProvider({ children }: NowViewProviderProps) { (task: Task, description: string) => { void (async () => { await ensureStorageReady(); - await getTaskRepository("local").saveTask(getDateKey(), { + await getTaskRepository("local").save(getDateKey(), { ...task, description, });