From 4869402d45e74c250092874054ba1975e1850e66 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 15 Jun 2026 18:26:25 +0200 Subject: [PATCH] Add savings chat analysis feature --- README.md | 6 +- backlog/config.yml | 15 + ...- Add-complete-chat-transaction-context.md | 63 ++ ...expense-chart-values-as-positive-slices.md | 62 ++ convex/_generated/api.d.ts | 2 + convex/lib/helpers.ts | 4 +- convex/savingsChat.test.ts | 194 +++++ convex/savingsChat.ts | 399 +++++++++++ convex/schema.ts | 8 +- convex/transactions.ts | 66 +- package-lock.json | 668 +++++++++++++++++- package.json | 8 +- pnpm-lock.yaml | 377 ++++++++++ src/App.tsx | 2 + .../charts/CategoryBreakdownChart.test.ts | 36 + .../charts/CategoryBreakdownChart.tsx | 90 ++- src/components/charts/MonthlyTrendChart.tsx | 27 +- .../charts/categoryBreakdownData.ts | 17 + src/components/chat/ChatHistory.tsx | 88 +++ src/components/layout/CategoryFilter.tsx | 129 ++++ src/components/layout/Sidebar.tsx | 2 + src/context/FilterContext.tsx | 7 +- src/lib/format.ts | 11 + src/pages/SavingsChatPage.tsx | 265 +++++++ src/pages/TransactionsPage.tsx | 387 +++++++--- vitest.config.ts | 19 + 26 files changed, 2789 insertions(+), 163 deletions(-) create mode 100644 backlog/config.yml create mode 100644 backlog/tasks/task-1 - Add-complete-chat-transaction-context.md create mode 100644 backlog/tasks/task-2 - Render-category-expense-chart-values-as-positive-slices.md create mode 100644 convex/savingsChat.test.ts create mode 100644 convex/savingsChat.ts create mode 100644 src/components/charts/CategoryBreakdownChart.test.ts create mode 100644 src/components/charts/categoryBreakdownData.ts create mode 100644 src/components/chat/ChatHistory.tsx create mode 100644 src/components/layout/CategoryFilter.tsx create mode 100644 src/pages/SavingsChatPage.tsx create mode 100644 vitest.config.ts diff --git a/README.md b/README.md index bb14a4f..edd85e6 100644 --- a/README.md +++ b/README.md @@ -14,18 +14,22 @@ npm run dev:all Erstlogin: Registrieren auf `/login` → `ensureSeeded` legt Standard-Kategorien und Einstellungen an. -## Convex Secrets (comdirect) +## Convex Secrets ```bash npx convex env set COMDIRECT_CLIENT_ID "…" npx convex env set COMDIRECT_CLIENT_SECRET "…" +npx convex env set OPENAI_API_KEY "…" +npx convex env set SAVINGS_CHAT_MODEL "gpt-5.4-mini" # optional ``` Zugangsnummer und PIN werden **nur** pro Sync-Session eingegeben und nie gespeichert. +`SAVINGS_CHAT_MODEL` ist optional; bei Fehlen wird `gpt-5.4-mini` → `gpt-4.1-mini` → `gpt-4.1` als Fallback genutzt. ## Funktionen - Dashboard mit KPIs, Charts, Monats-Basis (effective/booking) +- KI-Analyse: neuer Bereich „Talk to Savings“ unter `/talk` - Transaktionen (paginiert, Filter, Bulk-Kategorisierung, Monatszuordnung) - Kategorien-CRUD mit Seed-Kategorien (§5 Spezifikation) - Kredite inkl. Tilgungsplan (keine Auto-Buchung als Transaktionen) diff --git a/backlog/config.yml b/backlog/config.yml new file mode 100644 index 0000000..c818ae7 --- /dev/null +++ b/backlog/config.yml @@ -0,0 +1,15 @@ +project_name: "finanz-dashboard" +default_status: "To Do" +statuses: ["To Do", "In Progress", "Done"] +labels: [] +date_format: yyyy-mm-dd +max_column_width: 20 +auto_open_browser: true +default_port: 6420 +remote_operations: true +auto_commit: false +filesystem_only: false +bypass_git_hooks: false +check_active_branches: true +active_branch_days: 30 +task_prefix: "task" diff --git a/backlog/tasks/task-1 - Add-complete-chat-transaction-context.md b/backlog/tasks/task-1 - Add-complete-chat-transaction-context.md new file mode 100644 index 0000000..c94d36d --- /dev/null +++ b/backlog/tasks/task-1 - Add-complete-chat-transaction-context.md @@ -0,0 +1,63 @@ +--- +id: TASK-1 +title: Add complete chat transaction context +status: In Progress +assignee: [] +created_date: '2026-06-15 13:52' +updated_date: '2026-06-15 14:02' +labels: [] +dependencies: [] +priority: high +ordinal: 1000 +--- + +## Description + + +Make the savings chat evaluate every transaction matching the selected date range, account, and basis instead of silently sampling 150/300 rows. This includes exact context totals, a full prompt context for the AI action, and regression coverage for large transaction windows. + + +## Acceptance Criteria + +- [x] #1 Chat context totals count all matching transactions in the selected range, including more than 400 rows +- [x] #2 Account filtering is applied before counting and summing transactions +- [x] #3 The chat action no longer accepts or passes maxTransactions and reports the complete transaction count +- [x] #4 The frontend no longer passes maxTransactions and continues to show exact totals +- [x] #5 Regression tests, lint, and build verification pass or documented blockers are reported + + +## Implementation Plan + + +1. Add Convex test dependencies and Vitest edge-runtime config +2. Write failing convex-test regression for 450 chat transactions and account filtering +3. Refactor savingsChat backend to remove maxTransactions and use indexed full scans +4. Update schema indexes for account+basis filters +5. Remove frontend maxTransactions arguments +6. Run focused test, lint, and build verification +7. Record verification notes and leave task In Progress pending user confirmation + + +## Implementation Notes + + +RED verified: npx vitest convex/savingsChat.test.ts --run fails because getContext does not return isComplete and still uses limited context shape. + +Verification: +- PASS npx vitest convex/savingsChat.test.ts --run +- PASS npm run build +- PASS npx eslint convex/savingsChat.ts convex/savingsChat.test.ts src/pages/SavingsChatPage.tsx vitest.config.ts +- BLOCKED npm run lint still fails 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, and related existing warnings. SavingsChatPage is no longer in the lint output. + +Review fix: +- Added effective-basis fallback for legacy transactions without effectiveMonth by reading bookingDate range and including only rows where effectiveMonth is undefined. +- Added prompt-context regression coverage via internal.savingsChat.getPromptContext. +- PASS npx vitest convex/savingsChat.test.ts --run (2 tests) +- PASS npm run build +- PASS npx eslint convex/savingsChat.ts convex/savingsChat.test.ts src/pages/SavingsChatPage.tsx vitest.config.ts + +Final review: +- Subagent re-review found no remaining correctness/spec issues. +- 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. + diff --git a/backlog/tasks/task-2 - Render-category-expense-chart-values-as-positive-slices.md b/backlog/tasks/task-2 - Render-category-expense-chart-values-as-positive-slices.md new file mode 100644 index 0000000..eee025c --- /dev/null +++ b/backlog/tasks/task-2 - Render-category-expense-chart-values-as-positive-slices.md @@ -0,0 +1,62 @@ +--- +id: TASK-2 +title: Render category expense chart values as positive slices +status: In Progress +assignee: [] +created_date: '2026-06-15 14:34' +updated_date: '2026-06-15 14:48' +labels: [] +dependencies: [] +priority: high +ordinal: 2000 +--- + +## Description + + +Fix the dashboard category expense pie chart so negative expense totals render as visible pie slices while preserving expense-style formatting in the UI. + + +## Acceptance Criteria + +- [x] #1 Category breakdown pie renders visible slices for negative expense totals +- [x] #2 Tooltip still displays expense values with the existing currency formatting +- [x] #3 Build or focused verification passes, or blockers are documented + + +## Implementation Plan + + +1. Normalize category breakdown values for chart rendering +2. Keep tooltip currency formatting consistent with expense values +3. Run focused build/type verification +4. Record verification notes and leave task In Progress pending user confirmation + + +## Implementation Notes + + +RED verified: npx vitest src/components/charts/CategoryBreakdownChart.test.ts --run fails because toCategoryPieData is missing. + +Implemented category pie normalization via src/components/charts/categoryBreakdownData.ts and wired CategoryBreakdownChart to use chartAmount for Recharts while formatting tooltip values from the original signed amount. + +Verification: +- PASS npx vitest src/components/charts/CategoryBreakdownChart.test.ts --run +- PASS npx eslint src/components/charts/CategoryBreakdownChart.tsx src/components/charts/CategoryBreakdownChart.test.ts src/components/charts/categoryBreakdownData.ts vitest.config.ts +- PASS npm run build + +Note: npm run build still emits the existing Vite chunk-size warning for the main bundle. + +Manual browser verification not completed in-agent because the in-app Browser target iab is unavailable in this session. Vite dev server is running at http://127.0.0.1:5173/ for user verification. + +User requested readability improvement: place the pie chart on the left and category list on the right instead of using the crowded default legend below the chart. + +Layout update: replaced the crowded Recharts category legend and slice labels with a responsive two-column layout: pie chart on the left, scrollable category list with swatches, signed amounts, and percentage shares on the right. Added stable min dimensions for the chart container after Vite logged Recharts container-size warnings during HMR. + +Verification after layout update: +- PASS npx vitest src/components/charts/CategoryBreakdownChart.test.ts --run +- PASS npx eslint src/components/charts/CategoryBreakdownChart.tsx src/components/charts/CategoryBreakdownChart.test.ts src/components/charts/categoryBreakdownData.ts vitest.config.ts +- PASS npm run build + +Note: npm run build still emits the existing Vite chunk-size warning for the main bundle. + diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 39fcb79..7c2835a 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -34,6 +34,7 @@ import type * as lib_helpers from "../lib/helpers.js"; import type * as lib_month from "../lib/month.js"; import type * as lib_seedCategories from "../lib/seedCategories.js"; import type * as loans from "../loans.js"; +import type * as savingsChat from "../savingsChat.js"; import type * as settings from "../settings.js"; import type * as transactions from "../transactions.js"; import type * as users from "../users.js"; @@ -71,6 +72,7 @@ declare const fullApi: ApiFromModules<{ "lib/month": typeof lib_month; "lib/seedCategories": typeof lib_seedCategories; loans: typeof loans; + savingsChat: typeof savingsChat; settings: typeof settings; transactions: typeof transactions; users: typeof users; diff --git a/convex/lib/helpers.ts b/convex/lib/helpers.ts index ea9db07..3b30599 100644 --- a/convex/lib/helpers.ts +++ b/convex/lib/helpers.ts @@ -1,11 +1,11 @@ import { getAuthUserId } from "@convex-dev/auth/server"; -import type { MutationCtx, QueryCtx } from "../_generated/server"; +import type { ActionCtx, MutationCtx, QueryCtx } from "../_generated/server"; import type { Id } from "../_generated/dataModel"; import { categorize, roundEur } from "./categorize"; import { computeEffectiveMonth, resolveAssignedAndEffective } from "./month"; import { computeDedupHash } from "./comdirectMap"; -export async function requireUserId(ctx: QueryCtx | MutationCtx): Promise> { +export async function requireUserId(ctx: QueryCtx | MutationCtx | ActionCtx): Promise> { const userId = await getAuthUserId(ctx); if (!userId) throw new Error("Nicht angemeldet"); return userId; diff --git a/convex/savingsChat.test.ts b/convex/savingsChat.test.ts new file mode 100644 index 0000000..b25be05 --- /dev/null +++ b/convex/savingsChat.test.ts @@ -0,0 +1,194 @@ +/// + +import { convexTest } from "convex-test"; +import { describe, expect, test } from "vitest"; +import { api, internal } from "./_generated/api"; +import type { Id } from "./_generated/dataModel"; +import schema from "./schema"; + +const modules = import.meta.glob("./**/*.ts"); +delete modules["./savingsChat.test.ts"]; + +describe("savingsChat.getContext", () => { + test("counts and sums every matching transaction before applying prompt limits", async () => { + const t = convexTest(schema, modules); + + const seeded = await t.run(async (ctx) => { + const userId = await ctx.db.insert("users", { + name: "Test User", + email: "test@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: "Tagesgeld", + type: "savings", + openingBalance: 0, + currency: "EUR", + isArchived: false, + }); + + const amounts: number[] = []; + const months = ["2025-12", "2026-01", "2026-02", "2026-03", "2026-04", "2026-05", "2026-06"]; + for (let index = 0; index < 450; index++) { + const month = months[index % months.length]; + const day = String((index % 27) + 1).padStart(2, "0"); + const bookingDate = `${month}-${day}`; + const amount = index % 3 === 0 ? 100 : -25; + amounts.push(amount); + await ctx.db.insert("transactions", { + userId, + accountId: giroAccountId, + bookingDate, + valueDate: bookingDate, + description: `Giro transaction ${index}`, + counterparty: "Counterparty", + amount, + isPending: false, + effectiveMonth: index % 10 === 0 ? undefined : bookingDate.slice(0, 7), + }); + } + + for (let index = 0; index < 50; index++) { + const bookingDate = `2026-06-${String((index % 27) + 1).padStart(2, "0")}`; + await ctx.db.insert("transactions", { + userId, + accountId: otherAccountId, + bookingDate, + valueDate: bookingDate, + description: `Other account transaction ${index}`, + amount: 999, + isPending: false, + effectiveMonth: bookingDate.slice(0, 7), + }); + } + + return { + userId, + giroAccountId, + expectedIncome: amounts.filter((amount) => amount > 0).reduce((sum, amount) => sum + amount, 0), + expectedExpenses: amounts.filter((amount) => amount < 0).reduce((sum, amount) => sum + amount, 0), + expectedBalance: amounts.reduce((sum, amount) => sum + amount, 0), + }; + }); + + const asUser = t.withIdentity({ + subject: `${seeded.userId}|test-session`, + tokenIdentifier: `test:${seeded.userId}`, + }); + + const context = await asUser.query(api.savingsChat.getContext, { + from: "2025-12-01", + to: "2026-06-30", + accountId: seeded.giroAccountId as Id<"accounts">, + basis: "effective", + }); + + expect(context.accountName).toBe("Girokonto"); + expect(context.isComplete).toBe(true); + expect(context.totals.transactionCount).toBe(450); + expect(context.totals.income).toBe(seeded.expectedIncome); + expect(context.totals.expenses).toBe(seeded.expectedExpenses); + expect(context.totals.balance).toBe(seeded.expectedBalance); + }); + + test("builds complete prompt lines for every matching transaction", async () => { + const t = convexTest(schema, modules); + + const seeded = await t.run(async (ctx) => { + const userId = await ctx.db.insert("users", { + name: "Prompt User", + email: "prompt@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 otherAccountId = await ctx.db.insert("accounts", { + userId, + name: "Depot", + type: "investment", + openingBalance: 0, + currency: "EUR", + isArchived: false, + }); + + await ctx.db.insert("transactions", { + userId, + accountId: giroAccountId, + categoryId, + bookingDate: "2026-02-14", + valueDate: "2026-02-14", + description: "Supermarkt", + counterparty: "Markt GmbH", + amount: -42.5, + isPending: false, + }); + await ctx.db.insert("transactions", { + userId, + accountId: giroAccountId, + bookingDate: "2026-02-15", + valueDate: "2026-02-15", + description: "Gehalt", + counterparty: "Arbeitgeber", + amount: 2500, + isPending: false, + effectiveMonth: "2026-02", + }); + await ctx.db.insert("transactions", { + userId, + accountId: otherAccountId, + bookingDate: "2026-02-16", + valueDate: "2026-02-16", + description: "Other account should not appear", + amount: 999, + isPending: false, + effectiveMonth: "2026-02", + }); + + return { userId, giroAccountId }; + }); + + const asUser = t.withIdentity({ + subject: `${seeded.userId}|test-session`, + tokenIdentifier: `test:${seeded.userId}`, + }); + + const context = await asUser.query(internal.savingsChat.getPromptContext, { + from: "2026-02-01", + to: "2026-02-28", + accountId: seeded.giroAccountId as Id<"accounts">, + basis: "effective", + }); + + expect(context.totals.transactionCount).toBe(2); + expect(context.transactionLines).toHaveLength(2); + expect(context.transactionLines.join("\n")).toContain( + "2026-02-14 | Supermarkt (Markt GmbH) | -42.50€ | Lebensmittel | Girokonto", + ); + expect(context.transactionLines.join("\n")).toContain( + "2026-02-15 | Gehalt (Arbeitgeber) | 2500.00€ | Ohne Kategorie | Girokonto", + ); + expect(context.transactionLines.join("\n")).not.toContain("Other account should not appear"); + }); +}); diff --git a/convex/savingsChat.ts b/convex/savingsChat.ts new file mode 100644 index 0000000..032230b --- /dev/null +++ b/convex/savingsChat.ts @@ -0,0 +1,399 @@ +import { action, internalQuery, query } from "./_generated/server"; +import { v } from "convex/values"; +import { generateText } from "ai"; +import { openai } from "@ai-sdk/openai"; +import { internal } from "./_generated/api"; +import { bookingMonth } from "./lib/month"; +import { requireUserId } from "./lib/helpers"; +import type { Doc, Id } from "./_generated/dataModel"; +import type { QueryCtx } from "./_generated/server"; + +type ChatRole = "user" | "assistant"; +type ChatMessage = { role: ChatRole; content: string }; + +const chatMessageValidator = v.object({ + role: v.union(v.literal("user"), v.literal("assistant")), + content: v.string(), +}); + +const MAX_CONVERSATION_MESSAGES = 20; +const MAX_PROMPT_CHARACTERS = 180_000; + +type ChatContextArgs = { + from: string; + to: string; + accountId?: Id<"accounts">; + basis: "effective" | "booking"; +}; +type ChatContextSummary = { + from: string; + to: string; + basis: "effective" | "booking"; + accountId?: Id<"accounts">; + accountName?: string; + totals: { transactionCount: number; income: number; expenses: number; balance: number }; + isComplete: true; +}; +type ChatPromptContext = ChatContextSummary & { + transactionLines: string[]; +}; +type ChatAskResult = { + model: string; + answer: string; + usedTransactions: number; + usedBalance: { income: number; expenses: number; balance: number }; +}; + +function formatEuro(value: number): string { + return `${value.toFixed(2)}€`; +} + +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.", + "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.", + "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; +} + +function sortTransactionsForContext( + transactions: Doc<"transactions">[], + basis: ChatContextArgs["basis"], +) { + return transactions.sort((a, b) => { + const aMonth = basis === "effective" ? a.effectiveMonth ?? bookingMonth(a.bookingDate) ?? "" : ""; + const bMonth = basis === "effective" ? b.effectiveMonth ?? bookingMonth(b.bookingDate) ?? "" : ""; + const aDate = basis === "booking" ? a.bookingDate ?? "" : a.valueDate ?? a.bookingDate ?? ""; + const bDate = basis === "booking" ? b.bookingDate ?? "" : b.valueDate ?? b.bookingDate ?? ""; + const aKey = `${aMonth}|${aDate}|${a._creationTime}`; + const bKey = `${bMonth}|${bDate}|${b._creationTime}`; + return bKey.localeCompare(aKey); + }); +} + +async function loadMatchingTransactions( + ctx: QueryCtx, + userId: Id<"users">, + args: ChatContextArgs, +): Promise[]> { + const monthFrom = args.from.slice(0, 7); + const monthTo = args.to.slice(0, 7); + const transactions: Doc<"transactions">[] = []; + + if (args.basis === "effective") { + if (args.accountId) { + const accountId = args.accountId; + const q = ctx.db + .query("transactions") + .withIndex("by_user_account_effmonth", (index) => + index + .eq("userId", userId) + .eq("accountId", accountId) + .gte("effectiveMonth", monthFrom) + .lte("effectiveMonth", monthTo), + ) + .order("desc"); + for await (const tx of q) transactions.push(tx); + + const fallback = ctx.db + .query("transactions") + .withIndex("by_user_account_booking", (index) => + index + .eq("userId", userId) + .eq("accountId", accountId) + .gte("bookingDate", args.from) + .lte("bookingDate", args.to), + ) + .order("desc"); + for await (const tx of fallback) { + if (tx.effectiveMonth === undefined) transactions.push(tx); + } + return sortTransactionsForContext(transactions, args.basis); + } + + const q = ctx.db + .query("transactions") + .withIndex("by_user_effmonth", (index) => + index.eq("userId", userId).gte("effectiveMonth", monthFrom).lte("effectiveMonth", monthTo), + ) + .order("desc"); + for await (const tx of q) transactions.push(tx); + + const fallback = ctx.db + .query("transactions") + .withIndex("by_user_booking", (index) => + index.eq("userId", userId).gte("bookingDate", args.from).lte("bookingDate", args.to), + ) + .order("desc"); + for await (const tx of fallback) { + if (tx.effectiveMonth === undefined) transactions.push(tx); + } + return sortTransactionsForContext(transactions, args.basis); + } + + if (args.accountId) { + const accountId = args.accountId; + const q = ctx.db + .query("transactions") + .withIndex("by_user_account_booking", (index) => + index + .eq("userId", userId) + .eq("accountId", accountId) + .gte("bookingDate", args.from) + .lte("bookingDate", args.to), + ) + .order("desc"); + for await (const tx of q) transactions.push(tx); + return transactions; + } + + const q = ctx.db + .query("transactions") + .withIndex("by_user_booking", (index) => + index.eq("userId", userId).gte("bookingDate", args.from).lte("bookingDate", args.to), + ) + .order("desc"); + for await (const tx of q) transactions.push(tx); + return sortTransactionsForContext(transactions, args.basis); +} + +function calculateTotals(transactions: Doc<"transactions">[]) { + const totals = transactions.reduce( + (acc, tx) => { + if (tx.amount > 0) acc.income += tx.amount; + if (tx.amount < 0) acc.expenses += tx.amount; + acc.balance += tx.amount; + acc.transactionCount += 1; + return acc; + }, + { income: 0, expenses: 0, balance: 0, transactionCount: 0 }, + ); + + return { + transactionCount: totals.transactionCount, + income: Math.round(totals.income * 100) / 100, + expenses: Math.round(totals.expenses * 100) / 100, + balance: Math.round(totals.balance * 100) / 100, + }; +} + +async function buildContextSummary( + ctx: QueryCtx, + userId: Id<"users">, + args: ChatContextArgs, +): Promise<{ summary: ChatContextSummary; transactions: Doc<"transactions">[] }> { + const transactions = await loadMatchingTransactions(ctx, userId, args); + const account = args.accountId ? await ctx.db.get(args.accountId) : null; + + return { + summary: { + from: args.from, + to: args.to, + basis: args.basis, + accountId: args.accountId, + accountName: account?.userId === userId ? account.name : undefined, + totals: calculateTotals(transactions), + isComplete: true, + }, + transactions, + }; +} + +const contextArgsValidator = { + from: v.string(), + to: v.string(), + accountId: v.optional(v.id("accounts")), + basis: v.union(v.literal("effective"), v.literal("booking")), +}; + +const totalsValidator = v.object({ + transactionCount: v.number(), + income: v.number(), + expenses: v.number(), + balance: v.number(), +}); + +const contextSummaryValidator = v.object({ + from: v.string(), + to: v.string(), + basis: v.union(v.literal("effective"), v.literal("booking")), + accountId: v.optional(v.id("accounts")), + accountName: v.optional(v.string()), + totals: totalsValidator, + isComplete: v.literal(true), +}); + +export const getContext = query({ + args: contextArgsValidator, + returns: contextSummaryValidator, + handler: async (ctx, args): Promise => { + const userId = await requireUserId(ctx); + const { summary } = await buildContextSummary(ctx, userId, args); + return summary; + }, +}); + +function toDisplayContextLine( + tx: Doc<"transactions">, + categoryById: Map, string>, + accountById: Map, string>, +) { + const date = tx.valueDate || tx.bookingDate || "n/a"; + const amount = formatEuro(tx.amount); + const name = tx.counterparty ?? "–"; + const category = tx.categoryId ? categoryById.get(tx.categoryId) : "Ohne Kategorie"; + const account = tx.accountId ? accountById.get(tx.accountId) : "Ohne Konto"; + return `${date} | ${tx.description} (${name}) | ${amount} | ${category ?? "Ohne Kategorie"} | ${account ?? "Ohne Konto"}${ + tx.isPending ? " | offen" : "" + }`; +} + +export const getPromptContext = internalQuery({ + args: contextArgsValidator, + returns: v.object({ + ...contextSummaryValidator.fields, + transactionLines: v.array(v.string()), + }), + handler: async (ctx, args): Promise => { + const userId = await requireUserId(ctx); + const { summary, transactions } = await buildContextSummary(ctx, userId, args); + + 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(); + const categoryById = new Map(categories.map((category) => [category._id, category.name])); + const accountById = new Map(accounts.map((account) => [account._id, account.name])); + + return { + ...summary, + transactionLines: transactions.map((tx) => + toDisplayContextLine(tx, categoryById, accountById), + ), + }; + }, +}); + +export const ask = action({ + args: { + messages: v.array(chatMessageValidator), + from: v.string(), + to: v.string(), + accountId: v.optional(v.id("accounts")), + basis: v.union(v.literal("effective"), v.literal("booking")), + }, + returns: v.object({ + model: v.string(), + answer: v.string(), + usedTransactions: v.number(), + usedBalance: v.object({ + income: v.number(), + expenses: v.number(), + balance: v.number(), + }), + }), + handler: async (ctx, args): Promise => { + if (args.messages.length === 0) { + throw new Error("Kein Nutzernachrichttext vorhanden."); + } + + if (!process.env.OPENAI_API_KEY) { + throw new Error( + "OPENAI_API_KEY ist nicht gesetzt. Bitte API-Key in den Convex-Umgebungsvariablen hinterlegen.", + ); + } + + await requireUserId(ctx); + + const context: ChatPromptContext = await ctx.runQuery(internal.savingsChat.getPromptContext, { + from: args.from, + to: args.to, + accountId: args.accountId, + basis: args.basis, + }); + + 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(context); + + const envModel = process.env.SAVINGS_CHAT_MODEL?.trim(); + const candidates = [ + envModel, + "gpt-5.4-mini", + "gpt-4.1-mini", + "gpt-4.1", + ].filter(Boolean) as string[]; + + let lastError: unknown; + for (const modelName of candidates) { + try { + const result = await generateText({ + model: openai(modelName), + system, + prompt, + }); + return { + model: modelName, + answer: result.text, + usedTransactions: context.totals.transactionCount, + usedBalance: { + income: context.totals.income, + expenses: context.totals.expenses, + balance: context.totals.balance, + }, + }; + } catch (error) { + lastError = error; + } + } + + const message = + lastError instanceof Error + ? lastError.message + : "Unbekannter Fehler bei der KI-Anfrage"; + throw new Error(`KI-Anfrage fehlgeschlagen: ${message}`); + }, +}); diff --git a/convex/schema.ts b/convex/schema.ts index 462d356..39daf7e 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -64,8 +64,14 @@ export default defineSchema({ .index("by_user_effmonth", ["userId", "effectiveMonth"]) .index("by_user_category", ["userId", "categoryId"]) .index("by_user_account", ["userId", "accountId"]) + .index("by_user_account_booking", ["userId", "accountId", "bookingDate"]) + .index("by_user_account_effmonth", ["userId", "accountId", "effectiveMonth"]) .index("by_user_dedup", ["userId", "dedupHash"]) - .index("by_user_extref", ["userId", "externalRef"]), + .index("by_user_extref", ["userId", "externalRef"]) + .searchIndex("search_description", { + searchField: "description", + filterFields: ["userId"], + }), loans: defineTable({ userId: v.id("users"), diff --git a/convex/transactions.ts b/convex/transactions.ts index 8d1db81..ab7d621 100644 --- a/convex/transactions.ts +++ b/convex/transactions.ts @@ -39,6 +39,7 @@ export const list = query({ from: v.optional(v.string()), to: v.optional(v.string()), categoryIds: v.optional(v.array(v.id("categories"))), + withoutCategory: v.optional(v.boolean()), accountId: v.optional(v.id("accounts")), type: v.optional(v.union(v.literal("einnahme"), v.literal("ausgabe"))), pendingOnly: v.optional(v.boolean()), @@ -50,48 +51,57 @@ export const list = query({ }), handler: async (ctx, args) => { const userId = await requireUserId(ctx); - let q = ctx.db - .query("transactions") - .withIndex("by_user_booking", (q) => q.eq("userId", userId)) - .order("desc"); - const result = await q.paginate(args.paginationOpts); - let page = result.page; - - if (args.from) { - page = page.filter((tx) => !tx.bookingDate || tx.bookingDate >= args.from!); + let q; + if (args.search) { + q = ctx.db + .query("transactions") + .withSearchIndex("search_description", (sq) => + 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 (args.to) { - page = page.filter((tx) => !tx.bookingDate || tx.bookingDate <= args.to!); + + if (args.pendingOnly) { + q = q.filter((f) => f.eq(f.field("isPending"), true)); } if (args.accountId) { - page = page.filter((tx) => tx.accountId === args.accountId); - } - if (args.pendingOnly) { - page = page.filter((tx) => tx.isPending); + q = q.filter((f) => f.eq(f.field("accountId"), args.accountId)); } if (args.type === "einnahme") { - page = page.filter((tx) => tx.amount > 0); + q = q.filter((f) => f.gt(f.field("amount"), 0)); } if (args.type === "ausgabe") { - page = page.filter((tx) => tx.amount < 0); + q = q.filter((f) => f.lt(f.field("amount"), 0)); } if (args.categoryIds && args.categoryIds.length > 0) { - const set = new Set(args.categoryIds); - page = page.filter((tx) => tx.categoryId && set.has(tx.categoryId)); - } - if (args.search) { - const s = args.search.toLowerCase(); - page = page.filter( - (tx) => - tx.description.toLowerCase().includes(s) || - (tx.counterparty?.toLowerCase().includes(s) ?? false) || - (tx.rawText?.toLowerCase().includes(s) ?? false), + q = q.filter((f) => + f.or(...args.categoryIds!.map((id) => f.eq(f.field("categoryId"), id))), ); } + if (args.withoutCategory) { + q = q.filter((f) => f.eq(f.field("categoryId"), undefined)); + } + + const result = await q.paginate(args.paginationOpts); return { - page, + page: result.page, isDone: result.isDone, continueCursor: result.continueCursor, }; diff --git a/package-lock.json b/package-lock.json index ae26560..3c24989 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "finanz-dashboard", "version": "0.0.0", "dependencies": { + "@ai-sdk/openai": "^3.0.71", "@auth/core": "^0.41.2", "@convex-dev/auth": "^0.0.94", "@hookform/resolvers": "^5.4.0", @@ -26,11 +27,14 @@ "@radix-ui/react-tabs": "^1.1.14", "@radix-ui/react-tooltip": "^1.2.9", "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.14.2", + "ai": "^6.0.205", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "convex": "^1.41.0", "date-fns": "^4.4.0", + "lib-fints": "^1.4.8", "lucide-react": "^1.18.0", "papaparse": "^5.5.3", "react": "^19.2.6", @@ -44,6 +48,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@edge-runtime/vm": "^5.0.0", "@eslint/js": "^10.0.1", "@tailwindcss/vite": "^4.3.1", "@types/node": "^24.13.2", @@ -51,6 +56,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "convex-test": "^0.0.53", "eslint": "^10.5.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", @@ -58,7 +64,70 @@ "tailwindcss": "^4.3.1", "typescript": "~6.0.2", "typescript-eslint": "^8.59.2", - "vite": "^8.0.12" + "vite": "^8.0.12", + "vitest": "^4.1.9" + } + }, + "node_modules/@ai-sdk/gateway": { + "version": "3.0.131", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.131.tgz", + "integrity": "sha512-CnjOZdywQaUnCyZ0N5wVNm7Sm63+NeHDVZQJKFX2IDq+t03SLwiiuoi3ILTLPlM+YSOhkQ/pvIDoR4qa98Zp5A==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.29", + "@vercel/oidc": "3.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai": { + "version": "3.0.71", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.71.tgz", + "integrity": "sha512-j6eBAa5oHFZ4U5CxpIV3T4zXNM/BviodNCZCL1qHkA4aqkwK9iQ18TWYz2DZcXpw4BO5pikKzqpXORxb1EnZGA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.29" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.10.tgz", + "integrity": "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "4.0.29", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.29.tgz", + "integrity": "sha512-uhukHaCBvqkwBHkT8C2PrnqKTCoLn3pdHXqtcR9I8ErH+flbzgW4o7VHSNIup9LRu+WBvZIZDQLsx6rwl2tiOA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" } }, "node_modules/@auth/core": { @@ -376,6 +445,29 @@ "integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==", "license": "MIT" }, + "node_modules/@edge-runtime/primitives": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@edge-runtime/primitives/-/primitives-6.0.0.tgz", + "integrity": "sha512-FqoxaBT+prPBHBwE1WXS1ocnu/VLTQyZ6NMUBAdbP7N2hsFTTxMC/jMu2D/8GAlMQfxeuppcPuCUk/HO3fpIvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@edge-runtime/vm": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@edge-runtime/vm/-/vm-5.0.0.tgz", + "integrity": "sha512-NKBGBSIKUG584qrS1tyxVpX/AKJKQw5HgjYEnPLC0QsTw79JrGn+qUr8CXFb955Iy7GUdiiUv1rJ6JBGvaKb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@edge-runtime/primitives": "6.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -1139,6 +1231,27 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@nodable/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@oslojs/asn1": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@oslojs/asn1/-/asn1-1.0.0.tgz", @@ -2786,6 +2899,23 @@ "react-dom": ">=16.8" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz", + "integrity": "sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@tanstack/table-core": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", @@ -2799,6 +2929,16 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.0.tgz", + "integrity": "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -2810,6 +2950,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -2873,6 +3024,13 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -3183,6 +3341,15 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vercel/oidc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz", + "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", @@ -3209,6 +3376,119 @@ } } }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -3232,6 +3512,24 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/ai": { + "version": "6.0.205", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.205.tgz", + "integrity": "sha512-F4akEGF41UdgJO3L4v+D5noVD1/czhJy6x0k9R/i1EXfxqrkBh/PdYSgRSLPiGFvrw76dzI8h4w3NYmLrTb8dw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "3.0.131", + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.29", + "@opentelemetry/api": "^1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -3249,6 +3547,18 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/anynum": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.0.tgz", + "integrity": "sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -3261,6 +3571,16 @@ "node": ">=10" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -3352,6 +3672,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -3434,6 +3764,16 @@ } } }, + "node_modules/convex-test": { + "version": "0.0.53", + "resolved": "https://registry.npmjs.org/convex-test/-/convex-test-0.0.53.tgz", + "integrity": "sha512-bouZQTnTvZi8IHljHL++yClj1vcV+/9ZxEcd8JZz7RDxOfPkRKrkMgkk/xlX4M1EAiwcEJPNiQE7VJFvDM3lCQ==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "convex": "^1.32.0" + } + }, "node_modules/cookie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", @@ -3668,6 +4008,13 @@ "node": ">=10.13.0" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-toolkit": { "version": "1.47.1", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.1.tgz", @@ -3917,6 +4264,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3933,6 +4290,25 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3954,6 +4330,45 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.9.0.tgz", + "integrity": "sha512-duBuXbyIhEeNO4GjFuVqr0nF047oNwr18aum+zJyqo0MUG/n7Afgs3Qv3D6VN3ONedUKxiuFlPiMGIa0Z11chA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.2.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^1.0.1", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.4.0", + "xml-naming": "^0.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -4181,6 +4596,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-unsafe": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-1.0.1.tgz", + "integrity": "sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4234,6 +4661,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -4294,6 +4727,18 @@ "node": ">= 0.8.0" } }, + "node_modules/lib-fints": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/lib-fints/-/lib-fints-1.4.8.tgz", + "integrity": "sha512-MrkTHuZDXLaRjURNetQUMYiZ1qKflO6m3/oNq5zs67NkyQxemMuUxO33FLr2OHWyA4mL/QTyHI/ICqrunQqnwA==", + "license": "LGPL-2.1-or-later", + "dependencies": { + "fast-xml-parser": "^5.8.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -4691,6 +5136,20 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4757,6 +5216,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -4773,6 +5247,13 @@ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5212,6 +5693,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -5232,6 +5720,35 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/strnum": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.0.tgz", + "integrity": "sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.0" + } + }, "node_modules/tailwind-merge": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", @@ -5269,6 +5786,23 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -5286,6 +5820,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -5556,6 +6100,96 @@ } } }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@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", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5572,6 +6206,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -5603,6 +6254,21 @@ } } }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 40933b7..92d440b 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "preview": "vite preview" }, "dependencies": { + "@ai-sdk/openai": "^3.0.71", "@auth/core": "^0.41.2", "@convex-dev/auth": "^0.0.94", "@hookform/resolvers": "^5.4.0", @@ -30,6 +31,8 @@ "@radix-ui/react-tabs": "^1.1.14", "@radix-ui/react-tooltip": "^1.2.9", "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.14.2", + "ai": "^6.0.205", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -49,6 +52,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@edge-runtime/vm": "^5.0.0", "@eslint/js": "^10.0.1", "@tailwindcss/vite": "^4.3.1", "@types/node": "^24.13.2", @@ -56,6 +60,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "convex-test": "^0.0.53", "eslint": "^10.5.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", @@ -63,6 +68,7 @@ "tailwindcss": "^4.3.1", "typescript": "~6.0.2", "typescript-eslint": "^8.59.2", - "vite": "^8.0.12" + "vite": "^8.0.12", + "vitest": "^4.1.9" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0536f2..8fa6099 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@ai-sdk/openai': + specifier: ^3.0.71 + version: 3.0.71(zod@4.4.3) '@auth/core': specifier: ^0.41.2 version: 0.41.2 @@ -62,6 +65,12 @@ importers: '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/react-virtual': + specifier: ^3.14.2 + version: 3.14.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + ai: + specifier: ^6.0.205 + version: 6.0.205(zod@4.4.3) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -114,6 +123,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@edge-runtime/vm': + specifier: ^5.0.0 + version: 5.0.0 '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.5.0(jiti@2.7.0)) @@ -135,6 +147,9 @@ importers: '@vitejs/plugin-react': specifier: ^6.0.1 version: 6.0.2(vite@8.0.16(@types/node@24.13.2)(esbuild@0.27.0)(jiti@2.7.0)) + convex-test: + specifier: ^0.0.53 + version: 0.0.53(convex@1.41.0(react@19.2.7)) eslint: specifier: ^10.5.0 version: 10.5.0(jiti@2.7.0) @@ -159,9 +174,34 @@ importers: vite: specifier: ^8.0.12 version: 8.0.16(@types/node@24.13.2)(esbuild@0.27.0)(jiti@2.7.0) + vitest: + specifier: ^4.1.9 + version: 4.1.9(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.27.0)(jiti@2.7.0)) packages: + '@ai-sdk/gateway@3.0.131': + resolution: {integrity: sha512-CnjOZdywQaUnCyZ0N5wVNm7Sm63+NeHDVZQJKFX2IDq+t03SLwiiuoi3ILTLPlM+YSOhkQ/pvIDoR4qa98Zp5A==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/openai@3.0.71': + resolution: {integrity: sha512-j6eBAa5oHFZ4U5CxpIV3T4zXNM/BviodNCZCL1qHkA4aqkwK9iQ18TWYz2DZcXpw4BO5pikKzqpXORxb1EnZGA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@4.0.29': + resolution: {integrity: sha512-uhukHaCBvqkwBHkT8C2PrnqKTCoLn3pdHXqtcR9I8ErH+flbzgW4o7VHSNIup9LRu+WBvZIZDQLsx6rwl2tiOA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@3.0.10': + resolution: {integrity: sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==} + engines: {node: '>=18'} + '@auth/core@0.41.2': resolution: {integrity: sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w==} peerDependencies: @@ -257,6 +297,14 @@ packages: '@date-fns/tz@1.5.0': resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} + '@edge-runtime/primitives@6.0.0': + resolution: {integrity: sha512-FqoxaBT+prPBHBwE1WXS1ocnu/VLTQyZ6NMUBAdbP7N2hsFTTxMC/jMu2D/8GAlMQfxeuppcPuCUk/HO3fpIvA==} + engines: {node: '>=18'} + + '@edge-runtime/vm@5.0.0': + resolution: {integrity: sha512-NKBGBSIKUG584qrS1tyxVpX/AKJKQw5HgjYEnPLC0QsTw79JrGn+qUr8CXFb955Iy7GUdiiUv1rJ6JBGvaKb6w==} + engines: {node: '>=18'} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -526,6 +574,10 @@ packages: '@nodable/entities@2.2.0': resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==} + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + '@oslojs/asn1@1.0.0': resolution: {integrity: sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==} @@ -1216,13 +1268,25 @@ packages: react: '>=16.8' react-dom: '>=16.8' + '@tanstack/react-virtual@3.14.2': + resolution: {integrity: sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/table-core@8.21.3': resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} engines: {node: '>=12'} + '@tanstack/virtual-core@3.17.0': + resolution: {integrity: sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==} + '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -1250,6 +1314,9 @@ packages: '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} @@ -1335,6 +1402,10 @@ packages: resolution: {integrity: sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vercel/oidc@3.2.0': + resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} + engines: {node: '>= 20'} + '@vitejs/plugin-react@6.0.2': resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1348,6 +1419,35 @@ packages: babel-plugin-react-compiler: optional: true + '@vitest/expect@4.1.9': + resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + + '@vitest/mocker@4.1.9': + resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.9': + resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + + '@vitest/runner@4.1.9': + resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + + '@vitest/snapshot@4.1.9': + resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + + '@vitest/spy@4.1.9': + resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + + '@vitest/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1358,6 +1458,12 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ai@6.0.205: + resolution: {integrity: sha512-F4akEGF41UdgJO3L4v+D5noVD1/czhJy6x0k9R/i1EXfxqrkBh/PdYSgRSLPiGFvrw76dzI8h4w3NYmLrTb8dw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} @@ -1368,6 +1474,10 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} @@ -1389,6 +1499,10 @@ packages: caniuse-lite@1.0.30001799: resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -1405,6 +1519,11 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + convex-test@0.0.53: + resolution: {integrity: sha512-bouZQTnTvZi8IHljHL++yClj1vcV+/9ZxEcd8JZz7RDxOfPkRKrkMgkk/xlX4M1EAiwcEJPNiQE7VJFvDM3lCQ==} + peerDependencies: + convex: ^1.32.0 + convex@1.41.0: resolution: {integrity: sha512-euxVf6yfpB7/VGKOobkLgjpbJidsUgW+b0ezonEyCUPqlpHFwR4/yIiI1hjjErzraiw91GxrtxpXQClMLNqU+w==} engines: {node: '>=18.0.0', npm: '>=7.0.0'} @@ -1511,6 +1630,9 @@ packages: resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} engines: {node: '>=10.13.0'} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-toolkit@1.47.1: resolution: {integrity: sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q==} @@ -1576,6 +1698,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -1583,6 +1708,14 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1714,6 +1847,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -1852,6 +1988,10 @@ packages: oauth4webapi@3.8.6: resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==} + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1882,6 +2022,9 @@ packages: path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2051,6 +2194,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + sonner@2.0.7: resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} peerDependencies: @@ -2061,6 +2207,12 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + strnum@2.4.0: resolution: {integrity: sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==} @@ -2077,10 +2229,21 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -2189,11 +2352,57 @@ packages: yaml: optional: true + vitest@4.1.9: + resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.9 + '@vitest/browser-preview': 4.1.9 + '@vitest/browser-webdriverio': 4.1.9 + '@vitest/coverage-istanbul': 4.1.9 + '@vitest/coverage-v8': 4.1.9 + '@vitest/ui': 4.1.9 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -2232,6 +2441,30 @@ packages: snapshots: + '@ai-sdk/gateway@3.0.131(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 3.0.10 + '@ai-sdk/provider-utils': 4.0.29(zod@4.4.3) + '@vercel/oidc': 3.2.0 + zod: 4.4.3 + + '@ai-sdk/openai@3.0.71(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 3.0.10 + '@ai-sdk/provider-utils': 4.0.29(zod@4.4.3) + zod: 4.4.3 + + '@ai-sdk/provider-utils@4.0.29(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 3.0.10 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.1.0 + zod: 4.4.3 + + '@ai-sdk/provider@3.0.10': + dependencies: + json-schema: 0.4.0 + '@auth/core@0.41.2': dependencies: '@panva/hkdf': 1.2.1 @@ -2359,6 +2592,12 @@ snapshots: '@date-fns/tz@1.5.0': {} + '@edge-runtime/primitives@6.0.0': {} + + '@edge-runtime/vm@5.0.0': + dependencies: + '@edge-runtime/primitives': 6.0.0 + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -2553,6 +2792,8 @@ snapshots: '@nodable/entities@2.2.0': {} + '@opentelemetry/api@1.9.1': {} + '@oslojs/asn1@1.0.0': dependencies: '@oslojs/binary': 1.0.0 @@ -3177,13 +3418,26 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) + '@tanstack/react-virtual@3.14.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/virtual-core': 3.17.0 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + '@tanstack/table-core@8.21.3': {} + '@tanstack/virtual-core@3.17.0': {} + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 optional: true + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} '@types/d3-color@3.1.3': {} @@ -3208,6 +3462,8 @@ snapshots: '@types/d3-timer@3.0.2': {} + '@types/deep-eql@4.0.2': {} + '@types/esrecurse@4.3.1': {} '@types/estree@1.0.9': {} @@ -3323,17 +3579,68 @@ snapshots: '@typescript-eslint/types': 8.61.0 eslint-visitor-keys: 5.0.1 + '@vercel/oidc@3.2.0': {} + '@vitejs/plugin-react@6.0.2(vite@8.0.16(@types/node@24.13.2)(esbuild@0.27.0)(jiti@2.7.0))': dependencies: '@rolldown/pluginutils': 1.0.1 vite: 8.0.16(@types/node@24.13.2)(esbuild@0.27.0)(jiti@2.7.0) + '@vitest/expect@4.1.9': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.9(vite@8.0.16(@types/node@24.13.2)(esbuild@0.27.0)(jiti@2.7.0))': + dependencies: + '@vitest/spy': 4.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.16(@types/node@24.13.2)(esbuild@0.27.0)(jiti@2.7.0) + + '@vitest/pretty-format@4.1.9': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.9': + dependencies: + '@vitest/utils': 4.1.9 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + '@vitest/utils': 4.1.9 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.9': {} + + '@vitest/utils@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + acorn-jsx@5.3.2(acorn@8.17.0): dependencies: acorn: 8.17.0 acorn@8.17.0: {} + ai@6.0.205(zod@4.4.3): + dependencies: + '@ai-sdk/gateway': 3.0.131(zod@4.4.3) + '@ai-sdk/provider': 3.0.10 + '@ai-sdk/provider-utils': 4.0.29(zod@4.4.3) + '@opentelemetry/api': 1.9.1 + zod: 4.4.3 + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -3347,6 +3654,8 @@ snapshots: dependencies: tslib: 2.8.1 + assertion-error@2.0.1: {} + balanced-match@4.0.4: {} baseline-browser-mapping@2.10.37: {} @@ -3365,6 +3674,8 @@ snapshots: caniuse-lite@1.0.30001799: {} + chai@6.2.2: {} + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -3385,6 +3696,10 @@ snapshots: convert-source-map@2.0.0: {} + convex-test@0.0.53(convex@1.41.0(react@19.2.7)): + dependencies: + convex: 1.41.0(react@19.2.7) + convex@1.41.0(react@19.2.7): dependencies: esbuild: 0.27.0 @@ -3465,6 +3780,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + es-module-lexer@2.1.0: {} + es-toolkit@1.47.1: {} esbuild@0.27.0: @@ -3579,10 +3896,18 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + esutils@2.0.3: {} eventemitter3@5.0.4: {} + eventsource-parser@3.1.0: {} + + expect-type@1.3.0: {} + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -3679,6 +4004,8 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@2.2.3: {} @@ -3782,6 +4109,8 @@ snapshots: oauth4webapi@3.8.6: {} + obug@2.1.3: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -3809,6 +4138,8 @@ snapshots: path-to-regexp@6.3.0: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -3967,6 +4298,8 @@ snapshots: shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + sonner@2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: react: 19.2.7 @@ -3974,6 +4307,10 @@ snapshots: source-map-js@1.2.1: {} + stackback@0.0.2: {} + + std-env@4.1.0: {} + strnum@2.4.0: dependencies: anynum: 1.0.0 @@ -3986,11 +4323,17 @@ snapshots: tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyrainbow@3.1.0: {} + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: typescript: 6.0.3 @@ -4075,10 +4418,44 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 + vitest@4.1.9(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.27.0)(jiti@2.7.0)): + dependencies: + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@8.0.16(@types/node@24.13.2)(esbuild@0.27.0)(jiti@2.7.0)) + '@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 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.0.16(@types/node@24.13.2)(esbuild@0.27.0)(jiti@2.7.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@edge-runtime/vm': 5.0.0 + '@opentelemetry/api': 1.9.1 + '@types/node': 24.13.2 + transitivePeerDependencies: + - msw + which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} ws@8.20.1: {} diff --git a/src/App.tsx b/src/App.tsx index 24435ba..7c960e0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import { DashboardPage } from "./pages/DashboardPage"; import { TransactionsPage } from "./pages/TransactionsPage"; import { CategoriesPage } from "./pages/CategoriesPage"; import { LoansPage } from "./pages/LoansPage"; +import { SavingsChatPage } from "./pages/SavingsChatPage"; import { ImportPage } from "./pages/ImportPage"; import { SettingsPage } from "./pages/SettingsPage"; import { Skeleton } from "./components/ui/skeleton"; @@ -49,6 +50,7 @@ const router = createBrowserRouter([ children: [ { path: "/", element: }, { path: "/transaktionen", element: }, + { path: "/talk", element: }, { path: "/kategorien", element: }, { path: "/kredite", element: }, { path: "/import", element: }, diff --git a/src/components/charts/CategoryBreakdownChart.test.ts b/src/components/charts/CategoryBreakdownChart.test.ts new file mode 100644 index 0000000..941180b --- /dev/null +++ b/src/components/charts/CategoryBreakdownChart.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "vitest"; +import { toCategoryPieData } from "./categoryBreakdownData"; + +describe("toCategoryPieData", () => { + test("uses positive chart values while preserving signed expense amounts", () => { + expect( + toCategoryPieData([ + { + name: "Lebensmittel", + amount: -123.45, + color: "#ef4444", + block: "variabel", + }, + { + name: "Rueckerstattung", + amount: 12, + color: "#22c55e", + }, + ]), + ).toEqual([ + { + name: "Lebensmittel", + amount: -123.45, + chartAmount: 123.45, + color: "#ef4444", + block: "variabel", + }, + { + name: "Rueckerstattung", + amount: 12, + chartAmount: 12, + color: "#22c55e", + }, + ]); + }); +}); diff --git a/src/components/charts/CategoryBreakdownChart.tsx b/src/components/charts/CategoryBreakdownChart.tsx index 7d8b237..7e9257d 100644 --- a/src/components/charts/CategoryBreakdownChart.tsx +++ b/src/components/charts/CategoryBreakdownChart.tsx @@ -3,24 +3,20 @@ import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recha import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { formatAmount } from "@/lib/format"; +import { type CategoryBreakdownItem, toCategoryPieData } from "./categoryBreakdownData"; -type Item = { - name: string; - amount: number; - color: string; - block?: "wiederkehrend" | "variabel"; -}; - -export function CategoryBreakdownChart({ data }: { data: Item[] }) { +export function CategoryBreakdownChart({ data }: { data: CategoryBreakdownItem[] }) { const [filter, setFilter] = useState<"all" | "wiederkehrend" | "variabel">("all"); const filtered = data.filter((d) => { if (filter === "all") return true; return d.block === filter; }); + const pieData = toCategoryPieData(filtered); + const total = pieData.reduce((sum, item) => sum + item.chartAmount, 0); return ( - + Ausgaben nach Kategorie
{(["all", "wiederkehrend", "variabel"] as const).map((f) => ( @@ -30,18 +26,56 @@ export function CategoryBreakdownChart({ data }: { data: Item[] }) { ))}
- - - - - {filtered.map((entry) => ( - - ))} - - formatAmount(Number(v ?? 0))} /> - - - + + {pieData.length === 0 ? ( +

Keine Ausgaben im gewählten Zeitraum

+ ) : ( +
+
+ + + + {pieData.map((entry) => ( + + ))} + + + formatAmount( + typeof item.payload?.amount === "number" ? item.payload.amount : Number(v ?? 0), + ) + } + /> + + +
+ +
+
    + {pieData.map((entry) => { + const share = total > 0 ? entry.chartAmount / total : 0; + + return ( +
  • +
    +
    +
    +
    {formatAmount(entry.amount)}
    +
    {Math.round(share * 100)}%
    +
    +
  • + ); + })} +
+
+
+ )}
); @@ -67,13 +101,21 @@ export function FixedVariableSplit({ - + {data.map((entry) => ( ))} - formatAmount(-Number(v ?? 0))} /> - + formatAmount(-Number(v ?? 0))} /> + diff --git a/src/components/charts/MonthlyTrendChart.tsx b/src/components/charts/MonthlyTrendChart.tsx index 9c271dc..7f8f57a 100644 --- a/src/components/charts/MonthlyTrendChart.tsx +++ b/src/components/charts/MonthlyTrendChart.tsx @@ -10,10 +10,12 @@ import { YAxis, } from "recharts"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { eur } from "@/lib/format"; +import { eur, formatEurCompact } from "@/lib/format"; type Point = { month: string; income: number; expenses: number; balance: number }; +const axisTick = { fontSize: 13, fill: "var(--muted-foreground)" }; + export function MonthlyTrendChart({ data }: { data: Point[] }) { return ( @@ -22,12 +24,25 @@ export function MonthlyTrendChart({ data }: { data: Point[] }) { - - - - eur.format(v)} /> + + + + eur.format(Number(v ?? 0))} /> - + diff --git a/src/components/charts/categoryBreakdownData.ts b/src/components/charts/categoryBreakdownData.ts new file mode 100644 index 0000000..3feba44 --- /dev/null +++ b/src/components/charts/categoryBreakdownData.ts @@ -0,0 +1,17 @@ +export type CategoryBreakdownItem = { + name: string; + amount: number; + color: string; + block?: "wiederkehrend" | "variabel"; +}; + +export type CategoryPieItem = CategoryBreakdownItem & { + chartAmount: number; +}; + +export function toCategoryPieData(data: CategoryBreakdownItem[]): CategoryPieItem[] { + return data.map((item) => ({ + ...item, + chartAmount: Math.abs(item.amount), + })); +} diff --git a/src/components/chat/ChatHistory.tsx b/src/components/chat/ChatHistory.tsx new file mode 100644 index 0000000..723de33 --- /dev/null +++ b/src/components/chat/ChatHistory.tsx @@ -0,0 +1,88 @@ +import { MessageCircle, Plus, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export type ChatHistoryItem = { + id: string; + title: string; + updatedAt: number; + messageCount: number; +}; + +type ChatHistoryProps = { + items: ChatHistoryItem[]; + activeId: string; + onSelect: (id: string) => void; + onCreate: () => void; + onDelete: (id: string) => void; +}; + +const dateFormatter = new Intl.DateTimeFormat("de-DE", { + day: "2-digit", + month: "2-digit", + hour: "2-digit", + minute: "2-digit", +}); + +export function ChatHistory({ + items, + activeId, + onSelect, + onCreate, + onDelete, +}: ChatHistoryProps) { + return ( + + ); +} diff --git a/src/components/layout/CategoryFilter.tsx b/src/components/layout/CategoryFilter.tsx new file mode 100644 index 0000000..8777605 --- /dev/null +++ b/src/components/layout/CategoryFilter.tsx @@ -0,0 +1,129 @@ +import { useMemo, useState } from "react"; +import * as Popover from "@radix-ui/react-popover"; +import { Check, ChevronDown, X } from "lucide-react"; +import { useQuery } from "convex/react"; +import { api } from "../../../convex/_generated/api"; +import { useFilters } from "@/context/FilterContext"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +const NONE_VALUE = "__none__"; + +export function CategoryFilter() { + const categories = useQuery(api.categories.list); + const { categoryIds, setCategoryIds } = useFilters(); + const [open, setOpen] = useState(false); + + const selectedSet = useMemo(() => new Set(categoryIds), [categoryIds]); + const noneSelected = selectedSet.has(NONE_VALUE); + + const toggle = (value: string) => { + const next = new Set(categoryIds); + if (next.has(value)) { + next.delete(value); + } else { + next.add(value); + } + setCategoryIds(Array.from(next)); + }; + + const clear = () => setCategoryIds([]); + + const label = useMemo(() => { + if (categoryIds.length === 0) return "Alle Kategorien"; + const names: string[] = []; + if (noneSelected) names.push("Ohne Kategorie"); + categories?.forEach((c) => { + if (selectedSet.has(c._id)) names.push(c.name); + }); + if (names.length === 1) return names[0]; + return `${names.length} Kategorien`; + }, [categoryIds.length, noneSelected, categories, selectedSet]); + + return ( + + + + + + +
+ Kategorien filtern + {categoryIds.length > 0 && ( + + )} +
+
+ toggle(NONE_VALUE)} + /> + {categories?.map((c) => ( + toggle(c._id)} + /> + ))} +
+
+
+
+ ); +} + +function CategoryItem({ + value, + label, + color, + checked, + onToggle, +}: { + value: string; + label: string; + color: string; + checked: boolean; + onToggle: () => void; +}) { + return ( + + ); +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 6cdd4be..9da23fa 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -2,6 +2,7 @@ import { NavLink } from "react-router-dom"; import { CreditCard, FolderTree, + MessageCircle, Import, LayoutDashboard, Settings, @@ -12,6 +13,7 @@ import { cn } from "@/lib/utils"; const links = [ { to: "/", label: "Übersicht", icon: LayoutDashboard }, { to: "/transaktionen", label: "Transaktionen", icon: Wallet }, + { to: "/talk", label: "Talk to Savings", icon: MessageCircle }, { to: "/kategorien", label: "Kategorien", icon: FolderTree }, { to: "/kredite", label: "Kredite", icon: CreditCard }, { to: "/import", label: "CSV & comdirect", icon: Import }, diff --git a/src/context/FilterContext.tsx b/src/context/FilterContext.tsx index 53bd739..09169c0 100644 --- a/src/context/FilterContext.tsx +++ b/src/context/FilterContext.tsx @@ -27,6 +27,8 @@ type FilterContextValue = { setCustomRange: (from: string, to: string) => void; accountId: string | undefined; setAccountId: (id: string | undefined) => void; + categoryIds: string[]; + setCategoryIds: (ids: string[]) => void; monthBasis: MonthBasis; setMonthBasis: (basis: MonthBasis) => void; }; @@ -63,6 +65,7 @@ export function FilterProvider({ children }: { children: ReactNode }) { const [customFrom, setCustomFrom] = useState(); const [customTo, setCustomTo] = useState(); const [accountId, setAccountId] = useState(); + const [categoryIds, setCategoryIds] = useState([]); const [monthBasis, setMonthBasis] = useState("effective"); const { from, to } = useMemo( @@ -83,10 +86,12 @@ export function FilterProvider({ children }: { children: ReactNode }) { }, accountId, setAccountId, + categoryIds, + setCategoryIds, monthBasis, setMonthBasis, }), - [preset, from, to, accountId, monthBasis], + [preset, from, to, accountId, categoryIds, monthBasis], ); return {children}; diff --git a/src/lib/format.ts b/src/lib/format.ts index 5b3bd7f..4ba0953 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -29,6 +29,17 @@ export function formatAmount(amount: number): string { return eur.format(amount); } +export function formatEurCompact(value: number): string { + const abs = Math.abs(value); + const sign = value < 0 ? "-" : ""; + const suffix = (n: number, digits = 1) => + `${sign}${n.toFixed(digits).replace(".", ",")}`; + if (abs >= 1_000_000) return `${suffix(abs / 1_000_000)} Mio. €`; + if (abs >= 10_000) return `${suffix(abs / 1000)}k €`; + if (abs >= 1000) return `${suffix(abs / 1000, 2)}k €`; + return eur.format(value); +} + export function amountClass(amount: number): string { if (amount < 0) return "text-red-600 dark:text-red-400"; if (amount > 0) return "text-emerald-600 dark:text-emerald-400"; diff --git a/src/pages/SavingsChatPage.tsx b/src/pages/SavingsChatPage.tsx new file mode 100644 index 0000000..c495af0 --- /dev/null +++ b/src/pages/SavingsChatPage.tsx @@ -0,0 +1,265 @@ +import { type FormEvent, useEffect, useRef, useState } from "react"; +import { useAction, useQuery } from "convex/react"; +import { MessageCircle, Send, Loader2 } from "lucide-react"; +import { api } from "../../convex/_generated/api"; +import { useAccountFilterId } from "@/components/layout/AccountFilter"; +import { useFilters } from "@/context/FilterContext"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +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 ChatSession = { + id: string; + title: string; + createdAt: number; + updatedAt: number; + messages: ChatMessage[]; +}; + +const STORAGE_KEY = "savings-chat-sessions"; +const initialAssistantMessage: ChatMessage = { + role: "assistant", + content: "Frag mich zu deinen Umsätzen – ich werte sie im aktuellen Zeitraum aus.", +}; +const fallbackMessages = [initialAssistantMessage]; + +function createSession(): ChatSession { + const now = Date.now(); + const randomId = + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `${now}-${Math.random().toString(36).slice(2)}`; + + return { + id: randomId, + title: "Neuer Chat", + createdAt: now, + updatedAt: now, + messages: [initialAssistantMessage], + }; +} + +function loadSessions(): ChatSession[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return [createSession()]; + const parsed = JSON.parse(raw) as ChatSession[]; + if (!Array.isArray(parsed) || parsed.length === 0) return [createSession()]; + return parsed; + } catch { + return [createSession()]; + } +} + +function titleFromMessages(messages: ChatMessage[]) { + const firstUserMessage = messages.find((message) => message.role === "user")?.content.trim(); + if (!firstUserMessage) return "Neuer Chat"; + return firstUserMessage.length > 44 ? `${firstUserMessage.slice(0, 44)}…` : firstUserMessage; +} + +export function SavingsChatPage() { + const { from, to, monthBasis } = useFilters(); + const accountId = useAccountFilterId(); + + const [draft, setDraft] = useState(""); + const [sessions, setSessions] = useState(loadSessions); + const [activeSessionId, setActiveSessionId] = useState(() => sessions[0]?.id ?? ""); + const [isSubmitting, setIsSubmitting] = useState(false); + const listRef = useRef(null); + const activeSession = sessions.find((session) => session.id === activeSessionId) ?? sessions[0]; + const messages = activeSession?.messages ?? fallbackMessages; + + const context = useQuery(api.savingsChat.getContext, { + from, + to, + accountId, + basis: monthBasis, + }); + const ask = useAction(api.savingsChat.ask); + + const buttonDisabled = isSubmitting || draft.trim().length === 0; + + const formatAmount = (amount: number) => + new Intl.NumberFormat("de-DE", { + style: "currency", + currency: "EUR", + }).format(amount); + + const getContextSummary = () => { + if (!context) return "Lade Kontext…"; + return `${context.totals.transactionCount} Umsätze · Einnahmen ${formatAmount( + context.totals.income, + )} · Ausgaben ${formatAmount(context.totals.expenses)}`; + }; + + useEffect(() => { + listRef.current?.lastElementChild?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + useEffect(() => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions)); + }, [sessions]); + + const updateSession = (id: string, nextMessages: ChatMessage[]) => { + const now = Date.now(); + setSessions((prev) => + prev.map((session) => + session.id === id + ? { + ...session, + title: titleFromMessages(nextMessages), + updatedAt: now, + messages: nextMessages, + } + : session, + ), + ); + }; + + const createNewChat = () => { + const session = createSession(); + setSessions((prev) => [session, ...prev]); + setActiveSessionId(session.id); + setDraft(""); + }; + + const deleteChat = (id: string) => { + const remaining = sessions.filter((session) => session.id !== id); + const nextSessions = remaining.length > 0 ? remaining : [createSession()]; + setSessions(nextSessions); + if (id === activeSessionId) setActiveSessionId(nextSessions[0].id); + }; + + const historyItems: ChatHistoryItem[] = sessions + .map((session) => ({ + id: session.id, + title: session.title, + updatedAt: session.updatedAt, + messageCount: session.messages.length, + })) + .sort((a, b) => b.updatedAt - a.updatedAt); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + const content = draft.trim(); + if (!content || isSubmitting) return; + + const submittedSessionId = activeSessionId; + const typedNextMessages: ChatMessage[] = [...messages, { role: "user", content }]; + updateSession(submittedSessionId, typedNextMessages); + setDraft(""); + setIsSubmitting(true); + + try { + const response = await ask({ + messages: typedNextMessages, + from, + to, + accountId, + basis: monthBasis, + }); + + updateSession(submittedSessionId, [ + ...typedNextMessages, + { role: "assistant", content: response.answer }, + ]); + } catch (error) { + console.error(error); + toast.error("Antwort konnte nicht geladen werden."); + updateSession(submittedSessionId, [ + ...typedNextMessages, + { + role: "assistant", + content: "Ich konnte gerade keine Antwort erzeugen. Bitte später erneut versuchen.", + }, + ]); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+ + +
+
+ +

Talk to your savings account

+
+ + + + Kontext der Auswertung + + +

Zeitraum: {from} bis {to}

+

Basis: {monthBasis === "effective" ? "Effektiv" : "Buchungstag"}

+

+ Konto: {context?.accountName ?? "Alle Konten"} (Basis-Saldo{" "} + {context ? formatAmount(context.totals.balance) : "—"}) +

+

+ {context ? getContextSummary() : "Lade Kontext…"} +

+
+
+ + + +
+
+ {messages.map((message, index) => ( +
+

{message.role}

+

{message.content}

+
+ ))} + {isSubmitting && ( +
+ + Denk mit der KI nach… +
+ )} +
+
+
+
+ +
+ setDraft(event.target.value)} + placeholder="Welche Auswertung soll ich machen?" + disabled={isSubmitting} + autoFocus + /> + +
+ + +

+ Antwortmodell: {context ? "Server-seitig bereit" : "…"} +

+
+
+ ); +} diff --git a/src/pages/TransactionsPage.tsx b/src/pages/TransactionsPage.tsx index 919768c..1a4db5e 100644 --- a/src/pages/TransactionsPage.tsx +++ b/src/pages/TransactionsPage.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useMutation, usePaginatedQuery, useQuery } from "convex/react"; import { flexRender, @@ -6,12 +6,15 @@ import { useReactTable, type ColumnDef, } from "@tanstack/react-table"; +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 { CategoryFilter } from "@/components/layout/CategoryFilter"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Badge } from "@/components/ui/badge"; import { amountClass, formatAmount, formatDate, formatMonth } from "@/lib/format"; import { TransactionFormDialog } from "@/components/transactions/TransactionFormDialog"; @@ -19,16 +22,138 @@ import { AssignMonthDialog } from "@/components/transactions/AssignMonthDialog"; import { toast } from "sonner"; type Tx = Doc<"transactions">; +type Category = Doc<"categories">; + +/* ── Memoized cell components ────────────────────────────────────── */ + +const RowCheckbox = memo(function RowCheckbox({ + checked, + onToggle, +}: { + checked: boolean; + onToggle: (e?: unknown) => void; +}) { + return ; +}); + +const AccountCell = memo(function AccountCell({ name }: { name: string | undefined }) { + return <>{name ?? "–"}; +}); + +const CategoryCell = memo(function CategoryCell({ + tx, + categories, + categoryMap, + onUpdate, +}: { + tx: Tx; + categories: Category[] | undefined; + categoryMap: Map, Category>; + onUpdate: (id: Id<"transactions">, categoryId: Id<"categories">) => void; +}) { + const [open, setOpen] = useState(false); + const cat = tx.categoryId ? categoryMap.get(tx.categoryId) : null; + + if (!open) { + return ( + + ); + } + + return ( + + ); +}); + +const AssignedCell = memo(function AssignedCell({ + assignedMonth, + bookingMonth, +}: { + assignedMonth: string | undefined; + bookingMonth: string | undefined; +}) { + if (!assignedMonth || assignedMonth === bookingMonth) return null; + return {formatMonth(assignedMonth)}; +}); + +const ActionsCell = memo(function ActionsCell({ + tx, + onEdit, + onAssign, + onRemove, +}: { + tx: Tx; + onEdit: (tx: Tx) => void; + onAssign: (tx: Tx) => void; + onRemove: (id: Id<"transactions">) => void; +}) { + return ( +
+ + + +
+ ); +}); + +/* ── Page ────────────────────────────────────────────────────────── */ export function TransactionsPage() { const [search, setSearch] = useState(""); const [type, setType] = useState<"all" | "einnahme" | "ausgabe">("all"); const [pendingOnly, setPendingOnly] = useState(false); - const [selected, setSelected] = useState[]>([]); + const [rowSelection, setRowSelection] = useState>({}); const [editTx, setEditTx] = useState(null); const [assignTx, setAssignTx] = useState(null); const [createOpen, setCreateOpen] = useState(false); + const { categoryIds } = useFilters(); + const categories = useQuery(api.categories.list); const accounts = useQuery(api.accounts.list); const removeTx = useMutation(api.transactions.remove); @@ -41,12 +166,46 @@ export function TransactionsPage() { search: search || undefined, type: type === "all" ? undefined : type, pendingOnly: pendingOnly || undefined, + categoryIds: + categoryIds.length > 0 + ? (categoryIds.filter((id) => id !== "__none__") as Id<"categories">[]) + : undefined, + withoutCategory: categoryIds.includes("__none__") || undefined, }, { initialNumItems: 50 }, ); - const categoryMap = useMemo(() => new Map(categories?.map((c) => [c._id, c])), [categories]); - const accountMap = useMemo(() => new Map(accounts?.map((a) => [a._id, a.name])), [accounts]); + const categoryMap = useMemo( + () => new Map(categories?.map((c) => [c._id, c])), + [categories], + ); + const accountMap = useMemo( + () => new Map(accounts?.map((a) => [a._id, a.name])), + [accounts], + ); + + const handleUpdateCategory = useCallback( + (id: Id<"transactions">, categoryId: Id<"categories">) => { + void updateTx({ id, categoryId }); + }, + [updateTx], + ); + const handleEdit = useCallback((tx: Tx) => setEditTx(tx), []); + const handleAssign = useCallback((tx: Tx) => setAssignTx(tx), []); + const handleRemove = useCallback( + async (id: Id<"transactions">) => { + if (confirm("Transaktion löschen?")) { + await removeTx({ id }); + toast.success("Gelöscht"); + } + }, + [removeTx], + ); + + const selectedIds = useMemo( + () => Object.keys(rowSelection).filter((k) => rowSelection[k]) as Id<"transactions">[], + [rowSelection], + ); const columns = useMemo[]>( () => [ @@ -54,16 +213,9 @@ export function TransactionsPage() { id: "select", header: () => null, cell: ({ row }) => ( - { - setSelected((prev) => - e.target.checked - ? [...prev, row.original._id] - : prev.filter((id) => id !== row.original._id), - ); - }} + ), }, @@ -78,36 +230,23 @@ export function TransactionsPage() { { id: "account", header: "Konto", - cell: ({ row }) => - row.original.accountId ? accountMap.get(row.original.accountId) ?? "–" : "–", + cell: ({ row }) => ( + + ), }, { id: "category", header: "Kategorie", - cell: ({ row }) => { - return ( - - ); - }, + cell: ({ row }) => ( + + ), }, { accessorKey: "amount", @@ -123,44 +262,72 @@ export function TransactionsPage() { header: "Zuordnung", cell: ({ row }) => { const bookingMonth = row.original.bookingDate?.slice(0, 7); - if (!row.original.assignedMonth || row.original.assignedMonth === bookingMonth) return null; - return {formatMonth(row.original.assignedMonth)}; + return ( + + ); }, }, { id: "actions", header: "", cell: ({ row }) => ( -
- - - -
+ ), }, ], - [categories, categoryMap, accountMap, selected, updateTx, removeTx], + [ + categories, + categoryMap, + accountMap, + handleUpdateCategory, + handleEdit, + handleAssign, + handleRemove, + ], ); - const table = useReactTable({ data: results ?? [], columns, getCoreRowModel: getCoreRowModel() }); + const table = useReactTable({ + data: results ?? [], + columns, + state: { rowSelection }, + onRowSelectionChange: setRowSelection, + getRowId: (row) => row._id, + getCoreRowModel: getCoreRowModel(), + }); + + /* ── Virtualization ── */ + const parentRef = useRef(null); + const rows = table.getRowModel().rows; + + const rowVirtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 44, + overscan: 10, + }); + + const virtualRows = rowVirtualizer.getVirtualItems(); + const totalSize = rowVirtualizer.getTotalSize(); + const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0; + const paddingBottom = + virtualRows.length > 0 ? totalSize - virtualRows[virtualRows.length - 1].end : 0; + + /* ── Auto-load more when scrolling near the end ── */ + useEffect(() => { + if (status !== "CanLoadMore") return; + const last = virtualRows[virtualRows.length - 1]; + if (last && last.index >= rows.length - 10) { + loadMore(50); + } + }, [virtualRows, rows.length, status, loadMore]); return ( -
+
Ausgaben + - {selected.length > 0 && categories && ( + {selectedIds.length > 0 && categories && (