feat: implement reports page with trends, category, and over-time charts
Add three chart tabs sharing a period selector: monthly income/expenses area chart, horizontal category bar chart, and stacked category-over-time bar chart with top 8 + Other grouping. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3506c2c87e
commit
d6000e191f
9 changed files with 594 additions and 10 deletions
65
src/components/reports/CategoryBarChart.tsx
Normal file
65
src/components/reports/CategoryBarChart.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from "recharts";
|
||||
import type { CategoryBreakdownItem } from "../../shared/types";
|
||||
|
||||
const eurFormatter = (value: number) =>
|
||||
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(value);
|
||||
|
||||
interface CategoryBarChartProps {
|
||||
data: CategoryBreakdownItem[];
|
||||
}
|
||||
|
||||
export default function CategoryBarChart({ data }: CategoryBarChartProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
||||
<p className="text-center text-[var(--muted-foreground)] py-8">{t("dashboard.noData")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
||||
<ResponsiveContainer width="100%" height={Math.max(400, data.length * 40)}>
|
||||
<BarChart data={data} layout="vertical" margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
|
||||
<XAxis
|
||||
type="number"
|
||||
tickFormatter={(v) => eurFormatter(v)}
|
||||
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
|
||||
stroke="var(--border)"
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="category_name"
|
||||
width={120}
|
||||
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
|
||||
stroke="var(--border)"
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number | undefined) => eurFormatter(value ?? 0)}
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="total" name={t("dashboard.expenses")} radius={[0, 4, 4, 0]}>
|
||||
{data.map((item, index) => (
|
||||
<Cell key={index} fill={item.category_color} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
src/components/reports/CategoryOverTimeChart.tsx
Normal file
77
src/components/reports/CategoryOverTimeChart.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
CartesianGrid,
|
||||
} from "recharts";
|
||||
import type { CategoryOverTimeData } from "../../shared/types";
|
||||
|
||||
const eurFormatter = (value: number) =>
|
||||
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(value);
|
||||
|
||||
function formatMonth(month: string): string {
|
||||
const [year, m] = month.split("-");
|
||||
const date = new Date(Number(year), Number(m) - 1);
|
||||
return date.toLocaleDateString("default", { month: "short", year: "2-digit" });
|
||||
}
|
||||
|
||||
interface CategoryOverTimeChartProps {
|
||||
data: CategoryOverTimeData;
|
||||
}
|
||||
|
||||
export default function CategoryOverTimeChart({ data }: CategoryOverTimeChartProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (data.data.length === 0) {
|
||||
return (
|
||||
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
||||
<p className="text-center text-[var(--muted-foreground)] py-8">{t("dashboard.noData")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<BarChart data={data.data} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tickFormatter={formatMonth}
|
||||
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
|
||||
stroke="var(--border)"
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(v) => eurFormatter(v)}
|
||||
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
|
||||
stroke="var(--border)"
|
||||
width={80}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number | undefined) => eurFormatter(value ?? 0)}
|
||||
labelFormatter={(label) => formatMonth(String(label))}
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
{data.categories.map((name) => (
|
||||
<Bar
|
||||
key={name}
|
||||
dataKey={name}
|
||||
stackId="stack"
|
||||
fill={data.colors[name]}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
src/components/reports/MonthlyTrendsChart.tsx
Normal file
93
src/components/reports/MonthlyTrendsChart.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
CartesianGrid,
|
||||
} from "recharts";
|
||||
import type { MonthlyTrendItem } from "../../shared/types";
|
||||
|
||||
const eurFormatter = (value: number) =>
|
||||
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(value);
|
||||
|
||||
function formatMonth(month: string): string {
|
||||
const [year, m] = month.split("-");
|
||||
const date = new Date(Number(year), Number(m) - 1);
|
||||
return date.toLocaleDateString("default", { month: "short", year: "2-digit" });
|
||||
}
|
||||
|
||||
interface MonthlyTrendsChartProps {
|
||||
data: MonthlyTrendItem[];
|
||||
}
|
||||
|
||||
export default function MonthlyTrendsChart({ data }: MonthlyTrendsChartProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
||||
<p className="text-center text-[var(--muted-foreground)] py-8">{t("dashboard.noData")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<AreaChart data={data} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="gradientIncome" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--positive)" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="var(--positive)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="gradientExpenses" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--negative)" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="var(--negative)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tickFormatter={formatMonth}
|
||||
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
|
||||
stroke="var(--border)"
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(v) => eurFormatter(v)}
|
||||
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
|
||||
stroke="var(--border)"
|
||||
width={80}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number | undefined) => eurFormatter(value ?? 0)}
|
||||
labelFormatter={(label) => formatMonth(String(label))}
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="income"
|
||||
name={t("dashboard.income")}
|
||||
stroke="var(--positive)"
|
||||
fill="url(#gradientIncome)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="expenses"
|
||||
name={t("dashboard.expenses")}
|
||||
stroke="var(--negative)"
|
||||
fill="url(#gradientExpenses)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
src/hooks/useReports.ts
Normal file
147
src/hooks/useReports.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { useReducer, useCallback, useEffect, useRef } from "react";
|
||||
import type {
|
||||
ReportTab,
|
||||
DashboardPeriod,
|
||||
MonthlyTrendItem,
|
||||
CategoryBreakdownItem,
|
||||
CategoryOverTimeData,
|
||||
} from "../shared/types";
|
||||
import { getMonthlyTrends, getCategoryOverTime } from "../services/reportService";
|
||||
import { getExpensesByCategory } from "../services/dashboardService";
|
||||
|
||||
interface ReportsState {
|
||||
tab: ReportTab;
|
||||
period: DashboardPeriod;
|
||||
monthlyTrends: MonthlyTrendItem[];
|
||||
categorySpending: CategoryBreakdownItem[];
|
||||
categoryOverTime: CategoryOverTimeData;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
type ReportsAction =
|
||||
| { type: "SET_TAB"; payload: ReportTab }
|
||||
| { type: "SET_PERIOD"; payload: DashboardPeriod }
|
||||
| { type: "SET_LOADING"; payload: boolean }
|
||||
| { type: "SET_ERROR"; payload: string | null }
|
||||
| { type: "SET_MONTHLY_TRENDS"; payload: MonthlyTrendItem[] }
|
||||
| { type: "SET_CATEGORY_SPENDING"; payload: CategoryBreakdownItem[] }
|
||||
| { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData };
|
||||
|
||||
const initialState: ReportsState = {
|
||||
tab: "trends",
|
||||
period: "6months",
|
||||
monthlyTrends: [],
|
||||
categorySpending: [],
|
||||
categoryOverTime: { categories: [], data: [], colors: {} },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
function reducer(state: ReportsState, action: ReportsAction): ReportsState {
|
||||
switch (action.type) {
|
||||
case "SET_TAB":
|
||||
return { ...state, tab: action.payload };
|
||||
case "SET_PERIOD":
|
||||
return { ...state, period: action.payload };
|
||||
case "SET_LOADING":
|
||||
return { ...state, isLoading: action.payload };
|
||||
case "SET_ERROR":
|
||||
return { ...state, error: action.payload, isLoading: false };
|
||||
case "SET_MONTHLY_TRENDS":
|
||||
return { ...state, monthlyTrends: action.payload, isLoading: false };
|
||||
case "SET_CATEGORY_SPENDING":
|
||||
return { ...state, categorySpending: action.payload, isLoading: false };
|
||||
case "SET_CATEGORY_OVER_TIME":
|
||||
return { ...state, categoryOverTime: action.payload, isLoading: false };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?: string } {
|
||||
if (period === "all") return {};
|
||||
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth();
|
||||
const day = now.getDate();
|
||||
|
||||
const dateTo = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
|
||||
let from: Date;
|
||||
switch (period) {
|
||||
case "month":
|
||||
from = new Date(year, month, 1);
|
||||
break;
|
||||
case "3months":
|
||||
from = new Date(year, month - 2, 1);
|
||||
break;
|
||||
case "6months":
|
||||
from = new Date(year, month - 5, 1);
|
||||
break;
|
||||
case "12months":
|
||||
from = new Date(year, month - 11, 1);
|
||||
break;
|
||||
}
|
||||
|
||||
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
|
||||
|
||||
return { dateFrom, dateTo };
|
||||
}
|
||||
|
||||
export function useReports() {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const fetchIdRef = useRef(0);
|
||||
|
||||
const fetchData = useCallback(async (tab: ReportTab, period: DashboardPeriod) => {
|
||||
const fetchId = ++fetchIdRef.current;
|
||||
dispatch({ type: "SET_LOADING", payload: true });
|
||||
dispatch({ type: "SET_ERROR", payload: null });
|
||||
|
||||
try {
|
||||
const { dateFrom, dateTo } = computeDateRange(period);
|
||||
|
||||
switch (tab) {
|
||||
case "trends": {
|
||||
const data = await getMonthlyTrends(dateFrom, dateTo);
|
||||
if (fetchId !== fetchIdRef.current) return;
|
||||
dispatch({ type: "SET_MONTHLY_TRENDS", payload: data });
|
||||
break;
|
||||
}
|
||||
case "byCategory": {
|
||||
const data = await getExpensesByCategory(dateFrom, dateTo);
|
||||
if (fetchId !== fetchIdRef.current) return;
|
||||
dispatch({ type: "SET_CATEGORY_SPENDING", payload: data });
|
||||
break;
|
||||
}
|
||||
case "overTime": {
|
||||
const data = await getCategoryOverTime(dateFrom, dateTo);
|
||||
if (fetchId !== fetchIdRef.current) return;
|
||||
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (fetchId !== fetchIdRef.current) return;
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
payload: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(state.tab, state.period);
|
||||
}, [state.tab, state.period, fetchData]);
|
||||
|
||||
const setTab = useCallback((tab: ReportTab) => {
|
||||
dispatch({ type: "SET_TAB", payload: tab });
|
||||
}, []);
|
||||
|
||||
const setPeriod = useCallback((period: DashboardPeriod) => {
|
||||
dispatch({ type: "SET_PERIOD", payload: period });
|
||||
}, []);
|
||||
|
||||
return { state, setTab, setPeriod };
|
||||
}
|
||||
|
|
@ -225,9 +225,9 @@
|
|||
"reports": {
|
||||
"title": "Reports",
|
||||
"period": "Period",
|
||||
"byCategory": "By Category",
|
||||
"byMonth": "By Month",
|
||||
"trends": "Trends",
|
||||
"byCategory": "Expenses by Category",
|
||||
"overTime": "Category Over Time",
|
||||
"trends": "Monthly Trends",
|
||||
"export": "Export"
|
||||
},
|
||||
"common": {
|
||||
|
|
|
|||
|
|
@ -225,9 +225,9 @@
|
|||
"reports": {
|
||||
"title": "Rapports",
|
||||
"period": "Période",
|
||||
"byCategory": "Par catégorie",
|
||||
"byMonth": "Par mois",
|
||||
"trends": "Tendances",
|
||||
"byCategory": "Dépenses par catégorie",
|
||||
"overTime": "Catégories dans le temps",
|
||||
"trends": "Tendances mensuelles",
|
||||
"export": "Exporter"
|
||||
},
|
||||
"common": {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,49 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { useReports } from "../hooks/useReports";
|
||||
import type { ReportTab } from "../shared/types";
|
||||
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
|
||||
import CategoryBarChart from "../components/reports/CategoryBarChart";
|
||||
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
|
||||
|
||||
const TABS: ReportTab[] = ["trends", "byCategory", "overTime"];
|
||||
|
||||
export default function ReportsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { state, setTab, setPeriod } = useReports();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">{t("reports.title")}</h1>
|
||||
<div className="bg-[var(--card)] rounded-xl p-8 border border-[var(--border)] text-center text-[var(--muted-foreground)]">
|
||||
<p>{t("common.noResults")}</p>
|
||||
<div className={state.isLoading ? "opacity-50 pointer-events-none" : ""}>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
||||
<h1 className="text-2xl font-bold">{t("reports.title")}</h1>
|
||||
<PeriodSelector value={state.period} onChange={setPeriod} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-6">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setTab(tab)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
tab === state.tab
|
||||
? "bg-[var(--primary)] text-white"
|
||||
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||
}`}
|
||||
>
|
||||
{t(`reports.${tab}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{state.error && (
|
||||
<div className="bg-red-100 text-red-700 rounded-xl p-4 mb-6">
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.tab === "trends" && <MonthlyTrendsChart data={state.monthlyTrends} />}
|
||||
{state.tab === "byCategory" && <CategoryBarChart data={state.categorySpending} />}
|
||||
{state.tab === "overTime" && <CategoryOverTimeChart data={state.categoryOverTime} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
146
src/services/reportService.ts
Normal file
146
src/services/reportService.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { getDb } from "./db";
|
||||
import type {
|
||||
MonthlyTrendItem,
|
||||
CategoryBreakdownItem,
|
||||
CategoryOverTimeData,
|
||||
CategoryOverTimeItem,
|
||||
} from "../shared/types";
|
||||
|
||||
export async function getMonthlyTrends(
|
||||
dateFrom?: string,
|
||||
dateTo?: string
|
||||
): Promise<MonthlyTrendItem[]> {
|
||||
const db = await getDb();
|
||||
|
||||
const whereClauses: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (dateFrom) {
|
||||
whereClauses.push(`date >= $${paramIndex}`);
|
||||
params.push(dateFrom);
|
||||
paramIndex++;
|
||||
}
|
||||
if (dateTo) {
|
||||
whereClauses.push(`date <= $${paramIndex}`);
|
||||
params.push(dateTo);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereSQL =
|
||||
whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
|
||||
|
||||
return db.select<MonthlyTrendItem[]>(
|
||||
`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
|
||||
${whereSQL}
|
||||
GROUP BY month
|
||||
ORDER BY month ASC`,
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
export async function getCategoryOverTime(
|
||||
dateFrom?: string,
|
||||
dateTo?: string,
|
||||
topN: number = 8
|
||||
): Promise<CategoryOverTimeData> {
|
||||
const db = await getDb();
|
||||
|
||||
const whereClauses: string[] = ["t.amount < 0"];
|
||||
const params: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (dateFrom) {
|
||||
whereClauses.push(`t.date >= $${paramIndex}`);
|
||||
params.push(dateFrom);
|
||||
paramIndex++;
|
||||
}
|
||||
if (dateTo) {
|
||||
whereClauses.push(`t.date <= $${paramIndex}`);
|
||||
params.push(dateTo);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereSQL = `WHERE ${whereClauses.join(" AND ")}`;
|
||||
|
||||
// Get top N categories by total spend
|
||||
const topCategories = await db.select<CategoryBreakdownItem[]>(
|
||||
`SELECT
|
||||
t.category_id,
|
||||
COALESCE(c.name, 'Uncategorized') AS category_name,
|
||||
COALESCE(c.color, '#9ca3af') AS category_color,
|
||||
ABS(SUM(t.amount)) AS total
|
||||
FROM transactions t
|
||||
LEFT JOIN categories c ON t.category_id = c.id
|
||||
${whereSQL}
|
||||
GROUP BY t.category_id
|
||||
ORDER BY total DESC
|
||||
LIMIT $${paramIndex}`,
|
||||
[...params, topN]
|
||||
);
|
||||
|
||||
const topCategoryIds = new Set(topCategories.map((c) => c.category_id));
|
||||
const colors: Record<string, string> = {};
|
||||
for (const cat of topCategories) {
|
||||
colors[cat.category_name] = cat.category_color;
|
||||
}
|
||||
|
||||
// Get monthly breakdown for all categories
|
||||
const monthlyRows = await db.select<
|
||||
Array<{
|
||||
month: string;
|
||||
category_id: number | null;
|
||||
category_name: string;
|
||||
total: number;
|
||||
}>
|
||||
>(
|
||||
`SELECT
|
||||
strftime('%Y-%m', t.date) AS month,
|
||||
t.category_id,
|
||||
COALESCE(c.name, 'Uncategorized') AS category_name,
|
||||
ABS(SUM(t.amount)) AS total
|
||||
FROM transactions t
|
||||
LEFT JOIN categories c ON t.category_id = c.id
|
||||
${whereSQL}
|
||||
GROUP BY month, t.category_id
|
||||
ORDER BY month ASC`,
|
||||
params
|
||||
);
|
||||
|
||||
// Build pivot data
|
||||
const monthMap = new Map<string, CategoryOverTimeItem>();
|
||||
let hasOther = false;
|
||||
|
||||
for (const row of monthlyRows) {
|
||||
if (!monthMap.has(row.month)) {
|
||||
monthMap.set(row.month, { month: row.month });
|
||||
}
|
||||
const item = monthMap.get(row.month)!;
|
||||
|
||||
if (topCategoryIds.has(row.category_id)) {
|
||||
item[row.category_name] = ((item[row.category_name] as number) || 0) + row.total;
|
||||
} else {
|
||||
item["Other"] = ((item["Other"] as number) || 0) + row.total;
|
||||
hasOther = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasOther) {
|
||||
colors["Other"] = "#9ca3af";
|
||||
}
|
||||
|
||||
const categories = topCategories.map((c) => c.category_name);
|
||||
if (hasOther) {
|
||||
categories.push("Other");
|
||||
}
|
||||
|
||||
return {
|
||||
categories,
|
||||
data: Array.from(monthMap.values()),
|
||||
colors,
|
||||
};
|
||||
}
|
||||
|
|
@ -233,6 +233,27 @@ export interface RecentTransaction {
|
|||
category_color: string | null;
|
||||
}
|
||||
|
||||
// --- Report Types ---
|
||||
|
||||
export type ReportTab = "trends" | "byCategory" | "overTime";
|
||||
|
||||
export interface MonthlyTrendItem {
|
||||
month: string; // "2025-01"
|
||||
income: number;
|
||||
expenses: number;
|
||||
}
|
||||
|
||||
export interface CategoryOverTimeItem {
|
||||
month: string;
|
||||
[categoryName: string]: number | string;
|
||||
}
|
||||
|
||||
export interface CategoryOverTimeData {
|
||||
categories: string[];
|
||||
data: CategoryOverTimeItem[];
|
||||
colors: Record<string, string>;
|
||||
}
|
||||
|
||||
export type ImportWizardStep =
|
||||
| "source-list"
|
||||
| "source-config"
|
||||
|
|
|
|||
Loading…
Reference in a new issue