fix(sync): replace stuck 'Syncing' toast on Sheets 401#9
Conversation
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughThis 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. ChangesGoogle Sheets Authorization Error Handling and Cloud Sync
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
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
📒 Files selected for processing (3)
services/sheets.services.tssrc/lib/application/commands/__tests__/sync.test.tssrc/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!
|
Note Autofix is a beta feature. Expect some limitations and changes as we gather feedback and continue to improve it. Fixes Applied SuccessfullyFixed 2 file(s) based on 3 unresolved review comments. Files modified:
Commit: The changes have been pushed to the Time taken: |
Fixed 2 file(s) based on 3 unresolved review comments. Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
|
@coderabbitai I have merged PR #8 into this PR. Please review carefully, ensure that anything outside of these two PRs are checked as well. |
|
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 -20Length 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/nullLength 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 -80Length 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.tsLength 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.tsLength 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 -40Length 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.tsLength of output: 7631
Overall AssessmentThe PR 🔴 Regression:
|
|
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 -2000Length 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 -200Length of output: 40185
PR
|
| # | Severity | File | Issue |
|---|---|---|---|
| 1 | 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 | 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.
|
@coderabbitai generate the prompt for my AI agent to fix it. |
|
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.tsLength of output: 6836
PR
|
| # | 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?
|
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 -20Length of output: 7210
# 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. |
|
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 -80Length of output: 29113
# 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.
|
@coderabbitai review latest commit |
|
✅ Action performedFull review finished. |
There was a problem hiding this comment.
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
📒 Files selected for processing (4)
services/auth.services.tsxservices/sheets.services.tssrc/lib/application/commands/__tests__/sync.test.tssrc/lib/application/commands/sync.ts
|
Note Autofix is a beta feature. Expect some limitations and changes as we gather feedback and continue to improve it. Fixes Applied SuccessfullyFixed 4 file(s) based on 4 unresolved review comments. Files modified:
Commit: The changes have been pushed to the Time taken: |
Fixed 4 file(s) based on 4 unresolved review comments. Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
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:
getSpreadsheetIdandloadFromGoogleSheets(inservices/sheets.services.ts) silently swallowed 401 errors in their try/catch blocks, callingclearGapiAccessToken()and returningnullinstead of re-throwing.syncData(insrc/lib/application/commands/sync.ts) hadif (cloudData) { ... }with no else branch, so anullreturn fell through tofinallywithout replacing the 'Syncing...' toast. The toast component has no auto-dismiss.Fix
services/sheets.services.ts: re-throw the 401 afterclearGapiAccessToken()in 3 places (getSpreadsheetIddrive.list,getSpreadsheetIdsheet create,loadFromGoogleSheetsbatchGet).src/lib/application/commands/sync.ts:e?.status === 401and shows'Session expired. Please sign in again.'+ triggersloginWithGoogle()(replaces the generic 'Cloud sync failed' message in this case).else { dismissToast() }branch on theif (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' + callsloginWithGoogle()'dismisses the stuck Syncing toast when loadFromGoogleSheets returns null (no spreadsheet linked)'— null return dismisses the toast instead of leaving it stuckManual 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
saveToSheetstill 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.Summary by CodeRabbit
Release Notes
Bug Fixes
Improvements