@arcforgelabs/flow 2026.10.44 → 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/app.js CHANGED
@@ -18,6 +18,7 @@ import { renderCustomFields } from '/assets/ui/custom-fields.js';
18
18
  import { renderJobs } from '/assets/ui/jobs.js';
19
19
  import { renderReconcile } from '/assets/ui/reconcile.js';
20
20
  import { renderXeroImport } from '/assets/ui/xero-import.js';
21
+ import { xeroUsage } from '/assets/ui/xero-usage.js';
21
22
  import { startMonitoring } from '/assets/ui/monitoring.js';
22
23
  import { isSearchKey, openSearch } from '/assets/ui/search.js';
23
24
  import { alertStrip } from '/assets/ui/alerts.js';
@@ -142,23 +143,26 @@ function roleSelect(value, attrs = {}) {
142
143
 
143
144
  let me = null;
144
145
 
145
- // The sidebar lists the views in this order, under their group's heading.
146
+ // Where each view sits: the sidebar, in this order, daily work first (no
147
+ // `place`); the sidebar's More menu (`place: 'more'`, `more` names its Lucide
148
+ // icon); the account menu (`place: 'account'`). `sidebar: false` views open
149
+ // by link and Search only.
146
150
  const VIEWS = {
147
- jobs: { label: 'Jobs', icon: 'jobs', group: 'Work', render: (main) => renderJobs(main, ui()) },
148
- invoices: { label: 'Invoices', icon: 'invoices', group: 'Work', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
149
- reconcile: { label: 'Reconcile', icon: 'reconcile', group: 'Work', render: (main, id) => renderReconcile(main, ui(), id) },
150
- 'invoicing-settings': { label: 'Rules', icon: 'rules', group: 'Settings', render: (main, id) => renderInvoicingSettings(main, ui(), id) },
151
- automations: { label: 'Automations', icon: 'bolt', group: 'Settings', render: (main) => renderAutomations(main, ui()) },
152
- 'price-list': { label: 'Price list', icon: 'tag', group: 'Settings', render: (main, id) => (id === 'reconcile' ? renderPriceReconcile(main, ui()) : renderPriceList(main, ui())) },
153
- 'custom-fields': { label: 'Custom fields', icon: 'fields', group: 'Settings', render: (main) => renderCustomFields(main, ui()) },
154
- people: { label: 'People', icon: 'people', group: 'Settings', ownerOnly: true, render: renderPeople },
155
- integrations: { label: 'Integrations', icon: 'plug', group: 'Settings', ownerOnly: true, render: (main, id) => (id ? renderIntegration(main, id) : renderIntegrations(main)) },
151
+ jobs: { label: 'Jobs', icon: 'jobs', render: (main) => renderJobs(main, ui()) },
152
+ invoices: { label: 'Invoices', icon: 'invoices', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
153
+ reconcile: { label: 'Reconcile', icon: 'reconcile', render: (main, id) => renderReconcile(main, ui(), id) },
154
+ 'price-list': { label: 'Price list', icon: 'tag', render: (main, id) => (id === 'reconcile' ? renderPriceReconcile(main, ui()) : renderPriceList(main, ui())) },
155
+ 'invoicing-settings': { label: 'Rules', icon: 'rules', render: (main, id) => renderInvoicingSettings(main, ui(), id) },
156
+ automations: { label: 'Automations', icon: 'bolt', place: 'more', more: 'zap', render: (main) => renderAutomations(main, ui()) },
157
+ 'custom-fields': { label: 'Custom fields', icon: 'fields', place: 'more', more: 'form-input', render: (main) => renderCustomFields(main, ui()) },
158
+ integrations: { label: 'Integrations', icon: 'plug', place: 'more', more: 'plug', ownerOnly: true, render: (main, id) => (id ? renderIntegration(main, id) : renderIntegrations(main)) },
159
+ people: { label: 'People', icon: 'people', place: 'account', ownerOnly: true, render: renderPeople },
156
160
  // Not in the sidebar: "Manage account" in the account menu opens the same
157
161
  // Clerk profile. The page stays for links and Search.
158
162
  // Opened from the Xero integration page: Xero invoices against Flow's own.
159
- 'xero-import': { label: 'Xero import', icon: 'plug', group: 'Settings', sidebar: false, render: (main) => renderXeroImport(main, ui()) },
160
- account: { label: 'Account', icon: 'person', group: 'Settings', sidebar: false, render: renderAccount },
161
- appearance: { label: 'Appearance', icon: 'palette', group: 'Settings', render: renderAppearance },
163
+ 'xero-import': { label: 'Xero import', icon: 'plug', sidebar: false, render: (main) => renderXeroImport(main, ui()) },
164
+ account: { label: 'Account', icon: 'person', sidebar: false, render: renderAccount },
165
+ appearance: { label: 'Appearance', icon: 'palette', place: 'account', render: renderAppearance },
162
166
  };
163
167
 
164
168
  // Line icons for the sidebar, drawn on a 24-unit grid in the text colour.
@@ -176,6 +180,7 @@ const ICON_PATHS = {
176
180
  flag: '<path d="M5 21V4M5 4h11l-2 4 2 4H5"/>',
177
181
  search: '<circle cx="11" cy="11" r="6.5"/><path d="M16 16l4.5 4.5"/>',
178
182
  sidebar: '<rect x="3" y="4" width="18" height="16" rx="2.5"/><path d="M9.5 4v16"/>',
183
+ more: '<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
179
184
  palette: '<path d="M12 3a9 9 0 100 18c1.1 0 1.5-.8 1.5-1.6 0-1-.8-1.4-.8-2.4 0-.9.7-1.5 1.6-1.5H17a4 4 0 004-4c0-4.7-4-8.5-9-8.5z"/><circle cx="7.5" cy="11" r="1"/><circle cx="10" cy="7" r="1"/><circle cx="15" cy="7" r="1"/>',
180
185
  };
181
186
 
@@ -238,7 +243,7 @@ document.addEventListener('keydown', (event) => {
238
243
  }
239
244
  });
240
245
  document.addEventListener('click', (event) => {
241
- if (!event.target.closest?.('.fx-side') || event.target.closest?.('[data-nav]')) closeMenu();
246
+ if (!event.target.closest?.('.fx-side') || event.target.closest?.('[data-nav]:not([data-nav="more"])')) closeMenu();
242
247
  });
243
248
  // Folded, a click on the sidebar's empty space opens it out again, as in
244
249
  // Claude. A click on a page, the account photo or Search still does its job.
@@ -266,15 +271,19 @@ document.addEventListener('keydown', (event) => {
266
271
 
267
272
  // "Report a problem" in Clerk's account menu, between "Manage account" and
268
273
  // "Sign out". Clerk mounts and unmounts the icon itself.
269
- function reportMenuItem(openReport) {
274
+ function accountMenuItem(label, iconName, onClick) {
270
275
  return {
271
- label: 'Report a problem',
272
- onClick: () => { openReport().catch((error) => toast(error.message, 'danger')); },
273
- mountIcon: (element) => element.replaceChildren(icon('flag')),
276
+ label,
277
+ onClick,
278
+ mountIcon: (element) => element.replaceChildren(icon(iconName)),
274
279
  unmountIcon: (element) => element.replaceChildren(),
275
280
  };
276
281
  }
277
282
 
283
+ function reportMenuItem(openReport) {
284
+ return accountMenuItem('Report a problem', 'flag', () => { openReport().catch((error) => toast(error.message, 'danger')); });
285
+ }
286
+
278
287
  // The page a person lands on after sign-in, and for / or an unknown path.
279
288
  // Invoices opens on its Ready tab. A home page can take this over later.
280
289
  const START_VIEW = 'invoices';
@@ -295,6 +304,8 @@ async function route() {
295
304
  for (const link of document.querySelectorAll('a[data-nav]')) {
296
305
  link.setAttribute('aria-current', link.dataset.nav === view ? 'page' : 'false');
297
306
  }
307
+ // More shows as current while one of its pages is open.
308
+ document.querySelector('[data-nav="more"]')?.setAttribute('aria-current', VIEWS[view].place === 'more' ? 'page' : 'false');
298
309
  main.replaceChildren();
299
310
  main.className = 'fx-main';
300
311
  document.title = `${VIEWS[view].label} · Flow`;
@@ -352,14 +363,17 @@ function renderShell() {
352
363
  document.body.dataset.state = 'signed-in';
353
364
  const nav = $('nav');
354
365
  const items = [];
355
- let group = null;
366
+ const more = [];
356
367
  for (const [name, view] of Object.entries(VIEWS)) {
357
368
  if (view.ownerOnly && me?.person.role !== 'owner') continue;
358
- if (view.sidebar === false) continue;
359
- if (view.group && view.group !== group) items.push(h('span', { class: 'fx-nav__group' }, view.group));
360
- group = view.group;
361
- items.push(h('a', { href: `/${name}`, 'data-nav': name, 'data-label': view.label, onclick: navigate }, icon(view.icon), h('span', { class: 'fx-nav__label' }, view.label)));
369
+ if (view.sidebar === false || view.place === 'account') continue;
370
+ if (view.place === 'more') more.push({ icon: view.more, text: view.label, run: () => { closeMenu(); go(`/${name}`); } });
371
+ else items.push(h('a', { href: `/${name}`, 'data-nav': name, 'data-label': view.label, onclick: navigate }, icon(view.icon), h('span', { class: 'fx-nav__label' }, view.label)));
362
372
  }
373
+ // The rest of the setup pages, behind More (the shared ⋯ menu).
374
+ const [moreButton, morePanel] = rowMenu('More', more, { trigger: [icon('more'), h('span', { class: 'fx-nav__label' }, 'More')], className: 'fx-nav__more', align: 'start' });
375
+ Object.assign(moreButton.dataset, { nav: 'more', label: 'More' });
376
+ items.push(moreButton, morePanel);
363
377
  // "Report a problem" sits at the foot of the menu, above the account chip,
364
378
  // while Sentry and its report form are on (monitoring.js).
365
379
  const report = h('button', { type: 'button', class: 'fx-nav__report', 'data-nav': 'report', 'data-label': 'Report a problem', hidden: true },
@@ -376,7 +390,11 @@ function renderShell() {
376
390
  drawMenu();
377
391
  // The sidebar foot shows who is signed in: photo or initial, then the name
378
392
  // (the email when the account has no name). It opens the account menu.
379
- const userButton = { afterSignOutUrl: '/sign-in', showName: true };
393
+ // People (owners) and Appearance live in the account menu.
394
+ const accountItems = Object.entries(VIEWS)
395
+ .filter(([, view]) => view.place === 'account' && (!view.ownerOnly || me?.person.role === 'owner'))
396
+ .map(([name, view]) => accountMenuItem(view.label, view.icon, () => go(`/${name}`)));
397
+ const userButton = { afterSignOutUrl: '/sign-in', showName: true, customMenuItems: accountItems };
380
398
  $('search').replaceChildren(icon('search'));
381
399
  $('search').onclick = search;
382
400
  mountClerk('userButton', $('user-button'), userButton);
@@ -389,7 +407,7 @@ function renderShell() {
389
407
  report.hidden = false;
390
408
  report.onclick = () => { openReport().catch((error) => toast(error.message, 'danger')); };
391
409
  unmountClerk($('user-button'));
392
- mountClerk('userButton', $('user-button'), { ...userButton, customMenuItems: [reportMenuItem(openReport)] });
410
+ mountClerk('userButton', $('user-button'), { ...userButton, customMenuItems: [...accountItems, reportMenuItem(openReport)] });
393
411
  })
394
412
  .catch((error) => console.warn(`[monitoring] ${error.message}`));
395
413
  window.addEventListener('popstate', route);
@@ -873,10 +891,11 @@ function xeroImportSection(organisations) {
873
891
  // Once Xero is on: each organisation's connection, the history copy, and
874
892
  // Connect, which goes to Xero and comes back.
875
893
  function xeroConnection(organisations, canChange) {
876
- const host = h('div', { class: 'fx-settings' });
894
+ const host = h('div', { class: 'fx-xero-orgs' });
877
895
  for (const organisation of organisations) {
896
+ const usage = h('div');
878
897
  const row = settingRow({ label: organisation.name, hint: 'Checking…' });
879
- host.append(row);
898
+ host.append(h('div', { class: 'fx-xero-org' }, usage, row));
880
899
  (async () => {
881
900
  let hint;
882
901
  let control = null;
@@ -884,6 +903,7 @@ function xeroConnection(organisations, canChange) {
884
903
  let refused = false;
885
904
  try {
886
905
  const status = await flow(`/api/xero/status?organisationId=${organisation.id}`);
906
+ usage.replaceChildren(xeroUsage(status) ?? '');
887
907
  if (status.connected && status.calls?.stopped) {
888
908
  // A refused connection stopped calls (the call guard): say so, and
889
909
  // make Connect again the main button, which starts them again.
@@ -1,8 +1,9 @@
1
1
  // A list's columns, as ClickUp's: drag a head's right edge to make the
2
2
  // column wider or narrower (never under its kind's minimum), drag a head
3
3
  // to move the column, and click or right-click a head for its menu (Move to
4
- // start, Move to end, Hide; + shows a hidden column again). Jobs, Invoices
5
- // and Reconcile build their tables here, so every list works the same.
4
+ // start, Move to end, Hide; + shows a hidden column again). Jobs, Invoices,
5
+ // Reconcile and the Price list build their tables here, so every list works
6
+ // the same.
6
7
  //
7
8
  // The view a person keeps for a list (with their account, PUT
8
9
  // /api/me/views/:page) holds two keys from here:
@@ -24,12 +25,58 @@
24
25
  // column keeps its place: no drag, no Move, no Hide.
25
26
  // rows the <tbody> nodes (rowCells builds a row's cells)
26
27
  // lead <th> nodes before the columns (the tick column)
28
+ //
29
+ // Cells every list shares: dateCell (a short date), personCell (a person's
30
+ // initials) and fieldColumn (one of the lab's custom fields as a column, on
31
+ // Jobs and on Invoices, where it is the invoice's job's value).
27
32
  import { lucide } from '/assets/ui/custom-fields.js';
28
- import { MAX_WIDTH, minWidth, moveColumn, widthOf } from '/assets/ui/job-view.js';
33
+ import { endWidth, LEAD_WIDTH, MAX_WIDTH, minWidth, moveColumn, TAIL_WIDTH, tableWidth, widthOf } from '/assets/ui/job-view.js';
34
+ import { initialsOf } from '/assets/ui/person.js';
35
+
36
+ const fullDate = (iso) => new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' });
37
+ // A list date, short: Today, Tomorrow or Yesterday, else day/month (and
38
+ // year when it is not this year).
39
+ export function shortDate(iso) {
40
+ if (!iso) return '—';
41
+ const at = new Date(`${iso}T00:00:00`);
42
+ const now = new Date();
43
+ const days = Math.round((at - new Date(now.getFullYear(), now.getMonth(), now.getDate())) / 86_400_000);
44
+ if (days === 0) return 'Today';
45
+ if (days === 1) return 'Tomorrow';
46
+ if (days === -1) return 'Yesterday';
47
+ return at.toLocaleDateString('en-AU', at.getFullYear() === now.getFullYear() ? { day: '2-digit', month: '2-digit' } : { day: '2-digit', month: '2-digit', year: '2-digit' });
48
+ }
49
+
50
+ // A date cell: the short date, the whole date on hover.
51
+ export const dateCell = (h, iso) => h('time', { datetime: iso ?? null, title: iso ? fullDate(iso) : null }, shortDate(iso));
52
+
53
+ // A person as a badge (initials), the name on hover; `people` is
54
+ // [{ id, name }] (GET /api/custom-fields).
55
+ export function personCell(h, personId, people) {
56
+ if (!personId) return h('span', { class: 'fx-dim' }, '—');
57
+ const name = people.find((p) => p.id === personId)?.name ?? 'Former person';
58
+ return h('span', { class: 'fx-avatar fx-avatar--sm', role: 'img', 'aria-label': name, title: name }, initialsOf(name));
59
+ }
60
+
61
+ // A custom field's value as text.
62
+ export function fieldText(field, value, people) {
63
+ if (value == null || value === false) return '—';
64
+ if (field.type === 'checkbox') return 'Yes';
65
+ if (field.type === 'person') return people.find((p) => p.id === value)?.name ?? 'Former person';
66
+ if (field.type === 'dropdown') return field.options?.find((o) => o.id === value)?.name ?? '—';
67
+ if (field.type === 'date') return shortDate(value);
68
+ return String(value);
69
+ }
70
+
71
+ // One custom field as a list column: `valueOf(row)` is the row's value
72
+ // (null when it has none), `people` names a person field's value.
73
+ export function fieldColumn(h, field, { id, valueOf, people }) {
74
+ const cell = field.type === 'person' ? (row) => personCell(h, valueOf(row), people)
75
+ : field.type === 'date' ? (row) => dateCell(h, valueOf(row)) : (row) => fieldText(field, valueOf(row), people);
76
+ const kind = field.type === 'date' ? 'date' : field.type === 'person' ? 'person' : field.type === 'number' ? 'total' : 'text';
77
+ return { id, label: field.name, kind, num: field.type === 'number', className: 'fx-dim', cell };
78
+ }
29
79
 
30
- // The tick column at the start and the + column at the end.
31
- const LEAD_WIDTH = 44;
32
- const TAIL_WIDTH = 40;
33
80
 
34
81
  // A row's cells for the shown columns: a class per kind, the label for
35
82
  // the phone layout, the full text as the cell's name on hover.
@@ -110,23 +157,42 @@ export function columnTable(h, config) {
110
157
 
111
158
  function table({ columns, rows, lead = [], className = '' }) {
112
159
  const saved = widths();
113
- // Each column keeps its own width: the table is as wide as its columns,
114
- // never stretched to the page. Space left over stays empty at the right.
115
- // A resize changes the table only; the tools stay put.
160
+ // Each column keeps its own width: never stretched to the page. As
161
+ // HubSpot's, the head and every row still run to the right edge: the
162
+ // end column (+ at its right) takes the space after the last column, so
163
+ // a row looks whole, not cut off. A resize changes the table only; the
164
+ // tools stay put.
116
165
  const cols = columns.map((c) => h('col', { style: { width: `${widthOf(c, saved)}px` } }));
117
166
  const leadCols = lead.map(() => h('col', { style: { width: `${LEAD_WIDTH}px` } }));
167
+ const endCol = h('col', { style: { width: `${TAIL_WIDTH}px` } });
118
168
  const tail = config.hidden ? h('th', { class: 'fx-th-add' }, h('button', {
119
169
  type: 'button', class: 'fx-th fx-th--add', 'aria-label': 'Show a column', title: 'Show a column', onclick: (event) => openMenu(null, event),
120
170
  }, '+')) : h('th', { class: 'fx-th-end', 'aria-hidden': 'true' });
121
171
  const heads = columns.map((c, n) => head(c, n));
122
172
  const node = h('table', { class: `fx-table fx-table--sized ${className}`.trim() },
123
- h('colgroup', {}, leadCols, cols, h('col', { style: { width: `${TAIL_WIDTH}px` } })),
173
+ h('colgroup', {}, leadCols, cols, endCol),
124
174
  h('thead', {}, h('tr', {}, lead, heads, tail)),
125
175
  rows);
126
- // The table is as wide as its columns; past the page it scrolls sideways.
127
- const fit = () => { node.style.width = `${columns.reduce((sum, c) => sum + widthOf(c, widths()), 0) + lead.length * LEAD_WIDTH + TAIL_WIDTH}px`; };
176
+ const box = h('div', { class: 'fx-table-scroll' }, node);
177
+ // The width the columns need; past the window the table scrolls
178
+ // sideways. A browser shares spare width among fixed columns, so the
179
+ // end column is sized here, and again when the window changes size.
180
+ let need = tableWidth(columns, saved, lead.length);
181
+ const fit = () => {
182
+ const end = endWidth(need, box.getBoundingClientRect?.().width ?? 0);
183
+ endCol.style.width = `${end}px`;
184
+ node.style.width = `${need - TAIL_WIDTH + end}px`;
185
+ };
128
186
  fit();
129
- return h('div', { class: 'fx-table-scroll' }, node);
187
+ if (typeof ResizeObserver === 'function') {
188
+ // Once the table has left the page (a redraw), stop watching.
189
+ let shown = false;
190
+ const watch = new ResizeObserver(() => {
191
+ if (box.isConnected) { shown = true; fit(); } else if (shown) watch.disconnect();
192
+ });
193
+ watch.observe(box);
194
+ }
195
+ return box;
130
196
 
131
197
  function head(c, n) {
132
198
  const button = h('button', {
@@ -167,12 +233,13 @@ export function columnTable(h, config) {
167
233
  grip.setPointerCapture(event.pointerId);
168
234
  const from = event.clientX;
169
235
  const start = col.getBoundingClientRect().width || widthOf(c, widths());
170
- const base = node.getBoundingClientRect().width;
236
+ const base = need;
171
237
  let w = start;
172
238
  node.dataset.resizing = '';
173
239
  const move = (e) => {
174
240
  w = set(start + e.clientX - from);
175
- node.style.width = `${base + w - start}px`;
241
+ need = base + w - start;
242
+ fit();
176
243
  };
177
244
  const up = () => {
178
245
  grip.removeEventListener('pointermove', move);