New /reports/cartes page surfaces a dashboard-style snapshot of the reference month: - 4 KPI cards (income / expenses / net / savings rate) showing MoM and YoY deltas simultaneously, each with a 13-month sparkline highlighting the reference month - 12-month income vs expenses overlay chart (bars + net balance line) - Top 5 category increases + top 5 decreases MoM, clickable through to the category zoom report - Budget adherence card: on-target count + 3 worst overruns with progress bars - Seasonality card: reference month vs same calendar month averaged over the two previous years, with deviation indicator All data is fetched in a single getCartesSnapshot() service call that runs four queries concurrently (25-month flow, MoM category deltas, budget-vs-actual, seasonality). Missing months are filled with zeroes in the sparklines but preserved as null in the MoM/YoY deltas so the UI can distinguish "no data" from "zero spend". - Exported pure helpers: shiftMonth, defaultCartesReferencePeriod - 13 vitest cases covering zero data, MoM/YoY computation, January wrap-around, missing-month handling, division by zero for the savings rate, seasonality with and without history, top mover sign splitting and 5-cap Note: src/components/reports/CompareReferenceMonthPicker.tsx is a temporary duplicate — the canonical copy lives on the issue-96 branch (refactor: compare report). Once both branches merge the content is identical and git will dedupe. Keeping the local copy here means the Cartes branch builds cleanly on main without depending on #96. Closes #97 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
106 lines
3.7 KiB
TypeScript
106 lines
3.7 KiB
TypeScript
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>
|
|
);
|
|
}
|