You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Backing up is currently something the user has to remember. Every path to a backup file is a
button: BackupCard → "Create backup" → pick device or Drive
(src/hooks/useBackupAction.ts), or the Export dialog. We even ship a nag for it — BackupReminderPrompt, mounted globally at src/App.tsx:74, shows a banner once a day and
then funnels the user to /settings/data to click the thing themselves.
A reminder is not a safety net. It is a to-do item, and people dismiss to-do items. The users
who most need a backup are exactly the ones who will not tap through a banner every day.
Meanwhile we already know data loss happens, because we already detect it. initializeDatabase (src/db/expenseTrackerDb.ts:112-145) compares the live expense count
against lastSeenExpenseCount from the expense-tracker-install localStorage marker
(touchInstallMarker, :96-104) and returns a 'data-loss' status when data that used to
exist is gone. Today that detection can only apologise. There is nothing on the device for it
to offer back.
The realistic ways an ExTrack user loses everything:
Cause
Frequency
Survives a same-origin copy?
IndexedDB / Dexie corruption (iOS Safari is a known offender)
Real, recurring
Yes
A bad schema migration on app update (we are on Dexie v2; #58 proposes v3)
Real, version-gated
Yes
Import "override" mode, which clears every table (importData, :338-373)
Real, user-triggered
Yes
FactoryReset mis-tap (DataManagementPage)
Real, user-triggered
Yes
Browser "Clear site data" / PWA uninstall
Real
No
Storage eviction
Real on non-installed iOS
No
Lost, stolen, or replaced phone
Real
No
So this needs to be two things at once, not one: an instant local undo buffer for the top
four, and a genuinely off-device copy for the bottom three. A single mechanism cannot cover
both, and pretending otherwise is how you ship a backup feature that is not one.
The shape of the solution
Two tiers that both run by themselves, plus the manual export we already have, plus a restore
path that finally makes the 'data-loss' branch useful.
Why not "just download to the same place every day"
Worth stating plainly, because it is the obvious first instinct and it cannot work.
downloadFile (src/lib/download.ts:1-9) builds a Blob, creates an <a download>, and
clicks it. The browser download stack — not the page — owns the destination. Consequences:
No overwrite is possible. Save extrack-backup-latest.extrack twice and the second
becomes extrack-backup-latest (1).extrack. A month of this is 30 files with escalating
suffixes, which is the opposite of "one latest copy".
The page cannot choose or even learn the path. There is no "same location" to target.
It needs a user gesture. A timer-driven .click() with no recent user activation is
blocked or throttled, and repeated automatic downloads trigger a browser permission prompt.
On iOS standalone PWA it is worse, routing through a visible "Save to Files" sheet.
The API that can overwrite a fixed path is File System Access
(showDirectoryPicker → persist the handle → silent writes on later visits). It exists in
Chromium desktop only: no Safari on any platform, no Firefox, no Chrome for Android.
Since ExTrack is overwhelmingly an installed mobile PWA, that tier is deliberately not in
this issue. It is the most machinery for the fewest users. If desktop demand shows up later,
it slots in as a third tier without disturbing anything here.
Tier 1 — the local snapshot (always on, zero configuration, every platform)
An automatic snapshot written to the Origin Private File System via navigator.storage.getDirectory(). No permission prompt, no user gesture, no setting, no
toggle — it simply happens. Works on iOS Safari 15.2+, Chrome and Firefox on Android, and
every desktop browser. This is the tier that honours "not behind a user preference".
Be honest about what it is. OPFS lives in the same storage bucket as IndexedDB. It
survives corruption, a bad migration, an override-import and a factory-reset mis-tap. It does not survive "Clear site data", storage eviction, or uninstalling the PWA. It is an undo
buffer for the data layer, and it must never be described to the user as protection against a
lost phone. Copy in the UI says what it does: "A local safety copy is kept automatically."
Not "your data is backed up." We already call requestPersistentStorage()
(src/db/expenseTrackerDb.ts:88-92), which reduces eviction risk, and installed PWAs are
exempt from Safari's 7-day eviction — worth keeping, not worth over-claiming.
Plaintext, deliberately. The snapshot never leaves the origin sandbox, which is the same
boundary that already protects the unencrypted IndexedDB it was copied from. Encrypting it
would add no attacker resistance — getStoredPassphrase (src/lib/backup.ts:70-72) keeps the
passphrase in IndexedDB, right next to the data, so anything that can read one can read the
other — while adding a hard failure mode: a user whose IndexedDB is gone has also lost the
passphrase, and there is no verifier, hint, or recovery anywhere in the codebase. An encrypted
rescue file nobody can open is not a rescue file.
Layout — a manifest is the "latest" pointer. OPFS has no portable atomic rename
(FileSystemHandle.move() is Chromium-only), so a filename cannot be the commit point. A
small manifest written last is:
backups/
manifest.json ← written last; names the current snapshot. This is "latest".
snapshot-<epochMs>.json ← newest
snapshot-<epochMs>.json ← up to 7 older, pruned oldest-first
manifest.json holds { latest, history[], schemaVersion, appVersion, writtenAt, expenseCount, categoryCount, byteSize }. A torn or partial snapshot is never referenced by the manifest, so
it can never be mistaken for a good backup — it is just an orphan the next prune deletes.
Rotation is by promotion, not duplication: the previous snapshot simply stays on disk and ages
out, so we hold 8 snapshots, never two copies of the same one.
When it runs. On app foreground, if the stored last-success date is not today. Not a
background job — PWAs have no dependable background execution, and iOS has no Periodic
Background Sync at all. This is fine, and not a compromise: data only changes when the app is
used, so "first launch of the day" and "daily" converge. Reuse the existing once-per-day date
comparison from shouldShowBackupReminderBanner (src/lib/backup.ts:226-243) rather than
inventing a second scheduler.
Skip when nothing changed. Compare expense count plus the max updatedAt against the
manifest. Unchanged means no write — just touch the timestamp. Saves churn and storage on the
many days a user does not log anything.
Refuse to overwrite good data with bad. The dangerous failure of any
overwrite-the-latest-copy scheme: a bug or a mis-tap empties the database, the next automatic
run faithfully snapshots the empty state, and the last good copy is gone — silently and
irreversibly. Guard: if the new snapshot's expense count has dropped by more than 20%, or to
zero, and no deletion happened in this session, do not write. Keep the existing snapshot,
record the anomaly, and surface it in the backup card. A backup system that can destroy your
backup is worse than none, because it is trusted.
One writer at a time. Two open tabs both waking up on the same day would race. Wrap the
run in navigator.locks.request("extrack-auto-backup", …).
Check the budget first. A second full copy of attachment-heavy data is not free, and Expense.attachment is an inline base64 data-URL (src/types/expense.ts:3-15) capped around
0.5MB pre-encode by useImageAttachment.ts — attachments, not row count, drive the size.
There is currently nonavigator.storage.estimate() call and noQuotaExceededError
handling anywhere in the repo. Before writing: estimate, and if the projected footprint would
exceed roughly 80% of quota, degrade in this order — first drop to fewer retained snapshots,
then write a snapshot with attachments stripped and flag it as partial in the manifest, and
only then skip and report. Never throw a raw quota error into a background task nobody is
watching.
Tier 2 — silent Drive upload (automatic once Drive is linked)
For the failure modes Tier 1 structurally cannot cover — cleared site data, uninstall, a phone
in a river — the copy has to leave the device. On mobile there is exactly one mechanism that
can do that without a tap, and we already built it.
getValidAccessToken (src/lib/driveAuth.ts:147-163) refreshes silently with no user
interaction. uploadFileToDrive (src/lib/driveApi.ts) already creates-or-PATCH-replaces by
name, which is natively the "one latest copy, overwritten" semantic. It is plain fetch, so it
behaves the same in an iOS PWA as on desktop. Today nothing calls it on a schedule; the only
trigger is a button.
So: in the same daily run, if Drive credentials exist, upload too. Same cadence, same
unchanged-data skip, same lock.
Encrypted, because it leaves the sandbox. Reuse createEncryptedBackupFile
(src/lib/backup.ts:193-201) — AES-GCM-256, PBKDF2-SHA256 600k iterations. Fixed remote
name extrack-backup-latest.extrack so replace-by-name keeps one current copy, plus a
weekly dated upload for rotation.
Requires a passphrase to already exist.encryptData throws "No encryption passphrase
set" (src/lib/backup.ts:105) otherwise. Do not prompt from a background task. If Drive is
linked but no passphrase is set, skip Tier 2 and put a single actionable row in BackupCard.
Add a passphrase verifier. There is no verifier, hint, or recovery blob anywhere today,
so "wrong passphrase" is only distinguishable by an AES-GCM auth-tag failure after the fact.
Store a small verifier so the app can confirm a passphrase before a user is relying on it
to recover, and so the restore flow can tell "wrong passphrase" from "corrupt file".
Never retry silently on session death.DriveSessionExpiredError already documents this
(src/lib/driveAuth.ts:16-26): clear credentials, prompt reconnect once, stop.
Offline is not a failure. No network means defer to the next foreground. Do not count it
as a failed backup and do not warn.
Prerequisite, checked and cleared.SCOPES includes https://www.googleapis.com/auth/drive.file
(src/lib/driveAuth.ts:33). Two things had to be confirmed before promising unattended Drive
upload, and both came back clean, verified directly in Google Cloud Console:
Publishing status is In production, user type External — not Testing, so the 7-day
refresh-token expiry that applies to Testing apps does not apply. A linked account's refresh
token is long-lived; silent daily upload will not degrade into a reconnect nag.
drive.file is declared non-sensitive on the project's Data Access page (zero sensitive
or restricted scopes registered). Google's 100-user lifetime cap and "unverified app" warning
are both gated specifically on unapproved sensitive/restricted scopes
(Google's own docs), so neither applies
here — there is no user ceiling and nothing to submit for verification.
Tier 2 can be described as unconditionally automatic once Drive is linked and a passphrase is
set — no hedge, no user cap, no reconnect cadence to design around.
Note this tier cannot be zero-configuration: a Google account grant cannot be obtained
silently. "Always on, no preference" holds fully for Tier 1, and for Tier 2 it means no
scheduling setting and no daily decision — link once, then never think about it again.
Restore — the half that makes the rest worth building
A backup nobody can restore is theatre. This is the payoff, and it hangs off detection we
already have.
On detected wipe.initializeDatabase already returns 'data-loss'
(src/db/expenseTrackerDb.ts:112-145). Instead of only apologising, check the OPFS manifest
and Drive, then offer what actually exists: "Your data is missing. We found a local safety
copy from yesterday with 1,204 expenses. Restore it?" One tap. Note that "Clear site data"
removes the localStorage install marker too, so this branch is most reliable for the
corruption and migration cases — which is exactly where Tier 1 has a copy.
On an empty state with a snapshot present. If the expenses list is empty but a snapshot
exists, say so there too. Do not make recovery a scavenger hunt through settings.
In BackupCard. A "Restore from safety copy" row listing available snapshots with date
and expense count. Reuse the existing override-vs-merge choice from ImportData.tsx rather
than inventing a third semantic.
Raise the import cap for internal restores.validateImportFile
(src/lib/importPreview.ts:36-39) rejects anything over 10MB. Attachments are inline
base64, so a real backup blows straight past that and the rescue file becomes unimportable.
The cap is a sane guard against a user picking a hostile file; it is nonsense applied to a
snapshot we wrote ourselves. Bypass it on the internal restore path, or raise it there.
Version the payload for real.buildBackupEnvelope (src/lib/backup.ts:86-97) writes a
free-text version: "1.0" that nothing checks, and buildImportPreview
(src/lib/importPreview.ts:13-34) only tests that expenses and categories are truthy.
Record the actual Dexie schema version in the manifest and envelope, and refuse a
snapshot from a newer schema than the running app with a clear message. Half-importing a
future format is worse than declining.
What the user actually sees
Near-silent by default. Success is not an event worth interrupting anyone for.
No toast on success. A daily "backed up!" toast is noise that trains people to ignore
the channel we need when something is wrong.
A status line in BackupCard (src/components/more/BackupCard.tsx, in the /settings/data card pattern from DataManagementPage.tsx:28-52): "Safety copy: today, 09:14 · 1,204 expenses", and when linked, "Drive: today, 09:14". Quiet, checkable, no action implied.
Escalate only on sustained failure. After 3 consecutive failed days, one banner reusing
the BackupReminderPrompt pattern. Not daily. A nag that fires every day for a thing the
user cannot fix is how the whole surface gets muted.
Suppress the existing reminder banner when automatic backup is healthy. This matters: BackupReminderPrompt currently nags the user to go and manually do the exact thing the app
is now doing by itself. Left alone, we ship a feature and keep pestering people as though we
had not. The banner's remaining job is the genuinely off-device copy — so it should only
appear when Drive is unlinked or Tier 2 has been failing.
"Save a copy" stays manual, and stays a download. Handing a file to the user is the one
thing that legitimately needs a tap. The existing device-download path
(saveBackupToDevice, src/lib/backupTargets.ts:36-42) is unchanged, and can now serve the
newest snapshot instead of re-reading the database.
Telemetry and privacy
Match the existing contract in src/lib/telemetry.ts exactly — counts and error strings only,
never descriptions, amounts, tags, category names, the passphrase, Drive tokens, or the account
email. capture("auto_backup_succeeded", { tier, expenseCount, byteSize, durationMs }) and captureError("auto_backup_failed", err, { tier, stage }). No file paths, no Drive file IDs.
Implementation notes
Reuse the daily guard, do not write a second scheduler. shouldShowBackupReminderBanner / getDaysSinceLastBackup / markBackupCompleted
(src/lib/backup.ts:203-289) already implement "compare stored yyyy-MM-dd against today"
over the expense-tracker-backup-reminder localStorage key
(src/db/userPreferences.ts:28, 104-143). Extend that state with automatic-run fields —
last success per tier, consecutive failure count, last anomaly — rather than adding a
parallel timestamp store. Keep lastBackupMode semantics intact so the existing banner
logic and tests/e2e/backup-reminder.spec.ts keep passing.
Payload source is exportAllData + buildBackupEnvelope, unchanged.exportAllData
(src/db/expenseTrackerDb.ts:328-335) returns {expenses, categories}; tagMetadata is
correctly omitted because importData (:338-373) rebuilds it from expense.tags. Do not
add tables to the envelope. Deliberately excluded and staying excluded: Drive OAuth
credentials and the passphrase (src/db/driveCredentials.ts — secrets must never enter a
backup), and the bulk-entry draft (src/db/bulkDraft.ts — scratch state). Note that
localStorage preferences (currency, theme) are outside the envelope, so a restore returns
data but not settings; that is acceptable and should be stated in the restore confirmation.
Serialize off the main thread if it hurts. An attachment-heavy JSON.stringify plus
write can be multiple MB. Start with createWritable() on the main thread — supported in
Chrome 86+, Firefox 111+, Safari 16.4+. If profiling on a mid-range phone shows jank, move
the write into a Worker using createSyncAccessHandle(), which is also the fallback if the
supported-Safari floor has to drop below 16.4.
Commit order is the correctness argument. Write snapshot → read it back and verify it
parses with matching counts → write manifest.json → prune unreferenced snapshots. The
manifest write is the commit. Verify-by-reread is cheap and turns "we wrote bytes" into "we
wrote a restorable backup".
New module, not more surface on backup.ts.src/lib/backup.ts is already carrying
crypto, envelope building, and reminder scheduling. Put the OPFS snapshot store in its own
module (e.g. src/lib/snapshotStore.ts: read/write/list/prune/manifest) and the orchestration
in a hook mounted once beside BackupReminderPrompt in src/App.tsx:74. Tier 2 calls the
existing uploadBackupToDrive path in src/lib/backupTargets.ts rather than re-implementing
upload.
Feature-detect, do not assume.navigator.storage?.getDirectory and navigator.locks
both need guards. Absent OPFS means Tier 1 silently does not run — no error, no UI, and BackupCard simply does not show the row. Never break app startup for a backup subsystem.
docs/features/drive-backup.md is stale — its header claims "Spec Confirmed — Ready for
Implementation" and its client-side-encryption section is marked future scope. Both have
shipped. Correct them while in here.
Acceptance criteria
On first foreground of a calendar day, a snapshot is written to OPFS with no prompt, no gesture, and no setting, on installed mobile PWA (Chrome, Brave) and on desktop.
manifest.json is written last and names the current snapshot; a snapshot interrupted mid-write is never referenced and is pruned as an orphan.
Exactly 8 snapshots are retained (current plus 7), pruned oldest-first, with no duplicate copy of the same snapshot.
A second foreground on the same day does not write again. A day with no data change does not write a new snapshot.
A run whose expense count has collapsed (to zero, or down more than 20% with no deletion in session) does not overwrite; the previous snapshot survives and the anomaly is surfaced in BackupCard.
Two tabs open simultaneously produce exactly one snapshot for the day.
Snapshots are verified by re-reading and comparing counts before the manifest is committed.
When storage is near quota, the run degrades (fewer snapshots → attachments stripped and flagged partial → skip with a report) instead of throwing an unhandled QuotaExceededError.
With Drive linked and a passphrase set, the same daily run silently uploads an encrypted .extrack to a fixed name, replacing the previous one, with no user interaction.
Drive linked but no passphrase set: Tier 2 is skipped, nothing is prompted from the background, and BackupCard shows one actionable row.
DriveSessionExpiredError clears credentials, prompts reconnect exactly once, and never silently retries. Being offline does not count as a failure and does not warn.
The OPFS snapshot is plaintext; every file that leaves the origin sandbox is encrypted. A passphrase verifier lets the app distinguish a wrong passphrase from a corrupt file.
On a detected wipe, the app offers the newest available snapshot with its date and expense count, and restores it in one tap.
An empty expenses list with a snapshot present surfaces the restore offer without the user going hunting in settings.
Restoring a snapshot larger than 10MB succeeds — the validateImportFile cap does not apply to internally written snapshots.
A snapshot from a newer schema version is refused with a clear message rather than partially imported.
No toast fires on success. BackupCard shows last automatic backup time and expense count per tier.
Sustained failure escalates to a single banner after 3 consecutive failed days, not daily.
The existing manual-backup reminder banner does not appear while automatic backup is healthy and Drive is current.
Telemetry emits counts and error strings only — no paths, IDs, amounts, or account identifiers.
In a browser without OPFS, the app starts and functions normally, Tier 1 is inert, and no error surfaces.
Test coverage
Playwright e2e under tests/e2e/, following existing conventions — gotoApp for
Dexie-boot-aware navigation, daysAgo for local-time dates (never toISOString(), which
shifts the day for IST users), and seedExpenses from tests/support/db.ts. Model the daily
guard tests on tests/e2e/backup-reminder.spec.ts, which already drives the expense-tracker-backup-reminder localStorage key directly.
At minimum:
Seed expenses, open the app, assert a snapshot and manifest exist in OPFS with matching
counts, having never clicked anything.
Reload the same day → snapshot count and manifest writtenAt unchanged.
Advance the stored last-run date by a day with no data change → still no new snapshot.
Add an expense, advance the date → exactly one new snapshot, history capped at 8.
Seed a snapshot, wipe the expenses table directly, foreground the app → the restore offer
appears with the correct date and count, and one tap restores every expense including
attachments.
Seed a snapshot with more than 10MB of attachment data and restore it → succeeds, proving
the import cap does not apply to the internal path.
Seed a snapshot, then reduce the database to zero expenses out-of-band and foreground →
the existing snapshot is not overwritten.
Seed a manifest whose schema version exceeds the app's → restore is refused with a message,
and nothing is written to the database.
Run on the safari-mobile-pwa project to confirm OPFS behaviour on WebKit. Note the caveat
documented at tests/e2e/pwa-standalone-safari.spec.ts:4-9: Playwright's WebKit is not
real Safari.app and never reaches display-mode: standalone, so treat this as indicative
and verify the iOS install path manually on a device before release.
Manual verification before release, because it cannot be automated here: install the PWA on a
real iPhone, log expenses, force-quit, reopen the next day, and confirm from BackupCard that
a snapshot was written with no interaction. Then, with Drive linked, confirm the Drive file is
replaced rather than duplicated across two consecutive days.
The problem
Backing up is currently something the user has to remember. Every path to a backup file is a
button:
BackupCard→ "Create backup" → pick device or Drive(
src/hooks/useBackupAction.ts), or the Export dialog. We even ship a nag for it —BackupReminderPrompt, mounted globally atsrc/App.tsx:74, shows a banner once a day andthen funnels the user to
/settings/datato click the thing themselves.A reminder is not a safety net. It is a to-do item, and people dismiss to-do items. The users
who most need a backup are exactly the ones who will not tap through a banner every day.
Meanwhile we already know data loss happens, because we already detect it.
initializeDatabase(src/db/expenseTrackerDb.ts:112-145) compares the live expense countagainst
lastSeenExpenseCountfrom theexpense-tracker-installlocalStorage marker(
touchInstallMarker,:96-104) and returns a'data-loss'status when data that used toexist is gone. Today that detection can only apologise. There is nothing on the device for it
to offer back.
The realistic ways an ExTrack user loses everything:
importData,:338-373)FactoryResetmis-tap (DataManagementPage)So this needs to be two things at once, not one: an instant local undo buffer for the top
four, and a genuinely off-device copy for the bottom three. A single mechanism cannot cover
both, and pretending otherwise is how you ship a backup feature that is not one.
The shape of the solution
Two tiers that both run by themselves, plus the manual export we already have, plus a restore
path that finally makes the
'data-loss'branch useful.Why not "just download to the same place every day"
Worth stating plainly, because it is the obvious first instinct and it cannot work.
downloadFile(src/lib/download.ts:1-9) builds a Blob, creates an<a download>, andclicks it. The browser download stack — not the page — owns the destination. Consequences:
extrack-backup-latest.extracktwice and the secondbecomes
extrack-backup-latest (1).extrack. A month of this is 30 files with escalatingsuffixes, which is the opposite of "one latest copy".
.click()with no recent user activation isblocked or throttled, and repeated automatic downloads trigger a browser permission prompt.
The API that can overwrite a fixed path is File System Access
(
showDirectoryPicker→ persist the handle → silent writes on later visits). It exists inChromium desktop only: no Safari on any platform, no Firefox, no Chrome for Android.
Since ExTrack is overwhelmingly an installed mobile PWA, that tier is deliberately not in
this issue. It is the most machinery for the fewest users. If desktop demand shows up later,
it slots in as a third tier without disturbing anything here.
Tier 1 — the local snapshot (always on, zero configuration, every platform)
An automatic snapshot written to the Origin Private File System via
navigator.storage.getDirectory(). No permission prompt, no user gesture, no setting, notoggle — it simply happens. Works on iOS Safari 15.2+, Chrome and Firefox on Android, and
every desktop browser. This is the tier that honours "not behind a user preference".
Be honest about what it is. OPFS lives in the same storage bucket as IndexedDB. It
survives corruption, a bad migration, an override-import and a factory-reset mis-tap. It does
not survive "Clear site data", storage eviction, or uninstalling the PWA. It is an undo
buffer for the data layer, and it must never be described to the user as protection against a
lost phone. Copy in the UI says what it does: "A local safety copy is kept automatically."
Not "your data is backed up." We already call
requestPersistentStorage()(
src/db/expenseTrackerDb.ts:88-92), which reduces eviction risk, and installed PWAs areexempt from Safari's 7-day eviction — worth keeping, not worth over-claiming.
Plaintext, deliberately. The snapshot never leaves the origin sandbox, which is the same
boundary that already protects the unencrypted IndexedDB it was copied from. Encrypting it
would add no attacker resistance —
getStoredPassphrase(src/lib/backup.ts:70-72) keeps thepassphrase in IndexedDB, right next to the data, so anything that can read one can read the
other — while adding a hard failure mode: a user whose IndexedDB is gone has also lost the
passphrase, and there is no verifier, hint, or recovery anywhere in the codebase. An encrypted
rescue file nobody can open is not a rescue file.
Layout — a manifest is the "latest" pointer. OPFS has no portable atomic rename
(
FileSystemHandle.move()is Chromium-only), so a filename cannot be the commit point. Asmall manifest written last is:
manifest.jsonholds{ latest, history[], schemaVersion, appVersion, writtenAt, expenseCount, categoryCount, byteSize }. A torn or partial snapshot is never referenced by the manifest, soit can never be mistaken for a good backup — it is just an orphan the next prune deletes.
Rotation is by promotion, not duplication: the previous snapshot simply stays on disk and ages
out, so we hold 8 snapshots, never two copies of the same one.
When it runs. On app foreground, if the stored last-success date is not today. Not a
background job — PWAs have no dependable background execution, and iOS has no Periodic
Background Sync at all. This is fine, and not a compromise: data only changes when the app is
used, so "first launch of the day" and "daily" converge. Reuse the existing once-per-day date
comparison from
shouldShowBackupReminderBanner(src/lib/backup.ts:226-243) rather thaninventing a second scheduler.
Skip when nothing changed. Compare expense count plus the max
updatedAtagainst themanifest. Unchanged means no write — just touch the timestamp. Saves churn and storage on the
many days a user does not log anything.
Refuse to overwrite good data with bad. The dangerous failure of any
overwrite-the-latest-copy scheme: a bug or a mis-tap empties the database, the next automatic
run faithfully snapshots the empty state, and the last good copy is gone — silently and
irreversibly. Guard: if the new snapshot's expense count has dropped by more than 20%, or to
zero, and no deletion happened in this session, do not write. Keep the existing snapshot,
record the anomaly, and surface it in the backup card. A backup system that can destroy your
backup is worse than none, because it is trusted.
One writer at a time. Two open tabs both waking up on the same day would race. Wrap the
run in
navigator.locks.request("extrack-auto-backup", …).Check the budget first. A second full copy of attachment-heavy data is not free, and
Expense.attachmentis an inline base64 data-URL (src/types/expense.ts:3-15) capped around0.5MB pre-encode by
useImageAttachment.ts— attachments, not row count, drive the size.There is currently no
navigator.storage.estimate()call and noQuotaExceededErrorhandling anywhere in the repo. Before writing: estimate, and if the projected footprint would
exceed roughly 80% of quota, degrade in this order — first drop to fewer retained snapshots,
then write a snapshot with attachments stripped and flag it as partial in the manifest, and
only then skip and report. Never throw a raw quota error into a background task nobody is
watching.
Tier 2 — silent Drive upload (automatic once Drive is linked)
For the failure modes Tier 1 structurally cannot cover — cleared site data, uninstall, a phone
in a river — the copy has to leave the device. On mobile there is exactly one mechanism that
can do that without a tap, and we already built it.
getValidAccessToken(src/lib/driveAuth.ts:147-163) refreshes silently with no userinteraction.
uploadFileToDrive(src/lib/driveApi.ts) already creates-or-PATCH-replaces byname, which is natively the "one latest copy, overwritten" semantic. It is plain
fetch, so itbehaves the same in an iOS PWA as on desktop. Today nothing calls it on a schedule; the only
trigger is a button.
So: in the same daily run, if Drive credentials exist, upload too. Same cadence, same
unchanged-data skip, same lock.
createEncryptedBackupFile(
src/lib/backup.ts:193-201) — AES-GCM-256, PBKDF2-SHA256 600k iterations. Fixed remotename
extrack-backup-latest.extrackso replace-by-name keeps one current copy, plus aweekly dated upload for rotation.
encryptDatathrows "No encryption passphraseset" (
src/lib/backup.ts:105) otherwise. Do not prompt from a background task. If Drive islinked but no passphrase is set, skip Tier 2 and put a single actionable row in
BackupCard.so "wrong passphrase" is only distinguishable by an AES-GCM auth-tag failure after the fact.
Store a small verifier so the app can confirm a passphrase before a user is relying on it
to recover, and so the restore flow can tell "wrong passphrase" from "corrupt file".
DriveSessionExpiredErroralready documents this(
src/lib/driveAuth.ts:16-26): clear credentials, prompt reconnect once, stop.as a failed backup and do not warn.
Prerequisite, checked and cleared.
SCOPESincludeshttps://www.googleapis.com/auth/drive.file(
src/lib/driveAuth.ts:33). Two things had to be confirmed before promising unattended Driveupload, and both came back clean, verified directly in Google Cloud Console:
refresh-token expiry that applies to Testing apps does not apply. A linked account's refresh
token is long-lived; silent daily upload will not degrade into a reconnect nag.
drive.fileis declared non-sensitive on the project's Data Access page (zero sensitiveor restricted scopes registered). Google's 100-user lifetime cap and "unverified app" warning
are both gated specifically on unapproved sensitive/restricted scopes
(Google's own docs), so neither applies
here — there is no user ceiling and nothing to submit for verification.
Tier 2 can be described as unconditionally automatic once Drive is linked and a passphrase is
set — no hedge, no user cap, no reconnect cadence to design around.
Note this tier cannot be zero-configuration: a Google account grant cannot be obtained
silently. "Always on, no preference" holds fully for Tier 1, and for Tier 2 it means no
scheduling setting and no daily decision — link once, then never think about it again.
Restore — the half that makes the rest worth building
A backup nobody can restore is theatre. This is the payoff, and it hangs off detection we
already have.
initializeDatabasealready returns'data-loss'(
src/db/expenseTrackerDb.ts:112-145). Instead of only apologising, check the OPFS manifestand Drive, then offer what actually exists: "Your data is missing. We found a local safety
copy from yesterday with 1,204 expenses. Restore it?" One tap. Note that "Clear site data"
removes the localStorage install marker too, so this branch is most reliable for the
corruption and migration cases — which is exactly where Tier 1 has a copy.
exists, say so there too. Do not make recovery a scavenger hunt through settings.
BackupCard. A "Restore from safety copy" row listing available snapshots with dateand expense count. Reuse the existing override-vs-merge choice from
ImportData.tsxratherthan inventing a third semantic.
validateImportFile(
src/lib/importPreview.ts:36-39) rejects anything over 10MB. Attachments are inlinebase64, so a real backup blows straight past that and the rescue file becomes unimportable.
The cap is a sane guard against a user picking a hostile file; it is nonsense applied to a
snapshot we wrote ourselves. Bypass it on the internal restore path, or raise it there.
buildBackupEnvelope(src/lib/backup.ts:86-97) writes afree-text
version: "1.0"that nothing checks, andbuildImportPreview(
src/lib/importPreview.ts:13-34) only tests thatexpensesandcategoriesare truthy.Record the actual Dexie schema version in the manifest and envelope, and refuse a
snapshot from a newer schema than the running app with a clear message. Half-importing a
future format is worse than declining.
What the user actually sees
Near-silent by default. Success is not an event worth interrupting anyone for.
the channel we need when something is wrong.
BackupCard(src/components/more/BackupCard.tsx, in the/settings/datacard pattern fromDataManagementPage.tsx:28-52):"Safety copy: today, 09:14 · 1,204 expenses", and when linked,
"Drive: today, 09:14". Quiet, checkable, no action implied.
the
BackupReminderPromptpattern. Not daily. A nag that fires every day for a thing theuser cannot fix is how the whole surface gets muted.
BackupReminderPromptcurrently nags the user to go and manually do the exact thing the appis now doing by itself. Left alone, we ship a feature and keep pestering people as though we
had not. The banner's remaining job is the genuinely off-device copy — so it should only
appear when Drive is unlinked or Tier 2 has been failing.
thing that legitimately needs a tap. The existing device-download path
(
saveBackupToDevice,src/lib/backupTargets.ts:36-42) is unchanged, and can now serve thenewest snapshot instead of re-reading the database.
Telemetry and privacy
Match the existing contract in
src/lib/telemetry.tsexactly — counts and error strings only,never descriptions, amounts, tags, category names, the passphrase, Drive tokens, or the account
email.
capture("auto_backup_succeeded", { tier, expenseCount, byteSize, durationMs })andcaptureError("auto_backup_failed", err, { tier, stage }). No file paths, no Drive file IDs.Implementation notes
shouldShowBackupReminderBanner/getDaysSinceLastBackup/markBackupCompleted(
src/lib/backup.ts:203-289) already implement "compare storedyyyy-MM-ddagainst today"over the
expense-tracker-backup-reminderlocalStorage key(
src/db/userPreferences.ts:28, 104-143). Extend that state with automatic-run fields —last success per tier, consecutive failure count, last anomaly — rather than adding a
parallel timestamp store. Keep
lastBackupModesemantics intact so the existing bannerlogic and
tests/e2e/backup-reminder.spec.tskeep passing.exportAllData+buildBackupEnvelope, unchanged.exportAllData(
src/db/expenseTrackerDb.ts:328-335) returns{expenses, categories};tagMetadataiscorrectly omitted because
importData(:338-373) rebuilds it fromexpense.tags. Do notadd tables to the envelope. Deliberately excluded and staying excluded: Drive OAuth
credentials and the passphrase (
src/db/driveCredentials.ts— secrets must never enter abackup), and the bulk-entry draft (
src/db/bulkDraft.ts— scratch state). Note thatlocalStorage preferences (currency, theme) are outside the envelope, so a restore returns
data but not settings; that is acceptable and should be stated in the restore confirmation.
JSON.stringifypluswrite can be multiple MB. Start with
createWritable()on the main thread — supported inChrome 86+, Firefox 111+, Safari 16.4+. If profiling on a mid-range phone shows jank, move
the write into a Worker using
createSyncAccessHandle(), which is also the fallback if thesupported-Safari floor has to drop below 16.4.
parses with matching counts → write
manifest.json→ prune unreferenced snapshots. Themanifest write is the commit. Verify-by-reread is cheap and turns "we wrote bytes" into "we
wrote a restorable backup".
backup.ts.src/lib/backup.tsis already carryingcrypto, envelope building, and reminder scheduling. Put the OPFS snapshot store in its own
module (e.g.
src/lib/snapshotStore.ts: read/write/list/prune/manifest) and the orchestrationin a hook mounted once beside
BackupReminderPromptinsrc/App.tsx:74. Tier 2 calls theexisting
uploadBackupToDrivepath insrc/lib/backupTargets.tsrather than re-implementingupload.
navigator.storage?.getDirectoryandnavigator.locksboth need guards. Absent OPFS means Tier 1 silently does not run — no error, no UI, and
BackupCardsimply does not show the row. Never break app startup for a backup subsystem.docs/features/drive-backup.mdis stale — its header claims "Spec Confirmed — Ready forImplementation" and its client-side-encryption section is marked future scope. Both have
shipped. Correct them while in here.
Acceptance criteria
manifest.jsonis written last and names the current snapshot; a snapshot interrupted mid-write is never referenced and is pruned as an orphan.BackupCard.QuotaExceededError..extrackto a fixed name, replacing the previous one, with no user interaction.BackupCardshows one actionable row.DriveSessionExpiredErrorclears credentials, prompts reconnect exactly once, and never silently retries. Being offline does not count as a failure and does not warn.validateImportFilecap does not apply to internally written snapshots.BackupCardshows last automatic backup time and expense count per tier.Test coverage
Playwright e2e under
tests/e2e/, following existing conventions —gotoAppforDexie-boot-aware navigation,
daysAgofor local-time dates (nevertoISOString(), whichshifts the day for IST users), and
seedExpensesfromtests/support/db.ts. Model the dailyguard tests on
tests/e2e/backup-reminder.spec.ts, which already drives theexpense-tracker-backup-reminderlocalStorage key directly.At minimum:
counts, having never clicked anything.
writtenAtunchanged.expensestable directly, foreground the app → the restore offerappears with the correct date and count, and one tap restores every expense including
attachments.
the import cap does not apply to the internal path.
the existing snapshot is not overwritten.
and nothing is written to the database.
safari-mobile-pwaproject to confirm OPFS behaviour on WebKit. Note the caveatdocumented at
tests/e2e/pwa-standalone-safari.spec.ts:4-9: Playwright's WebKit is notreal Safari.app and never reaches
display-mode: standalone, so treat this as indicativeand verify the iOS install path manually on a device before release.
Manual verification before release, because it cannot be automated here: install the PWA on a
real iPhone, log expenses, force-quit, reopen the next day, and confirm from
BackupCardthata snapshot was written with no interaction. Then, with Drive linked, confirm the Drive file is
replaced rather than duplicated across two consecutive days.