@arcforgelabs/flow 2026.10.45 → 2026.10.47

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 (49) hide show
  1. package/README.md +1 -0
  2. package/VISION.md +10 -10
  3. package/npm-shrinkwrap.json +104 -2
  4. package/package.json +6 -2
  5. package/server/api/invoicing.js +56 -16
  6. package/server/app.js +4 -2
  7. package/server/auth/people.js +5 -3
  8. package/server/auth/routes.js +4 -1
  9. package/server/core/invoice-rules.js +34 -4
  10. package/server/core/invoices.js +40 -1
  11. package/server/core/invoicing-rules.js +20 -3
  12. package/server/core/job-board.js +1 -1
  13. package/server/core/sending.js +76 -22
  14. package/server/integrations/call-guard.js +21 -4
  15. package/server/integrations/sentry/index.js +14 -7
  16. package/server/integrations/xero/client.js +12 -2
  17. package/server/integrations/xero/migrations/0010_xero_call_usage.sql +11 -0
  18. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  19. package/server/integrations/xero/routes.js +29 -5
  20. package/server/integrations/xero/runtime.js +5 -0
  21. package/server/integrations/xero/usage.js +74 -0
  22. package/server/ui/alerts.js +2 -1
  23. package/server/ui/app.js +110 -27
  24. package/server/ui/bulk-bar.js +18 -5
  25. package/server/ui/columns.js +54 -2
  26. package/server/ui/custom-fields.js +22 -7
  27. package/server/ui/flow.css +353 -45
  28. package/server/ui/invoicing.js +286 -64
  29. package/server/ui/job-board.js +10 -0
  30. package/server/ui/job-change.js +4 -3
  31. package/server/ui/job-view.js +14 -4
  32. package/server/ui/jobs.js +156 -56
  33. package/server/ui/kit.js +212 -51
  34. package/server/ui/list-views.js +50 -10
  35. package/server/ui/mobile.js +726 -0
  36. package/server/ui/monitoring.js +51 -0
  37. package/server/ui/nav-pins.js +41 -0
  38. package/server/ui/page.js +110 -5
  39. package/server/ui/pager.js +57 -10
  40. package/server/ui/price-list.js +1 -1
  41. package/server/ui/price-reconcile.js +12 -12
  42. package/server/ui/reconcile.js +19 -18
  43. package/server/ui/routes.js +1 -1
  44. package/server/ui/saved-views.js +6 -5
  45. package/server/ui/sidecar.js +106 -6
  46. package/server/ui/view-bar.js +22 -11
  47. package/server/ui/xero-import.js +48 -19
  48. package/server/ui/xero-usage-model.js +106 -0
  49. package/server/ui/xero-usage.js +133 -0
@@ -1,9 +1,12 @@
1
1
  // The sidecar, as in ClickUp: a panel at the right of a page or a dialog.
2
2
  // The Custom fields manager opens it over its table (`overlay`); a job's
3
- // panel keeps it as its right column, with tabs (Activity, Review). Closed,
4
- // it takes no room, so the main area gets the full width.
3
+ // panel keeps it as its right column, with tabs (Activity, Fix). Closed,
4
+ // it takes no room, so the main area gets the full width. `phone: 'page'`
5
+ // opens that panel as a full page on a phone: the tabs sit at the top of
6
+ // the record and the record bar (`recordBar`) carries back, the title and
7
+ // the next record. The main action stays in the caller's foot.
5
8
  //
6
- // sidecar(h, { label, overlay, escape, tabs, onChange }) answers
9
+ // sidecar(h, { label, overlay, escape, tabs, onChange, phone }) answers
7
10
  // { element, isOpen, open, show, close, tab, setTabs }:
8
11
  // open(id) selects tab `id` (or the first) and draws its `render()`
9
12
  // show(node) draws one node with no tabs (the overlay's own form)
@@ -13,14 +16,34 @@
13
16
  // `onChange(id)` hears each open (the tab id) and close (null).
14
17
 
15
18
  import { lucide } from '/assets/ui/custom-fields.js';
19
+ import { applyPhoneRule, isPhone, WIDTHS } from './mobile.js';
16
20
 
