@poodle64/ui 2026.10.0 → 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.
- package/README.md +79 -1
- package/dist/components/ui/ledger/index.d.ts +3 -0
- package/dist/components/ui/ledger/index.js +2 -0
- package/dist/components/ui/ledger/ledger-columns.svelte +107 -0
- package/dist/components/ui/ledger/ledger-columns.svelte.d.ts +15 -0
- package/dist/components/ui/ledger/ledger.d.ts +77 -0
- package/dist/components/ui/ledger/ledger.js +221 -0
- package/dist/components/ui/ledger/ledger.svelte +905 -0
- package/dist/components/ui/ledger/ledger.svelte.d.ts +78 -0
- package/dist/components/ui/ledger/types.d.ts +79 -0
- package/dist/components/ui/ledger/types.js +1 -0
- package/dist/format.d.ts +8 -0
- package/dist/format.js +9 -3
- package/package.json +2 -1
- package/registry/component-map.json +17 -3
- package/registry/component-map.md +9 -1
package/README.md
CHANGED
|
@@ -82,7 +82,7 @@ the one-owner-per-key note below). It becomes a candidate the day two apps want
|
|
|
82
82
|
the same chart palette; until then a shared chart component would be a shared
|
|
83
83
|
disagreement.
|
|
84
84
|
|
|
85
|
-
**Composed components
|
|
85
|
+
**Composed components.** Primitives are not what makes an app look like an
|
|
86
86
|
app — the page chrome is. These are the cross-cutting surfaces every route
|
|
87
87
|
composes from, so a household app gets its layout language from the package
|
|
88
88
|
rather than rebuilding it:
|
|
@@ -112,6 +112,7 @@ rather than rebuilding it:
|
|
|
112
112
|
| `collection-detail` | One collection's surface: identity (`detail-panel`), an at-a-glance `stat-list`, and the documents it holds, with `actions` and `children` slots for the app-specific rest. |
|
|
113
113
|
| `document-detail` | One document's surface: identity fields, locations, tags and collection memberships, each section present exactly when its data is. Membership links are the app's own via `collectionHref`. |
|
|
114
114
|
| `search-results` | A ranked retrieval answer: title, the matched passage with accent-tinted highlights, source chip, mapped state and a mono relevance figure per hit; plus the before-any-search and matched-nothing empties. |
|
|
115
|
+
| `ledger` | The one ledger every module draws: dated rows of money, coins or units, two lines a row, grouped by the viewer's period, the viewer's columns and running balance. See [The ledger](#the-ledger). |
|
|
115
116
|
|
|
116
117
|
**The application shell** (`app-shell`, `command-palette`). Page chrome is not
|
|
117
118
|
what makes an app feel like an app either — the shell is. Five household
|
|
@@ -854,6 +855,83 @@ but the same class on the `<th>` collapses the heading over its neighbour:
|
|
|
854
855
|
{ accessorKey: 'filename', header: 'Document', meta: { class: 'w-full', cellClass: 'max-w-0' } }
|
|
855
856
|
```
|
|
856
857
|
|
|
858
|
+
## The ledger
|
|
859
|
+
|
|
860
|
+
One component for every money list in the household: a property's books, a
|
|
861
|
+
bank account, a wallet, a brokerage. The module brings its rows and the columns
|
|
862
|
+
it offers; the ledger owns the look, the grouping and the viewer's toggles, so
|
|
863
|
+
no module rebuilds them and a need one module finds becomes a toggle for all.
|
|
864
|
+
|
|
865
|
+
```svelte
|
|
866
|
+
<script lang="ts">
|
|
867
|
+
import Ledger, { type LedgerColumn, type LedgerRow } from '@poodle64/ui/ledger';
|
|
868
|
+
|
|
869
|
+
type Row = LedgerRow & { category: string | null };
|
|
870
|
+
const columns: LedgerColumn<Row>[] = [
|
|
871
|
+
{ key: 'title', label: 'Payee' },
|
|
872
|
+
{ key: 'category', label: 'Category', text: (r) => r.category ?? 'Uncategorised' },
|
|
873
|
+
{ key: 'balance', label: 'Running balance', head: 'Balance' }
|
|
874
|
+
];
|
|
875
|
+
let preferences = $state(saved.ledger); // the app's own persisted preference
|
|
876
|
+
let selected = $state<number[]>([]);
|
|
877
|
+
let open = $state<number | null>(null);
|
|
878
|
+
</script>
|
|
879
|
+
|
|
880
|
+
<Ledger {rows} {columns} title="Transactions" bind:preferences
|
|
881
|
+
onPreferencesChange={(next) => save('ledger', next)} bind:selected bind:open>
|
|
882
|
+
{#snippet bulkActions(ticked)}<Button size="sm" onclick={() => review(ticked)}>Mark reviewed</Button>{/snippet}
|
|
883
|
+
{#snippet editor(row, { close, template, column })}<!-- the module's fields -->{/snippet}
|
|
884
|
+
</Ledger>
|
|
885
|
+
```
|
|
886
|
+
|
|
887
|
+
| Prop | Purpose |
|
|
888
|
+
| --- | --- |
|
|
889
|
+
| `rows` | `LedgerRow`s, extended with whatever the module's columns read: `id`, `date` (ISO), `title`, `note`, `amount` (signed), `balance`, `review` (`true` or a `Status`), `attachment` (its name), `origins` (a synthetic row's statement lines). Pass every row the view covers, filtered: the ledger sorts newest first, keeps a day's rows in the order given, and mounts only those near the view. |
|
|
890
|
+
| `columns` | The module's columns, in order after the amount. The keys `date`, `title`, `amount` and `balance` are the ledger's own cells: list one to rename or resize it (the title reads "Description" until the module names it), and list `balance` to offer the running balance at all, with `text` saying what it counts (money, a coin, units). A `cell` snippet's links and buttons take their own clicks; the rest of the cell opens the row. |
|
|
891
|
+
| `preferences`, `onPreferencesChange` | The viewer's `{ columns, period }`. Missing fields take the module's defaults (each column's `on`, the balance off, grouped by month); an unknown column key is ignored. The ledger stores nothing. |
|
|
892
|
+
| `fyStart` | The month a financial year starts, 1–12. July by default; anything else groups from July and logs an error in development. |
|
|
893
|
+
| `locale`, `currency` | Every figure, amounts, nets and counts, in this locale (`AU_LOCALE` by default) and currency (AUD). Dates keep the drawn "15 Sep": the ledger's words are English. |
|
|
894
|
+
| `title`, `meta`, `actions` | The toolbar on the page ground: its title, the count beside it, and the module's own actions after the Columns menu. |
|
|
895
|
+
| `selected`, `bulkActions` | Ticking exists only with `bulkActions`, whose snippet gets the ticked rows in the bar that replaces the toolbar. |
|
|
896
|
+
| `open`, `editor` | Opening exists only with `editor`: the row lifts out of its card as its own surface, holding the snippet. Its second argument carries the row's `template` and each column's grid line, so fields can sit under their columns. |
|
|
897
|
+
| `originActions`, `onAttachment` | Split, join and undo under a synthetic row's statement lines; the paperclip as a button. |
|
|
898
|
+
| `noun`, `empty`, `footer`, `layout` | "line" for a printed layer; the empty state; "Show more" after the last group; a forced `phone`/`narrow`/`wide` where the ledger's own width (below 600px, from 1200px) should not decide. |
|
|
899
|
+
|
|
900
|
+
Settled here, the same in every module, and not props:
|
|
901
|
+
|
|
902
|
+
- **Two lines a row**, the note under the title, the pair centred on the row; a
|
|
903
|
+
row with no note centres its title. The checkbox, the review dot, the date and
|
|
904
|
+
every single-value cell centre too.
|
|
905
|
+
- **One mark per kind.** A review dot at the row's left edge, never a status
|
|
906
|
+
chip; a synthetic row's "Not as printed" badge leads its second line and
|
|
907
|
+
opens the row in place to the lines it was made from.
|
|
908
|
+
- **PocketSmith's look.** No panel around the list; a head card exactly as wide
|
|
909
|
+
as the group cards, its columns on theirs; one rounded card per period with
|
|
910
|
+
hairlines between rows; the period's label, count and net on the page ground
|
|
911
|
+
above its card; figures in the body face with tabular numerals, money out
|
|
912
|
+
bracketed and money in green (`formatCurrency(v, { negative: 'brackets' })`).
|
|
913
|
+
- **The Columns menu** holds every column as a tick, the three fixed ones
|
|
914
|
+
locked and the running balance last, then the period: financial year,
|
|
915
|
+
calendar year, month, week (from Monday), day (the date heads the group and
|
|
916
|
+
its column goes) or none.
|
|
917
|
+
- **Density** is the package's own `data-ds-density`: a row is 3.5rem, 3rem
|
|
918
|
+
under `compact`. There is no one-line row.
|
|
919
|
+
|
|
920
|
+
What the module keeps: Find (search, filters, views) in its `ContextColumn`, its
|
|
921
|
+
rows' second layer (a bank's lines as printed) as a scope control that swaps
|
|
922
|
+
the rows and columns it passes, the opened row's fields, and where a choice is
|
|
923
|
+
persisted. Group totals are summed from the rows passed, which is why the module
|
|
924
|
+
passes them all rather than a page.
|
|
925
|
+
|
|
926
|
+
It fills its parent's height and scrolls its own rows, which is what holds the
|
|
927
|
+
head card at the top: give it a bounded parent (`flex min-h-0 flex-1`). It mounts
|
|
928
|
+
only the rows within about a screen of the view, measuring each as it renders and
|
|
929
|
+
standing in for the rest at their height, so the scroll bar and every card keep
|
|
930
|
+
their true size. Measured in Chromium at 1440x900: 5,000 rows paint their first
|
|
931
|
+
row in about 300ms with some 20 mounted, where mounting them all took 7.6 to 8.6
|
|
932
|
+
seconds and 65,000 nodes. Its look is measured in a real engine at 390, 1000,
|
|
933
|
+
1440 and 3360px, and at 5,000 rows (`harness/drive.md` §"The ledger").
|
|
934
|
+
|
|
857
935
|
## Hand-written forms
|
|
858
936
|
|
|
859
937
|
The shadcn-svelte Formsnap wrapper set, over `sveltekit-superforms`:
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The viewer's Columns menu: every column the module offers as a tick (the
|
|
4
|
+
* running balance one more, always last), then the period rows group by.
|
|
5
|
+
* An icon button named by its tooltip.
|
|
6
|
+
*/
|
|
7
|
+
import Columns3 from '@lucide/svelte/icons/columns-3';
|
|
8
|
+
import { Button, buttonVariants } from '../button/index.js';
|
|
9
|
+
import { Checkbox } from '../checkbox/index.js';
|
|
10
|
+
import * as Popover from '../popover/index.js';
|
|
11
|
+
import { Segmented } from '../segmented/index.js';
|
|
12
|
+
import * as Tooltip from '../tooltip/index.js';
|
|
13
|
+
import { cn } from '../../../utils.js';
|
|
14
|
+
import { LEDGER_PERIODS } from './ledger.js';
|
|
15
|
+
import type { LedgerPeriod, LedgerPreferences } from './types.js';
|
|
16
|
+
|
|
17
|
+
let {
|
|
18
|
+
offered,
|
|
19
|
+
preferences,
|
|
20
|
+
note,
|
|
21
|
+
onchange,
|
|
22
|
+
onreset
|
|
23
|
+
}: {
|
|
24
|
+
offered: { key: string; label: string; locked: boolean }[];
|
|
25
|
+
preferences: LedgerPreferences;
|
|
26
|
+
note?: string;
|
|
27
|
+
onchange: (next: LedgerPreferences) => void;
|
|
28
|
+
onreset: () => void;
|
|
29
|
+
} = $props();
|
|
30
|
+
|
|
31
|
+
let open = $state(false);
|
|
32
|
+
|
|
33
|
+
const HINT: Record<LedgerPeriod, string> = {
|
|
34
|
+
fy: 'Each financial year sits under its own header, which opens and closes.',
|
|
35
|
+
cy: 'Each calendar year sits under its own header, which opens and closes.',
|
|
36
|
+
month: 'Each month sits under its own header, which opens and closes.',
|
|
37
|
+
week: 'Each week sits under its own header, which opens and closes.',
|
|
38
|
+
day: 'The date heads each day’s rows, so the Date column goes.',
|
|
39
|
+
none: 'One list, newest first, under the column head.'
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function tick(key: string, on: boolean) {
|
|
43
|
+
const next = on ? [...preferences.columns, key] : preferences.columns.filter((k) => k !== key);
|
|
44
|
+
onchange({
|
|
45
|
+
...preferences,
|
|
46
|
+
columns: offered.map((c) => c.key).filter((k) => next.includes(k))
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const eyebrow = 'text-muted-foreground text-2xs tracking-eyebrow font-semibold uppercase';
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<Popover.Root bind:open>
|
|
54
|
+
<Tooltip.Provider delayDuration={150}>
|
|
55
|
+
<Tooltip.Root>
|
|
56
|
+
<Tooltip.Trigger>
|
|
57
|
+
{#snippet child({ props })}
|
|
58
|
+
<Popover.Trigger
|
|
59
|
+
{...props}
|
|
60
|
+
class={buttonVariants({ variant: open ? 'outline' : 'ghost', size: 'icon-sm' })}
|
|
61
|
+
aria-label="Columns, balance and grouping"
|
|
62
|
+
data-slot="ledger-columns-trigger"
|
|
63
|
+
>
|
|
64
|
+
<Columns3 />
|
|
65
|
+
</Popover.Trigger>
|
|
66
|
+
{/snippet}
|
|
67
|
+
</Tooltip.Trigger>
|
|
68
|
+
<Tooltip.Content side="bottom">Columns, balance and grouping</Tooltip.Content>
|
|
69
|
+
</Tooltip.Root>
|
|
70
|
+
</Tooltip.Provider>
|
|
71
|
+
<Popover.Content align="end" class="w-80 p-0" data-slot="ledger-columns">
|
|
72
|
+
<div class="flex flex-col px-2 pt-2.5 pb-2">
|
|
73
|
+
<span class={cn(eyebrow, 'px-1.5 pb-1.5')}>Columns</span>
|
|
74
|
+
{#each offered as c (c.key)}
|
|
75
|
+
{@const on = c.locked || preferences.columns.includes(c.key)}
|
|
76
|
+
<label
|
|
77
|
+
class={cn(
|
|
78
|
+
'flex min-h-8 items-center gap-2.5 rounded-md px-1.5 text-sm',
|
|
79
|
+
c.locked ? 'text-muted-foreground' : 'hover:bg-accent/50 cursor-pointer'
|
|
80
|
+
)}
|
|
81
|
+
>
|
|
82
|
+
<Checkbox
|
|
83
|
+
checked={on}
|
|
84
|
+
disabled={c.locked}
|
|
85
|
+
onCheckedChange={(v) => tick(c.key, v === true)}
|
|
86
|
+
/>
|
|
87
|
+
<span class="min-w-0 flex-1">{c.label}</span>
|
|
88
|
+
{#if c.locked}<span class="text-xs">Always shown</span>{/if}
|
|
89
|
+
</label>
|
|
90
|
+
{/each}
|
|
91
|
+
</div>
|
|
92
|
+
<div class="border-border flex flex-col gap-2 border-t px-3.5 pt-2.5 pb-3">
|
|
93
|
+
<span class={eyebrow}>Group by</span>
|
|
94
|
+
<Segmented
|
|
95
|
+
label="Group by"
|
|
96
|
+
options={LEDGER_PERIODS}
|
|
97
|
+
bind:value={() => preferences.period, (period) => onchange({ ...preferences, period })}
|
|
98
|
+
class="grid w-full grid-cols-2"
|
|
99
|
+
/>
|
|
100
|
+
<span class="text-muted-foreground text-xs">{HINT[preferences.period]}</span>
|
|
101
|
+
</div>
|
|
102
|
+
<div class="bg-surface-2 border-border flex items-center gap-2 border-t py-2 pr-2 pl-3.5">
|
|
103
|
+
<span class="text-muted-foreground min-w-0 flex-1 text-xs">{note ?? ''}</span>
|
|
104
|
+
<Button variant="ghost" size="sm" onclick={onreset}>Reset</Button>
|
|
105
|
+
</div>
|
|
106
|
+
</Popover.Content>
|
|
107
|
+
</Popover.Root>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { LedgerPreferences } from './types.js';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
offered: {
|
|
4
|
+
key: string;
|
|
5
|
+
label: string;
|
|
6
|
+
locked: boolean;
|
|
7
|
+
}[];
|
|
8
|
+
preferences: LedgerPreferences;
|
|
9
|
+
note?: string;
|
|
10
|
+
onchange: (next: LedgerPreferences) => void;
|
|
11
|
+
onreset: () => void;
|
|
12
|
+
};
|
|
13
|
+
declare const LedgerColumns: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
14
|
+
type LedgerColumns = ReturnType<typeof LedgerColumns>;
|
|
15
|
+
export default LedgerColumns;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { LedgerPeriod, LedgerRow } from './types.js';
|
|
2
|
+
export declare const LEDGER_PERIODS: readonly {
|
|
3
|
+
value: LedgerPeriod;
|
|
4
|
+
label: string;
|
|
5
|
+
}[];
|
|
6
|
+
/** "15 Sep", or "15 Sep 2026" with the year. */
|
|
7
|
+
export declare function ledgerDate(iso: string, withYear?: boolean): string;
|
|
8
|
+
/** The group a date falls in: sortable, so newer keys sort later. */
|
|
9
|
+
export declare function periodKey(iso: string, period: LedgerPeriod, fyStart?: number): string;
|
|
10
|
+
/** A group's label: "FY 2025–26", "2026", "September 2026", "15 to 21 Sep 2026", "Tuesday 15 Sep 2026". */
|
|
11
|
+
export declare function periodLabel(key: string, period: LedgerPeriod, fyStart?: number, withYear?: boolean): string;
|
|
12
|
+
/** "Jul 2025 to Jun 2026", or "Jul 2026 to now" for the year still open on `today`. */
|
|
13
|
+
export declare function fySpan(key: string, fyStart?: number, today?: string): string;
|
|
14
|
+
/** Rows newest first, a day's rows in the order given, grouped by period. */
|
|
15
|
+
export declare function groupRows<R extends LedgerRow>(rows: readonly R[], period: LedgerPeriod, fyStart?: number): {
|
|
16
|
+
key: string;
|
|
17
|
+
rows: R[];
|
|
18
|
+
}[];
|
|
19
|
+
/** Whole cents, so a group's sum carries no float error. */
|
|
20
|
+
export declare function cents(value: number | string | null | undefined): number;
|
|
21
|
+
/** Money as the ledger prints it: in plain, out in brackets. */
|
|
22
|
+
export declare function ledgerMoney(value: number | string, currency?: string, locale?: string): string;
|
|
23
|
+
/** One thing the list stacks: a group's head, a row on its card, or a row opened out of it. */
|
|
24
|
+
export type LedgerItem<R extends LedgerRow> = {
|
|
25
|
+
kind: 'head';
|
|
26
|
+
key: string;
|
|
27
|
+
group: number;
|
|
28
|
+
} | {
|
|
29
|
+
kind: 'row';
|
|
30
|
+
key: string;
|
|
31
|
+
row: R;
|
|
32
|
+
group: number;
|
|
33
|
+
groupKey: string;
|
|
34
|
+
run: number;
|
|
35
|
+
first: boolean;
|
|
36
|
+
last: boolean;
|
|
37
|
+
} | {
|
|
38
|
+
kind: 'open';
|
|
39
|
+
key: string;
|
|
40
|
+
row: R;
|
|
41
|
+
group: number;
|
|
42
|
+
};
|
|
43
|
+
/** Every group's head and rows in order; a run of rows on one card breaks where a row is open. */
|
|
44
|
+
export declare function ledgerItems<R extends LedgerRow>(groups: readonly {
|
|
45
|
+
key: string;
|
|
46
|
+
rows: readonly R[];
|
|
47
|
+
}[], opts: {
|
|
48
|
+
heads: boolean;
|
|
49
|
+
closed: (key: string) => boolean;
|
|
50
|
+
open: R['id'] | null;
|
|
51
|
+
}): LedgerItem<R>[];
|
|
52
|
+
/** The first and last items overlapping `[top, bottom)`, given each item's start (and the end last). */
|
|
53
|
+
export declare function windowOf(starts: ArrayLike<number>, top: number, bottom: number): [number, number];
|
|
54
|
+
/** What renders for items `a..b`: heads, opened rows, and cards cut where the window cuts them. */
|
|
55
|
+
export type LedgerBlock<R extends LedgerRow> = {
|
|
56
|
+
kind: 'head';
|
|
57
|
+
key: string;
|
|
58
|
+
group: number;
|
|
59
|
+
} | {
|
|
60
|
+
kind: 'open';
|
|
61
|
+
key: string;
|
|
62
|
+
row: R;
|
|
63
|
+
group: number;
|
|
64
|
+
} | {
|
|
65
|
+
kind: 'card';
|
|
66
|
+
key: string;
|
|
67
|
+
group: number;
|
|
68
|
+
run: number;
|
|
69
|
+
rows: {
|
|
70
|
+
row: R;
|
|
71
|
+
first: boolean;
|
|
72
|
+
key: string;
|
|
73
|
+
}[];
|
|
74
|
+
cutTop: boolean;
|
|
75
|
+
cutBottom: boolean;
|
|
76
|
+
};
|
|
77
|
+
export declare function blocksOf<R extends LedgerRow>(items: readonly LedgerItem<R>[], a: number, b: number): LedgerBlock<R>[];
|
|
@@ -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
|
+
}
|