Skip to content

Commit 30a0895

Browse files
authored
feat: resize tasks and consolidate storage (#1907)
* style: lighten header color * feat: pov of adjustable stuff * fix: timed task widths * refactor: simplify reusable task list * style: header positioning * style: divider color * refactor: storage interfaces * refactor(web): simplify storage boundaries
1 parent d0d9bab commit 30a0895

52 files changed

Lines changed: 928 additions & 550 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/architecture/event-and-task-domain-model.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,18 +115,19 @@ Task fields:
115115
- `description`
116116
- `user`
117117

118-
Tasks are currently local-storage centric and are stored with a `dateKey` in the adapter layer, not in the public `Task` shape.
118+
Tasks are stored with a `dateKey` in the offline data store, not in the public
119+
`Task` shape.
119120

120121
## Storage-Specific Task Shape
121122

122-
The IndexedDB adapter wraps tasks as `StoredTask`:
123+
The IndexedDB offline data store wraps tasks as `StoredTask`:
123124

124125
- public task data
125126
- plus `dateKey`
126127

127128
Source:
128129

129-
- `packages/web/src/common/storage/adapter/storage.adapter.ts`
130+
- `packages/web/src/common/storage/offline-data/offline-data.store.ts`
130131

131132
## Invariants To Preserve
132133

docs/development/common-change-recipes.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,10 @@ Do not edit recurring behavior from one layer only.
6161

6262
## Add Or Change Local Storage Data
6363

64-
1. Update `packages/web/src/common/storage/adapter/storage.adapter.ts` if the public adapter contract changes.
65-
2. Update `packages/web/src/common/storage/adapter/indexeddb.adapter.ts`.
64+
1. Update `packages/web/src/common/storage/offline-data/offline-data.store.ts` if the public store contract changes.
65+
2. Update `packages/web/src/common/storage/offline-data/indexeddb-offline-data.store.ts`.
6666
3. Add a migration if existing user data could become invalid.
67-
4. Add adapter and migration tests.
67+
4. Add offline data store and migration tests.
6868

6969
### Common Mistakes
7070

docs/development/feature-file-map.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ Use this document to find the first files to inspect for common Compass changes.
4040
- Day task hooks: `packages/web/src/views/Day/hooks/tasks`
4141
- Task UI components: `packages/web/src/views/Day/components/TaskList`
4242
- Drag handle floating placement guard: `packages/web/src/views/Day/components/Task/DraggableTask.tsx`
43-
- Local storage for tasks: `packages/web/src/common/storage/adapter`
43+
- Local storage for tasks: `packages/web/src/common/storage/offline-data`
4444

4545
## Day / Week Views
4646

@@ -68,10 +68,11 @@ Use this document to find the first files to inspect for common Compass changes.
6868

6969
## Offline Storage
7070

71-
- Adapter singleton and readiness: `packages/web/src/common/storage/adapter/adapter.ts`
72-
- IndexedDB implementation: `packages/web/src/common/storage/adapter/indexeddb.adapter.ts`
73-
- Legacy schema migration: `packages/web/src/common/storage/adapter/legacy-primary-key.migration.ts`
71+
- Offline data store singleton and readiness: `packages/web/src/common/storage/offline-data/offline-data.store.registry.ts`
72+
- IndexedDB implementation: `packages/web/src/common/storage/offline-data/indexeddb-offline-data.store.ts`
73+
- Legacy schema migration: `packages/web/src/common/storage/offline-data/legacy-primary-key.migration.ts`
7474
- Data/external migrations: `packages/web/src/common/storage/migrations`
75+
- Browser key-value state: `packages/web/src/common/storage/browser-key-value.store.ts`
7576

7677
## Sync And SSE
7778

docs/features/offline-storage-and-migrations.md

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,41 @@
22

33
Compass supports a meaningful local-first path for unauthenticated users and resilient fallback behavior for authenticated users.
44

5-
## Storage Entry Points
5+
## Storage Boundaries
6+
7+
Compass uses two intentionally separate storage abstractions:
8+
9+
- `OfflineDataStore` owns asynchronous event, task, and migration-record data.
10+
Its current implementation is `IndexedDbOfflineDataStore`.
11+
- `BrowserKeyValueStore` owns synchronous browser key-value state backed by
12+
`localStorage` or `sessionStorage`.
613

714
Primary files:
815

9-
- `packages/web/src/common/storage/adapter/adapter.ts`
10-
- `packages/web/src/common/storage/adapter/storage.adapter.ts`
11-
- `packages/web/src/common/storage/adapter/indexeddb.adapter.ts`
16+
- `packages/web/src/common/storage/offline-data/offline-data.store.registry.ts`
17+
- `packages/web/src/common/storage/offline-data/offline-data.store.ts`
18+
- `packages/web/src/common/storage/offline-data/indexeddb-offline-data.store.ts`
19+
- `packages/web/src/common/storage/browser-key-value.store.ts`
1220

13-
The adapter layer exists so application code is not tightly bound to Dexie.
21+
Components and hooks must use feature-level storage utilities. Native
22+
`localStorage`, `sessionStorage`, and IndexedDB access belong only in these
23+
storage implementations, migrations, or black-box test setup.
1424

15-
## Adapter Lifecycle
25+
## Offline Data Store Lifecycle
1626

17-
`initializeStorage()` does this:
27+
`initializeOfflineDataStore()` does this:
1828

19-
1. lazily create the storage adapter singleton
29+
1. lazily create the offline data store singleton
2030
2. open the underlying IndexedDB database
21-
3. run adapter-level schema upgrades
31+
3. run store-level schema upgrades
2232
4. run app-level data migrations
2333
5. run app-level external import migrations
2434

2535
The initialization call is idempotent and memoized by `initPromise`.
2636

27-
For tests that instantiate `IndexedDBAdapter` directly, call `adapter.close()` during teardown before deleting the test database. This closes the Dexie connection and resets adapter readiness state for the next test.
37+
For tests that instantiate `IndexedDbOfflineDataStore` directly, call
38+
`store.close()` during teardown before deleting the test database. This closes
39+
the Dexie connection and resets readiness state for the next test.
2840

2941
## IndexedDB Schema
3042

@@ -38,7 +50,7 @@ Current table groups:
3850
- `tasks`
3951
- `_migrations`
4052

41-
The IndexedDB adapter keeps:
53+
The IndexedDB store keeps:
4254

4355
- events keyed by `_id`
4456
- tasks keyed by `_id` and associated to a `dateKey`
@@ -48,7 +60,7 @@ The IndexedDB adapter keeps:
4860

4961
File:
5062

51-
- `packages/web/src/common/storage/adapter/legacy-primary-key.migration.ts`
63+
- `packages/web/src/common/storage/offline-data/legacy-primary-key.migration.ts`
5264

5365
There is explicit support for an older task schema that used `id` instead of `_id`.
5466

@@ -80,7 +92,7 @@ Current example:
8092

8193
External migrations:
8294

83-
- import data from outside the adapter
95+
- import data from outside the offline data store
8496
- are tracked in localStorage, not IndexedDB
8597
- are non-blocking on failure
8698

@@ -131,15 +143,15 @@ Add a migration when you change:
131143

132144
Choose the right mechanism:
133145

134-
- adapter schema/version change for storage structure
146+
- offline data store schema/version change for storage structure
135147
- data migration for data already in storage
136148
- external migration for imports from localStorage or other sources
137149

138150
## Safe Editing Checklist
139151

140152
Before changing storage behavior:
141153

142-
1. update adapter code
154+
1. update the relevant storage abstraction
143155
2. add or adjust migration if existing user data could break
144156
3. add tests for fresh database and migrated database paths
145157
4. confirm startup still degrades gracefully when storage fails

docs/frontend/frontend-runtime-flow.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ The web app uses multiple state layers:
176176
| Loading states, modal visibility, async status | Redux Toolkit slices | `packages/web/src/ducks/events/slices/` |
177177
| Async sequences and persistence orchestration | redux-saga | `packages/web/src/ducks/events/sagas/event.sagas.ts` |
178178
| Event entity CRUD, active event, and draft state | Elf store | `packages/web/src/store/events.ts` |
179-
| Offline persistence | IndexedDB adapter | `packages/web/src/common/storage/adapter/indexeddb.adapter.ts` |
179+
| Offline persistence | IndexedDB offline data store | `packages/web/src/common/storage/offline-data/indexeddb-offline-data.store.ts` |
180180
| Local vs remote persistence choice | Repository factory | `packages/web/src/common/repositories/event/event.repository.util.ts` |
181181

182182
These layers are intentional. Do not collapse event entities into Redux slices
@@ -251,12 +251,12 @@ Revoked state details:
251251

252252
Files:
253253

254-
- `packages/web/src/common/storage/adapter/adapter.ts`
254+
- `packages/web/src/common/storage/offline-data/offline-data.store.registry.ts`
255255
- `packages/web/src/common/storage/migrations/migrations.ts`
256256

257257
Startup storage flow:
258258

259-
1. create or reuse the storage adapter singleton
259+
1. create or reuse the offline data store singleton
260260
2. open IndexedDB and run internal schema migrations
261261
3. run data migrations
262262
4. run external import migrations

packages/web/src/__tests__/utils/storage/indexeddb.test.util.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
import {
2-
initializeStorage,
3-
resetStorageAsync,
4-
} from "@web/common/storage/adapter/adapter";
51
import { DEMO_DATA_SEED_FLAG_KEY } from "@web/common/storage/migrations/external/demo-data-seed";
2+
import {
3+
initializeOfflineDataStore,
4+
resetOfflineDataStoreAsync,
5+
} from "@web/common/storage/offline-data/offline-data.store.registry";
66

77
const COMPASS_LOCAL_DB_NAME = "compass-local";
88

99
export async function clearCompassLocalDb(): Promise<void> {
10-
await resetStorageAsync();
10+
await resetOfflineDataStoreAsync();
1111
await new Promise<void>((resolve) => {
1212
const request = indexedDB.deleteDatabase(COMPASS_LOCAL_DB_NAME);
1313
request.onsuccess = () => resolve();
@@ -25,5 +25,5 @@ export async function prepareEmptyStorageForTests(): Promise<void> {
2525
localStorage.clear();
2626
await clearCompassLocalDb();
2727
localStorage.setItem(DEMO_DATA_SEED_FLAG_KEY, "completed");
28-
await initializeStorage();
28+
await initializeOfflineDataStore();
2929
}

packages/web/src/__tests__/utils/storage/mock-storage-adapter.util.ts renamed to packages/web/src/__tests__/utils/storage/mock-offline-data-store.util.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
1-
import { type StorageAdapter } from "@web/common/storage/adapter/storage.adapter";
1+
import { type OfflineDataStore } from "@web/common/storage/offline-data/offline-data.store";
22
import { mock } from "bun:test";
33

44
/**
5-
* Creates a mock StorageAdapter for tests.
5+
* Creates a mock OfflineDataStore for tests.
66
*
77
* All methods default to empty results or no-op implementations.
88
* Override specific methods (e.g., getAllEvents, getAllTasks) as needed per test.
99
*/
10-
type MockedStorageAdapter = {
11-
[K in keyof StorageAdapter]: StorageAdapter[K] extends (
10+
type MockedOfflineDataStore = {
11+
[K in keyof OfflineDataStore]: OfflineDataStore[K] extends (
1212
...args: infer _Args
1313
) => infer _Return
1414
? ReturnType<typeof mock>
15-
: StorageAdapter[K];
15+
: OfflineDataStore[K];
1616
};
1717

18-
export function createMockStorageAdapter(): MockedStorageAdapter {
18+
export function createMockOfflineDataStore(): MockedOfflineDataStore {
1919
return {
2020
initialize: mock().mockResolvedValue(undefined),
2121
isReady: mock().mockReturnValue(true),

packages/web/src/auth/compass/state/auth.state.util.ts

Lines changed: 24 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
DEFAULT_AUTH_STATE,
55
} from "@web/common/constants/auth.constants";
66
import { STORAGE_KEYS } from "@web/common/constants/storage.constants";
7+
import { persistentBrowserStore } from "@web/common/storage/browser-key-value.store";
78
import { clearGoogleRevokedState } from "../../google/state/google.auth.state";
89

910
const authStateListeners = new Set<() => void>();
@@ -44,47 +45,38 @@ function normalizeStoredAuthState(parsed: unknown): AuthState {
4445
}
4546

4647
/**
47-
* Get the current authentication state from localStorage.
48+
* Get the current authentication state from persistent browser storage.
4849
* Returns default state if not found or invalid.
4950
*/
5051
export function getAuthState(): AuthState {
5152
if (typeof window === "undefined") return DEFAULT_AUTH_STATE;
5253

53-
try {
54-
const stored = localStorage.getItem(STORAGE_KEYS.AUTH);
55-
if (stored) {
56-
const parsed: unknown = JSON.parse(stored);
57-
return normalizeStoredAuthState(parsed);
58-
}
54+
const stored = persistentBrowserStore.get(STORAGE_KEYS.AUTH);
55+
if (!stored) return DEFAULT_AUTH_STATE;
5956

60-
return DEFAULT_AUTH_STATE;
57+
try {
58+
return normalizeStoredAuthState(JSON.parse(stored));
6159
} catch {
6260
return DEFAULT_AUTH_STATE;
6361
}
6462
}
6563

6664
/**
67-
* Update authentication state in localStorage.
65+
* Update authentication state in persistent browser storage.
6866
* Merges partial updates into existing state.
6967
*/
7068
export function updateAuthState(updates: Partial<AuthState>): void {
7169
if (typeof window === "undefined") return;
7270

73-
try {
74-
const current = getAuthState();
75-
const updated: AuthState = {
76-
...current,
77-
...updates,
78-
};
71+
const result = AuthStateSchema.safeParse({ ...getAuthState(), ...updates });
72+
if (!result.success) return;
7973

80-
// Validate with zod schema
81-
const result = AuthStateSchema.safeParse(updated);
82-
if (result.success) {
83-
localStorage.setItem(STORAGE_KEYS.AUTH, JSON.stringify(result.data));
84-
emitAuthStateChange();
85-
}
86-
} catch {
87-
// Silently fail if localStorage is unavailable
74+
const wasStored = persistentBrowserStore.set(
75+
STORAGE_KEYS.AUTH,
76+
JSON.stringify(result.data),
77+
);
78+
if (wasStored) {
79+
emitAuthStateChange();
8880
}
8981
}
9082

@@ -97,15 +89,11 @@ export function updateAuthState(updates: Partial<AuthState>): void {
9789
export function markUserAsAuthenticated(lastKnownEmail?: string): void {
9890
if (typeof window === "undefined") return;
9991

100-
try {
101-
updateAuthState({
102-
hasAuthenticated: true,
103-
...(lastKnownEmail ? { lastKnownEmail } : {}),
104-
});
105-
clearGoogleRevokedState();
106-
} catch {
107-
// Silently fail if localStorage is unavailable
108-
}
92+
updateAuthState({
93+
hasAuthenticated: true,
94+
...(lastKnownEmail ? { lastKnownEmail } : {}),
95+
});
96+
clearGoogleRevokedState();
10997
}
11098

11199
/**
@@ -115,19 +103,11 @@ export function markUserAsAuthenticated(lastKnownEmail?: string): void {
115103
* @returns true if user has previously authenticated
116104
*/
117105
export function hasUserEverAuthenticated(): boolean {
118-
try {
119-
return getAuthState().hasAuthenticated;
120-
} catch {
121-
return false;
122-
}
106+
return getAuthState().hasAuthenticated;
123107
}
124108

125109
export function getLastKnownEmail(): string | undefined {
126-
try {
127-
return getAuthState().lastKnownEmail;
128-
} catch {
129-
return undefined;
130-
}
110+
return getAuthState().lastKnownEmail;
131111
}
132112

133113
/**
@@ -137,11 +117,8 @@ export function getLastKnownEmail(): string | undefined {
137117
export function clearAuthenticationState(): void {
138118
if (typeof window === "undefined") return;
139119

140-
try {
141-
localStorage.removeItem(STORAGE_KEYS.AUTH);
120+
if (persistentBrowserStore.remove(STORAGE_KEYS.AUTH)) {
142121
emitAuthStateChange();
143-
} catch {
144-
// Silently fail if localStorage is unavailable
145122
}
146123
}
147124

@@ -154,13 +131,7 @@ export function markAnonymousCalendarChangeForSignUpPrompt(): void {
154131
}
155132

156133
export function shouldShowAnonymousCalendarChangeSignUpPrompt(): boolean {
157-
try {
158-
return (
159-
getAuthState().shouldPromptSignUpAfterAnonymousCalendarChange === true
160-
);
161-
} catch {
162-
return false;
163-
}
134+
return getAuthState().shouldPromptSignUpAfterAnonymousCalendarChange === true;
164135
}
165136

166137
export function subscribeToAuthState(listener: () => void): () => void {

0 commit comments

Comments
 (0)