feat(balance): 2-step onboarding card on empty /balance (#178) #184
8 changed files with 316 additions and 6 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
41
src/components/balance/BalanceOnboardingCard.test.tsx
Normal file
41
src/components/balance/BalanceOnboardingCard.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
210
src/components/balance/BalanceOnboardingCard.tsx
Normal file
210
src/components/balance/BalanceOnboardingCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
)}
|
||||
|
||||
<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">
|
||||
{/* Period selector */}
|
||||
|
|
|
|||
Loading…
Reference in a new issue