+
{navCards.map((card) => (
))}
diff --git a/src/services/reportService.cartes.test.ts b/src/services/reportService.cartes.test.ts
new file mode 100644
index 0000000..4e459fb
--- /dev/null
+++ b/src/services/reportService.cartes.test.ts
@@ -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");
+ });
+});
diff --git a/src/services/reportService.ts b/src/services/reportService.ts
index 8b85088..efe9632 100644
--- a/src/services/reportService.ts
+++ b/src/services/reportService.ts
@@ -1,4 +1,5 @@
import { getDb } from "./db";
+import { getBudgetVsActualData } from "./budgetService";
import type {
MonthlyTrendItem,
CategoryBreakdownItem,
@@ -12,6 +13,15 @@ import type {
CategoryZoomEvolutionPoint,
MonthBalance,
RecentTransaction,
+ CartesSnapshot,
+ CartesKpi,
+ CartesSparklinePoint,
+ CartesTopMover,
+ CartesMonthFlow,
+ CartesBudgetAdherence,
+ CartesBudgetWorstOverrun,
+ CartesSeasonality,
+ CartesSeasonalityYear,
} from "../shared/types";
export async function getMonthlyTrends(
@@ -570,3 +580,304 @@ export async function getCategoryZoom(
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 {
+ const db = await getDb();
+ return db.select(
+ `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 {
+ const db = await getDb();
+ const mm = String(month).padStart(2, "0");
+ return db.select(
+ `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 {
+ // 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();
+ 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,
+ };
+}
diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts
index 5ec6a98..f315f43 100644
--- a/src/shared/types/index.ts
+++ b/src/shared/types/index.ts
@@ -360,6 +360,87 @@ export interface BudgetVsActualRow {
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 =
| "source-list"
| "source-config"