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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ See `IMPLEMENTATION-PLAN.md` for the full 36-step plan.
- Step 23 (Non-Extractable Key Vault + Zustand Store Refactor) — complete
- Step 24 (Supabase API Adapter) — complete
- Step 25 (Encrypted Field CRUD) — complete
- Step 26 (Auto-Save + Sync Flow) — complete

### Implementation Notes

Expand Down
36 changes: 18 additions & 18 deletions docs/implementation-plan/06-phase-6-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@
- `loadAllFields(): Promise<Record<string, string | null>>` — load all three fields in parallel
- Gets user ID from auth store, gets CryptoKey from KeyVault (no separate hook needed)
- Wire into TanStack Query hooks:
- `useField(fieldName)` — query + cache decrypted field content, disabled while vault locked or field key not loaded
- `useSaveField(fieldName)` — mutation for saving field content, invalidates field query on success
- `useFieldQuery(fieldName)` — query + cache decrypted field content, disabled while vault locked or field key not loaded
- `useFieldMutation(fieldName)` — mutation for saving field content, invalidates field query on success
- `src/shared/types/entities/field.types.ts`:
- `FieldName` type (`'note' | 'website' | 'email'`), `FIELD_NAMES` canonical list
- `EncryptedField` and `DecryptedField` interfaces for server/client representations
Expand All @@ -70,24 +70,24 @@

---

## Step 26 — Auto-Save + Sync Flow
## Step 26 — Auto-Save + Sync Flow

**Goal:** Auto-save encrypted fields with debounce and optimistic updates.
**Goal:** Auto-save encrypted fields with debounce and local draft pattern.

**Code:**
- `src/features/fields/model/auto-save.ts`:
- Debounced auto-save: 1-second debounce after user stops typing, using a proper debounce utility (not raw `setTimeout`). Consider `useDebouncedCallback` from `usehooks-ts` or a custom hook that cancels on unmount.
- Optimistic update: update TanStack Query cache immediately, send to server in background
- Revert on error: roll back TanStack Query cache if save fails
- Save status indicator: "Saving...", "Saved", "Error — retry?"
- `src/features/fields/ui/SaveIndicator.tsx` — shows save status next to each field
- `src/features/fields/model/sync-status.ts` — Zustand store for sync status per field
- Handle concurrent edits: last-write-wins with version check
- Auto-save hook:
- Debounced save: 1-second debounce after user stops typing, using `setTimeout` with refs to avoid stale closures (ref to latest mutation function, ref to latest value). Clear timeouts on unmount and on vault lock.
- Local draft pattern: instead of mutating the TanStack Query cache, maintain a local `draft` state that takes priority over query data while the user is editing. When not editing, fall back to query data. This avoids cache-invalidation race conditions in text editors.
- Preserve draft on error: on save failure, keep the draft content and set sync status to `error`. Provide a `retry()` function that re-submits the latest draft immediately (no debounce). Do not roll back to stale server data.
- "Saved" auto-dismiss: transition from `saved` → `idle` after 3 seconds so the check mark doesn't persist forever.
- Vault lock handling: clear debounce/saved timers and reset editing state on vault lock; return empty string as value since query cache is purged.
- Save indicator component — renders nothing for `idle`, spinner + "Saving..." for `saving`, check + "Saved" for `saved`, error icon + "Save failed" + "Retry" button for `error`. Uses static i18n key map so i18next-parser can discover the strings.
- Sync status Zustand store — `Record<FieldName, SyncStatus>` with `setStatus`, `resetField`, `resetAll` actions. Uses devtools middleware. `SyncStatus` type: `'idle' | 'saving' | 'saved' | 'error'`.

