@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,198 @@
1
+ <script lang="ts">
2
+ import { monthWindow } from "../../finance/timeline.js";
3
+ import { queryDatabase } from "@hearthlog/sdk/data";
4
+ import { categoryLabel, TRANSACTION_CATEGORIES } from "../../finance/categories.js";
5
+ import { categorySource, suggestCategories, type CategorySuggestion } from "../../finance/category-suggestions.js";
6
+ import { formatMoney } from "@hearthlog/sdk/format";
7
+ import { CommandDrawer } from "@hearthlog/sdk/ui";
8
+ import { DataPlaceholder } from "@hearthlog/sdk/ui";
9
+ import { Button } from "@hearthlog/sdk/ui";
10
+ import { Select, type SelectOption } from "@hearthlog/sdk/ui";
11
+ import { format, parseISO } from "date-fns";
12
+ import { ChoiceChips } from "@hearthlog/sdk/ui";
13
+
14
+ /** One row's new category and what it replaces, so the action can be undone. */
15
+ export interface CategoryChange {
16
+ id: string;
17
+ category: string;
18
+ source: string;
19
+ previous: { category: string; source: string };
20
+ }
21
+
22
+ interface Entry {
23
+ id: string;
24
+ name: string;
25
+ date: string;
26
+ amount: number;
27
+ previous: { category: string; source: string };
28
+ choice: string;
29
+ source: string;
30
+ ticked: boolean;
31
+ applied: CategorySuggestion | null;
32
+ offered: CategorySuggestion | null;
33
+ }
34
+
35
+ let {
36
+ show,
37
+ month,
38
+ onClose,
39
+ onApply,
40
+ }: {
41
+ show: boolean;
42
+ /** The `yyyy-MM` month to sort. */
43
+ month: string;
44
+ onClose: () => void;
45
+ /** Writes every change in one action. Throws when saving fails. */
46
+ onApply: (changes: CategoryChange[]) => Promise<void>;
47
+ } = $props();
48
+
49
+ const options: SelectOption[] = TRANSACTION_CATEGORIES.map(({ value, label }) => ({ value, label: value === "unknown" ? "Choose" : label }));
50
+
51
+ let entries = $state<Entry[]>([]);
52
+ let loading = $state(false);
53
+ let busy = $state(false);
54
+ let errorMessage = $state("");
55
+ let run = 0;
56
+
57
+ const monthLabel = $derived(month ? format(parseISO(`${month}-01`), "MMMM yyyy") : "");
58
+ const ticked = $derived(entries.filter((entry) => entry.ticked));
59
+
60
+ // Phones: the suggestion is the chosen chip, the next likely ones beside it, … for every category.
61
+ // The phone form layout is the form layout at every width (docs/design-concepts/desktop-backport-2026-10-05).
62
+ const phone = { current: true };
63
+ let fullList = $state<Set<string>>(new Set());
64
+ const COMMON = { expense: ["food", "shopping", "transport", "leisure", "housing"], income: ["income", "gifts", "other"] };
65
+ function chipsFor(entry: Entry): { value: string; label: string }[] {
66
+ const values = [entry.choice, entry.offered?.category ?? "", ...COMMON[entry.amount >= 0 ? "income" : "expense"]]
67
+ .filter((value, index, all) => value && value !== "unknown" && all.indexOf(value) === index)
68
+ .slice(0, 3);
69
+ return values.map((value) => ({ value, label: categoryLabel(value) }));
70
+ }
71
+ function tapChip(entry: Entry, value: string): void {
72
+ if (entry.choice === value) {
73
+ // Tapping the chosen chip leaves this row out of Apply.
74
+ entry.choice = "unknown";
75
+ entry.ticked = false;
76
+ entry.applied = null;
77
+ return;
78
+ }
79
+ if (entry.offered?.category === value) takeOffer(entry);
80
+ else choose(entry, value);
81
+ }
82
+
83
+ $effect(() => {
84
+ if (!show) {
85
+ run += 1;
86
+ return;
87
+ }
88
+ void load(month);
89
+ });
90
+
91
+ async function load(key: string): Promise<void> {
92
+ const current = ++run;
93
+ loading = true;
94
+ errorMessage = "";
95
+ entries = [];
96
+ try {
97
+ const { start, end } = monthWindow(key);
98
+ const rows = await queryDatabase((repositories) => repositories.transactions.getUncategorizedInMonth(start, end));
99
+ if (current !== run) return;
100
+ const suggestions = await suggestCategories(rows.map((row) => ({
101
+ id: row.id,
102
+ name: row.name ?? "",
103
+ amount: Math.abs(row.amount),
104
+ direction: row.amount >= 0 ? "income" as const : "expense" as const,
105
+ })));
106
+ // The sheet may have closed or moved to another month while the model was thinking.
107
+ if (current !== run) return;
108
+ entries = rows.map((row) => {
109
+ const suggestion = suggestions.get(row.id);
110
+ const apply = suggestion && !(suggestion.origin.kind === "ai" && suggestion.origin.band === "offer");
111
+ return {
112
+ id: row.id,
113
+ name: row.name ?? "",
114
+ date: row.due_date?.slice(0, 10) ?? "",
115
+ amount: row.amount,
116
+ previous: { category: row.transaction_category || "unknown", source: row.category_source ?? "" },
117
+ choice: apply ? suggestion.category : "unknown",
118
+ source: apply ? categorySource(suggestion.origin) : "",
119
+ ticked: !!apply,
120
+ applied: apply ? suggestion : null,
121
+ offered: suggestion && !apply ? suggestion : null,
122
+ };
123
+ });
124
+ } catch (error) {
125
+ if (current !== run) return;
126
+ console.error("[Finance] Could not load uncategorized transactions:", error);
127
+ errorMessage = "The transactions could not be loaded.";
128
+ } finally {
129
+ if (current === run) loading = false;
130
+ }
131
+ }
132
+
133
+ function choose(entry: Entry, value: string): void {
134
+ entry.choice = value;
135
+ entry.source = value === "unknown" ? "" : "manual";
136
+ entry.ticked = value !== "unknown";
137
+ entry.applied = null;
138
+ entry.offered = null;
139
+ }
140
+
141
+ function takeOffer(entry: Entry): void {
142
+ const offer = entry.offered;
143
+ if (!offer) return;
144
+ entry.choice = offer.category;
145
+ entry.source = categorySource(offer.origin);
146
+ entry.applied = offer;
147
+ entry.offered = null;
148
+ entry.ticked = true;
149
+ }
150
+
151
+ async function apply(): Promise<void> {
152
+ if (busy || ticked.length === 0) return;
153
+ busy = true;
154
+ errorMessage = "";
155
+ try {
156
+ await onApply(ticked.map((entry) => ({ id: entry.id, category: entry.choice, source: entry.source, previous: entry.previous })));
157
+ } catch {
158
+ // The ticks stay, so the same Apply can be retried.
159
+ errorMessage = "The categories could not be saved. Your choices are still here.";
160
+ } finally {
161
+ busy = false;
162
+ }
163
+ }
164
+ </script>
165
+
166
+ <CommandDrawer {show} title="Sort uncategorized" description={monthLabel} size="lg" onClose={() => { if (!busy) onClose(); }}>
167
+ {#if loading}
168
+ <DataPlaceholder rows={6} label="Finding categories" />
169
+ {:else if entries.length === 0}
170
+ <p class="text-sm text-[var(--stor-muted-text)]">{errorMessage || "Everything this month has a category."}</p>
171
+ {:else}
172
+ <ul class="divide-y divide-[var(--stor-border)]" aria-label="Uncategorized transactions">
173
+ {#each entries as entry (entry.id)}
174
+ <li class="flex flex-col gap-2 py-3">
175
+ <div class="flex items-baseline justify-between gap-3">
176
+ <span class="truncate text-[15px] font-medium text-[var(--stor-ink)]">{entry.name || "Untitled"}</span>
177
+ <span class="shrink-0 text-xs tabular-nums text-[var(--stor-muted-text)]">{format(parseISO(entry.date || `${month}-01`), "d MMM")} · {formatMoney(entry.amount)}</span>
178
+ </div>
179
+ {#if fullList.has(entry.id)}
180
+ <Select value={entry.choice} {options} disabled={busy} ariaLabel={`Category for ${entry.name || "transaction"}`} onValueChange={(value) => choose(entry, value)} />
181
+ {:else}
182
+ <ChoiceChips label={`Category for ${entry.name || "transaction"}`} options={chipsFor(entry)} value={entry.choice} onChange={(value) => tapChip(entry, value)} more="All" onMore={() => (fullList = new Set([...fullList, entry.id]))} />
183
+ {/if}
184
+ </li>
185
+ {/each}
186
+ </ul>
187
+ {#if errorMessage}
188
+ <div class="mt-3 rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" role="alert">{errorMessage}</div>
189
+ {/if}
190
+ {/if}
191
+
192
+ {#snippet footer()}
193
+ <Button variant="secondary" size="sm" onclick={onClose} disabled={busy}>Cancel</Button>
194
+ <Button data-testid="apply-categories" disabled={busy || ticked.length === 0} onclick={() => void apply()}>
195
+ {busy ? "Saving..." : ticked.length === 0 ? "Apply" : phone.current ? `Apply ${ticked.length} ${ticked.length === 1 ? "category" : "categories"}` : `Apply ${ticked.length}`}
196
+ </Button>
197
+ {/snippet}
198
+ </CommandDrawer>
@@ -0,0 +1,319 @@
1
+ <script module lang="ts">
2
+ import type { TimelineKind } from "../../finance/timeline.js";
3
+
4
+ export type RowLike = {
5
+ id: string;
6
+ name: string;
7
+ amount: number;
8
+ date: string;
9
+ category: string;
10
+ kind: TimelineKind;
11
+ };
12
+ export type BalanceTone = "default" | "low" | "below";
13
+ </script>
14
+
15
+ <script lang="ts">
16
+ import type { Snippet } from "svelte";
17
+ import { DropdownMenu } from "bits-ui";
18
+ import { format, parseISO } from "date-fns";
19
+ import { categoryLabel } from "../../finance/categories.js";
20
+ import { formatAmount } from "@hearthlog/sdk/format";
21
+ import { CommandOnly } from "@hearthlog/sdk/ui";
22
+ import { CommandConfirmDialog } from "@hearthlog/sdk/ui";
23
+ import { SyncStateBadge } from "@hearthlog/sdk/ui";
24
+ import IconCheck from "~icons/mdi/check";
25
+ import IconDots from "~icons/mdi/dots-horizontal";
26
+
27
+ let {
28
+ row,
29
+ today,
30
+ firstOfDay = false,
31
+ divider = false,
32
+ balance = null,
33
+ balanceTone = "default",
34
+ showDate = true,
35
+ compact = false,
36
+ selectable = true,
37
+ selected = false,
38
+ busy = false,
39
+ highlighted = false,
40
+ flash = false,
41
+ onSelect,
42
+ onEdit,
43
+ onMarkDone,
44
+ onRestore,
45
+ onDelete,
46
+ onHover,
47
+ gutter,
48
+ }: {
49
+ row: RowLike;
50
+ today: string;
51
+ firstOfDay?: boolean;
52
+ /** A hairline above the first row of a day that follows another day. */
53
+ divider?: boolean;
54
+ /** The running balance after this row, for plans. */
55
+ balance?: number | null;
56
+ balanceTone?: BalanceTone;
57
+ showDate?: boolean;
58
+ /** Stacked layout for narrow side panels. */
59
+ compact?: boolean;
60
+ selectable?: boolean;
61
+ selected?: boolean;
62
+ busy?: boolean;
63
+ highlighted?: boolean;
64
+ flash?: boolean;
65
+ onSelect?: (row: RowLike, selected: boolean) => void;
66
+ onEdit?: (row: RowLike) => void;
67
+ onMarkDone?: (row: RowLike) => void | Promise<void>;
68
+ onRestore?: (row: RowLike) => void | Promise<void>;
69
+ onDelete?: (row: RowLike) => void | Promise<void>;
70
+ onHover?: (row: RowLike | null) => void;
71
+ /** Drawn in the river gutter to the left of the row. */
72
+ gutter?: Snippet;
73
+ } = $props();
74
+
75
+ let confirmDelete = $state(false);
76
+ const day = $derived(parseISO(row.date));
77
+ /** Today's plan can leave the outlook early; tomorrow it is done anyway. */
78
+ const markable = $derived(row.kind === "planned" && row.date === today);
79
+ /** Only a row done ahead of its day can go back to plans; earlier days are done by date. */
80
+ const restorable = $derived(row.kind === "done" && row.date >= today);
81
+ const name = $derived(row.name || "Untitled");
82
+ const balanceTitle = $derived(
83
+ balanceTone === "low" || balanceTone === "below" ? "Lowest balance ahead"
84
+ : "Balance after this plan",
85
+ );
86
+ </script>
87
+
88
+ <div
89
+ class="timeline-row"
90
+ class:divider
91
+ class:no-date={!showDate}
92
+ class:no-check={!selectable}
93
+ class:compact
94
+ class:done={row.kind === "done"}
95
+ class:selected
96
+ class:highlighted
97
+ class:flash
98
+ data-row-id={row.id}
99
+ data-kind={row.kind}
100
+ data-date={row.date}
101
+ role="group"
102
+ aria-label={name}
103
+ onmouseenter={() => onHover?.(row)}
104
+ onmouseleave={() => onHover?.(null)}
105
+ >
106
+ {#if gutter}<div class="gutter" data-river>{@render gutter()}</div>{/if}
107
+ <div class="cell-check" data-row-check>
108
+ {#if selectable}
109
+ <label class="check-hit">
110
+ <input
111
+ type="checkbox"
112
+ class="h-4 w-4 rounded border-[var(--stor-field-border)] accent-[var(--stor-action)]"
113
+ checked={selected}
114
+ aria-label={`Select ${name}`}
115
+ onchange={(event) => onSelect?.(row, event.currentTarget.checked)}
116
+ />
117
+ </label>
118
+ {/if}
119
+ </div>
120
+ <div class="cell-date" aria-hidden="true">
121
+ {#if showDate && firstOfDay}<b>{format(day, "d")}</b><span>{format(day, "EEE")}</span>{/if}
122
+ </div>
123
+ <button type="button" class="cell-main" onclick={() => onEdit?.(row)}>
124
+ <span class="name-line"><span class="name">{name}</span><SyncStateBadge entityId={`transaction:${row.id}`} /></span>
125
+ <span class="category">{categoryLabel(row.category)}</span>
126
+ </button>
127
+ <div class="cell-amount" class:income={row.amount > 0}>{formatAmount(row.amount, { signed: true })}</div>
128
+ <div class="cell-end">
129
+ {#if markable}
130
+ <CommandOnly>
131
+ <button type="button" class="done-button" disabled={busy} aria-label={`Mark ${name} done`} onclick={() => void onMarkDone?.(row)}>
132
+ <IconCheck class="h-3.5 w-3.5" />Done
133
+ </button>
134
+ </CommandOnly>
135
+ {:else if balance !== null}
136
+ <span class="balance {balanceTone}" title={balanceTitle} data-balance>{formatAmount(balance)}</span>
137
+ {:else if row.kind === "done"}
138
+ <span class="done-mark" title="Done"><IconCheck class="h-4 w-4" /><span class="sr-only">Done</span></span>
139
+ {/if}
140
+ </div>
141
+ <div class="cell-menu">
142
+ <CommandOnly>
143
+ <DropdownMenu.Root>
144
+ <DropdownMenu.Trigger
145
+ class="flex h-9 w-9 items-center justify-center rounded-lg text-[var(--stor-muted-text)] hover:bg-[var(--stor-surface-muted)] hover:text-[var(--stor-ink)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)] disabled:opacity-40"
146
+ aria-label={`Actions for ${name}`}
147
+ disabled={busy}
148
+ >
149
+ <IconDots class="h-4.5 w-4.5" />
150
+ </DropdownMenu.Trigger>
151
+ <DropdownMenu.Portal>
152
+ <DropdownMenu.Content side="bottom" align="end" sideOffset={4} loop class="stor-menu z-[var(--stor-layer-menu)] w-44 overflow-hidden py-1">
153
+ <DropdownMenu.Item class="cursor-default px-3 py-1.5 text-sm text-[var(--stor-ink)] outline-none data-[highlighted]:bg-[var(--stor-surface-muted)]" onSelect={() => onEdit?.(row)}>
154
+ Edit details
155
+ </DropdownMenu.Item>
156
+ {#if row.kind === "planned" || restorable}
157
+ <DropdownMenu.Item
158
+ class="cursor-default px-3 py-1.5 text-sm text-[var(--stor-ink)] outline-none data-[highlighted]:bg-[var(--stor-surface-muted)]"
159
+ onSelect={() => void (restorable ? onRestore?.(row) : onMarkDone?.(row))}
160
+ >
161
+ {restorable ? "Restore to plans" : "Mark done"}
162
+ </DropdownMenu.Item>
163
+ {/if}
164
+ <DropdownMenu.Item class="cursor-default px-3 py-1.5 text-sm text-[var(--stor-negative)] outline-none data-[highlighted]:bg-[var(--stor-negative-soft)]" onSelect={() => (confirmDelete = true)}>
165
+ Delete
166
+ </DropdownMenu.Item>
167
+ </DropdownMenu.Content>
168
+ </DropdownMenu.Portal>
169
+ </DropdownMenu.Root>
170
+ </CommandOnly>
171
+ </div>
172
+ </div>
173
+
174
+ <CommandConfirmDialog
175
+ show={confirmDelete}
176
+ title="Delete transaction"
177
+ message={`Delete “${name}”?`}
178
+ onConfirm={async () => {
179
+ confirmDelete = false;
180
+ await onDelete?.(row);
181
+ }}
182
+ onCancel={() => (confirmDelete = false)}
183
+ />
184
+
185
+ <style>
186
+ .timeline-row {
187
+ --check: 2.25rem;
188
+ --date: 2.25rem;
189
+ --gap: 0.625rem;
190
+ position: relative;
191
+ display: grid;
192
+ grid-template-columns: var(--check) var(--date) minmax(0, 1fr) auto 6.25rem 2.25rem;
193
+ grid-template-areas: "check date main amount end menu";
194
+ align-items: center;
195
+ column-gap: var(--gap);
196
+ min-height: 2.75rem;
197
+ padding: 0 0.5rem 0 calc(var(--river, 0px) + 0.25rem);
198
+ transition: background-color 120ms ease;
199
+ }
200
+ .timeline-row.no-date { --date: 0px; }
201
+ .timeline-row.divider::before {
202
+ content: "";
203
+ position: absolute;
204
+ top: 0;
205
+ right: 0.5rem;
206
+ left: calc(var(--river, 0px) + 0.25rem + var(--lead, calc(var(--check) + var(--date) + var(--gap) * 2)));
207
+ border-top: 1px solid color-mix(in srgb, var(--stor-border) 62%, transparent);
208
+ }
209
+ .timeline-row.compact {
210
+ grid-template-columns: var(--check) var(--date) minmax(0, 1fr) auto 2.25rem;
211
+ grid-template-areas: "check date main amount menu" "check date main end menu";
212
+ min-height: 3.25rem;
213
+ }
214
+ .timeline-row.compact.no-check {
215
+ --lead: calc(1rem + var(--date) + var(--gap));
216
+ grid-template-columns: var(--date) minmax(0, 1fr) auto 2.25rem;
217
+ grid-template-areas: "date main amount menu" "date main end menu";
218
+ padding-left: calc(var(--river, 0px) + 1.25rem);
219
+ }
220
+ .timeline-row.compact.no-check.no-date {
221
+ --lead: 1rem;
222
+ grid-template-columns: minmax(0, 1fr) auto 2.25rem;
223
+ grid-template-areas: "main amount menu" "main end menu";
224
+ }
225
+ .compact.no-check .cell-check, .compact.no-date .cell-date { display: none; }
226
+ .compact .cell-main { flex-direction: column; align-items: stretch; justify-content: center; gap: 1px; padding: 0.5rem 0; }
227
+ .compact .cell-amount { align-self: end; padding-top: 0.5rem; }
228
+ .compact .cell-end { align-self: start; padding-bottom: 0.5rem; }
229
+ .timeline-row:hover,
230
+ .timeline-row.highlighted {
231
+ background: linear-gradient(to right, transparent var(--river, 0px), var(--stor-surface-hover) var(--river, 0px));
232
+ }
233
+ .timeline-row.selected {
234
+ background: linear-gradient(to right, transparent var(--river, 0px), var(--stor-selected) var(--river, 0px));
235
+ }
236
+ .timeline-row.flash { animation: row-flash 1.8s ease-out; }
237
+ @keyframes row-flash {
238
+ from { background-color: var(--stor-selected); }
239
+ to { background-color: transparent; }
240
+ }
241
+ .gutter { position: absolute; top: 0; bottom: 0; left: 0; width: var(--river, 0px); }
242
+ .cell-check { grid-area: check; align-self: stretch; }
243
+ .check-hit { display: flex; height: 100%; min-height: 2.75rem; align-items: center; justify-content: center; cursor: pointer; }
244
+ .cell-date { grid-area: date; display: flex; flex-direction: column; line-height: 1.05; font-family: var(--stor-font-mono); }
245
+ .cell-date b { font-size: 13px; font-weight: 600; color: var(--stor-ink); }
246
+ .cell-date span { font-size: 10px; font-weight: 500; letter-spacing: 0.04em; text-transform: uppercase; color: var(--stor-muted-text); }
247
+ .cell-main {
248
+ grid-area: main;
249
+ display: flex;
250
+ min-width: 0;
251
+ min-height: 2.75rem;
252
+ align-items: baseline;
253
+ align-self: stretch;
254
+ gap: 0.5rem;
255
+ padding: 0.75rem 0;
256
+ border: 0;
257
+ background: transparent;
258
+ text-align: left;
259
+ border-radius: 8px;
260
+ }
261
+ button.cell-main:focus-visible { outline: 2px solid var(--stor-focus); outline-offset: -2px; }
262
+ button.cell-main:hover .name { text-decoration: underline; text-decoration-color: var(--stor-border); text-underline-offset: 3px; }
263
+ .name-line { display: inline-flex; min-width: 0; align-items: center; gap: 0.375rem; }
264
+ .name { overflow: hidden; min-width: 0; font-size: 13.5px; font-weight: 500; color: var(--stor-ink); text-overflow: ellipsis; white-space: nowrap; }
265
+ .category { flex: none; font-size: 12px; color: var(--stor-muted-text); white-space: nowrap; }
266
+ .done .name, .done .cell-amount { opacity: 0.62; }
267
+ .cell-amount { grid-area: amount; font-size: 13.5px; font-weight: 600; text-align: right; white-space: nowrap; color: var(--stor-ink); font-variant-numeric: tabular-nums; }
268
+ .cell-amount.income { color: var(--stor-positive); }
269
+ .cell-end { grid-area: end; display: flex; align-items: center; justify-content: flex-end; }
270
+ .cell-menu { grid-area: menu; display: flex; justify-content: flex-end; }
271
+ .balance { font-family: var(--stor-font-mono); font-size: 11.5px; font-weight: 500; color: var(--stor-muted-text); white-space: nowrap; }
272
+ .balance.low { color: var(--stor-ink); font-weight: 600; }
273
+ .balance.below { color: var(--stor-warning); font-weight: 600; }
274
+ .done-mark { display: inline-flex; color: var(--stor-muted-text); }
275
+ .done-button {
276
+ display: inline-flex;
277
+ height: 1.75rem;
278
+ align-items: center;
279
+ gap: 0.25rem;
280
+ padding: 0 0.625rem;
281
+ border: 0;
282
+ border-radius: 999px;
283
+ background: var(--stor-surface);
284
+ box-shadow: inset 0 0 0 1px var(--stor-border);
285
+ font-size: 12px;
286
+ font-weight: 600;
287
+ color: var(--stor-ink);
288
+ }
289
+ .done-button:hover { background: var(--stor-surface-hover); }
290
+ .done-button:disabled { opacity: 0.45; }
291
+ @media (max-width: 639px) {
292
+ .timeline-row {
293
+ --check: 2.5rem;
294
+ --date: 1.75rem;
295
+ --gap: 0.5rem;
296
+ grid-template-columns: var(--check) var(--date) minmax(0, 1fr) auto;
297
+ grid-template-areas: "check date main amount" "check date main end";
298
+ min-height: 2.875rem;
299
+ padding-right: 0.625rem;
300
+ }
301
+ /* Two tight lines per row, so a phone shows about eight transactions at once. */
302
+ .cell-main { flex-direction: column; align-items: stretch; justify-content: center; gap: 0; min-height: 0; padding: 0.375rem 0; line-height: 1.3; }
303
+ .cell-amount { align-self: end; padding-top: 0.375rem; line-height: 1.3; }
304
+ .cell-end { align-self: start; padding-bottom: 0.375rem; }
305
+ .cell-menu { display: none; }
306
+ .timeline-row.compact { grid-template-columns: var(--check) var(--date) minmax(0, 1fr) auto; grid-template-areas: "check date main amount" "check date main end"; }
307
+ .timeline-row.compact.no-check { grid-template-columns: var(--date) minmax(0, 1fr) auto; grid-template-areas: "date main amount" "date main end"; padding-left: 1rem; }
308
+ .timeline-row.compact.no-check.no-date { grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "main amount" "main end"; }
309
+ .done-button { height: 1.75rem; }
310
+ }
311
+ /* Phones read rows at full size now that checkboxes and balances step aside. */
312
+ @media (max-width: 767px) {
313
+ .timeline-row { min-height: 3.25rem; }
314
+ .name { font-size: 15px; }
315
+ .category { font-size: 12.5px; }
316
+ .cell-amount { font-size: 15px; }
317
+ .cell-date b { font-size: 14px; }
318
+ }
319
+ </style>