@butinapp/ui 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.
Files changed (112) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +21 -0
  3. package/dist/components/badge.d.ts +7 -0
  4. package/dist/components/badge.js +34 -0
  5. package/dist/components/button.d.ts +11 -0
  6. package/dist/components/button.js +30 -0
  7. package/dist/components/card.d.ts +8 -0
  8. package/dist/components/card.js +10 -0
  9. package/dist/components/checkbox.d.ts +3 -0
  10. package/dist/components/checkbox.js +7 -0
  11. package/dist/components/combobox.d.ts +14 -0
  12. package/dist/components/combobox.js +53 -0
  13. package/dist/components/data-table-model.d.ts +40 -0
  14. package/dist/components/data-table-model.js +105 -0
  15. package/dist/components/data-table.d.ts +33 -0
  16. package/dist/components/data-table.js +252 -0
  17. package/dist/components/dialog.d.ts +11 -0
  18. package/dist/components/dialog.js +15 -0
  19. package/dist/components/dropdown-menu.d.ts +7 -0
  20. package/dist/components/dropdown-menu.js +15 -0
  21. package/dist/components/echart.d.ts +5 -0
  22. package/dist/components/echart.js +27 -0
  23. package/dist/components/input.d.ts +3 -0
  24. package/dist/components/input.js +5 -0
  25. package/dist/components/label.d.ts +3 -0
  26. package/dist/components/label.js +5 -0
  27. package/dist/components/progress.d.ts +4 -0
  28. package/dist/components/progress.js +8 -0
  29. package/dist/components/select.d.ts +11 -0
  30. package/dist/components/select.js +5 -0
  31. package/dist/components/separator.d.ts +3 -0
  32. package/dist/components/separator.js +5 -0
  33. package/dist/components/service-icon.d.ts +19 -0
  34. package/dist/components/service-icon.js +49 -0
  35. package/dist/components/sheet.d.ts +10 -0
  36. package/dist/components/sheet.js +11 -0
  37. package/dist/components/skeleton.d.ts +2 -0
  38. package/dist/components/skeleton.js +4 -0
  39. package/dist/components/sparkline.d.ts +7 -0
  40. package/dist/components/sparkline.js +22 -0
  41. package/dist/components/switch.d.ts +3 -0
  42. package/dist/components/switch.js +5 -0
  43. package/dist/components/tabs.d.ts +8 -0
  44. package/dist/components/tabs.js +15 -0
  45. package/dist/components/tooltip.d.ts +6 -0
  46. package/dist/components/tooltip.js +8 -0
  47. package/dist/components/truncated-cell.d.ts +3 -0
  48. package/dist/components/truncated-cell.js +22 -0
  49. package/dist/dashboard.d.ts +12 -0
  50. package/dist/dashboard.js +13 -0
  51. package/dist/features/charts.d.ts +7 -0
  52. package/dist/features/charts.js +4 -0
  53. package/dist/features/dashboard/dashboard-renderer.d.ts +32 -0
  54. package/dist/features/dashboard/dashboard-renderer.js +322 -0
  55. package/dist/features/dashboard/format-role.d.ts +2 -0
  56. package/dist/features/dashboard/format-role.js +26 -0
  57. package/dist/features/dashboard/overview-model.d.ts +59 -0
  58. package/dist/features/dashboard/overview-model.js +170 -0
  59. package/dist/features/dashboard/overview.d.ts +8 -0
  60. package/dist/features/dashboard/overview.js +136 -0
  61. package/dist/features/dashboard/plan-views.d.ts +7 -0
  62. package/dist/features/dashboard/plan-views.js +29 -0
  63. package/dist/features/dashboard/rollup.d.ts +31 -0
  64. package/dist/features/dashboard/rollup.js +45 -0
  65. package/dist/features/dashboard/timeseries-chart.d.ts +11 -0
  66. package/dist/features/dashboard/timeseries-chart.js +191 -0
  67. package/dist/features/dashboard/view-models.d.ts +72 -0
  68. package/dist/features/dashboard/view-models.js +291 -0
  69. package/dist/features/documents/doc-models.d.ts +1 -0
  70. package/dist/features/documents/doc-models.js +17 -0
  71. package/dist/features/documents/open-file-button.d.ts +6 -0
  72. package/dist/features/documents/open-file-button.js +16 -0
  73. package/dist/features/shell/app-shell.d.ts +13 -0
  74. package/dist/features/shell/app-shell.js +22 -0
  75. package/dist/features/shell/conn-state.d.ts +10 -0
  76. package/dist/features/shell/conn-state.js +29 -0
  77. package/dist/features/shell/service-tabs.d.ts +13 -0
  78. package/dist/features/shell/service-tabs.js +20 -0
  79. package/dist/features/shell/sidebar.d.ts +31 -0
  80. package/dist/features/shell/sidebar.js +31 -0
  81. package/dist/features/shell/theme-toggle.d.ts +5 -0
  82. package/dist/features/shell/theme-toggle.js +12 -0
  83. package/dist/i18n/context.d.ts +16 -0
  84. package/dist/i18n/context.js +19 -0
  85. package/dist/i18n/format-context.d.ts +7 -0
  86. package/dist/i18n/format-context.js +6 -0
  87. package/dist/i18n/format.d.ts +10 -0
  88. package/dist/i18n/format.js +31 -0
  89. package/dist/i18n/index.d.ts +9 -0
  90. package/dist/i18n/index.js +8 -0
  91. package/dist/i18n/labels.d.ts +400 -0
  92. package/dist/i18n/labels.js +828 -0
  93. package/dist/i18n/plugin-text.d.ts +4 -0
  94. package/dist/i18n/plugin-text.js +150 -0
  95. package/dist/index.d.ts +6 -0
  96. package/dist/index.js +15 -0
  97. package/dist/lib/echarts-theme.d.ts +38 -0
  98. package/dist/lib/echarts-theme.js +59 -0
  99. package/dist/lib/format.d.ts +4 -0
  100. package/dist/lib/format.js +18 -0
  101. package/dist/lib/monthly-buckets.d.ts +9 -0
  102. package/dist/lib/monthly-buckets.js +32 -0
  103. package/dist/lib/utils.d.ts +2 -0
  104. package/dist/lib/utils.js +3 -0
  105. package/dist/primitives.d.ts +28 -0
  106. package/dist/primitives.js +26 -0
  107. package/dist/shell.d.ts +8 -0
  108. package/dist/shell.js +10 -0
  109. package/dist/theme.css +137 -0
  110. package/dist/types.d.ts +11 -0
  111. package/dist/types.js +6 -0
  112. package/package.json +78 -0
