@butinapp/shapes 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +8 -0
- package/dist/bundle.d.ts +2 -0
- package/dist/bundle.js +7 -0
- package/dist/daily.d.ts +18 -0
- package/dist/daily.js +73 -0
- package/dist/export-bundle.d.ts +147 -0
- package/dist/export-bundle.js +68 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +9 -0
- package/dist/ledger.d.ts +55 -0
- package/dist/ledger.js +3 -0
- package/dist/manifest.d.ts +27 -0
- package/dist/manifest.js +97 -0
- package/dist/overview.d.ts +54 -0
- package/dist/overview.js +35 -0
- package/dist/plugin-view.d.ts +42 -0
- package/dist/plugin-view.js +7 -0
- package/package.json +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yann Allard
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# @butinapp/shapes
|
|
2
|
+
|
|
3
|
+
Host-layer data shapes for [Butin](https://butin.app), built on the `@butinapp/sdk` data-view contract. Not the plugin-authoring contract (that's `@butinapp/sdk`).
|
|
4
|
+
|
|
5
|
+
- `@butinapp/shapes` — the cross-service Overview input, the persisted ledger, the presentation manifest, per-day spend derivation
|
|
6
|
+
- `@butinapp/shapes/bundle` — the portable, versioned export-bundle wire format (what a snapshot-fed dashboard consumes)
|
|
7
|
+
|
|
8
|
+
MIT licensed.
|
package/dist/bundle.d.ts
ADDED
package/dist/bundle.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// @butinapp/shapes/bundle — the portable export bundle: Butin's normalized data lifted into ONE
|
|
2
|
+
// self-contained, versioned JSON blob. The system's most boundary-crossing object — produced by core
|
|
3
|
+
// (buildExportBundle), optionally merged by a gateway, consumed by a separately-deployed viewer — so it
|
|
4
|
+
// carries its own EXPORT_BUNDLE_FORMAT_VERSION and a parseExportBundle that gates an untrusted blob on that
|
|
5
|
+
// version. Kept on its own subpath (not on the host-layer root) because it's the stable wire contract a
|
|
6
|
+
// consumer depends on exactly — nothing else.
|
|
7
|
+
export * from './export-bundle.js';
|
package/dist/daily.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ResetPeriod } from '@butinapp/sdk/data';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
export declare const DailyPointSchema: z.ZodObject<{
|
|
4
|
+
date: z.ZodString;
|
|
5
|
+
value: z.ZodNumber;
|
|
6
|
+
estimated: z.ZodOptional<z.ZodBoolean>;
|
|
7
|
+
}, z.core.$strip>;
|
|
8
|
+
export type DailyPoint = z.infer<typeof DailyPointSchema>;
|
|
9
|
+
export type DailySource = {
|
|
10
|
+
date: string;
|
|
11
|
+
capturedAt: string;
|
|
12
|
+
section: string;
|
|
13
|
+
value: number;
|
|
14
|
+
};
|
|
15
|
+
export declare const latestPerDay: <T>(points: T[], dayOf: (p: T) => string, instant: (p: T) => string) => T[];
|
|
16
|
+
export declare const deriveDailySpend: (points: DailySource[], resetPeriod?: ResetPeriod) => DailyPoint[];
|
|
17
|
+
export declare const deriveDailyByRow: (readingsByRow: Record<string, DailySource[]>, resetPeriod?: ResetPeriod) => Record<string, DailyPoint[]>;
|
|
18
|
+
export declare const combineDailySpend: (series: DailyPoint[][]) => DailyPoint[];
|
package/dist/daily.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Pure per-day spend derivation from a cumulative metric series. Shared by core (derives from a capability's
|
|
2
|
+
// ledger series) and the UI chart — no Electron, no IO.
|
|
3
|
+
import { groupBy, maxBy, sortBy } from 'lodash-es';
|
|
4
|
+
import { DateTime } from 'luxon';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
// A derived per-day spend value. `estimated` flags days inferred across a gap (no snapshot that day).
|
|
7
|
+
export const DailyPointSchema = z.object({ date: z.string(), value: z.number(), estimated: z.boolean().optional() });
|
|
8
|
+
const ym = (date) => date.slice(0, 7);
|
|
9
|
+
// Collapse many captures (across times of day, and across people) to one reading per day: the one with the
|
|
10
|
+
// latest `instant` — the closest reading to that day's end — sorted chronologically. `dayOf` buckets a point
|
|
11
|
+
// to its calendar day; `instant` is the full ISO timestamp that picks the winner and orders the result (ISO
|
|
12
|
+
// timestamps sort chronologically, and one-per-day means instant order equals day order).
|
|
13
|
+
export const latestPerDay = (points, dayOf, instant) => sortBy(Object.values(groupBy(points, dayOf)).map((perDay) => maxBy(perDay, instant)), instant);
|
|
14
|
+
const endOfDayPerDay = (points) => latestPerDay(points, (p) => p.date, (p) => p.capturedAt);
|
|
15
|
+
// Calendar days strictly after `from` up to and including `to`.
|
|
16
|
+
const daysBetween = (from, to) => {
|
|
17
|
+
const out = [];
|
|
18
|
+
const end = DateTime.fromISO(to, { zone: 'utc' });
|
|
19
|
+
for (let d = DateTime.fromISO(from, { zone: 'utc' }).plus({ days: 1 }); d <= end; d = d.plus({ days: 1 })) {
|
|
20
|
+
out.push(d.toISODate());
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
};
|
|
24
|
+
// Derive per-day spend from a cumulative series. A monthly-resetting counter (MTD) attributes a month
|
|
25
|
+
// rollover's new value as-is instead of a negative diff. `resetPeriod` states this explicitly; absent, it
|
|
26
|
+
// falls back to sniffing the 'spend' section. Gaps spread evenly, flagged estimated.
|
|
27
|
+
export const deriveDailySpend = (points, resetPeriod) => {
|
|
28
|
+
const sorted = endOfDayPerDay(points);
|
|
29
|
+
const resetsMonthly = resetPeriod ? resetPeriod === 'monthly' : sorted[0]?.section === 'spend';
|
|
30
|
+
const out = [];
|
|
31
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
32
|
+
const cur = sorted[i];
|
|
33
|
+
const prev = sorted[i - 1];
|
|
34
|
+
if (!prev) {
|
|
35
|
+
// A lone first-of-month point: MTD on day 1 IS that day's spend. Otherwise we can't diff.
|
|
36
|
+
if (resetsMonthly && cur.date.endsWith('-01')) {
|
|
37
|
+
out.push({ date: cur.date, value: cur.value });
|
|
38
|
+
}
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (resetsMonthly && ym(cur.date) !== ym(prev.date)) {
|
|
42
|
+
out.push({ date: cur.date, value: cur.value });
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const gap = daysBetween(prev.date, cur.date);
|
|
46
|
+
const delta = cur.value - prev.value;
|
|
47
|
+
if (gap.length <= 1) {
|
|
48
|
+
out.push({ date: cur.date, value: delta });
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const per = delta / gap.length;
|
|
52
|
+
for (const date of gap) {
|
|
53
|
+
out.push({ date, value: per, estimated: true });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
};
|
|
58
|
+
// Derive a per-day series for each row of a keyed cumulative column: differences that row's reading series
|
|
59
|
+
// independently. The row id keys both the input and the output.
|
|
60
|
+
export const deriveDailyByRow = (readingsByRow, resetPeriod) => {
|
|
61
|
+
const out = {};
|
|
62
|
+
for (const [rowId, points] of Object.entries(readingsByRow)) {
|
|
63
|
+
out[rowId] = deriveDailySpend(points, resetPeriod);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
};
|
|
67
|
+
// Sum several services' per-day series into one combined series, keyed by date. A day is `estimated` in the
|
|
68
|
+
// combined series if ANY contributing service estimated it (the total carries that day's uncertainty).
|
|
69
|
+
export const combineDailySpend = (series) => sortBy(Object.values(groupBy(series.flat(), (p) => p.date)).map((perDate) => ({
|
|
70
|
+
date: perDate[0].date,
|
|
71
|
+
value: perDate.reduce((sum, p) => sum + p.value, 0),
|
|
72
|
+
estimated: perDate.some((p) => p.estimated) || undefined
|
|
73
|
+
})), (p) => p.date);
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const EXPORT_BUNDLE_FORMAT_VERSION = 1;
|
|
3
|
+
export declare const ExportBundleCapabilitySchema: z.ZodObject<{
|
|
4
|
+
id: z.ZodString;
|
|
5
|
+
label: z.ZodString;
|
|
6
|
+
result: z.ZodOptional<z.ZodUnknown>;
|
|
7
|
+
lastRunAt: z.ZodOptional<z.ZodString>;
|
|
8
|
+
error: z.ZodOptional<z.ZodString>;
|
|
9
|
+
}, z.core.$strip>;
|
|
10
|
+
export type ExportBundleCapability = z.infer<typeof ExportBundleCapabilitySchema>;
|
|
11
|
+
export declare const ExportPluginProvenanceSchema: z.ZodObject<{
|
|
12
|
+
contributedBy: z.ZodArray<z.ZodObject<{
|
|
13
|
+
contributorId: z.ZodString;
|
|
14
|
+
label: z.ZodString;
|
|
15
|
+
capturedAt: z.ZodString;
|
|
16
|
+
}, z.core.$strip>>;
|
|
17
|
+
coveredBy: z.ZodNumber;
|
|
18
|
+
}, z.core.$strip>;
|
|
19
|
+
export type ExportPluginProvenance = z.infer<typeof ExportPluginProvenanceSchema>;
|
|
20
|
+
export declare const ExportPluginSchema: z.ZodObject<{
|
|
21
|
+
meta: z.ZodObject<{
|
|
22
|
+
id: z.ZodString;
|
|
23
|
+
name: z.ZodString;
|
|
24
|
+
vendor: z.ZodOptional<z.ZodString>;
|
|
25
|
+
category: z.ZodOptional<z.ZodString>;
|
|
26
|
+
color: z.ZodOptional<z.ZodString>;
|
|
27
|
+
icon: z.ZodOptional<z.ZodString>;
|
|
28
|
+
dashboardUrl: z.ZodOptional<z.ZodString>;
|
|
29
|
+
capabilities: z.ZodArray<z.ZodObject<{
|
|
30
|
+
id: z.ZodString;
|
|
31
|
+
label: z.ZodString;
|
|
32
|
+
}, z.core.$strip>>;
|
|
33
|
+
}, z.core.$strip>;
|
|
34
|
+
capabilities: z.ZodArray<z.ZodObject<{
|
|
35
|
+
id: z.ZodString;
|
|
36
|
+
label: z.ZodString;
|
|
37
|
+
result: z.ZodOptional<z.ZodUnknown>;
|
|
38
|
+
lastRunAt: z.ZodOptional<z.ZodString>;
|
|
39
|
+
error: z.ZodOptional<z.ZodString>;
|
|
40
|
+
}, z.core.$strip>>;
|
|
41
|
+
provenance: z.ZodOptional<z.ZodObject<{
|
|
42
|
+
contributedBy: z.ZodArray<z.ZodObject<{
|
|
43
|
+
contributorId: z.ZodString;
|
|
44
|
+
label: z.ZodString;
|
|
45
|
+
capturedAt: z.ZodString;
|
|
46
|
+
}, z.core.$strip>>;
|
|
47
|
+
coveredBy: z.ZodNumber;
|
|
48
|
+
}, z.core.$strip>>;
|
|
49
|
+
}, z.core.$strip>;
|
|
50
|
+
export type ExportPlugin = z.infer<typeof ExportPluginSchema>;
|
|
51
|
+
export declare const ExportBundleSchema: z.ZodObject<{
|
|
52
|
+
formatVersion: z.ZodLiteral<1>;
|
|
53
|
+
generatedAt: z.ZodString;
|
|
54
|
+
butinVersion: z.ZodOptional<z.ZodString>;
|
|
55
|
+
profileName: z.ZodOptional<z.ZodString>;
|
|
56
|
+
plugins: z.ZodArray<z.ZodObject<{
|
|
57
|
+
meta: z.ZodObject<{
|
|
58
|
+
id: z.ZodString;
|
|
59
|
+
name: z.ZodString;
|
|
60
|
+
vendor: z.ZodOptional<z.ZodString>;
|
|
61
|
+
category: z.ZodOptional<z.ZodString>;
|
|
62
|
+
color: z.ZodOptional<z.ZodString>;
|
|
63
|
+
icon: z.ZodOptional<z.ZodString>;
|
|
64
|
+
dashboardUrl: z.ZodOptional<z.ZodString>;
|
|
65
|
+
capabilities: z.ZodArray<z.ZodObject<{
|
|
66
|
+
id: z.ZodString;
|
|
67
|
+
label: z.ZodString;
|
|
68
|
+
}, z.core.$strip>>;
|
|
69
|
+
}, z.core.$strip>;
|
|
70
|
+
capabilities: z.ZodArray<z.ZodObject<{
|
|
71
|
+
id: z.ZodString;
|
|
72
|
+
label: z.ZodString;
|
|
73
|
+
result: z.ZodOptional<z.ZodUnknown>;
|
|
74
|
+
lastRunAt: z.ZodOptional<z.ZodString>;
|
|
75
|
+
error: z.ZodOptional<z.ZodString>;
|
|
76
|
+
}, z.core.$strip>>;
|
|
77
|
+
provenance: z.ZodOptional<z.ZodObject<{
|
|
78
|
+
contributedBy: z.ZodArray<z.ZodObject<{
|
|
79
|
+
contributorId: z.ZodString;
|
|
80
|
+
label: z.ZodString;
|
|
81
|
+
capturedAt: z.ZodString;
|
|
82
|
+
}, z.core.$strip>>;
|
|
83
|
+
coveredBy: z.ZodNumber;
|
|
84
|
+
}, z.core.$strip>>;
|
|
85
|
+
}, z.core.$strip>>;
|
|
86
|
+
overview: z.ZodArray<z.ZodObject<{
|
|
87
|
+
pluginId: z.ZodString;
|
|
88
|
+
pluginName: z.ZodString;
|
|
89
|
+
color: z.ZodOptional<z.ZodString>;
|
|
90
|
+
icon: z.ZodOptional<z.ZodString>;
|
|
91
|
+
monthly: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
92
|
+
month: z.ZodString;
|
|
93
|
+
amount: z.ZodNumber;
|
|
94
|
+
}, z.core.$strip>>>;
|
|
95
|
+
daily: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
96
|
+
date: z.ZodString;
|
|
97
|
+
value: z.ZodNumber;
|
|
98
|
+
estimated: z.ZodOptional<z.ZodBoolean>;
|
|
99
|
+
}, z.core.$strip>>>;
|
|
100
|
+
currentMonthAccrual: z.ZodOptional<z.ZodNumber>;
|
|
101
|
+
currency: z.ZodOptional<z.ZodString>;
|
|
102
|
+
balance: z.ZodOptional<z.ZodNumber>;
|
|
103
|
+
itemCount: z.ZodOptional<z.ZodNumber>;
|
|
104
|
+
lastRunAt: z.ZodOptional<z.ZodString>;
|
|
105
|
+
state: z.ZodOptional<z.ZodEnum<{
|
|
106
|
+
connected: "connected";
|
|
107
|
+
disconnected: "disconnected";
|
|
108
|
+
}>>;
|
|
109
|
+
summaries: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
110
|
+
section: z.ZodEnum<{
|
|
111
|
+
balance: "balance";
|
|
112
|
+
other: "other";
|
|
113
|
+
spend: "spend";
|
|
114
|
+
}>;
|
|
115
|
+
label: z.ZodString;
|
|
116
|
+
value: z.ZodNumber;
|
|
117
|
+
role: z.ZodEnum<{
|
|
118
|
+
count: "count";
|
|
119
|
+
money: "money";
|
|
120
|
+
percent: "percent";
|
|
121
|
+
}>;
|
|
122
|
+
currency: z.ZodOptional<z.ZodString>;
|
|
123
|
+
basis: z.ZodOptional<z.ZodEnum<{
|
|
124
|
+
accrued: "accrued";
|
|
125
|
+
flat: "flat";
|
|
126
|
+
invoiced: "invoiced";
|
|
127
|
+
lastInvoice: "lastInvoice";
|
|
128
|
+
upcoming: "upcoming";
|
|
129
|
+
}>>;
|
|
130
|
+
spark: z.ZodOptional<z.ZodObject<{
|
|
131
|
+
dataset: z.ZodString;
|
|
132
|
+
x: z.ZodString;
|
|
133
|
+
y: z.ZodString;
|
|
134
|
+
}, z.core.$strip>>;
|
|
135
|
+
headline: z.ZodOptional<z.ZodBoolean>;
|
|
136
|
+
}, z.core.$strip>>>;
|
|
137
|
+
}, z.core.$strip>>;
|
|
138
|
+
}, z.core.$strip>;
|
|
139
|
+
export type ExportBundle = z.infer<typeof ExportBundleSchema>;
|
|
140
|
+
export type ParseBundleResult = {
|
|
141
|
+
ok: true;
|
|
142
|
+
bundle: ExportBundle;
|
|
143
|
+
} | {
|
|
144
|
+
ok: false;
|
|
145
|
+
errors: string[];
|
|
146
|
+
};
|
|
147
|
+
export declare const parseExportBundle: (raw: unknown) => ParseBundleResult;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { OverviewPluginSchema } from './overview.js';
|
|
3
|
+
// The portable export artifact — Butin's normalized data lifted out as ONE self-contained JSON blob, so a
|
|
4
|
+
// cloud page can render it through @butinapp/ui with zero recompute. Producer = core (buildExportBundle);
|
|
5
|
+
// consumer = @butinapp/viewer.
|
|
6
|
+
//
|
|
7
|
+
// Versioned + zod-validated: the bundle is the most boundary-crossing object in the system — produced by one
|
|
8
|
+
// version of Butin, possibly merged by a gateway, consumed by a separately-deployed viewer. A consumer runs
|
|
9
|
+
// `parseExportBundle` to gate on `formatVersion` (refusing a bundle from a NEWER Butin) and to reject a
|
|
10
|
+
// malformed blob rather than mis-rendering it. Bump the version only on a breaking shape change.
|
|
11
|
+
export const EXPORT_BUNDLE_FORMAT_VERSION = 1;
|
|
12
|
+
// One capability's cached state at export time. `result` is the stored CapabilityResult (datasets/views/
|
|
13
|
+
// summary) — whatever the generic renderer received live. `error` carries a failed last run so the viewer
|
|
14
|
+
// can surface it instead of an empty tab.
|
|
15
|
+
export const ExportBundleCapabilitySchema = z.object({
|
|
16
|
+
id: z.string(),
|
|
17
|
+
label: z.string(),
|
|
18
|
+
result: z.unknown().optional(),
|
|
19
|
+
lastRunAt: z.string().optional(),
|
|
20
|
+
error: z.string().optional()
|
|
21
|
+
});
|
|
22
|
+
// Who contributed a service in a MERGED bundle (produced by @butinapp/gateway). Additive + optional, so it is
|
|
23
|
+
// formatVersion-safe: the single-instance export omits it and the current viewer ignores it.
|
|
24
|
+
export const ExportPluginProvenanceSchema = z.object({
|
|
25
|
+
contributedBy: z.array(z.object({ contributorId: z.string(), label: z.string(), capturedAt: z.string() })),
|
|
26
|
+
coveredBy: z.number()
|
|
27
|
+
});
|
|
28
|
+
// One service's identity + its capabilities' cached data. `meta.icon` (a self-contained data-URI) is
|
|
29
|
+
// optional — the viewer renders a brand-colored letter monogram from `name` + `color` when it's absent.
|
|
30
|
+
export const ExportPluginSchema = z.object({
|
|
31
|
+
meta: z.object({
|
|
32
|
+
id: z.string(),
|
|
33
|
+
name: z.string(),
|
|
34
|
+
vendor: z.string().optional(),
|
|
35
|
+
category: z.string().optional(),
|
|
36
|
+
color: z.string().optional(),
|
|
37
|
+
icon: z.string().optional(),
|
|
38
|
+
dashboardUrl: z.string().optional(),
|
|
39
|
+
capabilities: z.array(z.object({ id: z.string(), label: z.string() }))
|
|
40
|
+
}),
|
|
41
|
+
capabilities: z.array(ExportBundleCapabilitySchema),
|
|
42
|
+
// Present only on a gateway-merged bundle; absent on a single-instance export.
|
|
43
|
+
provenance: ExportPluginProvenanceSchema.optional()
|
|
44
|
+
});
|
|
45
|
+
export const ExportBundleSchema = z.object({
|
|
46
|
+
formatVersion: z.literal(EXPORT_BUNDLE_FORMAT_VERSION),
|
|
47
|
+
generatedAt: z.string(), // ISO
|
|
48
|
+
butinVersion: z.string().optional(),
|
|
49
|
+
profileName: z.string().optional(), // display-only label
|
|
50
|
+
plugins: z.array(ExportPluginSchema),
|
|
51
|
+
// The exact input @butinapp/ui's <Overview> consumes — precomputed by the producer so the viewer is a pure
|
|
52
|
+
// renderer ("the cloud is dumb on purpose").
|
|
53
|
+
overview: z.array(OverviewPluginSchema)
|
|
54
|
+
});
|
|
55
|
+
export const parseExportBundle = (raw) => {
|
|
56
|
+
const version = raw?.formatVersion;
|
|
57
|
+
if (typeof version === 'number' && version > EXPORT_BUNDLE_FORMAT_VERSION) {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
errors: [`bundle formatVersion ${version} is newer than this viewer supports (${EXPORT_BUNDLE_FORMAT_VERSION})`]
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const parsed = ExportBundleSchema.safeParse(raw);
|
|
64
|
+
if (!parsed.success) {
|
|
65
|
+
return { ok: false, errors: parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`) };
|
|
66
|
+
}
|
|
67
|
+
return { ok: true, bundle: parsed.data };
|
|
68
|
+
};
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// @butinapp/shapes — the host-layer data shapes the app + viewer share, built ON TOP of the SDK's data-view
|
|
2
|
+
// contract (@butinapp/sdk/data): the cross-service Overview input, the persisted ledger, the presentation
|
|
3
|
+
// manifest, and per-day spend derivation. NOT the plugin-authoring contract (that's @butinapp/sdk) and NOT
|
|
4
|
+
// the portable export wire format (that's @butinapp/shapes/bundle, which the out-of-repo viewer consumes).
|
|
5
|
+
// Producer = core; consumers = core, @butinapp/ui, and the embed viewer.
|
|
6
|
+
export * from './overview.js';
|
|
7
|
+
export * from './ledger.js';
|
|
8
|
+
export * from './manifest.js';
|
|
9
|
+
export * from './daily.js';
|
package/dist/ledger.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { SemanticRole, Section } from '@butinapp/sdk/data';
|
|
2
|
+
import type { MtdBasis } from '@butinapp/sdk/presets';
|
|
3
|
+
export declare const SCHEMA_VERSION = 1;
|
|
4
|
+
export interface StoredColumn {
|
|
5
|
+
key: string;
|
|
6
|
+
role: SemanticRole;
|
|
7
|
+
currency?: string;
|
|
8
|
+
accrual?: 'cumulative' | 'incremental';
|
|
9
|
+
resetPeriod?: 'monthly' | 'none';
|
|
10
|
+
}
|
|
11
|
+
export interface StoredDataset {
|
|
12
|
+
id: string;
|
|
13
|
+
shape: 'table' | 'record';
|
|
14
|
+
columns: StoredColumn[];
|
|
15
|
+
rows: Record<string, unknown>[];
|
|
16
|
+
key?: string | string[];
|
|
17
|
+
}
|
|
18
|
+
export interface StoredSummary {
|
|
19
|
+
section: Section;
|
|
20
|
+
value: number;
|
|
21
|
+
role: 'money' | 'count' | 'percent';
|
|
22
|
+
currency?: string;
|
|
23
|
+
basis?: MtdBasis;
|
|
24
|
+
}
|
|
25
|
+
export interface RowVersion {
|
|
26
|
+
from: string;
|
|
27
|
+
to?: string;
|
|
28
|
+
data: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
export interface LedgerRow {
|
|
31
|
+
id: string;
|
|
32
|
+
firstSeen: string;
|
|
33
|
+
seenTo: string;
|
|
34
|
+
versions: RowVersion[];
|
|
35
|
+
}
|
|
36
|
+
export interface DatasetLog {
|
|
37
|
+
id: string;
|
|
38
|
+
key?: string | string[];
|
|
39
|
+
columns: StoredColumn[];
|
|
40
|
+
rows: LedgerRow[];
|
|
41
|
+
}
|
|
42
|
+
export interface SectionPoint {
|
|
43
|
+
capturedAt: string;
|
|
44
|
+
value: number;
|
|
45
|
+
currency?: string;
|
|
46
|
+
}
|
|
47
|
+
export interface SectionSeries {
|
|
48
|
+
section: Section;
|
|
49
|
+
points: SectionPoint[];
|
|
50
|
+
}
|
|
51
|
+
export interface Ledger {
|
|
52
|
+
schemaVersion: number;
|
|
53
|
+
datasets: DatasetLog[];
|
|
54
|
+
series: SectionSeries[];
|
|
55
|
+
}
|
package/dist/ledger.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { BadgeTone, CapabilityResult, View } from '@butinapp/sdk/data';
|
|
2
|
+
import type { StoredDataset, StoredSummary } from './ledger.js';
|
|
3
|
+
export interface ColumnPresentation {
|
|
4
|
+
label?: string;
|
|
5
|
+
badges?: Record<string, BadgeTone>;
|
|
6
|
+
hidden?: boolean;
|
|
7
|
+
truncate?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface SummaryPresentation {
|
|
10
|
+
label?: string;
|
|
11
|
+
spark?: {
|
|
12
|
+
dataset: string;
|
|
13
|
+
x: string;
|
|
14
|
+
y: string;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export interface PresentationManifest {
|
|
18
|
+
views: View[];
|
|
19
|
+
columns?: Record<string, Record<string, ColumnPresentation>>;
|
|
20
|
+
summaries?: Record<string, SummaryPresentation>;
|
|
21
|
+
}
|
|
22
|
+
export declare const splitResult: (result: CapabilityResult) => {
|
|
23
|
+
datasets: StoredDataset[];
|
|
24
|
+
summaries: StoredSummary[];
|
|
25
|
+
manifest: PresentationManifest;
|
|
26
|
+
};
|
|
27
|
+
export declare const toDisplayResult: (datasets: StoredDataset[], summaries: StoredSummary[], manifest: PresentationManifest) => CapabilityResult;
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { omitUndef } from '@butinapp/sdk/util';
|
|
2
|
+
const storedColumn = (c) => omitUndef({ key: c.key, role: c.role, currency: c.currency, accrual: c.accrual, resetPeriod: c.resetPeriod });
|
|
3
|
+
const columnPresentation = (c) => {
|
|
4
|
+
const p = omitUndef({ label: c.label, badges: c.badges, hidden: c.hidden, truncate: c.truncate });
|
|
5
|
+
return Object.keys(p).length > 0 ? p : undefined;
|
|
6
|
+
};
|
|
7
|
+
const storedDataset = (ds) => {
|
|
8
|
+
if (ds.shape === 'table') {
|
|
9
|
+
const out = { id: ds.id, shape: 'table', columns: ds.columns.map(storedColumn), rows: ds.rows };
|
|
10
|
+
if (ds.key !== undefined) {
|
|
11
|
+
out.key = ds.key;
|
|
12
|
+
}
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
return { id: ds.id, shape: 'record', columns: ds.fields.map(storedColumn), rows: [ds.value] };
|
|
16
|
+
};
|
|
17
|
+
const storedSummary = (s) => omitUndef({ section: s.section, value: s.value, role: s.role, currency: s.currency, basis: s.basis });
|
|
18
|
+
// Separate a CapabilityResult into the data-first records + summaries that land in the ledger, and the
|
|
19
|
+
// presentation manifest that's re-derived at render. Pure.
|
|
20
|
+
export const splitResult = (result) => {
|
|
21
|
+
const columns = {};
|
|
22
|
+
for (const ds of result.datasets) {
|
|
23
|
+
const cols = ds.shape === 'table' ? ds.columns : ds.fields;
|
|
24
|
+
const byKey = {};
|
|
25
|
+
for (const c of cols) {
|
|
26
|
+
const p = columnPresentation(c);
|
|
27
|
+
if (p) {
|
|
28
|
+
byKey[c.key] = p;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (Object.keys(byKey).length > 0) {
|
|
32
|
+
columns[ds.id] = byKey;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const summaryMap = {};
|
|
36
|
+
for (const s of result.summaries ?? []) {
|
|
37
|
+
summaryMap[s.section] = omitUndef({ label: s.label, spark: s.spark });
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
datasets: result.datasets.map(storedDataset),
|
|
41
|
+
summaries: (result.summaries ?? []).map(storedSummary),
|
|
42
|
+
manifest: omitUndef({
|
|
43
|
+
views: result.views ?? [],
|
|
44
|
+
columns: Object.keys(columns).length > 0 ? columns : undefined,
|
|
45
|
+
summaries: Object.keys(summaryMap).length > 0 ? summaryMap : undefined
|
|
46
|
+
})
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
const displayColumn = (c, pres) => omitUndef({
|
|
50
|
+
key: c.key,
|
|
51
|
+
label: pres?.label ?? c.key,
|
|
52
|
+
role: c.role,
|
|
53
|
+
currency: c.currency,
|
|
54
|
+
accrual: c.accrual,
|
|
55
|
+
resetPeriod: c.resetPeriod,
|
|
56
|
+
badges: pres?.badges,
|
|
57
|
+
hidden: pres?.hidden,
|
|
58
|
+
truncate: pres?.truncate
|
|
59
|
+
});
|
|
60
|
+
// Rebuild a full CapabilityResult from the stored data-first records + the presentation manifest. The inverse
|
|
61
|
+
// of splitResult: column labels/badges/hidden and summary label/spark come back from the manifest; views are
|
|
62
|
+
// the manifest's. A column with no manifest entry falls back to its key as the label.
|
|
63
|
+
export const toDisplayResult = (datasets, summaries, manifest) => {
|
|
64
|
+
const displayDatasets = datasets.map((ds) => {
|
|
65
|
+
const pres = manifest.columns?.[ds.id] ?? {};
|
|
66
|
+
const cols = ds.columns.map((c) => displayColumn(c, pres[c.key]));
|
|
67
|
+
if (ds.shape === 'table') {
|
|
68
|
+
const t = {
|
|
69
|
+
id: ds.id,
|
|
70
|
+
shape: 'table',
|
|
71
|
+
columns: cols,
|
|
72
|
+
rows: ds.rows,
|
|
73
|
+
...(ds.key !== undefined ? { key: ds.key } : {})
|
|
74
|
+
};
|
|
75
|
+
return t;
|
|
76
|
+
}
|
|
77
|
+
const r = { id: ds.id, shape: 'record', fields: cols, value: ds.rows[0] ?? {} };
|
|
78
|
+
return r;
|
|
79
|
+
});
|
|
80
|
+
const displaySummaries = summaries.map((s) => {
|
|
81
|
+
const pres = manifest.summaries?.[s.section];
|
|
82
|
+
return omitUndef({
|
|
83
|
+
section: s.section,
|
|
84
|
+
label: pres?.label ?? s.section,
|
|
85
|
+
value: s.value,
|
|
86
|
+
role: s.role,
|
|
87
|
+
currency: s.currency,
|
|
88
|
+
basis: s.basis,
|
|
89
|
+
spark: pres?.spark
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
return omitUndef({
|
|
93
|
+
datasets: displayDatasets,
|
|
94
|
+
views: manifest.views,
|
|
95
|
+
summaries: displaySummaries.length > 0 ? displaySummaries : undefined
|
|
96
|
+
});
|
|
97
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const OverviewPluginSchema: z.ZodObject<{
|
|
3
|
+
pluginId: z.ZodString;
|
|
4
|
+
pluginName: z.ZodString;
|
|
5
|
+
color: z.ZodOptional<z.ZodString>;
|
|
6
|
+
icon: z.ZodOptional<z.ZodString>;
|
|
7
|
+
monthly: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
8
|
+
month: z.ZodString;
|
|
9
|
+
amount: z.ZodNumber;
|
|
10
|
+
}, z.core.$strip>>>;
|
|
11
|
+
daily: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
12
|
+
date: z.ZodString;
|
|
13
|
+
value: z.ZodNumber;
|
|
14
|
+
estimated: z.ZodOptional<z.ZodBoolean>;
|
|
15
|
+
}, z.core.$strip>>>;
|
|
16
|
+
currentMonthAccrual: z.ZodOptional<z.ZodNumber>;
|
|
17
|
+
currency: z.ZodOptional<z.ZodString>;
|
|
18
|
+
balance: z.ZodOptional<z.ZodNumber>;
|
|
19
|
+
itemCount: z.ZodOptional<z.ZodNumber>;
|
|
20
|
+
lastRunAt: z.ZodOptional<z.ZodString>;
|
|
21
|
+
state: z.ZodOptional<z.ZodEnum<{
|
|
22
|
+
connected: "connected";
|
|
23
|
+
disconnected: "disconnected";
|
|
24
|
+
}>>;
|
|
25
|
+
summaries: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
26
|
+
section: z.ZodEnum<{
|
|
27
|
+
balance: "balance";
|
|
28
|
+
other: "other";
|
|
29
|
+
spend: "spend";
|
|
30
|
+
}>;
|
|
31
|
+
label: z.ZodString;
|
|
32
|
+
value: z.ZodNumber;
|
|
33
|
+
role: z.ZodEnum<{
|
|
34
|
+
count: "count";
|
|
35
|
+
money: "money";
|
|
36
|
+
percent: "percent";
|
|
37
|
+
}>;
|
|
38
|
+
currency: z.ZodOptional<z.ZodString>;
|
|
39
|
+
basis: z.ZodOptional<z.ZodEnum<{
|
|
40
|
+
accrued: "accrued";
|
|
41
|
+
flat: "flat";
|
|
42
|
+
invoiced: "invoiced";
|
|
43
|
+
lastInvoice: "lastInvoice";
|
|
44
|
+
upcoming: "upcoming";
|
|
45
|
+
}>>;
|
|
46
|
+
spark: z.ZodOptional<z.ZodObject<{
|
|
47
|
+
dataset: z.ZodString;
|
|
48
|
+
x: z.ZodString;
|
|
49
|
+
y: z.ZodString;
|
|
50
|
+
}, z.core.$strip>>;
|
|
51
|
+
headline: z.ZodOptional<z.ZodBoolean>;
|
|
52
|
+
}, z.core.$strip>>>;
|
|
53
|
+
}, z.core.$strip>;
|
|
54
|
+
export type OverviewPlugin = z.infer<typeof OverviewPluginSchema>;
|
package/dist/overview.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { MonthPointSchema, SummarySchema } from '@butinapp/sdk/schema';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { DailyPointSchema } from './daily.js';
|
|
4
|
+
// The pure INPUT view-model for the cross-service Overview. Lives here (not in @butinapp/ui) so both the
|
|
5
|
+
// producer (core's overview assembler / the export bundle) and the consumer (@butinapp/ui's derivation, the
|
|
6
|
+
// embed viewer) share one type. @butinapp/ui re-exports it and owns the DERIVATION (combinedMonthly, movers,
|
|
7
|
+
// byServiceRows, …) — this module is types only, no Electron, no React. Schema-defined so the export-bundle
|
|
8
|
+
// validator can reuse it at the cross-version boundary.
|
|
9
|
+
export const OverviewPluginSchema = z.object({
|
|
10
|
+
pluginId: z.string(),
|
|
11
|
+
pluginName: z.string(),
|
|
12
|
+
color: z.string().optional(),
|
|
13
|
+
icon: z.string().optional(),
|
|
14
|
+
monthly: z.array(MonthPointSchema).optional(),
|
|
15
|
+
// Pre-derived per-day spend (from this plugin's billing capture series), summed across services for the
|
|
16
|
+
// combined chart's Daily mode.
|
|
17
|
+
daily: z.array(DailyPointSchema).optional(),
|
|
18
|
+
// Peak spend reading captured this (reporting-zone) month — the rollup's fallback THIS MO for an arrears
|
|
19
|
+
// service whose just-closed month has no invoice bar yet and whose live open-period figure has reset.
|
|
20
|
+
currentMonthAccrual: z.number().optional(),
|
|
21
|
+
// The currency this plugin's monthly/daily spend series + balance are denominated in (its reportingCurrency).
|
|
22
|
+
// Optional: a never-fetched / non-money plugin omits it. The Overview derivation converts from it to baseCurrency.
|
|
23
|
+
currency: z.string().optional(),
|
|
24
|
+
// Partition inputs: a service appears in every section its summaries report; balance feeds the Balances
|
|
25
|
+
// rollup; itemCount + lastRunAt + state feed the Activity tiles. All optional — a never-fetched plugin
|
|
26
|
+
// still gets a tile.
|
|
27
|
+
balance: z.number().optional(),
|
|
28
|
+
itemCount: z.number().optional(),
|
|
29
|
+
lastRunAt: z.string().optional(),
|
|
30
|
+
state: z.enum(['connected', 'disconnected']).optional(),
|
|
31
|
+
// Every summary this service reports (deduped by section, first-wins). Drives the section partition + the
|
|
32
|
+
// cross-service rollup so a bundle-fed Overview (via @butinapp/viewer) sees every section a service emits.
|
|
33
|
+
// Absent when a tile has no summaries.
|
|
34
|
+
summaries: z.array(SummarySchema).optional()
|
|
35
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ConfigFieldCondition } from '@butinapp/sdk';
|
|
2
|
+
export type CapabilityView = {
|
|
3
|
+
id: string;
|
|
4
|
+
label: string;
|
|
5
|
+
incremental?: boolean;
|
|
6
|
+
};
|
|
7
|
+
export type ConfigFieldView = {
|
|
8
|
+
key: string;
|
|
9
|
+
label: string;
|
|
10
|
+
kind: 'text' | 'secret' | 'select' | 'combobox';
|
|
11
|
+
required?: boolean;
|
|
12
|
+
placeholder?: string;
|
|
13
|
+
help?: string;
|
|
14
|
+
options?: Array<{
|
|
15
|
+
value: string;
|
|
16
|
+
label: string;
|
|
17
|
+
}>;
|
|
18
|
+
showWhen?: ConfigFieldCondition;
|
|
19
|
+
};
|
|
20
|
+
export type PluginView = {
|
|
21
|
+
id: string;
|
|
22
|
+
name: string;
|
|
23
|
+
vendor?: string;
|
|
24
|
+
version?: string;
|
|
25
|
+
description?: string;
|
|
26
|
+
category?: string;
|
|
27
|
+
color?: string;
|
|
28
|
+
icon?: string;
|
|
29
|
+
dashboardUrl?: string;
|
|
30
|
+
hasCookie: boolean;
|
|
31
|
+
connected: boolean;
|
|
32
|
+
sessionless: boolean;
|
|
33
|
+
installed?: boolean;
|
|
34
|
+
onboardedAt?: number;
|
|
35
|
+
troubleshooting?: Partial<Record<string, {
|
|
36
|
+
hint: string;
|
|
37
|
+
docUrl?: string;
|
|
38
|
+
}>>;
|
|
39
|
+
configFields: ConfigFieldView[];
|
|
40
|
+
config: Record<string, string>;
|
|
41
|
+
capabilities: CapabilityView[];
|
|
42
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// The plugin view-model the renderer draws — the ONE definition core's IPC DTO and @butinapp/ui both build
|
|
2
|
+
// on, so they can't drift. Lives here (the neutral host-shapes package, below both core and ui) rather than
|
|
3
|
+
// in either consumer: core's `PluginSummary` is `PluginView & { …transport-only extras }` and @butinapp/ui
|
|
4
|
+
// re-exports these for embedders, so an embed feeds the same components from a published snapshot and the
|
|
5
|
+
// Electron app from live IPC. No Electron, no React, no IPC — types only. The config-field shapes reuse the
|
|
6
|
+
// SDK's `ConfigFieldCondition` so the settings form and the SDK agree by construction.
|
|
7
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@butinapp/shapes",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Butin's host-layer data shapes built on the @butinapp/sdk data-view contract: the cross-service Overview input, the persisted ledger, the presentation manifest, and the portable export-bundle wire format.",
|
|
5
|
+
"homepage": "https://butin.app",
|
|
6
|
+
"bugs": "https://github.com/butinapp/butin/issues",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/butinapp/butin.git",
|
|
11
|
+
"directory": "packages/shapes"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"LICENSE",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"type": "module",
|
|
19
|
+
"sideEffects": false,
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"import": "./dist/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./bundle": {
|
|
27
|
+
"types": "./dist/bundle.d.ts",
|
|
28
|
+
"import": "./dist/bundle.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@types/lodash-es": "^4.17.12",
|
|
36
|
+
"@types/luxon": "^3.7.1",
|
|
37
|
+
"lodash-es": "^4.18.1",
|
|
38
|
+
"luxon": "^3.7.2",
|
|
39
|
+
"zod": "^4.3.6",
|
|
40
|
+
"@butinapp/sdk": "0.1.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"vitest": "^4.1.7"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsgo -p tsconfig.build.json",
|
|
47
|
+
"typecheck": "tsgo --noEmit -p tsconfig.json",
|
|
48
|
+
"test": "vitest run"
|
|
49
|
+
}
|
|
50
|
+
}
|