@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,305 @@
1
+ import { sql, type RawBuilder } from 'kysely';
2
+ import type { ReadModelSession } from '@hearthlog/sdk/data';
3
+ import { withTimeout } from '@hearthlog/sdk/data';
4
+ import type { TimelineKind, TimelineSection, TimelineShow } from '../finance/timeline.js';
5
+
6
+ /**
7
+ * Reads for the endless Finance timeline. Every running balance, grouping,
8
+ * low point and total is computed here in SQLite; the page only lays the rows
9
+ * out and asks for the months that scroll into view.
10
+ *
11
+ * Balance model: current assets plus every plan. A plan is an active row
12
+ * dated today or later. A row is done once it is marked done or its day has
13
+ * passed: done rows are history that assets already reflect, so they never
14
+ * move a balance. Balances therefore exist from today on only; after the last
15
+ * plan they stay constant.
16
+ */
17
+
18
+ const DB_TIMEOUT_MS = 5000;
19
+
20
+ export interface TimelineRow {
21
+ id: string;
22
+ name: string;
23
+ amount: number;
24
+ date: string;
25
+ category: string;
26
+ status: 'active' | 'archived';
27
+ kind: TimelineKind;
28
+ section: TimelineSection;
29
+ /** Running balance after this row for plans; null for done rows. */
30
+ balanceAfter: number | null;
31
+ /** First visible row of its day within its section. */
32
+ firstOfDay: boolean;
33
+ /** The last plan of the day with the lowest balance ahead. */
34
+ isLow: boolean;
35
+ }
36
+
37
+ export interface TimelineMonth {
38
+ month: string;
39
+ /** Income and expenses of the rows the month's header describes. */
40
+ income: number;
41
+ expense: number;
42
+ /** Projected balance entering the month: available now up to today's month. */
43
+ start: number;
44
+ /** Projected balance after the month's plans. */
45
+ end: number;
46
+ }
47
+
48
+ export interface TimelineOutlook {
49
+ available: number;
50
+ /** Lowest end-of-day balance across every plan ahead (today when nothing dips). */
51
+ low: { amount: number; date: string };
52
+ /** Balance after the last plan. */
53
+ end: number;
54
+ /** Day of the last plan; null when nothing is planned. */
55
+ endDate: string | null;
56
+ /** Day of the latest done row, which is ahead of today when marked done early; null when nothing is done. */
57
+ lastDone: string | null;
58
+ }
59
+
60
+ export interface BalanceRange {
61
+ min: number;
62
+ max: number;
63
+ }
64
+
65
+ export interface TimelineRowsOptions {
66
+ today: string;
67
+ start: string;
68
+ end: string;
69
+ show?: TimelineShow;
70
+ /** The lowest point is searched from today through this day (all plans when omitted). */
71
+ lowThrough?: string;
72
+ limit?: number;
73
+ }
74
+
75
+ const FAR_FUTURE = '9999-12-31';
76
+
77
+ function toNumber(value: unknown): number {
78
+ const parsed = Number(value ?? 0);
79
+ return Number.isFinite(parsed) ? parsed : 0;
80
+ }
81
+
82
+ function toNullableNumber(value: unknown): number | null {
83
+ return value === null || value === undefined ? null : toNumber(value);
84
+ }
85
+
86
+ /** Shared CTEs: typed transactions and the current asset total. */
87
+ function baseCtes(session: ReadModelSession, today: string): RawBuilder<unknown> {
88
+ return sql`
89
+ typed AS (
90
+ SELECT
91
+ id,
92
+ COALESCE(name, '') AS name,
93
+ amount,
94
+ substr(trim(due_date), 1, 10) AS day,
95
+ COALESCE(NULLIF(transaction_category, ''), 'unknown') AS category,
96
+ CASE WHEN transaction_status = 'archived' THEN 'archived' ELSE 'active' END AS status,
97
+ CASE
98
+ WHEN transaction_status = 'archived' OR substr(trim(due_date), 1, 10) < ${today} THEN 'done'
99
+ ELSE 'planned'
100
+ END AS kind,
101
+ COALESCE(created_at, '') AS created_at
102
+ FROM ${session.table('transactions')} AS transactions
103
+ ),
104
+ asset_total AS (
105
+ SELECT COALESCE(SUM(amount), 0) AS amount
106
+ FROM ${session.table('assets')} AS assets
107
+ WHERE asset_type = 'asset'
108
+ )`;
109
+ }
110
+
111
+ /** End-of-day balances on every day with plans. Must follow `WITH`. */
112
+ function dayEndCtes(session: ReadModelSession, today: string): RawBuilder<unknown> {
113
+ return sql`
114
+ ${baseCtes(session, today)},
115
+ planned_days AS (
116
+ SELECT day, SUM(amount) AS amount FROM typed WHERE kind = 'planned' GROUP BY day
117
+ ),
118
+ day_end AS (
119
+ SELECT day, (SELECT amount FROM asset_total) + SUM(amount) OVER (
120
+ ORDER BY day ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
121
+ ) AS balance
122
+ FROM planned_days
123
+ )`;
124
+ }
125
+
126
+ export function createTimelineRepository(session: ReadModelSession) {
127
+ return {
128
+ /** Ordered rows for a window: done up to Today, then the rest with running balances for plans. */
129
+ async getRows(options: TimelineRowsOptions): Promise<TimelineRow[]> {
130
+ const { today, start, end, show = 'planned', limit } = options;
131
+ const lowThrough = options.lowThrough ?? FAR_FUTURE;
132
+ const result = await withTimeout(
133
+ session.execute(sql<{
134
+ id: string; name: string; amount: unknown; date: string; category: string; status: string;
135
+ kind: TimelineKind; section: unknown; balance_after: unknown; first_of_day: unknown; is_low: unknown;
136
+ }>`
137
+ WITH ${baseCtes(session, today)},
138
+ running AS (
139
+ SELECT
140
+ id,
141
+ day,
142
+ (SELECT amount FROM asset_total) + SUM(amount) OVER (
143
+ ORDER BY day, created_at, id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
144
+ ) AS balance_after,
145
+ ROW_NUMBER() OVER (PARTITION BY day ORDER BY created_at DESC, id DESC) AS from_day_end
146
+ FROM typed
147
+ WHERE kind = 'planned' AND day <= max(${lowThrough}, ${end})
148
+ ),
149
+ low_day AS (
150
+ SELECT day FROM running
151
+ WHERE from_day_end = 1 AND day <= ${lowThrough} AND balance_after < (SELECT amount FROM asset_total)
152
+ ORDER BY balance_after ASC, day ASC
153
+ LIMIT 1
154
+ ),
155
+ placed AS (
156
+ SELECT
157
+ typed.*,
158
+ CASE WHEN typed.kind = 'done' AND typed.day <= ${today} THEN 0 ELSE 1 END AS section
159
+ FROM typed
160
+ WHERE (${show} = 'all' OR typed.kind = ${show}) AND typed.day >= ${start} AND typed.day <= ${end}
161
+ )
162
+ SELECT
163
+ placed.id,
164
+ placed.name,
165
+ placed.amount,
166
+ placed.day AS date,
167
+ placed.category,
168
+ placed.status,
169
+ placed.kind,
170
+ placed.section,
171
+ running.balance_after,
172
+ ROW_NUMBER() OVER (
173
+ PARTITION BY placed.section, placed.day ORDER BY placed.created_at, placed.id
174
+ ) = 1 AS first_of_day,
175
+ CASE WHEN running.from_day_end = 1 AND running.day = (SELECT day FROM low_day)
176
+ THEN 1 ELSE 0 END AS is_low
177
+ FROM placed
178
+ LEFT JOIN running ON running.id = placed.id
179
+ ORDER BY placed.section, placed.day, placed.created_at, placed.id
180
+ ${limit ? sql`LIMIT ${limit}` : sql``}
181
+ `),
182
+ DB_TIMEOUT_MS,
183
+ 'Timeline query timed out',
184
+ );
185
+ return result.rows.map((row) => ({
186
+ id: String(row.id),
187
+ name: String(row.name ?? ''),
188
+ amount: toNumber(row.amount),
189
+ date: String(row.date),
190
+ category: String(row.category),
191
+ status: row.status === 'archived' ? 'archived' : 'active',
192
+ kind: row.kind,
193
+ section: toNumber(row.section) as TimelineSection,
194
+ balanceAfter: toNullableNumber(row.balance_after),
195
+ firstOfDay: toNumber(row.first_of_day) === 1,
196
+ isLow: toNumber(row.is_low) === 1,
197
+ }));
198
+ },
199
+
200
+ /**
201
+ * Income and expenses, and the projected balance around them, for every
202
+ * month from `from` to `to`. The flows count what the month's header
203
+ * describes: done rows under Done, plans under Planned, and under All
204
+ * done rows before today's month and plans from it on.
205
+ */
206
+ async getMonths(options: { today: string; from: string; to: string; show?: TimelineShow }): Promise<TimelineMonth[]> {
207
+ const { today, from, to, show = 'planned' } = options;
208
+ const result = await withTimeout(
209
+ session.execute(sql<{ month: string; income: unknown; expense: unknown; start_balance: unknown; end_balance: unknown }>`
210
+ WITH RECURSIVE ${baseCtes(session, today)},
211
+ months(month) AS (
212
+ SELECT substr(${from}, 1, 7)
213
+ UNION ALL
214
+ SELECT strftime('%Y-%m', date(month || '-01', '+1 month')) FROM months
215
+ WHERE month < substr(${to}, 1, 7)
216
+ ),
217
+ month_flows AS (
218
+ SELECT
219
+ substr(day, 1, 7) AS month,
220
+ SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) AS income,
221
+ SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense
222
+ FROM typed
223
+ WHERE day >= ${from} AND day <= ${to} AND CASE ${show}
224
+ WHEN 'all' THEN kind = 'planned' OR substr(day, 1, 7) < substr(${today}, 1, 7)
225
+ ELSE kind = ${show}
226
+ END
227
+ GROUP BY substr(day, 1, 7)
228
+ )
229
+ SELECT
230
+ months.month,
231
+ COALESCE(month_flows.income, 0) AS income,
232
+ COALESCE(month_flows.expense, 0) AS expense,
233
+ (SELECT amount FROM asset_total) + COALESCE((
234
+ SELECT SUM(amount) FROM typed WHERE kind = 'planned' AND substr(day, 1, 7) < months.month
235
+ ), 0) AS start_balance,
236
+ (SELECT amount FROM asset_total) + COALESCE((
237
+ SELECT SUM(amount) FROM typed WHERE kind = 'planned' AND substr(day, 1, 7) <= months.month
238
+ ), 0) AS end_balance
239
+ FROM months
240
+ LEFT JOIN month_flows ON month_flows.month = months.month
241
+ ORDER BY months.month
242
+ `),
243
+ DB_TIMEOUT_MS,
244
+ 'Timeline months query timed out',
245
+ );
246
+ return result.rows.map((month) => ({
247
+ month: String(month.month),
248
+ income: toNumber(month.income),
249
+ expense: toNumber(month.expense),
250
+ start: toNumber(month.start_balance),
251
+ end: toNumber(month.end_balance),
252
+ }));
253
+ },
254
+
255
+ /** Available now, the lowest balance across every plan ahead, the balance after the last plan and the latest done day. */
256
+ async getOutlook(options: { today: string }): Promise<TimelineOutlook> {
257
+ const { today } = options;
258
+ const result = await withTimeout(
259
+ session.execute(sql<{ available: unknown; low_amount: unknown; low_date: unknown; end_balance: unknown; end_date: unknown; last_done: unknown }>`
260
+ WITH ${dayEndCtes(session, today)},
261
+ lowest AS (
262
+ SELECT day, balance FROM day_end ORDER BY balance ASC, day ASC LIMIT 1
263
+ )
264
+ SELECT
265
+ (SELECT amount FROM asset_total) AS available,
266
+ CASE WHEN (SELECT balance FROM lowest) < (SELECT amount FROM asset_total)
267
+ THEN (SELECT balance FROM lowest) ELSE (SELECT amount FROM asset_total) END AS low_amount,
268
+ CASE WHEN (SELECT balance FROM lowest) < (SELECT amount FROM asset_total)
269
+ THEN (SELECT day FROM lowest) ELSE ${today} END AS low_date,
270
+ (SELECT amount FROM asset_total) + COALESCE((SELECT SUM(amount) FROM planned_days), 0) AS end_balance,
271
+ (SELECT MAX(day) FROM planned_days) AS end_date,
272
+ (SELECT MAX(day) FROM typed WHERE kind = 'done') AS last_done
273
+ `),
274
+ DB_TIMEOUT_MS,
275
+ 'Timeline outlook query timed out',
276
+ );
277
+ const row = result.rows[0];
278
+ return {
279
+ available: toNumber(row?.available),
280
+ low: { amount: toNumber(row?.low_amount), date: row?.low_date ? String(row.low_date) : today },
281
+ end: toNumber(row?.end_balance),
282
+ endDate: row?.end_date ? String(row.end_date) : null,
283
+ lastDone: row?.last_done ? String(row.last_done) : null,
284
+ };
285
+ },
286
+
287
+ /** Lowest and highest balance from now through every plan: available now and each plan day's end. */
288
+ async getRange(options: { today: string }): Promise<BalanceRange> {
289
+ const { today } = options;
290
+ const result = await withTimeout(
291
+ session.execute(sql<{ min_balance: unknown; max_balance: unknown }>`
292
+ WITH ${dayEndCtes(session, today)}
293
+ SELECT
294
+ min(COALESCE((SELECT MIN(balance) FROM day_end), amount), amount) AS min_balance,
295
+ max(COALESCE((SELECT MAX(balance) FROM day_end), amount), amount) AS max_balance
296
+ FROM asset_total
297
+ `),
298
+ DB_TIMEOUT_MS,
299
+ 'Timeline range query timed out',
300
+ );
301
+ const row = result.rows[0];
302
+ return { min: toNumber(row?.min_balance), max: toNumber(row?.max_balance) };
303
+ },
304
+ };
305
+ }
@@ -0,0 +1,256 @@
1
+ import type { ReadModelSession } from '@hearthlog/sdk/data';
2
+ import { withTimeout } from '@hearthlog/sdk/data';
3
+ import { endOfMonth, format, startOfMonth } from 'date-fns';
4
+ import { sql } from 'kysely';
5
+
6
+ export type ForecastPoint = { date: string; amount: number };
7
+
8
+ /** A forecast series plus the summary values charts and widgets render. */
9
+ export interface ForecastSummary {
10
+ /** Full generated series, for charts. */
11
+ points: ForecastPoint[];
12
+ /** Points inside the requested month, for the Today sparkline. */
13
+ monthPoints: ForecastPoint[];
14
+ first: number;
15
+ end: number;
16
+ low: number;
17
+ monthEnd: number;
18
+ monthLow: number;
19
+ }
20
+
21
+ interface ForecastRow {
22
+ date: string;
23
+ amount: number | string | null;
24
+ current_assets: number | string | null;
25
+ forecast_end: number | string | null;
26
+ forecast_low: number | string | null;
27
+ month_end: number | string | null;
28
+ month_low: number | string | null;
29
+ }
30
+
31
+ const FORECAST_ASSET_TYPE = 'asset';
32
+ const DB_TIMEOUT_MS = 5000;
33
+
34
+ function toNumber(value: number | string | bigint | null | undefined): number {
35
+ const parsed = Number(value ?? 0);
36
+ return Number.isFinite(parsed) ? parsed : 0;
37
+ }
38
+
39
+ function forecastRowsQuery(
40
+ session: ReadModelSession,
41
+ today: string,
42
+ horizonEnd: string,
43
+ monthStart: string,
44
+ monthEnd: string,
45
+ ) {
46
+ return sql<ForecastRow>`
47
+ WITH RECURSIVE days(day) AS (
48
+ SELECT ${today}
49
+ UNION ALL
50
+ SELECT date(day, '+1 day') FROM days WHERE day < ${horizonEnd}
51
+ ),
52
+ asset_total AS (
53
+ SELECT COALESCE(SUM(amount), 0) AS amount
54
+ FROM ${session.table('assets')} AS assets
55
+ WHERE asset_type = ${FORECAST_ASSET_TYPE}
56
+ ),
57
+ daily_plans AS (
58
+ SELECT substr(trim(due_date), 1, 10) AS day, SUM(amount) AS amount
59
+ FROM ${session.table('transactions')} AS transactions
60
+ WHERE transaction_status = 'active'
61
+ AND due_date >= ${today}
62
+ AND due_date <= ${horizonEnd}
63
+ GROUP BY substr(trim(due_date), 1, 10)
64
+ ),
65
+ forecast AS (
66
+ SELECT
67
+ days.day AS date,
68
+ asset_total.amount + COALESCE(
69
+ SUM(COALESCE(daily_plans.amount, 0)) OVER (
70
+ ORDER BY days.day ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
71
+ ),
72
+ 0
73
+ ) AS amount,
74
+ asset_total.amount AS current_assets
75
+ FROM days
76
+ CROSS JOIN asset_total
77
+ LEFT JOIN daily_plans ON daily_plans.day = days.day
78
+ )
79
+ SELECT
80
+ date,
81
+ amount,
82
+ current_assets,
83
+ FIRST_VALUE(amount) OVER (ORDER BY date DESC) AS forecast_end,
84
+ MIN(amount) OVER () AS forecast_low,
85
+ (
86
+ SELECT amount FROM forecast AS month_end_forecast
87
+ WHERE month_end_forecast.date >= ${monthStart}
88
+ AND month_end_forecast.date <= ${monthEnd}
89
+ ORDER BY month_end_forecast.date DESC
90
+ LIMIT 1
91
+ ) AS month_end,
92
+ (
93
+ SELECT MIN(amount) FROM forecast AS month_low_forecast
94
+ WHERE month_low_forecast.date >= ${monthStart}
95
+ AND month_low_forecast.date <= ${monthEnd}
96
+ ) AS month_low
97
+ FROM forecast
98
+ ORDER BY date
99
+ `;
100
+ }
101
+
102
+ function monthForecastRowsQuery(
103
+ session: ReadModelSession,
104
+ today: string,
105
+ horizonEnd: string,
106
+ monthStart: string,
107
+ monthEnd: string,
108
+ ) {
109
+ return sql<Pick<ForecastRow, 'date' | 'amount'>>`
110
+ WITH RECURSIVE days(day) AS (
111
+ SELECT ${today}
112
+ UNION ALL
113
+ SELECT date(day, '+1 day') FROM days WHERE day < ${horizonEnd}
114
+ ),
115
+ asset_total AS (
116
+ SELECT COALESCE(SUM(amount), 0) AS amount
117
+ FROM ${session.table('assets')} AS assets
118
+ WHERE asset_type = ${FORECAST_ASSET_TYPE}
119
+ ),
120
+ daily_plans AS (
121
+ SELECT substr(trim(due_date), 1, 10) AS day, SUM(amount) AS amount
122
+ FROM ${session.table('transactions')} AS transactions
123
+ WHERE transaction_status = 'active'
124
+ AND due_date >= ${today}
125
+ AND due_date <= ${horizonEnd}
126
+ GROUP BY substr(trim(due_date), 1, 10)
127
+ ),
128
+ forecast AS (
129
+ SELECT
130
+ days.day AS date,
131
+ asset_total.amount + COALESCE(
132
+ SUM(COALESCE(daily_plans.amount, 0)) OVER (
133
+ ORDER BY days.day ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
134
+ ),
135
+ 0
136
+ ) AS amount
137
+ FROM days
138
+ CROSS JOIN asset_total
139
+ LEFT JOIN daily_plans ON daily_plans.day = days.day
140
+ )
141
+ SELECT date, amount
142
+ FROM forecast
143
+ WHERE date >= ${monthStart}
144
+ AND date <= ${monthEnd}
145
+ ORDER BY date
146
+ `;
147
+ }
148
+
149
+ function forecastBounds(year: number, month: number, today: string) {
150
+ const requestedMonth = new Date(year, month - 1, 1);
151
+ const requestedMonthEnd = format(endOfMonth(requestedMonth), 'yyyy-MM-dd');
152
+ return {
153
+ horizonEnd: requestedMonthEnd < today ? today : requestedMonthEnd,
154
+ monthStart: format(startOfMonth(requestedMonth), 'yyyy-MM-dd'),
155
+ monthEnd: requestedMonthEnd,
156
+ };
157
+ }
158
+
159
+ function toPoints(rows: Array<Pick<ForecastRow, 'date' | 'amount'>>): ForecastPoint[] {
160
+ return rows.map((row) => ({ date: String(row.date), amount: toNumber(row.amount) }));
161
+ }
162
+
163
+ export function createForecastRepository(session: ReadModelSession) {
164
+ return {
165
+ async getForecast(year: number, month: number, today: string): Promise<ForecastPoint[]> {
166
+ const { horizonEnd, monthStart, monthEnd } = forecastBounds(year, month, today);
167
+ const result = await withTimeout(
168
+ session.execute(forecastRowsQuery(session, today, horizonEnd, monthStart, monthEnd)),
169
+ DB_TIMEOUT_MS,
170
+ 'Forecast query timed out',
171
+ );
172
+ return toPoints(result.rows);
173
+ },
174
+
175
+ /** Daily points and summary values, filtered and aggregated in SQLite. */
176
+ async getForecastSummary(year: number, month: number, today: string): Promise<ForecastSummary> {
177
+ const { horizonEnd, monthStart, monthEnd } = forecastBounds(year, month, today);
178
+ const [forecastResult, monthResult] = await withTimeout(
179
+ Promise.all([
180
+ session.execute(forecastRowsQuery(session, today, horizonEnd, monthStart, monthEnd)),
181
+ session.execute(monthForecastRowsQuery(session, today, horizonEnd, monthStart, monthEnd)),
182
+ ]),
183
+ DB_TIMEOUT_MS,
184
+ 'Forecast summary queries timed out',
185
+ );
186
+ const summary = forecastResult.rows[0];
187
+ return {
188
+ points: toPoints(forecastResult.rows),
189
+ monthPoints: toPoints(monthResult.rows),
190
+ first: toNumber(summary?.current_assets),
191
+ end: toNumber(summary?.forecast_end),
192
+ low: toNumber(summary?.forecast_low),
193
+ monthEnd: toNumber(summary?.month_end),
194
+ monthLow: toNumber(summary?.month_low),
195
+ };
196
+ },
197
+
198
+ /** Full-year balance points for the static year-wide chart.
199
+ * Past dates show the actual balance (current assets minus all
200
+ * transactions that occurred after that date). Future dates show
201
+ * the forecast (current assets plus active planned transactions). */
202
+ async getYearForecast(year: number, today: string): Promise<ForecastPoint[]> {
203
+ const yearStart = format(new Date(year, 0, 1), 'yyyy-MM-dd');
204
+ const yearEnd = format(new Date(year, 11, 31), 'yyyy-MM-dd');
205
+ const pastEnd = yearEnd < today ? yearEnd : today;
206
+
207
+ const query = sql<Pick<ForecastRow, 'date' | 'amount'>>`
208
+ WITH RECURSIVE days(day) AS (
209
+ SELECT ${yearStart}
210
+ UNION ALL
211
+ SELECT date(day, '+1 day') FROM days WHERE day < ${yearEnd}
212
+ ),
213
+ asset_total AS (
214
+ SELECT COALESCE(SUM(amount), 0) AS amount
215
+ FROM ${session.table('assets')} AS assets
216
+ WHERE asset_type = ${FORECAST_ASSET_TYPE}
217
+ ),
218
+ year_start_balance AS (
219
+ SELECT
220
+ (SELECT amount FROM asset_total)
221
+ - COALESCE((
222
+ SELECT SUM(amount)
223
+ FROM ${session.table('transactions')}
224
+ WHERE due_date >= ${yearStart} AND due_date <= ${pastEnd}
225
+ ), 0) AS start_balance
226
+ ),
227
+ daily_effects AS (
228
+ SELECT substr(trim(due_date), 1, 10) AS day, SUM(amount) AS amount
229
+ FROM ${session.table('transactions')} AS transactions
230
+ WHERE
231
+ (due_date >= ${yearStart} AND due_date <= ${pastEnd})
232
+ OR (transaction_status = 'active' AND due_date > ${pastEnd} AND due_date <= ${yearEnd})
233
+ GROUP BY substr(trim(due_date), 1, 10)
234
+ ),
235
+ balance AS (
236
+ SELECT
237
+ days.day AS date,
238
+ (SELECT start_balance FROM year_start_balance)
239
+ + COALESCE(SUM(daily_effects.amount) OVER (
240
+ ORDER BY days.day ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
241
+ ), 0) AS amount
242
+ FROM days
243
+ LEFT JOIN daily_effects ON daily_effects.day = days.day
244
+ )
245
+ SELECT date, amount FROM balance ORDER BY date
246
+ `;
247
+
248
+ const result = await withTimeout(
249
+ session.execute(query),
250
+ DB_TIMEOUT_MS,
251
+ 'Year forecast query timed out',
252
+ );
253
+ return toPoints(result.rows);
254
+ },
255
+ };
256
+ }
@@ -0,0 +1,57 @@
1
+ import { sql } from 'kysely';
2
+ import { withTimeout, type ReadModelSession } from '@hearthlog/sdk/data';
3
+
4
+ const DB_TIMEOUT_MS = 5000;
5
+
6
+ export interface PlannedDay {
7
+ /** `YYYY-MM-DD`. */
8
+ day: string;
9
+ count: number;
10
+ total: number;
11
+ /** The largest plans of the day, comma separated. */
12
+ names: string;
13
+ }
14
+
15
+ function toCount(value: number | string | null | undefined): number {
16
+ const count = Number(value ?? 0);
17
+ return Number.isFinite(count) ? count : 0;
18
+ }
19
+
20
+ export function createPlannedDaysRepository(session: ReadModelSession) {
21
+ return {
22
+ /** Plans (active transactions) per day in `[from, until]`: how many, their net, and the largest names. */
23
+ async plannedDays(from: string, until: string, limit: number): Promise<PlannedDay[]> {
24
+ const result = await withTimeout(
25
+ session.execute(sql<{ day: string; count: number | string; total: number | string | null; names: string | null }>`
26
+ WITH planned AS (
27
+ SELECT SUBSTR(TRIM(t.due_date), 1, 10) AS day,
28
+ COALESCE(NULLIF(TRIM(t.name), ''), 'Unnamed') AS name,
29
+ t.amount AS amount,
30
+ ROW_NUMBER() OVER (
31
+ PARTITION BY SUBSTR(TRIM(t.due_date), 1, 10)
32
+ ORDER BY ABS(t.amount) DESC, t.id
33
+ ) AS pos
34
+ FROM ${session.table('transactions')} AS t
35
+ WHERE t.transaction_status = 'active'
36
+ AND SUBSTR(TRIM(t.due_date), 1, 10) >= ${from}
37
+ AND SUBSTR(TRIM(t.due_date), 1, 10) <= ${until}
38
+ ), ranked AS (SELECT * FROM planned ORDER BY day, pos)
39
+ SELECT day, COUNT(*) AS count, SUM(amount) AS total,
40
+ GROUP_CONCAT(CASE WHEN pos <= 3 THEN name END, ', ') AS names
41
+ FROM ranked
42
+ GROUP BY day
43
+ ORDER BY day ASC
44
+ LIMIT ${limit}
45
+ `),
46
+ DB_TIMEOUT_MS,
47
+ 'Planned days query timed out',
48
+ );
49
+ return result.rows.map((row) => ({
50
+ day: row.day,
51
+ count: toCount(row.count),
52
+ total: Number(row.total ?? 0),
53
+ names: row.names ?? '',
54
+ }));
55
+ },
56
+ };
57
+ }
@@ -0,0 +1,20 @@
1
+ /** Finance's read repositories, added to `queryDatabase` by the composer. */
2
+
3
+ import type { ReadModelSession } from '@hearthlog/sdk/data';
4
+ import { createAssetRepository } from './assets.js';
5
+ import { createBudgetRepository } from './budgets.js';
6
+ import { createTimelineRepository } from './finance-timeline.js';
7
+ import { createForecastRepository } from './forecast.js';
8
+ import { createPlannedDaysRepository } from './planned-days.js';
9
+ import { createTransactionRepository } from './transactions.js';
10
+
11
+ export function createRepositories(session: ReadModelSession) {
12
+ return {
13
+ assets: createAssetRepository(session),
14
+ budgets: createBudgetRepository(session),
15
+ forecasts: createForecastRepository(session),
16
+ plannedDays: createPlannedDaysRepository(session),
17
+ timeline: createTimelineRepository(session),
18
+ transactions: createTransactionRepository(session),
19
+ };
20
+ }