@arcforgelabs/flow 2026.10.45 → 2026.10.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server/ui/page.js CHANGED
@@ -71,20 +71,125 @@ ${body}
71
71
  `;
72
72
  }
73
73
 
74
- /** A one-message page, such as the end of connecting Xero. */
75
- export function messagePage({ title, text, defaultFamily, tone = 'info' }) {
74
+ // Mode labels on the connected callback card (issue #211). Sandbox is the
75
+ // Demo Company; replay is for development and CI.
76
+ const MODE_LABELS = Object.freeze({ live: 'Live', sandbox: 'Demo', replay: 'Replay' });
77
+
78
+ function modeLabel(mode) {
79
+ if (mode == null || mode === '') return '';
80
+ const key = String(mode).toLowerCase();
81
+ return MODE_LABELS[key] ?? String(mode).replace(/^./, (c) => c.toUpperCase());
82
+ }
83
+
84
+ function lucideIcon(name) {
85
+ return `<span class="fx-lucide" style="--icon: url(/assets/icons/${escapeHtml(name)}.svg)" aria-hidden="true"></span>`;
86
+ }
87
+
88
+ /**
89
+ * A one-message page, such as the end of connecting an integration.
90
+ *
91
+ * Optional `integration` ({ name, logo }) shows Flow's mark beside the
92
+ * maker's logo. Optional `organisation` and `mode` become a status line.
93
+ * On success, `autoReturnSeconds` (default 5 when tone is success and an
94
+ * integration is named) starts a polite countdown and returns to Flow; set
95
+ * it to 0 or false to keep the person on the page. Failure never auto-returns.
96
+ */
97
+ export function messagePage({
98
+ title,
99
+ text,
100
+ defaultFamily,
101
+ tone = 'info',
102
+ integration = null,
103
+ organisation = null,
104
+ mode = null,
105
+ autoReturnSeconds,
106
+ href = '/',
107
+ }) {
108
+ const safeHref = typeof href === 'string' && href.startsWith('/') && !href.startsWith('//') ? href : '/';
109
+ const logo = integration?.logo && /^[a-z0-9][a-z0-9._-]*\.svg$/i.test(integration.logo) ? integration.logo : null;
110
+ const integrationName = integration?.name ? String(integration.name) : '';
111
+ const showMarks = Boolean(logo || integrationName);
112
+ const resultIcon = tone === 'success' ? 'check-circle' : tone === 'danger' ? 'x-circle' : null;
113
+ const org = organisation != null && String(organisation).trim() ? String(organisation).trim() : '';
114
+ const modeText = modeLabel(mode);
115
+ const statusParts = [org, modeText].filter(Boolean);
116
+ const returnSeconds = autoReturnSeconds === false || autoReturnSeconds === 0
117
+ ? 0
118
+ : tone === 'success' && showMarks
119
+ ? Math.max(1, Math.min(30, Number(autoReturnSeconds) || 5))
120
+ : 0;
121
+
122
+ const marks = showMarks
123
+ ? `<div class="fx-message__marks" aria-hidden="true">
124
+ <span class="fx-logo fx-logo--lg fx-message__flow">${FLOW_MARK}</span>
125
+ <span class="fx-message__plus">+</span>
126
+ <span class="fx-logo fx-logo--lg">${logo
127
+ ? `<img src="/assets/ui/logos/${escapeHtml(logo)}" alt="">`
128
+ : lucideIcon('plug')}</span>
129
+ ${resultIcon ? `<span class="fx-message__result" data-tone="${escapeHtml(tone)}">${lucideIcon(resultIcon)}</span>` : ''}
130
+ </div>`
131
+ : resultIcon
132
+ ? `<div class="fx-message__marks fx-message__marks--solo" aria-hidden="true">
133
+ <span class="fx-message__result" data-tone="${escapeHtml(tone)}">${lucideIcon(resultIcon)}</span>
134
+ </div>`
135
+ : '';
136
+
137
+ const status = statusParts.length
138
+ ? `<p class="fx-message__status"><span class="fx-status" data-tone="${escapeHtml(tone === 'danger' ? 'danger' : 'success')}">${escapeHtml(statusParts.join(' · '))}</span></p>`
139
+ : '';
140
+
141
+ const returnLine = returnSeconds
142
+ ? `<p class="fx-message__return" hidden aria-live="polite" data-return-after="${returnSeconds}" data-return-href="${escapeHtml(safeHref)}"></p>`
143
+ : '';
144
+
145
+ // Clean the OAuth query from the address. On success only, count down and
146
+ // return to Flow. Without JavaScript the Back button is enough.
147
+ const cleanScript = showMarks || returnSeconds
148
+ ? `<script>try{history.replaceState(null,'',location.pathname)}catch(e){}</script>`
149
+ : '';
150
+ const returnScript = returnSeconds
151
+ ? `<script>
152
+ (function () {
153
+ var el = document.querySelector('[data-return-after]');
154
+ if (!el) return;
155
+ var left = Number(el.getAttribute('data-return-after')) || 0;
156
+ var href = el.getAttribute('data-return-href') || '/';
157
+ if (left < 1) return;
158
+ el.hidden = false;
159
+ function say(n) {
160
+ el.textContent = n === 1 ? 'Going back to Flow in 1 second' : 'Going back to Flow in ' + n + ' seconds';
161
+ }
162
+ say(left);
163
+ var timer = setInterval(function () {
164
+ left -= 1;
165
+ if (left <= 0) {
166
+ clearInterval(timer);
167
+ location.assign(href);
168
+ return;
169
+ }
170
+ say(left);
171
+ }, 1000);
172
+ })();
173
+ </script>`
174
+ : '';
175
+
76
176
  return renderPage({
77
177
  title,
78
178
  defaultFamily,
79
179
  bodyClass: 'fx-centre',
80
180
  scripts: ['/assets/ui/appearance.js'],
81
181
  body: `<main class="fx-message">
