Add savings chat analysis feature

This commit is contained in:
Matthias
2026-06-15 18:26:25 +02:00
parent d65e7681ac
commit 4869402d45
26 changed files with 2789 additions and 163 deletions

View File

@@ -0,0 +1,36 @@
import { describe, expect, test } from "vitest";
import { toCategoryPieData } from "./categoryBreakdownData";
describe("toCategoryPieData", () => {
test("uses positive chart values while preserving signed expense amounts", () => {
expect(
toCategoryPieData([
{
name: "Lebensmittel",
amount: -123.45,
color: "#ef4444",
block: "variabel",
},
{
name: "Rueckerstattung",
amount: 12,
color: "#22c55e",
},
]),
).toEqual([
{
name: "Lebensmittel",
amount: -123.45,
chartAmount: 123.45,
color: "#ef4444",
block: "variabel",
},
{
name: "Rueckerstattung",
amount: 12,
chartAmount: 12,
color: "#22c55e",
},
]);
});
});

View File

@@ -3,24 +3,20 @@ import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recha
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { formatAmount } from "@/lib/format";
import { type CategoryBreakdownItem, toCategoryPieData } from "./categoryBreakdownData";
type Item = {
name: string;
amount: number;
color: string;
block?: "wiederkehrend" | "variabel";
};
export function CategoryBreakdownChart({ data }: { data: Item[] }) {
export function CategoryBreakdownChart({ data }: { data: CategoryBreakdownItem[] }) {
const [filter, setFilter] = useState<"all" | "wiederkehrend" | "variabel">("all");
const filtered = data.filter((d) => {
if (filter === "all") return true;
return d.block === filter;
});
const pieData = toCategoryPieData(filtered);
const total = pieData.reduce((sum, item) => sum + item.chartAmount, 0);
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle>Ausgaben nach Kategorie</CardTitle>
<div className="flex gap-1">
{(["all", "wiederkehrend", "variabel"] as const).map((f) => (
@@ -30,18 +26,56 @@ export function CategoryBreakdownChart({ data }: { data: Item[] }) {
))}
</div>
</CardHeader>
<CardContent className="h-80">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={filtered} dataKey="amount" nameKey="name" cx="50%" cy="50%" outerRadius={100} label>
{filtered.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<Tooltip formatter={(v) => formatAmount(Number(v ?? 0))} />
<Legend />
</PieChart>
</ResponsiveContainer>
<CardContent>
{pieData.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Ausgaben im gewählten Zeitraum</p>
) : (
<div className="grid gap-6 lg:grid-cols-[minmax(260px,0.85fr)_minmax(320px,1.15fr)] lg:items-center">
<div className="h-72 min-h-72 min-w-0 w-full">
<ResponsiveContainer width="100%" height="100%" minWidth={240} minHeight={240}>
<PieChart>
<Pie data={pieData} dataKey="chartAmount" nameKey="name" cx="50%" cy="50%" outerRadius={105}>
{pieData.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<Tooltip
formatter={(v, _name, item) =>
formatAmount(
typeof item.payload?.amount === "number" ? item.payload.amount : Number(v ?? 0),
)
}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="max-h-72 min-w-0 overflow-y-auto pr-1">
<ul className="space-y-2">
{pieData.map((entry) => {
const share = total > 0 ? entry.chartAmount / total : 0;
return (
<li key={entry.name} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 text-sm">
<div className="flex min-w-0 items-center gap-2">
<span
className="h-3 w-3 shrink-0 rounded-sm"
style={{ backgroundColor: entry.color }}
aria-hidden="true"
/>
<span className="truncate font-medium">{entry.name}</span>
</div>
<div className="text-right tabular-nums">
<div className="font-medium">{formatAmount(entry.amount)}</div>
<div className="text-xs text-muted-foreground">{Math.round(share * 100)}%</div>
</div>
</li>
);
})}
</ul>
</div>
</div>
)}
</CardContent>
</Card>
);
@@ -67,13 +101,21 @@ export function FixedVariableSplit({
<CardContent className="h-64">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={data} dataKey="value" nameKey="name" innerRadius={50} outerRadius={80}>
<Pie
data={data}
dataKey="value"
nameKey="name"
innerRadius={50}
outerRadius={80}
stroke="var(--card)"
strokeWidth={2}
>
{data.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<Tooltip formatter={(v) => formatAmount(-Number(v ?? 0))} />
<Legend />
<Tooltip formatter={(v) => formatAmount(-Number(v ?? 0))} />
<Legend wrapperStyle={{ fontSize: 14 }} iconType="circle" />
</PieChart>
</ResponsiveContainer>
</CardContent>

View File

@@ -10,10 +10,12 @@ import {
YAxis,
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { eur } from "@/lib/format";
import { eur, formatEurCompact } from "@/lib/format";
type Point = { month: string; income: number; expenses: number; balance: number };
const axisTick = { fontSize: 13, fill: "var(--muted-foreground)" };
export function MonthlyTrendChart({ data }: { data: Point[] }) {
return (
<Card>
@@ -22,12 +24,25 @@ export function MonthlyTrendChart({ data }: { data: Point[] }) {
</CardHeader>
<CardContent className="h-80">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis tickFormatter={(v) => eur.format(v)} />
<ComposedChart data={data} margin={{ top: 8, right: 16, bottom: 0, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="month"
tick={axisTick}
tickLine={false}
axisLine={{ stroke: "var(--border)" }}
height={44}
interval="preserveStartEnd"
/>
<YAxis
tickFormatter={formatEurCompact}
tick={axisTick}
tickLine={false}
axisLine={false}
width={64}
/>
<Tooltip formatter={(v) => eur.format(Number(v ?? 0))} />
<Legend />
<Legend wrapperStyle={{ fontSize: 14, paddingTop: 8 }} />
<Bar dataKey="income" name="Einnahmen" fill="#22c55e" />
<Bar dataKey="expenses" name="Ausgaben" fill="#ef4444" />
<Line dataKey="balance" name="Saldo" stroke="#6366f1" strokeWidth={2} />

View File

@@ -0,0 +1,17 @@
export type CategoryBreakdownItem = {
name: string;
amount: number;
color: string;
block?: "wiederkehrend" | "variabel";
};
export type CategoryPieItem = CategoryBreakdownItem & {
chartAmount: number;
};
export function toCategoryPieData(data: CategoryBreakdownItem[]): CategoryPieItem[] {
return data.map((item) => ({
...item,
chartAmount: Math.abs(item.amount),
}));
}

View File

@@ -0,0 +1,88 @@
import { MessageCircle, Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export type ChatHistoryItem = {
id: string;
title: string;
updatedAt: number;
messageCount: number;
};
type ChatHistoryProps = {
items: ChatHistoryItem[];
activeId: string;
onSelect: (id: string) => void;
onCreate: () => void;
onDelete: (id: string) => void;
};
const dateFormatter = new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
export function ChatHistory({
items,
activeId,
onSelect,
onCreate,
onDelete,
}: ChatHistoryProps) {
return (
<aside className="rounded-xl border bg-card text-card-foreground shadow-sm">
<div className="flex items-center justify-between gap-2 border-b p-3">
<div className="flex min-w-0 items-center gap-2">
<MessageCircle className="h-4 w-4 shrink-0" />
<h2 className="truncate text-sm font-semibold">Chat-Historie</h2>
</div>
<Button type="button" variant="outline" size="icon" onClick={onCreate} title="Neuer Chat">
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="max-h-[64vh] overflow-y-auto p-2">
{items.length === 0 ? (
<p className="px-2 py-6 text-center text-sm text-muted-foreground">
Noch keine Chats
</p>
) : (
<div className="space-y-1">
{items.map((item) => (
<div
key={item.id}
className={cn(
"group flex items-center gap-1 rounded-md",
item.id === activeId ? "bg-accent" : "hover:bg-muted",
)}
>
<button
type="button"
onClick={() => onSelect(item.id)}
className="min-w-0 flex-1 px-2 py-2 text-left"
>
<span className="block truncate text-sm font-medium">{item.title}</span>
<span className="block truncate text-xs text-muted-foreground">
{dateFormatter.format(new Date(item.updatedAt))} · {item.messageCount} Nachrichten
</span>
</button>
<Button
type="button"
variant="ghost"
size="icon"
className="mr-1 h-8 w-8 opacity-70 hover:opacity-100"
onClick={() => onDelete(item.id)}
title="Chat löschen"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
</aside>
);
}

View File

@@ -0,0 +1,129 @@
import { useMemo, useState } from "react";
import * as Popover from "@radix-ui/react-popover";
import { Check, ChevronDown, X } from "lucide-react";
import { useQuery } from "convex/react";
import { api } from "../../../convex/_generated/api";
import { useFilters } from "@/context/FilterContext";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const NONE_VALUE = "__none__";
export function CategoryFilter() {
const categories = useQuery(api.categories.list);
const { categoryIds, setCategoryIds } = useFilters();
const [open, setOpen] = useState(false);
const selectedSet = useMemo(() => new Set(categoryIds), [categoryIds]);
const noneSelected = selectedSet.has(NONE_VALUE);
const toggle = (value: string) => {
const next = new Set(categoryIds);
if (next.has(value)) {
next.delete(value);
} else {
next.add(value);
}
setCategoryIds(Array.from(next));
};
const clear = () => setCategoryIds([]);
const label = useMemo(() => {
if (categoryIds.length === 0) return "Alle Kategorien";
const names: string[] = [];
if (noneSelected) names.push("Ohne Kategorie");
categories?.forEach((c) => {
if (selectedSet.has(c._id)) names.push(c.name);
});
if (names.length === 1) return names[0];
return `${names.length} Kategorien`;
}, [categoryIds.length, noneSelected, categories, selectedSet]);
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<Button variant="outline" className="w-[200px] justify-between px-3">
<span className="truncate">{label}</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align="start"
sideOffset={4}
className="z-50 w-[220px] rounded-md border bg-popover p-2 text-popover-foreground shadow-md"
>
<div className="mb-1 flex items-center justify-between px-1">
<span className="text-xs font-medium text-muted-foreground">Kategorien filtern</span>
{categoryIds.length > 0 && (
<button
type="button"
onClick={clear}
className="inline-flex items-center gap-0.5 text-xs text-muted-foreground hover:text-foreground"
>
<X className="h-3 w-3" /> Zurücksetzen
</button>
)}
</div>
<div className="max-h-72 overflow-auto">
<CategoryItem
value={NONE_VALUE}
label="Ohne Kategorie"
color="#9ca3af"
checked={noneSelected}
onToggle={() => toggle(NONE_VALUE)}
/>
{categories?.map((c) => (
<CategoryItem
key={c._id}
value={c._id}
label={c.name}
color={c.color}
checked={selectedSet.has(c._id)}
onToggle={() => toggle(c._id)}
/>
))}
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}
function CategoryItem({
value,
label,
color,
checked,
onToggle,
}: {
value: string;
label: string;
color: string;
checked: boolean;
onToggle: () => void;
}) {
return (
<label
htmlFor={`cat-filter-${value}`}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent",
checked && "bg-accent/50",
)}
>
<span className="flex h-4 w-4 items-center justify-center rounded border">
{checked && <Check className="h-3 w-3" />}
</span>
<input
id={`cat-filter-${value}`}
type="checkbox"
className="sr-only"
checked={checked}
onChange={onToggle}
/>
<span className="inline-block h-2.5 w-2.5 rounded-full" style={{ backgroundColor: color }} />
<span className="flex-1 truncate">{label}</span>
</label>
);
}

View File

@@ -2,6 +2,7 @@ import { NavLink } from "react-router-dom";
import {
CreditCard,
FolderTree,
MessageCircle,
Import,
LayoutDashboard,
Settings,
@@ -12,6 +13,7 @@ import { cn } from "@/lib/utils";
const links = [
{ to: "/", label: "Übersicht", icon: LayoutDashboard },
{ to: "/transaktionen", label: "Transaktionen", icon: Wallet },
{ to: "/talk", label: "Talk to Savings", icon: MessageCircle },
{ to: "/kategorien", label: "Kategorien", icon: FolderTree },
{ to: "/kredite", label: "Kredite", icon: CreditCard },
{ to: "/import", label: "CSV & comdirect", icon: Import },