Compare commits

..

12 commits

Author SHA1 Message Date
le king fu
9124c888de Merge PR #252: feat(categories) editable migration target with type-ahead (#246) 2026-07-05 17:04:15 -04:00
le king fu
2a3cc07a0e Merge PR #251: feat(balance) CSV import of holdings in detailed snapshot (#245) 2026-07-05 17:03:00 -04:00
le king fu
ead62943fe Merge PR #250: feat(balance) tile-based landing hub + reachable account management (#244) 2026-07-05 17:01:20 -04:00
le king fu
8bd51214c1 Merge PR #249: feat(reports) hierarchical real-vs-real compare with subtotals (#247) 2026-07-05 16:59:17 -04:00
le king fu
a6fa89e041 Merge PR #248: fix(reports) net transfer-type categories in real-vs-real compare (#243) 2026-07-05 16:59:17 -04:00
le king fu
4f87ce329c polish(categories): honest doc + extract/test picker-options glue (#246 review)
All checks were successful
PR Check / rust (pull_request) Successful in 22m33s
PR Check / frontend (pull_request) Successful in 2m26s
Address the two non-blocking review notes on PR #252:
- The re-injected non-leaf parent (e.g. #1710) is technically clickable, not
  'never offered'; correct the MappingRow comment to state selecting it is a
  no-op (only leaves resolve).
- Extract the per-row option-building glue into a pure, unit-tested helper
  comboboxCategoriesForTarget (4 cases: null/leaf pass-through by reference,
  non-leaf append, unresolvable stale id).
2026-07-05 16:53:27 -04:00
le king fu
ce2793fc2c refactor(categories): migration target picker lists leaves only
All checks were successful
PR Check / rust (pull_request) Successful in 22m29s
PR Check / frontend (pull_request) Successful in 2m30s
The v1 target combobox in the migration preview now offers only leaf
categories (the inputable end-categories), not intermediate parents, so a
transaction is never mapped to a grouping bucket. Leaves render as a flat,
un-indented list in taxonomy order.

A low-confidence default can still point a v2 category at a non-leaf parent
(e.g. 'Divertissement' #1710); MappingRow re-injects that current target for
its single row via findTaxonomyCategory so the input stays populated, while
new picks remain leaves-only. Adds a findTaxonomyCategory pure helper + tests
(adapter now returns 112 leaves, not the 150-node full tree).
2026-07-04 20:40:29 -04:00
le king fu
e45736bbde feat(categories): editable migration target with type-ahead (#246)
All checks were successful
PR Check / rust (pull_request) Successful in 22m32s
PR Check / frontend (pull_request) Successful in 2m28s
Make the v1 target editable on EVERY row of the categories-migration
preview (StepSimulate), not only the "needs review" ones. Each row now
renders a reused CategoryCombobox (accent-insensitive type-ahead,
keyboard, hierarchical) in place of the read-only text / flat <select>.

A pure adapter (taxonomyToComboboxCategories) flattens the full v1
taxonomy into Category-shaped rows once in StepSimulate and passes it as
a prop to the rows. The full tree (not just leaves) is fed so mid-tree
default targets such as "Divertissement" (id 1710, the default for
"Jeux, Films & Livres") both display and can be re-picked. The target
cell stops click/keydown propagation so picker interaction never toggles
the row preview. Reducer/guard behavior unchanged: resolving a "none"
row still bumps confidence to medium, and the unresolved-count guard
stays functional.

Resolves #246
2026-07-04 18:17:10 -04:00
le king fu
c8c765e2f0 feat(balance): CSV import of holdings in detailed snapshot
All checks were successful
PR Check / rust (pull_request) Successful in 22m45s
PR Check / frontend (pull_request) Successful in 2m25s
A detailed (by-security) account can now import its positions from a CSV
instead of adding each security one by one. An "Import CSV" button next to
"Add a title" opens a native picker; the delimiter, encoding and columns
(symbol, quantity, optional price + book_cost) are auto-detected via new
csvAutoDetect helpers and adjustable in a small mapping editor. Rows sharing
a symbol are merged (SUM qty + book_cost, first price) and the batch is merged
into the basket by normalized symbol, so no UNIQUE(snapshot_line_id,
security_id) violation can occur at save. A CSV without a price column imports
quantities with an empty unit_price (fetch/type later) — the existing atomic
save path is unchanged (no SQL/Rust change).

- csvAutoDetect: autoDetectHoldingColumns + analyzeHoldingsCsv (+ tests)
- useSnapshotEditor: holdingsFromCsvRows + IMPORT_HOLDINGS action + importHoldings (+ tests)
- HoldingsCsvImportModal: file pick -> parse -> mapping editor + preview
- i18n FR/EN under balance.snapshot.detailed.importCsv.*
- CHANGELOG (Added / Ajouté)

Resolves #245

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 18:04:12 -04:00
le king fu
82550d6d38 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
2026-07-04 17:43:43 -04:00
le king fu
f3e8e94b16 feat(reports): hierarchical real-vs-real compare with subtotals
Render the real-vs-real comparable report as a parent/child category tree
with subtotal rows, mirroring the budget report — rows grouped into
expense/income/transfer sections with per-section and grand totals and a
subtotals-on-top/bottom toggle. The compare service now builds the tree on
top of the flat per-category deltas, so leaf-category figures are unchanged
and the #243 transfer netting is preserved (a balanced transfer group
subtotals to ~0, i.e. the group's net).

- reportService: buildCompareTree() synthesizes subtotal rows from the flat
  leaves + category metadata; getCompareMonthOverMonth/YoY return the tree
  (COMPARE_DELTA_SQL and rowsToDeltas untouched).
- CategoryDelta gains optional parent_id/is_parent/depth/category_type.
- ComparePeriodTable: sections, depth indentation, reorderRows toggle,
  section/grand net totals.
- Cartes top-movers and ComparePeriodChart filter to leaf rows only.
- reorderRows constraint loosened to the two fields it reads.
- i18n (FR+EN) section labels; CHANGELOG entries.

Resolves #247

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 17:32:38 -04:00
le king fu
4b000d022f fix(reports): net transfer-type categories in real-vs-real compare
All checks were successful
PR Check / rust (pull_request) Successful in 23m23s
PR Check / frontend (pull_request) Successful in 2m25s
Reports > Compare (real vs real) summed only outflows (SUM(ABS(amount))
filtered on amount < 0), so a `transfer` category like "Paiement CC" -
whose debit and matching credit should cancel - always showed the sum of
its debits instead of ~0.

Net transfer-type categories via a signed SUM(t.amount) while keeping the
expense behavior (ABS + amount < 0) for every other type, and broaden the
WHERE so transfer credits survive the expense filter. Both MoM and YoY now
share a single COMPARE_DELTA_SQL constant so they stay in sync. Real-vs-
budget already nets (signed SUM, no filter) and is unchanged.

Resolves #243

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 17:08:17 -04:00
27 changed files with 2648 additions and 475 deletions

View file

@ -2,11 +2,25 @@
## [Non publié] ## [Non publié]
### Ajouté
- 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).
- Bilan : un compte détaillé (par titre) peut désormais **importer ses positions depuis un CSV** au lieu d'ajouter chaque titre un à un. Depuis un snapshot, un bouton « Importer un CSV » à côté de « Ajouter un titre » ouvre un sélecteur de fichier ; le séparateur, l'encodage et les colonnes (symbole, quantité et — au besoin — cours et coût d'acquisition) sont détectés automatiquement et ajustables avant l'import. Les lignes partageant un symbole sont fusionnées en une seule position, et un CSV sans colonne de cours importe tout de même les quantités pour récupérer ou saisir le prix ensuite (#245).
### 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).
- Migration des catégories (Paramètres → Catégories → migrer vers le jeu standard) : la catégorie cible de l'étape d'aperçu est désormais modifiable sur **chaque** ligne, et non plus seulement celles « à réviser ». Chaque ligne offre un champ de recherche avec autocomplétion (insensible aux accents), pour corriger une cible détectée automatiquement aussi facilement que résoudre une ligne sans correspondance (#246).
### 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).
- Épinglé la dépendance transitive de build `@babel/core` en 7.29.7 via une entrée `overrides` scopée (sous `@vitejs/plugin-react`), corrigeant le dernier advisory `npm audit` (GHSA-4x5r-pxfx-6jf8, lecture de fichier arbitraire via un commentaire `sourceMappingURL`, sévérité faible). Outillage de build uniquement — aucun changement runtime ni de comportement — et `npm audit` est désormais propre (#241). - Épinglé la dépendance transitive de build `@babel/core` en 7.29.7 via une entrée `overrides` scopée (sous `@vitejs/plugin-react`), corrigeant le dernier advisory `npm audit` (GHSA-4x5r-pxfx-6jf8, lecture de fichier arbitraire via un commentaire `sourceMappingURL`, sévérité faible). Outillage de build uniquement — aucun changement runtime ni de comportement — et `npm audit` est désormais propre (#241).
### Corrigé
- Rapports → Comparaison (réel vs réel) : les catégories de type transfert (ex. « Paiement CC ») s'annulent maintenant à ~0 au lieu d'afficher seulement leurs sorties. Un transfert est un mouvement d'argent, pas une dépense — son débit et son crédit correspondant sur la même période se compensent. Les catégories de dépense et de revenu conservent exactement leurs montants précédents (#243).
## [0.10.1] - 2026-06-30 ## [0.10.1] - 2026-06-30
### Corrigé ### Corrigé

View file

@ -2,11 +2,25 @@
## [Unreleased] ## [Unreleased]
### Added
- 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).
- Balance: a detailed (by-security) account can now **import its positions from a CSV** instead of adding each security one by one. From a snapshot, an "Import CSV" button next to "Add a title" opens a file picker; the delimiter, encoding, and columns (symbol, quantity, and — optionally — price and cost basis) are auto-detected and can be adjusted before importing. Rows that share a symbol are merged into one position, and a CSV without a price column still imports the quantities so the price can be fetched or typed afterwards (#245).
### 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).
- Categories migration (Settings → Categories → migrate to the standard set): the target category on the preview step is now editable on **every** row, not only the "needs review" ones. Each row carries a type-ahead picker (accent-insensitive search) so you can override an auto-detected target as easily as resolving an unmatched one (#246).
### 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).
- Pinned the build-time transitive dependency `@babel/core` to 7.29.7 through a scoped `overrides` entry (under `@vitejs/plugin-react`), clearing the last `npm audit` advisory (GHSA-4x5r-pxfx-6jf8, arbitrary file read via a `sourceMappingURL` comment, low severity). Build tooling only — no runtime or behaviour change — and `npm audit` is now clean (#241). - Pinned the build-time transitive dependency `@babel/core` to 7.29.7 through a scoped `overrides` entry (under `@vitejs/plugin-react`), clearing the last `npm audit` advisory (GHSA-4x5r-pxfx-6jf8, arbitrary file read via a `sourceMappingURL` comment, low severity). Build tooling only — no runtime or behaviour change — and `npm audit` is now clean (#241).
### Fixed
- Reports → Compare (real vs real): transfer-type categories (e.g. "Paiement CC") now net to ~0 instead of showing only their outflows. A transfer is a money move, not spending — its debit and matching credit over the same period cancel out. Expense and income categories keep exactly their previous amounts (#243).
## [0.10.1] - 2026-06-30 ## [0.10.1] - 2026-06-30
### Fixed ### Fixed

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,351 @@
// HoldingsCsvImportModal — bulk-import a detailed account's positions from a
// CSV instead of typing them one by one (Issue #245).
//
// Flow (all frontend — the save path is unchanged):
// 1. pick a CSV file (native dialog, reusing the existing `pick_import_file`
// Rust command with a CSV filter);
// 2. detect its encoding (`detect_encoding`, utf-8 fallback) + read it
// (`read_file_content`);
// 3. `analyzeHoldingsCsv` preprocesses, detects the delimiter/header and the
// symbol / quantity / unit_price / book_cost columns (flexible price
// detection: no price column ⇒ mapping stays null);
// 4. the user reviews/adjusts the mapping in a small editor + preview;
// 5. on confirm we hand the DATA rows + mapping up to the editor hook, which
// builds `HoldingDraft`s (FR numbers, duplicate-symbol merge) and merges
// them into the account's basket. The user then fetches/types any missing
// price and saves through the normal atomic path.
//
// The component owns the file/parse lifecycle; the actual draft building and
// basket merge live in `useSnapshotEditor` so they stay pure + unit-tested.
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { invoke } from "@tauri-apps/api/core";
import { Upload, X, AlertTriangle, FileSpreadsheet } from "lucide-react";
import {
analyzeHoldingsCsv,
type HoldingColumnMapping,
type HoldingCsvAnalysis,
} from "../../utils/csvAutoDetect";
import { normalizeSecuritySymbol } from "../../services/balance.service";
interface Props {
accountName: string;
/** Called with the parsed DATA rows + the confirmed column mapping. */
onImport: (rows: string[][], mapping: HoldingColumnMapping) => void;
onClose: () => void;
}
type Step = "select" | "loading" | "map" | "error";
const PREVIEW_ROWS = 5;
export default function HoldingsCsvImportModal({
accountName,
onImport,
onClose,
}: Props) {
const { t } = useTranslation();
const [step, setStep] = useState<Step>("select");
const [analysis, setAnalysis] = useState<HoldingCsvAnalysis | null>(null);
const [mapping, setMapping] = useState<HoldingColumnMapping | null>(null);
const [errorMsg, setErrorMsg] = useState<string>("");
const pickAndAnalyze = useCallback(async () => {
setStep("loading");
setErrorMsg("");
try {
const filePath = await invoke<string | null>("pick_import_file", {
filters: [["CSV", ["csv", "txt"]]],
});
if (!filePath) {
// User cancelled the native dialog — return to the select step.
setStep("select");
return;
}
let encoding = "utf-8";
try {
encoding = await invoke<string>("detect_encoding", {
filePath,
});
} catch {
// fall back to utf-8
}
const content = await invoke<string>("read_file_content", {
filePath,
encoding,
});
const result = analyzeHoldingsCsv(content);
if (!result) {
setErrorMsg(t("balance.snapshot.detailed.importCsv.detectError"));
setStep("error");
return;
}
setAnalysis(result);
setMapping(result.mapping);
setStep("map");
} catch (e) {
setErrorMsg(
e instanceof Error
? e.message
: t("balance.snapshot.detailed.importCsv.readError")
);
setStep("error");
}
}, [t]);
const columnOptions = useMemo(() => {
if (!analysis) return null;
return analysis.headers.map((h, i) => (
<option key={i} value={i}>
{i}: {h}
</option>
));
}, [analysis]);
const preview = useMemo(() => {
if (!analysis || !mapping) return [];
return analysis.rows.slice(0, PREVIEW_ROWS).map((row) => {
const symbol = normalizeSecuritySymbol((row[mapping.symbol] ?? "").trim());
const qty = (row[mapping.quantity] ?? "").trim();
const price =
mapping.unit_price !== null
? (row[mapping.unit_price] ?? "").trim()
: "";
const book =
mapping.book_cost !== null ? (row[mapping.book_cost] ?? "").trim() : "";
return { symbol, qty, price, book };
});
}, [analysis, mapping]);
// Count the distinct, non-blank symbols that would be imported (after merge).
const importableCount = useMemo(() => {
if (!analysis || !mapping) return 0;
const seen = new Set<string>();
for (const row of analysis.rows) {
const s = normalizeSecuritySymbol((row[mapping.symbol] ?? "").trim());
if (s) seen.add(s);
}
return seen.size;
}, [analysis, mapping]);
const handleImport = () => {
if (!analysis || !mapping) return;
onImport(analysis.rows, mapping);
onClose();
};
const selectClass =
"w-full px-3 py-2 text-sm rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]";
const setMappingField = (
field: keyof HoldingColumnMapping,
raw: string
) => {
if (!mapping) return;
const parsed = parseInt(raw, 10);
const next: number | null =
Number.isNaN(parsed) || parsed < 0 ? null : parsed;
setMapping({ ...mapping, [field]: next });
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-xl max-w-lg w-full p-6 max-h-[90vh] overflow-y-auto">
<div className="flex items-start justify-between gap-3 mb-4">
<div className="flex items-center gap-2 min-w-0">
<FileSpreadsheet size={20} className="text-[var(--primary)]" />
<h2 className="text-lg font-semibold truncate">
{t("balance.snapshot.detailed.importCsv.title")}
</h2>
</div>
<button
type="button"
onClick={onClose}
className="p-1 rounded text-[var(--muted-foreground)] hover:bg-[var(--muted)]"
aria-label={t("common.cancel")}
>
<X size={18} />
</button>
</div>
<p className="text-sm text-[var(--muted-foreground)] mb-4">
{t("balance.snapshot.detailed.importCsv.intro", {
account: accountName,
})}
</p>
{(step === "select" || step === "loading") && (
<div className="flex flex-col items-center gap-3 py-6">
<button
type="button"
onClick={pickAndAnalyze}
disabled={step === "loading"}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
>
<Upload size={16} />
{step === "loading"
? t("balance.snapshot.detailed.importCsv.loading")
: t("balance.snapshot.detailed.importCsv.selectFile")}
</button>
<p className="text-xs text-[var(--muted-foreground)] text-center">
{t("balance.snapshot.detailed.importCsv.hint")}
</p>
</div>
)}
{step === "error" && (
<div className="py-4">
<div className="flex items-start gap-2 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20">
<AlertTriangle size={16} className="mt-0.5 shrink-0" />
<span>{errorMsg}</span>
</div>
<div className="flex justify-end mt-4">
<button
type="button"
onClick={() => setStep("select")}
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)]"
>
{t("balance.snapshot.detailed.importCsv.retry")}
</button>
</div>
</div>
)}
{step === "map" && analysis && mapping && (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-[var(--muted-foreground)] mb-1">
{t("balance.snapshot.detailed.importCsv.symbolColumn")}
</label>
<select
value={mapping.symbol}
onChange={(e) => setMappingField("symbol", e.target.value)}
className={selectClass}
>
{columnOptions}
</select>
</div>
<div>
<label className="block text-xs font-medium text-[var(--muted-foreground)] mb-1">
{t("balance.snapshot.detailed.importCsv.quantityColumn")}
</label>
<select
value={mapping.quantity}
onChange={(e) => setMappingField("quantity", e.target.value)}
className={selectClass}
>
{columnOptions}
</select>
</div>
<div>
<label className="block text-xs font-medium text-[var(--muted-foreground)] mb-1">
{t("balance.snapshot.detailed.importCsv.priceColumn")}
</label>
<select
value={mapping.unit_price ?? -1}
onChange={(e) => setMappingField("unit_price", e.target.value)}
className={selectClass}
>
<option value={-1}>
{t("balance.snapshot.detailed.importCsv.noColumn")}
</option>
{columnOptions}
</select>
</div>
<div>
<label className="block text-xs font-medium text-[var(--muted-foreground)] mb-1">
{t("balance.snapshot.detailed.importCsv.bookCostColumn")}
</label>
<select
value={mapping.book_cost ?? -1}
onChange={(e) => setMappingField("book_cost", e.target.value)}
className={selectClass}
>
<option value={-1}>
{t("balance.snapshot.detailed.importCsv.noColumn")}
</option>
{columnOptions}
</select>
</div>
</div>
{mapping.unit_price === null && (
<p className="text-xs text-[var(--muted-foreground)] italic">
{t("balance.snapshot.detailed.importCsv.noPriceNote")}
</p>
)}
{/* Preview of the first rows as they will be imported. */}
<div className="rounded-lg border border-[var(--border)] overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="bg-[var(--muted)] text-left">
<th className="px-2 py-1.5 font-medium">
{t("balance.snapshot.detailed.col.title")}
</th>
<th className="px-2 py-1.5 font-medium text-right">
{t("balance.snapshot.detailed.col.quantity")}
</th>
<th className="px-2 py-1.5 font-medium text-right">
{t("balance.snapshot.detailed.col.unitPrice")}
</th>
<th className="px-2 py-1.5 font-medium text-right">
{t("balance.snapshot.detailed.col.bookCost")}
</th>
</tr>
</thead>
<tbody>
{preview.map((r, i) => (
<tr
key={i}
className="border-t border-[var(--border)] tabular-nums"
>
<td className="px-2 py-1.5 font-medium">
{r.symbol || "—"}
</td>
<td className="px-2 py-1.5 text-right">{r.qty || "—"}</td>
<td className="px-2 py-1.5 text-right">
{r.price || "—"}
</td>
<td className="px-2 py-1.5 text-right">{r.book || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-xs text-[var(--muted-foreground)]">
{t("balance.snapshot.detailed.importCsv.summary", {
count: importableCount,
})}
</p>
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)]"
>
{t("common.cancel")}
</button>
<button
type="button"
onClick={handleImport}
disabled={importableCount === 0}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
>
<Upload size={14} />
{t("balance.snapshot.detailed.importCsv.import")}
</button>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -14,6 +14,7 @@ import type {
BalanceSecurity, BalanceSecurity,
} from "../../shared/types"; } from "../../shared/types";
import type { HoldingDraft } from "../../hooks/useSnapshotEditor"; import type { HoldingDraft } from "../../hooks/useSnapshotEditor";
import type { HoldingColumnMapping } from "../../utils/csvAutoDetect";
import type { SecurityPick } from "./SecurityPicker"; import type { SecurityPick } from "./SecurityPicker";
import SnapshotLineRow from "./SnapshotLineRow"; import SnapshotLineRow from "./SnapshotLineRow";
import { renderCategoryLabelFromCategory } from "../../utils/renderCategoryLabel"; import { renderCategoryLabelFromCategory } from "../../utils/renderCategoryLabel";
@ -42,6 +43,12 @@ interface Props {
rowId: string, rowId: string,
pick: SecurityPick pick: SecurityPick
) => void; ) => void;
/** Import a batch of holdings from a CSV into a detailed account (#245). */
onImportHoldings: (
accountId: number,
rows: string[][],
mapping: HoldingColumnMapping
) => void;
disabled?: boolean; disabled?: boolean;
/** Snapshot date (YYYY-MM-DD) — forwarded to PriceFetchControl (Issue #158). */ /** Snapshot date (YYYY-MM-DD) — forwarded to PriceFetchControl (Issue #158). */
snapshotDate?: string; snapshotDate?: string;
@ -58,6 +65,7 @@ export default function SnapshotEditor({
onRemoveHolding, onRemoveHolding,
onHoldingFieldChange, onHoldingFieldChange,
onHoldingSecurityPick, onHoldingSecurityPick,
onImportHoldings,
disabled, disabled,
snapshotDate, snapshotDate,
}: Props) { }: Props) {
@ -124,6 +132,9 @@ export default function SnapshotEditor({
onHoldingSecurityPick={(rowId, pick) => onHoldingSecurityPick={(rowId, pick) =>
onHoldingSecurityPick(acc.id, rowId, pick) onHoldingSecurityPick(acc.id, rowId, pick)
} }
onImportHoldings={(rows, mapping) =>
onImportHoldings(acc.id, rows, mapping)
}
disabled={disabled} disabled={disabled}
snapshotDate={snapshotDate} snapshotDate={snapshotDate}
/> />

View file

@ -25,16 +25,18 @@
// strings on every change. Numeric validation happens at save time in // strings on every change. Numeric validation happens at save time in
// `useSnapshotEditor.save`. // `useSnapshotEditor.save`.
import { ChangeEvent, useMemo } from "react"; import { ChangeEvent, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Plus, Trash2 } from "lucide-react"; import { Plus, Trash2, Upload } from "lucide-react";
import type { import type {
BalanceAccountWithCategory, BalanceAccountWithCategory,
BalanceSecurity, BalanceSecurity,
} from "../../shared/types"; } from "../../shared/types";
import type { HoldingDraft } from "../../hooks/useSnapshotEditor"; import type { HoldingDraft } from "../../hooks/useSnapshotEditor";
import type { HoldingColumnMapping } from "../../utils/csvAutoDetect";
import PriceFetchControl from "./PriceFetchControl"; import PriceFetchControl from "./PriceFetchControl";
import SecurityPicker, { type SecurityPick } from "./SecurityPicker"; import SecurityPicker, { type SecurityPick } from "./SecurityPicker";
import HoldingsCsvImportModal from "./HoldingsCsvImportModal";
interface Props { interface Props {
account: BalanceAccountWithCategory; account: BalanceAccountWithCategory;
@ -57,6 +59,8 @@ interface Props {
) => void; ) => void;
/** Apply a SecurityPicker selection to a row (symbol + asset_type + name). */ /** Apply a SecurityPicker selection to a row (symbol + asset_type + name). */
onHoldingSecurityPick?: (rowId: string, pick: SecurityPick) => void; onHoldingSecurityPick?: (rowId: string, pick: SecurityPick) => void;
/** Import a batch of holdings from a CSV into this detailed account (#245). */
onImportHoldings?: (rows: string[][], mapping: HoldingColumnMapping) => void;
} }
/** /**
@ -84,9 +88,11 @@ export default function SnapshotLineRow({
onRemoveHolding, onRemoveHolding,
onHoldingFieldChange, onHoldingFieldChange,
onHoldingSecurityPick, onHoldingSecurityPick,
onImportHoldings,
}: Props) { }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const isDetailed = account.kind === "detailed"; const isDetailed = account.kind === "detailed";
const [importOpen, setImportOpen] = useState(false);
// Account total across the basket (live as the user types). // Account total across the basket (live as the user types).
const detailedTotal = useMemo(() => { const detailedTotal = useMemo(() => {
@ -152,15 +158,36 @@ export default function SnapshotLineRow({
</div> </div>
)} )}
<button <div className="mt-2 flex items-center gap-4">
type="button" <button
onClick={() => onAddHolding?.()} type="button"
disabled={disabled} onClick={() => onAddHolding?.()}
className="mt-2 inline-flex items-center gap-1 text-xs text-[var(--primary)] hover:underline disabled:opacity-50" disabled={disabled}
> className="inline-flex items-center gap-1 text-xs text-[var(--primary)] hover:underline disabled:opacity-50"
<Plus size={13} /> >
{t("balance.snapshot.detailed.addTitle")} <Plus size={13} />
</button> {t("balance.snapshot.detailed.addTitle")}
</button>
{onImportHoldings && (
<button
type="button"
onClick={() => setImportOpen(true)}
disabled={disabled}
className="inline-flex items-center gap-1 text-xs text-[var(--primary)] hover:underline disabled:opacity-50"
>
<Upload size={13} />
{t("balance.snapshot.detailed.importCsv.button")}
</button>
)}
</div>
{importOpen && onImportHoldings && (
<HoldingsCsvImportModal
accountName={account.name}
onImport={onImportHoldings}
onClose={() => setImportOpen(false)}
/>
)}
</div> </div>
); );
} }

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

@ -1,6 +1,8 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useCategoryTaxonomy } from "../../hooks/useCategoryTaxonomy"; import CategoryCombobox from "../shared/CategoryCombobox";
import { comboboxCategoriesForTarget } from "./migrationTargets";
import type { Category } from "../../shared/types";
import type { import type {
MappingRow as MappingRowType, MappingRow as MappingRowType,
ConfidenceBadge, ConfidenceBadge,
@ -13,13 +15,27 @@ interface MappingRowProps {
/** Callback fired when the row is clicked — opens the preview panel. */ /** Callback fired when the row is clicked — opens the preview panel. */
onSelect: (v2CategoryId: number) => void; onSelect: (v2CategoryId: number) => void;
/** /**
* Called with the new v1 target id + name when the user resolves the row * Called with the new v1 target id + name when the user picks a target via
* via the inline dropdown. The dropdown is only rendered for unresolved * the inline type-ahead combobox. Editable on EVERY row (resolved or not),
* ("🟠 needs review") rows resolved rows just show the target name. * so a user can override an auto-detected target as well as resolve a
* "needs review" one.
*/ */
onResolve: (v2CategoryId: number, v1TargetId: number, v1TargetName: string) => void; onResolve: (v2CategoryId: number, v1TargetId: number, v1TargetName: string) => void;
/** Number of transactions currently attached to this v2 category. */ /** Number of transactions currently attached to this v2 category. */
transactionCount: number; transactionCount: number;
/**
* v1 LEAF catalogue adapted to the `Category` shape the combobox expects.
* Computed ONCE by StepSimulate and shared across rows (perf). Leaves only
* a transaction is never mapped to a grouping bucket.
*/
targetCategories: Category[];
/**
* Resolves a target id (possibly a non-leaf parent absent from the leaves-only
* `targetCategories`) to a display `Category`. Used to keep a low-confidence
* parent default like "Divertissement" (#1710) visible on its row without
* offering parents as new picks.
*/
resolveTarget: (id: number) => Category | null;
} }
function badgeClass(confidence: ConfidenceBadge): string { function badgeClass(confidence: ConfidenceBadge): string {
@ -41,14 +57,21 @@ export default function MappingRow({
onSelect, onSelect,
onResolve, onResolve,
transactionCount, transactionCount,
targetCategories,
resolveTarget,
}: MappingRowProps) { }: MappingRowProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { getLeaves } = useCategoryTaxonomy();
// For the resolve dropdown: all v1 leaves (terminal categories). We keep the // The picker lists leaves only. If this row's current target is a non-leaf
// list flat because the simulate row is narrow; the search box in step 2 // parent (a low-confidence default like "Divertissement" #1710), it is absent
// already helps users find a target by keyword. // from that list and would blank the combobox input, so re-inject it for this
const v1Leaves = useMemo(() => getLeaves(), [getLeaves]); // row so it stays visible. It remains clickable, but selecting it is a no-op —
// handleTargetChange only resolves ids present in `targetCategories` (leaves) —
// so any pick that takes effect is a leaf. See comboboxCategoriesForTarget.
const comboboxCategories = useMemo(
() => comboboxCategoriesForTarget(targetCategories, row.v1TargetId ?? null, resolveTarget),
[targetCategories, row.v1TargetId, resolveTarget],
);
const badgeLabel = t( const badgeLabel = t(
`categoriesSeed.migration.simulate.confidence.${row.confidence}`, `categoriesSeed.migration.simulate.confidence.${row.confidence}`,
@ -59,12 +82,18 @@ export default function MappingRow({
const isUnresolved = row.v1TargetId === null || row.v1TargetId === undefined; const isUnresolved = row.v1TargetId === null || row.v1TargetId === undefined;
const handleResolveChange = (ev: React.ChangeEvent<HTMLSelectElement>) => { // Fired when the user picks a v1 leaf in the type-ahead combobox. The
const v1TargetId = Number(ev.target.value); // combobox only emits ids that exist in `targetCategories` (never null with
if (!Number.isFinite(v1TargetId) || v1TargetId <= 0) return; // our config), but we guard defensively. Resolving a "none" row bumps its
const leaf = v1Leaves.find((l) => l.id === v1TargetId); // confidence to "medium" via the reducer; editing an already-resolved row
if (!leaf) return; // keeps its confidence unchanged (see RESOLVE_ROW).
const name = t(leaf.i18n_key, { defaultValue: leaf.name }); const handleTargetChange = (v1TargetId: number | null) => {
if (v1TargetId === null) return;
const target = targetCategories.find((c) => c.id === v1TargetId);
if (!target) return;
const name = target.i18n_key
? t(target.i18n_key, { defaultValue: target.name })
: target.name;
onResolve(row.v2CategoryId, v1TargetId, name); onResolve(row.v2CategoryId, v1TargetId, name);
}; };
@ -119,32 +148,26 @@ export default function MappingRow({
</span> </span>
</div> </div>
{/* v1 target (or picker) */} {/* v1 target editable type-ahead combobox on EVERY row. Stop click +
keydown from bubbling so interacting with the picker (typing spaces,
Enter to select) never triggers the row's select/preview handler. */}
<div <div
className="col-span-5 flex items-center justify-end gap-2 min-w-0" className="col-span-5 flex items-center justify-end min-w-0"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
> >
{isUnresolved ? ( <div className="w-full max-w-[16rem]">
<select <CategoryCombobox
value="" categories={comboboxCategories}
onChange={handleResolveChange} value={row.v1TargetId ?? null}
aria-label={t("categoriesSeed.migration.simulate.chooseTarget")} onChange={handleTargetChange}
className="max-w-full truncate rounded-md border border-[var(--border)] bg-[var(--background)] px-2 py-1 text-sm text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/30" compact
> placeholder={t("categoriesSeed.migration.simulate.chooseTarget")}
<option value="" disabled> ariaLabel={t("categoriesSeed.migration.simulate.editTargetAria", {
{t("categoriesSeed.migration.simulate.chooseTarget")} category: row.v2CategoryName,
</option> })}
{v1Leaves.map((leaf) => ( />
<option key={leaf.id} value={leaf.id}> </div>
{t(leaf.i18n_key, { defaultValue: leaf.name })}
</option>
))}
</select>
) : (
<span className="truncate text-[var(--foreground)]">
{targetDisplayName}
</span>
)}
</div> </div>
</div> </div>
); );

View file

@ -1,8 +1,13 @@
import { useMemo } from "react"; import { useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ArrowLeft, ArrowRight, AlertTriangle, FolderHeart } from "lucide-react"; import { ArrowLeft, ArrowRight, AlertTriangle, FolderHeart } from "lucide-react";
import MappingRow from "./MappingRow"; import MappingRow from "./MappingRow";
import TransactionPreviewPanel from "./TransactionPreviewPanel"; import TransactionPreviewPanel from "./TransactionPreviewPanel";
import {
taxonomyToComboboxCategories,
findTaxonomyCategory,
} from "./migrationTargets";
import { useCategoryTaxonomy } from "../../hooks/useCategoryTaxonomy";
import type { import type {
MigrationPlan, MigrationPlan,
MappingRow as MappingRowType, MappingRow as MappingRowType,
@ -36,6 +41,24 @@ export default function StepSimulate({
onBack, onBack,
}: StepSimulateProps) { }: StepSimulateProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { taxonomy } = useCategoryTaxonomy();
// v1 LEAF catalogue adapted to the combobox `Category` shape. Computed once
// here (not per row) and shared across every MappingRow's target picker —
// only leaves are offered, so a transaction is never mapped to a grouping
// bucket.
const targetCategories = useMemo(
() => taxonomyToComboboxCategories(taxonomy.roots),
[taxonomy],
);
// Resolve a target id (possibly a non-leaf parent absent from the leaves-only
// list) to a display Category, so a row keeps showing a low-confidence parent
// default like "Divertissement" (#1710) without offering parents as picks.
const resolveTarget = useCallback(
(id: number) => findTaxonomyCategory(taxonomy.roots, id),
[taxonomy],
);
const selectedRow = useMemo<MappingRowType | null>(() => { const selectedRow = useMemo<MappingRowType | null>(() => {
if (selectedRowV2Id === null) return null; if (selectedRowV2Id === null) return null;
@ -149,6 +172,8 @@ export default function StepSimulate({
transactionCount={ transactionCount={
transactionCountByV2Id.get(row.v2CategoryId) ?? 0 transactionCountByV2Id.get(row.v2CategoryId) ?? 0
} }
targetCategories={targetCategories}
resolveTarget={resolveTarget}
/> />
</li> </li>
))} ))}

View file

@ -0,0 +1,177 @@
import { describe, it, expect } from "vitest";
import {
taxonomyToComboboxCategories,
findTaxonomyCategory,
comboboxCategoriesForTarget,
} from "./migrationTargets";
import type { Category } from "../../shared/types";
import {
getTaxonomyV1,
type TaxonomyNode,
} from "../../services/categoryTaxonomyService";
function node(
id: number,
name: string,
children: TaxonomyNode[] = [],
type: "expense" | "income" | "transfer" = "expense",
): TaxonomyNode {
return {
id,
name,
i18n_key: `categoriesSeed.test.${id}`,
type,
color: "#123456",
sort_order: 1,
children,
};
}
describe("taxonomyToComboboxCategories (leaves only)", () => {
it("returns [] for empty input", () => {
expect(taxonomyToComboboxCategories([])).toEqual([]);
});
it("keeps only leaves, dropping every intermediate parent, in DFS order", () => {
const roots = [
node(1000, "Revenus", [
node(1010, "Emploi", [node(1011, "Paie"), node(1012, "Prime")]),
]),
node(1100, "Alimentation", [node(1111, "Épicerie")]),
];
const out = taxonomyToComboboxCategories(roots);
// parents 1000/1010/1100 dropped; leaves kept in depth-first reading order
expect(out.map((c) => c.id)).toEqual([1011, 1012, 1111]);
});
it("flattens leaves: parent_id undefined + running sort_order (flat list)", () => {
const roots = [
node(1000, "Revenus", [
node(1010, "Emploi", [node(1011, "Paie"), node(1012, "Prime")]),
]),
node(1100, "Alimentation", [node(1111, "Épicerie")]),
];
const out = taxonomyToComboboxCategories(roots);
expect(out.every((c) => c.parent_id === undefined)).toBe(true);
expect(out.map((c) => c.sort_order)).toEqual([0, 1, 2]);
});
it("marks every returned row inputable (all leaves)", () => {
const roots = [
node(1700, "Loisirs", [
node(1710, "Divertissement", [node(1711, "Cinéma")]),
]),
];
const out = taxonomyToComboboxCategories(roots);
expect(out.map((c) => c.id)).toEqual([1711]); // 1700 + 1710 parents dropped
expect(out.every((c) => c.is_inputable)).toBe(true);
});
it("copies id/name/type/color/i18n_key and defaults is_active + created_at", () => {
const [c] = taxonomyToComboboxCategories([
node(1111, "Épicerie", [], "expense"),
]);
expect(c).toMatchObject({
id: 1111,
name: "Épicerie",
type: "expense",
color: "#123456",
i18n_key: "categoriesSeed.test.1111",
is_active: true,
is_inputable: true,
});
expect(typeof c.created_at).toBe("string");
});
it("adapts the real v1 taxonomy: 112 leaves, ids unique, NO non-leaf parent offered", () => {
const out = taxonomyToComboboxCategories(getTaxonomyV1().roots);
expect(out.length).toBe(112); // leaves only (was 150 incl. 38 parents)
const ids = new Set(out.map((c) => c.id));
expect(ids.size).toBe(out.length);
// The non-leaf parent 1710 "Divertissement" must NOT be a selectable option.
expect(out.find((c) => c.id === 1710)).toBeUndefined();
// A known leaf is present and inputable.
expect(out.find((c) => c.id === 1111)!.is_inputable).toBe(true);
expect(out.every((c) => c.is_inputable)).toBe(true);
});
});
describe("findTaxonomyCategory", () => {
const roots = [
node(1700, "Loisirs", [
node(1710, "Divertissement", [node(1711, "Cinéma")]),
]),
];
it("resolves a non-leaf parent by id (to display a current target)", () => {
const c = findTaxonomyCategory(roots, 1710);
expect(c).toMatchObject({
id: 1710,
name: "Divertissement",
is_inputable: false,
});
expect(c!.parent_id).toBeUndefined();
});
it("resolves a leaf too", () => {
expect(findTaxonomyCategory(roots, 1711)).toMatchObject({
id: 1711,
is_inputable: true,
});
});
it("returns null for an unknown id", () => {
expect(findTaxonomyCategory(roots, 9999)).toBeNull();
});
it("finds the real low-confidence default target 1710 in the bundled taxonomy", () => {
const c = findTaxonomyCategory(getTaxonomyV1().roots, 1710);
expect(c).toMatchObject({ id: 1710, is_inputable: false });
});
});
describe("comboboxCategoriesForTarget", () => {
const leaf = (id: number): Category => ({
id,
name: `Leaf ${id}`,
parent_id: undefined,
type: "expense",
is_active: true,
is_inputable: true,
sort_order: id,
created_at: "",
});
const leaves = [leaf(1111), leaf(1121), leaf(1131)];
const parent1710: Category = {
id: 1710,
name: "Divertissement",
parent_id: undefined,
type: "expense",
is_active: true,
is_inputable: false,
sort_order: Number.MAX_SAFE_INTEGER,
created_at: "",
};
const resolveTarget = (id: number) => (id === 1710 ? parent1710 : null);
it("returns the leaves list unchanged (same reference) when target is null", () => {
expect(comboboxCategoriesForTarget(leaves, null, resolveTarget)).toBe(leaves);
});
it("returns the leaves list unchanged (same reference) when target is already a leaf", () => {
expect(comboboxCategoriesForTarget(leaves, 1121, resolveTarget)).toBe(leaves);
});
it("appends the resolved non-leaf parent when the target is a parent absent from leaves", () => {
const out = comboboxCategoriesForTarget(leaves, 1710, resolveTarget);
expect(out).toHaveLength(leaves.length + 1);
expect(out[out.length - 1]).toMatchObject({ id: 1710, is_inputable: false });
// originals preserved, in order
expect(out.slice(0, 3).map((c) => c.id)).toEqual([1111, 1121, 1131]);
});
it("returns the leaves list unchanged when the non-leaf target cannot be resolved", () => {
// targetId absent from leaves AND resolver returns null (stale id) -> no injection
expect(comboboxCategoriesForTarget(leaves, 9999, resolveTarget)).toBe(leaves);
});
});

View file

@ -0,0 +1,110 @@
import type { Category } from "../../shared/types";
import type { TaxonomyNode } from "../../services/categoryTaxonomyService";
/**
* Flatten the v1 taxonomy into `Category`-shaped rows for the migration target
* picker, keeping ONLY the leaves the inputable end-categories a transaction
* can actually be filed under. Intermediate parents (grouping buckets) are
* dropped so the type-ahead offers real targets only, never a bucket.
*
* Leaves are emitted in depth-first reading order and carry a running
* `sort_order` with `parent_id` undefined, so `CategoryCombobox` renders them as
* one flat, un-indented list in taxonomy order (leaves stay grouped under their
* original parent) instead of re-scrambling them by the per-parent sort_order.
*
* Every leaf id is `is_inputable` in the DB after the migration, so any pick is
* FK-safe.
*
* Edge case: a low-confidence default can point a v2 category at a NON-leaf
* parent (e.g. v2 "Jeux, Films & Livres" -> v1 "Divertissement" #1710). Such a
* value is absent from this leaves-only list; `MappingRow` re-injects it for that
* single row (via `findTaxonomyCategory`) so the input still shows the current
* suggestion, while new picks stay leaves-only.
*
* Pure helper no i18n, no DB, no React.
*/
export function taxonomyToComboboxCategories(roots: TaxonomyNode[]): Category[] {
const out: Category[] = [];
let order = 0;
const walk = (node: TaxonomyNode): void => {
if (node.children.length === 0) {
out.push({
id: node.id,
name: node.name,
parent_id: undefined,
color: node.color,
type: node.type,
is_active: true,
is_inputable: true,
sort_order: order++,
i18n_key: node.i18n_key,
created_at: "",
});
return;
}
for (const child of node.children) walk(child);
};
for (const root of roots) walk(root);
return out;
}
/**
* Resolve any taxonomy node (leaf OR parent) by id into the `Category` shape,
* for DISPLAY of a current non-leaf target that the leaves-only picker omits.
* `sort_order` is pushed to the end so, when injected, the node sorts last.
* Returns null when the id is absent from the taxonomy. Pure helper.
*/
export function findTaxonomyCategory(
roots: TaxonomyNode[],
id: number,
): Category | null {
const stack: TaxonomyNode[] = [...roots];
while (stack.length > 0) {
const node = stack.pop()!;
if (node.id === id) {
return {
id: node.id,
name: node.name,
parent_id: undefined,
color: node.color,
type: node.type,
is_active: true,
is_inputable: node.children.length === 0,
sort_order: Number.MAX_SAFE_INTEGER,
i18n_key: node.i18n_key,
created_at: "",
};
}
for (const child of node.children) stack.push(child);
}
return null;
}
/**
* Build the target-picker option list for one migration row.
*
* Returns the leaves-only `leafOptions` unchanged when the row's current target
* is null or already a leaf (the common case same array reference, so the
* combobox never re-renders needlessly).
*
* When the target is a NON-leaf parent absent from the leaves list (a
* low-confidence default like "Divertissement" #1710), the resolved parent is
* appended so the combobox can DISPLAY it as the current value. It stays visible
* (and, since `CategoryCombobox` renders every option, clickable) but selecting
* it is a no-op: the row's resolve handler only accepts ids present in the
* leaves list, so re-picking the parent changes nothing and the user is steered
* to a leaf. New picks that take effect are therefore always leaves.
*
* Pure helper no React, no i18n, no DB.
*/
export function comboboxCategoriesForTarget(
leafOptions: Category[],
targetId: number | null,
resolveTarget: (id: number) => Category | null,
): Category[] {
if (targetId == null || leafOptions.some((c) => c.id === targetId)) {
return leafOptions;
}
const current = resolveTarget(targetId);
return current ? [...leafOptions, current] : leafOptions;
}

View file

@ -42,8 +42,11 @@ export default function ComparePeriodChart({
// Sort by current-period amount (largest spending first) so the user's eye // Sort by current-period amount (largest spending first) so the user's eye
// lands on the biggest categories, then reverse so the biggest appears at // lands on the biggest categories, then reverse so the biggest appears at
// the top of the vertical bar chart. // the top of the vertical bar chart. The table view groups by parent/child
const chartData = [...rows] // (Issue #247); the chart stays flat, so drop subtotal (is_parent) rows to
// avoid double-counting a group against its own leaves.
const chartData = rows
.filter((r) => !r.is_parent)
.sort((a, b) => b.currentAmount - a.currentAmount) .sort((a, b) => b.currentAmount - a.currentAmount)
.map((r) => ({ .map((r) => ({
name: r.categoryName, name: r.categoryName,

View file

@ -1,5 +1,8 @@
import { Fragment, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ArrowUpDown } from "lucide-react";
import type { CategoryDelta } from "../../shared/types"; import type { CategoryDelta } from "../../shared/types";
import { reorderRows } from "../../utils/reorderRows";
export interface ComparePeriodTableProps { export interface ComparePeriodTableProps {
rows: CategoryDelta[]; rows: CategoryDelta[];
@ -48,6 +51,40 @@ function variationColor(value: number): string {
return ""; return "";
} }
const STORAGE_KEY = "compare-subtotals-position";
type SectionType = "expense" | "income" | "transfer";
/** Aggregate of the 6 comparable figures across a set of leaf rows. */
interface Totals {
monthCurrent: number;
monthPrevious: number;
monthDelta: number;
ytdCurrent: number;
ytdPrevious: number;
ytdDelta: number;
}
function sumLeaves(rows: CategoryDelta[]): Totals {
return rows
.filter((r) => !r.is_parent)
.reduce<Totals>(
(acc, r) => ({
monthCurrent: acc.monthCurrent + r.currentAmount,
monthPrevious: acc.monthPrevious + r.previousAmount,
monthDelta: acc.monthDelta + r.deltaAbs,
ytdCurrent: acc.ytdCurrent + r.cumulativeCurrentAmount,
ytdPrevious: acc.ytdPrevious + r.cumulativePreviousAmount,
ytdDelta: acc.ytdDelta + r.cumulativeDeltaAbs,
}),
{ monthCurrent: 0, monthPrevious: 0, monthDelta: 0, ytdCurrent: 0, ytdPrevious: 0, ytdDelta: 0 },
);
}
function pct(delta: number, previous: number): number | null {
return previous !== 0 ? (delta / Math.abs(previous)) * 100 : null;
}
export default function ComparePeriodTable({ export default function ComparePeriodTable({
rows, rows,
previousLabel, previousLabel,
@ -56,31 +93,61 @@ export default function ComparePeriodTable({
cumulativeCurrentLabel, cumulativeCurrentLabel,
}: ComparePeriodTableProps) { }: ComparePeriodTableProps) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => {
const stored = localStorage.getItem(STORAGE_KEY);
return stored === null ? true : stored === "top";
});
const toggleSubtotals = () => {
setSubtotalsOnTop((prev) => {
const next = !prev;
localStorage.setItem(STORAGE_KEY, next ? "top" : "bottom");
return next;
});
};
const monthPrevLabel = previousLabel; const monthPrevLabel = previousLabel;
const monthCurrLabel = currentLabel; const monthCurrLabel = currentLabel;
const ytdPrevLabel = cumulativePreviousLabel ?? previousLabel; const ytdPrevLabel = cumulativePreviousLabel ?? previousLabel;
const ytdCurrLabel = cumulativeCurrentLabel ?? currentLabel; const ytdCurrLabel = cumulativeCurrentLabel ?? currentLabel;
// Totals across all rows (there is no parent/child structure in compare mode). // Group rows into contiguous type sections (the service already type-sorts).
const totals = rows.reduce( const sectionLabels: Record<SectionType, string> = {
(acc, r) => ({ expense: t("reports.compare.sections.expenses"),
monthCurrent: acc.monthCurrent + r.currentAmount, income: t("reports.compare.sections.income"),
monthPrevious: acc.monthPrevious + r.previousAmount, transfer: t("reports.compare.sections.transfers"),
monthDelta: acc.monthDelta + r.deltaAbs, };
ytdCurrent: acc.ytdCurrent + r.cumulativeCurrentAmount, const sectionTotalKeys: Record<SectionType, string> = {
ytdPrevious: acc.ytdPrevious + r.cumulativePreviousAmount, expense: "reports.compare.totalExpenses",
ytdDelta: acc.ytdDelta + r.cumulativeDeltaAbs, income: "reports.compare.totalIncome",
}), transfer: "reports.compare.totalTransfers",
{ monthCurrent: 0, monthPrevious: 0, monthDelta: 0, ytdCurrent: 0, ytdPrevious: 0, ytdDelta: 0 }, };
); const sections: { type: SectionType; rows: CategoryDelta[] }[] = [];
const totalMonthPct = let currentType: SectionType | null = null;
totals.monthPrevious !== 0 ? (totals.monthDelta / Math.abs(totals.monthPrevious)) * 100 : null; for (const row of rows) {
const totalYtdPct = const type = (row.category_type ?? "expense") as SectionType;
totals.ytdPrevious !== 0 ? (totals.ytdDelta / Math.abs(totals.ytdPrevious)) * 100 : null; if (type !== currentType) {
currentType = type;
sections.push({ type, rows: [] });
}
sections[sections.length - 1].rows.push(row);
}
// Grand totals across every leaf.
const totals = sumLeaves(rows);
return ( return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]">
<button
onClick={toggleSubtotals}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
>
<ArrowUpDown size={13} />
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
</button>
</div>
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}> <div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="sticky top-0 z-20"> <thead className="sticky top-0 z-20">
@ -138,109 +205,185 @@ export default function ComparePeriodTable({
<tbody> <tbody>
{rows.length === 0 ? ( {rows.length === 0 ? (
<tr> <tr>
<td <td colSpan={9} className="px-3 py-4 text-center text-[var(--muted-foreground)] italic">
colSpan={9}
className="px-3 py-4 text-center text-[var(--muted-foreground)] italic"
>
{t("reports.empty.noData")} {t("reports.empty.noData")}
</td> </td>
</tr> </tr>
) : ( ) : (
<> <>
{rows.map((row) => ( {sections.map((section) => {
<tr const sectionTotals = sumLeaves(section.rows);
key={`${row.categoryId ?? "uncat"}-${row.categoryName}`} return (
className="border-b border-[var(--border)]/50 hover:bg-[var(--muted)]/40" <Fragment key={section.type}>
> <tr className="bg-[var(--muted)]">
<td className="px-3 py-1.5 sticky left-0 bg-[var(--card)] z-10"> <td
<span className="flex items-center gap-2"> colSpan={9}
<span className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider sticky left-0 bg-[var(--muted)]"
className="w-2.5 h-2.5 rounded-full shrink-0" >
style={{ backgroundColor: row.categoryColor }} {sectionLabels[section.type]}
/> </td>
{row.categoryName} </tr>
</span> {reorderRows(section.rows, subtotalsOnTop).map((row) => {
</td> const isParent = row.is_parent ?? false;
{/* Monthly block */} const depth = row.depth ?? 0;
<td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums"> const isTopParent = isParent && depth === 0;
{formatCurrency(row.currentAmount, i18n.language)} const isIntermediateParent = isParent && depth >= 1;
</td> const paddingClass =
<td className="text-right px-3 py-1.5 tabular-nums"> depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
{formatCurrency(row.previousAmount, i18n.language)} return (
</td> <tr
<td key={`${row.categoryId ?? "uncat"}-${isParent}-${depth}-${row.categoryName}`}
className="text-right px-3 py-1.5 tabular-nums font-medium" className={`border-b border-[var(--border)]/50 ${
style={{ color: variationColor(row.deltaAbs) }} isTopParent
> ? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold"
{formatSignedCurrency(row.deltaAbs, i18n.language)} : isIntermediateParent
</td> ? "bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))] font-medium"
<td : "hover:bg-[var(--muted)]/40"
className="text-right px-3 py-1.5 tabular-nums" }`}
style={{ color: variationColor(row.deltaAbs) }} >
> <td
{formatPct(row.deltaPct, i18n.language)} className={`py-1.5 sticky left-0 z-10 ${
</td> isTopParent
{/* Cumulative YTD block */} ? "px-3 bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))]"
<td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums"> : isIntermediateParent
{formatCurrency(row.cumulativeCurrentAmount, i18n.language)} ? `${paddingClass} bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))]`
</td> : `${paddingClass} bg-[var(--card)]`
<td className="text-right px-3 py-1.5 tabular-nums"> }`}
{formatCurrency(row.cumulativePreviousAmount, i18n.language)} >
</td> <span className="flex items-center gap-2">
<td <span
className="text-right px-3 py-1.5 tabular-nums font-medium" className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ color: variationColor(row.cumulativeDeltaAbs) }} style={{ backgroundColor: row.categoryColor }}
> />
{formatSignedCurrency(row.cumulativeDeltaAbs, i18n.language)} {row.categoryName}
</td> </span>
<td </td>
className="text-right px-3 py-1.5 tabular-nums" {/* Monthly block */}
style={{ color: variationColor(row.cumulativeDeltaAbs) }} <td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums">
> {formatCurrency(row.currentAmount, lang)}
{formatPct(row.cumulativeDeltaPct, i18n.language)} </td>
</td> <td className="text-right px-3 py-1.5 tabular-nums">
</tr> {formatCurrency(row.previousAmount, lang)}
))} </td>
<td
className="text-right px-3 py-1.5 tabular-nums font-medium"
style={{ color: variationColor(row.deltaAbs) }}
>
{formatSignedCurrency(row.deltaAbs, lang)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums"
style={{ color: variationColor(row.deltaAbs) }}
>
{formatPct(row.deltaPct, lang)}
</td>
{/* Cumulative YTD block */}
<td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(row.cumulativeCurrentAmount, lang)}
</td>
<td className="text-right px-3 py-1.5 tabular-nums">
{formatCurrency(row.cumulativePreviousAmount, lang)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums font-medium"
style={{ color: variationColor(row.cumulativeDeltaAbs) }}
>
{formatSignedCurrency(row.cumulativeDeltaAbs, lang)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums"
style={{ color: variationColor(row.cumulativeDeltaAbs) }}
>
{formatPct(row.cumulativeDeltaPct, lang)}
</td>
</tr>
);
})}
{/* Section net total */}
<tr className="border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] font-semibold text-sm">
<td className="px-3 py-2.5 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] z-10">
{t(sectionTotalKeys[section.type])}
</td>
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(sectionTotals.monthCurrent, lang)}
</td>
<td className="text-right px-3 py-2.5 tabular-nums">
{formatCurrency(sectionTotals.monthPrevious, lang)}
</td>
<td
className="text-right px-3 py-2.5 tabular-nums"
style={{ color: variationColor(sectionTotals.monthDelta) }}
>
{formatSignedCurrency(sectionTotals.monthDelta, lang)}
</td>
<td
className="text-right px-3 py-2.5 tabular-nums"
style={{ color: variationColor(sectionTotals.monthDelta) }}
>
{formatPct(pct(sectionTotals.monthDelta, sectionTotals.monthPrevious), lang)}
</td>
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(sectionTotals.ytdCurrent, lang)}
</td>
<td className="text-right px-3 py-2.5 tabular-nums">
{formatCurrency(sectionTotals.ytdPrevious, lang)}
</td>
<td
className="text-right px-3 py-2.5 tabular-nums"
style={{ color: variationColor(sectionTotals.ytdDelta) }}
>
{formatSignedCurrency(sectionTotals.ytdDelta, lang)}
</td>
<td
className="text-right px-3 py-2.5 tabular-nums"
style={{ color: variationColor(sectionTotals.ytdDelta) }}
>
{formatPct(pct(sectionTotals.ytdDelta, sectionTotals.ytdPrevious), lang)}
</td>
</tr>
</Fragment>
);
})}
{/* Grand totals row */} {/* Grand totals row */}
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]"> <tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]">
<td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10"> <td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10">
{t("reports.compare.totalRow")} {t("reports.compare.totalRow")}
</td> </td>
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums"> <td className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(totals.monthCurrent, i18n.language)} {formatCurrency(totals.monthCurrent, lang)}
</td> </td>
<td className="text-right px-3 py-3 tabular-nums"> <td className="text-right px-3 py-3 tabular-nums">
{formatCurrency(totals.monthPrevious, i18n.language)} {formatCurrency(totals.monthPrevious, lang)}
</td> </td>
<td <td
className="text-right px-3 py-3 tabular-nums" className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.monthDelta) }} style={{ color: variationColor(totals.monthDelta) }}
> >
{formatSignedCurrency(totals.monthDelta, i18n.language)} {formatSignedCurrency(totals.monthDelta, lang)}
</td> </td>
<td <td
className="text-right px-3 py-3 tabular-nums" className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.monthDelta) }} style={{ color: variationColor(totals.monthDelta) }}
> >
{formatPct(totalMonthPct, i18n.language)} {formatPct(pct(totals.monthDelta, totals.monthPrevious), lang)}
</td> </td>
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums"> <td className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(totals.ytdCurrent, i18n.language)} {formatCurrency(totals.ytdCurrent, lang)}
</td> </td>
<td className="text-right px-3 py-3 tabular-nums"> <td className="text-right px-3 py-3 tabular-nums">
{formatCurrency(totals.ytdPrevious, i18n.language)} {formatCurrency(totals.ytdPrevious, lang)}
</td> </td>
<td <td
className="text-right px-3 py-3 tabular-nums" className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.ytdDelta) }} style={{ color: variationColor(totals.ytdDelta) }}
> >
{formatSignedCurrency(totals.ytdDelta, i18n.language)} {formatSignedCurrency(totals.ytdDelta, lang)}
</td> </td>
<td <td
className="text-right px-3 py-3 tabular-nums" className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.ytdDelta) }} style={{ color: variationColor(totals.ytdDelta) }}
> >
{formatPct(totalYtdPct, i18n.language)} {formatPct(pct(totals.ytdDelta, totals.ytdPrevious), lang)}
</td> </td>
</tr> </tr>
</> </>

View file

@ -28,10 +28,12 @@ import {
initialState, initialState,
makeEmptyHolding, makeEmptyHolding,
holdingsFromServiceHoldings, holdingsFromServiceHoldings,
holdingsFromCsvRows,
buildSimpleLines, buildSimpleLines,
buildDetailedLines, buildDetailedLines,
type HoldingDraft, type HoldingDraft,
} from "./useSnapshotEditor"; } from "./useSnapshotEditor";
import type { HoldingColumnMapping } from "../utils/csvAutoDetect";
import { BalanceServiceError } from "../services/balance.service"; import { BalanceServiceError } from "../services/balance.service";
import type { BalanceSnapshotHoldingWithSecurity } from "../shared/types"; import type { BalanceSnapshotHoldingWithSecurity } from "../shared/types";
@ -291,6 +293,222 @@ describe("buildDetailedLines — holdings save path (#213)", () => {
}); });
}); });
describe("holdingsFromCsvRows — CSV rows → drafts (#245)", () => {
const withPrice: HoldingColumnMapping = {
symbol: 0,
quantity: 1,
unit_price: 2,
book_cost: 3,
};
it("maps rows to drafts, normalizing the symbol and parsing FR numbers", () => {
const drafts = holdingsFromCsvRows(
[
["aapl", "10", "150,25", "1 200,00"],
["msft", "5", "300.50", "1400"],
],
withPrice
);
expect(drafts).toHaveLength(2);
expect(drafts[0].symbol).toBe("AAPL"); // normalized UPPER/TRIM
expect(drafts[0].quantity).toBe("10");
expect(drafts[0].unit_price).toBe("150.25"); // FR comma decimal
expect(drafts[0].book_cost).toBe("1200"); // FR thousands space stripped
expect(drafts[1].symbol).toBe("MSFT");
expect(drafts[1].unit_price).toBe("300.5");
});
it("leaves unit_price empty when there is no price column (flexible price)", () => {
const noPrice: HoldingColumnMapping = {
symbol: 0,
quantity: 1,
unit_price: null,
book_cost: null,
};
const [d] = holdingsFromCsvRows([["AAPL", "10"]], noPrice);
expect(d.symbol).toBe("AAPL");
expect(d.quantity).toBe("10");
expect(d.unit_price).toBe(""); // → validated at save (fetch/type later)
expect(d.book_cost).toBe("");
});
it("merges duplicate symbols: SUM quantity + book_cost, keep first price", () => {
const drafts = holdingsFromCsvRows(
[
["AAPL", "6", "150.00", "700"],
["MSFT", "5", "300.00", "1400"],
["AAPL", "4", "151.00", "500"], // second AAPL lot
],
withPrice
);
// Two distinct securities → two drafts, no UNIQUE(symbol) violation.
expect(drafts).toHaveLength(2);
const aapl = drafts.find((d) => d.symbol === "AAPL")!;
expect(aapl.quantity).toBe("10"); // 6 + 4
expect(aapl.book_cost).toBe("1200"); // 700 + 500
expect(aapl.unit_price).toBe("150"); // FIRST non-empty price wins
});
it("skips rows with a blank symbol and defaults asset_type", () => {
const drafts = holdingsFromCsvRows(
[
["", "10", "1", "1"],
[" ", "10", "1", "1"],
["BTC", "0.5", "1000", "400"],
],
withPrice,
{ defaultAssetType: "crypto" }
);
expect(drafts).toHaveLength(1);
expect(drafts[0].symbol).toBe("BTC");
expect(drafts[0].asset_type).toBe("crypto");
});
});
describe("IMPORT_HOLDINGS reducer — bulk merge into a basket (#245)", () => {
const mapping: HoldingColumnMapping = {
symbol: 0,
quantity: 1,
unit_price: 2,
book_cost: 3,
};
it("appends imported drafts into an empty basket", () => {
const drafts = holdingsFromCsvRows(
[
["AAPL", "10", "150", "1200"],
["MSFT", "5", "300", "1400"],
],
mapping
);
const s = reducer(base, {
type: "IMPORT_HOLDINGS",
payload: { accountId: 5, holdings: drafts },
});
expect(s.holdings[5].map((h) => h.symbol)).toEqual(["AAPL", "MSFT"]);
expect(s.isDirty).toBe(true);
});
it("updates an existing symbol in place (keeps its rowId) and appends new ones", () => {
const first = holdingsFromCsvRows([["AAPL", "10", "150", "1200"]], mapping);
let s = reducer(base, {
type: "IMPORT_HOLDINGS",
payload: { accountId: 5, holdings: first },
});
const aaplRowId = s.holdings[5][0].rowId;
const second = holdingsFromCsvRows(
[
["AAPL", "12", "160", "1300"],
["GOOG", "2", "140", "260"],
],
mapping
);
s = reducer(s, {
type: "IMPORT_HOLDINGS",
payload: { accountId: 5, holdings: second },
});
// No duplicate AAPL row — merged in place with a stable rowId.
expect(s.holdings[5].filter((h) => h.symbol === "AAPL")).toHaveLength(1);
const aapl = s.holdings[5].find((h) => h.symbol === "AAPL")!;
expect(aapl.rowId).toBe(aaplRowId);
expect(aapl.quantity).toBe("12"); // import wins for quantity
expect(aapl.unit_price).toBe("160");
expect(s.holdings[5].map((h) => h.symbol)).toEqual(["AAPL", "GOOG"]);
});
it("an import WITHOUT price does not wipe a previously-set price", () => {
const priced = holdingsFromCsvRows([["AAPL", "10", "150", "1200"]], mapping);
let s = reducer(base, {
type: "IMPORT_HOLDINGS",
payload: { accountId: 5, holdings: priced },
});
const noPriceMapping: HoldingColumnMapping = {
symbol: 0,
quantity: 1,
unit_price: null,
book_cost: null,
};
const repriced = holdingsFromCsvRows([["AAPL", "20"]], noPriceMapping);
s = reducer(s, {
type: "IMPORT_HOLDINGS",
payload: { accountId: 5, holdings: repriced },
});
const aapl = s.holdings[5][0];
expect(aapl.quantity).toBe("20"); // qty updated
expect(aapl.unit_price).toBe("150"); // price preserved
});
it("produces no duplicate symbols when the batch itself repeats a symbol", () => {
// holdingsFromCsvRows already merges within the batch; the reducer is a
// second guard. Even if fed pre-merged drafts, the basket stays unique.
const drafts = holdingsFromCsvRows(
[
["AAPL", "3", "150", "450"],
["AAPL", "7", "150", "1050"],
],
mapping
);
const s = reducer(base, {
type: "IMPORT_HOLDINGS",
payload: { accountId: 5, holdings: drafts },
});
expect(s.holdings[5]).toHaveLength(1);
expect(s.holdings[5][0].quantity).toBe("10");
});
});
describe("IMPORT_HOLDINGS → buildDetailedLines end-to-end (#245)", () => {
const mapping: HoldingColumnMapping = {
symbol: 0,
quantity: 1,
unit_price: 2,
book_cost: 3,
};
it("imported priced drafts flow through buildDetailedLines to holdings", () => {
const drafts = holdingsFromCsvRows(
[
["AAPL", "10", "150.25", "1200"],
["MSFT", "5", "300.50", "1400"],
],
mapping
);
const s = reducer(base, {
type: "IMPORT_HOLDINGS",
payload: { accountId: 5, holdings: drafts },
});
const [line] = buildDetailedLines(s.holdings, new Set([5]));
expect(line.holdings).toHaveLength(2);
expect(line.holdings![0].symbol).toBe("AAPL");
expect(line.holdings![0].unit_price).toBe(150.25);
expect(line.holdings![0].book_cost).toBe(1200);
// 10 * 150.25 + 5 * 300.50 = 1502.50 + 1502.50 = 3005.
expect(line.value).toBe(3005);
});
it("imported drafts WITHOUT price fail save validation until fetched (GOTCHA)", () => {
const noPriceMapping: HoldingColumnMapping = {
symbol: 0,
quantity: 1,
unit_price: null,
book_cost: null,
};
const drafts = holdingsFromCsvRows([["AAPL", "10"]], noPriceMapping);
const s = reducer(base, {
type: "IMPORT_HOLDINGS",
payload: { accountId: 5, holdings: drafts },
});
let code: string | null = null;
try {
buildDetailedLines(s.holdings, new Set([5]));
} catch (e) {
code = (e as BalanceServiceError).code;
}
expect(code).toBe("snapshot_priced_unit_price_required");
});
});
describe("dispatch on account.kind — detailed under a 'simple' category (#213)", () => { describe("dispatch on account.kind — detailed under a 'simple' category (#213)", () => {
it("routes a detailed account through holdings even if its category is simple", () => { it("routes a detailed account through holdings even if its category is simple", () => {
// Regression target: the account's OWN kind decides the path. Account 5 is // Regression target: the account's OWN kind decides the path. Account 5 is

View file

@ -52,10 +52,13 @@ import {
getPreviousSnapshot, getPreviousSnapshot,
getHoldingsForLatestSnapshot, getHoldingsForLatestSnapshot,
listHoldingsBySnapshotLine, listHoldingsBySnapshotLine,
normalizeSecuritySymbol,
BalanceServiceError, BalanceServiceError,
type SnapshotLineInput, type SnapshotLineInput,
type SnapshotHoldingInput, type SnapshotHoldingInput,
} from "../services/balance.service"; } from "../services/balance.service";
import { parseFrenchAmount } from "../utils/amountParser";
import type { HoldingColumnMapping } from "../utils/csvAutoDetect";
export type SnapshotEditorMode = "new" | "edit"; export type SnapshotEditorMode = "new" | "edit";
@ -152,6 +155,77 @@ export function holdingsFromServiceHoldings(
})); }));
} }
/**
* Map parsed CSV data rows into holding drafts for a detailed account (Issue
* #245 CSV import). `rows` are DATA rows only (no header); `mapping` gives the
* column indices from `analyzeHoldingsCsv`. Behavior:
* - Symbols are normalized (UPPER/TRIM) like manual entry (SecurityPicker) so
* an imported title collapses onto the same `balance_securities` row.
* - Numbers are parsed with `parseFrenchAmount` (handles `1 234,56`, `1,234.56`).
* - unit_price + book_cost are OPTIONAL: when the mapping's column is null (no
* price/cost column) the field stays empty; the user fetches/types it later.
* - Duplicate symbols WITHIN the CSV are merged into one draft to respect the
* UNIQUE(snapshot_line_id, security_id) constraint: quantities and book_costs
* are SUMMED (multiple lots of the same title) and the FIRST non-empty price
* is kept (the current market price is shared across lots).
* Rows with a blank symbol are skipped. Exported for unit tests.
*/
export function holdingsFromCsvRows(
rows: string[][],
mapping: HoldingColumnMapping,
opts: { defaultAssetType?: BalanceAssetType } = {}
): HoldingDraft[] {
const defaultAssetType = opts.defaultAssetType ?? "stock";
const order: string[] = [];
const bySymbol = new Map<
string,
{ symbol: string; qty: number; book: number | null; price: string }
>();
for (const row of rows) {
const rawSymbol = (row[mapping.symbol] ?? "").trim();
if (!rawSymbol) continue;
const symbol = normalizeSecuritySymbol(rawSymbol);
if (!symbol) continue;
const qtyParsed = parseFrenchAmount((row[mapping.quantity] ?? "").trim());
const qty = isNaN(qtyParsed) ? 0 : qtyParsed;
let price = "";
if (mapping.unit_price !== null) {
const p = parseFrenchAmount((row[mapping.unit_price] ?? "").trim());
if (!isNaN(p)) price = String(p);
}
let book: number | null = null;
if (mapping.book_cost !== null) {
const b = parseFrenchAmount((row[mapping.book_cost] ?? "").trim());
if (!isNaN(b)) book = b;
}
const existing = bySymbol.get(symbol);
if (existing) {
existing.qty += qty;
if (book !== null) existing.book = (existing.book ?? 0) + book;
if (!existing.price && price) existing.price = price; // first non-empty
} else {
order.push(symbol);
bySymbol.set(symbol, { symbol, qty, book, price });
}
}
return order.map((sym) => {
const a = bySymbol.get(sym)!;
return {
...makeEmptyHolding(defaultAssetType),
symbol: a.symbol,
quantity: String(a.qty),
unit_price: a.price,
book_cost: a.book !== null ? String(a.book) : "",
};
});
}
interface State { interface State {
mode: SnapshotEditorMode; mode: SnapshotEditorMode;
/** ISO YYYY-MM-DD; editable in both modes (a change in 'edit' moves the snapshot). */ /** ISO YYYY-MM-DD; editable in both modes (a change in 'edit' moves the snapshot). */
@ -212,6 +286,14 @@ type Action =
type: "ADD_HOLDING"; type: "ADD_HOLDING";
payload: { accountId: number; holding: HoldingDraft }; payload: { accountId: number; holding: HoldingDraft };
} }
| {
// Bulk-merge a batch of imported drafts into a detailed account's basket
// (Issue #245 — CSV import). Merges by normalized symbol so the batch
// never introduces a duplicate that would violate
// UNIQUE(snapshot_line_id, security_id) at save.
type: "IMPORT_HOLDINGS";
payload: { accountId: number; holdings: HoldingDraft[] };
}
| { type: "REMOVE_HOLDING"; payload: { accountId: number; rowId: string } } | { type: "REMOVE_HOLDING"; payload: { accountId: number; rowId: string } }
| { | {
type: "SET_HOLDING_FIELD"; type: "SET_HOLDING_FIELD";
@ -325,6 +407,56 @@ export function reducer(state: State, action: Action): State {
isDirty: true, isDirty: true,
}; };
} }
case "IMPORT_HOLDINGS": {
// Merge the imported batch into the existing basket by NORMALIZED symbol.
// A symbol already present is UPDATED in place (keeping its rowId for a
// stable React key); a new symbol is appended. This guarantees the basket
// never holds two rows for the same security (UNIQUE constraint at save).
const existing = state.holdings[action.payload.accountId] ?? [];
const result = existing.slice();
const indexBySymbol = new Map<string, number>();
result.forEach((h, i) => {
const key = normalizeSecuritySymbol(h.symbol);
if (key) indexBySymbol.set(key, i);
});
for (const draft of action.payload.holdings) {
const key = normalizeSecuritySymbol(draft.symbol);
if (!key) continue; // imported rows always carry a symbol; guard anyway
const idx = indexBySymbol.get(key);
if (idx !== undefined) {
const prev = result[idx];
result[idx] = {
...prev,
symbol: draft.symbol,
// Keep the existing asset_type: the CSV carries none (the draft only
// has the account's default class), while the current row's class
// was set deliberately via the picker or a prior entry.
security_name: draft.security_name || prev.security_name,
quantity: draft.quantity,
// Import wins for price/book_cost ONLY when it carries a value, so an
// import-without-price never wipes a previously fetched/typed price.
unit_price: draft.unit_price !== "" ? draft.unit_price : prev.unit_price,
book_cost: draft.book_cost !== "" ? draft.book_cost : prev.book_cost,
// A fresh imported price is a manual value → drop stale attribution.
price_source:
draft.unit_price !== "" ? null : prev.price_source,
price_fetched_at:
draft.unit_price !== "" ? null : prev.price_fetched_at,
};
} else {
indexBySymbol.set(key, result.length);
result.push(draft);
}
}
return {
...state,
holdings: {
...state.holdings,
[action.payload.accountId]: result,
},
isDirty: true,
};
}
case "REMOVE_HOLDING": { case "REMOVE_HOLDING": {
const existing = state.holdings[action.payload.accountId] ?? []; const existing = state.holdings[action.payload.accountId] ?? [];
return { return {
@ -665,6 +797,35 @@ export function useSnapshotEditor(options: Options = {}) {
[] []
); );
/**
* Import a batch of holdings from parsed CSV rows into a detailed account
* (Issue #245). Builds drafts via `holdingsFromCsvRows` (FR numbers, optional
* price, duplicate-symbol merge) then dispatches IMPORT_HOLDINGS, which merges
* them into the account's basket by symbol. New securities default to the
* account's category asset class (else 'stock').
*/
const importHoldings = useCallback(
(accountId: number, rows: string[][], mapping: HoldingColumnMapping) => {
const acc = state.accounts.find((a) => a.id === accountId);
const drafts = holdingsFromCsvRows(rows, mapping, {
defaultAssetType: acc?.category_asset_type ?? "stock",
});
if (drafts.length === 0) {
logInfo("Balance: CSV import produced no holdings (no valid rows)");
return 0;
}
dispatch({
type: "IMPORT_HOLDINGS",
payload: { accountId, holdings: drafts },
});
logInfo(
`Balance: imported ${drafts.length} holding(s) from CSV into account ${accountId}`
);
return drafts.length;
},
[state.accounts]
);
const removeHolding = useCallback((accountId: number, rowId: string) => { const removeHolding = useCallback((accountId: number, rowId: string) => {
dispatch({ type: "REMOVE_HOLDING", payload: { accountId, rowId } }); dispatch({ type: "REMOVE_HOLDING", payload: { accountId, rowId } });
}, []); }, []);
@ -843,6 +1004,7 @@ export function useSnapshotEditor(options: Options = {}) {
setDate, setDate,
setLineValue, setLineValue,
addHolding, addHolding,
importHoldings,
removeHolding, removeHolding,
setHoldingField, setHoldingField,
setHoldingSecurity, setHoldingSecurity,

View file

@ -427,7 +427,15 @@
"referenceMonth": "Reference month", "referenceMonth": "Reference month",
"currentAmount": "Current", "currentAmount": "Current",
"previousAmount": "Previous", "previousAmount": "Previous",
"totalRow": "Total" "totalRow": "Total",
"sections": {
"expenses": "Expenses",
"income": "Income",
"transfers": "Transfers"
},
"totalExpenses": "Total Expenses",
"totalIncome": "Total Income",
"totalTransfers": "Total Transfers"
}, },
"cartes": { "cartes": {
"kpiSectionAria": "Key indicators for the reference month", "kpiSectionAria": "Key indicators for the reference month",
@ -1435,6 +1443,7 @@
"loadError": "Failed to load profile data: {{error}}", "loadError": "Failed to load profile data: {{error}}",
"needsReview": "Needs review", "needsReview": "Needs review",
"chooseTarget": "Choose a target...", "chooseTarget": "Choose a target...",
"editTargetAria": "Change the target for {{category}}",
"txCount_one": "{{count}} transaction", "txCount_one": "{{count}} transaction",
"txCount_other": "{{count}} transactions", "txCount_other": "{{count}} transactions",
"unresolvedWarning_one": "You have {{count}} decision to make before you can continue.", "unresolvedWarning_one": "You have {{count}} decision to make before you can continue.",
@ -1579,20 +1588,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": {
@ -1795,6 +1810,25 @@
"stock": "Stock", "stock": "Stock",
"crypto": "Crypto" "crypto": "Crypto"
} }
},
"importCsv": {
"button": "Import CSV",
"title": "Import securities from a CSV",
"intro": "Import the positions of \"{{account}}\" from a CSV file (broker statement, export). Columns are detected automatically; adjust them if needed.",
"selectFile": "Choose a CSV file",
"loading": "Reading file…",
"hint": "Accepted formats: .csv, .txt. Delimiter and encoding are detected automatically.",
"detectError": "Could not detect columns in this file. Make sure it is a valid securities CSV.",
"readError": "Could not read the file.",
"retry": "Retry",
"symbolColumn": "Symbol column",
"quantityColumn": "Quantity column",
"priceColumn": "Price column (optional)",
"bookCostColumn": "Book cost column (optional)",
"noColumn": "— None —",
"noPriceNote": "No price column: securities will be imported without a price. You can fetch or type it afterwards.",
"summary": "{{count}} security(ies) will be imported.",
"import": "Import"
} }
}, },
"delete": { "delete": {

View file

@ -427,7 +427,15 @@
"referenceMonth": "Mois de référence", "referenceMonth": "Mois de référence",
"currentAmount": "Courant", "currentAmount": "Courant",
"previousAmount": "Précédent", "previousAmount": "Précédent",
"totalRow": "Total" "totalRow": "Total",
"sections": {
"expenses": "Dépenses",
"income": "Revenus",
"transfers": "Transferts"
},
"totalExpenses": "Total des dépenses",
"totalIncome": "Total des revenus",
"totalTransfers": "Total des transferts"
}, },
"cartes": { "cartes": {
"kpiSectionAria": "Indicateurs clés du mois de référence", "kpiSectionAria": "Indicateurs clés du mois de référence",
@ -1435,6 +1443,7 @@
"loadError": "Impossible de charger les données du profil : {{error}}", "loadError": "Impossible de charger les données du profil : {{error}}",
"needsReview": "À réviser", "needsReview": "À réviser",
"chooseTarget": "Choisir une cible...", "chooseTarget": "Choisir une cible...",
"editTargetAria": "Modifier la cible pour {{category}}",
"txCount_one": "{{count}} transaction", "txCount_one": "{{count}} transaction",
"txCount_other": "{{count}} transactions", "txCount_other": "{{count}} transactions",
"unresolvedWarning_one": "Vous avez {{count}} décision à prendre avant de pouvoir continuer.", "unresolvedWarning_one": "Vous avez {{count}} décision à prendre avant de pouvoir continuer.",
@ -1579,20 +1588,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": {
@ -1795,6 +1810,25 @@
"stock": "Action", "stock": "Action",
"crypto": "Crypto" "crypto": "Crypto"
} }
},
"importCsv": {
"button": "Importer un CSV",
"title": "Importer des titres depuis un CSV",
"intro": "Importez les positions de « {{account}} » depuis un fichier CSV (relevé de courtier, exportation). Les colonnes sont détectées automatiquement ; ajustez-les au besoin.",
"selectFile": "Choisir un fichier CSV",
"loading": "Lecture du fichier…",
"hint": "Formats acceptés : .csv, .txt. Le séparateur et l'encodage sont détectés automatiquement.",
"detectError": "Impossible de détecter les colonnes dans ce fichier. Vérifiez qu'il s'agit d'un CSV de titres valide.",
"readError": "Impossible de lire le fichier.",
"retry": "Réessayer",
"symbolColumn": "Colonne du symbole",
"quantityColumn": "Colonne de la quantité",
"priceColumn": "Colonne du cours (facultatif)",
"bookCostColumn": "Colonne du coût d'acquisition (facultatif)",
"noColumn": "— Aucune —",
"noPriceNote": "Aucune colonne de cours : les titres seront importés sans prix. Vous pourrez le récupérer ou le saisir ensuite.",
"summary": "{{count}} titre(s) seront importé(s).",
"import": "Importer"
} }
}, },
"delete": { "delete": {

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>
); );
})()} })()}