82
- <a class="fx-brand" href="/">${FLOW_MARK}<span>Flow</span></a>
182
+ <a class="fx-brand" href="${escapeHtml(safeHref)}">${FLOW_MARK}<span>Flow</span></a>
83
183
  <section class="fx-card fx-message__card" data-tone="${escapeHtml(tone)}">
184
+ ${marks}
84
185
  <h1 class="fx-title">${escapeHtml(title)}</h1>
186
+ ${status}
85
187
  <p>${escapeHtml(text)}</p>
86
- <a class="fx-btn" href="/">Back to Flow</a>
188
+ ${returnLine}
189
+ <a class="fx-btn" href="${escapeHtml(safeHref)}">Back to Flow</a>
87
190
  </section>
88
- </main>`,
191
+ </main>
192
+ ${cleanScript}
193
+ ${returnScript}`,
89
194
  });
90
195
  }
@@ -204,7 +204,7 @@ async function renderList(main, ui) {
204
204
  const host = h('div', { class: 'fx-rec' }, h('div', { class: 'fx-skeleton' }));
205
205
  const bulk = bulkBar(ui, { label: 'Ticked jobs' });
206
206
  const frame = listFrame(main, host);
207
- main.append(...listHead('Reconcile', { views: views.element, start: controls, end: find.element }).nodes, frame.element, bulk.element);
207
+ main.append(...listHead('Reconcile', { views: views.element, controls, end: find.element }).nodes, frame.element, bulk.element);
208
208
 
209
209
  // The Filter chip: the same panel as on Jobs (view-bar.js). Job rows also
210
210
  // have the lab's custom fields.
@@ -21,7 +21,7 @@ const themeDir = join(here, '..', 'theme');
21
21
  const logoDir = join(here, 'logos');
22
22
  const LOGO_FILES = new Set(['xero.svg', 'seazona.svg', 'sentry.svg']);
23
23
  const THEME_FILES = new Set(['themes.js', 'color-contrast.js', 'openclaw-palettes.js', 'font-families.js']);
24
- export const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'sidecar.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'price-match.js', 'price-reconcile.js', 'custom-fields.js', 'jobs.js', 'job-board.js', 'reconcile.js', 'xero-import.js', 'job-view.js', 'list-views.js', 'saved-views.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'tip.js', 'kit.js', 'columns.js', 'person.js', 'pager.js', 'flow-mark.svg']);
24
+ export const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'sidecar.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'price-match.js', 'price-reconcile.js', 'custom-fields.js', 'jobs.js', 'job-board.js', 'reconcile.js', 'xero-import.js', 'xero-usage.js', 'xero-usage-model.js', 'job-view.js', 'list-views.js', 'saved-views.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'tip.js', 'kit.js', 'columns.js', 'person.js', 'pager.js', 'flow-mark.svg']);
25
25
  const iconDir = join(dirname(require.resolve('lucide-static/package.json')), 'icons');
