@hearthlog/module-finance 1.2.0 → 1.3.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/hearthlog.module.json +1 -1
- package/package.json +6 -6
- package/src/components/finance/EndlessTimeline.svelte +0 -6
- package/src/components/finance/FinanceReadout.svelte +45 -92
- package/src/components/finance/MonthSection.svelte +43 -88
- package/src/components/finance/TimelineRowItem.svelte +68 -110
- package/src/data/transactions.ts +19 -0
- package/src/finance/category-icons.ts +32 -0
- package/src/finance/timeline.ts +2 -44
- package/src/pages/month.svelte +24 -11
- package/src/styles.css +1 -2
- package/src/components/finance/RiverSlice.svelte +0 -125
package/hearthlog.module.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hearthlog/module-finance",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
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",
|
|
23
|
+
"@hearthlog/sdk": "0.5.0 || 0.6.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",
|
|
40
|
+
"@hearthlog/sdk": "0.5.0 || 0.6.0",
|
|
41
|
+
"@hearthlog/tooling": "0.5.0 || 0.6.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 {
|
|
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
|
-
<!--
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
<
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
<
|
|
74
|
-
|
|
75
|
-
<DataValue
|
|
76
|
-
</
|
|
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
|
-
<
|
|
85
|
-
|
|
86
|
-
<DataValue
|
|
87
|
-
</
|
|
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">
|
|
93
|
-
<
|
|
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
|
-
|
|
104
|
-
</
|
|
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(
|
|
135
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
padding:
|
|
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
|
-
.
|
|
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
|
-
.
|
|
164
|
-
.
|
|
165
|
-
|
|
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
|
-
.
|
|
206
|
-
.cell-button { padding: 0.
|
|
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
|
-
.
|
|
210
|
-
.
|
|
211
|
-
.sheet
|
|
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 {
|
|
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 {
|
|
219
|
-
.
|
|
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:
|
|
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 {
|
|
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
|
|
12
|
-
type
|
|
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
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
|
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(
|
|
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[]
|
|
68
|
-
if (!data) return { entries: []
|
|
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"
|
|
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
|
|
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-
|
|
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={
|
|
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
|
-
<
|
|
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
|
-
|
|
188
|
-
<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)}
|
|
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.
|
|
217
|
-
|
|
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 {
|
|
230
|
-
.
|
|
231
|
-
.
|
|
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
|
|
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:
|
|
244
|
-
|
|
195
|
+
height: 3rem;
|
|
196
|
+
color: var(--stor-now);
|
|
197
|
+
font-size: var(--stor-text-small);
|
|
198
|
+
font-weight: 600;
|
|
245
199
|
}
|
|
246
|
-
.today-
|
|
247
|
-
.today-rule { flex: 1; height: 1.5px; background: var(--stor-
|
|
248
|
-
.today-available { flex: none; font-
|
|
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
|
|
253
|
-
.month-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
109
|
-
<
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
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"
|
|
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:
|
|
189
|
-
--gap: 0.
|
|
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
|
|
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:
|
|
197
|
-
padding: 0 0.5rem
|
|
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(--
|
|
207
|
-
border-top: 1px solid
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
.cell-check {
|
|
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 {
|
|
245
|
-
.
|
|
246
|
-
.
|
|
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
|
-
|
|
252
|
-
|
|
215
|
+
flex-direction: column;
|
|
216
|
+
justify-content: center;
|
|
253
217
|
align-self: stretch;
|
|
254
|
-
|
|
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:
|
|
265
|
-
.
|
|
266
|
-
.
|
|
267
|
-
.
|
|
268
|
-
.cell-amount
|
|
269
|
-
.
|
|
270
|
-
.
|
|
271
|
-
.
|
|
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
|
-
.
|
|
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:
|
|
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:
|
|
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
|
-
|
|
302
|
-
.cell-
|
|
303
|
-
.
|
|
304
|
-
.cell-
|
|
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
|
-
.
|
|
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
|
-
.
|
|
316
|
-
.cell-amount { font-size: 15px; }
|
|
317
|
-
.cell-date b { font-size: 14px; }
|
|
275
|
+
.amount { font-size: 15px; }
|
|
318
276
|
}
|
|
319
277
|
</style>
|
package/src/data/transactions.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/finance/timeline.ts
CHANGED
|
@@ -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
|
|
7
|
-
* months the endless list holds
|
|
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
|
-
}
|
package/src/pages/month.svelte
CHANGED
|
@@ -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,
|
|
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}
|
|
@@ -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
|
|
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]
|
|
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>
|