diff --git a/packages/web/package.json b/packages/web/package.json index 76cf85535..8f3f4653b 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -24,6 +24,8 @@ "classnames": "^2.3.1", "css-loader": "^6.3.0", "dayjs": "^1.10.7", + "dexie": "^4.2.1", + "dexie-react-hooks": "^4.2.0", "fast-deep-equal": "^3.1.3", "html-webpack-plugin": "^5.6.4", "mini-css-extract-plugin": "^2.3.0", diff --git a/packages/web/src/common/utils/auth/auth.util.ts b/packages/web/src/common/utils/auth/auth.util.ts new file mode 100644 index 000000000..d75e716b2 --- /dev/null +++ b/packages/web/src/common/utils/auth/auth.util.ts @@ -0,0 +1,34 @@ +import { session } from "@web/common/classes/Session"; + +/** + * Check if the user is currently authenticated + */ +export const isUserAuthenticated = async (): Promise => { + try { + return await session.doesSessionExist(); + } catch (error) { + console.error("Error checking authentication status:", error); + return false; + } +}; + +/** + * Get the user ID. Returns a placeholder ID if the user is not authenticated. + */ +export const getUserId = async (): Promise => { + try { + const authenticated = await isUserAuthenticated(); + if (!authenticated) { + return "local_user"; + } + + const accessTokenPayload = + (await session.getAccessTokenPayloadSecurely()) as { + sub: string; + }; + return accessTokenPayload.sub; + } catch (error) { + console.error("Error getting user ID:", error); + return "local_user"; + } +}; diff --git a/packages/web/src/common/utils/storage/compass-local.db.ts b/packages/web/src/common/utils/storage/compass-local.db.ts new file mode 100644 index 000000000..f09d6d93e --- /dev/null +++ b/packages/web/src/common/utils/storage/compass-local.db.ts @@ -0,0 +1,38 @@ +import Dexie, { Table } from "dexie"; +import { Schema_GridEvent } from "@web/common/types/web.event.types"; + +/** + * Extended event type for IndexedDB storage. + * Reuses the existing Schema_GridEvent type and ensures _id is present. + */ +export type ClientEvent = Schema_GridEvent & { + /** + * For local-only events, `_id` is a client-generated string. + * For now, we only persist local events here, not provider-backed ones. + */ + _id: string; +}; + +/** + * Dexie database for local storage of events and tasks. + * Used when user is not authenticated with Google Calendar. + */ +export class CompassLocalDB extends Dexie { + events!: Table; + + constructor() { + super("compass-local"); + + // Version 1: initial schema + this.version(1).stores({ + // Primary key and indexes + // _id is the PK; we also index on startDate/endDate/isSomeday for range queries + events: "_id, startDate, endDate, isSomeday, createdAt", + }); + + // Future versions: add .version(2)... etc with explicit migrations. + } +} + +// Singleton instance of the database +export const compassLocalDB = new CompassLocalDB(); diff --git a/packages/web/src/common/utils/storage/indexeddb.util.ts b/packages/web/src/common/utils/storage/indexeddb.util.ts new file mode 100644 index 000000000..0d1342097 --- /dev/null +++ b/packages/web/src/common/utils/storage/indexeddb.util.ts @@ -0,0 +1,87 @@ +import { Params_Events, Schema_Event } from "@core/types/event.types"; +import { Schema_GridEvent } from "@web/common/types/web.event.types"; +import { ClientEvent, compassLocalDB } from "./compass-local.db"; + +/** + * Save an event to IndexedDB + */ +export const saveEventToIndexedDB = async ( + event: Schema_GridEvent, +): Promise => { + if (!event._id) { + throw new Error("Event must have an _id to be saved to IndexedDB"); + } + + await compassLocalDB.events.put(event as ClientEvent); +}; + +/** + * Get events from IndexedDB based on query parameters + */ +export const getEventsFromIndexedDB = async ( + params: Params_Events, +): Promise => { + const { startDate, endDate, someday } = params; + + let query = compassLocalDB.events.toCollection(); + + // Filter by isSomeday + if (someday !== undefined) { + query = compassLocalDB.events + .where("isSomeday") + .equals(someday ? true : false); + } + + // Get all matching events + let events = await query.toArray(); + + // Filter by date range if provided + if (startDate || endDate) { + events = events.filter((event) => { + if (!event.startDate) return false; + + const eventStart = new Date(event.startDate).getTime(); + const rangeStart = startDate ? new Date(startDate).getTime() : 0; + const rangeEnd = endDate + ? new Date(endDate).getTime() + : Number.MAX_SAFE_INTEGER; + + return eventStart >= rangeStart && eventStart <= rangeEnd; + }); + } + + return events as Schema_Event[]; +}; + +/** + * Delete an event from IndexedDB + */ +export const deleteEventFromIndexedDB = async (_id: string): Promise => { + await compassLocalDB.events.delete(_id); +}; + +/** + * Update an event in IndexedDB + */ +export const updateEventInIndexedDB = async ( + _id: string, + updates: Partial, +): Promise => { + const existing = await compassLocalDB.events.get(_id); + if (!existing) { + throw new Error(`Event with id "${_id}" not found in IndexedDB for update`); + } + + await compassLocalDB.events.put({ + ...existing, + ...updates, + _id, // Ensure _id is preserved + } as ClientEvent); +}; + +/** + * Clear all events from IndexedDB + */ +export const clearEventsFromIndexedDB = async (): Promise => { + await compassLocalDB.events.clear(); +}; diff --git a/packages/web/src/ducks/events/sagas/event.sagas.ts b/packages/web/src/ducks/events/sagas/event.sagas.ts index 1e40b4276..aaa76951c 100644 --- a/packages/web/src/ducks/events/sagas/event.sagas.ts +++ b/packages/web/src/ducks/events/sagas/event.sagas.ts @@ -14,7 +14,14 @@ import { Schema_OptimisticEvent, Schema_WebEvent, } from "@web/common/types/web.event.types"; +import { isUserAuthenticated } from "@web/common/utils/auth/auth.util"; import { handleError } from "@web/common/utils/event/event.util"; +import { + deleteEventFromIndexedDB, + getEventsFromIndexedDB, + saveEventToIndexedDB, + updateEventInIndexedDB, +} from "@web/common/utils/storage/indexeddb.util"; import { EventApi } from "@web/ducks/events/event.api"; import { Action_ConvertEvent, @@ -64,7 +71,18 @@ export function* convertCalendarToSomedayEvent({ // Mark event as pending when edit starts yield put(pendingEventsSlice.actions.add(optimisticEvent._id)); - yield* _editEvent(gridEvent, { applyTo }); + // Check if user is authenticated + const authenticated = (yield call(isUserAuthenticated)) as boolean; + + if (authenticated) { + yield* _editEvent(gridEvent, { applyTo }); + } else { + // Update event in IndexedDB for unauthenticated users + yield call(updateEventInIndexedDB, optimisticEvent._id, { + ...gridEvent, + isSomeday: true, + }); + } yield put( eventsEntitiesSlice.actions.edit({ @@ -99,7 +117,15 @@ export function* createEvent({ payload }: Action_CreateEvent) { yield put(pendingEventsSlice.actions.add(event._id)); try { - yield call(EventApi.create, event); + // Check if user is authenticated + const authenticated = (yield call(isUserAuthenticated)) as boolean; + + if (authenticated) { + yield call(EventApi.create, event); + } else { + // Save to IndexedDB for unauthenticated users + yield call(saveEventToIndexedDB, event); + } yield put( eventsEntitiesSlice.actions.edit({ @@ -132,7 +158,15 @@ export function* deleteEvent({ payload }: Action_DeleteEvent) { const isPending = pendingEventIds.includes(payload._id); // Only call delete API if event is not pending (i.e., exists in DB) if (!isPending) { - yield call(EventApi.delete, payload._id, payload.applyTo); + // Check if user is authenticated + const authenticated = (yield call(isUserAuthenticated)) as boolean; + + if (authenticated) { + yield call(EventApi.delete, payload._id, payload.applyTo); + } else { + // Delete from IndexedDB for unauthenticated users + yield call(deleteEventFromIndexedDB, payload._id); + } } yield put(deleteEventSlice.actions.success()); @@ -156,7 +190,15 @@ export function* editEvent({ payload }: Action_EditEvent) { if (shouldRemove) yield put(eventsEntitiesSlice.actions.delete({ _id })); else yield put(eventsEntitiesSlice.actions.edit(payload)); - yield call(EventApi.edit, _id, event, { applyTo }); + // Check if user is authenticated + const authenticated = (yield call(isUserAuthenticated)) as boolean; + + if (authenticated) { + yield call(EventApi.edit, _id, event, { applyTo }); + } else { + // Update in IndexedDB for unauthenticated users + yield call(updateEventInIndexedDB, _id, event); + } // Remove from pending on success yield put(pendingEventsSlice.actions.remove(_id)); @@ -198,16 +240,37 @@ function* getEvents( const _payload = EventDateUtils.adjustStartEndDate(payload); - const res: Response_GetEventsSuccess = (yield call( - EventApi.get, - _payload, - )) as Response_GetEventsSuccess; + // Check if user is authenticated + const authenticated = (yield call(isUserAuthenticated)) as boolean; - const events = EventDateUtils.filterEventsByStartEndDate( - res.data, - payload.startDate as string, - payload.endDate as string, - ); + let events: Schema_Event[] = []; + + if (authenticated) { + // Fetch from API for authenticated users + const res: Response_GetEventsSuccess = (yield call( + EventApi.get, + _payload, + )) as Response_GetEventsSuccess; + + events = EventDateUtils.filterEventsByStartEndDate( + res.data, + payload.startDate as string, + payload.endDate as string, + ); + } else { + // Fetch from IndexedDB for unauthenticated users + const localEvents = (yield call(getEventsFromIndexedDB, { + someday: false, + startDate: _payload.startDate, + endDate: _payload.endDate, + })) as Schema_Event[]; + + events = EventDateUtils.filterEventsByStartEndDate( + localEvents, + payload.startDate as string, + payload.endDate as string, + ); + } const normalizedEvents = normalize(events, [ normalizedEventsSchema(), diff --git a/packages/web/src/ducks/events/sagas/someday.sagas.ts b/packages/web/src/ducks/events/sagas/someday.sagas.ts index 817bcc42e..613213750 100644 --- a/packages/web/src/ducks/events/sagas/someday.sagas.ts +++ b/packages/web/src/ducks/events/sagas/someday.sagas.ts @@ -2,8 +2,14 @@ import { normalize } from "normalizr"; import { call, put } from "redux-saga/effects"; import { Schema_Event } from "@core/types/event.types"; import { Schema_OptimisticEvent } from "@web/common/types/web.event.types"; +import { isUserAuthenticated } from "@web/common/utils/auth/auth.util"; import { handleError } from "@web/common/utils/event/event.util"; import { setSomedayEventsOrder } from "@web/common/utils/event/someday.event.util"; +import { + deleteEventFromIndexedDB, + getEventsFromIndexedDB, + updateEventInIndexedDB, +} from "@web/common/utils/storage/indexeddb.util"; import { EventApi } from "@web/ducks/events/event.api"; import { Action_ConvertEvent, @@ -41,7 +47,18 @@ export function* convertSomedayToCalendarEvent({ // Mark event as pending when edit starts yield put(pendingEventsSlice.actions.add(optimisticEvent._id)); - yield* _editEvent(gridEvent); + // Check if user is authenticated + const authenticated = (yield call(isUserAuthenticated)) as boolean; + + if (authenticated) { + yield* _editEvent(gridEvent); + } else { + // Update event in IndexedDB for unauthenticated users + yield call(updateEventInIndexedDB, optimisticEvent._id, { + ...gridEvent, + isSomeday: false, + }); + } yield put( eventsEntitiesSlice.actions.edit({ @@ -83,7 +100,15 @@ export function* deleteSomedayEvent({ payload }: Action_DeleteEvent) { try { yield put(eventsEntitiesSlice.actions.delete(payload)); - yield call(EventApi.delete, payload._id, payload.applyTo); + // Check if user is authenticated + const authenticated = (yield call(isUserAuthenticated)) as boolean; + + if (authenticated) { + yield call(EventApi.delete, payload._id, payload.applyTo); + } else { + // Delete from IndexedDB for unauthenticated users + yield call(deleteEventFromIndexedDB, payload._id); + } } catch (error) { yield put( getSomedayEventsSlice.actions.error({ @@ -101,13 +126,30 @@ export function* deleteSomedayEvent({ payload }: Action_DeleteEvent) { export function* getSomedayEvents({ payload }: Action_GetEvents) { try { - const res = (yield call(EventApi.get, { - someday: true, - startDate: payload.startDate, - endDate: payload.endDate, - })) as Response_GetEventsSuccess; - - const events = setSomedayEventsOrder(res.data); + // Check if user is authenticated + const authenticated = (yield call(isUserAuthenticated)) as boolean; + + let events: Schema_Event[] = []; + + if (authenticated) { + // Fetch from API for authenticated users + const res = (yield call(EventApi.get, { + someday: true, + startDate: payload.startDate, + endDate: payload.endDate, + })) as Response_GetEventsSuccess; + + events = setSomedayEventsOrder(res.data); + } else { + // Fetch from IndexedDB for unauthenticated users + const localEvents = (yield call(getEventsFromIndexedDB, { + someday: true, + startDate: payload.startDate, + endDate: payload.endDate, + })) as Schema_Event[]; + + events = setSomedayEventsOrder(localEvents); + } const normalizedEvents = normalize(events, [ normalizedEventsSchema(), @@ -119,10 +161,10 @@ export function* getSomedayEvents({ payload }: Action_GetEvents) { yield put( getSomedayEventsSlice.actions.success({ data: normalizedEvents.result, - count: res.count, - page: res.page, - pageSize: res.pageSize, - offset: res.offset, + count: events.length, + page: 1, + pageSize: events.length, + offset: 0, }), ); } catch (error) { @@ -136,7 +178,17 @@ export function* getSomedayEvents({ payload }: Action_GetEvents) { export function* reorderSomedayEvents({ payload }: Action_Someday_Reorder) { try { - yield call(EventApi.reorder, payload); + // Check if user is authenticated + const authenticated = (yield call(isUserAuthenticated)) as boolean; + + if (authenticated) { + yield call(EventApi.reorder, payload); + } else { + // For unauthenticated users, update the order in IndexedDB + for (const item of payload) { + yield call(updateEventInIndexedDB, item._id, { order: item.order }); + } + } } catch (error) { yield put( getSomedayEventsSlice.actions.error({ diff --git a/yarn.lock b/yarn.lock index 7a25bbf7f..6e439ec30 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5316,6 +5316,16 @@ detect-node@^2.0.4: resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1" integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g== +dexie-react-hooks@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/dexie-react-hooks/-/dexie-react-hooks-4.2.0.tgz#33d8a64c79e1e2067b9b3c4fe52367fbb6b36450" + integrity sha512-u7KqTX9JpBQK8+tEyA9X0yMGXlSCsbm5AU64N6gjvGk/IutYDpLBInMYEAEC83s3qhIvryFS+W+sqLZUBEvePQ== + +dexie@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/dexie/-/dexie-4.2.1.tgz#70d111ae8d2dabf53f424fca79f6f918c407e6db" + integrity sha512-Ckej0NS6jxQ4Po3OrSQBFddayRhTCic2DoCAG5zacOfOVB9P2Q5Xc5uL/nVa7ZVs+HdMnvUPzLFCB/JwpB6Csg== + dezalgo@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.4.tgz#751235260469084c132157dfa857f386d4c33d81"