@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/README.md +1 -0
- package/npm-shrinkwrap.json +104 -2
- package/package.json +6 -2
- package/server/api/invoicing.js +53 -3
- package/server/app.js +4 -2
- package/server/auth/people.js +4 -3
- package/server/integrations/call-guard.js +21 -4
- package/server/integrations/xero/client.js +12 -2
- package/server/integrations/xero/migrations/0010_xero_call_usage.sql +11 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/routes.js +29 -5
- package/server/integrations/xero/runtime.js +5 -0
- package/server/integrations/xero/usage.js +74 -0
- package/server/ui/app.js +47 -27
- package/server/ui/columns.js +82 -15
- package/server/ui/flow.css +148 -37
- package/server/ui/invoicing.js +87 -38
- package/server/ui/job-view.js +33 -4
- package/server/ui/jobs.js +6 -37
- package/server/ui/kit.js +18 -11
- package/server/ui/list-views.js +44 -2
- package/server/ui/monitoring.js +51 -8
- package/server/ui/page.js +110 -5
- package/server/ui/pager.js +67 -0
- package/server/ui/price-list.js +27 -7
- package/server/ui/reconcile.js +1 -1
- package/server/ui/routes.js +1 -1
- package/server/ui/saved-views.js +4 -4
- package/server/ui/xero-usage-model.js +106 -0
- package/server/ui/xero-usage.js +133 -0
package/server/ui/saved-views.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Saved views: the
|
|
2
|
-
//
|
|
1
|
+
// Saved views: the named view tabs of a list (Jobs, Invoices, Reconcile),
|
|
2
|
+
// as ClickUp's, in the toolbar beside Group, Sort and Filter (listHead). A view keeps the page's Group, Sort, Filter and
|
|
3
3
|
// Columns under a name. A tab shows it; "Save" and "Revert" show when the
|
|
4
4
|
// page no longer matches the chosen tab; "+ View" saves the page as a new
|
|
5
5
|
// view. Each tab's ⋯ menu has "Set as my default" (the view that opens
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// the person's account (PUT /api/me/saved-views/:page, list-views.js).
|
|
8
8
|
//
|
|
9
9
|
// const views = savedViews(ui, { page, current, apply });
|
|
10
|
-
// views.element the
|
|
10
|
+
// views.element the tabs, for listHead's `views`
|
|
11
11
|
// views.opening() the view to open on, or null: the default view on the
|
|
12
12
|
// first visit in this browser tab, else the page's own
|
|
13
13
|
// views.draw() call after any change to the page's view
|
|
@@ -117,7 +117,7 @@ export function savedViews(ui, { page, current, apply }) {
|
|
|
117
117
|
});
|
|
118
118
|
element.replaceChildren(...[
|
|
119
119
|
...tabs,
|
|
120
|
-
saved.list.length < MAX_SAVED_VIEWS ? h('button', { type: 'button', class: 'fx-views__add', onclick: addView }, lucide(h, 'plus'), 'View') : null,
|
|
120
|
+
saved.list.length < MAX_SAVED_VIEWS ? h('button', { type: 'button', class: 'fx-views__add', title: 'Save as a view', onclick: addView }, lucide(h, 'plus'), 'View') : null,
|
|
121
121
|
changed ? h('span', { class: 'fx-views__end' },
|
|
122
122
|
h('button', { type: 'button', class: 'fx-btn fx-btn--ghost fx-btn--sm', onclick: () => { apply(view.view); draw(); } }, 'Revert'),
|
|
123
123
|
h('button', { type: 'button', class: 'fx-btn fx-btn--primary fx-btn--sm', onclick: () => store({ ...saved, list: saved.list.map((v) => (v.id === view.id ? { ...v, view: current() } : v)) }) }, 'Save')) : null,
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Pure numbers for the Xero usage card. No DOM and no database: the server
|
|
2
|
+
// fills it from counters Flow already keeps, and the card draws it.
|
|
3
|
+
|
|
4
|
+
export const USAGE_LANES = Object.freeze(['writes', 'sync', 'reads', 'other']);
|
|
5
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
6
|
+
|
|
7
|
+
export function startOfUtcDay(ms) {
|
|
8
|
+
const date = new Date(ms);
|
|
9
|
+
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// 1,000 a day is Xero's Starter plan; 5,000 is Core and above (docs/XERO.md).
|
|
13
|
+
export function appTier(dayLimit) {
|
|
14
|
+
if (!Number.isFinite(dayLimit) || dayLimit <= 0) return null;
|
|
15
|
+
return dayLimit >= 5000 ? 'Core' : 'Starter';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// How old a time is, as a short chip: "2 min", "3 h", "4 d".
|
|
19
|
+
export function formatAge(at, now) {
|
|
20
|
+
if (!at) return null;
|
|
21
|
+
const ms = Math.max(0, now - new Date(at).getTime());
|
|
22
|
+
const minutes = Math.round(ms / 60_000);
|
|
23
|
+
if (minutes < 1) return 'now';
|
|
24
|
+
if (minutes < 60) return `${minutes} min`;
|
|
25
|
+
const hours = Math.round(ms / 3_600_000);
|
|
26
|
+
if (hours < 24) return `${hours} h`;
|
|
27
|
+
return `${Math.round(ms / DAY_MS)} d`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function formatWhen(at) {
|
|
31
|
+
if (!at) return null;
|
|
32
|
+
return new Date(at).toLocaleString('en-AU', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function emptyLanes() {
|
|
36
|
+
return { writes: 0, sync: 0, reads: 0, other: 0 };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function addLane(target, lane, n) {
|
|
40
|
+
if (USAGE_LANES.includes(lane)) target[lane] += n;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// `hourRows` are { hour, lane, n } for today. `dayRows` are { day, n } over
|
|
44
|
+
// the last 7 UTC days. `limitRows` are 429s { at, problem, lane, task }.
|
|
45
|
+
export function assembleUsage({
|
|
46
|
+
hourRows = [],
|
|
47
|
+
dayRows = [],
|
|
48
|
+
taskRows = [],
|
|
49
|
+
limitRows = [],
|
|
50
|
+
governor,
|
|
51
|
+
warningFraction,
|
|
52
|
+
now = Date.now(),
|
|
53
|
+
}) {
|
|
54
|
+
const dayStart = startOfUtcDay(now);
|
|
55
|
+
const hours = Array.from({ length: 24 }, (_, index) => ({
|
|
56
|
+
hour: new Date(dayStart + index * 3_600_000).toISOString(),
|
|
57
|
+
...emptyLanes(),
|
|
58
|
+
}));
|
|
59
|
+
const lanes = emptyLanes();
|
|
60
|
+
for (const row of hourRows) {
|
|
61
|
+
const index = Math.round((Date.parse(row.hour) - dayStart) / 3_600_000);
|
|
62
|
+
const n = Number(row.n) || 0;
|
|
63
|
+
if (index >= 0 && index < 24) addLane(hours[index], row.lane, n);
|
|
64
|
+
addLane(lanes, row.lane, n);
|
|
65
|
+
}
|
|
66
|
+
const logged = USAGE_LANES.reduce((sum, lane) => sum + lanes[lane], 0);
|
|
67
|
+
const dayLimit = Number(governor?.dayLimit) || 0;
|
|
68
|
+
const used = Math.max(Number(governor?.dayCount) || 0, logged);
|
|
69
|
+
const observed = governor?.observedDayRemaining;
|
|
70
|
+
const left = Number.isFinite(observed) ? observed : Math.max(0, dayLimit - used);
|
|
71
|
+
const fraction = Number.isFinite(warningFraction) ? warningFraction : 0.7;
|
|
72
|
+
const line = dayLimit * fraction;
|
|
73
|
+
|
|
74
|
+
const byDay = new Map(dayRows.map((row) => [String(row.day).slice(0, 10), Number(row.n) || 0]));
|
|
75
|
+
const weekStart = dayStart - 6 * DAY_MS;
|
|
76
|
+
let warnedDays = 0;
|
|
77
|
+
const days = Array.from({ length: 7 }, (_, index) => {
|
|
78
|
+
const day = new Date(weekStart + index * DAY_MS).toISOString().slice(0, 10);
|
|
79
|
+
const total = byDay.get(day) ?? 0;
|
|
80
|
+
const warning = dayLimit > 0 && total >= line;
|
|
81
|
+
if (warning) warnedDays += 1;
|
|
82
|
+
return { day, total, warning, over: dayLimit > 0 && total >= dayLimit };
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
used,
|
|
87
|
+
dayLimit,
|
|
88
|
+
left,
|
|
89
|
+
dayLimitSource: governor?.dayLimitSource ?? 'setting',
|
|
90
|
+
warningFraction: fraction,
|
|
91
|
+
warning: dayLimit > 0 && used >= line,
|
|
92
|
+
tier: appTier(dayLimit),
|
|
93
|
+
lanes,
|
|
94
|
+
hours,
|
|
95
|
+
days,
|
|
96
|
+
warnedDays,
|
|
97
|
+
suggestTier: warnedDays >= 2,
|
|
98
|
+
tasks: taskRows.map((row) => ({ task: row.task, calls: Number(row.n) || 0 })),
|
|
99
|
+
limited: limitRows.map((row) => ({
|
|
100
|
+
at: row.at ? new Date(row.at).toISOString() : null,
|
|
101
|
+
problem: row.problem || null,
|
|
102
|
+
lane: USAGE_LANES.includes(row.lane) ? row.lane : null,
|
|
103
|
+
task: row.task || null,
|
|
104
|
+
})),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// The Xero card's usage picture (Integrations › Xero). Three levels: the ring
|
|
2
|
+
// and chips always, today open, the week closed. Numbers come from
|
|
3
|
+
// GET /api/xero/status. No sentence is needed to read the first level.
|
|
4
|
+
import { h, notice } from '/assets/ui/kit.js';
|
|
5
|
+
import { svg } from '/assets/ui/view-bar.js';
|
|
6
|
+
import { USAGE_LANES, formatAge, formatWhen } from '/assets/ui/xero-usage-model.js';
|
|
7
|
+
|
|
8
|
+
const LANE_LABEL = { writes: 'Writes', sync: 'Sync', reads: 'Reads', other: 'Other' };
|
|
9
|
+
const num = new Intl.NumberFormat('en-AU');
|
|
10
|
+
const HOUR = 60 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
function lanePart(count, whole) {
|
|
13
|
+
if (!whole) return '0%';
|
|
14
|
+
return `${(count / whole) * 100}%`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function ring(usage) {
|
|
18
|
+
const pct = usage.dayLimit > 0 ? Math.min(100, Math.round((usage.used / usage.dayLimit) * 1000) / 10) : 0;
|
|
19
|
+
const label = `${num.format(usage.used)} of ${num.format(usage.dayLimit)}`;
|
|
20
|
+
return h('div', { class: 'fx-xu-ring', role: 'img', 'aria-label': label },
|
|
21
|
+
svg(`<svg viewBox="0 0 36 36" aria-hidden="true"><circle class="fx-xu-ring__track" cx="18" cy="18" r="15.5" pathLength="100"/><circle class="fx-xu-ring__used" cx="18" cy="18" r="15.5" pathLength="100" stroke-dasharray="${pct} 100"/></svg>`),
|
|
22
|
+
h('div', { class: 'fx-xu-ring__label' },
|
|
23
|
+
h('span', { class: 'fx-xu-ring__num' }, num.format(usage.used)),
|
|
24
|
+
h('span', { class: 'fx-xu-ring__of' }, num.format(usage.dayLimit))));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function chips(status, now) {
|
|
28
|
+
const stopped = status.calls?.stopped;
|
|
29
|
+
const connection = !status.connected
|
|
30
|
+
? ['mute', 'Off']
|
|
31
|
+
: stopped && status.calls.all
|
|
32
|
+
? ['danger', 'Stopped']
|
|
33
|
+
: stopped
|
|
34
|
+
? ['warn', 'Limited']
|
|
35
|
+
: ['success', 'Connected'];
|
|
36
|
+
const age = formatAge(status.freshness?.copyAt, now);
|
|
37
|
+
return h('div', { class: 'fx-xu__chips' },
|
|
38
|
+
h('span', { class: 'fx-status', 'data-tone': connection[0] }, connection[1]),
|
|
39
|
+
status.usage.tier ? h('span', { class: 'fx-chip fx-chip--static' }, status.usage.tier) : null,
|
|
40
|
+
h('span', { class: 'fx-chip fx-chip--static', title: 'Copy', 'aria-label': age ? `Copy ${age}` : 'Copy' }, age ?? '—'));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function legend() {
|
|
44
|
+
return h('div', { class: 'fx-xu-legend' }, USAGE_LANES.map((lane) => h('span', {},
|
|
45
|
+
h('i', { class: 'fx-xu-lane', 'data-lane': lane, 'aria-hidden': 'true' }),
|
|
46
|
+
LANE_LABEL[lane])));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function hourChart(usage) {
|
|
50
|
+
const peak = Math.max(1, ...usage.hours.map((hour) => USAGE_LANES.reduce((sum, lane) => sum + hour[lane], 0)));
|
|
51
|
+
return h('div', { class: 'fx-xu-hours', role: 'img', 'aria-label': 'Calls by hour' }, usage.hours.map((hour) => {
|
|
52
|
+
const total = USAGE_LANES.reduce((sum, lane) => sum + hour[lane], 0);
|
|
53
|
+
const stack = h('span', { class: 'fx-xu-hour__stack', style: { '--h': `${(total / peak) * 100}%` } },
|
|
54
|
+
USAGE_LANES.map((lane) => (hour[lane]
|
|
55
|
+
? h('i', { class: 'fx-xu-lane', 'data-lane': lane, style: { '--part': lanePart(hour[lane], total) } })
|
|
56
|
+
: null)));
|
|
57
|
+
const when = new Date(hour.hour);
|
|
58
|
+
stack.title = `${when.getUTCHours()}:00 · ${total}`;
|
|
59
|
+
return h('span', { class: 'fx-xu-hour' }, stack);
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function budgetBar(usage) {
|
|
64
|
+
return h('div', { class: 'fx-xu-budget', role: 'img', 'aria-label': 'Budget by lane' },
|
|
65
|
+
USAGE_LANES.map((lane) => h('i', {
|
|
66
|
+
class: 'fx-xu-lane',
|
|
67
|
+
'data-lane': lane,
|
|
68
|
+
style: { '--part': lanePart(usage.lanes[lane], usage.dayLimit) },
|
|
69
|
+
title: `${LANE_LABEL[lane]} ${num.format(usage.lanes[lane])}`,
|
|
70
|
+
})));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function dot(label, tone, title) {
|
|
74
|
+
return h('span', { class: 'fx-xu-dot', 'data-tone': tone, title: title || label }, label);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function freshnessDots(freshness, limited, now) {
|
|
78
|
+
const tone = (at, freshFor) => {
|
|
79
|
+
if (!at) return 'mute';
|
|
80
|
+
return now - new Date(at).getTime() <= freshFor ? 'success' : 'warn';
|
|
81
|
+
};
|
|
82
|
+
const recentLimit = limited.some((row) => row.at && now - new Date(row.at).getTime() <= 24 * HOUR);
|
|
83
|
+
return h('div', { class: 'fx-xu-dots' },
|
|
84
|
+
dot('Webhook', tone(freshness?.webhookAt, 24 * HOUR), formatWhen(freshness?.webhookAt) ?? 'Webhook'),
|
|
85
|
+
dot('Poll', tone(freshness?.pollAt, 2 * HOUR), formatWhen(freshness?.pollAt) ?? 'Poll'),
|
|
86
|
+
dot('Nightly', tone(freshness?.nightlyAt, 26 * HOUR), formatWhen(freshness?.nightlyAt) ?? 'Nightly'),
|
|
87
|
+
dot('429', recentLimit ? 'danger' : 'success', recentLimit ? `${limited.length}` : '429'));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function dayChart(usage) {
|
|
91
|
+
return h('div', { class: 'fx-xu-days', style: { '--warn': String(usage.warningFraction) }, role: 'img', 'aria-label': 'Calls over 7 days' },
|
|
92
|
+
h('span', { class: 'fx-xu-days__line', title: 'Warning' }),
|
|
93
|
+
usage.days.map((day) => {
|
|
94
|
+
const height = usage.dayLimit > 0 ? Math.min(100, (day.total / usage.dayLimit) * 100) : 0;
|
|
95
|
+
return h('span', { class: 'fx-xu-day', 'data-warning': String(day.warning), 'data-over': String(day.over), title: `${day.day} · ${num.format(day.total)}` },
|
|
96
|
+
h('span', { class: 'fx-xu-day__bar' }, h('b', { style: { '--h': `${height}%` } })),
|
|
97
|
+
h('span', {}, day.day.slice(8)));
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function rows(items, empty) {
|
|
102
|
+
if (!items.length) return h('p', { class: 'fx-dim' }, empty);
|
|
103
|
+
return h('div', { class: 'fx-xu-rows' }, items.map((item) => h('div', { class: 'fx-xu-row' },
|
|
104
|
+
h('span', {}, item.label),
|
|
105
|
+
h('b', {}, item.value))));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The picture for one connected organisation. `status` is GET /api/xero/status.
|
|
109
|
+
export function xeroUsage(status, now = Date.now()) {
|
|
110
|
+
const usage = status?.usage;
|
|
111
|
+
if (!usage) return null;
|
|
112
|
+
const tone = usage.used >= usage.dayLimit && usage.dayLimit > 0 ? 'danger' : usage.warning ? 'warn' : 'ok';
|
|
113
|
+
return h('section', { class: 'fx-xu', 'data-tone': tone, 'aria-label': 'Usage' },
|
|
114
|
+
h('div', { class: 'fx-xu__top' }, ring(usage), chips(status, now)),
|
|
115
|
+
h('details', { class: 'fx-xu__fold', open: true },
|
|
116
|
+
h('summary', {}, 'Today'),
|
|
117
|
+
legend(),
|
|
118
|
+
hourChart(usage),
|
|
119
|
+
budgetBar(usage),
|
|
120
|
+
freshnessDots(status.freshness, usage.limited, now)),
|
|
121
|
+
h('details', { class: 'fx-xu__fold' },
|
|
122
|
+
h('summary', {}, 'Detail'),
|
|
123
|
+
dayChart(usage),
|
|
124
|
+
rows(usage.tasks.map((task) => ({ label: task.task, value: num.format(task.calls) })), '—'),
|
|
125
|
+
rows(usage.limited.map((row) => ({
|
|
126
|
+
label: row.problem || '429',
|
|
127
|
+
value: formatWhen(row.at) ?? '—',
|
|
128
|
+
})), '—'),
|
|
129
|
+
h('div', { class: 'fx-xu-row' },
|
|
130
|
+
h('span', {}, 'Token'),
|
|
131
|
+
h('b', {}, formatWhen(status.freshness?.tokenRefreshedAt) ?? '—')),
|
|
132
|
+
usage.suggestTier ? notice(`Calls passed the warning on ${usage.warnedDays} days. A higher Xero tier raises the day's limit.`, 'warn', 'Tier') : null));
|
|
133
|
+
}
|