Compare commits
7 Commits
4a1cbd105b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 76b73ddb76 | |||
| 4836e12a11 | |||
| 3541d00864 | |||
| 0061d5ad82 | |||
| 3ceccafa57 | |||
| 238a30ae0c | |||
| 1c88d12f0d |
@@ -1,10 +1,10 @@
|
|||||||
---
|
---
|
||||||
id: TASK-1
|
id: TASK-1
|
||||||
title: Add complete chat transaction context
|
title: Add complete chat transaction context
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-15 13:52'
|
created_date: '2026-06-15 13:52'
|
||||||
updated_date: '2026-06-15 14:02'
|
updated_date: '2026-06-15 19:54'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
@@ -61,3 +61,9 @@ Final review:
|
|||||||
- Prior blocker resolved: effective-basis loading now includes rows without effectiveMonth via bookingDate fallback queries.
|
- 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.
|
- Fresh full npm run lint still fails only on unrelated existing files; no SavingsChatPage issue remains.
|
||||||
<!-- SECTION:NOTES:END -->
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Closed as Done after explicit user confirmation to mark all tasks done.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
---
|
---
|
||||||
id: TASK-2
|
id: TASK-2
|
||||||
title: Render category expense chart values as positive slices
|
title: Render category expense chart values as positive slices
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-15 14:34'
|
created_date: '2026-06-15 14:34'
|
||||||
updated_date: '2026-06-15 14:48'
|
updated_date: '2026-06-15 19:54'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
@@ -60,3 +60,9 @@ Verification after layout update:
|
|||||||
|
|
||||||
Note: npm run build still emits the existing Vite chunk-size warning for the main bundle.
|
Note: npm run build still emits the existing Vite chunk-size warning for the main bundle.
|
||||||
<!-- SECTION:NOTES:END -->
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Closed as Done after explicit user confirmation to mark all tasks done.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
id: TASK-3
|
||||||
|
title: Build read-only savings agent with tool calls
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-06-15 19:02'
|
||||||
|
updated_date: '2026-06-15 19:54'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
priority: high
|
||||||
|
ordinal: 3000
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Convert Talk to Savings from a prompt-packed chat into a read-only AI SDK tool-calling agent with deterministic transaction retrieval, spending summaries, forecasts, and compact frontend tool traces.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Savings chat action uses AI SDK tool calls with a bounded multi-step loop and returns a compact tool trace
|
||||||
|
- [x] #2 Read-only transaction retrieval tool supports selected scope, bounded custom ranges, account/category/search filters, exact totals, sanitized rows, and hasMore
|
||||||
|
- [x] #3 Spending summary and cashflow forecast tools compute deterministic aggregates without model-invented math
|
||||||
|
- [x] #4 Frontend remains localStorage-backed, loads legacy chats, and renders compact tool traces under assistant answers
|
||||||
|
- [x] #5 Focused Convex tests, build, and targeted lint verification pass or known blockers are documented
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Write failing Convex tests for read-only tool queries and traces
|
||||||
|
2. Implement internal read-only savings agent tool queries
|
||||||
|
3. Switch savingsChat.ask to AI SDK tool calling with bounded multi-step loop
|
||||||
|
4. Update frontend chat messages and compact tool trace rendering
|
||||||
|
5. Run focused tests, build, targeted lint, and record verification notes
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Implementation complete pending user confirmation.
|
||||||
|
|
||||||
|
Subagents:
|
||||||
|
- Frontend UX worker updated src/pages/SavingsChatPage.tsx for legacy-safe localStorage message normalization and compact assistant tool traces.
|
||||||
|
- QA explorer reviewed Convex test strategy and confirmed mock AI SDK approach for ask() coverage.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
- PASS npx vitest convex/savingsChat.test.ts --run (8 tests)
|
||||||
|
- PASS npx eslint convex/savingsChat.ts convex/savingsChat.test.ts src/pages/SavingsChatPage.tsx
|
||||||
|
- PASS npm run build
|
||||||
|
- BLOCKED npm run lint on pre-existing unrelated files: convex/bank/comdirectProvider.ts, convex/bank/config.ts, src/components/import/TanAwaitDialog.tsx, layout/ui fast-refresh exports, SettingsPage.tsx, generated eslint-disable warnings, and existing React compiler warnings. No TASK-3 touched file appears in the full-lint error list.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Closed as Done after explicit user confirmation to mark all tasks done.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
id: TASK-4
|
||||||
|
title: Add selection total to transactions
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-06-15 19:21'
|
||||||
|
updated_date: '2026-06-15 19:54'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
priority: high
|
||||||
|
ordinal: 4000
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Add table selection controls on the transactions page so visible/filtered rows can be selected in bulk and the sum of selected transaction amounts is shown.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 A select-all control can select and clear all currently visible filtered transactions
|
||||||
|
- [x] #2 Individual row selection continues to work alongside the select-all control
|
||||||
|
- [x] #3 The UI displays the count and formatted euro total of selected transactions
|
||||||
|
- [x] #4 Automated tests cover bulk selection and selected-total behavior
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Inspect current transaction table selection behavior
|
||||||
|
2. Add failing coverage for select-all and selected sum
|
||||||
|
3. Implement visible-row bulk selection and selected total UI
|
||||||
|
4. Run focused tests/build and update acceptance criteria
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Added failing Vitest coverage for visible bulk selection and selected totals, then implemented transactionsSelection helpers and wired TransactionsPage header checkbox plus selected count/sum. Focused test and build pass. Global lint currently fails on pre-existing unrelated lint errors in Convex/generated/UI files.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Closed as Done after explicit user confirmation to mark all tasks done.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
id: TASK-5
|
||||||
|
title: Make transaction filters combinable
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-06-15 19:32'
|
||||||
|
updated_date: '2026-06-15 19:54'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
priority: high
|
||||||
|
ordinal: 5000
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Fix the transactions page so global timeframe, account, and month-basis filters are applied together with page-level filters like search, type, category, and pending-only.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Changing the date range filters the transactions list
|
||||||
|
- [x] #2 Account, month basis, search, type, category, and pending-only filters combine instead of overriding each other
|
||||||
|
- [x] #3 Automated tests cover combined transaction filtering
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Trace current filter data flow from shared controls to transactions query
|
||||||
|
2. Add failing Convex test for combined date/account/category/search filtering
|
||||||
|
3. Extend transactions list query args and wire TransactionsPage to shared filters
|
||||||
|
4. Run focused tests, full tests/build, and update task notes
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Root cause: TransactionsPage did not pass global from/to/account/monthBasis filters into api.transactions.list, and transactions.list ignored date filters on the search-index path. Added red Convex tests covering combined filters and effective-month basis, then added basis/date filtering in the query and wired global filters from the page. Focused tests and build pass.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Closed as Done after explicit user confirmation to mark all tasks done.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
id: TASK-6
|
||||||
|
title: Add transaction filter reset button
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-06-15 19:35'
|
||||||
|
updated_date: '2026-06-15 19:54'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
priority: medium
|
||||||
|
ordinal: 6000
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Add a reset button on the transactions page that clears the active filter combination and returns global plus page-level transaction filters to their defaults.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 A reset button is available on the transactions page filter bar
|
||||||
|
- [x] #2 Clicking reset clears search, type, category, account, pending-only, and row selection
|
||||||
|
- [x] #3 Clicking reset returns date range and month basis to their defaults
|
||||||
|
- [x] #4 Automated tests cover the reset defaults
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Added a transactions toolbar reset button using shared reset defaults. The button clears global filters (current month, all accounts, no categories, effective month basis), clears page filters (search, type, pending-only), and clears row selection. Added Vitest coverage for reset defaults. Focused tests and build pass.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Closed as Done after explicit user confirmation to mark all tasks done.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
id: TASK-7
|
||||||
|
title: Add all read-only savings agent insight tools
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-06-15 19:39'
|
||||||
|
updated_date: '2026-06-15 19:54'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
priority: high
|
||||||
|
ordinal: 7000
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Expand Talk to Savings with eight additional read-only AI SDK tools for accounts, categories, recurring transactions, anomalies, uncategorized transactions, period comparison, fixed-cost forecasting, and savings-rate explanations.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Agent registers all eight new read-only tools and returns compact trace summaries without raw/private fields
|
||||||
|
- [x] #2 Metadata and data-quality tools return scoped, sanitized accounts, categories, and uncategorized transaction insight
|
||||||
|
- [x] #3 Period, savings-rate, recurring, fixed-cost forecast, and anomaly tools compute deterministic aggregates
|
||||||
|
- [x] #4 Convex tests cover each new tool and mocked ask() registration
|
||||||
|
- [x] #5 Focused tests, targeted lint, build, and full lint status are documented
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Add failing coverage for all eight read-only savings insight tools and ask() registry
|
||||||
|
2. Implement scoped/sanitized internal Convex queries and deterministic finance helpers
|
||||||
|
3. Register the tools in savingsChat.ask and add compact trace summaries
|
||||||
|
4. Verify focused tests, targeted lint, build, and document full-lint blockers
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Implemented eight new read-only tools in convex/savingsChat.ts: accounts, categories, recurring patterns, anomalies, uncategorized transactions, period comparison, fixed-cost forecast, and savings-rate explanation.
|
||||||
|
Verification: npx vitest convex/savingsChat.test.ts --run --reporter=dot passed (17 tests).
|
||||||
|
Verification: npx eslint convex/savingsChat.ts convex/savingsChat.test.ts src/pages/SavingsChatPage.tsx passed.
|
||||||
|
Verification: npm run build passed with existing Vite chunk-size warning.
|
||||||
|
Full npm run lint still fails on unrelated existing files: convex/bank/comdirectProvider.ts, convex/bank/config.ts, src/components/import/TanAwaitDialog.tsx, several react-refresh only-export-components files, and src/pages/SettingsPage.tsx; warnings also remain in generated Convex files and React Hook Form/TanStack Table locations.
|
||||||
|
|
||||||
|
Hardened compare_periods snapshots to omit category IDs while keeping existing summarize_spending output compatible. Re-ran focused Vitest, targeted ESLint, and build successfully after this change.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Closed as Done after explicit user confirmation to mark all tasks done. Implementation and verification notes are already recorded on the task.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
47
backlog/tasks/task-8 - Fix-savings-tool-category-matching.md
Normal file
47
backlog/tasks/task-8 - Fix-savings-tool-category-matching.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
id: TASK-8
|
||||||
|
title: Fix savings tool category matching
|
||||||
|
status: In Progress
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-06-15 20:10'
|
||||||
|
updated_date: '2026-06-15 20:19'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
priority: high
|
||||||
|
ordinal: 8000
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Make Talk to Savings read-only tools resolve user/model category names like Supermärkte or Lebensmittel und Supermarkt to existing categories such as Lebensmittel & Supermarkt, and surface diagnostics instead of misleading silent zero results.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Category filter aliases resolve ampersand/und, casing, punctuation, umlauts, and simple German plural/flexion variants
|
||||||
|
- [x] #2 All savings tools that accept categoryNames use shared resolution diagnostics
|
||||||
|
- [x] #3 Unknown or ambiguous category filters return diagnostics and compact trace summaries instead of silently confident zero results
|
||||||
|
- [x] #4 Regression tests cover Lebensmittel und Supermarkt, Supermärkte, unknown diagnostics, and trace summaries
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Add failing Convex regression tests for tolerant categoryNames and diagnostics
|
||||||
|
2. Implement shared category-name normalization and resolver in convex/savingsChat.ts
|
||||||
|
3. Thread diagnostics through every categoryNames-aware tool output and compact traces
|
||||||
|
4. Update tool/prompt guidance to avoid confident zero answers on unresolved filters
|
||||||
|
5. Run focused Vitest, targeted ESLint, and production build
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Implemented shared tolerant category resolver for savings tools and added diagnostics to categoryNames-aware outputs.
|
||||||
|
Verification passed: npx vitest convex/savingsChat.test.ts --run (20 tests), npx eslint convex/savingsChat.ts convex/savingsChat.test.ts, npm run build (with existing Vite chunk-size warning).
|
||||||
|
|
||||||
|
Added an additional regression test to preserve the virtual Ohne Kategorie filter while moving categoryNames to resolver-based category IDs. Re-ran focused Vitest, targeted ESLint, and build successfully after this adjustment.
|
||||||
|
|
||||||
|
Addressed QA review findings: added ASCII transliteration coverage for Supermaerkte, explicit ambiguous category filter coverage, and multi-diagnostic compact trace summaries. Final verification passed: npx vitest convex/savingsChat.test.ts --run (22 tests), npx eslint convex/savingsChat.ts convex/savingsChat.test.ts, npm run build (with existing Vite chunk-size warning).
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
id: TASK-9
|
||||||
|
title: Add Convex-synced savings chat history
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-06-16 07:58'
|
||||||
|
updated_date: '2026-06-16 08:23'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
priority: high
|
||||||
|
ordinal: 9000
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Persist savings chat sessions and messages in Convex per authenticated user so chat history syncs across devices, with automatic one-time import of existing localStorage chats.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Chat sessions and messages are stored in Convex per authenticated user and synchronize across devices
|
||||||
|
- [x] #2 Existing localStorage savings chats are imported once, deduplicated by legacy local id, and old local data is not deleted
|
||||||
|
- [x] #3 Sending a chat message stores both user and assistant messages, including assistant tool traces
|
||||||
|
- [x] #4 Deleting a chat hides it account-wide and unauthorized users cannot read or mutate another user's chats
|
||||||
|
- [x] #5 Focused Convex tests, focused ESLint, and build verification are run and recorded
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Add failing Convex tests for user-scoped chat history, legacy import, sendMessage persistence, and deletion
|
||||||
|
2. Add chatSessions/chatMessages schema tables
|
||||||
|
3. Implement savingsChatHistory queries and mutations
|
||||||
|
4. Refactor savingsChat AI generation helper and add sendMessage action
|
||||||
|
5. Move SavingsChatPage to Convex-backed state and automatic legacy import
|
||||||
|
6. Run focused tests, focused lint, build, and record verification notes
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Implementation completed on branch codex/convex-chat-history-sync.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
- RED verified first: npx vitest convex/savingsChat.test.ts --run failed with 3 missing chat-history/sendMessage tests.
|
||||||
|
- PASS npx vitest convex/savingsChat.test.ts --run (25 tests).
|
||||||
|
- PASS npx eslint convex/schema.ts convex/savingsChat.ts convex/savingsChatHistory.ts convex/savingsChat.test.ts src/pages/SavingsChatPage.tsx src/components/chat/ChatHistory.tsx.
|
||||||
|
- PASS npm run build (Vite chunk-size warning remains).
|
||||||
|
- FULL LINT 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, src/main.tsx, src/pages/SettingsPage.tsx, plus existing React Compiler warnings. No changed feature file appears in the full-lint error list.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Ran npx convex codegen; first sandboxed attempt failed on DNS/fetch to Sentry, rerun outside sandbox succeeded and updated convex/_generated/api.d.ts.
|
||||||
|
- Backlog task remains In Progress pending explicit user confirmation after manual testing.
|
||||||
|
|
||||||
|
Review follow-up:
|
||||||
|
- Addressed code-review finding: legacy import marker is now scoped by current Convex user id instead of only browser-global.
|
||||||
|
- Addressed code-review finding: sendMessage now persists an assistant failure message if AI generation fails after saving the user message.
|
||||||
|
- Added regression coverage for unauthorized listMessages, unauthorized sendMessage, and failed-generation persistence.
|
||||||
|
|
||||||
|
Final verification after review fixes:
|
||||||
|
- PASS npx vitest convex/savingsChat.test.ts --run (26 tests).
|
||||||
|
- PASS npx eslint convex/schema.ts convex/savingsChat.ts convex/savingsChatHistory.ts convex/savingsChat.test.ts src/pages/SavingsChatPage.tsx src/components/chat/ChatHistory.tsx.
|
||||||
|
- PASS npm run build (Vite chunk-size warning remains).
|
||||||
|
- FULL LINT npm run lint still fails only on pre-existing unrelated files and generated-file warnings; no changed feature file is in the full-lint error list.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Shipped Convex-backed savings chat history sync with user-scoped sessions/messages, automatic per-user legacy localStorage import, server-persisted sendMessage flow, account-wide delete behavior, authorization coverage, and focused verification. Full lint still has unrelated pre-existing failures documented in notes.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
2
convex/_generated/api.d.ts
vendored
2
convex/_generated/api.d.ts
vendored
@@ -35,6 +35,7 @@ import type * as lib_month from "../lib/month.js";
|
|||||||
import type * as lib_seedCategories from "../lib/seedCategories.js";
|
import type * as lib_seedCategories from "../lib/seedCategories.js";
|
||||||
import type * as loans from "../loans.js";
|
import type * as loans from "../loans.js";
|
||||||
import type * as savingsChat from "../savingsChat.js";
|
import type * as savingsChat from "../savingsChat.js";
|
||||||
|
import type * as savingsChatHistory from "../savingsChatHistory.js";
|
||||||
import type * as settings from "../settings.js";
|
import type * as settings from "../settings.js";
|
||||||
import type * as transactions from "../transactions.js";
|
import type * as transactions from "../transactions.js";
|
||||||
import type * as users from "../users.js";
|
import type * as users from "../users.js";
|
||||||
@@ -73,6 +74,7 @@ declare const fullApi: ApiFromModules<{
|
|||||||
"lib/seedCategories": typeof lib_seedCategories;
|
"lib/seedCategories": typeof lib_seedCategories;
|
||||||
loans: typeof loans;
|
loans: typeof loans;
|
||||||
savingsChat: typeof savingsChat;
|
savingsChat: typeof savingsChat;
|
||||||
|
savingsChatHistory: typeof savingsChatHistory;
|
||||||
settings: typeof settings;
|
settings: typeof settings;
|
||||||
transactions: typeof transactions;
|
transactions: typeof transactions;
|
||||||
users: typeof users;
|
users: typeof users;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
266
convex/savingsChatHistory.ts
Normal file
266
convex/savingsChatHistory.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import { paginationOptsValidator, paginationResultValidator } from "convex/server";
|
||||||
|
import { v } from "convex/values";
|
||||||
|
import { internalMutation, internalQuery, mutation, query } from "./_generated/server";
|
||||||
|
import type { Id } from "./_generated/dataModel";
|
||||||
|
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||||
|
import { assertOwned, requireUserId } from "./lib/helpers";
|
||||||
|
|
||||||
|
const initialAssistantMessage =
|
||||||
|
"Frag mich zu deinen Umsätzen – ich werte sie im aktuellen Zeitraum aus.";
|
||||||
|
|
||||||
|
const toolTraceValidator = v.object({
|
||||||
|
name: v.string(),
|
||||||
|
inputSummary: v.string(),
|
||||||
|
resultSummary: v.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const chatRoleValidator = v.union(v.literal("user"), v.literal("assistant"));
|
||||||
|
|
||||||
|
const importMessageValidator = v.object({
|
||||||
|
role: chatRoleValidator,
|
||||||
|
content: v.string(),
|
||||||
|
toolTrace: v.optional(v.array(toolTraceValidator)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionValidator = v.object({
|
||||||
|
_id: v.id("chatSessions"),
|
||||||
|
_creationTime: v.number(),
|
||||||
|
userId: v.id("users"),
|
||||||
|
title: v.string(),
|
||||||
|
createdAt: v.number(),
|
||||||
|
updatedAt: v.number(),
|
||||||
|
messageCount: v.number(),
|
||||||
|
legacyLocalId: v.optional(v.string()),
|
||||||
|
isDeleted: v.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const messageValidator = v.object({
|
||||||
|
_id: v.id("chatMessages"),
|
||||||
|
_creationTime: v.number(),
|
||||||
|
userId: v.id("users"),
|
||||||
|
sessionId: v.id("chatSessions"),
|
||||||
|
role: chatRoleValidator,
|
||||||
|
content: v.string(),
|
||||||
|
createdAt: v.number(),
|
||||||
|
toolTrace: v.optional(v.array(toolTraceValidator)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const promptMessageValidator = v.object({
|
||||||
|
role: chatRoleValidator,
|
||||||
|
content: v.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function normalizeTitle(title: string | undefined) {
|
||||||
|
const trimmed = title?.trim();
|
||||||
|
return trimmed ? trimmed : "Neuer Chat";
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleFromContent(content: string) {
|
||||||
|
const trimmed = content.trim();
|
||||||
|
if (!trimmed) return "Neuer Chat";
|
||||||
|
return trimmed.length > 44 ? `${trimmed.slice(0, 44)}…` : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireOwnedSession(
|
||||||
|
ctx: QueryCtx | MutationCtx,
|
||||||
|
sessionId: Id<"chatSessions">,
|
||||||
|
userId: Id<"users">,
|
||||||
|
) {
|
||||||
|
const session = await assertOwned(await ctx.db.get(sessionId), userId, "Chat");
|
||||||
|
if (session.isDeleted) throw new Error("Chat nicht gefunden");
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listSessions = query({
|
||||||
|
args: { paginationOpts: paginationOptsValidator },
|
||||||
|
returns: paginationResultValidator(sessionValidator),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const userId = await requireUserId(ctx);
|
||||||
|
return await ctx.db
|
||||||
|
.query("chatSessions")
|
||||||
|
.withIndex("by_user_deleted_updated", (index) =>
|
||||||
|
index.eq("userId", userId).eq("isDeleted", false),
|
||||||
|
)
|
||||||
|
.order("desc")
|
||||||
|
.paginate(args.paginationOpts);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listMessages = query({
|
||||||
|
args: {
|
||||||
|
sessionId: v.id("chatSessions"),
|
||||||
|
paginationOpts: paginationOptsValidator,
|
||||||
|
},
|
||||||
|
returns: paginationResultValidator(messageValidator),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const userId = await requireUserId(ctx);
|
||||||
|
await requireOwnedSession(ctx, args.sessionId, userId);
|
||||||
|
return await ctx.db
|
||||||
|
.query("chatMessages")
|
||||||
|
.withIndex("by_user_session_created", (index) =>
|
||||||
|
index.eq("userId", userId).eq("sessionId", args.sessionId),
|
||||||
|
)
|
||||||
|
.order("desc")
|
||||||
|
.paginate(args.paginationOpts);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createSession = mutation({
|
||||||
|
args: { title: v.optional(v.string()) },
|
||||||
|
returns: v.object({ sessionId: v.id("chatSessions") }),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const userId = await requireUserId(ctx);
|
||||||
|
const now = Date.now();
|
||||||
|
const sessionId = await ctx.db.insert("chatSessions", {
|
||||||
|
userId,
|
||||||
|
title: normalizeTitle(args.title),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
messageCount: 1,
|
||||||
|
isDeleted: false,
|
||||||
|
});
|
||||||
|
await ctx.db.insert("chatMessages", {
|
||||||
|
userId,
|
||||||
|
sessionId,
|
||||||
|
role: "assistant",
|
||||||
|
content: initialAssistantMessage,
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
return { sessionId };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteSession = mutation({
|
||||||
|
args: { sessionId: v.id("chatSessions") },
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const userId = await requireUserId(ctx);
|
||||||
|
await requireOwnedSession(ctx, args.sessionId, userId);
|
||||||
|
await ctx.db.patch(args.sessionId, {
|
||||||
|
isDeleted: true,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const importLocalSession = mutation({
|
||||||
|
args: {
|
||||||
|
legacyLocalId: v.string(),
|
||||||
|
title: v.string(),
|
||||||
|
createdAt: v.number(),
|
||||||
|
updatedAt: v.number(),
|
||||||
|
messages: v.array(importMessageValidator),
|
||||||
|
},
|
||||||
|
returns: v.object({ sessionId: v.id("chatSessions") }),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const userId = await requireUserId(ctx);
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query("chatSessions")
|
||||||
|
.withIndex("by_user_legacyLocalId", (index) =>
|
||||||
|
index.eq("userId", userId).eq("legacyLocalId", args.legacyLocalId),
|
||||||
|
)
|
||||||
|
.unique();
|
||||||
|
if (existing) return { sessionId: existing._id };
|
||||||
|
|
||||||
|
const sessionId = await ctx.db.insert("chatSessions", {
|
||||||
|
userId,
|
||||||
|
title: normalizeTitle(args.title),
|
||||||
|
createdAt: args.createdAt,
|
||||||
|
updatedAt: args.updatedAt,
|
||||||
|
messageCount: args.messages.length,
|
||||||
|
legacyLocalId: args.legacyLocalId,
|
||||||
|
isDeleted: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [index, message] of args.messages.entries()) {
|
||||||
|
await ctx.db.insert("chatMessages", {
|
||||||
|
userId,
|
||||||
|
sessionId,
|
||||||
|
role: message.role,
|
||||||
|
content: message.content,
|
||||||
|
createdAt: args.createdAt + index,
|
||||||
|
...(message.toolTrace ? { toolTrace: message.toolTrace } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sessionId };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const appendUserMessage = internalMutation({
|
||||||
|
args: {
|
||||||
|
sessionId: v.id("chatSessions"),
|
||||||
|
content: v.string(),
|
||||||
|
},
|
||||||
|
returns: v.object({ messageId: v.id("chatMessages") }),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const userId = await requireUserId(ctx);
|
||||||
|
const session = await requireOwnedSession(ctx, args.sessionId, userId);
|
||||||
|
const now = Date.now();
|
||||||
|
const messageId = await ctx.db.insert("chatMessages", {
|
||||||
|
userId,
|
||||||
|
sessionId: args.sessionId,
|
||||||
|
role: "user",
|
||||||
|
content: args.content,
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
await ctx.db.patch(args.sessionId, {
|
||||||
|
title: session.title === "Neuer Chat" ? titleFromContent(args.content) : session.title,
|
||||||
|
updatedAt: now,
|
||||||
|
messageCount: session.messageCount + 1,
|
||||||
|
});
|
||||||
|
return { messageId };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const appendAssistantMessage = internalMutation({
|
||||||
|
args: {
|
||||||
|
sessionId: v.id("chatSessions"),
|
||||||
|
content: v.string(),
|
||||||
|
toolTrace: v.optional(v.array(toolTraceValidator)),
|
||||||
|
},
|
||||||
|
returns: v.object({ messageId: v.id("chatMessages") }),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const userId = await requireUserId(ctx);
|
||||||
|
const session = await requireOwnedSession(ctx, args.sessionId, userId);
|
||||||
|
const now = Date.now();
|
||||||
|
const messageId = await ctx.db.insert("chatMessages", {
|
||||||
|
userId,
|
||||||
|
sessionId: args.sessionId,
|
||||||
|
role: "assistant",
|
||||||
|
content: args.content,
|
||||||
|
createdAt: now,
|
||||||
|
...(args.toolTrace ? { toolTrace: args.toolTrace } : {}),
|
||||||
|
});
|
||||||
|
await ctx.db.patch(args.sessionId, {
|
||||||
|
updatedAt: now,
|
||||||
|
messageCount: session.messageCount + 1,
|
||||||
|
});
|
||||||
|
return { messageId };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getRecentMessagesForPrompt = internalQuery({
|
||||||
|
args: {
|
||||||
|
sessionId: v.id("chatSessions"),
|
||||||
|
limit: v.number(),
|
||||||
|
},
|
||||||
|
returns: v.array(promptMessageValidator),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const userId = await requireUserId(ctx);
|
||||||
|
await requireOwnedSession(ctx, args.sessionId, userId);
|
||||||
|
const limit = Math.max(1, Math.min(50, Math.floor(args.limit)));
|
||||||
|
const messages = await ctx.db
|
||||||
|
.query("chatMessages")
|
||||||
|
.withIndex("by_user_session_created", (index) =>
|
||||||
|
index.eq("userId", userId).eq("sessionId", args.sessionId),
|
||||||
|
)
|
||||||
|
.order("desc")
|
||||||
|
.take(limit);
|
||||||
|
return messages.reverse().map((message) => ({
|
||||||
|
role: message.role,
|
||||||
|
content: message.content,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -9,6 +9,12 @@ const loanStatus = v.union(
|
|||||||
v.literal("abbezahlt"),
|
v.literal("abbezahlt"),
|
||||||
v.literal("pausiert"),
|
v.literal("pausiert"),
|
||||||
);
|
);
|
||||||
|
const chatRole = v.union(v.literal("user"), v.literal("assistant"));
|
||||||
|
const chatToolTrace = v.object({
|
||||||
|
name: v.string(),
|
||||||
|
inputSummary: v.string(),
|
||||||
|
resultSummary: v.string(),
|
||||||
|
});
|
||||||
|
|
||||||
export default defineSchema({
|
export default defineSchema({
|
||||||
...authTables,
|
...authTables,
|
||||||
@@ -175,4 +181,25 @@ export default defineSchema({
|
|||||||
isDecoupled: v.optional(v.boolean()),
|
isDecoupled: v.optional(v.boolean()),
|
||||||
submittedTan: v.optional(v.string()),
|
submittedTan: v.optional(v.string()),
|
||||||
}).index("by_user", ["userId"]),
|
}).index("by_user", ["userId"]),
|
||||||
|
|
||||||
|
chatSessions: defineTable({
|
||||||
|
userId: v.id("users"),
|
||||||
|
title: v.string(),
|
||||||
|
createdAt: v.number(),
|
||||||
|
updatedAt: v.number(),
|
||||||
|
messageCount: v.number(),
|
||||||
|
legacyLocalId: v.optional(v.string()),
|
||||||
|
isDeleted: v.boolean(),
|
||||||
|
})
|
||||||
|
.index("by_user_deleted_updated", ["userId", "isDeleted", "updatedAt"])
|
||||||
|
.index("by_user_legacyLocalId", ["userId", "legacyLocalId"]),
|
||||||
|
|
||||||
|
chatMessages: defineTable({
|
||||||
|
userId: v.id("users"),
|
||||||
|
sessionId: v.id("chatSessions"),
|
||||||
|
role: chatRole,
|
||||||
|
content: v.string(),
|
||||||
|
createdAt: v.number(),
|
||||||
|
toolTrace: v.optional(v.array(chatToolTrace)),
|
||||||
|
}).index("by_user_session_created", ["userId", "sessionId", "createdAt"]),
|
||||||
});
|
});
|
||||||
|
|||||||
189
convex/transactions.test.ts
Normal file
189
convex/transactions.test.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
import { convexTest } from "convex-test";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { api } from "./_generated/api";
|
||||||
|
import type { Id } from "./_generated/dataModel";
|
||||||
|
import schema from "./schema";
|
||||||
|
|
||||||
|
const modules = import.meta.glob("./**/*.ts");
|
||||||
|
delete modules["./transactions.test.ts"];
|
||||||
|
|
||||||
|
describe("transactions.list", () => {
|
||||||
|
test("combines search, date range, account, category, and type filters", async () => {
|
||||||
|
const t = convexTest(schema, modules);
|
||||||
|
|
||||||
|
const seeded = await t.run(async (ctx) => {
|
||||||
|
const userId = await ctx.db.insert("users", {
|
||||||
|
name: "Filter User",
|
||||||
|
email: "filter@example.com",
|
||||||
|
});
|
||||||
|
const giroAccountId = await ctx.db.insert("accounts", {
|
||||||
|
userId,
|
||||||
|
name: "Girokonto",
|
||||||
|
type: "checking",
|
||||||
|
openingBalance: 0,
|
||||||
|
currency: "EUR",
|
||||||
|
isArchived: false,
|
||||||
|
});
|
||||||
|
const otherAccountId = await ctx.db.insert("accounts", {
|
||||||
|
userId,
|
||||||
|
name: "Depot",
|
||||||
|
type: "investment",
|
||||||
|
openingBalance: 0,
|
||||||
|
currency: "EUR",
|
||||||
|
isArchived: false,
|
||||||
|
});
|
||||||
|
const groceryCategoryId = await ctx.db.insert("categories", {
|
||||||
|
userId,
|
||||||
|
name: "Lebensmittel & Supermarkt",
|
||||||
|
kind: "ausgabe",
|
||||||
|
block: "variabel",
|
||||||
|
color: "#ef4444",
|
||||||
|
sortOrder: 1,
|
||||||
|
isSystem: false,
|
||||||
|
});
|
||||||
|
const restaurantCategoryId = await ctx.db.insert("categories", {
|
||||||
|
userId,
|
||||||
|
name: "Restaurant",
|
||||||
|
kind: "ausgabe",
|
||||||
|
block: "variabel",
|
||||||
|
color: "#f97316",
|
||||||
|
sortOrder: 2,
|
||||||
|
isSystem: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const matchingId = await ctx.db.insert("transactions", {
|
||||||
|
userId,
|
||||||
|
accountId: giroAccountId,
|
||||||
|
categoryId: groceryCategoryId,
|
||||||
|
bookingDate: "2026-06-15",
|
||||||
|
description: "LIDL SAGT DANKE",
|
||||||
|
amount: -18.37,
|
||||||
|
isPending: false,
|
||||||
|
effectiveMonth: "2026-06",
|
||||||
|
});
|
||||||
|
await ctx.db.insert("transactions", {
|
||||||
|
userId,
|
||||||
|
accountId: giroAccountId,
|
||||||
|
categoryId: groceryCategoryId,
|
||||||
|
bookingDate: "2026-05-29",
|
||||||
|
description: "LIDL OLD MONTH",
|
||||||
|
amount: -21.92,
|
||||||
|
isPending: false,
|
||||||
|
effectiveMonth: "2026-05",
|
||||||
|
});
|
||||||
|
await ctx.db.insert("transactions", {
|
||||||
|
userId,
|
||||||
|
accountId: otherAccountId,
|
||||||
|
categoryId: groceryCategoryId,
|
||||||
|
bookingDate: "2026-06-16",
|
||||||
|
description: "LIDL OTHER ACCOUNT",
|
||||||
|
amount: -99,
|
||||||
|
isPending: false,
|
||||||
|
effectiveMonth: "2026-06",
|
||||||
|
});
|
||||||
|
await ctx.db.insert("transactions", {
|
||||||
|
userId,
|
||||||
|
accountId: giroAccountId,
|
||||||
|
categoryId: restaurantCategoryId,
|
||||||
|
bookingDate: "2026-06-17",
|
||||||
|
description: "LIDL RESTAURANT",
|
||||||
|
amount: -12,
|
||||||
|
isPending: false,
|
||||||
|
effectiveMonth: "2026-06",
|
||||||
|
});
|
||||||
|
await ctx.db.insert("transactions", {
|
||||||
|
userId,
|
||||||
|
accountId: giroAccountId,
|
||||||
|
categoryId: groceryCategoryId,
|
||||||
|
bookingDate: "2026-06-18",
|
||||||
|
description: "LIDL REFUND",
|
||||||
|
amount: 5,
|
||||||
|
isPending: false,
|
||||||
|
effectiveMonth: "2026-06",
|
||||||
|
});
|
||||||
|
|
||||||
|
return { userId, giroAccountId, groceryCategoryId, matchingId };
|
||||||
|
});
|
||||||
|
|
||||||
|
const asUser = t.withIdentity({
|
||||||
|
subject: `${seeded.userId}|test-session`,
|
||||||
|
tokenIdentifier: `test:${seeded.userId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await asUser.query(api.transactions.list, {
|
||||||
|
paginationOpts: { cursor: null, numItems: 20 },
|
||||||
|
search: "LIDL",
|
||||||
|
from: "2026-06-01",
|
||||||
|
to: "2026-06-30",
|
||||||
|
accountId: seeded.giroAccountId as Id<"accounts">,
|
||||||
|
categoryIds: [seeded.groceryCategoryId as Id<"categories">],
|
||||||
|
type: "ausgabe",
|
||||||
|
basis: "booking",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.page.map((tx) => tx._id)).toEqual([seeded.matchingId]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filters by effective month when the global basis is assignment month", async () => {
|
||||||
|
const t = convexTest(schema, modules);
|
||||||
|
|
||||||
|
const seeded = await t.run(async (ctx) => {
|
||||||
|
const userId = await ctx.db.insert("users", {
|
||||||
|
name: "Basis User",
|
||||||
|
email: "basis@example.com",
|
||||||
|
});
|
||||||
|
const accountId = await ctx.db.insert("accounts", {
|
||||||
|
userId,
|
||||||
|
name: "Girokonto",
|
||||||
|
type: "checking",
|
||||||
|
openingBalance: 0,
|
||||||
|
currency: "EUR",
|
||||||
|
isArchived: false,
|
||||||
|
});
|
||||||
|
const shiftedId = await ctx.db.insert("transactions", {
|
||||||
|
userId,
|
||||||
|
accountId,
|
||||||
|
bookingDate: "2026-05-29",
|
||||||
|
description: "Salary shifted into June",
|
||||||
|
amount: 2500,
|
||||||
|
isPending: false,
|
||||||
|
assignedMonth: "2026-06",
|
||||||
|
effectiveMonth: "2026-06",
|
||||||
|
});
|
||||||
|
await ctx.db.insert("transactions", {
|
||||||
|
userId,
|
||||||
|
accountId,
|
||||||
|
bookingDate: "2026-05-20",
|
||||||
|
description: "May only",
|
||||||
|
amount: -15,
|
||||||
|
isPending: false,
|
||||||
|
effectiveMonth: "2026-05",
|
||||||
|
});
|
||||||
|
|
||||||
|
return { userId, shiftedId };
|
||||||
|
});
|
||||||
|
|
||||||
|
const asUser = t.withIdentity({
|
||||||
|
subject: `${seeded.userId}|test-session`,
|
||||||
|
tokenIdentifier: `test:${seeded.userId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const effectiveResult = await asUser.query(api.transactions.list, {
|
||||||
|
paginationOpts: { cursor: null, numItems: 20 },
|
||||||
|
from: "2026-06-01",
|
||||||
|
to: "2026-06-30",
|
||||||
|
basis: "effective",
|
||||||
|
});
|
||||||
|
const bookingResult = await asUser.query(api.transactions.list, {
|
||||||
|
paginationOpts: { cursor: null, numItems: 20 },
|
||||||
|
from: "2026-06-01",
|
||||||
|
to: "2026-06-30",
|
||||||
|
basis: "booking",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(effectiveResult.page.map((tx) => tx._id)).toEqual([seeded.shiftedId]);
|
||||||
|
expect(bookingResult.page).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -41,6 +41,7 @@ export const list = query({
|
|||||||
categoryIds: v.optional(v.array(v.id("categories"))),
|
categoryIds: v.optional(v.array(v.id("categories"))),
|
||||||
withoutCategory: v.optional(v.boolean()),
|
withoutCategory: v.optional(v.boolean()),
|
||||||
accountId: v.optional(v.id("accounts")),
|
accountId: v.optional(v.id("accounts")),
|
||||||
|
basis: v.optional(v.union(v.literal("effective"), v.literal("booking"))),
|
||||||
type: v.optional(v.union(v.literal("einnahme"), v.literal("ausgabe"))),
|
type: v.optional(v.union(v.literal("einnahme"), v.literal("ausgabe"))),
|
||||||
pendingOnly: v.optional(v.boolean()),
|
pendingOnly: v.optional(v.boolean()),
|
||||||
},
|
},
|
||||||
@@ -51,6 +52,9 @@ export const list = query({
|
|||||||
}),
|
}),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const userId = await requireUserId(ctx);
|
const userId = await requireUserId(ctx);
|
||||||
|
const basis = args.basis ?? "booking";
|
||||||
|
const fromMonth = args.from?.slice(0, 7);
|
||||||
|
const toMonth = args.to?.slice(0, 7);
|
||||||
|
|
||||||
let q;
|
let q;
|
||||||
if (args.search) {
|
if (args.search) {
|
||||||
@@ -60,21 +64,77 @@ export const list = query({
|
|||||||
sq.search("description", args.search!).eq("userId", userId),
|
sq.search("description", args.search!).eq("userId", userId),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
q = ctx.db
|
if (basis === "effective") {
|
||||||
.query("transactions")
|
q = ctx.db
|
||||||
.withIndex("by_user_booking", (iq) => {
|
.query("transactions")
|
||||||
if (args.from && args.to) {
|
.withIndex("by_user_effmonth", (iq) => {
|
||||||
return iq.eq("userId", userId).gte("bookingDate", args.from).lte("bookingDate", args.to);
|
if (fromMonth && toMonth) {
|
||||||
}
|
return iq.eq("userId", userId).gte("effectiveMonth", fromMonth).lte("effectiveMonth", toMonth);
|
||||||
if (args.from) {
|
}
|
||||||
return iq.eq("userId", userId).gte("bookingDate", args.from);
|
if (fromMonth) {
|
||||||
}
|
return iq.eq("userId", userId).gte("effectiveMonth", fromMonth);
|
||||||
if (args.to) {
|
}
|
||||||
return iq.eq("userId", userId).lte("bookingDate", args.to);
|
if (toMonth) {
|
||||||
}
|
return iq.eq("userId", userId).lte("effectiveMonth", toMonth);
|
||||||
return iq.eq("userId", userId);
|
}
|
||||||
})
|
return iq.eq("userId", userId);
|
||||||
.order("desc");
|
})
|
||||||
|
.order("desc");
|
||||||
|
} else {
|
||||||
|
q = ctx.db
|
||||||
|
.query("transactions")
|
||||||
|
.withIndex("by_user_booking", (iq) => {
|
||||||
|
if (args.from && args.to) {
|
||||||
|
return iq.eq("userId", userId).gte("bookingDate", args.from).lte("bookingDate", args.to);
|
||||||
|
}
|
||||||
|
if (args.from) {
|
||||||
|
return iq.eq("userId", userId).gte("bookingDate", args.from);
|
||||||
|
}
|
||||||
|
if (args.to) {
|
||||||
|
return iq.eq("userId", userId).lte("bookingDate", args.to);
|
||||||
|
}
|
||||||
|
return iq.eq("userId", userId);
|
||||||
|
})
|
||||||
|
.order("desc");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.search) {
|
||||||
|
if (basis === "effective") {
|
||||||
|
if (fromMonth) {
|
||||||
|
const fallbackFrom = `${fromMonth}-01`;
|
||||||
|
q = q.filter((f) =>
|
||||||
|
f.or(
|
||||||
|
f.gte(f.field("effectiveMonth"), fromMonth),
|
||||||
|
f.and(
|
||||||
|
f.eq(f.field("effectiveMonth"), undefined),
|
||||||
|
f.gte(f.field("bookingDate"), fallbackFrom),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (toMonth) {
|
||||||
|
const fallbackTo = `${toMonth}-31`;
|
||||||
|
q = q.filter((f) =>
|
||||||
|
f.or(
|
||||||
|
f.lte(f.field("effectiveMonth"), toMonth),
|
||||||
|
f.and(
|
||||||
|
f.eq(f.field("effectiveMonth"), undefined),
|
||||||
|
f.lte(f.field("bookingDate"), fallbackTo),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (args.from) {
|
||||||
|
const from = args.from;
|
||||||
|
q = q.filter((f) => f.gte(f.field("bookingDate"), from));
|
||||||
|
}
|
||||||
|
if (args.to) {
|
||||||
|
const to = args.to;
|
||||||
|
q = q.filter((f) => f.lte(f.field("bookingDate"), to));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.pendingOnly) {
|
if (args.pendingOnly) {
|
||||||
|
|||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -158,7 +158,7 @@ importers:
|
|||||||
version: 7.1.1(eslint@10.5.0(jiti@2.7.0))
|
version: 7.1.1(eslint@10.5.0(jiti@2.7.0))
|
||||||
eslint-plugin-react-refresh:
|
eslint-plugin-react-refresh:
|
||||||
specifier: ^0.5.2
|
specifier: ^0.5.2
|
||||||
version: 0.5.2(eslint@10.5.0(jiti@2.7.0))
|
version: 0.5.3(eslint@10.5.0(jiti@2.7.0))
|
||||||
globals:
|
globals:
|
||||||
specifier: ^17.6.0
|
specifier: ^17.6.0
|
||||||
version: 17.6.0
|
version: 17.6.0
|
||||||
@@ -1655,8 +1655,8 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0
|
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0
|
||||||
|
|
||||||
eslint-plugin-react-refresh@0.5.2:
|
eslint-plugin-react-refresh@0.5.3:
|
||||||
resolution: {integrity: sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==}
|
resolution: {integrity: sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
eslint: ^9 || ^10
|
eslint: ^9 || ^10
|
||||||
|
|
||||||
@@ -3828,7 +3828,7 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
eslint-plugin-react-refresh@0.5.2(eslint@10.5.0(jiti@2.7.0)):
|
eslint-plugin-react-refresh@0.5.3(eslint@10.5.0(jiti@2.7.0)):
|
||||||
dependencies:
|
dependencies:
|
||||||
eslint: 10.5.0(jiti@2.7.0)
|
eslint: 10.5.0(jiti@2.7.0)
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,11 @@
|
|||||||
allowBuilds:
|
allowBuilds:
|
||||||
esbuild: true
|
esbuild: true
|
||||||
|
minimumReleaseAgeExclude:
|
||||||
|
- '@vitest/expect@4.1.9'
|
||||||
|
- '@vitest/mocker@4.1.9'
|
||||||
|
- '@vitest/pretty-format@4.1.9'
|
||||||
|
- '@vitest/runner@4.1.9'
|
||||||
|
- '@vitest/snapshot@4.1.9'
|
||||||
|
- '@vitest/spy@4.1.9'
|
||||||
|
- '@vitest/utils@4.1.9'
|
||||||
|
- vitest@4.1.9
|
||||||
|
|||||||
29
src/lib/transactionFilterReset.ts
Normal file
29
src/lib/transactionFilterReset.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export const DEFAULT_TRANSACTION_FILTER_RESET = {
|
||||||
|
global: {
|
||||||
|
preset: "current-month",
|
||||||
|
accountId: undefined,
|
||||||
|
categoryIds: [],
|
||||||
|
monthBasis: "effective",
|
||||||
|
},
|
||||||
|
page: {
|
||||||
|
search: "",
|
||||||
|
type: "all",
|
||||||
|
pendingOnly: false,
|
||||||
|
rowSelection: {},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type TransactionFilterResetState = typeof DEFAULT_TRANSACTION_FILTER_RESET;
|
||||||
|
|
||||||
|
export function getResetTransactionFilterState(): TransactionFilterResetState {
|
||||||
|
return {
|
||||||
|
global: {
|
||||||
|
...DEFAULT_TRANSACTION_FILTER_RESET.global,
|
||||||
|
categoryIds: [...DEFAULT_TRANSACTION_FILTER_RESET.global.categoryIds],
|
||||||
|
},
|
||||||
|
page: {
|
||||||
|
...DEFAULT_TRANSACTION_FILTER_RESET.page,
|
||||||
|
rowSelection: { ...DEFAULT_TRANSACTION_FILTER_RESET.page.rowSelection },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { type FormEvent, useEffect, useRef, useState } from "react";
|
import { type FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useAction, useQuery } from "convex/react";
|
import { useAction, useMutation, usePaginatedQuery, useQuery } from "convex/react";
|
||||||
import { MessageCircle, Send, Loader2 } from "lucide-react";
|
import { MessageCircle, Send, Loader2 } from "lucide-react";
|
||||||
import { api } from "../../convex/_generated/api";
|
import { api } from "../../convex/_generated/api";
|
||||||
|
import type { Id } from "../../convex/_generated/dataModel";
|
||||||
import { useAccountFilterId } from "@/components/layout/AccountFilter";
|
import { useAccountFilterId } from "@/components/layout/AccountFilter";
|
||||||
import { useFilters } from "@/context/FilterContext";
|
import { useFilters } from "@/context/FilterContext";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -11,67 +12,157 @@ import { Separator } from "@/components/ui/separator";
|
|||||||
import { ChatHistory, type ChatHistoryItem } from "@/components/chat/ChatHistory";
|
import { ChatHistory, type ChatHistoryItem } from "@/components/chat/ChatHistory";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
type ChatMessage = { role: "user" | "assistant"; content: string };
|
type ToolTrace = {
|
||||||
type ChatSession = {
|
name: string;
|
||||||
|
inputSummary: string;
|
||||||
|
resultSummary: string;
|
||||||
|
};
|
||||||
|
type UserChatMessage = { role: "user"; content: string };
|
||||||
|
type AssistantChatMessage = {
|
||||||
|
role: "assistant";
|
||||||
|
content: string;
|
||||||
|
toolTrace?: ToolTrace[];
|
||||||
|
};
|
||||||
|
type ChatMessage = UserChatMessage | AssistantChatMessage;
|
||||||
|
type LegacyChatSession = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
};
|
};
|
||||||
|
type DisplayChatMessage = ChatMessage & { _id: string };
|
||||||
|
|
||||||
const STORAGE_KEY = "savings-chat-sessions";
|
const STORAGE_KEY = "savings-chat-sessions";
|
||||||
|
const IMPORTED_KEY = "savings-chat-sessions-imported-v1";
|
||||||
const initialAssistantMessage: ChatMessage = {
|
const initialAssistantMessage: ChatMessage = {
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: "Frag mich zu deinen Umsätzen – ich werte sie im aktuellen Zeitraum aus.",
|
content: "Frag mich zu deinen Umsätzen – ich werte sie im aktuellen Zeitraum aus.",
|
||||||
};
|
};
|
||||||
const fallbackMessages = [initialAssistantMessage];
|
const fallbackMessages: DisplayChatMessage[] = [{ ...initialAssistantMessage, _id: "fallback-0" }];
|
||||||
|
|
||||||
function createSession(): ChatSession {
|
function normalizeToolTrace(value: unknown): ToolTrace[] | undefined {
|
||||||
const now = Date.now();
|
if (!Array.isArray(value)) return undefined;
|
||||||
const randomId =
|
const trace = value.flatMap((item) => {
|
||||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
if (!item || typeof item !== "object") return [];
|
||||||
? crypto.randomUUID()
|
const candidate = item as Record<string, unknown>;
|
||||||
: `${now}-${Math.random().toString(36).slice(2)}`;
|
if (
|
||||||
|
typeof candidate.name !== "string" ||
|
||||||
|
typeof candidate.inputSummary !== "string" ||
|
||||||
|
typeof candidate.resultSummary !== "string"
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: candidate.name,
|
||||||
|
inputSummary: candidate.inputSummary,
|
||||||
|
resultSummary: candidate.resultSummary,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return trace.length > 0 ? trace : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMessage(value: unknown): ChatMessage | null {
|
||||||
|
if (!value || typeof value !== "object") return null;
|
||||||
|
const candidate = value as Record<string, unknown>;
|
||||||
|
if (typeof candidate.content !== "string") return null;
|
||||||
|
if (candidate.role === "user") {
|
||||||
|
return { role: "user", content: candidate.content };
|
||||||
|
}
|
||||||
|
if (candidate.role === "assistant") {
|
||||||
|
const toolTrace = normalizeToolTrace(candidate.toolTrace);
|
||||||
|
return toolTrace
|
||||||
|
? { role: "assistant", content: candidate.content, toolTrace }
|
||||||
|
: { role: "assistant", content: candidate.content };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isChatMessage(value: ChatMessage | null): value is ChatMessage {
|
||||||
|
return value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSession(value: unknown): LegacyChatSession | null {
|
||||||
|
if (!value || typeof value !== "object") return null;
|
||||||
|
const candidate = value as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
typeof candidate.id !== "string" ||
|
||||||
|
typeof candidate.title !== "string" ||
|
||||||
|
typeof candidate.createdAt !== "number" ||
|
||||||
|
typeof candidate.updatedAt !== "number" ||
|
||||||
|
!Array.isArray(candidate.messages)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = candidate.messages.map(normalizeMessage);
|
||||||
|
if (!messages.every(isChatMessage)) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: randomId,
|
id: candidate.id,
|
||||||
title: "Neuer Chat",
|
title: candidate.title,
|
||||||
createdAt: now,
|
createdAt: candidate.createdAt,
|
||||||
updatedAt: now,
|
updatedAt: candidate.updatedAt,
|
||||||
messages: [initialAssistantMessage],
|
messages,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadSessions(): ChatSession[] {
|
function loadLegacySessions(): LegacyChatSession[] {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
if (!raw) return [createSession()];
|
if (!raw) return [];
|
||||||
const parsed = JSON.parse(raw) as ChatSession[];
|
const parsed: unknown = JSON.parse(raw);
|
||||||
if (!Array.isArray(parsed) || parsed.length === 0) return [createSession()];
|
if (!Array.isArray(parsed)) return [];
|
||||||
return parsed;
|
return parsed
|
||||||
|
.map(normalizeSession)
|
||||||
|
.filter((session): session is LegacyChatSession => session !== null)
|
||||||
|
.filter((session) => session.messages.some((message) => message.role === "user"));
|
||||||
} catch {
|
} catch {
|
||||||
return [createSession()];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
export function SavingsChatPage() {
|
||||||
const { from, to, monthBasis } = useFilters();
|
const { from, to, monthBasis } = useFilters();
|
||||||
const accountId = useAccountFilterId();
|
const accountId = useAccountFilterId();
|
||||||
|
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [sessions, setSessions] = useState<ChatSession[]>(loadSessions);
|
const [selectedSessionId, setSelectedSessionId] = useState<Id<"chatSessions"> | undefined>();
|
||||||
const [activeSessionId, setActiveSessionId] = useState(() => sessions[0]?.id ?? "");
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [legacyImportResult, setLegacyImportResult] = useState<{
|
||||||
|
key: string;
|
||||||
|
importedCount: number;
|
||||||
|
} | null>(null);
|
||||||
|
const legacyImportStartedRef = useRef<string | undefined>(undefined);
|
||||||
|
const createInitialStartedRef = useRef(false);
|
||||||
const listRef = useRef<HTMLDivElement | null>(null);
|
const listRef = useRef<HTMLDivElement | null>(null);
|
||||||
const activeSession = sessions.find((session) => session.id === activeSessionId) ?? sessions[0];
|
|
||||||
const messages = activeSession?.messages ?? fallbackMessages;
|
const sessionsQuery = usePaginatedQuery(
|
||||||
|
api.savingsChatHistory.listSessions,
|
||||||
|
{},
|
||||||
|
{ initialNumItems: 50 },
|
||||||
|
);
|
||||||
|
const sessions = sessionsQuery.results;
|
||||||
|
const activeSessionId =
|
||||||
|
selectedSessionId && sessions.some((session) => session._id === selectedSessionId)
|
||||||
|
? selectedSessionId
|
||||||
|
: sessions[0]?._id;
|
||||||
|
const activeSession = sessions.find((session) => session._id === activeSessionId);
|
||||||
|
const messagesQuery = usePaginatedQuery(
|
||||||
|
api.savingsChatHistory.listMessages,
|
||||||
|
activeSessionId ? { sessionId: activeSessionId } : "skip",
|
||||||
|
{ initialNumItems: 100 },
|
||||||
|
);
|
||||||
|
const messages = useMemo(
|
||||||
|
() => [...messagesQuery.results].reverse() as DisplayChatMessage[],
|
||||||
|
[messagesQuery.results],
|
||||||
|
);
|
||||||
|
const displayMessages = activeSessionId && messages.length > 0 ? messages : fallbackMessages;
|
||||||
|
|
||||||
const context = useQuery(api.savingsChat.getContext, {
|
const context = useQuery(api.savingsChat.getContext, {
|
||||||
from,
|
from,
|
||||||
@@ -79,9 +170,23 @@ export function SavingsChatPage() {
|
|||||||
accountId,
|
accountId,
|
||||||
basis: monthBasis,
|
basis: monthBasis,
|
||||||
});
|
});
|
||||||
const ask = useAction(api.savingsChat.ask);
|
const currentUser = useQuery(api.users.currentUser);
|
||||||
|
const createSession = useMutation(api.savingsChatHistory.createSession);
|
||||||
|
const deleteSession = useMutation(api.savingsChatHistory.deleteSession);
|
||||||
|
const importLocalSession = useMutation(api.savingsChatHistory.importLocalSession);
|
||||||
|
const sendMessage = useAction(api.savingsChat.sendMessage);
|
||||||
|
const importMarkerKey = currentUser ? `${IMPORTED_KEY}:${currentUser._id}` : undefined;
|
||||||
|
const legacyImportComplete = Boolean(
|
||||||
|
importMarkerKey &&
|
||||||
|
(legacyImportResult?.key === importMarkerKey ||
|
||||||
|
localStorage.getItem(importMarkerKey) === "true"),
|
||||||
|
);
|
||||||
|
const legacyImportedCount =
|
||||||
|
legacyImportResult && legacyImportResult.key === importMarkerKey
|
||||||
|
? legacyImportResult.importedCount
|
||||||
|
: 0;
|
||||||
|
|
||||||
const buttonDisabled = isSubmitting || draft.trim().length === 0;
|
const buttonDisabled = isSubmitting || draft.trim().length === 0 || !activeSessionId;
|
||||||
|
|
||||||
const formatAmount = (amount: number) =>
|
const formatAmount = (amount: number) =>
|
||||||
new Intl.NumberFormat("de-DE", {
|
new Intl.NumberFormat("de-DE", {
|
||||||
@@ -98,85 +203,120 @@ export function SavingsChatPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listRef.current?.lastElementChild?.scrollIntoView({ behavior: "smooth" });
|
listRef.current?.lastElementChild?.scrollIntoView({ behavior: "smooth" });
|
||||||
}, [messages]);
|
}, [displayMessages.length, activeSessionId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions));
|
if (!importMarkerKey || legacyImportComplete || legacyImportStartedRef.current === importMarkerKey) return;
|
||||||
}, [sessions]);
|
legacyImportStartedRef.current = importMarkerKey;
|
||||||
|
const legacySessions = loadLegacySessions();
|
||||||
|
if (legacySessions.length === 0) {
|
||||||
|
localStorage.setItem(importMarkerKey, "true");
|
||||||
|
void Promise.resolve().then(() =>
|
||||||
|
setLegacyImportResult({ key: importMarkerKey, importedCount: 0 }),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const updateSession = (id: string, nextMessages: ChatMessage[]) => {
|
let importedCount = 0;
|
||||||
const now = Date.now();
|
void Promise.all(
|
||||||
setSessions((prev) =>
|
legacySessions.map((session) =>
|
||||||
prev.map((session) =>
|
importLocalSession({
|
||||||
session.id === id
|
legacyLocalId: session.id,
|
||||||
? {
|
title: session.title,
|
||||||
...session,
|
createdAt: session.createdAt,
|
||||||
title: titleFromMessages(nextMessages),
|
updatedAt: session.updatedAt,
|
||||||
updatedAt: now,
|
messages: session.messages,
|
||||||
messages: nextMessages,
|
}),
|
||||||
}
|
|
||||||
: session,
|
|
||||||
),
|
),
|
||||||
);
|
)
|
||||||
};
|
.then(() => {
|
||||||
|
importedCount = legacySessions.length;
|
||||||
|
localStorage.setItem(importMarkerKey, "true");
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
toast.error("Lokale Chat-Historie konnte nicht importiert werden.");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLegacyImportResult({ key: importMarkerKey, importedCount });
|
||||||
|
});
|
||||||
|
}, [importLocalSession, importMarkerKey, legacyImportComplete]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!legacyImportComplete ||
|
||||||
|
legacyImportedCount > 0 ||
|
||||||
|
sessionsQuery.status === "LoadingFirstPage" ||
|
||||||
|
sessions.length > 0 ||
|
||||||
|
createInitialStartedRef.current
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createInitialStartedRef.current = true;
|
||||||
|
void createSession({ title: "Neuer Chat" })
|
||||||
|
.then((session) => setSelectedSessionId(session.sessionId))
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
toast.error("Neuer Chat konnte nicht erstellt werden.");
|
||||||
|
});
|
||||||
|
}, [createSession, legacyImportComplete, legacyImportedCount, sessions.length, sessionsQuery.status]);
|
||||||
|
|
||||||
const createNewChat = () => {
|
const createNewChat = () => {
|
||||||
const session = createSession();
|
|
||||||
setSessions((prev) => [session, ...prev]);
|
|
||||||
setActiveSessionId(session.id);
|
|
||||||
setDraft("");
|
setDraft("");
|
||||||
|
void createSession({ title: "Neuer Chat" })
|
||||||
|
.then((session) => setSelectedSessionId(session.sessionId))
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
toast.error("Neuer Chat konnte nicht erstellt werden.");
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteChat = (id: string) => {
|
const deleteChat = (id: string) => {
|
||||||
const remaining = sessions.filter((session) => session.id !== id);
|
void deleteSession({ sessionId: id as Id<"chatSessions"> })
|
||||||
const nextSessions = remaining.length > 0 ? remaining : [createSession()];
|
.then(() => {
|
||||||
setSessions(nextSessions);
|
if (id === activeSessionId) {
|
||||||
if (id === activeSessionId) setActiveSessionId(nextSessions[0].id);
|
const nextSession = sessions.find((session) => session._id !== id);
|
||||||
|
setSelectedSessionId(nextSession?._id);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
toast.error("Chat konnte nicht gelöscht werden.");
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const historyItems: ChatHistoryItem[] = sessions
|
const historyItems: ChatHistoryItem[] = useMemo(
|
||||||
.map((session) => ({
|
() =>
|
||||||
id: session.id,
|
sessions.map((session) => ({
|
||||||
title: session.title,
|
id: session._id,
|
||||||
updatedAt: session.updatedAt,
|
title: session.title,
|
||||||
messageCount: session.messages.length,
|
updatedAt: session.updatedAt,
|
||||||
}))
|
messageCount: session.messageCount,
|
||||||
.sort((a, b) => b.updatedAt - a.updatedAt);
|
})),
|
||||||
|
[sessions],
|
||||||
|
);
|
||||||
|
|
||||||
const submit = async (event: FormEvent) => {
|
const submit = async (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const content = draft.trim();
|
const content = draft.trim();
|
||||||
if (!content || isSubmitting) return;
|
if (!content || isSubmitting || !activeSessionId) return;
|
||||||
|
|
||||||
const submittedSessionId = activeSessionId;
|
|
||||||
const typedNextMessages: ChatMessage[] = [...messages, { role: "user", content }];
|
|
||||||
updateSession(submittedSessionId, typedNextMessages);
|
|
||||||
setDraft("");
|
setDraft("");
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await ask({
|
await sendMessage({
|
||||||
messages: typedNextMessages,
|
sessionId: activeSessionId,
|
||||||
|
content,
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
accountId,
|
accountId,
|
||||||
basis: monthBasis,
|
basis: monthBasis,
|
||||||
});
|
});
|
||||||
|
|
||||||
updateSession(submittedSessionId, [
|
|
||||||
...typedNextMessages,
|
|
||||||
{ role: "assistant", content: response.answer },
|
|
||||||
]);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error("Antwort konnte nicht geladen werden.");
|
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 {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -186,8 +326,8 @@ export function SavingsChatPage() {
|
|||||||
<div className="grid gap-4 xl:grid-cols-[280px_minmax(0,1fr)]">
|
<div className="grid gap-4 xl:grid-cols-[280px_minmax(0,1fr)]">
|
||||||
<ChatHistory
|
<ChatHistory
|
||||||
items={historyItems}
|
items={historyItems}
|
||||||
activeId={activeSessionId}
|
activeId={activeSessionId ?? ""}
|
||||||
onSelect={setActiveSessionId}
|
onSelect={(id) => setSelectedSessionId(id as Id<"chatSessions">)}
|
||||||
onCreate={createNewChat}
|
onCreate={createNewChat}
|
||||||
onDelete={deleteChat}
|
onDelete={deleteChat}
|
||||||
/>
|
/>
|
||||||
@@ -198,67 +338,80 @@ export function SavingsChatPage() {
|
|||||||
<h1 className="text-lg font-semibold">Talk to your savings account</h1>
|
<h1 className="text-lg font-semibold">Talk to your savings account</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Kontext der Auswertung</CardTitle>
|
<CardTitle>Kontext der Auswertung</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-1 text-sm">
|
<CardContent className="space-y-1 text-sm">
|
||||||
<p>Zeitraum: {from} bis {to}</p>
|
<p>Zeitraum: {from} bis {to}</p>
|
||||||
<p>Basis: {monthBasis === "effective" ? "Effektiv" : "Buchungstag"}</p>
|
<p>Basis: {monthBasis === "effective" ? "Effektiv" : "Buchungstag"}</p>
|
||||||
<p>
|
<p>
|
||||||
Konto: {context?.accountName ?? "Alle Konten"} (Basis-Saldo{" "}
|
Konto: {context?.accountName ?? "Alle Konten"} (Basis-Saldo{" "}
|
||||||
{context ? formatAmount(context.totals.balance) : "—"})
|
{context ? formatAmount(context.totals.balance) : "—"})
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>{context ? getContextSummary() : "Lade Kontext…"}</p>
|
||||||
{context ? getContextSummary() : "Lade Kontext…"}
|
</CardContent>
|
||||||
</p>
|
</Card>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="h-[52vh] overflow-y-auto" ref={listRef}>
|
<div className="h-[52vh] overflow-y-auto" ref={listRef}>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{messages.map((message, index) => (
|
{displayMessages.map((message) => (
|
||||||
<div
|
<div
|
||||||
key={`${message.role}-${index}`}
|
key={message._id}
|
||||||
className={`rounded-lg border p-3 ${
|
className={`rounded-lg border p-3 ${
|
||||||
message.role === "user" ? "bg-muted/50" : "bg-background"
|
message.role === "user" ? "bg-muted/50" : "bg-background"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<p className="text-xs uppercase text-muted-foreground">{message.role}</p>
|
<p className="text-xs uppercase text-muted-foreground">{message.role}</p>
|
||||||
<p className="whitespace-pre-wrap text-sm">{message.content}</p>
|
<p className="whitespace-pre-wrap text-sm">{message.content}</p>
|
||||||
</div>
|
{message.role === "assistant" && message.toolTrace && message.toolTrace.length > 0 && (
|
||||||
))}
|
<div className="mt-3 rounded-md border bg-muted/30 p-2">
|
||||||
{isSubmitting && (
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
Verwendete Werkzeuge
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
</p>
|
||||||
Denk mit der KI nach…
|
<div className="mt-2 space-y-2">
|
||||||
</div>
|
{message.toolTrace.map((tool, toolIndex) => (
|
||||||
)}
|
<div key={`${tool.name}-${toolIndex}`} className="text-xs">
|
||||||
|
<p className="font-medium">{tool.name}</p>
|
||||||
|
<p className="text-muted-foreground">{tool.resultSummary}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{isSubmitting && (
|
||||||
|
<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>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<form className="flex gap-2" onSubmit={submit}>
|
<form className="flex gap-2" onSubmit={submit}>
|
||||||
<Input
|
<Input
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(event) => setDraft(event.target.value)}
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
placeholder="Welche Auswertung soll ich machen?"
|
placeholder={activeSession ? "Welche Auswertung soll ich machen?" : "Chat wird vorbereitet…"}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting || !activeSessionId}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<Button type="submit" disabled={buttonDisabled}>
|
<Button type="submit" disabled={buttonDisabled}>
|
||||||
<Send className="h-4 w-4" />
|
<Send className="h-4 w-4" />
|
||||||
Senden
|
Senden
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Antwortmodell: {context ? "Server-seitig bereit" : "…"}
|
Antwortmodell: {context ? "Server-seitig bereit" : "…"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import {
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ChangeEventHandler,
|
||||||
|
} from "react";
|
||||||
import { useMutation, usePaginatedQuery, useQuery } from "convex/react";
|
import { useMutation, usePaginatedQuery, useQuery } from "convex/react";
|
||||||
import {
|
import {
|
||||||
flexRender,
|
flexRender,
|
||||||
@@ -10,6 +18,7 @@ import { useVirtualizer } from "@tanstack/react-virtual";
|
|||||||
import { api } from "../../convex/_generated/api";
|
import { api } from "../../convex/_generated/api";
|
||||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||||
import { useFilters } from "@/context/FilterContext";
|
import { useFilters } from "@/context/FilterContext";
|
||||||
|
import { useAccountFilterId } from "@/components/layout/AccountFilter";
|
||||||
import { CategoryFilter } from "@/components/layout/CategoryFilter";
|
import { CategoryFilter } from "@/components/layout/CategoryFilter";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -20,20 +29,50 @@ import { amountClass, formatAmount, formatDate, formatMonth } from "@/lib/format
|
|||||||
import { TransactionFormDialog } from "@/components/transactions/TransactionFormDialog";
|
import { TransactionFormDialog } from "@/components/transactions/TransactionFormDialog";
|
||||||
import { AssignMonthDialog } from "@/components/transactions/AssignMonthDialog";
|
import { AssignMonthDialog } from "@/components/transactions/AssignMonthDialog";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
getVisibleSelectionState,
|
||||||
|
selectedTransactionsTotal,
|
||||||
|
toggleVisibleSelection,
|
||||||
|
} from "./transactionsSelection";
|
||||||
|
import { getResetTransactionFilterState } from "@/lib/transactionFilterReset";
|
||||||
|
import { RotateCcw } from "lucide-react";
|
||||||
|
|
||||||
type Tx = Doc<"transactions">;
|
type Tx = Doc<"transactions">;
|
||||||
type Category = Doc<"categories">;
|
type Category = Doc<"categories">;
|
||||||
|
const EMPTY_TRANSACTIONS: Tx[] = [];
|
||||||
|
|
||||||
/* ── Memoized cell components ────────────────────────────────────── */
|
/* ── Memoized cell components ────────────────────────────────────── */
|
||||||
|
|
||||||
const RowCheckbox = memo(function RowCheckbox({
|
const RowCheckbox = memo(function RowCheckbox({
|
||||||
checked,
|
checked,
|
||||||
|
indeterminate = false,
|
||||||
|
disabled = false,
|
||||||
|
ariaLabel,
|
||||||
onToggle,
|
onToggle,
|
||||||
}: {
|
}: {
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
onToggle: (e?: unknown) => void;
|
indeterminate?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
ariaLabel: string;
|
||||||
|
onToggle: ChangeEventHandler<HTMLInputElement>;
|
||||||
}) {
|
}) {
|
||||||
return <input type="checkbox" checked={checked} onChange={onToggle} />;
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ref.current) ref.current.indeterminate = indeterminate;
|
||||||
|
}, [indeterminate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
onChange={onToggle}
|
||||||
|
className="h-4 w-4 rounded border-border accent-primary"
|
||||||
|
/>
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const AccountCell = memo(function AccountCell({ name }: { name: string | undefined }) {
|
const AccountCell = memo(function AccountCell({ name }: { name: string | undefined }) {
|
||||||
@@ -152,7 +191,17 @@ export function TransactionsPage() {
|
|||||||
const [assignTx, setAssignTx] = useState<Tx | null>(null);
|
const [assignTx, setAssignTx] = useState<Tx | null>(null);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
|
||||||
const { categoryIds } = useFilters();
|
const {
|
||||||
|
categoryIds,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
monthBasis,
|
||||||
|
setPreset,
|
||||||
|
setAccountId,
|
||||||
|
setCategoryIds,
|
||||||
|
setMonthBasis,
|
||||||
|
} = useFilters();
|
||||||
|
const accountId = useAccountFilterId();
|
||||||
|
|
||||||
const categories = useQuery(api.categories.list);
|
const categories = useQuery(api.categories.list);
|
||||||
const accounts = useQuery(api.accounts.list);
|
const accounts = useQuery(api.accounts.list);
|
||||||
@@ -164,6 +213,10 @@ export function TransactionsPage() {
|
|||||||
api.transactions.list,
|
api.transactions.list,
|
||||||
{
|
{
|
||||||
search: search || undefined,
|
search: search || undefined,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
accountId,
|
||||||
|
basis: monthBasis,
|
||||||
type: type === "all" ? undefined : type,
|
type: type === "all" ? undefined : type,
|
||||||
pendingOnly: pendingOnly || undefined,
|
pendingOnly: pendingOnly || undefined,
|
||||||
categoryIds:
|
categoryIds:
|
||||||
@@ -183,6 +236,15 @@ export function TransactionsPage() {
|
|||||||
() => new Map(accounts?.map((a) => [a._id, a.name])),
|
() => new Map(accounts?.map((a) => [a._id, a.name])),
|
||||||
[accounts],
|
[accounts],
|
||||||
);
|
);
|
||||||
|
const visibleTransactions = results ?? EMPTY_TRANSACTIONS;
|
||||||
|
const visibleSelectionState = useMemo(
|
||||||
|
() => getVisibleSelectionState(visibleTransactions, rowSelection),
|
||||||
|
[visibleTransactions, rowSelection],
|
||||||
|
);
|
||||||
|
const selectedTotal = useMemo(
|
||||||
|
() => selectedTransactionsTotal(visibleTransactions, rowSelection),
|
||||||
|
[visibleTransactions, rowSelection],
|
||||||
|
);
|
||||||
|
|
||||||
const handleUpdateCategory = useCallback(
|
const handleUpdateCategory = useCallback(
|
||||||
(id: Id<"transactions">, categoryId: Id<"categories">) => {
|
(id: Id<"transactions">, categoryId: Id<"categories">) => {
|
||||||
@@ -201,21 +263,59 @@ export function TransactionsPage() {
|
|||||||
},
|
},
|
||||||
[removeTx],
|
[removeTx],
|
||||||
);
|
);
|
||||||
|
const handleToggleAllVisible = useCallback(
|
||||||
|
(checked: boolean) => {
|
||||||
|
setRowSelection((selection) =>
|
||||||
|
toggleVisibleSelection(selection, visibleTransactions, checked),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[visibleTransactions],
|
||||||
|
);
|
||||||
|
const handleResetFilters = useCallback(() => {
|
||||||
|
const reset = getResetTransactionFilterState();
|
||||||
|
|
||||||
|
setPreset(reset.global.preset);
|
||||||
|
setAccountId(reset.global.accountId);
|
||||||
|
setCategoryIds([...reset.global.categoryIds]);
|
||||||
|
setMonthBasis(reset.global.monthBasis);
|
||||||
|
setSearch(reset.page.search);
|
||||||
|
setType(reset.page.type);
|
||||||
|
setPendingOnly(reset.page.pendingOnly);
|
||||||
|
setRowSelection(reset.page.rowSelection);
|
||||||
|
}, [setAccountId, setCategoryIds, setMonthBasis, setPreset]);
|
||||||
|
|
||||||
const selectedIds = useMemo(
|
const selectedIds = useMemo(
|
||||||
() => Object.keys(rowSelection).filter((k) => rowSelection[k]) as Id<"transactions">[],
|
() =>
|
||||||
[rowSelection],
|
visibleTransactions
|
||||||
|
.filter((tx) => rowSelection[tx._id])
|
||||||
|
.map((tx) => tx._id),
|
||||||
|
[visibleTransactions, rowSelection],
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<Tx>[]>(
|
const columns = useMemo<ColumnDef<Tx>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
id: "select",
|
id: "select",
|
||||||
header: () => null,
|
header: () => (
|
||||||
|
<RowCheckbox
|
||||||
|
checked={visibleSelectionState.allSelected}
|
||||||
|
indeterminate={
|
||||||
|
visibleSelectionState.someSelected && !visibleSelectionState.allSelected
|
||||||
|
}
|
||||||
|
disabled={visibleTransactions.length === 0}
|
||||||
|
ariaLabel={
|
||||||
|
visibleSelectionState.allSelected
|
||||||
|
? "Alle sichtbaren Transaktionen abwählen"
|
||||||
|
: "Alle sichtbaren Transaktionen auswählen"
|
||||||
|
}
|
||||||
|
onToggle={(event) => handleToggleAllVisible(event.target.checked)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<RowCheckbox
|
<RowCheckbox
|
||||||
checked={row.getIsSelected()}
|
checked={row.getIsSelected()}
|
||||||
onToggle={row.getToggleSelectedHandler()}
|
ariaLabel="Transaktion auswählen"
|
||||||
|
onToggle={(event) => row.getToggleSelectedHandler()(event)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -288,11 +388,14 @@ export function TransactionsPage() {
|
|||||||
handleEdit,
|
handleEdit,
|
||||||
handleAssign,
|
handleAssign,
|
||||||
handleRemove,
|
handleRemove,
|
||||||
|
visibleSelectionState,
|
||||||
|
visibleTransactions.length,
|
||||||
|
handleToggleAllVisible,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: results ?? [],
|
data: visibleTransactions,
|
||||||
columns,
|
columns,
|
||||||
state: { rowSelection },
|
state: { rowSelection },
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
@@ -354,7 +457,22 @@ export function TransactionsPage() {
|
|||||||
/>
|
/>
|
||||||
Nur offene
|
Nur offene
|
||||||
</label>
|
</label>
|
||||||
|
<Button variant="outline" onClick={handleResetFilters}>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
Zurücksetzen
|
||||||
|
</Button>
|
||||||
<Button onClick={() => setCreateOpen(true)}>Neu</Button>
|
<Button onClick={() => setCreateOpen(true)}>Neu</Button>
|
||||||
|
{visibleSelectionState.someSelected && (
|
||||||
|
<div className="flex h-9 items-center gap-2 rounded-md border bg-muted/40 px-3 text-sm">
|
||||||
|
<span className="font-medium">
|
||||||
|
{visibleSelectionState.selectedCount} ausgewählt
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">Summe</span>
|
||||||
|
<span className={`font-semibold tabular-nums ${amountClass(selectedTotal)}`}>
|
||||||
|
{formatAmount(selectedTotal)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{selectedIds.length > 0 && categories && (
|
{selectedIds.length > 0 && categories && (
|
||||||
<Select
|
<Select
|
||||||
onValueChange={async (categoryId) => {
|
onValueChange={async (categoryId) => {
|
||||||
|
|||||||
25
src/pages/transactionFilterReset.test.ts
Normal file
25
src/pages/transactionFilterReset.test.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import {
|
||||||
|
DEFAULT_TRANSACTION_FILTER_RESET,
|
||||||
|
getResetTransactionFilterState,
|
||||||
|
} from "@/lib/transactionFilterReset";
|
||||||
|
|
||||||
|
describe("transaction filter reset defaults", () => {
|
||||||
|
test("resets global and page-level transaction filters", () => {
|
||||||
|
expect(getResetTransactionFilterState()).toEqual({
|
||||||
|
global: {
|
||||||
|
preset: "current-month",
|
||||||
|
accountId: undefined,
|
||||||
|
categoryIds: [],
|
||||||
|
monthBasis: "effective",
|
||||||
|
},
|
||||||
|
page: {
|
||||||
|
search: "",
|
||||||
|
type: "all",
|
||||||
|
pendingOnly: false,
|
||||||
|
rowSelection: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(DEFAULT_TRANSACTION_FILTER_RESET.page.rowSelection).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
48
src/pages/transactionsSelection.test.ts
Normal file
48
src/pages/transactionsSelection.test.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import {
|
||||||
|
getVisibleSelectionState,
|
||||||
|
selectedTransactionsTotal,
|
||||||
|
toggleVisibleSelection,
|
||||||
|
} from "./transactionsSelection";
|
||||||
|
|
||||||
|
const visibleTransactions = [
|
||||||
|
{ _id: "tx-1", amount: -55.68 },
|
||||||
|
{ _id: "tx-2", amount: -26.75 },
|
||||||
|
{ _id: "tx-3", amount: 12 },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("transactions selection helpers", () => {
|
||||||
|
test("selects and clears all visible transactions without changing hidden selections", () => {
|
||||||
|
expect(
|
||||||
|
toggleVisibleSelection(
|
||||||
|
{ "hidden-tx": true, "tx-1": true },
|
||||||
|
visibleTransactions,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
"hidden-tx": true,
|
||||||
|
"tx-1": true,
|
||||||
|
"tx-2": true,
|
||||||
|
"tx-3": true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
toggleVisibleSelection(
|
||||||
|
{ "hidden-tx": true, "tx-1": true, "tx-2": true, "tx-3": true },
|
||||||
|
visibleTransactions,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
).toEqual({ "hidden-tx": true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("computes selected count, all-selected state, and signed total from visible transactions", () => {
|
||||||
|
const selection = { "hidden-tx": true, "tx-1": true, "tx-3": true };
|
||||||
|
|
||||||
|
expect(getVisibleSelectionState(visibleTransactions, selection)).toEqual({
|
||||||
|
allSelected: false,
|
||||||
|
someSelected: true,
|
||||||
|
selectedCount: 2,
|
||||||
|
});
|
||||||
|
expect(selectedTransactionsTotal(visibleTransactions, selection)).toBe(-43.68);
|
||||||
|
});
|
||||||
|
});
|
||||||
49
src/pages/transactionsSelection.ts
Normal file
49
src/pages/transactionsSelection.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
export type TransactionSelection = Record<string, boolean>;
|
||||||
|
|
||||||
|
export type SelectableTransaction = {
|
||||||
|
_id: string;
|
||||||
|
amount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function toggleVisibleSelection(
|
||||||
|
selection: TransactionSelection,
|
||||||
|
visibleTransactions: SelectableTransaction[],
|
||||||
|
selectAll: boolean,
|
||||||
|
): TransactionSelection {
|
||||||
|
const next = { ...selection };
|
||||||
|
|
||||||
|
for (const tx of visibleTransactions) {
|
||||||
|
if (selectAll) {
|
||||||
|
next[tx._id] = true;
|
||||||
|
} else {
|
||||||
|
delete next[tx._id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVisibleSelectionState(
|
||||||
|
visibleTransactions: SelectableTransaction[],
|
||||||
|
selection: TransactionSelection,
|
||||||
|
) {
|
||||||
|
const selectedCount = visibleTransactions.filter((tx) => selection[tx._id]).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
allSelected: visibleTransactions.length > 0 && selectedCount === visibleTransactions.length,
|
||||||
|
someSelected: selectedCount > 0,
|
||||||
|
selectedCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectedTransactionsTotal(
|
||||||
|
visibleTransactions: SelectableTransaction[],
|
||||||
|
selection: TransactionSelection,
|
||||||
|
): number {
|
||||||
|
const total = visibleTransactions.reduce(
|
||||||
|
(sum, tx) => (selection[tx._id] ? sum + tx.amount : sum),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Math.round(total * 100) / 100;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user