Skip to content

fix(sync): replace stuck 'Syncing' toast on Sheets 401#9

Merged
notedwin-dev merged 5 commits into
stagingfrom
bugfix/sync-toast-stuck-on-401
Jun 5, 2026
Merged

fix(sync): replace stuck 'Syncing' toast on Sheets 401#9
notedwin-dev merged 5 commits into
stagingfrom
bugfix/sync-toast-stuck-on-401

Conversation

@notedwin-dev
Copy link
Copy Markdown
Owner

@notedwin-dev notedwin-dev commented Jun 4, 2026

Bug

On Staging, when the Google Sheets API returns 401 (expired/invalid access token), the UI toast was stuck at 'Syncing with Google Sheets...' and the user received no feedback. The session was unusable until reload.

Root cause

Two layers compounded:

  1. getSpreadsheetId and loadFromGoogleSheets (in services/sheets.services.ts) silently swallowed 401 errors in their try/catch blocks, calling clearGapiAccessToken() and returning null instead of re-throwing.
  2. syncData (in src/lib/application/commands/sync.ts) had if (cloudData) { ... } with no else branch, so a null return fell through to finally without replacing the 'Syncing...' toast. The toast component has no auto-dismiss.

Fix

  • services/sheets.services.ts: re-throw the 401 after clearGapiAccessToken() in 3 places (getSpreadsheetId drive.list, getSpreadsheetId sheet create, loadFromGoogleSheets batchGet).
  • src/lib/application/commands/sync.ts:
    • Catch block now detects e?.status === 401 and shows 'Session expired. Please sign in again.' + triggers loginWithGoogle() (replaces the generic 'Cloud sync failed' message in this case).
    • Added else { dismissToast() } branch on the if (cloudData) path to handle the legitimate 'no spreadsheet linked' case (new user, before they pick a sheet).

Regression tests (3 new, all 86 pass)

  • 'replaces the stuck Syncing toast when loadFromGoogleSheets throws' — generic error shows 'Cloud sync failed. Working offline.'
  • 'shows Session expired toast and triggers re-login on 401' — 401 throw shows 'Session expired' + calls loginWithGoogle()
  • 'dismisses the stuck Syncing toast when loadFromGoogleSheets returns null (no spreadsheet linked)' — null return dismisses the toast instead of leaving it stuck

Manual verification

After deploy to Staging, trigger a 401 (e.g., revoke OAuth from the Google account) and click sync: the toast should change to 'Session expired. Please sign in again.' and the re-login flow should start.

Out of scope

  • saveToSheet still swallows 401s (line 803) — would cause partial-write inconsistency if re-thrown mid-batchGet. That's a separate 'silent write failure' bug; the write path always shows 'Cloud sync complete' even when writes fail.
  • Toast component has no auto-dismiss — leaving for now since the bug surfaces specifically when the toast gets orphaned by a swallowed error.

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Fixed an issue where profile email updates were syncing to an incorrect email address in cloud storage
    • Improved handling of expired Google authentication sessions to properly prompt users to sign in again
  • Improvements

    • Enhanced cloud sync error messaging to differentiate between authentication failures and connectivity issues
    • Strengthened session token management when Google authentication expires

When Google Sheets returned 401, loadFromGoogleSheets and getSpreadsheetId silently swallowed the error and returned null. syncData then took the 'if (cloudData)' early-skip path with no toast replacement, leaving the user staring at 'Syncing with Google Sheets...' indefinitely.

Fix: re-throw 401 errors from getSpreadsheetId and loadFromGoogleSheets' batchGet catch. syncData's catch now detects 401 and shows 'Session expired. Please sign in again.' then triggers loginWithGoogle. Also adds an else branch on the cloudData null path to dismiss the stuck toast (handles legitimate 'no spreadsheet linked' case).

Adds 3 regression tests covering: (1) generic throw shows 'Cloud sync failed', (2) 401 throw shows 'Session expired' + triggers re-login, (3) null return dismisses stuck toast. All 86 tests pass.
@vercel
Copy link
Copy Markdown

vercel Bot commented Jun 4, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
finance-tracker-app Ready Ready Preview, Comment Jun 5, 2026 9:28am

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 4, 2026

Review Change Stack

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d1218eea-b701-4edf-b966-554edf323d00

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR refactors Google Sheets API error handling to propagate 401 authorization failures up the call stack and adds corresponding error-handling logic to sync orchestration. The service layer now clears tokens and rethrows 401 errors instead of falling back or returning null. The sync command distinguishes 401 errors to trigger re-authentication or show offline fallback, and tests validate each path. A separate fix ensures profile email updates sync to the correct user record.

Changes

Google Sheets Authorization Error Handling and Cloud Sync

Layer / File(s) Summary
Sheets service 401 error propagation
services/sheets.services.ts
getSpreadsheetId clears token and rethrows 401 from Drive verification, search, and sheet creation. findUser and loadFromGoogleSheets rethrow 401 errors instead of swallowing them, preventing fallback to null returns or warning paths.
Sync command error handling and UI feedback
src/lib/application/commands/sync.ts
resetAndSync refactored to try/catch/finally, preserving encrypted_vault_key and distinguishing 401 (session-expired toast) from other errors (offline-working toast). syncData dismisses toast on null data and branches on error status: 401 triggers loginWithGoogle(), other errors show offline message.
Sync error handling test suite
src/lib/application/commands/__tests__/sync.test.ts
Tests validate non-401 errors show offline toast, 401 errors show session-expired toast and trigger re-authentication, and null cloud data correctly dismisses toast. Suite controls time and mocks storage and sheet services.
Profile email sync during update
services/auth.services.tsx
AuthProvider.updateProfile targets updated email (updatedP.email) instead of previous email when syncing to Google Sheets, ensuring email changes sync the correct user record.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(sync): replace stuck 'Syncing' toast on Sheets 401' directly addresses the main fix in the PR: handling 401 errors from Google Sheets to replace the stuck 'Syncing with Google Sheets...' toast with appropriate error messaging.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch bugfix/sync-toast-stuck-on-401

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/application/commands/__tests__/sync.test.ts`:
- Line 1: The test file uses the afterEach lifecycle hook but it isn't imported
from vitest; update the import statement that currently imports describe, it,
expect, beforeEach, vi in sync.test.ts to also include afterEach so the
afterEach call (used around the teardown on line ~31) resolves correctly.
- Around line 69-80: Update the test to assert the toast was dismissed by
checking the store's toast is null instead of only asserting its message
changed: after calling syncData (from the test using
vi.mocked(SheetService.loadFromGoogleSheets)), replace the
expect(state.toast?.message).not.toBe("Syncing with Google Sheets...") with
expect(state.toast).toBeNull() to match the dismissToast() contract and ensure
the stuck "Syncing" toast is cleared; locate this change in the test referencing
syncData and useSyncStore.

In `@src/lib/application/commands/sync.ts`:
- Around line 672-680: Remove the redundant call to
useSyncStore.getState().setIsSyncing(false) from the catch block in the sync
flow (the block that logs "Sync failed" and handles e?.status === 401 and calls
loginWithGoogle()), since the finally block already sets isSyncing to false;
keep the toast and loginWithGoogle() behavior intact and rely on the finally
clause to clear the syncing state.
🪄 Autofix (Beta)

✅ Autofix completed


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 35849c6d-34e9-4895-85c5-cadb6f7da334

📥 Commits

Reviewing files that changed from the base of the PR and between f07dd10 and 88644ad.

📒 Files selected for processing (3)
  • services/sheets.services.ts
  • src/lib/application/commands/__tests__/sync.test.ts
  • src/lib/application/commands/sync.ts
📜 Review details
🔇 Additional comments (7)
services/sheets.services.ts (3)

233-241: LGTM!


249-257: LGTM!


1343-1349: LGTM!

src/lib/application/commands/sync.ts (1)

669-671: LGTM!

src/lib/application/commands/__tests__/sync.test.ts (3)

17-29: LGTM!


35-50: LGTM!


52-67: LGTM!

Comment thread src/lib/application/commands/__tests__/sync.test.ts Outdated
Comment thread src/lib/application/commands/__tests__/sync.test.ts
Comment thread src/lib/application/commands/sync.ts Outdated
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 5, 2026

Note

Autofix is a beta feature. Expect some limitations and changes as we gather feedback and continue to improve it.

Fixes Applied Successfully

Fixed 2 file(s) based on 3 unresolved review comments.

Files modified:

  • src/lib/application/commands/__tests__/sync.test.ts
  • src/lib/application/commands/sync.ts

Commit: ed4bbcd7eeeb2b6bf25aaf5e2063876cfe211da3

The changes have been pushed to the bugfix/sync-toast-stuck-on-401 branch.

Time taken: 2m 27s

coderabbitai Bot and others added 2 commits June 5, 2026 08:43
Fixed 2 file(s) based on 3 unresolved review comments.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
@notedwin-dev
Copy link
Copy Markdown
Owner Author

@coderabbitai I have merged PR #8 into this PR. Please review carefully, ensure that anything outside of these two PRs are checked as well.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 5, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Get details of PR `#8`
gh pr view 8 --repo notedwin-dev/finance-tracker-app --json title,body,state,mergedAt,files

