@mulmoclaude/core 0.13.1 → 0.15.0
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/assets/helps/billing-clients-worklog.md +15 -12
- package/assets/helps/collection-skills.md +67 -22
- package/assets/helps/todo-collection.md +13 -7
- package/dist/{deriveAll-BQ_p5HSB.cjs → calendarGrid-CaR-q36Y.js} +352 -74
- package/dist/calendarGrid-CaR-q36Y.js.map +1 -0
- package/dist/calendarGrid-gN4sei7s.cjs +1070 -0
- package/dist/calendarGrid-gN4sei7s.cjs.map +1 -0
- package/dist/collection/core/backlinks.d.ts +20 -0
- package/dist/collection/core/ids.d.ts +12 -0
- package/dist/collection/core/recordZ.d.ts +23 -0
- package/dist/collection/core/schema.d.ts +72 -418
- package/dist/collection/core/schemaZ.d.ts +878 -0
- package/dist/collection/core/templatePath.d.ts +34 -0
- package/dist/collection/core/uiTypes.d.ts +22 -0
- package/dist/collection/core/where.d.ts +9 -15
- package/dist/collection/index.cjs +50 -330
- package/dist/collection/index.cjs.map +1 -1
- package/dist/collection/index.d.ts +2 -0
- package/dist/collection/index.js +14 -301
- package/dist/collection/index.js.map +1 -1
- package/dist/collection/paths.cjs +1 -1
- package/dist/collection/paths.cjs.map +1 -1
- package/dist/collection/paths.js +1 -1
- package/dist/collection/paths.js.map +1 -1
- package/dist/collection/registry/server/index.cjs +1 -1
- package/dist/collection/registry/server/index.js +1 -1
- package/dist/collection/server/discovery.d.ts +2 -244
- package/dist/collection/server/index.cjs +8 -2
- package/dist/collection/server/index.d.ts +1 -0
- package/dist/collection/server/index.js +3 -2
- package/dist/collection/server/io.d.ts +9 -0
- package/dist/collection/server/ontology.d.ts +35 -0
- package/dist/collection/server/templatePath.d.ts +1 -34
- package/dist/collection/server/validate.d.ts +11 -7
- package/dist/collection-watchers/index.cjs +5 -4
- package/dist/collection-watchers/index.cjs.map +1 -1
- package/dist/collection-watchers/index.js +3 -2
- package/dist/collection-watchers/index.js.map +1 -1
- package/dist/feeds/index.cjs +4 -4
- package/dist/feeds/index.js +2 -2
- package/dist/feeds/ingestTypes.d.ts +15 -40
- package/dist/feeds/server/index.cjs +5 -5
- package/dist/feeds/server/index.js +3 -3
- package/dist/{ingestTypes-C7EheYZX.cjs → ingestTypes-DG8RExWt.cjs} +3 -3
- package/dist/ingestTypes-DG8RExWt.cjs.map +1 -0
- package/dist/{ingestTypes-CmJeOUJc.js → ingestTypes-V4upKeBx.js} +2 -2
- package/dist/ingestTypes-V4upKeBx.js.map +1 -0
- package/dist/{server-DKXXFTbw.js → server-BNTR7ySK.js} +531 -196
- package/dist/server-BNTR7ySK.js.map +1 -0
- package/dist/{server-G6GtOdaW.cjs → server-bsNrf2yW.cjs} +572 -213
- package/dist/server-bsNrf2yW.cjs.map +1 -0
- package/package.json +1 -1
- package/dist/deriveAll-BQ_p5HSB.cjs.map +0 -1
- package/dist/deriveAll-CMFXDQ_G.js +0 -506
- package/dist/deriveAll-CMFXDQ_G.js.map +0 -1
- package/dist/ingestTypes-C7EheYZX.cjs.map +0 -1
- package/dist/ingestTypes-CmJeOUJc.js.map +0 -1
- package/dist/server-DKXXFTbw.js.map +0 -1
- package/dist/server-G6GtOdaW.cjs.map +0 -1
|
@@ -0,0 +1,1070 @@
|
|
|
1
|
+
//#region src/collection/core/schema.ts
|
|
2
|
+
/** Declarative retriever kinds a Feed's `ingest.kind` may declare. The host's
|
|
3
|
+
* feeds engine dispatches on these; they live here (with the schema contract)
|
|
4
|
+
* so the schema validator can enforce them. The host re-exports these from
|
|
5
|
+
* `server/workspace/feeds/ingestTypes.ts`. */
|
|
6
|
+
var INGEST_KINDS = [
|
|
7
|
+
"rss",
|
|
8
|
+
"atom",
|
|
9
|
+
"http-json"
|
|
10
|
+
];
|
|
11
|
+
/** The agent-performed ingest kind. Instead of a declarative fetch, the host
|
|
12
|
+
* dispatches a hidden background chat (origin `system`) in `ingest.role`,
|
|
13
|
+
* seeded with `ingest.template` + a summary of every record, on the
|
|
14
|
+
* `ingest.schedule` cadence; the worker edits records via the collections io
|
|
15
|
+
* layer. Kept separate from {@link INGEST_KINDS} (which the declarative
|
|
16
|
+
* retriever registry keys on) so the schema validator can model `ingest` as a
|
|
17
|
+
* discriminated union without the feeds engine gaining an "agent" retriever. */
|
|
18
|
+
var AGENT_INGEST_KIND = "agent";
|
|
19
|
+
/** Refresh cadences a Feed's `ingest.schedule` may declare. */
|
|
20
|
+
var FEED_SCHEDULES = [
|
|
21
|
+
"hourly",
|
|
22
|
+
"daily",
|
|
23
|
+
"weekly",
|
|
24
|
+
"on-demand"
|
|
25
|
+
];
|
|
26
|
+
/** derived/embed/backlinks/toggle are host-computed or projected — never
|
|
27
|
+
* written to the record JSON, so required / value checks and edit-draft
|
|
28
|
+
* slots must not apply to them. THE single source for "computed" —
|
|
29
|
+
* lives here (zod-free at runtime) so browser code (`./draft`) and the
|
|
30
|
+
* zod record compiler (`./recordZ`, which re-exports it) share one set
|
|
31
|
+
* instead of drifting copies. */
|
|
32
|
+
var COMPUTED_TYPES = /* @__PURE__ */ new Set([
|
|
33
|
+
"derived",
|
|
34
|
+
"embed",
|
|
35
|
+
"backlinks",
|
|
36
|
+
"toggle"
|
|
37
|
+
]);
|
|
38
|
+
/** Narrowing guard: true when `every` is the field-driven arm. */
|
|
39
|
+
function isFieldDrivenEvery(every) {
|
|
40
|
+
return "fromField" in every;
|
|
41
|
+
}
|
|
42
|
+
/** Resolve an `embed` field's target record id: the fixed `id`, or the value
|
|
43
|
+
* of the sibling `idField` on this record (empty string when neither applies
|
|
44
|
+
* — the caller renders that as "no record"). Pure + isomorphic so the server
|
|
45
|
+
* projection (`derive.ts`) and the client preview (`useCollectionRendering`)
|
|
46
|
+
* resolve embeds identically. Non-`embed` fields resolve to "no record". */
|
|
47
|
+
function embedTargetId(field, record) {
|
|
48
|
+
if (field.type !== "embed") return "";
|
|
49
|
+
if (field.id) return field.id;
|
|
50
|
+
if (field.idField && record) return String(record[field.idField] ?? "");
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
53
|
+
//#endregion
|
|
54
|
+
//#region src/collection/core/ids.ts
|
|
55
|
+
var SAFE_SLUG_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9_-]*[a-zA-Z0-9])?$/;
|
|
56
|
+
var SAFE_RECORD_ID_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9])?$/;
|
|
57
|
+
/** True when `value` is a well-formed collection slug (alphanumeric /
|
|
58
|
+
* hyphen / underscore, no path separators). The pattern admits no `/`,
|
|
59
|
+
* `\`, or `.`, so a passing value is trivially also a safe basename —
|
|
60
|
+
* validation callers need no `path.basename` round-trip (path-building
|
|
61
|
+
* callers use `../server/paths#safeSlugName`, which adds it). */
|
|
62
|
+
function isSafeSlug(value) {
|
|
63
|
+
return typeof value === "string" && SAFE_SLUG_PATTERN.test(value);
|
|
64
|
+
}
|
|
65
|
+
/** True when `value` is a well-formed record id (slug charset plus interior
|
|
66
|
+
* dots), with any `..` substring rejected explicitly. Validation-only
|
|
67
|
+
* counterpart of `../server/paths#safeRecordId`. */
|
|
68
|
+
function isSafeRecordId(value) {
|
|
69
|
+
if (typeof value !== "string" || !SAFE_RECORD_ID_PATTERN.test(value)) return false;
|
|
70
|
+
return !value.includes("..");
|
|
71
|
+
}
|
|
72
|
+
//#endregion
|
|
73
|
+
//#region src/collection/core/actionVisible.ts
|
|
74
|
+
/** Core matcher:
|
|
75
|
+
* - no `when` ⇒ always true (visible);
|
|
76
|
+
* - otherwise true only when `record[when.field]` is present and its
|
|
77
|
+
* stringified value is one of `when.in`.
|
|
78
|
+
* A missing/undefined/null field is treated as "not a match"
|
|
79
|
+
* (hidden), so a status-gated target never shows on a record that
|
|
80
|
+
* lacks the status. */
|
|
81
|
+
function whenMatches(when, record) {
|
|
82
|
+
if (!when) return true;
|
|
83
|
+
const value = record[when.field];
|
|
84
|
+
if (value === void 0 || value === null) return false;
|
|
85
|
+
return when.in.includes(String(value));
|
|
86
|
+
}
|
|
87
|
+
/** True when the action's button should render against `record`
|
|
88
|
+
* (see whenMatches). */
|
|
89
|
+
function actionVisible(action, record) {
|
|
90
|
+
return whenMatches(action.when, record);
|
|
91
|
+
}
|
|
92
|
+
/** True when the field should render against `record`. A field with
|
|
93
|
+
* no `when` is always shown; otherwise it's shown only when the
|
|
94
|
+
* record matches (e.g. hide a rating field until `visited` is true).
|
|
95
|
+
* Purely presentational — a hidden field's stored value is never
|
|
96
|
+
* altered, so toggling the gate back on restores it. */
|
|
97
|
+
function fieldVisible(field, record) {
|
|
98
|
+
return whenMatches(field.when, record);
|
|
99
|
+
}
|
|
100
|
+
//#endregion
|
|
101
|
+
//#region src/collection/core/backlinks.ts
|
|
102
|
+
/** The SOURCE records whose `via` field stores `recordId` (compared as
|
|
103
|
+
* strings, like every ref deref), with the optional `filter` applied —
|
|
104
|
+
* in the source items' given order. Fail-soft by construction: a `via`
|
|
105
|
+
* key that doesn't exist on the source records simply matches nothing.
|
|
106
|
+
* Callers pass DERIVED source records, so a `filter`/`display` on a
|
|
107
|
+
* derived column works when its formula is SELF-CONTAINED (an invoice
|
|
108
|
+
* `total` = sum over its own line items); a source column that derefs
|
|
109
|
+
* yet another collection stays absent — the same each-record-derives-
|
|
110
|
+
* against-itself rule ref targets follow. */
|
|
111
|
+
function backlinkRows(spec, recordId, sourceItems) {
|
|
112
|
+
if (!recordId) return [];
|
|
113
|
+
return sourceItems.filter((item) => String(item[spec.via] ?? "") === recordId && whenMatches(spec.filter, item));
|
|
114
|
+
}
|
|
115
|
+
/** Project one backlink row to the keys consumers surface: the source
|
|
116
|
+
* collection's primaryKey (rows must stay addressable — it's the link
|
|
117
|
+
* target) plus the declared `display` columns. Keys the row doesn't
|
|
118
|
+
* carry are simply absent, mirroring `projectFields` in getItems. */
|
|
119
|
+
function projectBacklinkRow(row, display, primaryKey) {
|
|
120
|
+
const keys = display.includes(primaryKey) ? display : [primaryKey, ...display];
|
|
121
|
+
return Object.fromEntries(keys.filter((key) => key in row).map((key) => [key, row[key]]));
|
|
122
|
+
}
|
|
123
|
+
//#endregion
|
|
124
|
+
//#region src/collection/core/where.ts
|
|
125
|
+
/** True when `record[field]` is absent (`undefined`/`null`) — the only case
|
|
126
|
+
* where `ne` and every other op disagree on the result. */
|
|
127
|
+
function isMissing(raw) {
|
|
128
|
+
return raw === void 0 || raw === null;
|
|
129
|
+
}
|
|
130
|
+
/** The effective comparison value for `cond`: its literal `value`, or — for
|
|
131
|
+
* a `valueFrom` reference — the target field read out of `recordsById`.
|
|
132
|
+
* `undefined` means UNRESOLVED (no such record, or the field on it is
|
|
133
|
+
* missing); the caller must treat that as "never matches", not as a
|
|
134
|
+
* literal `undefined` value to compare against. */
|
|
135
|
+
function resolveValue(cond, record, recordsById) {
|
|
136
|
+
if (!cond.valueFrom) return cond.value;
|
|
137
|
+
const { record: refRecord, field } = cond.valueFrom;
|
|
138
|
+
const raw = (refRecord === void 0 ? record : recordsById[refRecord])?.[field];
|
|
139
|
+
return isMissing(raw) ? void 0 : String(raw);
|
|
140
|
+
}
|
|
141
|
+
function matchesNumericOp(operator, left, right) {
|
|
142
|
+
if (operator === "gt") return left > right;
|
|
143
|
+
if (operator === "gte") return left >= right;
|
|
144
|
+
if (operator === "lt") return left < right;
|
|
145
|
+
return left <= right;
|
|
146
|
+
}
|
|
147
|
+
/** `Number("")` / `Number(" ")` are `0`, not `NaN`, so treat a blank string
|
|
148
|
+
* as non-numeric explicitly — an empty field must fail a numeric compare,
|
|
149
|
+
* not read as zero. */
|
|
150
|
+
function toNumber(raw) {
|
|
151
|
+
return raw.trim() === "" ? NaN : Number(raw);
|
|
152
|
+
}
|
|
153
|
+
function matchesNumeric(operator, raw, value) {
|
|
154
|
+
if (Array.isArray(value)) return false;
|
|
155
|
+
const left = toNumber(raw);
|
|
156
|
+
const right = toNumber(value);
|
|
157
|
+
if (Number.isNaN(left) || Number.isNaN(right)) return false;
|
|
158
|
+
return matchesNumericOp(operator, left, right);
|
|
159
|
+
}
|
|
160
|
+
/** True when the present string `raw` satisfies `operator` against the
|
|
161
|
+
* resolved `value` (field known to exist — MISSING is handled by the
|
|
162
|
+
* caller before this runs, and an UNRESOLVED `valueFrom` never reaches
|
|
163
|
+
* here either). */
|
|
164
|
+
function matchesPresent(operator, raw, value) {
|
|
165
|
+
switch (operator) {
|
|
166
|
+
case "eq": return raw === String(value);
|
|
167
|
+
case "ne": return raw !== String(value);
|
|
168
|
+
case "in": return Array.isArray(value) && value.includes(raw);
|
|
169
|
+
case "contains": return raw.includes(String(value));
|
|
170
|
+
case "gt":
|
|
171
|
+
case "gte":
|
|
172
|
+
case "lt":
|
|
173
|
+
case "lte": return matchesNumeric(operator, raw, value);
|
|
174
|
+
default: return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** True when `record` satisfies one condition, given `recordsById` to
|
|
178
|
+
* resolve a `valueFrom` reference. Two independent MISSING cases, checked
|
|
179
|
+
* in order:
|
|
180
|
+
* - `record[cond.field]` absent (`undefined`/`null`) → matches only `ne`
|
|
181
|
+
* (vacuously true — "not equal to X" holds when there's no value at
|
|
182
|
+
* all); every other op is false. Unchanged from the literal-`value`
|
|
183
|
+
* behaviour, regardless of whether `valueFrom` would also resolve.
|
|
184
|
+
* - the resolved comparison value is UNRESOLVED (a `valueFrom` whose
|
|
185
|
+
* target record/field doesn't exist) → false for EVERY op, including
|
|
186
|
+
* `ne` — a broken reference must never spuriously match. */
|
|
187
|
+
function matchesCond(cond, record, recordsById) {
|
|
188
|
+
const raw = record[cond.field];
|
|
189
|
+
if (isMissing(raw)) return cond.op === "ne";
|
|
190
|
+
const value = resolveValue(cond, record, recordsById);
|
|
191
|
+
if (value === void 0) return false;
|
|
192
|
+
return matchesPresent(cond.op, String(raw), value);
|
|
193
|
+
}
|
|
194
|
+
/** True when `record` satisfies every condition in `where` (AND). An empty
|
|
195
|
+
* `where` matches everything. `recordsById` — the source collection's
|
|
196
|
+
* records keyed by primaryKey — resolves any `valueFrom` reference;
|
|
197
|
+
* omitted (default `{}`) for callers with no cross-record lookups, in
|
|
198
|
+
* which case every `valueFrom` condition is UNRESOLVED and so never
|
|
199
|
+
* matches. */
|
|
200
|
+
function matchesWhere(where, record, recordsById = {}) {
|
|
201
|
+
return where.every((cond) => matchesCond(cond, record, recordsById));
|
|
202
|
+
}
|
|
203
|
+
//#endregion
|
|
204
|
+
//#region src/collection/core/dynamicIcon.ts
|
|
205
|
+
/** The record with the greatest `String(record[field])` (localeCompare) —
|
|
206
|
+
* ties keep the first-seen record (stable left-to-right `reduce`). */
|
|
207
|
+
function latestByField(pool, field) {
|
|
208
|
+
return pool.reduce((latest, candidate) => String(candidate[field] ?? "").localeCompare(String(latest[field] ?? "")) > 0 ? candidate : latest);
|
|
209
|
+
}
|
|
210
|
+
/** Reduce `records` to the one record that decides the effective icon, per
|
|
211
|
+
* `source`'s `where` filter + `from` strategy:
|
|
212
|
+
* - pool = `source.where`-filtered records, or every record when unset;
|
|
213
|
+
* - an empty pool resolves to `null` (no source record → fallback);
|
|
214
|
+
* - `from: "first"` / `"when"` → the first pool record (storage order);
|
|
215
|
+
* - `from: "latest"` (default), with `orderBy` given → the pool record
|
|
216
|
+
* whose `String(record[orderBy])` sorts highest;
|
|
217
|
+
* - `from: "latest"`, with no `orderBy` → the last pool record.
|
|
218
|
+
* `recordsById` (the source collection's records keyed by primaryKey)
|
|
219
|
+
* resolves any `valueFrom` reference inside `source.where`; omitted for
|
|
220
|
+
* callers with no cross-record lookups. */
|
|
221
|
+
function selectDynamicRecord(records, source, orderBy, recordsById = {}) {
|
|
222
|
+
const { where } = source;
|
|
223
|
+
const pool = where ? records.filter((record) => matchesWhere(where, record, recordsById)) : records;
|
|
224
|
+
if (pool.length === 0) return null;
|
|
225
|
+
if (source.from === "first" || source.from === "when") return pool[0];
|
|
226
|
+
return orderBy ? latestByField(pool, orderBy) : pool[pool.length - 1];
|
|
227
|
+
}
|
|
228
|
+
/** Map a resolved source record to the effective icon: `spec.fallback`
|
|
229
|
+
* (or the collection's own static `icon`) when there's no record or no
|
|
230
|
+
* rule matches; otherwise the `icon` of the first rule whose `where`
|
|
231
|
+
* matches the record. `recordsById` resolves any `valueFrom` reference
|
|
232
|
+
* inside a rule's `where`, same as `selectDynamicRecord`. */
|
|
233
|
+
function resolveIcon(record, spec, staticIcon, recordsById = {}) {
|
|
234
|
+
const fallback = spec.fallback ?? staticIcon;
|
|
235
|
+
if (!record) return fallback;
|
|
236
|
+
const matched = spec.rules.find((rule) => matchesWhere(rule.where, record, recordsById));
|
|
237
|
+
return matched ? matched.icon : fallback;
|
|
238
|
+
}
|
|
239
|
+
var isDateLikeField = (field) => field.type === "date" || field.type === "datetime";
|
|
240
|
+
/** The first field key (declaration order) whose type is `date` or
|
|
241
|
+
* `datetime` — the default `orderBy` for `from: "latest"` when a
|
|
242
|
+
* `DynamicIconSource` doesn't name one. `undefined` when the schema has
|
|
243
|
+
* no date-like field. */
|
|
244
|
+
function firstDateField(schema) {
|
|
245
|
+
return Object.entries(schema.fields).find(([, field]) => isDateLikeField(field))?.[0];
|
|
246
|
+
}
|
|
247
|
+
//#endregion
|
|
248
|
+
//#region src/collection/core/derivedFormula.ts
|
|
249
|
+
function evaluateDerived(formula, ctx) {
|
|
250
|
+
let tokens;
|
|
251
|
+
try {
|
|
252
|
+
tokens = tokenize(formula);
|
|
253
|
+
} catch {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
const parser = new Parser(tokens);
|
|
257
|
+
let ast;
|
|
258
|
+
try {
|
|
259
|
+
ast = parser.parseExpr();
|
|
260
|
+
if (!parser.atEnd()) return null;
|
|
261
|
+
} catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
const value = evaluate(ast, ctx);
|
|
265
|
+
return Number.isFinite(value) ? value : null;
|
|
266
|
+
}
|
|
267
|
+
var SINGLE_CHAR_PUNCT = /* @__PURE__ */ new Set([
|
|
268
|
+
"(",
|
|
269
|
+
")",
|
|
270
|
+
"+",
|
|
271
|
+
"-",
|
|
272
|
+
"*",
|
|
273
|
+
"/",
|
|
274
|
+
"."
|
|
275
|
+
]);
|
|
276
|
+
function consumeWhitespace(cur) {
|
|
277
|
+
const char = cur.input[cur.index];
|
|
278
|
+
if (char === " " || char === " " || char === "\n") {
|
|
279
|
+
cur.index++;
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
function consumeNumber(cur) {
|
|
285
|
+
const char = cur.input[cur.index] ?? "";
|
|
286
|
+
const next = cur.input[cur.index + 1] ?? "";
|
|
287
|
+
if (!isDigit(char) && !(char === "." && isDigit(next))) return null;
|
|
288
|
+
let raw = "";
|
|
289
|
+
while (cur.index < cur.input.length) {
|
|
290
|
+
const here = cur.input[cur.index] ?? "";
|
|
291
|
+
if (!isDigit(here) && here !== ".") break;
|
|
292
|
+
raw += here;
|
|
293
|
+
cur.index++;
|
|
294
|
+
}
|
|
295
|
+
const num = Number(raw);
|
|
296
|
+
if (!Number.isFinite(num)) throw new Error("bad number");
|
|
297
|
+
return {
|
|
298
|
+
kind: "number",
|
|
299
|
+
value: num
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
function consumeIdent(cur) {
|
|
303
|
+
if (!isIdentStart(cur.input[cur.index] ?? "")) return null;
|
|
304
|
+
let raw = "";
|
|
305
|
+
while (cur.index < cur.input.length && isIdentChar(cur.input[cur.index] ?? "")) {
|
|
306
|
+
raw += cur.input[cur.index];
|
|
307
|
+
cur.index++;
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
kind: "ident",
|
|
311
|
+
value: raw
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
function consumePunct(cur) {
|
|
315
|
+
const char = cur.input[cur.index] ?? "";
|
|
316
|
+
if (char === "[" && cur.input[cur.index + 1] === "]") {
|
|
317
|
+
cur.index += 2;
|
|
318
|
+
return { kind: "[]" };
|
|
319
|
+
}
|
|
320
|
+
if (SINGLE_CHAR_PUNCT.has(char)) {
|
|
321
|
+
cur.index++;
|
|
322
|
+
return { kind: char };
|
|
323
|
+
}
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
function tokenize(input) {
|
|
327
|
+
const tokens = [];
|
|
328
|
+
const cur = {
|
|
329
|
+
input,
|
|
330
|
+
index: 0
|
|
331
|
+
};
|
|
332
|
+
while (cur.index < input.length) {
|
|
333
|
+
if (consumeWhitespace(cur)) continue;
|
|
334
|
+
const numTok = consumeNumber(cur);
|
|
335
|
+
if (numTok) {
|
|
336
|
+
tokens.push(numTok);
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
const punctTok = consumePunct(cur);
|
|
340
|
+
if (punctTok) {
|
|
341
|
+
tokens.push(punctTok);
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const identTok = consumeIdent(cur);
|
|
345
|
+
if (identTok) {
|
|
346
|
+
tokens.push(identTok);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
throw new Error(`unexpected char ${input[cur.index]}`);
|
|
350
|
+
}
|
|
351
|
+
return tokens;
|
|
352
|
+
}
|
|
353
|
+
function isDigit(char) {
|
|
354
|
+
return char >= "0" && char <= "9";
|
|
355
|
+
}
|
|
356
|
+
function isIdentStart(char) {
|
|
357
|
+
return char >= "a" && char <= "z" || char >= "A" && char <= "Z" || char === "_";
|
|
358
|
+
}
|
|
359
|
+
function isIdentChar(char) {
|
|
360
|
+
return isIdentStart(char) || isDigit(char);
|
|
361
|
+
}
|
|
362
|
+
var Parser = class {
|
|
363
|
+
tokens;
|
|
364
|
+
cursor = 0;
|
|
365
|
+
constructor(tokens) {
|
|
366
|
+
this.tokens = tokens;
|
|
367
|
+
}
|
|
368
|
+
atEnd() {
|
|
369
|
+
return this.cursor >= this.tokens.length;
|
|
370
|
+
}
|
|
371
|
+
peek() {
|
|
372
|
+
return this.tokens[this.cursor];
|
|
373
|
+
}
|
|
374
|
+
consume() {
|
|
375
|
+
const tok = this.tokens[this.cursor++];
|
|
376
|
+
if (!tok) throw new Error("unexpected end of input");
|
|
377
|
+
return tok;
|
|
378
|
+
}
|
|
379
|
+
expect(kind) {
|
|
380
|
+
const tok = this.consume();
|
|
381
|
+
if (tok.kind !== kind) throw new Error(`expected ${kind}, got ${tok.kind}`);
|
|
382
|
+
return tok;
|
|
383
|
+
}
|
|
384
|
+
parseExpr() {
|
|
385
|
+
let left = this.parseTerm();
|
|
386
|
+
while (this.peek()?.kind === "+" || this.peek()?.kind === "-") {
|
|
387
|
+
const operator = this.consume().kind;
|
|
388
|
+
const right = this.parseTerm();
|
|
389
|
+
left = {
|
|
390
|
+
kind: "binop",
|
|
391
|
+
operator,
|
|
392
|
+
left,
|
|
393
|
+
right
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
return left;
|
|
397
|
+
}
|
|
398
|
+
parseTerm() {
|
|
399
|
+
let left = this.parseFactor();
|
|
400
|
+
while (this.peek()?.kind === "*" || this.peek()?.kind === "/") {
|
|
401
|
+
const operator = this.consume().kind;
|
|
402
|
+
const right = this.parseFactor();
|
|
403
|
+
left = {
|
|
404
|
+
kind: "binop",
|
|
405
|
+
operator,
|
|
406
|
+
left,
|
|
407
|
+
right
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
return left;
|
|
411
|
+
}
|
|
412
|
+
parseFactor() {
|
|
413
|
+
const tok = this.peek();
|
|
414
|
+
if (!tok) throw new Error("unexpected end in factor");
|
|
415
|
+
if (tok.kind === "number") {
|
|
416
|
+
this.consume();
|
|
417
|
+
return {
|
|
418
|
+
kind: "num",
|
|
419
|
+
value: tok.value
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
if (tok.kind === "(") {
|
|
423
|
+
this.consume();
|
|
424
|
+
const inner = this.parseExpr();
|
|
425
|
+
this.expect(")");
|
|
426
|
+
return inner;
|
|
427
|
+
}
|
|
428
|
+
if (tok.kind === "ident") {
|
|
429
|
+
const name = tok.value ?? "";
|
|
430
|
+
if (name === "sum" && this.tokens[this.cursor + 1]?.kind === "(") {
|
|
431
|
+
this.consume();
|
|
432
|
+
this.expect("(");
|
|
433
|
+
const arg = this.parseSumArg();
|
|
434
|
+
this.expect(")");
|
|
435
|
+
return {
|
|
436
|
+
kind: "sum",
|
|
437
|
+
arg
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
this.consume();
|
|
441
|
+
if (this.peek()?.kind === ".") {
|
|
442
|
+
this.consume();
|
|
443
|
+
return {
|
|
444
|
+
kind: "ref",
|
|
445
|
+
field: name,
|
|
446
|
+
col: this.expect("ident").value
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
return {
|
|
450
|
+
kind: "ident",
|
|
451
|
+
name
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
throw new Error(`unexpected token ${tok.kind} in factor`);
|
|
455
|
+
}
|
|
456
|
+
parseSumArg() {
|
|
457
|
+
const factors = [];
|
|
458
|
+
const operators = [];
|
|
459
|
+
factors.push(this.parseTableCol());
|
|
460
|
+
while (this.peek()?.kind === "*" || this.peek()?.kind === "/") {
|
|
461
|
+
const operator = this.consume().kind;
|
|
462
|
+
operators.push(operator);
|
|
463
|
+
factors.push(this.parseTableCol());
|
|
464
|
+
}
|
|
465
|
+
return {
|
|
466
|
+
factors,
|
|
467
|
+
operators
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
parseTableCol() {
|
|
471
|
+
const tableTok = this.expect("ident");
|
|
472
|
+
this.expect("[]");
|
|
473
|
+
this.expect(".");
|
|
474
|
+
const colTok = this.expect("ident");
|
|
475
|
+
return {
|
|
476
|
+
table: tableTok.value,
|
|
477
|
+
col: colTok.value
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
function evaluate(node, ctx) {
|
|
482
|
+
if (node.kind === "num") return node.value;
|
|
483
|
+
if (node.kind === "ident") {
|
|
484
|
+
const raw = ctx.record[node.name];
|
|
485
|
+
return toFiniteNumber(raw);
|
|
486
|
+
}
|
|
487
|
+
if (node.kind === "ref") {
|
|
488
|
+
const target = ctx.refs?.[node.field] ?? null;
|
|
489
|
+
if (!target) return NaN;
|
|
490
|
+
return toFiniteNumber(target[node.col]);
|
|
491
|
+
}
|
|
492
|
+
if (node.kind === "binop") {
|
|
493
|
+
const left = evaluate(node.left, ctx);
|
|
494
|
+
const right = evaluate(node.right, ctx);
|
|
495
|
+
return applyBinop(node.operator, left, right);
|
|
496
|
+
}
|
|
497
|
+
if (node.kind === "sum") return evaluateSum(node.arg, ctx);
|
|
498
|
+
throw new Error(`unknown node`);
|
|
499
|
+
}
|
|
500
|
+
function applyBinop(operator, left, right) {
|
|
501
|
+
if (!Number.isFinite(left) || !Number.isFinite(right)) return NaN;
|
|
502
|
+
if (operator === "+") return left + right;
|
|
503
|
+
if (operator === "-") return left - right;
|
|
504
|
+
if (operator === "*") return left * right;
|
|
505
|
+
if (right === 0) return NaN;
|
|
506
|
+
return left / right;
|
|
507
|
+
}
|
|
508
|
+
function evaluateSum(arg, ctx) {
|
|
509
|
+
if (arg.factors.length === 0) return 0;
|
|
510
|
+
const tableName = arg.factors[0].table;
|
|
511
|
+
for (const factor of arg.factors) if (factor.table !== tableName) return NaN;
|
|
512
|
+
const rows = ctx.record[tableName];
|
|
513
|
+
if (!Array.isArray(rows)) return 0;
|
|
514
|
+
let total = 0;
|
|
515
|
+
for (const row of rows) {
|
|
516
|
+
if (!row || typeof row !== "object") continue;
|
|
517
|
+
let product = toFiniteNumber(row[arg.factors[0].col]);
|
|
518
|
+
if (!Number.isFinite(product)) return NaN;
|
|
519
|
+
for (let i = 1; i < arg.factors.length; i++) {
|
|
520
|
+
const value = toFiniteNumber(row[arg.factors[i].col]);
|
|
521
|
+
if (!Number.isFinite(value)) return NaN;
|
|
522
|
+
product = applyBinop(arg.operators[i - 1], product, value);
|
|
523
|
+
}
|
|
524
|
+
total += product;
|
|
525
|
+
}
|
|
526
|
+
return total;
|
|
527
|
+
}
|
|
528
|
+
function toFiniteNumber(value) {
|
|
529
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : NaN;
|
|
530
|
+
if (typeof value === "string" && value.length > 0) {
|
|
531
|
+
const num = Number(value);
|
|
532
|
+
return Number.isFinite(num) ? num : NaN;
|
|
533
|
+
}
|
|
534
|
+
return NaN;
|
|
535
|
+
}
|
|
536
|
+
//#endregion
|
|
537
|
+
//#region src/collection/core/deriveAll.ts
|
|
538
|
+
/** Map each `ref` field's stored slug to its loaded target record (or
|
|
539
|
+
* null when dangling / not loaded), keyed by the LOCAL field name —
|
|
540
|
+
* the shape `evaluateDerived` reads for `<field>.<col>` derefs. */
|
|
541
|
+
function resolveRowRefs(schema, record, refRecords) {
|
|
542
|
+
const refs = {};
|
|
543
|
+
for (const [key, field] of Object.entries(schema.fields)) {
|
|
544
|
+
if (field.type !== "ref" || !field.to) continue;
|
|
545
|
+
const slug = record[key];
|
|
546
|
+
refs[key] = typeof slug === "string" ? refRecords[field.to]?.[slug] ?? null : null;
|
|
547
|
+
}
|
|
548
|
+
return refs;
|
|
549
|
+
}
|
|
550
|
+
/** Evaluate every `derived` field against `base`, saturating so a
|
|
551
|
+
* derived field can read another derived field computed in an earlier
|
|
552
|
+
* pass (`subtotal → tax → total` converges in ≤ field-count passes).
|
|
553
|
+
* Cycles can't loop forever — passes are bounded by the number of
|
|
554
|
+
* derived fields and the loop breaks as soon as a pass changes
|
|
555
|
+
* nothing. Failed formulas stay ABSENT (the UI renders them as
|
|
556
|
+
* em-dash). Returns a copy; `base` is never mutated.
|
|
557
|
+
*
|
|
558
|
+
* Derived keys already present in `base` are stripped before
|
|
559
|
+
* evaluation: computed output is host-truth, never persisted-input
|
|
560
|
+
* fallback. A record JSON can carry a stale (or forged) derived value
|
|
561
|
+
* — raw Write/Edit, legacy data — and without the strip, a failing
|
|
562
|
+
* formula would silently surface that value as if the host computed
|
|
563
|
+
* it. */
|
|
564
|
+
function deriveAll(schema, base, refRecords) {
|
|
565
|
+
const derivedKeys = new Set(Object.keys(schema.fields).filter((key) => schema.fields[key]?.type === "derived"));
|
|
566
|
+
const enriched = Object.fromEntries(Object.entries(base).filter(([key]) => !derivedKeys.has(key)));
|
|
567
|
+
const refs = resolveRowRefs(schema, base, refRecords);
|
|
568
|
+
const maxPasses = Object.values(schema.fields).filter((field) => field.type === "derived").length;
|
|
569
|
+
for (let pass = 0; pass < maxPasses; pass++) {
|
|
570
|
+
let mutated = false;
|
|
571
|
+
for (const [key, field] of Object.entries(schema.fields)) {
|
|
572
|
+
if (field.type !== "derived" || !field.formula) continue;
|
|
573
|
+
const next = evaluateDerived(field.formula, {
|
|
574
|
+
record: enriched,
|
|
575
|
+
refs
|
|
576
|
+
});
|
|
577
|
+
if (next !== null && enriched[key] !== next) {
|
|
578
|
+
enriched[key] = next;
|
|
579
|
+
mutated = true;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
if (!mutated) break;
|
|
583
|
+
}
|
|
584
|
+
return enriched;
|
|
585
|
+
}
|
|
586
|
+
//#endregion
|
|
587
|
+
//#region src/collection/core/calendarGrid.ts
|
|
588
|
+
var MS_PER_DAY = 864e5;
|
|
589
|
+
var ISO_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
590
|
+
var TWO_DIGIT_RE = /^\d{2}$/;
|
|
591
|
+
var CLOCK_RE = /(\d{1,2}):(\d{2})/g;
|
|
592
|
+
var RANGE_SEP_RE = /[-–—~〜~]/;
|
|
593
|
+
/** Minutes in a full day — the timeline's vertical extent. */
|
|
594
|
+
var MINUTES_PER_DAY = 1440;
|
|
595
|
+
function pad2(value) {
|
|
596
|
+
return String(value).padStart(2, "0");
|
|
597
|
+
}
|
|
598
|
+
/** Canonical `YYYY-MM-DD` string for a civil date. */
|
|
599
|
+
function ymdKey(ymd) {
|
|
600
|
+
return `${String(ymd.year).padStart(4, "0")}-${pad2(ymd.month)}-${pad2(ymd.day)}`;
|
|
601
|
+
}
|
|
602
|
+
/** Strictly parse a `YYYY-MM-DD` string into a civil date, rejecting
|
|
603
|
+
* anything that isn't a real calendar day (e.g. `2026-02-30`, `2026-13-01`).
|
|
604
|
+
* Returns null for non-strings and malformed values so callers can route
|
|
605
|
+
* records with no usable date into the "no date" tray rather than crash. */
|
|
606
|
+
function parseIsoDate(value) {
|
|
607
|
+
if (typeof value !== "string") return null;
|
|
608
|
+
const match = ISO_DATE_RE.exec(value.trim());
|
|
609
|
+
if (!match) return null;
|
|
610
|
+
const year = Number(match[1]);
|
|
611
|
+
const month = Number(match[2]);
|
|
612
|
+
const day = Number(match[3]);
|
|
613
|
+
const probe = new Date(Date.UTC(year, month - 1, day));
|
|
614
|
+
if (probe.getUTCFullYear() !== year || probe.getUTCMonth() !== month - 1 || probe.getUTCDate() !== day) return null;
|
|
615
|
+
return {
|
|
616
|
+
year,
|
|
617
|
+
month,
|
|
618
|
+
day
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
/** Minutes-of-day for an `HH:MM` pair, or null when out of range. */
|
|
622
|
+
function clockToMinutes(hours, minutes) {
|
|
623
|
+
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null;
|
|
624
|
+
return hours * 60 + minutes;
|
|
625
|
+
}
|
|
626
|
+
/** Strictly parse a `YYYY-MM-DDTHH:MM` (optional `:SS`) datetime into its
|
|
627
|
+
* civil date and minutes-of-day. Returns null for anything that isn't a real
|
|
628
|
+
* calendar day or a valid 24h clock. */
|
|
629
|
+
function parseIsoDateTime(value) {
|
|
630
|
+
if (typeof value !== "string") return null;
|
|
631
|
+
const trimmed = value.trim();
|
|
632
|
+
const tIndex = trimmed.indexOf("T");
|
|
633
|
+
if (tIndex === -1) return null;
|
|
634
|
+
const ymd = parseIsoDate(trimmed.slice(0, tIndex));
|
|
635
|
+
if (!ymd) return null;
|
|
636
|
+
const parts = trimmed.slice(tIndex + 1).split(":");
|
|
637
|
+
if (parts.length < 2 || parts.length > 3 || !parts.every((part) => TWO_DIGIT_RE.test(part))) return null;
|
|
638
|
+
const minutes = clockToMinutes(Number(parts[0]), Number(parts[1]));
|
|
639
|
+
if (minutes === null) return null;
|
|
640
|
+
return {
|
|
641
|
+
ymd,
|
|
642
|
+
minutes
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
/** Civil date from either a `YYYY-MM-DD` or a `YYYY-MM-DDTHH:MM` value, so the
|
|
646
|
+
* month grid buckets date-only and datetime anchors alike. */
|
|
647
|
+
function dateOf(value) {
|
|
648
|
+
return parseIsoDate(value) ?? parseIsoDateTime(value)?.ymd ?? null;
|
|
649
|
+
}
|
|
650
|
+
/** Minutes-of-day from a datetime value, or null for date-only / invalid. */
|
|
651
|
+
function timeOf(value) {
|
|
652
|
+
return parseIsoDateTime(value)?.minutes ?? null;
|
|
653
|
+
}
|
|
654
|
+
/** Parse a free-form time-string field into start/end minutes-of-day.
|
|
655
|
+
* Handles the common shapes in user data:
|
|
656
|
+
* "14:00-17:00" → { start: 840, end: 1020 } (range → block)
|
|
657
|
+
* "17:00-" → { start: 1020, end: null } (open end → single line)
|
|
658
|
+
* "16:30" → { start: 990, end: null } (point in time → single line)
|
|
659
|
+
* "終日" / "" → null (no clock → all-day)
|
|
660
|
+
* Returns null when no clock token is parseable. */
|
|
661
|
+
function parseTimeRange(value) {
|
|
662
|
+
if (typeof value !== "string") return null;
|
|
663
|
+
const text = value.trim();
|
|
664
|
+
if (!text) return null;
|
|
665
|
+
const tokens = [...text.matchAll(CLOCK_RE)];
|
|
666
|
+
if (tokens.length === 0) return null;
|
|
667
|
+
const minutesOf = (match) => clockToMinutes(Number(match[1]), Number(match[2]));
|
|
668
|
+
if (!RANGE_SEP_RE.test(text)) {
|
|
669
|
+
const startMin = minutesOf(tokens[0]);
|
|
670
|
+
return startMin === null ? null : {
|
|
671
|
+
startMin,
|
|
672
|
+
endMin: null
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
const sepIndex = text.search(RANGE_SEP_RE);
|
|
676
|
+
let startMin = null;
|
|
677
|
+
let endMin = null;
|
|
678
|
+
for (const token of tokens) if ((token.index ?? 0) < sepIndex) startMin = minutesOf(token);
|
|
679
|
+
else endMin = minutesOf(token);
|
|
680
|
+
if (startMin === null) return null;
|
|
681
|
+
return {
|
|
682
|
+
startMin,
|
|
683
|
+
endMin
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
function ymdToUtcMs(ymd) {
|
|
687
|
+
return Date.UTC(ymd.year, ymd.month - 1, ymd.day);
|
|
688
|
+
}
|
|
689
|
+
function utcMsToYmd(epochMs) {
|
|
690
|
+
const date = new Date(epochMs);
|
|
691
|
+
return {
|
|
692
|
+
year: date.getUTCFullYear(),
|
|
693
|
+
month: date.getUTCMonth() + 1,
|
|
694
|
+
day: date.getUTCDate()
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
/** Chronological comparison: negative if `left` precedes `right`, 0 if the
|
|
698
|
+
* same day, positive if after. */
|
|
699
|
+
function compareYmd(left, right) {
|
|
700
|
+
return ymdToUtcMs(left) - ymdToUtcMs(right);
|
|
701
|
+
}
|
|
702
|
+
/** True iff `day` falls within the inclusive span `[span.start, span.end]`. */
|
|
703
|
+
function spanCoversDay(span, day) {
|
|
704
|
+
return compareYmd(span.start, day) <= 0 && compareYmd(day, span.end) <= 0;
|
|
705
|
+
}
|
|
706
|
+
/** Build the 6×7 (42-cell) grid for the given month, including the
|
|
707
|
+
* leading/trailing days of the adjacent months so every week is full.
|
|
708
|
+
* `month` is 1-12. `weekStartsOn` is 0 (Sunday) … 6 (Saturday). */
|
|
709
|
+
function buildMonthGrid(year, month, weekStartsOn = 0) {
|
|
710
|
+
const lead = (new Date(Date.UTC(year, month - 1, 1)).getUTCDay() - weekStartsOn + 7) % 7;
|
|
711
|
+
const startMs = Date.UTC(year, month - 1, 1) - lead * MS_PER_DAY;
|
|
712
|
+
const cells = [];
|
|
713
|
+
for (let i = 0; i < 42; i++) {
|
|
714
|
+
const ymd = utcMsToYmd(startMs + i * MS_PER_DAY);
|
|
715
|
+
cells.push({
|
|
716
|
+
ymd,
|
|
717
|
+
inMonth: ymd.year === year && ymd.month === month,
|
|
718
|
+
key: ymdKey(ymd)
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
return cells;
|
|
722
|
+
}
|
|
723
|
+
/** Resolve a record's calendar span from its date/datetime fields. Returns
|
|
724
|
+
* null when the anchor date is missing/invalid (→ the caller's "no date"
|
|
725
|
+
* tray). An end date that is missing, invalid, or earlier than the start
|
|
726
|
+
* collapses to a single-day span — never an inverted range.
|
|
727
|
+
*
|
|
728
|
+
* Times for the day (time-allocation) view come from, in priority order:
|
|
729
|
+
* 1. the clock on a `datetime` anchor/end value, else
|
|
730
|
+
* 2. `timeField` — a separate free-form time-string column (e.g. "14:00-17:00").
|
|
731
|
+
* A record with no resolvable clock has `startMin === endMin === null`. */
|
|
732
|
+
function recordSpan(item, anchorField, endField, timeField) {
|
|
733
|
+
const startRaw = item[anchorField];
|
|
734
|
+
const start = dateOf(startRaw);
|
|
735
|
+
if (!start) return null;
|
|
736
|
+
let end = start;
|
|
737
|
+
let startMin = timeOf(startRaw);
|
|
738
|
+
let endMin = null;
|
|
739
|
+
if (endField) {
|
|
740
|
+
const endRaw = item[endField];
|
|
741
|
+
const parsedEnd = dateOf(endRaw);
|
|
742
|
+
if (parsedEnd && compareYmd(parsedEnd, start) >= 0) {
|
|
743
|
+
end = parsedEnd;
|
|
744
|
+
endMin = timeOf(endRaw);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
if (timeField && startMin === null && endMin === null) {
|
|
748
|
+
const range = parseTimeRange(item[timeField]);
|
|
749
|
+
if (range) ({startMin, endMin} = range);
|
|
750
|
+
}
|
|
751
|
+
return {
|
|
752
|
+
item,
|
|
753
|
+
start,
|
|
754
|
+
end,
|
|
755
|
+
startMin,
|
|
756
|
+
endMin
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
/** Split records into those that land on the calendar (with their spans)
|
|
760
|
+
* and those with no usable anchor date (the "no date" tray). Spans are
|
|
761
|
+
* sorted by start day so same-day stacking is stable across renders. */
|
|
762
|
+
function bucketRecords(items, anchorField, endField, timeField) {
|
|
763
|
+
const spans = [];
|
|
764
|
+
const noDate = [];
|
|
765
|
+
for (const item of items) {
|
|
766
|
+
const span = recordSpan(item, anchorField, endField, timeField);
|
|
767
|
+
if (span) spans.push(span);
|
|
768
|
+
else noDate.push(item);
|
|
769
|
+
}
|
|
770
|
+
spans.sort((left, right) => compareYmd(left.start, right.start));
|
|
771
|
+
return {
|
|
772
|
+
spans,
|
|
773
|
+
noDate
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
/** Project a record's span onto a single day for the time-allocation view, or
|
|
777
|
+
* null when the span doesn't cover that day. */
|
|
778
|
+
function daySlice(span, day) {
|
|
779
|
+
if (!spanCoversDay(span, day)) return null;
|
|
780
|
+
const hasStart = span.startMin !== null;
|
|
781
|
+
const hasEnd = span.endMin !== null;
|
|
782
|
+
if (!hasStart && !hasEnd) return {
|
|
783
|
+
kind: "allDay",
|
|
784
|
+
startMin: 0,
|
|
785
|
+
endMin: MINUTES_PER_DAY,
|
|
786
|
+
bleedsBefore: false,
|
|
787
|
+
bleedsAfter: false
|
|
788
|
+
};
|
|
789
|
+
const singleDay = compareYmd(span.start, span.end) === 0;
|
|
790
|
+
const isStartDay = compareYmd(day, span.start) === 0;
|
|
791
|
+
const isEndDay = compareYmd(day, span.end) === 0;
|
|
792
|
+
if (singleDay && hasStart && !hasEnd) return {
|
|
793
|
+
kind: "line",
|
|
794
|
+
startMin: span.startMin,
|
|
795
|
+
endMin: span.startMin,
|
|
796
|
+
bleedsBefore: false,
|
|
797
|
+
bleedsAfter: false
|
|
798
|
+
};
|
|
799
|
+
const startMin = isStartDay && hasStart ? span.startMin : 0;
|
|
800
|
+
const endMin = isEndDay && hasEnd ? span.endMin : MINUTES_PER_DAY;
|
|
801
|
+
if (singleDay && endMin <= startMin) return {
|
|
802
|
+
kind: "line",
|
|
803
|
+
startMin,
|
|
804
|
+
endMin: startMin,
|
|
805
|
+
bleedsBefore: false,
|
|
806
|
+
bleedsAfter: false
|
|
807
|
+
};
|
|
808
|
+
return {
|
|
809
|
+
kind: "block",
|
|
810
|
+
startMin,
|
|
811
|
+
endMin,
|
|
812
|
+
bleedsBefore: !isStartDay,
|
|
813
|
+
bleedsAfter: !isEndDay
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
function assignLanes(blocks) {
|
|
817
|
+
const order = [...blocks.keys()].sort((left, right) => blocks[left].startMin - blocks[right].startMin || blocks[left].endMin - blocks[right].endMin);
|
|
818
|
+
const result = blocks.map(() => ({
|
|
819
|
+
lane: 0,
|
|
820
|
+
lanes: 1
|
|
821
|
+
}));
|
|
822
|
+
let cluster = [];
|
|
823
|
+
let clusterEnd = Number.NEGATIVE_INFINITY;
|
|
824
|
+
const laneEnds = [];
|
|
825
|
+
const flush = () => {
|
|
826
|
+
for (const index of cluster) result[index].lanes = laneEnds.length;
|
|
827
|
+
cluster = [];
|
|
828
|
+
laneEnds.length = 0;
|
|
829
|
+
clusterEnd = Number.NEGATIVE_INFINITY;
|
|
830
|
+
};
|
|
831
|
+
for (const index of order) {
|
|
832
|
+
const block = blocks[index];
|
|
833
|
+
if (cluster.length > 0 && block.startMin >= clusterEnd) flush();
|
|
834
|
+
let lane = laneEnds.findIndex((end) => end <= block.startMin);
|
|
835
|
+
if (lane === -1) {
|
|
836
|
+
lane = laneEnds.length;
|
|
837
|
+
laneEnds.push(block.endMin);
|
|
838
|
+
} else laneEnds[lane] = block.endMin;
|
|
839
|
+
result[index].lane = lane;
|
|
840
|
+
cluster.push(index);
|
|
841
|
+
clusterEnd = Math.max(clusterEnd, block.endMin);
|
|
842
|
+
}
|
|
843
|
+
flush();
|
|
844
|
+
return result;
|
|
845
|
+
}
|
|
846
|
+
/** Month label key inputs — returns the 1st of the month as a `Date` so the
|
|
847
|
+
* component can feed it to `Intl.DateTimeFormat(locale, …)` for a localized
|
|
848
|
+
* "April 2026" header without this module taking a locale dependency. */
|
|
849
|
+
function monthAnchorDate(year, month) {
|
|
850
|
+
return new Date(Date.UTC(year, month - 1, 1));
|
|
851
|
+
}
|
|
852
|
+
//#endregion
|
|
853
|
+
Object.defineProperty(exports, "AGENT_INGEST_KIND", {
|
|
854
|
+
enumerable: true,
|
|
855
|
+
get: function() {
|
|
856
|
+
return AGENT_INGEST_KIND;
|
|
857
|
+
}
|
|
858
|
+
});
|
|
859
|
+
Object.defineProperty(exports, "COMPUTED_TYPES", {
|
|
860
|
+
enumerable: true,
|
|
861
|
+
get: function() {
|
|
862
|
+
return COMPUTED_TYPES;
|
|
863
|
+
}
|
|
864
|
+
});
|
|
865
|
+
Object.defineProperty(exports, "FEED_SCHEDULES", {
|
|
866
|
+
enumerable: true,
|
|
867
|
+
get: function() {
|
|
868
|
+
return FEED_SCHEDULES;
|
|
869
|
+
}
|
|
870
|
+
});
|
|
871
|
+
Object.defineProperty(exports, "INGEST_KINDS", {
|
|
872
|
+
enumerable: true,
|
|
873
|
+
get: function() {
|
|
874
|
+
return INGEST_KINDS;
|
|
875
|
+
}
|
|
876
|
+
});
|
|
877
|
+
Object.defineProperty(exports, "MINUTES_PER_DAY", {
|
|
878
|
+
enumerable: true,
|
|
879
|
+
get: function() {
|
|
880
|
+
return MINUTES_PER_DAY;
|
|
881
|
+
}
|
|
882
|
+
});
|
|
883
|
+
Object.defineProperty(exports, "SAFE_RECORD_ID_PATTERN", {
|
|
884
|
+
enumerable: true,
|
|
885
|
+
get: function() {
|
|
886
|
+
return SAFE_RECORD_ID_PATTERN;
|
|
887
|
+
}
|
|
888
|
+
});
|
|
889
|
+
Object.defineProperty(exports, "SAFE_SLUG_PATTERN", {
|
|
890
|
+
enumerable: true,
|
|
891
|
+
get: function() {
|
|
892
|
+
return SAFE_SLUG_PATTERN;
|
|
893
|
+
}
|
|
894
|
+
});
|
|
895
|
+
Object.defineProperty(exports, "actionVisible", {
|
|
896
|
+
enumerable: true,
|
|
897
|
+
get: function() {
|
|
898
|
+
return actionVisible;
|
|
899
|
+
}
|
|
900
|
+
});
|
|
901
|
+
Object.defineProperty(exports, "assignLanes", {
|
|
902
|
+
enumerable: true,
|
|
903
|
+
get: function() {
|
|
904
|
+
return assignLanes;
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
Object.defineProperty(exports, "backlinkRows", {
|
|
908
|
+
enumerable: true,
|
|
909
|
+
get: function() {
|
|
910
|
+
return backlinkRows;
|
|
911
|
+
}
|
|
912
|
+
});
|
|
913
|
+
Object.defineProperty(exports, "bucketRecords", {
|
|
914
|
+
enumerable: true,
|
|
915
|
+
get: function() {
|
|
916
|
+
return bucketRecords;
|
|
917
|
+
}
|
|
918
|
+
});
|
|
919
|
+
Object.defineProperty(exports, "buildMonthGrid", {
|
|
920
|
+
enumerable: true,
|
|
921
|
+
get: function() {
|
|
922
|
+
return buildMonthGrid;
|
|
923
|
+
}
|
|
924
|
+
});
|
|
925
|
+
Object.defineProperty(exports, "compareYmd", {
|
|
926
|
+
enumerable: true,
|
|
927
|
+
get: function() {
|
|
928
|
+
return compareYmd;
|
|
929
|
+
}
|
|
930
|
+
});
|
|
931
|
+
Object.defineProperty(exports, "dateOf", {
|
|
932
|
+
enumerable: true,
|
|
933
|
+
get: function() {
|
|
934
|
+
return dateOf;
|
|
935
|
+
}
|
|
936
|
+
});
|
|
937
|
+
Object.defineProperty(exports, "daySlice", {
|
|
938
|
+
enumerable: true,
|
|
939
|
+
get: function() {
|
|
940
|
+
return daySlice;
|
|
941
|
+
}
|
|
942
|
+
});
|
|
943
|
+
Object.defineProperty(exports, "deriveAll", {
|
|
944
|
+
enumerable: true,
|
|
945
|
+
get: function() {
|
|
946
|
+
return deriveAll;
|
|
947
|
+
}
|
|
948
|
+
});
|
|
949
|
+
Object.defineProperty(exports, "embedTargetId", {
|
|
950
|
+
enumerable: true,
|
|
951
|
+
get: function() {
|
|
952
|
+
return embedTargetId;
|
|
953
|
+
}
|
|
954
|
+
});
|
|
955
|
+
Object.defineProperty(exports, "evaluateDerived", {
|
|
956
|
+
enumerable: true,
|
|
957
|
+
get: function() {
|
|
958
|
+
return evaluateDerived;
|
|
959
|
+
}
|
|
960
|
+
});
|
|
961
|
+
Object.defineProperty(exports, "fieldVisible", {
|
|
962
|
+
enumerable: true,
|
|
963
|
+
get: function() {
|
|
964
|
+
return fieldVisible;
|
|
965
|
+
}
|
|
966
|
+
});
|
|
967
|
+
Object.defineProperty(exports, "firstDateField", {
|
|
968
|
+
enumerable: true,
|
|
969
|
+
get: function() {
|
|
970
|
+
return firstDateField;
|
|
971
|
+
}
|
|
972
|
+
});
|
|
973
|
+
Object.defineProperty(exports, "isFieldDrivenEvery", {
|
|
974
|
+
enumerable: true,
|
|
975
|
+
get: function() {
|
|
976
|
+
return isFieldDrivenEvery;
|
|
977
|
+
}
|
|
978
|
+
});
|
|
979
|
+
Object.defineProperty(exports, "isSafeRecordId", {
|
|
980
|
+
enumerable: true,
|
|
981
|
+
get: function() {
|
|
982
|
+
return isSafeRecordId;
|
|
983
|
+
}
|
|
984
|
+
});
|
|
985
|
+
Object.defineProperty(exports, "isSafeSlug", {
|
|
986
|
+
enumerable: true,
|
|
987
|
+
get: function() {
|
|
988
|
+
return isSafeSlug;
|
|
989
|
+
}
|
|
990
|
+
});
|
|
991
|
+
Object.defineProperty(exports, "matchesWhere", {
|
|
992
|
+
enumerable: true,
|
|
993
|
+
get: function() {
|
|
994
|
+
return matchesWhere;
|
|
995
|
+
}
|
|
996
|
+
});
|
|
997
|
+
Object.defineProperty(exports, "monthAnchorDate", {
|
|
998
|
+
enumerable: true,
|
|
999
|
+
get: function() {
|
|
1000
|
+
return monthAnchorDate;
|
|
1001
|
+
}
|
|
1002
|
+
});
|
|
1003
|
+
Object.defineProperty(exports, "parseIsoDate", {
|
|
1004
|
+
enumerable: true,
|
|
1005
|
+
get: function() {
|
|
1006
|
+
return parseIsoDate;
|
|
1007
|
+
}
|
|
1008
|
+
});
|
|
1009
|
+
Object.defineProperty(exports, "parseIsoDateTime", {
|
|
1010
|
+
enumerable: true,
|
|
1011
|
+
get: function() {
|
|
1012
|
+
return parseIsoDateTime;
|
|
1013
|
+
}
|
|
1014
|
+
});
|
|
1015
|
+
Object.defineProperty(exports, "parseTimeRange", {
|
|
1016
|
+
enumerable: true,
|
|
1017
|
+
get: function() {
|
|
1018
|
+
return parseTimeRange;
|
|
1019
|
+
}
|
|
1020
|
+
});
|
|
1021
|
+
Object.defineProperty(exports, "projectBacklinkRow", {
|
|
1022
|
+
enumerable: true,
|
|
1023
|
+
get: function() {
|
|
1024
|
+
return projectBacklinkRow;
|
|
1025
|
+
}
|
|
1026
|
+
});
|
|
1027
|
+
Object.defineProperty(exports, "recordSpan", {
|
|
1028
|
+
enumerable: true,
|
|
1029
|
+
get: function() {
|
|
1030
|
+
return recordSpan;
|
|
1031
|
+
}
|
|
1032
|
+
});
|
|
1033
|
+
Object.defineProperty(exports, "resolveIcon", {
|
|
1034
|
+
enumerable: true,
|
|
1035
|
+
get: function() {
|
|
1036
|
+
return resolveIcon;
|
|
1037
|
+
}
|
|
1038
|
+
});
|
|
1039
|
+
Object.defineProperty(exports, "resolveRowRefs", {
|
|
1040
|
+
enumerable: true,
|
|
1041
|
+
get: function() {
|
|
1042
|
+
return resolveRowRefs;
|
|
1043
|
+
}
|
|
1044
|
+
});
|
|
1045
|
+
Object.defineProperty(exports, "selectDynamicRecord", {
|
|
1046
|
+
enumerable: true,
|
|
1047
|
+
get: function() {
|
|
1048
|
+
return selectDynamicRecord;
|
|
1049
|
+
}
|
|
1050
|
+
});
|
|
1051
|
+
Object.defineProperty(exports, "spanCoversDay", {
|
|
1052
|
+
enumerable: true,
|
|
1053
|
+
get: function() {
|
|
1054
|
+
return spanCoversDay;
|
|
1055
|
+
}
|
|
1056
|
+
});
|
|
1057
|
+
Object.defineProperty(exports, "whenMatches", {
|
|
1058
|
+
enumerable: true,
|
|
1059
|
+
get: function() {
|
|
1060
|
+
return whenMatches;
|
|
1061
|
+
}
|
|
1062
|
+
});
|
|
1063
|
+
Object.defineProperty(exports, "ymdKey", {
|
|
1064
|
+
enumerable: true,
|
|
1065
|
+
get: function() {
|
|
1066
|
+
return ymdKey;
|
|
1067
|
+
}
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
//# sourceMappingURL=calendarGrid-gN4sei7s.cjs.map
|