@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/README.md CHANGED
@@ -36,6 +36,7 @@ run a lab.
36
36
  | `docs/releases/` | One release notes page per version (`<version>.md`), shown on the docs site |
37
37
  | `docs/DESIGN.md` | The design systems (theme families), how pages and Clerk's components take them, and the screens |
38
38
  | `docs/DESIGN_SYSTEM.md` | The Flow Design System artifact: how `npm run design-system` builds it from the code, the card per shared component, publishing and review |
39
+ | `docs/MOBILE_TESTS.md` | The phone suite: every screen at phone, tablet and desk sizes in WebKit and Chromium, its checks and budget, the three widths, saved images |
39
40
  | `docs/DATA_MODEL.md` | The PostgreSQL records, the rules the database holds, and how to change the schema |
40
41
  | `docs/DATA_SPINE.md` | The design of the tables and jobs every integration shares: mirror, identity map, job queue, outbox, review queue |
41
42
  | `docs/JOB_INVOICE_LINKS.md` | The pipeline: how each job links to its customer and to the accounting invoice that bills it |
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.45",
3
+ "version": "2026.10.46",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@arcforgelabs/flow",
9
- "version": "2026.10.45",
9
+ "version": "2026.10.46",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@clerk/backend": "3.25.0",
@@ -33,6 +33,8 @@
33
33
  "flow-server": "server/flow-server.js"
34
34
  },
35
35
  "devDependencies": {
36
+ "@axe-core/playwright": "4.13.0",
37
+ "@playwright/test": "1.56.1",
36
38
  "drizzle-kit": "0.31.11",
37
39
  "esbuild": "0.25.12"
38
40
  },
@@ -40,6 +42,19 @@
40
42
  "node": ">=24"
41
43
  }
42
44
  },
45
+ "node_modules/@axe-core/playwright": {
46
+ "version": "4.13.0",
47
+ "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz",
48
+ "integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==",
49
+ "dev": true,
50
+ "license": "MPL-2.0",
51
+ "dependencies": {
52
+ "axe-core": "~4.13.0"
53
+ },
54
+ "peerDependencies": {
55
+ "playwright-core": ">= 1.0.0"
56
+ }
57
+ },
43
58
  "node_modules/@clerk/backend": {
44
59
  "version": "3.25.0",
45
60
  "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-3.25.0.tgz",
@@ -1029,6 +1044,22 @@
1029
1044
  "url": "https://github.com/sponsors/oxc-project"
1030
1045
  }
1031
1046
  },
1047
+ "node_modules/@playwright/test": {
1048
+ "version": "1.56.1",
1049
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz",
1050
+ "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==",
1051
+ "dev": true,
1052
+ "license": "Apache-2.0",
1053
+ "dependencies": {
1054
+ "playwright": "1.56.1"
1055
+ },
1056
+ "bin": {
1057
+ "playwright": "cli.js"
1058
+ },
1059
+ "engines": {
1060
+ "node": ">=18"
1061
+ }
1062
+ },
1032
1063
  "node_modules/@sentry/bundler-plugins": {
1033
1064
  "version": "11.8.0",
1034
1065
  "resolved": "https://registry.npmjs.org/@sentry/bundler-plugins/-/bundler-plugins-11.8.0.tgz",
@@ -1183,6 +1214,16 @@
1183
1214
  "node": ">= 0.6"
1184
1215
  }
1185
1216
  },
1217
+ "node_modules/axe-core": {
1218
+ "version": "4.13.0",
1219
+ "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz",
1220
+ "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==",
1221
+ "dev": true,
1222
+ "license": "MPL-2.0",
1223
+ "engines": {
1224
+ "node": ">=4"
1225
+ }
1226
+ },
1186
1227
  "node_modules/balanced-match": {
1187
1228
  "version": "4.0.4",
1188
1229
  "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@@ -2380,6 +2421,67 @@
2380
2421
  "split2": "^4.1.0"
2381
2422
  }
2382
2423
  },
