@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
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"calendarGrid-C1rfCXJ3.cjs","names":[],"sources":["../src/collection/core/schema.ts","../src/collection/core/ids.ts","../src/collection/core/where.ts","../src/collection/core/dynamicIcon.ts","../src/collection/core/derivedFormula.ts","../src/collection/core/deriveAll.ts","../src/collection/core/calendarGrid.ts"],"sourcesContent":["// Schema-driven collection types. A \"collection\" is a skill (under\n// .claude/skills/<slug>/) that also ships a sibling `schema.json`.\n// The host's <CollectionView> reads the schema + records and renders\n// a table/form; Claude reads SKILL.md and CRUDs the records as JSON\n// files.\n//\n// SINGLE SOURCE OF TRUTH: every type describing the schema.json contract is\n// derived (`z.infer`) from the zod definitions in `./schemaZ` — the shapes,\n// their doc comments, and the validation rules live THERE; this module only\n// re-derives the TypeScript names consumers import. The imports from\n// `./schemaZ` are type-only, so zod never reaches the browser bundle through\n// the isomorphic barrel; at runtime the dependency points the other way\n// (schemaZ imports this module's consts).\n//\n// Field specs are a DISCRIMINATED UNION on `type`: narrow with\n// `field.type === \"enum\"` (etc.) before reading a variant key like `values`,\n// `to`, `formula`, or `of`.\n\nimport type { z } from \"zod\";\nimport type {\n ActionSpecZ,\n CollectionSchemaZ,\n CustomViewZ,\n DynamicIconRuleZ,\n DynamicIconSourceZ,\n DynamicIconSpecZ,\n EveryFieldDrivenZ,\n EveryLiteralZ,\n EveryZ,\n FieldSpecZ,\n IngestZ,\n SpawnZ,\n SubFieldSpecZ,\n WhenZ,\n} from \"./schemaZ\";\n\n/** Minimal \"this collection is a feed\" descriptor carried on the schema.\n * Deliberately narrow — the canonical collection contract stays\n * independent of the host's feeds subsystem. The host's richer retrieval\n * spec (`IngestSpec` in `feeds/ingestTypes.ts`) is a subtype, so feed code\n * reads the extra fields by typing feed schemas with that subtype;\n * collection rendering only needs these three + the presence check. */\nexport interface CollectionIngest {\n kind: string;\n schedule: string;\n /** Optional time-of-day anchor for `schedule: \"daily\"` — the hour (0–23) to\n * refresh around (the host ticks hourly, so the run lands within that hour).\n * Ignored for non-daily schedules. Absent ⇒ elapsed-based daily (\"≥24 h since\n * the last run\"). NOTE: **UTC**, not local — compared via `getUTCHours()` for\n * an unambiguous, DST-free check (matching the rest of the scheduler), so\n * convert local times before writing (e.g. 07:00 JST → `atHour: 22`). */\n atHour?: number;\n /** Declarative retrievers (`rss`/`atom`/`http-json`) only — the host fetches\n * this URL on the schedule. Absent for `kind: \"agent\"`, where the agent owns\n * retrieval. */\n url?: string;\n /** `kind: \"agent\"` only: role id the scheduled hidden worker runs in. */\n role?: string;\n /** `kind: \"agent\"` only: skill-relative template path (under `templates/`)\n * whose prose tells the worker how to refresh the records. */\n template?: string;\n}\n\n/** Declarative retriever kinds a Feed's `ingest.kind` may declare. The host's\n * feeds engine dispatches on these; they live here (with the schema contract)\n * so the schema validator can enforce them. The host re-exports these from\n * `server/workspace/feeds/ingestTypes.ts`. */\nexport const INGEST_KINDS = [\"rss\", \"atom\", \"http-json\"] as const;\nexport type IngestKind = (typeof INGEST_KINDS)[number];\n\n/** The agent-performed ingest kind. Instead of a declarative fetch, the host\n * dispatches a hidden background chat (origin `system`) in `ingest.role`,\n * seeded with `ingest.template` + a summary of every record, on the\n * `ingest.schedule` cadence; the worker edits records via the collections io\n * layer. Kept separate from {@link INGEST_KINDS} (which the declarative\n * retriever registry keys on) so the schema validator can model `ingest` as a\n * discriminated union without the feeds engine gaining an \"agent\" retriever. */\nexport const AGENT_INGEST_KIND = \"agent\" as const;\nexport type AgentIngestKind = typeof AGENT_INGEST_KIND;\n\n/** Refresh cadences a Feed's `ingest.schedule` may declare. */\nexport const FEED_SCHEDULES = [\"hourly\", \"daily\", \"weekly\", \"on-demand\"] as const;\nexport type FeedSchedule = (typeof FEED_SCHEDULES)[number];\n\n// \"feed\" collections live in the non-skill `<workspace>/feeds/` registry\n// and carry an `ingest` block; they reuse the same storage + rendering\n// as skill-backed collections but are never loaded into the agent prompt.\nexport type CollectionSource = \"user\" | \"project\" | \"feed\";\n\n/** One field of a record — a discriminated union on `type`; see the variant\n * docs in `./schemaZ` (`FieldSpecZ`). */\nexport type CollectionFieldSpec = z.infer<typeof FieldSpecZ>;\n\n/** A `table` field's row sub-schema entry — the field union minus `table` /\n * `derived` / display-only types (see `SubFieldSpecZ`). */\nexport type CollectionSubFieldSpec = z.infer<typeof SubFieldSpecZ>;\n\nexport type CollectionFieldType = CollectionFieldSpec[\"type\"];\n\n/** Optional visibility predicate: the target (an action button or a\n * field) renders only when the open record's `field` (stringified) is\n * one of `in`. Generic and domain-free — the host evaluates it against\n * the record with no knowledge of what the field means. Absent ⇒\n * always shown. */\nexport type CollectionWhen = z.infer<typeof WhenZ>;\n\n/** @deprecated Name retained for back-compat; use {@link CollectionWhen}.\n * Both actions and fields share the same predicate shape. No in-repo\n * consumers, but the package is public API (MulmoTerminal). */\n// eslint-disable-next-line sonarjs/redundant-type-aliases -- deliberate deprecated back-compat export\nexport type CollectionActionWhen = CollectionWhen;\n\n/** A schema-declared, per-record action rendered as a button in the\n * read-only detail view. Pure UI/behaviour directive — never stored,\n * never validated against record data. All domain specifics (label,\n * role, template) live in the schema / skill folder, so the host\n * stays generic. See `ActionSpecZ`. */\nexport type CollectionAction = z.infer<typeof ActionSpecZ>;\n\n/** The kind of work an action kicks off. v1 ships only `\"chat\"`; the enum\n * (in `ActionSpecZ`) reserves room for a future `\"mutate\"`. */\nexport type CollectionActionKind = CollectionAction[\"kind\"];\n\n/** A custom (LLM-authored) HTML view for a collection. The host renders\n * `file` in a sandboxed iframe over the collection's records; the view\n * reaches its data only through a slug- and capability-scoped token (see\n * `server/api/auth/viewToken.ts`). Pure data — the host holds no\n * view-specific code; meaning lives in the HTML file + this registration.\n * See `CustomViewZ` for the per-key contracts. */\nexport type CollectionCustomView = z.infer<typeof CustomViewZ>;\n\n/** What a custom view's capability token is allowed to do against the\n * collection's data endpoint. `read` returns enriched records (getItems\n * semantics); `write` validates-and-stores rows (putItems semantics).\n * There is deliberately no `delete` — a view can never do more than the\n * agent's own `manageCollection` tool. */\nexport type CollectionViewCapability = NonNullable<CollectionCustomView[\"capabilities\"]>[number];\n\n/** How a `spawn` advances the source item's `triggerField` date to\n * produce the successor's. All arithmetic is done on the civil\n * (year, month, day) triple — never by adding milliseconds — so month\n * lengths and leap years are handled correctly. */\nexport type CollectionEvery = z.infer<typeof EveryLiteralZ>;\n\n/** Recurrence unit for a `spawn.every` advance. */\nexport type CollectionRecurUnit = CollectionEvery[\"unit\"];\n\n/** Field-driven recurrence: the advance interval is selected PER RECORD by\n * the value of an `enum` field (`fromField`), looked up in `map`. See\n * `EveryFieldDrivenZ`. */\nexport type CollectionEveryFieldDriven = z.infer<typeof EveryFieldDrivenZ>;\n\n/** The `every` of a `spawn`: either a single literal interval applied to\n * every record, or a per-record interval selected by an `enum` field. The\n * literal arm is what `advanceTriggerDate` consumes — the field-driven arm\n * is resolved down to one of its `map` values before the date math runs. */\nexport type CollectionSpawnEvery = z.infer<typeof EveryZ>;\n\n/** Narrowing guard: true when `every` is the field-driven arm. */\nexport function isFieldDrivenEvery(every: CollectionSpawnEvery): every is CollectionEveryFieldDriven {\n return \"fromField\" in every;\n}\n\n/** Host-driven recurrence. See `SpawnZ`. */\nexport type CollectionSpawn = z.infer<typeof SpawnZ>;\n\n/** One rule in a `dynamicIcon.rules` list: when the resolved source\n * record matches `where` (an AND of typed conditions, see `./where`),\n * the collection's effective launcher icon becomes `icon`. Evaluated top\n * to bottom — the first match wins. */\nexport type DynamicIconRule = z.infer<typeof DynamicIconRuleZ>;\n\n/** Where a {@link DynamicIconSpec}'s source record comes from: a (possibly\n * cross-collection) pool of records, optionally narrowed by `where` and\n * reduced to a single record by `from`. */\nexport type DynamicIconSource = z.infer<typeof DynamicIconSourceZ>;\n\n/** Declarative \"data state → icon\" mapping for a collection's launcher\n * shortcut icon (see `CollectionSchema.dynamicIcon`). When absent, the\n * launcher icon is the static `schema.icon`. */\nexport type DynamicIconSpec = z.infer<typeof DynamicIconSpecZ>;\n\n/** The `ingest` block as the schema validator accepts it — a discriminated\n * union on `kind` (declarative retrievers | agent worker). The feeds\n * subsystem's `IngestSpec` is the same union under its historical name. */\nexport type CollectionIngestSpec = z.infer<typeof IngestZ>;\n\n/** The whole `schema.json` contract. Key-level docs live on\n * `CollectionSchemaZ` in `./schemaZ`. */\nexport type CollectionSchema = z.infer<typeof CollectionSchemaZ>;\n\nexport interface CollectionSummary {\n slug: string;\n title: string;\n icon: string;\n source: CollectionSource;\n /** Slugs of the source collection(s) a `dynamicIcon` icon was computed\n * from — present only when `schema.dynamicIcon` is set. Lets a client\n * know which collection change-channel(s) to watch for a live icon\n * update (see `useDynamicShortcutIcons`). */\n iconSources?: string[];\n}\n\nexport interface CollectionDetail extends CollectionSummary {\n schema: CollectionSchema;\n}\n\nexport type CollectionItem = Record<string, unknown>;\n\n/** Resolve an `embed` field's target record id: the fixed `id`, or the value\n * of the sibling `idField` on this record (empty string when neither applies\n * — the caller renders that as \"no record\"). Pure + isomorphic so the server\n * projection (`derive.ts`) and the client preview (`useCollectionRendering`)\n * resolve embeds identically. Non-`embed` fields resolve to \"no record\". */\nexport function embedTargetId(field: CollectionFieldSpec, record: CollectionItem | null): string {\n if (field.type !== \"embed\") return \"\";\n if (field.id) return field.id;\n if (field.idField && record) return String(record[field.idField] ?? \"\");\n return \"\";\n}\n","// Pure slug / record-id character rules. Shared by the isomorphic schema\n// validator (`./schemaZ`) — which must stay node-free — and the server-side\n// path sanitisers (`../server/paths`), which wrap these patterns with the\n// `path.basename` round-trip CodeQL recognises as a `js/path-injection`\n// sanitiser. Both layers MUST gate on the same patterns; importing them from\n// here is what keeps them in sync.\n\n// The ONE slug pattern — `server/workspace/skills/catalog.ts` imports it\n// for its own sanitiser, so there is no second copy to keep in sync.\n// Bounded character classes, no nested quantifiers; ReDoS-safe.\n// eslint-disable-next-line security/detect-unsafe-regex -- non-overlapping character classes, no catastrophic backtracking\nexport const SAFE_SLUG_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9_-]*[a-zA-Z0-9])?$/;\n\n// Record ids are a superset of slugs: they're only ever filename stems\n// (`<id>.json`), never directory names or URL segments, so they may carry\n// dots — natural keys like a Slack ts (`1718900000.123456`), a SemVer\n// (`1.2.3`), or a decimal timestamp. The interior class adds `.` to the slug\n// set; the explicit `..` reject in `isSafeRecordId` keeps a\n// parent-dir-looking segment out while still allowing repeated `-`/`_`\n// (`a--b`, `a__b`). Start/end stay alphanumeric so leading/trailing dots\n// (hidden files, the special `.`/`..` names) and `..`-only ids are all\n// excluded.\n// eslint-disable-next-line security/detect-unsafe-regex -- non-overlapping character classes, no catastrophic backtracking\nexport const SAFE_RECORD_ID_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9])?$/;\n\n/** True when `value` is a well-formed collection slug (alphanumeric /\n * hyphen / underscore, no path separators). The pattern admits no `/`,\n * `\\`, or `.`, so a passing value is trivially also a safe basename —\n * validation callers need no `path.basename` round-trip (path-building\n * callers use `../server/paths#safeSlugName`, which adds it). */\nexport function isSafeSlug(value: string): boolean {\n return typeof value === \"string\" && SAFE_SLUG_PATTERN.test(value);\n}\n\n/** True when `value` is a well-formed record id (slug charset plus interior\n * dots), with any `..` substring rejected explicitly. Validation-only\n * counterpart of `../server/paths#safeRecordId`. */\nexport function isSafeRecordId(value: string): boolean {\n if (typeof value !== \"string\" || !SAFE_RECORD_ID_PATTERN.test(value)) return false;\n return !value.includes(\"..\");\n}\n","// Pure SQL-like `where` predicate for `dynamicIcon` (see\n// `DynamicIconSource.where` / `DynamicIconRule.where` in `./schema`).\n// An AND of typed conditions — richer than the single-field `CollectionWhen`\n// used elsewhere (fields/actions via `./actionVisible`), which stays as-is\n// for its existing callers. No fs, no host state. The condition SHAPES are\n// derived from the zod source of truth in `./schemaZ` (type-only imports —\n// this evaluator stays zod-free at runtime).\n\nimport type { z } from \"zod\";\nimport type { ValueRefZ, WhereCondZ, WhereZ } from \"./schemaZ\";\n\n/** Reads the comparison value from a field instead of a schema literal:\n * - `record` set → another record: `recordsById[record][field]` (e.g. a\n * `_config` singleton's `defaultCity`, following a per-user setting);\n * - `record` omitted → the SAME record being matched (field-to-field, e.g.\n * `spent > budget`). */\nexport type ValueRef = z.infer<typeof ValueRefZ>;\n\n/** One typed condition: `record[field] <op> value`. The comparison value is\n * either a literal `value` (a plain string for every op except `in`, which\n * takes the allowed set) or a `valueFrom` reference resolved against the\n * `recordsById` map passed to `matchesWhere`. Exactly one of the two is\n * expected — enforced by zod at the schema boundary (`./schemaZ`), not\n * here. */\nexport type WhereCond = z.infer<typeof WhereCondZ>;\n\n/** Comparison operators one `WhereCond` may apply to `record[field]`. */\nexport type WhereOp = WhereCond[\"op\"];\n\n/** A `where` clause is the AND of its conditions — every one must match. */\nexport type Where = z.infer<typeof WhereZ>;\n\n/** True when `record[field]` is absent (`undefined`/`null`) — the only case\n * where `ne` and every other op disagree on the result. */\nfunction isMissing(raw: unknown): boolean {\n return raw === undefined || raw === null;\n}\n\n/** The effective comparison value for `cond`: its literal `value`, or — for\n * a `valueFrom` reference — the target field read out of `recordsById`.\n * `undefined` means UNRESOLVED (no such record, or the field on it is\n * missing); the caller must treat that as \"never matches\", not as a\n * literal `undefined` value to compare against. */\nfunction resolveValue(cond: WhereCond, record: Record<string, unknown>, recordsById: Record<string, Record<string, unknown>>): string | string[] | undefined {\n if (!cond.valueFrom) return cond.value;\n const { record: refRecord, field } = cond.valueFrom;\n const target = refRecord === undefined ? record : recordsById[refRecord];\n const raw = target?.[field];\n return isMissing(raw) ? undefined : String(raw);\n}\n\nfunction matchesNumericOp(operator: \"gt\" | \"gte\" | \"lt\" | \"lte\", left: number, right: number): boolean {\n if (operator === \"gt\") return left > right;\n if (operator === \"gte\") return left >= right;\n if (operator === \"lt\") return left < right;\n return left <= right;\n}\n\n/** `Number(\"\")` / `Number(\" \")` are `0`, not `NaN`, so treat a blank string\n * as non-numeric explicitly — an empty field must fail a numeric compare,\n * not read as zero. */\nfunction toNumber(raw: string): number {\n return raw.trim() === \"\" ? NaN : Number(raw);\n}\n\nfunction matchesNumeric(operator: \"gt\" | \"gte\" | \"lt\" | \"lte\", raw: string, value: string | string[]): boolean {\n if (Array.isArray(value)) return false;\n const left = toNumber(raw);\n const right = toNumber(value);\n if (Number.isNaN(left) || Number.isNaN(right)) return false;\n return matchesNumericOp(operator, left, right);\n}\n\n/** True when the present string `raw` satisfies `operator` against the\n * resolved `value` (field known to exist — MISSING is handled by the\n * caller before this runs, and an UNRESOLVED `valueFrom` never reaches\n * here either). */\nfunction matchesPresent(operator: WhereOp, raw: string, value: string | string[]): boolean {\n switch (operator) {\n case \"eq\":\n return raw === String(value);\n case \"ne\":\n return raw !== String(value);\n case \"in\":\n return Array.isArray(value) && value.includes(raw);\n case \"contains\":\n return raw.includes(String(value));\n case \"gt\":\n case \"gte\":\n case \"lt\":\n case \"lte\":\n return matchesNumeric(operator, raw, value);\n default:\n return false;\n }\n}\n\n/** True when `record` satisfies one condition, given `recordsById` to\n * resolve a `valueFrom` reference. Two independent MISSING cases, checked\n * in order:\n * - `record[cond.field]` absent (`undefined`/`null`) → matches only `ne`\n * (vacuously true — \"not equal to X\" holds when there's no value at\n * all); every other op is false. Unchanged from the literal-`value`\n * behaviour, regardless of whether `valueFrom` would also resolve.\n * - the resolved comparison value is UNRESOLVED (a `valueFrom` whose\n * target record/field doesn't exist) → false for EVERY op, including\n * `ne` — a broken reference must never spuriously match. */\nfunction matchesCond(cond: WhereCond, record: Record<string, unknown>, recordsById: Record<string, Record<string, unknown>>): boolean {\n const raw = record[cond.field];\n if (isMissing(raw)) return cond.op === \"ne\";\n const value = resolveValue(cond, record, recordsById);\n if (value === undefined) return false;\n return matchesPresent(cond.op, String(raw), value);\n}\n\n/** True when `record` satisfies every condition in `where` (AND). An empty\n * `where` matches everything. `recordsById` — the source collection's\n * records keyed by primaryKey — resolves any `valueFrom` reference;\n * omitted (default `{}`) for callers with no cross-record lookups, in\n * which case every `valueFrom` condition is UNRESOLVED and so never\n * matches. */\nexport function matchesWhere(where: Where, record: Record<string, unknown>, recordsById: Record<string, Record<string, unknown>> = {}): boolean {\n return where.every((cond) => matchesCond(cond, record, recordsById));\n}\n","// Pure resolver for a collection's dynamic launcher-shortcut icon (see\n// `CollectionSchema.dynamicIcon`). Selects one \"source\" record from a\n// (possibly cross-collection, optionally `where`-filtered) records pool,\n// then maps it through a first-match-wins rules list to an icon name.\n// No fs, no host state — the server-side compute\n// (`packages/core/src/collection/server/dynamicIcon.ts`) loads the source\n// collection's records and calls these.\n\nimport { matchesWhere } from \"./where\";\nimport type { CollectionFieldSpec, CollectionItem, CollectionSchema, DynamicIconSource, DynamicIconSpec } from \"./schema\";\n\n/** The record with the greatest `String(record[field])` (localeCompare) —\n * ties keep the first-seen record (stable left-to-right `reduce`). */\nfunction latestByField(pool: CollectionItem[], field: string): CollectionItem {\n return pool.reduce((latest, candidate) => (String(candidate[field] ?? \"\").localeCompare(String(latest[field] ?? \"\")) > 0 ? candidate : latest));\n}\n\n/** Reduce `records` to the one record that decides the effective icon, per\n * `source`'s `where` filter + `from` strategy:\n * - pool = `source.where`-filtered records, or every record when unset;\n * - an empty pool resolves to `null` (no source record → fallback);\n * - `from: \"first\"` / `\"when\"` → the first pool record (storage order);\n * - `from: \"latest\"` (default), with `orderBy` given → the pool record\n * whose `String(record[orderBy])` sorts highest;\n * - `from: \"latest\"`, with no `orderBy` → the last pool record.\n * `recordsById` (the source collection's records keyed by primaryKey)\n * resolves any `valueFrom` reference inside `source.where`; omitted for\n * callers with no cross-record lookups. */\nexport function selectDynamicRecord(\n records: CollectionItem[],\n source: DynamicIconSource,\n orderBy: string | undefined,\n recordsById: Record<string, CollectionItem> = {},\n): CollectionItem | null {\n const { where } = source;\n const pool = where ? records.filter((record) => matchesWhere(where, record, recordsById)) : records;\n if (pool.length === 0) return null;\n if (source.from === \"first\" || source.from === \"when\") return pool[0];\n return orderBy ? latestByField(pool, orderBy) : pool[pool.length - 1];\n}\n\n/** Map a resolved source record to the effective icon: `spec.fallback`\n * (or the collection's own static `icon`) when there's no record or no\n * rule matches; otherwise the `icon` of the first rule whose `where`\n * matches the record. `recordsById` resolves any `valueFrom` reference\n * inside a rule's `where`, same as `selectDynamicRecord`. */\nexport function resolveIcon(\n record: CollectionItem | null,\n spec: DynamicIconSpec,\n staticIcon: string,\n recordsById: Record<string, CollectionItem> = {},\n): string {\n const fallback = spec.fallback ?? staticIcon;\n if (!record) return fallback;\n const matched = spec.rules.find((rule) => matchesWhere(rule.where, record, recordsById));\n return matched ? matched.icon : fallback;\n}\n\nconst isDateLikeField = (field: CollectionFieldSpec): boolean => field.type === \"date\" || field.type === \"datetime\";\n\n/** The first field key (declaration order) whose type is `date` or\n * `datetime` — the default `orderBy` for `from: \"latest\"` when a\n * `DynamicIconSource` doesn't name one. `undefined` when the schema has\n * no date-like field. */\nexport function firstDateField(schema: CollectionSchema): string | undefined {\n return Object.entries(schema.fields).find(([, field]) => isDateLikeField(field))?.[0];\n}\n","// Tiny expression evaluator for the `derived` field type on\n// schema-driven collections (see plans/done/feat-mc-invoice.md).\n//\n// Grammar (recursive-descent, no precedence climbing — six\n// non-terminals total):\n//\n// expr := term (('+' | '-') term)*\n// term := factor (('*' | '/') factor)*\n// factor := number | sumCall | refAccess | identifier | '(' expr ')'\n// sumCall:= 'sum' '(' sumArg ')'\n// sumArg := tableCol (('*' | '/') tableCol)* // e.g. lineItems[].quantity * lineItems[].rate\n// tableCol := identifier '[]' '.' identifier\n// refAccess := identifier '.' identifier // e.g. ticker.price — deref a ref field into its target record\n//\n// `identifier` accepts top-level field names (single segment).\n// Inside `sumArg`, identifiers are the `<table>[].col` form.\n// A two-segment `<field>.<col>` at factor level is a *ref deref*:\n// `<field>` must be a `ref`-typed field on this record (its stored\n// value is the target item's slug), and `<col>` is a numeric column\n// read from that target record. The caller resolves the target into\n// `ctx.refs` (it owns the schema + the loaded target collection);\n// the evaluator stays pure and never does I/O.\n//\n// What's deliberately NOT supported (and would parse-error rather\n// than silently misbehave):\n// - String literals, boolean operators, comparisons, conditionals\n// - Nested function calls beyond `sum(...)`\n// - Anything in the record that isn't a number / table-of-objects\n//\n// All evaluation is pure — no eval(), no Function constructor.\n// Returns `null` on any failure (parse error, unbound identifier,\n// non-finite arithmetic). The caller renders `null` as em-dash in\n// the table cell + form display.\n\nexport interface FormulaContext {\n /** The record being evaluated. For derived fields in the form,\n * this is the live draft (text + table both converted via the\n * same `draftToRecord` pipeline). For the main table cell,\n * this is the persisted item. */\n record: Record<string, unknown>;\n /** Resolved ref-target records for THIS row, keyed by the local\n * `ref` field name. The caller (which has the schema + the linked\n * collection's items loaded) maps each ref field's stored slug to\n * the full target record and passes it here, so a `<field>.<col>`\n * formula can read a numeric column off the referenced record\n * (e.g. `shares * ticker.price`). A missing key or `null` value\n * (unknown field / dangling slug) makes that deref evaluate to\n * NaN → the whole formula returns `null` → em-dash, consistent\n * with every other failure mode. Absent ⇒ no refs available. */\n refs?: Record<string, Record<string, unknown> | null>;\n}\n\nexport function evaluateDerived(formula: string, ctx: FormulaContext): number | null {\n let tokens: Token[];\n try {\n tokens = tokenize(formula);\n } catch {\n return null;\n }\n // eslint-disable-next-line @typescript-eslint/no-use-before-define -- Parser class is defined later in the file (grouped with its AST + evaluator); evaluateDerived runs after module init so the TDZ concern doesn't apply.\n const parser = new Parser(tokens);\n let ast: Node;\n try {\n ast = parser.parseExpr();\n if (!parser.atEnd()) return null; // trailing junk\n } catch {\n return null;\n }\n const value = evaluate(ast, ctx);\n return Number.isFinite(value) ? value : null;\n}\n\n// ─── Tokens ────────────────────────────────────────────────\n\ntype TokenKind = \"number\" | \"ident\" | \"(\" | \")\" | \"+\" | \"-\" | \"*\" | \"/\" | \"[]\" | \".\";\n\ninterface Token {\n kind: TokenKind;\n value?: string | number;\n}\n\nconst SINGLE_CHAR_PUNCT = new Set<TokenKind>([\"(\", \")\", \"+\", \"-\", \"*\", \"/\", \".\"]);\n\ninterface Cursor {\n input: string;\n index: number;\n}\n\nfunction consumeWhitespace(cur: Cursor): boolean {\n const char = cur.input[cur.index];\n if (char === \" \" || char === \"\\t\" || char === \"\\n\") {\n cur.index++;\n return true;\n }\n return false;\n}\n\nfunction consumeNumber(cur: Cursor): Token | null {\n const char = cur.input[cur.index] ?? \"\";\n const next = cur.input[cur.index + 1] ?? \"\";\n if (!isDigit(char) && !(char === \".\" && isDigit(next))) return null;\n let raw = \"\";\n while (cur.index < cur.input.length) {\n const here = cur.input[cur.index] ?? \"\";\n if (!isDigit(here) && here !== \".\") break;\n raw += here;\n cur.index++;\n }\n const num = Number(raw);\n if (!Number.isFinite(num)) throw new Error(\"bad number\");\n return { kind: \"number\", value: num };\n}\n\nfunction consumeIdent(cur: Cursor): Token | null {\n const char = cur.input[cur.index] ?? \"\";\n if (!isIdentStart(char)) return null;\n let raw = \"\";\n while (cur.index < cur.input.length && isIdentChar(cur.input[cur.index] ?? \"\")) {\n raw += cur.input[cur.index];\n cur.index++;\n }\n return { kind: \"ident\", value: raw };\n}\n\nfunction consumePunct(cur: Cursor): Token | null {\n const char = cur.input[cur.index] ?? \"\";\n if (char === \"[\" && cur.input[cur.index + 1] === \"]\") {\n cur.index += 2;\n return { kind: \"[]\" };\n }\n if (SINGLE_CHAR_PUNCT.has(char as TokenKind)) {\n cur.index++;\n return { kind: char as TokenKind };\n }\n return null;\n}\n\nfunction tokenize(input: string): Token[] {\n const tokens: Token[] = [];\n const cur: Cursor = { input, index: 0 };\n while (cur.index < input.length) {\n if (consumeWhitespace(cur)) continue;\n // Number FIRST so a leading-dot literal (`.25`) isn't split by\n // the `.` punctuation branch.\n const numTok = consumeNumber(cur);\n if (numTok) {\n tokens.push(numTok);\n continue;\n }\n const punctTok = consumePunct(cur);\n if (punctTok) {\n tokens.push(punctTok);\n continue;\n }\n const identTok = consumeIdent(cur);\n if (identTok) {\n tokens.push(identTok);\n continue;\n }\n throw new Error(`unexpected char ${input[cur.index]}`);\n }\n return tokens;\n}\n\nfunction isDigit(char: string): boolean {\n return char >= \"0\" && char <= \"9\";\n}\nfunction isIdentStart(char: string): boolean {\n return (char >= \"a\" && char <= \"z\") || (char >= \"A\" && char <= \"Z\") || char === \"_\";\n}\nfunction isIdentChar(char: string): boolean {\n return isIdentStart(char) || isDigit(char);\n}\n\n// ─── AST + Parser ───────────────────────────────────────────\n\ntype Node =\n | { kind: \"num\"; value: number }\n | { kind: \"ident\"; name: string }\n | { kind: \"ref\"; field: string; col: string }\n | { kind: \"binop\"; operator: \"+\" | \"-\" | \"*\" | \"/\"; left: Node; right: Node }\n | { kind: \"sum\"; arg: SumArg };\n\ninterface SumArg {\n // factors multiplied/divided together; each is a (tableName, colName) ref into a row.\n factors: { table: string; col: string }[];\n /** Operators between factors: length = factors.length - 1; each\n * is \"*\" or \"/\". For a single-factor sum (`sum(lineItems[].amount)`)\n * this is empty. */\n operators: (\"*\" | \"/\")[];\n}\n\nclass Parser {\n private cursor = 0;\n constructor(private readonly tokens: Token[]) {}\n\n atEnd(): boolean {\n return this.cursor >= this.tokens.length;\n }\n private peek(): Token | undefined {\n return this.tokens[this.cursor];\n }\n private consume(): Token {\n const tok = this.tokens[this.cursor++];\n if (!tok) throw new Error(\"unexpected end of input\");\n return tok;\n }\n private expect(kind: TokenKind): Token {\n const tok = this.consume();\n if (tok.kind !== kind) throw new Error(`expected ${kind}, got ${tok.kind}`);\n return tok;\n }\n\n parseExpr(): Node {\n let left = this.parseTerm();\n while (this.peek()?.kind === \"+\" || this.peek()?.kind === \"-\") {\n const operator = this.consume().kind as \"+\" | \"-\";\n const right = this.parseTerm();\n left = { kind: \"binop\", operator, left, right };\n }\n return left;\n }\n\n private parseTerm(): Node {\n let left = this.parseFactor();\n while (this.peek()?.kind === \"*\" || this.peek()?.kind === \"/\") {\n const operator = this.consume().kind as \"*\" | \"/\";\n const right = this.parseFactor();\n left = { kind: \"binop\", operator, left, right };\n }\n return left;\n }\n\n private parseFactor(): Node {\n const tok = this.peek();\n if (!tok) throw new Error(\"unexpected end in factor\");\n if (tok.kind === \"number\") {\n this.consume();\n return { kind: \"num\", value: tok.value as number };\n }\n if (tok.kind === \"(\") {\n this.consume();\n const inner = this.parseExpr();\n this.expect(\")\");\n return inner;\n }\n if (tok.kind === \"ident\") {\n const name = (tok.value as string) ?? \"\";\n // sum(...) — only function call we support\n if (name === \"sum\" && this.tokens[this.cursor + 1]?.kind === \"(\") {\n this.consume(); // ident\n this.expect(\"(\");\n const arg = this.parseSumArg();\n this.expect(\")\");\n return { kind: \"sum\", arg };\n }\n this.consume(); // ident\n // ref deref: `<field>.<col>` (e.g. ticker.price). The table-row\n // form `<table>[].col` only appears inside sum(), so a `.`\n // immediately after a top-level ident is unambiguously a ref\n // dereference here.\n if (this.peek()?.kind === \".\") {\n this.consume(); // '.'\n const col = this.expect(\"ident\");\n return { kind: \"ref\", field: name, col: col.value as string };\n }\n return { kind: \"ident\", name };\n }\n throw new Error(`unexpected token ${tok.kind} in factor`);\n }\n\n private parseSumArg(): SumArg {\n const factors: { table: string; col: string }[] = [];\n const operators: (\"*\" | \"/\")[] = [];\n factors.push(this.parseTableCol());\n while (this.peek()?.kind === \"*\" || this.peek()?.kind === \"/\") {\n const operator = this.consume().kind as \"*\" | \"/\";\n operators.push(operator);\n factors.push(this.parseTableCol());\n }\n return { factors, operators };\n }\n\n private parseTableCol(): { table: string; col: string } {\n const tableTok = this.expect(\"ident\");\n this.expect(\"[]\");\n this.expect(\".\");\n const colTok = this.expect(\"ident\");\n return { table: tableTok.value as string, col: colTok.value as string };\n }\n}\n\n// ─── Evaluator ──────────────────────────────────────────────\n\nfunction evaluate(node: Node, ctx: FormulaContext): number {\n if (node.kind === \"num\") return node.value;\n if (node.kind === \"ident\") {\n const raw = ctx.record[node.name];\n return toFiniteNumber(raw);\n }\n if (node.kind === \"ref\") {\n // `<field>.<col>`: read `col` off the resolved target record the\n // caller put in ctx.refs. Unknown field / dangling slug → null →\n // NaN, so the whole formula fails soft to an em-dash.\n const target = ctx.refs?.[node.field] ?? null;\n if (!target) return Number.NaN;\n return toFiniteNumber(target[node.col]);\n }\n if (node.kind === \"binop\") {\n const left = evaluate(node.left, ctx);\n const right = evaluate(node.right, ctx);\n return applyBinop(node.operator, left, right);\n }\n if (node.kind === \"sum\") {\n return evaluateSum(node.arg, ctx);\n }\n // Exhaustive — TS narrows above branches but throw keeps runtime honest.\n throw new Error(`unknown node`);\n}\n\nfunction applyBinop(operator: \"+\" | \"-\" | \"*\" | \"/\", left: number, right: number): number {\n if (!Number.isFinite(left) || !Number.isFinite(right)) return Number.NaN;\n if (operator === \"+\") return left + right;\n if (operator === \"-\") return left - right;\n if (operator === \"*\") return left * right;\n // operator === \"/\"\n if (right === 0) return Number.NaN;\n return left / right;\n}\n\nfunction evaluateSum(arg: SumArg, ctx: FormulaContext): number {\n if (arg.factors.length === 0) return 0;\n const tableName = arg.factors[0].table;\n // All factors must reference the SAME table (you can't multiply\n // a row from lineItems against a row from another table — the\n // semantics would be ambiguous). Reject mismatch.\n for (const factor of arg.factors) {\n if (factor.table !== tableName) return Number.NaN;\n }\n const rows = ctx.record[tableName];\n if (!Array.isArray(rows)) return 0;\n let total = 0;\n for (const row of rows) {\n if (!row || typeof row !== \"object\") continue;\n let product = toFiniteNumber((row as Record<string, unknown>)[arg.factors[0].col]);\n if (!Number.isFinite(product)) return Number.NaN;\n for (let i = 1; i < arg.factors.length; i++) {\n const value = toFiniteNumber((row as Record<string, unknown>)[arg.factors[i].col]);\n if (!Number.isFinite(value)) return Number.NaN;\n product = applyBinop(arg.operators[i - 1], product, value);\n }\n total += product;\n }\n return total;\n}\n\nfunction toFiniteNumber(value: unknown): number {\n if (typeof value === \"number\") return Number.isFinite(value) ? value : Number.NaN;\n if (typeof value === \"string\" && value.length > 0) {\n const num = Number(value);\n return Number.isFinite(num) ? num : Number.NaN;\n }\n return Number.NaN;\n}\n","// The derived-field saturation loop for schema-driven collections,\n// extracted from `composables/collections/useCollectionRendering.ts` so\n// the server (manageCollection getItems enrichment) and the client\n// (table cells, form display) evaluate formulas through ONE\n// implementation — if the two ever diverged, the UI and the LLM would\n// disagree on a number. Pure module: no Vue, no I/O.\n//\n// Like `actionVisible.ts`, the input types are minimal structural\n// shapes so both the client `FieldSpec`/`CollectionSchema`\n// (src/components/collectionTypes.ts) and the server\n// `CollectionFieldSpec`/`CollectionSchema`\n// (server/workspace/collections/types.ts) satisfy them as-is.\n\nimport { evaluateDerived, type FormulaContext } from \"./derivedFormula\";\n\n/** Minimal field shape the derive loop needs — accepts both the client\n * FieldSpec and the server CollectionFieldSpec. */\nexport interface DerivableFieldSpec {\n type: string;\n /** When type === \"ref\": slug of the target collection. */\n to?: string;\n /** When type === \"derived\": formula evaluated against the record. */\n formula?: string;\n}\n\n/** Minimal schema shape: just the ordered field map. */\nexport interface DerivableSchema {\n fields: Record<string, DerivableFieldSpec>;\n}\n\nexport type DerivableRecord = Record<string, unknown>;\n\n/** Per-target-collection cache of loaded referenced records:\n * target collection slug → item slug → full record. Mirrors the\n * client's `RefRecordCache` / the server's enrichment loader. */\nexport type DeriveRefRecords = Record<string, Record<string, DerivableRecord>>;\n\n/** Map each `ref` field's stored slug to its loaded target record (or\n * null when dangling / not loaded), keyed by the LOCAL field name —\n * the shape `evaluateDerived` reads for `<field>.<col>` derefs. */\nexport function resolveRowRefs(schema: DerivableSchema, record: DerivableRecord, refRecords: DeriveRefRecords): NonNullable<FormulaContext[\"refs\"]> {\n const refs: NonNullable<FormulaContext[\"refs\"]> = {};\n for (const [key, field] of Object.entries(schema.fields)) {\n if (field.type !== \"ref\" || !field.to) continue;\n const slug = record[key];\n refs[key] = typeof slug === \"string\" ? (refRecords[field.to]?.[slug] ?? null) : null;\n }\n return refs;\n}\n\n/** Evaluate every `derived` field against `base`, saturating so a\n * derived field can read another derived field computed in an earlier\n * pass (`subtotal → tax → total` converges in ≤ field-count passes).\n * Cycles can't loop forever — passes are bounded by the number of\n * derived fields and the loop breaks as soon as a pass changes\n * nothing. Failed formulas stay ABSENT (the UI renders them as\n * em-dash). Returns a copy; `base` is never mutated.\n *\n * Derived keys already present in `base` are stripped before\n * evaluation: computed output is host-truth, never persisted-input\n * fallback. A record JSON can carry a stale (or forged) derived value\n * — raw Write/Edit, legacy data — and without the strip, a failing\n * formula would silently surface that value as if the host computed\n * it. */\nexport function deriveAll(schema: DerivableSchema, base: DerivableRecord, refRecords: DeriveRefRecords): DerivableRecord {\n const derivedKeys = new Set(Object.keys(schema.fields).filter((key) => schema.fields[key]?.type === \"derived\"));\n const enriched: DerivableRecord = Object.fromEntries(Object.entries(base).filter(([key]) => !derivedKeys.has(key)));\n const refs = resolveRowRefs(schema, base, refRecords);\n const maxPasses = Object.values(schema.fields).filter((field) => field.type === \"derived\").length;\n for (let pass = 0; pass < maxPasses; pass++) {\n let mutated = false;\n for (const [key, field] of Object.entries(schema.fields)) {\n if (field.type !== \"derived\" || !field.formula) continue;\n const next = evaluateDerived(field.formula, { record: enriched, refs });\n if (next !== null && enriched[key] !== next) {\n enriched[key] = next;\n mutated = true;\n }\n }\n if (!mutated) break;\n }\n return enriched;\n}\n","// Pure, deterministic helpers for the collection calendar view: parse\n// `date`-field values, build a month grid, and bucket records onto the\n// days they cover. No `Date.now()` / `new Date()` (argless) here — every\n// function takes its inputs explicitly so the logic is unit-testable\n// without faking the clock. All internal arithmetic runs in UTC (which\n// has no DST), so fixed 86_400_000 ms steps never skip or double a day.\n\nconst MS_PER_DAY = 86_400_000;\nconst ISO_DATE_RE = /^(\\d{4})-(\\d{2})-(\\d{2})$/;\n// A two-digit field (hours / minutes / seconds) of a clock value.\nconst TWO_DIGIT_RE = /^\\d{2}$/;\n// A single clock token inside a free-form `time` string field (e.g. the\n// \"14:00-17:00\" / \"17:00-\" / \"16:30\" / \"終日\" shapes seen in user data).\nconst CLOCK_RE = /(\\d{1,2}):(\\d{2})/g;\n// Range separators we tolerate between two clock tokens: ASCII hyphen, en/em\n// dash, tilde, and the Japanese wave dashes.\nconst RANGE_SEP_RE = /[-–—~〜~]/;\n\n/** Minutes in a full day — the timeline's vertical extent. */\nexport const MINUTES_PER_DAY = 1440;\n\n/** A civil date triple. `month` is 1-12 (NOT the 0-based `Date` month). */\nexport interface Ymd {\n year: number;\n month: number;\n day: number;\n}\n\n/** One cell of the 6×7 month grid. */\nexport interface DayCell {\n ymd: Ymd;\n /** False for the leading/trailing days that belong to the adjacent\n * month (rendered greyed). */\n inMonth: boolean;\n /** Canonical `YYYY-MM-DD` key for this cell. */\n key: string;\n}\n\n/** A record placed on the calendar: the inclusive `[start, end]` span of\n * days it covers. `end === start` for a single-day record. `startMin` /\n * `endMin` are minutes-of-day for the time-allocation (day) view, resolved\n * from either a `datetime` field's clock or a separate time-string field.\n * `null` means \"no clock\" — `startMin === null && endMin === null` is an\n * all-day record; a non-null `startMin` with a null `endMin` is a\n * point-in-time record (rendered as a single line). */\nexport interface RecordSpan<T> {\n item: T;\n start: Ymd;\n end: Ymd;\n startMin: number | null;\n endMin: number | null;\n}\n\nfunction pad2(value: number): string {\n return String(value).padStart(2, \"0\");\n}\n\n/** Canonical `YYYY-MM-DD` string for a civil date. */\nexport function ymdKey(ymd: Ymd): string {\n return `${String(ymd.year).padStart(4, \"0\")}-${pad2(ymd.month)}-${pad2(ymd.day)}`;\n}\n\n/** Strictly parse a `YYYY-MM-DD` string into a civil date, rejecting\n * anything that isn't a real calendar day (e.g. `2026-02-30`, `2026-13-01`).\n * Returns null for non-strings and malformed values so callers can route\n * records with no usable date into the \"no date\" tray rather than crash. */\nexport function parseIsoDate(value: unknown): Ymd | null {\n if (typeof value !== \"string\") return null;\n const match = ISO_DATE_RE.exec(value.trim());\n if (!match) return null;\n const year = Number(match[1]);\n const month = Number(match[2]);\n const day = Number(match[3]);\n // Round-trip through a UTC Date to reject impossible days: a value the\n // Date constructor rolls over (Feb 30 → Mar 2) won't match back.\n const probe = new Date(Date.UTC(year, month - 1, day));\n if (probe.getUTCFullYear() !== year || probe.getUTCMonth() !== month - 1 || probe.getUTCDate() !== day) return null;\n return { year, month, day };\n}\n\n/** Minutes-of-day for an `HH:MM` pair, or null when out of range. */\nfunction clockToMinutes(hours: number, minutes: number): number | null {\n if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null;\n return hours * 60 + minutes;\n}\n\n/** Strictly parse a `YYYY-MM-DDTHH:MM` (optional `:SS`) datetime into its\n * civil date and minutes-of-day. Returns null for anything that isn't a real\n * calendar day or a valid 24h clock. */\nexport function parseIsoDateTime(value: unknown): { ymd: Ymd; minutes: number } | null {\n if (typeof value !== \"string\") return null;\n const trimmed = value.trim();\n const tIndex = trimmed.indexOf(\"T\");\n if (tIndex === -1) return null;\n const ymd = parseIsoDate(trimmed.slice(0, tIndex));\n if (!ymd) return null;\n // `HH:MM` with an optional `:SS` the browser appends for non-zero seconds.\n const parts = trimmed.slice(tIndex + 1).split(\":\");\n if (parts.length < 2 || parts.length > 3 || !parts.every((part) => TWO_DIGIT_RE.test(part))) return null;\n const minutes = clockToMinutes(Number(parts[0]), Number(parts[1]));\n if (minutes === null) return null;\n return { ymd, minutes };\n}\n\n/** Civil date from either a `YYYY-MM-DD` or a `YYYY-MM-DDTHH:MM` value, so the\n * month grid buckets date-only and datetime anchors alike. */\nexport function dateOf(value: unknown): Ymd | null {\n return parseIsoDate(value) ?? parseIsoDateTime(value)?.ymd ?? null;\n}\n\n/** Minutes-of-day from a datetime value, or null for date-only / invalid. */\nfunction timeOf(value: unknown): number | null {\n return parseIsoDateTime(value)?.minutes ?? null;\n}\n\n/** Parse a free-form time-string field into start/end minutes-of-day.\n * Handles the common shapes in user data:\n * \"14:00-17:00\" → { start: 840, end: 1020 } (range → block)\n * \"17:00-\" → { start: 1020, end: null } (open end → single line)\n * \"16:30\" → { start: 990, end: null } (point in time → single line)\n * \"終日\" / \"\" → null (no clock → all-day)\n * Returns null when no clock token is parseable. */\nexport function parseTimeRange(value: unknown): { startMin: number | null; endMin: number | null } | null {\n if (typeof value !== \"string\") return null;\n const text = value.trim();\n if (!text) return null;\n const tokens = [...text.matchAll(CLOCK_RE)];\n if (tokens.length === 0) return null;\n const minutesOf = (match: RegExpMatchArray): number | null => clockToMinutes(Number(match[1]), Number(match[2]));\n // No separator → a single point in time (start only).\n if (!RANGE_SEP_RE.test(text)) {\n const startMin = minutesOf(tokens[0]);\n return startMin === null ? null : { startMin, endMin: null };\n }\n // Separator present → assign each token to the side of the first separator.\n const sepIndex = text.search(RANGE_SEP_RE);\n let startMin: number | null = null;\n let endMin: number | null = null;\n for (const token of tokens) {\n if ((token.index ?? 0) < sepIndex) startMin = minutesOf(token);\n else endMin = minutesOf(token);\n }\n // A start-less range (\"-09:00\") has no anchor on the timeline → treat as\n // unparseable so the record falls back to the all-day strip.\n if (startMin === null) return null;\n return { startMin, endMin };\n}\n\nfunction ymdToUtcMs(ymd: Ymd): number {\n return Date.UTC(ymd.year, ymd.month - 1, ymd.day);\n}\n\nfunction utcMsToYmd(epochMs: number): Ymd {\n const date = new Date(epochMs);\n return { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1, day: date.getUTCDate() };\n}\n\n/** Chronological comparison: negative if `left` precedes `right`, 0 if the\n * same day, positive if after. */\nexport function compareYmd(left: Ymd, right: Ymd): number {\n return ymdToUtcMs(left) - ymdToUtcMs(right);\n}\n\n/** True iff `day` falls within the inclusive span `[span.start, span.end]`. */\nexport function spanCoversDay<T>(span: RecordSpan<T>, day: Ymd): boolean {\n return compareYmd(span.start, day) <= 0 && compareYmd(day, span.end) <= 0;\n}\n\n/** Build the 6×7 (42-cell) grid for the given month, including the\n * leading/trailing days of the adjacent months so every week is full.\n * `month` is 1-12. `weekStartsOn` is 0 (Sunday) … 6 (Saturday). */\nexport function buildMonthGrid(year: number, month: number, weekStartsOn = 0): DayCell[] {\n const firstWeekday = new Date(Date.UTC(year, month - 1, 1)).getUTCDay();\n const lead = (firstWeekday - weekStartsOn + 7) % 7;\n const startMs = Date.UTC(year, month - 1, 1) - lead * MS_PER_DAY;\n const cells: DayCell[] = [];\n for (let i = 0; i < 42; i++) {\n const ymd = utcMsToYmd(startMs + i * MS_PER_DAY);\n cells.push({ ymd, inMonth: ymd.year === year && ymd.month === month, key: ymdKey(ymd) });\n }\n return cells;\n}\n\n/** Resolve a record's calendar span from its date/datetime fields. Returns\n * null when the anchor date is missing/invalid (→ the caller's \"no date\"\n * tray). An end date that is missing, invalid, or earlier than the start\n * collapses to a single-day span — never an inverted range.\n *\n * Times for the day (time-allocation) view come from, in priority order:\n * 1. the clock on a `datetime` anchor/end value, else\n * 2. `timeField` — a separate free-form time-string column (e.g. \"14:00-17:00\").\n * A record with no resolvable clock has `startMin === endMin === null`. */\nexport function recordSpan<T extends Record<string, unknown>>(item: T, anchorField: string, endField?: string, timeField?: string): RecordSpan<T> | null {\n const startRaw = item[anchorField];\n const start = dateOf(startRaw);\n if (!start) return null;\n let end = start;\n let startMin = timeOf(startRaw);\n let endMin: number | null = null;\n if (endField) {\n const endRaw = item[endField];\n const parsedEnd = dateOf(endRaw);\n if (parsedEnd && compareYmd(parsedEnd, start) >= 0) {\n end = parsedEnd;\n endMin = timeOf(endRaw);\n }\n }\n // Fall back to a separate time-string field only when the date fields\n // carried no clock (the date-only anchor + `time` column shape).\n if (timeField && startMin === null && endMin === null) {\n const range = parseTimeRange(item[timeField]);\n if (range) {\n ({ startMin, endMin } = range);\n }\n }\n return { item, start, end, startMin, endMin };\n}\n\n/** Split records into those that land on the calendar (with their spans)\n * and those with no usable anchor date (the \"no date\" tray). Spans are\n * sorted by start day so same-day stacking is stable across renders. */\nexport function bucketRecords<T extends Record<string, unknown>>(\n items: readonly T[],\n anchorField: string,\n endField?: string,\n timeField?: string,\n): { spans: RecordSpan<T>[]; noDate: T[] } {\n const spans: RecordSpan<T>[] = [];\n const noDate: T[] = [];\n for (const item of items) {\n const span = recordSpan(item, anchorField, endField, timeField);\n if (span) spans.push(span);\n else noDate.push(item);\n }\n spans.sort((left, right) => compareYmd(left.start, right.start));\n return { spans, noDate };\n}\n\n/** Geometry for one record on one day of the time-allocation view.\n * `kind`:\n * \"allDay\" — no clock anywhere → render in the bottom all-day strip.\n * \"line\" — a single point in time → a 1px marker at `startMin`.\n * \"block\" — a [startMin, endMin) span, clamped to this day's [0, 1440).\n * `bleedsBefore` / `bleedsAfter` flag a multi-day span that began on an\n * earlier day or continues onto a later one (so the view can show arrows). */\nexport interface DaySlice {\n kind: \"allDay\" | \"line\" | \"block\";\n startMin: number;\n endMin: number;\n bleedsBefore: boolean;\n bleedsAfter: boolean;\n}\n\n/** Project a record's span onto a single day for the time-allocation view, or\n * null when the span doesn't cover that day. */\nexport function daySlice<T>(span: RecordSpan<T>, day: Ymd): DaySlice | null {\n if (!spanCoversDay(span, day)) return null;\n const hasStart = span.startMin !== null;\n const hasEnd = span.endMin !== null;\n if (!hasStart && !hasEnd) {\n return { kind: \"allDay\", startMin: 0, endMin: MINUTES_PER_DAY, bleedsBefore: false, bleedsAfter: false };\n }\n const singleDay = compareYmd(span.start, span.end) === 0;\n const isStartDay = compareYmd(day, span.start) === 0;\n const isEndDay = compareYmd(day, span.end) === 0;\n // A point in time: a start clock with no end, all on one day.\n if (singleDay && hasStart && !hasEnd) {\n return { kind: \"line\", startMin: span.startMin as number, endMin: span.startMin as number, bleedsBefore: false, bleedsAfter: false };\n }\n const startMin = isStartDay && hasStart ? (span.startMin as number) : 0;\n const endMin = isEndDay && hasEnd ? (span.endMin as number) : MINUTES_PER_DAY;\n // Zero-length or inverted same-day range → degrade to a line.\n if (singleDay && endMin <= startMin) {\n return { kind: \"line\", startMin, endMin: startMin, bleedsBefore: false, bleedsAfter: false };\n }\n return { kind: \"block\", startMin, endMin, bleedsBefore: !isStartDay, bleedsAfter: !isEndDay };\n}\n\n/** Side-by-side lane assignment for overlapping timeline blocks. Each input\n * is an `[startMin, endMin)` interval; the result (parallel to the input)\n * gives each item its `lane` (column index) and the `lanes` total of its\n * overlap cluster, so a renderer can size every block to `1 / lanes` width\n * and offset it by `lane / lanes`. Non-overlapping items get `lanes === 1`. */\nexport interface LaneSpan {\n startMin: number;\n endMin: number;\n}\nexport interface LaneAssignment {\n lane: number;\n lanes: number;\n}\n\nexport function assignLanes(blocks: readonly LaneSpan[]): LaneAssignment[] {\n const order = [...blocks.keys()].sort((left, right) => blocks[left].startMin - blocks[right].startMin || blocks[left].endMin - blocks[right].endMin);\n const result: LaneAssignment[] = blocks.map(() => ({ lane: 0, lanes: 1 }));\n let cluster: number[] = [];\n let clusterEnd = Number.NEGATIVE_INFINITY;\n const laneEnds: number[] = [];\n const flush = (): void => {\n for (const index of cluster) result[index].lanes = laneEnds.length;\n cluster = [];\n laneEnds.length = 0;\n clusterEnd = Number.NEGATIVE_INFINITY;\n };\n for (const index of order) {\n const block = blocks[index];\n if (cluster.length > 0 && block.startMin >= clusterEnd) flush();\n let lane = laneEnds.findIndex((end) => end <= block.startMin);\n if (lane === -1) {\n lane = laneEnds.length;\n laneEnds.push(block.endMin);\n } else {\n laneEnds[lane] = block.endMin;\n }\n result[index].lane = lane;\n cluster.push(index);\n clusterEnd = Math.max(clusterEnd, block.endMin);\n }\n flush();\n return result;\n}\n\n/** Month label key inputs — returns the 1st of the month as a `Date` so the\n * component can feed it to `Intl.DateTimeFormat(locale, …)` for a localized\n * \"April 2026\" header without this module taking a locale dependency. */\nexport function monthAnchorDate(year: number, month: number): Date {\n return new Date(Date.UTC(year, month - 1, 1));\n}\n"],"mappings":"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"}
|
|
@@ -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,77 +521,272 @@ function deriveAll(schema, base, refRecords) {
|
|
|
501
521
|
return enriched;
|
|
502
522
|
}
|
|
503
523
|
//#endregion
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
})
|
|
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
|
+
export { isFieldDrivenEvery as A, SAFE_SLUG_PATTERN as C, FEED_SCHEDULES as D, AGENT_INGEST_KIND as E, INGEST_KINDS as O, SAFE_RECORD_ID_PATTERN as S, isSafeSlug as T, evaluateDerived as _, compareYmd as a, selectDynamicRecord as b, monthAnchorDate as c, parseTimeRange as d, recordSpan as f, resolveRowRefs as g, deriveAll as h, buildMonthGrid as i, embedTargetId as k, parseIsoDate as l, ymdKey as m, assignLanes as n, dateOf as o, spanCoversDay as p, bucketRecords as r, daySlice as s, MINUTES_PER_DAY as t, parseIsoDateTime as u, firstDateField as v, isSafeRecordId as w, matchesWhere as x, resolveIcon as y };
|
|
576
791
|
|
|
577
|
-
//# sourceMappingURL=
|
|
792
|
+
//# sourceMappingURL=calendarGrid-kO6rGfm0.js.map
|