View file

@ -212,6 +212,7 @@ export default function SnapshotEditPage() {
onRemoveHolding={editor.removeHolding} onRemoveHolding={editor.removeHolding}
onHoldingFieldChange={editor.setHoldingField} onHoldingFieldChange={editor.setHoldingField}
onHoldingSecurityPick={editor.setHoldingSecurity} onHoldingSecurityPick={editor.setHoldingSecurity}
onImportHoldings={editor.importHoldings}
disabled={state.isSaving} disabled={state.isSaving}
snapshotDate={state.snapshotDate} snapshotDate={state.snapshotDate}
/> />

View file

@ -5,7 +5,9 @@ import {
getCompareMonthOverMonth, getCompareMonthOverMonth,
getCompareYearOverYear, getCompareYearOverYear,
getCategoryZoom, getCategoryZoom,
buildCompareTree,
} from "./reportService"; } from "./reportService";
import type { CategoryDelta } from "../shared/types";
// Mock the db module // Mock the db module
vi.mock("./db", () => { vi.mock("./db", () => {
@ -325,7 +327,10 @@ describe("getCompareMonthOverMonth", () => {
await getCompareMonthOverMonth(2026, 4); await getCompareMonthOverMonth(2026, 4);
expect(mockSelect).toHaveBeenCalledTimes(1); // Two queries now: the delta aggregation (call 0, unchanged) + the category
// metadata for the hierarchy (call 1, Issue #247).
expect(mockSelect).toHaveBeenCalledTimes(2);
expect(mockSelect.mock.calls[1][0]).toContain("FROM categories");
const sql = mockSelect.mock.calls[0][0] as string; const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[]; const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).toContain("$1"); expect(sql).toContain("$1");
@ -476,6 +481,117 @@ describe("getCompareYearOverYear", () => {
}); });
}); });
describe("real-vs-real compare — transfer netting (Issue #243)", () => {
// A `transfer` category (e.g. "Paiement CC") is a money move, not spending:
// its debit and matching credit must cancel to ~0 in the real-vs-real
// compare. Ordinary categories keep the expense-report behavior (only
// outflows, summed as positive magnitudes). The netting lives entirely in
// the SQL (COMPARE_DELTA_SQL): a signed SUM(t.amount) for `transfer`,
// ABS(t.amount) filtered on amount < 0 for everything else, with the WHERE
// broadened so transfer credits are not dropped before they can cancel.
//
// There is no runnable SQLite in these unit tests (tauri-plugin-sql only
// exists inside the Tauri WebView, and CI runs Node 20 → no node:sqlite), so
// the netting is pinned two ways: (1) a reference-semantics check over
// concrete debit/credit rows that reproduces the exact per-row rule the SQL
// encodes, and (2) structural assertions that the real query on BOTH compare
// functions carries that rule and the broadened WHERE.
/** Per-row contribution to a bucket — the exact rule COMPARE_DELTA_SQL encodes. */
function bucketContribution(amount: number, type: string | null): number {
return (type ?? "expense") === "transfer"
? amount // signed → a debit and its matching credit cancel
: amount < 0
? Math.abs(amount) // expense outflow as a positive magnitude
: 0; // non-transfer credits dropped → no revenues surfaced
}
it("nets a balanced transfer to ~0 while an expense keeps its sum of outflows", () => {
// "Paiement CC" (transfer): -500 out of chequing, +500 onto the card.
const transfer = [-500, 500].reduce((s, a) => s + bucketContribution(a, "transfer"), 0);
expect(transfer).toBeCloseTo(0, 6);
// "Épicerie" (expense): two debits + one refund credit that must NOT net it down.
const expense = [-120, -80, 60].reduce((s, a) => s + bucketContribution(a, "expense"), 0);
expect(expense).toBe(200);
// Uncategorized (c.type NULL) defaults to expense behavior.
const uncategorized = [-40, 15].reduce((s, a) => s + bucketContribution(a, null), 0);
expect(uncategorized).toBe(40);
});
it("getCompareMonthOverMonth SQL nets transfers (signed) but keeps ABS for other types", async () => {
mockSelect.mockResolvedValueOnce([]);
await getCompareMonthOverMonth(2026, 4);
const sql = mockSelect.mock.calls[0][0] as string;
// transfer → signed t.amount; every other type → ABS(t.amount)
expect(sql).toContain(
"COALESCE(c.type, 'expense') = 'transfer' THEN t.amount ELSE ABS(t.amount)",
);
// WHERE broadened so transfer credits survive the expense (amount < 0) filter
expect(sql).toContain(
"WHERE (t.amount < 0 OR COALESCE(c.type, 'expense') = 'transfer')",
);
// still four date buckets
expect(sql).toContain("month_current_total");
expect(sql).toContain("cumulative_previous_total");
});
it("getCompareYearOverYear applies the identical netting contract (both tabs fixed)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getCompareYearOverYear(2026, 4);
const sql = mockSelect.mock.calls[0][0] as string;
expect(sql).toContain(
"COALESCE(c.type, 'expense') = 'transfer' THEN t.amount ELSE ABS(t.amount)",
);
expect(sql).toContain(
"WHERE (t.amount < 0 OR COALESCE(c.type, 'expense') = 'transfer')",
);
});
it("keeps a netted transfer at 0 through the pipeline while an expense is unchanged", async () => {
// Rows as the netted SQL returns them: the balanced transfer arrives at 0,
// the ordinary expense keeps its outflow sum. Assert nothing downstream
// re-inflates the transfer.
mockSelect.mockResolvedValueOnce([
{
category_id: 5,
category_name: "Épicerie",
category_color: "#10b981",
month_current_total: 200,
month_previous_total: 150,
cumulative_current_total: 800,
cumulative_previous_total: 600,
},
{
category_id: 71,
category_name: "Paiement CC",
category_color: "#9ca3af",
month_current_total: 0,
month_previous_total: 0,
cumulative_current_total: 0,
cumulative_previous_total: 0,
},
]);
const result = await getCompareMonthOverMonth(2026, 4);
const transferRow = result.find((r) => r.categoryName === "Paiement CC")!;
const expenseRow = result.find((r) => r.categoryName === "Épicerie")!;
expect(transferRow.currentAmount).toBe(0);
expect(transferRow.previousAmount).toBe(0);
expect(transferRow.deltaAbs).toBe(0);
expect(transferRow.cumulativeCurrentAmount).toBe(0);
// Ordinary expense keeps EXACTLY its sum of outflows.
expect(expenseRow.currentAmount).toBe(200);
expect(expenseRow.previousAmount).toBe(150);
});
});
describe("getCategoryZoom", () => { describe("getCategoryZoom", () => {
it("uses a bounded recursive CTE when including subcategories", async () => { it("uses a bounded recursive CTE when including subcategories", async () => {
mockSelect mockSelect
@ -530,3 +646,167 @@ describe("getCategoryZoom", () => {
expect(txSql).toContain("t.category_id = $1"); expect(txSql).toContain("t.category_id = $1");
}); });
}); });
describe("buildCompareTree — hierarchical real-vs-real (Issue #247)", () => {
// Flat leaf, mirroring what rowsToDeltas returns from COMPARE_DELTA_SQL.
function leaf(
categoryId: number | null,
categoryName: string,
mc: number,
mp: number,
cc = mc,
cp = mp,
): CategoryDelta {
return {
categoryId,
categoryName,
categoryColor: "#000",
previousAmount: mp,
currentAmount: mc,
deltaAbs: mc - mp,
deltaPct: mp !== 0 ? ((mc - mp) / mp) * 100 : null,
cumulativePreviousAmount: cp,
cumulativeCurrentAmount: cc,
cumulativeDeltaAbs: cc - cp,
cumulativeDeltaPct: cp !== 0 ? ((cc - cp) / cp) * 100 : null,
};
}
const cats = [
{ id: 2, name: "Dépenses", color: null, type: "expense", parent_id: null },
{ id: 22, name: "Épicerie", color: "#10b981", type: "expense", parent_id: 2 },
{ id: 24, name: "Restaurant", color: "#f97316", type: "expense", parent_id: 2 },
{ id: 5, name: "Placements", color: null, type: "transfer", parent_id: null },
{ id: 50, name: "REER", color: null, type: "transfer", parent_id: 5 },
] as Parameters<typeof buildCompareTree>[1];
it("nests leaves under a parent subtotal equal to the sum of its children", () => {
const rows = buildCompareTree(
[leaf(22, "Épicerie", 500, 400, 2000, 1500), leaf(24, "Restaurant", 120, 200, 300, 500)],
cats,
);
// Parent subtotal on top, then children ordered by |monthly delta| desc.
expect(rows.map((r) => r.categoryName)).toEqual(["Dépenses", "Épicerie", "Restaurant"]);
const parent = rows[0];
expect(parent.is_parent).toBe(true);
expect(parent.categoryId).toBe(2);
expect(parent.depth).toBe(0);
expect(parent.category_type).toBe("expense");
// Subtotal = sum of children (the group's net).
expect(parent.currentAmount).toBe(620);
expect(parent.previousAmount).toBe(600);
expect(parent.deltaAbs).toBe(20);
expect(parent.cumulativeCurrentAmount).toBe(2300);
expect(parent.cumulativePreviousAmount).toBe(2000);
expect(parent.cumulativeDeltaAbs).toBe(300);
// Children are leaves at depth 1 with UNCHANGED values (no regression).
const epicerie = rows.find((r) => r.categoryName === "Épicerie")!;
expect(epicerie.is_parent).toBeFalsy();
expect(epicerie.depth).toBe(1);
expect(epicerie.parent_id).toBe(2);
expect(epicerie.currentAmount).toBe(500);
expect(epicerie.previousAmount).toBe(400);
expect(epicerie.deltaAbs).toBe(100);
});
it("keeps a balanced transfer group netted to 0 at the subtotal (preserves #243)", () => {
const rows = buildCompareTree([leaf(50, "REER", 0, 0, 0, 0)], cats);
const parent = rows.find((r) => r.categoryId === 5)!;
expect(parent.is_parent).toBe(true);
expect(parent.category_type).toBe("transfer");
// The netted-to-zero transfer stays 0 through the subtotal — not re-inflated.
expect(parent.currentAmount).toBe(0);
expect(parent.previousAmount).toBe(0);
expect(parent.deltaAbs).toBe(0);
});
it("orders sections expense → income → transfer and keeps subtrees contiguous", () => {
const rows = buildCompareTree(
[leaf(22, "Épicerie", 500, 400), leaf(50, "REER", 0, 0)],
cats,
);
const types = rows.map((r) => r.category_type);
// All expense rows precede all transfer rows.
expect(types).toEqual(["expense", "expense", "transfer", "transfer"]);
});
it("preserves grand-total invariance vs the flat leaves", () => {
const flat = [
leaf(22, "Épicerie", 500, 400, 2000, 1500),
leaf(24, "Restaurant", 120, 200, 300, 500),
leaf(50, "REER", 0, 0, 0, 0),
];
const rows = buildCompareTree(flat, cats);
const sumLeaves = (rs: CategoryDelta[]) =>
rs.filter((r) => !r.is_parent).reduce((s, r) => s + r.currentAmount, 0);
expect(sumLeaves(rows)).toBe(sumLeaves(flat));
});
it("surfaces an uncategorized (null id) row as a top-level leaf", () => {
const rows = buildCompareTree([leaf(null, "Uncategorized", 90, 40)], cats);
expect(rows).toHaveLength(1);
expect(rows[0].categoryName).toBe("Uncategorized");
expect(rows[0].is_parent).toBeFalsy();
expect(rows[0].depth).toBe(0);
expect(rows[0].currentAmount).toBe(90);
});
it("keeps a leaf whose category was soft-deleted (still in the metadata)", () => {
// A single leaf with no siblings collapses to just that leaf (no lone subtotal).
const rows = buildCompareTree([leaf(22, "Épicerie", 75, 60)], cats);
const names = rows.map((r) => r.categoryName);
expect(names).toContain("Épicerie");
// A parent with exactly one contributing child still gets a subtotal row.
const parent = rows.find((r) => r.is_parent);
expect(parent?.currentAmount).toBe(75);
});
});
describe("getCompareMonthOverMonth — wires the hierarchy (Issue #247)", () => {
it("returns parent subtotal rows when category metadata is available", async () => {
mockSelect
// 1. COMPARE_DELTA_SQL — two sibling leaves under parent id 2.
.mockResolvedValueOnce([
{
category_id: 22,
category_name: "Épicerie",
category_color: "#10b981",
month_current_total: 500,
month_previous_total: 400,
cumulative_current_total: 2000,
cumulative_previous_total: 1500,
},
{
category_id: 24,
category_name: "Restaurant",
category_color: "#f97316",
month_current_total: 120,
month_previous_total: 200,
cumulative_current_total: 300,
cumulative_previous_total: 500,
},
])
// 2. COMPARE_CATEGORIES_SQL — the hierarchy.
.mockResolvedValueOnce([
{ id: 2, name: "Dépenses", color: null, type: "expense", parent_id: null },
{ id: 22, name: "Épicerie", color: "#10b981", type: "expense", parent_id: 2 },
{ id: 24, name: "Restaurant", color: "#f97316", type: "expense", parent_id: 2 },
]);
const result = await getCompareMonthOverMonth(2026, 4);
// Delta query stays call 0 (netting SQL + params assertions elsewhere rely on it).
expect(mockSelect.mock.calls[0][0]).toContain("month_current_total");
expect(mockSelect.mock.calls[1][0]).toContain("FROM categories");
const parent = result.find((r) => r.is_parent);
expect(parent).toBeDefined();
expect(parent!.categoryId).toBe(2);
expect(parent!.currentAmount).toBe(620);
// Leaves survive unchanged.
expect(result.find((r) => r.categoryName === "Épicerie")!.currentAmount).toBe(500);
});
});

View file

@ -429,11 +429,262 @@ function monthBoundaries(year: number, month: number): { start: string; end: str
return { start: `${year}-${mm}-01`, end: `${year}-${mm}-${dd}` }; return { start: `${year}-${mm}-01`, end: `${year}-${mm}-${dd}` };
} }
// --- Compare hierarchy (Issue #247) ---
/**
* Minimal category metadata for building the compare tree. Fetched WITHOUT an
* `is_active` filter so soft-deleted categories (is_active = 0) that still carry
* historic transactions keep their place in the hierarchy matching the raw
* LEFT JOIN behavior of COMPARE_DELTA_SQL (no regression on the leaves shown).
*/
interface CompareCatMeta {
id: number;
name: string;
color: string | null;
type: "expense" | "income" | "transfer" | null;
parent_id: number | null;
}
const COMPARE_CATEGORIES_SQL = `SELECT id, name, color, type, parent_id FROM categories`;
const COMPARE_TYPE_ORDER: Record<string, number> = { expense: 0, income: 1, transfer: 2 };
/** Depth cap mirroring CATEGORY_TREE_CTE — guards a cyclic parent_id chain. */
const MAX_TREE_DEPTH = 5;
/**
* Turns the flat per-category deltas returned by COMPARE_DELTA_SQL into a
* parent/child tree with subtotal (`is_parent`) rows, mirroring the hierarchy of
* getBudgetVsActualData.
*
* The #243 transfer netting is untouched: leaf values are exactly what the SQL
* returned, and a subtotal is the arithmetic sum of its descendant leaves so a
* group of balanced (netted-to-zero) transfers subtotals to ~0, i.e. the group's
* net. Driven by `leaves` (the categories that actually had transactions in the
* window) rather than the full category list, so netted-to-zero transfers and
* soft-deleted categories with history are preserved and no empty rows appear.
*
* Exported for unit testing.
*/
export function buildCompareTree(
leaves: CategoryDelta[],
categories: CompareCatMeta[],
): CategoryDelta[] {
const catById = new Map<number, CompareCatMeta>();
for (const c of categories) catById.set(c.id, c);
// Flat delta lookup by category id. Rows whose category id is null
// (Uncategorized) or absent from the table (hard-deleted) become orphans:
// depth-0 leaves preserved in their incoming order.
const deltaByCat = new Map<number, CategoryDelta>();
const orphans: CategoryDelta[] = [];
for (const d of leaves) {
if (d.categoryId != null && catById.has(d.categoryId)) {
deltaByCat.set(d.categoryId, d);
} else {
orphans.push(d);
}
}
// "Relevant" = every category that has a delta plus all of its ancestors.
const relevant = new Set<number>();
for (const id of deltaByCat.keys()) {
let cur: number | null | undefined = id;
let guard = 0;
while (cur != null && guard <= MAX_TREE_DEPTH) {
if (relevant.has(cur)) break;
relevant.add(cur);
cur = catById.get(cur)?.parent_id ?? null;
guard++;
}
}
// Adjacency among relevant categories only, preserving DB order.
const childrenByParent = new Map<number, CompareCatMeta[]>();
for (const c of categories) {
if (c.parent_id != null && relevant.has(c.id) && relevant.has(c.parent_id)) {
let arr = childrenByParent.get(c.parent_id);
if (!arr) childrenByParent.set(c.parent_id, (arr = []));
arr.push(c);
}
}
const typeOf = (c: CompareCatMeta): "expense" | "income" | "transfer" => c.type ?? "expense";
const leafRow = (
cat: CompareCatMeta,
parentId: number | null,
depth: number,
): CategoryDelta => ({
...deltaByCat.get(cat.id)!,
parent_id: parentId,
is_parent: false,
depth,
category_type: typeOf(cat),
});
const subtotalRow = (
cat: CompareCatMeta,
descendantLeaves: CategoryDelta[],
parentId: number | null,
depth: number,
): CategoryDelta => {
let previousAmount = 0;
let currentAmount = 0;
let cumulativePreviousAmount = 0;
let cumulativeCurrentAmount = 0;
for (const l of descendantLeaves) {
previousAmount += l.previousAmount;
currentAmount += l.currentAmount;
cumulativePreviousAmount += l.cumulativePreviousAmount;
cumulativeCurrentAmount += l.cumulativeCurrentAmount;
}
const deltaAbs = currentAmount - previousAmount;
const cumulativeDeltaAbs = cumulativeCurrentAmount - cumulativePreviousAmount;
return {
categoryId: cat.id,
categoryName: cat.name,
categoryColor: cat.color ?? "#9ca3af",
previousAmount,
currentAmount,
deltaAbs,
// Match rowsToDeltas' leaf formula (signed denominator) so a single-child
// parent shows the same % as its child.
deltaPct: previousAmount !== 0 ? (deltaAbs / previousAmount) * 100 : null,
cumulativePreviousAmount,
cumulativeCurrentAmount,
cumulativeDeltaAbs,
cumulativeDeltaPct:
cumulativePreviousAmount !== 0
? (cumulativeDeltaAbs / cumulativePreviousAmount) * 100
: null,
parent_id: parentId,
is_parent: true,
depth,
category_type: typeOf(cat),
};
};
interface Block {
rows: CategoryDelta[];
sortKey: number; // |monthly delta| of the block head — orders siblings
}
// Builds a node's block: a pure leaf, or a subtotal followed by its
// (recursively built) child blocks, siblings ordered by |monthly delta| desc.
const buildNode = (cat: CompareCatMeta, depth: number): Block | null => {
// Stop descending past the depth cap so a corrupted parent_id cycle can
// never recurse forever — deeper nodes collapse to leaves (real category
// trees are ≤ 3 levels).
const children = depth >= MAX_TREE_DEPTH ? [] : (childrenByParent.get(cat.id) ?? []);
const hasDirect = deltaByCat.has(cat.id);
if (children.length === 0) {
if (!hasDirect) return null;
const leaf = leafRow(cat, cat.parent_id ?? null, depth);
return { rows: [leaf], sortKey: Math.abs(leaf.deltaAbs) };
}
const childBlocks: Block[] = [];
// A category with both children AND its own transactions surfaces the direct
// spend as a "(direct)" leaf so the subtotal stays the sum of its visible
// rows (mirrors getBudgetVsActualData). Rare: a parent auto-loses
// is_inputable when a child is added.
if (hasDirect) {
const direct = leafRow(cat, cat.id, depth + 1);
childBlocks.push({
rows: [{ ...direct, categoryName: `${cat.name} (direct)` }],
sortKey: Math.abs(direct.deltaAbs),
});
}
for (const child of children) {
const b = buildNode(child, depth + 1);
if (b) childBlocks.push(b);
}
childBlocks.sort((a, b) => b.sortKey - a.sortKey);
const childRows = childBlocks.flatMap((b) => b.rows);
const descendantLeaves = childRows.filter((r) => !r.is_parent);
const subtotal = subtotalRow(cat, descendantLeaves, cat.parent_id ?? null, depth);
return { rows: [subtotal, ...childRows], sortKey: Math.abs(subtotal.deltaAbs) };
};
// Roots = relevant categories with no relevant parent. Ordered by magnitude.
const rootBlocks: Block[] = [];
for (const c of categories) {
if (!relevant.has(c.id)) continue;
if (c.parent_id != null && relevant.has(c.parent_id)) continue;
const b = buildNode(c, 0);
if (b) rootBlocks.push(b);
}
rootBlocks.sort((a, b) => b.sortKey - a.sortKey);
const rows = rootBlocks.flatMap((b) => b.rows);
// Orphans (Uncategorized + hard-deleted) appended in their incoming order.
for (const d of orphans) {
rows.push({ ...d, parent_id: null, is_parent: false, depth: 0, category_type: d.category_type ?? "expense" });
}
// Stable sort by type so sections (expense → income → transfer) are
// contiguous; magnitude/tree order within a type is preserved via the index.
const order = new Map<CategoryDelta, number>();
rows.forEach((r, i) => order.set(r, i));
rows.sort((a, b) => {
const ta = COMPARE_TYPE_ORDER[a.category_type ?? "expense"] ?? 9;
const tb = COMPARE_TYPE_ORDER[b.category_type ?? "expense"] ?? 9;
if (ta !== tb) return ta - tb;
return order.get(a)! - order.get(b)!;
});
return rows;
}
function previousMonth(year: number, month: number): { year: number; month: number } { function previousMonth(year: number, month: number): { year: number; month: number } {
if (month === 1) return { year: year - 1, month: 12 }; if (month === 1) return { year: year - 1, month: 12 };
return { year, month: month - 1 }; return { year, month: month - 1 };
} }
/**
* Shared aggregation for the real-vs-real compare (both MoM and YoY drive the
* exact same query only the eight date bounds differ). Four date buckets
* ($1..$8): monthly current/previous and cumulative current/previous.
*
* Per-category sign convention (Issue #243):
* - `transfer` categories NET via a signed SUM(t.amount), so a balanced
* debit/credit pair (e.g. "Paiement CC": -500 out, +500 in) cancels to ~0.
* A transfer is a money move, not spending, and must not inflate the
* expense figures.
* - Every other type keeps the expense-report behavior: only outflows
* (amount < 0) count, summed as positive magnitudes via ABS.
*
* The WHERE is broadened with `OR COALESCE(c.type, 'expense') = 'transfer'` so
* transfer *credits* survive the `amount < 0` expense filter and can actually
* cancel their debits without it the credit leg would be dropped and the
* category could never net to zero. For non-transfer rows the WHERE still
* admits only outflows, so their totals are byte-identical to the previous
* behavior (no revenues surfaced, no zero-rows for pure-income categories).
* Uncategorized rows (LEFT JOIN c.type NULL) default to 'expense'.
*/
const COMPARE_DELTA_SQL = `SELECT
t.category_id,
COALESCE(c.name, 'Uncategorized') AS category_name,
COALESCE(c.color, '#9ca3af') AS category_color,
COALESCE(SUM(CASE WHEN t.date >= $1 AND t.date <= $2 THEN CASE WHEN COALESCE(c.type, 'expense') = 'transfer' THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS month_current_total,
COALESCE(SUM(CASE WHEN t.date >= $3 AND t.date <= $4 THEN CASE WHEN COALESCE(c.type, 'expense') = 'transfer' THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS month_previous_total,
COALESCE(SUM(CASE WHEN t.date >= $5 AND t.date <= $6 THEN CASE WHEN COALESCE(c.type, 'expense') = 'transfer' THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS cumulative_current_total,
COALESCE(SUM(CASE WHEN t.date >= $7 AND t.date <= $8 THEN CASE WHEN COALESCE(c.type, 'expense') = 'transfer' THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS cumulative_previous_total
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
WHERE (t.amount < 0 OR COALESCE(c.type, 'expense') = 'transfer')
AND (
(t.date >= $1 AND t.date <= $2)
OR (t.date >= $3 AND t.date <= $4)
OR (t.date >= $5 AND t.date <= $6)
OR (t.date >= $7 AND t.date <= $8)
)
GROUP BY t.category_id, category_name, category_color
ORDER BY ABS(month_current_total - month_previous_total) DESC`;
/** /**
* Month-over-month expense delta by category. Returns both a monthly view * Month-over-month expense delta by category. Returns both a monthly view
* (reference month vs immediately-previous month) and a cumulative YTD view * (reference month vs immediately-previous month) and a cumulative YTD view
@ -461,34 +712,19 @@ export async function getCompareMonthOverMonth(
const cumPreviousStart = `${prev.year}-01-01`; const cumPreviousStart = `${prev.year}-01-01`;
const cumPreviousEnd = prevEnd; const cumPreviousEnd = prevEnd;
const rows = await db.select<RawDeltaRow[]>( // Delta select stays first so its params/SQL remain `mock.calls[0]`; the
`SELECT // category metadata (for the hierarchy) is fetched alongside. `?? []` guards
t.category_id, // under-specified mocks — db.select never returns undefined in production.
COALESCE(c.name, 'Uncategorized') AS category_name, const [rows, cats] = await Promise.all([
COALESCE(c.color, '#9ca3af') AS category_color, db.select<RawDeltaRow[]>(COMPARE_DELTA_SQL, [
COALESCE(SUM(CASE WHEN t.date >= $1 AND t.date <= $2 THEN ABS(t.amount) ELSE 0 END), 0) AS month_current_total,
COALESCE(SUM(CASE WHEN t.date >= $3 AND t.date <= $4 THEN ABS(t.amount) ELSE 0 END), 0) AS month_previous_total,
COALESCE(SUM(CASE WHEN t.date >= $5 AND t.date <= $6 THEN ABS(t.amount) ELSE 0 END), 0) AS cumulative_current_total,
COALESCE(SUM(CASE WHEN t.date >= $7 AND t.date <= $8 THEN ABS(t.amount) ELSE 0 END), 0) AS cumulative_previous_total
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
WHERE t.amount < 0
AND (
(t.date >= $1 AND t.date <= $2)
OR (t.date >= $3 AND t.date <= $4)
OR (t.date >= $5 AND t.date <= $6)
OR (t.date >= $7 AND t.date <= $8)
)
GROUP BY t.category_id, category_name, category_color
ORDER BY ABS(month_current_total - month_previous_total) DESC`,
[
curStart, curEnd, curStart, curEnd,
prevStart, prevEnd, prevStart, prevEnd,
cumCurrentStart, cumCurrentEnd, cumCurrentStart, cumCurrentEnd,
cumPreviousStart, cumPreviousEnd, cumPreviousStart, cumPreviousEnd,
], ]),
); db.select<CompareCatMeta[]>(COMPARE_CATEGORIES_SQL),
return rowsToDeltas(rows); ]);
return buildCompareTree(rowsToDeltas(rows), cats ?? []);
} }
/** /**
@ -512,34 +748,17 @@ export async function getCompareYearOverYear(
const cumPreviousStart = `${year - 1}-01-01`; const cumPreviousStart = `${year - 1}-01-01`;
const cumPreviousEnd = prevMonthEnd; const cumPreviousEnd = prevMonthEnd;
const rows = await db.select<RawDeltaRow[]>( // See getCompareMonthOverMonth: delta select first, categories alongside.
`SELECT const [rows, cats] = await Promise.all([
t.category_id, db.select<RawDeltaRow[]>(COMPARE_DELTA_SQL, [
COALESCE(c.name, 'Uncategorized') AS category_name,
COALESCE(c.color, '#9ca3af') AS category_color,
COALESCE(SUM(CASE WHEN t.date >= $1 AND t.date <= $2 THEN ABS(t.amount) ELSE 0 END), 0) AS month_current_total,
COALESCE(SUM(CASE WHEN t.date >= $3 AND t.date <= $4 THEN ABS(t.amount) ELSE 0 END), 0) AS month_previous_total,
COALESCE(SUM(CASE WHEN t.date >= $5 AND t.date <= $6 THEN ABS(t.amount) ELSE 0 END), 0) AS cumulative_current_total,
COALESCE(SUM(CASE WHEN t.date >= $7 AND t.date <= $8 THEN ABS(t.amount) ELSE 0 END), 0) AS cumulative_previous_total
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
WHERE t.amount < 0
AND (
(t.date >= $1 AND t.date <= $2)
OR (t.date >= $3 AND t.date <= $4)
OR (t.date >= $5 AND t.date <= $6)
OR (t.date >= $7 AND t.date <= $8)
)
GROUP BY t.category_id, category_name, category_color
ORDER BY ABS(month_current_total - month_previous_total) DESC`,
[
curMonthStart, curMonthEnd, curMonthStart, curMonthEnd,
prevMonthStart, prevMonthEnd, prevMonthStart, prevMonthEnd,
cumCurrentStart, cumCurrentEnd, cumCurrentStart, cumCurrentEnd,
cumPreviousStart, cumPreviousEnd, cumPreviousStart, cumPreviousEnd,
], ]),
); db.select<CompareCatMeta[]>(COMPARE_CATEGORIES_SQL),
return rowsToDeltas(rows); ]);
return buildCompareTree(rowsToDeltas(rows), cats ?? []);
} }
// --- Category zoom (Issue #74) --- // --- Category zoom (Issue #74) ---
@ -947,10 +1166,14 @@ export async function getCartesSnapshot(
// 12-month income vs expenses series for the overlay chart. // 12-month income vs expenses series for the overlay chart.
const flow12Months = buildSeries(12); const flow12Months = buildSeries(12);
// Top movers: biggest MoM increases / decreases. `momRows` are sorted by // Top movers: biggest MoM increases / decreases. `momRows` now carries the
// absolute delta already; filter out near-zero noise and split by sign. // compare hierarchy (Issue #247) — skip the subtotal (`is_parent`) rows so a
// parent group can't double-count against its own leaves. The surviving leaves
// are byte-identical to the previous flat output; the sort/slice below is
// unchanged. `momRows` are sorted by absolute delta already; filter out
// near-zero noise and split by sign.
const significantMovers = momRows.filter( const significantMovers = momRows.filter(
(r) => r.deltaAbs !== 0 && (r.previousAmount > 0 || r.currentAmount > 0), (r) => !r.is_parent && r.deltaAbs !== 0 && (r.previousAmount > 0 || r.currentAmount > 0),
); );
// Project the richer CategoryDelta shape down to the narrower CartesTopMover // Project the richer CategoryDelta shape down to the narrower CartesTopMover
// shape so the Cartes dashboard keeps its stable contract regardless of how // shape so the Cartes dashboard keeps its stable contract regardless of how

View file

@ -308,6 +308,16 @@ export interface CategoryDelta {
cumulativeCurrentAmount: number; cumulativeCurrentAmount: number;
cumulativeDeltaAbs: number; cumulativeDeltaAbs: number;
cumulativeDeltaPct: number | null; cumulativeDeltaPct: number | null;
// Hierarchy block (Issue #247) — populated ONLY by the real-vs-real Compare
// tree builder (getCompareMonthOverMonth / getCompareYearOverYear). Flat
// consumers (Highlights movers, Cartes top movers) leave these undefined and
// must not read them. Snake_case (unlike the camelCase value fields above) to
// mirror BudgetVsActualRow and stay compatible with the shared `reorderRows`
// util. `is_parent` rows are subtotals whose value = the net of their group.
parent_id?: number | null;
is_parent?: boolean;
depth?: number;
category_type?: "expense" | "income" | "transfer";
} }
// Historical alias — used by the highlights hub. Shape identical to CategoryDelta. // Historical alias — used by the highlights hub. Shape identical to CategoryDelta.

View file

@ -0,0 +1,123 @@
// csvAutoDetect — holdings-CSV detection tests (Issue #245).
//
// Covers `autoDetectHoldingColumns` (column detection) and `analyzeHoldingsCsv`
// (delimiter/header + full analysis). These helpers are pure and DOM-free, in
// line with the project's "test the extracted pure pieces" convention. The
// numeric parsing + duplicate-merge path lives in `holdingsFromCsvRows`
// (tested in useSnapshotEditor.test.ts).
import { describe, it, expect } from "vitest";
import {
autoDetectHoldingColumns,
analyzeHoldingsCsv,
} from "./csvAutoDetect";
describe("autoDetectHoldingColumns (#245)", () => {
it("detects symbol/quantity/price/book_cost from EN headers", () => {
const data = [
["Symbol", "Quantity", "Price", "Book Cost"],
["AAPL", "10", "150.25", "1200.00"],
["MSFT", "5", "300.50", "1400.00"],
];
expect(autoDetectHoldingColumns(data, true)).toEqual({
symbol: 0,
quantity: 1,
unit_price: 2,
book_cost: 3,
});
});
it("leaves unit_price + book_cost null when the CSV has no such columns", () => {
const data = [
["Symbol", "Shares"],
["AAPL", "10"],
["MSFT", "5"],
];
const m = autoDetectHoldingColumns(data, true)!;
expect(m.symbol).toBe(0);
expect(m.quantity).toBe(1);
expect(m.unit_price).toBeNull();
expect(m.book_cost).toBeNull();
});
it("never maps a Value (qty×price) column to price or book_cost", () => {
const data = [
["Symbol", "Quantity", "Price", "Value"],
["AAPL", "10", "150.25", "1502.50"],
["MSFT", "4", "300.50", "1202.00"],
];
const m = autoDetectHoldingColumns(data, true)!;
expect(m.symbol).toBe(0);
expect(m.quantity).toBe(1);
expect(m.unit_price).toBe(2);
// The Value column (col 3) must NOT be picked up as the cost basis.
expect(m.book_cost).toBeNull();
});
it("detects headerless CSVs positionally (symbol / int qty / decimal price)", () => {
const data = [
["AAPL", "10", "150.25"],
["MSFT", "5", "300.50"],
["GOOG", "2", "140.10"],
];
const m = autoDetectHoldingColumns(data, false)!;
expect(m.symbol).toBe(0);
expect(m.quantity).toBe(1);
expect(m.unit_price).toBe(2);
});
it("returns null when there are no data rows", () => {
expect(autoDetectHoldingColumns([], true)).toBeNull();
expect(autoDetectHoldingColumns([["Symbol", "Qty"]], true)).toBeNull();
});
});
describe("analyzeHoldingsCsv (#245)", () => {
it("parses an EN comma CSV with a header row", () => {
const csv = "Symbol,Quantity,Price\nAAPL,10,150.25\nMSFT,5,300.50\n";
const a = analyzeHoldingsCsv(csv)!;
expect(a.delimiter).toBe(",");
expect(a.hasHeader).toBe(true);
expect(a.headers).toEqual(["Symbol", "Quantity", "Price"]);
expect(a.rows).toEqual([
["AAPL", "10", "150.25"],
["MSFT", "5", "300.50"],
]);
expect(a.mapping).toEqual({
symbol: 0,
quantity: 1,
unit_price: 2,
book_cost: null,
});
});
it("handles FR headers (accents), a semicolon delimiter and FR numbers", () => {
const csv =
"Symbole;Quantité;Cours;Coût\nAAPL;10;150,25;1 200,00\nMSFT;5;300,50;1 400,00\n";
const a = analyzeHoldingsCsv(csv)!;
expect(a.delimiter).toBe(";");
expect(a.hasHeader).toBe(true);
expect(a.mapping).toEqual({
symbol: 0,
quantity: 1,
unit_price: 2,
book_cost: 3,
});
expect(a.rows[0]).toEqual(["AAPL", "10", "150,25", "1 200,00"]);
});
it("returns generated headers + all rows for a headerless CSV", () => {
const csv = "AAPL,10,150.25\nMSFT,5,300.50\nGOOG,2,140.10\n";
const a = analyzeHoldingsCsv(csv)!;
expect(a.hasHeader).toBe(false);
expect(a.headers).toEqual(["Col 0", "Col 1", "Col 2"]);
expect(a.rows).toHaveLength(3);
expect(a.mapping.symbol).toBe(0);
expect(a.mapping.quantity).toBe(1);
});
it("returns null on empty / whitespace-only content", () => {
expect(analyzeHoldingsCsv("")).toBeNull();
expect(analyzeHoldingsCsv(" \n \n")).toBeNull();
});
});

View file

@ -556,3 +556,301 @@ function isSparseComplementary(
return total > 0 && complementary / total >= 0.7; return total > 0 && complementary / total >= 0.7;
} }
// -----------------------------------------------------------------------------
// Holdings CSV detection (Issue #245) — a detailed balance account can import a
// CSV of positions instead of typing them one by one. This is a SEPARATE flow
// from the transaction import above: `autoDetectConfig` is coupled to the
// date/amount transaction model, whereas a holdings CSV maps to
// symbol / quantity / unit_price / book_cost. Price + book_cost are OPTIONAL
// (flexible price detection): when the CSV has no price column the mapping
// leaves it `null` and the holdings import without a price (the user fetches or
// types it afterwards). Detection is best-effort and the UI lets the user
// adjust every column, so an imperfect guess is always recoverable.
// -----------------------------------------------------------------------------
export interface HoldingColumnMapping {
/** Column index of the security symbol/ticker (required). */
symbol: number;
/** Column index of the quantity held (required). */
quantity: number;
/** Column index of the unit price, or null when the CSV has no price column. */
unit_price: number | null;
/** Column index of the acquisition cost basis, or null when absent. */
book_cost: number | null;
}
export interface HoldingCsvAnalysis {
delimiter: string;
hasHeader: boolean;
/** Header labels (actual cells when `hasHeader`, else `Col 0`, `Col 1`, …). */
headers: string[];
/** DATA rows only (the header row, if any, is stripped). */
rows: string[][];
mapping: HoldingColumnMapping;
}
// Header keyword sets, matched against accent-stripped, alphanumeric-only
// header cells. Bilingual (FR default + EN). Order inside each list is priority
// order for the primary-keyword pass.
const SYMBOL_HEADER_KEYWORDS = ["symbol", "symbole", "ticker", "titre"];
const QUANTITY_HEADER_KEYWORDS = [
"quantity",
"quantite",
"qty",
"qte",
"shares",
"actions",
"parts",
"unites",
"units",
"nombre",
];
const PRICE_HEADER_KEYWORDS = [
"prixunitaire",
"unitprice",
"marketprice",
"price",
"prix",
"cours",
"cotation",
"cloture",
"close",
];
const BOOKCOST_HEADER_KEYWORDS = [
"bookcost",
"costbasis",
"prixderevient",
"acquisition",
"revient",
"cost",
"cout",
];
// Value/market-value columns must never be auto-picked as price or book_cost.
const VALUE_HEADER_KEYWORDS = ["value", "valeur", "montant", "marchande"];
/** Accent-strip + lowercase + keep alphanumerics only (for header matching). */
function normalizeHeaderCell(s: string): string {
return (s ?? "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]/g, "");
}
/**
* Find the first unused column whose normalized header contains one of the
* keywords (keywords tried in priority order). `exclude` skips columns whose
* header contains any excluded token (e.g. a "value" column for price).
*/
function matchHeaderColumn(
normalizedHeaders: string[],
keywords: string[],
used: Set<number>,
exclude: string[] = []
): number | null {
for (const kw of keywords) {
for (let i = 0; i < normalizedHeaders.length; i++) {
if (used.has(i)) continue;
const h = normalizedHeaders[i];
if (!h) continue;
if (exclude.some((ex) => h.includes(ex))) continue;
if (h.includes(kw)) return i;
}
}
return null;
}
/** Pick the shortest-average-length non-numeric, unused text column (symbols
* are short tokens; a name/description column is longer). */
function pickSymbolColumn(
rows: string[][],
colCount: number,
numericCols: Set<number>,
used: Set<number>
): number | null {
let best: number | null = null;
let bestAvg = Infinity;
for (let col = 0; col < colCount; col++) {
if (used.has(col) || numericCols.has(col)) continue;
let totalLen = 0;
let count = 0;
for (const row of rows) {
const cell = row[col]?.trim();
if (!cell) continue;
totalLen += cell.length;
count++;
}
if (count === 0) continue;
const avg = totalLen / count;
if (avg < bestAvg) {
bestAvg = avg;
best = col;
}
}
return best;
}
/** Pick an unused numeric column, optionally preferring integer-heavy (quantity)
* or decimal-heavy (price) columns. Returns null when none remain. */
function pickNumericColumn(
rows: string[][],
numericCols: number[],
used: Set<number>,
opts: { preferIntegers?: boolean; preferDecimals?: boolean }
): number | null {
let best: number | null = null;
let bestScore = -1;
for (const col of numericCols) {
if (used.has(col)) continue;
let ints = 0;
let decimals = 0;
let nonEmpty = 0;
for (const row of rows) {
const cell = row[col]?.trim();
if (!cell) continue;
const v = parseFrenchAmount(cell);
if (isNaN(v)) continue;
nonEmpty++;
if (Number.isInteger(v)) ints++;
else decimals++;
}
if (nonEmpty === 0) continue;
let score = 1;
if (opts.preferIntegers) score = ints / nonEmpty;
else if (opts.preferDecimals) score = decimals / nonEmpty;
if (score > bestScore) {
bestScore = score;
best = col;
}
}
return best;
}
/**
* Detect the symbol / quantity / unit_price / book_cost columns of a holdings
* CSV. `data` is the parsed 2-D array INCLUDING the header row when
* `hasHeader`. Returns a best-effort mapping (symbol + quantity always set so
* the editor can render), or null only when there is no usable data.
* Exported for unit tests.
*/
export function autoDetectHoldingColumns(
data: string[][],
hasHeader: boolean
): HoldingColumnMapping | null {
if (data.length === 0) return null;
const colCount = Math.max(...data.map((r) => r.length));
if (colCount === 0) return null;
const dataRows = hasHeader ? data.slice(1) : data;
if (dataRows.length === 0) return null;
const used = new Set<number>();
let symbol: number | null = null;
let quantity: number | null = null;
let unitPrice: number | null = null;
let bookCost: number | null = null;
// Step 1 — header keyword matching (most reliable when present).
if (hasHeader) {
const normalized = data[0].map(normalizeHeaderCell);
symbol = matchHeaderColumn(normalized, SYMBOL_HEADER_KEYWORDS, used);
if (symbol !== null) used.add(symbol);
quantity = matchHeaderColumn(normalized, QUANTITY_HEADER_KEYWORDS, used);
if (quantity !== null) used.add(quantity);
unitPrice = matchHeaderColumn(
normalized,
PRICE_HEADER_KEYWORDS,
used,
VALUE_HEADER_KEYWORDS
);
if (unitPrice !== null) used.add(unitPrice);
bookCost = matchHeaderColumn(
normalized,
BOOKCOST_HEADER_KEYWORDS,
used,
VALUE_HEADER_KEYWORDS
);
if (bookCost !== null) used.add(bookCost);
}
// A value / market-value column must never be auto-assigned to a numeric
// target by the heuristics below (it is qty × price, not a source column).
// Mark such columns used up-front so the fallback pickers skip them.
if (hasHeader) {
const normalized = data[0].map(normalizeHeaderCell);
normalized.forEach((h, i) => {
if (!used.has(i) && VALUE_HEADER_KEYWORDS.some((v) => h && h.includes(v))) {
used.add(i);
}
});
}
// Step 2 — numeric/text heuristics fill any column the header didn't resolve.
const sampleRows = dataRows.slice(0, 20);
const numericCols = detectNumericColumns(sampleRows, colCount);
const numericSet = new Set(numericCols);
if (symbol === null) {
symbol = pickSymbolColumn(sampleRows, colCount, numericSet, used);
if (symbol !== null) used.add(symbol);
}
if (quantity === null) {
quantity = pickNumericColumn(sampleRows, numericCols, used, {
preferIntegers: true,
});
if (quantity !== null) used.add(quantity);
}
if (unitPrice === null) {
unitPrice = pickNumericColumn(sampleRows, numericCols, used, {
preferDecimals: true,
});
if (unitPrice !== null) used.add(unitPrice);
}
if (bookCost === null) {
bookCost = pickNumericColumn(sampleRows, numericCols, used, {});
if (bookCost !== null) used.add(bookCost);
}
// symbol + quantity are required; fall back to sane defaults so the editor
// always has something to show (the user can correct it).
if (symbol === null) symbol = 0;
if (quantity === null) quantity = symbol === 0 && colCount > 1 ? 1 : 0;
return { symbol, quantity, unit_price: unitPrice, book_cost: bookCost };
}
/**
* Full holdings-CSV analysis: preprocess quoted lines, detect the delimiter and
* header, then the column mapping. Returns the header labels + DATA rows (header
* stripped) so the caller can render a mapping editor + preview and feed the
* rows to `holdingsFromCsvRows`. Null when the content has no usable rows.
* Exported for unit tests.
*/
export function analyzeHoldingsCsv(
rawContent: string
): HoldingCsvAnalysis | null {
const content = preprocessQuotedCSV(rawContent);
const nonEmptyLines = content.split(/\r?\n/).filter((l) => l.trim());
if (nonEmptyLines.length === 0) return null;
const delimiter = detectDelimiter(nonEmptyLines.slice(0, 10));
if (!delimiter) return null;
const parsed = Papa.parse(content, { delimiter, skipEmptyLines: true });
const data = (parsed.data as string[][]).filter((r) =>
r.some((c) => (c ?? "").trim() !== "")
);
if (data.length === 0) return null;
const hasHeader = detectHeader(data[0]);
const mapping = autoDetectHoldingColumns(data, hasHeader);
if (!mapping) return null;
const colCount = Math.max(...data.map((r) => r.length));
const headers = hasHeader
? data[0].map((h, i) => (h ?? "").trim() || `Col ${i}`)
: Array.from({ length: colCount }, (_, i) => `Col ${i}`);
const rows = hasHeader ? data.slice(1) : data;
return { delimiter, hasHeader, headers, rows, mapping };
}

View file

@ -2,9 +2,13 @@
* Shared utility for reordering budget table rows. * Shared utility for reordering budget table rows.
* Recursively moves subtotal (parent) rows below their children * Recursively moves subtotal (parent) rows below their children
* at every depth level when "subtotals on bottom" is enabled. * at every depth level when "subtotals on bottom" is enabled.
*
* The generic constraint only requires the two fields the algorithm actually
* reads (`is_parent`, `depth`) so it works for both the budget rows (required
* `is_parent`) and the Compare `CategoryDelta` rows (optional `is_parent`).
*/ */
export function reorderRows< export function reorderRows<
T extends { is_parent: boolean; parent_id: number | null; category_id: number; depth?: number }, T extends { is_parent?: boolean; depth?: number },
>(rows: T[], subtotalsOnTop: boolean): T[] { >(rows: T[], subtotalsOnTop: boolean): T[] {
if (subtotalsOnTop) return rows; if (subtotalsOnTop) return rows;