@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.
- package/LICENSE +235 -0
- package/README.md +11 -2
- package/hearthlog.module.json +90 -0
- package/package.json +44 -3
- package/src/attention.ts +41 -0
- package/src/components/finance/AddAssetForm.svelte +58 -0
- package/src/components/finance/AddTransactionForm.svelte +127 -0
- package/src/components/finance/AssetDrawer.svelte +133 -0
- package/src/components/finance/AssetItem.svelte +96 -0
- package/src/components/finance/AssetView.svelte +165 -0
- package/src/components/finance/AssetsSheet.svelte +122 -0
- package/src/components/finance/BudgetItemActionsCell.svelte +18 -0
- package/src/components/finance/BudgetItemAmountCell.svelte +5 -0
- package/src/components/finance/BudgetItemCategoryCell.svelte +7 -0
- package/src/components/finance/BudgetItemDrawer.svelte +116 -0
- package/src/components/finance/BudgetItemNameCell.svelte +5 -0
- package/src/components/finance/BudgetItemSelectCell.svelte +25 -0
- package/src/components/finance/BudgetItemsTable.svelte +206 -0
- package/src/components/finance/BudgetPlanDrawer.svelte +89 -0
- package/src/components/finance/BudgetPlans.svelte +419 -0
- package/src/components/finance/EndlessTimeline.svelte +174 -0
- package/src/components/finance/FinanceReadout.svelte +229 -0
- package/src/components/finance/MonthSection.svelte +255 -0
- package/src/components/finance/RiverSlice.svelte +125 -0
- package/src/components/finance/SortUncategorizedSheet.svelte +198 -0
- package/src/components/finance/TimelineRowItem.svelte +319 -0
- package/src/components/finance/TransactionDrawer.svelte +480 -0
- package/src/components/finance/TransactionSelectionBar.svelte +200 -0
- package/src/components/finance/timeline-months.svelte.ts +37 -0
- package/src/components/finance/transaction-draft.ts +121 -0
- package/src/data/assets.ts +48 -0
- package/src/data/budgets.ts +123 -0
- package/src/data/definitions.ts +135 -0
- package/src/data/finance-timeline.ts +305 -0
- package/src/data/forecast.ts +256 -0
- package/src/data/planned-days.ts +57 -0
- package/src/data/repositories.ts +20 -0
- package/src/data/transactions.ts +452 -0
- package/src/finance/categories.ts +29 -0
- package/src/finance/category-pick.ts +8 -0
- package/src/finance/category-suggestions.ts +65 -0
- package/src/finance/floor.ts +29 -0
- package/src/finance/phone-balance.ts +22 -0
- package/src/finance/timeline.ts +113 -0
- package/src/pages/index.svelte +19 -0
- package/src/pages/index.ts +7 -0
- package/src/pages/layout.svelte +13 -0
- package/src/pages/layout.ts +18 -0
- package/src/pages/month.svelte +909 -0
- package/src/pages/month.ts +99 -0
- package/src/server/category-pick.ts +44 -0
- package/src/server/index.ts +11 -0
- package/src/styles.css +12 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { deferPage, type PagePublisher } from '@hearthlog/sdk/runtime';
|
|
2
|
+
import type { ModuleLoadEvent } from '@hearthlog/sdk/runtime';
|
|
3
|
+
import type { Asset } from '../data/assets.js';
|
|
4
|
+
import type { BudgetItem } from '@hearthlog/sdk/data';
|
|
5
|
+
import type { BudgetItemSort, BudgetPlanSummary } from '../data/budgets.js';
|
|
6
|
+
import type { BalanceRange, TimelineOutlook } from '../data/finance-timeline.js';
|
|
7
|
+
import { queryDatabase } from '@hearthlog/sdk/data';
|
|
8
|
+
import { parseBudgetItemSort } from '../data/budgets.js';
|
|
9
|
+
import { loadBalanceFloor } from '../finance/floor.js';
|
|
10
|
+
import { appClock } from '@hearthlog/sdk/runtime';
|
|
11
|
+
import { format } from 'date-fns';
|
|
12
|
+
|
|
13
|
+
export interface FinancePageData {
|
|
14
|
+
/** The month the endless views open at; today's month opens at Today. */
|
|
15
|
+
year: number;
|
|
16
|
+
month: number;
|
|
17
|
+
today: string;
|
|
18
|
+
outlook: TimelineOutlook;
|
|
19
|
+
/** Lowest and highest balance over all history and every plan: one fixed scale. */
|
|
20
|
+
range: BalanceRange;
|
|
21
|
+
floor: number;
|
|
22
|
+
assets: Asset[];
|
|
23
|
+
assetsSum: number;
|
|
24
|
+
budgetPlans: BudgetPlanSummary[];
|
|
25
|
+
budgetIncomeItems: BudgetItem[];
|
|
26
|
+
budgetExpenseItems: BudgetItem[];
|
|
27
|
+
selectedPlanId: string | null;
|
|
28
|
+
incomeSort: BudgetItemSort | null;
|
|
29
|
+
expenseSort: BudgetItemSort | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function emptyData(year: number, month: number): FinancePageData {
|
|
33
|
+
return {
|
|
34
|
+
year,
|
|
35
|
+
month,
|
|
36
|
+
today: '',
|
|
37
|
+
outlook: { available: 0, low: { amount: 0, date: '' }, end: 0, endDate: null, lastDone: null },
|
|
38
|
+
range: { min: 0, max: 0 },
|
|
39
|
+
floor: 0,
|
|
40
|
+
assets: [],
|
|
41
|
+
assetsSum: 0,
|
|
42
|
+
budgetPlans: [],
|
|
43
|
+
budgetIncomeItems: [],
|
|
44
|
+
budgetExpenseItems: [],
|
|
45
|
+
selectedPlanId: null,
|
|
46
|
+
incomeSort: null,
|
|
47
|
+
expenseSort: null,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const loadData = async (
|
|
52
|
+
{ params, parent, url }: ModuleLoadEvent,
|
|
53
|
+
publish: PagePublisher<FinancePageData>,
|
|
54
|
+
): Promise<FinancePageData> => {
|
|
55
|
+
// The layout's load supplies the assets; the route loader receives untyped parent data.
|
|
56
|
+
const { assetsData } = await parent() as { assetsData: Promise<{ assets: Asset[]; assetsSum: number }> };
|
|
57
|
+
const assetsReady = assetsData.then((value) => { publish(value, 'assets'); return value; });
|
|
58
|
+
void assetsReady.catch(() => undefined);
|
|
59
|
+
const now = await appClock.ready();
|
|
60
|
+
const today = format(now, 'yyyy-MM-dd');
|
|
61
|
+
const isBudgetTab = url.searchParams.get('tab') === 'budget';
|
|
62
|
+
const requestedPlanId = url.searchParams.get('plan');
|
|
63
|
+
const incomeSort = parseBudgetItemSort(url.searchParams.get('incomeSort'));
|
|
64
|
+
const expenseSort = parseBudgetItemSort(url.searchParams.get('expenseSort'));
|
|
65
|
+
|
|
66
|
+
// The endless views read the months and weeks that scroll into view on
|
|
67
|
+
// their own; the page loads what spans all of them.
|
|
68
|
+
const [{ assets, assetsSum }, floor, result] = await Promise.all([
|
|
69
|
+
assetsReady,
|
|
70
|
+
loadBalanceFloor(),
|
|
71
|
+
queryDatabase(async (repositories) => {
|
|
72
|
+
const [outlook, range, budgetPlans] = await Promise.all([
|
|
73
|
+
repositories.timeline.getOutlook({ today }),
|
|
74
|
+
repositories.timeline.getRange({ today }),
|
|
75
|
+
isBudgetTab ? repositories.budgets.getPlans() : Promise.resolve([] as BudgetPlanSummary[]),
|
|
76
|
+
]);
|
|
77
|
+
const selectedPlan = budgetPlans.find((plan) => plan.id === requestedPlanId) ?? budgetPlans[0];
|
|
78
|
+
const [budgetIncomeItems, budgetExpenseItems] = selectedPlan
|
|
79
|
+
? await Promise.all([
|
|
80
|
+
repositories.budgets.getPlanItems(selectedPlan.id, 'income', incomeSort),
|
|
81
|
+
repositories.budgets.getPlanItems(selectedPlan.id, 'expense', expenseSort),
|
|
82
|
+
])
|
|
83
|
+
: [[], []];
|
|
84
|
+
return { outlook, range, budgetPlans, budgetIncomeItems, budgetExpenseItems, selectedPlanId: selectedPlan ? selectedPlan.id : null };
|
|
85
|
+
}),
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
return { year: Number(params.year ?? 0), month: Number(params.month ?? 0), today, floor, assets, assetsSum, incomeSort, expenseSort, ...result };
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const load = (event: ModuleLoadEvent) => {
|
|
92
|
+
event.depends('app:transactions');
|
|
93
|
+
event.depends('app:budgets');
|
|
94
|
+
return deferPage<FinancePageData>(
|
|
95
|
+
event,
|
|
96
|
+
emptyData(Number(event.params.year ?? 0), Number(event.params.month ?? 0)),
|
|
97
|
+
(publish) => loadData(event, publish),
|
|
98
|
+
);
|
|
99
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { aiInput, AiRequestError, type AiCapabilityDefinition } from '@hearthlog/contracts';
|
|
2
|
+
import { TRANSACTION_CATEGORIES } from '../finance/categories.js';
|
|
3
|
+
import type { CategoryPickItem } from '../finance/category-pick.js';
|
|
4
|
+
|
|
5
|
+
const MAX_NAME = 200;
|
|
6
|
+
|
|
7
|
+
const MEANINGS: Record<string, string> = {
|
|
8
|
+
income: 'Money received: salary, refunds, interest',
|
|
9
|
+
housing: 'Rent, mortgage, energy, water, home repairs',
|
|
10
|
+
food: 'Groceries, restaurants, cafés, takeaway',
|
|
11
|
+
transport: 'Fuel, public transport, taxis, parking, car costs',
|
|
12
|
+
subscriptions: 'Recurring digital or membership services',
|
|
13
|
+
health: 'Doctors, pharmacy, insurance for health, sport',
|
|
14
|
+
shopping: 'Clothes, electronics, household goods, online shops',
|
|
15
|
+
leisure: 'Entertainment, hobbies, culture, trips',
|
|
16
|
+
kids: 'Children: school, childcare, toys, clothing',
|
|
17
|
+
gifts: 'Gifts and donations',
|
|
18
|
+
other: 'A real expense that fits none of the others',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Which category a bank transaction belongs to, picked by a small decision model. */
|
|
22
|
+
export const categoryPick: AiCapabilityDefinition = {
|
|
23
|
+
options: () => TRANSACTION_CATEGORIES
|
|
24
|
+
.filter((category) => category.value !== 'unknown')
|
|
25
|
+
.map((category) => ({ value: category.value, meaning: MEANINGS[category.value] ?? category.label })),
|
|
26
|
+
parseItem(value): CategoryPickItem {
|
|
27
|
+
const item = aiInput.object(value);
|
|
28
|
+
const direction = item.direction;
|
|
29
|
+
if (direction !== 'income' && direction !== 'expense') throw new AiRequestError('Invalid direction');
|
|
30
|
+
const amount = item.amount;
|
|
31
|
+
if (typeof amount !== 'number' || !Number.isFinite(amount) || amount < 0 || amount > 1e12) {
|
|
32
|
+
throw new AiRequestError('Invalid amount');
|
|
33
|
+
}
|
|
34
|
+
return { id: aiInput.id(item.id), name: aiInput.text(item.name, MAX_NAME), amount, direction };
|
|
35
|
+
},
|
|
36
|
+
question(item) {
|
|
37
|
+
const transaction = item as CategoryPickItem;
|
|
38
|
+
return {
|
|
39
|
+
state: `Bank transaction of a Czech household. Name: ${JSON.stringify(transaction.name)}. ` +
|
|
40
|
+
`Amount: ${transaction.amount} CZK (${transaction.direction}).`,
|
|
41
|
+
instructions: 'Which category does this transaction belong to? Answer none if the name is unclear or no category fits.',
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ServerModule } from '@hearthlog/sdk/server';
|
|
2
|
+
import { categoryPick } from './category-pick.js';
|
|
3
|
+
|
|
4
|
+
/** Finance's part of the gateway Worker: the AI decision that suggests a transaction's category. */
|
|
5
|
+
const finance: ServerModule<Env> = {
|
|
6
|
+
id: 'org.stor.finance',
|
|
7
|
+
routes: [],
|
|
8
|
+
ai: { 'category-pick': categoryPick },
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export default finance;
|
package/src/styles.css
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/* Finance's recipes: the Today card link and the phone list's running balance. Imported by the host's stylesheet. */
|
|
2
|
+
|
|
3
|
+
@layer components {
|
|
4
|
+
a > .stor-today-card { transition: transform 160ms ease, box-shadow 160ms ease; }
|
|
5
|
+
a > .stor-today-card:hover { transform: translateY(-1px); box-shadow: var(--stor-shadow-md); }
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
@media (max-width: 767px) {
|
|
9
|
+
/* Lists show their figures only when asked: the running balance, the balance line, month figures. */
|
|
10
|
+
[data-phone-balance="off"] [data-balance], [data-phone-balance="off"] [data-river] { display: none; }
|
|
11
|
+
[data-phone-balance="off"] [data-timeline-scroll] { --river: 0px !important; }
|
|
12
|
+
}
|