@mulmoclaude/core 0.13.1 → 0.14.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 +42 -19
- package/assets/helps/todo-collection.md +13 -7
- package/dist/{deriveAll-CMFXDQ_G.js → calendarGrid-C1rfCXJ3.cjs} +468 -3
- package/dist/calendarGrid-C1rfCXJ3.cjs.map +1 -0
- package/dist/{deriveAll-BQ_p5HSB.cjs → calendarGrid-kO6rGfm0.js} +289 -74
- package/dist/calendarGrid-kO6rGfm0.js.map +1 -0
- package/dist/collection/core/ids.d.ts +12 -0
- package/dist/collection/core/recordZ.d.ts +25 -0
- package/dist/collection/core/schema.d.ts +65 -418
- package/dist/collection/core/schemaZ.d.ts +846 -0
- package/dist/collection/core/templatePath.d.ts +34 -0
- package/dist/collection/core/where.d.ts +9 -15
- package/dist/collection/index.cjs +34 -295
- package/dist/collection/index.cjs.map +1 -1
- package/dist/collection/index.d.ts +1 -0
- package/dist/collection/index.js +5 -270
- 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 +3 -1
- package/dist/collection/server/index.js +2 -2
- package/dist/collection/server/templatePath.d.ts +1 -34
- package/dist/collection/server/validate.d.ts +11 -7
- package/dist/collection-watchers/index.cjs +1 -1
- package/dist/collection-watchers/index.js +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-BIFXlw7M.cjs} +3 -3
- package/dist/ingestTypes-BIFXlw7M.cjs.map +1 -0
- package/dist/{ingestTypes-CmJeOUJc.js → ingestTypes-KuYPX9Ea.js} +2 -2
- package/dist/ingestTypes-KuYPX9Ea.js.map +1 -0
- package/dist/{server-DKXXFTbw.js → server-Cc6XcyOA.js} +417 -187
- package/dist/server-Cc6XcyOA.js.map +1 -0
- package/dist/{server-G6GtOdaW.cjs → server-Cd5WdM8a.cjs} +440 -198
- package/dist/server-Cd5WdM8a.cjs.map +1 -0
- package/package.json +1 -1
- package/dist/deriveAll-BQ_p5HSB.cjs.map +0 -1
- 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
|
@@ -103,18 +103,19 @@ Don't push for fields the user hasn't given you — leave optional fields out of
|
|
|
103
103
|
the JSON entirely.
|
|
104
104
|
|
|
105
105
|
## What to do
|
|
106
|
-
**Add**: derive an `id`, build the record,
|
|
107
|
-
|
|
108
|
-
silently
|
|
106
|
+
**Add**: derive an `id`, build the record, write via `manageCollection`
|
|
107
|
+
`putItems` with `mode: "create"` — an id collision comes back as a `rejected`
|
|
108
|
+
row instead of silently overwriting; pick a fresh slug and retry.
|
|
109
109
|
|
|
110
|
-
**List / look up**:
|
|
110
|
+
**List / look up**: `manageCollection` `getItems`, answer from the rows. Don't
|
|
111
111
|
recite the whole table in chat — the user sees it at `/collections/clients`. A
|
|
112
112
|
one-line confirmation ("Added Acme Corp.") is enough.
|
|
113
113
|
|
|
114
|
-
**Update**:
|
|
115
|
-
|
|
114
|
+
**Update**: `putItems` with `mode: "merge"` and a partial row
|
|
115
|
+
(`{ id, <changed fields> }`) — the default upsert replaces the whole record.
|
|
116
116
|
|
|
117
|
-
**Delete**: confirm once if the request is ambiguous, then remove the file
|
|
117
|
+
**Delete**: confirm once if the request is ambiguous, then remove the file
|
|
118
|
+
(`manageCollection` has no delete).
|
|
118
119
|
|
|
119
120
|
## Linking to a client in chat
|
|
120
121
|
Link to the collection view, not the raw JSON path:
|
|
@@ -179,18 +180,20 @@ description: A simple timesheet — log billable / non-billable hours per client
|
|
|
179
180
|
## clientId resolution
|
|
180
181
|
`clientId` is a `ref` to the `clients` collection — write the raw client slug.
|
|
181
182
|
When the user says "log 2 hours for Acme":
|
|
182
|
-
-
|
|
183
|
-
(case-insensitive substring is
|
|
183
|
+
- `manageCollection` `getItems` on `clients` (`fields: ["id", "name"]`) and
|
|
184
|
+
find the slug whose `name` matches "Acme" (case-insensitive substring is
|
|
185
|
+
fine).
|
|
184
186
|
- If no match: ask whether to create the client first (via the `clients` skill)
|
|
185
187
|
or use a literal slug they supply. Never invent a clientId that doesn't exist —
|
|
186
188
|
it renders as a broken link.
|
|
187
189
|
|
|
188
190
|
## What to do
|
|
189
191
|
**Log hours**: derive `id`, default `billable: true`, default `date` to today if
|
|
190
|
-
unspecified,
|
|
191
|
-
|
|
192
|
+
unspecified, write via `manageCollection` `putItems` (`mode: "create"`; fix any
|
|
193
|
+
`rejected` row from its `problem` text and retry). (This skill tracks total
|
|
194
|
+
hours per day per client — not start/end times.)
|
|
192
195
|
|
|
193
|
-
**List / summarize**:
|
|
196
|
+
**List / summarize**: `manageCollection` `getItems`, answer from the rows. Don't
|
|
194
197
|
recite the table — point at `/collections/worklog`. For aggregates ("how many
|
|
195
198
|
hours did I bill Acme last month?") group by clientId + date range and answer in
|
|
196
199
|
one line.
|
|
@@ -31,7 +31,7 @@ data/skills/<slug>/ ← YOU write here (Write / Edit)
|
|
|
31
31
|
SKILL.md · schema.json · templates/*.md
|
|
32
32
|
|
|
33
33
|
data/<name>/items/ ← the records (separate from the skill dir)
|
|
34
|
-
<id>.json ← one record per file (
|
|
34
|
+
<id>.json ← one record per file (write via manageCollection putItems)
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
- **Author under `data/skills/<slug>/`, NEVER `.claude/skills/<slug>/`
|
|
@@ -761,10 +761,25 @@ single source of truth and the "done" checkbox is a `toggle` field projecting it
|
|
|
761
761
|
|
|
762
762
|
## Records — one JSON object per file
|
|
763
763
|
|
|
764
|
-
|
|
765
|
-
|
|
764
|
+
Each record is a plain file at `<dataPath>/<id>.json` (the `id` field's value is
|
|
765
|
+
the filename, no extension) — that is the storage model. But you read and write
|
|
766
|
+
records through **`manageCollection`**, not raw file I/O:
|
|
767
|
+
|
|
768
|
+
- **Create / update — `putItems`.** Every row is validated against the schema
|
|
769
|
+
BEFORE the write (required fields, enum membership, primaryKey = record id)
|
|
770
|
+
and the result reports `{ written, rejected }` — fix each rejected row from
|
|
771
|
+
its `problem` text and retry just those rows. Use `mode: "create"` when
|
|
772
|
+
adding, so an id collision is rejected instead of silently overwritten, and
|
|
773
|
+
`mode: "merge"` with a partial row (`{ id, <changed fields> }`) when
|
|
774
|
+
updating — the default upsert replaces the WHOLE record and would erase
|
|
775
|
+
every optional field the row omits.
|
|
776
|
+
- **Read / list — `getItems`.** The only way to see host-computed `derived` /
|
|
777
|
+
`toggle` / `embed` values (the stored JSON never contains them). Pass `ids`
|
|
778
|
+
/ `fields` on large collections to keep the result small — e.g.
|
|
779
|
+
`fields: ["id"]` to check for an id collision before an add.
|
|
780
|
+
- **Delete** — remove the record file (`manageCollection` has no delete).
|
|
766
781
|
- **Id charset** (enforced by `safeRecordId` in
|
|
767
|
-
`packages/
|
|
782
|
+
`packages/core/src/collection/server/paths.ts` — the single source of
|
|
768
783
|
truth; `manageCollection` rejects ids that fail it): start and end with a
|
|
769
784
|
letter or digit; inside, also `-`, `_`, and `.` are allowed (so natural keys
|
|
770
785
|
like a Slack ts `1718900000.123456` or a SemVer `1.2.3` work). **No** path
|
|
@@ -774,6 +789,24 @@ single source of truth and the "done" checkbox is a `toggle` field projecting it
|
|
|
774
789
|
enforces this on every targeted read/write, so an id that only _looks_ fine in
|
|
775
790
|
a full `getItems` listing but violates the rule can't be updated or deleted by
|
|
776
791
|
id — fix the id, don't work around it with raw file I/O.
|
|
792
|
+
- **Never write `derived` fields**, and never write an `embed` field — both are
|
|
793
|
+
display-only / host-computed (`putItems` rejects rows that carry them).
|
|
794
|
+
- Leave optional fields out of the row entirely rather than writing empty
|
|
795
|
+
strings.
|
|
796
|
+
- For a `ref` field, write the raw target slug, and make sure that record
|
|
797
|
+
actually exists in the target collection — an invalid slug renders as a broken
|
|
798
|
+
link. The host enforces structure and safety; **you own semantic correctness**
|
|
799
|
+
(valid refs, sane values).
|
|
800
|
+
|
|
801
|
+
### Raw file I/O on records — the escape hatch
|
|
802
|
+
|
|
803
|
+
Read / Write / Edit on the record files stays available (files are the source
|
|
804
|
+
of truth), but it skips `putItems`' pre-write validation — a mistake lands on
|
|
805
|
+
disk instead of coming back as a `rejected` row. Reach for it only when the
|
|
806
|
+
tool can't do the job: bulk file surgery, or repairing a file so malformed
|
|
807
|
+
that `manageCollection` can't address it. If you do write record files
|
|
808
|
+
directly:
|
|
809
|
+
|
|
777
810
|
- **The file MUST be valid JSON.** A malformed record is **silently skipped** at
|
|
778
811
|
read time (logged server-side, but invisible in the UI) — so one bad file out
|
|
779
812
|
of fifteen looks like "fourteen records vanished." The #1 cause is an
|
|
@@ -782,22 +815,12 @@ single source of truth and the "done" checkbox is a `toggle` field projecting it
|
|
|
782
815
|
(`text`, `markdown`, a long `objective`), either escape every inner ASCII quote
|
|
783
816
|
as `\"`, or — better — use the language's own quotation marks (`「」`/`『』` for
|
|
784
817
|
Japanese, `‘ ’`/`“ ”` or `'…'` for English) so no escaping is needed.
|
|
785
|
-
|
|
818
|
+
- `presentCollection` re-validates the records and reports any unreadable /
|
|
786
819
|
malformed / schema-violating files back to you (a `⚠️` in its result) — so
|
|
787
|
-
always follow a batch of writes with a `presentCollection` call and
|
|
788
|
-
any ⚠️ it returns
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
overwriting. Update = Read, merge, Write back (preserve fields you weren't
|
|
792
|
-
asked to change). Delete = remove the file.
|
|
793
|
-
- **Never write `derived` fields**, and never write an `embed` field — both are
|
|
794
|
-
display-only / host-computed.
|
|
795
|
-
- Leave optional fields out of the JSON entirely rather than writing empty
|
|
796
|
-
strings.
|
|
797
|
-
- For a `ref` field, write the raw target slug, and make sure that record
|
|
798
|
-
actually exists in the target collection — an invalid slug renders as a broken
|
|
799
|
-
link. The host enforces structure and safety; **you own semantic correctness**
|
|
800
|
-
(valid refs, sane values).
|
|
820
|
+
always follow a batch of direct writes with a `presentCollection` call and
|
|
821
|
+
**act on any ⚠️ it returns**, rather than assuming every record landed.
|
|
822
|
+
(This safety net applies after `putItems` batches too, but direct writes are
|
|
823
|
+
where it earns its keep.)
|
|
801
824
|
|
|
802
825
|
## End-to-end: creating a new collection skill
|
|
803
826
|
|
|
@@ -80,11 +80,16 @@ description: The user's personal todo list. Use whenever they add, list, edit,
|
|
|
80
80
|
- Do NOT write a `done` field — it's a projection of `status`.
|
|
81
81
|
|
|
82
82
|
## What to do
|
|
83
|
-
- **Add**: derive an id, default `status: "todo"`,
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
- **
|
|
87
|
-
|
|
83
|
+
- **Add**: derive an id, default `status: "todo"`, write via `manageCollection`
|
|
84
|
+
`putItems` (`mode: "create"`; a rejected row comes back with a `problem` to
|
|
85
|
+
fix and retry).
|
|
86
|
+
- **List**: `manageCollection` `getItems` (the only way to see the computed
|
|
87
|
+
`done` toggle); point the user at `/collections/todos` rather than reciting
|
|
88
|
+
the table.
|
|
89
|
+
- **Mark done**: `putItems` with `mode: "merge"` and `{ id, status: "done" }`.
|
|
90
|
+
- **Edit**: `putItems` `mode: "merge"` with a partial row — the default upsert
|
|
91
|
+
replaces the whole record. **Delete**: remove the file (`manageCollection`
|
|
92
|
+
has no delete).
|
|
88
93
|
- After a change, call `presentCollection` with slug `todos` (and the id) to show
|
|
89
94
|
it inline.
|
|
90
95
|
```
|
|
@@ -131,8 +136,9 @@ single array) with the columns in the sibling `columns.json`. Convert them:
|
|
|
131
136
|
the legacy list had.
|
|
132
137
|
3. **Fold `completed` into `status`.** A legacy `completed: true` item belongs in
|
|
133
138
|
the done column; don't carry a separate boolean.
|
|
134
|
-
4. **One
|
|
135
|
-
preserving each legacy `id`
|
|
139
|
+
4. **One record per item.** Split the array into one `putItems` row per item
|
|
140
|
+
(stored as `data/todos/items/<id>.json`), preserving each legacy `id`
|
|
141
|
+
verbatim.
|
|
136
142
|
5. **Convert `createdAt`.** The legacy value is Unix milliseconds — convert it to
|
|
137
143
|
a `YYYY-MM-DD` `date` field. Carry `priority`, `dueDate`, `note` straight
|
|
138
144
|
across.
|
|
@@ -31,13 +31,33 @@ function isFieldDrivenEvery(every) {
|
|
|
31
31
|
* of the sibling `idField` on this record (empty string when neither applies
|
|
32
32
|
* — the caller renders that as "no record"). Pure + isomorphic so the server
|
|
33
33
|
* projection (`derive.ts`) and the client preview (`useCollectionRendering`)
|
|
34
|
-
* resolve embeds identically. */
|
|
34
|
+
* resolve embeds identically. Non-`embed` fields resolve to "no record". */
|
|
35
35
|
function embedTargetId(field, record) {
|
|
36
|
+
if (field.type !== "embed") return "";
|
|
36
37
|
if (field.id) return field.id;
|
|
37
38
|
if (field.idField && record) return String(record[field.idField] ?? "");
|
|
38
39
|
return "";
|
|
39
40
|
}
|
|
40
41
|
//#endregion
|
|
42
|
+
//#region src/collection/core/ids.ts
|
|
43
|
+
var SAFE_SLUG_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9_-]*[a-zA-Z0-9])?$/;
|
|
44
|
+
var SAFE_RECORD_ID_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9])?$/;
|
|
45
|
+
/** True when `value` is a well-formed collection slug (alphanumeric /
|
|
46
|
+
* hyphen / underscore, no path separators). The pattern admits no `/`,
|
|
47
|
+
* `\`, or `.`, so a passing value is trivially also a safe basename —
|
|
48
|
+
* validation callers need no `path.basename` round-trip (path-building
|
|
49
|
+
* callers use `../server/paths#safeSlugName`, which adds it). */
|
|
50
|
+
function isSafeSlug(value) {
|
|
51
|
+
return typeof value === "string" && SAFE_SLUG_PATTERN.test(value);
|
|
52
|
+
}
|
|
53
|
+
/** True when `value` is a well-formed record id (slug charset plus interior
|
|
54
|
+
* dots), with any `..` substring rejected explicitly. Validation-only
|
|
55
|
+
* counterpart of `../server/paths#safeRecordId`. */
|
|
56
|
+
function isSafeRecordId(value) {
|
|
57
|
+
if (typeof value !== "string" || !SAFE_RECORD_ID_PATTERN.test(value)) return false;
|
|
58
|
+
return !value.includes("..");
|
|
59
|
+
}
|
|
60
|
+
//#endregion
|
|
41
61
|
//#region src/collection/core/where.ts
|
|
42
62
|
/** True when `record[field]` is absent (`undefined`/`null`) — the only case
|
|
43
63
|
* where `ne` and every other op disagree on the result. */
|
|
@@ -501,6 +521,451 @@ function deriveAll(schema, base, refRecords) {
|
|
|
501
521
|
return enriched;
|
|
502
522
|
}
|
|
503
523
|
//#endregion
|
|
504
|
-
|
|
524
|
+
//#region src/collection/core/calendarGrid.ts
|
|
525
|
+
var MS_PER_DAY = 864e5;
|
|
526
|
+
var ISO_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
527
|
+
var TWO_DIGIT_RE = /^\d{2}$/;
|
|
528
|
+
var CLOCK_RE = /(\d{1,2}):(\d{2})/g;
|
|
529
|
+
var RANGE_SEP_RE = /[-–—~〜~]/;
|
|
530
|
+
/** Minutes in a full day — the timeline's vertical extent. */
|
|
531
|
+
var MINUTES_PER_DAY = 1440;
|
|
532
|
+
function pad2(value) {
|
|
533
|
+
return String(value).padStart(2, "0");
|
|
534
|
+
}
|
|
535
|
+
/** Canonical `YYYY-MM-DD` string for a civil date. */
|
|
536
|
+
function ymdKey(ymd) {
|
|
537
|
+
return `${String(ymd.year).padStart(4, "0")}-${pad2(ymd.month)}-${pad2(ymd.day)}`;
|
|
538
|
+
}
|
|
539
|
+
/** Strictly parse a `YYYY-MM-DD` string into a civil date, rejecting
|
|
540
|
+
* anything that isn't a real calendar day (e.g. `2026-02-30`, `2026-13-01`).
|
|
541
|
+
* Returns null for non-strings and malformed values so callers can route
|
|
542
|
+
* records with no usable date into the "no date" tray rather than crash. */
|
|
543
|
+
function parseIsoDate(value) {
|
|
544
|
+
if (typeof value !== "string") return null;
|
|
545
|
+
const match = ISO_DATE_RE.exec(value.trim());
|
|
546
|
+
if (!match) return null;
|
|
547
|
+
const year = Number(match[1]);
|
|
548
|
+
const month = Number(match[2]);
|
|
549
|
+
const day = Number(match[3]);
|
|
550
|
+
const probe = new Date(Date.UTC(year, month - 1, day));
|
|
551
|
+
if (probe.getUTCFullYear() !== year || probe.getUTCMonth() !== month - 1 || probe.getUTCDate() !== day) return null;
|
|
552
|
+
return {
|
|
553
|
+
year,
|
|
554
|
+
month,
|
|
555
|
+
day
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
/** Minutes-of-day for an `HH:MM` pair, or null when out of range. */
|
|
559
|
+
function clockToMinutes(hours, minutes) {
|
|
560
|
+
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null;
|
|
561
|
+
return hours * 60 + minutes;
|
|
562
|
+
}
|
|
563
|
+
/** Strictly parse a `YYYY-MM-DDTHH:MM` (optional `:SS`) datetime into its
|
|
564
|
+
* civil date and minutes-of-day. Returns null for anything that isn't a real
|
|
565
|
+
* calendar day or a valid 24h clock. */
|
|
566
|
+
function parseIsoDateTime(value) {
|
|
567
|
+
if (typeof value !== "string") return null;
|
|
568
|
+
const trimmed = value.trim();
|
|
569
|
+
const tIndex = trimmed.indexOf("T");
|
|
570
|
+
if (tIndex === -1) return null;
|
|
571
|
+
const ymd = parseIsoDate(trimmed.slice(0, tIndex));
|
|
572
|
+
if (!ymd) return null;
|
|
573
|
+
const parts = trimmed.slice(tIndex + 1).split(":");
|
|
574
|
+
if (parts.length < 2 || parts.length > 3 || !parts.every((part) => TWO_DIGIT_RE.test(part))) return null;
|
|
575
|
+
const minutes = clockToMinutes(Number(parts[0]), Number(parts[1]));
|
|
576
|
+
if (minutes === null) return null;
|
|
577
|
+
return {
|
|
578
|
+
ymd,
|
|
579
|
+
minutes
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
/** Civil date from either a `YYYY-MM-DD` or a `YYYY-MM-DDTHH:MM` value, so the
|
|
583
|
+
* month grid buckets date-only and datetime anchors alike. */
|
|
584
|
+
function dateOf(value) {
|
|
585
|
+
return parseIsoDate(value) ?? parseIsoDateTime(value)?.ymd ?? null;
|
|
586
|
+
}
|
|
587
|
+
/** Minutes-of-day from a datetime value, or null for date-only / invalid. */
|
|
588
|
+
function timeOf(value) {
|
|
589
|
+
return parseIsoDateTime(value)?.minutes ?? null;
|
|
590
|
+
}
|
|
591
|
+
/** Parse a free-form time-string field into start/end minutes-of-day.
|
|
592
|
+
* Handles the common shapes in user data:
|
|
593
|
+
* "14:00-17:00" → { start: 840, end: 1020 } (range → block)
|
|
594
|
+
* "17:00-" → { start: 1020, end: null } (open end → single line)
|
|
595
|
+
* "16:30" → { start: 990, end: null } (point in time → single line)
|
|
596
|
+
* "終日" / "" → null (no clock → all-day)
|
|
597
|
+
* Returns null when no clock token is parseable. */
|
|
598
|
+
function parseTimeRange(value) {
|
|
599
|
+
if (typeof value !== "string") return null;
|
|
600
|
+
const text = value.trim();
|
|
601
|
+
if (!text) return null;
|
|
602
|
+
const tokens = [...text.matchAll(CLOCK_RE)];
|
|
603
|
+
if (tokens.length === 0) return null;
|
|
604
|
+
const minutesOf = (match) => clockToMinutes(Number(match[1]), Number(match[2]));
|
|
605
|
+
if (!RANGE_SEP_RE.test(text)) {
|
|
606
|
+
const startMin = minutesOf(tokens[0]);
|
|
607
|
+
return startMin === null ? null : {
|
|
608
|
+
startMin,
|
|
609
|
+
endMin: null
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
const sepIndex = text.search(RANGE_SEP_RE);
|
|
613
|
+
let startMin = null;
|
|
614
|
+
let endMin = null;
|
|
615
|
+
for (const token of tokens) if ((token.index ?? 0) < sepIndex) startMin = minutesOf(token);
|
|
616
|
+
else endMin = minutesOf(token);
|
|
617
|
+
if (startMin === null) return null;
|
|
618
|
+
return {
|
|
619
|
+
startMin,
|
|
620
|
+
endMin
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
function ymdToUtcMs(ymd) {
|
|
624
|
+
return Date.UTC(ymd.year, ymd.month - 1, ymd.day);
|
|
625
|
+
}
|
|
626
|
+
function utcMsToYmd(epochMs) {
|
|
627
|
+
const date = new Date(epochMs);
|
|
628
|
+
return {
|
|
629
|
+
year: date.getUTCFullYear(),
|
|
630
|
+
month: date.getUTCMonth() + 1,
|
|
631
|
+
day: date.getUTCDate()
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
/** Chronological comparison: negative if `left` precedes `right`, 0 if the
|
|
635
|
+
* same day, positive if after. */
|
|
636
|
+
function compareYmd(left, right) {
|
|
637
|
+
return ymdToUtcMs(left) - ymdToUtcMs(right);
|
|
638
|
+
}
|
|
639
|
+
/** True iff `day` falls within the inclusive span `[span.start, span.end]`. */
|
|
640
|
+
function spanCoversDay(span, day) {
|
|
641
|
+
return compareYmd(span.start, day) <= 0 && compareYmd(day, span.end) <= 0;
|
|
642
|
+
}
|
|
643
|
+
/** Build the 6×7 (42-cell) grid for the given month, including the
|
|
644
|
+
* leading/trailing days of the adjacent months so every week is full.
|
|
645
|
+
* `month` is 1-12. `weekStartsOn` is 0 (Sunday) … 6 (Saturday). */
|
|
646
|
+
function buildMonthGrid(year, month, weekStartsOn = 0) {
|
|
647
|
+
const lead = (new Date(Date.UTC(year, month - 1, 1)).getUTCDay() - weekStartsOn + 7) % 7;
|
|
648
|
+
const startMs = Date.UTC(year, month - 1, 1) - lead * MS_PER_DAY;
|
|
649
|
+
const cells = [];
|
|
650
|
+
for (let i = 0; i < 42; i++) {
|
|
651
|
+
const ymd = utcMsToYmd(startMs + i * MS_PER_DAY);
|
|
652
|
+
cells.push({
|
|
653
|
+
ymd,
|
|
654
|
+
inMonth: ymd.year === year && ymd.month === month,
|
|
655
|
+
key: ymdKey(ymd)
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
return cells;
|
|
659
|
+
}
|
|
660
|
+
/** Resolve a record's calendar span from its date/datetime fields. Returns
|
|
661
|
+
* null when the anchor date is missing/invalid (→ the caller's "no date"
|
|
662
|
+
* tray). An end date that is missing, invalid, or earlier than the start
|
|
663
|
+
* collapses to a single-day span — never an inverted range.
|
|
664
|
+
*
|
|
665
|
+
* Times for the day (time-allocation) view come from, in priority order:
|
|
666
|
+
* 1. the clock on a `datetime` anchor/end value, else
|
|
667
|
+
* 2. `timeField` — a separate free-form time-string column (e.g. "14:00-17:00").
|
|
668
|
+
* A record with no resolvable clock has `startMin === endMin === null`. */
|
|
669
|
+
function recordSpan(item, anchorField, endField, timeField) {
|
|
670
|
+
const startRaw = item[anchorField];
|
|
671
|
+
const start = dateOf(startRaw);
|
|
672
|
+
if (!start) return null;
|
|
673
|
+
let end = start;
|
|
674
|
+
let startMin = timeOf(startRaw);
|
|
675
|
+
let endMin = null;
|
|
676
|
+
if (endField) {
|
|
677
|
+
const endRaw = item[endField];
|
|
678
|
+
const parsedEnd = dateOf(endRaw);
|
|
679
|
+
if (parsedEnd && compareYmd(parsedEnd, start) >= 0) {
|
|
680
|
+
end = parsedEnd;
|
|
681
|
+
endMin = timeOf(endRaw);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
if (timeField && startMin === null && endMin === null) {
|
|
685
|
+
const range = parseTimeRange(item[timeField]);
|
|
686
|
+
if (range) ({startMin, endMin} = range);
|
|
687
|
+
}
|
|
688
|
+
return {
|
|
689
|
+
item,
|
|
690
|
+
start,
|
|
691
|
+
end,
|
|
692
|
+
startMin,
|
|
693
|
+
endMin
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
/** Split records into those that land on the calendar (with their spans)
|
|
697
|
+
* and those with no usable anchor date (the "no date" tray). Spans are
|
|
698
|
+
* sorted by start day so same-day stacking is stable across renders. */
|
|
699
|
+
function bucketRecords(items, anchorField, endField, timeField) {
|
|
700
|
+
const spans = [];
|
|
701
|
+
const noDate = [];
|
|
702
|
+
for (const item of items) {
|
|
703
|
+
const span = recordSpan(item, anchorField, endField, timeField);
|
|
704
|
+
if (span) spans.push(span);
|
|
705
|
+
else noDate.push(item);
|
|
706
|
+
}
|
|
707
|
+
spans.sort((left, right) => compareYmd(left.start, right.start));
|
|
708
|
+
return {
|
|
709
|
+
spans,
|
|
710
|
+
noDate
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
/** Project a record's span onto a single day for the time-allocation view, or
|
|
714
|
+
* null when the span doesn't cover that day. */
|
|
715
|
+
function daySlice(span, day) {
|
|
716
|
+
if (!spanCoversDay(span, day)) return null;
|
|
717
|
+
const hasStart = span.startMin !== null;
|
|
718
|
+
const hasEnd = span.endMin !== null;
|
|
719
|
+
if (!hasStart && !hasEnd) return {
|
|
720
|
+
kind: "allDay",
|
|
721
|
+
startMin: 0,
|
|
722
|
+
endMin: MINUTES_PER_DAY,
|
|
723
|
+
bleedsBefore: false,
|
|
724
|
+
bleedsAfter: false
|
|
725
|
+
};
|
|
726
|
+
const singleDay = compareYmd(span.start, span.end) === 0;
|
|
727
|
+
const isStartDay = compareYmd(day, span.start) === 0;
|
|
728
|
+
const isEndDay = compareYmd(day, span.end) === 0;
|
|
729
|
+
if (singleDay && hasStart && !hasEnd) return {
|
|
730
|
+
kind: "line",
|
|
731
|
+
startMin: span.startMin,
|
|
732
|
+
endMin: span.startMin,
|
|
733
|
+
bleedsBefore: false,
|
|
734
|
+
bleedsAfter: false
|
|
735
|
+
};
|
|
736
|
+
const startMin = isStartDay && hasStart ? span.startMin : 0;
|
|
737
|
+
const endMin = isEndDay && hasEnd ? span.endMin : MINUTES_PER_DAY;
|
|
738
|
+
if (singleDay && endMin <= startMin) return {
|
|
739
|
+
kind: "line",
|
|
740
|
+
startMin,
|
|
741
|
+
endMin: startMin,
|
|
742
|
+
bleedsBefore: false,
|
|
743
|
+
bleedsAfter: false
|
|
744
|
+
};
|
|
745
|
+
return {
|
|
746
|
+
kind: "block",
|
|
747
|
+
startMin,
|
|
748
|
+
endMin,
|
|
749
|
+
bleedsBefore: !isStartDay,
|
|
750
|
+
bleedsAfter: !isEndDay
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
function assignLanes(blocks) {
|
|
754
|
+
const order = [...blocks.keys()].sort((left, right) => blocks[left].startMin - blocks[right].startMin || blocks[left].endMin - blocks[right].endMin);
|
|
755
|
+
const result = blocks.map(() => ({
|
|
756
|
+
lane: 0,
|
|
757
|
+
lanes: 1
|
|
758
|
+
}));
|
|
759
|
+
let cluster = [];
|
|
760
|
+
let clusterEnd = Number.NEGATIVE_INFINITY;
|
|
761
|
+
const laneEnds = [];
|
|
762
|
+
const flush = () => {
|
|
763
|
+
for (const index of cluster) result[index].lanes = laneEnds.length;
|
|
764
|
+
cluster = [];
|
|
765
|
+
laneEnds.length = 0;
|
|
766
|
+
clusterEnd = Number.NEGATIVE_INFINITY;
|
|
767
|
+
};
|
|
768
|
+
for (const index of order) {
|
|
769
|
+
const block = blocks[index];
|
|
770
|
+
if (cluster.length > 0 && block.startMin >= clusterEnd) flush();
|
|
771
|
+
let lane = laneEnds.findIndex((end) => end <= block.startMin);
|
|
772
|
+
if (lane === -1) {
|
|
773
|
+
lane = laneEnds.length;
|
|
774
|
+
laneEnds.push(block.endMin);
|
|
775
|
+
} else laneEnds[lane] = block.endMin;
|
|
776
|
+
result[index].lane = lane;
|
|
777
|
+
cluster.push(index);
|
|
778
|
+
clusterEnd = Math.max(clusterEnd, block.endMin);
|
|
779
|
+
}
|
|
780
|
+
flush();
|
|
781
|
+
return result;
|
|
782
|
+
}
|
|
783
|
+
/** Month label key inputs — returns the 1st of the month as a `Date` so the
|
|
784
|
+
* component can feed it to `Intl.DateTimeFormat(locale, …)` for a localized
|
|
785
|
+
* "April 2026" header without this module taking a locale dependency. */
|
|
786
|
+
function monthAnchorDate(year, month) {
|
|
787
|
+
return new Date(Date.UTC(year, month - 1, 1));
|
|
788
|
+
}
|
|
789
|
+
//#endregion
|
|
790
|
+
Object.defineProperty(exports, "AGENT_INGEST_KIND", {
|
|
791
|
+
enumerable: true,
|
|
792
|
+
get: function() {
|
|
793
|
+
return AGENT_INGEST_KIND;
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
Object.defineProperty(exports, "FEED_SCHEDULES", {
|
|
797
|
+
enumerable: true,
|
|
798
|
+
get: function() {
|
|
799
|
+
return FEED_SCHEDULES;
|
|
800
|
+
}
|
|
801
|
+
});
|
|
802
|
+
Object.defineProperty(exports, "INGEST_KINDS", {
|
|
803
|
+
enumerable: true,
|
|
804
|
+
get: function() {
|
|
805
|
+
return INGEST_KINDS;
|
|
806
|
+
}
|
|
807
|
+
});
|
|
808
|
+
Object.defineProperty(exports, "MINUTES_PER_DAY", {
|
|
809
|
+
enumerable: true,
|
|
810
|
+
get: function() {
|
|
811
|
+
return MINUTES_PER_DAY;
|
|
812
|
+
}
|
|
813
|
+
});
|
|
814
|
+
Object.defineProperty(exports, "SAFE_RECORD_ID_PATTERN", {
|
|
815
|
+
enumerable: true,
|
|
816
|
+
get: function() {
|
|
817
|
+
return SAFE_RECORD_ID_PATTERN;
|
|
818
|
+
}
|
|
819
|
+
});
|
|
820
|
+
Object.defineProperty(exports, "SAFE_SLUG_PATTERN", {
|
|
821
|
+
enumerable: true,
|
|
822
|
+
get: function() {
|
|
823
|
+
return SAFE_SLUG_PATTERN;
|
|
824
|
+
}
|
|
825
|
+
});
|
|
826
|
+
Object.defineProperty(exports, "assignLanes", {
|
|
827
|
+
enumerable: true,
|
|
828
|
+
get: function() {
|
|
829
|
+
return assignLanes;
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
Object.defineProperty(exports, "bucketRecords", {
|
|
833
|
+
enumerable: true,
|
|
834
|
+
get: function() {
|
|
835
|
+
return bucketRecords;
|
|
836
|
+
}
|
|
837
|
+
});
|
|
838
|
+
Object.defineProperty(exports, "buildMonthGrid", {
|
|
839
|
+
enumerable: true,
|
|
840
|
+
get: function() {
|
|
841
|
+
return buildMonthGrid;
|
|
842
|
+
}
|
|
843
|
+
});
|
|
844
|
+
Object.defineProperty(exports, "compareYmd", {
|
|
845
|
+
enumerable: true,
|
|
846
|
+
get: function() {
|
|
847
|
+
return compareYmd;
|
|
848
|
+
}
|
|
849
|
+
});
|
|
850
|
+
Object.defineProperty(exports, "dateOf", {
|
|
851
|
+
enumerable: true,
|
|
852
|
+
get: function() {
|
|
853
|
+
return dateOf;
|
|
854
|
+
}
|
|
855
|
+
});
|
|
856
|
+
Object.defineProperty(exports, "daySlice", {
|
|
857
|
+
enumerable: true,
|
|
858
|
+
get: function() {
|
|
859
|
+
return daySlice;
|
|
860
|
+
}
|
|
861
|
+
});
|
|
862
|
+
Object.defineProperty(exports, "deriveAll", {
|
|
863
|
+
enumerable: true,
|
|
864
|
+
get: function() {
|
|
865
|
+
return deriveAll;
|
|
866
|
+
}
|
|
867
|
+
});
|
|
868
|
+
Object.defineProperty(exports, "embedTargetId", {
|
|
869
|
+
enumerable: true,
|
|
870
|
+
get: function() {
|
|
871
|
+
return embedTargetId;
|
|
872
|
+
}
|
|
873
|
+
});
|
|
874
|
+
Object.defineProperty(exports, "evaluateDerived", {
|
|
875
|
+
enumerable: true,
|
|
876
|
+
get: function() {
|
|
877
|
+
return evaluateDerived;
|
|
878
|
+
}
|
|
879
|
+
});
|
|
880
|
+
Object.defineProperty(exports, "firstDateField", {
|
|
881
|
+
enumerable: true,
|
|
882
|
+
get: function() {
|
|
883
|
+
return firstDateField;
|
|
884
|
+
}
|
|
885
|
+
});
|
|
886
|
+
Object.defineProperty(exports, "isFieldDrivenEvery", {
|
|
887
|
+
enumerable: true,
|
|
888
|
+
get: function() {
|
|
889
|
+
return isFieldDrivenEvery;
|
|
890
|
+
}
|
|
891
|
+
});
|
|
892
|
+
Object.defineProperty(exports, "isSafeRecordId", {
|
|
893
|
+
enumerable: true,
|
|
894
|
+
get: function() {
|
|
895
|
+
return isSafeRecordId;
|
|
896
|
+
}
|
|
897
|
+
});
|
|
898
|
+
Object.defineProperty(exports, "isSafeSlug", {
|
|
899
|
+
enumerable: true,
|
|
900
|
+
get: function() {
|
|
901
|
+
return isSafeSlug;
|
|
902
|
+
}
|
|
903
|
+
});
|
|
904
|
+
Object.defineProperty(exports, "matchesWhere", {
|
|
905
|
+
enumerable: true,
|
|
906
|
+
get: function() {
|
|
907
|
+
return matchesWhere;
|
|
908
|
+
}
|
|
909
|
+
});
|
|
910
|
+
Object.defineProperty(exports, "monthAnchorDate", {
|
|
911
|
+
enumerable: true,
|
|
912
|
+
get: function() {
|
|
913
|
+
return monthAnchorDate;
|
|
914
|
+
}
|
|
915
|
+
});
|
|
916
|
+
Object.defineProperty(exports, "parseIsoDate", {
|
|
917
|
+
enumerable: true,
|
|
918
|
+
get: function() {
|
|
919
|
+
return parseIsoDate;
|
|
920
|
+
}
|
|
921
|
+
});
|
|
922
|
+
Object.defineProperty(exports, "parseIsoDateTime", {
|
|
923
|
+
enumerable: true,
|
|
924
|
+
get: function() {
|
|
925
|
+
return parseIsoDateTime;
|
|
926
|
+
}
|
|
927
|
+
});
|
|
928
|
+
Object.defineProperty(exports, "parseTimeRange", {
|
|
929
|
+
enumerable: true,
|
|
930
|
+
get: function() {
|
|
931
|
+
return parseTimeRange;
|
|
932
|
+
}
|
|
933
|
+
});
|
|
934
|
+
Object.defineProperty(exports, "recordSpan", {
|
|
935
|
+
enumerable: true,
|
|
936
|
+
get: function() {
|
|
937
|
+
return recordSpan;
|
|
938
|
+
}
|
|
939
|
+
});
|
|
940
|
+
Object.defineProperty(exports, "resolveIcon", {
|
|
941
|
+
enumerable: true,
|
|
942
|
+
get: function() {
|
|
943
|
+
return resolveIcon;
|
|
944
|
+
}
|
|
945
|
+
});
|
|
946
|
+
Object.defineProperty(exports, "resolveRowRefs", {
|
|
947
|
+
enumerable: true,
|
|
948
|
+
get: function() {
|
|
949
|
+
return resolveRowRefs;
|
|
950
|
+
}
|
|
951
|
+
});
|
|
952
|
+
Object.defineProperty(exports, "selectDynamicRecord", {
|
|
953
|
+
enumerable: true,
|
|
954
|
+
get: function() {
|
|
955
|
+
return selectDynamicRecord;
|
|
956
|
+
}
|
|
957
|
+
});
|
|
958
|
+
Object.defineProperty(exports, "spanCoversDay", {
|
|
959
|
+
enumerable: true,
|
|
960
|
+
get: function() {
|
|
961
|
+
return spanCoversDay;
|
|
962
|
+
}
|
|
963
|
+
});
|
|
964
|
+
Object.defineProperty(exports, "ymdKey", {
|
|
965
|
+
enumerable: true,
|
|
966
|
+
get: function() {
|
|
967
|
+
return ymdKey;
|
|
968
|
+
}
|
|
969
|
+
});
|
|
505
970
|
|
|
506
|
-
//# sourceMappingURL=
|
|
971
|
+
//# sourceMappingURL=calendarGrid-C1rfCXJ3.cjs.map
|