@hearthlog/module-finance 0.0.0-stage → 1.0.0

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 +46 -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,133 @@
1
+ <script lang="ts">
2
+ const drawerId = $props.id();
3
+ const drawerFormId0 = drawerId + "-0";
4
+
5
+ import { tick } from "svelte";
6
+ import type { Asset } from "../../data/assets.js";
7
+ import { CommandDrawer } from "@hearthlog/sdk/ui";
8
+ import { Button } from "@hearthlog/sdk/ui";
9
+ import { Input } from "@hearthlog/sdk/ui";
10
+ import { Label } from "@hearthlog/sdk/ui";
11
+
12
+ let {
13
+ show,
14
+ asset = null,
15
+ onClose,
16
+ onSave,
17
+ }: {
18
+ show: boolean;
19
+ asset?: Asset | null;
20
+ onClose: () => void;
21
+ onSave: (draft: { name: string; amount: number }) => Promise<void>;
22
+ } = $props();
23
+
24
+ let name = $state("");
25
+ let amount = $state(0);
26
+ let saving = $state(false);
27
+ let errorMessage = $state("");
28
+ let loadedKey = "";
29
+
30
+ $effect(() => {
31
+ if (!show) {
32
+ loadedKey = "";
33
+ return;
34
+ }
35
+ const key = `${show}:${asset?.id ?? "new"}`;
36
+ if (key === loadedKey) return;
37
+ loadedKey = key;
38
+ name = asset?.name ?? "";
39
+ amount = asset?.amount ?? 0;
40
+ errorMessage = "";
41
+ });
42
+
43
+ $effect(() => {
44
+ if (!show) return;
45
+ let focusFrame = 0;
46
+ void tick().then(() => {
47
+ focusFrame = requestAnimationFrame(() => {
48
+ document.querySelector<HTMLInputElement>(
49
+ '[data-testid="asset-name"]',
50
+ )?.focus();
51
+ });
52
+ });
53
+ return () => cancelAnimationFrame(focusFrame);
54
+ });
55
+
56
+ async function submit(event: SubmitEvent) {
57
+ event.preventDefault();
58
+ if (saving) return;
59
+ if (!name.trim()) {
60
+ errorMessage = "Enter an asset name.";
61
+ return;
62
+ }
63
+ if (!Number.isFinite(Number(amount)) || Number(amount) <= 0) {
64
+ errorMessage = "Enter a balance greater than zero.";
65
+ return;
66
+ }
67
+
68
+ saving = true;
69
+ errorMessage = "";
70
+ try {
71
+ await onSave({ name: name.trim(), amount: Number(amount) });
72
+ } catch (error) {
73
+ errorMessage =
74
+ error instanceof Error
75
+ ? error.message
76
+ : "The asset could not be saved.";
77
+ } finally {
78
+ saving = false;
79
+ }
80
+ }
81
+ </script>
82
+
83
+ <CommandDrawer
84
+ {show}
85
+ title={asset ? "Edit asset" : "Add asset"}
86
+ description={asset
87
+ ? "Update the name or current balance."
88
+ : "Add an account or balance to your financial overview."}
89
+ {onClose}
90
+ >
91
+ <form id={drawerFormId0} class="space-y-4" onsubmit={submit}>
92
+ <Label class="block">
93
+ Asset name
94
+ <Input
95
+ autofocus
96
+ data-testid="asset-name"
97
+ bind:value={name}
98
+ placeholder="Savings account"
99
+ class="mt-1.5"
100
+ />
101
+ </Label>
102
+
103
+ <Label class="block">
104
+ Balance
105
+ <div class="relative mt-1.5">
106
+ <Input
107
+ type="number"
108
+ min="0.01"
109
+ step="0.01"
110
+ bind:value={amount}
111
+ class="pr-12 font-medium"
112
+ />
113
+ <span class="pointer-events-none absolute inset-y-0 right-4 flex items-center text-sm text-[var(--stor-muted-text)]">Kč</span>
114
+ </div>
115
+ </Label>
116
+
117
+ {#if errorMessage}
118
+ <div class="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" role="alert">
119
+ {errorMessage}
120
+ </div>
121
+ {/if}
122
+
123
+ </form>
124
+ {#snippet footer()}
125
+ <Button variant="secondary" size="sm" onclick={onClose} disabled={saving}>
126
+ Cancel
127
+ </Button>
128
+ <Button type="submit" form={drawerFormId0} disabled={saving}>
129
+ {saving ? "Saving..." : asset ? "Save changes" : "Add asset"}
130
+ </Button>
131
+
132
+ {/snippet}
133
+ </CommandDrawer>
@@ -0,0 +1,96 @@
1
+ <script lang="ts">
2
+ import { formatMoney } from "@hearthlog/sdk/format";
3
+ import type { Asset } from "../../data/assets.js";
4
+ import { type RemoveAssetEvent } from "@hearthlog/sdk/data";
5
+ import { executeLocalCommand } from "@hearthlog/sdk/data";
6
+ import { notify } from "@hearthlog/sdk/runtime";
7
+ import IconPencil from "~icons/mdi/pencil";
8
+ import IconDeleteOutline from "~icons/mdi/delete-outline";
9
+ import { CommandConfirmDialog } from "@hearthlog/sdk/ui";
10
+ import { CommandOnly } from "@hearthlog/sdk/ui";
11
+ import { SyncStateBadge } from "@hearthlog/sdk/ui";
12
+
13
+ let {
14
+ asset,
15
+ onEdit,
16
+ } = $props<{
17
+ asset: Asset;
18
+ onEdit: () => void;
19
+ }>();
20
+
21
+ let showConfirmDelete = $state(false);
22
+ let pending = $state(false);
23
+
24
+ const assetMoney = { format: (value: number) => formatMoney(value) };
25
+
26
+ async function dispatchEvent(event: RemoveAssetEvent) {
27
+ pending = true;
28
+ try {
29
+ await executeLocalCommand(event);
30
+ return true;
31
+ } catch (error) {
32
+ console.error("[AssetItem] Failed to dispatch event:", error);
33
+ notify.error("Could not update the asset");
34
+ return false;
35
+ } finally {
36
+ pending = false;
37
+ }
38
+ }
39
+
40
+ async function handleDelete() {
41
+ const deleteEvent: RemoveAssetEvent = {
42
+ id: crypto.randomUUID(),
43
+ type: "ASSET_REMOVED",
44
+ timestamp: Date.now(),
45
+ payload: { id: asset.id },
46
+ };
47
+ if (await dispatchEvent(deleteEvent)) {
48
+ notify.success("Asset deleted");
49
+ }
50
+ }
51
+
52
+ </script>
53
+
54
+ <CommandOnly onUnavailable={() => {
55
+ showConfirmDelete = false;
56
+ }} />
57
+
58
+ <li
59
+ class="grid min-h-9 grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-x-2 py-1.5 text-sm last:border-b-0"
60
+ >
61
+ <span class="flex min-w-0 items-center gap-2"><span class="truncate font-medium text-[var(--stor-ink)]">{asset.name}</span><SyncStateBadge entityId={`asset:${asset.id}`} /></span>
62
+ <span class="text-right tabular-nums font-semibold text-[var(--stor-positive)]"
63
+ >{assetMoney.format(asset.amount)}</span
64
+ >
65
+ <CommandOnly>
66
+ <span class="text-right flex items-center justify-end space-x-1">
67
+ <button
68
+ aria-label="Edit"
69
+ class="flex cursor-pointer items-center rounded-lg p-1.5 text-[var(--stor-muted-text)] transition hover:bg-[var(--stor-surface-muted)] hover:text-[var(--stor-ink)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)]"
70
+ onclick={onEdit}
71
+ disabled={pending}
72
+ >
73
+ <IconPencil class="h-4.5 w-4.5" />
74
+ </button>
75
+ <button
76
+ aria-label="Delete"
77
+ class="flex cursor-pointer items-center rounded-lg p-1.5 text-[var(--stor-muted-text)] transition hover:bg-[var(--stor-negative-soft)] hover:text-[var(--stor-negative)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)]"
78
+ onclick={() => (showConfirmDelete = true)}
79
+ disabled={pending}
80
+ >
81
+ <IconDeleteOutline class="h-4.5 w-4.5" />
82
+ </button>
83
+ </span>
84
+ </CommandOnly>
85
+ </li>
86
+
87
+ <CommandConfirmDialog
88
+ show={showConfirmDelete}
89
+ title="Delete asset?"
90
+ message={`Delete “${asset.name}”?`}
91
+ onConfirm={async () => {
92
+ showConfirmDelete = false;
93
+ await handleDelete();
94
+ }}
95
+ onCancel={() => (showConfirmDelete = false)}
96
+ />
@@ -0,0 +1,165 @@
1
+ <script lang="ts">
2
+ import { formatMoney } from "@hearthlog/sdk/format";
3
+ import { currentPageResource } from "@hearthlog/sdk/runtime";
4
+ import { DataValue } from "@hearthlog/sdk/ui";
5
+ import { DataPlaceholder } from "@hearthlog/sdk/ui";
6
+ const resource = currentPageResource();
7
+ const loading = $derived(resource?.().loading ?? false);
8
+ import type { Asset } from "../../data/assets.js";
9
+ import type { AddAssetEvent, UpdateAssetEvent } from "@hearthlog/sdk/data";
10
+ import { executeLocalCommand } from "@hearthlog/sdk/data";
11
+ import { notify } from "@hearthlog/sdk/runtime";
12
+ import AssetItem from "./AssetItem.svelte";
13
+ import AssetDrawer from "./AssetDrawer.svelte";
14
+ import { CommandOnly } from "@hearthlog/sdk/ui";
15
+ import IconPlus from "~icons/mdi/plus";
16
+
17
+ let { assets, assetsSum, onUpdateAll } = $props<{
18
+ assets: Asset[];
19
+ assetsSum: number;
20
+ /** Opens every balance in one sheet with one save. */
21
+ onUpdateAll?: () => void;
22
+ }>();
23
+
24
+ let isAdding = $state(false);
25
+ let editingAsset = $state<Asset | null>(null);
26
+
27
+ const assetMoney = { format: (value: number) => formatMoney(value) };
28
+
29
+ function compareAssets(a: Asset, b: Asset): number {
30
+ const ac = new Date(a.created_at).getTime();
31
+ const bc = new Date(b.created_at).getTime();
32
+ if (ac !== bc) return ac - bc; // earlier first
33
+ return a.id.localeCompare(b.id);
34
+ }
35
+
36
+ const sortedAssets = $derived.by<Asset[]>(() => {
37
+ return [...assets].sort(compareAssets) as Asset[];
38
+ });
39
+
40
+ let assetError = $state<string | null>(null);
41
+
42
+ async function handleSaveAsset(draft: { name: string; amount: number }) {
43
+ if (editingAsset) {
44
+ const updateEvent: UpdateAssetEvent = {
45
+ id: crypto.randomUUID(),
46
+ type: "ASSET_UPDATED",
47
+ timestamp: Date.now(),
48
+ payload: {
49
+ id: editingAsset.id,
50
+ name: draft.name,
51
+ amount: draft.amount,
52
+ },
53
+ };
54
+ await executeLocalCommand(updateEvent);
55
+ editingAsset = null;
56
+ notify.success("Asset updated");
57
+ return;
58
+ }
59
+
60
+ const newAssetEvent: AddAssetEvent = {
61
+ id: crypto.randomUUID(),
62
+ type: "ASSET_ADDED",
63
+ timestamp: Date.now(),
64
+ payload: {
65
+ id: crypto.randomUUID(),
66
+ name: draft.name,
67
+ amount: draft.amount,
68
+ asset_type: "asset",
69
+ },
70
+ };
71
+
72
+ try {
73
+ await executeLocalCommand(newAssetEvent);
74
+ assetError = null;
75
+ isAdding = false;
76
+ notify.success("Asset added");
77
+ } catch (error) {
78
+ console.error("[Asset] Failed to create asset:", error);
79
+ assetError =
80
+ error instanceof Error
81
+ ? `Failed to create asset: ${error.message}`
82
+ : "Failed to create asset. Please try again.";
83
+ throw error;
84
+ }
85
+ }
86
+
87
+ function closeAssetDrawer() {
88
+ isAdding = false;
89
+ editingAsset = null;
90
+ assetError = null;
91
+ }
92
+ </script>
93
+
94
+ <CommandOnly
95
+ onUnavailable={() => {
96
+ closeAssetDrawer();
97
+ }}
98
+ />
99
+
100
+ <div class="text-sm sm:text-base">
101
+ <svelte:boundary>
102
+ <div class="stor-today-card overflow-hidden">
103
+ <div class="flex items-center justify-between gap-3 px-4 py-3">
104
+ <div class="min-w-0">
105
+ <p class="text-sm font-semibold text-[var(--stor-ink)]">Current assets</p>
106
+ </div>
107
+ <CommandOnly>
108
+ {#if !isAdding && !editingAsset}
109
+ {#if onUpdateAll && sortedAssets.length > 1}
110
+ <button type="button" class="ml-auto mr-2 shrink-0 text-xs font-semibold text-[var(--stor-accent-text)]" onclick={onUpdateAll}>Update balances</button>
111
+ {/if}
112
+ <button
113
+ type="button"
114
+ class="inline-flex shrink-0 items-center gap-1.5 rounded-lg bg-[var(--stor-surface)] px-2.5 py-1.5 text-xs font-semibold text-[var(--stor-ink)] transition hover:border-[var(--stor-field-border)] hover:bg-[var(--stor-surface-muted)]"
115
+ onclick={() => (isAdding = true)}
116
+ >
117
+ <IconPlus class="h-3.5 w-3.5" /> Add
118
+ </button>
119
+ {/if}
120
+ </CommandOnly>
121
+ </div>
122
+ {#if assetError}
123
+ <div class="mx-4 mt-3 rounded-lg border border-[color-mix(in_srgb,var(--stor-negative),transparent_35%)] bg-[var(--stor-negative-soft)] px-3 py-2 text-sm text-[var(--stor-negative)]">
124
+ {assetError}
125
+ <button
126
+ onclick={() => (assetError = null)}
127
+ class="ml-2 font-bold"
128
+ aria-label="Dismiss error"
129
+ >
130
+ ✕
131
+ </button>
132
+ </div>
133
+ {/if}
134
+ <ul class="px-3 py-1.5">
135
+ {#if loading}
136
+ <li><DataPlaceholder rows={2} /></li>
137
+ {:else}
138
+ {#each sortedAssets as asset (asset.id)}
139
+ <AssetItem {asset} onEdit={() => (editingAsset = asset)} />
140
+ {/each}
141
+ {/if}
142
+ </ul>
143
+ {#if !loading && sortedAssets.length > 0}
144
+ <div class="mx-4 flex items-center justify-between border-t border-[var(--stor-border)] py-2 text-sm font-semibold text-[var(--stor-ink)]">
145
+ <span>Total assets</span><span class="tabular-nums"><DataValue>{assetMoney.format(assetsSum)}</DataValue></span>
146
+ </div>
147
+ {/if}
148
+ {#if !loading && sortedAssets.length === 0 && !isAdding}
149
+ <div class="px-4 pb-4 pt-1 text-sm leading-5 text-[var(--stor-muted-text)]">
150
+ No current assets yet. Add cash, savings, or investments to establish what you have now.
151
+ </div>
152
+ {/if}
153
+ </div>
154
+ {#snippet pending()}
155
+ <p>..</p>
156
+ {/snippet}
157
+ </svelte:boundary>
158
+ </div>
159
+
160
+ <AssetDrawer
161
+ show={isAdding || !!editingAsset}
162
+ asset={editingAsset}
163
+ onClose={closeAssetDrawer}
164
+ onSave={handleSaveAsset}
165
+ />
@@ -0,0 +1,122 @@
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 { CommandOnly } from "@hearthlog/sdk/ui";
6
+ import { executeLocalCommand } from "@hearthlog/sdk/data";
7
+ import type { Asset } from "../../data/assets.js";
8
+ import type { AddAssetEvent, RemoveAssetEvent, UpdateAssetEvent } from "@hearthlog/sdk/data";
9
+ import { refreshPageData } from "@hearthlog/sdk/runtime";
10
+ import { notify } from "@hearthlog/sdk/runtime";
11
+ import { formatAmount } from "@hearthlog/sdk/format";
12
+ import IconDelete from "~icons/mdi/delete-outline";
13
+ import IconUndo from "~icons/mdi/undo";
14
+
15
+ /**
16
+ * Every asset's balance in one sheet (phones): change any of them, add one
17
+ * in the empty last row, mark one for removal; Save writes one command for
18
+ * all changes, so updating balances at the end of a month is one save.
19
+ */
20
+ let { show, assets, onClose }: { show: boolean; assets: Asset[]; onClose: () => void } = $props();
21
+
22
+ type Row = { id: string; name: string; amount: number | null; original: Asset | null; removed: boolean };
23
+ let rows = $state<Row[]>([]);
24
+ let saving = $state(false);
25
+ let error = $state("");
26
+ let submissionId = crypto.randomUUID();
27
+
28
+ let opened = false;
29
+ $effect(() => {
30
+ if (show && !opened) {
31
+ opened = true;
32
+ rows = [
33
+ ...assets.map((asset) => ({ id: asset.id, name: asset.name, amount: Number(asset.amount), original: asset, removed: false })),
34
+ { id: crypto.randomUUID(), name: "", amount: null, original: null, removed: false },
35
+ ];
36
+ error = "";
37
+ submissionId = crypto.randomUUID();
38
+ } else if (!show) {
39
+ opened = false;
40
+ }
41
+ });
42
+
43
+ // A new empty row appears once the last one is being filled.
44
+ $effect(() => {
45
+ const last = rows[rows.length - 1];
46
+ if (last && !last.original && (last.name.trim() || last.amount !== null)) {
47
+ rows = [...rows, { id: crypto.randomUUID(), name: "", amount: null, original: null, removed: false }];
48
+ }
49
+ });
50
+
51
+ const total = $derived(rows.reduce((sum, row) => (row.removed ? sum : sum + (Number(row.amount) || 0)), 0));
52
+
53
+ function events(): Array<AddAssetEvent | UpdateAssetEvent | RemoveAssetEvent> {
54
+ const timestamp = Date.now();
55
+ const out: Array<AddAssetEvent | UpdateAssetEvent | RemoveAssetEvent> = [];
56
+ for (const row of rows) {
57
+ if (row.original && row.removed) {
58
+ out.push({ id: crypto.randomUUID(), type: "ASSET_REMOVED", timestamp, payload: { id: row.id } });
59
+ } else if (row.original) {
60
+ const name = row.name.trim() || row.original.name;
61
+ const amount = Number(row.amount) || 0;
62
+ if (name !== row.original.name || amount !== Number(row.original.amount)) {
63
+ out.push({ id: crypto.randomUUID(), type: "ASSET_UPDATED", timestamp, payload: { id: row.id, name, amount } });
64
+ }
65
+ } else if (row.name.trim() && row.amount !== null && !row.removed) {
66
+ out.push({ id: crypto.randomUUID(), type: "ASSET_ADDED", timestamp, payload: { id: row.id, name: row.name.trim(), amount: Number(row.amount), asset_type: "asset" } });
67
+ }
68
+ }
69
+ return out;
70
+ }
71
+ const changes = $derived(events().length);
72
+
73
+ async function save(event: SubmitEvent): Promise<void> {
74
+ event.preventDefault();
75
+ if (saving) return;
76
+ const batch = events();
77
+ if (batch.length === 0) { onClose(); return; }
78
+ saving = true;
79
+ error = "";
80
+ try {
81
+ await executeLocalCommand(batch, { submissionId });
82
+ onClose();
83
+ notify.success(batch.length === 1 ? "Assets updated" : `${batch.length} asset changes saved`);
84
+ await refreshPageData("app:assets", "app:transactions");
85
+ } catch (failure) {
86
+ console.error("[Assets] Failed to save balances:", failure);
87
+ error = "The balances could not be saved. Your values are still here.";
88
+ } finally {
89
+ saving = false;
90
+ }
91
+ }
92
+ </script>
93
+
94
+ <CommandOnly onUnavailable={onClose}>
95
+ <Drawer {show} title="Assets" {onClose}>
96
+ <form id="assets-sheet-form" class="flex flex-col" onsubmit={save}>
97
+ {#each rows as row (row.id)}
98
+ <div class="grid grid-cols-[minmax(0,1fr)_8.5rem_2.25rem] items-center gap-2 border-b border-[color-mix(in_srgb,var(--stor-border)_62%,transparent)] py-2 {row.removed ? 'opacity-45' : ''}">
99
+ <label><span class="sr-only">Asset name</span>
100
+ <input bind:value={row.name} placeholder={row.original ? "" : "New asset"} disabled={row.removed} class="h-11 w-full min-w-0 bg-transparent text-[15px] font-medium text-[var(--stor-ink)] outline-none placeholder:text-[var(--stor-muted-text)] {row.removed ? 'line-through' : ''}" />
101
+ </label>
102
+ <label class="relative"><span class="sr-only">Balance of {row.name || "the new asset"}</span>
103
+ <Input type="number" inputmode="decimal" step="0.01" bind:value={row.amount} placeholder="0" disabled={row.removed} class="pr-9 text-right" />
104
+ <span class="pointer-events-none absolute inset-y-0 right-3 flex items-center text-sm text-[var(--stor-muted-text)]">Kč</span>
105
+ </label>
106
+ {#if row.original}
107
+ <button type="button" class="grid h-9 w-9 place-items-center rounded-lg text-[var(--stor-muted-text)]" aria-label={row.removed ? `Keep ${row.name}` : `Remove ${row.name}`} onclick={() => (row.removed = !row.removed)}>
108
+ {#if row.removed}<IconUndo class="h-5 w-5" />{:else}<IconDelete class="h-5 w-5" />{/if}
109
+ </button>
110
+ {:else}<span></span>{/if}
111
+ </div>
112
+ {/each}
113
+ <div class="flex items-baseline justify-between pt-3 text-sm text-[var(--stor-muted-text)]">
114
+ <span>Available now</span><b class="text-xl font-semibold tabular-nums text-[var(--stor-ink)]">{formatAmount(total)} Kč</b>
115
+ </div>
116
+ {#if error}<p class="mt-2 text-sm text-[var(--stor-negative)]" role="alert">{error}</p>{/if}
117
+ </form>
118
+ {#snippet footer()}
119
+ <Button type="submit" form="assets-sheet-form" class="flex-1" disabled={saving}>{saving ? "Saving…" : changes > 0 ? `Save ${changes} ${changes === 1 ? "change" : "changes"}` : "Done"}</Button>
120
+ {/snippet}
121
+ </Drawer>
122
+ </CommandOnly>
@@ -0,0 +1,18 @@
1
+ <script lang="ts">
2
+ import { CommandOnly } from "@hearthlog/sdk/ui";
3
+ import IconPencil from "~icons/mdi/pencil-outline";
4
+ import IconDelete from "~icons/mdi/delete-outline";
5
+
6
+ let { name, onEdit, onDelete }: { name: string; onEdit: () => void; onDelete: () => void } = $props();
7
+ </script>
8
+
9
+ <div class="flex items-center justify-end gap-1">
10
+ <CommandOnly>
11
+ <button type="button" aria-label={`Edit ${name}`} class="flex h-9 w-9 items-center justify-center rounded-lg text-[var(--stor-muted-text)] transition hover:bg-[var(--stor-accent-soft)] hover:text-[var(--stor-accent-strong)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)]" onclick={onEdit}>
12
+ <IconPencil class="h-4 w-4" />
13
+ </button>
14
+ <button type="button" aria-label={`Delete ${name}`} class="flex h-9 w-9 items-center justify-center rounded-lg text-[var(--stor-muted-text)] transition hover:bg-[var(--stor-negative-soft)] hover:text-[var(--stor-negative)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)]" onclick={onDelete}>
15
+ <IconDelete class="h-4 w-4" />
16
+ </button>
17
+ </CommandOnly>
18
+ </div>
@@ -0,0 +1,5 @@
1
+ <script lang="ts">
2
+ let { text, tone }: { text: string; tone: "income" | "expense" } = $props();
3
+ </script>
4
+
5
+ <span class="block whitespace-nowrap text-right font-semibold tabular-nums {tone === 'income' ? 'text-[var(--stor-positive)]' : 'text-[var(--stor-ink)]'}" data-testid="budget-row-amount">{text}</span>
@@ -0,0 +1,7 @@
1
+ <script lang="ts">
2
+ let { category }: { category: string } = $props();
3
+
4
+ const label = $derived(category ? `${category.charAt(0).toUpperCase()}${category.slice(1).replaceAll("-", " ")}` : "Other");
5
+ </script>
6
+
7
+ <span class="text-xs text-[var(--stor-muted-text)]">{label}</span>
@@ -0,0 +1,116 @@
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 { Select, type SelectOption } from "@hearthlog/sdk/ui";
7
+ import type { BudgetItem } from "@hearthlog/sdk/data";
8
+ import { tick } from "svelte";
9
+
10
+ export interface BudgetItemDraft {
11
+ id?: string;
12
+ name: string;
13
+ amount: number;
14
+ itemType: "income" | "expense";
15
+ category: string;
16
+ }
17
+
18
+ let { show, planName, initialItem, initialType, onClose, onSave } = $props<{
19
+ show: boolean;
20
+ planName: string;
21
+ initialItem: BudgetItem | null;
22
+ initialType: "income" | "expense";
23
+ onClose: () => void;
24
+ onSave: (draft: BudgetItemDraft, addAnother: boolean) => Promise<void>;
25
+ }>();
26
+
27
+ const categories: SelectOption[] = [
28
+ { value: "income", label: "Income" },
29
+ { value: "housing", label: "Housing" },
30
+ { value: "food", label: "Food" },
31
+ { value: "transport", label: "Transport" },
32
+ { value: "subscriptions", label: "Subscriptions" },
33
+ { value: "health", label: "Health" },
34
+ { value: "savings", label: "Savings" },
35
+ { value: "other", label: "Other" },
36
+ ];
37
+ let form = $state<BudgetItemDraft>({ name: "", amount: 0, itemType: "expense", category: "other" });
38
+ let addAnother = $state(false);
39
+ let saving = $state(false);
40
+ let errorMessage = $state("");
41
+ let loadedKey = "";
42
+
43
+ $effect(() => {
44
+ const key = `${show}:${initialItem?.id ?? "new"}:${initialType}`;
45
+ // Closing forgets the loaded item, so the next new item starts clean.
46
+ if (!show) loadedKey = "";
47
+ if (!show || key === loadedKey) return;
48
+ loadedKey = key;
49
+ form = initialItem ? {
50
+ id: initialItem.id,
51
+ name: initialItem.name,
52
+ amount: initialItem.amount,
53
+ itemType: initialItem.item_type as "income" | "expense",
54
+ category: initialItem.category,
55
+ } : { name: "", amount: 0, itemType: initialType, category: initialType === "income" ? "income" : "other" };
56
+ addAnother = false;
57
+ errorMessage = "";
58
+ let firstFrame = 0;
59
+ let secondFrame = 0;
60
+ void tick().then(() => {
61
+ firstFrame = requestAnimationFrame(() => {
62
+ secondFrame = requestAnimationFrame(() => {
63
+ document.querySelector<HTMLInputElement>('[data-testid="budget-item-name"]')?.focus();
64
+ });
65
+ });
66
+ });
67
+ return () => {
68
+ cancelAnimationFrame(firstFrame);
69
+ cancelAnimationFrame(secondFrame);
70
+ };
71
+ });
72
+
73
+ async function submit(event: SubmitEvent) {
74
+ event.preventDefault();
75
+ if (saving) return;
76
+ const amount = Number(form.amount);
77
+ if (!form.name.trim()) { errorMessage = "Enter an item name."; return; }
78
+ if (!Number.isFinite(amount) || amount <= 0) { errorMessage = "Enter an amount greater than zero."; return; }
79
+ saving = true;
80
+ errorMessage = "";
81
+ try {
82
+ await onSave({ ...form, name: form.name.trim(), amount }, addAnother && !form.id);
83
+ if (addAnother && !form.id) {
84
+ form = { ...form, name: "", amount: 0 };
85
+ loadedKey = "";
86
+ await tick();
87
+ document.querySelector<HTMLInputElement>('[data-testid="budget-item-name"]')?.focus();
88
+ }
89
+ } catch (error) {
90
+ errorMessage = error instanceof Error ? error.message : "The item could not be saved.";
91
+ } finally {
92
+ saving = false;
93
+ }
94
+ }
95
+ </script>
96
+
97
+ <Drawer show={show} title={initialItem ? "Edit planned item" : "Add planned item"} description={planName} onClose={onClose}>
98
+ <form id="budget-item-form" class="space-y-4" onsubmit={submit}>
99
+ <fieldset>
100
+ <legend class="mb-1.5 text-sm font-medium text-stone-800">Type</legend>
101
+ <div class="grid grid-cols-2 rounded-xl bg-[var(--stor-surface-muted)] p-1">
102
+ <button type="button" class="h-10 rounded-lg text-sm font-semibold transition {form.itemType === 'expense' ? 'bg-[var(--stor-selected)] text-[var(--stor-accent-strong)] shadow-sm' : 'text-[var(--stor-muted-text)]'}" onclick={() => { form.itemType = 'expense'; if (form.category === 'income') form.category = 'other'; }}>Expense</button>
103
+ <button type="button" class="h-10 rounded-lg text-sm font-semibold transition {form.itemType === 'income' ? 'bg-[var(--stor-selected)] text-[var(--stor-accent-strong)] shadow-sm' : 'text-[var(--stor-muted-text)]'}" onclick={() => { form.itemType = 'income'; form.category = 'income'; }}>Income</button>
104
+ </div>
105
+ </fieldset>
106
+ <Label class="block">Name<Input data-testid="budget-item-name" class="mt-1.5" bind:value={form.name} placeholder="Rent, salary, groceries…" /></Label>
107
+ <Label class="block">Amount<div class="relative mt-1.5"><Input data-testid="budget-item-amount" type="number" inputmode="decimal" min="0.01" step="0.01" class="pr-12 font-medium" bind:value={form.amount} /><span class="pointer-events-none absolute inset-y-0 right-4 flex items-center text-sm text-[var(--stor-muted-text)]">Kč</span></div><span class="mt-1 block text-xs text-[var(--stor-muted-text)] max-md:hidden">Always enter a positive amount.</span></Label>
108
+ <Label class="block">Category<Select class="mt-1.5" value={form.category} options={categories} onValueChange={(value) => form.category = value} /></Label>
109
+ {#if !initialItem}<label class="flex min-h-11 items-center gap-2 text-sm text-stone-700"><input type="checkbox" bind:checked={addAnother} class="h-4 w-4 rounded accent-[var(--stor-action)]" /> Add another after saving</label>{/if}
110
+ {#if errorMessage}<p class="text-sm text-[var(--stor-negative)]" role="alert">{errorMessage}</p>{/if}
111
+ </form>
112
+ {#snippet footer()}
113
+ <Button variant="secondary" onclick={onClose} disabled={saving}>Cancel</Button>
114
+ <Button type="submit" form="budget-item-form" disabled={saving} data-testid="save-budget-item">{saving ? "Saving…" : initialItem ? "Save changes" : form.itemType === "income" ? "Add income" : "Add expense"}</Button>
115
+ {/snippet}
116
+ </Drawer>