@hearthlog/module-finance 1.2.0 → 1.3.2

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.
@@ -2,7 +2,7 @@
2
2
  "manifestVersion": 1,
3
3
  "id": "org.stor.finance",
4
4
  "package": "@hearthlog/module-finance",
5
- "version": "1.2.0",
5
+ "version": "1.3.2",
6
6
  "compatibility": {
7
7
  "moduleApi": 1
8
8
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hearthlog/module-finance",
3
- "version": "1.2.0",
3
+ "version": "1.3.2",
4
4
  "license": "AGPL-3.0-only",
5
5
  "type": "module",
6
6
  "exports": {
@@ -19,8 +19,8 @@
19
19
  "kysely": "^0.29.6"
20
20
  },
21
21
  "peerDependencies": {
22
- "@hearthlog/contracts": "0.5.0",
23
- "@hearthlog/sdk": "0.5.0"
22
+ "@hearthlog/contracts": "0.5.0 || 0.6.0 || 0.9.0",
23
+ "@hearthlog/sdk": "0.5.0 || 0.6.0 || 0.9.0"
24
24
  },
25
25
  "repository": {
26
26
  "type": "git",
@@ -36,9 +36,9 @@
36
36
  "!**/*.test.ts"
37
37
  ],
38
38
  "devDependencies": {
39
- "@hearthlog/contracts": "0.5.0",
40
- "@hearthlog/sdk": "0.5.0",
41
- "@hearthlog/tooling": "0.5.0",
39
+ "@hearthlog/contracts": "0.5.0 || 0.6.0 || 0.9.0",
40
+ "@hearthlog/sdk": "0.5.0 || 0.6.0 || 0.9.0",
41
+ "@hearthlog/tooling": "0.5.0 || 0.6.0 || 0.9.0",
42
42
  "vitest": "^4.1.11"
43
43
  },
44
44
  "scripts": {
@@ -19,7 +19,6 @@
19
19
  anchor,
20
20
  show,
21
21
  bounds,
22
- domain,
23
22
  floor,
24
23
  available,
25
24
  selectedIds,
@@ -43,7 +42,6 @@
43
42
  show: TimelineShow;
44
43
  /** The months this filter lists; the first one is fixed while the list is open. */
45
44
  bounds: MonthBounds;
46
- domain: [number, number];
47
45
  floor: number;
48
46
  available: number;
49
47
  selectedIds: Set<string>;
@@ -63,8 +61,6 @@
63
61
  onVisibleChange?: (visible: { month: string; date: string }) => void;
64
62
  } = $props();
65
63
 
66
- const river = $derived(show !== "done");
67
-
68
64
  // Months are counted from today's month. All runs both ways, plans start
69
65
  // there and done history ends at its last month; an open end is wide
70
66
  // enough to feel endless without prepending anything. The first month is fixed while the
@@ -143,7 +139,6 @@
143
139
  home={{ ...todayTarget, label: "Today" }}
144
140
  {pick}
145
141
  onVisibleChange={(position) => onVisibleChange?.({ month: position.key, date: position.at })}
146
- class={river ? "[--river:2.75rem] sm:[--river:8rem] lg:[--river:9.5rem] xl:[--river:12rem]" : ""}
147
142
  data-timeline-scroll
148
143
  >
149
144
  {#snippet openingPlaceholder()}
@@ -155,7 +150,6 @@
155
150
  {data}
156
151
  {today}
157
152
  {show}
158
- {domain}
159
153
  {floor}
160
154
  {available}
161
155
  {selectedIds}
@@ -4,7 +4,7 @@
4
4
  import type { TimelineOutlook } from "../../data/finance-timeline.js";
5
5
  import { parseFloor } from "../../finance/floor.js";
6
6
  import { formatAmount } from "@hearthlog/sdk/format";
7
- import { Amount } from "@hearthlog/sdk/ui";
7
+ import { Metric } from "@hearthlog/sdk/ui";
8
8
  import { DataValue } from "@hearthlog/sdk/ui";
9
9
  import AssetView from "./AssetView.svelte";
10
10
  import IconChevronDown from "~icons/mdi/chevron-down";
@@ -56,53 +56,46 @@
56
56
  }
57
57
  </script>
58
58
 
59
- <!-- Phones show the two numbers that decide the month; opening Available now adds the rest and the assets. -->
60
- <section class="stor-panel readout shrink-0" class:open={assetsOpen || sheet} class:sheet aria-label="Outlook">
61
- <div class="cell">
62
- <button type="button" class="cell-button" aria-expanded={assetsOpen} aria-controls="finance-current-assets" onclick={() => (assetsOpen = !assetsOpen)}>
63
- <span class="label">Available now<IconChevronDown class="phone-only h-3.5 w-3.5 shrink-0 transition-transform {assetsOpen ? 'rotate-180' : ''}" /></span>
64
- <span class="value"><DataValue><Amount value={outlook.available} /></DataValue></span>
65
- <span class="sub">
66
- <span class="truncate">{assetNames || "Add current assets"}</span>
67
- <IconChevronDown class="h-3.5 w-3.5 shrink-0 transition-transform {assetsOpen ? 'rotate-180' : ''}" />
68
- </span>
59
+ <!-- One summary band: Available now leads as the display figure, the rest sit beside it behind hairlines.
60
+ Phones show the two numbers that decide the month; opening Available now adds the rest and the assets. -->
61
+ <section class="readout shrink-0" class:open={assetsOpen || sheet} class:sheet aria-label="Outlook">
62
+ <div class="cell lead">
63
+ <button type="button" class="cell-button" aria-expanded={assetsOpen} aria-controls="finance-current-assets" aria-label="Available now" onclick={() => (assetsOpen = !assetsOpen)}>
64
+ <Metric label="Available now" size="lg" unit="Kč" detail={assetNames || "Add current assets"}>
65
+ <DataValue>{formatAmount(outlook.available)}</DataValue>
66
+ </Metric>
67
+ <IconChevronDown class="chevron h-4 w-4 shrink-0 transition-transform {assetsOpen ? 'rotate-180' : ''}" />
69
68
  </button>
70
69
  </div>
71
70
  <div class="cell" class:warn={below}>
72
71
  <button type="button" class="cell-button" disabled={!onJump || lowDay === "Today"} onclick={() => onJump?.(outlook.low.date)}>
73
- <span class="label">Lowest ahead</span>
74
- <span class="value">
75
- <DataValue><Amount value={outlook.low.amount} /></DataValue>
76
- </span>
77
- <span class="sub">
78
- <DataValue>{lowDay}{lowDistance > 0 && floor === 0 && !below ? ` · in ${lowDistance} ${lowDistance === 1 ? "day" : "days"}` : ` · ${formatAmount(below ? -margin : margin)} ${below ? "under" : "over"} floor`}</DataValue>
79
- </span>
72
+ <Metric label="Lowest ahead" unit="Kč" tone={below ? "negative" : "neutral"}>
73
+ <DataValue>{formatAmount(outlook.low.amount)}</DataValue>
74
+ {#snippet delta()}<DataValue>{lowDay}{lowDistance > 0 && floor === 0 && !below ? ` · in ${lowDistance} ${lowDistance === 1 ? "day" : "days"}` : ` · ${formatAmount(below ? -margin : margin)} ${below ? "under" : "over"} floor`}</DataValue>{/snippet}
75
+ </Metric>
80
76
  </button>
81
77
  </div>
82
78
  <div class="cell extra">
83
79
  <button type="button" class="cell-button" disabled={!onJump || !outlook.endDate} onclick={() => outlook.endDate && onJump?.(outlook.endDate)}>
84
- <span class="label">After last plan</span>
85
- <span class="value">
86
- <DataValue><Amount value={outlook.end} /></DataValue>
87
- </span>
88
- <span class="sub"><DataValue>{outlook.endDate ? `${endDay} · ${formatAmount(outlook.end - outlook.available, { signed: true })}` : "Nothing planned"}</DataValue></span>
80
+ <Metric label="After last plan" unit="Kč">
81
+ <DataValue>{formatAmount(outlook.end)}</DataValue>
82
+ {#snippet delta()}<DataValue>{outlook.endDate ? `${endDay} · ${formatAmount(outlook.end - outlook.available, { signed: true })}` : "Nothing planned"}</DataValue>{/snippet}
83
+ </Metric>
89
84
  </button>
90
85
  </div>
91
86
  <div class="cell extra">
92
- <label class="label" for="finance-floor">Floor</label>
93
- <span class="value">
87
+ <label class="floor-label" for="finance-floor">Edit floor</label>
88
+ <Metric label="Floor" unit="Kč" detail="Warn below this balance">
94
89
  <input
95
90
  id="finance-floor"
96
91
  class="floor-input"
97
92
  inputmode="numeric"
98
93
  autocomplete="off"
99
94
  value={formatAmount(floor)}
100
- aria-describedby="finance-floor-help"
101
95
  onchange={(event) => commitFloor(event.currentTarget)}
102
96
  onkeydown={(event) => { if (event.key === "Enter") event.currentTarget.blur(); }}
103
- /><span class="stor-unit">Kč</span>
104
- </span>
105
- <span class="sub" id="finance-floor-help">Warn below this balance</span>
97
+ />
98
+ </Metric>
106
99
  </div>
107
100
  </section>
108
101
 
@@ -131,68 +124,35 @@
131
124
  <style>
132
125
  .readout {
133
126
  display: grid;
134
- grid-template-columns: repeat(4, minmax(0, 1fr));
135
- overflow: hidden;
136
- }
137
- .cell {
138
- position: relative;
139
- display: flex;
140
- min-width: 0;
141
- flex-direction: column;
142
- gap: 2px;
143
- padding: 0.875rem 1.25rem 0.8125rem;
144
- border-left: 1px solid color-mix(in srgb, var(--stor-border) 62%, transparent);
127
+ grid-template-columns: 1.55fr repeat(3, minmax(0, 1fr));
128
+ border-block: 1px solid var(--stor-border);
145
129
  }
130
+ .cell { position: relative; min-width: 0; padding: 1rem 1.5rem; border-left: 1px solid var(--stor-border); }
131
+ .cell:first-child { border-left: 0; padding-left: 0; }
146
132
  .cell:has(> .cell-button) { padding: 0; }
147
- .cell:first-child { border-left: 0; }
148
133
  .cell-button {
149
- display: flex;
150
- min-width: 0;
151
- flex-direction: column;
152
- gap: 2px;
153
- padding: 0.875rem 1.25rem 0.8125rem;
134
+ position: relative;
135
+ display: block;
136
+ width: 100%;
137
+ height: 100%;
138
+ padding: 1rem 1.5rem;
154
139
  border: 0;
155
140
  background: transparent;
156
141
  text-align: left;
157
142
  color: inherit;
158
143
  }
159
- .cell:first-child .cell-button { border-radius: var(--stor-panel-radius) 0 0 var(--stor-panel-radius); }
144
+ .lead .cell-button { padding-left: 0; }
160
145
  .cell-button:not(:disabled):hover { background: var(--stor-surface-hover); }
161
146
  .cell-button:disabled { cursor: default; }
162
147
  .cell-button:focus-visible { outline: 2px solid var(--stor-focus); outline-offset: -2px; }
163
- .label { font-size: 12px; font-weight: 500; color: var(--stor-muted-text); }
164
- .value {
165
- display: flex;
166
- flex-wrap: wrap;
167
- align-items: baseline;
168
- column-gap: 0.5rem;
169
- font-size: 22px;
170
- font-weight: 600;
171
- line-height: 1.25;
172
- letter-spacing: -0.012em;
173
- color: var(--stor-ink);
174
- white-space: nowrap;
175
- font-variant-numeric: tabular-nums;
176
- }
177
- .warn .value, .warn .sub { color: var(--stor-warning); }
178
- .sub {
179
- display: flex;
180
- min-width: 0;
181
- align-items: center;
182
- gap: 0.25rem;
183
- overflow: hidden;
184
- font-family: var(--stor-font-mono);
185
- font-size: 11.5px;
186
- font-weight: 500;
187
- color: var(--stor-muted-text);
188
- white-space: nowrap;
189
- text-overflow: ellipsis;
190
- }
148
+ .readout :global(.chevron) { position: absolute; top: 1rem; right: 1rem; color: var(--stor-faint-text); }
149
+ .warn :global(.stor-metric-support) { color: var(--stor-warning); }
150
+ .floor-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
191
151
  .floor-input {
192
152
  width: 7ch;
193
153
  padding: 0;
194
154
  border: 0;
195
- border-bottom: 1.5px dashed var(--stor-border);
155
+ border-bottom: 1.5px dashed var(--stor-border-strong);
196
156
  border-radius: 0;
197
157
  background: transparent;
198
158
  font: inherit;
@@ -202,28 +162,21 @@
202
162
  }
203
163
  .floor-input:focus { border-bottom-color: var(--stor-focus); }
204
164
  @media (max-width: 1023px) {
205
- .cell { padding: 0.625rem 0.875rem; }
206
- .cell-button { padding: 0.625rem 0.875rem; }
207
- .value { font-size: 18px; }
165
+ .readout { grid-template-columns: repeat(4, minmax(0, 1fr)); }
166
+ .cell, .cell-button { padding: 0.75rem 1rem; }
208
167
  }
209
- .label { display: flex; align-items: center; gap: 0.25rem; }
210
- .label :global(.phone-only) { display: none; }
211
- .sheet { grid-template-columns: repeat(2, minmax(0, 1fr)); box-shadow: none; border: 1px solid color-mix(in srgb, var(--stor-border) 62%, transparent); }
168
+ .sheet { grid-template-columns: repeat(2, minmax(0, 1fr)); border-block: 0; }
169
+ .sheet .cell:first-child { padding-left: 1rem; }
170
+ .sheet .lead .cell-button { padding-left: 1rem; }
212
171
  .sheet .cell:nth-child(3) { border-left: 0; }
213
- .sheet .extra { display: flex; border-top: 1px solid color-mix(in srgb, var(--stor-border) 62%, transparent); }
214
- .sheet .label :global(.phone-only) { display: none; }
172
+ .sheet .extra { border-top: 1px solid var(--stor-border); }
215
173
  /* Phones keep the outlook to one short row: label and figure, the details once it is opened. */
216
174
  @media (max-width: 639px) {
217
175
  .readout { grid-template-columns: repeat(2, minmax(0, 1fr)); }
218
- .cell, .cell-button { gap: 0; padding: 0.4375rem 0.75rem 0.5rem; }
219
- .label { font-size: 11px; line-height: 1.3; }
220
- .label :global(.phone-only) { display: block; }
221
- .value { font-size: 16px; line-height: 1.3; }
222
- .sub { font-size: 11px; }
223
- .readout:not(.open) .sub { display: none; }
176
+ .cell, .cell-button { padding: 0.5rem 0.75rem; }
177
+ .readout:not(.open) :global(.stor-metric-support) { display: none; }
224
178
  .extra { display: none; }
225
- .open .extra { display: flex; border-top: 1px solid color-mix(in srgb, var(--stor-border) 62%, transparent); }
179
+ .open .extra { display: block; border-top: 1px solid var(--stor-border); }
226
180
  .open .cell:nth-child(3) { border-left: 0; }
227
- .open .cell:first-child .cell-button { border-radius: var(--stor-panel-radius) 0 0 0; }
228
181
  }
229
182
  </style>
@@ -1,23 +1,20 @@
1
1
  <script lang="ts">
2
2
  import { format, parseISO } from "date-fns";
3
3
  import type { TimelineRow } from "../../data/finance-timeline.js";
4
- import { axisLabel, type TimelineShow } from "../../finance/timeline.js";
4
+ import type { TimelineShow } from "../../finance/timeline.js";
5
5
  import { monthKeyOf } from "@hearthlog/sdk/axis";
6
6
  import { formatAmount } from "@hearthlog/sdk/format";
7
7
  import TimelineRowItem, { type BalanceTone, type RowLike } from "./TimelineRowItem.svelte";
8
- import RiverSlice, { type RiverMark } from "./RiverSlice.svelte";
9
8
  import type { MonthData } from "./timeline-months.svelte.js";
10
9
 
11
- type Slice = { before: number; after: number; projected: boolean; mark: RiverMark; label: string; grid: boolean; below: boolean };
12
- type RowEntry = { type: "row"; key: string; row: TimelineRow; divider: boolean; balance: number | null; tone: BalanceTone; slice: Slice };
13
- type Entry = RowEntry | { type: "today"; key: string; slice: Slice };
10
+ type RowEntry = { type: "row"; key: string; row: TimelineRow; divider: boolean; balance: number | null; tone: BalanceTone };
11
+ type Entry = RowEntry | { type: "today"; key: string };
14
12
 
15
13
  let {
16
14
  monthKey,
17
15
  data,
18
16
  today,
19
17
  show,
20
- domain,
21
18
  floor,
22
19
  available,
23
20
  selectedIds,
@@ -36,7 +33,6 @@
36
33
  data: MonthData | undefined;
37
34
  today: string;
38
35
  show: TimelineShow;
39
- domain: [number, number];
40
36
  floor: number;
41
37
  available: number;
42
38
  selectedIds: Set<string>;
@@ -53,19 +49,19 @@
53
49
  } = $props();
54
50
 
55
51
  /**
56
- * The balance line runs from the Today line through the plans; done rows
57
- * never draw it. Months from today's on show balances; earlier months and
58
- * done history show their flows.
52
+ * Balances follow the Today line through the plans; done rows never carry
53
+ * one. Months from today's on show balances; earlier months and done
54
+ * history show their flows.
59
55
  */
60
- const river = $derived(show !== "done");
56
+ const tracked = $derived(show !== "done");
61
57
  const isTodayMonth = $derived(monthKey === monthKeyOf(today));
62
58
  const afterToday = $derived(monthKey > monthKeyOf(today));
63
- const balances = $derived(river && (isTodayMonth || afterToday));
59
+ const balances = $derived(tracked && (isTodayMonth || afterToday));
64
60
  const title = $derived(format(parseISO(`${monthKey}-01`), "MMMM yyyy"));
65
61
 
66
62
  /** Lays out the SQL-ordered rows and carries the balance line through them. */
67
- const layout = $derived.by<{ entries: Entry[]; last: number }>(() => {
68
- if (!data) return { entries: [], last: 0 };
63
+ const layout = $derived.by<{ entries: Entry[] }>(() => {
64
+ if (!data) return { entries: [] };
69
65
  const result: Entry[] = [];
70
66
  let todayPlaced = false;
71
67
  let lastDate: string | null = null;
@@ -74,7 +70,7 @@
74
70
  if (todayPlaced || !isTodayMonth) return;
75
71
  todayPlaced = true;
76
72
  current = available;
77
- result.push({ type: "today", key: "today", slice: { before: available, after: available, projected: false, mark: "today", label: "", grid: false, below: false } });
73
+ result.push({ type: "today", key: "today" });
78
74
  lastDate = null;
79
75
  };
80
76
  for (const row of data.rows) {
@@ -83,15 +79,6 @@
83
79
  if (projected) placeToday();
84
80
  const after = row.balanceAfter ?? current;
85
81
  const low = row.isLow && row.balanceAfter !== null;
86
- const slice: Slice = {
87
- before: current,
88
- after,
89
- projected,
90
- mark: low ? "low" : "none",
91
- label: low ? `Low ahead ${axisLabel(after)}` : "",
92
- grid: projected,
93
- below: low && after < floor,
94
- };
95
82
  current = after;
96
83
  const entry: RowEntry = {
97
84
  type: "row",
@@ -100,25 +87,23 @@
100
87
  divider: row.firstOfDay && lastDate !== null,
101
88
  balance: row.balanceAfter,
102
89
  tone: low ? (after < floor ? "below" : "low") : "default",
103
- slice,
104
90
  };
105
91
  lastDate = row.date;
106
92
  result.push(entry);
107
93
  }
108
94
  placeToday();
109
- return { entries: result, last: current };
95
+ return { entries: result };
110
96
  });
111
97
  const entries = $derived(layout.entries);
112
98
 
113
99
  const empty = $derived(!!data && data.rows.length === 0 && !isTodayMonth);
100
+ const count = $derived(data?.rows.length ?? 0);
114
101
  const change = $derived(data ? data.stats.end - data.stats.start : 0);
115
102
  const net = $derived(data ? data.stats.income + data.stats.expense : 0);
103
+ /** A month after the current one holds plans only. */
104
+ const planned = $derived(afterToday);
116
105
  </script>
117
106
 
118
- {#snippet slice(value: Slice, hot = false, income = false)}
119
- <RiverSlice before={value.before} after={value.after} {domain} {floor} projected={value.projected} {income} {hot} mark={value.mark} label={value.label} grid={value.grid} below={value.below} />
120
- {/snippet}
121
-
122
107
  {#snippet rowView(entry: RowEntry)}
123
108
  <TimelineRowItem
124
109
  row={entry.row}
@@ -137,39 +122,25 @@
137
122
  {onRestore}
138
123
  {onDelete}
139
124
  {onHover}
140
- >
141
- {#snippet gutter()}{#if river && entry.slice.projected}{@render slice(entry.slice, hoveredId === entry.row.id, entry.row.amount > 0)}{/if}{/snippet}
142
- </TimelineRowItem>
125
+ />
143
126
  {/snippet}
144
127
 
145
128
  <section class="month" class:empty aria-label={title}>
146
- {#if river && afterToday && data}
147
- <!-- The header sticks while its month scrolls; its piece of the line stays in place. -->
148
- <div class="gutter head-gutter" data-river>
149
- {@render slice({ before: data.stats.start, after: data.stats.start, projected: true, mark: empty ? "none" : "month", label: empty ? "" : formatAmount(data.stats.start), grid: true, below: false })}
150
- </div>
151
- {/if}
152
129
  <header class="month-head" data-month-head>
153
- <h3 class="stor-serif-title month-title">{title}</h3>
130
+ <h3 class="stor-title-2 month-title">{title}</h3>
154
131
  {#if !data}
155
132
  <span class="month-stats" data-balance>Loading…</span>
156
133
  {:else if empty}
157
134
  <span class="month-stats" data-balance>No transactions{#if balances}{" · "}<b>{formatAmount(data.stats.end)}</b>{/if}</span>
158
- {:else if !balances}
159
- <span
160
- data-balance
161
- class="month-stats"
162
- title={`In ${formatAmount(data.stats.income, { signed: true })} · Out ${formatAmount(data.stats.expense)} · Net ${formatAmount(net, { signed: true })}`}
163
- >
164
- <span class="hidden sm:inline">{formatAmount(data.stats.income, { signed: true })}{" · "}{formatAmount(data.stats.expense)}{" · "}</span><b class:income={net > 0}>{formatAmount(net, { signed: true })}</b>
165
- </span>
166
135
  {:else}
167
136
  <span
168
137
  data-balance
169
138
  class="month-stats"
170
- title={`Starts ${formatAmount(data.stats.start)} · In ${formatAmount(data.stats.income, { signed: true })} · Out ${formatAmount(data.stats.expense)} · Ends ${formatAmount(data.stats.end)}`}
139
+ title={balances
140
+ ? `Starts ${formatAmount(data.stats.start)} · In ${formatAmount(data.stats.income, { signed: true })} · Out ${formatAmount(data.stats.expense)} · Ends ${formatAmount(data.stats.end)}`
141
+ : `In ${formatAmount(data.stats.income, { signed: true })} · Out ${formatAmount(data.stats.expense)} · Net ${formatAmount(net, { signed: true })}`}
171
142
  >
172
- <span class="hidden sm:inline">{formatAmount(data.stats.start)}{" → "}</span><b>{formatAmount(data.stats.end)}</b>{" "}<b class:income={change > 0}>({formatAmount(change, { signed: true })})</b>
143
+ <b class:income={net > 0}>{formatAmount(net, { signed: true })} Kč</b>{" "}{planned ? "planned" : "net"}{" · "}{count} {count === 1 ? "transaction" : "transactions"}
173
144
  </span>
174
145
  {/if}
175
146
  </header>
@@ -184,72 +155,56 @@
184
155
  {@render rowView(entry)}
185
156
  {:else}
186
157
  <div class="today-line" data-today-line>
187
- {#if river}<div class="gutter" data-river>{@render slice(entry.slice)}</div>{/if}
188
- <span class="today-pill">Today · {format(parseISO(today), "EEE d MMM")}</span>
158
+ <span class="today-dot" aria-hidden="true"></span>
159
+ <span>Today · {format(parseISO(today), "EEEE d MMMM")}</span>
189
160
  <span class="today-rule" aria-hidden="true"></span>
190
- <span class="today-available" data-balance><b>{formatAmount(available)}</b><span class="hidden sm:inline">{" "}available</span></span>
161
+ <span class="today-available" data-balance><b>{formatAmount(available)} Kč</b></span>
191
162
  </div>
192
163
  {/if}
193
164
  {/each}
194
- {#if !empty}
195
- <div class="tail" aria-hidden="true">
196
- {#if balances}<div class="gutter" data-river>{@render slice({ before: layout.last, after: data.stats.end, projected: true, mark: "none", label: "", grid: true, below: false })}</div>{/if}
197
- </div>
198
- {/if}
199
165
  {/if}
200
166
  </section>
201
167
 
202
168
  <style>
203
- .month { position: relative; }
204
- .tail { position: relative; height: 0.75rem; }
205
- .gutter { position: absolute; top: 0; bottom: 0; left: 0; width: var(--river, 0px); }
169
+ .month { position: relative; padding: 0 0.5rem 0.5rem; }
206
170
  .month-head {
207
- --head: 3rem;
208
171
  position: sticky;
209
172
  top: 0;
210
173
  z-index: 2;
211
174
  display: flex;
212
- height: var(--head);
213
175
  align-items: baseline;
176
+ justify-content: space-between;
214
177
  gap: 0.75rem;
215
178
  margin: 0;
216
- padding: 1.125rem 0.75rem 0 calc(var(--river, 0px) + 0.75rem);
217
- background: linear-gradient(to right, transparent var(--river, 0px), var(--stor-surface) var(--river, 0px));
218
- }
219
- .head-gutter { bottom: auto; height: 3rem; }
220
- .empty .head-gutter { height: 2.25rem; }
221
- .month-head::after {
222
- content: "";
223
- position: absolute;
224
- right: 0.5rem;
225
- bottom: 0;
226
- left: calc(var(--river, 0px) + 0.75rem);
227
- border-bottom: 1px solid color-mix(in srgb, var(--stor-border) 62%, transparent);
179
+ padding: 1.5rem 0 0.625rem;
180
+ border-bottom: 1px solid var(--stor-border-strong);
181
+ background: var(--stor-surface);
228
182
  }
229
- .empty .month-head { --head: 2.25rem; position: relative; padding-top: 0.625rem; background: transparent; }
230
- .empty .month-head::after { display: none; }
231
- .empty .month-title { font-size: 14px; color: var(--stor-muted-text); }
232
- .month-title { margin: 0; font-size: 17px; line-height: 1.3; color: var(--stor-ink); white-space: nowrap; }
233
- .month-stats { margin-left: auto; font-family: var(--stor-font-mono); font-size: 11.5px; font-weight: 500; color: var(--stor-muted-text); white-space: nowrap; }
183
+ .empty .month-head { position: relative; padding-top: 0.75rem; opacity: 0.7; }
184
+ .month-title { margin: 0; color: var(--stor-ink); white-space: nowrap; }
185
+ .month-stats { font-size: var(--stor-text-small); line-height: var(--stor-text-small-line); color: var(--stor-muted-text); white-space: nowrap; font-variant-numeric: tabular-nums; }
234
186
  .month-stats b { font-weight: 600; color: var(--stor-ink); }
235
187
  .month-stats b.income { color: var(--stor-positive); }
236
- .placeholder { display: flex; min-height: 21rem; flex-direction: column; gap: 0.625rem; padding: 0.75rem 0.75rem 1rem calc(var(--river, 0px) + 0.75rem); }
188
+ .placeholder { display: flex; min-height: 21rem; flex-direction: column; gap: 0.625rem; padding: 0.75rem 0 1rem; }
237
189
  .placeholder span { height: 1.5rem; border-radius: 8px; background: var(--stor-surface-muted); }
238
190
  .today-line {
239
191
  position: relative;
240
192
  display: flex;
241
193
  align-items: center;
242
194
  gap: 0.625rem;
243
- height: 2.75rem;
244
- padding: 0 0.75rem 0 calc(var(--river, 0px) + 0.75rem);
195
+ height: 3rem;
196
+ color: var(--stor-now);
197
+ font-size: var(--stor-text-small);
198
+ font-weight: 600;
245
199
  }
246
- .today-pill { flex: none; padding: 3px 9px; border-radius: 999px; background: var(--stor-action); color: var(--stor-action-text); font-size: 11.5px; font-weight: 600; }
247
- .today-rule { flex: 1; height: 1.5px; background: var(--stor-action); opacity: 0.85; }
248
- .today-available { flex: none; font-family: var(--stor-font-mono); font-size: 11.5px; font-weight: 500; color: var(--stor-muted-text); }
200
+ .today-dot { flex: none; width: 8px; height: 8px; border-radius: 999px; background: var(--stor-now); box-shadow: 0 0 0 4px var(--stor-now-soft); }
201
+ .today-rule { flex: 1; height: 1.5px; background: var(--stor-now); opacity: 0.55; }
202
+ .today-available { flex: none; font-variant-numeric: tabular-nums; color: var(--stor-muted-text); }
249
203
  .today-available b { font-weight: 600; color: var(--stor-ink); }
250
204
  /* Phones name the month in the toolbar above the list, so headers here only divide months. */
251
205
  @media (max-width: 767px) {
252
- .month-head { --head: 2.25rem; position: relative; padding-top: 0.875rem; }
253
- .month-title { font-family: var(--stor-font-sans); font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--stor-muted-text); }
206
+ .month { padding-inline: 0; }
207
+ .month-head { position: relative; padding: 0.875rem 0.75rem 0.5rem; }
208
+ .month-title { font-size: var(--stor-text-title-3); line-height: var(--stor-text-title-3-line); }
254
209
  }
255
210
  </style>
@@ -13,11 +13,12 @@
13
13
  </script>
14
14
 
15
15
  <script lang="ts">
16
- import type { Snippet } from "svelte";
17
16
  import { DropdownMenu } from "bits-ui";
18
17
  import { format, parseISO } from "date-fns";
19
18
  import { categoryLabel } from "../../finance/categories.js";
19
+ import { categoryIcon } from "../../finance/category-icons.js";
20
20
  import { formatAmount } from "@hearthlog/sdk/format";
21
+ import { Badge } from "@hearthlog/sdk/ui";
21
22
  import { CommandOnly } from "@hearthlog/sdk/ui";
22
23
  import { CommandConfirmDialog } from "@hearthlog/sdk/ui";
23
24
  import { SyncStateBadge } from "@hearthlog/sdk/ui";
@@ -31,9 +32,6 @@
31
32
  divider = false,
32
33
  balance = null,
33
34
  balanceTone = "default",
34
- showDate = true,
35
- compact = false,
36
- selectable = true,
37
35
  selected = false,
38
36
  busy = false,
39
37
  highlighted = false,
@@ -44,7 +42,6 @@
44
42
  onRestore,
45
43
  onDelete,
46
44
  onHover,
47
- gutter,
48
45
  }: {
49
46
  row: RowLike;
50
47
  today: string;
@@ -54,10 +51,6 @@
54
51
  /** The running balance after this row, for plans. */
55
52
  balance?: number | null;
56
53
  balanceTone?: BalanceTone;
57
- showDate?: boolean;
58
- /** Stacked layout for narrow side panels. */
59
- compact?: boolean;
60
- selectable?: boolean;
61
54
  selected?: boolean;
62
55
  busy?: boolean;
63
56
  highlighted?: boolean;
@@ -68,17 +61,17 @@
68
61
  onRestore?: (row: RowLike) => void | Promise<void>;
69
62
  onDelete?: (row: RowLike) => void | Promise<void>;
70
63
  onHover?: (row: RowLike | null) => void;
71
- /** Drawn in the river gutter to the left of the row. */
72
- gutter?: Snippet;
73
64
  } = $props();
74
65
 
75
66
  let confirmDelete = $state(false);
76
67
  const day = $derived(parseISO(row.date));
68
+ const planned = $derived(row.kind === "planned");
77
69
  /** Today's plan can leave the outlook early; tomorrow it is done anyway. */
78
- const markable = $derived(row.kind === "planned" && row.date === today);
70
+ const markable = $derived(planned && row.date === today);
79
71
  /** Only a row done ahead of its day can go back to plans; earlier days are done by date. */
80
72
  const restorable = $derived(row.kind === "done" && row.date >= today);
81
73
  const name = $derived(row.name || "Untitled");
74
+ const Icon = $derived(categoryIcon(row.category));
82
75
  const balanceTitle = $derived(
83
76
  balanceTone === "low" || balanceTone === "below" ? "Lowest balance ahead"
84
77
  : "Balance after this plan",
@@ -88,10 +81,7 @@
88
81
  <div
89
82
  class="timeline-row"
90
83
  class:divider
91
- class:no-date={!showDate}
92
- class:no-check={!selectable}
93
- class:compact
94
- class:done={row.kind === "done"}
84
+ class:planned
95
85
  class:selected
96
86
  class:highlighted
97
87
  class:flash
@@ -103,28 +93,29 @@
103
93
  onmouseenter={() => onHover?.(row)}
104
94
  onmouseleave={() => onHover?.(null)}
105
95
  >
106
- {#if gutter}<div class="gutter" data-river>{@render gutter()}</div>{/if}
107
96
  <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}
97
+ <label class="check-hit">
98
+ <input
99
+ type="checkbox"
100
+ class="h-4 w-4 rounded border-[var(--stor-field-border)] accent-[var(--stor-action)]"
101
+ checked={selected}
102
+ aria-label={`Select ${name}`}
103
+ onchange={(event) => onSelect?.(row, event.currentTarget.checked)}
104
+ />
105
+ </label>
119
106
  </div>
120
107
  <div class="cell-date" aria-hidden="true">
121
- {#if showDate && firstOfDay}<b>{format(day, "d")}</b><span>{format(day, "EEE")}</span>{/if}
108
+ {#if firstOfDay}<span class="weekday">{format(day, "EEE")}</span><b class="stor-title-2 daynum">{format(day, "d")}</b>{/if}
122
109
  </div>
110
+ <span class="tile" aria-hidden="true"><Icon class="h-[18px] w-[18px]" /></span>
123
111
  <button type="button" class="cell-main" onclick={() => onEdit?.(row)}>
124
112
  <span class="name-line"><span class="name">{name}</span><SyncStateBadge entityId={`transaction:${row.id}`} /></span>
125
- <span class="category">{categoryLabel(row.category)}</span>
113
+ <span class="category">{#if planned}<Badge class="planned-badge">Planned</Badge>{/if}{categoryLabel(row.category)}</span>
126
114
  </button>
127
- <div class="cell-amount" class:income={row.amount > 0}>{formatAmount(row.amount, { signed: true })}</div>
115
+ <div class="cell-amount">
116
+ <span class="amount" class:income={row.amount > 0}>{formatAmount(row.amount, { signed: true })}</span>
117
+ {#if balance !== null}<span class="balance {balanceTone}" title={balanceTitle} data-balance>{formatAmount(balance)}</span>{/if}
118
+ </div>
128
119
  <div class="cell-end">
129
120
  {#if markable}
130
121
  <CommandOnly>
@@ -132,10 +123,6 @@
132
123
  <IconCheck class="h-3.5 w-3.5" />Done
133
124
  </button>
134
125
  </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
126
  {/if}
140
127
  </div>
141
128
  <div class="cell-menu">
@@ -185,93 +172,71 @@
185
172
  <style>
186
173
  .timeline-row {
187
174
  --check: 2.25rem;
188
- --date: 2.25rem;
189
- --gap: 0.625rem;
175
+ --date: 3.25rem;
176
+ --gap: 0.75rem;
190
177
  position: relative;
191
178
  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";
179
+ grid-template-columns: var(--check) var(--date) 2rem minmax(0, 1fr) auto 4.5rem 2.25rem;
194
180
  align-items: center;
195
181
  column-gap: var(--gap);
196
- min-height: 2.75rem;
197
- padding: 0 0.5rem 0 calc(var(--river, 0px) + 0.25rem);
182
+ min-height: 3.375rem;
183
+ padding: 0 0.5rem;
198
184
  transition: background-color 120ms ease;
199
185
  }
200
- .timeline-row.no-date { --date: 0px; }
201
186
  .timeline-row.divider::before {
202
187
  content: "";
203
188
  position: absolute;
204
189
  top: 0;
205
190
  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);
191
+ left: calc(var(--check) + var(--gap) + 0.5rem);
192
+ border-top: 1px solid var(--stor-border);
219
193
  }
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
194
  .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
- }
195
+ .timeline-row.highlighted { background: var(--stor-surface-hover); }
196
+ .timeline-row.selected { background: var(--stor-selected); }
236
197
  .timeline-row.flash { animation: row-flash 1.8s ease-out; }
237
198
  @keyframes row-flash {
238
199
  from { background-color: var(--stor-selected); }
239
200
  to { background-color: transparent; }
240
201
  }
241
- .gutter { position: absolute; top: 0; bottom: 0; left: 0; width: var(--river, 0px); }
242
- .cell-check { grid-area: check; align-self: stretch; }
202
+ /* The checkbox keeps its column but only shows on hover, focus, a selected row or while selecting. */
203
+ .cell-check { align-self: stretch; opacity: 0; transition: opacity 120ms ease; }
204
+ .timeline-row:hover .cell-check, .cell-check:focus-within, .selected .cell-check, :global([data-selecting="true"]) .cell-check { opacity: 1; }
243
205
  .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); }
206
+ .cell-date { display: flex; flex-direction: column; line-height: 14px; }
207
+ .weekday { font-size: 10.5px; line-height: 14px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--stor-muted-text); }
208
+ .daynum { font-size: 20px; line-height: 22px; font-weight: 500; color: var(--stor-ink); }
209
+ .planned .daynum { color: var(--stor-muted-text); }
210
+ .tile { display: inline-flex; width: 2rem; height: 2rem; align-items: center; justify-content: center; border-radius: 10px; background: var(--stor-surface-muted); color: var(--stor-muted-text); }
211
+ .planned .tile { background: transparent; box-shadow: inset 0 0 0 1px var(--stor-border-strong); }
247
212
  .cell-main {
248
- grid-area: main;
249
213
  display: flex;
250
214
  min-width: 0;
251
- min-height: 2.75rem;
252
- align-items: baseline;
215
+ flex-direction: column;
216
+ justify-content: center;
253
217
  align-self: stretch;
254
- gap: 0.5rem;
255
- padding: 0.75rem 0;
218
+ padding: 0.5rem 0;
256
219
  border: 0;
257
220
  background: transparent;
258
221
  text-align: left;
259
222
  border-radius: 8px;
260
223
  }
261
224
  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; }
225
+ button.cell-main:hover .name { text-decoration: underline; text-decoration-color: var(--stor-border-strong); text-underline-offset: 3px; }
263
226
  .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; }
227
+ .name { overflow: hidden; min-width: 0; font-size: 14px; line-height: 20px; font-weight: 500; color: var(--stor-ink); text-overflow: ellipsis; white-space: nowrap; }
228
+ .planned .name { color: var(--stor-muted-text); }
229
+ .category { display: flex; align-items: center; gap: 0.375rem; font-size: 12px; line-height: 16px; color: var(--stor-muted-text); white-space: nowrap; }
230
+ .category :global(.planned-badge) { min-height: 18px; padding: 0 6px; font-size: 11px; line-height: 18px; }
231
+ .cell-amount { display: flex; flex-direction: column; align-items: flex-end; font-variant-numeric: tabular-nums; }
232
+ .amount { font-size: 14px; line-height: 20px; font-weight: 600; white-space: nowrap; color: var(--stor-ink); }
233
+ .amount.income { color: var(--stor-positive); }
234
+ .planned .amount { font-weight: 500; color: var(--stor-muted-text); }
235
+ .balance { font-size: 12px; line-height: 16px; color: var(--stor-faint-text); white-space: nowrap; }
272
236
  .balance.low { color: var(--stor-ink); font-weight: 600; }
273
237
  .balance.below { color: var(--stor-warning); font-weight: 600; }
274
- .done-mark { display: inline-flex; color: var(--stor-muted-text); }
238
+ .cell-end { display: flex; align-items: center; justify-content: flex-end; }
239
+ .cell-menu { display: flex; justify-content: flex-end; }
275
240
  .done-button {
276
241
  display: inline-flex;
277
242
  height: 1.75rem;
@@ -281,7 +246,7 @@
281
246
  border: 0;
282
247
  border-radius: 999px;
283
248
  background: var(--stor-surface);
284
- box-shadow: inset 0 0 0 1px var(--stor-border);
249
+ box-shadow: inset 0 0 0 1px var(--stor-border-strong);
285
250
  font-size: 12px;
286
251
  font-weight: 600;
287
252
  color: var(--stor-ink);
@@ -291,29 +256,22 @@
291
256
  @media (max-width: 639px) {
292
257
  .timeline-row {
293
258
  --check: 2.5rem;
294
- --date: 1.75rem;
259
+ --date: 2.25rem;
295
260
  --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;
261
+ grid-template-columns: var(--check) var(--date) 2rem minmax(0, 1fr) auto;
262
+ grid-template-areas: "check date tile main amount" "check date tile main end";
263
+ min-height: 3.25rem;
299
264
  padding-right: 0.625rem;
300
265
  }
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; }
266
+ .cell-check { grid-area: check; }
267
+ .cell-date { grid-area: date; }
268
+ .tile { grid-area: tile; }
269
+ .cell-main { grid-area: main; }
270
+ .cell-amount { grid-area: amount; }
271
+ .cell-end { grid-area: end; }
305
272
  .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; }
273
+ .daynum { font-size: 17px; }
314
274
  .name { font-size: 15px; }
315
- .category { font-size: 12.5px; }
316
- .cell-amount { font-size: 15px; }
317
- .cell-date b { font-size: 14px; }
275
+ .amount { font-size: 15px; }
318
276
  }
319
277
  </style>
@@ -412,6 +412,25 @@ export function createTransactionRepository(session: ReadModelSession) {
412
412
  return toNumber(result?.count);
413
413
  },
414
414
 
415
+ /** How many rows of the month are plans and how many are done, by the timeline's rule: archived or dated before today is done. */
416
+ async countKindsInMonth(start: string, end: string, today: string): Promise<{ planned: number; done: number }> {
417
+ const done = sql<boolean>`(transaction_status = 'archived' OR substr(trim(due_date), 1, 10) < ${today})`;
418
+ const result = await withTimeout(
419
+ session.selectFrom('transactions')
420
+ .select(({ fn }) => [
421
+ fn.countAll().as('total'),
422
+ sql<number>`COALESCE(SUM(CASE WHEN ${done} THEN 1 ELSE 0 END), 0)`.as('done'),
423
+ ])
424
+ .where(sql<boolean>`substr(trim(due_date), 1, 10) BETWEEN ${start} AND ${end}`)
425
+ .executeTakeFirst(),
426
+ DB_TIMEOUT_MS,
427
+ 'Transaction count timed out',
428
+ );
429
+ const total = toNumber(result?.total);
430
+ const finished = toNumber(result?.done);
431
+ return { planned: total - finished, done: finished };
432
+ },
433
+
415
434
  /** The categories used most on or after a day for one direction, most used first (phone category chips). */
416
435
  async getTopCategories(since: string, direction: 'income' | 'expense', limit = 4): Promise<string[]> {
417
436
  const rows = await withTimeout(
@@ -0,0 +1,32 @@
1
+ import type { Component } from 'svelte';
2
+ import IconTag from '~icons/mdi/tag-outline';
3
+ import IconIncome from '~icons/mdi/wallet-outline';
4
+ import IconHousing from '~icons/mdi/home-outline';
5
+ import IconFood from '~icons/mdi/cart-outline';
6
+ import IconTransport from '~icons/mdi/car-outline';
7
+ import IconSubscriptions from '~icons/mdi/autorenew';
8
+ import IconHealth from '~icons/mdi/heart-pulse';
9
+ import IconShopping from '~icons/mdi/shopping-outline';
10
+ import IconLeisure from '~icons/mdi/ticket-outline';
11
+ import IconKids from '~icons/mdi/baby-face-outline';
12
+ import IconGifts from '~icons/mdi/gift-outline';
13
+ import IconOther from '~icons/mdi/dots-horizontal-circle-outline';
14
+
15
+ const ICONS: Record<string, Component> = {
16
+ income: IconIncome,
17
+ housing: IconHousing,
18
+ food: IconFood,
19
+ transport: IconTransport,
20
+ subscriptions: IconSubscriptions,
21
+ health: IconHealth,
22
+ shopping: IconShopping,
23
+ leisure: IconLeisure,
24
+ kids: IconKids,
25
+ gifts: IconGifts,
26
+ other: IconOther,
27
+ };
28
+
29
+ /** The line icon a category draws in its row's tile; uncategorized rows get a plain tag. */
30
+ export function categoryIcon(value: string | null | undefined): Component {
31
+ return ICONS[value ?? ''] ?? IconTag;
32
+ }
@@ -1,10 +1,9 @@
1
1
  import { endOfMonth, format, parseISO } from 'date-fns';
2
- import { formatNumber, MINUS } from '@hearthlog/sdk/format';
3
2
  import { monthKeyOf } from '@hearthlog/sdk/axis';
4
3
 
5
4
  /**
6
- * Pure helpers for the Finance timeline: which filter the URL asks for, which
7
- * months the endless list holds, and the geometry the balance line draws.
5
+ * Pure helpers for the Finance timeline: which filter the URL asks for and
6
+ * which months the endless list holds.
8
7
  * Balances and totals always come from SQLite.
9
8
  */
10
9
 
@@ -70,44 +69,3 @@ export function monthWindow(key: string): DateWindow {
70
69
  const first = parseISO(`${key}-01`);
71
70
  return { start: format(first, DAY), end: format(endOfMonth(first), DAY) };
72
71
  }
73
-
74
- // ---------------------------------------------------------------- geometry
75
-
76
- /** A little air around the reached values, always including the floor. */
77
- export function paddedDomain(min: number, max: number, floor: number, pad = 0.08): [number, number] {
78
- const lo = Math.min(min, max, floor);
79
- const hi = Math.max(min, max, floor);
80
- const span = hi - lo || Math.max(Math.abs(hi), 1);
81
- return [lo - span * pad, hi + span * pad];
82
- }
83
-
84
- /**
85
- * The scale for balance charts: the reached values, plus the floor only when
86
- * it is near them, so a distant floor (often 0) never flattens the line.
87
- */
88
- export function outlookDomain(min: number, max: number, floor: number): [number, number] {
89
- const span = max - min || Math.max(Math.abs(max), 1);
90
- return paddedDomain(min, max, floor >= min - span * 0.25 ? floor : min);
91
- }
92
-
93
- /** Round tick values inside a domain: steps of 1, 2 or 5 times a power of ten. */
94
- export function niceTicks(lo: number, hi: number, count: number): number[] {
95
- const span = hi - lo;
96
- if (!(span > 0) || count < 1) return [];
97
- const raw = span / count;
98
- const magnitude = 10 ** Math.floor(Math.log10(raw));
99
- const normalized = raw / magnitude;
100
- const step = (normalized < 1.5 ? 1 : normalized < 3 ? 2 : normalized < 7 ? 5 : 10) * magnitude;
101
- const ticks: number[] = [];
102
- for (let value = Math.ceil(lo / step) * step; value <= hi + step * 1e-9; value += step) {
103
- ticks.push(Math.round(value / step) * step);
104
- }
105
- return ticks;
106
- }
107
-
108
- /** `184k`, `−12k`, `950` — compact axis labels in the app locale. */
109
- export function axisLabel(value: number): string {
110
- const magnitude = Math.abs(value);
111
- const text = magnitude >= 1000 ? `${formatNumber(Math.round(magnitude / 1000))}k` : formatNumber(Math.round(magnitude));
112
- return value < 0 ? `${MINUS}${text}` : text;
113
- }
@@ -15,7 +15,7 @@
15
15
  import { events } from "@hearthlog/sdk/runtime";
16
16
  import { notify } from "@hearthlog/sdk/runtime";
17
17
  import { saveBalanceFloor } from "../finance/floor.js";
18
- import { clampMonth, monthBounds, monthWindow, outlookDomain, parseShow, showsKind, type TimelineKind, type TimelineShow } from "../finance/timeline.js";
18
+ import { clampMonth, monthBounds, monthWindow, parseShow, showsKind, type TimelineKind, type TimelineShow } from "../finance/timeline.js";
19
19
  import { queryDatabase } from "@hearthlog/sdk/data";
20
20
  import { monthKeyOf, shiftMonthKey } from "@hearthlog/sdk/axis";
21
21
  import { PageHeader } from "@hearthlog/sdk/ui";
@@ -193,12 +193,6 @@
193
193
  { id: "done", label: "Done" },
194
194
  ];
195
195
 
196
- // ---------------------------------------------------------------- one fixed scale
197
- const domain = $derived.by<[number, number]>(() => {
198
- const available = view.outlook.available;
199
- return outlookDomain(Math.min(view.range.min, available), Math.max(view.range.max, available), floor);
200
- });
201
-
202
196
  function changeFloor(value: number): void {
203
197
  floor = value;
204
198
  floorTouched = true;
@@ -355,6 +349,26 @@
355
349
  });
356
350
  }
357
351
 
352
+ // ---------------------------------------------------------------- filter counts
353
+ /** Plans and done rows of the month in view, counted in SQL, shown on the filter. */
354
+ let kindCounts = $state({ planned: 0, done: 0 });
355
+
356
+ $effect(() => {
357
+ const key = visibleMonth;
358
+ void revision;
359
+ if (!key || !shown || isBudgetTab) return;
360
+ let current = true;
361
+ const { start, end } = monthWindow(key);
362
+ void queryDatabase((repositories) => repositories.transactions.countKindsInMonth(start, end, today))
363
+ .then((counts) => { if (current) kindCounts = counts; })
364
+ .catch(() => undefined);
365
+ return () => { current = false; };
366
+ });
367
+
368
+ function filterCount(id: TimelineShow): number | null {
369
+ return id === "planned" ? kindCounts.planned : id === "done" ? kindCounts.done : null;
370
+ }
371
+
358
372
  // ---------------------------------------------------------------- sort uncategorized
359
373
  /** Rows of the month in view that still have no category, counted in SQL. */
360
374
  let uncategorized = $state(0);
@@ -672,7 +686,7 @@
672
686
  aria-current={show === option.id ? "page" : undefined}
673
687
  data-state={show === option.id ? "active" : undefined}
674
688
  data-sveltekit-reset="false"
675
- >{option.label}</a>
689
+ >{option.label}{#if filterCount(option.id) !== null}<span class="stor-tab-count ml-1.5">{filterCount(option.id)}</span>{/if}</a>
676
690
  {/each}
677
691
  </nav>
678
692
  {#if uncategorized > 0}
@@ -695,7 +709,7 @@
695
709
  <PageHeader title="Finance" class="shrink-0 px-2.5 pt-2 pb-1 max-md:sr-only sm:px-3 sm:pt-4 sm:pb-2" sidebarTabs tabs={financeNavigation} context={headerPeriod}>
696
710
  {#if !isBudgetTab}
697
711
  <CommandOnly>
698
- <Button class="hidden lg:inline-flex" data-testid="open-transaction-drawer" onclick={() => openAddTransaction(undefined, show === "done" ? "archived" : "active")}>
712
+ <Button class="hidden lg:inline-flex" data-testid="open-transaction-drawer" data-stor-new shortcut="N" onclick={() => openAddTransaction(undefined, show === "done" ? "archived" : "active")}>
699
713
  <IconPlus class="h-4 w-4" /> {show === "done" ? "Record past transaction" : "Add transaction"}
700
714
  </Button>
701
715
  </CommandOnly>
@@ -798,7 +812,6 @@
798
812
  anchor={openingDay}
799
813
  show={show}
800
814
  bounds={bounds}
801
- domain={domain}
802
815
  floor={floor}
803
816
  available={view.outlook.available}
804
817
  selectedIds={selectedIds}
@@ -857,14 +870,14 @@
857
870
  data-state={show === option.id ? "active" : undefined}
858
871
  data-sveltekit-reset="false"
859
872
  onclick={() => (optionsOpen = false)}
860
- >{option.label}</a>
873
+ >{option.label}{#if filterCount(option.id) !== null}<span class="stor-tab-count ml-1.5">{filterCount(option.id)}</span>{/if}</a>
861
874
  {/each}
862
875
  </nav>
863
876
  <div class="mt-2 flex flex-col divide-y divide-[color-mix(in_srgb,var(--stor-border)_62%,transparent)]">
864
877
  <button type="button" class="flex min-h-16 items-center gap-3 text-left" role="switch" aria-checked={phoneBalance} onclick={togglePhoneBalance}>
865
878
  <span class="min-w-0 flex-1">
866
879
  <span class="block text-[15px] font-medium text-[var(--stor-ink)]">Running balance</span>
867
- <span class="block text-[12.5px] text-[var(--stor-muted-text)]">The balance after each plan, the balance line and each month's figures</span>
880
+ <span class="block text-[12.5px] text-[var(--stor-muted-text)]">The balance after each plan and each month's figures</span>
868
881
  </span>
869
882
  <span class="relative h-[30px] w-12 shrink-0 rounded-full transition-colors {phoneBalance ? 'bg-[var(--stor-action)]' : 'bg-[var(--stor-surface-well)] shadow-[inset_0_0_0_1px_var(--stor-border)]'}" aria-hidden="true">
870
883
  <span class="absolute top-[3px] h-6 w-6 rounded-full bg-white shadow transition-[left] {phoneBalance ? 'left-[21px]' : 'left-[3px]'}"></span>
package/src/styles.css CHANGED
@@ -7,6 +7,5 @@
7
7
 
8
8
  @media (max-width: 767px) {
9
9
  /* Lists show their figures only when asked: the running balance, the balance line, month figures. */
10
- [data-phone-balance="off"] [data-balance], [data-phone-balance="off"] [data-river] { display: none; }
11
- [data-phone-balance="off"] [data-timeline-scroll] { --river: 0px !important; }
10
+ [data-phone-balance="off"] [data-balance] { display: none; }
12
11
  }
@@ -1,125 +0,0 @@
1
- <script module lang="ts">
2
- /** What the slice marks at its centre. */
3
- export type RiverMark = "none" | "month" | "low" | "today";
4
- </script>
5
-
6
- <script lang="ts">
7
- import { axisLabel, niceTicks } from "../../finance/timeline.js";
8
- import { formatAmount } from "@hearthlog/sdk/format";
9
-
10
- let {
11
- before,
12
- after = before,
13
- domain,
14
- floor,
15
- projected = false,
16
- income = false,
17
- hot = false,
18
- mark = "none",
19
- below = false,
20
- label = "",
21
- grid = false,
22
- }: {
23
- /** Balance entering the slice at its top edge. */
24
- before: number;
25
- /** Balance leaving at its bottom edge; the step happens at the centre. */
26
- after?: number;
27
- /** One fixed scale for the whole timeline. */
28
- domain: [number, number];
29
- floor: number;
30
- /** Plans are a dashed projection; records are solid. */
31
- projected?: boolean;
32
- income?: boolean;
33
- /** The row is hovered: highlight the step and show before → after. */
34
- hot?: boolean;
35
- mark?: RiverMark;
36
- /** The lowest point is under the floor. */
37
- below?: boolean;
38
- label?: string;
39
- /** Faint scale lines, used from Today on. */
40
- grid?: boolean;
41
- } = $props();
42
-
43
- /** Percent across the gutter, with a little air on both sides. */
44
- function x(value: number): number {
45
- const [lo, hi] = domain;
46
- const ratio = (value - lo) / (hi - lo || 1);
47
- return Math.max(0.5, Math.min(99.5, 4 + ratio * 92));
48
- }
49
-
50
- const xb = $derived(x(before));
51
- const xa = $derived(x(after));
52
- const steps = $derived(Math.abs(xa - xb) > 0.05);
53
- const floorX = $derived(floor > domain[0] && floor < domain[1] ? x(floor) : null);
54
- const ticks = $derived(grid || mark === "today" ? niceTicks(domain[0], domain[1], 3).map((value) => ({ value, x: x(value) })) : []);
55
- const markX = $derived(mark === "today" || mark === "month" ? xb : xa);
56
- const labelRight = $derived(markX < 58);
57
- </script>
58
-
59
- <div class="slice" class:hot aria-hidden="true">
60
- <svg viewBox="0 0 100 100" preserveAspectRatio="none">
61
- {#if grid}
62
- {#each ticks as tick (tick.value)}<line class="grid" x1={tick.x} x2={tick.x} y1="0" y2="100" vector-effect="non-scaling-stroke" />{/each}
63
- {/if}
64
- {#if floorX !== null && (grid || projected)}
65
- <line class="floor" x1={floorX} x2={floorX} y1="0" y2="100" vector-effect="non-scaling-stroke" />
66
- {/if}
67
- <polygon class="area" points={`0,0 ${xb},0 ${xb},50 ${xa},50 ${xa},100 0,100`} />
68
- <path class="line" class:projected d={`M${xb},0 V50 H${xa} V100`} vector-effect="non-scaling-stroke" />
69
- {#if steps}
70
- <line class="jog" class:income x1={xb} x2={xa} y1="50" y2="50" vector-effect="non-scaling-stroke" />
71
- {/if}
72
- {#if mark === "today"}
73
- <line class="today" x1="0" x2="100" y1="50" y2="50" vector-effect="non-scaling-stroke" />
74
- {/if}
75
- </svg>
76
- {#if mark === "today"}
77
- {#each ticks as tick (tick.value)}<span class="tick" style:left={`${tick.x}%`}>{axisLabel(tick.value)}</span>{/each}
78
- {/if}
79
- {#if mark !== "none"}
80
- <span class="dot {mark}" class:below style:left={`${markX}%`}></span>
81
- {/if}
82
- {#if label}
83
- <span class="label" class:low={mark === "low"} class:below class:right={labelRight} style:left={`${markX}%`}>{label}</span>
84
- {/if}
85
- {#if hot && steps}
86
- <span class="hover" style:left={`${Math.min(70, Math.max(30, (xb + xa) / 2))}%`}>{formatAmount(before)} → {formatAmount(after)}</span>
87
- {/if}
88
- </div>
89
-
90
- <style>
91
- .slice { position: absolute; inset: 0; container-type: inline-size; pointer-events: none; }
92
- svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
93
- .grid { stroke: color-mix(in srgb, var(--stor-border) 62%, transparent); stroke-width: 1; }
94
- .floor { stroke: var(--stor-warning); stroke-width: 1; stroke-dasharray: 4 3; }
95
- .area { fill: color-mix(in srgb, var(--stor-ink) 6%, transparent); }
96
- .line { fill: none; stroke: var(--stor-ink); stroke-width: 1.25; }
97
- .line.projected { stroke-dasharray: 4 3; }
98
- .jog { stroke: var(--stor-ink); stroke-width: 2.5; stroke-linecap: round; }
99
- .jog.income { stroke: var(--stor-positive); }
100
- .hot .jog { stroke: var(--stor-action); stroke-width: 4; }
101
- .today { stroke: var(--stor-action); stroke-width: 1.5; opacity: 0.85; }
102
- .dot { position: absolute; top: 50%; width: 7px; height: 7px; border-radius: 50%; transform: translate(-50%, -50%); background: var(--stor-ink); }
103
- .dot.low { width: 9px; height: 9px; border: 2px solid var(--stor-ink); background: var(--stor-surface); }
104
- .dot.low.below { border-color: var(--stor-warning); }
105
- .dot.today { width: 9px; height: 9px; border: 2px solid var(--stor-action); background: var(--stor-surface); }
106
- .label, .tick, .hover { position: absolute; font-family: var(--stor-font-mono); font-size: 10px; font-weight: 600; line-height: 1; white-space: nowrap; }
107
- .label { top: 50%; color: var(--stor-ink); transform: translate(calc(-100% - 7px), -50%); }
108
- .label.right { transform: translate(7px, -50%); }
109
- .label.low { top: calc(50% + 12px); }
110
- .label.below { color: var(--stor-warning); }
111
- .tick { top: calc(50% - 16px); color: var(--stor-muted-text); font-size: 9.5px; font-weight: 500; transform: translateX(-50%); }
112
- .hover {
113
- top: calc(50% - 22px);
114
- z-index: 1;
115
- padding: 3px 6px;
116
- border-radius: 5px;
117
- background: var(--stor-surface-raised);
118
- box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--stor-border) 80%, transparent);
119
- color: var(--stor-ink);
120
- transform: translateX(-50%);
121
- }
122
- @container (max-width: 90px) {
123
- .label, .tick, .hover { display: none; }
124
- }
125
- </style>