@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,15 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Tabs as TabsPrimitive } from 'radix-ui';
3
+ import { cn } from '../lib/utils.js';
4
+ // Tabs (radix). The default list is a pill rail; variant="line" is an underlined bar.
5
+ // The trigger reads the list's variant via the named `group/list` so List + Trigger stay decoupled.
6
+ export const Tabs = ({ className, ...props }) => (_jsx(TabsPrimitive.Root, { "data-slot": "tabs", className: cn('flex flex-col gap-3', className), ...props }));
7
+ export const TabsList = ({ className, variant = 'pill', ...props }) => (_jsx(TabsPrimitive.List, { "data-slot": "tabs-list", "data-variant": variant, className: cn('group/list inline-flex items-center', variant === 'pill'
8
+ ? 'bg-muted text-muted-foreground h-9 w-fit gap-1 rounded-lg p-1'
9
+ : 'text-muted-foreground h-9 w-full gap-4 border-b', className), ...props }));
10
+ export const TabsTrigger = ({ className, ...props }) => (_jsx(TabsPrimitive.Trigger, { "data-slot": "tabs-trigger", className: cn("inline-flex cursor-pointer items-center justify-center gap-1.5 text-sm font-medium whitespace-nowrap transition-colors outline-none hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
11
+ // pill list — active trigger gets a raised card
12
+ 'group-data-[variant=pill]/list:rounded-md group-data-[variant=pill]/list:px-2.5 group-data-[variant=pill]/list:py-1 group-data-[variant=pill]/list:data-[state=active]:bg-background group-data-[variant=pill]/list:data-[state=active]:shadow-sm',
13
+ // line list — active trigger grows an underline from the list's bottom border
14
+ 'group-data-[variant=line]/list:-mb-px group-data-[variant=line]/list:h-full group-data-[variant=line]/list:border-b-2 group-data-[variant=line]/list:border-transparent group-data-[variant=line]/list:px-0.5 group-data-[variant=line]/list:data-[state=active]:border-primary', className), ...props }));
15
+ export const TabsContent = ({ className, ...props }) => (_jsx(TabsPrimitive.Content, { "data-slot": "tabs-content", className: cn('flex-1 outline-none', className), ...props }));
@@ -0,0 +1,6 @@
1
+ import { Tooltip as TooltipPrimitive } from 'radix-ui';
2
+ import type { ComponentProps } from 'react';
3
+ export declare const TooltipProvider: ({ delayDuration, ...props }: ComponentProps<typeof TooltipPrimitive.Provider>) => import("react").JSX.Element;
4
+ export declare const Tooltip: ({ ...props }: ComponentProps<typeof TooltipPrimitive.Root>) => import("react").JSX.Element;
5
+ export declare const TooltipTrigger: import("react").ForwardRefExoticComponent<TooltipPrimitive.TooltipTriggerProps & import("react").RefAttributes<HTMLButtonElement>>;
6
+ export declare const TooltipContent: ({ className, sideOffset, children, ...props }: ComponentProps<typeof TooltipPrimitive.Content>) => import("react").JSX.Element;
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Tooltip as TooltipPrimitive } from 'radix-ui';
3
+ import { cn } from '../lib/utils.js';
4
+ // Hover/focus tooltip (radix). Provider self-included so a bare <Tooltip> works without app-level wiring.
5
+ export const TooltipProvider = ({ delayDuration = 200, ...props }) => (_jsx(TooltipPrimitive.Provider, { "data-slot": "tooltip-provider", delayDuration: delayDuration, ...props }));
6
+ export const Tooltip = ({ ...props }) => (_jsx(TooltipProvider, { children: _jsx(TooltipPrimitive.Root, { "data-slot": "tooltip", ...props }) }));
7
+ export const TooltipTrigger = TooltipPrimitive.Trigger;
8
+ export const TooltipContent = ({ className, sideOffset = 4, children, ...props }) => (_jsx(TooltipPrimitive.Portal, { children: _jsx(TooltipPrimitive.Content, { "data-slot": "tooltip-content", sideOffset: sideOffset, className: cn('bg-popover text-popover-foreground z-50 w-fit rounded-md border px-2.5 py-1.5 text-xs shadow-md', 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0', className), ...props, children: children }) }));
@@ -0,0 +1,3 @@
1
+ export declare const TruncatedCell: ({ text }: {
2
+ text: string;
3
+ }) => import("react").JSX.Element | null;
@@ -0,0 +1,22 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Check, Copy } from 'lucide-react';
3
+ import { useState } from 'react';
4
+ import { useLabels } from '../i18n/context.js';
5
+ // A width-capped table cell for long free-text values. The value shows as one ellipsized line (a native
6
+ // `title` gives a hover peek); clicking opens a popover holding the full, selectable, copyable value. Plain
7
+ // useState (not a radix popover) so it opens under fireEvent.click in jsdom — radix menus need pointer-capture
8
+ // jsdom lacks. Empty text renders nothing.
9
+ export const TruncatedCell = ({ text }) => {
10
+ const t = useLabels();
11
+ const [open, setOpen] = useState(false);
12
+ const [copied, setCopied] = useState(false);
13
+ if (text === '') {
14
+ return null;
15
+ }
16
+ const copy = () => {
17
+ void navigator.clipboard?.writeText(text);
18
+ setCopied(true);
19
+ setTimeout(() => setCopied(false), 1500);
20
+ };
21
+ return (_jsxs("div", { className: "relative", children: [_jsx("button", { type: "button", title: text, "aria-haspopup": "dialog", "aria-expanded": open, onClick: () => setOpen((v) => !v), className: "hover:text-foreground block w-full max-w-full truncate text-left", children: text }), open && (_jsxs(_Fragment, { children: [_jsx("div", { className: "fixed inset-0 z-40", onClick: () => setOpen(false), "aria-hidden": true }), _jsxs("div", { role: "dialog", className: "bg-popover absolute left-0 z-50 mt-1 w-[min(34rem,80vw)] rounded-md border p-2 shadow-md", children: [_jsx("p", { className: "max-h-[60vh] overflow-auto text-sm whitespace-pre-wrap select-text", children: text }), _jsx("div", { className: "mt-2 flex justify-end", children: _jsxs("button", { type: "button", onClick: copy, className: "text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs", children: [copied ? _jsx(Check, { className: "size-3.5" }) : _jsx(Copy, { className: "size-3.5" }), copied ? t.cellCopied : t.cellCopy] }) })] })] }))] }));
22
+ };
@@ -0,0 +1,12 @@
1
+ export { DashboardRenderer } from './features/dashboard/dashboard-renderer.js';
2
+ export type { FileDownloadStatus, TableFilesBridge } from './features/dashboard/dashboard-renderer.js';
3
+ export { formatByRole } from './features/dashboard/format-role.js';
4
+ export { findCumulativeColumn } from './features/dashboard/view-models.js';
5
+ export { TimeseriesChart } from './features/dashboard/timeseries-chart.js';
6
+ export type { ChartPoint } from './features/dashboard/view-models.js';
7
+ export { Overview } from './features/dashboard/overview.js';
8
+ export type { OverviewPlugin } from './features/dashboard/overview-model.js';
9
+ export type { RollupTile, RollupBand } from './features/dashboard/rollup.js';
10
+ export { formatSize } from './features/documents/doc-models.js';
11
+ export { formatUsd, formatUsdCompact, formatMoney } from './lib/format.js';
12
+ export { useEchartsTheme, asCanvasColor, grid } from './lib/echarts-theme.js';
@@ -0,0 +1,13 @@
1
+ // @butinapp/ui/dashboard — the embeddable data-view layer: the generic descriptor-driven renderer, the
2
+ // cross-service Overview, and the chart/format helpers a host needs to compose its own dashboards. This is
3
+ // what an out-of-repo embed (the snapshot viewer) consumes — it carries NO Electron app chrome (see /shell).
4
+ export { DashboardRenderer } from './features/dashboard/dashboard-renderer.js';
5
+ export { formatByRole } from './features/dashboard/format-role.js';
6
+ export { findCumulativeColumn } from './features/dashboard/view-models.js';
7
+ export { TimeseriesChart } from './features/dashboard/timeseries-chart.js';
8
+ export { Overview } from './features/dashboard/overview.js';
9
+ // Downloadable-table file size cell used by the renderer.
10
+ export { formatSize } from './features/documents/doc-models.js';
11
+ // Chart/format utilities (for hosts composing their own dashboards).
12
+ export { formatUsd, formatUsdCompact, formatMoney } from './lib/format.js';
13
+ export { useEchartsTheme, asCanvasColor, grid } from './lib/echarts-theme.js';
@@ -0,0 +1,7 @@
1
+ import type { ReactNode } from 'react';
2
+ export declare const StatCard: ({ label, value, sub, valueClass }: {
3
+ label: string;
4
+ value: ReactNode;
5
+ sub?: ReactNode;
6
+ valueClass?: string;
7
+ }) => import("react").JSX.Element;
@@ -0,0 +1,4 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Card, CardContent } from '../components/card.js';
3
+ // Compact label/value stat tile. `value`/`sub` accept rich nodes; money should be a `$` string.
4
+ export const StatCard = ({ label, value, sub, valueClass }) => (_jsx(Card, { className: "h-full gap-0 py-3", children: _jsxs(CardContent, { className: "px-4", children: [_jsx("div", { className: "text-muted-foreground truncate text-[11px]", children: label }), _jsx("div", { className: `mt-0.5 font-mono text-lg leading-tight font-semibold break-words tabular-nums ${valueClass ?? ''}`, title: typeof value === 'string' ? value : undefined, children: value }), sub ? _jsx("div", { className: "text-muted-foreground mt-0.5 text-[10px]", children: sub }) : null] }) }));
@@ -0,0 +1,32 @@
1
+ import { type CapabilityResult } from '@butinapp/sdk/data';
2
+ import type { DailyPoint } from '@butinapp/shapes';
3
+ import type { DataTableState } from '../../components/data-table-model.js';
4
+ export type FileDownloadStatus = 'queued' | 'downloading' | 'done' | 'skipped' | 'error';
5
+ export type TableFilesBridge = {
6
+ located: Record<string, {
7
+ path: string;
8
+ sizeBytes: number;
9
+ }>;
10
+ statuses: Record<string, FileDownloadStatus>;
11
+ selection: Set<string>;
12
+ onSelectionChange: (next: Set<string>) => void;
13
+ onDownload: (selection: string[] | 'all') => void;
14
+ onOpen: (path: string) => void;
15
+ busy: boolean;
16
+ folderPath?: string;
17
+ onPickFolder?: () => void;
18
+ onRevealFolder?: () => void;
19
+ };
20
+ export declare const DashboardRenderer: ({ result, daily, rowDaily, tableState, onTableStateChange, onExport, downloads, width }: {
21
+ result: CapabilityResult;
22
+ daily?: DailyPoint[];
23
+ rowDaily?: Record<string, Record<string, DailyPoint[]>>;
24
+ tableState?: DataTableState;
25
+ onTableStateChange?: (next: DataTableState) => void;
26
+ onExport?: (blob: {
27
+ text: string;
28
+ mime: string;
29
+ }, filename: string) => void;
30
+ downloads?: TableFilesBridge;
31
+ width?: 'auto' | 'wide' | 'narrow';
32
+ }) => import("react").JSX.Element;
@@ -0,0 +1,322 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { resolveTableFiles } from '@butinapp/sdk/data';
3
+ import { startCase } from '@butinapp/sdk/util';
4
+ import { ChevronDown, ChevronRight, Download, ExternalLink, FolderCog, FolderOpen, Loader2 } from 'lucide-react';
5
+ import { useState } from 'react';
6
+ import { Badge } from '../../components/badge.js';
7
+ import { Button } from '../../components/button.js';
8
+ import { Card, CardContent, CardHeader, CardTitle } from '../../components/card.js';
9
+ import { DataTable } from '../../components/data-table.js';
10
+ import { Progress } from '../../components/progress.js';
11
+ import { Sparkline } from '../../components/sparkline.js';
12
+ import { TruncatedCell } from '../../components/truncated-cell.js';
13
+ import { useLabels } from '../../i18n/context.js';
14
+ import { useFormat } from '../../i18n/format-context.js';
15
+ import { resolveNumberLocale } from '../../i18n/format.js';
16
+ import { cn } from '../../lib/utils.js';
17
+ import { StatCard } from '../charts.js';
18
+ import { formatSize } from '../documents/doc-models.js';
19
+ import { OpenFileButton, ViewFileLink } from '../documents/open-file-button.js';
20
+ import { formatByRole } from './format-role.js';
21
+ import { planViews } from './plan-views.js';
22
+ import { TimeseriesChart } from './timeseries-chart.js';
23
+ import { datasetRowId, categoricalTone, defaultTableSort, groupDailyByMonth, latestMonthDaily, monthlyAggregate, recordRows, resolveBadgeTone, seriesData, stackedSeries, statCards } from './view-models.js';
24
+ // A table with at least this many columns is treated as "wide" and the dashboard widens its container so the
25
+ // data breathes instead of wrapping. Content-derived layout, not a per-plugin setting.
26
+ const WIDE_TABLE_COLS = 6;
27
+ const Panel = ({ title, children }) => (_jsxs(Card, { className: "gap-2 py-3", children: [title ? (_jsx(CardHeader, { className: "px-4", children: _jsx(CardTitle, { className: "text-muted-foreground text-[11px] font-medium", children: title }) })) : null, _jsx(CardContent, { className: "px-4", children: children })] }));
28
+ // Value tint for a stat whose field declared a tone (e.g. a negative spend delta reads as a win → green).
29
+ const TONE_CLASS = {
30
+ positive: 'text-emerald-500',
31
+ negative: 'text-rose-500',
32
+ muted: 'text-muted-foreground'
33
+ };
34
+ const StatView = ({ ds, fields, locale }) => {
35
+ const t = useLabels();
36
+ return (_jsx("div", { className: "grid grid-cols-2 gap-2 sm:grid-cols-4", children: statCards(ds, fields, locale).map((c) => (_jsx(StatCard, { label: t.s(c.label), valueClass: c.tone ? TONE_CLASS[c.tone] : undefined, value: _jsxs("span", { children: [c.value, c.unit ? _jsxs("span", { className: "text-muted-foreground text-xs font-normal", children: [" ", c.unit] }) : null, c.denominator ? (_jsxs("span", { className: "text-muted-foreground text-sm font-normal", children: [" / ", c.denominator] })) : null] }), sub: c.progress != null || c.caption ? (_jsxs("div", { className: "flex flex-col gap-1", children: [c.progress != null ? (_jsxs("div", { className: "mt-1 flex items-center gap-2", children: [_jsx(Progress, { value: c.progress }), c.percentLabel ? (_jsx("span", { className: "text-muted-foreground shrink-0 text-[10px] tabular-nums", children: c.percentLabel })) : null] })) : null, c.caption ? _jsx("span", { children: c.caption }) : null] })) : undefined }, c.key))) }));
37
+ };
38
+ const KeyValueView = ({ ds, title, fields, locale }) => {
39
+ const t = useLabels();
40
+ return (_jsx(Panel, { title: title, children: _jsx("dl", { className: "grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-xs", children: recordRows(ds, fields, locale).map((r) => (_jsxs("div", { className: "contents", children: [_jsx("dt", { className: "text-muted-foreground", children: t.s(r.label) }), _jsx("dd", { className: "text-right tabular-nums", children: r.value })] }, r.label))) }) }));
41
+ };
42
+ // Render one cell body by its column's semantic role: a url → external-link icon, a status → sentiment badge,
43
+ // a category → distinct-hue badge, a truncatable free-text → a peek/copy cell, everything else → formatByRole.
44
+ // Shared by the main table and a nested row-detail table so both format identically.
45
+ const renderRoleCell = (c, raw, locale) => {
46
+ if (c.role === 'url' && typeof raw === 'string' && raw !== '') {
47
+ return (_jsx("a", { href: raw, target: "_blank", rel: "noreferrer", className: "text-muted-foreground hover:text-foreground inline-flex", children: _jsx(ExternalLink, { className: "size-3.5" }) }));
48
+ }
49
+ if (c.role === 'status' && raw != null && raw !== '') {
50
+ // Sentiment tone inferred from the value (a `badges` override wins); the label is title-cased so a machine
51
+ // enum ('past_due', 'paid') reads cleanly without each plugin pre-formatting it.
52
+ return _jsx(Badge, { variant: resolveBadgeTone(raw, c.badges), children: startCase(String(raw)) });
53
+ }
54
+ if (c.role === 'category' && raw != null && raw !== '') {
55
+ // A distinct, stable hue per value (no sentiment) — same value, same color everywhere.
56
+ return _jsx(Badge, { variant: categoricalTone(raw), children: startCase(String(raw)) });
57
+ }
58
+ if (c.truncate && raw != null && raw !== '') {
59
+ return _jsx(TruncatedCell, { text: String(raw) });
60
+ }
61
+ return formatByRole(raw, c.role, c.currency, locale);
62
+ };
63
+ // A parent row's expanded detail: a compact table of the child dataset's rows (already filtered to this
64
+ // parent), drawn with the child's own column roles.
65
+ const DetailTable = ({ ds, rows, locale }) => {
66
+ const t = useLabels();
67
+ const cols = ds.columns.filter((c) => !c.hidden);
68
+ const alignRight = (c) => c.role === 'money' || c.role === 'count' || c.role === 'percent';
69
+ return (_jsx("div", { className: "px-2 py-1.5", children: _jsxs("table", { className: "w-full text-xs", children: [_jsx("thead", { className: "text-muted-foreground text-[10px] tracking-wide uppercase", children: _jsx("tr", { className: "text-left", children: cols.map((c) => (_jsx("th", { className: cn('py-1 pr-4 font-medium', alignRight(c) && 'text-right'), children: t.s(c.label ?? c.key) }, c.key))) }) }), _jsx("tbody", { children: rows.map((row, i) => (_jsx("tr", { className: "border-border/40 border-t", children: cols.map((c) => (_jsx("td", { className: cn('py-1 pr-4 tabular-nums', alignRight(c) && 'text-right'), children: renderRoleCell(c, row[c.key], locale) }, c.key))) }, i))) })] }) }));
70
+ };
71
+ const TableView = ({ ds, title, columns, groupBy, locale, tableState, onTableStateChange, onExport, files, downloads, rowDaily, detailDataset, detailOn }) => {
72
+ const t = useLabels();
73
+ const getRowId = (row) => String(ds.rows.indexOf(row));
74
+ // A table with a date column opens newest-first; a saved sort pref (loaded into tableState) always wins.
75
+ const initialState = tableState?.sort
76
+ ? tableState
77
+ : { ...tableState, sort: defaultTableSort(ds.columns) };
78
+ const downloadable = Boolean(files && downloads);
79
+ const resolved = downloadable ? resolveTableFiles(files) : undefined;
80
+ // Columns with hidden:true are machinery fields (accumulation keys, download filenames, fetchFile payloads)
81
+ // that must not appear in the table body — filter them out before anything else.
82
+ const picked = (columns ? columns.map((k) => ds.columns.find((c) => c.key === k)).filter((c) => c != null) : ds.columns).filter((c) => !c.hidden);
83
+ // The groupBy column renders as the section header, not a body column. For a downloadable table the url
84
+ // column also leaves the body — it folds into the trailing View/Open action — so every download panel ends
85
+ // in `… Size · action`.
86
+ const hidden = new Set([
87
+ ...(groupBy ? [groupBy] : []),
88
+ ...(downloadable && resolved?.urlKey ? [resolved.urlKey] : [])
89
+ ]);
90
+ const body = picked.filter((c) => !hidden.has(c.key));
91
+ const cols = body.map((c) => ({
92
+ key: c.key,
93
+ label: t.s(c.label ?? c.key),
94
+ align: c.role === 'money' || c.role === 'count' || c.role === 'percent' ? 'right' : 'left',
95
+ // Short fixed-width roles stay on one line; free text (label/text/url) wraps + absorbs the slack.
96
+ noWrap: ['timestamp', 'money', 'count', 'percent', 'status', 'identifier'].includes(c.role),
97
+ // A truncated column is the greedy one — it fills the leftover row width and ellipsizes there.
98
+ grow: c.truncate,
99
+ // Floor the amount columns so the greedy column can't squeeze them below a comfortable width. Dates don't
100
+ // need a floor — the 'Order date' header already sizes the column wider than a 'YYYY-MM-DD' value.
101
+ minWidth: c.role === 'money' ? 'min-w-24' : undefined,
102
+ sortValue: (row) => {
103
+ const raw = row[c.key];
104
+ return typeof raw === 'number' ? raw : raw == null ? undefined : String(raw);
105
+ },
106
+ exportValue: (row) => {
107
+ const raw = row[c.key];
108
+ return typeof raw === 'number' ? raw : raw == null ? undefined : String(raw);
109
+ },
110
+ cell: (row) => renderRoleCell(c, row[c.key], locale)
111
+ }));
112
+ // A cumulative column (an MTD-per-row counter) with a per-row daily series → a trailing Trend column: each
113
+ // row's current-month day-over-day series as a sparkline. Fewer than two days this month (or no series)
114
+ // renders an em dash. The column is informational, so it never sorts/exports.
115
+ const trendCol = rowDaily ? ds.columns.find((c) => c.accrual === 'cumulative') : undefined;
116
+ if (trendCol) {
117
+ cols.push({
118
+ key: '__trend',
119
+ label: t.s('Trend'),
120
+ align: 'right',
121
+ sortable: false,
122
+ hideable: false,
123
+ cell: (row) => {
124
+ const id = datasetRowId(row, ds.key);
125
+ const month = latestMonthDaily(id ? (rowDaily[id] ?? []) : []);
126
+ return month.length >= 2 ? (_jsx(Sparkline, { values: month.map((p) => p.value), className: "text-foreground/70" })) : (_jsx("span", { className: "text-muted-foreground", children: "\u2014" }));
127
+ }
128
+ });
129
+ }
130
+ // Downloadable tables end in two fixed columns: Size (on-disk size) second-to-last, then the trailing
131
+ // action last. The action is additive + state-aware: View (when the row has a urlKey URL — opens it
132
+ // externally) and Open (once on disk) can both show; a spinner while fetching; '—' when there's neither.
133
+ if (downloadable) {
134
+ const urlKey = resolved.urlKey;
135
+ const rowUrl = (row) => {
136
+ const raw = urlKey ? row[urlKey] : undefined;
137
+ return typeof raw === 'string' && raw !== '' ? raw : undefined;
138
+ };
139
+ cols.push({
140
+ key: '__size',
141
+ label: t.s('Size'),
142
+ align: 'right',
143
+ sortable: false,
144
+ hideable: false,
145
+ minWidth: 'min-w-20',
146
+ cell: (row) => {
147
+ const loc = downloads.located[getRowId(row)];
148
+ return loc ? formatSize(loc.sizeBytes) : '—';
149
+ }
150
+ });
151
+ cols.push({
152
+ key: '__files',
153
+ label: '',
154
+ align: 'right',
155
+ sortable: false,
156
+ hideable: false,
157
+ exportValue: (row) => {
158
+ const loc = downloads.located[getRowId(row)];
159
+ return loc ? loc.path : rowUrl(row);
160
+ },
161
+ cell: (row) => {
162
+ const id = getRowId(row);
163
+ const loc = downloads.located[id];
164
+ const url = rowUrl(row);
165
+ const actions = [];
166
+ if (url) {
167
+ actions.push(_jsx(ViewFileLink, { url: url }, "view"));
168
+ }
169
+ if (loc) {
170
+ actions.push(_jsx(OpenFileButton, { onClick: () => downloads.onOpen(loc.path) }, "open"));
171
+ }
172
+ else if (downloads.statuses[id] === 'downloading') {
173
+ actions.push(_jsx(Loader2, { className: "text-muted-foreground size-3.5 animate-spin" }, "spin"));
174
+ }
175
+ return actions.length > 0 ? (_jsx("div", { className: "flex items-center justify-end gap-1", children: actions })) : (_jsx("span", { className: "text-muted-foreground", children: "\u2014" }));
176
+ }
177
+ });
178
+ }
179
+ const selectedIds = downloadable ? [...downloads.selection] : [];
180
+ const actionBar = downloadable && downloads ? (_jsxs(_Fragment, { children: [downloads.folderPath ? (_jsxs("div", { className: "text-muted-foreground mr-auto flex min-w-0 items-center gap-1.5 text-xs", children: [_jsx("span", { className: "shrink-0", children: t.documentsFolderLabel }), _jsx("span", { className: "truncate font-mono", children: downloads.folderPath }), downloads.onPickFolder ? (_jsxs("button", { onClick: downloads.onPickFolder, className: "hover:text-foreground inline-flex items-center gap-1", children: [_jsx(FolderCog, { className: "size-3.5" }), " ", t.documentsChangeFolder] })) : null] })) : null, downloads.onRevealFolder ? (_jsxs(Button, { size: "xs", variant: "ghost", onClick: downloads.onRevealFolder, children: [_jsx(FolderOpen, {}), " ", t.openFolder] })) : null, _jsxs(Button, { size: "xs", variant: "outline", disabled: downloads.busy, onClick: () => downloads.onDownload('all'), children: [downloads.busy ? _jsx(Loader2, { className: "animate-spin" }) : _jsx(Download, {}), " ", t.downloadAll] }), _jsxs(Button, { size: "xs", disabled: downloads.busy || selectedIds.length === 0, onClick: () => downloads.onDownload(selectedIds), children: [downloads.busy ? _jsx(Loader2, { className: "animate-spin" }) : _jsx(Download, {}), ' ', t.downloadSelected(selectedIds.length)] })] })) : undefined;
181
+ // Collapsible sections keyed by the groupBy column; for a downloadable grouped table each section gets a
182
+ // "Download N selected" action (only when rows in it are checked), mirroring the per-group download.
183
+ const grouped = groupBy != null
184
+ ? {
185
+ by: (row) => String(row[groupBy] ?? 'Other'),
186
+ groupAction: downloadable && downloads
187
+ ? (_key, _ids, selectedIds) => selectedIds.length > 0 ? (_jsx(Button, { size: "xs", variant: "ghost", disabled: downloads.busy, onClick: () => downloads.onDownload(selectedIds), children: t.downloadSelected(selectedIds.length) })) : null
188
+ : undefined
189
+ }
190
+ : undefined;
191
+ // Row expansion. An explicit row-detail binding (a static child table) wins; otherwise a cumulative column
192
+ // with a per-row daily series expands each row to its real per-day usage (the same derived series the Trend
193
+ // column draws) so the day-by-day breakdown is readable even when the sparkline can't draw yet.
194
+ const expandable = detailDataset && detailOn
195
+ ? (row) => {
196
+ const parentVal = row[detailOn];
197
+ const childRows = detailDataset.rows.filter((r) => r[detailOn] === parentVal);
198
+ return childRows.length > 0 ? _jsx(DetailTable, { ds: detailDataset, rows: childRows, locale: locale }) : null;
199
+ }
200
+ : trendCol
201
+ ? (row) => {
202
+ const id = datasetRowId(row, ds.key);
203
+ const series = id ? rowDaily[id] : undefined;
204
+ return series && series.length > 0 ? (_jsx(DailyDetail, { series: series, role: trendCol.role, currency: trendCol.currency, locale: locale })) : null;
205
+ }
206
+ : undefined;
207
+ return (_jsx(Panel, { title: title, children: _jsx(DataTable, { columns: cols, rows: ds.rows, getRowId: getRowId, selection: downloadable ? downloads.selection : undefined, onSelectionChange: downloadable ? downloads.onSelectionChange : undefined, grouped: grouped, expandable: expandable, actionBar: actionBar, paginated: true, toolbar: { search: true, columns: true },
208
+ // The search floats into the panel's title row; without a title there's no row to float into, so it
209
+ // sits inline instead of drifting into the empty space above the card.
210
+ searchFloating: Boolean(title), initialState: initialState, onStateChange: onTableStateChange, onExport: onExport, exportName: ds.id }) }));
211
+ };
212
+ const MONTHS3 = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
213
+ // Short axis label for an x key: a 'YYYY-MM' month → "Feb '26", a 'YYYY-MM-DD' day → "Feb 3", else the key.
214
+ const axisLabel = (key) => {
215
+ const month = /^(\d{4})-(\d{2})$/.exec(key);
216
+ if (month) {
217
+ return `${MONTHS3[Number(month[2]) - 1] ?? month[2]} '${month[1].slice(2)}`;
218
+ }
219
+ const day = /^\d{4}-(\d{2})-(\d{2})$/.exec(key);
220
+ return day ? `${MONTHS3[Number(day[1]) - 1] ?? day[1]} ${Number(day[2])}` : key;
221
+ };
222
+ // The expanded per-row detail for a cumulative column: a compact trend of the current month's daily series
223
+ // (drawn only when there are at least two days to trend) plus month sections — newest month first, days
224
+ // ascending within each, with the month's subtotal. Only the current month opens by default; previous months
225
+ // stay collapsed behind their header and expand individually, so a long history reads by period. Each value is
226
+ // formatted by the column's role and estimated days are flagged.
227
+ const DailyDetail = ({ series, role, currency, locale }) => {
228
+ const t = useLabels();
229
+ const groups = groupDailyByMonth(series);
230
+ // Newest month open, the rest collapsed.
231
+ const [open, setOpen] = useState(() => new Set(groups[0] ? [groups[0].month] : []));
232
+ const toggle = (month) => setOpen((prev) => {
233
+ const next = new Set(prev);
234
+ if (next.has(month)) {
235
+ next.delete(month);
236
+ }
237
+ else {
238
+ next.add(month);
239
+ }
240
+ return next;
241
+ });
242
+ const trend = groups[0]?.points ?? [];
243
+ return (_jsxs("div", { className: "space-y-3", children: [_jsxs("div", { className: "text-muted-foreground flex items-center gap-2 text-[10px] font-medium tracking-wide uppercase", children: [t.perDayHeading, trend.length >= 2 ? _jsx(Sparkline, { values: trend.map((p) => p.value), className: "text-foreground/70" }) : null] }), groups.map((g) => {
244
+ const isOpen = open.has(g.month);
245
+ return (_jsxs("div", { className: "space-y-1", children: [_jsxs("button", { onClick: () => toggle(g.month), "aria-expanded": isOpen, className: "hover:text-foreground flex w-full items-center justify-between border-b pb-0.5 text-[11px]", children: [_jsxs("span", { className: "text-foreground inline-flex items-center gap-1 font-medium", children: [isOpen ? _jsx(ChevronDown, { className: "size-3" }) : _jsx(ChevronRight, { className: "size-3" }), g.label] }), _jsx("span", { className: "text-muted-foreground tabular-nums", children: formatByRole(g.subtotal, role, currency, locale) })] }), isOpen ? (_jsx("ul", { className: "grid grid-cols-2 gap-x-6 gap-y-0.5 text-xs sm:grid-cols-3 lg:grid-cols-4", children: g.points.map((p) => (_jsxs("li", { className: "flex items-center justify-between gap-3", children: [_jsx("span", { className: "text-muted-foreground", children: axisLabel(p.date) }), _jsxs("span", { className: "tabular-nums", children: [formatByRole(p.value, role, currency, locale), p.estimated ? _jsxs("span", { className: "text-muted-foreground/70", children: [" ", t.estimatedTag] }) : null] })] }, p.date))) })) : null] }, g.month));
246
+ })] }));
247
+ };
248
+ const renderView = (view, dataset, locale, tx, tableProps, daily, downloads, rowDaily, currentMonthEstimate, byId) => {
249
+ const title = view.title ? tx(view.title) : undefined;
250
+ if (view.type === 'stat' && dataset.shape === 'record') {
251
+ return _jsx(StatView, { ds: dataset, fields: view.fields, locale: locale });
252
+ }
253
+ if (view.type === 'keyvalue' && dataset.shape === 'record') {
254
+ return _jsx(KeyValueView, { ds: dataset, title: title, locale: locale });
255
+ }
256
+ if (view.type === 'table' && dataset.shape === 'table') {
257
+ // Resolve a row-detail binding to its child TABLE dataset; a dangling or non-table ref just drops the
258
+ // expansion (the validator already flags it) rather than blanking the table.
259
+ const child = view.detail ? byId?.get(view.detail.dataset) : undefined;
260
+ const detailDataset = child?.shape === 'table' ? child : undefined;
261
+ return (_jsx(TableView, { ds: dataset, title: title, columns: view.columns, groupBy: view.groupBy, locale: locale, tableState: tableProps?.state, onTableStateChange: tableProps?.onStateChange, onExport: tableProps?.onExport, files: view.files, downloads: view.files ? downloads : undefined, rowDaily: rowDaily?.[dataset.id], detailDataset: detailDataset, detailOn: detailDataset ? view.detail?.on : undefined }));
262
+ }
263
+ if (view.type === 'timeseries' && dataset.shape === 'table') {
264
+ // Every timeseries draws on the rich TimeseriesChart (single or stacked). The host normalizes the dataset
265
+ // into `monthly` (always) + an optional per-day `daily` series by source granularity: a daily-source view
266
+ // opens in Monthly mode (its days aggregated) and drills into days; a monthly-source single series uses the
267
+ // host-derived `daily` for its drill-down and gap-fills its month axis; a stacked monthly series pivots to
268
+ // long-format monthly points with no daily drill.
269
+ const yCol = dataset.columns.find((c) => c.key === view.y);
270
+ const isMoney = yCol?.role === 'money';
271
+ const { x, y, stackBy } = view;
272
+ const points = view.granularity === 'daily'
273
+ ? (() => {
274
+ const raw = dataset.rows.map((r) => ({
275
+ key: String(r[x] ?? ''),
276
+ value: Number(r[y]) || 0,
277
+ ...(stackBy ? { category: String(r[stackBy] ?? '') } : {})
278
+ }));
279
+ return { monthly: monthlyAggregate(raw), daily: raw };
280
+ })()
281
+ : stackBy
282
+ ? (() => {
283
+ const { labels, series } = stackedSeries(dataset, x, y, stackBy);
284
+ const monthly = series.flatMap((s) => labels.map((key, i) => ({ key, value: s.values[i] ?? 0, category: s.name })));
285
+ return { monthly };
286
+ })()
287
+ : (() => {
288
+ const s = seriesData(dataset, x, y);
289
+ const monthly = s.labels.map((key, i) => ({ key, value: s.values[i] ?? 0 }));
290
+ return { monthly, daily: daily?.map((p) => ({ key: p.date, value: p.value, estimated: p.estimated })) };
291
+ })();
292
+ return (_jsx(TimeseriesChart, { monthly: points.monthly, daily: points.daily, isMoney: isMoney, currency: yCol?.currency, title: title, height: stackBy ? 200 : 170, estimateCurrentMonth: !stackBy && view.granularity === 'monthly' ? currentMonthEstimate : undefined }));
293
+ }
294
+ return null;
295
+ };
296
+ // The generic, descriptor-driven dashboard. Resolves each declarative view to its dataset (planViews,
297
+ // which also synthesizes defaults + skips dangling refs) and renders the matching generic view. Plugins
298
+ // emit a CapabilityResult — no per-kind React. Locale/labels come from context (English by default).
299
+ // Table prefs (persistence) + export are host concerns, threaded through to every table view.
300
+ export const DashboardRenderer = ({ result, daily, rowDaily, tableState, onTableStateChange, onExport, downloads, width = 'auto' }) => {
301
+ const t = useLabels();
302
+ const prefs = useFormat();
303
+ const numberLocale = resolveNumberLocale(prefs.currencyStyle, t.intlLocale);
304
+ const plans = planViews(result);
305
+ // Every dataset by id, so a table view's row-detail binding can resolve its child dataset.
306
+ const byId = new Map(result.datasets.map((d) => [d.id, d]));
307
+ // A running (not yet settled) MTD figure projects the open month onto the monthly chart as an estimate. Only
308
+ // a live basis qualifies — an invoiced/last-invoice figure is already a completed bar in the series.
309
+ const mtd = result.summaries?.find((s) => s.section === 'spend');
310
+ const currentMonthEstimate = mtd && (mtd.basis === 'accrued' || mtd.basis === 'upcoming') ? mtd.value : undefined;
311
+ // Width tier: a roomy container lets a dense (many-column) table breathe; the narrow cap keeps a result of
312
+ // only small panels (record/keyvalue/stat) or slim tables from sprawling across a wide monitor. When the
313
+ // host pins the tier (`wide`/`narrow`) that wins; otherwise it's derived from the densest table's columns.
314
+ const maxTableCols = Math.max(0, ...plans.map((p) => (p.dataset.shape === 'table' ? p.dataset.columns.length : 0)));
315
+ const wide = width === 'auto' ? maxTableCols >= WIDE_TABLE_COLS : width === 'wide';
316
+ // Centered, width-capped so the content doesn't stretch edge-to-edge on a wide monitor. A 2-column grid
317
+ // lets narrow `keyvalue` panels (account, payment method) sit side by side; stat rows, charts, and tables
318
+ // span the full width. Single column below md. Each cell is `min-w-0` so a wide table's intrinsic content
319
+ // width can't blow the track past the capped container — without it the grid item's default
320
+ // `min-width: auto` lets a freshly-mounted table flash full-width for a frame before snapping to the cap.
321
+ return (_jsx("div", { className: cn('mx-auto grid w-full grid-cols-1 gap-3 md:grid-cols-2', wide ? 'max-w-[110rem]' : 'max-w-6xl'), children: plans.map((p, i) => (_jsx("div", { className: cn('min-w-0', p.view.type === 'keyvalue' ? 'md:col-span-1' : 'md:col-span-2'), children: renderView(p.view, p.dataset, numberLocale, t.s, { state: tableState, onStateChange: onTableStateChange, onExport }, daily, downloads, rowDaily, currentMonthEstimate, byId) }, i))) }));
322
+ };
@@ -0,0 +1,2 @@
1
+ import { type SemanticRole } from '@butinapp/sdk/data';
2
+ export declare const formatByRole: (value: unknown, role: SemanticRole, currency?: string, locale?: string) => string;
@@ -0,0 +1,26 @@
1
+ import {} from '@butinapp/sdk/data';
2
+ // The single value -> display-string formatter, keyed by a column's semantic role. `currency` (ISO 4217)
3
+ // applies only to the 'money' role and defaults to USD. `locale` (BCP-47) drives Intl number formatting
4
+ // and defaults to en-US. null/undefined/'' render as an em dash. A value that can't be coerced to a
5
+ // number in a numeric role falls back to its raw string form.
6
+ export const formatByRole = (value, role, currency = 'USD', locale = 'en-US') => {
7
+ if (value == null || value === '') {
8
+ return '—';
9
+ }
10
+ if (role === 'money' || role === 'count' || role === 'percent') {
11
+ const n = Number(value);
12
+ if (!Number.isFinite(n)) {
13
+ return String(value);
14
+ }
15
+ if (role === 'money') {
16
+ return n.toLocaleString(locale, { style: 'currency', currency });
17
+ }
18
+ if (role === 'count') {
19
+ return n.toLocaleString(locale);
20
+ }
21
+ // percent: the value is a 0..1 fraction (0.46 → "46%"); Intl's percent style scales + appends the sign.
22
+ return n.toLocaleString(locale, { style: 'percent', maximumFractionDigits: 1 });
23
+ }
24
+ // timestamp, status, label, identifier, url, text — shown verbatim
25
+ return String(value);
26
+ };
@@ -0,0 +1,59 @@
1
+ import { type MonthPoint, type Summary } from '@butinapp/sdk/data';
2
+ import { type FxRates } from '@butinapp/sdk/util';
3
+ import { type DailyPoint, type OverviewPlugin } from '@butinapp/shapes';
4
+ export type { MonthPoint, OverviewPlugin };
5
+ export interface MonthStats {
6
+ mtd: number;
7
+ lastFullMonth: number;
8
+ annualizedRunRate: number;
9
+ }
10
+ export interface Mover {
11
+ pluginId: string;
12
+ pluginName: string;
13
+ color?: string;
14
+ icon?: string;
15
+ delta: number;
16
+ pct: number;
17
+ }
18
+ export interface ByServiceRow {
19
+ pluginId: string;
20
+ pluginName: string;
21
+ color?: string;
22
+ icon?: string;
23
+ mtd: number;
24
+ lastMo: number;
25
+ prevMo: number;
26
+ momPct: number;
27
+ share: number;
28
+ spark: number[];
29
+ byMonth: Record<string, number>;
30
+ total: number;
31
+ }
32
+ export declare const currentMtd: (p: OverviewPlugin, nowMonth: string, baseCurrency?: string, rates?: FxRates) => number;
33
+ export declare const combinedMonthly: (plugins: OverviewPlugin[], baseCurrency?: string, rates?: FxRates) => MonthPoint[];
34
+ export declare const combinedDaily: (plugins: OverviewPlugin[], baseCurrency?: string, rates?: FxRates) => DailyPoint[];
35
+ export declare const monthStats: (plugins: OverviewPlugin[], nowMonth: string, baseCurrency?: string, rates?: FxRates) => MonthStats;
36
+ export declare const movers: (plugins: OverviewPlugin[], from: string, to: string, baseCurrency?: string, rates?: FxRates) => {
37
+ increases: Mover[];
38
+ drops: Mover[];
39
+ };
40
+ export declare const byServiceRows: (plugins: OverviewPlugin[], nowMonth: string, baseCurrency?: string, rates?: FxRates) => ByServiceRow[];
41
+ export declare const windowTotal: (row: ByServiceRow, months: string[], nowMonth: string) => number;
42
+ export interface SpendBreakdownRow {
43
+ currency: string;
44
+ mtd: number;
45
+ convertible: boolean;
46
+ }
47
+ export interface SpendBreakdown {
48
+ rows: SpendBreakdownRow[];
49
+ unconverted: number;
50
+ }
51
+ export declare const spendBreakdown: (plugins: OverviewPlugin[], nowMonth: string, baseCurrency?: string, rates?: FxRates) => SpendBreakdown;
52
+ export declare const monthColumns: (plugins: OverviewPlugin[], nowMonth: string, max?: number) => string[];
53
+ export declare const pickHeadline: (p: OverviewPlugin) => Summary | undefined;
54
+ export interface Partitioned {
55
+ spend: OverviewPlugin[];
56
+ balances: OverviewPlugin[];
57
+ others: OverviewPlugin[];
58
+ }
59
+ export declare const partition: (plugins: OverviewPlugin[]) => Partitioned;