**Tests:**
- Unit: debounce doesn't trigger on rapid keystrokes
- Unit: optimistic update shows saved content immediately
- Unit: error reverts to previous content
- Component test: SaveIndicator shows correct states
- Integration: type content → auto-save → verify saved in Supabase
- Integration: type content → network error → retry → success
- Unit: debounce cancels earlier timers — rapid `setValue` calls trigger only one save
- Unit: local draft updates immediately on `setValue` (optimistic display)
- Unit: error preserves draft content and sets status to `error`; `retry` resubmits without debounce
- Unit: vault lock resets editing state and clears timers
- Component test: SaveIndicator renders correct UI for each status, including retry button
- Unit: sync status store transitions through full lifecycle and resets
Empty file removed src/features/auth/lib/.gitkeep
Empty file.
2 changes: 1 addition & 1 deletion src/features/encryption/ui/VaultIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ function VaultIndicator() {
onClick={openUnlockDialog}
className={cn(
'flex items-center gap-1.5 text-sm transition-colors duration-300',
'text-muted-foreground hover:text-foreground',
'text-muted-foreground hover:text-foreground cursor-pointer',
)}
aria-label={t('vault.unlock')}
>
Expand Down
57 changes: 57 additions & 0 deletions src/features/fields/model/sync-status-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { useSyncStatusStore } from '@/features/fields/model/sync-status-store'
import { FIELD_NAMES } from '@/shared/types/entities/field.types'

describe('useSyncStatusStore', () => {
beforeEach(() => {
useSyncStatusStore.getState().resetAll()
})

it('starts with all fields idle', () => {
const { status } = useSyncStatusStore.getState()
FIELD_NAMES.forEach((name) => expect(status[name]).toBe('idle'))
})

it('setStatus updates a single field without affecting others', () => {
useSyncStatusStore.getState().setStatus('note', 'saving')
const { status } = useSyncStatusStore.getState()
expect(status.note).toBe('saving')
expect(status.website).toBe('idle')
expect(status.email).toBe('idle')
})

it('setStatus can transition through the full lifecycle', () => {
const store = useSyncStatusStore.getState()
store.setStatus('note', 'saving')
expect(useSyncStatusStore.getState().status.note).toBe('saving')

store.setStatus('note', 'saved')
expect(useSyncStatusStore.getState().status.note).toBe('saved')

store.setStatus('note', 'idle')
expect(useSyncStatusStore.getState().status.note).toBe('idle')
})

it('setStatus can set error state', () => {
useSyncStatusStore.getState().setStatus('website', 'error')
expect(useSyncStatusStore.getState().status.website).toBe('error')
})

it('resetField resets a specific field to idle', () => {
useSyncStatusStore.getState().setStatus('note', 'saving')
useSyncStatusStore.getState().setStatus('website', 'error')
useSyncStatusStore.getState().resetField('note')
const { status } = useSyncStatusStore.getState()
expect(status.note).toBe('idle')
expect(status.website).toBe('error')
})

it('resetAll resets all fields to idle', () => {
useSyncStatusStore.getState().setStatus('note', 'saving')
useSyncStatusStore.getState().setStatus('website', 'saved')
useSyncStatusStore.getState().setStatus('email', 'error')
useSyncStatusStore.getState().resetAll()
const { status } = useSyncStatusStore.getState()
FIELD_NAMES.forEach((name) => expect(status[name]).toBe('idle'))
})
})
45 changes: 45 additions & 0 deletions src/features/fields/model/sync-status-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import type { FieldName } from '@/shared/types/entities/field.types'

export type SyncStatus = 'idle' | 'saving' | 'saved' | 'error'

interface SyncStatusState {
status: Record<FieldName, SyncStatus>
}

interface SyncStatusActions {
setStatus: (fieldName: FieldName, status: SyncStatus) => void
resetField: (fieldName: FieldName) => void
resetAll: () => void
}

const initialStatus: Record<FieldName, SyncStatus> = {
note: 'idle',
website: 'idle',
email: 'idle',
}

const useSyncStatusStore = create<SyncStatusState & SyncStatusActions>()(
devtools(
(set) => ({
status: { ...initialStatus },
setStatus: (fieldName, status) =>
set(
(state) => ({ status: { ...state.status, [fieldName]: status } }),
false,
`syncStatus/setStatus/${fieldName}`,
),
resetField: (fieldName) =>
set(
(state) => ({ status: { ...state.status, [fieldName]: 'idle' } }),
false,
`syncStatus/resetField/${fieldName}`,
),
resetAll: () => set({ status: { ...initialStatus } }, false, 'syncStatus/resetAll'),
}),
{ name: 'SyncStatusStore' },
),
)

export { useSyncStatusStore }
Loading
Loading