@poodle64/ui 2026.9.23 → 2026.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,221 @@
1
+ /**
2
+ * The ledger's reading of dates, periods and money: pure, so the component and
3
+ * its tests share one. Dates are ISO `YYYY-MM-DD` and are never converted
4
+ * through a time zone: a date is not an instant.
5
+ */
6
+ import { formatCurrency } from '../../../format.js';
7
+ export const LEDGER_PERIODS = [
8
+ { value: 'fy', label: 'Financial year' },
9
+ { value: 'cy', label: 'Calendar year' },
10
+ { value: 'month', label: 'Month' },
11
+ { value: 'week', label: 'Week' },
12
+ { value: 'day', label: 'Day' },
13
+ { value: 'none', label: 'None' }
14
+ ];
15
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
16
+ const MONTHS_LONG = [
17
+ 'January',
18
+ 'February',
19
+ 'March',
20
+ 'April',
21
+ 'May',
22
+ 'June',
23
+ 'July',
24
+ 'August',
25
+ 'September',
26
+ 'October',
27
+ 'November',
28
+ 'December'
29
+ ];
30
+ const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
31
+ function parts(iso) {
32
+ return [Number(iso.slice(0, 4)), Number(iso.slice(5, 7)), Number(iso.slice(8, 10))];
33
+ }
34
+ /** A calendar date `days` on from an ISO date, in UTC so no clock change moves it. */
35
+ function shift(iso, days) {
36
+ const [y, m, d] = parts(iso);
37
+ return new Date(Date.UTC(y, m - 1, d + days));
38
+ }
39
+ function isoOf(t) {
40
+ const pad = (n) => String(n).padStart(2, '0');
41
+ return `${t.getUTCFullYear()}-${pad(t.getUTCMonth() + 1)}-${pad(t.getUTCDate())}`;
42
+ }
43
+ /** "15 Sep", or "15 Sep 2026" with the year. */
44
+ export function ledgerDate(iso, withYear = true) {
45
+ const [y, m, d] = parts(iso);
46
+ return `${d} ${MONTHS[m - 1]}${withYear ? ` ${y}` : ''}`;
47
+ }
48
+ /** The year a financial year ends in. A January start makes it the calendar year. */
49
+ function fyEnd(iso, fyStart) {
50
+ const [y, m] = parts(iso);
51
+ return fyStart > 1 && m >= fyStart ? y + 1 : y;
52
+ }
53
+ /** The group a date falls in: sortable, so newer keys sort later. */
54
+ export function periodKey(iso, period, fyStart = 7) {
55
+ switch (period) {
56
+ case 'fy':
57
+ return String(fyEnd(iso, fyStart));
58
+ case 'cy':
59
+ return iso.slice(0, 4);
60
+ case 'month':
61
+ return iso.slice(0, 7);
62
+ case 'week': {
63
+ const [y, m, d] = parts(iso);
64
+ const monday = (new Date(Date.UTC(y, m - 1, d)).getUTCDay() + 6) % 7;
65
+ return isoOf(shift(iso, -monday));
66
+ }
67
+ case 'day':
68
+ return iso.slice(0, 10);
69
+ default:
70
+ return '';
71
+ }
72
+ }
73
+ /** A group's label: "FY 2025–26", "2026", "September 2026", "15 to 21 Sep 2026", "Tuesday 15 Sep 2026". */
74
+ export function periodLabel(key, period, fyStart = 7, withYear = true) {
75
+ switch (period) {
76
+ case 'fy': {
77
+ const end = Number(key);
78
+ return fyStart > 1 ? `FY ${end - 1}–${String(end).slice(2)}` : `FY ${end}`;
79
+ }
80
+ case 'cy':
81
+ return key;
82
+ case 'month':
83
+ return `${MONTHS_LONG[Number(key.slice(5, 7)) - 1]} ${key.slice(0, 4)}`;
84
+ case 'week': {
85
+ const end = isoOf(shift(key, 6));
86
+ const [sy, sm, sd] = parts(key);
87
+ const [ey, em] = parts(end);
88
+ const start = `${sd}${sm !== em ? ` ${MONTHS[sm - 1]}` : ''}${sy !== ey ? ` ${sy}` : ''}`;
89
+ return `${start} to ${ledgerDate(end, withYear || sy !== ey)}`;
90
+ }
91
+ case 'day': {
92
+ const [y, m, d] = parts(key);
93
+ return `${WEEKDAYS[new Date(Date.UTC(y, m - 1, d)).getUTCDay()]} ${ledgerDate(key, withYear)}`;
94
+ }
95
+ default:
96
+ return '';
97
+ }
98
+ }
99
+ /** "Jul 2025 to Jun 2026", or "Jul 2026 to now" for the year still open on `today`. */
100
+ export function fySpan(key, fyStart = 7, today = isoOf(new Date())) {
101
+ const end = Number(key);
102
+ const from = `${MONTHS[fyStart - 1]} ${fyStart > 1 ? end - 1 : end}`;
103
+ if (fyEnd(today, fyStart) === end)
104
+ return `${from} to now`;
105
+ return `${from} to ${MONTHS[(fyStart + 10) % 12]} ${end}`;
106
+ }
107
+ /** Rows newest first, a day's rows in the order given, grouped by period. */
108
+ export function groupRows(rows, period, fyStart = 7) {
109
+ const sorted = [...rows].sort((a, b) => a.date.slice(0, 10) < b.date.slice(0, 10)
110
+ ? 1
111
+ : a.date.slice(0, 10) > b.date.slice(0, 10)
112
+ ? -1
113
+ : 0);
114
+ const out = [];
115
+ for (const r of sorted) {
116
+ const key = periodKey(r.date, period, fyStart);
117
+ const last = out[out.length - 1];
118
+ if (last && last.key === key)
119
+ last.rows.push(r);
120
+ else
121
+ out.push({ key, rows: [r] });
122
+ }
123
+ return out;
124
+ }
125
+ /** Whole cents, so a group's sum carries no float error. */
126
+ export function cents(value) {
127
+ const n = Number(value);
128
+ return Number.isFinite(n) ? Math.round(n * 100) : 0;
129
+ }
130
+ /** Money as the ledger prints it: in plain, out in brackets. */
131
+ export function ledgerMoney(value, currency = 'AUD', locale) {
132
+ return formatCurrency(value, { currency, locale, negative: 'brackets' });
133
+ }
134
+ /** Every group's head and rows in order; a run of rows on one card breaks where a row is open. */
135
+ export function ledgerItems(groups, opts) {
136
+ const out = [];
137
+ groups.forEach((g, group) => {
138
+ if (opts.heads)
139
+ out.push({ kind: 'head', key: `h:${g.key}`, group });
140
+ if (opts.heads && opts.closed(g.key))
141
+ return;
142
+ let run = 0;
143
+ let from = -1;
144
+ const seal = () => {
145
+ if (from < 0)
146
+ return;
147
+ out[from].first = true;
148
+ out[out.length - 1].last = true;
149
+ from = -1;
150
+ };
151
+ for (const row of g.rows) {
152
+ if (row.id === opts.open) {
153
+ seal();
154
+ out.push({ kind: 'open', key: `o:${row.id}`, row, group });
155
+ run++;
156
+ continue;
157
+ }
158
+ if (from < 0)
159
+ from = out.length;
160
+ out.push({
161
+ kind: 'row',
162
+ key: `r:${row.id}`,
163
+ row,
164
+ group,
165
+ groupKey: g.key,
166
+ run,
167
+ first: false,
168
+ last: false
169
+ });
170
+ }
171
+ seal();
172
+ });
173
+ return out;
174
+ }
175
+ /** The first and last items overlapping `[top, bottom)`, given each item's start (and the end last). */
176
+ export function windowOf(starts, top, bottom) {
177
+ const n = starts.length - 1;
178
+ const firstAt = (pred) => {
179
+ let lo = 0;
180
+ let hi = n;
181
+ while (lo < hi) {
182
+ const mid = (lo + hi) >> 1;
183
+ if (pred(mid))
184
+ hi = mid;
185
+ else
186
+ lo = mid + 1;
187
+ }
188
+ return lo;
189
+ };
190
+ const a = firstAt((i) => starts[i + 1] > top);
191
+ const b = firstAt((i) => starts[i] >= bottom) - 1;
192
+ return [a, b];
193
+ }
194
+ export function blocksOf(items, a, b) {
195
+ const out = [];
196
+ for (let i = Math.max(0, a); i <= b && i < items.length; i++) {
197
+ const it = items[i];
198
+ if (it.kind !== 'row') {
199
+ out.push(it);
200
+ continue;
201
+ }
202
+ const last = out[out.length - 1];
203
+ const entry = { row: it.row, first: it.first, key: it.key };
204
+ if (last?.kind === 'card' && last.group === it.group && last.run === it.run) {
205
+ last.rows.push(entry);
206
+ last.cutBottom = !it.last;
207
+ }
208
+ else {
209
+ out.push({
210
+ kind: 'card',
211
+ key: `c:${it.groupKey}:${it.run}`,
212
+ group: it.group,
213
+ run: it.run,
214
+ rows: [entry],
215
+ cutTop: !it.first,
216
+ cutBottom: !it.last
217
+ });
218
+ }
219
+ }
220
+ return out;
221
+ }