diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 2ced1e7..4ec2d50 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -2,6 +2,10 @@ ## [Non publié] +### Modifié + +- Bilan : la page d'accueil du bilan est désormais un hub de tuiles. Avoir des comptes mais aucun snapshot ne vous bloque plus sur une invite « créer un snapshot » — la page affiche maintenant des tuiles de navigation, et **Gérer les comptes** est accessible en tout temps, y compris depuis le tableau de bord peuplé. La page de gestion des comptes (créer/modifier/archiver les comptes et les types d'actif) n'était auparavant accessible qu'en tapant son URL (#244). + ### Sécurité - Mise à jour de dépendances pour corriger quatre advisories signalés par `npm audit` : `react-router-dom` 7.13 → 7.18.1 (ce qui tire `react-router` au-delà des advisories RCE turbo-stream, DoS `__manifest`/single-fetch et XSS de redirection RSC), plus l'outillage de développement `vite` 6.4.2 → 6.4.3 et `vitest` 4.0.18 → 4.1.9. Aucun changement de comportement. L'app est livrée comme un client de bureau local : les advisories runtime (qui nécessitent un serveur SSR/RSC react-router) ne s'appliquaient pas au produit livré ; ce sont des mises à jour de durcissement (#235, #236, #237, #238). diff --git a/CHANGELOG.md b/CHANGELOG.md index 9442e91..fa8ebf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Balance: the balance-sheet landing page is now a tile-based hub. Having accounts but no snapshot yet no longer strands you on a "create a snapshot" prompt — the page now shows navigation tiles, and **Manage accounts** is reachable at all times, including from the populated dashboard. The account-management page (create/edit/archive accounts and asset types) was previously only reachable by typing its URL (#244). + ### Security - Updated dependencies to clear four advisories reported by `npm audit`: `react-router-dom` 7.13 → 7.18.1 (which pulls `react-router` past the turbo-stream RCE, `__manifest`/single-fetch DoS, and RSC-redirect XSS advisories), plus the dev toolchain `vite` 6.4.2 → 6.4.3 and `vitest` 4.0.18 → 4.1.9. No behaviour change. The app ships as a local desktop client, so the runtime advisories (which require a react-router SSR/RSC server) did not apply to the shipped product; these are hardening bumps (#235, #236, #237, #238). diff --git a/src/components/balance/BalanceOnboardingCard.test.tsx b/src/components/balance/BalanceOnboardingCard.test.tsx deleted file mode 100644 index a9aecd3..0000000 --- a/src/components/balance/BalanceOnboardingCard.test.tsx +++ /dev/null @@ -1,41 +0,0 @@ -// 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 deleted file mode 100644 index ff987e9..0000000 --- a/src/components/balance/BalanceOnboardingCard.tsx +++ /dev/null @@ -1,206 +0,0 @@ -// 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 { 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" - /> - } - 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")} - /> -
-
- ); -} - -// ----------------------------------------------------------------------------- -// Internal — single step row -// ----------------------------------------------------------------------------- - -interface StepProps { - number: number; - state: StepState; - icon: React.ReactNode; - title: string; - description: string; - ctaLabel: string; - ctaHref: string; - disabledHint?: string; -} - -function Step({ - number, - state, - icon, - title, - description, - ctaLabel, - ctaHref, - disabledHint, -}: StepProps) { - const { t } = useTranslation(); - 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/components/balance/balanceLanding.test.ts b/src/components/balance/balanceLanding.test.ts new file mode 100644 index 0000000..adab304 --- /dev/null +++ b/src/components/balance/balanceLanding.test.ts @@ -0,0 +1,30 @@ +// balanceLanding — unit tests (issue #244) +// +// Covers the pure `deriveLandingState` helper that replaces the old boolean +// empty-state guard. No React rendering (this project has no jsdom configured); +// the decoupled-guard logic is exercised directly as a pure helper. + +import { describe, it, expect } from "vitest"; +import { deriveLandingState } from "./balanceLanding"; + +describe("balanceLanding — deriveLandingState", () => { + it("0 accounts → empty (regardless of snapshot flag)", () => { + expect(deriveLandingState(0, false)).toBe("empty"); + // Anomalous (a snapshot implies an account) but handled conservatively. + expect(deriveLandingState(0, true)).toBe("empty"); + }); + + it(">=1 account but no snapshot → accounts-no-snapshot (no longer 'empty')", () => { + expect(deriveLandingState(1, false)).toBe("accounts-no-snapshot"); + expect(deriveLandingState(5, false)).toBe("accounts-no-snapshot"); + }); + + it(">=1 account with a snapshot → populated", () => { + expect(deriveLandingState(1, true)).toBe("populated"); + expect(deriveLandingState(12, true)).toBe("populated"); + }); + + it("guards against negative counts → empty", () => { + expect(deriveLandingState(-1, true)).toBe("empty"); + }); +}); diff --git a/src/components/balance/balanceLanding.ts b/src/components/balance/balanceLanding.ts new file mode 100644 index 0000000..92d74c3 --- /dev/null +++ b/src/components/balance/balanceLanding.ts @@ -0,0 +1,37 @@ +// balanceLanding.ts — pure landing-state helper for the /balance page. +// +// Issue #244. Decouples the old boolean empty-state guard +// (`accountsCount === 0 || !hasAnySnapshot`) into three explicit states so the +// UI can stop conflating "no account yet" with "accounts but no snapshot yet": +// +// - "empty": no active account at all → offer account creation +// (StarterAccountsModal + a "Manage accounts" tile). +// - "accounts-no-snapshot": at least one account but no snapshot → offer BOTH +// "Manage accounts" and "New snapshot" tiles, without +// forcing the user toward the snapshot flow. +// - "populated": at least one account with at least one snapshot → +// render the full dashboard (overview + chart + +// accounts table) plus the navigation tiles. +// +// Extracted as a pure, DOM-free helper because this project has no +// @testing-library/react or jsdom configured — UI branching is unit-tested via +// pure helpers (same convention as `deriveOnboardingSteps` / `computeBalanceDateRange`). + +export type BalanceLandingState = "empty" | "accounts-no-snapshot" | "populated"; + +/** + * Derive the landing state from the two independent signals the balance page + * already computes: the number of active accounts and whether ANY snapshot + * exists (probed across accounts, independent of the active period filter). + * + * @param accountsCount number of active (non-archived) accounts + * @param hasAnySnapshot true when at least one account carries a snapshot date + */ +export function deriveLandingState( + accountsCount: number, + hasAnySnapshot: boolean +): BalanceLandingState { + if (accountsCount <= 0) return "empty"; + if (!hasAnySnapshot) return "accounts-no-snapshot"; + return "populated"; +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 2610644..d83eb97 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1579,20 +1579,26 @@ "byVehicle": "By envelope" } }, - "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" + "hub": { + "getStarted": "Get started", + "manage": "Manage", + "accounts": { + "title": "Manage accounts", + "description": "Create, edit, or archive your accounts and asset types." }, - "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." + "snapshot": { + "title": "New snapshot", + "description": "Record the balance of each account at a given date." + } + }, + "landing": { + "empty": { + "title": "Track your net worth", + "subtitle": "Start by creating an account: chequing, TFSA, RRSP, stocks, crypto, and so on." + }, + "noSnapshot": { + "title": "Take your first snapshot", + "subtitle": "Your accounts are ready. Enter a snapshot to start tracking your net worth over time — or keep managing your accounts." } }, "starters": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 5e8d2a2..8d43725 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1579,20 +1579,26 @@ "byVehicle": "Par enveloppe" } }, - "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" + "hub": { + "getStarted": "Commencer", + "manage": "Gérer", + "accounts": { + "title": "Gérer les comptes", + "description": "Créez, modifiez ou archivez vos comptes et vos types d'actif." }, - "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." + "snapshot": { + "title": "Nouveau snapshot", + "description": "Enregistrez le solde de chaque compte à une date donnée." + } + }, + "landing": { + "empty": { + "title": "Suivez votre valeur nette", + "subtitle": "Commencez par créer un compte : compte chèque, CELI, REER, actions, crypto, etc." + }, + "noSnapshot": { + "title": "Saisissez votre premier snapshot", + "subtitle": "Vos comptes sont prêts. Saisissez un snapshot pour suivre l'évolution de votre valeur nette dans le temps — ou continuez à gérer vos comptes." } }, "starters": { diff --git a/src/pages/BalancePage.tsx b/src/pages/BalancePage.tsx index f17565a..60a3b51 100644 --- a/src/pages/BalancePage.tsx +++ b/src/pages/BalancePage.tsx @@ -13,7 +13,7 @@ import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Wallet } from "lucide-react"; +import { Wallet, FilePlus } from "lucide-react"; import { useBalanceOverview, type BalancePeriod, @@ -30,12 +30,15 @@ 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"; import DetailAccountWizard from "../components/balance/DetailAccountWizard"; import StarterAccountsModal from "../components/balance/StarterAccountsModal"; +import HubReportNavCard, { + type HubReportNavCardProps, +} from "../components/reports/HubReportNavCard"; +import { deriveLandingState } from "../components/balance/balanceLanding"; import { getPreference, setPreference } from "../services/userPreferenceService"; import { renderCategoryLabelFromAccount } from "../utils/renderCategoryLabel"; @@ -195,25 +198,88 @@ export default function BalancePage() { )} - {/* Issue #178 — empty-state guard. We probe accountsLatest for ANY - snapshot date so the guard is independent of the active period - filter (state.period). When empty, we render only the onboarding - card — period selector, chart and accounts table would all show - empty states stacked under it (S2 from #187). */} + {/* Issue #244 — landing state. We probe accountsLatest for ANY snapshot + date (independent of the active period filter, state.period) and + derive one of three states via `deriveLandingState`. The old boolean + guard conflated "no account" with "accounts but no snapshot" and + stranded the latter on a snapshot-only onboarding card, leaving the + account-management page (/balance/accounts) unreachable. We now render + navigation tiles (HubReportNavCard, modeled on the Reports hub) so + account management is reachable in every state, and only the + "populated" state (>=1 account WITH a snapshot) renders the full + dashboard — otherwise period selector, chart and accounts table would + all stack empty states (S2 from #187). */} {(() => { const accountsCount = state.accountsLatest.length; const hasAnySnapshot = state.accountsLatest.some( (a) => a.latest_snapshot_date != null ); - const isEmpty = accountsCount === 0 || !hasAnySnapshot; + const landingState = deriveLandingState(accountsCount, hasAnySnapshot); - if (isEmpty) { + // Navigation tiles reused across states. "Manage accounts" is present + // everywhere so /balance/accounts is reachable at all times (#244); the + // snapshot tile is withheld in the empty state (nothing to snapshot). + const accountsTile: HubReportNavCardProps = { + to: "/balance/accounts", + icon: , + title: t("balance.hub.accounts.title"), + description: t("balance.hub.accounts.description"), + }; + const snapshotTile: HubReportNavCardProps = { + to: "/balance/snapshot", + icon: , + title: t("balance.hub.snapshot.title"), + description: t("balance.hub.snapshot.description"), + }; + const renderHub = ( + tiles: HubReportNavCardProps[], + headingKey: string + ) => ( +
    +

    + {t(headingKey)} +

    +
    + {tiles.map((card) => ( + + ))} +
    +
    + ); + + if (landingState === "empty") { + // No account yet. StarterAccountsModal auto-opens (one-shot, #179); + // once dismissed the "Manage accounts" tile keeps account creation + // reachable. The snapshot tile is withheld — nothing to snapshot yet. return (
    - +
    +

    + {t("balance.landing.empty.title")} +

    +

    + {t("balance.landing.empty.subtitle")} +

    +
    + {renderHub([accountsTile], "balance.hub.getStarted")} +
    + ); + } + + if (landingState === "accounts-no-snapshot") { + // Accounts exist but no snapshot. Offer BOTH paths (manage accounts + // OR enter a snapshot) without forcing the snapshot flow (#244). + return ( +
    +
    +

    + {t("balance.landing.noSnapshot.title")} +

    +

    + {t("balance.landing.noSnapshot.subtitle")} +

    +
    + {renderHub([accountsTile, snapshotTile], "balance.hub.getStarted")}
    ); } @@ -332,6 +398,8 @@ export default function BalancePage() { onDetailAccount={(acc) => setDetailTarget(acc)} /> + + {renderHub([accountsTile, snapshotTile], "balance.hub.manage")} ); })()}