26
26
  const ICON_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.svg$/;
27
27
  const MAX_AGE = '1h';
@@ -1,5 +1,5 @@
1
- // Saved views: the row of named view tabs over a list (Jobs, Invoices,
2
- // Reconcile), as ClickUp's. A view keeps the page's Group, Sort, Filter and
1
+ // Saved views: the named view tabs of a list (Jobs, Invoices, Reconcile),
2
+ // as ClickUp's, in the toolbar beside Group, Sort and Filter (listHead). A view keeps the page's Group, Sort, Filter and
3
3
  // Columns under a name. A tab shows it; "Save" and "Revert" show when the
4
4
  // page no longer matches the chosen tab; "+ View" saves the page as a new
5
5
  // view. Each tab's ⋯ menu has "Set as my default" (the view that opens
@@ -7,7 +7,7 @@
7
7
  // the person's account (PUT /api/me/saved-views/:page, list-views.js).
8
8
  //
9
9
  // const views = savedViews(ui, { page, current, apply });
10
- // views.element the tab row, for the page to place
10
+ // views.element the tabs, for listHead's `views`
11
11
  // views.opening() the view to open on, or null: the default view on the
12
12
  // first visit in this browser tab, else the page's own
13
13
  // views.draw() call after any change to the page's view
@@ -117,7 +117,7 @@ export function savedViews(ui, { page, current, apply }) {
117
117
  });
118
118
  element.replaceChildren(...[
119
119
  ...tabs,
120
- saved.list.length < MAX_SAVED_VIEWS ? h('button', { type: 'button', class: 'fx-views__add', onclick: addView }, lucide(h, 'plus'), 'View') : null,
120
+ saved.list.length < MAX_SAVED_VIEWS ? h('button', { type: 'button', class: 'fx-views__add', title: 'Save as a view', onclick: addView }, lucide(h, 'plus'), 'View') : null,
121
121
  changed ? h('span', { class: 'fx-views__end' },
122
122
  h('button', { type: 'button', class: 'fx-btn fx-btn--ghost fx-btn--sm', onclick: () => { apply(view.view); draw(); } }, 'Revert'),
123
123
  h('button', { type: 'button', class: 'fx-btn fx-btn--primary fx-btn--sm', onclick: () => store({ ...saved, list: saved.list.map((v) => (v.id === view.id ? { ...v, view: current() } : v)) }) }, 'Save')) : null,
@@ -0,0 +1,106 @@
1
+ // Pure numbers for the Xero usage card. No DOM and no database: the server
2
+ // fills it from counters Flow already keeps, and the card draws it.
3
+
4
+ export const USAGE_LANES = Object.freeze(['writes', 'sync', 'reads', 'other']);
5
+ const DAY_MS = 24 * 60 * 60 * 1000;
6
+
7
+ export function startOfUtcDay(ms) {
8
+ const date = new Date(ms);
9
+ return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
10
+ }
11
+
12
+ // 1,000 a day is Xero's Starter plan; 5,000 is Core and above (docs/XERO.md).
13
+ export function appTier(dayLimit) {
14
+ if (!Number.isFinite(dayLimit) || dayLimit <= 0) return null;
15
+ return dayLimit >= 5000 ? 'Core' : 'Starter';
16
+ }
17
+
18
+ // How old a time is, as a short chip: "2 min", "3 h", "4 d".
19
+ export function formatAge(at, now) {
20
+ if (!at) return null;
21
+ const ms = Math.max(0, now - new Date(at).getTime());
22
+ const minutes = Math.round(ms / 60_000);
23
+ if (minutes < 1) return 'now';
24
+ if (minutes < 60) return `${minutes} min`;
25
+ const hours = Math.round(ms / 3_600_000);
26
+ if (hours < 24) return `${hours} h`;
27
+ return `${Math.round(ms / DAY_MS)} d`;
28
+ }
29
+
30
+ export function formatWhen(at) {
31
+ if (!at) return null;
32
+ return new Date(at).toLocaleString('en-AU', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
33
+ }
34
+
35
+ function emptyLanes() {
36
+ return { writes: 0, sync: 0, reads: 0, other: 0 };
37
+ }
38
+
39
+ function addLane(target, lane, n) {
40
+ if (USAGE_LANES.includes(lane)) target[lane] += n;
41
+ }
42
+
43
+ // `hourRows` are { hour, lane, n } for today. `dayRows` are { day, n } over
44
+ // the last 7 UTC days. `limitRows` are 429s { at, problem, lane, task }.
45
+ export function assembleUsage({
46
+ hourRows = [],
47
+ dayRows = [],
48
+ taskRows = [],
49
+ limitRows = [],
50
+ governor,
51
+ warningFraction,
52
+ now = Date.now(),
53
+ }) {
54
+ const dayStart = startOfUtcDay(now);
55
+ const hours = Array.from({ length: 24 }, (_, index) => ({
56
+ hour: new Date(dayStart + index * 3_600_000).toISOString(),
57
+ ...emptyLanes(),
58
+ }));
59
+ const lanes = emptyLanes();
60
+ for (const row of hourRows) {
61
+ const index = Math.round((Date.parse(row.hour) - dayStart) / 3_600_000);
62
+ const n = Number(row.n) || 0;
63
+ if (index >= 0 && index < 24) addLane(hours[index], row.lane, n);
64
+ addLane(lanes, row.lane, n);
65
+ }
66
+ const logged = USAGE_LANES.reduce((sum, lane) => sum + lanes[lane], 0);
67
+ const dayLimit = Number(governor?.dayLimit) || 0;
68
+ const used = Math.max(Number(governor?.dayCount) || 0, logged);
69
+ const observed = governor?.observedDayRemaining;
70
+ const left = Number.isFinite(observed) ? observed : Math.max(0, dayLimit - used);
71
+ const fraction = Number.isFinite(warningFraction) ? warningFraction : 0.7;
72
+ const line = dayLimit * fraction;
73
+
74
+ const byDay = new Map(dayRows.map((row) => [String(row.day).slice(0, 10), Number(row.n) || 0]));
75
+ const weekStart = dayStart - 6 * DAY_MS;
76
+ let warnedDays = 0;
77
+ const days = Array.from({ length: 7 }, (_, index) => {
78
+ const day = new Date(weekStart + index * DAY_MS).toISOString().slice(0, 10);
79
+ const total = byDay.get(day) ?? 0;
80
+ const warning = dayLimit > 0 && total >= line;
81
+ if (warning) warnedDays += 1;
82
+ return { day, total, warning, over: dayLimit > 0 && total >= dayLimit };
83
+ });
84
+
85
+ return {
86
+ used,
87
+ dayLimit,
88
+ left,
89
+ dayLimitSource: governor?.dayLimitSource ?? 'setting',
90
+ warningFraction: fraction,
91
+ warning: dayLimit > 0 && used >= line,
92
+ tier: appTier(dayLimit),
93
+ lanes,
94
+ hours,
95
+ days,
96
+ warnedDays,
97
+ suggestTier: warnedDays >= 2,
98
+ tasks: taskRows.map((row) => ({ task: row.task, calls: Number(row.n) || 0 })),
99
+ limited: limitRows.map((row) => ({
100
+ at: row.at ? new Date(row.at).toISOString() : null,
101
+ problem: row.problem || null,
102
+ lane: USAGE_LANES.includes(row.lane) ? row.lane : null,
103
+ task: row.task || null,
104
+ })),
105
+ };
106
+ }
@@ -0,0 +1,133 @@
1
+ // The Xero card's usage picture (Integrations › Xero). Three levels: the ring
2
+ // and chips always, today open, the week closed. Numbers come from
3
+ // GET /api/xero/status. No sentence is needed to read the first level.
4
+ import { h, notice } from '/assets/ui/kit.js';
5
+ import { svg } from '/assets/ui/view-bar.js';
6
+ import { USAGE_LANES, formatAge, formatWhen } from '/assets/ui/xero-usage-model.js';
7
+
8
+ const LANE_LABEL = { writes: 'Writes', sync: 'Sync', reads: 'Reads', other: 'Other' };
9
+ const num = new Intl.NumberFormat('en-AU');
10
+ const HOUR = 60 * 60 * 1000;
11
+
12
+ function lanePart(count, whole) {
13
+ if (!whole) return '0%';
14
+ return `${(count / whole) * 100}%`;
15
+ }
16
+
17
+ function ring(usage) {
18
+ const pct = usage.dayLimit > 0 ? Math.min(100, Math.round((usage.used / usage.dayLimit) * 1000) / 10) : 0;
19
+ const label = `${num.format(usage.used)} of ${num.format(usage.dayLimit)}`;
20
+ return h('div', { class: 'fx-xu-ring', role: 'img', 'aria-label': label },
21
+ svg(`<svg viewBox="0 0 36 36" aria-hidden="true"><circle class="fx-xu-ring__track" cx="18" cy="18" r="15.5" pathLength="100"/><circle class="fx-xu-ring__used" cx="18" cy="18" r="15.5" pathLength="100" stroke-dasharray="${pct} 100"/></svg>`),
22
+ h('div', { class: 'fx-xu-ring__label' },
23
+ h('span', { class: 'fx-xu-ring__num' }, num.format(usage.used)),
24
+ h('span', { class: 'fx-xu-ring__of' }, num.format(usage.dayLimit))));
25
+ }
26
+
27
+ function chips(status, now) {
28
+ const stopped = status.calls?.stopped;
29
+ const connection = !status.connected
30
+ ? ['mute', 'Off']
31
+ : stopped && status.calls.all
32
+ ? ['danger', 'Stopped']
33
+ : stopped
34
+ ? ['warn', 'Limited']
35
+ : ['success', 'Connected'];
36
+ const age = formatAge(status.freshness?.copyAt, now);
37
+ return h('div', { class: 'fx-xu__chips' },
38
+ h('span', { class: 'fx-status', 'data-tone': connection[0] }, connection[1]),
39
+ status.usage.tier ? h('span', { class: 'fx-chip fx-chip--static' }, status.usage.tier) : null,
40
+ h('span', { class: 'fx-chip fx-chip--static', title: 'Copy', 'aria-label': age ? `Copy ${age}` : 'Copy' }, age ?? '—'));
41
+ }
42
+
43
+ function legend() {
44
+ return h('div', { class: 'fx-xu-legend' }, USAGE_LANES.map((lane) => h('span', {},
45
+ h('i', { class: 'fx-xu-lane', 'data-lane': lane, 'aria-hidden': 'true' }),
46
+ LANE_LABEL[lane])));
47
+ }
48
+
49
+ function hourChart(usage) {
50
+ const peak = Math.max(1, ...usage.hours.map((hour) => USAGE_LANES.reduce((sum, lane) => sum + hour[lane], 0)));
51
+ return h('div', { class: 'fx-xu-hours', role: 'img', 'aria-label': 'Calls by hour' }, usage.hours.map((hour) => {
52
+ const total = USAGE_LANES.reduce((sum, lane) => sum + hour[lane], 0);
53
+ const stack = h('span', { class: 'fx-xu-hour__stack', style: { '--h': `${(total / peak) * 100}%` } },
54
+ USAGE_LANES.map((lane) => (hour[lane]
55
+ ? h('i', { class: 'fx-xu-lane', 'data-lane': lane, style: { '--part': lanePart(hour[lane], total) } })
56
+ : null)));
57
+ const when = new Date(hour.hour);
58
+ stack.title = `${when.getUTCHours()}:00 · ${total}`;
59
+ return h('span', { class: 'fx-xu-hour' }, stack);
60
+ }));
61
+ }
62
+
63
+ function budgetBar(usage) {
64
+ return h('div', { class: 'fx-xu-budget', role: 'img', 'aria-label': 'Budget by lane' },
65
+ USAGE_LANES.map((lane) => h('i', {
66
+ class: 'fx-xu-lane',
67
+ 'data-lane': lane,
68
+ style: { '--part': lanePart(usage.lanes[lane], usage.dayLimit) },
69
+ title: `${LANE_LABEL[lane]} ${num.format(usage.lanes[lane])}`,
70
+ })));
71
+ }
72
+
73
+ function dot(label, tone, title) {
74
+ return h('span', { class: 'fx-xu-dot', 'data-tone': tone, title: title || label }, label);
75
+ }
76
+
77
+ function freshnessDots(freshness, limited, now) {
78
+ const tone = (at, freshFor) => {
79
+ if (!at) return 'mute';
80
+ return now - new Date(at).getTime() <= freshFor ? 'success' : 'warn';
81
+ };
82
+ const recentLimit = limited.some((row) => row.at && now - new Date(row.at).getTime() <= 24 * HOUR);
83
+ return h('div', { class: 'fx-xu-dots' },
84
+ dot('Webhook', tone(freshness?.webhookAt, 24 * HOUR), formatWhen(freshness?.webhookAt) ?? 'Webhook'),
85
+ dot('Poll', tone(freshness?.pollAt, 2 * HOUR), formatWhen(freshness?.pollAt) ?? 'Poll'),
86
+ dot('Nightly', tone(freshness?.nightlyAt, 26 * HOUR), formatWhen(freshness?.nightlyAt) ?? 'Nightly'),
87
+ dot('429', recentLimit ? 'danger' : 'success', recentLimit ? `${limited.length}` : '429'));
88
+ }
89
+
90
+ function dayChart(usage) {
91
+ return h('div', { class: 'fx-xu-days', style: { '--warn': String(usage.warningFraction) }, role: 'img', 'aria-label': 'Calls over 7 days' },
92
+ h('span', { class: 'fx-xu-days__line', title: 'Warning' }),
93
+ usage.days.map((day) => {
94
+ const height = usage.dayLimit > 0 ? Math.min(100, (day.total / usage.dayLimit) * 100) : 0;
95
+ return h('span', { class: 'fx-xu-day', 'data-warning': String(day.warning), 'data-over': String(day.over), title: `${day.day} · ${num.format(day.total)}` },
96
+ h('span', { class: 'fx-xu-day__bar' }, h('b', { style: { '--h': `${height}%` } })),
97
+ h('span', {}, day.day.slice(8)));
98
+ }));
99
+ }
100
+
101
+ function rows(items, empty) {
102
+ if (!items.length) return h('p', { class: 'fx-dim' }, empty);
103
+ return h('div', { class: 'fx-xu-rows' }, items.map((item) => h('div', { class: 'fx-xu-row' },
104
+ h('span', {}, item.label),
105
+ h('b', {}, item.value))));
106
+ }
107
+
108
+ // The picture for one connected organisation. `status` is GET /api/xero/status.
109
+ export function xeroUsage(status, now = Date.now()) {
110
+ const usage = status?.usage;
111
+ if (!usage) return null;
112
+ const tone = usage.used >= usage.dayLimit && usage.dayLimit > 0 ? 'danger' : usage.warning ? 'warn' : 'ok';
113
+ return h('section', { class: 'fx-xu', 'data-tone': tone, 'aria-label': 'Usage' },
114
+ h('div', { class: 'fx-xu__top' }, ring(usage), chips(status, now)),
115
+ h('details', { class: 'fx-xu__fold', open: true },
116
+ h('summary', {}, 'Today'),
117
+ legend(),
118
+ hourChart(usage),
119
+ budgetBar(usage),
120
+ freshnessDots(status.freshness, usage.limited, now)),
121
+ h('details', { class: 'fx-xu__fold' },
122
+ h('summary', {}, 'Detail'),
123
+ dayChart(usage),
124
+ rows(usage.tasks.map((task) => ({ label: task.task, value: num.format(task.calls) })), '—'),
125
+ rows(usage.limited.map((row) => ({
126
+ label: row.problem || '429',
127
+ value: formatWhen(row.at) ?? '—',
128
+ })), '—'),
129
+ h('div', { class: 'fx-xu-row' },
130
+ h('span', {}, 'Token'),
131
+ h('b', {}, formatWhen(status.freshness?.tokenRefreshedAt) ?? '—')),
132
+ usage.suggestTier ? notice(`Calls passed the warning on ${usage.warnedDays} days. A higher Xero tier raises the day's limit.`, 'warn', 'Tier') : null));
133
+ }