@hearthlog/module-finance 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +235 -0
  2. package/README.md +11 -2
  3. package/hearthlog.module.json +90 -0
  4. package/package.json +44 -3
  5. package/src/attention.ts +41 -0
  6. package/src/components/finance/AddAssetForm.svelte +58 -0
  7. package/src/components/finance/AddTransactionForm.svelte +127 -0
  8. package/src/components/finance/AssetDrawer.svelte +133 -0
  9. package/src/components/finance/AssetItem.svelte +96 -0
  10. package/src/components/finance/AssetView.svelte +165 -0
  11. package/src/components/finance/AssetsSheet.svelte +122 -0
  12. package/src/components/finance/BudgetItemActionsCell.svelte +18 -0
  13. package/src/components/finance/BudgetItemAmountCell.svelte +5 -0
  14. package/src/components/finance/BudgetItemCategoryCell.svelte +7 -0
  15. package/src/components/finance/BudgetItemDrawer.svelte +116 -0
  16. package/src/components/finance/BudgetItemNameCell.svelte +5 -0
  17. package/src/components/finance/BudgetItemSelectCell.svelte +25 -0
  18. package/src/components/finance/BudgetItemsTable.svelte +206 -0
  19. package/src/components/finance/BudgetPlanDrawer.svelte +89 -0
  20. package/src/components/finance/BudgetPlans.svelte +419 -0
  21. package/src/components/finance/EndlessTimeline.svelte +174 -0
  22. package/src/components/finance/FinanceReadout.svelte +229 -0
  23. package/src/components/finance/MonthSection.svelte +255 -0
  24. package/src/components/finance/RiverSlice.svelte +125 -0
  25. package/src/components/finance/SortUncategorizedSheet.svelte +198 -0
  26. package/src/components/finance/TimelineRowItem.svelte +319 -0
  27. package/src/components/finance/TransactionDrawer.svelte +480 -0
  28. package/src/components/finance/TransactionSelectionBar.svelte +200 -0
  29. package/src/components/finance/timeline-months.svelte.ts +37 -0
  30. package/src/components/finance/transaction-draft.ts +121 -0
  31. package/src/data/assets.ts +48 -0
  32. package/src/data/budgets.ts +123 -0
  33. package/src/data/definitions.ts +135 -0
  34. package/src/data/finance-timeline.ts +305 -0
  35. package/src/data/forecast.ts +256 -0
  36. package/src/data/planned-days.ts +57 -0
  37. package/src/data/repositories.ts +20 -0
  38. package/src/data/transactions.ts +452 -0
  39. package/src/finance/categories.ts +29 -0
  40. package/src/finance/category-pick.ts +8 -0
  41. package/src/finance/category-suggestions.ts +65 -0
  42. package/src/finance/floor.ts +29 -0
  43. package/src/finance/phone-balance.ts +22 -0
  44. package/src/finance/timeline.ts +113 -0
  45. package/src/pages/index.svelte +19 -0
  46. package/src/pages/index.ts +7 -0
  47. package/src/pages/layout.svelte +13 -0
  48. package/src/pages/layout.ts +18 -0
  49. package/src/pages/month.svelte +909 -0
  50. package/src/pages/month.ts +99 -0
  51. package/src/server/category-pick.ts +44 -0
  52. package/src/server/index.ts +11 -0
  53. package/src/styles.css +12 -0
@@ -0,0 +1,452 @@
1
+ import type { Transaction } from '@hearthlog/sdk/data';
2
+ import type { ReadModelSession } from '@hearthlog/sdk/data';
3
+ import { withTimeout } from '@hearthlog/sdk/data';
4
+ import { sql } from 'kysely';
5
+
6
+ export type { Transaction };
7
+
8
+ const DB_TIMEOUT_MS = 5000;
9
+
10
+ /** Month totals the finance workbench renders, aggregated in SQLite. */
11
+ export interface TransactionMonthSummary {
12
+ incomeTotal: number;
13
+ expenseTotal: number;
14
+ monthFlow: number;
15
+ /** Active rows in the month, counted in SQL. */
16
+ activeTransactionCount: number;
17
+ activeDayCount: number;
18
+ archivedCount: number;
19
+ }
20
+
21
+ /** Income, expenses and net of a picked selection, aggregated in SQLite. */
22
+ export interface TransactionSelectionSummary {
23
+ count: number;
24
+ incomeTotal: number;
25
+ expenseTotal: number;
26
+ netTotal: number;
27
+ }
28
+
29
+ /** The category the earlier, categorised rows of the same name lead to. */
30
+ export interface CategoryFromHistory {
31
+ /** The name as asked, so callers match without normalising. */
32
+ name: string;
33
+ category: string;
34
+ /** Rows of this name in the leading category. */
35
+ count: number;
36
+ /** The leading category's share of all categorised rows of this name. */
37
+ share: number;
38
+ }
39
+
40
+ /** A history rule needs at least this many agreeing rows ... */
41
+ export const HISTORY_MIN_COUNT = 2;
42
+ /** ... and this share of all categorised rows of the name. */
43
+ export const HISTORY_MIN_SHARE = 0.8;
44
+
45
+ export interface DailyBalance {
46
+ date: string;
47
+ balance: number;
48
+ }
49
+
50
+ export interface PlanningSummary {
51
+ plannedIncome: number;
52
+ plannedExpenses: number;
53
+ plannedFlow: number;
54
+ plannedCount: number;
55
+ plannedDayCount: number;
56
+ pastDueCount: number;
57
+ archivedCount: number;
58
+ }
59
+
60
+ function toNumber(value: number | string | bigint | null | undefined): number {
61
+ const parsed = Number(value ?? 0);
62
+ return Number.isFinite(parsed) ? parsed : 0;
63
+ }
64
+
65
+ function monthBounds(year: number, month: number): { startDate: string; endDate: string } {
66
+ return {
67
+ startDate: new Date(year, month - 1, 1).toISOString().slice(0, 10),
68
+ endDate: new Date(year, month, 0).toISOString().slice(0, 10),
69
+ };
70
+ }
71
+
72
+ export function createTransactionRepository(session: ReadModelSession) {
73
+ return {
74
+ getAllTransactions(): Promise<Transaction[]> {
75
+ return withTimeout(
76
+ session.selectFrom('transactions')
77
+ .selectAll()
78
+ .orderBy('due_date', 'asc')
79
+ .orderBy('created_at', 'asc')
80
+ .orderBy('id', 'asc')
81
+ .execute(),
82
+ DB_TIMEOUT_MS,
83
+ 'getAllTransactions timed out',
84
+ );
85
+ },
86
+
87
+ getTransactions(year: number, month: number): Promise<Transaction[]> {
88
+ const { startDate, endDate } = monthBounds(year, month);
89
+ return withTimeout(
90
+ session.selectFrom('transactions')
91
+ .selectAll()
92
+ .where('due_date', '>=', startDate)
93
+ .where('due_date', '<=', endDate)
94
+ .orderBy('due_date', 'asc')
95
+ .orderBy('created_at', 'asc')
96
+ .orderBy('id', 'asc')
97
+ .execute(),
98
+ DB_TIMEOUT_MS,
99
+ 'getTransactions timed out',
100
+ );
101
+ },
102
+
103
+ /** Future plans in the selected horizon. Today remains planned until archived. */
104
+ getPlannedTransactions(today: string, endDate: string): Promise<Transaction[]> {
105
+ return withTimeout(
106
+ session.selectFrom('transactions')
107
+ .selectAll()
108
+ .where('transaction_status', '=', 'active')
109
+ .where('due_date', '>=', today)
110
+ .where('due_date', '<=', endDate)
111
+ .orderBy('due_date', 'asc')
112
+ .orderBy('created_at', 'asc')
113
+ .orderBy('id', 'asc')
114
+ .execute(),
115
+ DB_TIMEOUT_MS,
116
+ 'Planned transaction query timed out',
117
+ );
118
+ },
119
+
120
+ getNextPlannedTransactions(today: string, endDate: string, limit = 5): Promise<Transaction[]> {
121
+ return withTimeout(
122
+ session.selectFrom('transactions')
123
+ .selectAll()
124
+ .where('transaction_status', '=', 'active')
125
+ .where('due_date', '>=', today)
126
+ .where('due_date', '<=', endDate)
127
+ .orderBy('due_date', 'asc')
128
+ .orderBy('created_at', 'asc')
129
+ .orderBy('id', 'asc')
130
+ .limit(limit)
131
+ .execute(),
132
+ DB_TIMEOUT_MS,
133
+ 'Next planned transaction query timed out',
134
+ );
135
+ },
136
+
137
+ /** Active entries whose date passed and now require an explicit decision. */
138
+ getPastDueTransactions(today: string): Promise<Transaction[]> {
139
+ return withTimeout(
140
+ session.selectFrom('transactions')
141
+ .selectAll()
142
+ .where('transaction_status', '=', 'active')
143
+ .where('due_date', '<', today)
144
+ .orderBy('due_date', 'desc')
145
+ .orderBy('created_at', 'asc')
146
+ .orderBy('id', 'asc')
147
+ .execute(),
148
+ DB_TIMEOUT_MS,
149
+ 'Past-due transaction query timed out',
150
+ );
151
+ },
152
+
153
+ /** Completed entries for the selected archive month. */
154
+ getArchivedTransactions(year: number, month: number): Promise<Transaction[]> {
155
+ const { startDate, endDate } = monthBounds(year, month);
156
+ return withTimeout(
157
+ session.selectFrom('transactions')
158
+ .selectAll()
159
+ .where('transaction_status', '=', 'archived')
160
+ .where('due_date', '>=', startDate)
161
+ .where('due_date', '<=', endDate)
162
+ .orderBy('due_date', 'desc')
163
+ .orderBy('created_at', 'asc')
164
+ .orderBy('id', 'asc')
165
+ .execute(),
166
+ DB_TIMEOUT_MS,
167
+ 'Archived transaction query timed out',
168
+ );
169
+ },
170
+
171
+ /** Planning totals are calculated from future active entries only. */
172
+ async getPlanningSummary(today: string, endDate: string): Promise<PlanningSummary> {
173
+ const result = await withTimeout(
174
+ session.execute(sql<{
175
+ planned_income: number | string | null;
176
+ planned_expenses: number | string | null;
177
+ planned_flow: number | string | null;
178
+ planned_count: number | string | null;
179
+ planned_day_count: number | string | null;
180
+ past_due_count: number | string | null;
181
+ archived_count: number | string | null;
182
+ }>`
183
+ SELECT
184
+ SUM(CASE WHEN transaction_status = 'active' AND due_date >= ${today} AND due_date <= ${endDate} AND amount > 0 THEN amount ELSE 0 END) AS planned_income,
185
+ SUM(CASE WHEN transaction_status = 'active' AND due_date >= ${today} AND due_date <= ${endDate} AND amount < 0 THEN -amount ELSE 0 END) AS planned_expenses,
186
+ SUM(CASE WHEN transaction_status = 'active' AND due_date >= ${today} AND due_date <= ${endDate} THEN amount ELSE 0 END) AS planned_flow,
187
+ SUM(CASE WHEN transaction_status = 'active' AND due_date >= ${today} AND due_date <= ${endDate} THEN 1 ELSE 0 END) AS planned_count,
188
+ COUNT(DISTINCT CASE WHEN transaction_status = 'active' AND due_date >= ${today} AND due_date <= ${endDate} THEN substr(trim(due_date), 1, 10) END) AS planned_day_count,
189
+ SUM(CASE WHEN transaction_status = 'active' AND due_date < ${today} THEN 1 ELSE 0 END) AS past_due_count,
190
+ SUM(CASE WHEN transaction_status = 'archived' THEN 1 ELSE 0 END) AS archived_count
191
+ FROM ${session.table('transactions')} AS transactions
192
+ `),
193
+ DB_TIMEOUT_MS,
194
+ 'Planning summary query timed out',
195
+ );
196
+ const row = result.rows[0];
197
+ return {
198
+ plannedIncome: toNumber(row?.planned_income),
199
+ plannedExpenses: toNumber(row?.planned_expenses),
200
+ plannedFlow: toNumber(row?.planned_flow),
201
+ plannedCount: toNumber(row?.planned_count),
202
+ plannedDayCount: toNumber(row?.planned_day_count),
203
+ pastDueCount: toNumber(row?.past_due_count),
204
+ archivedCount: toNumber(row?.archived_count),
205
+ };
206
+ },
207
+
208
+ async getSumOfTransactionAmountsUpToDate(date: string): Promise<number> {
209
+ const result = await withTimeout(
210
+ session.selectFrom('transactions')
211
+ .select(({ fn }) => fn.sum('amount').as('total_amount'))
212
+ .where('due_date', '<', date)
213
+ .where('transaction_status', '=', 'active')
214
+ .executeTakeFirst(),
215
+ DB_TIMEOUT_MS,
216
+ 'getSumOfTransactionAmountsUpToDate timed out',
217
+ );
218
+ return toNumber(result?.total_amount);
219
+ },
220
+
221
+ /**
222
+ * Income, expenses and net of a picked set of rows. Selection totals are
223
+ * aggregated in SQLite so the page never sums rows in JavaScript.
224
+ */
225
+ async getSelectionSummary(ids: string[]): Promise<TransactionSelectionSummary> {
226
+ if (ids.length === 0) {
227
+ return { count: 0, incomeTotal: 0, expenseTotal: 0, netTotal: 0 };
228
+ }
229
+ const result = await withTimeout(
230
+ session.execute(sql<{
231
+ selected_count: number | string | null;
232
+ income_total: number | string | null;
233
+ expense_total: number | string | null;
234
+ net_total: number | string | null;
235
+ }>`
236
+ SELECT
237
+ COUNT(*) AS selected_count,
238
+ SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) AS income_total,
239
+ SUM(CASE WHEN amount < 0 THEN -amount ELSE 0 END) AS expense_total,
240
+ COALESCE(SUM(amount), 0) AS net_total
241
+ FROM ${session.table('transactions')} AS transactions
242
+ WHERE id IN (${sql.join(ids)})
243
+ `),
244
+ DB_TIMEOUT_MS,
245
+ 'Transaction selection summary timed out',
246
+ );
247
+ const row = result.rows[0];
248
+ return {
249
+ count: toNumber(row?.selected_count),
250
+ incomeTotal: toNumber(row?.income_total),
251
+ expenseTotal: toNumber(row?.expense_total),
252
+ netTotal: toNumber(row?.net_total),
253
+ };
254
+ },
255
+
256
+ /**
257
+ * One balance per calendar day of the month, produced by SQLite with a
258
+ * date series and a cumulative window sum over active daily totals.
259
+ */
260
+ async getMonthDailyBalances(
261
+ year: number,
262
+ month: number,
263
+ openingBalance: number,
264
+ ): Promise<DailyBalance[]> {
265
+ const { startDate, endDate } = monthBounds(year, month);
266
+ const result = await withTimeout(
267
+ session.execute(sql<{ date: string; balance: number | string | null }>`
268
+ WITH RECURSIVE month_days(day) AS (
269
+ SELECT ${startDate}
270
+ UNION ALL
271
+ SELECT date(day, '+1 day') FROM month_days WHERE day < ${endDate}
272
+ ),
273
+ daily AS (
274
+ SELECT substr(trim(due_date), 1, 10) AS day, SUM(amount) AS amount
275
+ FROM ${session.table('transactions')} AS transactions
276
+ WHERE transaction_status = 'active'
277
+ AND due_date >= ${startDate}
278
+ AND due_date <= ${endDate}
279
+ GROUP BY day
280
+ )
281
+ SELECT month_days.day AS date,
282
+ ${openingBalance} + COALESCE(SUM(daily.amount) OVER (ORDER BY month_days.day), 0) AS balance
283
+ FROM month_days
284
+ LEFT JOIN daily ON daily.day = month_days.day
285
+ ORDER BY month_days.day
286
+ `),
287
+ DB_TIMEOUT_MS,
288
+ 'Month daily balance query timed out',
289
+ );
290
+ return result.rows.map((row) => ({
291
+ date: String(row.date),
292
+ balance: toNumber(row.balance),
293
+ }));
294
+ },
295
+
296
+ /** Income, expenses, flow, active-day count and archived count in one pass. */
297
+ async getMonthTransactionSummary(
298
+ year: number,
299
+ month: number,
300
+ ): Promise<TransactionMonthSummary> {
301
+ const { startDate, endDate } = monthBounds(year, month);
302
+ const result = await withTimeout(
303
+ session.execute(sql<{
304
+ income_total: number | string | null;
305
+ expense_total: number | string | null;
306
+ month_flow: number | string | null;
307
+ active_transaction_count: number | string | null;
308
+ active_day_count: number | string | null;
309
+ archived_count: number | string | null;
310
+ }>`
311
+ SELECT
312
+ SUM(CASE WHEN transaction_status = 'active' AND amount > 0 THEN amount ELSE 0 END) AS income_total,
313
+ SUM(CASE WHEN transaction_status = 'active' AND amount < 0 THEN -amount ELSE 0 END) AS expense_total,
314
+ SUM(CASE WHEN transaction_status = 'active' THEN amount ELSE 0 END) AS month_flow,
315
+ SUM(CASE WHEN transaction_status = 'active' THEN 1 ELSE 0 END) AS active_transaction_count,
316
+ COUNT(DISTINCT CASE WHEN transaction_status = 'active' THEN substr(trim(due_date), 1, 10) END) AS active_day_count,
317
+ SUM(CASE WHEN transaction_status = 'archived' THEN 1 ELSE 0 END) AS archived_count
318
+ FROM ${session.table('transactions')} AS transactions
319
+ WHERE due_date >= ${startDate}
320
+ AND due_date <= ${endDate}
321
+ `),
322
+ DB_TIMEOUT_MS,
323
+ 'Month transaction summary query timed out',
324
+ );
325
+ const row = result.rows[0];
326
+ return {
327
+ incomeTotal: toNumber(row?.income_total),
328
+ expenseTotal: toNumber(row?.expense_total),
329
+ monthFlow: toNumber(row?.month_flow),
330
+ activeTransactionCount: toNumber(row?.active_transaction_count),
331
+ activeDayCount: toNumber(row?.active_day_count),
332
+ archivedCount: toNumber(row?.archived_count),
333
+ };
334
+ },
335
+
336
+ /**
337
+ * The history rule: for each name, the category earlier rows of the
338
+ * same name (case and surrounding spaces ignored) were given. Only
339
+ * categorised rows count, and a name qualifies when at least
340
+ * {@link HISTORY_MIN_COUNT} rows agree and the leading category holds
341
+ * {@link HISTORY_MIN_SHARE} of them. Counting and ranking run in SQL.
342
+ */
343
+ async categoryFromHistory(names: string[]): Promise<CategoryFromHistory[]> {
344
+ const wanted = [...new Set(names.map((name) => name.trim()).filter((name) => name !== ''))];
345
+ if (wanted.length === 0) return [];
346
+ const result = await withTimeout(
347
+ session.execute(sql<{
348
+ name: string;
349
+ category: string;
350
+ count: number | string;
351
+ share: number | string;
352
+ }>`
353
+ WITH wanted(name) AS (VALUES ${sql.join(wanted.map((name) => sql`(${name})`))}),
354
+ counts AS (
355
+ SELECT wanted.name AS name, transactions.transaction_category AS category, COUNT(*) AS count
356
+ FROM wanted
357
+ JOIN ${session.table('transactions')} AS transactions
358
+ ON lower(trim(transactions.name)) = lower(wanted.name)
359
+ WHERE COALESCE(transactions.transaction_category, '') NOT IN ('', 'unknown')
360
+ GROUP BY wanted.name, transactions.transaction_category
361
+ ),
362
+ ranked AS (
363
+ SELECT name, category, count,
364
+ SUM(count) OVER (PARTITION BY name) AS total,
365
+ ROW_NUMBER() OVER (PARTITION BY name ORDER BY count DESC, category ASC) AS place
366
+ FROM counts
367
+ )
368
+ SELECT name, category, count, CAST(count AS REAL) / total AS share
369
+ FROM ranked
370
+ WHERE place = 1
371
+ AND count >= ${HISTORY_MIN_COUNT}
372
+ AND CAST(count AS REAL) / total >= ${HISTORY_MIN_SHARE}
373
+ `),
374
+ DB_TIMEOUT_MS,
375
+ 'Category history query timed out',
376
+ );
377
+ return result.rows.map((row) => ({
378
+ name: String(row.name),
379
+ category: String(row.category),
380
+ count: toNumber(row.count),
381
+ share: toNumber(row.share),
382
+ }));
383
+ },
384
+
385
+ /** Rows of a month (`start` to `end`, whole days) that still have no category, oldest first. */
386
+ getUncategorizedInMonth(start: string, end: string): Promise<Transaction[]> {
387
+ return withTimeout(
388
+ session.selectFrom('transactions')
389
+ .selectAll()
390
+ .where(sql<boolean>`COALESCE(transaction_category, '') IN ('', 'unknown')`)
391
+ .where(sql<boolean>`substr(trim(due_date), 1, 10) BETWEEN ${start} AND ${end}`)
392
+ .orderBy('due_date', 'asc')
393
+ .orderBy('created_at', 'asc')
394
+ .orderBy('id', 'asc')
395
+ .execute(),
396
+ DB_TIMEOUT_MS,
397
+ 'Uncategorized transaction query timed out',
398
+ );
399
+ },
400
+
401
+ /** How many rows of the month still have no category. */
402
+ async countUncategorizedInMonth(start: string, end: string): Promise<number> {
403
+ const result = await withTimeout(
404
+ session.selectFrom('transactions')
405
+ .select(({ fn }) => fn.countAll().as('count'))
406
+ .where(sql<boolean>`COALESCE(transaction_category, '') IN ('', 'unknown')`)
407
+ .where(sql<boolean>`substr(trim(due_date), 1, 10) BETWEEN ${start} AND ${end}`)
408
+ .executeTakeFirst(),
409
+ DB_TIMEOUT_MS,
410
+ 'Uncategorized transaction count timed out',
411
+ );
412
+ return toNumber(result?.count);
413
+ },
414
+
415
+ /** The categories used most on or after a day for one direction, most used first (phone category chips). */
416
+ async getTopCategories(since: string, direction: 'income' | 'expense', limit = 4): Promise<string[]> {
417
+ const rows = await withTimeout(
418
+ session.selectFrom('transactions')
419
+ .select(['transaction_category as category', ({ fn }) => fn.countAll().as('uses')])
420
+ .where(sql<boolean>`COALESCE(transaction_category, '') NOT IN ('', 'unknown')`)
421
+ .where(sql<boolean>`substr(trim(due_date), 1, 10) >= ${since}`)
422
+ .where(direction === 'income' ? sql<boolean>`amount > 0` : sql<boolean>`amount < 0`)
423
+ .groupBy('transaction_category')
424
+ .orderBy('uses', 'desc')
425
+ .orderBy('transaction_category', 'asc')
426
+ .limit(limit)
427
+ .execute(),
428
+ DB_TIMEOUT_MS,
429
+ 'Top categories query timed out',
430
+ );
431
+ return rows.map((row) => String(row.category));
432
+ },
433
+
434
+ /** The most recently updated active rows of a month, chosen in SQL. */
435
+ getRecentActiveTransactions(year: number, month: number, limit = 5): Promise<Transaction[]> {
436
+ const { startDate, endDate } = monthBounds(year, month);
437
+ return withTimeout(
438
+ session.selectFrom('transactions')
439
+ .selectAll()
440
+ .where('transaction_status', '=', 'active')
441
+ .where('due_date', '>=', startDate)
442
+ .where('due_date', '<=', endDate)
443
+ .orderBy('updated_at', 'desc')
444
+ .orderBy('id', 'asc')
445
+ .limit(limit)
446
+ .execute(),
447
+ DB_TIMEOUT_MS,
448
+ 'Recent active transaction query timed out',
449
+ );
450
+ },
451
+ };
452
+ }
@@ -0,0 +1,29 @@
1
+ /** The fixed transaction categories, in the order forms list them. */
2
+ export const TRANSACTION_CATEGORIES = [
3
+ { value: 'unknown', label: 'Uncategorized' },
4
+ { value: 'income', label: 'Income' },
5
+ { value: 'housing', label: 'Housing' },
6
+ { value: 'food', label: 'Food' },
7
+ { value: 'transport', label: 'Transport' },
8
+ { value: 'subscriptions', label: 'Subscriptions' },
9
+ { value: 'health', label: 'Health' },
10
+ { value: 'shopping', label: 'Shopping' },
11
+ { value: 'leisure', label: 'Leisure' },
12
+ { value: 'kids', label: 'Kids' },
13
+ { value: 'gifts', label: 'Gifts and giving' },
14
+ { value: 'other', label: 'Other' },
15
+ ] as const;
16
+
17
+ export type TransactionCategory = (typeof TRANSACTION_CATEGORIES)[number]['value'];
18
+
19
+ const LABELS: Record<string, string> = Object.fromEntries(
20
+ TRANSACTION_CATEGORIES.map((category) => [category.value, category.label]),
21
+ );
22
+
23
+ export function categoryLabel(value: string | null | undefined): string {
24
+ return LABELS[value ?? ''] ?? 'Uncategorized';
25
+ }
26
+
27
+ export function isTransactionCategory(value: unknown): value is TransactionCategory {
28
+ return typeof value === 'string' && value in LABELS;
29
+ }
@@ -0,0 +1,8 @@
1
+ /** The `category-pick` AI capability's item, shared by the browser request and the gateway's question. */
2
+ export interface CategoryPickItem {
3
+ id: string;
4
+ name: string;
5
+ /** Absolute amount; the direction is separate. */
6
+ amount: number;
7
+ direction: 'income' | 'expense';
8
+ }
@@ -0,0 +1,65 @@
1
+ import { requestDecisions, suggestionBand } from '@hearthlog/sdk/ai';
2
+ import type { CategoryPickItem } from './category-pick.js';
3
+ import { queryDatabase } from '@hearthlog/sdk/data';
4
+ import { isTransactionCategory } from './categories.js';
5
+
6
+ /**
7
+ * Suggests a category for transactions that have none. The history rule runs
8
+ * first, in SQL, with no model; the rest go to the decision API. A suggestion
9
+ * is never saved here: callers fill a field or offer a chip, and the user's
10
+ * Save or Apply is the only write.
11
+ */
12
+
13
+ export type CategoryOrigin =
14
+ /** Same name was given this category before: a plain reason, no AI mark. */
15
+ | { kind: 'history'; count: number }
16
+ /** The decision model picked it. `apply` fills the value; `offer` shows a chip. */
17
+ | { kind: 'ai'; model: string; probability: number; band: 'apply' | 'offer' };
18
+
19
+ export interface CategorySuggestion {
20
+ id: string;
21
+ category: string;
22
+ origin: CategoryOrigin;
23
+ }
24
+
25
+ /** What `transaction.category_source` stores for a suggested category. */
26
+ export function categorySource(origin: CategoryOrigin): string {
27
+ return origin.kind === 'history' ? 'history' : `ai:${origin.model}`;
28
+ }
29
+
30
+ /** The suggestions found for the given rows, by row id. Rows without a usable suggestion are absent. */
31
+ export async function suggestCategories(rows: CategoryPickItem[]): Promise<Map<string, CategorySuggestion>> {
32
+ return (await suggestCategoriesDetailed(rows)).suggestions;
33
+ }
34
+
35
+ /** Like {@link suggestCategories}, and says whether the model could not be asked, so a button can explain an empty result. */
36
+ export async function suggestCategoriesDetailed(
37
+ rows: CategoryPickItem[],
38
+ ): Promise<{ suggestions: Map<string, CategorySuggestion>; aiUnavailable: boolean }> {
39
+ const suggestions = new Map<string, CategorySuggestion>();
40
+ if (rows.length === 0) return { suggestions, aiUnavailable: false };
41
+
42
+ const history = await queryDatabase((repositories) => repositories.transactions.categoryFromHistory(rows.map((row) => row.name)))
43
+ .catch(() => []);
44
+ const byName = new Map(history.map((entry) => [entry.name, entry]));
45
+ for (const row of rows) {
46
+ const hit = byName.get(row.name.trim());
47
+ if (hit && isTransactionCategory(hit.category)) {
48
+ suggestions.set(row.id, { id: row.id, category: hit.category, origin: { kind: 'history', count: hit.count } });
49
+ }
50
+ }
51
+
52
+ const remaining = rows.filter((row) => !suggestions.has(row.id));
53
+ if (remaining.length === 0) return { suggestions, aiUnavailable: false };
54
+ const model = await requestDecisions('category-pick', remaining);
55
+ for (const decision of model.decisions) {
56
+ const band = suggestionBand(decision.option, decision.probability);
57
+ if (band === 'choose' || !isTransactionCategory(decision.option) || decision.option === 'unknown') continue;
58
+ suggestions.set(decision.id, {
59
+ id: decision.id,
60
+ category: decision.option,
61
+ origin: { kind: 'ai', model: model.model, probability: decision.probability, band },
62
+ });
63
+ }
64
+ return { suggestions, aiUnavailable: !model.answered };
65
+ }
@@ -0,0 +1,29 @@
1
+ import { getDeviceSetting as kvGet, setDeviceSetting as kvSet } from '@hearthlog/sdk/runtime';
2
+
3
+ /**
4
+ * The balance floor is a personal warning line ("tell me when a plan takes me
5
+ * below 20 000 Kč"). It is a device preference, like calculator inputs, so it
6
+ * lives in local KV and never becomes an event.
7
+ */
8
+
9
+ const FLOOR_KEY = 'finance:balance-floor';
10
+
11
+ export function parseFloor(value: unknown): number | null {
12
+ if (typeof value === 'number') return Number.isFinite(value) ? Math.round(value) : null;
13
+ if (typeof value !== 'string') return null;
14
+ const digits = value.replace(/[\s  ]/g, '').replace(/kč$/i, '').replace('−', '-');
15
+ if (!/^-?\d+$/.test(digits)) return null;
16
+ return Number(digits);
17
+ }
18
+
19
+ export async function loadBalanceFloor(): Promise<number> {
20
+ try {
21
+ return parseFloor(await kvGet<unknown>(FLOOR_KEY)) ?? 0;
22
+ } catch {
23
+ return 0;
24
+ }
25
+ }
26
+
27
+ export function saveBalanceFloor(value: number): Promise<void> {
28
+ return kvSet(FLOOR_KEY, value);
29
+ }
@@ -0,0 +1,22 @@
1
+ import { getDeviceSetting as kvGet, setDeviceSetting as kvSet } from '@hearthlog/sdk/runtime';
2
+
3
+ /**
4
+ * Whether a phone shows the running balance, the balance line and each month's
5
+ * figures in the transactions list. A device preference like the floor, so it
6
+ * lives in local KV and never becomes an event. Off until someone turns it on;
7
+ * wider screens always show them.
8
+ */
9
+
10
+ const PHONE_BALANCE_KEY = 'finance:phone-balance';
11
+
12
+ export async function loadPhoneBalance(): Promise<boolean> {
13
+ try {
14
+ return (await kvGet<unknown>(PHONE_BALANCE_KEY)) === true;
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ export function savePhoneBalance(on: boolean): Promise<void> {
21
+ return kvSet(PHONE_BALANCE_KEY, on);
22
+ }