From eac2a516b535312c0b1265a2246997f18bf4c165 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 2 May 2026 11:48:57 -0400 Subject: [PATCH] feat(balance): 2-step onboarding card on /balance empty state Replace empty BalanceOverviewCard with BalanceOnboardingCard showing two steps: 1. Create an account 2. Enter a snapshot Step 2 is grayed out until at least one account exists; the entire card is replaced by BalanceOverviewCard once a snapshot is recorded. Hide "+ New snapshot" button when 0 accounts (it lives inside the overview card, which is now hidden in that state). Improve SnapshotEditPage noAccounts copy to clarify account vs snapshot semantics. Resolves #178 --- CHANGELOG.fr.md | 4 + CHANGELOG.md | 4 + docs/architecture.md | 2 +- .../balance/BalanceOnboardingCard.test.tsx | 41 ++++ .../balance/BalanceOnboardingCard.tsx | 210 ++++++++++++++++++ src/i18n/locales/en.json | 20 +- src/i18n/locales/fr.json | 20 +- src/pages/BalancePage.tsx | 21 +- 8 files changed, 316 insertions(+), 6 deletions(-) create mode 100644 src/components/balance/BalanceOnboardingCard.test.tsx create mode 100644 src/components/balance/BalanceOnboardingCard.tsx diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 0043bd4..7a536b4 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -2,6 +2,10 @@ ## [Non publié] +### Modifié + +- Bilan : remplacement de l'état vide de /balance par une carte d'onboarding à 2 étapes (Créer un compte → Saisir un snapshot) pour éviter l'écran « aucun snapshot » déroutant avant qu'un compte n'existe. Le bouton « + Nouveau snapshot » est masqué tant qu'aucun compte n'existe. La copie de l'état vide de /balance/snapshot clarifie la différence entre un compte et un snapshot (#178). + ### Corrigé - Bilan : correction de l'erreur SQLite « misuse of aggregate function MIN() » au chargement de /balance avec des snapshots existants ; remplacement du pattern aggregate-in-WHERE par une window function ROW_NUMBER() dans getAccountsPeriodAnchor (#175). diff --git a/CHANGELOG.md b/CHANGELOG.md index e7c0c29..9635c56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Bilan: replaced empty /balance state with a 2-step onboarding card (Create an account → Enter a snapshot) so users no longer see a confusing "no snapshot" screen before any account exists. The "+ New snapshot" button is hidden until at least one account exists. The /balance/snapshot empty-state copy now clarifies what an account is vs. what a snapshot is (#178). + ### Fixed - Bilan: fix SQLite "misuse of aggregate function MIN()" error when loading /balance with existing snapshots; replaced aggregate-in-WHERE pattern with ROW_NUMBER() window function in getAccountsPeriodAnchor (#175). diff --git a/docs/architecture.md b/docs/architecture.md index a6082ef..5c43820 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -28,7 +28,7 @@ simpl-resultat/ ├── src/ # Frontend React/TypeScript │ ├── components/ # 58 composants organisés par domaine │ │ ├── adjustments/ # 3 composants -│ │ ├── balance/ # 7 composants Bilan (AccountForm, BalanceAccountsTable, BalanceEvolutionChart, BalanceOverviewCard, LinkTransfersModal, SnapshotEditor, SnapshotLineRow) +│ │ ├── balance/ # 8 composants Bilan (AccountForm, BalanceAccountsTable, BalanceEvolutionChart, BalanceOnboardingCard, BalanceOverviewCard, LinkTransfersModal, SnapshotEditor, SnapshotLineRow) │ │ ├── budget/ # 5 composants │ │ ├── categories/ # 5 composants │ │ ├── dashboard/ # 2 composants diff --git a/src/components/balance/BalanceOnboardingCard.test.tsx b/src/components/balance/BalanceOnboardingCard.test.tsx new file mode 100644 index 0000000..a9aecd3 --- /dev/null +++ b/src/components/balance/BalanceOnboardingCard.test.tsx @@ -0,0 +1,41 @@ +// BalanceOnboardingCard — unit tests (issue #178) +// +// NOTE: This project does not have @testing-library/react or jsdom configured +// (logged as MEDIUM in autopilot decisions-log). Tests cover the pure +// `deriveOnboardingSteps` helper that drives the visual state of each step. +// All React rendering is bypassed. + +import { describe, it, expect } from "vitest"; +import { deriveOnboardingSteps } from "./BalanceOnboardingCard"; + +describe("BalanceOnboardingCard — deriveOnboardingSteps", () => { + it("0 accounts, 0 snapshots → step1 active, step2 disabled", () => { + const r = deriveOnboardingSteps(0, 0); + expect(r.step1).toBe("active"); + expect(r.step2).toBe("disabled"); + }); + + it(">=1 account, 0 snapshots → step1 done, step2 active", () => { + const r = deriveOnboardingSteps(1, 0); + expect(r.step1).toBe("done"); + expect(r.step2).toBe("active"); + + const r2 = deriveOnboardingSteps(5, 0); + expect(r2.step1).toBe("done"); + expect(r2.step2).toBe("active"); + }); + + it(">=1 account, >=1 snapshot → both done (defensive — card normally hidden)", () => { + const r = deriveOnboardingSteps(2, 3); + expect(r.step1).toBe("done"); + expect(r.step2).toBe("done"); + }); + + it("guard: 0 accounts but >=1 snapshot (anomaly) → step1 active, step2 done", () => { + // This combination should not happen in practice (a snapshot requires at + // least one account), but the helper handles it conservatively. + const r = deriveOnboardingSteps(0, 1); + expect(r.step1).toBe("active"); + expect(r.step2).toBe("done"); + }); +}); diff --git a/src/components/balance/BalanceOnboardingCard.tsx b/src/components/balance/BalanceOnboardingCard.tsx new file mode 100644 index 0000000..3f88402 --- /dev/null +++ b/src/components/balance/BalanceOnboardingCard.tsx @@ -0,0 +1,210 @@ +// BalanceOnboardingCard — empty-state onboarding for /balance. +// +// Issue #178. Replaces the BalanceOverviewCard when the user has no accounts +// or no snapshots yet. Two vertical steps: +// 1. Create an account → /balance/accounts +// 2. Enter a snapshot → /balance/snapshot +// +// Each step has 3 states: +// - "active": primary CTA, currently the next thing to do +// - "done": marked with a checkmark, no CTA +// - "disabled": grayed out (e.g. step 2 when 0 accounts), CTA disabled +// +// The whole card is replaced by BalanceOverviewCard once at least one +// snapshot exists, so step 2 in practice is rendered as "active" or +// "disabled"; the "done" branch is supported for completeness/tests. + +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import type { TFunction } from "i18next"; +import { Wallet, FileText, Check, ArrowRight } from "lucide-react"; + +interface BalanceOnboardingCardProps { + /** Number of active (non-archived) accounts. */ + accountsCount: number; + /** Number of snapshots saved (any date). */ + snapshotsCount: number; +} + +export type StepState = "active" | "done" | "disabled"; + +/** + * Pure helper exposed for unit tests — derives the state of each onboarding + * step from the (accountsCount, snapshotsCount) pair. + * + * - Step 1 is "done" once at least one account exists, "active" otherwise. + * - Step 2 is "done" once any snapshot exists, "active" once at least one + * account exists, "disabled" otherwise. In practice the parent guard on + * /balance only renders this card when snapshotsCount === 0, so the + * "done" branch for step 2 is mostly defensive. + */ +export function deriveOnboardingSteps( + accountsCount: number, + snapshotsCount: number +): { step1: StepState; step2: StepState } { + const step1: StepState = accountsCount >= 1 ? "done" : "active"; + const step2: StepState = + snapshotsCount >= 1 + ? "done" + : accountsCount >= 1 + ? "active" + : "disabled"; + return { step1, step2 }; +} + +export default function BalanceOnboardingCard({ + accountsCount, + snapshotsCount, +}: BalanceOnboardingCardProps) { + const { t } = useTranslation(); + + const { step1: step1State, step2: step2State } = deriveOnboardingSteps( + accountsCount, + snapshotsCount + ); + + return ( +
+

