@arcforgelabs/flow 2026.10.52 → 2026.10.53-1
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/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.53-1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@arcforgelabs/flow",
|
|
9
|
-
"version": "2026.10.
|
|
9
|
+
"version": "2026.10.53-1",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.25.0",
|
package/package.json
CHANGED
package/server/core/captures.js
CHANGED
|
@@ -3,7 +3,8 @@ import { and, desc, eq, gt, isNotNull, isNull, lt, or, sql } from 'drizzle-orm';
|
|
|
3
3
|
import { CAPTURE_DECISIONS, captures } from '../db/schema.js';
|
|
4
4
|
import { FlowError, notFound } from './errors.js';
|
|
5
5
|
import { matchJobs, proposeChange, readDate } from './capture-match.js';
|
|
6
|
-
import { listFields, setJobFieldValue } from './custom-fields.js';
|
|
6
|
+
import { listFields, listJobFieldValues, setJobFieldValue } from './custom-fields.js';
|
|
7
|
+
import { JOB_FIELDS } from '../ui/capture-fields.js';
|
|
7
8
|
|
|
8
9
|
// Shown when the module is off or the keys are missing. Type it still works.
|
|
9
10
|
const READER_OFF = 'reader turned off';
|
|
@@ -253,7 +254,7 @@ export async function matchCapture(db, storage, organisationId, id, { jobs, fiel
|
|
|
253
254
|
state: 'ready',
|
|
254
255
|
problem: null,
|
|
255
256
|
match,
|
|
256
|
-
...(replace ? { proposal: proposeChange(read, match, jobs) } : {}),
|
|
257
|
+
...(replace ? { proposal: await toCustomFields(db, organisationId, proposeChange(read, match, jobs)) } : {}),
|
|
257
258
|
updatedAt: sql`now()`,
|
|
258
259
|
}).where(and(
|
|
259
260
|
eq(captures.organisationId, organisationId),
|
|
@@ -305,14 +306,43 @@ function fieldValue(field, text) {
|
|
|
305
306
|
}
|
|
306
307
|
}
|
|
307
308
|
|
|
309
|
+
const jobOf = (jobId) => {
|
|
310
|
+
const at = jobId.indexOf(':');
|
|
311
|
+
return { sourceSystem: jobId.slice(0, at), sourceJobRef: jobId.slice(at + 1) };
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// A custom field's stored value as the sheet shows it ("from").
|
|
315
|
+
function shownValue(field, value) {
|
|
316
|
+
if (value == null) return '';
|
|
317
|
+
if (field.type === 'dropdown') return field.options?.find((option) => option.id === value)?.name ?? '';
|
|
318
|
+
if (field.type === 'checkbox') return value ? 'Yes' : 'No';
|
|
319
|
+
return String(value);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// A read field goes to the lab's custom field of the same name ("Shade")
|
|
323
|
+
// when there is one: Flow owns it, so Apply writes it. Any other field stays
|
|
324
|
+
// the work system's; the sheet marks it to change there (maintainer, #357).
|
|
325
|
+
async function toCustomFields(db, organisationId, proposal) {
|
|
326
|
+
if (proposal.action !== 'update') return proposal;
|
|
327
|
+
const byName = new Map((await listFields(db, organisationId)).map((field) => [field.name.trim().toLowerCase(), field]));
|
|
328
|
+
const labels = new Map(JOB_FIELDS.map((field) => [field.key, field.label.toLowerCase()]));
|
|
329
|
+
const values = proposal.jobId
|
|
330
|
+
? new Map((await listJobFieldValues(db, organisationId, { jobs: [jobOf(proposal.jobId)] })).map((row) => [row.fieldId, row.value]))
|
|
331
|
+
: new Map();
|
|
332
|
+
const changes = proposal.changes.map((change) => {
|
|
333
|
+
const field = byName.get(labels.get(change.field));
|
|
334
|
+
return field ? { ...change, field: `custom_${field.id}`, from: shownValue(field, values.get(field.id)) } : change;
|
|
335
|
+
}).filter((change) => change.from !== change.to);
|
|
336
|
+
return { ...proposal, changes };
|
|
337
|
+
}
|
|
338
|
+
|
|
308
339
|
// Apply writes what Flow owns: the job's custom fields, each with its own
|
|
309
340
|
// event (who, when, from and to). A field the work system owns (due date,
|
|
310
341
|
// shade) stays there; the result sheet links to the job in that system.
|
|
311
342
|
// Every value is checked before the first write, so a bad one writes none.
|
|
312
343
|
async function applyToJob(db, organisationId, proposal, personId) {
|
|
313
344
|
if (proposal?.action !== 'update' || !proposal.jobId) return;
|
|
314
|
-
const
|
|
315
|
-
const job = { sourceSystem: proposal.jobId.slice(0, at), sourceJobRef: proposal.jobId.slice(at + 1) };
|
|
345
|
+
const job = jobOf(proposal.jobId);
|
|
316
346
|
const fields = new Map((await listFields(db, organisationId)).map((field) => [`custom_${field.id}`, field]));
|
|
317
347
|
const writes = proposal.changes
|
|
318
348
|
.filter((change) => fields.has(change.field))
|
|
@@ -430,47 +430,62 @@ const laneRuns = {
|
|
|
430
430
|
|
|
431
431
|
async orders_recent(ctx) {
|
|
432
432
|
const { db, client, sync, at } = ctx;
|
|
433
|
-
const
|
|
434
|
-
const lookbackStart =
|
|
435
|
-
const
|
|
436
|
-
const
|
|
437
|
-
const partialDay = Date.parse(partial?.day ?? '');
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
433
|
+
const today = floorUtcDay(at.getTime());
|
|
434
|
+
const lookbackStart = today - sync.orderLookbackDays * DAY_MS;
|
|
435
|
+
const cursor = ctx.state?.cursor ?? {};
|
|
436
|
+
const doneThrough = Date.parse(cursor.completedThrough ?? '');
|
|
437
|
+
const partialDay = Date.parse(cursor.partial?.day ?? '');
|
|
438
|
+
const todayPartialDay = Date.parse(cursor.todayPartial?.day ?? '');
|
|
439
|
+
let partial = partialDay >= lookbackStart && partialDay < today ? cursor.partial : null;
|
|
440
|
+
// A partial newest day becomes an older day at midnight. Keep its page
|
|
441
|
+
// unless an older partial already owns the rolling walk; that walk will
|
|
442
|
+
// revisit the former newest day on its next cycle too.
|
|
443
|
+
if (!partial && todayPartialDay >= lookbackStart && todayPartialDay < today) partial = cursor.todayPartial;
|
|
444
|
+
let resume = partial ? Date.parse(partial.day) : Number.isFinite(doneThrough) ? Math.max(lookbackStart, floorUtcDay(doneThrough) + DAY_MS) : lookbackStart;
|
|
445
|
+
if (resume >= today) resume = lookbackStart;
|
|
446
|
+
|
|
447
|
+
// Give today and the rolling previous-day walk a share of the existing
|
|
448
|
+
// budgets. With only one day or call available, alternate them instead
|
|
449
|
+
// of permanently starving either. Missing fields migrate naturally.
|
|
450
|
+
const single = sync.listPagesPerRun === 1 || sync.orderDaysPerRun === 1;
|
|
451
|
+
const readToday = !single || !cursor.historyNext;
|
|
447
452
|
const pageSize = Math.min(ORDER_PAGE_MAX, sync.listCap);
|
|
448
453
|
const byId = new Map();
|
|
449
454
|
const days = [];
|
|
450
455
|
let calls = 0;
|
|
451
456
|
let lastDone = null;
|
|
452
|
-
let stoppedAt =
|
|
453
|
-
|
|
457
|
+
let stoppedAt = partial;
|
|
458
|
+
let todayPartial = todayPartialDay === today ? cursor.todayPartial : partialDay === today ? cursor.partial : null;
|
|
459
|
+
async function readDay(day, progress, callLimit) {
|
|
454
460
|
const label = `order list for ${formatOrderedDay(day)}`;
|
|
455
|
-
let page =
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
break;
|
|
461
|
-
}
|
|
461
|
+
let page = Math.max(1, Number(progress?.page) || 1);
|
|
462
|
+
// Keep at most one page of IDs in the existing cursor, so splitting
|
|
463
|
+
// the budget never disables the guard against an API ignoring page.
|
|
464
|
+
let previous = progress?.previousIds ? new Set(progress.previousIds) : null;
|
|
465
|
+
while (calls < callLimit) {
|
|
462
466
|
calls += 1;
|
|
463
467
|
if (!days.includes(day)) days.push(day);
|
|
464
468
|
const list = listOrFail(await client.get('orders/', { ordered: formatOrderedDay(day), page, pageSize }), label);
|
|
465
469
|
const ids = list.map((item) => text(item?.id)).filter(Boolean);
|
|
466
|
-
// A Seazona that does not page answers the same rows again.
|
|
467
470
|
if (previous && ids.length && ids.every((id) => previous.has(id))) throw cannotGoOn(label, sync, 'the next page answers the same orders');
|
|
468
471
|
for (const item of list) if (text(item?.id)) byId.set(text(item.id), item);
|
|
469
|
-
|
|
472
|
+
// The guide's on-or-after lists and single-day lists both work.
|
|
473
|
+
if (list.length < pageSize || placedAfter(list[list.length - 1], day + DAY_MS)) return null;
|
|
470
474
|
previous = new Set(ids);
|
|
471
475
|
page += 1;
|
|
472
476
|
}
|
|
473
|
-
|
|
477
|
+
return { day: new Date(day).toISOString(), page, previousIds: [...previous] };
|
|
478
|
+
}
|
|
479
|
+
if (readToday) {
|
|
480
|
+
const todayCalls = single ? sync.listPagesPerRun : Math.ceil(sync.listPagesPerRun / 2);
|
|
481
|
+
todayPartial = await readDay(today, todayPartial, todayCalls);
|
|
482
|
+
}
|
|
483
|
+
if (!single || !readToday) {
|
|
484
|
+
for (let day = resume; day < today && days.length < sync.orderDaysPerRun && calls < sync.listPagesPerRun; day += DAY_MS) {
|
|
485
|
+
stoppedAt = await readDay(day, day === resume ? partial : null, sync.listPagesPerRun);
|
|
486
|
+
if (stoppedAt) break;
|
|
487
|
+
lastDone = day;
|
|
488
|
+
}
|
|
474
489
|
}
|
|
475
490
|
const known = await readKnown(db, 'orders', [...byId.keys()]);
|
|
476
491
|
const summary = { created: 0, changed: 0, unchanged: 0 };
|
|
@@ -506,7 +521,9 @@ const laneRuns = {
|
|
|
506
521
|
cursorTime: through === null ? ctx.state?.cursorTime ?? null : new Date(through),
|
|
507
522
|
cursor: {
|
|
508
523
|
...(through === null ? {} : { completedThrough: new Date(through).toISOString() }),
|
|
509
|
-
...(stoppedAt ? { partial:
|
|
524
|
+
...(stoppedAt ? { partial: stoppedAt } : {}),
|
|
525
|
+
...(todayPartial ? { todayPartial } : {}),
|
|
526
|
+
...(single ? { historyNext: readToday } : {}),
|
|
510
527
|
},
|
|
511
528
|
};
|
|
512
529
|
},
|
package/server/ui/captures.js
CHANGED
|
@@ -180,8 +180,14 @@ export async function renderCaptures(main, ui) {
|
|
|
180
180
|
? `${job.caseRef ? `Job ${job.caseRef}` : job.jobId} · ${job.clientName ?? ''}`
|
|
181
181
|
: proposal.action === 'create' ? 'New job' : 'Pick a job';
|
|
182
182
|
|
|
183
|
+
// A field the work system owns: Apply leaves it; the person changes it
|
|
184
|
+
// there (until a two-way sync can send it, #357).
|
|
185
|
+
const system = proposal.action === 'update' && proposal.jobId ? proposal.jobId.split(':')[0] : null;
|
|
186
|
+
const systemName = system ? system[0].toUpperCase() + system.slice(1) : null;
|
|
187
|
+
const elsewhere = (change) => systemName && !change.field.startsWith('custom_');
|
|
183
188
|
const changeRow = (change) => h('div', { class: 'fx-capture__change', 'data-unsure': change.sure != null && change.sure < SURE_LINE ? 'true' : 'false' },
|
|
184
|
-
h('span', { class: 'fx-capture__field' }, labels.get(change.field) ?? change.field
|
|
189
|
+
h('span', { class: 'fx-capture__field' }, labels.get(change.field) ?? change.field,
|
|
190
|
+
elsewhere(change) ? h('span', { class: 'fx-capture__where', title: `Change this in ${systemName}` }, systemName) : null),
|
|
185
191
|
h('span', { class: 'fx-capture__from' }, change.from || '–'),
|
|
186
192
|
h('span', { class: 'fx-capture__arrow', 'aria-hidden': 'true' }, '→'),
|
|
187
193
|
h('span', { class: 'fx-capture__to' }, change.to));
|
|
@@ -111,7 +111,7 @@ export async function renderCustomFields(main, ui) {
|
|
|
111
111
|
const { h, flow, toast, notice, dialog, pageHeader, me } = ui;
|
|
112
112
|
const disabled = !['owner', 'admin'].includes(me?.person.role);
|
|
113
113
|
const host = h('div', { class: 'fx-card fx-cfm__card' }, h('div', { class: 'fx-skeleton' }));
|
|
114
|
-
const addButton = h('button', { class: 'fx-btn fx-btn--primary fx-btn--sm', type: 'button', disabled
|
|
114
|
+
const addButton = h('button', { class: 'fx-btn fx-btn--primary fx-btn--sm', type: 'button', disabled, title: 'New custom field' }, lucide(h, 'plus'), 'New');
|
|
115
115
|
main.append(pageHeader('Custom fields', { help: { href: DOCS, label: 'How custom fields work' } }), host);
|
|
116
116
|
|
|
117
117
|
let data = null;
|
|
@@ -309,7 +309,7 @@ export async function renderCustomFields(main, ui) {
|
|
|
309
309
|
]);
|
|
310
310
|
const filterButton = h('button', { class: 'fx-chip fx-cfm__tool fx-cfm__tool--dashed', type: 'button' });
|
|
311
311
|
const filterMenu = popMenu(filterButton, (close) => [
|
|
312
|
-
item(view.archived ? 'check' : null, '
|
|
312
|
+
item(view.archived ? 'check' : null, 'Archived', () => { view.archived = !view.archived; close(); draw(); }, { role: 'menuitemcheckbox', checked: view.archived }),
|
|
313
313
|
]);
|
|
314
314
|
const toolbar = h('div', { class: 'fx-cfm__bar' }, search, typeButton, typeMenu, filterButton, filterMenu, h('span', { class: 'fx-cfm__gap' }), addButton);
|
|
315
315
|
const table = h('div', { class: 'fx-cfm', role: 'table', 'aria-label': 'Custom fields' });
|
|
@@ -505,15 +505,9 @@ export async function renderCustomFields(main, ui) {
|
|
|
505
505
|
parts.push(...group('archived', { text: 'Archived', icon: 'archive', color: 'gray' }, fields));
|
|
506
506
|
}
|
|
507
507
|
if (!count && (view.q || view.types.size)) parts.push(h('p', { class: 'fx-cfm__empty' }, 'No custom field matches.'));
|
|
508
|
-
if (!
|
|
509
|
-
parts.push(h('p', { class: 'fx-cfm__empty' }, 'No custom fields yet. Create one, for example Priority or Technician, to group the Jobs board by it.'));
|
|
510
|
-
}
|
|
508
|
+
else if (!count) parts.push(h('p', { class: 'fx-cfm__empty' }, 'No custom fields'));
|
|
511
509
|
table.replaceChildren(...parts);
|
|
512
|
-
// The count is what the table shows, the work systems' fields too.
|
|
513
|
-
foot.replaceChildren(h('span', {}, `${count} ${count === 1 ? 'custom field' : 'custom fields'}`));
|
|
514
510
|
}
|
|
515
|
-
const foot = h('div', { class: 'fx-cfm__foot' });
|
|
516
|
-
host.append(foot);
|
|
517
511
|
|
|
518
512
|
async function load() {
|
|
519
513
|
try {
|
package/server/ui/flow.css
CHANGED
|
@@ -1093,6 +1093,7 @@ th:hover > .fx-th-grip::after { background: var(--t-brd-hi); }
|
|
|
1093
1093
|
min-height: 44px; padding: 4px 8px; border-bottom: 1px solid var(--fx-line-soft);
|
|
1094
1094
|
}
|
|
1095
1095
|
.fx-capture__field { color: var(--t-dim); }
|
|
1096
|
+
.fx-capture__where { display: block; font-size: 12px; color: var(--t-dim); }
|
|
1096
1097
|
.fx-capture__from { color: var(--t-dim); text-decoration: line-through; overflow-wrap: anywhere; }
|
|
1097
1098
|
.fx-capture__arrow { color: var(--t-dim); }
|
|
1098
1099
|
.fx-capture__to { color: var(--t-strong); font-weight: 600; overflow-wrap: anywhere; }
|
|
@@ -1682,7 +1683,6 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1682
1683
|
.fx-cfm__create .fx-lucide { width: 14px; height: 14px; }
|
|
1683
1684
|
.fx-cfm__empty { margin: 0; padding: 20px 16px; color: var(--t-dim); }
|
|
1684
1685
|
.fx-cfm > .fx-notice { margin: 12px 16px 0; }
|
|
1685
|
-
.fx-cfm__foot { display: flex; justify-content: space-between; gap: 12px; padding: 12px 16px; border-top: 1px solid var(--fx-line-soft); color: var(--t-dim); font-size: 13px; }
|
|
1686
1686
|
.fx-pop__item:disabled { opacity: .5; cursor: default; }
|
|
1687
1687
|
.fx-pop__item[data-tone="danger"], .fx-pop__item[data-tone="danger"] .fx-pop__tick { color: var(--t-danger); }
|
|
1688
1688
|
.fx-pop__item .fx-lucide { width: 15px; height: 15px; }
|
|
@@ -1759,6 +1759,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1759
1759
|
.fx-cfm__head, .fx-cfm__row { padding-left: 16px; }
|
|
1760
1760
|
.fx-cfm__create { padding-left: 16px; }
|
|
1761
1761
|
.fx-cfm__search { flex: 1 1 100%; width: auto; }
|
|
1762
|
+
.fx-cfm__bar :is(.fx-cfm__search, .fx-cfm__tool, .fx-btn) { height: auto; min-height: 44px; }
|
|
1762
1763
|
.fx-cfm__edit { display: none !important; }
|
|
1763
1764
|
/* Custom fields uses the same phone slots as a column list: name and the
|
|
1764
1765
|
menu on line 1, up to three facts on line 2, 56px. */
|