From dece46fc7f83d88577058856af34bb50d220bb87 Mon Sep 17 00:00:00 2001 From: Chirag Bangera Date: Tue, 8 Sep 2026 20:53:28 -0400 Subject: [PATCH] feat: replace demo data automatically on first real import --- src/features/demo/demo-manifest.test.ts | 56 +++++++++++++ src/features/demo/demo-manifest.ts | 59 +++++++++++++ src/features/demo/demo-seed.ts | 21 +++-- src/features/imports/import-page.test.tsx | 92 +++++++++++++++++++++ src/features/imports/import-page.tsx | 14 ++++ src/features/imports/import-service.test.ts | 57 +++++++++++++ src/features/imports/import-service.ts | 32 +++++++ 7 files changed, 323 insertions(+), 8 deletions(-) create mode 100644 src/features/demo/demo-manifest.test.ts create mode 100644 src/features/demo/demo-manifest.ts diff --git a/src/features/demo/demo-manifest.test.ts b/src/features/demo/demo-manifest.test.ts new file mode 100644 index 0000000..e0f275c --- /dev/null +++ b/src/features/demo/demo-manifest.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest" + +import { + clearDemoManifest, + DEMO_MANIFEST_KEY, + readDemoManifest, + writeDemoManifest, +} from "@/features/demo/demo-manifest" + +function memoryStorage(initial: Record = {}): Storage { + const store = new Map(Object.entries(initial)) + return { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, value) + }, + removeItem: (key: string) => { + store.delete(key) + }, + clear: () => store.clear(), + get length() { + return store.size + }, + key: (index: number) => [...store.keys()][index] ?? null, + } +} + +describe("demo manifest", () => { + it("round-trips recorded budget and group ids", () => { + const storage = memoryStorage() + writeDemoManifest({ budgetIds: ["b1"], groupIds: ["g1", "g2"] }, storage) + expect(storage.getItem(DEMO_MANIFEST_KEY)).toContain("b1") + expect(readDemoManifest(storage)).toEqual({ budgetIds: ["b1"], groupIds: ["g1", "g2"] }) + }) + + it("returns null when missing, corrupt, or empty", () => { + expect(readDemoManifest(memoryStorage())).toBeNull() + expect(readDemoManifest(memoryStorage({ [DEMO_MANIFEST_KEY]: "not-json" }))).toBeNull() + expect(readDemoManifest(memoryStorage({ [DEMO_MANIFEST_KEY]: JSON.stringify({}) }))).toBeNull() + expect( + readDemoManifest( + memoryStorage({ [DEMO_MANIFEST_KEY]: JSON.stringify({ budgetIds: [], groupIds: [] }) }), + ), + ).toBeNull() + }) + + it("drops non-string entries and clears on demand", () => { + const storage = memoryStorage({ + [DEMO_MANIFEST_KEY]: JSON.stringify({ budgetIds: ["b1", 42, null], groupIds: "nope" }), + }) + expect(readDemoManifest(storage)).toEqual({ budgetIds: ["b1"], groupIds: [] }) + clearDemoManifest(storage) + expect(readDemoManifest(storage)).toBeNull() + expect(storage.getItem(DEMO_MANIFEST_KEY)).toBeNull() + }) +}) diff --git a/src/features/demo/demo-manifest.ts b/src/features/demo/demo-manifest.ts new file mode 100644 index 0000000..3d0e2c7 --- /dev/null +++ b/src/features/demo/demo-manifest.ts @@ -0,0 +1,59 @@ +// Tracks exactly which budget/group rows the demo seeder created, so the +// first real import can remove them again. ids are random per seed, so they +// cannot be rediscovered from content. Leaf module (no imports) to avoid a +// cycle: demo-seed writes it, import-service reads and clears it. + +export const DEMO_MANIFEST_KEY = "budgetlens.demo-manifest.v1" + +export interface DemoManifest { + budgetIds: string[] + groupIds: string[] +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function asIdList(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return value.filter((item): item is string => typeof item === "string" && item.length > 0) +} + +export function readDemoManifest( + storage: Pick = globalThis.localStorage, +): DemoManifest | null { + try { + const raw = storage.getItem(DEMO_MANIFEST_KEY) + if (!raw) return null + const parsed: unknown = JSON.parse(raw) as unknown + if (!isRecord(parsed)) return null + const budgetIds = asIdList(parsed.budgetIds) + const groupIds = asIdList(parsed.groupIds) + if (budgetIds.length === 0 && groupIds.length === 0) return null + return { budgetIds, groupIds } + } catch { + return null + } +} + +export function writeDemoManifest( + manifest: DemoManifest, + storage: Pick = globalThis.localStorage, +): void { + try { + storage.setItem(DEMO_MANIFEST_KEY, JSON.stringify(manifest)) + } catch { + // Private-mode storage may throw; seeding still succeeds, and replacement + // falls back to import-linked rows only (budgets/groups are re-seedable). + } +} + +export function clearDemoManifest( + storage: Pick = globalThis.localStorage, +): void { + try { + storage.removeItem(DEMO_MANIFEST_KEY) + } catch { + // Best-effort only. + } +} diff --git a/src/features/demo/demo-seed.ts b/src/features/demo/demo-seed.ts index 546cfea..a93c732 100644 --- a/src/features/demo/demo-seed.ts +++ b/src/features/demo/demo-seed.ts @@ -4,6 +4,7 @@ import { database } from "@/db/database" import { ImportService } from "@/features/imports/import-service" import { readOnboardingChoice } from "@/features/onboarding/onboarding-storage" +import { writeDemoManifest } from "./demo-manifest" import { DEFAULT_DEMO_TEMPLATE_ID, getDemoTemplate, @@ -48,14 +49,13 @@ export async function seedDemoDataIfEmpty( await importService.commit(preview) const now = new Date().toISOString() - await db.budgets.bulkPut( - template.budgets.map((goal) => ({ - ...goal, - id: crypto.randomUUID(), - createdAt: now, - updatedAt: now, - })), - ) + const budgetRows = template.budgets.map((goal) => ({ + ...goal, + id: crypto.randomUUID(), + createdAt: now, + updatedAt: now, + })) + await db.budgets.bulkPut(budgetRows) const groupSeeds = template.groups.map((group) => { const id = crypto.randomUUID() @@ -63,6 +63,11 @@ export async function seedDemoDataIfEmpty( }) await db.transactionGroups.bulkPut(groupSeeds.map((seed) => seed.row)) const groupIds = new Map(groupSeeds.map((seed) => [seed.name, seed.id])) + // Record exactly what was seeded so the first real import can remove it. + writeDemoManifest({ + budgetIds: budgetRows.map((row) => row.id), + groupIds: groupSeeds.map((seed) => seed.id), + }) // Link the tagged trip expenses to the trip group as a shared two-way split. const tripGroupId = groupIds.get(template.tripGroupName) diff --git a/src/features/imports/import-page.test.tsx b/src/features/imports/import-page.test.tsx index 5a624ef..e84392c 100644 --- a/src/features/imports/import-page.test.tsx +++ b/src/features/imports/import-page.test.tsx @@ -1,7 +1,9 @@ import { render, screen } from "@testing-library/react" import userEvent from "@testing-library/user-event" +import { database } from "@/db/database" import type { ImportBatch } from "@/domain/models" +import { DEMO_SOURCE_NAME } from "@/features/demo/golden-bundle" import type { ImportCollectionPreview, ImportDeletionReceipt, @@ -537,3 +539,93 @@ describe("ImportPage", () => { ).not.toBeInTheDocument() }) }) + +describe("demo replacement notice", () => { + afterEach(async () => { + await database.imports.clear() + }) + + it("warns that importing replaces demo data, once per preview", async () => { + const user = userEvent.setup() + await database.imports.add({ + id: "demo-batch-1", + kind: "bundle", + sourceName: DEMO_SOURCE_NAME, + sourceHash: "demo-hash", + rowCount: 1, + importedCount: 1, + skippedCount: 0, + replacedCount: 0, + importedAt: "2026-07-22T12:00:00.000Z", + }) + mocks.preview.mockResolvedValueOnce({ + kind: "transactions", + sourceName: "real.csv", + sourceHash: "real-hash", + rowCount: 1, + transactions: [], + wealth: [], + wealthBreakdown: [], + wealthAccounts: [], + issues: [], + duplicateFile: false, + duplicateCount: 0, + replacementCount: 0, + importableCount: 1, + duplicatePolicy: "skip", + wealthPolicy: "skip", + }) + render() + const file = new File(["Date,Description,Amount"], "real.csv", { type: "text/csv" }) + Object.defineProperty(file, "text", { + value: () => Promise.resolve("Date,Description,Amount"), + }) + await user.upload(screen.getByLabelText("CSV or JSON files"), file) + await screen.findByRole("heading", { name: "Import preview" }) + expect( + await screen.findByText("Importing will replace the sample demo data currently shown."), + ).toBeInTheDocument() + await database.imports.clear() + }) + + it("stays silent for demo bundles", async () => { + const user = userEvent.setup() + await database.imports.add({ + id: "demo-batch-1", + kind: "bundle", + sourceName: DEMO_SOURCE_NAME, + sourceHash: "demo-hash", + rowCount: 1, + importedCount: 1, + skippedCount: 0, + replacedCount: 0, + importedAt: "2026-07-22T12:00:00.000Z", + }) + mocks.preview.mockResolvedValueOnce({ + kind: "bundle", + sourceName: DEMO_SOURCE_NAME, + sourceHash: "demo-hash-2", + rowCount: 1, + transactions: [], + wealth: [], + wealthBreakdown: [], + wealthAccounts: [], + issues: [], + duplicateFile: false, + duplicateCount: 0, + replacementCount: 0, + importableCount: 1, + duplicatePolicy: "skip", + wealthPolicy: "skip", + }) + render() + const file = new File(["{}"], "demo.json", { type: "application/json" }) + Object.defineProperty(file, "text", { value: () => Promise.resolve("{}") }) + await user.upload(screen.getByLabelText("CSV or JSON files"), file) + await screen.findByRole("heading", { name: "Import preview" }) + expect( + screen.queryByText("Importing will replace the sample demo data currently shown."), + ).not.toBeInTheDocument() + await database.imports.clear() + }) +}) diff --git a/src/features/imports/import-page.tsx b/src/features/imports/import-page.tsx index 7fbff1c..5e4494a 100644 --- a/src/features/imports/import-page.tsx +++ b/src/features/imports/import-page.tsx @@ -7,6 +7,8 @@ import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { repositories } from "@/db/repositories" import type { ImportBatch } from "@/domain/models" +import { useIsDemoData } from "@/features/demo/demo-seed" +import { isDemoSourceName } from "@/features/demo/demo-templates" import type { CsvColumnMapping, CsvMappableField } from "@/features/imports/csv-mapping" import { CSV_MAPPABLE_FIELDS, @@ -177,6 +179,7 @@ export async function readFilesIndependently( export function ImportPage() { const inputId = useId() + const demoPresent = useIsDemoData() const [preview, setPreview] = useState(null) const [collection, setCollection] = useState(null) const [history, setHistory] = useState([]) @@ -678,6 +681,12 @@ export function ImportPage() { + {demoPresent && + collection.previews.some((item) => !isDemoSourceName(item.sourceName)) ? ( +

