@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.
- package/README.md +84 -6
- package/dist/components/settings/index.d.ts +3 -0
- package/dist/components/settings/index.js +3 -0
- package/dist/components/settings/nest.d.ts +15 -0
- package/dist/components/settings/nest.js +58 -0
- package/dist/components/settings/settings-page.svelte +159 -0
- package/dist/components/settings/settings-page.svelte.d.ts +17 -0
- package/dist/components/ui/input-group/input-group-input.svelte.d.ts +1 -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 +6 -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
|
|
@@ -568,7 +569,7 @@ remembered, not what the house looks like.
|
|
|
568
569
|
was before the feature existed — gated, not assumed:
|
|
569
570
|
|
|
570
571
|
```sh
|
|
571
|
-
node harness/additivity.mjs ui
|
|
572
|
+
node harness/additivity.mjs ee0db12 # ui 2026.8.3
|
|
572
573
|
# 15 surface/viewport pairs, 105 compared fields (including the screenshot hash)
|
|
573
574
|
# IDENTICAL on every field and every pixel against an explicit texture="none".
|
|
574
575
|
```
|
|
@@ -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`:
|
|
@@ -1379,7 +1457,7 @@ pnpm run check # svelte-check
|
|
|
1379
1457
|
pnpm run test:browser # build the harness and drive it in a real browser
|
|
1380
1458
|
|
|
1381
1459
|
# Before releasing a change that claims to be additive (minutes, not seconds):
|
|
1382
|
-
node harness/additivity.mjs ui
|
|
1460
|
+
node harness/additivity.mjs ee0db12 # ui 2026.8.3
|
|
1383
1461
|
```
|
|
1384
1462
|
|
|
1385
1463
|
`pnpm test` includes the gates that compile the built package through the real
|
|
@@ -1410,7 +1488,7 @@ non-zero and naming the field on any difference.
|
|
|
1410
1488
|
|
|
1411
1489
|
1. Change a component; bump `version` in `package.json` (CalVer).
|
|
1412
1490
|
2. `pnpm build`, which runs `svelte-package` then `publint` (package.json/exports sanity).
|
|
1413
|
-
3.
|
|
1414
|
-
4. `.github/workflows/publish-kit-typescript.yaml` (repo root) runs on that push and publishes
|
|
1415
|
-
OIDC trusted publishing: `gh run list -R radar-hooves/app-factory --workflow=publish-kit-typescript.yaml`.
|
|
1491
|
+
3. Merge to main. No tag.
|
|
1492
|
+
4. `.github/workflows/publish-kit-typescript.yaml` (repo root) runs on that push and publishes the new
|
|
1493
|
+
version via npm OIDC trusted publishing: `gh run list -R radar-hooves/app-factory --workflow=publish-kit-typescript.yaml`.
|
|
1416
1494
|
Confirm with `npm view @poodle64/ui version`.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings API is flat, keyed by dotted names (`intelligence.extraction_model`),
|
|
3
|
+
* and `<SchemaForm>` reads a `.` in a property name as a path. These nest the flat
|
|
4
|
+
* document along those paths, so every key is addressable, the change it reports
|
|
5
|
+
* carries the key back as `change.path`, and settings group by their domain. The
|
|
6
|
+
* form's generated layout does not descend into an object, so `settingsLayout`
|
|
7
|
+
* gives it one: a Group per domain, a Control per setting.
|
|
8
|
+
*/
|
|
9
|
+
import type { JsonSchema, UISchemaElement } from '../ui/schema-form/index.js';
|
|
10
|
+
/** `{ 'a.b': 1, c: 2 }` → `{ a: { b: 1 }, c: 2 }`. */
|
|
11
|
+
export declare function nestValues(flat: Record<string, unknown>): Record<string, unknown>;
|
|
12
|
+
/** The same nesting for a flat object schema: each path segment becomes an object group. */
|
|
13
|
+
export declare function nestSchema(schema: JsonSchema): JsonSchema;
|
|
14
|
+
/** The layout `<SchemaForm>` renders a nested settings schema with; a group's label is its segment in sentence case. */
|
|
15
|
+
export declare function settingsLayout(schema: JsonSchema): UISchemaElement;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings API is flat, keyed by dotted names (`intelligence.extraction_model`),
|
|
3
|
+
* and `<SchemaForm>` reads a `.` in a property name as a path. These nest the flat
|
|
4
|
+
* document along those paths, so every key is addressable, the change it reports
|
|
5
|
+
* carries the key back as `change.path`, and settings group by their domain. The
|
|
6
|
+
* form's generated layout does not descend into an object, so `settingsLayout`
|
|
7
|
+
* gives it one: a Group per domain, a Control per setting.
|
|
8
|
+
*/
|
|
9
|
+
/** `{ 'a.b': 1, c: 2 }` → `{ a: { b: 1 }, c: 2 }`. */
|
|
10
|
+
export function nestValues(flat) {
|
|
11
|
+
const root = {};
|
|
12
|
+
for (const [key, value] of Object.entries(flat)) {
|
|
13
|
+
const parts = key.split('.');
|
|
14
|
+
const leaf = parts.pop() ?? key;
|
|
15
|
+
let node = root;
|
|
16
|
+
for (const part of parts) {
|
|
17
|
+
node = (node[part] ??= {});
|
|
18
|
+
}
|
|
19
|
+
node[leaf] = value;
|
|
20
|
+
}
|
|
21
|
+
return root;
|
|
22
|
+
}
|
|
23
|
+
/** The same nesting for a flat object schema: each path segment becomes an object group. */
|
|
24
|
+
export function nestSchema(schema) {
|
|
25
|
+
const properties = {};
|
|
26
|
+
for (const [key, property] of Object.entries(schema.properties ?? {})) {
|
|
27
|
+
const parts = key.split('.');
|
|
28
|
+
const leaf = parts.pop() ?? key;
|
|
29
|
+
let node = properties;
|
|
30
|
+
for (const part of parts) {
|
|
31
|
+
node = (node[part] ??= { type: 'object', properties: {} }).properties ??= {};
|
|
32
|
+
}
|
|
33
|
+
node[leaf] = property;
|
|
34
|
+
}
|
|
35
|
+
return { ...schema, properties };
|
|
36
|
+
}
|
|
37
|
+
/** The layout `<SchemaForm>` renders a nested settings schema with; a group's label is its segment in sentence case. */
|
|
38
|
+
export function settingsLayout(schema) {
|
|
39
|
+
const layout = {
|
|
40
|
+
type: 'VerticalLayout',
|
|
41
|
+
elements: layoutElements(schema, '#')
|
|
42
|
+
};
|
|
43
|
+
return layout;
|
|
44
|
+
}
|
|
45
|
+
function layoutElements(schema, scope) {
|
|
46
|
+
return Object.entries(schema.properties ?? {}).map(([key, child]) => {
|
|
47
|
+
const childScope = `${scope}/properties/${key}`;
|
|
48
|
+
if (child.type === 'object' && child.properties) {
|
|
49
|
+
const label = key.replaceAll('_', ' ');
|
|
50
|
+
return {
|
|
51
|
+
type: 'Group',
|
|
52
|
+
label: label.charAt(0).toUpperCase() + label.slice(1),
|
|
53
|
+
elements: layoutElements(child, childScope)
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return { type: 'Control', scope: childScope };
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
The runtime settings page for app-slices' settings routes: every declared
|
|
3
|
+
Setting, rendered from the JSON Schema `GET <endpoint>/` returns, nested by
|
|
4
|
+
dot path and laid out one group per domain (`nest.ts`), so an app that
|
|
5
|
+
declares a setting adds no code here. `<SchemaForm>` has no per-field reset,
|
|
6
|
+
so "default versus override, reset in one act" (the operator's own ask) is
|
|
7
|
+
the panel below it. An app mounts it as its settings route's whole body.
|
|
8
|
+
-->
|
|
9
|
+
<script lang="ts" module>
|
|
10
|
+
import type { JsonSchema } from '../ui/schema-form/index.js';
|
|
11
|
+
|
|
12
|
+
export type SettingValue = boolean | number | string;
|
|
13
|
+
|
|
14
|
+
/** What `GET <endpoint>/` answers: app-slices' `SettingsDocument`. */
|
|
15
|
+
export type SettingsDocument = {
|
|
16
|
+
schema: JsonSchema;
|
|
17
|
+
value: Record<string, SettingValue>;
|
|
18
|
+
defaults: Record<string, SettingValue>;
|
|
19
|
+
};
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<script lang="ts">
|
|
23
|
+
import { onMount } from 'svelte';
|
|
24
|
+
import { toast } from 'svelte-sonner';
|
|
25
|
+
import { Button } from '../ui/button/index.js';
|
|
26
|
+
import { LoadingState } from '../ui/loading-state/index.js';
|
|
27
|
+
import { PageHeader } from '../ui/page-header/index.js';
|
|
28
|
+
import { Panel } from '../ui/panel/index.js';
|
|
29
|
+
import { SchemaForm, type SchemaFormChange } from '../ui/schema-form/index.js';
|
|
30
|
+
import { nestSchema, nestValues, settingsLayout } from './nest.js';
|
|
31
|
+
|
|
32
|
+
let {
|
|
33
|
+
/** The app's settings routes, defaulting to where the factory mounts them. */
|
|
34
|
+
endpoint = '/api/settings',
|
|
35
|
+
title = 'Application',
|
|
36
|
+
subtitle = "Runtime settings this deployment's operator can change without a code change or a deploy.",
|
|
37
|
+
/** What a 401 does: the app's own re-authentication (its API client's redirect), since only the app knows its sign-in. */
|
|
38
|
+
onunauthorized
|
|
39
|
+
}: {
|
|
40
|
+
endpoint?: string;
|
|
41
|
+
title?: string;
|
|
42
|
+
subtitle?: string;
|
|
43
|
+
onunauthorized?: () => void;
|
|
44
|
+
} = $props();
|
|
45
|
+
|
|
46
|
+
const TIMEOUT_MS = 30_000;
|
|
47
|
+
|
|
48
|
+
let doc = $state<SettingsDocument | null>(null);
|
|
49
|
+
let loadError = $state<string | null>(null);
|
|
50
|
+
/** What `<SchemaForm>` renders: the flat document nested by dot path; the flat one stays the state. */
|
|
51
|
+
let formSchema = $derived(doc ? nestSchema(doc.schema) : null);
|
|
52
|
+
let formValue = $derived(doc ? nestValues(doc.value) : {});
|
|
53
|
+
let formLayout = $derived(formSchema ? settingsLayout(formSchema) : undefined);
|
|
54
|
+
/** Keys whose effective value differs from its default — derived, never a second copy to disagree with the API. */
|
|
55
|
+
let overridden = $derived(
|
|
56
|
+
doc ? Object.keys(doc.value).filter((key) => doc!.value[key] !== doc!.defaults[key]) : []
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
/** The body on success, or the household error body's `message` (`{"error", "message"}`). */
|
|
60
|
+
async function call<T>(
|
|
61
|
+
path: string,
|
|
62
|
+
init: RequestInit = {}
|
|
63
|
+
): Promise<{ data: T } | { failure: string }> {
|
|
64
|
+
let response: Response;
|
|
65
|
+
try {
|
|
66
|
+
response = await fetch(`${endpoint}/${path}`, {
|
|
67
|
+
...init,
|
|
68
|
+
credentials: 'include',
|
|
69
|
+
signal: AbortSignal.timeout(TIMEOUT_MS)
|
|
70
|
+
});
|
|
71
|
+
} catch {
|
|
72
|
+
return { failure: 'The server could not be reached.' };
|
|
73
|
+
}
|
|
74
|
+
if (response.status === 401) onunauthorized?.();
|
|
75
|
+
const body = await response.json().catch(() => null);
|
|
76
|
+
if (response.ok && body !== null) return { data: body as T };
|
|
77
|
+
if (response.ok) return { failure: 'The server did not answer with settings.' };
|
|
78
|
+
return { failure: body?.message ?? `The server answered ${response.status}.` };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function load() {
|
|
82
|
+
const result = await call<SettingsDocument>('');
|
|
83
|
+
if ('failure' in result) {
|
|
84
|
+
loadError = result.failure;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
loadError = null;
|
|
88
|
+
doc = result.data;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
onMount(load);
|
|
92
|
+
|
|
93
|
+
async function save(key: string, path: string, init: RequestInit, failure: string) {
|
|
94
|
+
const result = await call<{ value: SettingValue }>(path, init);
|
|
95
|
+
if ('failure' in result) {
|
|
96
|
+
toast.error(failure, { description: result.failure });
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
doc!.value[key] = result.data.value;
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function handleChange(_next: Record<string, unknown>, change: SchemaFormChange) {
|
|
104
|
+
if (!doc) return;
|
|
105
|
+
const key = change.path;
|
|
106
|
+
const previous = doc.value[key];
|
|
107
|
+
doc.value[key] = change.value as SettingValue;
|
|
108
|
+
const saved = await save(
|
|
109
|
+
key,
|
|
110
|
+
encodeURIComponent(key),
|
|
111
|
+
{
|
|
112
|
+
method: 'PATCH',
|
|
113
|
+
headers: { 'content-type': 'application/json' },
|
|
114
|
+
body: JSON.stringify({ value: change.value })
|
|
115
|
+
},
|
|
116
|
+
'Could not save the setting'
|
|
117
|
+
);
|
|
118
|
+
if (!saved && previous !== undefined) doc.value[key] = previous;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function reset(key: string) {
|
|
122
|
+
void save(key, `${encodeURIComponent(key)}/reset`, { method: 'POST' }, 'Could not reset the setting');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function label(key: string): string {
|
|
126
|
+
const properties = (doc!.schema as { properties?: Record<string, { title?: string }> }).properties;
|
|
127
|
+
return properties?.[key]?.title ?? key;
|
|
128
|
+
}
|
|
129
|
+
</script>
|
|
130
|
+
|
|
131
|
+
<PageHeader {title} {subtitle} />
|
|
132
|
+
|
|
133
|
+
{#if loadError}
|
|
134
|
+
<Panel title="Could not load settings" tone="destructive">{loadError}</Panel>
|
|
135
|
+
{:else if !doc}
|
|
136
|
+
<LoadingState message="Loading…" class="mt-16" />
|
|
137
|
+
{:else}
|
|
138
|
+
<Panel title="Settings">
|
|
139
|
+
<SchemaForm schema={formSchema!} uischema={formLayout} value={formValue} onChange={handleChange} />
|
|
140
|
+
</Panel>
|
|
141
|
+
|
|
142
|
+
{#if overridden.length}
|
|
143
|
+
<Panel title="Overridden from default" subtitle="{overridden.length} changed">
|
|
144
|
+
<ul class="grid gap-2">
|
|
145
|
+
{#each overridden as key (key)}
|
|
146
|
+
<li class="flex items-center justify-between gap-4">
|
|
147
|
+
<span class="text-sm">
|
|
148
|
+
{label(key)}
|
|
149
|
+
<span class="text-muted-foreground font-mono text-xs">
|
|
150
|
+
default: {doc.defaults[key]}
|
|
151
|
+
</span>
|
|
152
|
+
</span>
|
|
153
|
+
<Button variant="outline" size="sm" onclick={() => reset(key)}>Reset</Button>
|
|
154
|
+
</li>
|
|
155
|
+
{/each}
|
|
156
|
+
</ul>
|
|
157
|
+
</Panel>
|
|
158
|
+
{/if}
|
|
159
|
+
{/if}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { JsonSchema } from '../ui/schema-form/index.js';
|
|
2
|
+
export type SettingValue = boolean | number | string;
|
|
3
|
+
/** What `GET <endpoint>/` answers: app-slices' `SettingsDocument`. */
|
|
4
|
+
export type SettingsDocument = {
|
|
5
|
+
schema: JsonSchema;
|
|
6
|
+
value: Record<string, SettingValue>;
|
|
7
|
+
defaults: Record<string, SettingValue>;
|
|
8
|
+
};
|
|
9
|
+
type $$ComponentProps = {
|
|
10
|
+
endpoint?: string;
|
|
11
|
+
title?: string;
|
|
12
|
+
subtitle?: string;
|
|
13
|
+
onunauthorized?: () => void;
|
|
14
|
+
};
|
|
15
|
+
declare const SettingsPage: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
16
|
+
type SettingsPage = ReturnType<typeof SettingsPage>;
|
|
17
|
+
export default SettingsPage;
|
|
@@ -2,7 +2,7 @@ declare const InputGroupInput: import("svelte").Component<(Omit<import("svelte/e
|
|
|
2
2
|
type: "file";
|
|
3
3
|
files?: FileList;
|
|
4
4
|
} | {
|
|
5
|
-
type?: "number" | "month" | "url" | "hidden" | "color" | "submit" | "reset" | "button" | "search" | "checkbox" | "radio" | (string & {}) | "text" | "tel" | "email" | "week" | "date" | "time" | "
|
|
5
|
+
type?: "number" | "month" | "url" | "hidden" | "color" | "submit" | "reset" | "button" | "search" | "checkbox" | "radio" | (string & {}) | "text" | "tel" | "email" | "week" | "date" | "time" | "datetime-local" | "image" | "password" | "range";
|
|
6
6
|
files?: undefined;
|
|
7
7
|
})) & {
|
|
8
8
|
ref?: HTMLElement | null | undefined;
|
|
@@ -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>[];
|