Compare commits

...

2 Commits

16 changed files with 1942 additions and 74 deletions

View File

@@ -0,0 +1,53 @@
---
id: TASK-3
title: Build read-only savings agent with tool calls
status: In Progress
assignee: []
created_date: '2026-06-15 19:02'
updated_date: '2026-06-15 19:11'
labels: []
dependencies: []
priority: high
ordinal: 3000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Convert Talk to Savings from a prompt-packed chat into a read-only AI SDK tool-calling agent with deterministic transaction retrieval, spending summaries, forecasts, and compact frontend tool traces.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Savings chat action uses AI SDK tool calls with a bounded multi-step loop and returns a compact tool trace
- [x] #2 Read-only transaction retrieval tool supports selected scope, bounded custom ranges, account/category/search filters, exact totals, sanitized rows, and hasMore
- [x] #3 Spending summary and cashflow forecast tools compute deterministic aggregates without model-invented math
- [x] #4 Frontend remains localStorage-backed, loads legacy chats, and renders compact tool traces under assistant answers
- [x] #5 Focused Convex tests, build, and targeted lint verification pass or known blockers are documented
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Write failing Convex tests for read-only tool queries and traces
2. Implement internal read-only savings agent tool queries
3. Switch savingsChat.ask to AI SDK tool calling with bounded multi-step loop
4. Update frontend chat messages and compact tool trace rendering
5. Run focused tests, build, targeted lint, and record verification notes
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implementation complete pending user confirmation.
Subagents:
- Frontend UX worker updated src/pages/SavingsChatPage.tsx for legacy-safe localStorage message normalization and compact assistant tool traces.
- QA explorer reviewed Convex test strategy and confirmed mock AI SDK approach for ask() coverage.
Verification:
- PASS npx vitest convex/savingsChat.test.ts --run (8 tests)
- PASS npx eslint convex/savingsChat.ts convex/savingsChat.test.ts src/pages/SavingsChatPage.tsx
- PASS npm run build
- BLOCKED npm run lint on pre-existing unrelated files: convex/bank/comdirectProvider.ts, convex/bank/config.ts, src/components/import/TanAwaitDialog.tsx, layout/ui fast-refresh exports, SettingsPage.tsx, generated eslint-disable warnings, and existing React compiler warnings. No TASK-3 touched file appears in the full-lint error list.
<!-- SECTION:NOTES:END -->

View File

@@ -0,0 +1,41 @@
---
id: TASK-4
title: Add selection total to transactions
status: In Progress
assignee: []
created_date: '2026-06-15 19:21'
updated_date: '2026-06-15 19:24'
labels: []
dependencies: []
priority: high
ordinal: 4000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add table selection controls on the transactions page so visible/filtered rows can be selected in bulk and the sum of selected transaction amounts is shown.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 A select-all control can select and clear all currently visible filtered transactions
- [x] #2 Individual row selection continues to work alongside the select-all control
- [x] #3 The UI displays the count and formatted euro total of selected transactions
- [x] #4 Automated tests cover bulk selection and selected-total behavior
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Inspect current transaction table selection behavior
2. Add failing coverage for select-all and selected sum
3. Implement visible-row bulk selection and selected total UI
4. Run focused tests/build and update acceptance criteria
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Added failing Vitest coverage for visible bulk selection and selected totals, then implemented transactionsSelection helpers and wired TransactionsPage header checkbox plus selected count/sum. Focused test and build pass. Global lint currently fails on pre-existing unrelated lint errors in Convex/generated/UI files.
<!-- SECTION:NOTES:END -->

View File

@@ -0,0 +1,40 @@
---
id: TASK-5
title: Make transaction filters combinable
status: In Progress
assignee: []
created_date: '2026-06-15 19:32'
updated_date: '2026-06-15 19:34'
labels: []
dependencies: []
priority: high
ordinal: 5000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Fix the transactions page so global timeframe, account, and month-basis filters are applied together with page-level filters like search, type, category, and pending-only.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Changing the date range filters the transactions list
- [x] #2 Account, month basis, search, type, category, and pending-only filters combine instead of overriding each other
- [x] #3 Automated tests cover combined transaction filtering
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Trace current filter data flow from shared controls to transactions query
2. Add failing Convex test for combined date/account/category/search filtering
3. Extend transactions list query args and wire TransactionsPage to shared filters
4. Run focused tests, full tests/build, and update task notes
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Root cause: TransactionsPage did not pass global from/to/account/monthBasis filters into api.transactions.list, and transactions.list ignored date filters on the search-index path. Added red Convex tests covering combined filters and effective-month basis, then added basis/date filtering in the query and wired global filters from the page. Focused tests and build pass.
<!-- SECTION:NOTES:END -->

View File

@@ -0,0 +1,32 @@
---
id: TASK-6
title: Add transaction filter reset button
status: In Progress
assignee: []
created_date: '2026-06-15 19:35'
updated_date: '2026-06-15 19:37'
labels: []
dependencies: []
priority: medium
ordinal: 6000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add a reset button on the transactions page that clears the active filter combination and returns global plus page-level transaction filters to their defaults.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 A reset button is available on the transactions page filter bar
- [x] #2 Clicking reset clears search, type, category, account, pending-only, and row selection
- [x] #3 Clicking reset returns date range and month basis to their defaults
- [x] #4 Automated tests cover the reset defaults
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Added a transactions toolbar reset button using shared reset defaults. The button clears global filters (current month, all accounts, no categories, effective month basis), clears page filters (search, type, pending-only), and clears row selection. Added Vitest coverage for reset defaults. Focused tests and build pass.
<!-- SECTION:NOTES:END -->

View File

