Add savings chat analysis feature
This commit is contained in:
@@ -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)
|
||||
|
||||
15
backlog/config.yml
Normal file
15
backlog/config.yml
Normal file
@@ -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"
|
||||
@@ -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
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
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.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [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
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
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
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
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.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
@@ -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
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Fix the dashboard category expense pie chart so negative expense totals render as visible pie slices while preserving expense-style formatting in the UI.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [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
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
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
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
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.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
2
convex/_generated/api.d.ts
vendored
2
convex/_generated/api.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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<Id<"users">> {
|
||||
export async function requireUserId(ctx: QueryCtx | MutationCtx | ActionCtx): Promise<Id<"users">> {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) throw new Error("Nicht angemeldet");
|
||||
return userId;
|
||||
|
||||
194
convex/savingsChat.test.ts
Normal file
194
convex/savingsChat.test.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
399
convex/savingsChat.ts
Normal file
399
convex/savingsChat.ts
Normal file
@@ -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<Doc<"transactions">[]> {
|
||||
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<ChatContextSummary> => {
|
||||
const userId = await requireUserId(ctx);
|
||||
const { summary } = await buildContextSummary(ctx, userId, args);
|
||||
return summary;
|
||||
},
|
||||
});
|
||||
|
||||
function toDisplayContextLine(
|
||||
tx: Doc<"transactions">,
|
||||
categoryById: Map<Id<"categories">, string>,
|
||||
accountById: Map<Id<"accounts">, 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<ChatPromptContext> => {
|
||||
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<ChatAskResult> => {
|
||||
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}`);
|
||||
},
|
||||
});
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
668
package-lock.json
generated
668
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
377
pnpm-lock.yaml
generated
377
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
@@ -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: <DashboardPage /> },
|
||||
{ path: "/transaktionen", element: <TransactionsPage /> },
|
||||
{ path: "/talk", element: <SavingsChatPage /> },
|
||||
{ path: "/kategorien", element: <CategoriesPage /> },
|
||||
{ path: "/kredite", element: <LoansPage /> },
|
||||
{ path: "/import", element: <ImportPage /> },
|
||||
|
||||
36
src/components/charts/CategoryBreakdownChart.test.ts
Normal file
36
src/components/charts/CategoryBreakdownChart.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { toCategoryPieData } from "./categoryBreakdownData";
|
||||
|
||||
describe("toCategoryPieData", () => {
|
||||
test("uses positive chart values while preserving signed expense amounts", () => {
|
||||
expect(
|
||||
toCategoryPieData([
|
||||
{
|
||||
name: "Lebensmittel",
|
||||
amount: -123.45,
|
||||
color: "#ef4444",
|
||||
block: "variabel",
|
||||
},
|
||||
{
|
||||
name: "Rueckerstattung",
|
||||
amount: 12,
|
||||
color: "#22c55e",
|
||||
},
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
name: "Lebensmittel",
|
||||
amount: -123.45,
|
||||
chartAmount: 123.45,
|
||||
color: "#ef4444",
|
||||
block: "variabel",
|
||||
},
|
||||
{
|
||||
name: "Rueckerstattung",
|
||||
amount: 12,
|
||||
chartAmount: 12,
|
||||
color: "#22c55e",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -3,24 +3,20 @@ import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recha
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatAmount } from "@/lib/format";
|
||||
import { type CategoryBreakdownItem, toCategoryPieData } from "./categoryBreakdownData";
|
||||
|
||||
type Item = {
|
||||
name: string;
|
||||
amount: number;
|
||||
color: string;
|
||||
block?: "wiederkehrend" | "variabel";
|
||||
};
|
||||
|
||||
export function CategoryBreakdownChart({ data }: { data: Item[] }) {
|
||||
export function CategoryBreakdownChart({ data }: { data: CategoryBreakdownItem[] }) {
|
||||
const [filter, setFilter] = useState<"all" | "wiederkehrend" | "variabel">("all");
|
||||
const filtered = data.filter((d) => {
|
||||
if (filter === "all") return true;
|
||||
return d.block === filter;
|
||||
});
|
||||
const pieData = toCategoryPieData(filtered);
|
||||
const total = pieData.reduce((sum, item) => sum + item.chartAmount, 0);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle>Ausgaben nach Kategorie</CardTitle>
|
||||
<div className="flex gap-1">
|
||||
{(["all", "wiederkehrend", "variabel"] as const).map((f) => (
|
||||
@@ -30,18 +26,56 @@ export function CategoryBreakdownChart({ data }: { data: Item[] }) {
|
||||
))}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie data={filtered} dataKey="amount" nameKey="name" cx="50%" cy="50%" outerRadius={100} label>
|
||||
{filtered.map((entry) => (
|
||||
<Cell key={entry.name} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v) => formatAmount(Number(v ?? 0))} />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<CardContent>
|
||||
{pieData.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Keine Ausgaben im gewählten Zeitraum</p>
|
||||
) : (
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(260px,0.85fr)_minmax(320px,1.15fr)] lg:items-center">
|
||||
<div className="h-72 min-h-72 min-w-0 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={240} minHeight={240}>
|
||||
<PieChart>
|
||||
<Pie data={pieData} dataKey="chartAmount" nameKey="name" cx="50%" cy="50%" outerRadius={105}>
|
||||
{pieData.map((entry) => (
|
||||
<Cell key={entry.name} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(v, _name, item) =>
|
||||
formatAmount(
|
||||
typeof item.payload?.amount === "number" ? item.payload.amount : Number(v ?? 0),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="max-h-72 min-w-0 overflow-y-auto pr-1">
|
||||
<ul className="space-y-2">
|
||||
{pieData.map((entry) => {
|
||||
const share = total > 0 ? entry.chartAmount / total : 0;
|
||||
|
||||
return (
|
||||
<li key={entry.name} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 text-sm">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className="h-3 w-3 shrink-0 rounded-sm"
|
||||
style={{ backgroundColor: entry.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate font-medium">{entry.name}</span>
|
||||
</div>
|
||||
<div className="text-right tabular-nums">
|
||||
<div className="font-medium">{formatAmount(entry.amount)}</div>
|
||||
<div className="text-xs text-muted-foreground">{Math.round(share * 100)}%</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -67,13 +101,21 @@ export function FixedVariableSplit({
|
||||
<CardContent className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie data={data} dataKey="value" nameKey="name" innerRadius={50} outerRadius={80}>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
innerRadius={50}
|
||||
outerRadius={80}
|
||||
stroke="var(--card)"
|
||||
strokeWidth={2}
|
||||
>
|
||||
{data.map((entry) => (
|
||||
<Cell key={entry.name} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v) => formatAmount(-Number(v ?? 0))} />
|
||||
<Legend />
|
||||
<Tooltip formatter={(v) => formatAmount(-Number(v ?? 0))} />
|
||||
<Legend wrapperStyle={{ fontSize: 14 }} iconType="circle" />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
|
||||
@@ -10,10 +10,12 @@ import {
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { eur } from "@/lib/format";
|
||||
import { eur, formatEurCompact } from "@/lib/format";
|
||||
|
||||
type Point = { month: string; income: number; expenses: number; balance: number };
|
||||
|
||||
const axisTick = { fontSize: 13, fill: "var(--muted-foreground)" };
|
||||
|
||||
export function MonthlyTrendChart({ data }: { data: Point[] }) {
|
||||
return (
|
||||
<Card>
|
||||
@@ -22,12 +24,25 @@ export function MonthlyTrendChart({ data }: { data: Point[] }) {
|
||||
</CardHeader>
|
||||
<CardContent className="h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis tickFormatter={(v) => eur.format(v)} />
|
||||
<ComposedChart data={data} margin={{ top: 8, right: 16, bottom: 0, left: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tick={axisTick}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "var(--border)" }}
|
||||
height={44}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={formatEurCompact}
|
||||
tick={axisTick}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={64}
|
||||
/>
|
||||
<Tooltip formatter={(v) => eur.format(Number(v ?? 0))} />
|
||||
<Legend />
|
||||
<Legend wrapperStyle={{ fontSize: 14, paddingTop: 8 }} />
|
||||
<Bar dataKey="income" name="Einnahmen" fill="#22c55e" />
|
||||
<Bar dataKey="expenses" name="Ausgaben" fill="#ef4444" />
|
||||
<Line dataKey="balance" name="Saldo" stroke="#6366f1" strokeWidth={2} />
|
||||
|
||||
17
src/components/charts/categoryBreakdownData.ts
Normal file
17
src/components/charts/categoryBreakdownData.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export type CategoryBreakdownItem = {
|
||||
name: string;
|
||||
amount: number;
|
||||
color: string;
|
||||
block?: "wiederkehrend" | "variabel";
|
||||
};
|
||||
|
||||
export type CategoryPieItem = CategoryBreakdownItem & {
|
||||
chartAmount: number;
|
||||
};
|
||||
|
||||
export function toCategoryPieData(data: CategoryBreakdownItem[]): CategoryPieItem[] {
|
||||
return data.map((item) => ({
|
||||
...item,
|
||||
chartAmount: Math.abs(item.amount),
|
||||
}));
|
||||
}
|
||||
88
src/components/chat/ChatHistory.tsx
Normal file
88
src/components/chat/ChatHistory.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { MessageCircle, Plus, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ChatHistoryItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
updatedAt: number;
|
||||
messageCount: number;
|
||||
};
|
||||
|
||||
type ChatHistoryProps = {
|
||||
items: ChatHistoryItem[];
|
||||
activeId: string;
|
||||
onSelect: (id: string) => void;
|
||||
onCreate: () => void;
|
||||
onDelete: (id: string) => void;
|
||||
};
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
export function ChatHistory({
|
||||
items,
|
||||
activeId,
|
||||
onSelect,
|
||||
onCreate,
|
||||
onDelete,
|
||||
}: ChatHistoryProps) {
|
||||
return (
|
||||
<aside className="rounded-xl border bg-card text-card-foreground shadow-sm">
|
||||
<div className="flex items-center justify-between gap-2 border-b p-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<MessageCircle className="h-4 w-4 shrink-0" />
|
||||
<h2 className="truncate text-sm font-semibold">Chat-Historie</h2>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="icon" onClick={onCreate} title="Neuer Chat">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[64vh] overflow-y-auto p-2">
|
||||
{items.length === 0 ? (
|
||||
<p className="px-2 py-6 text-center text-sm text-muted-foreground">
|
||||
Noch keine Chats
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"group flex items-center gap-1 rounded-md",
|
||||
item.id === activeId ? "bg-accent" : "hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(item.id)}
|
||||
className="min-w-0 flex-1 px-2 py-2 text-left"
|
||||
>
|
||||
<span className="block truncate text-sm font-medium">{item.title}</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{dateFormatter.format(new Date(item.updatedAt))} · {item.messageCount} Nachrichten
|
||||
</span>
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mr-1 h-8 w-8 opacity-70 hover:opacity-100"
|
||||
onClick={() => onDelete(item.id)}
|
||||
title="Chat löschen"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
129
src/components/layout/CategoryFilter.tsx
Normal file
129
src/components/layout/CategoryFilter.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { Check, ChevronDown, X } from "lucide-react";
|
||||
import { useQuery } from "convex/react";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import { useFilters } from "@/context/FilterContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const NONE_VALUE = "__none__";
|
||||
|
||||
export function CategoryFilter() {
|
||||
const categories = useQuery(api.categories.list);
|
||||
const { categoryIds, setCategoryIds } = useFilters();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selectedSet = useMemo(() => new Set(categoryIds), [categoryIds]);
|
||||
const noneSelected = selectedSet.has(NONE_VALUE);
|
||||
|
||||
const toggle = (value: string) => {
|
||||
const next = new Set(categoryIds);
|
||||
if (next.has(value)) {
|
||||
next.delete(value);
|
||||
} else {
|
||||
next.add(value);
|
||||
}
|
||||
setCategoryIds(Array.from(next));
|
||||
};
|
||||
|
||||
const clear = () => setCategoryIds([]);
|
||||
|
||||
const label = useMemo(() => {
|
||||
if (categoryIds.length === 0) return "Alle Kategorien";
|
||||
const names: string[] = [];
|
||||
if (noneSelected) names.push("Ohne Kategorie");
|
||||
categories?.forEach((c) => {
|
||||
if (selectedSet.has(c._id)) names.push(c.name);
|
||||
});
|
||||
if (names.length === 1) return names[0];
|
||||
return `${names.length} Kategorien`;
|
||||
}, [categoryIds.length, noneSelected, categories, selectedSet]);
|
||||
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<Button variant="outline" className="w-[200px] justify-between px-3">
|
||||
<span className="truncate">{label}</span>
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
className="z-50 w-[220px] rounded-md border bg-popover p-2 text-popover-foreground shadow-md"
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between px-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">Kategorien filtern</span>
|
||||
{categoryIds.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clear}
|
||||
className="inline-flex items-center gap-0.5 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-3 w-3" /> Zurücksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-72 overflow-auto">
|
||||
<CategoryItem
|
||||
value={NONE_VALUE}
|
||||
label="Ohne Kategorie"
|
||||
color="#9ca3af"
|
||||
checked={noneSelected}
|
||||
onToggle={() => toggle(NONE_VALUE)}
|
||||
/>
|
||||
{categories?.map((c) => (
|
||||
<CategoryItem
|
||||
key={c._id}
|
||||
value={c._id}
|
||||
label={c.name}
|
||||
color={c.color}
|
||||
checked={selectedSet.has(c._id)}
|
||||
onToggle={() => toggle(c._id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryItem({
|
||||
value,
|
||||
label,
|
||||
color,
|
||||
checked,
|
||||
onToggle,
|
||||
}: {
|
||||
value: string;
|
||||
label: string;
|
||||
color: string;
|
||||
checked: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
htmlFor={`cat-filter-${value}`}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent",
|
||||
checked && "bg-accent/50",
|
||||
)}
|
||||
>
|
||||
<span className="flex h-4 w-4 items-center justify-center rounded border">
|
||||
{checked && <Check className="h-3 w-3" />}
|
||||
</span>
|
||||
<input
|
||||
id={`cat-filter-${value}`}
|
||||
type="checkbox"
|
||||
className="sr-only"
|
||||
checked={checked}
|
||||
onChange={onToggle}
|
||||
/>
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-full" style={{ backgroundColor: color }} />
|
||||
<span className="flex-1 truncate">{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<string>();
|
||||
const [customTo, setCustomTo] = useState<string>();
|
||||
const [accountId, setAccountId] = useState<string>();
|
||||
const [categoryIds, setCategoryIds] = useState<string[]>([]);
|
||||
const [monthBasis, setMonthBasis] = useState<MonthBasis>("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 <FilterContext.Provider value={value}>{children}</FilterContext.Provider>;
|
||||
|
||||
@@ -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";
|
||||
|
||||
265
src/pages/SavingsChatPage.tsx
Normal file
265
src/pages/SavingsChatPage.tsx
Normal file
@@ -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<ChatSession[]>(loadSessions);
|
||||
const [activeSessionId, setActiveSessionId] = useState(() => sessions[0]?.id ?? "");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const listRef = useRef<HTMLDivElement | null>(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 (
|
||||
<div className="grid gap-4 xl:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<ChatHistory
|
||||
items={historyItems}
|
||||
activeId={activeSessionId}
|
||||
onSelect={setActiveSessionId}
|
||||
onCreate={createNewChat}
|
||||
onDelete={deleteChat}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 space-y-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<MessageCircle className="h-5 w-5" />
|
||||
<h1 className="text-lg font-semibold">Talk to your savings account</h1>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Kontext der Auswertung</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1 text-sm">
|
||||
<p>Zeitraum: {from} bis {to}</p>
|
||||
<p>Basis: {monthBasis === "effective" ? "Effektiv" : "Buchungstag"}</p>
|
||||
<p>
|
||||
Konto: {context?.accountName ?? "Alle Konten"} (Basis-Saldo{" "}
|
||||
{context ? formatAmount(context.totals.balance) : "—"})
|
||||
</p>
|
||||
<p>
|
||||
{context ? getContextSummary() : "Lade Kontext…"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="h-[52vh] overflow-y-auto" ref={listRef}>
|
||||
<div className="space-y-3">
|
||||
{messages.map((message, index) => (
|
||||
<div
|
||||
key={`${message.role}-${index}`}
|
||||
className={`rounded-lg border p-3 ${
|
||||
message.role === "user" ? "bg-muted/50" : "bg-background"
|
||||
}`}
|
||||
>
|
||||
<p className="text-xs uppercase text-muted-foreground">{message.role}</p>
|
||||
<p className="whitespace-pre-wrap text-sm">{message.content}</p>
|
||||
</div>
|
||||
))}
|
||||
{isSubmitting && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Denk mit der KI nach…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<form className="flex gap-2" onSubmit={submit}>
|
||||
<Input
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
placeholder="Welche Auswertung soll ich machen?"
|
||||
disabled={isSubmitting}
|
||||
autoFocus
|
||||
/>
|
||||
<Button type="submit" disabled={buttonDisabled}>
|
||||
<Send className="h-4 w-4" />
|
||||
Senden
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<Separator />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Antwortmodell: {context ? "Server-seitig bereit" : "…"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <input type="checkbox" checked={checked} onChange={onToggle} />;
|
||||
});
|
||||
|
||||
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<Id<"categories">, 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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="inline-flex h-6 items-center rounded px-1.5 text-xs hover:bg-accent"
|
||||
>
|
||||
{cat ? (
|
||||
<Badge style={{ backgroundColor: cat.color, color: "#fff", border: "none" }}>
|
||||
{cat.name}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Kategorie…</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
defaultOpen
|
||||
value={tx.categoryId ?? "none"}
|
||||
onValueChange={(v) => {
|
||||
if (v !== "none") onUpdate(tx._id, v as Id<"categories">);
|
||||
setOpen(false);
|
||||
}}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setOpen(false);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-[130px]">
|
||||
<SelectValue placeholder="Kategorie" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories?.map((c) => (
|
||||
<SelectItem key={c._id} value={c._id}>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: c.color }}
|
||||
/>
|
||||
{c.name}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
});
|
||||
|
||||
const AssignedCell = memo(function AssignedCell({
|
||||
assignedMonth,
|
||||
bookingMonth,
|
||||
}: {
|
||||
assignedMonth: string | undefined;
|
||||
bookingMonth: string | undefined;
|
||||
}) {
|
||||
if (!assignedMonth || assignedMonth === bookingMonth) return null;
|
||||
return <Badge variant="outline">{formatMonth(assignedMonth)}</Badge>;
|
||||
});
|
||||
|
||||
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 (
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" variant="ghost" onClick={() => onEdit(tx)}>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => onAssign(tx)}>
|
||||
Monat…
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => onRemove(tx._id)}>
|
||||
Löschen
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/* ── 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<Id<"transactions">[]>([]);
|
||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
||||
const [editTx, setEditTx] = useState<Tx | null>(null);
|
||||
const [assignTx, setAssignTx] = useState<Tx | null>(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<ColumnDef<Tx>[]>(
|
||||
() => [
|
||||
@@ -54,16 +213,9 @@ export function TransactionsPage() {
|
||||
id: "select",
|
||||
header: () => null,
|
||||
cell: ({ row }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(row.original._id)}
|
||||
onChange={(e) => {
|
||||
setSelected((prev) =>
|
||||
e.target.checked
|
||||
? [...prev, row.original._id]
|
||||
: prev.filter((id) => id !== row.original._id),
|
||||
);
|
||||
}}
|
||||
<RowCheckbox
|
||||
checked={row.getIsSelected()}
|
||||
onToggle={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -78,36 +230,23 @@ export function TransactionsPage() {
|
||||
{
|
||||
id: "account",
|
||||
header: "Konto",
|
||||
cell: ({ row }) =>
|
||||
row.original.accountId ? accountMap.get(row.original.accountId) ?? "–" : "–",
|
||||
cell: ({ row }) => (
|
||||
<AccountCell
|
||||
name={row.original.accountId ? accountMap.get(row.original.accountId) : undefined}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "category",
|
||||
header: "Kategorie",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Select
|
||||
value={row.original.categoryId ?? "none"}
|
||||
onValueChange={async (v) => {
|
||||
if (v === "none") return;
|
||||
await updateTx({ id: row.original._id, categoryId: v as Id<"categories"> });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[180px]">
|
||||
<SelectValue placeholder="Kategorie" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories?.map((c) => (
|
||||
<SelectItem key={c._id} value={c._id}>
|
||||
<Badge style={{ backgroundColor: c.color, color: "#fff", border: "none" }}>
|
||||
{c.name}
|
||||
</Badge>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<CategoryCell
|
||||
tx={row.original}
|
||||
categories={categories}
|
||||
categoryMap={categoryMap}
|
||||
onUpdate={handleUpdateCategory}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 <Badge variant="outline">{formatMonth(row.original.assignedMonth)}</Badge>;
|
||||
return (
|
||||
<AssignedCell assignedMonth={row.original.assignedMonth} bookingMonth={bookingMonth} />
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditTx(row.original)}>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setAssignTx(row.original)}>
|
||||
Monat…
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={async () => {
|
||||
if (confirm("Transaktion löschen?")) {
|
||||
await removeTx({ id: row.original._id });
|
||||
toast.success("Gelöscht");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Löschen
|
||||
</Button>
|
||||
</div>
|
||||
<ActionsCell
|
||||
tx={row.original}
|
||||
onEdit={handleEdit}
|
||||
onAssign={handleAssign}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[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<HTMLDivElement>(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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex h-[calc(100vh-8rem)] min-h-0 flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
placeholder="Suche…"
|
||||
@@ -178,17 +345,25 @@ export function TransactionsPage() {
|
||||
<SelectItem value="ausgabe">Ausgaben</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<CategoryFilter />
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={pendingOnly} onChange={(e) => setPendingOnly(e.target.checked)} />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={pendingOnly}
|
||||
onChange={(e) => setPendingOnly(e.target.checked)}
|
||||
/>
|
||||
Nur offene
|
||||
</label>
|
||||
<Button onClick={() => setCreateOpen(true)}>Neu</Button>
|
||||
{selected.length > 0 && categories && (
|
||||
{selectedIds.length > 0 && categories && (
|
||||
<Select
|
||||
onValueChange={async (categoryId) => {
|
||||
await bulkCategory({ ids: selected, categoryId: categoryId as Id<"categories"> });
|
||||
await bulkCategory({
|
||||
ids: selectedIds,
|
||||
categoryId: categoryId as Id<"categories">,
|
||||
});
|
||||
toast.success("Kategorie zugewiesen");
|
||||
setSelected([]);
|
||||
setRowSelection({});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
@@ -205,37 +380,53 @@ export function TransactionsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((hg) => (
|
||||
<TableRow key={hg.id}>
|
||||
{hg.headers.map((h) => (
|
||||
<TableHead key={h.id}>{flexRender(h.column.columnDef.header, h.getContext())}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="min-h-0 flex-1 rounded-md border">
|
||||
<div ref={parentRef} className="relative h-full w-full overflow-auto">
|
||||
<table className="w-full caption-bottom text-sm">
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((hg) => (
|
||||
<TableRow key={hg.id}>
|
||||
{hg.headers.map((h) => (
|
||||
<TableHead key={h.id} className="sticky top-0 z-10 bg-background">
|
||||
{flexRender(h.column.columnDef.header, h.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paddingTop > 0 && <tr style={{ height: paddingTop }} />}
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index];
|
||||
return (
|
||||
<tr
|
||||
key={row.id}
|
||||
data-index={virtualRow.index}
|
||||
ref={(node: HTMLTableRowElement | null) =>
|
||||
rowVirtualizer.measureElement(node)
|
||||
}
|
||||
className="border-b transition-colors hover:bg-muted/50"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{paddingBottom > 0 && <tr style={{ height: paddingBottom }} />}
|
||||
</TableBody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status === "CanLoadMore" && (
|
||||
<Button variant="outline" onClick={() => loadMore(50)}>
|
||||
Mehr laden
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<TransactionFormDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||||
<TransactionFormDialog open={!!editTx} onOpenChange={(o) => !o && setEditTx(null)} transaction={editTx ?? undefined} />
|
||||
<TransactionFormDialog
|
||||
open={!!editTx}
|
||||
onOpenChange={(o) => !o && setEditTx(null)}
|
||||
transaction={editTx ?? undefined}
|
||||
/>
|
||||
<AssignMonthDialog transaction={assignTx} onClose={() => setAssignTx(null)} />
|
||||
</div>
|
||||
);
|
||||
|
||||
19
vitest.config.ts
Normal file
19
vitest.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
"@convex-lib": path.resolve(__dirname, "./convex/lib"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "edge-runtime",
|
||||
server: {
|
||||
deps: {
|
||||
inline: ["convex-test"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user