+ Importing will replace the sample demo data currently shown. +

+ ) : null}
Rows
@@ -955,6 +964,11 @@ export function ImportPage() { + {demoPresent && !isDemoSourceName(preview.sourceName) ? ( +

+ Importing will replace the sample demo data currently shown. +

+ ) : null}
Rows
diff --git a/src/features/imports/import-service.test.ts b/src/features/imports/import-service.test.ts index 0b13bc1..d7f605d 100644 --- a/src/features/imports/import-service.test.ts +++ b/src/features/imports/import-service.test.ts @@ -2,6 +2,9 @@ import { readFile } from "node:fs/promises" import path from "node:path" import { BudgetLensDatabase } from "@/db/database" +import { clearDemoManifest, readDemoManifest } from "@/features/demo/demo-manifest" +import { seedDemoDataIfEmpty } from "@/features/demo/demo-seed" +import { DEMO_SOURCE_NAME } from "@/features/demo/golden-bundle" import { ImportService } from "@/features/imports/import-service" async function fixture(name: string): Promise { @@ -379,3 +382,57 @@ describe("ImportService", () => { ).rejects.toThrow("combined byte limit") }) }) + +describe("demo replacement on real import", () => { + let db: BudgetLensDatabase + let service: ImportService + + beforeEach(() => { + db = new BudgetLensDatabase(`budgetlens-restore-test-${crypto.randomUUID()}`) + service = new ImportService(db) + clearDemoManifest() + }) + + afterEach(async () => { + clearDemoManifest() + await db.delete() + }) + + it("removes demo content when real data lands", async () => { + expect(await seedDemoDataIfEmpty(db)).toBe(true) + expect(await db.transactions.count()).toBeGreaterThan(0) + expect(await db.budgets.count()).toBeGreaterThan(0) + expect(readDemoManifest()).not.toBeNull() + + const preview = await service.preview( + "Date,Description,Amount,Account Name\n2026-09-01,Real Grocer,-42.10,Real Checking", + "real.csv", + ) + await service.commit(preview) + + expect(await db.transactions.count()).toBe(1) + expect(await db.wealth.count()).toBe(0) + expect(await db.budgets.count()).toBe(0) + expect(await db.transactionGroups.count()).toBe(0) + expect(await db.imports.count()).toBe(1) + expect((await db.imports.toArray())[0]).toMatchObject({ sourceName: "real.csv" }) + expect(readDemoManifest()).toBeNull() + }) + + it("keeps demo content when committing demo-shaped data", async () => { + expect(await seedDemoDataIfEmpty(db)).toBe(true) + const before = await db.transactions.count() + + const preview = await service.preview( + "Date,Description,Amount\n2026-09-02,Extra,-5.00", + "extra.csv", + ) + await service.commit({ ...preview, sourceName: DEMO_SOURCE_NAME }) + + expect(await db.transactions.count()).toBe(before + 1) + expect(await db.budgets.count()).toBeGreaterThan(0) + expect(readDemoManifest()).not.toBeNull() + const sources = (await db.imports.toArray()).map((batch) => batch.sourceName) + expect(sources).toContain(DEMO_SOURCE_NAME) + }) +}) diff --git a/src/features/imports/import-service.ts b/src/features/imports/import-service.ts index ea7cd20..718f3cb 100644 --- a/src/features/imports/import-service.ts +++ b/src/features/imports/import-service.ts @@ -14,6 +14,8 @@ import type { } from "@/domain/models" import { DEFAULT_SHARE_COUNT } from "@/domain/models" import { normalizeTransactionAmountMinor } from "@/domain/transaction-amount" +import { clearDemoManifest, readDemoManifest } from "@/features/demo/demo-manifest" +import { isDemoSourceName } from "@/features/demo/demo-templates" import type { CsvColumnMapping } from "@/features/imports/csv-mapping" import { parseImportContent, @@ -470,6 +472,33 @@ export class ImportService { return { receipts, failures } } + /** + * Remove demo-seeded content when real data lands, fulfilling the banner's + * "import to replace it" promise. Runs at the end of a successful commit so + * a failed import never destroys the demo. Demo-to-demo commits (seeding, + * template switches) skip it via the source-name check. + */ + private async replaceDemoSeed(sourceName: string): Promise { + if (isDemoSourceName(sourceName)) return + const demoBatchIds = ( + await this.db.imports.filter((batch) => isDemoSourceName(batch.sourceName)).primaryKeys() + ).filter((id): id is string => typeof id === "string") + const manifest = readDemoManifest() + if (demoBatchIds.length === 0 && !manifest) return + if (demoBatchIds.length > 0) { + await this.db.transactions.where("importBatchId").anyOf(demoBatchIds).delete() + await this.db.wealth.where("importBatchId").anyOf(demoBatchIds).delete() + await this.db.wealthBreakdown.where("importBatchId").anyOf(demoBatchIds).delete() + await this.db.wealthAccounts.where("importBatchId").anyOf(demoBatchIds).delete() + await this.db.imports.bulkDelete(demoBatchIds) + } + if (manifest) { + await this.db.budgets.bulkDelete(manifest.budgetIds) + await this.db.transactionGroups.bulkDelete(manifest.groupIds) + clearDemoManifest() + } + } + async commit(preview: ImportPreview): Promise { if (preview.duplicateFile && preview.duplicatePolicy === "skip") { throw new Error("This exact file was already imported.") @@ -510,6 +539,8 @@ export class ImportService { this.db.wealthBreakdown, this.db.wealthAccounts, this.db.imports, + this.db.budgets, + this.db.transactionGroups, ], async () => { if ( @@ -662,6 +693,7 @@ export class ImportService { importedAt, } await this.db.imports.add(batch) + await this.replaceDemoSeed(preview.sourceName) return batch }, )