feat(balance): tile-based landing hub + reachable account management
All checks were successful
PR Check / rust (pull_request) Successful in 25m34s
PR Check / frontend (pull_request) Successful in 2m30s

Decouple the /balance empty-state guard into three explicit states via a
new pure helper `deriveLandingState` (empty / accounts-no-snapshot /
populated). Having accounts but no snapshot no longer strands the user on a
snapshot-only onboarding card: each state now renders navigation tiles
(HubReportNavCard, modeled on the Reports hub), and "Manage accounts"
(/balance/accounts) is reachable in every state, including the populated
dashboard.

- Remove BalanceOnboardingCard (component + test) and the dead
  balance.onboarding.* i18n keys; add balanceLanding.ts + test.
- Add balance.hub.* and balance.landing.* keys in FR and EN.
- CHANGELOG (EN + FR) under [Unreleased].

Resolves #244
This commit is contained in:
le king fu 2026-07-04 17:43:43 -04:00
parent 110ad57e1b
commit 82550d6d38
9 changed files with 194 additions and 286 deletions

View file

@ -2,6 +2,10 @@
## [Non publié] ## [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é ### 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). - 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).

View file

@ -2,6 +2,10 @@
## [Unreleased] ## [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 ### 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). - 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).

View file

@ -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");
});
});

View file

@ -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 (
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
<h2 className="text-lg font-semibold mb-1">
{t("balance.onboarding.title")}
</h2>
<p className="text-sm text-[var(--muted-foreground)] mb-5">
{t("balance.onboarding.subtitle")}
</p>
<ol className="space-y-3">
<Step
number={1}
state={step1State}
icon={<Wallet size={18} />}
title={t("balance.onboarding.step1.title")}
description={t("balance.onboarding.step1.description")}
ctaLabel={t("balance.onboarding.step1.cta")}
ctaHref="/balance/accounts"
/>
<Step
number={2}
state={step2State}
icon={<FileText size={18} />}
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")}
/>
</ol>
</div>
);
}
// -----------------------------------------------------------------------------
// 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 (
<li
data-testid={`balance-onboarding-step-${number}`}
data-state={state}
className={`flex items-start gap-4 p-4 rounded-lg border ${
isDisabled
? "border-[var(--border)] opacity-60"
: "border-[var(--border)]"
}`}
>
<div
className={`shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold ${bubbleClass}`}
aria-hidden="true"
>
{isDone ? <Check size={16} /> : number}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-[var(--muted-foreground)]" aria-hidden="true">
{icon}
</span>
<h3 className={`text-sm font-semibold ${titleClass}`}>{title}</h3>
</div>
<p className="text-sm text-[var(--muted-foreground)]">{description}</p>
{isDisabled && disabledHint && (
<p className="text-xs text-[var(--muted-foreground)] italic mt-1">
{disabledHint}
</p>
)}
</div>
<div className="shrink-0 self-center">
{isDone ? (
<span
className="inline-flex items-center gap-1 text-xs text-[var(--positive)] font-medium"
data-testid={`balance-onboarding-step-${number}-done-badge`}
>
<Check size={14} />
{t("balance.onboarding.doneBadge")}
</span>
) : isActive ? (
<Link
to={ctaHref}
data-testid={`balance-onboarding-step-${number}-cta`}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90"
>
{ctaLabel}
<ArrowRight size={14} />
</Link>
) : (
<button
type="button"
disabled
data-testid={`balance-onboarding-step-${number}-cta`}
aria-disabled="true"
title={disabledHint}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-[var(--border)] text-[var(--muted-foreground)] text-sm font-medium cursor-not-allowed"
>
{ctaLabel}
<ArrowRight size={14} />
</button>
)}
</div>
</li>
);
}

View file

@ -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");
});
});

View file

@ -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";
}

View file