2424
+ "node_modules/playwright": {
2425
+ "version": "1.56.1",
2426
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
2427
+ "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
2428
+ "dev": true,
2429
+ "license": "Apache-2.0",
2430
+ "dependencies": {
2431
+ "playwright-core": "1.56.1"
2432
+ },
2433
+ "bin": {
2434
+ "playwright": "cli.js"
2435
+ },
2436
+ "engines": {
2437
+ "node": ">=18"
2438
+ },
2439
+ "optionalDependencies": {
2440
+ "fsevents": "2.3.2"
2441
+ }
2442
+ },
2443
+ "node_modules/playwright-core": {
2444
+ "version": "1.64.1",
2445
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
2446
+ "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
2447
+ "dev": true,
2448
+ "license": "Apache-2.0",
2449
+ "peer": true,
2450
+ "bin": {
2451
+ "playwright-core": "cli.js"
2452
+ },
2453
+ "engines": {
2454
+ "node": ">=20"
2455
+ }
2456
+ },
2457
+ "node_modules/playwright/node_modules/fsevents": {
2458
+ "version": "2.3.2",
2459
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
2460
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
2461
+ "dev": true,
2462
+ "hasInstallScript": true,
2463
+ "license": "MIT",
2464
+ "optional": true,
2465
+ "os": [
2466
+ "darwin"
2467
+ ],
2468
+ "engines": {
2469
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
2470
+ }
2471
+ },
2472
+ "node_modules/playwright/node_modules/playwright-core": {
2473
+ "version": "1.56.1",
2474
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
2475
+ "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
2476
+ "dev": true,
2477
+ "license": "Apache-2.0",
2478
+ "bin": {
2479
+ "playwright-core": "cli.js"
2480
+ },
2481
+ "engines": {
2482
+ "node": ">=18"
2483
+ }
2484
+ },
2383
2485
  "node_modules/png-js": {
2384
2486
  "version": "2.0.0",
2385
2487
  "resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.45",
3
+ "version": "2026.10.46",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -37,7 +37,9 @@
37
37
  "db:generate": "drizzle-kit generate && node scripts/db-guard-migrations.js",
38
38
  "db:follow-production": "node scripts/replit-workspace-db.js sync",
39
39
  "db:follow-production:status": "node scripts/replit-workspace-db.js status",
40
- "design-system": "node scripts/design-system/build.mjs"
40
+ "design-system": "node scripts/design-system/build.mjs",
41
+ "test:mobile": "playwright test -c test/mobile/playwright.config.js",
42
+ "test:mobile:budget": "node test/mobile/budget.mjs"
41
43
  },
42
44
  "dependencies": {
43
45
  "@clerk/backend": "3.25.0",
@@ -61,6 +63,8 @@
61
63
  "pg": "8.23.1"
62
64
  },
63
65
  "devDependencies": {
66
+ "@axe-core/playwright": "4.13.0",
67
+ "@playwright/test": "1.56.1",
64
68
  "drizzle-kit": "0.31.11",
65
69
  "esbuild": "0.25.12"
66
70
  },
package/server/app.js CHANGED
@@ -26,7 +26,8 @@ import { createUiAssetsRouter } from './ui/routes.js';
26
26
  // per client, sized for a lab of a few people: `rateLimitPerMinute` for /api,
27
27
  // `signInRateLimitPerMinute` for the sign-in page and its config, and
28
28
  // `clerkProxyRateLimitPerMinute` for Clerk's Frontend API, which a signed-in
29
- // browser calls often. `build` is what GET /health reports
29
+ // browser calls often, and `assetRateLimitPerMinute` for the scripts, styles
30
+ // and faces every page loads. `build` is what GET /health reports
30
31
  // (server/deployed-build.js); `updates` is a server/update-check.js checker.
31
32
  // `integrations` is the started registry (server/integrations/index.js); each
32
33
  // adapter's own routes are mounted under /api/<id>, and GET /health reports
