@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,25 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// The checkbox reads selection state through a getter so the cell updates
|
|
3
|
+
// even when the table renderer captured its props once.
|
|
4
|
+
let {
|
|
5
|
+
name,
|
|
6
|
+
isSelected,
|
|
7
|
+
onToggle,
|
|
8
|
+
}: {
|
|
9
|
+
name: string;
|
|
10
|
+
isSelected: () => boolean;
|
|
11
|
+
onToggle: (selected: boolean) => void;
|
|
12
|
+
} = $props();
|
|
13
|
+
|
|
14
|
+
const selected = $derived(isSelected());
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<label class="flex h-11 w-11 items-center justify-center">
|
|
18
|
+
<input
|
|
19
|
+
type="checkbox"
|
|
20
|
+
class="h-4 w-4 rounded border-[var(--stor-field-border)] accent-[var(--stor-action)]"
|
|
21
|
+
checked={selected}
|
|
22
|
+
aria-label={`Select ${name}`}
|
|
23
|
+
onchange={(event) => onToggle(event.currentTarget.checked)}
|
|
24
|
+
/>
|
|
25
|
+
</label>
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { formatMoney } from "@hearthlog/sdk/format";
|
|
3
|
+
import { renderComponent } from "@tanstack/svelte-table";
|
|
4
|
+
import { DataTable } from "@hearthlog/sdk/table";
|
|
5
|
+
import { DataTableHeader } from "@hearthlog/sdk/table";
|
|
6
|
+
import { createAppColumnHelper } from "@hearthlog/sdk/table";
|
|
7
|
+
import type { DataTableColumn, DataTableSorting } from "@hearthlog/sdk/table";
|
|
8
|
+
import { CommandOnly } from "@hearthlog/sdk/ui";
|
|
9
|
+
import type { BudgetItem } from "@hearthlog/sdk/data";
|
|
10
|
+
import type { BudgetItemSort } from "../../data/budgets.js";
|
|
11
|
+
import IconPlus from "~icons/mdi/plus";
|
|
12
|
+
import { categoryLabel } from "../../finance/categories.js";
|
|
13
|
+
import BudgetItemActionsCell from "./BudgetItemActionsCell.svelte";
|
|
14
|
+
import BudgetItemAmountCell from "./BudgetItemAmountCell.svelte";
|
|
15
|
+
import BudgetItemCategoryCell from "./BudgetItemCategoryCell.svelte";
|
|
16
|
+
import BudgetItemNameCell from "./BudgetItemNameCell.svelte";
|
|
17
|
+
import BudgetItemSelectCell from "./BudgetItemSelectCell.svelte";
|
|
18
|
+
|
|
19
|
+
let {
|
|
20
|
+
type,
|
|
21
|
+
label,
|
|
22
|
+
total,
|
|
23
|
+
items,
|
|
24
|
+
sort,
|
|
25
|
+
selectedIds,
|
|
26
|
+
onSelectionChange,
|
|
27
|
+
onAdd,
|
|
28
|
+
onEdit,
|
|
29
|
+
onDelete,
|
|
30
|
+
onSortChange,
|
|
31
|
+
}: {
|
|
32
|
+
type: "income" | "expense";
|
|
33
|
+
label: string;
|
|
34
|
+
total: number;
|
|
35
|
+
items: BudgetItem[];
|
|
36
|
+
sort: BudgetItemSort | null;
|
|
37
|
+
selectedIds: Set<string>;
|
|
38
|
+
onSelectionChange: (items: BudgetItem[], selected: boolean) => void;
|
|
39
|
+
onAdd: () => void;
|
|
40
|
+
onEdit: (item: BudgetItem) => void;
|
|
41
|
+
onDelete: (item: BudgetItem) => void;
|
|
42
|
+
onSortChange: (sort: BudgetItemSort | null) => void;
|
|
43
|
+
} = $props();
|
|
44
|
+
|
|
45
|
+
const moneyFormatter = { format: (value: number) => formatMoney(value) };
|
|
46
|
+
|
|
47
|
+
/** Maps the URL sort value onto the shared table's sorting state and back. */
|
|
48
|
+
function toTableSorting(value: BudgetItemSort | null): DataTableSorting {
|
|
49
|
+
if (value === "name-asc") return { id: "name", desc: false };
|
|
50
|
+
if (value === "name-desc") return { id: "name", desc: true };
|
|
51
|
+
if (value === "amount-asc") return { id: "amount", desc: false };
|
|
52
|
+
if (value === "amount-desc") return { id: "amount", desc: true };
|
|
53
|
+
return { id: null, desc: false };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function fromTableSorting(sorting: DataTableSorting): BudgetItemSort | null {
|
|
57
|
+
if (sorting.id !== "name" && sorting.id !== "amount") return null;
|
|
58
|
+
return `${sorting.id}-${sorting.desc ? "desc" : "asc"}` as BudgetItemSort;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Columns are built once; DataTableHeader cycles ascending, descending, then
|
|
62
|
+
// clears the sort, which lands back on creation order.
|
|
63
|
+
const columnHelper = createAppColumnHelper<Record<string, unknown>>();
|
|
64
|
+
const columns: DataTableColumn[] = columnHelper.columns([
|
|
65
|
+
columnHelper.display({
|
|
66
|
+
id: "__select",
|
|
67
|
+
enableSorting: false,
|
|
68
|
+
header: () => renderComponent(DataTableHeader, { name: "" }),
|
|
69
|
+
cell: (info) => renderComponent(BudgetItemSelectCell, {
|
|
70
|
+
name: String(info.row.original.name ?? ""),
|
|
71
|
+
isSelected: () => selectedIds.has(String(info.row.original.id)),
|
|
72
|
+
onToggle: (selected: boolean) =>
|
|
73
|
+
onSelectionChange([info.row.original as unknown as BudgetItem], selected),
|
|
74
|
+
}),
|
|
75
|
+
}),
|
|
76
|
+
columnHelper.accessor((row) => row.name, {
|
|
77
|
+
id: "name",
|
|
78
|
+
enableSorting: true,
|
|
79
|
+
header: (context) => renderComponent(DataTableHeader, { name: "Name", context }),
|
|
80
|
+
cell: (info) => renderComponent(BudgetItemNameCell, { name: String(info.getValue() ?? "") }),
|
|
81
|
+
}),
|
|
82
|
+
columnHelper.accessor((row) => row.category, {
|
|
83
|
+
id: "category",
|
|
84
|
+
enableSorting: false,
|
|
85
|
+
header: () => renderComponent(DataTableHeader, { name: "Category" }),
|
|
86
|
+
cell: (info) => renderComponent(BudgetItemCategoryCell, { category: String(info.getValue() ?? "") }),
|
|
87
|
+
}),
|
|
88
|
+
columnHelper.accessor((row) => row.amount, {
|
|
89
|
+
id: "amount",
|
|
90
|
+
enableSorting: true,
|
|
91
|
+
header: (context) => renderComponent(DataTableHeader, { name: "Amount", align: "right", context }),
|
|
92
|
+
cell: (info) => renderComponent(BudgetItemAmountCell, {
|
|
93
|
+
text: moneyFormatter.format(Number(info.getValue() ?? 0)),
|
|
94
|
+
tone: type,
|
|
95
|
+
}),
|
|
96
|
+
}),
|
|
97
|
+
columnHelper.display({
|
|
98
|
+
id: "__actions",
|
|
99
|
+
enableSorting: false,
|
|
100
|
+
header: () => renderComponent(DataTableHeader, { name: "Actions", align: "right" }),
|
|
101
|
+
cell: (info) => renderComponent(BudgetItemActionsCell, {
|
|
102
|
+
name: String(info.row.original.name ?? ""),
|
|
103
|
+
onEdit: () => onEdit(info.row.original as unknown as BudgetItem),
|
|
104
|
+
onDelete: () => onDelete(info.row.original as unknown as BudgetItem),
|
|
105
|
+
}),
|
|
106
|
+
}),
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
const sorting = $derived(toTableSorting(sort));
|
|
110
|
+
const rows = $derived(items as unknown as Record<string, unknown>[]);
|
|
111
|
+
const sectionSelectedCount = $derived(
|
|
112
|
+
items.filter((item) => selectedIds.has(item.id)).length,
|
|
113
|
+
);
|
|
114
|
+
const sectionAllSelected = $derived(
|
|
115
|
+
items.length > 0 && sectionSelectedCount === items.length,
|
|
116
|
+
);
|
|
117
|
+
const pagination = $derived({
|
|
118
|
+
pageIndex: 0,
|
|
119
|
+
pageSize: Math.max(items.length, 1),
|
|
120
|
+
rowCount: items.length,
|
|
121
|
+
pageCount: 1,
|
|
122
|
+
});
|
|
123
|
+
</script>
|
|
124
|
+
|
|
125
|
+
<div class="budget-table border-t border-[var(--stor-border)]" data-testid={`budget-group-${type}`}>
|
|
126
|
+
<div class="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 bg-[var(--stor-surface-muted)]/55 px-4 py-2.5 max-md:pl-4">
|
|
127
|
+
<div class="flex items-center gap-1.5">
|
|
128
|
+
<label class="flex h-9 w-9 items-center justify-center max-md:hidden">
|
|
129
|
+
<input
|
|
130
|
+
type="checkbox"
|
|
131
|
+
class="h-4 w-4 rounded border-[var(--stor-field-border)] accent-[var(--stor-action)]"
|
|
132
|
+
checked={sectionAllSelected}
|
|
133
|
+
indeterminate={sectionSelectedCount > 0 && !sectionAllSelected}
|
|
134
|
+
aria-label={`Select all ${type} items`}
|
|
135
|
+
onchange={(event) => onSelectionChange(items, event.currentTarget.checked)}
|
|
136
|
+
/>
|
|
137
|
+
</label>
|
|
138
|
+
<div class="flex items-baseline gap-2">
|
|
139
|
+
<h3 class="text-sm font-semibold text-[var(--stor-ink)]">{label}</h3>
|
|
140
|
+
<span class="text-xs tabular-nums text-[var(--stor-muted-text)]">{moneyFormatter.format(total)}</span>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
<CommandOnly>
|
|
144
|
+
<button class="min-h-9 text-xs font-semibold text-[var(--stor-accent-strong)]" onclick={onAdd}>
|
|
145
|
+
<IconPlus class="mr-1 inline h-4 w-4" /> Add {type}
|
|
146
|
+
</button>
|
|
147
|
+
</CommandOnly>
|
|
148
|
+
</div>
|
|
149
|
+
{#snippet empty()}
|
|
150
|
+
<CommandOnly>
|
|
151
|
+
<button type="button" class="w-full px-4 py-6 text-center text-sm text-[var(--stor-muted-text)] hover:bg-[var(--stor-surface-muted)]" onclick={onAdd}>Add your first planned {type}.</button>
|
|
152
|
+
</CommandOnly>
|
|
153
|
+
{/snippet}
|
|
154
|
+
<!-- Phones read the plan as a list: name and category, then the amount; a tap edits the item. -->
|
|
155
|
+
<ul class="md:hidden">
|
|
156
|
+
{#each items as item (item.id)}
|
|
157
|
+
<li class="[&+li]:border-t [&+li]:border-[color-mix(in_srgb,var(--stor-border)_62%,transparent)]">
|
|
158
|
+
<button type="button" class="flex min-h-14 w-full items-center gap-3 px-4 py-2 text-left" onclick={() => onEdit(item)}>
|
|
159
|
+
<span class="min-w-0 flex-1">
|
|
160
|
+
<span class="block truncate text-[15px] font-medium text-[var(--stor-ink)]">{item.name}</span>
|
|
161
|
+
<span class="block truncate text-[12.5px] text-[var(--stor-muted-text)]">{categoryLabel(String(item.category ?? ""))}</span>
|
|
162
|
+
</span>
|
|
163
|
+
<span class="shrink-0 text-[15px] font-semibold tabular-nums {type === 'income' ? 'text-[var(--stor-positive)]' : 'text-[var(--stor-ink)]'}">{moneyFormatter.format(Number(item.amount))}</span>
|
|
164
|
+
</button>
|
|
165
|
+
</li>
|
|
166
|
+
{:else}
|
|
167
|
+
<li>{@render empty()}</li>
|
|
168
|
+
{/each}
|
|
169
|
+
</ul>
|
|
170
|
+
<div class="max-md:hidden"><DataTable
|
|
171
|
+
{columns}
|
|
172
|
+
data={rows}
|
|
173
|
+
{pagination}
|
|
174
|
+
{sorting}
|
|
175
|
+
onSortingChange={(next) => onSortChange(fromTableSorting(next))}
|
|
176
|
+
emptyContent={empty}
|
|
177
|
+
testId={`budget-table-${type}`}
|
|
178
|
+
scrollTestId={`budget-table-${type}-scroll`}
|
|
179
|
+
rowClass={(row) => (selectedIds.has(String(row.id)) ? "bg-[var(--stor-accent-soft)]" : "")}
|
|
180
|
+
/></div>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<style>
|
|
184
|
+
/* Fixed column widths keep the Income and Expenses tables aligned with each
|
|
185
|
+
other even though their content differs. The Name column takes the rest. */
|
|
186
|
+
.budget-table :global(table) {
|
|
187
|
+
table-layout: fixed;
|
|
188
|
+
min-width: 30rem;
|
|
189
|
+
}
|
|
190
|
+
.budget-table :global(th:nth-child(1)),
|
|
191
|
+
.budget-table :global(td:nth-child(1)) {
|
|
192
|
+
width: 3rem;
|
|
193
|
+
}
|
|
194
|
+
.budget-table :global(th:nth-child(3)),
|
|
195
|
+
.budget-table :global(td:nth-child(3)) {
|
|
196
|
+
width: 7rem;
|
|
197
|
+
}
|
|
198
|
+
.budget-table :global(th:nth-child(4)),
|
|
199
|
+
.budget-table :global(td:nth-child(4)) {
|
|
200
|
+
width: 8.5rem;
|
|
201
|
+
}
|
|
202
|
+
.budget-table :global(th:nth-child(5)),
|
|
203
|
+
.budget-table :global(td:nth-child(5)) {
|
|
204
|
+
width: 7rem;
|
|
205
|
+
}
|
|
206
|
+
</style>
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Drawer } from "@hearthlog/sdk/ui";
|
|
3
|
+
import { Button } from "@hearthlog/sdk/ui";
|
|
4
|
+
import { Input } from "@hearthlog/sdk/ui";
|
|
5
|
+
import { Label } from "@hearthlog/sdk/ui";
|
|
6
|
+
import { tick } from "svelte";
|
|
7
|
+
import { ChoiceChips } from "@hearthlog/sdk/ui";
|
|
8
|
+
|
|
9
|
+
let {
|
|
10
|
+
show,
|
|
11
|
+
title,
|
|
12
|
+
description,
|
|
13
|
+
actionLabel,
|
|
14
|
+
initialName,
|
|
15
|
+
onClose,
|
|
16
|
+
onSave,
|
|
17
|
+
startFrom,
|
|
18
|
+
}: {
|
|
19
|
+
show: boolean;
|
|
20
|
+
title: string;
|
|
21
|
+
description: string;
|
|
22
|
+
actionLabel: string;
|
|
23
|
+
initialName: string;
|
|
24
|
+
onClose: () => void;
|
|
25
|
+
onSave: (name: string, copy: boolean) => Promise<void>;
|
|
26
|
+
/** Phones: a new plan can start from the current one (`detail` says what it copies). */
|
|
27
|
+
startFrom?: { label: string; detail: string };
|
|
28
|
+
} = $props();
|
|
29
|
+
|
|
30
|
+
let copy = $state(true);
|
|
31
|
+
|
|
32
|
+
let name = $state("");
|
|
33
|
+
let saving = $state(false);
|
|
34
|
+
let errorMessage = $state("");
|
|
35
|
+
let loadedKey = "";
|
|
36
|
+
|
|
37
|
+
$effect(() => {
|
|
38
|
+
const key = `${show}:${title}:${initialName}`;
|
|
39
|
+
// Closing forgets the loaded plan, so the next new plan starts clean.
|
|
40
|
+
if (!show) loadedKey = "";
|
|
41
|
+
if (!show || key === loadedKey) return;
|
|
42
|
+
loadedKey = key;
|
|
43
|
+
name = initialName;
|
|
44
|
+
copy = true;
|
|
45
|
+
errorMessage = "";
|
|
46
|
+
void tick().then(() => document.querySelector<HTMLInputElement>('[data-testid="budget-plan-name"]')?.focus());
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
async function submit(event: SubmitEvent) {
|
|
50
|
+
event.preventDefault();
|
|
51
|
+
if (saving) return;
|
|
52
|
+
if (!name.trim()) {
|
|
53
|
+
errorMessage = "Enter a plan name.";
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
saving = true;
|
|
57
|
+
errorMessage = "";
|
|
58
|
+
try {
|
|
59
|
+
await onSave(name.trim(), !!startFrom && copy);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
errorMessage = error instanceof Error ? error.message : "The plan could not be saved.";
|
|
62
|
+
} finally {
|
|
63
|
+
saving = false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
</script>
|
|
67
|
+
|
|
68
|
+
<Drawer {show} {title} {description} {onClose}>
|
|
69
|
+
<form id="budget-plan-form" class="space-y-4" onsubmit={submit}>
|
|
70
|
+
<Label class="block">
|
|
71
|
+
Plan name
|
|
72
|
+
<Input data-testid="budget-plan-name" class="mt-1.5" bind:value={name} placeholder="Baseline, Save more…" />
|
|
73
|
+
</Label>
|
|
74
|
+
{#if startFrom}
|
|
75
|
+
<div class="flex flex-col gap-1.5">
|
|
76
|
+
<span class="text-sm font-medium text-[var(--stor-ink)]">Start from</span>
|
|
77
|
+
<ChoiceChips label="Start from" options={[{ value: "copy", label: startFrom.label }, { value: "empty", label: "Empty" }]} value={copy ? "copy" : "empty"} onChange={(value) => (copy = value === "copy")} />
|
|
78
|
+
{#if copy}<p class="text-xs text-[var(--stor-muted-text)]">{startFrom.detail}</p>{/if}
|
|
79
|
+
</div>
|
|
80
|
+
{/if}
|
|
81
|
+
{#if errorMessage}<p class="text-sm text-[var(--stor-negative)]" role="alert">{errorMessage}</p>{/if}
|
|
82
|
+
</form>
|
|
83
|
+
{#snippet footer()}
|
|
84
|
+
<Button variant="secondary" onclick={onClose} disabled={saving}>Cancel</Button>
|
|
85
|
+
<Button type="submit" form="budget-plan-form" disabled={saving} data-testid="save-budget-plan">
|
|
86
|
+
{saving ? "Saving…" : actionLabel}
|
|
87
|
+
</Button>
|
|
88
|
+
{/snippet}
|
|
89
|
+
</Drawer>
|