@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,170 @@
1
+ import {} from '@butinapp/sdk/data';
2
+ import { convert, round2 } from '@butinapp/sdk/util';
3
+ import { combineDailySpend } from '@butinapp/shapes';
4
+ import { groupBy, sortBy, sumBy } from 'lodash-es';
5
+ const amountAt = (series, month) => series?.find((p) => p.month === month)?.amount ?? 0;
6
+ // The spend-section summary a service reports — the live current-period figure its own page headlines.
7
+ const spendOf = (p) => (p.summaries ?? []).find((s) => s.section === 'spend');
8
+ // A plugin's current-month spend in the base currency. Prefer the current LOCAL month's bar from the service's
9
+ // own per-month series (the figure its chart shows for this month) — built from clean per-month data (invoices,
10
+ // cost-explorer months), so it rolls when the user's local month rolls, not when the provider resets its billing
11
+ // period. A service whose provider period reset early (UTC month rollover) still reads the user's current month
12
+ // here instead of the tiny post-reset open-period figure. With no bar for this month (an arrears service invoices
13
+ // in arrears, so its current month isn't in the series yet) it falls back to the live spend-summary value — its
14
+ // open-period spend, matching its own page. 0 when there's neither, or the native currency has no rate.
15
+ export const currentMtd = (p, nowMonth, baseCurrency = 'USD', rates = {}) => {
16
+ const bar = monthlyInBase(p, baseCurrency, rates).find((pt) => pt.month === nowMonth);
17
+ if (bar) {
18
+ return bar.amount;
19
+ }
20
+ // No bar for this month (an arrears service invoices in arrears): the peak accrued reading Butin captured this
21
+ // month — restores the just-closed month's figure when the provider's live open-period value has already reset
22
+ // for the next period. Falls through to the live spend-summary value only when nothing was captured this month.
23
+ if (p.currentMonthAccrual != null && p.currentMonthAccrual > 0) {
24
+ return convert(p.currentMonthAccrual, p.currency ?? baseCurrency, baseCurrency, rates) ?? 0;
25
+ }
26
+ const s = spendOf(p);
27
+ return s ? (convert(s.value, s.currency ?? p.currency ?? baseCurrency, baseCurrency, rates) ?? 0) : 0;
28
+ };
29
+ // A plugin's monthly amounts converted into the base currency. Sorted by month because the stored series
30
+ // arrives in keyed-table insertion order, and consumers read it by position (prevMo, the spark). An
31
+ // unconvertible currency (no rate) yields an empty series so it never distorts a base-currency aggregate.
32
+ const monthlyInBase = (p, baseCurrency, rates) => {
33
+ const ccy = p.currency ?? baseCurrency;
34
+ return (p.monthly ?? [])
35
+ .flatMap((pt) => {
36
+ const amount = convert(pt.amount, ccy, baseCurrency, rates);
37
+ return amount === null ? [] : [{ month: pt.month, amount }];
38
+ })
39
+ .sort((a, b) => a.month.localeCompare(b.month));
40
+ };
41
+ // A plugin's per-day spend converted into the base currency (same missing-rate exclusion as monthlyInBase).
42
+ const dailyInBase = (p, baseCurrency, rates) => {
43
+ const ccy = p.currency ?? baseCurrency;
44
+ return (p.daily ?? []).flatMap((pt) => {
45
+ const value = convert(pt.value, ccy, baseCurrency, rates);
46
+ return value === null ? [] : [{ ...pt, value }];
47
+ });
48
+ };
49
+ // Union every plugin's months into one sorted, summed series — converting each to the base currency first.
50
+ export const combinedMonthly = (plugins, baseCurrency = 'USD', rates = {}) => {
51
+ const byMonth = groupBy(plugins.flatMap((p) => monthlyInBase(p, baseCurrency, rates)), (point) => point.month);
52
+ return sortBy(Object.entries(byMonth), ([month]) => month).map(([month, points]) => ({
53
+ month,
54
+ amount: round2(sumBy(points, (pt) => pt.amount))
55
+ }));
56
+ };
57
+ // Sum every spend plugin's per-day series into one combined daily series (a day is estimated if any
58
+ // contributing service estimated it), converting each to the base currency. Empty when no plugin has
59
+ // captured snapshots yet.
60
+ export const combinedDaily = (plugins, baseCurrency = 'USD', rates = {}) => combineDailySpend(plugins.map((p) => dailyInBase(p, baseCurrency, rates)));
61
+ // Headline numbers. `nowMonth` is the current (partial) month — excluded from the "completed months" the
62
+ // run-rate averages. mtd = nowMonth's total; lastFullMonth = the last completed month before nowMonth.
63
+ export const monthStats = (plugins, nowMonth, baseCurrency = 'USD', rates = {}) => {
64
+ const combined = combinedMonthly(plugins, baseCurrency, rates);
65
+ const completed = combined.filter((p) => p.month < nowMonth);
66
+ const lastFullMonth = completed.at(-1)?.amount ?? 0;
67
+ const recent = completed.slice(-3);
68
+ const avg = recent.length > 0 ? sumBy(recent, (p) => p.amount) / recent.length : 0;
69
+ const mtd = sumBy(plugins, (p) => currentMtd(p, nowMonth, baseCurrency, rates));
70
+ return {
71
+ mtd: round2(mtd),
72
+ lastFullMonth,
73
+ annualizedRunRate: avg * 12
74
+ };
75
+ };
76
+ // Per-plugin change between two months, split into increases (delta > 0) and drops (delta < 0), each
77
+ // sorted by magnitude. pct is relative to the `from` month (0 → 100% when growing from nothing).
78
+ export const movers = (plugins, from, to, baseCurrency = 'USD', rates = {}) => {
79
+ const all = plugins.map((p) => {
80
+ const series = monthlyInBase(p, baseCurrency, rates);
81
+ const a = amountAt(series, from);
82
+ const b = amountAt(series, to);
83
+ const delta = round2(b - a);
84
+ return {
85
+ pluginId: p.pluginId,
86
+ pluginName: p.pluginName,
87
+ color: p.color,
88
+ icon: p.icon,
89
+ delta,
90
+ pct: a === 0 ? (b > 0 ? 100 : 0) : Math.round(((b - a) / a) * 100)
91
+ };
92
+ });
93
+ return {
94
+ increases: all.filter((m) => m.delta > 0).sort((x, y) => y.delta - x.delta),
95
+ drops: all.filter((m) => m.delta < 0).sort((x, y) => x.delta - y.delta)
96
+ };
97
+ };
98
+ // One table row per plugin: current-month MTD, last completed month, MoM %, share of last-month total,
99
+ // the full spark series, and the all-window total.
100
+ export const byServiceRows = (plugins, nowMonth, baseCurrency = 'USD', rates = {}) => {
101
+ const lastMonth = combinedMonthly(plugins, baseCurrency, rates)
102
+ .filter((p) => p.month < nowMonth)
103
+ .at(-1)?.month;
104
+ const series = new Map(plugins.map((p) => [p.pluginId, monthlyInBase(p, baseCurrency, rates)]));
105
+ const lastTotal = sumBy(plugins, (p) => (lastMonth ? amountAt(series.get(p.pluginId), lastMonth) : 0));
106
+ return plugins.map((p) => {
107
+ const months = series.get(p.pluginId) ?? [];
108
+ const mtd = currentMtd(p, nowMonth, baseCurrency, rates);
109
+ const lastMo = lastMonth ? amountAt(months, lastMonth) : 0;
110
+ const prevMo = months.filter((pt) => lastMonth && pt.month < lastMonth).at(-1)?.amount ?? 0;
111
+ const momPct = prevMo === 0 ? (lastMo > 0 ? 100 : 0) : Math.round(((lastMo - prevMo) / prevMo) * 100);
112
+ return {
113
+ pluginId: p.pluginId,
114
+ pluginName: p.pluginName,
115
+ color: p.color,
116
+ icon: p.icon,
117
+ mtd,
118
+ lastMo,
119
+ prevMo,
120
+ momPct,
121
+ share: lastTotal === 0 ? 0 : Math.round((lastMo / lastTotal) * 100),
122
+ spark: months.map((pt) => pt.amount),
123
+ byMonth: Object.fromEntries(months.map((pt) => [pt.month, pt.amount])),
124
+ total: round2(sumBy(months, (pt) => pt.amount))
125
+ };
126
+ });
127
+ };
128
+ // The detailed table's window "Total": a row summed over only the months currently displayed (the current
129
+ // month read from its live `mtd`, every other month from the series), as opposed to `total` (all-time). A
130
+ // displayed month the service has no data for contributes 0.
131
+ export const windowTotal = (row, months, nowMonth) => round2(sumBy(months, (m) => (m === nowMonth ? row.mtd : (row.byMonth[m] ?? 0))));
132
+ // The current-month spend grouped by each service's NATIVE currency (exact, no conversion), plus a count of
133
+ // currencies with no rate. The Overview shows this breakdown — and an `≈` on the converted headline — only
134
+ // when more than one currency is present or some can't be converted; a single-base-currency setup hides it.
135
+ export const spendBreakdown = (plugins, nowMonth, baseCurrency = 'USD', rates = {}) => {
136
+ // Native (un-converted) current-month spend per service: the live spend-summary value, else the series bar
137
+ // — so the per-currency breakdown agrees with the headline for arrears services.
138
+ const byCcy = groupBy(plugins, (p) => p.currency ?? baseCurrency);
139
+ const rows = Object.entries(byCcy).map(([currency, group]) => ({
140
+ currency,
141
+ mtd: round2(sumBy(group, (p) => spendOf(p)?.value ?? amountAt(p.monthly, nowMonth))),
142
+ convertible: convert(1, currency, baseCurrency, rates) !== null
143
+ }));
144
+ return { rows, unconverted: rows.filter((r) => !r.convertible).length };
145
+ };
146
+ // The older completed months to show as their own columns in the detailed spend table — newest-first,
147
+ // capped at `max`. Excludes the current partial month (its own MTD column) and the last completed month
148
+ // (its own "Last mo" column), so these are the months *before* last-mo (e.g. Apr, Mar, Feb, Jan, Dec).
149
+ export const monthColumns = (plugins, nowMonth, max = 5) => {
150
+ const completed = combinedMonthly(plugins)
151
+ .map((p) => p.month)
152
+ .filter((m) => m < nowMonth);
153
+ return completed.slice(0, -1).reverse().slice(0, max);
154
+ };
155
+ // A service's headline summary: an explicit headline:true wins, else spend > balance > other.
156
+ export const pickHeadline = (p) => {
157
+ const summaries = p.summaries ?? [];
158
+ const order = ['spend', 'balance', 'other'];
159
+ return summaries.find((s) => s.headline) ?? order.flatMap((sec) => summaries.filter((s) => s.section === sec))[0];
160
+ };
161
+ // A service lands in Spending / Balances for each monetary headline it reports — a bank with a spend AND a
162
+ // balance shows in both. Others is the non-monetary catch-all: a service shows there only when it reports
163
+ // NEITHER spend nor balance (a usage/count-only service, or one with no summary). A billing service that also
164
+ // reports a usage-cost 'other' metric stays in Spending — its usage lives on its own page, not a second tile.
165
+ const has = (p, section) => (p.summaries ?? []).some((s) => s.section === section);
166
+ export const partition = (plugins) => ({
167
+ spend: plugins.filter((p) => has(p, 'spend')),
168
+ balances: plugins.filter((p) => has(p, 'balance')),
169
+ others: plugins.filter((p) => !has(p, 'spend') && !has(p, 'balance'))
170
+ });
@@ -0,0 +1,8 @@
1
+ import { type FxRates } from '@butinapp/sdk/util';
2
+ import { type OverviewPlugin } from './overview-model.js';
3
+ export declare const Overview: ({ plugins, baseCurrency, rates, onOpen }: {
4
+ plugins: OverviewPlugin[];
5
+ baseCurrency?: string;
6
+ rates?: FxRates;
7
+ onOpen?: (pluginId: string) => void;
8
+ }) => import("react").JSX.Element;
@@ -0,0 +1,136 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { MONTH_ABBR, convert } from '@butinapp/sdk/util';
3
+ import { ArrowDownRight, ArrowUpRight, ChevronDown } from 'lucide-react';
4
+ import { useMemo, useState } from 'react';
5
+ import { Card, CardContent, CardHeader, CardTitle } from '../../components/card.js';
6
+ import { DropdownMenu, DropdownMenuItem, DropdownMenuContent, DropdownMenuTrigger } from '../../components/dropdown-menu.js';
7
+ import { ServiceIcon } from '../../components/service-icon.js';
8
+ import { Tooltip, TooltipContent, TooltipTrigger } from '../../components/tooltip.js';
9
+ import { useLabels } from '../../i18n/context.js';
10
+ import { useFormat } from '../../i18n/format-context.js';
11
+ import { formatDateTime } from '../../i18n/format.js';
12
+ import { formatMoney, formatMoneyCompact } from '../../lib/format.js';
13
+ import { StatCard } from '../charts.js';
14
+ import { byServiceRows, combinedDaily, combinedMonthly, monthColumns, monthStats, movers, partition, spendBreakdown, windowTotal } from './overview-model.js';
15
+ import { TimeseriesChart } from './timeseries-chart.js';
16
+ const monthLabel = (key) => {
17
+ const [y, m] = key.split('-');
18
+ return `${MONTH_ABBR[Number(m) - 1] ?? m} '${(y ?? '').slice(2)}`;
19
+ };
20
+ const nowMonthKey = () => {
21
+ const d = new Date();
22
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
23
+ };
24
+ const pctClass = (n) => n > 0 ? 'text-red-500 dark:text-red-400' : n < 0 ? 'text-emerald-500 dark:text-emerald-400' : 'text-muted-foreground';
25
+ // The Spending band: rollup stat cards, a combined monthly-spend bar chart, "what changed" movers, and a
26
+ // by-service table. Fed only the plugins reporting a spend section (see `partition`) — so non-monetary
27
+ // services never land in the spend math. Reads each plugin's monthly series; one with none contributes nothing.
28
+ const SpendingSection = ({ plugins, fx, onOpen }) => {
29
+ const t = useLabels();
30
+ const nowMonth = nowMonthKey();
31
+ const { baseCurrency, rates } = fx;
32
+ const [view, setView] = useState('compact');
33
+ // How many trailing months the detailed table shows as columns (This mo + Last mo + the older columns).
34
+ const [monthsToShow, setMonthsToShow] = useState(6);
35
+ const combined = useMemo(() => combinedMonthly(plugins, baseCurrency, rates), [plugins, baseCurrency, rates]);
36
+ const daily = useMemo(() => combinedDaily(plugins, baseCurrency, rates), [plugins, baseCurrency, rates]);
37
+ const stats = useMemo(() => monthStats(plugins, nowMonth, baseCurrency, rates), [plugins, nowMonth, baseCurrency, rates]);
38
+ const rows = useMemo(() => byServiceRows(plugins, nowMonth, baseCurrency, rates).sort((a, b) => b.lastMo - a.lastMo), [plugins, nowMonth, baseCurrency, rates]);
39
+ // The older-month columns: the chosen window minus the dedicated This-mo + Last-mo columns.
40
+ const cols = useMemo(() => monthColumns(plugins, nowMonth, Math.max(0, monthsToShow - 2)), [plugins, nowMonth, monthsToShow]);
41
+ const breakdown = useMemo(() => spendBreakdown(plugins, nowMonth, baseCurrency, rates), [plugins, nowMonth, baseCurrency, rates]);
42
+ const completed = combined.filter((p) => p.month < nowMonth);
43
+ const from = completed.at(-2)?.month ?? completed.at(-1)?.month ?? nowMonth;
44
+ const to = completed.at(-1)?.month ?? nowMonth;
45
+ const move = useMemo(() => movers(plugins, from, to, baseCurrency, rates), [plugins, from, to, baseCurrency, rates]);
46
+ const hints = columnHints(nowMonth, to, from);
47
+ if (combined.length === 0) {
48
+ return _jsx("p", { className: "text-muted-foreground text-sm", children: t.noDataYet });
49
+ }
50
+ // Mixed currencies (or one with no rate) → the headline total is an approximation; flag it with `≈` and
51
+ // surface the exact per-currency subtotals so the converted number is never the whole story.
52
+ const approx = breakdown.rows.length > 1 || breakdown.unconverted > 0;
53
+ const money = (n) => `${approx ? '≈ ' : ''}${formatMoney(n, baseCurrency)}`;
54
+ return (_jsxs("div", { className: "space-y-5", children: [_jsxs("div", { className: "grid grid-cols-1 gap-3 sm:grid-cols-3", children: [_jsx(StatCard, { label: "Last full month", value: money(stats.lastFullMonth) }), _jsx(StatCard, { label: "This month (MTD)", value: money(stats.mtd) }), _jsx(StatCard, { label: "Annualized run-rate", value: `${approx ? '≈ ' : ''}${formatMoneyCompact(stats.annualizedRunRate, baseCurrency)}`, sub: "last 3 mo avg \u00D7 12" })] }), approx ? _jsx(CurrencyBreakdownRow, { breakdown: breakdown, baseCurrency: baseCurrency }) : null, _jsx(TimeseriesChart, { monthly: combined.map((p) => ({ key: p.month, value: p.amount })), daily: daily.map((p) => ({ key: p.date, value: p.value, estimated: p.estimated })), isMoney: true, currency: baseCurrency, title: "Total monthly spend", height: 220, highlightCurrentMonth: true }), move.increases.length > 0 || move.drops.length > 0 ? (_jsxs("div", { className: "grid grid-cols-1 gap-3 lg:grid-cols-2", children: [_jsx(MoverList, { title: "Biggest increases", movers: move.increases.slice(0, 4), currency: baseCurrency, onOpen: onOpen }), _jsx(MoverList, { title: "Biggest drops", movers: move.drops.slice(0, 4), currency: baseCurrency, onOpen: onOpen })] })) : null, _jsxs(Card, { className: "gap-2 py-0", children: [cols.length > 0 ? (_jsxs(CardHeader, { className: "flex-row items-center justify-between px-4 pt-3", children: [_jsx(CardTitle, { className: "text-muted-foreground text-[11px] font-medium", children: "By service" }), _jsxs("div", { className: "flex items-center gap-2", children: [view === 'detailed' ? _jsx(MonthsPicker, { value: monthsToShow, onChange: setMonthsToShow }) : null, _jsx(ViewToggle, { view: view, onChange: setView })] })] })) : null, _jsx(CardContent, { className: "overflow-x-auto p-0", children: view === 'detailed' && cols.length > 0 ? (_jsx(DetailedSpendTable, { rows: rows, cols: cols, months: [nowMonth, ...(to !== nowMonth ? [to] : []), ...cols], nowMonth: nowMonth, currency: baseCurrency, hints: hints, onOpen: onOpen })) : (_jsx(CompactSpendTable, { rows: rows, currency: baseCurrency, hints: hints, onOpen: onOpen })) })] })] }));
55
+ };
56
+ // The exact per-currency subtotals behind an approximated headline: one chip per native currency, plus a
57
+ // note when some currency has no rate (so its spend isn't in the converted total). Links to currency settings.
58
+ const CurrencyBreakdownRow = ({ breakdown, baseCurrency }) => (_jsxs("div", { className: "flex flex-wrap items-center gap-1.5 text-xs", children: [breakdown.rows.map((r) => (_jsx("span", { className: `rounded-md px-2 py-1 font-mono tabular-nums ${r.convertible ? 'bg-secondary/50' : 'bg-amber-500/10 text-amber-600 dark:text-amber-400'}`, title: r.convertible ? undefined : `No ${r.currency}→${baseCurrency} rate — not in the total`, children: formatMoney(r.mtd, r.currency) }, r.currency))), breakdown.unconverted > 0 ? (_jsxs("span", { className: "text-muted-foreground", children: [breakdown.unconverted, " not converted \u2014 add a rate in Settings"] })) : null] }));
59
+ const ViewToggle = ({ view, onChange }) => (_jsx("div", { className: "bg-secondary/50 flex rounded-md p-0.5 text-[11px]", children: ['compact', 'detailed'].map((v) => (_jsx("button", { onClick: () => onChange(v), className: `rounded px-2 py-0.5 capitalize transition-colors ${view === v ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`, children: v }, v))) }));
60
+ const columnHints = (now, last, prev) => ({
61
+ thisMo: `Spend so far this month (${monthLabel(now)}), still accruing`,
62
+ lastMo: `Total for the last completed month (${monthLabel(last)})`,
63
+ prevMo: `Total for the month before last (${monthLabel(prev)})`,
64
+ mom: `Change from ${monthLabel(prev)} (Prev mo) to ${monthLabel(last)} (Last mo) — the current partial month is excluded`
65
+ });
66
+ // A column header whose label reveals its hint on hover/focus.
67
+ const HeaderTip = ({ label, hint }) => (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx("span", { className: "cursor-default", children: label }) }), _jsx(TooltipContent, { children: hint })] }));
68
+ const MONTH_OPTIONS = [6, 12, 24];
69
+ // How many trailing months the detailed table shows as columns. Mirrors the chart's small dropdown.
70
+ const MonthsPicker = ({ value, onChange }) => (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { 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: [value, " mo", _jsx(ChevronDown, { className: "size-3" })] }), _jsx(DropdownMenuContent, { children: MONTH_OPTIONS.map((n) => (_jsxs(DropdownMenuItem, { onSelect: () => onChange(n), children: [n, " months"] }, n))) })] }));
71
+ // A minimal inline trend line — no ECharts instance per row. Colored by net direction (spend up = red,
72
+ // matching the MoM coloring). Renders nothing for a flat/single-point series.
73
+ const Sparkline = ({ data }) => {
74
+ if (data.length < 2) {
75
+ return null;
76
+ }
77
+ const w = 64;
78
+ const h = 18;
79
+ const max = Math.max(...data);
80
+ const min = Math.min(...data);
81
+ const range = max - min || 1;
82
+ const points = data
83
+ .map((v, i) => `${((i / (data.length - 1)) * w).toFixed(1)},${(h - ((v - min) / range) * h).toFixed(1)}`)
84
+ .join(' ');
85
+ return (_jsx("svg", { width: w, height: h, viewBox: `0 0 ${w} ${h}`, preserveAspectRatio: "none", className: pctClass(data.at(-1) - data[0]), children: _jsx("polyline", { points: points, fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinejoin: "round", strokeLinecap: "round" }) }));
86
+ };
87
+ const CompactSpendTable = ({ rows, currency, hints, onOpen }) => (_jsxs("table", { className: "w-full text-sm", children: [_jsx("thead", { className: "text-muted-foreground border-b text-left text-[11px] uppercase", children: _jsxs("tr", { children: [_jsx("th", { className: "px-4 py-2 font-medium", children: "Service" }), _jsx("th", { className: "px-4 py-2 text-right font-medium", children: _jsx(HeaderTip, { label: "This mo", hint: hints.thisMo }) }), _jsx("th", { className: "px-4 py-2 text-right font-medium", children: _jsx(HeaderTip, { label: "Last mo", hint: hints.lastMo }) }), _jsx("th", { className: "px-4 py-2 text-right font-medium", children: _jsx(HeaderTip, { label: "Prev mo", hint: hints.prevMo }) }), _jsx("th", { className: "px-4 py-2 text-right font-medium", children: _jsx(HeaderTip, { label: "MoM", hint: hints.mom }) }), _jsx("th", { className: "px-4 py-2 text-right font-medium", children: "Share" }), _jsx("th", { className: "px-4 py-2 text-right font-medium", children: "All time" })] }) }), _jsx("tbody", { children: rows.map((r) => (_jsxs("tr", { className: `border-b last:border-0 ${onOpen ? 'hover:bg-secondary/40 cursor-pointer' : ''}`, onClick: onOpen ? () => onOpen(r.pluginId) : undefined, children: [_jsx("td", { className: "px-4 py-2", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(ServiceIcon, { id: r.pluginId, icon: r.icon, name: r.pluginName, color: r.color, size: 16 }), r.pluginName] }) }), _jsx("td", { className: "px-4 py-2 text-right font-mono tabular-nums", children: r.mtd ? formatMoney(r.mtd, currency) : '—' }), _jsx("td", { className: "px-4 py-2 text-right font-mono tabular-nums", children: formatMoney(r.lastMo, currency) }), _jsx("td", { className: "text-muted-foreground px-4 py-2 text-right font-mono tabular-nums", children: formatMoney(r.prevMo, currency) }), _jsxs("td", { className: `px-4 py-2 text-right font-mono tabular-nums ${pctClass(r.momPct)}`, children: [r.momPct > 0 ? '+' : '', r.momPct, "%"] }), _jsxs("td", { className: "text-muted-foreground px-4 py-2 text-right font-mono tabular-nums", children: [r.share, "%"] }), _jsx("td", { className: "px-4 py-2 text-right font-mono tabular-nums", children: formatMoney(r.total, currency) })] }, r.pluginId))) })] }));
88
+ // The wide view: the compact summary columns plus a trend sparkline and one column per older completed month,
89
+ // then the displayed-window Total and the all-time column. `months` is every month the row displays (This mo +
90
+ // Last mo + the older `cols`), driving the window Total. The card's overflow-x-auto scrolls it on narrow widths.
91
+ const DetailedSpendTable = ({ rows, cols, months, nowMonth, currency, hints, onOpen }) => (_jsxs("table", { className: "w-full text-sm whitespace-nowrap", children: [_jsx("thead", { className: "text-muted-foreground border-b text-left text-[11px] uppercase", children: _jsxs("tr", { children: [_jsx("th", { className: "px-4 py-2 font-medium", children: "Service" }), _jsx("th", { className: "px-4 py-2 text-right font-medium", children: _jsx(HeaderTip, { label: "This mo", hint: hints.thisMo }) }), _jsx("th", { className: "px-4 py-2 text-right font-medium", children: _jsx(HeaderTip, { label: "Last mo", hint: hints.lastMo }) }), _jsx("th", { className: "px-4 py-2 text-right font-medium", children: _jsx(HeaderTip, { label: "MoM", hint: hints.mom }) }), _jsx("th", { className: "px-4 py-2 text-right font-medium", children: "Share" }), _jsx("th", { className: "px-4 py-2 text-center font-medium", children: "Trend" }), cols.map((m) => (_jsx("th", { className: "px-4 py-2 text-right font-medium", children: monthLabel(m) }, m))), _jsx("th", { className: "px-4 py-2 text-right font-medium", children: "Total" }), _jsx("th", { className: "text-muted-foreground px-4 py-2 text-right font-medium", children: "All time" })] }) }), _jsx("tbody", { children: rows.map((r) => (_jsxs("tr", { className: `border-b last:border-0 ${onOpen ? 'hover:bg-secondary/40 cursor-pointer' : ''}`, onClick: onOpen ? () => onOpen(r.pluginId) : undefined, children: [_jsx("td", { className: "px-4 py-2", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(ServiceIcon, { id: r.pluginId, icon: r.icon, name: r.pluginName, color: r.color, size: 16 }), r.pluginName] }) }), _jsx("td", { className: "px-4 py-2 text-right font-mono tabular-nums", children: r.mtd ? formatMoney(r.mtd, currency) : '—' }), _jsx("td", { className: "px-4 py-2 text-right font-mono tabular-nums", children: formatMoney(r.lastMo, currency) }), _jsxs("td", { className: `px-4 py-2 text-right font-mono tabular-nums ${pctClass(r.momPct)}`, children: [r.momPct > 0 ? '+' : '', r.momPct, "%"] }), _jsxs("td", { className: "text-muted-foreground px-4 py-2 text-right font-mono tabular-nums", children: [r.share, "%"] }), _jsx("td", { className: "px-4 py-2", children: _jsx("div", { className: "flex justify-center", children: _jsx(Sparkline, { data: r.spark }) }) }), cols.map((m) => (_jsx("td", { className: "px-4 py-2 text-right font-mono tabular-nums", children: m in r.byMonth ? formatMoney(r.byMonth[m], currency) : '—' }, m))), _jsx("td", { className: "px-4 py-2 text-right font-mono tabular-nums", children: formatMoney(windowTotal(r, months, nowMonth), currency) }), _jsx("td", { className: "text-muted-foreground px-4 py-2 text-right font-mono tabular-nums", children: formatMoney(r.total, currency) })] }, r.pluginId))) })] }));
92
+ const MoverList = ({ title, movers: items, currency, onOpen }) => (_jsxs(Card, { className: "gap-2 py-3", children: [_jsx(CardHeader, { className: "px-4", children: _jsx(CardTitle, { className: "text-muted-foreground text-[11px] font-medium", children: title }) }), _jsx(CardContent, { className: "flex flex-wrap gap-2 px-4", children: items.length === 0 ? (_jsx("span", { className: "text-muted-foreground text-xs", children: "\u2014" })) : (items.map((m) => {
93
+ const up = m.delta > 0;
94
+ return (_jsxs("button", { onClick: onOpen ? () => onOpen(m.pluginId) : undefined, className: "bg-secondary/50 hover:bg-secondary flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1 text-xs transition-colors", children: [_jsx(ServiceIcon, { id: m.pluginId, icon: m.icon, name: m.pluginName, color: m.color, size: 14 }), _jsx("span", { children: m.pluginName }), _jsxs("span", { className: `flex items-center font-mono tabular-nums ${up ? 'text-red-500 dark:text-red-400' : 'text-emerald-500 dark:text-emerald-400'}`, children: [up ? _jsx(ArrowUpRight, { className: "size-3" }) : _jsx(ArrowDownRight, { className: "size-3" }), formatMoneyCompact(Math.abs(m.delta), currency)] })] }, m.pluginId));
95
+ })) })] }));
96
+ // The Balances band: money you HOLD, never folded into spend. A net-worth rollup + one row per balance
97
+ // plugin. Renders only when populated, so it's invisible until a balance-section plugin (e.g. a bank) lands.
98
+ const BalancesSection = ({ plugins, fx, onOpen }) => {
99
+ const t = useLabels();
100
+ const { baseCurrency, rates } = fx;
101
+ // Net worth is summed in the base currency; a balance whose currency has no rate is left out of the total
102
+ // (its native amount still shows on its own row). `≈` flags the conversion when any row needed one.
103
+ let approx = false;
104
+ const netWorth = plugins.reduce((sum, p) => {
105
+ const ccy = p.currency ?? baseCurrency;
106
+ const converted = convert(p.balance ?? 0, ccy, baseCurrency, rates);
107
+ if (ccy !== baseCurrency) {
108
+ approx = true;
109
+ }
110
+ return sum + (converted ?? 0);
111
+ }, 0);
112
+ const rows = [...plugins].sort((a, b) => (b.balance ?? 0) - (a.balance ?? 0));
113
+ return (_jsxs("div", { className: "space-y-3", children: [_jsx("div", { className: "grid grid-cols-1 gap-3 sm:grid-cols-3", children: _jsx(StatCard, { label: t.netWorth, value: `${approx ? '≈ ' : ''}${formatMoney(netWorth, baseCurrency)}` }) }), _jsx(Card, { className: "gap-0 py-0", children: _jsx(CardContent, { className: "p-0", children: _jsx("ul", { children: rows.map((r) => (_jsxs("li", { className: `flex items-center justify-between border-b px-4 py-2.5 text-sm last:border-0 ${onOpen ? 'hover:bg-secondary/40 cursor-pointer' : ''}`, onClick: onOpen ? () => onOpen(r.pluginId) : undefined, children: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(ServiceIcon, { id: r.pluginId, icon: r.icon, name: r.pluginName, color: r.color, size: 16 }), r.pluginName] }), _jsx("span", { className: "font-mono tabular-nums", children: formatMoney(r.balance ?? 0, r.currency ?? baseCurrency) })] }, r.pluginId))) }) }) })] }));
114
+ };
115
+ // The Others band: services whose headline is a per-service 'other' metric (usage/count) — never summed.
116
+ // A light launcher tile each: identity, a derived item count, last-fetched, and a connection-state dot.
117
+ const OthersSection = ({ plugins, onOpen }) => {
118
+ const t = useLabels();
119
+ const prefs = useFormat();
120
+ return (_jsx("div", { className: "grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4", children: plugins.map((p) => (_jsx(Card, { className: `gap-1 py-3 ${onOpen ? 'hover:bg-secondary/40 cursor-pointer transition-colors' : ''}`, onClick: onOpen ? () => onOpen(p.pluginId) : undefined, children: _jsxs(CardContent, { className: "space-y-1 px-4", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(ServiceIcon, { id: p.pluginId, icon: p.icon, name: p.pluginName, color: p.color, size: 16 }), _jsx("span", { className: "truncate font-medium", children: p.pluginName }), _jsx("span", { className: `ml-auto size-2 shrink-0 rounded-full ${p.state === 'connected' ? 'bg-emerald-500' : 'bg-muted-foreground/40'}` })] }), p.itemCount != null ? _jsx("p", { className: "font-mono text-sm tabular-nums", children: t.itemCount(p.itemCount) }) : null, p.lastRunAt ? (_jsx("p", { className: "text-muted-foreground text-xs", children: t.lastFetched(formatDateTime(p.lastRunAt, prefs, t.intlLocale)) })) : null] }) }, p.pluginId))) }));
121
+ };
122
+ const SectionHeader = ({ title }) => (_jsx("h2", { className: "text-muted-foreground text-[11px] font-semibold tracking-wide uppercase", children: title }));
123
+ // The cross-service home: partitions plugins by summary section into Spending / Balances / Others bands (a
124
+ // service appears in each band it reports), rendering only the populated ones. Headers appear only when >1
125
+ // band shows, so a spend-only user sees the spend dashboard with no extra chrome. Pure + prop-driven.
126
+ export const Overview = ({ plugins, baseCurrency = 'USD', rates = {}, onOpen }) => {
127
+ const t = useLabels();
128
+ const fx = { baseCurrency, rates };
129
+ const { spend, balances, others } = partition(plugins);
130
+ const populated = [spend.length > 0, balances.length > 0, others.length > 0].filter(Boolean).length;
131
+ if (populated === 0) {
132
+ return _jsx("p", { className: "text-muted-foreground text-sm", children: t.noDataYet });
133
+ }
134
+ const showHeaders = populated > 1;
135
+ return (_jsxs("div", { className: "space-y-8", children: [spend.length > 0 ? (_jsxs("section", { className: "space-y-3", children: [showHeaders ? _jsx(SectionHeader, { title: t.overviewSpending }) : null, _jsx(SpendingSection, { plugins: spend, fx: fx, onOpen: onOpen })] })) : null, balances.length > 0 ? (_jsxs("section", { className: "space-y-3", children: [showHeaders ? _jsx(SectionHeader, { title: t.overviewBalances }) : null, _jsx(BalancesSection, { plugins: balances, fx: fx, onOpen: onOpen })] })) : null, others.length > 0 ? (_jsxs("section", { className: "space-y-3", children: [showHeaders ? _jsx(SectionHeader, { title: t.overviewOthers }) : null, _jsx(OthersSection, { plugins: others, onOpen: onOpen })] })) : null] }));
136
+ };
@@ -0,0 +1,7 @@
1
+ import { type CapabilityResult, type Dataset, type View } from '@butinapp/sdk/data';
2
+ export declare const defaultViews: (datasets: Dataset[]) => View[];
3
+ export interface PlannedView {
4
+ view: View;
5
+ dataset: Dataset;
6
+ }
7
+ export declare const planViews: (result: CapabilityResult) => PlannedView[];
@@ -0,0 +1,29 @@
1
+ import {} from '@butinapp/sdk/data';
2
+ // Synthesize views for a result that ships none: a record -> stat; a table with a timestamp column and a
3
+ // money|count column -> timeseries; every table also gets a table view.
4
+ export const defaultViews = (datasets) => {
5
+ const views = [];
6
+ for (const ds of datasets) {
7
+ if (ds.shape === 'record') {
8
+ views.push({ type: 'stat', dataset: ds.id });
9
+ continue;
10
+ }
11
+ const ts = ds.columns.find((c) => c.role === 'timestamp');
12
+ const num = ds.columns.find((c) => c.role === 'money' || c.role === 'count');
13
+ if (ts && num) {
14
+ views.push({ type: 'timeseries', dataset: ds.id, x: ts.key, y: num.key });
15
+ }
16
+ views.push({ type: 'table', dataset: ds.id });
17
+ }
18
+ return views;
19
+ };
20
+ // Resolve each view (explicit, or defaulted) to its dataset, dropping any whose dataset id is unknown.
21
+ // The validator already flags dangling refs; the renderer just skips them so one bad view never blanks
22
+ // the whole dashboard.
23
+ export const planViews = (result) => {
24
+ const byId = new Map(result.datasets.map((d) => [d.id, d]));
25
+ const views = result.views ?? defaultViews(result.datasets);
26
+ return views
27
+ .map((view) => ({ view, dataset: byId.get(view.dataset) }))
28
+ .filter((p) => p.dataset != null);
29
+ };
@@ -0,0 +1,31 @@
1
+ import { type Section, type Summary } from '@butinapp/sdk/data';
2
+ import type { MtdBasis } from '@butinapp/sdk/presets';
3
+ import { type FxRates } from '@butinapp/sdk/util';
4
+ export interface RollupTile {
5
+ pluginId: string;
6
+ pluginName: string;
7
+ color?: string;
8
+ state?: 'connected' | 'disconnected';
9
+ summary?: Summary;
10
+ summaries?: Summary[];
11
+ }
12
+ export interface CurrencySubtotal {
13
+ currency: string;
14
+ value: number;
15
+ contributors: number;
16
+ }
17
+ export interface RollupBand {
18
+ section: Section;
19
+ label: string;
20
+ role: 'money' | 'count';
21
+ currency: string;
22
+ value: number;
23
+ contributors: number;
24
+ breakdown: CurrencySubtotal[];
25
+ unconverted: number;
26
+ bases?: Set<MtdBasis>;
27
+ }
28
+ export interface CurrencyRollup {
29
+ bands: RollupBand[];
30
+ }
31
+ export declare const rollup: (tiles: RollupTile[], baseCurrency: string, rates: FxRates) => CurrencyRollup;
@@ -0,0 +1,45 @@
1
+ import {} from '@butinapp/sdk/data';
2
+ import { convert, round2 } from '@butinapp/sdk/util';
3
+ // Combine the spend summaries across services into one band; balance + other never sum (they're per-service).
4
+ // Money is converted to baseCurrency (a currency with no rate stays out of the total and is counted in
5
+ // `unconverted`, never silently summed); the exact per-currency subtotals ride along in `breakdown`.
6
+ export const rollup = (tiles, baseCurrency, rates) => {
7
+ const band = {
8
+ section: 'spend',
9
+ label: 'Spend this month',
10
+ role: 'money',
11
+ currency: baseCurrency,
12
+ value: 0,
13
+ contributors: 0,
14
+ breakdown: [],
15
+ unconverted: 0
16
+ };
17
+ for (const tile of tiles) {
18
+ const spend = (tile.summaries ?? []).find((s) => s.section === 'spend');
19
+ if (!spend || spend.role !== 'money') {
20
+ continue;
21
+ }
22
+ band.contributors += 1;
23
+ if (spend.basis) {
24
+ ;
25
+ (band.bases ??= new Set()).add(spend.basis);
26
+ }
27
+ const ccy = spend.currency ?? baseCurrency;
28
+ const sub = band.breakdown.find((b) => b.currency === ccy);
29
+ if (sub) {
30
+ sub.value = round2(sub.value + spend.value);
31
+ sub.contributors += 1;
32
+ }
33
+ else {
34
+ band.breakdown.push({ currency: ccy, value: round2(spend.value), contributors: 1 });
35
+ }
36
+ const converted = convert(spend.value, ccy, baseCurrency, rates);
37
+ if (converted === null) {
38
+ band.unconverted += 1;
39
+ }
40
+ else {
41
+ band.value = round2(band.value + converted);
42
+ }
43
+ }
44
+ return { bands: band.contributors > 0 ? [band] : [] };
45
+ };
@@ -0,0 +1,11 @@
1
+ import { type ChartPoint } from './view-models.js';
2
+ export declare const TimeseriesChart: ({ monthly, daily, isMoney, currency, title, height, highlightCurrentMonth, estimateCurrentMonth }: {
3
+ monthly: ChartPoint[];
4
+ daily?: ChartPoint[];
5
+ isMoney?: boolean;
6
+ currency?: string;
7
+ title?: string;
8
+ height?: number;
9
+ highlightCurrentMonth?: boolean;
10
+ estimateCurrentMonth?: number;
11
+ }) => import("react").JSX.Element;