@@ -51,6 +52,7 @@ export function createApp({
51
52
  rateLimitPerMinute = 120,
52
53
  signInRateLimitPerMinute = 30,
53
54
  clerkProxyRateLimitPerMinute = 300,
55
+ assetRateLimitPerMinute = 600,
54
56
  defaultFamily = DEFAULT_FAMILY,
55
57
  // Buttons that call Xero or Seazona wait between presses (the action
56
58
  // guard, server/integrations/call-guard.js). Off only in tests.
@@ -95,7 +97,7 @@ export function createApp({
95
97
  }
96
98
 
97
99
  // The design systems, Flow's components and the faces every page loads.
98
- app.use(createUiAssetsRouter());
100
+ app.use(createUiAssetsRouter({ rateLimitPerMinute: assetRateLimitPerMinute }));
99
101
 
100
102
  app.use('/api', rateLimit({ limit: rateLimitPerMinute, windowMs: 60_000 }));
101
103
 
@@ -2,7 +2,7 @@ import { and, asc, eq, isNull, ne, sql } from 'drizzle-orm';
2
2
  import { people, PERSON_ROLES } from '../db/schema.js';
3
3
  import { FlowError } from '../core/errors.js';
4
4
  import { isThemeFamily, normalizeAccent } from '../theme/themes.js';
5
- import { INVOICE_COLUMNS, normaliseJobsView, normaliseListView, PRICE_COLUMNS, RECONCILE_COLUMNS } from '../ui/job-view.js';
5
+ import { invoiceColumnsView, normaliseJobsView, normaliseListView, PRICE_COLUMNS, RECONCILE_COLUMNS } from '../ui/job-view.js';
6
6
  import { normaliseSavedViews, SAVED_VIEW_PAGES } from '../ui/list-views.js';
7
7
  import { isPlainEmail } from './settings.js';
8
8
 
@@ -58,7 +58,7 @@ export async function setPersonAppearance(db, { id, appearance }) {
58
58
  function viewCheck(page) {
59
59
  switch (page) {
60
60
  case 'jobs': return normaliseJobsView;
61
- case 'invoices': return (view) => normaliseListView(view, INVOICE_COLUMNS);
61
+ case 'invoices': return invoiceColumnsView;
62
62
  case 'reconcile': return (view) => normaliseListView(view, RECONCILE_COLUMNS);
63
63
  case 'price-list': return (view) => normaliseListView(view, PRICE_COLUMNS);
64
64
  default: return null;
@@ -203,6 +203,10 @@ export function createCallGuard({
203
203
  lockName = `${integration}-call-guard`,
204
204
  report: reports = true,
205
205
  policy: overrides = {},
206
+ // Optional columns on this adapter's call table, filled from record().
207
+ // `lane` is the allow-list (anything else is stored null). `rateLimitProblem`
208
+ // keeps a short token such as Xero's X-Rate-Limit-Problem.
209
+ telemetry = {},
206
210
  now = Date.now,
207
211
  log = console,
208
212
  } = {}) {
@@ -215,6 +219,14 @@ export function createCallGuard({
215
219
  const C = { key: sql.identifier(columns.key ?? 'key'), at: sql.identifier(columns.at ?? 'at') };
216
220
  const tag = `[${integration}]`;
217
221
  const failed = sql`(failure_kind is not null or status_code >= 500 or (status_code >= 400 and status_code <> 404))`;
222
+ const laneAllowed = Array.isArray(telemetry.lane) ? telemetry.lane : null;
223
+ const keepProblem = telemetry.rateLimitProblem === true;
224
+ const cleanLane = (value) => (laneAllowed?.includes(value) ? value : null);
225
+ const cleanProblem = (value) => {
226
+ if (!keepProblem || typeof value !== 'string') return null;
227
+ const text = value.trim();
228
+ return /^[A-Za-z][A-Za-z0-9_-]{0,39}$/.test(text) ? text : null;
229
+ };
218
230
 
219
231
  const lock = (tx, key) => tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`${lockName}:${key}`}))`);
220
232
  const extraColumns = Object.keys(callDefaults).map((column) => sql`, ${sql.identifier(column)}`);
@@ -263,12 +275,17 @@ export function createCallGuard({
263
275
  // resourceResumed, opened, closed, budgetSpent }),
264
276
  // each of which was just reported once. `callId` names a call row the
265
277
  // adapter wrote when it admitted the call: that row is updated, not added.
266
- async function record(rawKey, { callId = null, resource = null, path = null, statusCode = null, failureKind = null, durationMs = null } = {}) {
278
+ async function record(rawKey, { callId = null, resource = null, path = null, statusCode = null, failureKind = null, durationMs = null, lane: laneRaw = null, rateLimitProblem = null } = {}) {
267
279
  const key = cleanKey(rawKey);
268
280
  const where = cleanResource(resource);
269
281
  const nowMs = Number(now());
270
282
  const nowAt = new Date(nowMs);
271
283
  const status = Number.isInteger(statusCode) ? statusCode : null;
284
+ const lane = cleanLane(laneRaw);
285
+ const problem = cleanProblem(rateLimitProblem);
286
+ const extraCols = sql`${laneAllowed ? sql`, lane` : sql``}${keepProblem ? sql`, rate_limit_problem` : sql``}`;
287
+ const extraVals = sql`${laneAllowed ? sql`, ${lane}` : sql``}${keepProblem ? sql`, ${problem}` : sql``}`;
288
+ const extraSet = sql`${laneAllowed ? sql`, lane = ${lane}` : sql``}${keepProblem ? sql`, rate_limit_problem = ${problem}` : sql``}`;
272
289
  const outcome = await db.transaction(async (tx) => {
273
290
  await lock(tx, key);
274
291
  await ensureRow(tx, key);
@@ -276,10 +293,10 @@ export function createCallGuard({
276
293
  const took = Number.isFinite(durationMs) ? Math.max(0, Math.round(durationMs)) : null;
277
294
  if (callId !== null) {
278
295
  await tx.execute(sql`update ${T.calls} set resource = ${where}, request_path = ${normaliseRequestPath(path)}, status_code = ${status},
279
- failure_kind = ${kind}, duration_ms = ${took} where id = ${callId} and ${C.key} = ${key}`);
296
+ failure_kind = ${kind}, duration_ms = ${took}${extraSet} where id = ${callId} and ${C.key} = ${key}`);
280
297
  } else {
281
- await tx.execute(sql`insert into ${T.calls} (${C.key}, ${C.at}, resource, request_path, status_code, failure_kind, duration_ms${sql.join(extraColumns, sql``)})
282
- values (${key}, ${nowAt}, ${where}, ${normaliseRequestPath(path)}, ${status}, ${kind}, ${took}${sql.join(extraValues, sql``)})`);
298
+ await tx.execute(sql`insert into ${T.calls} (${C.key}, ${C.at}, resource, request_path, status_code, failure_kind, duration_ms${extraCols}${sql.join(extraColumns, sql``)})
299
+ values (${key}, ${nowAt}, ${where}, ${normaliseRequestPath(path)}, ${status}, ${kind}, ${took}${extraVals}${sql.join(extraValues, sql``)})`);
283
300
  }
284
301
  const state = await readState(tx, key);
285
302
  if (!countsAsFailure({ statusCode: status, failureKind })) {
@@ -1,4 +1,5 @@
1
1
  import { FlowError } from '../../core/errors.js';
2
+ import { currentXeroLane } from './governor.js';
2
3
  import { XERO_ENDPOINTS } from './settings.js';
3
4
 
4
5
  // The Xero Accounting API client. Every call goes through the rate-limit
@@ -130,7 +131,7 @@ export function createXeroClient({
130
131
  } catch (error) {
131
132
  if (error?.code === 'xero_replay_no_match') throw error;
132
133
  const timedOut = error?.name === 'AbortError';
133
- await guard?.record(tenantId, { ...call, failureKind: timedOut ? 'timeout' : 'network', durationMs: Date.now() - startedAt });
134
+ await guard?.record(tenantId, { ...call, failureKind: timedOut ? 'timeout' : 'network', durationMs: Date.now() - startedAt, lane: currentXeroLane() });
134
135
  throw xeroError(timedOut ? 'xero_timeout' : 'xero_unreachable',
135
136
  timedOut ? `Xero did not answer within ${timeoutMs} ms.` : 'Flow could not reach Xero.',
136
137
  { status: timedOut ? 504 : 503 });
@@ -169,7 +170,16 @@ export function createXeroClient({
169
170
  // A first 401 usually means the access token expired: it is not counted,
170
171
  // and the call is tried once more with a fresh token.
171
172
  const expiredToken = response.status === 401 && !refreshed;
172
- if (!expiredToken) await guard?.record(tenantId, { ...call, statusCode: response.status, durationMs });
173
+ const problem = response.status === 429 ? response.headers?.get?.('x-rate-limit-problem')?.trim() || null : null;
174
+ if (!expiredToken) {
175
+ await guard?.record(tenantId, {
176
+ ...call,
177
+ statusCode: response.status,
178
+ durationMs,
179
+ lane: currentXeroLane(),
180
+ rateLimitProblem: problem,
181
+ });
182
+ }
173
183
  if (response.ok) return payload ?? {};
174
184
 
175
185
  const error = apiError(response, payload);
@@ -0,0 +1,11 @@
1
+ -- The lane a call counted against, and a 429's X-Rate-Limit-Problem, so the
2
+ -- Xero card can show the day's calls without asking Xero. A row from before
3
+ -- this column has no lane: the card does not guess one.
4
+ ALTER TABLE "xero_calls" ADD COLUMN IF NOT EXISTS "lane" text;
5
+ --> statement-breakpoint
6
+ ALTER TABLE "xero_calls" ADD COLUMN IF NOT EXISTS "rate_limit_problem" text;
7
+ --> statement-breakpoint
8
+ DO $flow_guard$ BEGIN
9
+ ALTER TABLE "xero_calls" ADD CONSTRAINT "xero_calls_lane_valid" CHECK ("lane" is null or "lane" in ('writes', 'sync', 'reads', 'other'));
10
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
11
+ END $flow_guard$;
@@ -71,6 +71,13 @@
71
71
  "when": 1792520000000,
72
72
  "tag": "0009_xero_sent_to_contact",
73
73
  "breakpoints": true
74
+ },
75
+ {
76
+ "idx": 10,
77
+ "version": "7",
78
+ "when": 1792530000000,
79
+ "tag": "0010_xero_call_usage",
80
+ "breakpoints": true
74
81
  }
75
82
  ]
76
83
  }
@@ -14,6 +14,7 @@ import { accountingStatus, checkSentInvoices } from './reconcile.js';
14
14
  import { decryptToken, deleteConnection, describeConnection, readConnection, saveConnection } from './connections.js';
15
15
  import { authorizeUrl, exchangeCode, fetchConnections, revokeToken, selectConnection } from './oauth.js';
16
16
  import { writesAllowed } from './settings.js';
17
+ import { readXeroUsage } from './usage.js';
17
18
  import { saveEvents, signatureValid } from './webhooks.js';
18
19
  import { DEFAULT_FAMILY } from '../../theme/themes.js';
19
20
  import { messagePage as themedMessagePage } from '../../ui/page.js';
@@ -109,8 +110,12 @@ export function callsState(status) {
109
110
  return { stopped: Boolean(status.haltedReason) || resources.length > 0, all: Boolean(status.haltedReason), resources, since };
110
111
  }
111
112
 
113
+ const XERO_INTEGRATION = Object.freeze({ name: 'Xero', logo: 'xero.svg' });
114
+
112
115
  export function createXeroRouter({ db, personAuth, runtime, log = console, buttonCooldowns = true, actions = createActionGuard({ integration: 'xero', actions: XERO_ACTIONS, off: !buttonCooldowns }), messagePage = (options) => themedMessagePage({ ...options, defaultFamily: DEFAULT_FAMILY }) }) {
113
- const page = (title, text, tone) => messagePage({ title, text, tone });
116
+ // Shared callback card (server/ui/page.js): integration marks, status line,
117
+ // and a short auto-return on success only (issue #211).
118
+ const page = (title, text, tone, extra = {}) => messagePage({ title, text, tone, integration: XERO_INTEGRATION, ...extra });
114
119
  const { settings } = runtime;
115
120
  const router = express.Router();
116
121
  const act = (name, key) => actions.route(name, { key });
@@ -144,16 +149,29 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, butto
144
149
  router.get('/status', ...signedIn, handle(log, async (req, res) => {
145
150
  const organisationId = await requireOrganisation(db, req.query.organisationId);
146
151
  const connection = await readConnection(db, organisationId);
152
+ const connected = Boolean(connection && connection.mode === settings.mode);
153
+ const picture = connected
154
+ ? await readXeroUsage(db, {
155
+ organisationId,
156
+ tenantId: connection.tenantId,
157
+ governor: runtime.governor.status(connection.tenantId),
158
+ warningFraction: settings.usageWarningFraction,
159
+ refreshedAt: connection.refreshedAt,
160
+ now: runtime.now(),
161
+ })
162
+ : { usage: null, freshness: null };
147
163
  res.json({
148
164
  ok: true,
149
165
  mode: settings.mode,
150
166
  writesAllowed: writesAllowed(settings),
151
167
  redirectUri: settings.redirectUri,
152
- connected: Boolean(connection && connection.mode === settings.mode),
168
+ connected,
153
169
  connection: describeConnection(connection),
154
- rateLimit: connection ? runtime.governor.status(connection.tenantId) : null,
155
- calls: connection ? callsState(await runtime.guardFor(db).status(connection.tenantId)) : null,
170
+ rateLimit: connected ? runtime.governor.status(connection.tenantId) : null,
171
+ calls: connected ? callsState(await runtime.guardFor(db).status(connection.tenantId)) : null,
156
172
  lastSync: await lastSync(db, organisationId),
173
+ usage: picture.usage,
174
+ freshness: picture.freshness,
157
175
  });
158
176
  }));
159
177
 
@@ -213,7 +231,13 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, butto
213
231
  if (previous && previous.tenantId !== connection.tenantId) await resetImport(db, entry.organisationId);
214
232
  await queueHistoryImport(db, entry.organisationId, { reason: 'connected' });
215
233
  log.info?.(`[xero] organisation ${entry.organisationId} connected to Xero tenant ${connection.tenantId} (${settings.mode}) by person ${entry.personId}`);
216
- return res.type('html').send(page('Xero is connected', `Flow is connected to ${connection.tenantName || 'your Xero organisation'} (${settings.mode}). Flow is now copying its invoice and payment history in the background. You can close this tab.`, 'success'));
234
+ const organisation = connection.tenantName || 'your Xero organisation';
235
+ return res.type('html').send(page(
236
+ 'Xero is connected',
237
+ 'Flow is now copying its invoice and payment history in the background.',
238
+ 'success',
239
+ { organisation, mode: settings.mode, autoReturnSeconds: 5 },
240
+ ));
217
241
  } catch (error) {
218
242
  log.error?.(`[xero] connect failed: ${error.code ?? 'error'}: ${error.message}`);
219
243
  const message = error instanceof FlowError ? error.message : 'Something went wrong talking to Xero. Try again.';
@@ -21,6 +21,11 @@ export const XERO_CALL_GUARD = Object.freeze({
21
21
  name: 'Xero',
22
22
  tables: Object.freeze({ state: 'xero_call_guard', calls: 'xero_calls' }),
23
23
  policy: Object.freeze({ authStops: 'resource' }),
24
+ // Kept on the call row so the Xero card can show lanes and 429s after a restart.
25
+ telemetry: Object.freeze({
26
+ lane: Object.freeze(['writes', 'sync', 'reads', 'other']),
27
+ rateLimitProblem: true,
28
+ }),
24
29
  });
25
30
 
26
31
  export function requireXeroSettings(env = process.env) {
@@ -0,0 +1,74 @@
1
+ import { sql } from 'drizzle-orm';
2
+ import { assembleUsage, startOfUtcDay } from '../../ui/xero-usage-model.js';
3
+
4
+ // What the Xero card draws. Read from counters Flow already keeps: the
5
+ // governor's budget, the call log, and the copy's own timestamps. No Xero call.
6
+
7
+ const DAY_MS = 24 * 60 * 60 * 1000;
8
+ const iso = (value) => (value ? new Date(value).toISOString() : null);
9
+
10
+ // Calls that count toward the day's budget. A token refresh is recorded, and
11
+ // it is not an Accounting API call, so it stays out of the lanes.
12
+ const BUDGET_CALL = sql`lane is not null and coalesce(resource, '') <> 'token'`;
13
+
14
+ export async function readXeroUsage(db, { organisationId, tenantId, governor, warningFraction, refreshedAt, now = Date.now() }) {
15
+ const at = Number(now);
16
+ const dayStart = startOfUtcDay(at);
17
+ const weekStart = new Date(dayStart - 6 * DAY_MS);
18
+ const dayEnd = new Date(dayStart + DAY_MS);
19
+ const today = new Date(dayStart);
20
+
21
+ const [hours, days, tasks, limited, freshness] = await Promise.all([
22
+ db.execute(sql`
23
+ select to_char(date_trunc('hour', "at" at time zone 'UTC'), 'YYYY-MM-DD"T"HH24:00:00"Z"') as hour,
24
+ lane, count(*)::int as n
25
+ from xero_calls
26
+ where key = ${tenantId} and "at" >= ${today} and "at" < ${dayEnd} and ${BUDGET_CALL}
27
+ group by 1, 2`),
28
+ db.execute(sql`
29
+ select to_char("at" at time zone 'UTC', 'YYYY-MM-DD') as day, count(*)::int as n
30
+ from xero_calls
31
+ where key = ${tenantId} and "at" >= ${weekStart} and "at" < ${dayEnd} and ${BUDGET_CALL}
32
+ group by 1`),
33
+ db.execute(sql`
34
+ select coalesce(nullif(resource, ''), 'call') as task, count(*)::int as n
35
+ from xero_calls
36
+ where key = ${tenantId} and "at" >= ${today} and "at" < ${dayEnd} and coalesce(resource, '') <> 'token'
37
+ group by 1
38
+ order by n desc, task
39
+ limit 8`),
40
+ db.execute(sql`
41
+ select "at", rate_limit_problem as problem, lane, resource as task
42
+ from xero_calls
43
+ where key = ${tenantId} and status_code = 429 and "at" >= ${weekStart}
44
+ order by "at" desc
45
+ limit 12`),
46
+ db.execute(sql`
47
+ select
48
+ (select max(received_at) from xero_webhook_events where organisation_id = ${organisationId} and source = 'webhook') as webhook_at,
49
+ (select max(last_read_at) from xero_mirror_cursors where organisation_id = ${organisationId}) as poll_at,
50
+ (select last_finished_at from xero_daily_checks where organisation_id = ${organisationId}) as nightly_at,
51
+ (select max(read_at) from xero_webhook_events where organisation_id = ${organisationId}) as read_at`),
52
+ ]);
53
+
54
+ const fresh = freshness.rows[0] ?? {};
55
+ const copyCandidates = [fresh.poll_at, fresh.nightly_at, fresh.read_at].filter(Boolean).map((value) => new Date(value).getTime());
56
+ return {
57
+ usage: assembleUsage({
58
+ hourRows: hours.rows,
59
+ dayRows: days.rows,
60
+ taskRows: tasks.rows,
61
+ limitRows: limited.rows,
62
+ governor,
63
+ warningFraction,
64
+ now: at,
65
+ }),
66
+ freshness: {
67
+ webhookAt: iso(fresh.webhook_at),
68
+ pollAt: iso(fresh.poll_at),
69
+ nightlyAt: iso(fresh.nightly_at),
70
+ copyAt: copyCandidates.length ? new Date(Math.max(...copyCandidates)).toISOString() : null,
71
+ tokenRefreshedAt: iso(refreshedAt),
72
+ },
73
+ };
74
+ }
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';
@@ -890,10 +891,11 @@ function xeroImportSection(organisations) {
890
891
  // Once Xero is on: each organisation's connection, the history copy, and
891
892
  // Connect, which goes to Xero and comes back.
892
893
  function xeroConnection(organisations, canChange) {
893
- const host = h('div', { class: 'fx-settings' });
894
+ const host = h('div', { class: 'fx-xero-orgs' });
894
895
  for (const organisation of organisations) {
896
+ const usage = h('div');
895
897
  const row = settingRow({ label: organisation.name, hint: 'Checking…' });
896
- host.append(row);
898
+ host.append(h('div', { class: 'fx-xero-org' }, usage, row));
897
899
  (async () => {
898
900
  let hint;
899
901
  let control = null;
@@ -901,6 +903,7 @@ function xeroConnection(organisations, canChange) {
901
903
  let refused = false;
902
904
  try {
903
905
  const status = await flow(`/api/xero/status?organisationId=${organisation.id}`);
906
+ usage.replaceChildren(xeroUsage(status) ?? '');
904
907
  if (status.connected && status.calls?.stopped) {
905
908
  // A refused connection stopped calls (the call guard): say so, and
906
909
  // make Connect again the main button, which starts them again.
@@ -25,8 +25,57 @@
25
25
  // column keeps its place: no drag, no Move, no Hide.
26
26
  // rows the <tbody> nodes (rowCells builds a row's cells)
27
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).
28
32
  import { lucide } from '/assets/ui/custom-fields.js';
29
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
+ }
30
79
 
31
80
 
32
81
  // A row's cells for the shown columns: a class per kind, the label for