Compare commits
2 Commits
4a1cbd105b
...
codex/savi
| Author | SHA1 | Date | |
|---|---|---|---|
| 238a30ae0c | |||
| 1c88d12f0d |
@@ -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 -->
|
||||
@@ -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 -->
|
||||
@@ -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 -->
|
||||
@@ -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 -->
|
||||
@@ -1,14 +1,58 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
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 type { Id } from "./_generated/dataModel";
|
||||
import { buildToolTraceFromSteps } from "./savingsChat";
|
||||
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");
|
||||
delete modules["./savingsChat.test.ts"];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("savingsChat.getContext", () => {
|
||||
test("counts and sums every matching transaction before applying prompt limits", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
@@ -192,3 +236,479 @@ describe("savingsChat.getContext", () => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { action, internalQuery, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { generateText } from "ai";
|
||||
import { generateText, stepCountIs, tool } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
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 type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { QueryCtx } from "./_generated/server";
|
||||
@@ -17,7 +18,9 @@ const chatMessageValidator = v.object({
|
||||
});
|
||||
|
||||
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 = {
|
||||
from: string;
|
||||
@@ -42,6 +45,34 @@ type ChatAskResult = {
|
||||
answer: string;
|
||||
usedTransactions: 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 {
|
||||
@@ -51,37 +82,18 @@ function formatEuro(value: number): string {
|
||||
function buildSystemPrompt(context: { from: string; to: string; basis: string; accountName?: string }) {
|
||||
return [
|
||||
"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.",
|
||||
`Zeitraum: ${context.from} bis ${context.to}.`,
|
||||
`Basis: ${context.basis}.`,
|
||||
context.accountName ? `Konto: ${context.accountName}.` : "Konto: Alle Konten.",
|
||||
"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.",
|
||||
].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" {
|
||||
return role;
|
||||
}
|
||||
@@ -253,6 +265,55 @@ const contextSummaryValidator = v.object({
|
||||
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({
|
||||
args: contextArgsValidator,
|
||||
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(
|
||||
tx: Doc<"transactions">,
|
||||
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({
|
||||
args: contextArgsValidator,
|
||||
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({
|
||||
args: {
|
||||
messages: v.array(chatMessageValidator),
|
||||
@@ -325,6 +831,7 @@ export const ask = action({
|
||||
expenses: v.number(),
|
||||
balance: v.number(),
|
||||
}),
|
||||
toolTrace: v.array(toolTraceValidator),
|
||||
}),
|
||||
handler: async (ctx, args): Promise<ChatAskResult> => {
|
||||
if (args.messages.length === 0) {
|
||||
@@ -338,26 +845,65 @@ export const ask = action({
|
||||
}
|
||||
|
||||
await requireUserId(ctx);
|
||||
|
||||
const context: ChatPromptContext = await ctx.runQuery(internal.savingsChat.getPromptContext, {
|
||||
const scope: AgentToolScope = {
|
||||
from: args.from,
|
||||
to: args.to,
|
||||
accountId: args.accountId,
|
||||
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
|
||||
.map((message): ChatMessage => ({ role: normalizeRole(message.role), content: message.content }))
|
||||
.slice(-MAX_CONVERSATION_MESSAGES);
|
||||
|
||||
const prompt = buildPrompt(context, lastMessages);
|
||||
if (prompt.length > MAX_PROMPT_CHARACTERS) {
|
||||
throw new Error(
|
||||
"Der ausgewählte Zeitraum enthält zu viele Umsatzdetails für eine vollständige KI-Anfrage. Bitte Zeitraum oder Konto eingrenzen.",
|
||||
);
|
||||
}
|
||||
const system = buildSystemPrompt({
|
||||
from: args.from,
|
||||
to: args.to,
|
||||
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 candidates = [
|
||||
@@ -373,17 +919,20 @@ export const ask = action({
|
||||
const result = await generateText({
|
||||
model: openai(modelName),
|
||||
system,
|
||||
prompt,
|
||||
messages: lastMessages,
|
||||
tools: savingsTools,
|
||||
stopWhen: stepCountIs(5),
|
||||
});
|
||||
return {
|
||||
model: modelName,
|
||||
answer: result.text,
|
||||
usedTransactions: context.totals.transactionCount,
|
||||
usedTransactions: selectedSummary.totals.transactionCount,
|
||||
usedBalance: {
|
||||
income: context.totals.income,
|
||||
expenses: context.totals.expenses,
|
||||
balance: context.totals.balance,
|
||||
income: selectedSummary.totals.income,
|
||||
expenses: selectedSummary.totals.expenses,
|
||||
balance: selectedSummary.totals.balance,
|
||||
},
|
||||
toolTrace: buildToolTraceFromSteps(result.steps),
|
||||
};
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
189
convex/transactions.test.ts
Normal file
189
convex/transactions.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,7 @@ export const list = query({
|
||||
categoryIds: v.optional(v.array(v.id("categories"))),
|
||||
withoutCategory: v.optional(v.boolean()),
|
||||
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"))),
|
||||
pendingOnly: v.optional(v.boolean()),
|
||||
},
|
||||
@@ -51,6 +52,9 @@ export const list = query({
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
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;
|
||||
if (args.search) {
|
||||
@@ -60,21 +64,77 @@ export const list = query({
|
||||
sq.search("description", args.search!).eq("userId", userId),
|
||||
);
|
||||
} else {
|
||||
q = ctx.db
|
||||
.query("transactions")
|
||||
.withIndex("by_user_booking", (iq) => {
|
||||
if (args.from && args.to) {
|
||||
return iq.eq("userId", userId).gte("bookingDate", args.from).lte("bookingDate", args.to);
|
||||
}
|
||||
if (args.from) {
|
||||
return iq.eq("userId", userId).gte("bookingDate", args.from);
|
||||
}
|
||||
if (args.to) {
|
||||
return iq.eq("userId", userId).lte("bookingDate", args.to);
|
||||
}
|
||||
return iq.eq("userId", userId);
|
||||
})
|
||||
.order("desc");
|
||||
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 {
|
||||
q = ctx.db
|
||||
.query("transactions")
|
||||
.withIndex("by_user_booking", (iq) => {
|
||||
if (args.from && args.to) {
|
||||
return iq.eq("userId", userId).gte("bookingDate", args.from).lte("bookingDate", args.to);
|
||||
}
|
||||
if (args.from) {
|
||||
return iq.eq("userId", userId).gte("bookingDate", args.from);
|
||||
}
|
||||
if (args.to) {
|
||||
return iq.eq("userId", userId).lte("bookingDate", args.to);
|
||||
}
|
||||
return iq.eq("userId", userId);
|
||||
})
|
||||
.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) {
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -158,7 +158,7 @@ importers:
|
||||
version: 7.1.1(eslint@10.5.0(jiti@2.7.0))
|
||||
eslint-plugin-react-refresh:
|
||||
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:
|
||||
specifier: ^17.6.0
|
||||
version: 17.6.0
|
||||
@@ -1655,8 +1655,8 @@ packages:
|
||||
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-plugin-react-refresh@0.5.2:
|
||||
resolution: {integrity: sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==}
|
||||
eslint-plugin-react-refresh@0.5.3:
|
||||
resolution: {integrity: sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==}
|
||||
peerDependencies:
|
||||
eslint: ^9 || ^10
|
||||
|
||||
@@ -3828,7 +3828,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- 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:
|
||||
eslint: 10.5.0(jiti@2.7.0)
|
||||
|
||||
|
||||
@@ -1,2 +1,11 @@
|
||||
allowBuilds:
|
||||
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
|
||||
|
||||
29
src/lib/transactionFilterReset.ts
Normal file
29
src/lib/transactionFilterReset.ts
Normal 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 },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -11,7 +11,18 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { ChatHistory, type ChatHistoryItem } from "@/components/chat/ChatHistory";
|
||||
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 = {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -27,6 +38,77 @@ const initialAssistantMessage: ChatMessage = {
|
||||
};
|
||||
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 {
|
||||
const now = Date.now();
|
||||
const randomId =
|
||||
@@ -47,9 +129,11 @@ function loadSessions(): ChatSession[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
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()];
|
||||
return parsed;
|
||||
const sessions = parsed.map(normalizeSession);
|
||||
if (sessions.some((session) => session === null)) return [createSession()];
|
||||
return sessions as ChatSession[];
|
||||
} catch {
|
||||
return [createSession()];
|
||||
}
|
||||
@@ -156,16 +240,23 @@ export function SavingsChatPage() {
|
||||
|
||||
try {
|
||||
const response = await ask({
|
||||
messages: typedNextMessages,
|
||||
messages: typedNextMessages.map((message) => ({
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
})),
|
||||
from,
|
||||
to,
|
||||
accountId,
|
||||
basis: monthBasis,
|
||||
});
|
||||
}) as { answer: string; toolTrace?: unknown };
|
||||
|
||||
updateSession(submittedSessionId, [
|
||||
...typedNextMessages,
|
||||
{ role: "assistant", content: response.answer },
|
||||
{
|
||||
role: "assistant",
|
||||
content: response.answer,
|
||||
toolTrace: normalizeToolTrace(response.toolTrace),
|
||||
},
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -228,6 +319,21 @@ export function SavingsChatPage() {
|
||||
>
|
||||
<p className="text-xs uppercase text-muted-foreground">{message.role}</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>
|
||||
))}
|
||||
{isSubmitting && (
|
||||
|
||||
@@ -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 {
|
||||
flexRender,
|
||||
@@ -10,6 +18,7 @@ import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||
import { useFilters } from "@/context/FilterContext";
|
||||
import { useAccountFilterId } from "@/components/layout/AccountFilter";
|
||||
import { CategoryFilter } from "@/components/layout/CategoryFilter";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 { AssignMonthDialog } from "@/components/transactions/AssignMonthDialog";
|
||||
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 Category = Doc<"categories">;
|
||||
const EMPTY_TRANSACTIONS: Tx[] = [];
|
||||
|
||||
/* ── Memoized cell components ────────────────────────────────────── */
|
||||
|
||||
const RowCheckbox = memo(function RowCheckbox({
|
||||
checked,
|
||||
indeterminate = false,
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
onToggle,
|
||||
}: {
|
||||
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 }) {
|
||||
@@ -152,7 +191,17 @@ export function TransactionsPage() {
|
||||
const [assignTx, setAssignTx] = useState<Tx | null>(null);
|
||||
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 accounts = useQuery(api.accounts.list);
|
||||
@@ -164,6 +213,10 @@ export function TransactionsPage() {
|
||||
api.transactions.list,
|
||||
{
|
||||
search: search || undefined,
|
||||
from,
|
||||
to,
|
||||
accountId,
|
||||
basis: monthBasis,
|
||||
type: type === "all" ? undefined : type,
|
||||
pendingOnly: pendingOnly || undefined,
|
||||
categoryIds:
|
||||
@@ -183,6 +236,15 @@ export function TransactionsPage() {
|
||||
() => new Map(accounts?.map((a) => [a._id, a.name])),
|
||||
[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(
|
||||
(id: Id<"transactions">, categoryId: Id<"categories">) => {
|
||||
@@ -201,21 +263,59 @@ export function TransactionsPage() {
|
||||
},
|
||||
[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(
|
||||
() => 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>[]>(
|
||||
() => [
|
||||
{
|
||||
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 }) => (
|
||||
<RowCheckbox
|
||||
checked={row.getIsSelected()}
|
||||
onToggle={row.getToggleSelectedHandler()}
|
||||
ariaLabel="Transaktion auswählen"
|
||||
onToggle={(event) => row.getToggleSelectedHandler()(event)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -288,11 +388,14 @@ export function TransactionsPage() {
|
||||
handleEdit,
|
||||
handleAssign,
|
||||
handleRemove,
|
||||
visibleSelectionState,
|
||||
visibleTransactions.length,
|
||||
handleToggleAllVisible,
|
||||
],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: results ?? [],
|
||||
data: visibleTransactions,
|
||||
columns,
|
||||
state: { rowSelection },
|
||||
onRowSelectionChange: setRowSelection,
|
||||
@@ -354,7 +457,22 @@ export function TransactionsPage() {
|
||||
/>
|
||||
Nur offene
|
||||
</label>
|
||||
<Button variant="outline" onClick={handleResetFilters}>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Zurücksetzen
|
||||
</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 && (
|
||||
<Select
|
||||
onValueChange={async (categoryId) => {
|
||||
|
||||
25
src/pages/transactionFilterReset.test.ts
Normal file
25
src/pages/transactionFilterReset.test.ts
Normal 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({});
|
||||
});
|
||||
});
|
||||
48
src/pages/transactionsSelection.test.ts
Normal file
48
src/pages/transactionsSelection.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
49
src/pages/transactionsSelection.ts
Normal file
49
src/pages/transactionsSelection.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user