Compare commits
3 commits
31765e6d17
...
4c58b8bab8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c58b8bab8 | ||
| 5fd2108d07 | |||
|
|
4116db4090 |
26 changed files with 1906 additions and 94 deletions
|
|
@ -2,6 +2,12 @@
|
||||||
|
|
||||||
## [Non publié]
|
## [Non publié]
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- **Rapport Cartes** (`/reports/cartes`) : nouveau sous-rapport de type tableau de bord dans le hub Rapports. Combine quatre cartes KPI (Revenus, Dépenses, Solde net, Taux d'épargne) affichant les deltas MoM et YoY simultanément avec une sparkline 13 mois dont le mois de référence est mis en évidence, un graphique overlay revenus vs dépenses sur 12 mois (barres + ligne de solde net), le top 5 des catégories en hausse et en baisse par rapport au mois précédent, une carte d'adhérence au budget (N/M dans la cible plus les 3 pires dépassements avec barres de progression) et une carte de saisonnalité qui compare le mois de référence à la moyenne du même mois sur les deux années précédentes. Toutes les données proviennent d'un seul appel `getCartesSnapshot()` qui exécute ses requêtes en parallèle (#97)
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
- **Rapport Comparables** (`/reports/compare`) : passage de trois onglets (MoM / YoY / Budget) à deux modes (Réel vs réel / Réel vs budget). La vue « Réel vs réel » affiche désormais un sélecteur de mois de référence en en-tête (défaut : mois précédent), un sous-toggle MoM ↔ YoY, et un graphique en barres groupées côte-à-côte (deux barres par catégorie : période de référence vs période comparée). Le `PeriodSelector` d'URL reste synchronisé avec le sélecteur de mois (#96)
|
||||||
|
|
||||||
## [0.8.0] - 2026-04-14
|
## [0.8.0] - 2026-04-14
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,12 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Cartes report** (`/reports/cartes`): new dashboard-style sub-report in the Reports hub. Combines four KPI cards (income, expenses, net balance, savings rate) showing MoM and YoY deltas simultaneously with a 13-month sparkline highlighting the reference month, a 12-month income vs. expenses overlay chart (bars + net balance line), top 5 category increases and top 5 decreases vs. the previous month, a budget-adherence card (N/M on-target plus the three worst overruns with progress bars), and a seasonality card that compares the reference month against the same calendar month from the two previous years. All data comes from a single `getCartesSnapshot()` service call that runs its queries concurrently (#97)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Compare report** (`/reports/compare`): reduced from three tabs (MoM / YoY / Budget) to two modes (Actual vs. actual / Actual vs. budget). The actual-vs-actual view now has an explicit reference-month dropdown in the header (defaults to the previous month), a MoM ↔ YoY sub-toggle, and a grouped side-by-side bar chart (two bars per category: reference period vs. comparison period). The URL `PeriodSelector` stays in sync with the reference month picker (#96)
|
||||||
|
|
||||||
## [0.8.0] - 2026-04-14
|
## [0.8.0] - 2026-04-14
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,7 @@ Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le
|
||||||
| `adjustmentService.ts` | Gestion des ajustements |
|
| `adjustmentService.ts` | Gestion des ajustements |
|
||||||
| `budgetService.ts` | Gestion budgétaire |
|
| `budgetService.ts` | Gestion budgétaire |
|
||||||
| `dashboardService.ts` | Agrégation données tableau de bord |
|
| `dashboardService.ts` | Agrégation données tableau de bord |
|
||||||
| `reportService.ts` | Génération de rapports : `getMonthlyTrends`, `getCategoryOverTime`, `getHighlights`, `getCompareMonthOverMonth`, `getCompareYearOverYear`, `getCategoryZoom` (CTE récursive bornée anti-cycle) |
|
| `reportService.ts` | Génération de rapports : `getMonthlyTrends`, `getCategoryOverTime`, `getHighlights`, `getCompareMonthOverMonth`, `getCompareYearOverYear`, `getCategoryZoom` (CTE récursive bornée anti-cycle), `getCartesSnapshot` (snapshot dashboard Cartes, requêtes parallèles) |
|
||||||
| `dataExportService.ts` | Export de données (chiffré) |
|
| `dataExportService.ts` | Export de données (chiffré) |
|
||||||
| `userPreferenceService.ts` | Stockage préférences utilisateur |
|
| `userPreferenceService.ts` | Stockage préférences utilisateur |
|
||||||
| `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) |
|
| `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) |
|
||||||
|
|
@ -149,8 +149,9 @@ Chaque hook encapsule la logique d'état via `useReducer` :
|
||||||
| `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) |
|
| `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) |
|
||||||
| `useHighlights` | Panneau de faits saillants du hub rapports |
|
| `useHighlights` | Panneau de faits saillants du hub rapports |
|
||||||
| `useTrends` | Rapport Tendances (sous-vue flux global / par catégorie) |
|
| `useTrends` | Rapport Tendances (sous-vue flux global / par catégorie) |
|
||||||
| `useCompare` | Rapport Comparables (mode MoM / YoY / budget) |
|
| `useCompare` | Rapport Comparables (mode `actual`/`budget`, sous-toggle MoM ↔ YoY, mois de référence explicite avec wrap-around janvier) |
|
||||||
| `useCategoryZoom` | Rapport Zoom catégorie avec rollup sous-catégories |
|
| `useCategoryZoom` | Rapport Zoom catégorie avec rollup sous-catégories |
|
||||||
|
| `useCartes` | Rapport Cartes (snapshot KPI + sparklines + top movers + budget + saisonnalité via `getCartesSnapshot`) |
|
||||||
| `useDataExport` | Export de données |
|
| `useDataExport` | Export de données |
|
||||||
| `useTheme` | Thème clair/sombre |
|
| `useTheme` | Thème clair/sombre |
|
||||||
| `useUpdater` | Mise à jour de l'application (gated par entitlement licence) |
|
| `useUpdater` | Mise à jour de l'application (gated par entitlement licence) |
|
||||||
|
|
@ -289,6 +290,7 @@ Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `App
|
||||||
| `/reports/trends` | `ReportsTrendsPage` | Tendances (flux global + par catégorie) |
|
| `/reports/trends` | `ReportsTrendsPage` | Tendances (flux global + par catégorie) |
|
||||||
| `/reports/compare` | `ReportsComparePage` | Comparables (MoM / YoY / Réel vs budget) |
|
| `/reports/compare` | `ReportsComparePage` | Comparables (MoM / YoY / Réel vs budget) |
|
||||||
| `/reports/category` | `ReportsCategoryPage` | Zoom catégorie avec rollup + édition contextuelle de mots-clés |
|
| `/reports/category` | `ReportsCategoryPage` | Zoom catégorie avec rollup + édition contextuelle de mots-clés |
|
||||||
|
| `/reports/cartes` | `ReportsCartesPage` | Tableau de bord KPI avec sparklines, top movers, budget et saisonnalité |
|
||||||
| `/settings` | `SettingsPage` | Paramètres |
|
| `/settings` | `SettingsPage` | Paramètres |
|
||||||
| `/docs` | `DocsPage` | Documentation in-app |
|
| `/docs` | `DocsPage` | Documentation in-app |
|
||||||
| `/changelog` | `ChangelogPage` | Historique des versions (bilingue FR/EN) |
|
| `/changelog` | `ChangelogPage` | Historique des versions (bilingue FR/EN) |
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import ReportsHighlightsPage from "./pages/ReportsHighlightsPage";
|
||||||
import ReportsTrendsPage from "./pages/ReportsTrendsPage";
|
import ReportsTrendsPage from "./pages/ReportsTrendsPage";
|
||||||
import ReportsComparePage from "./pages/ReportsComparePage";
|
import ReportsComparePage from "./pages/ReportsComparePage";
|
||||||
import ReportsCategoryPage from "./pages/ReportsCategoryPage";
|
import ReportsCategoryPage from "./pages/ReportsCategoryPage";
|
||||||
|
import ReportsCartesPage from "./pages/ReportsCartesPage";
|
||||||
import SettingsPage from "./pages/SettingsPage";
|
import SettingsPage from "./pages/SettingsPage";
|
||||||
import DocsPage from "./pages/DocsPage";
|
import DocsPage from "./pages/DocsPage";
|
||||||
import ChangelogPage from "./pages/ChangelogPage";
|
import ChangelogPage from "./pages/ChangelogPage";
|
||||||
|
|
@ -109,6 +110,7 @@ export default function App() {
|
||||||
<Route path="/reports/trends" element={<ReportsTrendsPage />} />
|
<Route path="/reports/trends" element={<ReportsTrendsPage />} />
|
||||||
<Route path="/reports/compare" element={<ReportsComparePage />} />
|
<Route path="/reports/compare" element={<ReportsComparePage />} />
|
||||||
<Route path="/reports/category" element={<ReportsCategoryPage />} />
|
<Route path="/reports/category" element={<ReportsCategoryPage />} />
|
||||||
|
<Route path="/reports/cartes" element={<ReportsCartesPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/docs" element={<DocsPage />} />
|
<Route path="/docs" element={<DocsPage />} />
|
||||||
<Route path="/changelog" element={<ChangelogPage />} />
|
<Route path="/changelog" element={<ChangelogPage />} />
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,7 @@ export default function CompareModeTabs({ value, onChange }: CompareModeTabsProp
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const modes: { id: CompareMode; labelKey: string }[] = [
|
const modes: { id: CompareMode; labelKey: string }[] = [
|
||||||
{ id: "mom", labelKey: "reports.compare.modeMoM" },
|
{ id: "actual", labelKey: "reports.compare.modeActual" },
|
||||||
{ id: "yoy", labelKey: "reports.compare.modeYoY" },
|
|
||||||
{ id: "budget", labelKey: "reports.compare.modeBudget" },
|
{ id: "budget", labelKey: "reports.compare.modeBudget" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,17 @@ import {
|
||||||
Bar,
|
Bar,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
Cell,
|
|
||||||
ReferenceLine,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
Legend,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
|
CartesianGrid,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import type { CategoryDelta } from "../../shared/types";
|
import type { CategoryDelta } from "../../shared/types";
|
||||||
import { ChartPatternDefs, getPatternFill } from "../../utils/chartPatterns";
|
|
||||||
|
|
||||||
export interface ComparePeriodChartProps {
|
export interface ComparePeriodChartProps {
|
||||||
rows: CategoryDelta[];
|
rows: CategoryDelta[];
|
||||||
|
previousLabel: string;
|
||||||
|
currentLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCurrency(amount: number, language: string): string {
|
function formatCurrency(amount: number, language: string): string {
|
||||||
|
|
@ -24,7 +25,11 @@ function formatCurrency(amount: number, language: string): string {
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ComparePeriodChart({ rows }: ComparePeriodChartProps) {
|
export default function ComparePeriodChart({
|
||||||
|
rows,
|
||||||
|
previousLabel,
|
||||||
|
currentLabel,
|
||||||
|
}: ComparePeriodChartProps) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
|
|
@ -35,31 +40,44 @@ export default function ComparePeriodChart({ rows }: ComparePeriodChartProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const chartData = rows
|
// Sort by current-period amount (largest spending first) so the user's eye
|
||||||
.map((r, i) => ({
|
// lands on the biggest categories, then reverse so the biggest appears at
|
||||||
|
// the top of the vertical bar chart.
|
||||||
|
const chartData = [...rows]
|
||||||
|
.sort((a, b) => b.currentAmount - a.currentAmount)
|
||||||
|
.map((r) => ({
|
||||||
name: r.categoryName,
|
name: r.categoryName,
|
||||||
|
previousAmount: r.previousAmount,
|
||||||
|
currentAmount: r.currentAmount,
|
||||||
color: r.categoryColor,
|
color: r.categoryColor,
|
||||||
delta: r.deltaAbs,
|
}));
|
||||||
index: i,
|
|
||||||
}))
|
const previousFill = "var(--muted-foreground)";
|
||||||
.sort((a, b) => a.delta - b.delta);
|
const currentFill = "var(--primary)";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
|
||||||
<ResponsiveContainer width="100%" height={Math.max(240, chartData.length * 32 + 40)}>
|
<ResponsiveContainer width="100%" height={Math.max(280, chartData.length * 44 + 60)}>
|
||||||
<BarChart data={chartData} layout="vertical" margin={{ top: 10, right: 20, bottom: 10, left: 10 }}>
|
<BarChart
|
||||||
<ChartPatternDefs
|
data={chartData}
|
||||||
prefix="compare-delta"
|
layout="vertical"
|
||||||
categories={chartData.map((d) => ({ color: d.color, index: d.index }))}
|
margin={{ top: 10, right: 24, bottom: 10, left: 10 }}
|
||||||
/>
|
barCategoryGap="20%"
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" horizontal={false} />
|
||||||
<XAxis
|
<XAxis
|
||||||
type="number"
|
type="number"
|
||||||
tickFormatter={(v) => formatCurrency(v, i18n.language)}
|
tickFormatter={(v) => formatCurrency(v, i18n.language)}
|
||||||
stroke="var(--muted-foreground)"
|
stroke="var(--muted-foreground)"
|
||||||
fontSize={11}
|
fontSize={11}
|
||||||
/>
|
/>
|
||||||
<YAxis type="category" dataKey="name" width={140} stroke="var(--muted-foreground)" fontSize={11} />
|
<YAxis
|
||||||
<ReferenceLine x={0} stroke="var(--border)" />
|
type="category"
|
||||||
|
dataKey="name"
|
||||||
|
width={140}
|
||||||
|
stroke="var(--muted-foreground)"
|
||||||
|
fontSize={11}
|
||||||
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value) =>
|
formatter={(value) =>
|
||||||
typeof value === "number" ? formatCurrency(value, i18n.language) : String(value)
|
typeof value === "number" ? formatCurrency(value, i18n.language) : String(value)
|
||||||
|
|
@ -69,15 +87,23 @@ export default function ComparePeriodChart({ rows }: ComparePeriodChartProps) {
|
||||||
border: "1px solid var(--border)",
|
border: "1px solid var(--border)",
|
||||||
borderRadius: "0.5rem",
|
borderRadius: "0.5rem",
|
||||||
}}
|
}}
|
||||||
|
cursor={{ fill: "var(--muted)", fillOpacity: 0.2 }}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="delta">
|
<Legend
|
||||||
{chartData.map((entry) => (
|
wrapperStyle={{ paddingTop: 8, fontSize: 12, color: "var(--muted-foreground)" }}
|
||||||
<Cell
|
/>
|
||||||
key={entry.name}
|
<Bar
|
||||||
fill={getPatternFill("compare-delta", entry.index, entry.color)}
|
dataKey="previousAmount"
|
||||||
|
name={previousLabel}
|
||||||
|
fill={previousFill}
|
||||||
|
radius={[0, 4, 4, 0]}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="currentAmount"
|
||||||
|
name={currentLabel}
|
||||||
|
fill={currentFill}
|
||||||
|
radius={[0, 4, 4, 0]}
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</Bar>
|
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
93
src/components/reports/CompareReferenceMonthPicker.tsx
Normal file
93
src/components/reports/CompareReferenceMonthPicker.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export interface CompareReferenceMonthPickerProps {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
onChange: (year: number, month: number) => void;
|
||||||
|
/** Number of recent months to show in the dropdown. Default: 24. */
|
||||||
|
monthCount?: number;
|
||||||
|
/** "today" override for tests. */
|
||||||
|
today?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
value: string; // "YYYY-MM"
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMonth(year: number, month: number, language: string): string {
|
||||||
|
const date = new Date(year, month - 1, 1);
|
||||||
|
return new Intl.DateTimeFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CompareReferenceMonthPicker({
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
onChange,
|
||||||
|
monthCount = 24,
|
||||||
|
today = new Date(),
|
||||||
|
}: CompareReferenceMonthPickerProps) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
|
const options = useMemo<Option[]>(() => {
|
||||||
|
const list: Option[] = [];
|
||||||
|
let y = today.getFullYear();
|
||||||
|
let m = today.getMonth() + 1;
|
||||||
|
for (let i = 0; i < monthCount; i++) {
|
||||||
|
list.push({
|
||||||
|
value: `${y}-${String(m).padStart(2, "0")}`,
|
||||||
|
year: y,
|
||||||
|
month: m,
|
||||||
|
label: formatMonth(y, m, i18n.language),
|
||||||
|
});
|
||||||
|
m -= 1;
|
||||||
|
if (m === 0) {
|
||||||
|
m = 12;
|
||||||
|
y -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}, [today, monthCount, i18n.language]);
|
||||||
|
|
||||||
|
const currentValue = `${year}-${String(month).padStart(2, "0")}`;
|
||||||
|
const isKnown = options.some((o) => o.value === currentValue);
|
||||||
|
const displayOptions = isKnown
|
||||||
|
? options
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
value: currentValue,
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
label: formatMonth(year, month, i18n.language),
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className="inline-flex items-center gap-2">
|
||||||
|
<span className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
{t("reports.compare.referenceMonth")}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={currentValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
const opt = displayOptions.find((o) => o.value === e.target.value);
|
||||||
|
if (opt) onChange(opt.year, opt.month);
|
||||||
|
}}
|
||||||
|
className="rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] px-3 py-2 text-sm hover:bg-[var(--muted)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
|
||||||
|
>
|
||||||
|
{displayOptions.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
src/components/reports/CompareSubModeToggle.tsx
Normal file
40
src/components/reports/CompareSubModeToggle.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { CompareSubMode } from "../../hooks/useCompare";
|
||||||
|
|
||||||
|
export interface CompareSubModeToggleProps {
|
||||||
|
value: CompareSubMode;
|
||||||
|
onChange: (subMode: CompareSubMode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CompareSubModeToggle({ value, onChange }: CompareSubModeToggleProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const items: { id: CompareSubMode; labelKey: string }[] = [
|
||||||
|
{ id: "mom", labelKey: "reports.compare.subModeMoM" },
|
||||||
|
{ id: "yoy", labelKey: "reports.compare.subModeYoY" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="inline-flex rounded-lg border border-[var(--border)] bg-[var(--card)] p-0.5"
|
||||||
|
role="group"
|
||||||
|
aria-label={t("reports.compare.subModeAria")}
|
||||||
|
>
|
||||||
|
{items.map(({ id, labelKey }) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(id)}
|
||||||
|
aria-pressed={value === id}
|
||||||
|
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
value === id
|
||||||
|
? "bg-[var(--primary)] text-white"
|
||||||
|
: "text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(labelKey)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
src/components/reports/cards/BudgetAdherenceCard.tsx
Normal file
111
src/components/reports/cards/BudgetAdherenceCard.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Target } from "lucide-react";
|
||||||
|
import type { CartesBudgetAdherence } from "../../../shared/types";
|
||||||
|
|
||||||
|
export interface BudgetAdherenceCardProps {
|
||||||
|
adherence: CartesBudgetAdherence;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency(amount: number, language: string): string {
|
||||||
|
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "CAD",
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPct(pct: number | null, language: string): string {
|
||||||
|
if (pct === null) return "—";
|
||||||
|
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||||
|
style: "percent",
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
signDisplay: "always",
|
||||||
|
}).format(pct / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BudgetAdherenceCard({ adherence }: BudgetAdherenceCardProps) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const { categoriesInTarget, categoriesTotal, worstOverruns } = adherence;
|
||||||
|
const score = categoriesTotal === 0 ? null : (categoriesInTarget / categoriesTotal) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Target size={16} className="text-[var(--primary)]" />
|
||||||
|
<h3 className="text-sm font-medium text-[var(--foreground)]">
|
||||||
|
{t("reports.cartes.budgetAdherenceTitle")}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{categoriesTotal === 0 ? (
|
||||||
|
<div className="text-xs italic text-[var(--muted-foreground)] py-2">
|
||||||
|
{t("reports.cartes.budgetAdherenceEmpty")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold tabular-nums text-[var(--foreground)]">
|
||||||
|
{categoriesInTarget}
|
||||||
|
<span className="text-sm text-[var(--muted-foreground)] font-normal">
|
||||||
|
{" / "}
|
||||||
|
{categoriesTotal}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
{t("reports.cartes.budgetAdherenceSubtitle", {
|
||||||
|
score:
|
||||||
|
score !== null
|
||||||
|
? new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", {
|
||||||
|
style: "percent",
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(score / 100)
|
||||||
|
: "—",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{worstOverruns.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-2 pt-2 border-t border-[var(--border)]">
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||||
|
{t("reports.cartes.budgetAdherenceWorst")}
|
||||||
|
</div>
|
||||||
|
{worstOverruns.map((r) => {
|
||||||
|
const progressPct = r.budget > 0 ? Math.min((r.actual / r.budget) * 100, 200) : 0;
|
||||||
|
return (
|
||||||
|
<div key={r.categoryId} className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center justify-between gap-2 text-xs">
|
||||||
|
<span className="flex items-center gap-2 min-w-0">
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: r.categoryColor }}
|
||||||
|
/>
|
||||||
|
<span className="truncate text-[var(--foreground)]">{r.categoryName}</span>
|
||||||
|
</span>
|
||||||
|
<span className="tabular-nums text-[var(--muted-foreground)]">
|
||||||
|
{formatCurrency(r.actual, i18n.language)}
|
||||||
|
{" / "}
|
||||||
|
{formatCurrency(r.budget, i18n.language)}
|
||||||
|
<span className="text-[var(--negative)] ml-1 font-medium">
|
||||||
|
{formatPct(r.overrunPct, i18n.language)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="h-1.5 rounded-full bg-[var(--muted)] overflow-hidden"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full bg-[var(--negative)]"
|
||||||
|
style={{ width: `${Math.min(progressPct, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
src/components/reports/cards/IncomeExpenseOverlayChart.tsx
Normal file
97
src/components/reports/cards/IncomeExpenseOverlayChart.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ComposedChart,
|
||||||
|
Bar,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
CartesianGrid,
|
||||||
|
ReferenceLine,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from "recharts";
|
||||||
|
import type { CartesMonthFlow } from "../../../shared/types";
|
||||||
|
|
||||||
|
export interface IncomeExpenseOverlayChartProps {
|
||||||
|
flow: CartesMonthFlow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency(amount: number, language: string): string {
|
||||||
|
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "CAD",
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMonthShort(month: string, language: string): string {
|
||||||
|
const [y, m] = month.split("-").map(Number);
|
||||||
|
if (!Number.isFinite(y) || !Number.isFinite(m)) return month;
|
||||||
|
return new Intl.DateTimeFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||||
|
month: "short",
|
||||||
|
year: "2-digit",
|
||||||
|
}).format(new Date(y, m - 1, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IncomeExpenseOverlayChart({ flow }: IncomeExpenseOverlayChartProps) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
|
if (flow.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
|
||||||
|
{t("reports.empty.noData")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = flow.map((p) => ({
|
||||||
|
...p,
|
||||||
|
label: formatMonthShort(p.month, i18n.language),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
|
||||||
|
<div className="text-sm font-medium text-[var(--foreground)] mb-3">
|
||||||
|
{t("reports.cartes.flowChartTitle")}
|
||||||
|
</div>
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<ComposedChart data={data} margin={{ top: 10, right: 20, bottom: 0, left: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
|
||||||
|
<XAxis dataKey="label" stroke="var(--muted-foreground)" fontSize={11} />
|
||||||
|
<YAxis
|
||||||
|
stroke="var(--muted-foreground)"
|
||||||
|
fontSize={11}
|
||||||
|
tickFormatter={(v) => formatCurrency(v, i18n.language)}
|
||||||
|
width={80}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value) =>
|
||||||
|
typeof value === "number" ? formatCurrency(value, i18n.language) : String(value)
|
||||||
|
}
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "var(--card)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ paddingTop: 8, fontSize: 12, color: "var(--muted-foreground)" }}
|
||||||
|
/>
|
||||||
|
<ReferenceLine y={0} stroke="var(--border)" />
|
||||||
|
<Bar dataKey="income" name={t("reports.cartes.income")} fill="var(--positive)" />
|
||||||
|
<Bar dataKey="expenses" name={t("reports.cartes.expenses")} fill="var(--negative)" />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="net"
|
||||||
|
name={t("reports.cartes.net")}
|
||||||
|
stroke="var(--primary)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 2 }}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
src/components/reports/cards/KpiCard.tsx
Normal file
139
src/components/reports/cards/KpiCard.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import KpiSparkline from "./KpiSparkline";
|
||||||
|
import type { CartesKpi, CartesKpiId } from "../../../shared/types";
|
||||||
|
|
||||||
|
export interface KpiCardProps {
|
||||||
|
id: CartesKpiId;
|
||||||
|
title: string;
|
||||||
|
kpi: CartesKpi;
|
||||||
|
format: "currency" | "percent";
|
||||||
|
/** When true, positive deltas are rendered in red (e.g. rising expenses). */
|
||||||
|
deltaIsBadWhenUp?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency(amount: number, language: string): string {
|
||||||
|
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "CAD",
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPercent(value: number, language: string, signed = false): string {
|
||||||
|
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||||
|
style: "percent",
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
signDisplay: signed ? "always" : "auto",
|
||||||
|
}).format(value / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(value: number, format: "currency" | "percent", language: string): string {
|
||||||
|
return format === "currency" ? formatCurrency(value, language) : formatPercent(value, language);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDeltaAbs(
|
||||||
|
value: number,
|
||||||
|
format: "currency" | "percent",
|
||||||
|
language: string,
|
||||||
|
): string {
|
||||||
|
if (format === "currency") {
|
||||||
|
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "CAD",
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
signDisplay: "always",
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
// Savings rate delta in percentage points — not a % of %
|
||||||
|
const formatted = new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
signDisplay: "always",
|
||||||
|
}).format(value);
|
||||||
|
return `${formatted} pt`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeltaBadgeProps {
|
||||||
|
abs: number | null;
|
||||||
|
pct: number | null;
|
||||||
|
label: string;
|
||||||
|
format: "currency" | "percent";
|
||||||
|
language: string;
|
||||||
|
deltaIsBadWhenUp: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeltaBadge({ abs, pct, label, format, language, deltaIsBadWhenUp }: DeltaBadgeProps) {
|
||||||
|
if (abs === null) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<span className="text-[10px] uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-[var(--muted-foreground)] italic">—</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const isUp = abs >= 0;
|
||||||
|
const isBad = deltaIsBadWhenUp ? isUp : !isUp;
|
||||||
|
// Treat near-zero as neutral
|
||||||
|
const isNeutral = abs === 0;
|
||||||
|
const colorClass = isNeutral
|
||||||
|
? "text-[var(--muted-foreground)]"
|
||||||
|
: isBad
|
||||||
|
? "text-[var(--negative)]"
|
||||||
|
: "text-[var(--positive)]";
|
||||||
|
const absText = formatDeltaAbs(abs, format, language);
|
||||||
|
const pctText = pct === null ? "" : ` (${formatPercent(pct, language, true)})`;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<span className="text-[10px] uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs font-medium tabular-nums ${colorClass}`}>
|
||||||
|
{absText}
|
||||||
|
{pctText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KpiCard({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
kpi,
|
||||||
|
format,
|
||||||
|
deltaIsBadWhenUp = false,
|
||||||
|
}: KpiCardProps) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const language = i18n.language;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-kpi={id}
|
||||||
|
className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-3"
|
||||||
|
>
|
||||||
|
<div className="text-sm text-[var(--muted-foreground)]">{title}</div>
|
||||||
|
<div className="text-2xl font-bold tabular-nums text-[var(--foreground)]">
|
||||||
|
{formatValue(kpi.current, format, language)}
|
||||||
|
</div>
|
||||||
|
<KpiSparkline data={kpi.sparkline} />
|
||||||
|
<div className="flex items-start justify-between gap-2 pt-1 border-t border-[var(--border)]">
|
||||||
|
<DeltaBadge
|
||||||
|
abs={kpi.deltaMoMAbs}
|
||||||
|
pct={kpi.deltaMoMPct}
|
||||||
|
label={t("reports.cartes.deltaMoMLabel")}
|
||||||
|
format={format}
|
||||||
|
language={language}
|
||||||
|
deltaIsBadWhenUp={deltaIsBadWhenUp}
|
||||||
|
/>
|
||||||
|
<DeltaBadge
|
||||||
|
abs={kpi.deltaYoYAbs}
|
||||||
|
pct={kpi.deltaYoYPct}
|
||||||
|
label={t("reports.cartes.deltaYoYLabel")}
|
||||||
|
format={format}
|
||||||
|
language={language}
|
||||||
|
deltaIsBadWhenUp={deltaIsBadWhenUp}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
src/components/reports/cards/KpiSparkline.tsx
Normal file
50
src/components/reports/cards/KpiSparkline.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { LineChart, Line, ResponsiveContainer, YAxis, ReferenceDot } from "recharts";
|
||||||
|
import type { CartesSparklinePoint } from "../../../shared/types";
|
||||||
|
|
||||||
|
export interface KpiSparklineProps {
|
||||||
|
data: CartesSparklinePoint[];
|
||||||
|
color?: string;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact line chart with the reference month (the last point) highlighted
|
||||||
|
* by a filled dot. Rendered inside the KPI cards on the Cartes page.
|
||||||
|
*/
|
||||||
|
export default function KpiSparkline({
|
||||||
|
data,
|
||||||
|
color = "var(--primary)",
|
||||||
|
height = 40,
|
||||||
|
}: KpiSparklineProps) {
|
||||||
|
if (data.length === 0) {
|
||||||
|
return <div style={{ width: "100%", height }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartData = data.map((p, index) => ({ index, value: p.value, month: p.month }));
|
||||||
|
const lastIndex = chartData.length - 1;
|
||||||
|
const lastValue = chartData[lastIndex]?.value ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={height}>
|
||||||
|
<LineChart data={chartData} margin={{ top: 4, right: 6, bottom: 2, left: 2 }}>
|
||||||
|
<YAxis hide domain={["dataMin", "dataMax"]} />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="value"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={1.75}
|
||||||
|
dot={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
<ReferenceDot
|
||||||
|
x={lastIndex}
|
||||||
|
y={lastValue}
|
||||||
|
r={3.5}
|
||||||
|
fill={color}
|
||||||
|
stroke="var(--card)"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
src/components/reports/cards/SeasonalityCard.tsx
Normal file
106
src/components/reports/cards/SeasonalityCard.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { CalendarClock } from "lucide-react";
|
||||||
|
import type { CartesSeasonality } from "../../../shared/types";
|
||||||
|
|
||||||
|
export interface SeasonalityCardProps {
|
||||||
|
seasonality: CartesSeasonality;
|
||||||
|
referenceYear: number;
|
||||||
|
referenceMonth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency(amount: number, language: string): string {
|
||||||
|
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "CAD",
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPct(pct: number, language: string, signed = true): string {
|
||||||
|
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||||
|
style: "percent",
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
signDisplay: signed ? "always" : "auto",
|
||||||
|
}).format(pct / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMonthYear(year: number, month: number, language: string): string {
|
||||||
|
return new Intl.DateTimeFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(new Date(year, month - 1, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SeasonalityCard({
|
||||||
|
seasonality,
|
||||||
|
referenceYear,
|
||||||
|
referenceMonth,
|
||||||
|
}: SeasonalityCardProps) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const language = i18n.language;
|
||||||
|
const { referenceAmount, historicalYears, historicalAverage, deviationPct } = seasonality;
|
||||||
|
|
||||||
|
const refLabel = formatMonthYear(referenceYear, referenceMonth, language);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CalendarClock size={16} className="text-[var(--primary)]" />
|
||||||
|
<h3 className="text-sm font-medium text-[var(--foreground)]">
|
||||||
|
{t("reports.cartes.seasonalityTitle")}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{historicalYears.length === 0 ? (
|
||||||
|
<div className="text-xs italic text-[var(--muted-foreground)] py-2">
|
||||||
|
{t("reports.cartes.seasonalityEmpty")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<span className="text-xs text-[var(--muted-foreground)]">{refLabel}</span>
|
||||||
|
<span className="text-lg font-bold tabular-nums text-[var(--foreground)]">
|
||||||
|
{formatCurrency(referenceAmount, language)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1 text-xs">
|
||||||
|
{historicalYears.map((y) => (
|
||||||
|
<div
|
||||||
|
key={y.year}
|
||||||
|
className="flex items-center justify-between text-[var(--muted-foreground)]"
|
||||||
|
>
|
||||||
|
<span>{y.year}</span>
|
||||||
|
<span className="tabular-nums">{formatCurrency(y.amount, language)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{historicalAverage !== null && (
|
||||||
|
<div className="flex items-center justify-between border-t border-[var(--border)] pt-1 mt-1 text-[var(--foreground)]">
|
||||||
|
<span>{t("reports.cartes.seasonalityAverage")}</span>
|
||||||
|
<span className="tabular-nums font-medium">
|
||||||
|
{formatCurrency(historicalAverage, language)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{deviationPct !== null && (
|
||||||
|
<div
|
||||||
|
className={`text-xs font-medium ${
|
||||||
|
deviationPct > 5
|
||||||
|
? "text-[var(--negative)]"
|
||||||
|
: deviationPct < -5
|
||||||
|
? "text-[var(--positive)]"
|
||||||
|
: "text-[var(--muted-foreground)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t("reports.cartes.seasonalityDeviation", {
|
||||||
|
pct: formatPct(deviationPct, language),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
src/components/reports/cards/TopMoversList.tsx
Normal file
86
src/components/reports/cards/TopMoversList.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { TrendingUp, TrendingDown } from "lucide-react";
|
||||||
|
import type { CartesTopMover } from "../../../shared/types";
|
||||||
|
|
||||||
|
export interface TopMoversListProps {
|
||||||
|
movers: CartesTopMover[];
|
||||||
|
direction: "up" | "down";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSignedCurrency(amount: number, language: string): string {
|
||||||
|
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "CAD",
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
signDisplay: "always",
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPct(pct: number | null, language: string): string {
|
||||||
|
if (pct === null) return "—";
|
||||||
|
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||||
|
style: "percent",
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
signDisplay: "always",
|
||||||
|
}).format(pct / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryHref(categoryId: number | null): string {
|
||||||
|
if (categoryId === null) return "/transactions";
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
params.set("cat", String(categoryId));
|
||||||
|
return `/reports/category?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TopMoversList({ movers, direction }: TopMoversListProps) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
|
const title =
|
||||||
|
direction === "up"
|
||||||
|
? t("reports.cartes.topMoversUp")
|
||||||
|
: t("reports.cartes.topMoversDown");
|
||||||
|
const Icon = direction === "up" ? TrendingUp : TrendingDown;
|
||||||
|
const accentClass = direction === "up" ? "text-[var(--negative)]" : "text-[var(--positive)]";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon size={16} className={accentClass} />
|
||||||
|
<h3 className="text-sm font-medium text-[var(--foreground)]">{title}</h3>
|
||||||
|
</div>
|
||||||
|
{movers.length === 0 ? (
|
||||||
|
<div className="text-xs italic text-[var(--muted-foreground)] py-2">
|
||||||
|
{t("reports.empty.noData")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{movers.map((m) => (
|
||||||
|
<li key={`${m.categoryId ?? "uncat"}-${m.categoryName}`}>
|
||||||
|
<Link
|
||||||
|
to={categoryHref(m.categoryId)}
|
||||||
|
className="flex items-center justify-between gap-3 px-2 py-1.5 rounded-md hover:bg-[var(--muted)] transition-colors"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 min-w-0">
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: m.categoryColor }}
|
||||||
|
/>
|
||||||
|
<span className="truncate text-sm text-[var(--foreground)]">
|
||||||
|
{m.categoryName}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs font-medium tabular-nums ${accentClass}`}>
|
||||||
|
{formatSignedCurrency(m.deltaAbs, i18n.language)}
|
||||||
|
<span className="text-[var(--muted-foreground)] ml-1">
|
||||||
|
{formatPct(m.deltaPct, i18n.language)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/hooks/useCartes.test.ts
Normal file
25
src/hooks/useCartes.test.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { defaultCartesReferencePeriod } from "./useCartes";
|
||||||
|
|
||||||
|
describe("defaultCartesReferencePeriod", () => {
|
||||||
|
it("returns the month before the given date", () => {
|
||||||
|
expect(defaultCartesReferencePeriod(new Date(2026, 3, 15))).toEqual({
|
||||||
|
year: 2026,
|
||||||
|
month: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps around January to December of the previous year", () => {
|
||||||
|
expect(defaultCartesReferencePeriod(new Date(2026, 0, 10))).toEqual({
|
||||||
|
year: 2025,
|
||||||
|
month: 12,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles the last day of a month", () => {
|
||||||
|
expect(defaultCartesReferencePeriod(new Date(2026, 5, 30))).toEqual({
|
||||||
|
year: 2026,
|
||||||
|
month: 5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
103
src/hooks/useCartes.ts
Normal file
103
src/hooks/useCartes.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { useReducer, useCallback, useEffect, useRef } from "react";
|
||||||
|
import type { CartesSnapshot } from "../shared/types";
|
||||||
|
import { getCartesSnapshot } from "../services/reportService";
|
||||||
|
import { useReportsPeriod } from "./useReportsPeriod";
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
snapshot: CartesSnapshot | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } }
|
||||||
|
| { type: "SET_LOADING"; payload: boolean }
|
||||||
|
| { type: "SET_SNAPSHOT"; payload: CartesSnapshot }
|
||||||
|
| { type: "SET_ERROR"; payload: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default reference period for the Cartes report: the month preceding `today`.
|
||||||
|
* January wraps around to December of the previous year. Exported for tests.
|
||||||
|
*/
|
||||||
|
export function defaultCartesReferencePeriod(
|
||||||
|
today: Date = new Date(),
|
||||||
|
): { year: number; month: number } {
|
||||||
|
const y = today.getFullYear();
|
||||||
|
const m = today.getMonth() + 1;
|
||||||
|
if (m === 1) return { year: y - 1, month: 12 };
|
||||||
|
return { year: y, month: m - 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultRef = defaultCartesReferencePeriod();
|
||||||
|
const initialState: State = {
|
||||||
|
year: defaultRef.year,
|
||||||
|
month: defaultRef.month,
|
||||||
|
snapshot: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function reducer(state: State, action: Action): State {
|
||||||
|
switch (action.type) {
|
||||||
|
case "SET_REFERENCE_PERIOD":
|
||||||
|
return { ...state, year: action.payload.year, month: action.payload.month };
|
||||||
|
case "SET_LOADING":
|
||||||
|
return { ...state, isLoading: action.payload };
|
||||||
|
case "SET_SNAPSHOT":
|
||||||
|
return { ...state, snapshot: action.payload, isLoading: false, error: null };
|
||||||
|
case "SET_ERROR":
|
||||||
|
return { ...state, error: action.payload, isLoading: false };
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCartes() {
|
||||||
|
const { from, to, period, setPeriod, setCustomDates } = useReportsPeriod();
|
||||||
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
|
const fetchIdRef = useRef(0);
|
||||||
|
|
||||||
|
const fetch = useCallback(async (year: number, month: number) => {
|
||||||
|
const id = ++fetchIdRef.current;
|
||||||
|
dispatch({ type: "SET_LOADING", payload: true });
|
||||||
|
try {
|
||||||
|
const snapshot = await getCartesSnapshot(year, month);
|
||||||
|
if (id !== fetchIdRef.current) return;
|
||||||
|
dispatch({ type: "SET_SNAPSHOT", payload: snapshot });
|
||||||
|
} catch (e) {
|
||||||
|
if (id !== fetchIdRef.current) return;
|
||||||
|
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(state.year, state.month);
|
||||||
|
}, [fetch, state.year, state.month]);
|
||||||
|
|
||||||
|
// Keep the reference month in sync with the URL `to` date, so navigating
|
||||||
|
// via PeriodSelector works as expected.
|
||||||
|
useEffect(() => {
|
||||||
|
const [y, m] = to.split("-").map(Number);
|
||||||
|
if (!Number.isFinite(y) || !Number.isFinite(m)) return;
|
||||||
|
if (y !== state.year || m !== state.month) {
|
||||||
|
dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year: y, month: m } });
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [to]);
|
||||||
|
|
||||||
|
const setReferencePeriod = useCallback((year: number, month: number) => {
|
||||||
|
dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year, month } });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
setReferencePeriod,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
period,
|
||||||
|
setPeriod,
|
||||||
|
setCustomDates,
|
||||||
|
};
|
||||||
|
}
|
||||||
47
src/hooks/useCompare.test.ts
Normal file
47
src/hooks/useCompare.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { previousMonth, defaultReferencePeriod, comparisonMeta } from "./useCompare";
|
||||||
|
|
||||||
|
describe("useCompare helpers", () => {
|
||||||
|
describe("previousMonth", () => {
|
||||||
|
it("goes back one month within the same year", () => {
|
||||||
|
expect(previousMonth(2026, 3)).toEqual({ year: 2026, month: 2 });
|
||||||
|
expect(previousMonth(2026, 12)).toEqual({ year: 2026, month: 11 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps around January to December of previous year", () => {
|
||||||
|
expect(previousMonth(2026, 1)).toEqual({ year: 2025, month: 12 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("defaultReferencePeriod", () => {
|
||||||
|
it("returns the month before the given date", () => {
|
||||||
|
expect(defaultReferencePeriod(new Date(2026, 3, 15))).toEqual({ year: 2026, month: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps around when today is in January", () => {
|
||||||
|
expect(defaultReferencePeriod(new Date(2026, 0, 10))).toEqual({ year: 2025, month: 12 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles the last day of a month", () => {
|
||||||
|
expect(defaultReferencePeriod(new Date(2026, 6, 31))).toEqual({ year: 2026, month: 6 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("comparisonMeta", () => {
|
||||||
|
it("MoM returns the previous month", () => {
|
||||||
|
expect(comparisonMeta("mom", 2026, 3)).toEqual({ previousYear: 2026, previousMonth: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("MoM wraps around January", () => {
|
||||||
|
expect(comparisonMeta("mom", 2026, 1)).toEqual({ previousYear: 2025, previousMonth: 12 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("YoY returns the same month in the previous year", () => {
|
||||||
|
expect(comparisonMeta("yoy", 2026, 3)).toEqual({ previousYear: 2025, previousMonth: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("YoY for January stays on January of previous year", () => {
|
||||||
|
expect(comparisonMeta("yoy", 2026, 1)).toEqual({ previousYear: 2025, previousMonth: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -3,10 +3,12 @@ import type { CategoryDelta } from "../shared/types";
|
||||||
import { getCompareMonthOverMonth, getCompareYearOverYear } from "../services/reportService";
|
import { getCompareMonthOverMonth, getCompareYearOverYear } from "../services/reportService";
|
||||||
import { useReportsPeriod } from "./useReportsPeriod";
|
import { useReportsPeriod } from "./useReportsPeriod";
|
||||||
|
|
||||||
export type CompareMode = "mom" | "yoy" | "budget";
|
export type CompareMode = "actual" | "budget";
|
||||||
|
export type CompareSubMode = "mom" | "yoy";
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
mode: CompareMode;
|
mode: CompareMode;
|
||||||
|
subMode: CompareSubMode;
|
||||||
year: number;
|
year: number;
|
||||||
month: number;
|
month: number;
|
||||||
rows: CategoryDelta[];
|
rows: CategoryDelta[];
|
||||||
|
|
@ -16,16 +18,52 @@ interface State {
|
||||||
|
|
||||||
type Action =
|
type Action =
|
||||||
| { type: "SET_MODE"; payload: CompareMode }
|
| { type: "SET_MODE"; payload: CompareMode }
|
||||||
| { type: "SET_PERIOD"; payload: { year: number; month: number } }
|
| { type: "SET_SUB_MODE"; payload: CompareSubMode }
|
||||||
|
| { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } }
|
||||||
| { type: "SET_LOADING"; payload: boolean }
|
| { type: "SET_LOADING"; payload: boolean }
|
||||||
| { type: "SET_ROWS"; payload: CategoryDelta[] }
|
| { type: "SET_ROWS"; payload: CategoryDelta[] }
|
||||||
| { type: "SET_ERROR"; payload: string };
|
| { type: "SET_ERROR"; payload: string };
|
||||||
|
|
||||||
const today = new Date();
|
/**
|
||||||
|
* Wrap-around helper: returns (year, month) shifted back by one month.
|
||||||
|
* Example: previousMonth(2026, 1) -> { year: 2025, month: 12 }.
|
||||||
|
*/
|
||||||
|
export function previousMonth(year: number, month: number): { year: number; month: number } {
|
||||||
|
if (month === 1) return { year: year - 1, month: 12 };
|
||||||
|
return { year, month: month - 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default reference period for the Compare report: the month preceding `today`.
|
||||||
|
* Exported for unit tests.
|
||||||
|
*/
|
||||||
|
export function defaultReferencePeriod(today: Date = new Date()): { year: number; month: number } {
|
||||||
|
return previousMonth(today.getFullYear(), today.getMonth() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the comparison meta for a given subMode + reference period.
|
||||||
|
* - MoM: previous month vs current month
|
||||||
|
* - YoY: same month previous year vs current year
|
||||||
|
*/
|
||||||
|
export function comparisonMeta(
|
||||||
|
subMode: CompareSubMode,
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
): { previousYear: number; previousMonth: number } {
|
||||||
|
if (subMode === "mom") {
|
||||||
|
const prev = previousMonth(year, month);
|
||||||
|
return { previousYear: prev.year, previousMonth: prev.month };
|
||||||
|
}
|
||||||
|
return { previousYear: year - 1, previousMonth: month };
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultRef = defaultReferencePeriod();
|
||||||
const initialState: State = {
|
const initialState: State = {
|
||||||
mode: "mom",
|
mode: "actual",
|
||||||
year: today.getFullYear(),
|
subMode: "mom",
|
||||||
month: today.getMonth() + 1,
|
year: defaultRef.year,
|
||||||
|
month: defaultRef.month,
|
||||||
rows: [],
|
rows: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
@ -35,7 +73,9 @@ function reducer(state: State, action: Action): State {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "SET_MODE":
|
case "SET_MODE":
|
||||||
return { ...state, mode: action.payload };
|
return { ...state, mode: action.payload };
|
||||||
case "SET_PERIOD":
|
case "SET_SUB_MODE":
|
||||||
|
return { ...state, subMode: action.payload };
|
||||||
|
case "SET_REFERENCE_PERIOD":
|
||||||
return { ...state, year: action.payload.year, month: action.payload.month };
|
return { ...state, year: action.payload.year, month: action.payload.month };
|
||||||
case "SET_LOADING":
|
case "SET_LOADING":
|
||||||
return { ...state, isLoading: action.payload };
|
return { ...state, isLoading: action.payload };
|
||||||
|
|
@ -53,13 +93,14 @@ export function useCompare() {
|
||||||
const [state, dispatch] = useReducer(reducer, initialState);
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
const fetchIdRef = useRef(0);
|
const fetchIdRef = useRef(0);
|
||||||
|
|
||||||
const fetch = useCallback(async (mode: CompareMode, year: number, month: number) => {
|
const fetch = useCallback(
|
||||||
|
async (mode: CompareMode, subMode: CompareSubMode, year: number, month: number) => {
|
||||||
if (mode === "budget") return; // Budget view uses BudgetVsActualTable directly
|
if (mode === "budget") return; // Budget view uses BudgetVsActualTable directly
|
||||||
const id = ++fetchIdRef.current;
|
const id = ++fetchIdRef.current;
|
||||||
dispatch({ type: "SET_LOADING", payload: true });
|
dispatch({ type: "SET_LOADING", payload: true });
|
||||||
try {
|
try {
|
||||||
const rows =
|
const rows =
|
||||||
mode === "mom"
|
subMode === "mom"
|
||||||
? await getCompareMonthOverMonth(year, month)
|
? await getCompareMonthOverMonth(year, month)
|
||||||
: await getCompareYearOverYear(year);
|
: await getCompareYearOverYear(year);
|
||||||
if (id !== fetchIdRef.current) return;
|
if (id !== fetchIdRef.current) return;
|
||||||
|
|
@ -68,18 +109,22 @@ export function useCompare() {
|
||||||
if (id !== fetchIdRef.current) return;
|
if (id !== fetchIdRef.current) return;
|
||||||
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
||||||
}
|
}
|
||||||
}, []);
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(state.mode, state.year, state.month);
|
fetch(state.mode, state.subMode, state.year, state.month);
|
||||||
}, [fetch, state.mode, state.year, state.month]);
|
}, [fetch, state.mode, state.subMode, state.year, state.month]);
|
||||||
|
|
||||||
// When the URL period changes, use the `to` date to infer the target year/month.
|
// When the URL period changes, align the reference month with `to`.
|
||||||
|
// The explicit dropdown remains the primary selector — this effect only
|
||||||
|
// keeps the two in sync when the user navigates via PeriodSelector.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const [y, m] = to.split("-").map(Number);
|
const [y, m] = to.split("-").map(Number);
|
||||||
if (!Number.isFinite(y) || !Number.isFinite(m)) return;
|
if (!Number.isFinite(y) || !Number.isFinite(m)) return;
|
||||||
if (y !== state.year || m !== state.month) {
|
if (y !== state.year || m !== state.month) {
|
||||||
dispatch({ type: "SET_PERIOD", payload: { year: y, month: m } });
|
dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year: y, month: m } });
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [to]);
|
}, [to]);
|
||||||
|
|
@ -88,9 +133,13 @@ export function useCompare() {
|
||||||
dispatch({ type: "SET_MODE", payload: m });
|
dispatch({ type: "SET_MODE", payload: m });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setTargetPeriod = useCallback((year: number, month: number) => {
|
const setSubMode = useCallback((s: CompareSubMode) => {
|
||||||
dispatch({ type: "SET_PERIOD", payload: { year, month } });
|
dispatch({ type: "SET_SUB_MODE", payload: s });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { ...state, setMode, setTargetPeriod, from, to };
|
const setReferencePeriod = useCallback((year: number, month: number) => {
|
||||||
|
dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year, month } });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { ...state, setMode, setSubMode, setReferencePeriod, from, to };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -395,18 +395,43 @@
|
||||||
"trends": "Trends",
|
"trends": "Trends",
|
||||||
"trendsDescription": "Where you're heading over 12 months",
|
"trendsDescription": "Where you're heading over 12 months",
|
||||||
"compare": "Compare",
|
"compare": "Compare",
|
||||||
"compareDescription": "Month, year, and budget comparisons",
|
"compareDescription": "Compare a reference month against previous month, previous year, or budget",
|
||||||
"categoryZoom": "Category Analysis",
|
"categoryZoom": "Category Analysis",
|
||||||
"categoryZoomDescription": "Zoom in on a single category"
|
"categoryZoomDescription": "Zoom in on a single category",
|
||||||
|
"cartes": "Cards",
|
||||||
|
"cartesDescription": "KPI dashboard with sparklines, top movers, budget adherence, and seasonality"
|
||||||
},
|
},
|
||||||
"trends": {
|
"trends": {
|
||||||
"subviewGlobal": "Global flow",
|
"subviewGlobal": "Global flow",
|
||||||
"subviewByCategory": "By category"
|
"subviewByCategory": "By category"
|
||||||
},
|
},
|
||||||
"compare": {
|
"compare": {
|
||||||
"modeMoM": "Month vs previous month",
|
"modeActual": "Actual vs actual",
|
||||||
"modeYoY": "Year vs previous year",
|
"modeBudget": "Actual vs budget",
|
||||||
"modeBudget": "Actual vs budget"
|
"subModeMoM": "Previous month",
|
||||||
|
"subModeYoY": "Previous year",
|
||||||
|
"subModeAria": "Comparison period",
|
||||||
|
"referenceMonth": "Reference month"
|
||||||
|
},
|
||||||
|
"cartes": {
|
||||||
|
"kpiSectionAria": "Key indicators for the reference month",
|
||||||
|
"income": "Income",
|
||||||
|
"expenses": "Expenses",
|
||||||
|
"net": "Net balance",
|
||||||
|
"savingsRate": "Savings rate",
|
||||||
|
"deltaMoMLabel": "vs last month",
|
||||||
|
"deltaYoYLabel": "vs last year",
|
||||||
|
"flowChartTitle": "Income vs expenses — last 12 months",
|
||||||
|
"topMoversUp": "Biggest increases",
|
||||||
|
"topMoversDown": "Biggest decreases",
|
||||||
|
"budgetAdherenceTitle": "Budget adherence",
|
||||||
|
"budgetAdherenceSubtitle": "{{score}} of budgeted categories on target",
|
||||||
|
"budgetAdherenceEmpty": "No budgeted categories this month",
|
||||||
|
"budgetAdherenceWorst": "Worst overruns",
|
||||||
|
"seasonalityTitle": "Seasonality",
|
||||||
|
"seasonalityEmpty": "Not enough history for this month",
|
||||||
|
"seasonalityAverage": "Average",
|
||||||
|
"seasonalityDeviation": "{{pct}} vs average"
|
||||||
},
|
},
|
||||||
"category": {
|
"category": {
|
||||||
"selectCategory": "Select a category",
|
"selectCategory": "Select a category",
|
||||||
|
|
|
||||||
|
|
@ -395,18 +395,43 @@
|
||||||
"trends": "Tendances",
|
"trends": "Tendances",
|
||||||
"trendsDescription": "Où vous allez sur 12 mois",
|
"trendsDescription": "Où vous allez sur 12 mois",
|
||||||
"compare": "Comparables",
|
"compare": "Comparables",
|
||||||
"compareDescription": "Comparaisons mois, année et budget",
|
"compareDescription": "Comparer un mois de référence au précédent, à l'année passée ou au budget",
|
||||||
"categoryZoom": "Analyse par catégorie",
|
"categoryZoom": "Analyse par catégorie",
|
||||||
"categoryZoomDescription": "Zoom sur une catégorie"
|
"categoryZoomDescription": "Zoom sur une catégorie",
|
||||||
|
"cartes": "Cartes",
|
||||||
|
"cartesDescription": "Tableau de bord KPI, sparklines, top mouvements, budget et saisonnalité"
|
||||||
},
|
},
|
||||||
"trends": {
|
"trends": {
|
||||||
"subviewGlobal": "Flux global",
|
"subviewGlobal": "Flux global",
|
||||||
"subviewByCategory": "Par catégorie"
|
"subviewByCategory": "Par catégorie"
|
||||||
},
|
},
|
||||||
"compare": {
|
"compare": {
|
||||||
"modeMoM": "Mois vs mois précédent",
|
"modeActual": "Réel vs réel",
|
||||||
"modeYoY": "Année vs année précédente",
|
"modeBudget": "Réel vs budget",
|
||||||
"modeBudget": "Réel vs budget"
|
"subModeMoM": "Mois précédent",
|
||||||
|
"subModeYoY": "Année précédente",
|
||||||
|
"subModeAria": "Période de comparaison",
|
||||||
|
"referenceMonth": "Mois de référence"
|
||||||
|
},
|
||||||
|
"cartes": {
|
||||||
|
"kpiSectionAria": "Indicateurs clés du mois de référence",
|
||||||
|
"income": "Revenus",
|
||||||
|
"expenses": "Dépenses",
|
||||||
|
"net": "Solde net",
|
||||||
|
"savingsRate": "Taux d'épargne",
|
||||||
|
"deltaMoMLabel": "vs mois précédent",
|
||||||
|
"deltaYoYLabel": "vs l'an dernier",
|
||||||
|
"flowChartTitle": "Revenus vs dépenses — 12 derniers mois",
|
||||||
|
"topMoversUp": "Catégories en hausse",
|
||||||
|
"topMoversDown": "Catégories en baisse",
|
||||||
|
"budgetAdherenceTitle": "Respect du budget",
|
||||||
|
"budgetAdherenceSubtitle": "{{score}} des catégories avec budget sont dans la cible",
|
||||||
|
"budgetAdherenceEmpty": "Aucune catégorie avec budget ce mois-ci",
|
||||||
|
"budgetAdherenceWorst": "Pires dépassements",
|
||||||
|
"seasonalityTitle": "Saisonnalité",
|
||||||
|
"seasonalityEmpty": "Pas assez d'historique pour ce mois",
|
||||||
|
"seasonalityAverage": "Moyenne",
|
||||||
|
"seasonalityDeviation": "{{pct}} par rapport à la moyenne"
|
||||||
},
|
},
|
||||||
"category": {
|
"category": {
|
||||||
"selectCategory": "Choisir une catégorie",
|
"selectCategory": "Choisir une catégorie",
|
||||||
|
|
|
||||||
120
src/pages/ReportsCartesPage.tsx
Normal file
120
src/pages/ReportsCartesPage.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||||
|
import CompareReferenceMonthPicker from "../components/reports/CompareReferenceMonthPicker";
|
||||||
|
import KpiCard from "../components/reports/cards/KpiCard";
|
||||||
|
import IncomeExpenseOverlayChart from "../components/reports/cards/IncomeExpenseOverlayChart";
|
||||||
|
import TopMoversList from "../components/reports/cards/TopMoversList";
|
||||||
|
import BudgetAdherenceCard from "../components/reports/cards/BudgetAdherenceCard";
|
||||||
|
import SeasonalityCard from "../components/reports/cards/SeasonalityCard";
|
||||||
|
import { useCartes } from "../hooks/useCartes";
|
||||||
|
|
||||||
|
export default function ReportsCartesPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const {
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
snapshot,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
setReferencePeriod,
|
||||||
|
period,
|
||||||
|
setPeriod,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
setCustomDates,
|
||||||
|
} = useCartes();
|
||||||
|
|
||||||
|
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={isLoading ? "opacity-60" : ""}>
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<Link
|
||||||
|
to={`/reports${preserveSearch}`}
|
||||||
|
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] p-1 rounded-md hover:bg-[var(--muted)]"
|
||||||
|
aria-label={t("reports.hub.title")}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl font-bold">{t("reports.hub.cartes")}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6 flex-wrap">
|
||||||
|
<PeriodSelector
|
||||||
|
value={period}
|
||||||
|
onChange={setPeriod}
|
||||||
|
customDateFrom={from}
|
||||||
|
customDateTo={to}
|
||||||
|
onCustomDateChange={setCustomDates}
|
||||||
|
/>
|
||||||
|
<CompareReferenceMonthPicker year={year} month={month} onChange={setReferencePeriod} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!snapshot ? (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
|
||||||
|
{t("reports.empty.noData")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<section
|
||||||
|
className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3"
|
||||||
|
aria-label={t("reports.cartes.kpiSectionAria")}
|
||||||
|
>
|
||||||
|
<KpiCard
|
||||||
|
id="income"
|
||||||
|
title={t("reports.cartes.income")}
|
||||||
|
kpi={snapshot.kpis.income}
|
||||||
|
format="currency"
|
||||||
|
deltaIsBadWhenUp={false}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
id="expenses"
|
||||||
|
title={t("reports.cartes.expenses")}
|
||||||
|
kpi={snapshot.kpis.expenses}
|
||||||
|
format="currency"
|
||||||
|
deltaIsBadWhenUp={true}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
id="net"
|
||||||
|
title={t("reports.cartes.net")}
|
||||||
|
kpi={snapshot.kpis.net}
|
||||||
|
format="currency"
|
||||||
|
deltaIsBadWhenUp={false}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
id="savingsRate"
|
||||||
|
title={t("reports.cartes.savingsRate")}
|
||||||
|
kpi={snapshot.kpis.savingsRate}
|
||||||
|
format="percent"
|
||||||
|
deltaIsBadWhenUp={false}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<IncomeExpenseOverlayChart flow={snapshot.flow12Months} />
|
||||||
|
|
||||||
|
<section className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
|
<TopMoversList movers={snapshot.topMoversUp} direction="up" />
|
||||||
|
<TopMoversList movers={snapshot.topMoversDown} direction="down" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
|
<BudgetAdherenceCard adherence={snapshot.budgetAdherence} />
|
||||||
|
<SeasonalityCard
|
||||||
|
seasonality={snapshot.seasonality}
|
||||||
|
referenceYear={year}
|
||||||
|
referenceMonth={month}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,42 +4,53 @@ import { Link } from "react-router-dom";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||||
import CompareModeTabs from "../components/reports/CompareModeTabs";
|
import CompareModeTabs from "../components/reports/CompareModeTabs";
|
||||||
|
import CompareSubModeToggle from "../components/reports/CompareSubModeToggle";
|
||||||
|
import CompareReferenceMonthPicker from "../components/reports/CompareReferenceMonthPicker";
|
||||||
import ComparePeriodTable from "../components/reports/ComparePeriodTable";
|
import ComparePeriodTable from "../components/reports/ComparePeriodTable";
|
||||||
import ComparePeriodChart from "../components/reports/ComparePeriodChart";
|
import ComparePeriodChart from "../components/reports/ComparePeriodChart";
|
||||||
import CompareBudgetView from "../components/reports/CompareBudgetView";
|
import CompareBudgetView from "../components/reports/CompareBudgetView";
|
||||||
import ViewModeToggle, { readViewMode, type ViewMode } from "../components/reports/ViewModeToggle";
|
import ViewModeToggle, { readViewMode, type ViewMode } from "../components/reports/ViewModeToggle";
|
||||||
import { useCompare } from "../hooks/useCompare";
|
import { useCompare, comparisonMeta } from "../hooks/useCompare";
|
||||||
import { useReportsPeriod } from "../hooks/useReportsPeriod";
|
import { useReportsPeriod } from "../hooks/useReportsPeriod";
|
||||||
|
|
||||||
const STORAGE_KEY = "reports-viewmode-compare";
|
const STORAGE_KEY = "reports-viewmode-compare";
|
||||||
|
|
||||||
const MONTH_NAMES_EN = [
|
function formatMonthLabel(year: number, month: number, language: string): string {
|
||||||
"January", "February", "March", "April", "May", "June",
|
const date = new Date(year, month - 1, 1);
|
||||||
"July", "August", "September", "October", "November", "December",
|
return new Intl.DateTimeFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||||
];
|
month: "long",
|
||||||
const MONTH_NAMES_FR = [
|
year: "numeric",
|
||||||
"Janvier", "Février", "Mars", "Avril", "Mai", "Juin",
|
}).format(date);
|
||||||
"Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre",
|
|
||||||
];
|
|
||||||
|
|
||||||
function monthName(month: number, language: string): string {
|
|
||||||
return (language === "fr" ? MONTH_NAMES_FR : MONTH_NAMES_EN)[month - 1] ?? String(month);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ReportsComparePage() {
|
export default function ReportsComparePage() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
|
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
|
||||||
const { mode, setMode, year, month, rows, isLoading, error } = useCompare();
|
const {
|
||||||
|
mode,
|
||||||
|
subMode,
|
||||||
|
setMode,
|
||||||
|
setSubMode,
|
||||||
|
setReferencePeriod,
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
rows,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useCompare();
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY));
|
const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY));
|
||||||
|
|
||||||
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
|
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
|
||||||
|
|
||||||
const previousLabel =
|
const { previousYear, previousMonth: prevMonth } = comparisonMeta(subMode, year, month);
|
||||||
mode === "mom"
|
|
||||||
? `${monthName(month === 1 ? 12 : month - 1, i18n.language)} ${month === 1 ? year - 1 : year}`
|
|
||||||
: `${year - 1}`;
|
|
||||||
const currentLabel =
|
const currentLabel =
|
||||||
mode === "mom" ? `${monthName(month, i18n.language)} ${year}` : `${year}`;
|
subMode === "mom" ? formatMonthLabel(year, month, i18n.language) : String(year);
|
||||||
|
const previousLabel =
|
||||||
|
subMode === "mom"
|
||||||
|
? formatMonthLabel(previousYear, prevMonth, i18n.language)
|
||||||
|
: String(previousYear);
|
||||||
|
|
||||||
|
const showActualControls = mode === "actual";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={isLoading ? "opacity-60" : ""}>
|
<div className={isLoading ? "opacity-60" : ""}>
|
||||||
|
|
@ -54,7 +65,7 @@ export default function ReportsComparePage() {
|
||||||
<h1 className="text-2xl font-bold">{t("reports.hub.compare")}</h1>
|
<h1 className="text-2xl font-bold">{t("reports.hub.compare")}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 flex-wrap">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4 flex-wrap">
|
||||||
<PeriodSelector
|
<PeriodSelector
|
||||||
value={period}
|
value={period}
|
||||||
onChange={setPeriod}
|
onChange={setPeriod}
|
||||||
|
|
@ -64,10 +75,23 @@ export default function ReportsComparePage() {
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2 items-center flex-wrap">
|
<div className="flex gap-2 items-center flex-wrap">
|
||||||
<CompareModeTabs value={mode} onChange={setMode} />
|
<CompareModeTabs value={mode} onChange={setMode} />
|
||||||
{mode !== "budget" && (
|
</div>
|
||||||
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6 flex-wrap">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<CompareReferenceMonthPicker
|
||||||
|
year={year}
|
||||||
|
month={month}
|
||||||
|
onChange={setReferencePeriod}
|
||||||
|
/>
|
||||||
|
{showActualControls && (
|
||||||
|
<CompareSubModeToggle value={subMode} onChange={setSubMode} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{showActualControls && (
|
||||||
|
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|
@ -79,7 +103,11 @@ export default function ReportsComparePage() {
|
||||||
{mode === "budget" ? (
|
{mode === "budget" ? (
|
||||||
<CompareBudgetView year={year} month={month} />
|
<CompareBudgetView year={year} month={month} />
|
||||||
) : viewMode === "chart" ? (
|
) : viewMode === "chart" ? (
|
||||||
<ComparePeriodChart rows={rows} />
|
<ComparePeriodChart
|
||||||
|
rows={rows}
|
||||||
|
previousLabel={previousLabel}
|
||||||
|
currentLabel={currentLabel}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ComparePeriodTable rows={rows} previousLabel={previousLabel} currentLabel={currentLabel} />
|
<ComparePeriodTable rows={rows} previousLabel={previousLabel} currentLabel={currentLabel} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Sparkles, TrendingUp, Scale, Search } from "lucide-react";
|
import { Sparkles, TrendingUp, Scale, Search, LayoutDashboard } from "lucide-react";
|
||||||
import { PageHelp } from "../components/shared/PageHelp";
|
import { PageHelp } from "../components/shared/PageHelp";
|
||||||
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||||
import HubHighlightsPanel from "../components/reports/HubHighlightsPanel";
|
import HubHighlightsPanel from "../components/reports/HubHighlightsPanel";
|
||||||
|
|
@ -38,6 +38,12 @@ export default function ReportsPage() {
|
||||||
title: t("reports.hub.categoryZoom"),
|
title: t("reports.hub.categoryZoom"),
|
||||||
description: t("reports.hub.categoryZoomDescription"),
|
description: t("reports.hub.categoryZoomDescription"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: `/reports/cartes${preserveSearch}`,
|
||||||
|
icon: <LayoutDashboard size={24} />,
|
||||||
|
title: t("reports.hub.cartes"),
|
||||||
|
description: t("reports.hub.cartesDescription"),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -62,7 +68,7 @@ export default function ReportsPage() {
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-[var(--muted-foreground)] mb-3">
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-[var(--muted-foreground)] mb-3">
|
||||||
{t("reports.hub.explore")}
|
{t("reports.hub.explore")}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-3">
|
||||||
{navCards.map((card) => (
|
{navCards.map((card) => (
|
||||||
<HubReportNavCard key={card.to} {...card} />
|
<HubReportNavCard key={card.to} {...card} />
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
229
src/services/reportService.cartes.test.ts
Normal file
229
src/services/reportService.cartes.test.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { shiftMonth, getCartesSnapshot } from "./reportService";
|
||||||
|
|
||||||
|
vi.mock("./db", () => ({
|
||||||
|
getDb: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { getDb } from "./db";
|
||||||
|
|
||||||
|
const mockSelect = vi.fn();
|
||||||
|
const mockDb = { select: mockSelect };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(getDb).mockResolvedValue(mockDb as never);
|
||||||
|
mockSelect.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shiftMonth", () => {
|
||||||
|
it("shifts forward within a year", () => {
|
||||||
|
expect(shiftMonth(2026, 1, 2)).toEqual({ year: 2026, month: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shifts backward within a year", () => {
|
||||||
|
expect(shiftMonth(2026, 6, -3)).toEqual({ year: 2026, month: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps around January to the previous year", () => {
|
||||||
|
expect(shiftMonth(2026, 1, -1)).toEqual({ year: 2025, month: 12 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps past multiple years back", () => {
|
||||||
|
expect(shiftMonth(2026, 4, -24)).toEqual({ year: 2024, month: 4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps past year forward", () => {
|
||||||
|
expect(shiftMonth(2025, 11, 3)).toEqual({ year: 2026, month: 2 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch mock SELECT responses based on the SQL fragment being queried.
|
||||||
|
* Each entry returns the canned rows for queries whose text contains `match`.
|
||||||
|
*/
|
||||||
|
function routeSelect(routes: { match: string; rows: unknown[] }[]): void {
|
||||||
|
mockSelect.mockImplementation((sql: string) => {
|
||||||
|
for (const r of routes) {
|
||||||
|
if (sql.includes(r.match)) return Promise.resolve(r.rows);
|
||||||
|
}
|
||||||
|
return Promise.resolve([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("getCartesSnapshot", () => {
|
||||||
|
it("returns zero-filled KPIs when there is no data", async () => {
|
||||||
|
routeSelect([]);
|
||||||
|
const snapshot = await getCartesSnapshot(2026, 3);
|
||||||
|
expect(snapshot.referenceYear).toBe(2026);
|
||||||
|
expect(snapshot.referenceMonth).toBe(3);
|
||||||
|
expect(snapshot.kpis.income.current).toBe(0);
|
||||||
|
expect(snapshot.kpis.expenses.current).toBe(0);
|
||||||
|
expect(snapshot.kpis.net.current).toBe(0);
|
||||||
|
expect(snapshot.kpis.savingsRate.current).toBe(0);
|
||||||
|
expect(snapshot.kpis.income.sparkline).toHaveLength(13);
|
||||||
|
expect(snapshot.flow12Months).toHaveLength(12);
|
||||||
|
expect(snapshot.topMoversUp).toHaveLength(0);
|
||||||
|
expect(snapshot.topMoversDown).toHaveLength(0);
|
||||||
|
expect(snapshot.budgetAdherence.categoriesTotal).toBe(0);
|
||||||
|
expect(snapshot.seasonality.historicalYears).toHaveLength(0);
|
||||||
|
expect(snapshot.seasonality.historicalAverage).toBeNull();
|
||||||
|
expect(snapshot.seasonality.deviationPct).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes MoM and YoY deltas from a monthly flow stream", async () => {
|
||||||
|
// Reference = 2026-03
|
||||||
|
routeSelect([
|
||||||
|
{
|
||||||
|
match: "strftime('%Y-%m', date)",
|
||||||
|
rows: [
|
||||||
|
{ month: "2025-03", income: 3000, expenses: 1800 }, // YoY comparison
|
||||||
|
{ month: "2026-02", income: 4000, expenses: 2000 }, // MoM comparison
|
||||||
|
{ month: "2026-03", income: 5000, expenses: 2500 }, // Reference
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const snapshot = await getCartesSnapshot(2026, 3);
|
||||||
|
|
||||||
|
expect(snapshot.kpis.income.current).toBe(5000);
|
||||||
|
expect(snapshot.kpis.income.previousMonth).toBe(4000);
|
||||||
|
expect(snapshot.kpis.income.previousYear).toBe(3000);
|
||||||
|
expect(snapshot.kpis.income.deltaMoMAbs).toBe(1000);
|
||||||
|
expect(snapshot.kpis.income.deltaMoMPct).toBe(25);
|
||||||
|
expect(snapshot.kpis.income.deltaYoYAbs).toBe(2000);
|
||||||
|
expect(Math.round(snapshot.kpis.income.deltaYoYPct ?? 0)).toBe(67);
|
||||||
|
|
||||||
|
expect(snapshot.kpis.expenses.current).toBe(2500);
|
||||||
|
expect(snapshot.kpis.net.current).toBe(2500);
|
||||||
|
expect(snapshot.kpis.savingsRate.current).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("January reference month shifts MoM to December of previous year", async () => {
|
||||||
|
routeSelect([
|
||||||
|
{
|
||||||
|
match: "strftime('%Y-%m', date)",
|
||||||
|
rows: [
|
||||||
|
{ month: "2025-12", income: 2000, expenses: 1000 },
|
||||||
|
{ month: "2026-01", income: 3000, expenses: 1500 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const snapshot = await getCartesSnapshot(2026, 1);
|
||||||
|
|
||||||
|
expect(snapshot.kpis.income.current).toBe(3000);
|
||||||
|
expect(snapshot.kpis.income.previousMonth).toBe(2000);
|
||||||
|
// YoY for January 2026 = January 2025 = no data
|
||||||
|
expect(snapshot.kpis.income.previousYear).toBeNull();
|
||||||
|
expect(snapshot.kpis.income.deltaYoYAbs).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("savings rate stays at 0 when income is zero (no division by zero)", async () => {
|
||||||
|
routeSelect([
|
||||||
|
{
|
||||||
|
match: "strftime('%Y-%m', date)",
|
||||||
|
rows: [
|
||||||
|
{ month: "2026-03", income: 0, expenses: 500 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const snapshot = await getCartesSnapshot(2026, 3);
|
||||||
|
expect(snapshot.kpis.savingsRate.current).toBe(0);
|
||||||
|
expect(snapshot.kpis.income.current).toBe(0);
|
||||||
|
expect(snapshot.kpis.expenses.current).toBe(500);
|
||||||
|
expect(snapshot.kpis.net.current).toBe(-500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles less than 13 months of history by filling gaps with zero", async () => {
|
||||||
|
routeSelect([
|
||||||
|
{
|
||||||
|
match: "strftime('%Y-%m', date)",
|
||||||
|
rows: [
|
||||||
|
{ month: "2026-03", income: 1000, expenses: 400 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const snapshot = await getCartesSnapshot(2026, 3);
|
||||||
|
expect(snapshot.kpis.income.sparkline).toHaveLength(13);
|
||||||
|
// First 12 points are zero, last one is 1000
|
||||||
|
expect(snapshot.kpis.income.sparkline[12].value).toBe(1000);
|
||||||
|
expect(snapshot.kpis.income.sparkline[0].value).toBe(0);
|
||||||
|
// MoM comparison with a missing month returns null (no data for 2026-02)
|
||||||
|
expect(snapshot.kpis.income.previousMonth).toBeNull();
|
||||||
|
expect(snapshot.kpis.income.deltaMoMAbs).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes seasonality only when historical data exists", async () => {
|
||||||
|
routeSelect([
|
||||||
|
{
|
||||||
|
match: "strftime('%Y-%m', date)",
|
||||||
|
rows: [{ month: "2026-03", income: 3000, expenses: 1500 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: "CAST(strftime('%Y', date) AS INTEGER) AS year",
|
||||||
|
rows: [
|
||||||
|
{ year: 2025, amount: 1200 },
|
||||||
|
{ year: 2024, amount: 1000 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const snapshot = await getCartesSnapshot(2026, 3);
|
||||||
|
expect(snapshot.seasonality.historicalYears).toHaveLength(2);
|
||||||
|
expect(snapshot.seasonality.historicalAverage).toBe(1100);
|
||||||
|
expect(snapshot.seasonality.referenceAmount).toBe(1500);
|
||||||
|
// (1500 - 1100) / 1100 * 100 ≈ 36.36
|
||||||
|
expect(Math.round(snapshot.seasonality.deviationPct ?? 0)).toBe(36);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seasonality deviation stays null when there is no historical average", async () => {
|
||||||
|
routeSelect([
|
||||||
|
{
|
||||||
|
match: "strftime('%Y-%m', date)",
|
||||||
|
rows: [{ month: "2026-03", income: 2000, expenses: 800 }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const snapshot = await getCartesSnapshot(2026, 3);
|
||||||
|
expect(snapshot.seasonality.historicalYears).toHaveLength(0);
|
||||||
|
expect(snapshot.seasonality.historicalAverage).toBeNull();
|
||||||
|
expect(snapshot.seasonality.deviationPct).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits top movers by sign and caps each list at 5", async () => {
|
||||||
|
// Seven up-movers, three down-movers — verify we get 5 up and 3 down.
|
||||||
|
const momRows = [
|
||||||
|
{ category_id: 1, category_name: "C1", category_color: "#000", current_total: 200, previous_total: 100 },
|
||||||
|
{ category_id: 2, category_name: "C2", category_color: "#000", current_total: 400, previous_total: 100 },
|
||||||
|
{ category_id: 3, category_name: "C3", category_color: "#000", current_total: 500, previous_total: 100 },
|
||||||
|
{ category_id: 4, category_name: "C4", category_color: "#000", current_total: 700, previous_total: 100 },
|
||||||
|
{ category_id: 5, category_name: "C5", category_color: "#000", current_total: 900, previous_total: 100 },
|
||||||
|
{ category_id: 6, category_name: "C6", category_color: "#000", current_total: 1100, previous_total: 100 },
|
||||||
|
{ category_id: 7, category_name: "C7", category_color: "#000", current_total: 1300, previous_total: 100 },
|
||||||
|
{ category_id: 8, category_name: "D1", category_color: "#000", current_total: 100, previous_total: 500 },
|
||||||
|
{ category_id: 9, category_name: "D2", category_color: "#000", current_total: 100, previous_total: 700 },
|
||||||
|
{ category_id: 10, category_name: "D3", category_color: "#000", current_total: 100, previous_total: 900 },
|
||||||
|
];
|
||||||
|
routeSelect([
|
||||||
|
{
|
||||||
|
match: "strftime('%Y-%m', date)",
|
||||||
|
rows: [{ month: "2026-03", income: 1000, expenses: 500 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Matches the getCompareMonthOverMonth SQL pattern.
|
||||||
|
match: "ORDER BY ABS(current_total - previous_total) DESC",
|
||||||
|
rows: momRows,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const snapshot = await getCartesSnapshot(2026, 3);
|
||||||
|
expect(snapshot.topMoversUp).toHaveLength(5);
|
||||||
|
expect(snapshot.topMoversDown).toHaveLength(3);
|
||||||
|
// Top up is the biggest delta (C7: +1200)
|
||||||
|
expect(snapshot.topMoversUp[0].categoryName).toBe("C7");
|
||||||
|
// Top down is the biggest negative delta (D3: -800)
|
||||||
|
expect(snapshot.topMoversDown[0].categoryName).toBe("D3");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
|
import { getBudgetVsActualData } from "./budgetService";
|
||||||
import type {
|
import type {
|
||||||
MonthlyTrendItem,
|
MonthlyTrendItem,
|
||||||
CategoryBreakdownItem,
|
CategoryBreakdownItem,
|
||||||
|
|
@ -12,6 +13,15 @@ import type {
|
||||||
CategoryZoomEvolutionPoint,
|
CategoryZoomEvolutionPoint,
|
||||||
MonthBalance,
|
MonthBalance,
|
||||||
RecentTransaction,
|
RecentTransaction,
|
||||||
|
CartesSnapshot,
|
||||||
|
CartesKpi,
|
||||||
|
CartesSparklinePoint,
|
||||||
|
CartesTopMover,
|
||||||
|
CartesMonthFlow,
|
||||||
|
CartesBudgetAdherence,
|
||||||
|
CartesBudgetWorstOverrun,
|
||||||
|
CartesSeasonality,
|
||||||
|
CartesSeasonalityYear,
|
||||||
} from "../shared/types";
|
} from "../shared/types";
|
||||||
|
|
||||||
export async function getMonthlyTrends(
|
export async function getMonthlyTrends(
|
||||||
|
|
@ -570,3 +580,304 @@ export async function getCategoryZoom(
|
||||||
transactions: txRows,
|
transactions: txRows,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Cartes dashboard (Issue #97) ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signed month shift. Exported for unit tests.
|
||||||
|
* shiftMonth(2026, 1, -1) -> { year: 2025, month: 12 }
|
||||||
|
* shiftMonth(2026, 4, -24) -> { year: 2024, month: 4 }
|
||||||
|
*/
|
||||||
|
export function shiftMonth(
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
offset: number,
|
||||||
|
): { year: number; month: number } {
|
||||||
|
const total = year * 12 + (month - 1) + offset;
|
||||||
|
return {
|
||||||
|
year: Math.floor(total / 12),
|
||||||
|
month: (total % 12) + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthKey(year: number, month: number): string {
|
||||||
|
return `${year}-${String(month).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDelta(
|
||||||
|
current: number,
|
||||||
|
previous: number | null,
|
||||||
|
): { abs: number | null; pct: number | null } {
|
||||||
|
if (previous === null) return { abs: null, pct: null };
|
||||||
|
const abs = current - previous;
|
||||||
|
const pct = previous === 0 ? null : (abs / previous) * 100;
|
||||||
|
return { abs, pct };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildKpi(
|
||||||
|
sparkline: CartesSparklinePoint[],
|
||||||
|
current: number,
|
||||||
|
previousMonth: number | null,
|
||||||
|
previousYear: number | null,
|
||||||
|
): CartesKpi {
|
||||||
|
const mom = extractDelta(current, previousMonth);
|
||||||
|
const yoy = extractDelta(current, previousYear);
|
||||||
|
return {
|
||||||
|
current,
|
||||||
|
previousMonth,
|
||||||
|
previousYear,
|
||||||
|
deltaMoMAbs: mom.abs,
|
||||||
|
deltaMoMPct: mom.pct,
|
||||||
|
deltaYoYAbs: yoy.abs,
|
||||||
|
deltaYoYPct: yoy.pct,
|
||||||
|
sparkline,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawMonthFlow {
|
||||||
|
month: string;
|
||||||
|
income: number | null;
|
||||||
|
expenses: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchMonthlyFlows(
|
||||||
|
dateFrom: string,
|
||||||
|
dateTo: string,
|
||||||
|
): Promise<RawMonthFlow[]> {
|
||||||
|
const db = await getDb();
|
||||||
|
return db.select<RawMonthFlow[]>(
|
||||||
|
`SELECT
|
||||||
|
strftime('%Y-%m', date) AS month,
|
||||||
|
COALESCE(SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END), 0) AS income,
|
||||||
|
ABS(COALESCE(SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END), 0)) AS expenses
|
||||||
|
FROM transactions
|
||||||
|
WHERE date >= $1 AND date <= $2
|
||||||
|
GROUP BY month
|
||||||
|
ORDER BY month ASC`,
|
||||||
|
[dateFrom, dateTo],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawSeasonalityRow {
|
||||||
|
year: number;
|
||||||
|
amount: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSeasonality(
|
||||||
|
month: number,
|
||||||
|
yearFrom: number,
|
||||||
|
yearTo: number,
|
||||||
|
): Promise<RawSeasonalityRow[]> {
|
||||||
|
const db = await getDb();
|
||||||
|
const mm = String(month).padStart(2, "0");
|
||||||
|
return db.select<RawSeasonalityRow[]>(
|
||||||
|
`SELECT
|
||||||
|
CAST(strftime('%Y', date) AS INTEGER) AS year,
|
||||||
|
ABS(COALESCE(SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END), 0)) AS amount
|
||||||
|
FROM transactions
|
||||||
|
WHERE strftime('%m', date) = $1
|
||||||
|
AND CAST(strftime('%Y', date) AS INTEGER) >= $2
|
||||||
|
AND CAST(strftime('%Y', date) AS INTEGER) <= $3
|
||||||
|
GROUP BY year
|
||||||
|
ORDER BY year DESC`,
|
||||||
|
[mm, yearFrom, yearTo],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cartes dashboard snapshot. Single entry point that returns every widget's
|
||||||
|
* data for the Cartes report, computed against a reference (year, month).
|
||||||
|
*
|
||||||
|
* Layout (all concurrent):
|
||||||
|
* 1. 25-month expense/income series (covers ref, MoM, YoY, 12-month flow,
|
||||||
|
* 13-month sparklines without any extra round trips).
|
||||||
|
* 2. Month-over-month category deltas for top movers (existing service).
|
||||||
|
* 3. Year-over-year category deltas to seed the savings-rate YoY lookup
|
||||||
|
* via the monthly series instead of re-querying.
|
||||||
|
* 4. Budget vs actual for the reference month.
|
||||||
|
* 5. Seasonality: same calendar month across the two prior years.
|
||||||
|
*/
|
||||||
|
export async function getCartesSnapshot(
|
||||||
|
referenceYear: number,
|
||||||
|
referenceMonth: number,
|
||||||
|
): Promise<CartesSnapshot> {
|
||||||
|
// Date window: 25 months back from the reference to cover YoY + a 13-month
|
||||||
|
// sparkline. Start = 24 months before ref = (ref - 24 months) = month offset -24.
|
||||||
|
const windowStart = shiftMonth(referenceYear, referenceMonth, -24);
|
||||||
|
const { start: windowStartIso } = monthBoundaries(windowStart.year, windowStart.month);
|
||||||
|
const { end: refEnd } = monthBoundaries(referenceYear, referenceMonth);
|
||||||
|
|
||||||
|
// Seasonality range: previous 2 years for the same calendar month.
|
||||||
|
const [seasonalityRows, flowRows, momRows, budgetRows] = await Promise.all([
|
||||||
|
fetchSeasonality(referenceMonth, referenceYear - 2, referenceYear - 1),
|
||||||
|
fetchMonthlyFlows(windowStartIso, refEnd),
|
||||||
|
getCompareMonthOverMonth(referenceYear, referenceMonth),
|
||||||
|
getBudgetVsActualData(referenceYear, referenceMonth),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Index the flow rows by month for O(1) lookup, then fill missing months
|
||||||
|
// with zeroes so downstream consumers get a contiguous series.
|
||||||
|
const flowByMonth = new Map<string, { income: number; expenses: number }>();
|
||||||
|
for (const r of flowRows) {
|
||||||
|
flowByMonth.set(r.month, {
|
||||||
|
income: Number(r.income ?? 0),
|
||||||
|
expenses: Number(r.expenses ?? 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildSeries = (count: number): CartesMonthFlow[] => {
|
||||||
|
const series: CartesMonthFlow[] = [];
|
||||||
|
for (let i = count - 1; i >= 0; i--) {
|
||||||
|
const { year: y, month: m } = shiftMonth(referenceYear, referenceMonth, -i);
|
||||||
|
const key = monthKey(y, m);
|
||||||
|
const row = flowByMonth.get(key);
|
||||||
|
const income = row?.income ?? 0;
|
||||||
|
const expenses = row?.expenses ?? 0;
|
||||||
|
series.push({ month: key, income, expenses, net: income - expenses });
|
||||||
|
}
|
||||||
|
return series;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 13-month sparklines for each KPI (reference month + 12 prior).
|
||||||
|
const sparkSeries = buildSeries(13);
|
||||||
|
const incomeSpark: CartesSparklinePoint[] = sparkSeries.map((p) => ({
|
||||||
|
month: p.month,
|
||||||
|
value: p.income,
|
||||||
|
}));
|
||||||
|
const expensesSpark: CartesSparklinePoint[] = sparkSeries.map((p) => ({
|
||||||
|
month: p.month,
|
||||||
|
value: p.expenses,
|
||||||
|
}));
|
||||||
|
const netSpark: CartesSparklinePoint[] = sparkSeries.map((p) => ({
|
||||||
|
month: p.month,
|
||||||
|
value: p.net,
|
||||||
|
}));
|
||||||
|
const savingsSpark: CartesSparklinePoint[] = sparkSeries.map((p) => ({
|
||||||
|
month: p.month,
|
||||||
|
value: p.income > 0 ? (p.net / p.income) * 100 : 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Compute MoM / YoY values directly from `flowByMonth` (which preserves the
|
||||||
|
// "missing" distinction). The sparkline fills gaps with zero for display,
|
||||||
|
// but deltas must remain null when the comparison month has no data.
|
||||||
|
const refKey = monthKey(referenceYear, referenceMonth);
|
||||||
|
const momMeta = shiftMonth(referenceYear, referenceMonth, -1);
|
||||||
|
const momKey = monthKey(momMeta.year, momMeta.month);
|
||||||
|
const yoyMeta = { year: referenceYear - 1, month: referenceMonth };
|
||||||
|
const yoyKey = monthKey(yoyMeta.year, yoyMeta.month);
|
||||||
|
|
||||||
|
const refRow = flowByMonth.get(refKey);
|
||||||
|
const refIncome = refRow?.income ?? 0;
|
||||||
|
const refExpenses = refRow?.expenses ?? 0;
|
||||||
|
const refNet = refIncome - refExpenses;
|
||||||
|
const refSavings = refIncome > 0 ? (refNet / refIncome) * 100 : 0;
|
||||||
|
|
||||||
|
const momRow = flowByMonth.get(momKey);
|
||||||
|
const momIncome = momRow ? momRow.income : null;
|
||||||
|
const momExpenses = momRow ? momRow.expenses : null;
|
||||||
|
const momNet = momRow ? momRow.income - momRow.expenses : null;
|
||||||
|
const momSavings =
|
||||||
|
momRow && momRow.income > 0 ? ((momRow.income - momRow.expenses) / momRow.income) * 100 : null;
|
||||||
|
|
||||||
|
const yoyRow = flowByMonth.get(yoyKey);
|
||||||
|
const yoyIncome = yoyRow ? yoyRow.income : null;
|
||||||
|
const yoyExpenses = yoyRow ? yoyRow.expenses : null;
|
||||||
|
const yoyNet = yoyRow ? yoyRow.income - yoyRow.expenses : null;
|
||||||
|
const yoySavings =
|
||||||
|
yoyRow && yoyRow.income > 0 ? ((yoyRow.income - yoyRow.expenses) / yoyRow.income) * 100 : null;
|
||||||
|
|
||||||
|
const incomeKpi = buildKpi(incomeSpark, refIncome, momIncome, yoyIncome);
|
||||||
|
const expensesKpi = buildKpi(expensesSpark, refExpenses, momExpenses, yoyExpenses);
|
||||||
|
const netKpi = buildKpi(netSpark, refNet, momNet, yoyNet);
|
||||||
|
const savingsKpi = buildKpi(savingsSpark, refSavings, momSavings, yoySavings);
|
||||||
|
|
||||||
|
// 12-month income vs expenses series for the overlay chart.
|
||||||
|
const flow12Months = buildSeries(12);
|
||||||
|
|
||||||
|
// Top movers: biggest MoM increases / decreases. `momRows` are sorted by
|
||||||
|
// absolute delta already; filter out near-zero noise and split by sign.
|
||||||
|
const significantMovers = momRows.filter(
|
||||||
|
(r) => r.deltaAbs !== 0 && (r.previousAmount > 0 || r.currentAmount > 0),
|
||||||
|
);
|
||||||
|
const topMoversUp: CartesTopMover[] = significantMovers
|
||||||
|
.filter((r) => r.deltaAbs > 0)
|
||||||
|
.sort((a, b) => b.deltaAbs - a.deltaAbs)
|
||||||
|
.slice(0, 5);
|
||||||
|
const topMoversDown: CartesTopMover[] = significantMovers
|
||||||
|
.filter((r) => r.deltaAbs < 0)
|
||||||
|
.sort((a, b) => a.deltaAbs - b.deltaAbs)
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
// Budget adherence — only expense categories with a non-zero budget count.
|
||||||
|
// monthActual is signed from transactions; expense categories have
|
||||||
|
// monthActual <= 0, so we compare on absolute values.
|
||||||
|
const budgetedExpenseRows = budgetRows.filter(
|
||||||
|
(r) => r.category_type === "expense" && r.monthBudget > 0 && !r.is_parent,
|
||||||
|
);
|
||||||
|
const budgetsInTarget = budgetedExpenseRows.filter(
|
||||||
|
(r) => Math.abs(r.monthActual) <= r.monthBudget,
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const overruns: CartesBudgetWorstOverrun[] = budgetedExpenseRows
|
||||||
|
.map((r) => {
|
||||||
|
const actual = Math.abs(r.monthActual);
|
||||||
|
const overrunAbs = actual - r.monthBudget;
|
||||||
|
const overrunPct = r.monthBudget > 0 ? (overrunAbs / r.monthBudget) * 100 : null;
|
||||||
|
return {
|
||||||
|
categoryId: r.category_id,
|
||||||
|
categoryName: r.category_name,
|
||||||
|
categoryColor: r.category_color,
|
||||||
|
budget: r.monthBudget,
|
||||||
|
actual,
|
||||||
|
overrunAbs,
|
||||||
|
overrunPct,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((r) => r.overrunAbs > 0)
|
||||||
|
.sort((a, b) => b.overrunAbs - a.overrunAbs)
|
||||||
|
.slice(0, 3);
|
||||||
|
|
||||||
|
const budgetAdherence: CartesBudgetAdherence = {
|
||||||
|
categoriesInTarget: budgetsInTarget,
|
||||||
|
categoriesTotal: budgetedExpenseRows.length,
|
||||||
|
worstOverruns: overruns,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Seasonality — average of the same calendar month across the previous
|
||||||
|
// two years. If no data, average stays null.
|
||||||
|
const historicalYears: CartesSeasonalityYear[] = seasonalityRows.map((r) => ({
|
||||||
|
year: Number(r.year),
|
||||||
|
amount: Number(r.amount ?? 0),
|
||||||
|
}));
|
||||||
|
const historicalAverage = historicalYears.length
|
||||||
|
? historicalYears.reduce((sum, r) => sum + r.amount, 0) / historicalYears.length
|
||||||
|
: null;
|
||||||
|
const referenceAmount = expensesKpi.current;
|
||||||
|
const deviationPct =
|
||||||
|
historicalAverage !== null && historicalAverage > 0
|
||||||
|
? ((referenceAmount - historicalAverage) / historicalAverage) * 100
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const seasonality: CartesSeasonality = {
|
||||||
|
referenceAmount,
|
||||||
|
historicalYears,
|
||||||
|
historicalAverage,
|
||||||
|
deviationPct,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
referenceYear,
|
||||||
|
referenceMonth,
|
||||||
|
kpis: {
|
||||||
|
income: incomeKpi,
|
||||||
|
expenses: expensesKpi,
|
||||||
|
net: netKpi,
|
||||||
|
savingsRate: savingsKpi,
|
||||||
|
},
|
||||||
|
flow12Months,
|
||||||
|
topMoversUp,
|
||||||
|
topMoversDown,
|
||||||
|
budgetAdherence,
|
||||||
|
seasonality,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -360,6 +360,87 @@ export interface BudgetVsActualRow {
|
||||||
ytdVariationPct: number | null;
|
ytdVariationPct: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Cartes (Issue #97) — dashboard snapshot ---
|
||||||
|
|
||||||
|
export interface CartesSparklinePoint {
|
||||||
|
month: string; // "YYYY-MM"
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CartesKpi {
|
||||||
|
current: number;
|
||||||
|
previousMonth: number | null;
|
||||||
|
previousYear: number | null;
|
||||||
|
deltaMoMAbs: number | null;
|
||||||
|
deltaMoMPct: number | null;
|
||||||
|
deltaYoYAbs: number | null;
|
||||||
|
deltaYoYPct: number | null;
|
||||||
|
sparkline: CartesSparklinePoint[]; // 13 months ending at reference month
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CartesKpiId = "income" | "expenses" | "net" | "savingsRate";
|
||||||
|
|
||||||
|
export interface CartesTopMover {
|
||||||
|
categoryId: number | null;
|
||||||
|
categoryName: string;
|
||||||
|
categoryColor: string;
|
||||||
|
previousAmount: number;
|
||||||
|
currentAmount: number;
|
||||||
|
deltaAbs: number;
|
||||||
|
deltaPct: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CartesMonthFlow {
|
||||||
|
month: string; // "YYYY-MM"
|
||||||
|
income: number;
|
||||||
|
expenses: number;
|
||||||
|
net: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CartesBudgetWorstOverrun {
|
||||||
|
categoryId: number;
|
||||||
|
categoryName: string;
|
||||||
|
categoryColor: string;
|
||||||
|
budget: number;
|
||||||
|
actual: number;
|
||||||
|
overrunAbs: number;
|
||||||
|
overrunPct: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CartesBudgetAdherence {
|
||||||
|
categoriesInTarget: number;
|
||||||
|
categoriesTotal: number;
|
||||||
|
worstOverruns: CartesBudgetWorstOverrun[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CartesSeasonalityYear {
|
||||||
|
year: number;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CartesSeasonality {
|
||||||
|
referenceAmount: number;
|
||||||
|
historicalYears: CartesSeasonalityYear[]; // up to 2 previous years
|
||||||
|
historicalAverage: number | null;
|
||||||
|
deviationPct: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CartesSnapshot {
|
||||||
|
referenceYear: number;
|
||||||
|
referenceMonth: number;
|
||||||
|
kpis: {
|
||||||
|
income: CartesKpi;
|
||||||
|
expenses: CartesKpi;
|
||||||
|
net: CartesKpi;
|
||||||
|
savingsRate: CartesKpi; // value stored as 0-100
|
||||||
|
};
|
||||||
|
flow12Months: CartesMonthFlow[];
|
||||||
|
topMoversUp: CartesTopMover[];
|
||||||
|
topMoversDown: CartesTopMover[];
|
||||||
|
budgetAdherence: CartesBudgetAdherence;
|
||||||
|
seasonality: CartesSeasonality;
|
||||||
|
}
|
||||||
|
|
||||||
export type ImportWizardStep =
|
export type ImportWizardStep =
|
||||||
| "source-list"
|
| "source-list"
|
||||||
| "source-config"
|
| "source-config"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue