@arcforgelabs/flow 2026.10.43 → 2026.10.44

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.
@@ -23,42 +23,61 @@ export const COLUMNS = {
23
23
  };
24
24
  export const DEFAULT_COLUMNS = Object.freeze(['practitioner', 'patient', 'stage', 'assignee', 'due', 'total']);
25
25
 
26
- // Filters, like ClickUp's: rows of field, operator and value, all of them
27
- // ('all') or any of them ('any') must match. A custom field filters too
28
- // ('field:<id>'); its kind comes from its type. Each kind has its
29
- // operators, in the order the menu shows them.
26
+ // Filters, like ClickUp's: groups of rows of field, operator and value. The
27
+ // rows in a group all match ('all') or any one matches ('any'); the groups
28
+ // join the same way (the view's `match`). A custom field filters too
29
+ // ('field:<id>'); its kind comes from its type. Each kind has ClickUp's
30
+ // operators, in the order ClickUp's menu shows them.
30
31
  export const FILTER_FIELDS = {
31
- status: { label: 'Status', kind: 'choice' },
32
- stage: { label: 'Invoice stage', kind: 'choice' },
33
- client: { label: 'Client', kind: 'choice' },
34
- practitioner: { label: 'Practitioner', kind: 'choice' },
35
- assignee: { label: 'Assignee', kind: 'person' },
36
- due: { label: 'Due', kind: 'date' },
37
- ordered: { label: 'Requested', kind: 'date' },
38
- shipped: { label: 'Shipped', kind: 'date' },
39
- total: { label: 'Total', kind: 'number' },
40
- problem: { label: 'Problem', kind: 'check' },
32
+ status: { label: 'Status', kind: 'choice', icon: 'circle-dot' },
33
+ stage: { label: 'Invoice stage', kind: 'choice', icon: 'receipt' },
34
+ client: { label: 'Client', kind: 'choice', icon: 'building-2' },
35
+ practitioner: { label: 'Practitioner', kind: 'choice', icon: 'stethoscope' },
36
+ assignee: { label: 'Assignee', kind: 'person', icon: 'user-round' },
37
+ due: { label: 'Due', kind: 'date', icon: 'calendar' },
38
+ ordered: { label: 'Requested', kind: 'date', icon: 'calendar' },
39
+ shipped: { label: 'Shipped', kind: 'date', icon: 'calendar' },
40
+ total: { label: 'Total', kind: 'number', icon: 'dollar-sign' },
41
+ problem: { label: 'Problem', kind: 'check', icon: 'triangle-alert' },
41
42
  };
42
- const CHOICE_OPS = { is: 'is', not: 'is not', set: 'is set', unset: 'is not set' };
43
+ const CHOICE_OPS = { is: 'Is', not: 'Is not', set: 'Is set', unset: 'Is not set' };
43
44
  export const OPERATORS = {
44
45
  choice: CHOICE_OPS,
45
46
  person: CHOICE_OPS,
46
- date: {
47
- overdue: 'is overdue', today: 'is today', next7: 'is in the next 7 days', last7: 'was in the last 7 days',
48
- before: 'is before', after: 'is after', on: 'is on', set: 'is set', unset: 'is not set',
49
- },
50
- number: { eq: 'is', gt: 'is more than', lt: 'is less than', set: 'is set', unset: 'is not set' },
51
- check: { yes: 'is ticked', no: 'is not ticked' },
52
- text: { has: 'contains', is: 'is', set: 'is set', unset: 'is not set' },
47
+ // A date row's range is its value (DATE_VALUES), as on ClickUp.
48
+ date: CHOICE_OPS,
49
+ number: { set: 'Is set', unset: 'Is not set', gt: 'Greater than', lt: 'Less than', eq: 'Equal to' },
50
+ check: { yes: 'Is checked', no: 'Is not checked' },
51
+ text: { has: 'Contains', hasnt: 'Does not contain', is: 'Is', set: 'Is set', unset: 'Is not set' },
52
+ };
53
+ // The ranges a date row can match, as ClickUp's list names them. 'on',
54
+ // 'before' and 'after' take a day ('on:2026-03-10'), 'between' two days
55
+ // ('between:2026-03-01..2026-03-31'), 'last' and 'next' a count of days
56
+ // ('next:14').
57
+ export const DATE_VALUES = {
58
+ today: 'Today', yesterday: 'Yesterday', tomorrow: 'Tomorrow', next7: 'Next 7 days', last7: 'Last 7 days',
59
+ thisweek: 'This week', nextweek: 'Next week', lastweek: 'Last week',
60
+ lastmonth: 'Last month', thismonth: 'This month', nextmonth: 'Next month', earlier: 'Today & Earlier',
61
+ lastquarter: 'Last quarter', thisquarter: 'This quarter', nextquarter: 'Next quarter',
62
+ overdue: 'Overdue', later: 'Later than Today', last: 'Last', next: 'Next',
63
+ lastyear: 'Last year', thisyear: 'This year', nextyear: 'Next year',
64
+ on: 'Exact date', before: 'Before date', after: 'After date', between: 'Date range',
53
65
  };
54
66
  export const KIND_OF_TYPE = { dropdown: 'choice', person: 'person', checkbox: 'check', date: 'date', number: 'number', text: 'text' };
67
+ // Each kind's icon in the field list (Lucide, /assets/icons/), as ClickUp
68
+ // shows a field's type beside its name.
69
+ export const KIND_ICONS = { choice: 'square-chevron-down', person: 'user-round', check: 'square-check', date: 'calendar', number: 'hash', text: 'type' };
55
70
  export const MATCHES = ['all', 'any'];
56
71
  // Operators that need no value.
57
- const NO_VALUE = new Set(['set', 'unset', 'overdue', 'today', 'next7', 'last7', 'yes', 'no']);
72
+ const NO_VALUE = new Set(['set', 'unset', 'yes', 'no']);
58
73
  const ALL_OPS = new Set(Object.values(OPERATORS).flatMap(Object.keys));
74
+ // Filters saved before dates took ClickUp's form had the range as the
75
+ // operator ('next7', 'before' with a day). They load as "Is <range>".
76
+ const OLD_DATE_OPS = new Set(['overdue', 'today', 'next7', 'last7', 'before', 'after', 'on']);
59
77
 
60
78
  export const DEFAULT_VIEW = Object.freeze({
61
- layout: 'board', group: 'status', groupDir: 'asc', sorts: Object.freeze([]), match: 'all', filters: Object.freeze([]), columns: DEFAULT_COLUMNS,
79
+ layout: 'board', group: 'status', groupDir: 'asc', sorts: Object.freeze([]), match: 'all', groups: Object.freeze([]), columns: DEFAULT_COLUMNS,
80
+ widths: Object.freeze({}),
62
81
  });
63
82
 
64
83
  // Group by one of the lab's custom fields (docs/CUSTOM_FIELDS.md):
@@ -71,6 +90,7 @@ const MAX_VALUES = 100;
71
90
  const MAX_COLUMNS = 40;
72
91
  const MAX_LABEL = 120;
73
92
  const MAX_FILTERS = 20;
93
+ const MAX_GROUPS = 10;
74
94
  const MAX_SORTS = 5;
75
95
 
76
96
  const cleanLabels = (list) => [...new Set(list.filter((label) => typeof label === 'string' && label.length > 0 && label.length <= MAX_LABEL))].slice(0, MAX_VALUES);
@@ -82,23 +102,48 @@ function cleanValue(value) {
82
102
  return null;
83
103
  }
84
104
 