@@ -1,14 +1,58 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
import { convexTest } from "convex-test"; import { convexTest } from "convex-test";
import { describe, expect, test } from "vitest"; import { generateText } from "ai";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { api, internal } from "./_generated/api"; import { api, internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel"; import type { Id } from "./_generated/dataModel";
import { buildToolTraceFromSteps } from "./savingsChat";
import schema from "./schema"; import schema from "./schema";
vi.mock("ai", async (importOriginal) => {
const actual = await importOriginal<typeof import("ai")>();
return {
...actual,
generateText: vi.fn(async (options: {
tools: {
get_transactions: { execute: (input: unknown) => Promise<unknown> };
summarize_spending: { execute: (input: unknown) => Promise<unknown> };
};
}) => {
const transactionInput = { from: "2026-02-01", to: "2026-02-28", limit: 2 };
const summaryInput = { from: "2026-02-01", to: "2026-02-28" };
const transactionOutput = await options.tools.get_transactions.execute(transactionInput);
const summaryOutput = await options.tools.summarize_spending.execute(summaryInput);
return {
text: "Agenten-Antwort",
steps: [
{
toolResults: [
{
toolName: "get_transactions",
input: transactionInput,
output: transactionOutput,
},
{
toolName: "summarize_spending",
input: summaryInput,
output: summaryOutput,
},
],
},
],
};
}),
};
});
const modules = import.meta.glob("./**/*.ts"); const modules = import.meta.glob("./**/*.ts");
delete modules["./savingsChat.test.ts"]; delete modules["./savingsChat.test.ts"];
beforeEach(() => {
vi.clearAllMocks();
});
describe("savingsChat.getContext", () => { describe("savingsChat.getContext", () => {
test("counts and sums every matching transaction before applying prompt limits", async () => { test("counts and sums every matching transaction before applying prompt limits", async () => {
const t = convexTest(schema, modules); const t = convexTest(schema, modules);
@@ -192,3 +236,479 @@ describe("savingsChat.getContext", () => {
expect(context.transactionLines.join("\n")).not.toContain("Other account should not appear"); expect(context.transactionLines.join("\n")).not.toContain("Other account should not appear");
}); });
}); });
describe("savingsChat read-only agent tools", () => {
test("getTransactionsTool applies account scope, exact totals, limits, and sanitizes rows", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Tool User",
email: "tool@example.com",
});
const categoryId = await ctx.db.insert("categories", {
userId,
name: "Lebensmittel",
kind: "ausgabe",
block: "variabel",
color: "#22c55e",
sortOrder: 1,
isSystem: false,
});
const giroAccountId = await ctx.db.insert("accounts", {
userId,
name: "Girokonto",
type: "checking",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
const savingsAccountId = await ctx.db.insert("accounts", {
userId,
name: "Tagesgeld",
type: "savings",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
await ctx.db.insert("transactions", {
userId,
accountId: giroAccountId,
categoryId,
bookingDate: "2026-01-05",
valueDate: "2026-01-05",
description: "Supermarkt",
counterparty: "Markt GmbH",
amount: -100,
isPending: false,
rawText: "RAW BANK PAYLOAD",
notes: "private note",
dedupHash: "secret-hash",
externalRef: "external-ref",
effectiveMonth: "2026-01",
});
await ctx.db.insert("transactions", {
userId,
accountId: giroAccountId,
bookingDate: "2026-01-07",
valueDate: "2026-01-07",
description: "Baecker",
amount: -15,
isPending: false,
effectiveMonth: "2026-01",
});
await ctx.db.insert("transactions", {
userId,
accountId: giroAccountId,
bookingDate: "2026-01-03",
valueDate: "2026-01-03",
description: "Kaffee",
amount: -5,
isPending: false,
effectiveMonth: "2026-01",
});
await ctx.db.insert("transactions", {
userId,
accountId: savingsAccountId,
bookingDate: "2026-01-08",
valueDate: "2026-01-08",
description: "Other account",
amount: 999,
isPending: false,
effectiveMonth: "2026-01",
});
return { userId, giroAccountId };
});
const asUser = t.withIdentity({
subject: `${seeded.userId}|test-session`,
tokenIdentifier: `test:${seeded.userId}`,
});
const result = await asUser.query(internal.savingsChat.getTransactionsTool, {
scope: {
from: "2026-01-01",
to: "2026-01-31",
accountId: seeded.giroAccountId as Id<"accounts">,
basis: "booking",
},
limit: 2,
});
expect(result.totalCount).toBe(3);
expect(result.hasMore).toBe(true);
expect(result.rows).toHaveLength(2);
expect(result.totals).toEqual({
transactionCount: 3,
income: 0,
expenses: -120,
balance: -120,
});
expect(result.rows[0].accountName).toBe("Girokonto");
expect(result.rows.find((row) => row.description === "Supermarkt")?.categoryName).toBe(
"Lebensmittel",
);
expect(Object.keys(result.rows[0])).not.toContain("rawText");
expect(Object.keys(result.rows[0])).not.toContain("notes");
expect(Object.keys(result.rows[0])).not.toContain("dedupHash");
expect(Object.keys(result.rows[0])).not.toContain("externalRef");
expect(Object.keys(result.rows[0])).not.toContain("userId");
});
test("getTransactionsTool includes legacy effective-month rows via booking fallback", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Legacy User",
email: "legacy@example.com",
});
const accountId = await ctx.db.insert("accounts", {
userId,
name: "Girokonto",
type: "checking",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
await ctx.db.insert("transactions", {
userId,
accountId,
bookingDate: "2026-03-12",
valueDate: "2026-03-12",
description: "Legacy no effective month",
amount: -30,
isPending: false,
});
await ctx.db.insert("transactions", {
userId,
accountId,
bookingDate: "2026-03-20",
valueDate: "2026-03-20",
description: "Modern effective month",
amount: -20,
isPending: false,
effectiveMonth: "2026-03",
});
return { userId, accountId };
});
const asUser = t.withIdentity({
subject: `${seeded.userId}|test-session`,
tokenIdentifier: `test:${seeded.userId}`,
});
const result = await asUser.query(internal.savingsChat.getTransactionsTool, {
scope: {
from: "2026-03-01",
to: "2026-03-31",
accountId: seeded.accountId as Id<"accounts">,
basis: "effective",
},
limit: 10,
});
expect(result.totalCount).toBe(2);
expect(result.totals.expenses).toBe(-50);
expect(result.rows.map((row) => row.description)).toContain("Legacy no effective month");
});
test("summarizeSpendingTool computes exact monthly and category aggregates", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Summary User",
email: "summary@example.com",
});
const accountId = await ctx.db.insert("accounts", {
userId,
name: "Girokonto",
type: "checking",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
const rentId = await ctx.db.insert("categories", {
userId,
name: "Miete",
kind: "ausgabe",
block: "wiederkehrend",
color: "#64748b",
sortOrder: 1,
isSystem: false,
});
const foodId = await ctx.db.insert("categories", {
userId,
name: "Lebensmittel",
kind: "ausgabe",
block: "variabel",
color: "#22c55e",
sortOrder: 2,
isSystem: false,
});
const salaryId = await ctx.db.insert("categories", {
userId,
name: "Gehalt",
kind: "einnahme",
color: "#0ea5e9",
sortOrder: 3,
isSystem: false,
});
for (const tx of [
{ date: "2026-01-01", description: "Gehalt Januar", amount: 3000, categoryId: salaryId },
{ date: "2026-01-02", description: "Miete Januar", amount: -1000, categoryId: rentId },
{ date: "2026-01-10", description: "Supermarkt Januar", amount: -200, categoryId: foodId },
{ date: "2026-02-01", description: "Gehalt Februar", amount: 3000, categoryId: salaryId },
{ date: "2026-02-02", description: "Sonstiges Februar", amount: -50, categoryId: undefined },
]) {
await ctx.db.insert("transactions", {
userId,
accountId,
categoryId: tx.categoryId,
bookingDate: tx.date,
valueDate: tx.date,
description: tx.description,
amount: tx.amount,
isPending: false,
effectiveMonth: tx.date.slice(0, 7),
});
}
return { userId, accountId };
});
const asUser = t.withIdentity({
subject: `${seeded.userId}|test-session`,
tokenIdentifier: `test:${seeded.userId}`,
});
const result = await asUser.query(internal.savingsChat.summarizeSpendingTool, {
scope: {
from: "2026-01-01",
to: "2026-02-28",
accountId: seeded.accountId as Id<"accounts">,
basis: "effective",
},
});
expect(result.totals).toEqual({
transactionCount: 5,
income: 6000,
expenses: -1250,
balance: 4750,
});
expect(result.fixedCosts).toBe(-1000);
expect(result.variableCosts).toBe(-200);
expect(result.monthlyTrend).toEqual([
{ month: "2026-01", income: 3000, expenses: -1200, balance: 1800 },
{ month: "2026-02", income: 3000, expenses: -50, balance: 2950 },
]);
expect(result.categoryBreakdown.map((entry) => [entry.name, entry.amount])).toEqual([
["Miete", -1000],
["Lebensmittel", -200],
["Ohne Kategorie", -50],
]);
});
test("forecastCashflowTool excludes partial current month from the baseline", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Forecast User",
email: "forecast@example.com",
});
const accountId = await ctx.db.insert("accounts", {
userId,
name: "Girokonto",
type: "checking",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
for (const tx of [
{ date: "2026-04-01", description: "Gehalt April", amount: 3000 },
{ date: "2026-04-10", description: "Kosten April", amount: -2000 },
{ date: "2026-05-01", description: "Gehalt Mai", amount: 3200 },
{ date: "2026-05-10", description: "Kosten Mai", amount: -2200 },
{ date: "2026-06-01", description: "Gehalt Juni", amount: 3000 },
{ date: "2026-06-10", description: "Teilkosten Juni", amount: -500 },
]) {
await ctx.db.insert("transactions", {
userId,
accountId,
bookingDate: tx.date,
valueDate: tx.date,
description: tx.description,
amount: tx.amount,
isPending: false,
effectiveMonth: tx.date.slice(0, 7),
});
}
return { userId, accountId };
});
const asUser = t.withIdentity({
subject: `${seeded.userId}|test-session`,
tokenIdentifier: `test:${seeded.userId}`,
});
const result = await asUser.query(internal.savingsChat.forecastCashflowTool, {
scope: {
from: "2026-04-01",
to: "2026-06-30",
accountId: seeded.accountId as Id<"accounts">,
basis: "effective",
},
horizonMonths: 3,
asOf: "2026-06-15",
});
expect(result.baselineMonths).toEqual(["2026-04", "2026-05"]);
expect(result.excludedPartialMonth).toBe("2026-06");
expect(result.monthlyAverage).toEqual({ income: 3100, expenses: -2100, balance: 1000 });
expect(result.projection).toEqual([
{ month: "2026-07", income: 3100, expenses: -2100, balance: 1000 },
{ month: "2026-08", income: 3100, expenses: -2100, balance: 1000 },
{ month: "2026-09", income: 3100, expenses: -2100, balance: 1000 },
]);
});
test("buildToolTraceFromSteps returns compact summaries without raw tool output", () => {
const trace = buildToolTraceFromSteps([
{
toolResults: [
{
toolName: "get_transactions",
input: { from: "2026-01-01", to: "2026-01-31", limit: 50 },
output: {
totalCount: 2,
hasMore: false,
totals: { income: 0, expenses: -115, balance: -115, transactionCount: 2 },
rows: [
{ description: "Supermarkt", amount: -100 },
{ description: "Baecker", amount: -15 },
],
},
},
],
},
]);
expect(trace).toEqual([
{
name: "get_transactions",
inputSummary: "2026-01-01 bis 2026-01-31, Limit 50",
resultSummary: "2 Umsätze, Saldo -115.00€, vollständig",
},
]);
});
test("ask returns compact tool traces from executed read-only tools", async () => {
const t = convexTest(schema, modules);
const previousKey = process.env.OPENAI_API_KEY;
const previousModel = process.env.SAVINGS_CHAT_MODEL;
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Ask User",
email: "ask@example.com",
});
const accountId = await ctx.db.insert("accounts", {
userId,
name: "Girokonto",
type: "checking",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
await ctx.db.insert("transactions", {
userId,
accountId,
bookingDate: "2026-02-01",
valueDate: "2026-02-01",
description: "Gehalt",
amount: 3000,
isPending: false,
effectiveMonth: "2026-02",
rawText: "RAW PAYLOAD SHOULD NOT LEAK",
});
await ctx.db.insert("transactions", {
userId,
accountId,
bookingDate: "2026-02-10",
valueDate: "2026-02-10",
description: "Supermarkt",
amount: -120,
isPending: false,
effectiveMonth: "2026-02",
notes: "private note should not leak",
});
return { userId, accountId };
});
try {
process.env.OPENAI_API_KEY = "test-key";
delete process.env.SAVINGS_CHAT_MODEL;
const asUser = t.withIdentity({
subject: `${seeded.userId}|test-session`,
tokenIdentifier: `test:${seeded.userId}`,
});
const result = await asUser.action(api.savingsChat.ask, {
messages: [{ role: "user", content: "Wie sieht Februar aus?" }],
from: "2026-02-01",
to: "2026-02-28",
accountId: seeded.accountId as Id<"accounts">,
basis: "effective",
});
expect(result.answer).toBe("Agenten-Antwort");
expect(result.model).toBe("gpt-5.4-mini");
expect(result.usedTransactions).toBe(2);
expect(result.usedBalance).toEqual({ income: 3000, expenses: -120, balance: 2880 });
expect(result.toolTrace).toEqual([
{
name: "get_transactions",
inputSummary: "2026-02-01 bis 2026-02-28, Limit 2",
resultSummary: "2 Umsätze, Saldo 2880.00€, vollständig",
},
{
name: "summarize_spending",
inputSummary: "2026-02-01 bis 2026-02-28",
resultSummary: "2 Umsätze, Saldo 2880.00€, 1 Kategorien",
},
]);
expect(JSON.stringify(result.toolTrace)).not.toContain("RAW PAYLOAD");
expect(JSON.stringify(result.toolTrace)).not.toContain("private note");
expect(generateText).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.objectContaining({
get_transactions: expect.any(Object),
summarize_spending: expect.any(Object),
forecast_cashflow: expect.any(Object),
}),
stopWhen: expect.any(Function),
}),
);
} finally {
if (previousKey === undefined) {
delete process.env.OPENAI_API_KEY;
} else {
process.env.OPENAI_API_KEY = previousKey;
}
if (previousModel === undefined) {
delete process.env.SAVINGS_CHAT_MODEL;
} else {
process.env.SAVINGS_CHAT_MODEL = previousModel;
}
}
});
});

