@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.
- package/LICENSE +235 -0
- package/README.md +11 -2
- package/hearthlog.module.json +46 -0
- package/package.json +44 -3
- package/src/attention.ts +41 -0
- package/src/components/finance/AddAssetForm.svelte +58 -0
- package/src/components/finance/AddTransactionForm.svelte +127 -0
- package/src/components/finance/AssetDrawer.svelte +133 -0
- package/src/components/finance/AssetItem.svelte +96 -0
- package/src/components/finance/AssetView.svelte +165 -0
- package/src/components/finance/AssetsSheet.svelte +122 -0
- package/src/components/finance/BudgetItemActionsCell.svelte +18 -0
- package/src/components/finance/BudgetItemAmountCell.svelte +5 -0
- package/src/components/finance/BudgetItemCategoryCell.svelte +7 -0
- package/src/components/finance/BudgetItemDrawer.svelte +116 -0
- package/src/components/finance/BudgetItemNameCell.svelte +5 -0
- package/src/components/finance/BudgetItemSelectCell.svelte +25 -0
- package/src/components/finance/BudgetItemsTable.svelte +206 -0
- package/src/components/finance/BudgetPlanDrawer.svelte +89 -0
- package/src/components/finance/BudgetPlans.svelte +419 -0
- package/src/components/finance/EndlessTimeline.svelte +174 -0
- package/src/components/finance/FinanceReadout.svelte +229 -0
- package/src/components/finance/MonthSection.svelte +255 -0
- package/src/components/finance/RiverSlice.svelte +125 -0
- package/src/components/finance/SortUncategorizedSheet.svelte +198 -0
- package/src/components/finance/TimelineRowItem.svelte +319 -0
- package/src/components/finance/TransactionDrawer.svelte +480 -0
- package/src/components/finance/TransactionSelectionBar.svelte +200 -0
- package/src/components/finance/timeline-months.svelte.ts +37 -0
- package/src/components/finance/transaction-draft.ts +121 -0
- package/src/data/assets.ts +48 -0
- package/src/data/budgets.ts +123 -0
- package/src/data/definitions.ts +135 -0
- package/src/data/finance-timeline.ts +305 -0
- package/src/data/forecast.ts +256 -0
- package/src/data/planned-days.ts +57 -0
- package/src/data/repositories.ts +20 -0
- package/src/data/transactions.ts +452 -0
- package/src/finance/categories.ts +29 -0
- package/src/finance/category-pick.ts +8 -0
- package/src/finance/category-suggestions.ts +65 -0
- package/src/finance/floor.ts +29 -0
- package/src/finance/phone-balance.ts +22 -0
- package/src/finance/timeline.ts +113 -0
- package/src/pages/index.svelte +19 -0
- package/src/pages/index.ts +7 -0
- package/src/pages/layout.svelte +13 -0
- package/src/pages/layout.ts +18 -0
- package/src/pages/month.svelte +909 -0
- package/src/pages/month.ts +99 -0
- package/src/server/category-pick.ts +44 -0
- package/src/server/index.ts +11 -0
- package/src/styles.css +12 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { differenceInCalendarDays, format, parseISO } from "date-fns";
|
|
3
|
+
import type { Asset } from "../../data/assets.js";
|
|
4
|
+
import type { TimelineOutlook } from "../../data/finance-timeline.js";
|
|
5
|
+
import { parseFloor } from "../../finance/floor.js";
|
|
6
|
+
import { formatAmount } from "@hearthlog/sdk/format";
|
|
7
|
+
import { Amount } from "@hearthlog/sdk/ui";
|
|
8
|
+
import { DataValue } from "@hearthlog/sdk/ui";
|
|
9
|
+
import AssetView from "./AssetView.svelte";
|
|
10
|
+
import IconChevronDown from "~icons/mdi/chevron-down";
|
|
11
|
+
|
|
12
|
+
let {
|
|
13
|
+
outlook,
|
|
14
|
+
today,
|
|
15
|
+
floor,
|
|
16
|
+
assets,
|
|
17
|
+
assetsSum,
|
|
18
|
+
onFloorChange,
|
|
19
|
+
onJump,
|
|
20
|
+
sheet = false,
|
|
21
|
+
onUpdateAssets,
|
|
22
|
+
}: {
|
|
23
|
+
outlook: TimelineOutlook;
|
|
24
|
+
today: string;
|
|
25
|
+
floor: number;
|
|
26
|
+
assets: Asset[];
|
|
27
|
+
assetsSum: number;
|
|
28
|
+
onFloorChange: (value: number) => void;
|
|
29
|
+
/** Scrolls the list to a day named in the readout. */
|
|
30
|
+
onJump?: (date: string) => void;
|
|
31
|
+
/** In the phone summary sheet: every figure and the assets, two by two. */
|
|
32
|
+
sheet?: boolean;
|
|
33
|
+
/** In the sheet: opens every balance for editing in place of the outlook. */
|
|
34
|
+
onUpdateAssets?: () => void;
|
|
35
|
+
} = $props();
|
|
36
|
+
|
|
37
|
+
// svelte-ignore state_referenced_locally
|
|
38
|
+
let assetsOpen = $state(sheet);
|
|
39
|
+
const margin = $derived(outlook.low.amount - floor);
|
|
40
|
+
const below = $derived(margin < 0);
|
|
41
|
+
const lowDay = $derived(
|
|
42
|
+
!outlook.low.date ? "" : outlook.low.date === today ? "Today" : format(parseISO(outlook.low.date), "EEE d MMM"),
|
|
43
|
+
);
|
|
44
|
+
const lowDistance = $derived(outlook.low.date && today ? differenceInCalendarDays(parseISO(outlook.low.date), parseISO(today)) : 0);
|
|
45
|
+
const endDay = $derived(outlook.endDate ? format(parseISO(outlook.endDate), outlook.endDate.slice(0, 4) === today.slice(0, 4) ? "d MMM" : "d MMM yyyy") : "");
|
|
46
|
+
const assetNames = $derived(assets.map((asset) => asset.name).join(" · "));
|
|
47
|
+
|
|
48
|
+
function commitFloor(input: HTMLInputElement): void {
|
|
49
|
+
const value = parseFloor(input.value);
|
|
50
|
+
if (value === null) {
|
|
51
|
+
input.value = formatAmount(floor);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
input.value = formatAmount(value);
|
|
55
|
+
if (value !== floor) onFloorChange(value);
|
|
56
|
+
}
|
|
57
|
+
</script>
|
|
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>
|
|
69
|
+
</button>
|
|
70
|
+
</div>
|
|
71
|
+
<div class="cell" class:warn={below}>
|
|
72
|
+
<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>
|
|
80
|
+
</button>
|
|
81
|
+
</div>
|
|
82
|
+
<div class="cell extra">
|
|
83
|
+
<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>
|
|
89
|
+
</button>
|
|
90
|
+
</div>
|
|
91
|
+
<div class="cell extra">
|
|
92
|
+
<label class="label" for="finance-floor">Floor</label>
|
|
93
|
+
<span class="value">
|
|
94
|
+
<input
|
|
95
|
+
id="finance-floor"
|
|
96
|
+
class="floor-input"
|
|
97
|
+
inputmode="numeric"
|
|
98
|
+
autocomplete="off"
|
|
99
|
+
value={formatAmount(floor)}
|
|
100
|
+
aria-describedby="finance-floor-help"
|
|
101
|
+
onchange={(event) => commitFloor(event.currentTarget)}
|
|
102
|
+
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>
|
|
106
|
+
</div>
|
|
107
|
+
</section>
|
|
108
|
+
|
|
109
|
+
{#if sheet && onUpdateAssets}
|
|
110
|
+
<!-- The sheet lists the assets; Update assets opens every balance in one place. -->
|
|
111
|
+
<div id="finance-current-assets" class="mt-3 flex flex-col">
|
|
112
|
+
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--stor-muted-text)]">Assets</p>
|
|
113
|
+
{#each assets as asset (asset.id)}
|
|
114
|
+
<div class="flex min-h-12 items-center justify-between gap-3 border-b border-[color-mix(in_srgb,var(--stor-border)_62%,transparent)] text-[15px]">
|
|
115
|
+
<span class="min-w-0 truncate text-[var(--stor-ink)]">{asset.name}</span>
|
|
116
|
+
<b class="shrink-0 font-semibold tabular-nums text-[var(--stor-ink)]">{formatAmount(Number(asset.amount))} Kč</b>
|
|
117
|
+
</div>
|
|
118
|
+
{:else}
|
|
119
|
+
<p class="py-3 text-sm text-[var(--stor-muted-text)]">No assets yet.</p>
|
|
120
|
+
{/each}
|
|
121
|
+
<button type="button" class="flex min-h-12 items-center justify-between text-left text-[15px] font-semibold text-[var(--stor-accent-text)]" onclick={onUpdateAssets}>
|
|
122
|
+
{assets.length ? "Update assets" : "Add an asset"}<IconChevronDown class="h-5 w-5 -rotate-90" />
|
|
123
|
+
</button>
|
|
124
|
+
</div>
|
|
125
|
+
{:else if assetsOpen}
|
|
126
|
+
<div id="finance-current-assets" class="shrink-0">
|
|
127
|
+
<AssetView {assets} {assetsSum} onUpdateAll={onUpdateAssets} />
|
|
128
|
+
</div>
|
|
129
|
+
{/if}
|
|
130
|
+
|
|
131
|
+
<style>
|
|
132
|
+
.readout {
|
|
133
|
+
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);
|
|
145
|
+
}
|
|
146
|
+
.cell:has(> .cell-button) { padding: 0; }
|
|
147
|
+
.cell:first-child { border-left: 0; }
|
|
148
|
+
.cell-button {
|
|
149
|
+
display: flex;
|
|
150
|
+
min-width: 0;
|
|
151
|
+
flex-direction: column;
|
|
152
|
+
gap: 2px;
|
|
153
|
+
padding: 0.875rem 1.25rem 0.8125rem;
|
|
154
|
+
border: 0;
|
|
155
|
+
background: transparent;
|
|
156
|
+
text-align: left;
|
|
157
|
+
color: inherit;
|
|
158
|
+
}
|
|
159
|
+
.cell:first-child .cell-button { border-radius: var(--stor-panel-radius) 0 0 var(--stor-panel-radius); }
|
|
160
|
+
.cell-button:not(:disabled):hover { background: var(--stor-surface-hover); }
|
|
161
|
+
.cell-button:disabled { cursor: default; }
|
|
162
|
+
.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
|
+
}
|
|
191
|
+
.floor-input {
|
|
192
|
+
width: 7ch;
|
|
193
|
+
padding: 0;
|
|
194
|
+
border: 0;
|
|
195
|
+
border-bottom: 1.5px dashed var(--stor-border);
|
|
196
|
+
border-radius: 0;
|
|
197
|
+
background: transparent;
|
|
198
|
+
font: inherit;
|
|
199
|
+
letter-spacing: inherit;
|
|
200
|
+
color: inherit;
|
|
201
|
+
outline: none;
|
|
202
|
+
}
|
|
203
|
+
.floor-input:focus { border-bottom-color: var(--stor-focus); }
|
|
204
|
+
@media (max-width: 1023px) {
|
|
205
|
+
.cell { padding: 0.625rem 0.875rem; }
|
|
206
|
+
.cell-button { padding: 0.625rem 0.875rem; }
|
|
207
|
+
.value { font-size: 18px; }
|
|
208
|
+
}
|
|
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); }
|
|
212
|
+
.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; }
|
|
215
|
+
/* Phones keep the outlook to one short row: label and figure, the details once it is opened. */
|
|
216
|
+
@media (max-width: 639px) {
|
|
217
|
+
.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; }
|
|
224
|
+
.extra { display: none; }
|
|
225
|
+
.open .extra { display: flex; border-top: 1px solid color-mix(in srgb, var(--stor-border) 62%, transparent); }
|
|
226
|
+
.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
|
+
}
|
|
229
|
+
</style>
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { format, parseISO } from "date-fns";
|
|
3
|
+
import type { TimelineRow } from "../../data/finance-timeline.js";
|
|
4
|
+
import { axisLabel, type TimelineShow } from "../../finance/timeline.js";
|
|
5
|
+
import { monthKeyOf } from "@hearthlog/sdk/axis";
|
|
6
|
+
import { formatAmount } from "@hearthlog/sdk/format";
|
|
7
|
+
import TimelineRowItem, { type BalanceTone, type RowLike } from "./TimelineRowItem.svelte";
|
|
8
|
+
import RiverSlice, { type RiverMark } from "./RiverSlice.svelte";
|
|
9
|
+
import type { MonthData } from "./timeline-months.svelte.js";
|
|
10
|
+
|
|
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 };
|
|
14
|
+
|
|
15
|
+
let {
|
|
16
|
+
monthKey,
|
|
17
|
+
data,
|
|
18
|
+
today,
|
|
19
|
+
show,
|
|
20
|
+
domain,
|
|
21
|
+
floor,
|
|
22
|
+
available,
|
|
23
|
+
selectedIds,
|
|
24
|
+
busyId = null,
|
|
25
|
+
flashId = null,
|
|
26
|
+
hoveredId = null,
|
|
27
|
+
hoveredDate = null,
|
|
28
|
+
onSelect,
|
|
29
|
+
onEdit,
|
|
30
|
+
onMarkDone,
|
|
31
|
+
onRestore,
|
|
32
|
+
onDelete,
|
|
33
|
+
onHover,
|
|
34
|
+
}: {
|
|
35
|
+
monthKey: string;
|
|
36
|
+
data: MonthData | undefined;
|
|
37
|
+
today: string;
|
|
38
|
+
show: TimelineShow;
|
|
39
|
+
domain: [number, number];
|
|
40
|
+
floor: number;
|
|
41
|
+
available: number;
|
|
42
|
+
selectedIds: Set<string>;
|
|
43
|
+
busyId?: string | null;
|
|
44
|
+
flashId?: string | null;
|
|
45
|
+
hoveredId?: string | null;
|
|
46
|
+
hoveredDate?: string | null;
|
|
47
|
+
onSelect: (row: RowLike, selected: boolean) => void;
|
|
48
|
+
onEdit: (row: RowLike) => void;
|
|
49
|
+
onMarkDone: (row: RowLike) => void | Promise<void>;
|
|
50
|
+
onRestore: (row: RowLike) => void | Promise<void>;
|
|
51
|
+
onDelete: (row: RowLike) => void | Promise<void>;
|
|
52
|
+
onHover?: (row: RowLike | null) => void;
|
|
53
|
+
} = $props();
|
|
54
|
+
|
|
55
|
+
/**
|
|
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.
|
|
59
|
+
*/
|
|
60
|
+
const river = $derived(show !== "done");
|
|
61
|
+
const isTodayMonth = $derived(monthKey === monthKeyOf(today));
|
|
62
|
+
const afterToday = $derived(monthKey > monthKeyOf(today));
|
|
63
|
+
const balances = $derived(river && (isTodayMonth || afterToday));
|
|
64
|
+
const title = $derived(format(parseISO(`${monthKey}-01`), "MMMM yyyy"));
|
|
65
|
+
|
|
66
|
+
/** 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 };
|
|
69
|
+
const result: Entry[] = [];
|
|
70
|
+
let todayPlaced = false;
|
|
71
|
+
let lastDate: string | null = null;
|
|
72
|
+
let current = data.stats.start;
|
|
73
|
+
const placeToday = () => {
|
|
74
|
+
if (todayPlaced || !isTodayMonth) return;
|
|
75
|
+
todayPlaced = true;
|
|
76
|
+
current = available;
|
|
77
|
+
result.push({ type: "today", key: "today", slice: { before: available, after: available, projected: false, mark: "today", label: "", grid: false, below: false } });
|
|
78
|
+
lastDate = null;
|
|
79
|
+
};
|
|
80
|
+
for (const row of data.rows) {
|
|
81
|
+
// Below Today: plans, and rows marked done ahead of their day.
|
|
82
|
+
const projected = row.section === 1;
|
|
83
|
+
if (projected) placeToday();
|
|
84
|
+
const after = row.balanceAfter ?? current;
|
|
85
|
+
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
|
+
current = after;
|
|
96
|
+
const entry: RowEntry = {
|
|
97
|
+
type: "row",
|
|
98
|
+
key: row.id,
|
|
99
|
+
row,
|
|
100
|
+
divider: row.firstOfDay && lastDate !== null,
|
|
101
|
+
balance: row.balanceAfter,
|
|
102
|
+
tone: low ? (after < floor ? "below" : "low") : "default",
|
|
103
|
+
slice,
|
|
104
|
+
};
|
|
105
|
+
lastDate = row.date;
|
|
106
|
+
result.push(entry);
|
|
107
|
+
}
|
|
108
|
+
placeToday();
|
|
109
|
+
return { entries: result, last: current };
|
|
110
|
+
});
|
|
111
|
+
const entries = $derived(layout.entries);
|
|
112
|
+
|
|
113
|
+
const empty = $derived(!!data && data.rows.length === 0 && !isTodayMonth);
|
|
114
|
+
const change = $derived(data ? data.stats.end - data.stats.start : 0);
|
|
115
|
+
const net = $derived(data ? data.stats.income + data.stats.expense : 0);
|
|
116
|
+
</script>
|
|
117
|
+
|
|
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
|
+
{#snippet rowView(entry: RowEntry)}
|
|
123
|
+
<TimelineRowItem
|
|
124
|
+
row={entry.row}
|
|
125
|
+
{today}
|
|
126
|
+
firstOfDay={entry.row.firstOfDay}
|
|
127
|
+
divider={entry.divider}
|
|
128
|
+
balance={entry.balance}
|
|
129
|
+
balanceTone={entry.tone}
|
|
130
|
+
selected={selectedIds.has(entry.row.id)}
|
|
131
|
+
busy={busyId === entry.row.id}
|
|
132
|
+
highlighted={hoveredDate === entry.row.date || hoveredId === entry.row.id}
|
|
133
|
+
flash={flashId === entry.row.id}
|
|
134
|
+
{onSelect}
|
|
135
|
+
{onEdit}
|
|
136
|
+
{onMarkDone}
|
|
137
|
+
{onRestore}
|
|
138
|
+
{onDelete}
|
|
139
|
+
{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>
|
|
143
|
+
{/snippet}
|
|
144
|
+
|
|
145
|
+
<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
|
+
<header class="month-head" data-month-head>
|
|
153
|
+
<h3 class="stor-serif-title month-title">{title}</h3>
|
|
154
|
+
{#if !data}
|
|
155
|
+
<span class="month-stats" data-balance>Loading…</span>
|
|
156
|
+
{:else if empty}
|
|
157
|
+
<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
|
+
{:else}
|
|
167
|
+
<span
|
|
168
|
+
data-balance
|
|
169
|
+
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)}`}
|
|
171
|
+
>
|
|
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>
|
|
173
|
+
</span>
|
|
174
|
+
{/if}
|
|
175
|
+
</header>
|
|
176
|
+
|
|
177
|
+
{#if !data}
|
|
178
|
+
<div class="placeholder" role="status" aria-label={`Loading ${title}`}>
|
|
179
|
+
{#each [0, 1, 2] as line (line)}<span class="motion-safe:animate-pulse"></span>{/each}
|
|
180
|
+
</div>
|
|
181
|
+
{:else}
|
|
182
|
+
{#each entries as entry (entry.key)}
|
|
183
|
+
{#if entry.type === "row"}
|
|
184
|
+
{@render rowView(entry)}
|
|
185
|
+
{:else}
|
|
186
|
+
<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>
|
|
189
|
+
<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>
|
|
191
|
+
</div>
|
|
192
|
+
{/if}
|
|
193
|
+
{/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
|
+
{/if}
|
|
200
|
+
</section>
|
|
201
|
+
|
|
202
|
+
<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); }
|
|
206
|
+
.month-head {
|
|
207
|
+
--head: 3rem;
|
|
208
|
+
position: sticky;
|
|
209
|
+
top: 0;
|
|
210
|
+
z-index: 2;
|
|
211
|
+
display: flex;
|
|
212
|
+
height: var(--head);
|
|
213
|
+
align-items: baseline;
|
|
214
|
+
gap: 0.75rem;
|
|
215
|
+
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);
|
|
228
|
+
}
|
|
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; }
|
|
234
|
+
.month-stats b { font-weight: 600; color: var(--stor-ink); }
|
|
235
|
+
.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); }
|
|
237
|
+
.placeholder span { height: 1.5rem; border-radius: 8px; background: var(--stor-surface-muted); }
|
|
238
|
+
.today-line {
|
|
239
|
+
position: relative;
|
|
240
|
+
display: flex;
|
|
241
|
+
align-items: center;
|
|
242
|
+
gap: 0.625rem;
|
|
243
|
+
height: 2.75rem;
|
|
244
|
+
padding: 0 0.75rem 0 calc(var(--river, 0px) + 0.75rem);
|
|
245
|
+
}
|
|
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); }
|
|
249
|
+
.today-available b { font-weight: 600; color: var(--stor-ink); }
|
|
250
|
+
/* Phones name the month in the toolbar above the list, so headers here only divide months. */
|
|
251
|
+
@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); }
|
|
254
|
+
}
|
|
255
|
+
</style>
|
|
@@ -0,0 +1,125 @@
|
|
|
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>
|