85
- function cleanFilter(filter) {
105
+ // One row, checked. `kindOf(field)` is the field's kind, '*' for a custom
106
+ // field (its type is not known here, so any operator stands and the page
107
+ // skips one that does not fit), or null for a field the list does not have.
108
+ function cleanFilter(filter, kindOf) {
86
109
  if (!filter || typeof filter !== 'object') return null;
87
- const builtIn = Object.hasOwn(FILTER_FIELDS, filter.field ?? '') ? FILTER_FIELDS[filter.field] : null;
88
- if (!builtIn && !FIELD_GROUP.test(filter.field ?? '')) return null;
89
- // A custom field's type is not known here, so any operator stands; the
90
- // page skips one that does not fit.
91
- const ok = builtIn ? Object.hasOwn(OPERATORS[builtIn.kind], filter.op ?? '') : ALL_OPS.has(filter.op);
110
+ const kind = kindOf(filter.field ?? '');
111
+ if (!kind) return null;
112
+ if (OLD_DATE_OPS.has(filter.op) && (kind === 'date' || kind === '*')) {
113
+ const day = typeof filter.value === 'string' && ISO_DAY.test(filter.value) ? filter.value : null;
114
+ const range = ['before', 'after', 'on'].includes(filter.op) ? (day ? `${filter.op}:${day}` : null) : filter.op;
115
+ return { field: filter.field, op: 'is', value: range };
116
+ }
117
+ const ok = kind === '*' ? ALL_OPS.has(filter.op) : Object.hasOwn(OPERATORS[kind] ?? {}, filter.op ?? '');
92
118
  return ok ? { field: filter.field, op: filter.op, value: NO_VALUE.has(filter.op) ? null : cleanValue(filter.value) } : null;
93
119
  }
94
120
 
121
+ // A filter as Flow keeps it: { match, groups: [{ match, filters }] }. A
122
+ // filter saved before groups ({ match, filters }) loads as one group.
123
+ export function cleanFilterView(value, kindOf) {
124
+ const input = value && typeof value === 'object' ? value : {};
125
+ const match = (m) => (MATCHES.includes(m) ? m : 'all');
126
+ const list = Array.isArray(input.groups) ? input.groups
127
+ : Array.isArray(input.filters) ? [{ match: input.match, filters: input.filters }] : [];
128
+ let room = MAX_FILTERS;
129
+ const groups = list.slice(0, MAX_GROUPS).map((g) => {
130
+ const filters = (Array.isArray(g?.filters) ? g.filters : []).map((f) => cleanFilter(f, kindOf)).filter(Boolean).slice(0, room);
131
+ room -= filters.length;
132
+ return { match: match(g?.match), filters };
133
+ }).filter((g) => g.filters.length);
134
+ return { match: Array.isArray(input.groups) ? match(input.match) : 'all', groups };
135
+ }
136
+
137
+ // A job filter field's kind, for cleanFilterView.
138
+ const jobKind = (field) => FILTER_FIELDS[field]?.kind ?? (FIELD_GROUP.test(field) ? '*' : null);
139
+
95
140
  // A view saved before filter rows had fixed filters: hidden statuses, late
96
141
  // jobs, jobs with a problem, and "Assignee: me". They become rows.
97
142
  function oldFilters(value) {
98
143
  const filters = [];
99
144
  const hide = Array.isArray(value.hide) ? cleanLabels(value.hide) : [];
100
145
  if (hide.length) filters.push({ field: 'status', op: 'not', value: hide });
101
- if (value.late === true) filters.push({ field: 'due', op: 'overdue', value: null });
146
+ if (value.late === true) filters.push({ field: 'due', op: 'is', value: 'overdue' });
102
147
  if (value.problems === true) filters.push({ field: 'problem', op: 'yes', value: null });
103
148
  if (value.mine === true) filters.push({ field: 'assignee', op: 'is', value: ['me'] });
104
149
  return filters;
@@ -123,27 +168,41 @@ export function normaliseJobsView(input) {
123
168
  group: FIELD_GROUP.test(value.group ?? '') ? value.group : pick('group', Object.keys(GROUPS)),
124
169
  groupDir: pick('groupDir', DIRS),
125
170
  sorts: cleanSorts(value),
126
- match: pick('match', MATCHES),
127
- filters: (Array.isArray(value.filters) ? value.filters.map(cleanFilter).filter(Boolean) : oldFilters(value)).slice(0, MAX_FILTERS),
171
+ ...cleanFilterView(Array.isArray(value.groups) || Array.isArray(value.filters) ? value : { filters: oldFilters(value) }, jobKind),
128
172
  columns: Array.isArray(value.columns)
129
173
  ? [...new Set(value.columns.filter((c) => Object.hasOwn(COLUMNS, c) || FIELD_GROUP.test(c ?? '')))].slice(0, MAX_COLUMNS)
130
174
  : [...DEFAULT_COLUMNS],
175
+ widths: cleanWidths(value.widths),
131
176
  };
132
177
  }
133
178
 
134
179
  const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
180
+ const DAY_RANGE = /^between:(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/;
181
+ // Is a date row's value a range Flow knows?
182
+ export function dateValueIsOn(value) {
183
+ if (typeof value !== 'string') return false;
184
+ if (Object.hasOwn(DATE_VALUES, value)) return !['on', 'before', 'after', 'between', 'last', 'next'].includes(value);
185
+ const [kind, rest = ''] = value.split(':');
186
+ if (['on', 'before', 'after'].includes(kind)) return ISO_DAY.test(rest);
187
+ if (['last', 'next'].includes(kind)) return /^[1-9]\d{0,3}$/.test(rest);
188
+ return DAY_RANGE.test(value);
189
+ }
190
+
135
191
  // A row filters only once it is complete: a row still being filled in
136
- // (no value yet) keeps every job.
137
- export function filterIsOn(filter) {
192
+ // (no value yet) keeps every job. `kind` is the row's field kind.
193
+ export function filterIsOn(filter, kind = null) {
138
194
  if (NO_VALUE.has(filter.op)) return true;
139
- if (['before', 'after', 'on'].includes(filter.op)) return ISO_DAY.test(filter.value ?? '');
195
+ if (kind === 'date' || (kind == null && typeof filter.value === 'string' && dateValueIsOn(filter.value))) return dateValueIsOn(filter.value);
140
196
  if (['eq', 'gt', 'lt'].includes(filter.op)) return typeof filter.value === 'number';
141
197
  return Array.isArray(filter.value) ? filter.value.length > 0 : typeof filter.value === 'string' && filter.value.trim().length > 0;
142
198
  }
143
199
 
200
+ // Every row of a view, in its groups' order.
201
+ export const filterRows = (view) => (view.groups ?? []).flatMap((g) => g.filters);
202
+
144
203
  // How many filters are on: shown on the Filter button.
145
- export function filterCount(view) {
146
- return view.filters.filter(filterIsOn).length;
204
+ export function filterCount(view, kindOf = () => null) {
205
+ return filterRows(view).filter((f) => filterIsOn(f, kindOf(f.field))).length;
147
206
  }
148
207
 
149
208
  const text = (value) => (value ?? '').toString().toLowerCase();
@@ -152,13 +211,74 @@ const addDays = (iso, days) => {
152
211
  at.setUTCDate(at.getUTCDate() + days);
153
212
  return at.toISOString().slice(0, 10);
154
213
  };
214
+ const parts = (iso) => iso.split('-').map(Number);
215
+ const dayIso = (y, m, d) => new Date(Date.UTC(y, m - 1, d)).toISOString().slice(0, 10);
216
+ // The first day of the week (Monday, as in Australia) that holds `iso`.
217
+ const weekStart = (iso) => addDays(iso, -((new Date(`${iso}T00:00:00Z`).getUTCDay() + 6) % 7));
218
+ const monthRange = (y, m) => ({ from: dayIso(y, m, 1), to: dayIso(y, m + 1, 0) });
219
+ const quarterRange = (y, q) => ({ from: dayIso(y, q * 3 + 1, 1), to: dayIso(y, q * 3 + 4, 0) });
155
220
 
156
- // Does one value pass one filter row? `late` says if the job is late (for
157
- // "is overdue" on a job's due date); `me` is the person looking (for "me"
158
- // in a person filter); `today` is yyyy-mm-dd.
159
- export function matchesFilter(filter, value, { today, me = null, late = null } = {}) {
221
+ // The days a date value matches, both ends kept: { from, to }, where a
222
+ // missing end is open. Null for 'overdue' (it needs the job) or a value
223
+ // Flow does not know.
224
+ export function dateRange(value, today) {
225
+ const [y, m] = parts(today);
226
+ const q = Math.floor((m - 1) / 3);
227
+ const week = weekStart(today);
228
+ switch (value) {
229
+ case 'today': return { from: today, to: today };
230
+ case 'yesterday': return { from: addDays(today, -1), to: addDays(today, -1) };
231
+ case 'tomorrow': return { from: addDays(today, 1), to: addDays(today, 1) };
232
+ case 'next7': return { from: today, to: addDays(today, 7) };
233
+ case 'last7': return { from: addDays(today, -7), to: addDays(today, -1) };
234
+ case 'thisweek': return { from: week, to: addDays(week, 6) };
235
+ case 'nextweek': return { from: addDays(week, 7), to: addDays(week, 13) };
236
+ case 'lastweek': return { from: addDays(week, -7), to: addDays(week, -1) };
237
+ case 'thismonth': return monthRange(y, m);
238
+ case 'nextmonth': return monthRange(y, m + 1);
239
+ case 'lastmonth': return monthRange(y, m - 1);
240
+ case 'earlier': return { from: null, to: today };
241
+ case 'later': return { from: addDays(today, 1), to: null };
242
+ case 'thisquarter': return quarterRange(y, q);
243
+ case 'nextquarter': return quarterRange(y, q + 1);
244
+ case 'lastquarter': return quarterRange(y, q - 1);
245
+ case 'thisyear': return { from: dayIso(y, 1, 1), to: dayIso(y, 12, 31) };
246
+ case 'nextyear': return { from: dayIso(y + 1, 1, 1), to: dayIso(y + 1, 12, 31) };
247
+ case 'lastyear': return { from: dayIso(y - 1, 1, 1), to: dayIso(y - 1, 12, 31) };
248
+ default: break;
249
+ }
250
+ if (!dateValueIsOn(value) || value === 'overdue') return null;
251
+ const between = DAY_RANGE.exec(value);
252
+ if (between) return between[1] <= between[2] ? { from: between[1], to: between[2] } : { from: between[2], to: between[1] };
253
+ const [kind, rest] = value.split(':');
254
+ if (kind === 'on') return { from: rest, to: rest };
255
+ if (kind === 'before') return { from: null, to: addDays(rest, -1) };
256
+ if (kind === 'after') return { from: addDays(rest, 1), to: null };
257
+ if (kind === 'last') return { from: addDays(today, -Number(rest)), to: addDays(today, -1) };
258
+ if (kind === 'next') return { from: today, to: addDays(today, Number(rest)) };
259
+ return null;
260
+ }
261
+
262
+ // Is a day (yyyy-mm-dd) in a date value's range? `late` says if the job is
263
+ // late, for "Overdue" on a job's due date.
264
+ function inDateValue(day, want, today, late) {
265
+ if (want === 'overdue') return late ? late() : day != null && day < today;
266
+ const range = dateRange(want, today);
267
+ if (!range || day == null) return false;
268
+ return (range.from == null || day >= range.from) && (range.to == null || day <= range.to);
269
+ }
270
+
271
+ // Does one value pass one filter row? `kind` is the field's kind; `late`
272
+ // says if the job is late (for "Overdue" on a job's due date); `me` is the
273
+ // person looking (for "Me" in a person filter); `today` is yyyy-mm-dd.
274
+ export function matchesFilter(filter, value, { today, me = null, late = null, kind = null } = {}) {
160
275
  const want = filter.value;
161
276
  const empty = value == null || value === '' || value === false;
277
+ const day = typeof value === 'string' && ISO_DAY.test(value.slice(0, 10)) ? value.slice(0, 10) : null;
278
+ if (kind === 'date' && ['is', 'not'].includes(filter.op)) {
279
+ const inside = inDateValue(day, want, today, late);
280
+ return filter.op === 'is' ? inside : day != null && !inside;
281
+ }
162
282
  switch (filter.op) {
163
283
  case 'set': return !empty;
164
284
  case 'unset': return empty;
@@ -167,38 +287,39 @@ export function matchesFilter(filter, value, { today, me = null, late = null } =
167
287
  case 'is': return Array.isArray(want) ? want.map((w) => (w === 'me' ? me : w)).includes(value) : !empty && text(value) === text(want).trim();
168
288
  case 'not': return !want.map((w) => (w === 'me' ? me : w)).includes(value);
169
289
  case 'has': return text(value).includes(text(want).trim());
290
+ case 'hasnt': return !text(value).includes(text(want).trim());
170
291
  case 'eq': return typeof value === 'number' && value === want;
171
292
  case 'gt': return typeof value === 'number' && value > want;
172
293
  case 'lt': return typeof value === 'number' && value < want;
173
- case 'overdue': return late ? late() : !empty && value < today;
174
- case 'today': return value === today;
175
- case 'next7': return !empty && value >= today && value <= addDays(today, 7);
176
- case 'last7': return !empty && value < today && value >= addDays(today, -7);
177
- case 'before': return !empty && value < want;
178
- case 'after': return !empty && value > want;
179
- case 'on': return value === want;
180
294
  default: return true;
181
295
  }
182
296
  }
183
297
 
184
298
  // The jobs the filter keeps. `valueOf(job, field)` reads a job's value for
185
299
  // a filter field; `kindOf(field)` gives its kind, or null when the field is
186
- // gone (its rows are skipped); `late(job)` says if a job is late.
300
+ // gone (its rows are skipped); `late(job)` says if a job is late. A group
301
+ // keeps a job when all its rows pass ('all') or one does ('any'); the
302
+ // groups join the same way. A group with no complete row is left out.
187
303
  export function filterJobs(jobs, view, { valueOf, kindOf = (field) => FILTER_FIELDS[field]?.kind ?? null, late = () => false, me = null, today }) {
188
- const rows = view.filters.filter((f) => filterIsOn(f) && kindOf(f.field) && Object.hasOwn(OPERATORS[kindOf(f.field)], f.op));
189
- if (!rows.length) return [...jobs];
190
- const pass = (job) => (f) => matchesFilter(f, valueOf(job, f.field), { today, me, late: f.field === 'due' ? () => late(job) : null });
191
- return jobs.filter((job) => (view.match === 'any' ? rows.some(pass(job)) : rows.every(pass(job))));
304
+ const live = (f) => kindOf(f.field) && Object.hasOwn(OPERATORS[kindOf(f.field)], f.op) && filterIsOn(f, kindOf(f.field));
305
+ const groups = (view.groups ?? []).map((g) => ({ match: g.match, rows: g.filters.filter(live) })).filter((g) => g.rows.length);
306
+ if (!groups.length) return [...jobs];
307
+ const pass = (job) => (f) => matchesFilter(f, valueOf(job, f.field), { today, me, kind: kindOf(f.field), late: f.field === 'due' ? () => late(job) : null });
308
+ const passGroup = (job) => (g) => (g.match === 'any' ? g.rows.some(pass(job)) : g.rows.every(pass(job)));
309
+ return jobs.filter((job) => (view.match === 'any' ? groups.some(passGroup(job)) : groups.every(passGroup(job))));
192
310
  }
193
311
 
194
312
  // The status lanes to show: a status filter hides the lanes it leaves out,
195
- // even empty ones. With "any" a lane can still match another row, so all
196
- // lanes show.
313
+ // even empty ones. A row whose group (or the groups' join) is "any" can be
314
+ // passed another way, so it hides no lane.
197
315
  export function keptStatuses(view, labels) {
198
- if (view.match === 'any') return labels;
199
- return view.filters.filter((f) => f.field === 'status' && filterIsOn(f) && Array.isArray(f.value)).reduce((kept, f) => (f.op === 'is'
200
- ? kept.filter((label) => f.value.includes(label))
201
- : f.op === 'not' ? kept.filter((label) => !f.value.includes(label)) : kept), labels);
316
+ const groups = (view.groups ?? []).filter((g) => g.filters.some((f) => filterIsOn(f)));
317
+ if (view.match === 'any' && groups.length > 1) return labels;
318
+ return groups.filter((g) => g.match !== 'any' || g.filters.length === 1)
319
+ .flatMap((g) => g.filters)
320
+ .filter((f) => f.field === 'status' && filterIsOn(f) && Array.isArray(f.value)).reduce((kept, f) => (f.op === 'is'
321
+ ? kept.filter((label) => f.value.includes(label))
322
+ : f.op === 'not' ? kept.filter((label) => !f.value.includes(label)) : kept), labels);
202
323
  }
203
324
 
204
325
  const dayOf = (job) => job.dueOn ?? job.orderedOn ?? job.jobDate ?? null;
@@ -275,3 +396,77 @@ export function laneOf(lanes, value) {
275
396
  if (value === false || value == null) return lanes[0].label;
276
397
  return (lanes.find((lane) => lane.value === value) ?? lanes[0]).label;
277
398
  }
399
+
400
+ // ── Columns (columns.js draws them) ──
401
+ // Each kind of column starts at a width and never gets narrower than its
402
+ // minimum, so a head and a short value always fit.
403
+ export const COLUMN_WIDTH = Object.freeze({
404
+ client: 240, text: 176, case: 96, date: 120, total: 112, stage: 168, person: 96, status: 128, link: 96, icon: 48, number: 112,
405
+ });
406
+ export const COLUMN_MIN = Object.freeze({
407
+ client: 140, text: 112, case: 64, date: 76, total: 84, stage: 104, person: 64, status: 92, link: 72, icon: 40, number: 80,
408
+ });
409
+ export const MAX_WIDTH = 800;
410
+ const COLUMN_ID = /^[a-z][a-z0-9_-]{0,31}(:[0-9a-f-]{36})?$/i;
411
+
412
+ export const minWidth = (kind) => COLUMN_MIN[kind] ?? COLUMN_MIN.text;
413
+ export const startWidth = (kind) => COLUMN_WIDTH[kind] ?? COLUMN_WIDTH.text;
414
+ // A column's width now: the person's width (kept to the kind's minimum and
415
+ // the most), else the kind's start width.
416
+ export function widthOf(column, widths) {
417
+ const saved = widths?.[column.id];
418
+ return Number.isFinite(saved) ? Math.round(Math.min(MAX_WIDTH, Math.max(minWidth(column.kind), saved))) : startWidth(column.kind);
419
+ }
420
+
421
+ // Saved widths as Flow keeps them: known-looking ids, whole pixels in range.
422
+ // The minimum per kind applies when a width is drawn (widthOf), since the
423
+ // server does not know a custom column's kind.
424
+ export function cleanWidths(input) {
425
+ const out = {};
426
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return out;
427
+ for (const [id, px] of Object.entries(input).slice(0, MAX_COLUMNS)) {
428
+ if (COLUMN_ID.test(id) && Number.isFinite(px) && px >= COLUMN_MIN.icon && px <= MAX_WIDTH) out[id] = Math.round(px);
429
+ }
430
+ return out;
431
+ }
432
+
433
+ // A saved column order: ids `known(id)` allows, each once.
434
+ export function cleanColumnIds(input, known) {
435
+ if (!Array.isArray(input)) return null;
436
+ return [...new Set(input.filter((id) => typeof id === 'string' && COLUMN_ID.test(id) && known(id)))].slice(0, MAX_COLUMNS);
437
+ }
438
+
439
+ // The ids with `id` put at place `to` (0 is the start).
440
+ export function moveColumn(ids, id, to) {
441
+ const rest = ids.filter((c) => c !== id);
442
+ const at = Math.max(0, Math.min(rest.length, to));
443
+ return [...rest.slice(0, at), id, ...rest.slice(at)];
444
+ }
445
+
446
+ // The columns in a view's order: the ones it names first, in its order, and
447
+ // any it does not name after them, as the page lists them.
448
+ export function inOrder(columns, ids) {
449
+ if (!Array.isArray(ids)) return columns;
450
+ const at = new Map(ids.map((id, n) => [id, n]));
451
+ return columns.map((c, n) => [c, at.has(c.id) ? at.get(c.id) : ids.length + n]).sort((a, b) => a[1] - b[1]).map(([c]) => c);
452
+ }
453
+
454
+ // The columns of the Invoices and Reconcile lists. On Pending, Reference
455
+ // shows the invoice's problem; Number shows on the tabs with numbers only.
456
+ export const INVOICE_COLUMNS = Object.freeze({
457
+ number: 'Number', client: 'Client', reference: 'Reference', date: 'Date', total: 'Total', status: 'Status', linked: 'Linked',
458
+ });
459
+ export const RECONCILE_COLUMNS = Object.freeze({
460
+ kind: 'Type', client: 'Client', detail: 'Detail', date: 'Date', total: 'Total', possible: 'Possible',
461
+ });
462
+
463
+ // A person's view of a list with set columns (Invoices, Reconcile): the
464
+ // shown columns in order (every one when none is saved) and their widths.
465
+ export function normaliseListView(input, columns) {
466
+ const value = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
467
+ const ids = Object.keys(columns);
468
+ return {
469
+ columns: cleanColumnIds(value.columns, (id) => ids.includes(id)) ?? ids,
470
+ widths: cleanWidths(value.widths),
471
+ };
472
+ }