echo "=== PR `#8` Files Changed ==="
gh pr diff 8 --repo notedwin-dev/finance-tracker-app 2>/dev/null | head -500

echo "=== PR `#9` Current State ==="
gh pr view 9 --repo notedwin-dev/finance-tracker-app --json title,body,state,commits,files

echo "=== Current branch files ==="
git log --oneline -20

Length of output: 34654


🏁 Script executed:

#!/bin/bash
echo "=== Full diff of PR `#9` ==="
gh pr diff 9 --repo notedwin-dev/finance-tracker-app 2>/dev/null

Length of output: 5290


🏁 Script executed:

#!/bin/bash
echo "=== List all changed files in PR `#9` ==="
gh pr view 9 --repo notedwin-dev/finance-tracker-app --json files --jq '.files[].path'

echo "=== Tree of src ==="
tree src/ -I "node_modules" 2>/dev/null || find src/ -type f | head -60

echo "=== Tree of services ==="
find . -name "*.ts" -not -path "*/node_modules/*" | head -80

Length of output: 3697


🏁 Script executed:

#!/bin/bash
echo "=== sync.ts full content ==="
cat src/lib/application/commands/sync.ts

echo ""
echo "=== sync.store.ts full content ==="
cat src/stores/sync.store.ts

echo ""
echo "=== types.ts ==="
cat types.ts

Length of output: 29258


🏁 Script executed:

#!/bin/bash
echo "=== migration.ts (domain) ==="
cat src/lib/domain/migration.ts

echo ""
echo "=== migration.ts (application) ==="
cat src/lib/application/commands/migration.ts

echo ""
echo "=== storage.services.ts ==="
cat services/storage.services.ts

Length of output: 19655


🏁 Script executed:

#!/bin/bash
echo "=== auth.services.tsx ==="
cat services/auth.services.tsx

echo ""
echo "=== AccountForm.tsx relevant portion ==="
# Get just the note/sanitize section
grep -n "sanitize\|note\|redacted" components/AccountForm.tsx | head -40

Length of output: 9802


🏁 Script executed:

#!/bin/bash
echo "=== sheets.services.ts - relevant sections around 401 handling ==="
# Show around lines 220-270 (getSpreadsheetId) and 1270-1310 (loadFromGoogleSheets) and saveToSheet area
sed -n '220,275p' services/sheets.services.ts
echo "--- loadFromGoogleSheets batchGet area ---"
sed -n '1265,1320p' services/sheets.services.ts
echo "--- saveToSheet 401 area (out of scope noted) ---"
sed -n '790,830p' services/sheets.services.ts

echo ""
echo "=== sync.test.ts full content ==="
cat src/lib/application/commands/__tests__/sync.test.ts

Length of output: 7631



@notedwin-dev: I've reviewed the combined state of both PRs carefully. Here's my full assessment:


Overall Assessment

The PR #9 core fix is correct and the 3 regression tests cover the primary scenarios well. The autofix commit added TOAST_AUTO_DISMISS_MS = 3000 to sync.store.ts, which closes the stuck-toast root cause at the store level as a safety net. However, combining PRs #8 and #9 introduces one regression and several minor issues worth addressing.


🔴 Regression: resetAndSync is now broken on 401

Before PR #9: loadFromGoogleSheets returned null on 401 → resetAndSync handled it gracefully with "No cloud data found. Cannot reset.".

After PR #9: loadFromGoogleSheets throws on 401 → resetAndSync has no try/catch, so the error propagates uncaught, setIsSyncing is never set to false, and the spinner gets stuck — the very bug PR #9 was supposed to fix.

// sync.ts — resetAndSync needs a try/catch
export async function resetAndSync(...): Promise<void> {
  // ...
  setIsSyncing(true);

  try {
    const cloudData = await SheetService.loadFromGoogleSheets(profile.email);
    if (!cloudData || !cloudData.accounts) {
      showToast("No cloud data found. Cannot reset.", "alert");
      return;
    }
    // ... rest of reset logic ...
  } catch (e: any) {
    if (e?.status === 401) {
      showToast("Session expired. Please sign in again.", "info");
      // optionally trigger re-login
    } else {
      showToast("Reset failed. Working offline.", "info");
    }
  } finally {
    setIsSyncing(false);
  }
}

🟡 sanitizeNote regex in AccountForm.tsx is overly aggressive

The pattern /\d{3,}/g redacts any 3+ digit sequence — this silently overwrites years (2024), amounts (500 MYR), reference numbers, and phone numbers entered in notes. It fires on every keystroke with no user notification.

// Current — too broad
const sanitizeNote = (value: string) =>
  value
    .replace(/\d{3,}/g, "[redacted]")
    .replace(/last\s*\d{3,}/gi, "[redacted]");

Suggest either: (a) narrowing the pattern to target only card/account-like sequences (16-digit runs, CVV groups), (b) replacing silent redaction with a visible warning hint below the textarea, or (c) dropping client-side sanitization entirely since the vault that warranted it is now removed by PR #8.


