Merge PR #184: feat(balance) 2-step onboarding card on empty /balance (#178)

This commit is contained in:
le king fu 2026-05-02 15:32:01 -04:00
commit a9d1301dd2
8 changed files with 316 additions and 6 deletions

View file

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

View file

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

View file

@ -28,7 +28,7 @@ simpl-resultat/
├── src/ # Frontend React/TypeScript ├── src/ # Frontend React/TypeScript
│ ├── components/ # 58 composants organisés par domaine │ ├── components/ # 58 composants organisés par domaine
│ │ ├── adjustments/ # 3 composants │ │ ├── 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 │ │ ├── budget/ # 5 composants
│ │ ├── categories/ # 5 composants │ │ ├── categories/ # 5 composants
│ │ ├── dashboard/ # 2 composants │ │ ├── dashboard/ # 2 composants

View file

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

View file

@ -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 (
<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"
t={t}
/>
<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")}
t={t}
/>
</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;
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 (
<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

@ -1537,6 +1537,22 @@
"stacked": "Stacked by category" "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", "sidebar": "Balance sheet",
"accountsPage": { "accountsPage": {
"title": "Balance accounts", "title": "Balance accounts",
@ -1643,8 +1659,8 @@
"dateLabel": "Snapshot date", "dateLabel": "Snapshot date",
"dateImmutable": "An existing snapshot date cannot be changed. To change the date, delete this snapshot and create a new one.", "dateImmutable": "An existing snapshot date cannot be changed. To change the date, delete this snapshot and create a new one.",
"total": "Entered total", "total": "Entered total",
"noAccounts": "You need to create at least one balance account first.", "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": "Go to accounts", "goToAccounts": "Create an account",
"prefill": "Prefill from previous", "prefill": "Prefill from previous",
"prefillTooltip": "Copy values from the snapshot dated {{date}}", "prefillTooltip": "Copy values from the snapshot dated {{date}}",
"prefillNoPrevious": "No earlier snapshot available.", "prefillNoPrevious": "No earlier snapshot available.",

View file

@ -1537,6 +1537,22 @@
"stacked": "Empilé par catégorie" "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", "sidebar": "Bilan",
"accountsPage": { "accountsPage": {
"title": "Comptes du bilan", "title": "Comptes du bilan",
@ -1643,8 +1659,8 @@
"dateLabel": "Date du snapshot", "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.", "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", "total": "Total saisi",
"noAccounts": "Vous devez d'abord créer au moins un compte de bilan.", "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": "Aller aux comptes", "goToAccounts": "Créer un compte",
"prefill": "Pré-remplir depuis le précédent", "prefill": "Pré-remplir depuis le précédent",
"prefillTooltip": "Copier les valeurs du snapshot du {{date}}", "prefillTooltip": "Copier les valeurs du snapshot du {{date}}",
"prefillNoPrevious": "Aucun snapshot antérieur disponible.", "prefillNoPrevious": "Aucun snapshot antérieur disponible.",

View file

@ -27,6 +27,7 @@ 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";
@ -127,7 +128,25 @@ export default function BalancePage() {
)} )}
<div className="space-y-6"> <div className="space-y-6">
<BalanceOverviewCard totals={state.evolutionTotals} /> {(() => {
// 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 (
<BalanceOnboardingCard
accountsCount={accountsCount}
snapshotsCount={hasAnySnapshot ? 1 : 0}
/>
);
}
return <BalanceOverviewCard totals={state.evolutionTotals} />;
})()}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
{/* Period selector */} {/* Period selector */}