@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,480 @@
1
+ <script lang="ts">
2
+ const drawerId = $props.id();
3
+ const drawerFormId0 = drawerId + "-0";
4
+
5
+ import { Drawer } from "@hearthlog/sdk/ui";
6
+ import { Button } from "@hearthlog/sdk/ui";
7
+ import { DatePicker } from "@hearthlog/sdk/ui";
8
+ import { Input } from "@hearthlog/sdk/ui";
9
+ import { Label } from "@hearthlog/sdk/ui";
10
+ import type { SelectOption } from "@hearthlog/sdk/ui";
11
+ import { Checkbox } from "@hearthlog/sdk/ui";
12
+ import { CommandConfirmDialog } from "@hearthlog/sdk/ui";
13
+ import { SuggestButton } from "@hearthlog/sdk/ui";
14
+ import { SuggestionChip } from "@hearthlog/sdk/ui";
15
+ import { SuggestionMark } from "@hearthlog/sdk/ui";
16
+ import { categoryLabel, TRANSACTION_CATEGORIES } from "../../finance/categories.js";
17
+ import { categorySource, suggestCategoriesDetailed, type CategorySuggestion } from "../../finance/category-suggestions.js";
18
+ import { isAiCapabilityEnabled } from "@hearthlog/sdk/runtime";
19
+ import { canMarkHappened, defaultHappened, expandDraft, formatDayRange, isDoneAhead, isRepeatDays, MAX_REPEAT_DAYS, resolveDraftStatus, type TransactionDraft } from "./transaction-draft.js";
20
+ import { appClock } from "@hearthlog/sdk/runtime";
21
+ import { formatMoney } from "@hearthlog/sdk/format";
22
+ import { addDays, format, parseISO } from "date-fns";
23
+ import { tick } from "svelte";
24
+ import { queryDatabase } from "@hearthlog/sdk/data";
25
+ import IconChevronDown from "~icons/mdi/chevron-down";
26
+ import IconCalendar from "~icons/mdi/calendar-month-outline";
27
+ import { Switch } from "@hearthlog/sdk/ui";
28
+
29
+ let {
30
+ show,
31
+ initialDraft,
32
+ onClose,
33
+ onSave,
34
+ onDelete,
35
+ }: {
36
+ show: boolean;
37
+ initialDraft: TransactionDraft;
38
+ onClose: () => void;
39
+ /** Saves one transaction, or one per day for a daily repeat. */
40
+ onSave: (drafts: TransactionDraft[]) => Promise<void>;
41
+ /** Deletes the transaction being edited. */
42
+ onDelete?: () => Promise<void>;
43
+ } = $props();
44
+
45
+ let confirmDelete = $state(false);
46
+ // Phones put the amount first with the number pad, offer days and categories as chips, and fold Repeat.
47
+ // The phone form layout is the form layout at every width (docs/design-concepts/desktop-backport-2026-10-05).
48
+ const phone = { current: true };
49
+ let moreOpen = $state(false);
50
+ let pickDate = $state(false);
51
+ let allCategories = $state(false);
52
+ const chip = (on: boolean) => `h-9 rounded-full px-3.5 text-sm font-medium ${on ? "bg-[var(--stor-accent-soft)] font-semibold text-[var(--stor-accent-strong)]" : "bg-[var(--stor-surface-muted)] text-[var(--stor-ink)]"}`;
53
+ let topCategories = $state<string[]>([]);
54
+
55
+ let form = $state<TransactionDraft>({
56
+ name: "",
57
+ amount: 0,
58
+ dueDate: "",
59
+ category: "unknown",
60
+ status: "active",
61
+ direction: "expense",
62
+ });
63
+ let saving = $state(false);
64
+ const today = $derived(show ? format(appClock.now(), "yyyy-MM-dd") : "");
65
+ // Earlier days are done by their date; an entry for today asks whether it
66
+ // already happened. An explicit choice sticks while the date changes.
67
+ let happenedChoice = $state<boolean | null>(null);
68
+ const alreadyHappened = $derived(happenedChoice ?? defaultHappened(initialDraft.status));
69
+ const doneAhead = $derived(isDoneAhead(initialDraft, today));
70
+ const resolvedStatus = $derived(resolveDraftStatus(form, today, alreadyHappened, doneAhead));
71
+ // New transactions can repeat daily: one transaction per day, same values.
72
+ let repeatDaily = $state(false);
73
+ let repeatDays = $state<number | null>(7);
74
+ const repeating = $derived(!initialDraft.id && repeatDaily);
75
+ const days = $derived(repeating ? Number(repeatDays) : 1);
76
+ const entries = $derived(expandDraft(form, days, today, alreadyHappened, doneAhead));
77
+ const canHappen = $derived(entries.some((entry) => canMarkHappened(entry, today)));
78
+ const series = $derived(repeating && entries.length > 1);
79
+ const anyDone = $derived(entries.some((entry) => entry.status === "archived"));
80
+ const allDone = $derived(entries.every((entry) => entry.status === "archived"));
81
+ const seriesSummary = $derived(series
82
+ ? `${entries.length} transactions · ${formatMoney(Math.abs(Number(form.amount) || 0) * entries.length)} · ${formatDayRange(entries[0].dueDate, entries[entries.length - 1].dueDate)}`
83
+ : "");
84
+ const happenedHint = $derived(
85
+ series
86
+ ? "Record today’s entry as done. Unchecked, it stays planned for today."
87
+ : "Record it as done. Unchecked, it stays planned for today.",
88
+ );
89
+ const phoneAmount = $derived(Math.abs(Number(form.amount)) || 0);
90
+ const saveLabel = $derived(
91
+ initialDraft.id
92
+ ? "Save changes"
93
+ : phone.current && !series && phoneAmount > 0
94
+ ? `${resolvedStatus === "archived" ? "Record" : "Plan"} ${formatMoney(form.direction === "expense" ? -phoneAmount : phoneAmount)}`
95
+ : series
96
+ ? `${allDone ? "Record" : anyDone ? "Add" : "Plan"} ${entries.length} transactions`
97
+ : resolvedStatus === "archived" ? "Record transaction" : "Plan transaction",
98
+ );
99
+ let errorMessage = $state("");
100
+ let loadedKey = "";
101
+ const AUTO_SUGGEST_DELAY_MS = 700;
102
+
103
+ const categoryOptions: SelectOption[] = TRANSACTION_CATEGORIES.map(({ value, label }) => ({ value, label }));
104
+
105
+ // A category is suggested once the name is known, while it is still
106
+ // Uncategorized and untouched. A suggestion only fills the field; Save is
107
+ // the only write, and choosing a category yourself removes the tag.
108
+ let categoryTouched = $state(false);
109
+ let appliedSuggestion = $state<CategorySuggestion | null>(null);
110
+ let offeredSuggestion = $state<CategorySuggestion | null>(null);
111
+ let suggestionRun = 0;
112
+ let lastAsked = "";
113
+ /** What the Suggest button is doing, or why it found nothing. */
114
+ let suggestState = $state<"idle" | "busy" | "none" | "unavailable">("idle");
115
+ const aiEnabled = isAiCapabilityEnabled("category-pick");
116
+
117
+ function resetSuggestion(): void {
118
+ suggestionRun += 1;
119
+ categoryTouched = false;
120
+ appliedSuggestion = null;
121
+ offeredSuggestion = null;
122
+ lastAsked = "";
123
+ suggestState = "idle";
124
+ }
125
+
126
+ function applySuggestion(suggestion: CategorySuggestion): void {
127
+ form.category = suggestion.category;
128
+ form.categorySource = categorySource(suggestion.origin);
129
+ appliedSuggestion = suggestion;
130
+ offeredSuggestion = null;
131
+ }
132
+
133
+ function chooseCategory(value: string): void {
134
+ suggestionRun += 1;
135
+ suggestState = "idle";
136
+ categoryTouched = true;
137
+ appliedSuggestion = null;
138
+ offeredSuggestion = null;
139
+ form.category = value;
140
+ form.categorySource = value === "unknown" ? "" : "manual";
141
+ }
142
+
143
+ /** Asks for a suggestion. The automatic ask (`manual` false) is quiet; the button explains its result. */
144
+ async function suggest(manual = false): Promise<void> {
145
+ const name = form.name.trim();
146
+ if (!show || !name) return;
147
+ const unsettled = !categoryTouched && form.category === "unknown";
148
+ if (!manual && !unsettled) return;
149
+ const amount = Math.abs(Number(form.amount)) || 0;
150
+ const key = `${name}|${amount}|${form.direction}`;
151
+ if (!manual && key === lastAsked) return;
152
+ lastAsked = key;
153
+ const run = ++suggestionRun;
154
+ if (manual) {
155
+ suggestState = "busy";
156
+ appliedSuggestion = null;
157
+ offeredSuggestion = null;
158
+ }
159
+ const result = await suggestCategoriesDetailed([{ id: "draft", name, amount, direction: form.direction }]).catch(() => null);
160
+ // An answer that arrives after the user chose a category or closed the drawer is dropped.
161
+ if (run !== suggestionRun || !show) return;
162
+ if (!manual && (categoryTouched || form.category !== "unknown")) return;
163
+ const found = result?.suggestions.get("draft");
164
+ if (manual) suggestState = found ? "idle" : !result || result.aiUnavailable ? "unavailable" : "none";
165
+ if (!found) return;
166
+ if (found.origin.kind === "ai" && found.origin.band === "offer") offeredSuggestion = found;
167
+ else applySuggestion(found);
168
+ }
169
+
170
+ $effect(() => {
171
+ const key = `${show}:${initialDraft.id ?? "new"}:${initialDraft.dueDate}`;
172
+ // Closing forgets the loaded draft, so the next new transaction on the same day starts clean.
173
+ if (!show) loadedKey = "";
174
+ if (show && key !== loadedKey) {
175
+ loadedKey = key;
176
+ form = { ...initialDraft };
177
+ happenedChoice = null;
178
+ repeatDaily = false;
179
+ repeatDays = 7;
180
+ errorMessage = "";
181
+ moreOpen = false;
182
+ pickDate = false;
183
+ allCategories = false;
184
+ resetSuggestion();
185
+ }
186
+ });
187
+
188
+ // The categories used most in the last 90 days for this direction, counted in SQL.
189
+ $effect(() => {
190
+ const direction = form.direction;
191
+ if (!show || !phone.current) return;
192
+ let current = true;
193
+ const since = format(addDays(appClock.now(), -90), "yyyy-MM-dd");
194
+ void queryDatabase((repositories) => repositories.transactions.getTopCategories(since, direction))
195
+ .then((categories) => { if (current) topCategories = categories; })
196
+ .catch(() => undefined);
197
+ return () => { current = false; };
198
+ });
199
+ const categoryChips = $derived.by(() => {
200
+ const chips = topCategories.slice(0, 4);
201
+ if (form.category !== "unknown" && !chips.includes(form.category)) chips.unshift(form.category);
202
+ return chips;
203
+ });
204
+ const tomorrow = $derived(today ? format(addDays(parseISO(today), 1), "yyyy-MM-dd") : "");
205
+ const otherDay = $derived(!!form.dueDate && form.dueDate !== today && form.dueDate !== tomorrow);
206
+
207
+ $effect(() => {
208
+ if (!show) suggestionRun += 1;
209
+ });
210
+
211
+ // Ask a moment after typing stops. Blur asks at once; the same name and amount is never asked twice.
212
+ $effect(() => {
213
+ void form.amount;
214
+ void form.direction;
215
+ if (!show || form.name.trim().length < 3) return;
216
+ const timer = setTimeout(() => void suggest(), AUTO_SUGGEST_DELAY_MS);
217
+ return () => clearTimeout(timer);
218
+ });
219
+
220
+ $effect(() => {
221
+ if (!show) return;
222
+ let focusFrame = 0;
223
+ let settleFrame = 0;
224
+ void tick().then(() => {
225
+ focusFrame = requestAnimationFrame(() => {
226
+ settleFrame = requestAnimationFrame(() => {
227
+ document.querySelector<HTMLInputElement>(
228
+ phone.current && !initialDraft.id ? '[data-testid="transaction-amount"]' : '[data-testid="transaction-name"]',
229
+ )?.focus();
230
+ });
231
+ });
232
+ });
233
+ return () => {
234
+ cancelAnimationFrame(focusFrame);
235
+ cancelAnimationFrame(settleFrame);
236
+ };
237
+ });
238
+
239
+ async function submit(event: SubmitEvent) {
240
+ event.preventDefault();
241
+ if (saving) return;
242
+ if (!form.name.trim()) {
243
+ errorMessage = "Enter a transaction name.";
244
+ return;
245
+ }
246
+ if (!Number.isFinite(Number(form.amount)) || Number(form.amount) === 0) {
247
+ errorMessage = "Enter a non-zero amount.";
248
+ return;
249
+ }
250
+ if (!form.dueDate) {
251
+ errorMessage = "Choose a transaction date.";
252
+ return;
253
+ }
254
+ if (repeating && !isRepeatDays(days)) {
255
+ errorMessage = `Repeat for 2 to ${MAX_REPEAT_DAYS} days.`;
256
+ return;
257
+ }
258
+
259
+ saving = true;
260
+ errorMessage = "";
261
+ try {
262
+ await onSave(expandDraft({
263
+ ...form,
264
+ name: form.name.trim(),
265
+ amount: form.direction === "expense" ? -Math.abs(Number(form.amount)) : Math.abs(Number(form.amount)),
266
+ }, days, today, alreadyHappened, doneAhead));
267
+ } catch (error) {
268
+ errorMessage =
269
+ error instanceof Error
270
+ ? error.message
271
+ : "The transaction could not be saved.";
272
+ } finally {
273
+ saving = false;
274
+ }
275
+ }
276
+ </script>
277
+
278
+ <Drawer
279
+ {show}
280
+ title={initialDraft.id ? "Edit transaction" : initialDraft.status === "archived" ? "Record past transaction" : "New transaction"}
281
+ {onClose}
282
+ >
283
+ <form id={drawerFormId0} class="flex flex-col gap-4" onsubmit={submit}>
284
+ <div class="grid grid-cols-2 rounded-full bg-[var(--stor-surface-muted)] p-1" aria-label="Transaction direction">
285
+ <button type="button" class="h-10 rounded-full text-sm font-semibold transition {form.direction === 'expense' ? 'bg-[var(--stor-action)] text-[var(--stor-action-text)] shadow-[var(--stor-shadow-sm)]' : 'text-[var(--stor-muted-text)]'}" aria-pressed={form.direction === "expense"} onclick={() => (form.direction = "expense")}>Expense</button>
286
+ <button type="button" class="h-10 rounded-full text-sm font-semibold transition {form.direction === 'income' ? 'bg-[var(--stor-action)] text-[var(--stor-action-text)] shadow-[var(--stor-shadow-sm)]' : 'text-[var(--stor-muted-text)]'}" aria-pressed={form.direction === "income"} onclick={() => (form.direction = "income")}>Income</button>
287
+ </div>
288
+
289
+ <Label class="block max-md:order-2">
290
+ Name
291
+ <Input
292
+ autofocus
293
+ data-testid="transaction-name"
294
+ type="text"
295
+ bind:value={form.name}
296
+ placeholder="Rent, salary, groceries..."
297
+ class="mt-1.5"
298
+ onblur={() => void suggest()}
299
+ />
300
+ </Label>
301
+
302
+ <Label class="block max-md:order-1">
303
+ <span class="max-md:sr-only">Amount</span>
304
+ <div class="relative mt-1.5 max-md:mt-0">
305
+ <Input
306
+ data-testid="transaction-amount"
307
+ type="number"
308
+ inputmode="decimal"
309
+ step="0.01"
310
+ bind:value={form.amount}
311
+ class="pr-12 font-medium max-md:!h-16 max-md:!rounded-none max-md:!border-0 max-md:!border-b-2 max-md:!border-[var(--stor-action)] max-md:!bg-transparent max-md:!px-0 max-md:!text-[40px] max-md:!font-semibold max-md:!shadow-none max-md:!ring-0 max-md:!outline-none"
312
+ onblur={() => void suggest()}
313
+ />
314
+ <span class="pointer-events-none absolute inset-y-0 right-4 flex items-center text-sm text-[var(--stor-muted-text)]">Kč</span>
315
+ </div>
316
+ <span class="mt-1 block text-[11px] leading-4 text-[var(--stor-muted-text)] max-md:hidden">Enter a positive amount; Expense or Income sets the direction.</span>
317
+ </Label>
318
+
319
+ <div class="grid gap-3 max-md:order-3 sm:grid-cols-2">
320
+ <div class="block">
321
+ <span class="text-sm font-medium text-[var(--stor-ink)]" id="{drawerId}-day">Date</span>
322
+ <div class="mt-1.5 flex flex-wrap gap-2" role="group" aria-labelledby="{drawerId}-day">
323
+ <button type="button" class={chip(form.dueDate === today)} aria-pressed={form.dueDate === today} onclick={() => { form.dueDate = today; pickDate = false; }}>Today</button>
324
+ <button type="button" class={chip(form.dueDate === tomorrow)} aria-pressed={form.dueDate === tomorrow} onclick={() => { form.dueDate = tomorrow; pickDate = false; }}>Tomorrow</button>
325
+ <button type="button" class="{chip(otherDay)} inline-flex items-center gap-1.5" aria-expanded={pickDate} onclick={() => (pickDate = !pickDate)}>
326
+ <IconCalendar class="h-4 w-4" />{otherDay ? format(parseISO(form.dueDate), "EEE d MMM") : "Pick date"}
327
+ </button>
328
+ </div>
329
+ {#if pickDate}
330
+ <DatePicker
331
+ bind:value={form.dueDate}
332
+ class="mt-2 text-sm"
333
+ placeholder="Choose transaction date"
334
+ />
335
+ {/if}
336
+ </div>
337
+ <div class="block">
338
+ <div class="flex items-center justify-between gap-2">
339
+ <Label>Category</Label>
340
+ {#if aiEnabled}
341
+ <SuggestButton label="Suggest a category" busy={suggestState === "busy"} disabled={!form.name.trim()} onclick={() => void suggest(true)} />
342
+ {/if}
343
+ </div>
344
+ <!-- The most used categories, then All for the full list; one list, not chips and a select. -->
345
+ <div class="mt-1.5 flex flex-wrap gap-2" role="group" aria-label="Categories">
346
+ {#each allCategories ? categoryOptions.map((option) => option.value) : categoryChips as value (value)}
347
+ <button type="button" class={chip(form.category === value)} aria-pressed={form.category === value} onclick={() => chooseCategory(value)}>{categoryLabel(value)}</button>
348
+ {/each}
349
+ {#if !allCategories}
350
+ <button type="button" class="{chip(false)} inline-flex items-center gap-1" onclick={() => (allCategories = true)}>All<IconChevronDown class="h-4 w-4" /></button>
351
+ {/if}
352
+ </div>
353
+ <div class="mt-1.5 flex min-h-5 items-center" aria-live="polite">
354
+ {#if appliedSuggestion}
355
+ <SuggestionMark
356
+ kind={appliedSuggestion.origin.kind}
357
+ reason={appliedSuggestion.origin.kind === "history" ? `Same as ${appliedSuggestion.origin.count} earlier` : ""}
358
+ />
359
+ {:else if offeredSuggestion}
360
+ <SuggestionChip label={categoryLabel(offeredSuggestion.category)} onclick={() => { if (offeredSuggestion) { categoryTouched = true; applySuggestion(offeredSuggestion); } }} />
361
+ {:else if suggestState === "none"}
362
+ <span class="text-[11px] leading-4 text-[var(--stor-muted-text)]">No suggestion for this name.</span>
363
+ {:else if suggestState === "unavailable"}
364
+ <span class="text-[11px] leading-4 text-[var(--stor-muted-text)]">Suggestions aren’t available right now.</span>
365
+ {/if}
366
+ </div>
367
+ </div>
368
+ </div>
369
+
370
+ {#if !initialDraft.id && phone.current && !moreOpen}
371
+ <button type="button" class="flex items-center gap-1 self-start text-sm font-semibold text-[var(--stor-accent-text)] max-md:order-5" aria-expanded="false" onclick={() => (moreOpen = true)}>
372
+ Repeat<IconChevronDown class="h-4 w-4" />
373
+ </button>
374
+ {:else if !initialDraft.id}
375
+ <div class="grid gap-3 max-md:order-5 sm:grid-cols-2">
376
+ <div class="block">
377
+ <Label id="{drawerId}-repeat">Repeat</Label>
378
+ <div class="stor-segmented-control mt-1.5 grid grid-cols-2 sm:h-9" role="group" aria-labelledby="{drawerId}-repeat">
379
+ <button type="button" class="stor-segmented-option min-h-11 sm:min-h-0" aria-pressed={!repeatDaily} onclick={() => (repeatDaily = false)}>Once</button>
380
+ <button type="button" class="stor-segmented-option min-h-11 sm:min-h-0" aria-pressed={repeatDaily} onclick={() => (repeatDaily = true)}>Daily</button>
381
+ </div>
382
+ </div>
383
+ {#if repeatDaily}
384
+ <Label class="block">
385
+ Days
386
+ <Input
387
+ data-testid="transaction-repeat-days"
388
+ type="number"
389
+ inputmode="numeric"
390
+ min="2"
391
+ max={MAX_REPEAT_DAYS}
392
+ step="1"
393
+ bind:value={repeatDays}
394
+ class="mt-1.5"
395
+ />
396
+ </Label>
397
+ {/if}
398
+ </div>
399
+ {#if seriesSummary}
400
+ <p class="text-xs leading-5 text-[var(--stor-muted-text)] max-md:order-5" data-testid="transaction-repeat-summary">{seriesSummary}</p>
401
+ {/if}
402
+ {/if}
403
+
404
+ {#if canHappen && phone.current}
405
+ <Switch
406
+ class="max-md:order-4"
407
+ checked={alreadyHappened}
408
+ label="Already happened"
409
+ hint={alreadyHappened ? "Recorded as done" : series ? "Off: today's entry stays a plan" : "Off: it stays a plan for today"}
410
+ onCheckedChange={(value) => (happenedChoice = value)}
411
+ />
412
+ {:else if canHappen}
413
+ <label class="flex cursor-pointer items-start gap-3 rounded-xl bg-[var(--stor-surface-muted)] px-3 py-2.5" for="transaction-already-happened">
414
+ <Checkbox id="transaction-already-happened" checked={alreadyHappened} onCheckedChange={(value) => (happenedChoice = value === true)} class="mt-0.5" aria-describedby="transaction-already-happened-hint" />
415
+ <span class="min-w-0">
416
+ <span class="block text-sm font-semibold text-[var(--stor-ink)]">Already happened</span>
417
+ <span id="transaction-already-happened-hint" class="block text-xs leading-5 text-[var(--stor-muted-text)]">{happenedHint}</span>
418
+ </span>
419
+ </label>
420
+ {/if}
421
+
422
+ {#if !initialDraft.id && anyDone}
423
+ <div class="rounded-xl bg-[var(--stor-info-soft)] px-3 py-2 max-md:order-6 text-xs leading-5 text-[var(--stor-muted-text)]">Done transactions go straight to history and don’t change your current assets.</div>
424
+ {/if}
425
+
426
+ {#if errorMessage}
427
+ <div class="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 max-md:order-7" role="alert">
428
+ {errorMessage}
429
+ </div>
430
+ {/if}
431
+
432
+ </form>
433
+ {#snippet footer()}
434
+ {#if initialDraft.id && onDelete}
435
+ <Button
436
+ variant="ghost"
437
+ size="sm"
438
+ class="mr-auto text-[var(--stor-negative)]"
439
+ onclick={() => (confirmDelete = true)}
440
+ disabled={saving}
441
+ >
442
+ Delete
443
+ </Button>
444
+ {/if}
445
+ <Button
446
+ variant="secondary"
447
+ size="sm"
448
+ onclick={onClose}
449
+ disabled={saving}
450
+ >
451
+ Cancel
452
+ </Button>
453
+ <Button
454
+ type="submit" form={drawerFormId0}
455
+ data-testid="save-transaction"
456
+ disabled={saving}
457
+ >
458
+ {saving ? "Saving..." : saveLabel}
459
+ </Button>
460
+
461
+ {/snippet}
462
+ </Drawer>
463
+
464
+ <CommandConfirmDialog
465
+ show={confirmDelete}
466
+ title="Delete transaction"
467
+ message={`Delete “${form.name || "this transaction"}”?`}
468
+ onConfirm={async () => {
469
+ confirmDelete = false;
470
+ saving = true;
471
+ try {
472
+ await onDelete?.();
473
+ } catch {
474
+ errorMessage = "The transaction could not be deleted.";
475
+ } finally {
476
+ saving = false;
477
+ }
478
+ }}
479
+ onCancel={() => (confirmDelete = false)}
480
+ />