17
- export function sidecar(h, { label, overlay = false, escape = false, tabs = [], onChange = null } = {}) {
18
- const element = h('aside', { class: `fx-sidecar${overlay ? ' fx-sidecar--over' : ''}`, 'aria-label': label, hidden: true });
21
+ const CHEVRON_LEFT = 'M14.5 6l-6 6 6 6';
22
+ const CHEVRON_RIGHT = 'M9.5 6l6 6-6 6';
23
+
24
+ function glyph(button, path) {
25
+ button.innerHTML = `<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="${path}" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
26
+ return button;
27
+ }
28
+
29
+ export function sidecar(h, { label, overlay = false, escape = false, tabs = [], onChange = null, phone = null } = {}) {
30
+ const element = applyPhoneRule(h('aside', { class: `fx-sidecar${overlay ? ' fx-sidecar--over' : ''}`, 'aria-label': label, hidden: true }), 'sidecar');
19
31
  let list = tabs;
20
32
  let current = null;
33
+ let drawn = false;
34
+ if (phone === 'page') element.dataset.phone = 'page';
21
35
 
22
36
  const closeButton = () => h('button', { class: 'fx-sidecar__close', type: 'button', 'aria-label': 'Close', title: 'Close', onclick: () => api.close() }, lucide(h, 'x'));
23
37
 
38
+ // On a phone page the tab strip is the record's top tabs, so it lives on
39
+ // the record (the parent) and stays put while the record scrolls. The
40
+ // body stays in the sidecar, which is what the phone suite measures.
41
+ function parkHead(head) {
42
+ const grid = element.parentElement;
43
+ if (element.classList.contains('fx-sidecar--page') && grid) grid.prepend(head);
44
+ else element.prepend(head);
45
+ }
46
+
24
47
  function drawTabs() {
25
48
  const head = h('header', { class: 'fx-sidecar__head' },
26
49
  h('div', { class: 'fx-sidecar__tabs', role: 'tablist', 'aria-label': label },
@@ -30,7 +53,10 @@ export function sidecar(h, { label, overlay = false, escape = false, tabs = [],
30
53
  }, lucide(h, t.icon), h('span', {}, t.label), t.mark ? h('span', { class: 'fx-sidecar__mark', 'aria-hidden': 'true' }) : null))),
31
54
  closeButton());
32
55
  const tab = list.find((t) => t.id === current);
33
- element.replaceChildren(head, h('div', { class: 'fx-sidecar__body', role: 'tabpanel' }, tab?.render?.() ?? null));
56
+ const body = h('div', { class: 'fx-sidecar__body', role: 'tabpanel' }, tab?.render?.() ?? null);
57
+ element.parentElement?.querySelector(':scope > .fx-sidecar__head')?.remove();
58
+ element.replaceChildren(body);
59
+ parkHead(head);
34
60
  }
35
61
 
36
62
  const api = {
@@ -41,18 +67,27 @@ export function sidecar(h, { label, overlay = false, escape = false, tabs = [],
41
67
  current = list.find((t) => t.id === id)?.id ?? list[0]?.id ?? null;
42
68
  drawTabs();
43
69
  element.hidden = false;
70
+ // The first open leaves the record where it is. A later tab brings
71
+ // its panel up under the sticky tabs.
72
+ if (drawn && element.classList.contains('fx-sidecar--page')) {
73
+ element.querySelector('.fx-sidecar__body')?.scrollIntoView({ block: 'start' });
74
+ }
75
+ drawn = true;
44
76
  onChange?.(current);
45
77
  },
46
78
  show(node) {
47
79
  current = null;
80
+ element.parentElement?.querySelector(':scope > .fx-sidecar__head')?.remove();
48
81
  element.replaceChildren(node);
49
82
  element.hidden = false;
50
83
  },
51
84
  close() {
52
85
  if (element.hidden) return;
53
86
  element.hidden = true;
87
+ element.parentElement?.querySelector(':scope > .fx-sidecar__head')?.remove();
54
88
  element.replaceChildren();
55
89
  current = null;
90
+ drawn = false;
56
91
  onChange?.(null);
57
92
  },
58
93
  // New tabs (a tab's data came in): an open tab draws again.
@@ -63,6 +98,15 @@ export function sidecar(h, { label, overlay = false, escape = false, tabs = [],
63
98
  closeButton,
64
99
  };
65
100
 
101
+ if (phone === 'page') {
102
+ const sync = () => {
103
+ element.classList.toggle('fx-sidecar--page', isPhone());
104
+ if (!element.hidden && current) drawTabs();
105
+ };
106
+ sync();
107
+ if (typeof matchMedia === 'function') matchMedia(WIDTHS.phone).addEventListener('change', sync);
108
+ }
109
+
66
110
  if (escape) {
67
111
  document.addEventListener('keydown', (event) => {
68
112
  if (event.key === 'Escape' && element.isConnected && !element.hidden && !document.querySelector('dialog[open], .fx-pop:popover-open')) api.close();
@@ -70,3 +114,59 @@ export function sidecar(h, { label, overlay = false, escape = false, tabs = [],
70
114
  }
71
115
  return api;
72
116
  }
117
+
118
+ // The bar at the top of a record on a phone: ← back, the title, ‹ › for the
119
+ // previous and next record, and an optional ⋯ menu. Hidden above phone
120
+ // width (flow.css), so a desk record keeps its own bar. `prev` and `next`
121
+ // are { label, disabled, onclick }. `setSteps` and `setMenu` update them
122
+ // after the list arrives.
123
+ export function recordBar(h, { title = '', onBack, prev = null, next = null, menu = null, label = 'Record' } = {}) {
124
+ const back = glyph(h('button', { class: 'fx-record__back', type: 'button', 'aria-label': 'Back', title: 'Back', onclick: onBack }), CHEVRON_LEFT);
125
+ const titleNode = h('h2', { class: 'fx-record__title', title }, title);
126
+ const step = (path) => glyph(h('button', { class: 'fx-record__step', type: 'button' }), path);
127
+ const prevBtn = step(CHEVRON_LEFT);
128
+ const nextBtn = step(CHEVRON_RIGHT);
129
+ const end = h('div', { class: 'fx-record__end' }, prevBtn, nextBtn);
130
+ const apply = (button, spec, fallback) => {
131
+ const name = spec?.label ?? fallback;
132
+ button.setAttribute('aria-label', name);
133
+ button.title = name;
134
+ button.disabled = !spec || Boolean(spec.disabled);
135
+ button.onclick = spec?.onclick ?? null;
136
+ };
137
+ apply(prevBtn, prev, 'Previous');
138
+ apply(nextBtn, next, 'Next');
139
+ const element = h('header', { class: 'fx-record', 'aria-label': label }, back, titleNode, end);
140
+ element.setSteps = (nextPrev, nextNext) => {
141
+ apply(prevBtn, nextPrev, 'Previous');
142
+ apply(nextBtn, nextNext, 'Next');
143
+ };
144
+ element.setMenu = (node) => {
145
+ end.querySelector('.fx-record__menu')?.remove();
146
+ if (node) end.append(node);
147
+ };
148
+ if (menu) element.setMenu(menu);
149
+ return element;
150
+ }
151
+
152
+ // Keeps `pick()` in `foot` while the window is a phone, and back in `home`
153
+ // (the header's actions) otherwise. Returns the function to call again when
154
+ // the primary action changes (Save appears, a send arrives).
155
+ export function watchPhonePrimary(home, foot, pick) {
156
+ let parked = null;
157
+ const apply = () => {
158
+ const next = isPhone() ? (pick() ?? null) : null;
159
+ if (parked && parked !== next && home) home.append(parked);
160
+ parked = next;
161
+ if (parked) {
162
+ foot.hidden = false;
163
+ if (parked.parentElement !== foot) foot.replaceChildren(parked);
164
+ } else {
165
+ foot.replaceChildren();
166
+ foot.hidden = true;
167
+ }
168
+ };
169
+ apply();
170
+ if (typeof matchMedia === 'function') matchMedia(WIDTHS.phone).addEventListener('change', apply);
171
+ return apply;
172
+ }
@@ -22,6 +22,7 @@
22
22
  // first the field a new row starts on
23
23
  // empty the words when no filter is on
24
24
  import { lucide } from '/assets/ui/custom-fields.js';
25
+ import { applyPhoneRule } from './mobile.js';
25
26
  import { DATE_VALUES, dateValueIsOn, filterCount, KIND_ICONS, OPERATORS } from '/assets/ui/job-view.js';
26
27
 
27
28
  const DAY_BETWEEN = /^between:(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/;
@@ -59,17 +60,22 @@ export function viewBar(h) {
59
60
  const panel = h('div', { class: `fx-pop${rows ? ' fx-pop--rows' : ''}`, id, popover: 'auto', role: rows ? 'dialog' : 'menu' });
60
61
  const button = h('button', { type: 'button', class: 'fx-chip', popovertarget: id, 'aria-haspopup': rows ? 'dialog' : 'menu', 'aria-expanded': 'false' });
61
62
  const clear = h('button', { type: 'button', class: 'fx-chip__clear', 'aria-label': clearLabel, title: clearLabel, hidden: true }, lucide(h, 'x'));
62
- const wrap = h('span', { class: 'fx-chipset' }, button, clear);
63
+ const wrap = applyPhoneRule(h('span', { class: 'fx-chipset' }, button, clear), 'viewBar');
63
64
  let onClear = null;
64
65
  clear.addEventListener('click', () => onClear?.());
65
- const fill = () => panel.replaceChildren(...build(fill, () => panel.hidePopover()).filter(Boolean));
66
+ // Place again after the rows draw. A filter row is wider than the empty
67
+ // panel, and the panel is measured once it is open; without this a button
68
+ // at the right (Invoices) leaves the value and the bin off the screen.
69
+ const placeMenu = () => { if (panel.matches(':popover-open')) place(button, panel, 6); };
70
+ const fill = () => {
71
+ panel.replaceChildren(...build(fill, () => panel.hidePopover()).filter(Boolean));
72
+ placeMenu();
73
+ requestAnimationFrame(placeMenu);
74
+ };
66
75
  panel.addEventListener('toggle', (event) => {
67
76
  button.setAttribute('aria-expanded', String(event.newState === 'open'));
68
77
  if (event.newState !== 'open') return;
69
78
  fill();
70
- const at = button.getBoundingClientRect();
71
- panel.style.top = `${Math.round(at.bottom + 6)}px`;
72
- panel.style.left = `${Math.round(Math.max(8, Math.min(at.left, innerWidth - panel.offsetWidth - 8)))}px`;
73
79
  });
74
80
  return {
75
81
  node: [wrap, panel],
@@ -144,11 +150,16 @@ export function viewBar(h) {
144
150
  });
145
151
  return [button, pop];
146
152
  }
147
- const place = (button, pop) => {
148
- const at = button.getBoundingClientRect();
149
- pop.style.top = `${Math.round(at.bottom + 4)}px`;
150
- pop.style.left = `${Math.round(Math.max(8, Math.min(at.left, innerWidth - pop.offsetWidth - 8)))}px`;
151
- };
153
+ // Keep a popover on the screen: at the control's left edge, shifted left
154
+ // when it would run off the right. Called again after a filter row draws,
155
+ // because that row is wider than the empty panel.
156
+ function place(anchor, pop, gap = 4) {
157
+ const at = anchor.getBoundingClientRect();
158
+ const width = pop.offsetWidth;
159
+ const left = Math.max(8, Math.min(at.left, innerWidth - width - 8));
160
+ pop.style.top = `${Math.round(at.bottom + gap)}px`;
161
+ pop.style.left = `${Math.round(left)}px`;
162
+ }
152
163
 
153
164
  // ── Filters ──
154
165
  // As ClickUp's: groups of rows of field, operator and value, with a bin on
@@ -328,7 +339,7 @@ export function viewBar(h) {
328
339
  export function findBox(h, { label, value = '', oninput }) {
329
340
  const input = h('input', { class: 'fx-input fx-input--sm fx-find__input', type: 'search', placeholder: label, 'aria-label': label, value });
330
341
  const button = h('button', { type: 'button', class: 'fx-chip fx-find__button', 'aria-label': label, title: label }, lucide(h, 'search'));
331
- const element = h('span', { class: `fx-find${value ? ' fx-find--open' : ''}` }, button, input);
342
+ const element = applyPhoneRule(h('span', { class: `fx-find${value ? ' fx-find--open' : ''}` }, button, input), 'findBox');
332
343
  button.addEventListener('click', () => { element.classList.add('fx-find--open'); input.focus(); });
333
344
  input.addEventListener('input', () => oninput(input.value));
334
345
  input.addEventListener('blur', () => { if (!input.value) element.classList.remove('fx-find--open'); });
@@ -10,6 +10,16 @@
10
10
  import { bulkBar } from '/assets/ui/bulk-bar.js';
11
11
  import { lucide } from '/assets/ui/custom-fields.js';
12
12
  import { formatCents } from '/assets/ui/invoicing.js';
13
+ import { applyPhoneRule, assignPhoneSlots, phoneSlot } from './mobile.js';
14
+
15
+ // The list rows use the same phone slots as a column list.
16
+ const phoneAt = (defs) => {
17
+ const columns = assignPhoneSlots(defs);
18
+ return (index) => {
19
+ const slot = phoneSlot(columns[index]);
20
+ return slot ? { 'data-phone-slot': slot } : {};
21
+ };
22
+ };
13
23
 
14
24
  const XERO_STATUS = { DRAFT: 'Draft', SUBMITTED: 'Awaiting approval', AUTHORISED: 'Approved', PAID: 'Paid' };
15
25
  const XERO_TONE = { DRAFT: 'neutral', SUBMITTED: 'warn', AUTHORISED: 'info', PAID: 'success' };
@@ -212,13 +222,23 @@ export async function renderXeroImport(main, ui) {
212
222
  drawBar();
213
223
  } });
214
224
  const sure = rows.filter((r) => r.sure);
225
+ const at = phoneAt([
226
+ { kind: 'text', label: 'Number' },
227
+ { kind: 'client', label: 'Client', phone: 'title' },
228
+ { kind: 'date', label: 'Date', phone: 'meta' },
229
+ { kind: 'total', num: true, label: 'Total' },
230
+ { kind: 'status', label: 'Match', phone: 'meta' },
231
+ { kind: 'text', label: 'Flow', phone: 'meta' },
232
+ { kind: 'date', label: 'Flow date', phone: 'hide' },
233
+ { kind: 'total', label: 'Flow total', phone: 'hide' },
234
+ ]);
215
235
  host.replaceChildren(
216
236
  h('div', { class: 'fx-xi__bulk' }, icon('list-checks'), h('span', { class: 'fx-xi__bulk-text' }, 'Tick rows, then use the bar below.'),
217
237
  canAct && sure.length ? h('button', { class: 'fx-btn fx-btn--sm', type: 'button', onclick: () => {
218
238
  for (const r of sure) { ticked.add(r.xeroInvoiceId); boxes.get(r.xeroInvoiceId).checked = true; }
219
239
  drawBar();
220
240
  } }, icon('check-check'), `Tick ${sure.length} sure`) : null),
221
- h('div', { class: 'fx-xi__scroll' }, h('table', { class: 'fx-table fx-table--rows fx-xi-list' },
241
+ applyPhoneRule(h('div', { class: 'fx-xi__scroll' }, h('table', { class: 'fx-table fx-table--rows fx-xi-list' },
222
242
  h('thead', {}, h('tr', {},
223
243
  canAct ? h('th', { class: 'fx-tick-cell' }, all) : null,
224
244
  h('th', { colspan: '4' }, xeroMark()), h('th', { class: 'fx-xi-list__mid' }), h('th', { colspan: '3' }, flowMark()))),
@@ -231,15 +251,15 @@ export async function renderXeroImport(main, ui) {
231
251
  boxes.set(row.xeroInvoiceId, box);
232
252
  return h('tr', {},
233
253
  canAct ? h('td', { class: 'fx-tick-cell' }, box) : null,
234
- h('td', { class: 'fx-num' }, row.number ?? '—'),
235
- h('td', {}, row.clientName ?? '—'),
236
- h('td', { class: 'fx-dim' }, shortDate(row.issuedOn)),
237
- h('td', { class: 'fx-num fx-strong' }, money(row.totalCents)),
238
- h('td', { class: 'fx-xi-list__mid' }, row.sure ? icon('check-check', 'Sure') : fitIcon(fit), fit.count > 1 ? h('span', { class: 'fx-dim' }, `${fit.count}`) : null),
239
- h('td', { class: 'fx-num' }, fit.name),
240
- h('td', { class: 'fx-dim' }, shortDate(fit.date)),
241
- h('td', { class: 'fx-num fx-strong' }, money(fit.totalCents)));
242
- })))));
254
+ h('td', { class: 'fx-num', ...at(0) }, row.number ?? '—'),
255
+ h('td', { ...at(1) }, row.clientName ?? '—'),
256
+ h('td', { class: 'fx-dim', ...at(2) }, shortDate(row.issuedOn)),
257
+ h('td', { class: 'fx-num fx-strong', ...at(3) }, money(row.totalCents)),
258
+ h('td', { class: 'fx-xi-list__mid', ...at(4) }, row.sure ? icon('check-check', 'Sure') : fitIcon(fit), fit.count > 1 ? h('span', { class: 'fx-dim' }, `${fit.count}`) : null),
259
+ h('td', { class: 'fx-num', ...at(5) }, fit.name),
260
+ h('td', { class: 'fx-dim', ...at(6) }, shortDate(fit.date)),
261
+ h('td', { class: 'fx-num fx-strong', ...at(7) }, money(fit.totalCents)));
262
+ })))), 'columnTable'));
243
263
  drawBar();
244
264
  }
245
265
 
@@ -259,13 +279,22 @@ export async function renderXeroImport(main, ui) {
259
279
  } });
260
280
  const more = h('div', {});
261
281
  const STATE = { linked: ['check', 'In Flow'], invoice_fits: ['receipt', 'A Flow invoice fits'], job_fits: ['briefcase', 'A job fits'], none: ['circle-dashed', 'Not in Flow'] };
282
+ const at = phoneAt([
283
+ { kind: 'status', label: 'State', phone: 'meta' },
284
+ { kind: 'text', label: 'Number' },
285
+ { kind: 'client', label: 'Client', phone: 'title' },
286
+ { kind: 'date', label: 'Date', phone: 'meta' },
287
+ { kind: 'total', num: true, label: 'Total' },
288
+ { kind: 'status', label: 'Xero', phone: 'meta' },
289
+ { kind: 'text', label: 'Flow', phone: 'hide' },
290
+ ]);
262
291
  host.replaceChildren(
263
292
  h('div', { class: 'fx-xi__bulk' }, icon('search'), search),
264
- h('div', { class: 'fx-xi__scroll' }, h('table', { class: 'fx-table fx-table--rows fx-xi-list' },
293
+ applyPhoneRule(h('div', { class: 'fx-xi__scroll' }, h('table', { class: 'fx-table fx-table--rows fx-xi-list' },
265
294
  h('thead', {}, h('tr', {},
266
295
  canAct ? h('th', { class: 'fx-tick-cell' }, all) : null,
267
296
  h('th', {}, ''), h('th', {}, 'Number'), h('th', {}, 'Client'), h('th', {}, 'Date'), h('th', { class: 'fx-num' }, 'Total'), h('th', {}, xeroMark()), h('th', {}, flowMark()))),
268
- body)),
297
+ body)), 'columnTable'),
269
298
  more);
270
299
  let timer = null;
271
300
  search.addEventListener('input', () => {
@@ -295,13 +324,13 @@ export async function renderXeroImport(main, ui) {
295
324
  } });
296
325
  return h('tr', { 'data-state': row.state },
297
326
  canAct ? h('td', { class: 'fx-tick-cell' }, box) : null,
298
- h('td', { class: 'fx-xi-list__mid' }, row.state === 'linked' || row.state === 'none' ? icon(glyph, label) : h('a', { href: '/xero-import', onclick: (event) => ui.go(event), 'aria-label': label, title: label }, icon(glyph))),
299
- h('td', { class: 'fx-num' }, row.number ?? '—'),
300
- h('td', {}, row.clientName ?? '—'),
301
- h('td', { class: 'fx-dim' }, shortDate(row.issuedOn)),
302
- h('td', { class: 'fx-num fx-strong' }, money(row.totalCents)),
303
- h('td', {}, pill(row)),
304
- h('td', { class: 'fx-num' }, row.state === 'linked' ? go(`/invoices/${row.link.invoiceId}`, row.link.number ?? 'Draft') : ''));
327
+ h('td', { class: 'fx-xi-list__mid', ...at(0) }, row.state === 'linked' || row.state === 'none' ? icon(glyph, label) : h('a', { href: '/xero-import', onclick: (event) => ui.go(event), 'aria-label': label, title: label }, icon(glyph))),
328
+ h('td', { class: 'fx-num', ...at(1) }, row.number ?? '—'),
329
+ h('td', { ...at(2) }, row.clientName ?? '—'),
330
+ h('td', { class: 'fx-dim', ...at(3) }, shortDate(row.issuedOn)),
331
+ h('td', { class: 'fx-num fx-strong', ...at(4) }, money(row.totalCents)),
332
+ h('td', { ...at(5) }, pill(row)),
333
+ h('td', { class: 'fx-num', ...at(6) }, row.state === 'linked' ? go(`/invoices/${row.link.invoiceId}`, row.link.number ?? 'Draft') : ''));
305
334
  };
306
335
  async function load() {
307
336
  let page;
@@ -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
+ }