🟡 Vestigial _forceUnlock parameter in loadData (PR #8 cleanup)

// sync.ts
export async function loadData(profile: any, _forceUnlock?: boolean): Promise<void> {

This parameter was meaningful when the vault existed (unlocking would re-decrypt accounts). Since PR #8 removed the vault, it's dead. Worth removing to keep the API clean — callers should update as well.


🔵 Minor: sync.test.tstestTime type annotation

let testTime: number;          // typed as number, but uninitialized
testTime = (testTime ?? 0) + 10_000;  // `?? 0` implies it can be undefined

Should be let testTime: number | undefined; to match the intent of the null-coalescing guard. Works at runtime (JS let is undefined before first assign), but TypeScript may warn depending on strict settings.


🔵 Minor: Redundant cleanup in early-return paths of syncData

Several early-return paths inside the try block manually call syncInProgress = false and setIsSyncing(false), and then finally calls them again. This is harmless but noisy:

// Example — one of three occurrences
syncInProgress = false;            // ← redundant with finally
useSyncStore.getState().setIsSyncing(false);  // ← redundant with finally
return;

Since finally always runs, the manual cleanup inside try can be removed.


🔵 Minor: useSyncStore.setLastSyncTime is never called

sync.store.ts exports a setLastSyncTime action, but syncData uses the module-level lastSyncTime variable for throttling. The store's lastSyncTime state is always 0. Either wire them together or remove the dead store action.


✅ What's good

  • The 401 rethrow in sheets.services.ts is clean and correctly scoped to the three batchGet/drive paths.
  • The else { dismissToast() } branch correctly handles the new-user/no-sheet-linked case.
  • All 3 regression tests are well-structured and exercise the right paths.
  • Auto-dismiss timer in sync.store.ts (from autofix) is a solid defence-in-depth for any future toast leaks.
  • The vault removal (PR #8) is thorough — stripVaultFromAccount / stripVaultFromProfile are applied consistently in syncData and runVaultSchemaMigration, and needsV1Migration acts as a self-healing guard on every sync.
  • getStoredProfile defaults schemaVersion: 2 to avoid triggering spurious migrations on fresh installs.

The resetAndSync regression is the only change I'd flag as a blocker before merging to staging. Would you like me to open a follow-up issue for the sanitizeNote concern?

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 5, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Get details of PR `#8`
gh pr view 8 --repo notedwin-dev/finance-tracker-app --json title,body,files,additions,deletions,mergedAt,baseRefName,headRefName

echo "=== PR `#8` files ==="
gh pr diff 8 --repo notedwin-dev/finance-tracker-app 2>/dev/null | head -500 || echo "PR `#8` diff not available"

Length of output: 27645


🏁 Script executed:

#!/bin/bash
# Get the full diff of PR `#9`
gh pr diff 9 --repo notedwin-dev/finance-tracker-app 2>/dev/null | head -2000

Length of output: 5271


🏁 Script executed:

#!/bin/bash
# List all changed files in PR `#9`
gh pr view 9 --repo notedwin-dev/finance-tracker-app --json files | jq '.files[] | {path: .path, additions: .additions, deletions: .deletions, status: .status}'

Length of output: 579


🏁 Script executed:

#!/bin/bash
# Read main changed files
echo "=== services/sheets.services.ts ==="
cat services/sheets.services.ts 2>/dev/null || fd -i "sheets.services.ts" --exec cat {}

echo ""
echo "=== sync.ts ==="
fd -i "sync.ts" --exclude "*.test.ts" --exec cat {}

echo ""
echo "=== sync.test.ts ==="
fd -i "sync.test.ts" --exec cat {}

Length of output: 61417


🏁 Script executed:

#!/bin/bash
echo "=== types.ts ==="
cat types.ts 2>/dev/null | head -200

echo ""
echo "=== src/lib/domain/migration.ts ==="
fd -i "migration.ts" --exclude "*.test.ts" --exec cat {}

echo ""
echo "=== src/lib/application/commands/migration.ts ==="
fd "commands/migration.ts" --exec cat {}

echo ""
echo "=== src/stores/sync.store.ts ==="
fd -i "sync.store.ts" --exclude "*.test.ts" --exec cat {}

echo ""
echo "=== src/stores/mask.store.ts ==="
fd -i "mask.store.ts" --exclude "*.test.ts" --exec cat {}

echo ""
echo "=== src/stores/index.ts ==="
fd -i "index.ts" --search-path src/stores --exec cat {}

Length of output: 12821


🏁 Script executed:

#!/bin/bash
echo "=== services/auth.services.tsx ==="
cat services/auth.services.tsx 2>/dev/null

echo ""
echo "=== services/storage.services.ts ==="
cat services/storage.services.ts 2>/dev/null

echo ""
echo "=== helpers/useAppInit.tsx ==="
cat helpers/useAppInit.tsx 2>/dev/null

echo ""
echo "=== src/lib/application/commands/accounts.ts ==="
fd "commands/accounts.ts" --exec cat {}

Length of output: 27187


🏁 Script executed:

#!/bin/bash
echo "=== src/lib/application/commands.ts ==="
fd "application/commands.ts" --exclude "__tests__" --exec cat {}

echo ""
echo "=== src/lib/domain/__tests__/migration.test.ts ==="
fd "domain/__tests__/migration.test.ts" --exec cat {}

echo ""
echo "=== src/lib/application/commands/__tests__/migration.test.ts ==="
fd "commands/__tests__/migration.test.ts" --exec cat {}

echo ""
echo "=== helpers/useMask.tsx ==="
cat helpers/useMask.tsx 2>/dev/null

echo ""
echo "=== components/Profile.tsx ==="
cat components/Profile.tsx 2>/dev/null | head -200

echo ""
echo "=== CONTEXT.md ==="
cat CONTEXT.md 2>/dev/null | head -100

echo ""
echo "=== docs/adrs/002-drop-vault-data-minimization.md ==="
fd "002*" docs/ --exec cat {} 2>/dev/null || fd "adrs" --exec cat {} 2>/dev/null | head -200

Length of output: 40185



@notedwin-dev: Here's my full review of the combined branch (PR #8 + PR #9).


PR #9 — Sync Toast / 401 Fix

The core fix is correct and the three regression tests are well-structured. A few things worth noting:

✅ What looks good

  • Re-throwing 401 in all three getSpreadsheetId/loadFromGoogleSheets catch blocks is the right approach.
  • The else { dismissToast() } branch correctly prevents the stuck "Syncing…" toast on null returns.
  • The test's use of vi.useFakeTimers() + incrementing testTime bypasses the MIN_SYNC_INTERVAL throttle correctly.

⚠️ Issues in PR #9 scope

1. resetAndSync is not protected against 401

resetAndSync in sync.ts calls SheetService.loadFromGoogleSheets(profile.email) directly with no 401 handling. If the token has expired and the user clicks "Reset & Sync" from the Profile page, the function will throw and propagate an unhandled rejection rather than showing the session-expired toast.

// sync.ts — resetAndSync
const cloudData = await SheetService.loadFromGoogleSheets(profile.email);
// ↑ No try/catch around this; a 401 rethrown from loadFromGoogleSheets will escape

Consider wrapping with a catch that mirrors syncData's catch block.

2. getSpreadsheetId saved-ID path doesn't rethrow 401

When the user has a manually-linked sheet (zenfinance_selected_sheet_id in localStorage), getSpreadsheetId calls drive.files.get and catches any error with console.warn + localStorage.removeItem — it does not rethrow a 401. So a 401 on that first path silently falls through to the drive.files.list search path (which does rethrow). The behaviour is ultimately correct but the silent swallow on the first path means the token isn't cleared before the retry, which could result in a confusing double-API-call on 401.

3. sync.store.ts now has auto-dismiss

The PR description says "toast component has no auto-dismiss," but sync.store.ts (as landed from PR #8) already contains TOAST_AUTO_DISMISS_MS = 3000. The PR description is now stale; this doesn't affect correctness but may mislead future contributors. Worth a comment update.


PR #8 — Drop Vault (ADR-002)

✅ What looks good

  • stripVaultFromAccount / stripVaultFromProfile / migrateSchemaV1toV2 are pure and well-tested.
  • needsV1Migration correctly guards on both schemaVersion and actual field presence (prevents a v1 client re-introducing legacy fields).
  • saveToSheet strips sensitiveFields before building headers/rows — so even if a v1-era account sneaks in, the Sheet write won't preserve them.
  • useMaskStore rename is clean; all consumers updated consistently.
  • CONTEXT.md removal criteria and the grep hint are excellent.

🔴 Medium issues

4. sanitizeNote regex is overly aggressive in AccountForm.tsx

const sanitizeNote = (value: string) =>
  value
    .replace(/\d{3,}/g, "[redacted]")        // ← any 3+ digit number
    .replace(/last\s*\d{3,}/gi, "[redacted]");

/\d{3,}/g will redact anything with three or more consecutive digits: years (2024), amounts (500), room numbers, postal codes, phone-less references, etc. This runs on every keystroke, causing jarring mid-typing replacements. The intent appears to be preventing card-number-like sequences. Consider tightening to the actual threat model:

// Only redact sequences of 13–19 digits (PANs) or 3–4 digit CVVs following "cvv"/"cvc"
const sanitizeNote = (value: string) =>
  value
    .replace(/\b\d{13,19}\b/g, "[redacted]")     // PAN-length sequences
    .replace(/\b(cvv|cvc)\s*:?\s*\d{3,4}\b/gi, "[redacted]"); // CVV pattern

5. Stale closure in updateProfileauth.services.tsx

updateProfile: async (u, skipCloud = false) => {
  let updatedP: UserProfile;
  setProfile((prev) => {
    updatedP = { ...prev, ...u };
    StorageService.saveProfile(updatedP);
    return updatedP;
  });
  // ↓ `profile` here is captured from the outer closure — could be stale
  if (!skipCloud && SheetService.isClientReady() && profile.email) {
    await SheetService.updateUser(profile.email, u);
  }

profile.email is read from the closure captured when the context value was created (the last render). If setProfile hasn't triggered a re-render yet when the async path runs, profile.email could be stale. Use updatedP.email (or read from prev in the functional updater and pass it out):

setProfile((prev) => {
  updatedP = { ...prev, ...u };
  StorageService.saveProfile(updatedP);
  return updatedP;
});
await SheetService.updateUser(updatedP!.email, u); // use updatedP

6. findUser silently swallows 401

export const findUser = async (email: string) => {
  // ...
  } catch (e) {
    return null;  // ← 401 silently becomes null
  }
};

findUser is called during handleGoogleSuccess in auth.services.tsx. If a 401 occurs there (e.g., stale token during re-auth), the user creation path proceeds as if the user doesn't exist, potentially creating a duplicate row. Consider checking e?.status === 401 here too.

ℹ️ Minor / follow-up items

  • saveToSheet 401 swallowing (noted as out of scope in PR description): creates an asymmetry where load failures surface to the UI but save failures during sync are silent. Consider opening a follow-up issue. Would you like me to create one?

  • src/lib/application/commands/__tests__/migration.test.ts and src/lib/domain/__tests__/migration.test.ts: the files appear empty in the sandbox (fd returned no content). If these 247+316-line test files from PR #8 are actually present and passing, this is a sandbox resolution issue and can be ignored. Please confirm npx vitest run still shows 110 passing.

  • sync.test.tstestTime declaration: let testTime: number is never initialised before first use; (testTime ?? 0) relies on undefined ?? 0 at runtime but TypeScript declares it as number. This works in practice but is technically unsound — let testTime = 0 and testTime += 10_000 in beforeEach is cleaner.


Summary table

# Severity File Issue
1 ⚠️ Medium sync.ts resetAndSync not guarded against 401
2 ℹ️ Low sheets.services.ts Saved-ID path swallows 401 before retry
3 ℹ️ Low PR description "no auto-dismiss" is now inaccurate
4 🔴 Medium AccountForm.tsx sanitizeNote regex too aggressive
5 🔴 Medium auth.services.tsx updateProfile stale profile.email closure
6 ⚠️ Medium sheets.services.ts findUser silently swallows 401
7 ℹ️ Info sheets.services.ts saveToSheet 401 (OOS — follow-up issue?)
8 ℹ️ Low sync.test.ts testTime number vs undefined on first use

Overall the direction is solid — the vault removal is well-motivated and the 401 fix is sound. Items 4 and 5 are the most worth addressing before merging.

@notedwin-dev
Copy link
Copy Markdown
Owner Author

@coderabbitai generate the prompt for my AI agent to fix it.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 5, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Get PR `#8` details
gh pr view 8 --repo notedwin-dev/finance-tracker-app --json title,body,files,commits,state

echo "=== PR `#8` diff ==="
gh pr diff 8 --repo notedwin-dev/finance-tracker-app

echo "=== PR `#9` full diff ==="
gh pr diff 9 --repo notedwin-dev/finance-tracker-app

echo "=== Changed files in PR `#9` ==="
gh pr view 9 --repo notedwin-dev/finance-tracker-app --json files

echo "=== Full file listing in repo ==="
tree -L 4 --gitignore 2>/dev/null || find . -maxdepth 4 -not -path '*/.git/*' -not -path '*/node_modules/*'

Length of output: 202060


🏁 Script executed:

#!/bin/bash
echo "=== ArrowPathIcon import in Profile.tsx ==="
rg -n "ArrowPathIcon" components/Profile.tsx

echo ""
echo "=== syncData function - full catch/finally block ==="
rg -n -A5 -B2 "catch.*e.*any\|e\?\.status.*401" src/lib/application/commands/sync.ts

echo ""
echo "=== syncCooldownMs / lastSyncTime guard in syncData ==="
rg -n "syncCooldown\|lastSyncTime\|syncInProgress" src/lib/application/commands/sync.ts | head -30

echo ""
echo "=== getSpreadsheetId callers ==="
rg -n "getSpreadsheetId" services/sheets.services.ts | head -20

echo ""
echo "=== saveToSheet 401 handling (still swallowed?) ==="
rg -n -A5 -B2 "401" services/sheets.services.ts

echo ""
echo "=== loginWithGoogle signature check ==="
rg -n "loginWithGoogle" src/lib/application/commands/sync.ts | head -10
rg -n "loginWithGoogle.*=>" services/auth.services.tsx | head -5

echo ""
echo "=== needsV1Migration test description mismatch ==="
rg -n "returns true when v1 user has plain empty array" src/lib/domain/__tests__/migration.test.ts

echo ""
echo "=== toast type for sync errors ==="
rg -n "showToast.*Session\|showToast.*Cloud sync\|showToast.*failed" src/lib/application/commands/sync.ts

echo ""
echo "=== useAppInit.tsx - migration error handling ==="
cat helpers/useAppInit.tsx

echo ""
echo "=== sync.ts - null cloudData else branch context ==="
rg -n -A3 -B8 "dismissToast" src/lib/application/commands/sync.ts

Length of output: 6836



@notedwin-dev Thanks for merging PR #8 in — I've reviewed the combined diff carefully across all changed files. The core bug fix is sound and the vault drop is well-structured. Here's the full picture:


PR #9 — sync 401 fix

✅ Core fix is correct

The three re-throws in services/sheets.services.ts (Drive list, sheet create, batchGet) propagate cleanly up through every caller of getSpreadsheetId and loadFromGoogleSheets into syncData's catch block. The finally block correctly handles setIsSyncing(false) and syncInProgress = false in all paths, including the new 401 branch.

⚠️ "Session expired" toast uses "info" severity

// src/lib/application/commands/sync.ts
useSyncStore.getState().showToast("Session expired. Please sign in again.", "info");

This is an error condition — the user's session is gone and re-login is being forced. The pre-existing "Cloud sync failed" toast also uses "info", but for a new user-facing error message, "alert" is more appropriate so it's visually distinct from informational messages.

📋 saveToSheet 401 still swallowed (noted as out of scope)

// services/sheets.services.ts line 744–748
if (err?.status === 401) {
    console.warn("Google Access Token expired, clearing session.");
    clearGapiAccessToken();
    // ← no throw here
}

This is correctly called out in the PR description as a separate bug. I'd suggest opening a follow-up issue to track it so it doesn't fall through the cracks.


PR #8 — vault drop / data minimisation

✅ Overall quality is high

The migration domain layer (migrateSchemaV1toV2, needsV1Migration, stripVaultFromAccount, stripVaultFromProfile) is clean, well-tested, and idempotent. The stripVaultFromAccount defense-in-depth in both the sync merge path and the post-subscription path is a good addition.

⚠️ useAppInit.tsx — misleading error message when migration fails

// helpers/useAppInit.tsx
loadData(profile)
  .then(() => runVaultSchemaMigration())
  .catch((e) => {
    console.error("loadData failed during init:", e);         // ← message blames loadData
    useSyncStore.getState().showToast(
      "Failed to load data. Please refresh.",
      "alert",
    );
  });

If runVaultSchemaMigration() rejects, the catch fires and shows "Failed to load data" — but data loaded fine; migration failed. A simple fix: use a separate .catch() on the migration promise, or check the error source:

loadData(profile)
  .catch((e) => {
    console.error("loadData failed during init:", e);
    useSyncStore.getState().showToast("Failed to load data. Please refresh.", "alert");
  })
  .then(() => runVaultSchemaMigration())
  .catch((e) => {
    console.error("Migration failed:", e);
    // migration failure is non-fatal; optionally show a softer toast
  });

🐛 Test description mismatch in migration.test.ts (line 299)

it("returns true when v1 user has plain empty array for biometricCredIds (treated as absent)", () => {
  expect(
    needsV1Migration(
      asProfile({ schemaVersion: 2, biometricCredIds: [] }),
      [],
    ),
  ).toBe(false);   // ← expects false, but description says "returns true"

The assertion (toBe(false)) is correctisPresent([]) returns false for empty arrays, so migration is not needed. But the it description says "returns true", which is the opposite. This won't affect test runs but will confuse anyone reading the test suite.


Summary table

# File Severity Description
1 sync.ts Minor "Session expired" toast type should be "alert", not "info"
2 sheets.services.ts line 744 Out of scope saveToSheet 401 still swallowed — recommend follow-up issue
3 useAppInit.tsx Minor Catch covers both loadData and runVaultSchemaMigration; message incorrectly blames loadData
4 migration.test.ts:299 Nit Test description says "returns true" but assertion is toBe(false)

Everything else looks correct — the auto-dismiss restoration in sync.store.ts, the maskMode/privacyMode fallback in stripVaultFromProfile, the schemaVersion header range fix (A1:J1), the ArrowPathIcon import, and the google_refresh_token cleanup on logout are all solid. The 127-test suite gives good coverage of the new paths.

Would you like me to open a GitHub issue for the saveToSheet 401 follow-up?

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 5, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== sync.ts — resetAndSync ==="
grep -n "resetAndSync" src/lib/application/commands/sync.ts
sed -n '/export async function resetAndSync/,/^}/p' src/lib/application/commands/sync.ts | head -60

echo ""
echo "=== sync.ts — syncData catch block ==="
grep -n "catch\|loginWithGoogle\|dismissToast\|status.*401\|e?.status" src/lib/application/commands/sync.ts

echo ""
echo "=== sheets.services.ts — getSpreadsheetId saved-ID path (drive.files.get) ==="
grep -n "zenfinance_selected_sheet_id\|drive.files.get\|console.warn\|removeItem" services/sheets.services.ts | head -20

echo ""
echo "=== sheets.services.ts — findUser ==="
sed -n '/export const findUser/,/^};/p' services/sheets.services.ts | head -40

echo ""
echo "=== AccountForm.tsx — sanitizeNote ==="
grep -n "sanitizeNote\|redacted\|replace\|digit" components/AccountForm.tsx | head -20

echo ""
echo "=== auth.services.tsx — updateProfile ==="
grep -n "updateProfile\|updatedP\|profile.email\|setProfile\|skipCloud" services/auth.services.tsx | head -30

echo ""
echo "=== sync.test.ts — testTime ==="
grep -n "testTime\|let testTime\|beforeEach\|MIN_SYNC" src/lib/application/commands/__tests__/sync.test.ts | head -20

Length of output: 7210



@notedwin-dev: Here's the prompt — copy it directly into your AI agent:


# Task: Fix 6 code issues identified in PR `#9` (finance-tracker-app, branch `bugfix/sync-toast-stuck-on-401`)

Fix every issue below in-place. Do NOT change any logic that is not explicitly described. Run `npx tsc --noEmit` after applying all changes; there must be zero type errors.

---

## Issue 1 — `src/lib/application/commands/sync.ts`: `resetAndSync` unguarded against 401

**Problem:** `resetAndSync` calls `SheetService.loadFromGoogleSheets(profile.email)` with no try/catch. Since PR `#9` made that function throw on 401, a token-expiry during "Reset & Sync" will propagate an unhandled rejection and leave `isSyncing` stuck at `true`.

**Fix:** Wrap the entire body of `resetAndSync` (after the `confirm` guard) in try/catch/finally, mirroring `syncData`'s error handling. The `finally` block must always call `setIsSyncing(false)`. The catch block must:
- On `e?.status === 401`: call `showToast("Session expired. Please sign in again.", "info")`.
- On any other error: call `showToast("Reset failed. Working offline.", "info")`.

Remove the two manual `setIsSyncing(false)` calls that currently exist inside the function body (the one before `return` on the no-cloud-data path and the one at the very end); `finally` will handle both.

Current shape (lines 30–75 of `sync.ts`):
```ts
export async function resetAndSync(
  profile: UserProfile,
  onProfileUpdate?: (updates: Partial<UserProfile>) => void,
): Promise<void> {
  const { showToast, setIsSyncing } = useSyncStore.getState();

  if (!confirm("Reset local cache?")) return;

  setIsSyncing(true);

  const cloudData = await SheetService.loadFromGoogleSheets(profile.email);
  if (!cloudData || !cloudData.accounts) {
    showToast("No cloud data found. Cannot reset.", "alert");
    setIsSyncing(false);   // ← remove (finally handles)
    return;
  }

  // ... storage reset logic ...

  showToast("Sync reset complete", "success");
  setIsSyncing(false);     // ← remove (finally handles)
}
```

Target shape:
```ts
export async function resetAndSync(
  profile: UserProfile,
  onProfileUpdate?: (updates: Partial<UserProfile>) => void,
): Promise<void> {
  const { showToast, setIsSyncing } = useSyncStore.getState();

  if (!confirm("Reset local cache?")) return;

  setIsSyncing(true);

  try {
    const cloudData = await SheetService.loadFromGoogleSheets(profile.email);
    if (!cloudData || !cloudData.accounts) {
      showToast("No cloud data found. Cannot reset.", "alert");
      return;
    }

    // ... (unchanged storage reset logic) ...

    showToast("Sync reset complete", "success");
  } catch (e: any) {
    if (e?.status === 401) {
      showToast("Session expired. Please sign in again.", "info");
    } else {
      showToast("Reset failed. Working offline.", "info");
    }
  } finally {
    setIsSyncing(false);
  }
}
```

---

## Issue 2 — `services/sheets.services.ts`: saved-ID path in `getSpreadsheetId` swallows 401

**Problem:** When a manually-linked sheet ID is stored in `localStorage` (`zenfinance_selected_sheet_id`), `getSpreadsheetId` calls `drive.files.get`. Its `catch` block (around line 218) only logs a warning and removes the item — it does not rethrow on 401, so the token is not cleared and the error is silently swallowed before falling through to the `drive.files.list` search.

**Fix:** In that specific `catch` block, before (or instead of) the existing `console.warn` + `localStorage.removeItem`, check if `e?.status === 401` and if so call `clearGapiAccessToken()` then `throw e`. The non-401 path (sheet no longer accessible) should continue to do only `console.warn` + `removeItem` (no rethrow) — that is the intended "fall through to search" behaviour.

Current shape (around line 212–220):
```ts
try {
  await window.gapi.client.drive.files.get({ fileId: savedId, fields: "id" });
  return savedId;
} catch (e) {
  console.warn("Saved spreadsheet ID is no longer accessible", e);
  localStorage.removeItem("zenfinance_selected_sheet_id");
}
```

Target shape:
```ts
try {
  await window.gapi.client.drive.files.get({ fileId: savedId, fields: "id" });
  return savedId;
} catch (e: any) {
  if (e?.status === 401) {
    clearGapiAccessToken();
    throw e;
  }
  console.warn("Saved spreadsheet ID is no longer accessible", e);
  localStorage.removeItem("zenfinance_selected_sheet_id");
}
```

---

## Issue 3 — `components/AccountForm.tsx`: `sanitizeNote` regex is too aggressive

**Problem:** The pattern `/\d{3,}/g` (line 41) redacts any sequence of 3+ digits — including years (`2024`), monetary amounts (`500`), reference numbers, etc. — on every keystroke, with no user notification.

**Fix:** Replace both patterns with ones scoped to the actual threat model (card-number-like sequences and explicit CVV labels):

Current (lines 39–43):
```ts
const sanitizeNote = (value: string) =>
  value
    .replace(/\d{3,}/g, "[redacted]")
    .replace(/last\s*\d{3,}/gi, "[redacted]");
```

Target:
```ts
const sanitizeNote = (value: string) =>
  value
    .replace(/\b\d{13,19}\b/g, "[redacted]")           // PAN-length sequences
    .replace(/\b(cvv|cvc)\s*:?\s*\d{3,4}\b/gi, "[redacted]"); // CVV patterns
```

---

## Issue 4 — `services/auth.services.tsx`: `updateProfile` reads stale `profile.email` from closure

**Problem:** `updateProfile` (line 276) builds `updatedP` inside `setProfile`'s functional updater, then uses the outer closure's `profile.email` (line 285–287) for the cloud write. If `setProfile` hasn't caused a re-render yet, `profile.email` may be the previous value.

**Fix:** Use `updatedP!.email` (the freshly merged value) instead of `profile.email` for the `SheetService.updateUser` call.

Current (lines 276–288):
```ts
updateProfile: async (u, skipCloud = false) => {
  let updatedP: UserProfile;
  setProfile((prev) => {
    updatedP = { ...prev, ...u };
    StorageService.saveProfile(updatedP);
    return updatedP;
  });
  if (!skipCloud && SheetService.isClientReady() && profile.email) {
    await SheetService.updateUser(profile.email, u);
  }
},
```

Target:
```ts
updateProfile: async (u, skipCloud = false) => {
  let updatedP: UserProfile;
  setProfile((prev) => {
    updatedP = { ...prev, ...u };
    StorageService.saveProfile(updatedP);
    return updatedP;
  });
  if (!skipCloud && SheetService.isClientReady() && updatedP!.email) {
    await SheetService.updateUser(updatedP!.email, u);
  }
},
```

---

## Issue 5 — `services/sheets.services.ts`: `findUser` silently swallows 401

**Problem:** `findUser`'s catch block returns `null` for all errors. A 401 during `handleGoogleSuccess` is treated as "user not found", potentially creating a duplicate profile row.

**Fix:** In `findUser`'s catch block, check `e?.status === 401` and rethrow; let all other errors continue to return `null`.

Current (end of `findUser`):
```ts
  } catch (e) {
    return null;
  }
};
```

Target:
```ts
  } catch (e: any) {
    if (e?.status === 401) throw e;
    return null;
  }
};
```

---

## Issue 6 — `src/lib/application/commands/__tests__/sync.test.ts`: `testTime` declared as `number` but used as `undefined`

**Problem:** Line 18 declares `let testTime: number` but never initialises it. The first `beforeEach` relies on `testTime ?? 0` to handle the implicit `undefined`, which is unsound under `strict` TypeScript.

**Fix:** Change the declaration to `let testTime = 0` and the increment to `testTime += 10_000` (removing the nullish coalescing guard):

Current (lines 18–22):
```ts
let testTime: number;
beforeEach(() => {
  testTime = (testTime ?? 0) + 10_000;
  vi.setSystemTime(new Date(testTime));
```

Target:
```ts
let testTime = 0;
beforeEach(() => {
  testTime += 10_000;
  vi.setSystemTime(new Date(testTime));
```

---

## Verification checklist (agent must confirm before finishing)

1. `npx tsc --noEmit` exits with code 0.
2. All 6 issues above are addressed with no other logic changed.
3. No new `any` casts introduced beyond what is explicitly required above.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 5, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== sync.ts ==="
cat src/lib/application/commands/sync.ts

echo ""
echo "=== sheets.services.ts — getSpreadsheetId saved-ID path + findUser + saveToSheet 401 ==="
rg -n "401\|findUser\|getSpreadsheetId\|zenfinance_selected_sheet" services/sheets.services.ts | head -60

echo ""
echo "=== AccountForm.tsx — sanitizeNote ==="
rg -n -A8 -B2 "sanitizeNote" components/AccountForm.tsx | head -60

echo ""
echo "=== auth.services.tsx — updateProfile + findUser ==="
rg -n -A15 -B2 "updateProfile\|findUser" services/auth.services.tsx | head -100

echo ""
echo "=== useAppInit.tsx ==="
cat helpers/useAppInit.tsx

echo ""
echo "=== migration.test.ts line 280-320 ==="
sed -n '280,320p' src/lib/domain/__tests__/migration.test.ts

echo ""
echo "=== sync.test.ts ==="
cat src/lib/application/commands/__tests__/sync.test.ts

echo ""
echo "=== sync.ts resetAndSync function ==="
rg -n -A40 "resetAndSync" src/lib/application/commands/sync.ts | head -80

Length of output: 29113



@notedwin-dev Here is a copy-pasteable prompt for your AI agent. It covers all issues from both review passes, ordered by severity.


# AI Agent Fix Prompt — finance-tracker-app PR `#9` + PR `#8` combined

You are fixing bugs and code quality issues identified in a code review of the `bugfix/sync-toast-stuck-on-401` branch. Apply every change described below. Do not alter any other logic.

---

## Fix 1 — `resetAndSync` is unprotected against 401 errors  
**File:** `src/lib/application/commands/sync.ts`  
**Severity:** High (regression — throws uncaught error, spinner gets stuck)

`resetAndSync` calls `SheetService.loadFromGoogleSheets` with no error handling. Since PR `#9` re-throws 401 errors from that function, a token-expired click on "Reset & Sync" now crashes with an unhandled rejection and `isSyncing` is never cleared.

Wrap the entire body after the `confirm()` guard in a `try/catch/finally`:

```typescript
export async function resetAndSync(
  profile: UserProfile,
  onProfileUpdate?: (updates: Partial<UserProfile>) => void,
): Promise<void> {
  const { showToast, setIsSyncing } = useSyncStore.getState();

  if (!confirm("Reset local cache?")) return;

  setIsSyncing(true);

  try {
    const cloudData = await SheetService.loadFromGoogleSheets(profile.email);
    if (!cloudData || !cloudData.accounts) {
      showToast("No cloud data found. Cannot reset.", "alert");
      return;
    }

    const keysToKeep = [
      "google_access_token",
      "google_token_expiry",
      "google_refresh_token",
      "device_id",
      StorageService.KEYS.PROFILE,
    ];
    const saved: Record<string, string | null> = {};
    keysToKeep.forEach((k) => (saved[k] = localStorage.getItem(k)));
    localStorage.clear();
    keysToKeep.forEach((k) => saved[k] && localStorage.setItem(k, saved[k]));

    if (cloudData.profile) {
      const mergedProfile = { ...profile, ...cloudData.profile };
      StorageService.saveProfile(mergedProfile);
      if (onProfileUpdate) onProfileUpdate(cloudData.profile);
    }
    StorageService.saveAccounts(cloudData.accounts);
    StorageService.saveTransactions(cloudData.transactions);
    StorageService.saveCategories(cloudData.categories);
    StorageService.saveGoals(cloudData.goals);
    StorageService.saveSubscriptions(cloudData.subscriptions || []);
    StorageService.savePots(cloudData.pots || []);
    StorageService.savePockets(cloudData.pockets || []);
    StorageService.saveChatSessions(cloudData.chatSessions || []);
    showToast("Sync reset complete", "success");
  } catch (e: any) {
    if (e?.status === 401) {
      showToast("Session expired. Please sign in again.", "alert");
    } else {
      showToast("Reset failed. Could not load cloud data.", "alert");
    }
  } finally {
    setIsSyncing(false);
  }
}
```

---

## Fix 2 — "Session expired" and "Cloud sync failed" toasts use wrong severity  
**File:** `src/lib/application/commands/sync.ts`  
**Severity:** Medium (UX — errors indistinguishable from informational messages)

Change **all** `showToast` calls for error conditions from `"info"` to `"alert"`. There are three occurrences:

1. Inside the `!SheetService.isClientReady()` branch (token missing before sync starts):
```typescript
// Before
useSyncStore.getState().showToast("Session expired. Please sign in again.", "info");
// After
useSyncStore.getState().showToast("Session expired. Please sign in again.", "alert");
```

2. Inside the `catch` block — the 401 branch:
```typescript
// Before
useSyncStore.getState().showToast("Session expired. Please sign in again.", "info");
// After
useSyncStore.getState().showToast("Session expired. Please sign in again.", "alert");
```

3. Inside the `catch` block — the generic error branch:
```typescript
// Before
useSyncStore.getState().showToast("Cloud sync failed. Working offline.", "info");
// After
useSyncStore.getState().showToast("Cloud sync failed. Working offline.", "alert");
```

---

## Fix 3 — `sanitizeNote` regex is too aggressive  
**File:** `components/AccountForm.tsx`  
**Severity:** Medium (data loss — silently overwrites years, amounts, postal codes on every keystroke)

The pattern `/\d{3,}/g` redacts any 3+ digit sequence. Replace it with patterns scoped to actual card-number threats:

```typescript
// Before
const sanitizeNote = (value: string) =>
  value
    .replace(/\d{3,}/g, "[redacted]")
    .replace(/last\s*\d{3,}/gi, "[redacted]");

// After
const sanitizeNote = (value: string) =>
  value
    // Redact PAN-length sequences (13–19 consecutive digits, e.g. card numbers)
    .replace(/\b\d{13,19}\b/g, "[redacted]")
    // Redact CVV/CVC patterns (keyword followed by 3–4 digits)
    .replace(/\b(cvv|cvc)\s*:?\s*\d{3,4}\b/gi, "[redacted]");
```

---

## Fix 4 — `updateProfile` reads stale `profile.email` from closure  
**File:** `services/auth.services.tsx`  
**Severity:** Medium (race condition — cloud update may use the pre-update email)

Inside `updateProfile`, after the `setProfile` functional updater runs, the code reads `profile.email` from the outer React context closure, which may not have re-rendered yet. Use `updatedP.email` (the value computed inside the functional updater) instead.

Find the `updateProfile` async function. It should look roughly like:

```typescript
updateProfile: async (u, skipCloud = false) => {
  let updatedP: UserProfile;
  setProfile((prev) => {
    updatedP = { ...prev, ...u };
    StorageService.saveProfile(updatedP);
    return updatedP;
  });
  if (!skipCloud && SheetService.isClientReady() && profile.email) {   // ← stale
    await SheetService.updateUser(profile.email, u);                   // ← stale
  }
```

Change it to:

```typescript
updateProfile: async (u, skipCloud = false) => {
  let updatedP!: UserProfile;
  setProfile((prev) => {
    updatedP = { ...prev, ...u };
    StorageService.saveProfile(updatedP);
    return updatedP;
  });
  if (!skipCloud && SheetService.isClientReady() && updatedP.email) {
    await SheetService.updateUser(updatedP.email, u);
  }
```

---

## Fix 5 — `findUser` silently swallows 401  
**File:** `services/sheets.services.ts`  
**Severity:** Medium (silent failure — 401 during re-auth appears as "user not found", may create a duplicate user row)

Find the `findUser` function. Its catch block currently returns `null` unconditionally:

```typescript
export const findUser = async (email: string) => {
  try {
    // ... batchGet call ...
  } catch (e) {
    return null;  // ← swallows 401
  }
};
```

Change it to rethrow on 401 (matching the pattern used in `getSpreadsheetId` and `loadFromGoogleSheets`):

```typescript
export const findUser = async (email: string) => {
  try {
    // ... existing batchGet call unchanged ...
  } catch (e: any) {
    if (e?.status === 401) {
      console.warn("findUser: Google Access Token expired, clearing session.");
      clearGapiAccessToken();
      throw e;
    }
    return null;
  }
};
```

---

## Fix 6 — `useAppInit.tsx` catch covers both `loadData` and `runVaultSchemaMigration`  
**File:** `helpers/useAppInit.tsx`  
**Severity:** Minor (misleading error message — migration failure shows "Failed to load data")

The current promise chain:

```typescript
loadData(profile)
  .then(() => runVaultSchemaMigration())
  .catch((e) => {
    console.error("loadData failed during init:", e);
    useSyncStore.getState().showToast("Failed to load data. Please refresh.", "alert");
  });
```

Split into separate catches so each failure is attributed correctly:

```typescript
loadData(profile)
  .catch((e) => {
    console.error("loadData failed during init:", e);
    useSyncStore.getState().showToast("Failed to load data. Please refresh.", "alert");
  })
  .then(() => runVaultSchemaMigration())
  .catch((e) => {
    console.error("Schema migration failed during init:", e);
    // Migration failure is non-fatal; no toast needed, but log for debugging
  });
```

---

## Fix 7 — Test description says "returns true" but assertion is `toBe(false)`  
**File:** `src/lib/domain/__tests__/migration.test.ts`  
**Severity:** Nit (misleading test name)

Find the `it` block with description:
> `"returns true when v1 user has plain empty array for biometricCredIds (treated as absent)"`

Change the description to match the assertion (`toBe(false)`):

```typescript
// Before
it("returns true when v1 user has plain empty array for biometricCredIds (treated as absent)", () => {

// After
it("returns false when v1 user has plain empty array for biometricCredIds (treated as absent)", () => {
```

---

## Fix 8 — `testTime` declared as `number` but used before initialisation  
**File:** `src/lib/application/commands/__tests__/sync.test.ts`  
**Severity:** Minor (technically unsound TypeScript — `let testTime: number` is `undefined` at runtime on first `beforeEach`)

```typescript
// Before
let testTime: number;
beforeEach(() => {
  testTime = (testTime ?? 0) + 10_000;
  // ...
});

// After
let testTime = 0;
beforeEach(() => {
  testTime += 10_000;
  // ...
});
```

---

## Fix 9 — Remove dead `_forceUnlock` parameter from `loadData`  
**File:** `src/lib/application/commands/sync.ts`  
**Severity:** Minor (dead code — the vault feature that required this parameter was removed in PR `#8`)

```typescript
// Before
export async function loadData(profile: any, _forceUnlock?: boolean): Promise<void> {

// After
export async function loadData(profile: any): Promise<void> {
```

Also search for all callers of `loadData` across the codebase and remove the second argument if any call site passes it.

---

## Summary checklist

| # | File | Change |
|---|------|--------|
| 1 | `src/lib/application/commands/sync.ts` | Wrap `resetAndSync` body in `try/catch/finally` |
| 2 | `src/lib/application/commands/sync.ts` | Change 3× `"info"``"alert"` for error toasts |
| 3 | `components/AccountForm.tsx` | Narrow `sanitizeNote` regex to PAN/CVV patterns only |
| 4 | `services/auth.services.tsx` | Use `updatedP.email` instead of stale `profile.email` in `updateProfile` |
| 5 | `services/sheets.services.ts` | Rethrow 401 in `findUser` catch block |
| 6 | `helpers/useAppInit.tsx` | Split `.catch()` so `loadData` and migration failures are attributed separately |
| 7 | `src/lib/domain/__tests__/migration.test.ts` | Fix test description from "returns true" → "returns false" |
| 8 | `src/lib/application/commands/__tests__/sync.test.ts` | Initialise `testTime = 0` and use `+=` |
| 9 | `src/lib/application/commands/sync.ts` | Remove dead `_forceUnlock` parameter from `loadData` |

After applying all fixes, run `npx vitest run` and confirm all tests pass before committing.

1. resetAndSync: wrap in try/catch/finally mirroring syncData. 401 -> 'Session expired' toast; other errors -> 'Reset failed. Working offline.'; finally always setIsSyncing(false). Removes 2 manual setIsSyncing(false) calls.

2. getSpreadsheetId: rethrow 401 from saved-ID drive.files.get catch (after clearGapiAccessToken) so token-clearing is consistent with the drive.files.list path. Non-401 errors still fall through to remove the saved ID and search.

3. auth.services updateProfile: use updatedP!.email instead of profile.email for the cloud write. The outer closure's profile.email may be stale; updatedP was just assigned inside setProfile's updater and reflects the merged value.

4. findUser: rethrow 401 (after clearGapiAccessToken in upstream catch) so handleGoogleSuccess can distinguish auth failure from 'user not found' and not create a duplicate row.

5. sync.test.ts: initialise testTime to 0, drop the nullish-coalescing guard. The ?? 0 was a workaround for an implicit undefined; direct initialisation is sound under strict.

Validation: npx tsc --noEmit clean, 86/86 tests pass.
@notedwin-dev
Copy link
Copy Markdown
Owner Author

@coderabbitai review latest commit

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 5, 2026

@notedwin-dev Sure! I'll kick off a fresh review of the latest commit now.

✅ Action performed

Full review finished.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@services/auth.services.tsx`:
- Around line 285-287: The updateProfile flow currently calls
SheetService.updateUser(updatedP!.email, u) but ignores its boolean return and
assumes email-change lookup never occurs; update the logic in updateProfile to
check the boolean result from SheetService.updateUser and handle failures (log
via processLogger or the existing logger and surface an error/return false/throw
so callers know sync failed), keep the existing skipCloud and
SheetService.isClientReady checks, and do not rely on profile.email being
mutated by Profile.tsx (i.e., handle the normal path where email is unchanged).

In `@services/sheets.services.ts`:
- Around line 326-327: In the catch block inside findUser (the one currently
testing e?.status === 401), clear the in-memory flag and persisted Google token
before rethrowing: explicitly set hasAccessToken = false (or the equivalent
field on SheetService) and remove the stored access token from wherever it’s
persisted (localStorage/cookie/secure store used by your service) so
SheetService.isClientReady() won't treat the session as valid; then rethrow the
original error. Ensure you reference and modify the same token storage
keys/variables your sheet auth code uses so the invalid token is fully cleared
before propagation.

In `@src/lib/application/commands/__tests__/sync.test.ts`:
- Around line 17-80: Add a regression test for resetAndSync that simulates a 401
from SheetService.loadFromGoogleSheets and verifies resetAndSync cleans up
isSyncing and preserves the agreed localStorage keep-list: mock
SheetService.loadFromGoogleSheets to reject with an error object having
status:401, set up localStorage (or StorageService) with both keys that should
be preserved and keys that should be cleared, call resetAndSync({ ...baseProfile
} as any, updateProfileFn, loginWithGoogleFn), then assert
useSyncStore.getState().isSyncing is false, the toast message is "Session
expired. Please sign in again.", loginWithGoogleFn was called once, and that the
keep-list keys remain while non-kept keys were removed per resetAndSync's
contract.

In `@src/lib/application/commands/sync.ts`:
- Around line 47-58: The code in resetAndSync() clears localStorage but fails to
preserve the linked spreadsheet id key used by getSpreadsheetId()
(zenfinance_selected_sheet_id), which causes manual sheet links to be lost;
update the keysToKeep array in sync.ts (the constant currently containing
google_access_token, google_token_expiry, google_refresh_token,
encrypted_vault_key, device_id, StorageService.KEYS.PROFILE) to also include the
linked sheet id key (e.g., "zenfinance_selected_sheet_id" or the corresponding
constant if one exists in StorageService.KEYS), so the saved map keeps that
entry and it is restored after localStorage.clear().
🪄 Autofix (Beta)

✅ Autofix completed


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 83c415e0-8ecf-44b4-8692-271bfe4bd858

📥 Commits

Reviewing files that changed from the base of the PR and between e7e457d and 7c5b8a6.

📒 Files selected for processing (4)
  • services/auth.services.tsx
  • services/sheets.services.ts
  • src/lib/application/commands/__tests__/sync.test.ts
  • src/lib/application/commands/sync.ts

Comment thread services/auth.services.tsx Outdated
Comment thread services/sheets.services.ts Outdated
Comment thread src/lib/application/commands/__tests__/sync.test.ts
Comment thread src/lib/application/commands/sync.ts
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 5, 2026

Note

Autofix is a beta feature. Expect some limitations and changes as we gather feedback and continue to improve it.

Fixes Applied Successfully

Fixed 4 file(s) based on 4 unresolved review comments.

Files modified:

  • services/auth.services.tsx
  • services/sheets.services.ts
  • src/lib/application/commands/__tests__/sync.test.ts
  • src/lib/application/commands/sync.ts

Commit: be5dfaf3f678dc76d95e1f080df6fdc6536e279e

The changes have been pushed to the bugfix/sync-toast-stuck-on-401 branch.

Time taken: 4m 30s

Fixed 4 file(s) based on 4 unresolved review comments.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant