@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.
Files changed (51) hide show
  1. package/assets/helps/billing-clients-worklog.md +15 -12
  2. package/assets/helps/collection-skills.md +42 -19
  3. package/assets/helps/todo-collection.md +13 -7
  4. package/dist/{deriveAll-CMFXDQ_G.js → calendarGrid-C1rfCXJ3.cjs} +468 -3
  5. package/dist/calendarGrid-C1rfCXJ3.cjs.map +1 -0
  6. package/dist/{deriveAll-BQ_p5HSB.cjs → calendarGrid-kO6rGfm0.js} +289 -74
  7. package/dist/calendarGrid-kO6rGfm0.js.map +1 -0
  8. package/dist/collection/core/ids.d.ts +12 -0
  9. package/dist/collection/core/recordZ.d.ts +25 -0
  10. package/dist/collection/core/schema.d.ts +65 -418
  11. package/dist/collection/core/schemaZ.d.ts +846 -0
  12. package/dist/collection/core/templatePath.d.ts +34 -0
  13. package/dist/collection/core/where.d.ts +9 -15
  14. package/dist/collection/index.cjs +34 -295
  15. package/dist/collection/index.cjs.map +1 -1
  16. package/dist/collection/index.d.ts +1 -0
  17. package/dist/collection/index.js +5 -270
  18. package/dist/collection/index.js.map +1 -1
  19. package/dist/collection/paths.cjs +1 -1
  20. package/dist/collection/paths.cjs.map +1 -1
  21. package/dist/collection/paths.js +1 -1
  22. package/dist/collection/paths.js.map +1 -1
  23. package/dist/collection/registry/server/index.cjs +1 -1
  24. package/dist/collection/registry/server/index.js +1 -1
  25. package/dist/collection/server/discovery.d.ts +2 -244
  26. package/dist/collection/server/index.cjs +3 -1
  27. package/dist/collection/server/index.js +2 -2
  28. package/dist/collection/server/templatePath.d.ts +1 -34
  29. package/dist/collection/server/validate.d.ts +11 -7
  30. package/dist/collection-watchers/index.cjs +1 -1
  31. package/dist/collection-watchers/index.js +1 -1
  32. package/dist/feeds/index.cjs +4 -4
  33. package/dist/feeds/index.js +2 -2
  34. package/dist/feeds/ingestTypes.d.ts +15 -40
  35. package/dist/feeds/server/index.cjs +5 -5
  36. package/dist/feeds/server/index.js +3 -3
  37. package/dist/{ingestTypes-C7EheYZX.cjs → ingestTypes-BIFXlw7M.cjs} +3 -3
  38. package/dist/ingestTypes-BIFXlw7M.cjs.map +1 -0
  39. package/dist/{ingestTypes-CmJeOUJc.js → ingestTypes-KuYPX9Ea.js} +2 -2
  40. package/dist/ingestTypes-KuYPX9Ea.js.map +1 -0
  41. package/dist/{server-DKXXFTbw.js → server-Cc6XcyOA.js} +417 -187
  42. package/dist/server-Cc6XcyOA.js.map +1 -0
  43. package/dist/{server-G6GtOdaW.cjs → server-Cd5WdM8a.cjs} +440 -198
  44. package/dist/server-Cd5WdM8a.cjs.map +1 -0
  45. package/package.json +1 -1
  46. package/dist/deriveAll-BQ_p5HSB.cjs.map +0 -1
  47. package/dist/deriveAll-CMFXDQ_G.js.map +0 -1
  48. package/dist/ingestTypes-C7EheYZX.cjs.map +0 -1
  49. package/dist/ingestTypes-CmJeOUJc.js.map +0 -1
  50. package/dist/server-DKXXFTbw.js.map +0 -1
  51. 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, Write `data/clients/items/<id>.json`.
107
- List the directory first and pick a fresh slug if the file already exists — don't
108
- silently overwrite.
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**: read `data/clients/items/`, answer from those files. Don't
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**: Read merge changes Write back. Preserve fields you weren't asked
115
- to change.
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
- - List `data/clients/items/` and find the slug whose `name` matches "Acme"
183
- (case-insensitive substring is fine).
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, Write the JSON. (This skill tracks total hours per day per client —
191
- not start/end times.)
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**: read `data/worklog/items/`, answer from the files. Don't
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 (you write; host reads + renders)
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
- - Write each record to `<dataPath>/<id>.json` via the **Write** tool; the `id`
765
- field's value is the filename (no extension).
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/plugins/collection-plugin/src/server/paths.ts` — the single source of
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
- `presentCollection` re-validates the records and reports any unreadable /
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 **act on
788
- any ⚠️ it returns** (Read → fix → Write), rather than assuming every record
789
- landed.
790
- - **List the directory first** and pick a fresh id rather than silently
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"`, Write the JSON.
84
- - **List**: read `data/todos/items/`, answer from the files; point the user at
85
- `/collections/todos` rather than reciting the table.
86
- - **Mark done**: Read set `status: "done"` Write.
87
- - **Edit / Delete**: Read mutate / remove the file (preserve untouched fields).
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 file per item.** Split the array into `data/todos/items/<id>.json`,
135
- preserving each legacy `id` verbatim.
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
- export { resolveIcon as a, AGENT_INGEST_KIND as c, embedTargetId as d, isFieldDrivenEvery as f, firstDateField as i, FEED_SCHEDULES as l, resolveRowRefs as n, selectDynamicRecord as o, evaluateDerived as r, matchesWhere as s, deriveAll as t, INGEST_KINDS as u };
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=deriveAll-CMFXDQ_G.js.map
971
+ //# sourceMappingURL=calendarGrid-C1rfCXJ3.cjs.map