fix(balance): hide period selector, chart and table on empty /balance (S2)

Before this commit, /balance rendered the BalanceOnboardingCard plus the
period selector + evolution chart + accounts table whenever the user had
no accounts or no snapshot. The lower three components surfaced their
own empty states, producing 3 stacked "no data" messages under the
onboarding card.

Lifts the (accountsCount, hasAnySnapshot) computation out of the inline
IIFE and uses a single isEmpty branch: empty profiles see only the
BalanceOnboardingCard; populated profiles see the full overview.

No logic change — only JSX restructuring. Tests covering useBalanceOverview
and BalanceOnboardingCard remain green (61 tests passing).

Suggestion S2 from PR #184 review (#187).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-05-03 16:28:41 -04:00
parent 445822b792
commit 372a785834

View file

@ -173,96 +173,104 @@ export default function BalancePage() {
</div> </div>
)} )}
<div className="space-y-6"> {/* Issue #178 empty-state guard. We probe accountsLatest for ANY
{(() => { snapshot date so the guard is independent of the active period
// Issue #178 — show a 2-step onboarding card while the user has no filter (state.period). When empty, we render only the onboarding
// accounts or no snapshots yet. We probe accountsLatest for ANY card period selector, chart and accounts table would all show
// snapshot date so the empty-state guard is independent of the empty states stacked under it (S2 from #187). */}
// active period filter (state.period). {(() => {
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
); );
if (accountsCount === 0 || !hasAnySnapshot) { const isEmpty = accountsCount === 0 || !hasAnySnapshot;
return (
if (isEmpty) {
return (
<div className="space-y-6">
<BalanceOnboardingCard <BalanceOnboardingCard
accountsCount={accountsCount} accountsCount={accountsCount}
snapshotsCount={hasAnySnapshot ? 1 : 0} snapshotsCount={hasAnySnapshot ? 1 : 0}
/> />
); </div>
} );
return <BalanceOverviewCard totals={state.evolutionTotals} />; }
})()}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"> return (
{/* Period selector */} <div className="space-y-6">
<div <BalanceOverviewCard totals={state.evolutionTotals} />
role="group"
aria-label={t("balance.period.legend")} <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
className="inline-flex rounded-lg border border-[var(--border)] overflow-hidden" {/* Period selector */}
> <div
{PERIOD_OPTIONS.map((p) => ( role="group"
<button aria-label={t("balance.period.legend")}
key={p} className="inline-flex rounded-lg border border-[var(--border)] overflow-hidden"
type="button"
onClick={() => setPeriod(p)}
className={`px-3 py-1.5 text-sm font-medium ${
state.period === p
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] text-[var(--foreground)] hover:bg-[var(--muted)]/40"
}`}
aria-pressed={state.period === p}
> >
{t(`balance.period.${p}`)} {PERIOD_OPTIONS.map((p) => (
</button> <button
))} key={p}
</div> type="button"
onClick={() => setPeriod(p)}
className={`px-3 py-1.5 text-sm font-medium ${
state.period === p
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] text-[var(--foreground)] hover:bg-[var(--muted)]/40"
}`}
aria-pressed={state.period === p}
>
{t(`balance.period.${p}`)}
</button>
))}
</div>
{/* Chart mode toggle */} {/* Chart mode toggle */}
<div <div
role="group" role="group"
aria-label={t("balance.chart.modeLegend")} aria-label={t("balance.chart.modeLegend")}
className="inline-flex rounded-lg border border-[var(--border)] overflow-hidden" className="inline-flex rounded-lg border border-[var(--border)] overflow-hidden"
>
{(["line", "stacked"] as BalanceChartMode[]).map((mode) => (
<button
key={mode}
type="button"
onClick={() => setChartMode(mode)}
className={`px-3 py-1.5 text-sm font-medium ${
state.chartMode === mode
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] text-[var(--foreground)] hover:bg-[var(--muted)]/40"
}`}
aria-pressed={state.chartMode === mode}
> >
{t(`balance.chart.mode.${mode}`)} {(["line", "stacked"] as BalanceChartMode[]).map((mode) => (
</button> <button
))} key={mode}
type="button"
onClick={() => setChartMode(mode)}
className={`px-3 py-1.5 text-sm font-medium ${
state.chartMode === mode
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] text-[var(--foreground)] hover:bg-[var(--muted)]/40"
}`}
aria-pressed={state.chartMode === mode}
>
{t(`balance.chart.mode.${mode}`)}
</button>
))}
</div>
</div>
<BalanceEvolutionChart
mode={state.chartMode}
totals={state.evolutionTotals}
byCategory={state.evolutionByCategory}
categoryLabels={categoryLabels}
transferMarkers={allTransferMarkers}
/>
<div>
<h2 className="text-lg font-semibold mb-3">
{t("balance.overview.accountsTitle")}
</h2>
<BalanceAccountsTable
accounts={state.accountsLatest}
periodAnchor={state.accountsPeriodAnchor}
sinceCreationDate={earliestSnapshotDate}
onArchiveAccount={(acc) => handleArchiveAccount(acc.account_id)}
onLinkTransfers={(acc) => setLinkTarget(acc)}
/>
</div>
</div> </div>
</div> );
})()}
<BalanceEvolutionChart
mode={state.chartMode}
totals={state.evolutionTotals}
byCategory={state.evolutionByCategory}
categoryLabels={categoryLabels}
transferMarkers={allTransferMarkers}
/>
<div>
<h2 className="text-lg font-semibold mb-3">
{t("balance.overview.accountsTitle")}
</h2>
<BalanceAccountsTable
accounts={state.accountsLatest}
periodAnchor={state.accountsPeriodAnchor}
sinceCreationDate={earliestSnapshotDate}
onArchiveAccount={(acc) => handleArchiveAccount(acc.account_id)}
onLinkTransfers={(acc) => setLinkTarget(acc)}
/>
</div>
</div>
<StarterAccountsModal <StarterAccountsModal
isOpen={showStarterModal} isOpen={showStarterModal}