@arcforgelabs/flow 2026.11.6 → 2026.11.7

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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.11.6",
3
+ "version": "2026.11.7",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@arcforgelabs/flow",
9
- "version": "2026.11.6",
9
+ "version": "2026.11.7",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@clerk/backend": "3.25.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.11.6",
3
+ "version": "2026.11.7",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/server/app.js CHANGED
@@ -146,7 +146,7 @@ export function createApp({
146
146
  // Settings, Integrations: an owner turns integrations on and enters their
147
147
  // settings (server/integrations/setup.js).
148
148
  if (integrations && database && auth?.personAuth) {
149
- app.use('/api/integrations', createSetupRouter({ db: database.db, registry: integrations, personAuth: auth.personAuth, secrets, log }));
149
+ app.use('/api/integrations', createSetupRouter({ db: database.db, registry: integrations, personAuth: auth.personAuth, secrets, log, buttonCooldowns }));
150
150
  }
151
151
  // Invoicing for signed-in people (server/api/invoicing.js). A job's draft
152
152
  // invoice needs a live check in the accounting system: the first enabled
@@ -0,0 +1,38 @@
1
+ // Sync now (docs/INTEGRATIONS.md, "Sync now"): a person asks Flow to read its
2
+ // systems again now, not at the next background run. An integration takes
3
+ // part while its own "Include in Sync now" setting is on, the default. Each
4
+ // integration's page also has its own Sync, which reads that one integration
5
+ // whatever the setting says. Each read goes through the integration's call guard
6
+ // and rate limit, as a background read does. The pipeline joins what they
7
+ // bring into jobs at its next run (every 2 minutes).
8
+
9
+ const named = (adapter, answer) => ({ id: adapter.id, name: adapter.name, ...answer });
10
+
11
+ // What the Sync now button shows: each integration that can take part,
12
+ // whether it does, whether a read is going, and when the last one started.
13
+ // Reads Flow's data only.
14
+ export async function syncNowStatus(registry) {
15
+ return Promise.all(registry.with('syncNowStatus').map(async (adapter) => {
16
+ try {
17
+ return named(adapter, await adapter.capabilities.syncNowStatus());
18
+ } catch {
19
+ return named(adapter, { included: false, running: false, lastAt: null, ok: null });
20
+ }
21
+ }));
22
+ }
23
+
24
+ // Starts each included integration's read, one after the other, or only the
25
+ // integration `only` names. An integration that cannot read answers
26
+ // `unavailable` with its reason; the others still run.
27
+ export async function startSyncNow(registry, { only = null, actor = null, log = console } = {}) {
28
+ const results = [];
29
+ for (const adapter of registry.with('syncNow').filter((a) => !only || a.id === only)) {
30
+ try {
31
+ results.push(named(adapter, await adapter.capabilities.syncNow({ asked: Boolean(only) })));
32
+ } catch (error) {
33
+ results.push(named(adapter, { state: 'unavailable', reason: error?.message || 'the read failed' }));
34
+ }
35
+ }
36
+ log.info?.(`[sync-now] person ${actor ?? 'unknown'}: ${results.map((r) => `${r.id} ${r.state}`).join(', ') || 'no integration can sync'}`);
37
+ return results;
38
+ }
@@ -39,6 +39,8 @@ export const CAPABILITIES = Object.freeze([
39
39
  'readChanges', // { state: fresh | read | unavailable }: the accounting system's latest changes into its copy now, just before Flow drafts or sends (read before act)
40
40
  'itemMatches', // { organisationId, codes } -> the Flow price codes that send as an item the system sells (Xero: linked item, else same code), from its copy; null while not connected or its item list was never read; never a live call
41
41
  'listPriceItems', // the system's own price list from its copy in Flow, for the price list's links and check; never a live call
42
+ 'syncNow', // { asked } -> { state: started | running | read | off | unavailable, reason?, changed? }: a person's Sync now; read the system's latest changes into its copy now, inside its call guard. off: its "Include in Sync now" setting is off and `asked` (a person's Sync for this integration alone) is false (server/core/sync-now.js)
43
+ 'syncNowStatus', // {} -> { included, running, lastAt, ok }: for the Sync now button; reads Flow's data only
42
44
  'health', // connection state for Settings
43
45
  'alerts', // what only a person can clear (a refused sign-in, stopped calls), for the Integrations page; reads Flow's data only
44
46
  'captureError', // an error Flow logged, sent to the installation's own error service (Sentry)
@@ -53,10 +53,15 @@ export const SEAZONA_SETUP = {
53
53
  { name: 'SEAZONA_API_CLIENT_SECRET', label: 'Client secret', secret: true, help: 'Stored encrypted. Flow never shows it again.' },
54
54
  { name: 'SEAZONA_ORDER_URL', label: 'Order address', kind: 'url', help: 'Optional. Open any order in Seazona and paste its address here, or write {ref} where the order id goes. Stage moves on the Jobs board and Open in Seazona use it.' },
55
55
  { name: 'SEAZONA_KEEP_PATIENT_REFERENCE', label: 'Keep patient names', kind: 'onoff', help: 'On: Flow keeps the patient name of each job for the invoice Reference.' },
56
+ { name: 'SEAZONA_SYNC_NOW', label: 'Include in Sync now', kind: 'onoff', default: 'on', help: 'On: Sync now on the Integrations page reads Seazona\'s new and changed orders, invoices and practices.' },
56
57
  { name: 'SEAZONA_API_READ_HIDE_PATIENT', label: 'Hide patient details', kind: 'onoff', help: 'On: an AI assistant reading Seazona through Flow gets no patient, note, file or clinical fields.' },
57
58
  ],
58
59
  };
59
60
 
61
+ // The lanes Sync now reads: what changes from day to day. Products, lab
62
+ // users, the history read and the sweep keep their own schedule.
63
+ export const SYNC_NOW_LANES = Object.freeze(['invoices', 'orders_recent', 'orders_active', 'clients']);
64
+
60
65
  // What a person reads for each lane: the Seazona records it copies.
61
66
  const READ_NAMES = {
62
67
  invoices: 'invoices',
@@ -72,6 +77,10 @@ const readNames = (lanes) => lanes.map((lane) => READ_NAMES[lane.lane] ?? lane.l
72
77
  export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.now, timers } = {}) {
73
78
  let runtime = null; // { settings, db, client, scheduler }
74
79
  const actReader = createActReader({ now });
80
+ // Sync now with no background sync (sandbox, replay, or background jobs
81
+ // off): one run at a time, and when the last one ended.
82
+ let manual = null;
83
+ let manualLast = null;
75
84
 
76
85
  function current() {
77
86
  if (!runtime) throw new Error('The Seazona adapter has not started');
@@ -121,6 +130,7 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
121
130
 
122
131
  async stop() {
123
132
  await runtime?.scheduler?.stop();
133
+ await manual;
124
134
  runtime = null;
125
135
  },
126
136
 
@@ -178,6 +188,40 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
178
188
  return alerts;
179
189
  },
180
190
 
191
+ // Sync now (server/core/sync-now.js): read new and changed orders,
192
+ // invoices and practices now, inside the rate governor. It answers at
193
+ // once; the run goes on in the background. Off while "Include in Sync
194
+ // now" is off, unless a person asked for Seazona alone (`asked`, its own
195
+ // Sync button).
196
+ async syncNow({ asked = false } = {}) {
197
+ if (!runtime) return { state: 'unavailable', reason: 'Seazona has not started' };
198
+ if (!runtime.settings.syncNow && !asked) return { state: 'off' };
199
+ const options = { only: SYNC_NOW_LANES };
200
+ if (runtime.scheduler) {
201
+ const state = runtime.scheduler.runNow(options);
202
+ return state === 'stopped' ? { state: 'unavailable', reason: 'Seazona is stopping' } : { state };
203
+ }
204
+ if (manual) return { state: 'running' };
205
+ const at = new Date(Number(now())).toISOString();
206
+ manual = runtime.run(options)
207
+ .then((result) => { manualLast = { at, ok: result.ok }; })
208
+ .catch(() => { manualLast = { at, ok: false }; })
209
+ .finally(() => { manual = null; });
210
+ return { state: 'started' };
211
+ },
212
+
213
+ async syncNowStatus() {
214
+ if (!runtime) return { included: false, running: false, lastAt: null, ok: null };
215
+ const scheduled = runtime.scheduler?.status();
216
+ const last = scheduled ? scheduled.lastRun : manualLast;
217
+ return {
218
+ included: runtime.settings.syncNow,
219
+ running: Boolean(scheduled?.running || manual),
220
+ lastAt: last?.at ?? null,
221
+ ok: last ? last.ok : null,
222
+ };
223
+ },
224
+
181
225
  // Billable work since a cursor: complete and shipped orders that
182
226
  // changed, with the Seazona invoice that carries each one. Orders
183
227
  // deleted in Seazona are never billable; they come back in `withdrawn`
@@ -85,14 +85,14 @@ export function createSeazonaScheduler({
85
85
  timer?.unref?.();
86
86
  }
87
87
 
88
- async function tick() {
88
+ async function tick(options = {}) {
89
89
  timer = null;
90
90
  if (stopped) return;
91
91
  const startedAt = new Date(Number(now())).toISOString();
92
92
  const turn = { abandoned: false };
93
93
  const thisRun = (async () => {
94
94
  try {
95
- const result = await run();
95
+ const result = await run(options);
96
96
  const failed = Object.entries(result.results).filter(([, lane]) => !lane.ok);
97
97
  last = { at: startedAt, ok: result.ok, lanes: Object.keys(result.results), failed: failed.map(([lane]) => lane) };
98
98
  report(result.results);
@@ -135,6 +135,17 @@ export function createSeazonaScheduler({
135
135
  nextRunAt = null;
136
136
  await running;
137
137
  },
138
+ // Sync now (server/core/sync-now.js): a person asks for a run now, with
139
+ // `options` (the lanes to read). While a run is going it starts nothing
140
+ // and answers 'running'. Otherwise the waiting run moves to now; the
141
+ // schedule goes on from its end as usual.
142
+ runNow(options = {}) {
143
+ if (stopped) return 'stopped';
144
+ if (running) return 'running';
145
+ if (timer) timers.clearTimeout(timer);
146
+ tick(options);
147
+ return 'started';
148
+ },
138
149
  status() {
139
150
  return { running: Boolean(running), nextRunAt, lastRun: last, stuckSince };
140
151
  },
@@ -206,6 +206,9 @@ export function resolveSeazonaSettings(env = process.env) {
206
206
  // order number, for "Open in Seazona" on a stage move. Optional; one
207
207
  // without {ref} is ignored.
208
208
  orderUrlTemplate: orderUrlTemplate(env.SEAZONA_ORDER_URL),
209
+ // "Include in Sync now" (server/core/sync-now.js): on unless the lab
210
+ // turns it off.
211
+ syncNow: !['0', 'false', 'off', 'no'].includes(text(env.SEAZONA_SYNC_NOW).toLowerCase()),
209
212
  };
210
213
 
211
214
  if (mode === 'live' || mode === 'sandbox') {
@@ -130,8 +130,15 @@ function laneReadsFrom(state, sync) {
130
130
  return Number.isFinite(cursorMs) ? cursorMs - sync.cursorOverlapMs : null;
131
131
  }
132
132
 
133
- function isDue(progress, at, sync) {
133
+ // What an orders cycle reads from each list row. 2: the remake flag
134
+ // (Seazona's isRemake, migration 0008). A cycle that finished before Flow
135
+ // kept the flag does not count, so the next run starts a new one and old
136
+ // orders get their flag without waiting out SEAZONA_SWEEP_CYCLE_DAYS.
137
+ export const ORDER_CYCLE_READS = 2;
138
+
139
+ function isDue(progress, at, sync, kind) {
134
140
  if (progress?.cycleStartedAt) return true;
141
+ if (kind === 'orders' && (progress?.reads ?? 1) < ORDER_CYCLE_READS) return true;
135
142
  const finished = Date.parse(progress?.lastFinishedAt ?? '');
136
143
  return !Number.isFinite(finished) || at.getTime() - finished >= sync.sweepCycleMs;
137
144
  }
@@ -253,7 +260,7 @@ async function sweepOrders(ctx, cursor, helpers, found) {
253
260
  progress.days.shift();
254
261
  progress.checkedDays += 1;
255
262
  }
256
- cursor.orders = { lastFinishedAt: at.toISOString(), checkedDays: progress.checkedDays };
263
+ cursor.orders = { lastFinishedAt: at.toISOString(), checkedDays: progress.checkedDays, reads: ORDER_CYCLE_READS };
257
264
  }
258
265
 
259
266
  // Clients and invoices: the whole list, then the held records it left out.
@@ -501,7 +508,7 @@ export async function runSweep(ctx, helpers) {
501
508
  try {
502
509
  for (const kind of SWEEP_KINDS) {
503
510
  if (budget.left <= 0) break;
504
- if (!isDue(cursor[kind], at, sync)) continue;
511
+ if (!isDue(cursor[kind], at, sync, kind)) continue;
505
512
  if (kind === 'orders') await sweepOrders(sweepCtx, cursor, helpers, found);
506
513
  else await sweepWholeList(sweepCtx, kind, cursor, laneStates, helpers, found);
507
514
  }
@@ -2,6 +2,8 @@ import { eq, sql } from 'drizzle-orm';
2
2
  import express from 'express';
3
3
  import { FlowError } from '../core/errors.js';
4
4
  import { deriveKey, openSecret, sealSecret } from '../core/secrets.js';
5
+ import { startSyncNow, syncNowStatus } from '../core/sync-now.js';
6
+ import { createActionGuard } from './call-guard.js';
5
7
  import { integrationSettingEvents, integrationSettings, organisations } from '../db/schema.js';
6
8
 
7
9
  // Integration setup in Settings (docs/INTEGRATIONS.md, "Set up in Flow"). An
@@ -183,8 +185,11 @@ export async function saveSettings(db, { adapter, secrets, actor, enabled, value
183
185
 
184
186
  // GET /api/integrations (owner): every adapter with settings, its state and
185
187
  // what is set. PUT /api/integrations/:id (owner): save and apply.
186
- export function createSetupRouter({ db, registry, personAuth, secrets, baseEnv = process.env, log = console }) {
188
+ export function createSetupRouter({ db, registry, personAuth, secrets, baseEnv = process.env, log = console, buttonCooldowns = true }) {
187
189
  const router = express.Router();
190
+ // Sync now can call Xero at a press, so it is an action (docs/INTEGRATIONS.md,
191
+ // "Button presses"): one a minute, for all and for each integration alone.
192
+ const actions = createActionGuard({ integration: 'sync-now', actions: { syncNow: { cooldownMs: 60_000 } }, off: !buttonCooldowns });
188
193
  const owner = [personAuth.requireSession, personAuth.requireRole('owner')];
189
194
  // A change needs a verified owner sign-in, as connecting Xero does.
190
195
  const verifiedOwner = [personAuth.requireSession, personAuth.requireOwner];
@@ -205,6 +210,21 @@ export function createSetupRouter({ db, registry, personAuth, secrets, baseEnv =
205
210
  } catch (error) { next(error); }
206
211
  });
207
212
 
213
+ // Sync now (server/core/sync-now.js): what the button shows, and the
214
+ // press. Reads only; the reads stay inside each call guard.
215
+ router.get('/sync-now', ...owner, async (_req, res, next) => {
216
+ try {
217
+ res.json({ ok: true, integrations: await syncNowStatus(registry) });
218
+ } catch (error) { next(error); }
219
+ });
220
+ router.post('/sync-now', ...owner, express.json({ limit: '4kb' }), actions.route('syncNow', { key: (req) => req.body?.only ?? 'all' }), async (req, res, next) => {
221
+ try {
222
+ const only = req.body?.only ?? null;
223
+ if (only !== null && !registry.get(only)) return res.status(404).json({ ok: false, code: 'not_found', error: 'No such integration' });
224
+ res.json({ ok: true, results: await startSyncNow(registry, { only, actor: req.person.id, log }) });
225
+ } catch (error) { next(error); }
226
+ });
227
+
208
228
  router.put('/:id', ...verifiedOwner, express.json({ limit: '32kb' }), async (req, res) => {
209
229
  const adapter = registry.get(req.params.id);
210
230
  if (!adapter?.setup) return res.status(404).json({ ok: false, code: 'not_found', error: 'No such integration' });
@@ -48,6 +48,7 @@ export const XERO_SETUP = {
48
48
  { name: 'XERO_TENANT_NAME', label: 'Xero organisation name', help: 'Live mode connects only the organisation with this exact name.' },
49
49
  { name: 'XERO_ALLOW_LIVE_WRITES', label: 'Create draft invoices in live Xero', kind: 'onoff', help: 'Off: Flow only reads from Xero.' },
50
50
  { name: 'XERO_CREATE_CONTACTS', label: 'Create missing clients', kind: 'onoff', default: 'on', help: 'On: a pending invoice whose client is not in Xero offers Create. Off: add the client in Xero, then sync.' },
51
+ { name: 'XERO_SYNC_NOW', label: 'Include in Sync now', kind: 'onoff', default: 'on', help: 'On: Sync now on the Integrations page also reads Xero\'s changed invoices and credit notes. Each uses a few of the day\'s Xero calls.' },
51
52
  { name: 'XERO_WEBHOOK_KEY', label: 'Webhook key', secret: true, help: 'From the Xero app\'s Webhooks page. Empty: Flow reads Xero\'s changes every 15 minutes instead.' },
52
53
  ],
53
54
  derivedKeys: ['XERO_TOKEN_KEY'],
@@ -160,6 +161,30 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
160
161
  // Xero's latest changes into the copy just before Flow drafts by rule or
161
162
  // sends (webhooks.js); no call when the copy is current.
162
163
  readChanges: ({ organisationId }) => readChangesFirst(database, current(), organisationId),
164
+ // Sync now (server/core/sync-now.js): Xero's latest invoice and credit
165
+ // note changes into the copy now, for each connected organisation, even
166
+ // when the copy counts as current. Off while "Include in Sync now" is off,
167
+ // unless a person asked for Xero alone (`asked`, its own Sync button).
168
+ syncNow: async ({ asked = false } = {}) => {
169
+ const { settings } = current();
170
+ if (!settings.syncNow && !asked) return { state: 'off' };
171
+ const { rows } = await database.execute(sql`select organisation_id from xero_connections where mode = ${settings.mode}`);
172
+ if (!rows.length) return { state: 'unavailable', reason: 'Xero is not connected' };
173
+ let changed = 0;
174
+ for (const row of rows) {
175
+ const answer = await readChangesFirst(database, current(), row.organisation_id, { force: true });
176
+ if (answer.state === 'unavailable') return answer;
177
+ changed += answer.changed ?? 0;
178
+ }
179
+ return { state: 'read', changed };
180
+ },
181
+ syncNowStatus: async () => {
182
+ const { settings } = current();
183
+ const { rows: [row] } = await database.execute(sql`select max(c.last_read_at) as at from xero_mirror_cursors c
184
+ join xero_connections x on x.organisation_id = c.organisation_id and x.mode = ${settings.mode}
185
+ where c.resource = 'invoices'`);
186
+ return { included: settings.syncNow, running: false, lastAt: row?.at ? new Date(row.at).toISOString() : null, ok: null };
187
+ },
163
188
  // Active Xero contacts, from Flow's copy (lookup.js).
164
189
  listCustomers: ({ organisationId }) => listCustomers(database, organisationId),
165
190
  // Create the Xero contact for one Flow contact, when that setting is on
@@ -196,6 +196,9 @@ export function resolveXeroSettings(env = process.env) {
196
196
  // On unless the lab turns it off: a pending invoice whose client is not
197
197
  // in Xero offers to create that client (docs/CONTACTS.md).
198
198
  createContacts: readOnOff(env, 'XERO_CREATE_CONTACTS', true),
199
+ // On unless the lab turns it off: Sync now (server/core/sync-now.js) also
200
+ // reads Xero's changes. Each Sync now costs a few of the day's calls.
201
+ syncNow: readOnOff(env, 'XERO_SYNC_NOW', true),
199
202
  mirrorPageSize: Math.min(readPositiveInteger(env, 'XERO_MIRROR_PAGE_SIZE', DEFAULT_MIRROR_PAGE_SIZE), MAX_MIRROR_PAGE_SIZE),
200
203
  // The share of the day's calls the mirror leaves for drafts and people.
201
204
  mirrorReserveFraction: readFraction(env, 'XERO_MIRROR_RESERVE_FRACTION', 0.3),
@@ -272,16 +272,17 @@ export async function dueDailyChecks(db, runtime, { now = Date.now() } = {}) {
272
272
  // unavailable the read could not run (`reason`); act on nothing
273
273
  export const READ_FIRST_MS = 10 * 60 * 1000;
274
274
 
275
- export async function readChangesFirst(db, runtime, organisationId, { now = Date.now } = {}) {
275
+ // `force` (Sync now) reads even when the copy counts as current.
276
+ export async function readChangesFirst(db, runtime, organisationId, { now = Date.now, force = false } = {}) {
276
277
  const { rows: [connection] } = await db.execute(sql`select tenant_id from xero_connections
277
278
  where organisation_id = ${organisationId} and mode = ${runtime.settings.mode}`);
278
279
  if (!connection) return { state: 'unavailable', reason: 'Xero is not connected' };
279
280
  const { rows: [{ waiting }] } = await db.execute(sql`select count(*)::int as waiting from xero_webhook_events
280
281
  where organisation_id = ${organisationId} and pending`);
281
- if (!waiting && await webhooksActive(db, runtime, organisationId, { now: Number(now()) })) return { state: 'fresh' };
282
+ if (!force && !waiting && await webhooksActive(db, runtime, organisationId, { now: Number(now()) })) return { state: 'fresh' };
282
283
  const { rows: [{ oldest }] } = await db.execute(sql`select min(last_read_at) as oldest from xero_mirror_cursors
283
284
  where organisation_id = ${organisationId} and resource in ('invoices', 'credit_notes')`);
284
- if (!waiting && oldest && Number(now()) - new Date(oldest).getTime() <= READ_FIRST_MS) return { state: 'fresh' };
285
+ if (!force && !waiting && oldest && Number(now()) - new Date(oldest).getTime() <= READ_FIRST_MS) return { state: 'fresh' };
285
286
  try {
286
287
  const inbox = await readInbox(db, runtime, organisationId, { now });
287
288
  if (inbox.stopped) return { state: 'unavailable', reason: inbox.stopped === 'budget' ? 'the day\'s Xero calls are kept for people' : 'Xero calls are stopped' };
package/server/ui/app.js CHANGED
@@ -640,6 +640,50 @@ function integrationLogo(integration, size = '') {
640
640
  file ? h('img', { src: `/assets/ui/logos/${file}`, alt: '' }) : icon('plug'));
641
641
  }
642
642
 
643
+ // Sync now (server/core/sync-now.js): reads each integration whose "Include
644
+ // in Sync now" setting is on, now. With `only`, it is that integration's own
645
+ // Sync on its page, and reads it whatever the setting says. Which ones, and
646
+ // when the last read started, are on hover. While a read goes on, the button
647
+ // says so and asks Flow again every 5 seconds (Flow's data only). Hidden while
648
+ // nothing it would read can sync.
649
+ function syncNowButton({ only = null } = {}) {
650
+ const word = only ? 'Sync' : 'Sync now';
651
+ const button = h('button', { class: 'fx-btn', type: 'button', hidden: true, disabled: true }, word);
652
+ const when = (at) => new Date(at).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' });
653
+ let timer = null;
654
+ const show = (all) => {
655
+ const list = only ? all.filter((i) => i.id === only) : all;
656
+ const included = only ? list : list.filter((i) => i.included);
657
+ const running = included.some((i) => i.running);
658
+ const last = included.map((i) => i.lastAt).filter(Boolean).sort().pop();
659
+ button.hidden = !list.length;
660
+ button.disabled = running || !included.length;
661
+ button.replaceChildren(...(running ? [h('span', { class: 'fx-spinner fx-spinner--sm', 'aria-hidden': 'true' }), 'Syncing'] : [word]));
662
+ button.title = included.length
663
+ ? `Reads ${included.map((i) => i.name).join(' and ')}.${last ? ` Last read ${when(last)}.` : ''}`
664
+ : 'Turn on "Include in Sync now" in an integration\'s settings.';
665
+ clearTimeout(timer);
666
+ if (running) timer = setTimeout(() => { if (button.isConnected) load(); }, 5000);
667
+ };
668
+ const load = () => flow('/api/integrations/sync-now')
669
+ .then(({ integrations }) => show(integrations))
670
+ .catch(() => { button.hidden = true; });
671
+ button.addEventListener('click', async () => {
672
+ button.disabled = true;
673
+ try {
674
+ const { results } = await flow('/api/integrations/sync-now', { method: 'POST', body: JSON.stringify(only ? { only } : {}) });
675
+ for (const r of results.filter((r) => r.state === 'unavailable')) toast(`${r.name}: ${r.reason}`, 'danger');
676
+ const reading = results.filter((r) => ['started', 'running', 'read'].includes(r.state));
677
+ if (reading.length) toast(`Reading ${reading.map((r) => r.name).join(' and ')}.`, 'success');
678
+ } catch (error) {
679
+ toast(error.message, 'danger');
680
+ }
681
+ load();
682
+ });
683
+ load();
684
+ return button;
685
+ }
686
+
643
687
  async function renderIntegrations(main) {
644
688
  const state = { tab: 'all', category: 'all', q: '' };
645
689
  const tabs = h('div', { class: 'fx-seg', role: 'tablist' });
@@ -649,7 +693,7 @@ async function renderIntegrations(main) {
649
693
  const notices = h('div', { class: 'fx-fields' });
650
694
  const content = h('div', { class: 'fx-int-sections' }, h('div', { class: 'fx-skeleton' }));
651
695
  main.append(
652
- pageHeader('Integrations'),
696
+ pageHeader('Integrations', { actions: [syncNowButton()] }),
653
697
  alertStrip(ui(), 'integrations').element,
654
698
  h('div', { class: 'fx-int-bar' }, tabs, h('div', { class: 'fx-row' }, category, search)),
655
699
  notices, content);
@@ -848,6 +892,18 @@ async function renderIntegration(main, id) {
848
892
  }
849
893
  };
850
894
 
895
+ // A disconnect stops the integration, so it asks first: one stray click
896
+ // must not stop a lab's sync.
897
+ const disconnect = async () => {
898
+ const ok = await dialog({
899
+ title: `Disconnect ${current.name}?`,
900
+ body: h('p', {}, `Flow stops working with ${current.name} until someone connects it again. Its settings and Flow's records stay.`),
901
+ confirm: 'Disconnect',
902
+ tone: 'danger',
903
+ });
904
+ if (ok) await save(false);
905
+ };
906
+
851
907
  const on = current.enabled;
852
908
  const guide = current.guide;
853
909
  const hostOwned = current.enabledBy === 'host';
@@ -855,10 +911,13 @@ async function renderIntegration(main, id) {
855
911
  const modeLabel = mode?.choices?.find((c) => c.value === mode.value)?.label ?? mode?.value;
856
912
  const busy = progress && progress.steps.some((step) => step.state === 'running');
857
913
 
858
- const primary = !canChange || hostOwned ? null : on
859
- ? h('button', { class: 'fx-btn', type: 'button', onclick: () => save(false) }, 'Disconnect')
914
+ const primary = !canChange || hostOwned || on ? null
860
915
  : h('button', { class: 'fx-btn fx-btn--primary', type: 'button', disabled: busy, onclick: connect },
861
916
  busy ? h('span', { class: 'fx-spinner fx-spinner--sm', 'aria-hidden': 'true' }) : null, busy ? 'Connecting' : 'Connect');
917
+ // Disconnect sits at the end of the page, away from Sync.
918
+ const disconnectRow = !canChange || hostOwned || !on ? null
919
+ : h('div', { class: 'fx-int-section' }, h('div', { class: 'fx-row fx-row--end' },
920
+ h('button', { class: 'fx-btn fx-btn--ghost fx-btn--danger', type: 'button', onclick: disconnect }, 'Disconnect')));
862
921
 
863
922
  host.replaceChildren(
864
923
  crumbs(['/integrations', 'Integrations'], current.name),
@@ -869,6 +928,7 @@ async function renderIntegration(main, id) {
869
928
  h('h1', { class: 'fx-title' }, current.name),
870
929
  h('div', { class: 'fx-int-hero__actions' },
871
930
  on ? statusPill(current) : null,
931
+ on && current.state === 'ready' ? syncNowButton({ only: current.id }) : null,
872
932
  primary,
873
933
  progress ? connectProgress(progress) : null)),
874
934
  alerts.element,
@@ -893,6 +953,7 @@ async function renderIntegration(main, id) {
893
953
  h('summary', {}, 'Advanced'),
894
954
  h('div', { class: 'fx-settings' }, advanced)) : null,
895
955
  canChange && on && inputs.size ? h('div', { class: 'fx-row fx-row--end' }, h('button', { class: 'fx-btn', type: 'button', onclick: () => save(undefined) }, 'Save changes')) : null) : null,
956
+ disconnectRow,
896
957
  ].filter(Boolean)),
897
958
  h('aside', { class: 'fx-int-page__meta' },
898
959
  h('dl', { class: 'fx-meta' }, ...[
@@ -1888,7 +1888,8 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1888
1888
  content: ''; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px;
1889
1889
  border-radius: 50%; background: var(--t-paper); transition: transform 0.15s;
1890
1890
  }
1891
- .fx-switch[aria-checked="true"] { background: var(--t-primary); }
1891
+ /* background-color only: the shorthand would reset background-clip and fill the touch border. */
1892
+ .fx-switch[aria-checked="true"] { background-color: var(--t-primary); }
1892
1893
  .fx-switch[aria-checked="true"]::after { transform: translateX(18px); }
1893
1894
  .fx-switch:focus-visible { outline: none; box-shadow: var(--fx-focus); }
1894
1895
  .fx-switch:disabled { cursor: default; opacity: 0.6; }
package/server/ui/kit.js CHANGED
@@ -244,7 +244,7 @@ export function dialog({ title, body, confirm, tone = 'primary', choices = null
244
244
  h('h2', { class: 'fx-dialog__title' }, title),
245
245
  choiceList ?? body,
246
246
  actions);
247
- const element = applyPhoneRule(h('dialog', { class: 'fx-dialog' }, form), 'dialog');
247
+ const element = applyPhoneRule(h('dialog', { class: 'fx-dialog', 'aria-label': title }, form), 'dialog');
248
248
  element.addEventListener('close', () => {
249
249
  const value = element.returnValue;
250
250
  element.remove();
@@ -258,7 +258,8 @@ export function dialog({ title, body, confirm, tone = 'primary', choices = null
258
258
  if (choices) element.addEventListener('click', (event) => { if (event.target === element) element.close(); });
259
259
  document.body.append(element);
260
260
  element.showModal();
261
- element.querySelector(choices ? '.fx-sheet__choice' : 'input, select, button[value="ok"]')?.focus();
261
+ // A danger step starts on Cancel, so Enter or a second tap does not confirm it.
262
+ element.querySelector(choices ? '.fx-sheet__choice' : `input, select, button[value="${tone === 'danger' ? 'cancel' : 'ok'}"]`)?.focus();
262
263
  });
263
264
  }
264
265