Compare commits

..

5 Commits

20 changed files with 3972 additions and 78 deletions

View File

@@ -1,10 +1,10 @@
---
id: TASK-1
title: Add complete chat transaction context
status: In Progress
status: Done
assignee: []
created_date: '2026-06-15 13:52'
updated_date: '2026-06-15 14:02'
updated_date: '2026-06-15 19:54'
labels: []
dependencies: []
priority: high
@@ -61,3 +61,9 @@ Final review:
- Prior blocker resolved: effective-basis loading now includes rows without effectiveMonth via bookingDate fallback queries.
- Fresh full npm run lint still fails only on unrelated existing files; no SavingsChatPage issue remains.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed as Done after explicit user confirmation to mark all tasks done.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-2
title: Render category expense chart values as positive slices
status: In Progress
status: Done
assignee: []
created_date: '2026-06-15 14:34'
updated_date: '2026-06-15 14:48'
updated_date: '2026-06-15 19:54'
labels: []
dependencies: []
priority: high
@@ -60,3 +60,9 @@ Verification after layout update:
Note: npm run build still emits the existing Vite chunk-size warning for the main bundle.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed as Done after explicit user confirmation to mark all tasks done.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,59 @@
---
id: TASK-3
title: Build read-only savings agent with tool calls
status: Done
assignee: []
created_date: '2026-06-15 19:02'
updated_date: '2026-06-15 19:54'
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 -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed as Done after explicit user confirmation to mark all tasks done.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,47 @@
---
id: TASK-4
title: Add selection total to transactions
status: Done
assignee: []
created_date: '2026-06-15 19:21'
updated_date: '2026-06-15 19:54'
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 -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed as Done after explicit user confirmation to mark all tasks done.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,46 @@
---
id: TASK-5
title: Make transaction filters combinable
status: Done
assignee: []
created_date: '2026-06-15 19:32'
updated_date: '2026-06-15 19:54'
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 -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed as Done after explicit user confirmation to mark all tasks done.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,38 @@
---
id: TASK-6
title: Add transaction filter reset button
status: Done
assignee: []
created_date: '2026-06-15 19:35'
updated_date: '2026-06-15 19:54'
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 -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed as Done after explicit user confirmation to mark all tasks done.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,54 @@
---
id: TASK-7
title: Add all read-only savings agent insight tools
status: Done
assignee: []
created_date: '2026-06-15 19:39'
updated_date: '2026-06-15 19:54'
labels: []
dependencies: []
priority: high
ordinal: 7000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Expand Talk to Savings with eight additional read-only AI SDK tools for accounts, categories, recurring transactions, anomalies, uncategorized transactions, period comparison, fixed-cost forecasting, and savings-rate explanations.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Agent registers all eight new read-only tools and returns compact trace summaries without raw/private fields
- [x] #2 Metadata and data-quality tools return scoped, sanitized accounts, categories, and uncategorized transaction insight
- [x] #3 Period, savings-rate, recurring, fixed-cost forecast, and anomaly tools compute deterministic aggregates
- [x] #4 Convex tests cover each new tool and mocked ask() registration
- [x] #5 Focused tests, targeted lint, build, and full lint status are documented
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add failing coverage for all eight read-only savings insight tools and ask() registry
2. Implement scoped/sanitized internal Convex queries and deterministic finance helpers
3. Register the tools in savingsChat.ask and add compact trace summaries
4. Verify focused tests, targeted lint, build, and document full-lint blockers
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented eight new read-only tools in convex/savingsChat.ts: accounts, categories, recurring patterns, anomalies, uncategorized transactions, period comparison, fixed-cost forecast, and savings-rate explanation.
Verification: npx vitest convex/savingsChat.test.ts --run --reporter=dot passed (17 tests).
Verification: npx eslint convex/savingsChat.ts convex/savingsChat.test.ts src/pages/SavingsChatPage.tsx passed.
Verification: npm run build passed with existing Vite chunk-size warning.
Full npm run lint still fails on unrelated existing files: convex/bank/comdirectProvider.ts, convex/bank/config.ts, src/components/import/TanAwaitDialog.tsx, several react-refresh only-export-components files, and src/pages/SettingsPage.tsx; warnings also remain in generated Convex files and React Hook Form/TanStack Table locations.
Hardened compare_periods snapshots to omit category IDs while keeping existing summarize_spending output compatible. Re-ran focused Vitest, targeted ESLint, and build successfully after this change.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed as Done after explicit user confirmation to mark all tasks done. Implementation and verification notes are already recorded on the task.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,47 @@
---
id: TASK-8
title: Fix savings tool category matching
status: In Progress
assignee: []
created_date: '2026-06-15 20:10'
updated_date: '2026-06-15 20:19'
labels: []
dependencies: []
priority: high
ordinal: 8000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Make Talk to Savings read-only tools resolve user/model category names like Supermärkte or Lebensmittel und Supermarkt to existing categories such as Lebensmittel & Supermarkt, and surface diagnostics instead of misleading silent zero results.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Category filter aliases resolve ampersand/und, casing, punctuation, umlauts, and simple German plural/flexion variants
- [x] #2 All savings tools that accept categoryNames use shared resolution diagnostics
- [x] #3 Unknown or ambiguous category filters return diagnostics and compact trace summaries instead of silently confident zero results
- [x] #4 Regression tests cover Lebensmittel und Supermarkt, Supermärkte, unknown diagnostics, and trace summaries
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add failing Convex regression tests for tolerant categoryNames and diagnostics
2. Implement shared category-name normalization and resolver in convex/savingsChat.ts
3. Thread diagnostics through every categoryNames-aware tool output and compact traces
4. Update tool/prompt guidance to avoid confident zero answers on unresolved filters
5. Run focused Vitest, targeted ESLint, and production build
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented shared tolerant category resolver for savings tools and added diagnostics to categoryNames-aware outputs.
Verification passed: npx vitest convex/savingsChat.test.ts --run (20 tests), npx eslint convex/savingsChat.ts convex/savingsChat.test.ts, npm run build (with existing Vite chunk-size warning).
Added an additional regression test to preserve the virtual Ohne Kategorie filter while moving categoryNames to resolver-based category IDs. Re-ran focused Vitest, targeted ESLint, and build successfully after this adjustment.
Addressed QA review findings: added ASCII transliteration coverage for Supermaerkte, explicit ambiguous category filter coverage, and multi-diagnostic compact trace summaries. Final verification passed: npx vitest convex/savingsChat.test.ts --run (22 tests), npx eslint convex/savingsChat.ts convex/savingsChat.test.ts, npm run build (with existing Vite chunk-size warning).
<!-- SECTION:NOTES:END -->

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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"))),
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
View File

@@ -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)

View File

@@ -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

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 { 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 && (

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 {
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) => {

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;
}