+ {t("balance.onboarding.title")} +

+

+ {t("balance.onboarding.subtitle")} +

+ +
    + } + title={t("balance.onboarding.step1.title")} + description={t("balance.onboarding.step1.description")} + ctaLabel={t("balance.onboarding.step1.cta")} + ctaHref="/balance/accounts" + t={t} + /> + } + title={t("balance.onboarding.step2.title")} + description={t("balance.onboarding.step2.description")} + ctaLabel={t("balance.onboarding.step2.cta")} + ctaHref="/balance/snapshot" + disabledHint={t("balance.onboarding.step2.disabledHint")} + t={t} + /> +
+
+ ); +} + +// ----------------------------------------------------------------------------- +// Internal — single step row +// ----------------------------------------------------------------------------- + +interface StepProps { + number: number; + state: StepState; + icon: React.ReactNode; + title: string; + description: string; + ctaLabel: string; + ctaHref: string; + disabledHint?: string; + t: TFunction; +} + +function Step({ + number, + state, + icon, + title, + description, + ctaLabel, + ctaHref, + disabledHint, + t, +}: StepProps) { + const isDone = state === "done"; + const isActive = state === "active"; + const isDisabled = state === "disabled"; + + // Number bubble: green check when done, primary bg when active, muted when disabled. + const bubbleClass = isDone + ? "bg-[var(--positive)] text-white" + : isActive + ? "bg-[var(--primary)] text-white" + : "bg-[var(--muted)] text-[var(--muted-foreground)]"; + + const titleClass = isDisabled + ? "text-[var(--muted-foreground)]" + : "text-[var(--foreground)]"; + + return ( +
  • + + +
    +
    + +

    {title}

    +
    +

    {description}

    + {isDisabled && disabledHint && ( +

    + {disabledHint} +

    + )} +
    + +
    + {isDone ? ( + + + {t("balance.onboarding.doneBadge")} + + ) : isActive ? ( + + {ctaLabel} + + + ) : ( + + )} +
    +
  • + ); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 16412cd..8f344e3 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1537,6 +1537,22 @@ "stacked": "Stacked by category" } }, + "onboarding": { + "title": "Get started with your balance sheet", + "subtitle": "Two steps to start tracking your net worth.", + "doneBadge": "Done", + "step1": { + "title": "Create an account", + "description": "An account is where you keep money: chequing, TFSA, RRSP, stocks, crypto, and so on.", + "cta": "Create an account" + }, + "step2": { + "title": "Enter a snapshot", + "description": "A snapshot is the picture, at a given date, of the balance in each account. Enter one a month to track changes over time.", + "cta": "Enter a snapshot", + "disabledHint": "Create an account first to unlock this step." + } + }, "sidebar": "Balance sheet", "accountsPage": { "title": "Balance accounts", @@ -1643,8 +1659,8 @@ "dateLabel": "Snapshot date", "dateImmutable": "An existing snapshot date cannot be changed. To change the date, delete this snapshot and create a new one.", "total": "Entered total", - "noAccounts": "You need to create at least one balance account first.", - "goToAccounts": "Go to accounts", + "noAccounts": "To enter a snapshot, first create at least one account. An account = where you keep money (chequing, TFSA, RRSP, stocks, etc.). A snapshot = the picture of how much was in each account on a given date.", + "goToAccounts": "Create an account", "prefill": "Prefill from previous", "prefillTooltip": "Copy values from the snapshot dated {{date}}", "prefillNoPrevious": "No earlier snapshot available.", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index f33f8d4..60606cd 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1537,6 +1537,22 @@ "stacked": "Empilé par catégorie" } }, + "onboarding": { + "title": "Premiers pas avec le bilan", + "subtitle": "Deux étapes pour commencer à suivre votre valeur nette.", + "doneBadge": "Fait", + "step1": { + "title": "Créer un compte", + "description": "Un compte représente l'endroit où vous tenez votre argent : compte chèque, CELI, REER, actions, crypto, etc.", + "cta": "Créer un compte" + }, + "step2": { + "title": "Saisir un snapshot", + "description": "Un snapshot est la photo, à une date donnée, du solde de chaque compte. Saisissez-en un par mois pour suivre l'évolution.", + "cta": "Saisir un snapshot", + "disabledHint": "Créez d'abord un compte pour activer cette étape." + } + }, "sidebar": "Bilan", "accountsPage": { "title": "Comptes du bilan", @@ -1643,8 +1659,8 @@ "dateLabel": "Date du snapshot", "dateImmutable": "La date d'un snapshot existant ne peut pas être modifiée. Pour changer la date, supprimez ce snapshot et créez-en un nouveau.", "total": "Total saisi", - "noAccounts": "Vous devez d'abord créer au moins un compte de bilan.", - "goToAccounts": "Aller aux comptes", + "noAccounts": "Pour saisir un snapshot, créez d'abord au moins un compte. Un compte = où vous tenez votre argent (chèque, CELI, REER, actions, etc.). Un snapshot = la photo de combien il y avait dans chaque compte à une date donnée.", + "goToAccounts": "Créer un compte", "prefill": "Pré-remplir depuis le précédent", "prefillTooltip": "Copier les valeurs du snapshot du {{date}}", "prefillNoPrevious": "Aucun snapshot antérieur disponible.", diff --git a/src/pages/BalancePage.tsx b/src/pages/BalancePage.tsx index 41e2904..5c24bdb 100644 --- a/src/pages/BalancePage.tsx +++ b/src/pages/BalancePage.tsx @@ -27,6 +27,7 @@ import { import { getAllCategories } from "../services/transactionService"; import type { Category, BalanceAccountTransferWithTransaction } from "../shared/types"; import BalanceOverviewCard from "../components/balance/BalanceOverviewCard"; +import BalanceOnboardingCard from "../components/balance/BalanceOnboardingCard"; import BalanceEvolutionChart from "../components/balance/BalanceEvolutionChart"; import BalanceAccountsTable from "../components/balance/BalanceAccountsTable"; import LinkTransfersModal from "../components/balance/LinkTransfersModal"; @@ -127,7 +128,25 @@ export default function BalancePage() { )}
    - + {(() => { + // Issue #178 — show a 2-step onboarding card while the user has no + // accounts or no snapshots yet. We probe accountsLatest for ANY + // snapshot date so the empty-state guard is independent of the + // active period filter (state.period). + const accountsCount = state.accountsLatest.length; + const hasAnySnapshot = state.accountsLatest.some( + (a) => a.latest_snapshot_date != null + ); + if (accountsCount === 0 || !hasAnySnapshot) { + return ( + + ); + } + return ; + })()}
    {/* Period selector */}