@@ -0,0 +1,191 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { MONTH_ABBR } from '@butinapp/sdk/util';
3
+ import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
4
+ import { useMemo, useState } from 'react';
5
+ import { Card, CardContent, CardHeader, CardTitle } from '../../components/card.js';
6
+ import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger } from '../../components/dropdown-menu.js';
7
+ import { EChart } from '../../components/echart.js';
8
+ import { useLabels } from '../../i18n/context.js';
9
+ import { grid, useEchartsTheme } from '../../lib/echarts-theme.js';
10
+ import { formatMoney, formatMoneyCompact } from '../../lib/format.js';
11
+ import {} from './view-models.js';
12
+ const monthLabel = (key) => {
13
+ const [y, m] = key.split('-');
14
+ return `${MONTH_ABBR[Number(m) - 1] ?? m} '${(y ?? '').slice(2)}`;
15
+ };
16
+ // 'YYYY-MM-DD' -> a short day axis label, e.g. 'Jun 3'.
17
+ const dayLabel = (key) => {
18
+ const [, m, d] = key.split('-');
19
+ return `${MONTH_ABBR[Number(m) - 1] ?? m} ${Number(d)}`;
20
+ };
21
+ const monthOf = (key) => key.slice(0, 7);
22
+ const nowMonthKey = () => {
23
+ const d = new Date();
24
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
25
+ };
26
+ // A muted, hatched bar marks an in-progress / gap-filled value (a synthesized day, or the current month
27
+ // projected from its MTD) so it reads distinctly from a settled bar.
28
+ const estimatedBar = (color) => ({
29
+ color,
30
+ opacity: 0.4,
31
+ decal: {
32
+ symbol: 'line',
33
+ color: 'rgba(255,255,255,0.5)',
34
+ dashArrayX: [1, 4],
35
+ dashArrayY: [2, 2],
36
+ rotation: -Math.PI / 4
37
+ }
38
+ });
39
+ // Distinct categories of a point set in first-seen order; [''] for a single (categoryless) series.
40
+ const categoriesOf = (points) => {
41
+ const seen = [];
42
+ for (const p of points) {
43
+ const c = p.category ?? '';
44
+ if (!seen.includes(c)) {
45
+ seen.push(c);
46
+ }
47
+ }
48
+ return seen.length > 0 ? seen : [''];
49
+ };
50
+ // Build the bar option for points already filtered to the visible window. One bar series per category
51
+ // (stacked, with a legend) or a single series; per-point itemStyle carries the estimate hatch and the
52
+ // single-series current-month highlight. Money formatting is gated on `isMoney`.
53
+ const buildBarOption = (points, labelFn, opts) => {
54
+ const { stacked, isMoney, currency, theme, highlightKey } = opts;
55
+ const { LABEL, AXIS_LINE, TOOLTIP, ACCENT, PALETTE, LEGEND_TEXT } = theme;
56
+ const fmtFull = (v) => (isMoney ? formatMoney(v, currency) : String(v));
57
+ const fmtCompact = (v) => (isMoney ? formatMoneyCompact(v, currency) : String(v));
58
+ const axis = [...new Set(points.map((p) => p.key))].sort();
59
+ const cats = categoriesOf(points);
60
+ const cell = new Map();
61
+ for (const p of points) {
62
+ cell.set(`${p.category ?? ''}|${p.key}`, p);
63
+ }
64
+ return {
65
+ tooltip: {
66
+ ...TOOLTIP,
67
+ formatter: (params) => {
68
+ const arr = Array.isArray(params) ? params : [params];
69
+ const first = arr[0];
70
+ const head = first?.axisValueLabel ?? first?.name ?? '';
71
+ const lines = arr
72
+ .map((p) => {
73
+ const row = p;
74
+ const v = typeof row.value === 'number' ? row.value : 0;
75
+ const name = stacked ? `${row.marker ?? ''}${row.seriesName} ` : '';
76
+ return `${name}<b>${fmtFull(v)}</b>`;
77
+ })
78
+ .join('<br/>');
79
+ return `${head}<br/>${lines}`;
80
+ }
81
+ },
82
+ legend: stacked
83
+ ? { data: cats, textStyle: { color: LEGEND_TEXT }, itemWidth: 10, itemHeight: 10, top: 0 }
84
+ : undefined,
85
+ grid: grid(stacked ? 28 : 16),
86
+ xAxis: { type: 'category', data: axis.map(labelFn), axisLabel: LABEL, ...AXIS_LINE },
87
+ yAxis: { type: 'value', axisLabel: { ...LABEL, formatter: (v) => fmtCompact(v) }, ...AXIS_LINE },
88
+ series: cats.map((cat, i) => {
89
+ const color = stacked ? (PALETTE[i % PALETTE.length] ?? ACCENT) : ACCENT;
90
+ return {
91
+ name: cat || undefined,
92
+ type: 'bar',
93
+ stack: stacked ? 'total' : undefined,
94
+ emphasis: { disabled: true },
95
+ data: axis.map((k) => {
96
+ const p = cell.get(`${cat}|${k}`);
97
+ const itemStyle = p?.estimated
98
+ ? estimatedBar(color)
99
+ : { color: !stacked && highlightKey && k === highlightKey ? 'var(--border)' : color };
100
+ return { value: p?.value ?? 0, itemStyle };
101
+ })
102
+ };
103
+ })
104
+ };
105
+ };
106
+ // The shared timeseries chart: a Monthly|Daily toggle over bar series, single or stacked. Pure + prop-driven —
107
+ // the host normalizes a dataset into `monthly` (always) and an optional per-day `daily` series, each point
108
+ // optionally carrying a `category` (→ stacked bars + legend). Monthly mode filters by a multi-year picker
109
+ // (default: current year); daily mode shows one calendar month with a ‹ › stepper and renders gap-filled
110
+ // (`estimated`) bars muted + hatched. A daily-source chart opens in Monthly mode. The MTD projection +
111
+ // current-month highlight apply only to a single (categoryless) series. Both per-service and the Overview feed it.
112
+ export const TimeseriesChart = ({ monthly, daily, isMoney = false, currency, title, height = 200, highlightCurrentMonth = false, estimateCurrentMonth }) => {
113
+ const t = useLabels();
114
+ const theme = useEchartsTheme();
115
+ const nowMonth = nowMonthKey();
116
+ const stacked = monthly.some((p) => p.category != null) || (daily ?? []).some((p) => p.category != null);
117
+ const hasDaily = (daily?.length ?? 0) > 0;
118
+ const [mode, setMode] = useState('monthly');
119
+ const effectiveMode = hasDaily ? mode : 'monthly';
120
+ const years = useMemo(() => [...new Set(monthly.map((p) => p.key.slice(0, 4)))].sort(), [monthly]);
121
+ const [selectedYears, setSelectedYears] = useState(() => new Set([nowMonth.slice(0, 4)]));
122
+ const showYearPicker = effectiveMode === 'monthly' && years.length > 1;
123
+ // Months present in the daily series, sorted — the steppable range. Default to the most recent.
124
+ const dailyMonths = useMemo(() => [...new Set((daily ?? []).map((p) => monthOf(p.key)))].sort(), [daily]);
125
+ const [dailyMonth, setDailyMonth] = useState('');
126
+ const activeDailyMonth = dailyMonth && dailyMonths.includes(dailyMonth) ? dailyMonth : (dailyMonths.at(-1) ?? '');
127
+ const monthIndex = dailyMonths.indexOf(activeDailyMonth);
128
+ const monthlyChart = useMemo(() => {
129
+ const shown = years.length > 1 ? monthly.filter((p) => selectedYears.has(p.key.slice(0, 4))) : monthly;
130
+ const points = shown.map((p) => ({ ...p }));
131
+ // Project the open month from its MTD figure (single series only) when nothing has settled it yet: fill the
132
+ // empty current-month bucket (or append a missing one) with the running total, flagged so it draws as an estimate.
133
+ if (!stacked && estimateCurrentMonth != null && (years.length <= 1 || selectedYears.has(nowMonth.slice(0, 4)))) {
134
+ const cur = points.find((p) => p.key === nowMonth);
135
+ if (cur && cur.value === 0) {
136
+ cur.value = estimateCurrentMonth;
137
+ cur.estimated = true;
138
+ }
139
+ else if (!cur) {
140
+ points.push({ key: nowMonth, value: estimateCurrentMonth, estimated: true });
141
+ }
142
+ }
143
+ return buildBarOption(points, monthLabel, {
144
+ stacked,
145
+ isMoney,
146
+ currency,
147
+ theme,
148
+ highlightKey: highlightCurrentMonth ? nowMonth : undefined
149
+ });
150
+ }, [
151
+ monthly,
152
+ years,
153
+ selectedYears,
154
+ estimateCurrentMonth,
155
+ stacked,
156
+ isMoney,
157
+ currency,
158
+ theme,
159
+ nowMonth,
160
+ highlightCurrentMonth
161
+ ]);
162
+ const dailyChart = useMemo(() => {
163
+ const days = (daily ?? []).filter((p) => monthOf(p.key) === activeDailyMonth);
164
+ return buildBarOption(days, dayLabel, { stacked, isMoney, currency, theme });
165
+ }, [daily, activeDailyMonth, stacked, isMoney, currency, theme]);
166
+ return (_jsxs(Card, { className: "gap-2 py-3", children: [_jsxs(CardHeader, { className: "flex-row items-center justify-between px-4", children: [_jsx(CardTitle, { className: "text-muted-foreground text-[11px] font-medium", children: title }), _jsxs("div", { className: "flex items-center gap-2", children: [effectiveMode === 'daily' && dailyMonths.length > 0 ? (_jsxs("div", { className: "flex items-center gap-1 text-[11px]", children: [_jsx("button", { type: "button", disabled: monthIndex <= 0, onClick: () => setDailyMonth(dailyMonths[monthIndex - 1] ?? activeDailyMonth), className: "text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:hover:text-muted-foreground", "aria-label": "Previous month", children: _jsx(ChevronLeft, { className: "size-4" }) }), _jsx("span", { className: "text-foreground min-w-14 text-center tabular-nums", children: monthLabel(activeDailyMonth) }), _jsx("button", { type: "button", disabled: monthIndex < 0 || monthIndex >= dailyMonths.length - 1, onClick: () => setDailyMonth(dailyMonths[monthIndex + 1] ?? activeDailyMonth), className: "text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:hover:text-muted-foreground", "aria-label": "Next month", children: _jsx(ChevronRight, { className: "size-4" }) })] })) : null, showYearPicker ? (_jsx(YearPicker, { years: years, selected: selectedYears, onChange: setSelectedYears, t: t })) : null, hasDaily ? (_jsx("div", { className: "bg-secondary/50 flex rounded-md p-0.5 text-[11px]", children: ['monthly', 'daily'].map((m) => (_jsx("button", { type: "button", onClick: () => setMode(m), className: `rounded px-2 py-0.5 transition-colors ${effectiveMode === m
167
+ ? 'bg-background text-foreground shadow-sm'
168
+ : 'text-muted-foreground hover:text-foreground'}`, children: m === 'monthly' ? t.chartMonthly : t.chartDaily }, m))) })) : null] })] }), _jsx(CardContent, { className: "px-4", children: _jsx(EChart, { option: effectiveMode === 'daily' ? dailyChart : monthlyChart, height: height }) })] }));
169
+ };
170
+ // Multi-select year filter. "All years" clears the filter (every year shows); ticking individual years
171
+ // narrows. A year set that excludes everything falls back to showing all (never an empty chart).
172
+ const YearPicker = ({ years, selected, onChange, t }) => {
173
+ const all = years.every((y) => selected.has(y));
174
+ const label = all ? t.chartAllYears : [...selected].sort().join(', ') || t.chartAllYears;
175
+ const toggle = (year) => {
176
+ const next = new Set(selected);
177
+ if (next.has(year)) {
178
+ next.delete(year);
179
+ }
180
+ else {
181
+ next.add(year);
182
+ }
183
+ if (next.size === 0) {
184
+ for (const y of years) {
185
+ next.add(y);
186
+ }
187
+ }
188
+ onChange(next);
189
+ };
190
+ return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { "aria-label": t.chartYears, className: "bg-secondary/50 hover:bg-secondary text-muted-foreground hover:text-foreground flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] transition-colors", children: [label, _jsx(ChevronDown, { className: "size-3" })] }), _jsxs(DropdownMenuContent, { children: [_jsx(DropdownMenuCheckboxItem, { checked: all, onCheckedChange: () => onChange(new Set(years)), children: t.chartAllYears }), years.map((y) => (_jsx(DropdownMenuCheckboxItem, { checked: selected.has(y), onCheckedChange: () => toggle(y), children: y }, y)))] })] }));
191
+ };
@@ -0,0 +1,72 @@
1
+ import { type BadgeTone, type CapabilityResult, type Column, type RecordDataset, type StatField, type StatTone, type TableDataset } from '@butinapp/sdk/data';
2
+ import type { DailyPoint } from '@butinapp/shapes';
3
+ export declare const resolveBadgeTone: (value: unknown, badges?: Record<string, BadgeTone>) => BadgeTone;
4
+ export declare const CATEGORICAL_HUES: readonly ['indigo', 'violet', 'purple', 'fuchsia', 'pink', 'cyan', 'teal', 'rose'];
5
+ export type CategoricalHue = (typeof CATEGORICAL_HUES)[number];
6
+ export declare const categoricalTone: (value: unknown) => CategoricalHue;
7
+ export interface LabelledValue {
8
+ label: string;
9
+ value: string;
10
+ }
11
+ export interface TableCell {
12
+ text: string;
13
+ href?: string;
14
+ }
15
+ export interface TableModel {
16
+ columns: Array<{
17
+ key: string;
18
+ label: string;
19
+ }>;
20
+ rows: TableCell[][];
21
+ }
22
+ export declare const recordRows: (ds: RecordDataset, fields?: string[], locale?: string) => LabelledValue[];
23
+ export declare const tableModel: (ds: TableDataset, columns?: string[], locale?: string) => TableModel;
24
+ export declare const defaultTableSort: (columns: Column[]) => {
25
+ key: string;
26
+ dir: 'desc';
27
+ } | undefined;
28
+ export declare const datasetRowId: (row: Record<string, unknown>, key: string | string[] | undefined) => string | undefined;
29
+ export declare const findCumulativeColumn: (result: CapabilityResult) => {
30
+ datasetId: string;
31
+ columnKey: string;
32
+ } | undefined;
33
+ export interface StatCardModel {
34
+ key: string;
35
+ label: string;
36
+ value: string;
37
+ unit?: string;
38
+ denominator?: string;
39
+ progress?: number;
40
+ percentLabel?: string;
41
+ caption?: string;
42
+ tone?: StatTone;
43
+ }
44
+ export declare const statCards: (ds: RecordDataset, fields: (string | StatField)[] | undefined, locale?: string) => StatCardModel[];
45
+ export interface SeriesData {
46
+ labels: string[];
47
+ values: number[];
48
+ }
49
+ export interface StackedSeries {
50
+ labels: string[];
51
+ series: {
52
+ name: string;
53
+ values: number[];
54
+ }[];
55
+ }
56
+ export type ChartPoint = {
57
+ key: string;
58
+ value: number;
59
+ category?: string;
60
+ estimated?: boolean;
61
+ };
62
+ export declare const monthlyAggregate: (points: ChartPoint[]) => ChartPoint[];
63
+ export interface DailyMonthGroup {
64
+ month: string;
65
+ label: string;
66
+ subtotal: number;
67
+ points: DailyPoint[];
68
+ }
69
+ export declare const groupDailyByMonth: (series: DailyPoint[]) => DailyMonthGroup[];
70
+ export declare const latestMonthDaily: (series: DailyPoint[]) => DailyPoint[];
71
+ export declare const stackedSeries: (ds: TableDataset, x: string, y: string, stackBy: string) => StackedSeries;
72
+ export declare const seriesData: (ds: TableDataset, x: string, y: string, nowMonth?: string) => SeriesData;
@@ -0,0 +1,291 @@
1
+ import {} from '@butinapp/sdk/data';
2
+ import { monthlyBuckets } from '../../lib/monthly-buckets.js';
3
+ import { formatByRole } from './format-role.js';
4
+ // The default sentiment a status value carries, so a role:'status' column auto-tones with no per-plugin map.
5
+ // Keys are lowercase; matched case-insensitively. A plugin's `badges` map overrides an entry here, and any
6
+ // value absent from both falls back to neutral. This is presentation (which words read as good/bad), so it
7
+ // lives in the renderer, not the contract.
8
+ const STATUS_LEXICON = {
9
+ // settled / healthy
10
+ paid: 'success',
11
+ success: 'success',
12
+ succeeded: 'success',
13
+ finalized: 'success',
14
+ collected: 'success',
15
+ processed: 'success',
16
+ posted: 'success',
17
+ complete: 'success',
18
+ completed: 'success',
19
+ active: 'success',
20
+ current: 'success',
21
+ // awaiting payment / attention
22
+ open: 'warning',
23
+ pending: 'warning',
24
+ processing: 'warning',
25
+ issued: 'warning',
26
+ unpaid: 'warning',
27
+ inactive: 'warning',
28
+ // forecast / future (not overdue)
29
+ upcoming: 'info',
30
+ scheduled: 'info',
31
+ // problem
32
+ failed: 'danger',
33
+ declined: 'danger',
34
+ uncollectible: 'danger',
35
+ expired: 'danger',
36
+ revoked: 'danger',
37
+ past_due: 'danger',
38
+ delinquent: 'danger',
39
+ suspended: 'danger',
40
+ disabled: 'danger',
41
+ // no judgment (informational)
42
+ draft: 'neutral',
43
+ void: 'neutral',
44
+ voided: 'neutral',
45
+ refunded: 'neutral',
46
+ closed: 'neutral',
47
+ archived: 'neutral',
48
+ canceled: 'neutral',
49
+ cancelled: 'neutral'
50
+ };
51
+ // Resolve a role:'status' value to its sentiment tone: a column `badges` override wins, else the renderer's
52
+ // built-in lexicon, else 'neutral'. Case-insensitive; a null/empty value is neutral. Pure (unit-tested off-DOM).
53
+ export const resolveBadgeTone = (value, badges) => {
54
+ if (value == null || value === '') {
55
+ return 'neutral';
56
+ }
57
+ const want = String(value).toLowerCase();
58
+ if (badges) {
59
+ for (const [key, tone] of Object.entries(badges)) {
60
+ if (key.toLowerCase() === want) {
61
+ return tone;
62
+ }
63
+ }
64
+ }
65
+ return STATUS_LEXICON[want] ?? 'neutral';
66
+ };
67
+ // The distinct hues a role:'category' value cycles through — colors with NO sentiment, so different
68
+ // categories (member roles, seat tiers) stay visually separable. Badge carries these variants on top of the
69
+ // sentiment tones.
70
+ export const CATEGORICAL_HUES = ['indigo', 'violet', 'purple', 'fuchsia', 'pink', 'cyan', 'teal', 'rose'];
71
+ // Assign a category value a stable distinct hue by hashing it, so the same value is the same color within a
72
+ // table and across services (owner is always one hue, admin another) with zero plugin input. A null/empty
73
+ // value gets the first hue.
74
+ export const categoricalTone = (value) => {
75
+ const text = value == null ? '' : String(value).toLowerCase();
76
+ let hash = 0;
77
+ for (let i = 0; i < text.length; i++) {
78
+ hash = (hash * 31 + text.charCodeAt(i)) | 0;
79
+ }
80
+ return CATEGORICAL_HUES[Math.abs(hash) % CATEGORICAL_HUES.length];
81
+ };
82
+ // Pick the columns to show: the named keys (in that order, skipping any that don't exist) or all of them.
83
+ const pick = (cols, keys) => keys ? keys.map((k) => cols.find((c) => c.key === k)).filter((c) => c != null) : cols;
84
+ // Record dataset -> formatted label/value rows. Feeds both the stat-tile and key/value renderers (they
85
+ // differ only in layout). `fields` narrows/orders which fields show.
86
+ export const recordRows = (ds, fields, locale) => pick(ds.fields, fields).map((col) => ({
87
+ label: col.label ?? col.key,
88
+ value: formatByRole(ds.value[col.key], col.role, col.currency, locale)
89
+ }));
90
+ // Table dataset -> header labels + per-cell formatted text. A url-role cell with a value also carries an
91
+ // `href` so the renderer can make it a link. `columns` narrows/orders which columns show.
92
+ export const tableModel = (ds, columns, locale) => {
93
+ const cols = pick(ds.columns, columns);
94
+ return {
95
+ columns: cols.map((c) => ({ key: c.key, label: c.label ?? c.key })),
96
+ rows: ds.rows.map((row) => cols.map((c) => {
97
+ const raw = row[c.key];
98
+ if (c.role === 'url' && typeof raw === 'string' && raw !== '') {
99
+ return { text: raw, href: raw };
100
+ }
101
+ return { text: formatByRole(raw, c.role, c.currency, locale) };
102
+ }))
103
+ };
104
+ };
105
+ // The default sort for a table that carries a date: the first timestamp-role column, descending (newest
106
+ // first) — so an invoices/statements list opens chronologically instead of in fetch order. undefined when no
107
+ // column is a timestamp (the table then keeps its natural row order). Only a fallback: a persisted user sort
108
+ // always wins.
109
+ export const defaultTableSort = (columns) => {
110
+ const ts = columns.find((c) => c.role === 'timestamp' && !c.hidden);
111
+ return ts ? { key: ts.key, dir: 'desc' } : undefined;
112
+ };
113
+ // A row's stable identity from a table's `key` — the SAME join the ledger uses to track a row across captures,
114
+ // so the renderer can look up a row's derived daily series by its ledger row id. undefined when any key part is
115
+ // missing/empty (an unkeyed row never accumulates).
116
+ export const datasetRowId = (row, key) => {
117
+ const parts = key === undefined ? [] : Array.isArray(key) ? key : [key];
118
+ if (parts.length === 0) {
119
+ return undefined;
120
+ }
121
+ const out = [];
122
+ for (const k of parts) {
123
+ const v = row[k];
124
+ if (v === null || v === undefined || v === '') {
125
+ return undefined;
126
+ }
127
+ out.push(String(v));
128
+ }
129
+ return out.join('');
130
+ };
131
+ // The first keyed table dataset carrying a cumulative column (an MTD-per-row counter), with that column's key.
132
+ // The host fetches its per-row daily series; the table view differences it into a trend sparkline. undefined
133
+ // when no dataset has one — most capabilities, so the renderer skips the extra fetch + column entirely.
134
+ export const findCumulativeColumn = (result) => {
135
+ for (const ds of result.datasets) {
136
+ if (ds.shape !== 'table' || ds.key === undefined) {
137
+ continue;
138
+ }
139
+ const col = ds.columns.find((c) => c.accrual === 'cumulative');
140
+ if (col) {
141
+ return { datasetId: ds.id, columnKey: col.key };
142
+ }
143
+ }
144
+ return undefined;
145
+ };
146
+ const clamp01 = (n) => Math.max(0, Math.min(1, n));
147
+ // Resolve a record dataset + a stat view's `fields` into render-ready cards. A bare string field shows the
148
+ // value as-is; a StatField adds a denominator/progress bar (`max`), a unit suffix, a caption, or a tone. The
149
+ // column's role/currency drives formatting of both the value and the denominator, so they always match.
150
+ export const statCards = (ds, fields, locale) => {
151
+ const specs = fields
152
+ ? fields.map((f) => (typeof f === 'string' ? { key: f } : f))
153
+ : ds.fields.map((c) => ({ key: c.key }));
154
+ return specs.flatMap((spec) => {
155
+ const col = ds.fields.find((c) => c.key === spec.key);
156
+ if (!col) {
157
+ return [];
158
+ }
159
+ const raw = ds.value[spec.key];
160
+ const card = {
161
+ key: spec.key,
162
+ label: col.label ?? col.key,
163
+ value: formatByRole(raw, col.role, col.currency, locale),
164
+ unit: spec.unit,
165
+ caption: spec.caption,
166
+ tone: spec.tone
167
+ };
168
+ if (spec.max != null && spec.max > 0 && typeof raw === 'number' && Number.isFinite(raw)) {
169
+ card.denominator = formatByRole(spec.max, col.role, col.currency, locale);
170
+ card.progress = clamp01(raw / spec.max);
171
+ card.percentLabel = `${Math.round((raw / spec.max) * 100)}%`;
172
+ }
173
+ return [card];
174
+ });
175
+ };
176
+ // Bucket long-format trend points into monthly points, summing per (month, category). Months sort ascending;
177
+ // categories keep first-seen order so a stacked chart's series order is stable. The monthly-aggregation
178
+ // counterpart to stackedSeries — feeds the Monthly view of a daily-sourced chart.
179
+ export const monthlyAggregate = (points) => {
180
+ const months = [];
181
+ const cats = [];
182
+ const sum = new Map();
183
+ for (const p of points) {
184
+ const month = p.key.slice(0, 7);
185
+ const cat = p.category ?? '';
186
+ if (!months.includes(month)) {
187
+ months.push(month);
188
+ }
189
+ if (!cats.includes(cat)) {
190
+ cats.push(cat);
191
+ }
192
+ const k = `${cat}|${month}`;
193
+ sum.set(k, (sum.get(k) ?? 0) + p.value);
194
+ }
195
+ months.sort();
196
+ const out = [];
197
+ for (const month of months) {
198
+ for (const cat of cats) {
199
+ const k = `${cat}|${month}`;
200
+ if (sum.has(k)) {
201
+ out.push(cat ? { key: month, value: sum.get(k), category: cat } : { key: month, value: sum.get(k) });
202
+ }
203
+ }
204
+ }
205
+ return out;
206
+ };
207
+ const MONTHS_FULL = [
208
+ 'January',
209
+ 'February',
210
+ 'March',
211
+ 'April',
212
+ 'May',
213
+ 'June',
214
+ 'July',
215
+ 'August',
216
+ 'September',
217
+ 'October',
218
+ 'November',
219
+ 'December'
220
+ ];
221
+ // 'YYYY-MM' -> 'June 2026'.
222
+ const monthFullLabel = (month) => {
223
+ const [y, m] = month.split('-');
224
+ return `${MONTHS_FULL[Number(m) - 1] ?? m} ${y ?? ''}`.trim();
225
+ };
226
+ // Group a per-day series into month sections: months newest-first, days ascending within each, plus the
227
+ // month's value subtotal. Feeds the expanded per-row daily breakdown so a long flat list reads by period.
228
+ export const groupDailyByMonth = (series) => {
229
+ const byMonth = new Map();
230
+ for (const p of series) {
231
+ const month = p.date.slice(0, 7);
232
+ const arr = byMonth.get(month) ?? [];
233
+ arr.push(p);
234
+ byMonth.set(month, arr);
235
+ }
236
+ return [...byMonth.entries()]
237
+ .sort((a, b) => b[0].localeCompare(a[0]))
238
+ .map(([month, points]) => ({
239
+ month,
240
+ label: monthFullLabel(month),
241
+ subtotal: points.reduce((s, p) => s + p.value, 0),
242
+ points: [...points].sort((a, b) => a.date.localeCompare(b.date))
243
+ }));
244
+ };
245
+ // The newest month's daily points (ascending), empty when there's no data. The Trend sparkline draws this
246
+ // alone so it reads as the current period's day-over-day shape rather than a jagged multi-month line that
247
+ // dips at each monthly reset.
248
+ export const latestMonthDaily = (series) => groupDailyByMonth(series)[0]?.points ?? [];
249
+ // Pivot long-format rows into stacked series: distinct x values (sorted) form the axis; each distinct
250
+ // `stackBy` category becomes a series whose value at each x is the sum of that (x, category) cell. Categories
251
+ // keep first-seen order, so a plugin controls stack order by row order.
252
+ export const stackedSeries = (ds, x, y, stackBy) => {
253
+ const labels = [];
254
+ const cats = [];
255
+ const cell = new Map();
256
+ for (const row of ds.rows) {
257
+ const xv = String(row[x] ?? '');
258
+ const cat = String(row[stackBy] ?? '');
259
+ const v = Number(row[y]) || 0;
260
+ if (!labels.includes(xv)) {
261
+ labels.push(xv);
262
+ }
263
+ if (!cats.includes(cat)) {
264
+ cats.push(cat);
265
+ }
266
+ const k = `${cat}${xv}`;
267
+ cell.set(k, (cell.get(k) ?? 0) + v);
268
+ }
269
+ labels.sort();
270
+ return {
271
+ labels,
272
+ series: cats.map((name) => ({ name, values: labels.map((xv) => cell.get(`${name}${xv}`) ?? 0) }))
273
+ };
274
+ };
275
+ const MONTH_KEY = /^\d{4}-\d{2}$/;
276
+ const nowMonthKey = () => {
277
+ const d = new Date();
278
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
279
+ };
280
+ // Table dataset -> one numeric series for the chart. When every x value is a YYYY-MM period key, gaps are
281
+ // filled to a continuous month axis (reusing monthlyBuckets, extended to nowMonth); otherwise rows pass
282
+ // through in order. `nowMonth` is injectable to keep the month branch pure/testable.
283
+ export const seriesData = (ds, x, y, nowMonth = nowMonthKey()) => {
284
+ const pairs = ds.rows.map((r) => ({ date: String(r[x] ?? ''), amount: Number(r[y]) || 0 }));
285
+ const isMonth = pairs.length > 0 && pairs.every((p) => MONTH_KEY.test(p.date));
286
+ if (isMonth) {
287
+ const buckets = monthlyBuckets(pairs, nowMonth);
288
+ return { labels: buckets.map((b) => b.month), values: buckets.map((b) => b.amount) };
289
+ }
290
+ return { labels: pairs.map((p) => p.date), values: pairs.map((p) => p.amount) };
291
+ };
@@ -0,0 +1 @@
1
+ export declare const formatSize: (bytes?: number) => string;
@@ -0,0 +1,17 @@
1
+ // Pure view-model helper for the downloadable-table file size cell — node-tested, no React.
2
+ export const formatSize = (bytes) => {
3
+ if (bytes === undefined) {
4
+ return '—';
5
+ }
6
+ if (bytes < 1024) {
7
+ return `${bytes} B`;
8
+ }
9
+ const units = ['KB', 'MB', 'GB'];
10
+ let value = bytes / 1024;
11
+ let i = 0;
12
+ while (value >= 1024 && i < units.length - 1) {
13
+ value /= 1024;
14
+ i++;
15
+ }
16
+ return `${value.toFixed(1)} ${units[i]}`;
17
+ };
@@ -0,0 +1,6 @@
1
+ export declare const OpenFileButton: ({ onClick }: {
2
+ onClick: () => void;
3
+ }) => import("react").JSX.Element;
4
+ export declare const ViewFileLink: ({ url }: {
5
+ url: string;
6
+ }) => import("react").JSX.Element;
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ExternalLink, FolderOpen } from 'lucide-react';
3
+ import { Button } from '../../components/button.js';
4
+ import { useLabels } from '../../i18n/context.js';
5
+ // The canonical "open a downloaded file" row action — icon + label — for a downloadable table's trailing
6
+ // action column (the local copy, once on disk).
7
+ export const OpenFileButton = ({ onClick }) => {
8
+ const t = useLabels();
9
+ return (_jsxs(Button, { variant: "ghost", size: "xs", className: "text-muted-foreground hover:text-foreground", title: t.openDocument, onClick: onClick, children: [_jsx(FolderOpen, { className: "size-3.5" }), " ", t.openDocument] }));
10
+ };
11
+ // The "view the file where it lives" row action — opens the row's URL externally (no local copy needed).
12
+ // Sits alongside Open in the trailing action column whenever the row has a URL.
13
+ export const ViewFileLink = ({ url }) => {
14
+ const t = useLabels();
15
+ return (_jsxs("a", { href: url, target: "_blank", rel: "noreferrer", title: t.viewDocument, className: "text-muted-foreground hover:text-foreground inline-flex h-6 items-center gap-1 rounded-md px-2 text-xs", children: [_jsx(ExternalLink, { className: "size-3.5" }), " ", t.viewDocument] }));
16
+ };
@@ -0,0 +1,13 @@
1
+ import { type ReactNode } from 'react';
2
+ export declare const AppShell: ({ brand, actions, sidebar, inset, children }: {
3
+ brand: ReactNode;
4
+ actions?: ReactNode;
5
+ sidebar: (ctx: {
6
+ onNavigate: () => void;
7
+ }) => ReactNode;
8
+ inset?: {
9
+ left?: number;
10
+ right?: number;
11
+ };
12
+ children: ReactNode;
13
+ }) => import("react").JSX.Element;
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Menu } from 'lucide-react';
3
+ import { useState } from 'react';
4
+ import { useLabels } from '../../i18n/index.js';
5
+ import { Button, Sheet, SheetContent, SheetTitle, SheetTrigger } from '../../primitives.js';
6
+ // The app frame: a slim sticky top bar over a sidebar/content body. The bar splits into a brand cell that
7
+ // sits in the sidebar column — same `w-56` width, its `border-r` continuing the sidebar divider so the
8
+ // wordmark anchors the rail rather than floating — and a content header holding the right-aligned `actions`
9
+ // slot. `sidebar` is a render prop so the same node renders inline on desktop and inside a closing Sheet on
10
+ // mobile — `onNavigate` closes the sheet after a pick. Pure + prop-driven; no IPC, no theme state.
11
+ // (SheetContent is a right-side slide-over by default; we override position to left for the nav drawer.)
12
+ //
13
+ // The header doubles as the OS title-bar drag handle when the host hides the native frame (`app-region: drag`,
14
+ // ignored in a browser); its 48px height must match the native window-controls overlay height the host sets.
15
+ // Interactive controls opt back out with `no-drag`, and `inset` reserves space on the side the native window
16
+ // controls occupy (right on Windows/Linux, left for macOS traffic lights) so the brand + actions never sit
17
+ // under them.
18
+ export const AppShell = ({ brand, actions, sidebar, inset, children }) => {
19
+ const t = useLabels();
20
+ const [menuOpen, setMenuOpen] = useState(false);
21
+ return (_jsxs("div", { className: "bg-background flex h-full flex-col overflow-hidden", children: [_jsxs("header", { className: "bg-card sticky top-0 z-40 flex h-12 shrink-0 items-center border-b [-webkit-app-region:drag]", children: [inset?.left ? _jsx("div", { "aria-hidden": true, style: { width: inset.left } }) : null, _jsxs(Sheet, { open: menuOpen, onOpenChange: setMenuOpen, children: [_jsx(SheetTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon", className: "ml-2 [-webkit-app-region:no-drag] md:hidden", "aria-label": t.menuLabel, children: _jsx(Menu, {}) }) }), _jsxs(SheetContent, { className: "right-auto left-0 w-56 max-w-[14rem] border-r border-l-0 p-0", children: [_jsx(SheetTitle, { className: "sr-only", children: t.menuLabel }), sidebar({ onNavigate: () => setMenuOpen(false) })] })] }), _jsx("div", { className: "flex h-full items-center px-4 [-webkit-app-region:no-drag] md:w-56 md:shrink-0 md:border-r", children: brand }), _jsx("div", { className: "flex flex-1 items-center px-4 sm:px-6", children: _jsx("div", { className: "ml-auto flex items-center gap-1 [-webkit-app-region:no-drag]", children: actions }) }), inset?.right ? _jsx("div", { "aria-hidden": true, style: { width: inset.right } }) : null] }), _jsxs("div", { className: "flex flex-1 overflow-hidden", children: [_jsx("div", { className: "hidden md:flex", children: sidebar({ onNavigate: () => { } }) }), _jsx("main", { className: "flex-1 overflow-auto", children: _jsx("div", { className: "w-full px-4 py-6 sm:px-6 lg:px-8", children: children }) })] })] }));
22
+ };
@@ -0,0 +1,10 @@
1
+ import { type ButinLabels } from '../../i18n/index.js';
2
+ export type ConnState = 'connected' | 'unverified' | 'disconnected';
3
+ export declare const connDotClass: Record<ConnState, string>;
4
+ export declare const connLabel: (t: ButinLabels, state: ConnState) => string;
5
+ export declare const ConnDot: ({ state, testing, className, title }: {
6
+ state: ConnState;
7
+ testing?: boolean;
8
+ className?: string;
9
+ title?: string;
10
+ }) => import("react").JSX.Element;