@ -1579,20 +1579,26 @@
"byVehicle": "By envelope" "byVehicle": "By envelope"
} }
}, },
"onboarding": { "hub": {
"title": "Get started with your balance sheet", "getStarted": "Get started",
"subtitle": "Two steps to start tracking your net worth.", "manage": "Manage",
"doneBadge": "Done", "accounts": {
"step1": { "title": "Manage accounts",
"title": "Create an account", "description": "Create, edit, or archive your accounts and asset types."
"description": "An account is where you keep money: chequing, TFSA, RRSP, stocks, crypto, and so on.",
"cta": "Create an account"
}, },
"step2": { "snapshot": {
"title": "Enter a snapshot", "title": "New 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.", "description": "Record the balance of each account at a given date."
"cta": "Enter a snapshot", }
"disabledHint": "Create an account first to unlock this step." },
"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": { "starters": {

View file

@ -1579,20 +1579,26 @@
"byVehicle": "Par enveloppe" "byVehicle": "Par enveloppe"
} }
}, },
"onboarding": { "hub": {
"title": "Premiers pas avec le bilan", "getStarted": "Commencer",
"subtitle": "Deux étapes pour commencer à suivre votre valeur nette.", "manage": "Gérer",
"doneBadge": "Fait", "accounts": {
"step1": { "title": "Gérer les comptes",
"title": "Créer un compte", "description": "Créez, modifiez ou archivez vos comptes et vos types d'actif."
"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": { "snapshot": {
"title": "Saisir un snapshot", "title": "Nouveau 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.", "description": "Enregistrez le solde de chaque compte à une date donnée."
"cta": "Saisir un snapshot", }
"disabledHint": "Créez d'abord un compte pour activer cette étape." },
"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": { "starters": {

View file

@ -13,7 +13,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Wallet } from "lucide-react"; import { Wallet, FilePlus } from "lucide-react";
import { import {
useBalanceOverview, useBalanceOverview,
type BalancePeriod, type BalancePeriod,
@ -30,12 +30,15 @@ import {
import { getAllCategories } from "../services/transactionService"; import { getAllCategories } from "../services/transactionService";
import type { Category, BalanceAccountTransferWithTransaction } from "../shared/types"; import type { Category, BalanceAccountTransferWithTransaction } from "../shared/types";
import BalanceOverviewCard from "../components/balance/BalanceOverviewCard"; import BalanceOverviewCard from "../components/balance/BalanceOverviewCard";
import BalanceOnboardingCard from "../components/balance/BalanceOnboardingCard";
import BalanceEvolutionChart from "../components/balance/BalanceEvolutionChart"; import BalanceEvolutionChart from "../components/balance/BalanceEvolutionChart";
import BalanceAccountsTable from "../components/balance/BalanceAccountsTable"; import BalanceAccountsTable from "../components/balance/BalanceAccountsTable";
import LinkTransfersModal from "../components/balance/LinkTransfersModal"; import LinkTransfersModal from "../components/balance/LinkTransfersModal";
import DetailAccountWizard from "../components/balance/DetailAccountWizard"; import DetailAccountWizard from "../components/balance/DetailAccountWizard";
import StarterAccountsModal from "../components/balance/StarterAccountsModal"; 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 { getPreference, setPreference } from "../services/userPreferenceService";
import { renderCategoryLabelFromAccount } from "../utils/renderCategoryLabel"; import { renderCategoryLabelFromAccount } from "../utils/renderCategoryLabel";
@ -195,25 +198,88 @@ export default function BalancePage() {
</div> </div>
)} )}
{/* Issue #178 empty-state guard. We probe accountsLatest for ANY {/* Issue #244 landing state. We probe accountsLatest for ANY snapshot
snapshot date so the guard is independent of the active period date (independent of the active period filter, state.period) and
filter (state.period). When empty, we render only the onboarding derive one of three states via `deriveLandingState`. The old boolean
card period selector, chart and accounts table would all show guard conflated "no account" with "accounts but no snapshot" and
empty states stacked under it (S2 from #187). */} 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 accountsCount = state.accountsLatest.length;
const hasAnySnapshot = state.accountsLatest.some( const hasAnySnapshot = state.accountsLatest.some(
(a) => a.latest_snapshot_date != null (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: <Wallet size={24} />,
title: t("balance.hub.accounts.title"),
description: t("balance.hub.accounts.description"),
};
const snapshotTile: HubReportNavCardProps = {
to: "/balance/snapshot",
icon: <FilePlus size={24} />,
title: t("balance.hub.snapshot.title"),
description: t("balance.hub.snapshot.description"),
};
const renderHub = (
tiles: HubReportNavCardProps[],
headingKey: string
) => (
<section>
<h2 className="text-sm font-semibold uppercase tracking-wide text-[var(--muted-foreground)] mb-3">
{t(headingKey)}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{tiles.map((card) => (
<HubReportNavCard key={card.to} {...card} />
))}
</div>
</section>
);
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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<BalanceOnboardingCard <div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
accountsCount={accountsCount} <h2 className="text-lg font-semibold mb-1">
snapshotsCount={hasAnySnapshot ? 1 : 0} {t("balance.landing.empty.title")}
/> </h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t("balance.landing.empty.subtitle")}
</p>
</div>
{renderHub([accountsTile], "balance.hub.getStarted")}
</div>
);
}
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 (
<div className="space-y-6">
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
<h2 className="text-lg font-semibold mb-1">
{t("balance.landing.noSnapshot.title")}
</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t("balance.landing.noSnapshot.subtitle")}
</p>
</div>
{renderHub([accountsTile, snapshotTile], "balance.hub.getStarted")}
</div> </div>
); );
} }
@ -332,6 +398,8 @@ export default function BalancePage() {
onDetailAccount={(acc) => setDetailTarget(acc)} onDetailAccount={(acc) => setDetailTarget(acc)}
/> />
</div> </div>
{renderHub([accountsTile, snapshotTile], "balance.hub.manage")}
</div> </div>
); );
})()} })()}