View File

@@ -1,9 +1,10 @@
import { action, internalQuery, query } from "./_generated/server"; import { action, internalQuery, query } from "./_generated/server";
import { v } from "convex/values"; import { v } from "convex/values";
import { generateText } from "ai"; import { generateText, stepCountIs, tool } from "ai";
import { openai } from "@ai-sdk/openai"; import { openai } from "@ai-sdk/openai";
import { internal } from "./_generated/api"; import { internal } from "./_generated/api";
import { bookingMonth } from "./lib/month"; import { z } from "zod";
import { addMonthsToMonthKey, bookingMonth, monthKeyFromBasis } from "./lib/month";
import { requireUserId } from "./lib/helpers"; import { requireUserId } from "./lib/helpers";
import type { Doc, Id } from "./_generated/dataModel"; import type { Doc, Id } from "./_generated/dataModel";
import type { QueryCtx } from "./_generated/server"; import type { QueryCtx } from "./_generated/server";
@@ -17,7 +18,9 @@ const chatMessageValidator = v.object({
}); });
const MAX_CONVERSATION_MESSAGES = 20; const MAX_CONVERSATION_MESSAGES = 20;
const MAX_PROMPT_CHARACTERS = 180_000; const DEFAULT_TOOL_ROW_LIMIT = 50;
const MAX_TOOL_ROW_LIMIT = 200;
const MAX_TOOL_RANGE_MONTHS = 18;
type ChatContextArgs = { type ChatContextArgs = {
from: string; from: string;
@@ -42,6 +45,34 @@ type ChatAskResult = {
answer: string; answer: string;
usedTransactions: number; usedTransactions: number;
usedBalance: { income: number; expenses: number; balance: number }; usedBalance: { income: number; expenses: number; balance: number };
toolTrace: ToolTrace[];
};
type ToolTrace = { name: string; inputSummary: string; resultSummary: string };
type TransactionTypeFilter = "income" | "expense";
type AgentToolScope = ChatContextArgs;
type TransactionToolArgs = {
scope: AgentToolScope;
from?: string;
to?: string;
accountId?: Id<"accounts">;
accountName?: string;
categoryIds?: Id<"categories">[];
categoryNames?: string[];
search?: string;
type?: TransactionTypeFilter;
limit?: number;
};
type ToolTransactionContext = {
from: string;
to: string;
basis: AgentToolScope["basis"];
accountId?: Id<"accounts">;
accountName?: string;
categories: Doc<"categories">[];
accounts: Doc<"accounts">[];
categoryById: Map<Id<"categories">, Doc<"categories">>;
accountById: Map<Id<"accounts">, Doc<"accounts">>;
transactions: Doc<"transactions">[];
}; };
function formatEuro(value: number): string { function formatEuro(value: number): string {
@@ -51,37 +82,18 @@ function formatEuro(value: number): string {
function buildSystemPrompt(context: { from: string; to: string; basis: string; accountName?: string }) { function buildSystemPrompt(context: { from: string; to: string; basis: string; accountName?: string }) {
return [ return [
"Du bist ein präziser Finanz-Chat-Assistent für Privatanwender.", "Du bist ein präziser Finanz-Chat-Assistent für Privatanwender.",
"Nutze ausschließlich die gelieferten Umsätze als Kontext und beziehe dich nur auf die angegebenen Werte.", "Nutze ausschließlich die bereitgestellten Werkzeuge und deren Ergebnisse als Finanzkontext.",
"Rufe Werkzeuge auf, wenn du Umsätze, Zusammenfassungen oder Prognosen brauchst; erfinde keine Beträge.",
"Antworte auf Deutsch, kurz und handlungsorientiert.", "Antworte auf Deutsch, kurz und handlungsorientiert.",
`Zeitraum: ${context.from} bis ${context.to}.`, `Zeitraum: ${context.from} bis ${context.to}.`,
`Basis: ${context.basis}.`, `Basis: ${context.basis}.`,
context.accountName ? `Konto: ${context.accountName}.` : "Konto: Alle Konten.", context.accountName ? `Konto: ${context.accountName}.` : "Konto: Alle Konten.",
"Wenn eine Aussage nur grob geschätzt werden kann, kennzeichne sie als Schätzung.", "Wenn eine Aussage nur grob geschätzt werden kann, kennzeichne sie als Schätzung.",
"Nenne keine internen IDs und keine Rohdatenfelder.",
"Verwende keine Links, keine HTML-Tags und keine Emojis.", "Verwende keine Links, keine HTML-Tags und keine Emojis.",
].join(" "); ].join(" ");
} }
function buildPrompt(context: ChatPromptContext, conversation: ChatMessage[]) {
return [
"Kontext der Auswertung:",
`Zeitraum: ${context.from} bis ${context.to}`,
`Basis: ${context.basis}`,
`Konto: ${context.accountName ?? "Alle Konten"}`,
`Anzahl Umsätze: ${context.totals.transactionCount}`,
`Einnahmen: ${formatEuro(context.totals.income)}`,
`Ausgaben: ${formatEuro(context.totals.expenses)}`,
`Saldo: ${formatEuro(context.totals.balance)}`,
"",
"Umsätze (neueste zuerst):",
...(context.transactionLines.length > 0
? context.transactionLines
: ["Keine Umsätze im Zeitraum."]),
"",
"Gesprächsverlauf:",
...conversation.map((message) => `${message.role}: ${message.content}`),
].join("\n");
}
function normalizeRole(role: ChatRole): "user" | "assistant" { function normalizeRole(role: ChatRole): "user" | "assistant" {
return role; return role;
} }
@@ -253,6 +265,55 @@ const contextSummaryValidator = v.object({
isComplete: v.literal(true), isComplete: v.literal(true),
}); });
const toolTraceValidator = v.object({
name: v.string(),
inputSummary: v.string(),
resultSummary: v.string(),
});
const toolScopeValidator = v.object(contextArgsValidator);
const transactionTypeFilterValidator = v.union(v.literal("income"), v.literal("expense"));
const transactionToolArgsValidator = {
scope: toolScopeValidator,
from: v.optional(v.string()),
to: v.optional(v.string()),
accountId: v.optional(v.id("accounts")),
accountName: v.optional(v.string()),
categoryIds: v.optional(v.array(v.id("categories"))),
categoryNames: v.optional(v.array(v.string())),
search: v.optional(v.string()),
type: v.optional(transactionTypeFilterValidator),
limit: v.optional(v.number()),
};
const safeTransactionRowValidator = v.object({
date: v.string(),
bookingDate: v.optional(v.string()),
effectiveMonth: v.optional(v.string()),
description: v.string(),
counterparty: v.optional(v.string()),
amount: v.number(),
categoryName: v.string(),
accountName: v.string(),
isPending: v.boolean(),
});
const monthlyTrendValidator = v.object({
month: v.string(),
income: v.number(),
expenses: v.number(),
balance: v.number(),
});
const categoryBreakdownValidator = v.object({
categoryId: v.optional(v.id("categories")),
name: v.string(),
amount: v.number(),
block: v.optional(v.union(v.literal("wiederkehrend"), v.literal("variabel"))),
});
export const getContext = query({ export const getContext = query({
args: contextArgsValidator, args: contextArgsValidator,
returns: contextSummaryValidator, returns: contextSummaryValidator,
@@ -263,6 +324,211 @@ export const getContext = query({
}, },
}); });
function parseMonthIndex(month: string) {
const [year, monthNumber] = month.split("-").map(Number);
return year * 12 + monthNumber;
}
function rangeMonthSpan(from: string, to: string) {
return parseMonthIndex(to.slice(0, 7)) - parseMonthIndex(from.slice(0, 7)) + 1;
}
function clampToolLimit(limit: number | undefined) {
if (limit === undefined) return DEFAULT_TOOL_ROW_LIMIT;
return Math.max(1, Math.min(MAX_TOOL_ROW_LIMIT, Math.floor(limit)));
}
function normalizeToolRange(scope: AgentToolScope, from?: string, to?: string) {
const range = {
from: from?.trim() || scope.from,
to: to?.trim() || scope.to,
};
if (range.from > range.to) {
throw new Error("Ungültiger Zeitraum: von-Datum liegt nach bis-Datum.");
}
if (rangeMonthSpan(range.from, range.to) > MAX_TOOL_RANGE_MONTHS) {
throw new Error(`Der Tool-Zeitraum darf maximal ${MAX_TOOL_RANGE_MONTHS} Monate umfassen.`);
}
return range;
}
async function loadNameMaps(ctx: QueryCtx, userId: Id<"users">) {
const categories = await ctx.db
.query("categories")
.withIndex("by_user", (index) => index.eq("userId", userId))
.collect();
const accounts = await ctx.db
.query("accounts")
.withIndex("by_user", (index) => index.eq("userId", userId))
.collect();
return {
categories,
accounts,
categoryById: new Map(categories.map((category) => [category._id, category])),
accountById: new Map(accounts.map((account) => [account._id, account])),
};
}
function findAccountIdByName(
accounts: Doc<"accounts">[],
accountName: string | undefined,
): Id<"accounts"> | undefined {
const normalized = accountName?.trim().toLocaleLowerCase("de-DE");
if (!normalized) return undefined;
return accounts.find((account) => account.name.toLocaleLowerCase("de-DE") === normalized)?._id;
}
function categoryNameSet(categoryNames: string[] | undefined) {
const normalized = categoryNames
?.map((name) => name.trim().toLocaleLowerCase("de-DE"))
.filter(Boolean);
return normalized && normalized.length > 0 ? new Set(normalized) : null;
}
function transactionMatchesToolFilters(
tx: Doc<"transactions">,
args: TransactionToolArgs,
context: Pick<ToolTransactionContext, "categoryById" | "accountById">,
) {
if (args.type === "income" && tx.amount <= 0) return false;
if (args.type === "expense" && tx.amount >= 0) return false;
if (args.categoryIds && args.categoryIds.length > 0) {
const categoryId = tx.categoryId;
if (!categoryId || !args.categoryIds.includes(categoryId)) return false;
}
const categoriesByName = categoryNameSet(args.categoryNames);
if (categoriesByName) {
const name = tx.categoryId ? context.categoryById.get(tx.categoryId)?.name : "Ohne Kategorie";
if (!categoriesByName.has((name ?? "Ohne Kategorie").toLocaleLowerCase("de-DE"))) {
return false;
}
}
const search = args.search?.trim().toLocaleLowerCase("de-DE");
if (search) {
const categoryName = tx.categoryId ? context.categoryById.get(tx.categoryId)?.name : "";
const accountName = tx.accountId ? context.accountById.get(tx.accountId)?.name : "";
const haystack = [
tx.description,
tx.counterparty,
categoryName,
accountName,
]
.filter(Boolean)
.join(" ")
.toLocaleLowerCase("de-DE");
if (!haystack.includes(search)) return false;
}
return true;
}
async function buildToolTransactionContext(
ctx: QueryCtx,
userId: Id<"users">,
args: TransactionToolArgs,
): Promise<ToolTransactionContext> {
const maps = await loadNameMaps(ctx, userId);
const range = normalizeToolRange(args.scope, args.from, args.to);
const accountId = args.accountId ?? findAccountIdByName(maps.accounts, args.accountName) ?? args.scope.accountId;
const account = accountId ? maps.accountById.get(accountId) : undefined;
const transactions = (await loadMatchingTransactions(ctx, userId, {
from: range.from,
to: range.to,
accountId,
basis: args.scope.basis,
})).filter((tx) => transactionMatchesToolFilters(tx, args, maps));
return {
...maps,
...range,
basis: args.scope.basis,
accountId,
accountName: account?.name,
transactions,
};
}
function safeTransactionRow(
tx: Doc<"transactions">,
context: Pick<ToolTransactionContext, "categoryById" | "accountById">,
) {
return {
date: tx.valueDate || tx.bookingDate || tx.effectiveMonth || "n/a",
bookingDate: tx.bookingDate,
effectiveMonth: tx.effectiveMonth,
description: tx.description,
counterparty: tx.counterparty,
amount: Math.round(tx.amount * 100) / 100,
categoryName: tx.categoryId
? context.categoryById.get(tx.categoryId)?.name ?? "Ohne Kategorie"
: "Ohne Kategorie",
accountName: tx.accountId
? context.accountById.get(tx.accountId)?.name ?? "Ohne Konto"
: "Ohne Konto",
isPending: tx.isPending,
};
}
function roundMoney(value: number) {
return Math.round(value * 100) / 100;
}
function summarizeTransactions(context: ToolTransactionContext) {
const monthlyMap = new Map<string, { income: number; expenses: number }>();
const categoryMap = new Map<
string,
{ categoryId?: Id<"categories">; name: string; amount: number; block?: "wiederkehrend" | "variabel" }
>();
let fixedCosts = 0;
let variableCosts = 0;
for (const tx of context.transactions) {
const month = monthKeyFromBasis(tx, context.basis);
if (month) {
const monthEntry = monthlyMap.get(month) ?? { income: 0, expenses: 0 };
if (tx.amount > 0) monthEntry.income += tx.amount;
if (tx.amount < 0) monthEntry.expenses += tx.amount;
monthlyMap.set(month, monthEntry);
}
if (tx.amount >= 0) continue;
const category = tx.categoryId ? context.categoryById.get(tx.categoryId) : undefined;
if (category?.block === "wiederkehrend") fixedCosts += tx.amount;
if (category?.block === "variabel") variableCosts += tx.amount;
const key = tx.categoryId ?? "none";
const categoryEntry = categoryMap.get(key) ?? {
categoryId: tx.categoryId,
name: category?.name ?? "Ohne Kategorie",
amount: 0,
block: category?.block,
};
categoryEntry.amount += tx.amount;
categoryMap.set(key, categoryEntry);
}
return {
totals: calculateTotals(context.transactions),
fixedCosts: roundMoney(fixedCosts),
variableCosts: roundMoney(variableCosts),
monthlyTrend: [...monthlyMap.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, data]) => ({
month,
income: roundMoney(data.income),
expenses: roundMoney(data.expenses),
balance: roundMoney(data.income + data.expenses),
})),
categoryBreakdown: [...categoryMap.values()]
.sort((a, b) => a.amount - b.amount)
.map((entry) => ({ ...entry, amount: roundMoney(entry.amount) })),
};
}
function toDisplayContextLine( function toDisplayContextLine(
tx: Doc<"transactions">, tx: Doc<"transactions">,
categoryById: Map<Id<"categories">, string>, categoryById: Map<Id<"categories">, string>,
@@ -278,6 +544,135 @@ function toDisplayContextLine(
}`; }`;
} }
export const getTransactionsTool = internalQuery({
args: transactionToolArgsValidator,
returns: v.object({
from: v.string(),
to: v.string(),
basis: v.union(v.literal("effective"), v.literal("booking")),
accountName: v.optional(v.string()),
totalCount: v.number(),
hasMore: v.boolean(),
totals: totalsValidator,
rows: v.array(safeTransactionRowValidator),
}),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const context = await buildToolTransactionContext(ctx, userId, args);
const limit = clampToolLimit(args.limit);
const rows = context.transactions
.slice(0, limit)
.map((tx) => safeTransactionRow(tx, context));
return {
from: context.from,
to: context.to,
basis: context.basis,
accountName: context.accountName,
totalCount: context.transactions.length,
hasMore: context.transactions.length > limit,
totals: calculateTotals(context.transactions),
rows,
};
},
});
export const summarizeSpendingTool = internalQuery({
args: {
scope: toolScopeValidator,
from: v.optional(v.string()),
to: v.optional(v.string()),
accountId: v.optional(v.id("accounts")),
accountName: v.optional(v.string()),
categoryIds: v.optional(v.array(v.id("categories"))),
categoryNames: v.optional(v.array(v.string())),
search: v.optional(v.string()),
type: v.optional(transactionTypeFilterValidator),
},
returns: v.object({
from: v.string(),
to: v.string(),
basis: v.union(v.literal("effective"), v.literal("booking")),
accountName: v.optional(v.string()),
totals: totalsValidator,
fixedCosts: v.number(),
variableCosts: v.number(),
monthlyTrend: v.array(monthlyTrendValidator),
categoryBreakdown: v.array(categoryBreakdownValidator),
}),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const context = await buildToolTransactionContext(ctx, userId, args);
const summary = summarizeTransactions(context);
return {
from: context.from,
to: context.to,
basis: context.basis,
accountName: context.accountName,
...summary,
};
},
});
export const forecastCashflowTool = internalQuery({
args: {
scope: toolScopeValidator,
from: v.optional(v.string()),
to: v.optional(v.string()),
accountId: v.optional(v.id("accounts")),
accountName: v.optional(v.string()),
horizonMonths: v.optional(v.number()),
asOf: v.optional(v.string()),
},
returns: v.object({
from: v.string(),
to: v.string(),
basis: v.union(v.literal("effective"), v.literal("booking")),
accountName: v.optional(v.string()),
baselineMonths: v.array(v.string()),
excludedPartialMonth: v.union(v.string(), v.null()),
monthlyAverage: v.object({
income: v.number(),
expenses: v.number(),
balance: v.number(),
}),
projection: v.array(monthlyTrendValidator),
}),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const context = await buildToolTransactionContext(ctx, userId, args);
const summary = summarizeTransactions(context);
const asOfMonth = (args.asOf ?? new Date().toISOString().slice(0, 10)).slice(0, 7);
const baseline = summary.monthlyTrend.filter((month) => month.month < asOfMonth);
const excludedPartialMonth = summary.monthlyTrend.some((month) => month.month === asOfMonth)
? asOfMonth
: null;
const horizonMonths = Math.max(1, Math.min(3, Math.floor(args.horizonMonths ?? 3)));
const denominator = baseline.length || 1;
const monthlyAverage = {
income: roundMoney(baseline.reduce((sum, month) => sum + month.income, 0) / denominator),
expenses: roundMoney(baseline.reduce((sum, month) => sum + month.expenses, 0) / denominator),
balance: roundMoney(baseline.reduce((sum, month) => sum + month.balance, 0) / denominator),
};
const projection = Array.from({ length: horizonMonths }, (_, index) => ({
month: addMonthsToMonthKey(asOfMonth, index + 1),
...monthlyAverage,
}));
return {
from: context.from,
to: context.to,
basis: context.basis,
accountName: context.accountName,
baselineMonths: baseline.map((month) => month.month),
excludedPartialMonth,
monthlyAverage,
projection,
};
},
});
export const getPromptContext = internalQuery({ export const getPromptContext = internalQuery({
args: contextArgsValidator, args: contextArgsValidator,
returns: v.object({ returns: v.object({
@@ -308,6 +703,117 @@ export const getPromptContext = internalQuery({
}, },
}); });
function unknownRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function maybeString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function maybeNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function summarizeToolInput(input: unknown) {
const record = unknownRecord(input);
const parts = [];
const from = maybeString(record.from);
const to = maybeString(record.to);
const search = maybeString(record.search);
const limit = maybeNumber(record.limit);
const horizonMonths = maybeNumber(record.horizonMonths);
const type = maybeString(record.type);
const categoryNames = Array.isArray(record.categoryNames)
? record.categoryNames.filter((name): name is string => typeof name === "string")
: [];
if (from || to) parts.push(`${from ?? "?"} bis ${to ?? "?"}`);
if (search) parts.push(`Suche "${search}"`);
if (categoryNames.length > 0) parts.push(`Kategorien ${categoryNames.join(", ")}`);
if (type) parts.push(type === "income" ? "Einnahmen" : "Ausgaben");
if (horizonMonths) parts.push(`${horizonMonths} Monate Prognose`);
if (limit) parts.push(`Limit ${limit}`);
return parts.length > 0 ? parts.join(", ") : "Standard-Kontext";
}
function totalsFromOutput(output: Record<string, unknown>) {
const totals = unknownRecord(output.totals);
return {
count: maybeNumber(output.totalCount) ?? maybeNumber(totals.transactionCount) ?? 0,
balance: maybeNumber(totals.balance) ?? 0,
};
}
function summarizeToolOutput(toolName: string, output: unknown) {
const record = unknownRecord(output);
if (toolName === "get_transactions") {
const totals = totalsFromOutput(record);
const hasMore = record.hasMore === true;
return `${totals.count} Umsätze, Saldo ${formatEuro(totals.balance)}, ${
hasMore ? "weitere vorhanden" : "vollständig"
}`;
}
if (toolName === "summarize_spending") {
const totals = totalsFromOutput(record);
const categoryCount = Array.isArray(record.categoryBreakdown)
? record.categoryBreakdown.length
: 0;
return `${totals.count} Umsätze, Saldo ${formatEuro(totals.balance)}, ${categoryCount} Kategorien`;
}
if (toolName === "forecast_cashflow") {
const projectionCount = Array.isArray(record.projection) ? record.projection.length : 0;
const average = unknownRecord(record.monthlyAverage);
const balance = maybeNumber(average.balance) ?? 0;
return `Prognose ${projectionCount} Monate, durchschnittlicher Saldo ${formatEuro(balance)}`;
}
return "Werkzeug ausgeführt";
}
export function buildToolTraceFromSteps(steps: unknown[]): ToolTrace[] {
const trace: ToolTrace[] = [];
for (const step of steps) {
const stepRecord = unknownRecord(step);
const toolResults = Array.isArray(stepRecord.toolResults) ? stepRecord.toolResults : [];
for (const result of toolResults) {
const resultRecord = unknownRecord(result);
const name = maybeString(resultRecord.toolName);
if (!name) continue;
trace.push({
name,
inputSummary: summarizeToolInput(resultRecord.input),
resultSummary: summarizeToolOutput(name, resultRecord.output),
});
}
}
return trace;
}
const transactionToolInputSchema = z.object({
from: z.string().optional().describe("Optionales Startdatum im Format YYYY-MM-DD."),
to: z.string().optional().describe("Optionales Enddatum im Format YYYY-MM-DD."),
accountName: z.string().optional().describe("Optionaler Kontoname, falls von der UI-Auswahl abweichend."),
categoryNames: z.array(z.string()).optional().describe("Optionale Kategorienamen wie Lebensmittel oder Miete."),
search: z.string().optional().describe("Optionaler Suchtext für Beschreibung, Gegenpartei, Konto oder Kategorie."),
type: z.enum(["income", "expense"]).optional().describe("Optional nur Einnahmen oder Ausgaben abrufen."),
limit: z.number().int().min(1).max(MAX_TOOL_ROW_LIMIT).optional().describe("Maximale Anzahl Umsatzzeilen."),
});
const summaryToolInputSchema = transactionToolInputSchema.omit({ limit: true });
const forecastToolInputSchema = z.object({
from: z.string().optional().describe("Optionales Startdatum der historischen Basis im Format YYYY-MM-DD."),
to: z.string().optional().describe("Optionales Enddatum der historischen Basis im Format YYYY-MM-DD."),
accountName: z.string().optional().describe("Optionaler Kontoname, falls von der UI-Auswahl abweichend."),
horizonMonths: z.number().int().min(1).max(3).optional().describe("Anzahl der zu prognostizierenden Monate, 1 bis 3."),
});
export const ask = action({ export const ask = action({
args: { args: {
messages: v.array(chatMessageValidator), messages: v.array(chatMessageValidator),
@@ -325,6 +831,7 @@ export const ask = action({
expenses: v.number(), expenses: v.number(),
balance: v.number(), balance: v.number(),
}), }),
toolTrace: v.array(toolTraceValidator),
}), }),
handler: async (ctx, args): Promise<ChatAskResult> => { handler: async (ctx, args): Promise<ChatAskResult> => {
if (args.messages.length === 0) { if (args.messages.length === 0) {
@@ -338,26 +845,65 @@ export const ask = action({
} }
await requireUserId(ctx); await requireUserId(ctx);
const scope: AgentToolScope = {
const context: ChatPromptContext = await ctx.runQuery(internal.savingsChat.getPromptContext, {
from: args.from, from: args.from,
to: args.to, to: args.to,
accountId: args.accountId, accountId: args.accountId,
basis: args.basis, basis: args.basis,
};
const selectedSummary: {
totalCount: number;
totals: { income: number; expenses: number; balance: number; transactionCount: number };
accountName?: string;
} = await ctx.runQuery(internal.savingsChat.getTransactionsTool, {
scope,
limit: 1,
}); });
const lastMessages = args.messages const lastMessages = args.messages
.map((message): ChatMessage => ({ role: normalizeRole(message.role), content: message.content })) .map((message): ChatMessage => ({ role: normalizeRole(message.role), content: message.content }))
.slice(-MAX_CONVERSATION_MESSAGES); .slice(-MAX_CONVERSATION_MESSAGES);
const prompt = buildPrompt(context, lastMessages); const system = buildSystemPrompt({
if (prompt.length > MAX_PROMPT_CHARACTERS) { from: args.from,
throw new Error( to: args.to,
"Der ausgewählte Zeitraum enthält zu viele Umsatzdetails für eine vollständige KI-Anfrage. Bitte Zeitraum oder Konto eingrenzen.", basis: args.basis,
); accountName: selectedSummary.accountName,
} });
const system = buildSystemPrompt(context); const savingsTools = {
get_transactions: tool({
description:
"Ruft passende Umsätze read-only ab. Nutze dieses Tool für Detailfragen, Suche nach Gegenparteien/Beschreibungen oder Belege einzelner Aussagen. Es liefert exakte Summen und nur begrenzte, sanitizte Zeilen.",
inputSchema: transactionToolInputSchema,
execute: async (input) =>
await ctx.runQuery(internal.savingsChat.getTransactionsTool, {
scope,
...input,
}),
}),
summarize_spending: tool({
description:
"Berechnet read-only exakte Summen, Monatsverläufe, Kategorien sowie fixe und variable Ausgaben für den ausgewählten oder angegebenen Zeitraum.",
inputSchema: summaryToolInputSchema,
execute: async (input) =>
await ctx.runQuery(internal.savingsChat.summarizeSpendingTool, {
scope,
...input,
}),
}),
forecast_cashflow: tool({
description:
"Erstellt eine deterministische Cashflow-Prognose für 1 bis 3 kommende Monate aus vollständigen historischen Monaten. Nutze es für Sparrate, Monatsüberschuss und kurzfristige Prognosen.",
inputSchema: forecastToolInputSchema,
execute: async (input) =>
await ctx.runQuery(internal.savingsChat.forecastCashflowTool, {
scope,
...input,
}),
}),
};
const envModel = process.env.SAVINGS_CHAT_MODEL?.trim(); const envModel = process.env.SAVINGS_CHAT_MODEL?.trim();
const candidates = [ const candidates = [
@@ -373,17 +919,20 @@ export const ask = action({
const result = await generateText({ const result = await generateText({
model: openai(modelName), model: openai(modelName),
system, system,
prompt, messages: lastMessages,
tools: savingsTools,
stopWhen: stepCountIs(5),
}); });
return { return {
model: modelName, model: modelName,
answer: result.text, answer: result.text,
usedTransactions: context.totals.transactionCount, usedTransactions: selectedSummary.totals.transactionCount,
usedBalance: { usedBalance: {
income: context.totals.income, income: selectedSummary.totals.income,
expenses: context.totals.expenses, expenses: selectedSummary.totals.expenses,
balance: context.totals.balance, balance: selectedSummary.totals.balance,
}, },
toolTrace: buildToolTraceFromSteps(result.steps),
}; };
} catch (error) { } catch (error) {
lastError = error; lastError = error;

189
convex/transactions.test.ts Normal file
View File

@@ -0,0 +1,189 @@
/// <reference types="vite/client" />
import { convexTest } from "convex-test";
import { describe, expect, test } from "vitest";
import { api } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import schema from "./schema";
const modules = import.meta.glob("./**/*.ts");
delete modules["./transactions.test.ts"];
describe("transactions.list", () => {
test("combines search, date range, account, category, and type filters", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Filter User",
email: "filter@example.com",
});
const giroAccountId = await ctx.db.insert("accounts", {
userId,
name: "Girokonto",
type: "checking",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
const otherAccountId = await ctx.db.insert("accounts", {
userId,
name: "Depot",
type: "investment",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
const groceryCategoryId = await ctx.db.insert("categories", {
userId,
name: "Lebensmittel & Supermarkt",
kind: "ausgabe",
block: "variabel",
color: "#ef4444",
sortOrder: 1,
isSystem: false,
});
const restaurantCategoryId = await ctx.db.insert("categories", {
userId,
name: "Restaurant",
kind: "ausgabe",
block: "variabel",
color: "#f97316",
sortOrder: 2,
isSystem: false,
});
const matchingId = await ctx.db.insert("transactions", {
userId,
accountId: giroAccountId,
categoryId: groceryCategoryId,
bookingDate: "2026-06-15",
description: "LIDL SAGT DANKE",
amount: -18.37,
isPending: false,
effectiveMonth: "2026-06",
});
await ctx.db.insert("transactions", {
userId,
accountId: giroAccountId,
categoryId: groceryCategoryId,
bookingDate: "2026-05-29",
description: "LIDL OLD MONTH",
amount: -21.92,
isPending: false,
effectiveMonth: "2026-05",
});
await ctx.db.insert("transactions", {
userId,
accountId: otherAccountId,
categoryId: groceryCategoryId,
bookingDate: "2026-06-16",
description: "LIDL OTHER ACCOUNT",
amount: -99,
isPending: false,
effectiveMonth: "2026-06",
});
await ctx.db.insert("transactions", {
userId,
accountId: giroAccountId,
categoryId: restaurantCategoryId,
bookingDate: "2026-06-17",
description: "LIDL RESTAURANT",
amount: -12,
isPending: false,
effectiveMonth: "2026-06",
});
await ctx.db.insert("transactions", {
userId,
accountId: giroAccountId,
categoryId: groceryCategoryId,
bookingDate: "2026-06-18",
description: "LIDL REFUND",
amount: 5,
isPending: false,
effectiveMonth: "2026-06",
});
return { userId, giroAccountId, groceryCategoryId, matchingId };
});
const asUser = t.withIdentity({
subject: `${seeded.userId}|test-session`,
tokenIdentifier: `test:${seeded.userId}`,
});
const result = await asUser.query(api.transactions.list, {
paginationOpts: { cursor: null, numItems: 20 },
search: "LIDL",
from: "2026-06-01",
to: "2026-06-30",
accountId: seeded.giroAccountId as Id<"accounts">,
categoryIds: [seeded.groceryCategoryId as Id<"categories">],
type: "ausgabe",
basis: "booking",
});
expect(result.page.map((tx) => tx._id)).toEqual([seeded.matchingId]);
});
test("filters by effective month when the global basis is assignment month", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Basis User",
email: "basis@example.com",
});
const accountId = await ctx.db.insert("accounts", {
userId,
name: "Girokonto",
type: "checking",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
const shiftedId = await ctx.db.insert("transactions", {
userId,
accountId,
bookingDate: "2026-05-29",
description: "Salary shifted into June",
amount: 2500,
isPending: false,
assignedMonth: "2026-06",
effectiveMonth: "2026-06",
});
await ctx.db.insert("transactions", {
userId,
accountId,
bookingDate: "2026-05-20",
description: "May only",
amount: -15,
isPending: false,
effectiveMonth: "2026-05",
});
return { userId, shiftedId };
});
const asUser = t.withIdentity({
subject: `${seeded.userId}|test-session`,
tokenIdentifier: `test:${seeded.userId}`,
});
const effectiveResult = await asUser.query(api.transactions.list, {
paginationOpts: { cursor: null, numItems: 20 },
from: "2026-06-01",
to: "2026-06-30",
basis: "effective",
});
const bookingResult = await asUser.query(api.transactions.list, {
paginationOpts: { cursor: null, numItems: 20 },
from: "2026-06-01",
to: "2026-06-30",
basis: "booking",
});
expect(effectiveResult.page.map((tx) => tx._id)).toEqual([seeded.shiftedId]);
expect(bookingResult.page).toEqual([]);
});
});

View File

@@ -41,6 +41,7 @@ export const list = query({
categoryIds: v.optional(v.array(v.id("categories"))), categoryIds: v.optional(v.array(v.id("categories"))),
withoutCategory: v.optional(v.boolean()), withoutCategory: v.optional(v.boolean()),
accountId: v.optional(v.id("accounts")), accountId: v.optional(v.id("accounts")),
basis: v.optional(v.union(v.literal("effective"), v.literal("booking"))),
type: v.optional(v.union(v.literal("einnahme"), v.literal("ausgabe"))), type: v.optional(v.union(v.literal("einnahme"), v.literal("ausgabe"))),
pendingOnly: v.optional(v.boolean()), pendingOnly: v.optional(v.boolean()),
}, },
@@ -51,6 +52,9 @@ export const list = query({
}), }),
handler: async (ctx, args) => { handler: async (ctx, args) => {
const userId = await requireUserId(ctx); const userId = await requireUserId(ctx);
const basis = args.basis ?? "booking";
const fromMonth = args.from?.slice(0, 7);
const toMonth = args.to?.slice(0, 7);
let q; let q;
if (args.search) { if (args.search) {
@@ -59,6 +63,23 @@ export const list = query({
.withSearchIndex("search_description", (sq) => .withSearchIndex("search_description", (sq) =>
sq.search("description", args.search!).eq("userId", userId), sq.search("description", args.search!).eq("userId", userId),
); );
} else {
if (basis === "effective") {
q = ctx.db
.query("transactions")
.withIndex("by_user_effmonth", (iq) => {
if (fromMonth && toMonth) {
return iq.eq("userId", userId).gte("effectiveMonth", fromMonth).lte("effectiveMonth", toMonth);
}
if (fromMonth) {
return iq.eq("userId", userId).gte("effectiveMonth", fromMonth);
}
if (toMonth) {
return iq.eq("userId", userId).lte("effectiveMonth", toMonth);
}
return iq.eq("userId", userId);
})
.order("desc");
} else { } else {
q = ctx.db q = ctx.db
.query("transactions") .query("transactions")
@@ -76,6 +97,45 @@ export const list = query({
}) })
.order("desc"); .order("desc");
} }
}
if (args.search) {
if (basis === "effective") {
if (fromMonth) {
const fallbackFrom = `${fromMonth}-01`;
q = q.filter((f) =>
f.or(
f.gte(f.field("effectiveMonth"), fromMonth),
f.and(
f.eq(f.field("effectiveMonth"), undefined),
f.gte(f.field("bookingDate"), fallbackFrom),
),
),
);
}
if (toMonth) {
const fallbackTo = `${toMonth}-31`;
q = q.filter((f) =>
f.or(
f.lte(f.field("effectiveMonth"), toMonth),
f.and(
f.eq(f.field("effectiveMonth"), undefined),
f.lte(f.field("bookingDate"), fallbackTo),
),
),
);
}
} else {
if (args.from) {
const from = args.from;
q = q.filter((f) => f.gte(f.field("bookingDate"), from));
}
if (args.to) {
const to = args.to;
q = q.filter((f) => f.lte(f.field("bookingDate"), to));
}
}
}
if (args.pendingOnly) { if (args.pendingOnly) {
q = q.filter((f) => f.eq(f.field("isPending"), true)); q = q.filter((f) => f.eq(f.field("isPending"), true));

8
pnpm-lock.yaml generated
View File

@@ -158,7 +158,7 @@ importers:
version: 7.1.1(eslint@10.5.0(jiti@2.7.0)) version: 7.1.1(eslint@10.5.0(jiti@2.7.0))
eslint-plugin-react-refresh: eslint-plugin-react-refresh:
specifier: ^0.5.2 specifier: ^0.5.2
version: 0.5.2(eslint@10.5.0(jiti@2.7.0)) version: 0.5.3(eslint@10.5.0(jiti@2.7.0))
globals: globals:
specifier: ^17.6.0 specifier: ^17.6.0
version: 17.6.0 version: 17.6.0
@@ -1655,8 +1655,8 @@ packages:
peerDependencies: peerDependencies:
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0
eslint-plugin-react-refresh@0.5.2: eslint-plugin-react-refresh@0.5.3:
resolution: {integrity: sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==} resolution: {integrity: sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==}
peerDependencies: peerDependencies:
eslint: ^9 || ^10 eslint: ^9 || ^10
@@ -3828,7 +3828,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-plugin-react-refresh@0.5.2(eslint@10.5.0(jiti@2.7.0)): eslint-plugin-react-refresh@0.5.3(eslint@10.5.0(jiti@2.7.0)):
dependencies: dependencies:
eslint: 10.5.0(jiti@2.7.0) eslint: 10.5.0(jiti@2.7.0)

View File

@@ -1,2 +1,11 @@
allowBuilds: allowBuilds:
esbuild: true esbuild: true
minimumReleaseAgeExclude:
- '@vitest/expect@4.1.9'
- '@vitest/mocker@4.1.9'
- '@vitest/pretty-format@4.1.9'
- '@vitest/runner@4.1.9'
- '@vitest/snapshot@4.1.9'
- '@vitest/spy@4.1.9'
- '@vitest/utils@4.1.9'
- vitest@4.1.9

View File

@@ -0,0 +1,29 @@
export const DEFAULT_TRANSACTION_FILTER_RESET = {
global: {
preset: "current-month",
accountId: undefined,
categoryIds: [],
monthBasis: "effective",
},
page: {
search: "",
type: "all",
pendingOnly: false,
rowSelection: {},
},
} as const;
export type TransactionFilterResetState = typeof DEFAULT_TRANSACTION_FILTER_RESET;
export function getResetTransactionFilterState(): TransactionFilterResetState {
return {
global: {
...DEFAULT_TRANSACTION_FILTER_RESET.global,
categoryIds: [...DEFAULT_TRANSACTION_FILTER_RESET.global.categoryIds],
},
page: {
...DEFAULT_TRANSACTION_FILTER_RESET.page,
rowSelection: { ...DEFAULT_TRANSACTION_FILTER_RESET.page.rowSelection },
},
};
}

View File

@@ -11,7 +11,18 @@ import { Separator } from "@/components/ui/separator";
import { ChatHistory, type ChatHistoryItem } from "@/components/chat/ChatHistory"; import { ChatHistory, type ChatHistoryItem } from "@/components/chat/ChatHistory";
import { toast } from "sonner"; import { toast } from "sonner";
type ChatMessage = { role: "user" | "assistant"; content: string }; type ToolTrace = {
name: string;
inputSummary: string;
resultSummary: string;
};
type UserChatMessage = { role: "user"; content: string };
type AssistantChatMessage = {
role: "assistant";
content: string;
toolTrace?: ToolTrace[];
};
type ChatMessage = UserChatMessage | AssistantChatMessage;
type ChatSession = { type ChatSession = {
id: string; id: string;
title: string; title: string;
@@ -27,6 +38,77 @@ const initialAssistantMessage: ChatMessage = {
}; };
const fallbackMessages = [initialAssistantMessage]; const fallbackMessages = [initialAssistantMessage];
function normalizeToolTrace(value: unknown): ToolTrace[] | undefined {
if (!Array.isArray(value)) return undefined;
const trace = value.flatMap((item) => {
if (!item || typeof item !== "object") return [];
const candidate = item as Record<string, unknown>;
if (
typeof candidate.name !== "string" ||
typeof candidate.inputSummary !== "string" ||
typeof candidate.resultSummary !== "string"
) {
return [];
}
return [
{
name: candidate.name,
inputSummary: candidate.inputSummary,
resultSummary: candidate.resultSummary,
},
];
});
return trace.length > 0 ? trace : undefined;
}
function normalizeMessage(value: unknown): ChatMessage | null {
if (!value || typeof value !== "object") return null;
const candidate = value as Record<string, unknown>;
if (typeof candidate.content !== "string") return null;
if (candidate.role === "user") {
return { role: "user", content: candidate.content };
}
if (candidate.role === "assistant") {
const toolTrace = normalizeToolTrace(candidate.toolTrace);
return toolTrace
? { role: "assistant", content: candidate.content, toolTrace }
: { role: "assistant", content: candidate.content };
}
return null;
}
function isChatMessage(value: ChatMessage | null): value is ChatMessage {
return value !== null;
}
function normalizeSession(value: unknown): ChatSession | null {
if (!value || typeof value !== "object") return null;
const candidate = value as Record<string, unknown>;
if (
typeof candidate.id !== "string" ||
typeof candidate.title !== "string" ||
typeof candidate.createdAt !== "number" ||
typeof candidate.updatedAt !== "number" ||
!Array.isArray(candidate.messages)
) {
return null;
}
const messages = candidate.messages.map(normalizeMessage);
if (!messages.every(isChatMessage)) return null;
return {
id: candidate.id,
title: candidate.title,
createdAt: candidate.createdAt,
updatedAt: candidate.updatedAt,
messages,
};
}
function createSession(): ChatSession { function createSession(): ChatSession {
const now = Date.now(); const now = Date.now();
const randomId = const randomId =
@@ -47,9 +129,11 @@ function loadSessions(): ChatSession[] {
try { try {
const raw = localStorage.getItem(STORAGE_KEY); const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [createSession()]; if (!raw) return [createSession()];
const parsed = JSON.parse(raw) as ChatSession[]; const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length === 0) return [createSession()]; if (!Array.isArray(parsed) || parsed.length === 0) return [createSession()];
return parsed; const sessions = parsed.map(normalizeSession);
if (sessions.some((session) => session === null)) return [createSession()];
return sessions as ChatSession[];
} catch { } catch {
return [createSession()]; return [createSession()];
} }
@@ -156,16 +240,23 @@ export function SavingsChatPage() {
try { try {
const response = await ask({ const response = await ask({
messages: typedNextMessages, messages: typedNextMessages.map((message) => ({
role: message.role,
content: message.content,
})),
from, from,
to, to,
accountId, accountId,
basis: monthBasis, basis: monthBasis,
}); }) as { answer: string; toolTrace?: unknown };
updateSession(submittedSessionId, [ updateSession(submittedSessionId, [
...typedNextMessages, ...typedNextMessages,
{ role: "assistant", content: response.answer }, {
role: "assistant",
content: response.answer,
toolTrace: normalizeToolTrace(response.toolTrace),
},
]); ]);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -228,6 +319,21 @@ export function SavingsChatPage() {
> >
<p className="text-xs uppercase text-muted-foreground">{message.role}</p> <p className="text-xs uppercase text-muted-foreground">{message.role}</p>
<p className="whitespace-pre-wrap text-sm">{message.content}</p> <p className="whitespace-pre-wrap text-sm">{message.content}</p>
{message.role === "assistant" && message.toolTrace && message.toolTrace.length > 0 && (
<div className="mt-3 rounded-md border bg-muted/30 p-2">
<p className="text-xs font-medium text-muted-foreground">
Verwendete Werkzeuge
</p>
<div className="mt-2 space-y-2">
{message.toolTrace.map((tool, toolIndex) => (
<div key={`${tool.name}-${toolIndex}`} className="text-xs">
<p className="font-medium">{tool.name}</p>
<p className="text-muted-foreground">{tool.resultSummary}</p>
</div>
))}
</div>
</div>
)}
</div> </div>
))} ))}
{isSubmitting && ( {isSubmitting && (

View File

@@ -1,4 +1,12 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import {
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ChangeEventHandler,
} from "react";
import { useMutation, usePaginatedQuery, useQuery } from "convex/react"; import { useMutation, usePaginatedQuery, useQuery } from "convex/react";
import { import {
flexRender, flexRender,
@@ -10,6 +18,7 @@ import { useVirtualizer } from "@tanstack/react-virtual";
import { api } from "../../convex/_generated/api"; import { api } from "../../convex/_generated/api";
import type { Doc, Id } from "../../convex/_generated/dataModel"; import type { Doc, Id } from "../../convex/_generated/dataModel";
import { useFilters } from "@/context/FilterContext"; import { useFilters } from "@/context/FilterContext";
import { useAccountFilterId } from "@/components/layout/AccountFilter";
import { CategoryFilter } from "@/components/layout/CategoryFilter"; import { CategoryFilter } from "@/components/layout/CategoryFilter";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -20,20 +29,50 @@ import { amountClass, formatAmount, formatDate, formatMonth } from "@/lib/format
import { TransactionFormDialog } from "@/components/transactions/TransactionFormDialog"; import { TransactionFormDialog } from "@/components/transactions/TransactionFormDialog";
import { AssignMonthDialog } from "@/components/transactions/AssignMonthDialog"; import { AssignMonthDialog } from "@/components/transactions/AssignMonthDialog";
import { toast } from "sonner"; import { toast } from "sonner";
import {
getVisibleSelectionState,
selectedTransactionsTotal,
toggleVisibleSelection,
} from "./transactionsSelection";
import { getResetTransactionFilterState } from "@/lib/transactionFilterReset";
import { RotateCcw } from "lucide-react";
type Tx = Doc<"transactions">; type Tx = Doc<"transactions">;
type Category = Doc<"categories">; type Category = Doc<"categories">;
const EMPTY_TRANSACTIONS: Tx[] = [];
/* ── Memoized cell components ────────────────────────────────────── */ /* ── Memoized cell components ────────────────────────────────────── */
const RowCheckbox = memo(function RowCheckbox({ const RowCheckbox = memo(function RowCheckbox({
checked, checked,
indeterminate = false,
disabled = false,
ariaLabel,
onToggle, onToggle,
}: { }: {
checked: boolean; checked: boolean;
onToggle: (e?: unknown) => void; indeterminate?: boolean;
disabled?: boolean;
ariaLabel: string;
onToggle: ChangeEventHandler<HTMLInputElement>;
}) { }) {
return <input type="checkbox" checked={checked} onChange={onToggle} />; const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
if (ref.current) ref.current.indeterminate = indeterminate;
}, [indeterminate]);
return (
<input
ref={ref}
type="checkbox"
checked={checked}
disabled={disabled}
aria-label={ariaLabel}
onChange={onToggle}
className="h-4 w-4 rounded border-border accent-primary"
/>
);
}); });
const AccountCell = memo(function AccountCell({ name }: { name: string | undefined }) { const AccountCell = memo(function AccountCell({ name }: { name: string | undefined }) {
@@ -152,7 +191,17 @@ export function TransactionsPage() {
const [assignTx, setAssignTx] = useState<Tx | null>(null); const [assignTx, setAssignTx] = useState<Tx | null>(null);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const { categoryIds } = useFilters(); const {
categoryIds,
from,
to,
monthBasis,
setPreset,
setAccountId,
setCategoryIds,
setMonthBasis,
} = useFilters();
const accountId = useAccountFilterId();
const categories = useQuery(api.categories.list); const categories = useQuery(api.categories.list);
const accounts = useQuery(api.accounts.list); const accounts = useQuery(api.accounts.list);
@@ -164,6 +213,10 @@ export function TransactionsPage() {
api.transactions.list, api.transactions.list,
{ {
search: search || undefined, search: search || undefined,
from,
to,
accountId,
basis: monthBasis,
type: type === "all" ? undefined : type, type: type === "all" ? undefined : type,
pendingOnly: pendingOnly || undefined, pendingOnly: pendingOnly || undefined,
categoryIds: categoryIds:
@@ -183,6 +236,15 @@ export function TransactionsPage() {
() => new Map(accounts?.map((a) => [a._id, a.name])), () => new Map(accounts?.map((a) => [a._id, a.name])),
[accounts], [accounts],
); );
const visibleTransactions = results ?? EMPTY_TRANSACTIONS;
const visibleSelectionState = useMemo(
() => getVisibleSelectionState(visibleTransactions, rowSelection),
[visibleTransactions, rowSelection],
);
const selectedTotal = useMemo(
() => selectedTransactionsTotal(visibleTransactions, rowSelection),
[visibleTransactions, rowSelection],
);
const handleUpdateCategory = useCallback( const handleUpdateCategory = useCallback(
(id: Id<"transactions">, categoryId: Id<"categories">) => { (id: Id<"transactions">, categoryId: Id<"categories">) => {
@@ -201,21 +263,59 @@ export function TransactionsPage() {
}, },
[removeTx], [removeTx],
); );
const handleToggleAllVisible = useCallback(
(checked: boolean) => {
setRowSelection((selection) =>
toggleVisibleSelection(selection, visibleTransactions, checked),
);
},
[visibleTransactions],
);
const handleResetFilters = useCallback(() => {
const reset = getResetTransactionFilterState();
setPreset(reset.global.preset);
setAccountId(reset.global.accountId);
setCategoryIds([...reset.global.categoryIds]);
setMonthBasis(reset.global.monthBasis);
setSearch(reset.page.search);
setType(reset.page.type);
setPendingOnly(reset.page.pendingOnly);
setRowSelection(reset.page.rowSelection);
}, [setAccountId, setCategoryIds, setMonthBasis, setPreset]);
const selectedIds = useMemo( const selectedIds = useMemo(
() => Object.keys(rowSelection).filter((k) => rowSelection[k]) as Id<"transactions">[], () =>
[rowSelection], visibleTransactions
.filter((tx) => rowSelection[tx._id])
.map((tx) => tx._id),
[visibleTransactions, rowSelection],
); );
const columns = useMemo<ColumnDef<Tx>[]>( const columns = useMemo<ColumnDef<Tx>[]>(
() => [ () => [
{ {
id: "select", id: "select",
header: () => null, header: () => (
<RowCheckbox
checked={visibleSelectionState.allSelected}
indeterminate={
visibleSelectionState.someSelected && !visibleSelectionState.allSelected
}
disabled={visibleTransactions.length === 0}
ariaLabel={
visibleSelectionState.allSelected
? "Alle sichtbaren Transaktionen abwählen"
: "Alle sichtbaren Transaktionen auswählen"
}
onToggle={(event) => handleToggleAllVisible(event.target.checked)}
/>
),
cell: ({ row }) => ( cell: ({ row }) => (
<RowCheckbox <RowCheckbox
checked={row.getIsSelected()} checked={row.getIsSelected()}
onToggle={row.getToggleSelectedHandler()} ariaLabel="Transaktion auswählen"
onToggle={(event) => row.getToggleSelectedHandler()(event)}
/> />
), ),
}, },
@@ -288,11 +388,14 @@ export function TransactionsPage() {
handleEdit, handleEdit,
handleAssign, handleAssign,
handleRemove, handleRemove,
visibleSelectionState,
visibleTransactions.length,
handleToggleAllVisible,
], ],
); );
const table = useReactTable({ const table = useReactTable({
data: results ?? [], data: visibleTransactions,
columns, columns,
state: { rowSelection }, state: { rowSelection },
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
@@ -354,7 +457,22 @@ export function TransactionsPage() {
/> />
Nur offene Nur offene
</label> </label>
<Button variant="outline" onClick={handleResetFilters}>
<RotateCcw className="h-4 w-4" />
Zurücksetzen
</Button>
<Button onClick={() => setCreateOpen(true)}>Neu</Button> <Button onClick={() => setCreateOpen(true)}>Neu</Button>
{visibleSelectionState.someSelected && (
<div className="flex h-9 items-center gap-2 rounded-md border bg-muted/40 px-3 text-sm">
<span className="font-medium">
{visibleSelectionState.selectedCount} ausgewählt
</span>
<span className="text-muted-foreground">Summe</span>
<span className={`font-semibold tabular-nums ${amountClass(selectedTotal)}`}>
{formatAmount(selectedTotal)}
</span>
</div>
)}
{selectedIds.length > 0 && categories && ( {selectedIds.length > 0 && categories && (
<Select <Select
onValueChange={async (categoryId) => { onValueChange={async (categoryId) => {

View File

@@ -0,0 +1,25 @@
import { describe, expect, test } from "vitest";
import {
DEFAULT_TRANSACTION_FILTER_RESET,
getResetTransactionFilterState,
} from "@/lib/transactionFilterReset";
describe("transaction filter reset defaults", () => {
test("resets global and page-level transaction filters", () => {
expect(getResetTransactionFilterState()).toEqual({
global: {
preset: "current-month",
accountId: undefined,
categoryIds: [],
monthBasis: "effective",
},
page: {
search: "",
type: "all",
pendingOnly: false,
rowSelection: {},
},
});
expect(DEFAULT_TRANSACTION_FILTER_RESET.page.rowSelection).toEqual({});
});
});

View File

@@ -0,0 +1,48 @@
import { describe, expect, test } from "vitest";
import {
getVisibleSelectionState,
selectedTransactionsTotal,
toggleVisibleSelection,
} from "./transactionsSelection";
const visibleTransactions = [
{ _id: "tx-1", amount: -55.68 },
{ _id: "tx-2", amount: -26.75 },
{ _id: "tx-3", amount: 12 },
];
describe("transactions selection helpers", () => {
test("selects and clears all visible transactions without changing hidden selections", () => {
expect(
toggleVisibleSelection(
{ "hidden-tx": true, "tx-1": true },
visibleTransactions,
true,
),
).toEqual({
"hidden-tx": true,
"tx-1": true,
"tx-2": true,
"tx-3": true,
});
expect(
toggleVisibleSelection(
{ "hidden-tx": true, "tx-1": true, "tx-2": true, "tx-3": true },
visibleTransactions,
false,
),
).toEqual({ "hidden-tx": true });
});
test("computes selected count, all-selected state, and signed total from visible transactions", () => {
const selection = { "hidden-tx": true, "tx-1": true, "tx-3": true };
expect(getVisibleSelectionState(visibleTransactions, selection)).toEqual({
allSelected: false,
someSelected: true,
selectedCount: 2,
});
expect(selectedTransactionsTotal(visibleTransactions, selection)).toBe(-43.68);
});
});

View File

@@ -0,0 +1,49 @@
export type TransactionSelection = Record<string, boolean>;
export type SelectableTransaction = {
_id: string;
amount: number;
};
export function toggleVisibleSelection(
selection: TransactionSelection,
visibleTransactions: SelectableTransaction[],
selectAll: boolean,
): TransactionSelection {
const next = { ...selection };
for (const tx of visibleTransactions) {
if (selectAll) {
next[tx._id] = true;
} else {
delete next[tx._id];
}
}
return next;
}
export function getVisibleSelectionState(
visibleTransactions: SelectableTransaction[],
selection: TransactionSelection,
) {
const selectedCount = visibleTransactions.filter((tx) => selection[tx._id]).length;
return {
allSelected: visibleTransactions.length > 0 && selectedCount === visibleTransactions.length,
someSelected: selectedCount > 0,
selectedCount,
};
}
export function selectedTransactionsTotal(
visibleTransactions: SelectableTransaction[],
selection: TransactionSelection,
): number {
const total = visibleTransactions.reduce(
(sum, tx) => (selection[tx._id] ? sum + tx.amount : sum),
0,
);
return Math.round(total * 100) / 100;
}