diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md
index 6e77ed0..9682ab5 100644
--- a/CHANGELOG.fr.md
+++ b/CHANGELOG.fr.md
@@ -6,6 +6,10 @@
- Rapports → Comparaison (réel vs réel) : le rapport comparable s'affiche désormais en **hiérarchie de catégories avec sous-totaux**, comme le rapport de budget. Les catégories parentes regroupent leurs enfants dans une ligne de sous-total — sa valeur est le net du groupe, donc un groupe de transferts équilibrés s'annule à ~0 — avec les lignes réparties en sections Dépenses / Revenus / Transferts portant des totaux par section et un total général, plus un choix sous-totaux en haut/en bas. Les montants des catégories feuilles sont inchangés, et l'annulation des transferts du correctif précédent est préservée (#247).
+### 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 10cd471..f66aad7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,10 @@
- Reports → Compare (real vs real): the comparable report is now shown as a **category hierarchy with subtotals**, like the budget report. Parent categories roll their children up into a subtotal row — its value is the group's net, so a group of balanced transfers subtotals to ~0 — with rows grouped into Expenses / Income / Transfers sections carrying per-section and grand totals, and a subtotals-on-top/bottom toggle. Leaf-category figures are unchanged, and the transfer netting from the previous fix is preserved (#247).
+### 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 (
-
- );
-}
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 055b277..ceb9c02 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -1587,20 +1587,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 511de40..b2ce8e6 100644
--- a/src/i18n/locales/fr.json
+++ b/src/i18n/locales/fr.json
@@ -1587,20 +1587,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 (
+ );
+ }
+
+ 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 (
+