diff --git a/src/data/assignment.seed.ts b/src/data/assignment.seed.ts new file mode 100644 index 0000000..5812f81 --- /dev/null +++ b/src/data/assignment.seed.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineSeed } from '@objectstack/spec/data'; + +import { Assignment } from '../objects/assignment.object.js'; + +import { ASSIGNMENTS } from './demo-assignments.js'; + +/** + * Two assignments: one fanned out to four people with mixed completion, one + * with `needs_collection` ticked. + * + * `task_count` is NOT seeded and must not be — it is an ADR-0021 summary the + * platform computes over the children on read. Writing it would be a second + * writer for a number the server owns, and it would be wrong the moment + * anybody closed a task. + * + * `assignees` is `multiple: true`, so it is seeded as an ARRAY of natural + * keys, one per element — a lone string is accepted as one-element shorthand, + * which is not what these need. + */ +export const assignmentSeed = defineSeed(Assignment, { + externalId: 'subject', + mode: 'upsert', + records: ASSIGNMENTS.map((assignment) => ({ + subject: assignment.subject, + description: assignment.description, + assigner: assignment.assigner, + assignees: [...assignment.assignees], + due_date: assignment.dueDate, + // `dispatched`, not `draft`: the tasks exist, so the assignment that owns + // them has to say it went out. A `draft` assignment with four children + // would be a state the flow can never produce. + status: 'dispatched', + needs_collection: assignment.needsCollection, + })), +}); diff --git a/src/data/catalog.seed.ts b/src/data/catalog.seed.ts new file mode 100644 index 0000000..2f98b4e --- /dev/null +++ b/src/data/catalog.seed.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineSeed } from '@objectstack/spec/data'; + +import { CatalogItem } from '../objects/catalog-item.object.js'; + +import { CATALOG_ITEMS, cadenceOf } from './demo-catalog.js'; + +/** + * The role catalog — twenty duty templates across three position codes. + * + * This is the screen that decides whether an evaluator believes the product: + * "these are the 26 things a plant compliance officer owes" is the artefact + * customers already have, usually as a spreadsheet, and seeing it rendered as + * a first-class object is the moment the app stops looking like a to-do list. + * `regulation_ref` is what does that work — a catalog without it reads as a + * checklist, and with it as an audit answer. + * + * ── Cadence is filtered by form, not by hand ───────────────────────────── + * `cadenceOf` decides which of the five cadence fields a row may carry (#61). + * A standing item carrying a frequency is not merely odd — `standing_no_frequency` + * REFUSES it, and the refusal takes the item, every duty instantiated from it + * and every task under those duties. The two standing items below therefore + * carry no frequency, no anchor, no offset, no lead and no grace; the + * conditional `defaultValue` expressions resolve all five to null. + */ +export const catalogSeed = defineSeed(CatalogItem, { + externalId: 'name', + // Idempotent on re-run: matched by name, updated in place, and skipped + // outright when nothing about the item has changed. + mode: 'upsert', + records: CATALOG_ITEMS.map((item) => ({ + name: item.name, + position_code: item.position, + form: item.form, + description: item.description, + regulation_ref: item.reference, + active: item.active ?? true, + ...cadenceOf(item), + })), +}); diff --git a/src/data/demo-assignments.ts b/src/data/demo-assignments.ts new file mode 100644 index 0000000..4a0c772 --- /dev/null +++ b/src/data/demo-assignments.ts @@ -0,0 +1,215 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { visibleFromFor } from '../functions/period.js'; + +import { ADMIN } from './demo-org.js'; +import { NOW, TODAY } from './demo-history.js'; + +/** + * The two assignments, and the tasks their fan-out would have produced. + * + * ⚠️ **The fan-out tasks are seeded directly, and that is not a shortcut.** + * `assignment.flow.ts` is a `record_change` flow, and the seed loader writes + * with `SEED_OPTIONS = { isSystem: true, skipTriggers: true, seedReplay: true }`. + * `skipTriggers` suppresses record-change AUTOMATION — that is its whole job — + * so a seeded assignment never fans out, and it never will, however the + * trigger plugins are wired. (#72 has since bound `record_change`, so the flow + * does fire for an assignment created by hand in the UI. That does not change + * anything here: it is the SEED path that is exempt.) Seeding an assignment + * and waiting for its children would leave the Assignments screen showing two + * rows with `task_count: 0` and nothing to open, which is exactly the "renders + * an empty screen" failure this card exists to prevent. + * + * So the rows below are written to be **byte-identical to what + * `assignment.flow.ts` would have created**, field for field: `subject` copied + * from the assignment, `owner` the assignee, `business_unit` denormalised from + * the owner, `assignment` the parent, `source: 'assigned'`, `visible_from` + * equal to `due_date` (an assignment has no lead time to spread), `status: + * 'open'` at creation — and NO `period_key`, because an assignment has no + * period and the dispatch identity index does not apply to it. If one of these + * assignments is ever re-saved by hand and the flow does fire, its own + * idempotency guard (it looks for an existing task on `(assignment, owner)` + * before creating one) sees these rows and creates nothing, so the seed and + * the flow do not fight. + * + * The statuses below are then moved on from `open` by hand, because "mixed + * completion" is the thing an assignment is worth looking at for. + */ + +/** `TODAY` shifted forward by `days`, through the period engine's own civil-date shift. */ +const inDays = (days: number): string => visibleFromFor(TODAY, -days); + +const DAY = 24 * 60 * 60 * 1000; +const HOUR = 60 * 60 * 1000; +const daysAgo = (days: number): string => new Date(NOW.getTime() - days * DAY + 3 * HOUR).toISOString(); + +export interface DemoAssignment { + subject: string; + description: string; + assigner: string; + assignees: readonly string[]; + dueDate: string; + needsCollection: boolean; +} + +export const ASSIGNMENTS: readonly DemoAssignment[] = [ + { + subject: 'Winter shutdown readiness check', + description: + 'Before the shutdown window opens, confirm your area is ready: isolations listed, spares on site, contractors booked. One line per point — no report.', + // Assigned BY the account an evaluator is logged in as, so "Sent by me" is + // not an empty screen on first boot. + assigner: ADMIN, + assignees: ['Marek Dvorak', 'Sami Okonkwo', 'Yuki Tanabe', 'Rosa Delgado'], + dueDate: inDays(21), + // The assigner gets NO task of their own. That is the product rule: a + // manager who hands out work does not inherit a to-do list from it. + needsCollection: false, + }, + { + subject: 'Q3 supplier certificate sweep', + description: + 'Pull the current certificate for every approved supplier you buy from and flag any that expired during the quarter.', + assigner: 'Priya Raman', + assignees: ['Rosa Delgado', 'Ibrahim Chaudhry'], + dueDate: inDays(10), + // The other half of the rule: ticking this — and only ticking this — is + // what gives the assigner a follow-up task once everyone is in. + needsCollection: true, + }, +]; + +export interface DemoAdHocTask { + subject: string; + owner: string; + /** `duly_assignment.subject`, resolved as a natural key. Null for a plain one-off. */ + assignment: string | null; + /** `duly_duty.name`. Null for a task that came out of an assignment. */ + duty: string | null; + source: 'catalog' | 'assigned' | 'self'; + status: 'open' | 'in_progress' | 'done'; + dueDate: string; + visibleFrom: string; + completedAt?: string; + lastUpdateAt: string; + note?: string; +} + +const readiness = ASSIGNMENTS[0]!; +const sweep = ASSIGNMENTS[1]!; + +/** + * The seven tasks the two fan-outs own, plus the one-off duty's single task. + * + * Mixed completion on the first assignment is the whole demonstration: four + * independent rows, four owners, four different states, and NOBODY maintaining + * a "2 of 4 done" field — `duly_assignment.task_count` is an ADR-0021 summary + * the platform computes on read. + */ +export const AD_HOC_TASKS: readonly DemoAdHocTask[] = [ + // ── Winter shutdown readiness check — four people, mixed ─────────────── + { + subject: readiness.subject, + owner: 'Marek Dvorak', + assignment: readiness.subject, + duty: null, + source: 'assigned', + status: 'done', + dueDate: readiness.dueDate, + visibleFrom: readiness.dueDate, + completedAt: daysAgo(4), + lastUpdateAt: daysAgo(4), + note: 'Isolations listed and countersigned. Spares are on site bar the two long-lead seals.', + }, + { + subject: readiness.subject, + owner: 'Sami Okonkwo', + assignment: readiness.subject, + duty: null, + source: 'assigned', + status: 'done', + dueDate: readiness.dueDate, + visibleFrom: readiness.dueDate, + completedAt: daysAgo(2), + lastUpdateAt: daysAgo(2), + }, + { + subject: readiness.subject, + owner: 'Yuki Tanabe', + assignment: readiness.subject, + duty: null, + source: 'assigned', + status: 'in_progress', + dueDate: readiness.dueDate, + visibleFrom: readiness.dueDate, + lastUpdateAt: daysAgo(1), + note: 'Contractor slot still to be confirmed for the Line C isolation.', + }, + { + subject: readiness.subject, + owner: 'Rosa Delgado', + assignment: readiness.subject, + duty: null, + source: 'assigned', + status: 'open', + dueDate: readiness.dueDate, + visibleFrom: readiness.dueDate, + lastUpdateAt: daysAgo(6), + }, + + // ── Q3 supplier certificate sweep — two people, plus the assigner ────── + { + subject: sweep.subject, + owner: 'Rosa Delgado', + assignment: sweep.subject, + duty: null, + source: 'assigned', + status: 'in_progress', + dueDate: sweep.dueDate, + visibleFrom: sweep.dueDate, + lastUpdateAt: daysAgo(3), + }, + { + subject: sweep.subject, + owner: 'Ibrahim Chaudhry', + assignment: sweep.subject, + duty: null, + source: 'assigned', + status: 'open', + dueDate: sweep.dueDate, + visibleFrom: sweep.dueDate, + lastUpdateAt: daysAgo(5), + }, + { + // The follow-up the assigner asked for by ticking `needs_collection`. + // Same shape as an assignee's: one owner, one row, nothing shared. + subject: sweep.subject, + owner: sweep.assigner, + assignment: sweep.subject, + duty: null, + source: 'assigned', + status: 'open', + dueDate: sweep.dueDate, + visibleFrom: sweep.dueDate, + lastUpdateAt: daysAgo(5), + }, + + // ── The one-off duty's single task ───────────────────────────────────── + { + // `subject` is copied from the duty at dispatch, exactly as + // `dispatch.plan.ts` does it, so renaming the duty never rewrites history. + subject: 'Commissioning file handover — Riverside upgrade', + owner: 'Owen Pryce', + assignment: null, + duty: 'Commissioning file handover — Riverside upgrade', + source: 'catalog', + status: 'in_progress', + // A one-off carries a due date set directly rather than derived from a + // period anchor — which is why #61 takes `due_anchor` / `due_offset_days` / + // `lead_days` off the one-off form entirely. It has no `period_key` either. + dueDate: inDays(12), + visibleFrom: inDays(-5), + lastUpdateAt: daysAgo(2), + note: 'As-builts and test records in; waiting on the spares list from the supplier.', + }, +]; diff --git a/src/data/demo-catalog.ts b/src/data/demo-catalog.ts new file mode 100644 index 0000000..4de18fc --- /dev/null +++ b/src/data/demo-catalog.ts @@ -0,0 +1,467 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Frequency } from '../functions/period.js'; + +import { ADMIN, POSITIONS } from './demo-org.js'; + +/** + * The role catalog, and the duties instantiated from it. + * + * ── Cadence is authored ONCE, on the catalog item ───────────────────────── + * A duty copies its item's cadence verbatim — that is what + * `duly_catalog_apply` does at run time, so the fixture does the same rather + * than restating five numbers per duty. {@link DemoDuty} therefore carries no + * cadence of its own: `duty.seed.ts` reads it off {@link CATALOG_ITEMS}. + * + * ── Which cadence fields a form may carry is now ENFORCED (#61) ─────────── + * `standing_no_frequency`, `non_recurring_no_due_timing` and + * `standing_no_grace_days` refuse the meaningless combinations at insert, on + * both `duly_catalog_item` and `duly_duty`. A standing row carrying a + * frequency does not read oddly — it is **rejected**, and with it every duty + * and task downstream. So: + * + * standing → no frequency, no dueAnchor, no dueOffsetDays, no leadDays, + * no graceDays. All five omitted; the conditional defaults + * resolve them to null. + * one_off → no dueAnchor, no dueOffsetDays, no leadDays. Keeps + * graceDays — its task has a real due date to be late against. + * recurring → all five. + * + * {@link cadenceOf} is the single place that decides this, so a new item + * cannot get it wrong by omission. + */ + +export type Form = 'recurring' | 'one_off' | 'standing'; + +export interface DemoCatalogItem { + name: string; + position: string; + form: Form; + description: string; + /** The clause this duty discharges. Invented internal policy — never a real regulation. */ + reference?: string; + active?: boolean; + // Cadence — present only on the forms allowed to carry each field. + frequency?: Frequency; + dueAnchor?: 'period_start' | 'period_end'; + dueOffsetDays?: number; + leadDays?: number; + graceDays?: number; +} + +const { compliance, supervisor, technician } = POSITIONS; + +/** + * Twenty items across three position codes. + * + * Frequency mix: eight monthly, three weekly, one fortnightly, two quarterly, + * one semi-annual, two annual, two **standing** and one one-off. `regulation_ref` + * is filled on all but one — it is what makes the catalog read as an audit + * answer rather than a to-do list. + */ +export const CATALOG_ITEMS: readonly DemoCatalogItem[] = [ + // ── Plant compliance officer ────────────────────────────────────────── + { + name: 'Emissions return', + position: compliance, + form: 'recurring', + frequency: 'monthly', + dueAnchor: 'period_end', + dueOffsetDays: -5, + leadDays: 10, + graceDays: 3, + description: 'Submit the site emissions figures for the month, with the meter readings they were derived from.', + reference: 'Group Environment Standard GE-02 §5', + }, + { + name: 'Waste transfer log review', + position: compliance, + form: 'recurring', + frequency: 'monthly', + dueAnchor: 'period_start', + dueOffsetDays: 4, + leadDays: 7, + graceDays: 2, + description: 'Check every transfer note raised last month against the carrier register; flag anything unmatched.', + reference: 'Group Environment Standard GE-04 §2', + }, + { + name: 'Effluent sampling record', + position: compliance, + form: 'recurring', + frequency: 'weekly', + dueAnchor: 'period_start', + dueOffsetDays: 1, + leadDays: 3, + graceDays: 1, + description: 'Draw and log the weekly outfall sample. Record the result even when it is within limits.', + reference: 'Site Discharge Consent DC-11 cl.4', + }, + { + name: 'Permit condition review', + position: compliance, + form: 'recurring', + frequency: 'quarterly', + dueAnchor: 'period_end', + dueOffsetDays: -10, + leadDays: 21, + graceDays: 5, + description: 'Walk the permit conditions one by one and record, for each, the evidence that it was met this quarter.', + reference: 'Group Environment Standard GE-09 §1', + }, + { + name: 'Site environmental audit', + position: compliance, + form: 'recurring', + frequency: 'semiannual', + dueAnchor: 'period_end', + dueOffsetDays: 0, + // A long lead on purpose: half a year of work needs half a year of notice, + // and it is what makes a task visible — and therefore capable of going + // STALE — months before it is late. See `demo-history.ts`. + leadDays: 150, + graceDays: 10, + description: 'Full walk-round audit against the group environmental standard, with findings and owners.', + reference: 'Group Assurance Plan AP-3 §6', + }, + { + name: 'Annual environmental statement', + position: compliance, + form: 'recurring', + frequency: 'annual', + dueAnchor: 'period_end', + dueOffsetDays: -30, + leadDays: 60, + graceDays: 14, + description: 'Compile the year\'s environmental performance into the statement the group publishes.', + reference: 'Group Environment Standard GE-01 §8', + }, + { + name: 'Keep the permit register current', + position: compliance, + form: 'standing', + description: 'The register reflects the permits actually in force — no expiry passes without the entry being updated. Never "done"; attested, not ticked.', + reference: 'Group Environment Standard GE-09 §4', + }, + + // ── Shift supervisor ────────────────────────────────────────────────── + { + name: 'Shift handover record', + position: supervisor, + form: 'recurring', + frequency: 'weekly', + dueAnchor: 'period_start', + dueOffsetDays: 0, + leadDays: 2, + graceDays: 0, + description: 'Written handover for every shift change in the week: state of the line, anything left open.', + reference: 'Works Instruction WI-120 §3', + }, + { + name: 'Line safety walk', + position: supervisor, + form: 'recurring', + frequency: 'monthly', + dueAnchor: 'period_start', + dueOffsetDays: 2, + leadDays: 7, + graceDays: 2, + description: 'Walk the line against the safety checklist with an operator present. Log what you fixed on the spot.', + reference: 'Site Safety Standard SS-07 §2', + }, + { + name: 'Toolbox talk record', + position: supervisor, + form: 'recurring', + frequency: 'monthly', + dueAnchor: 'period_start', + dueOffsetDays: 9, + leadDays: 7, + graceDays: 3, + description: 'Run one toolbox talk with the shift and record who attended.', + reference: 'Site Safety Standard SS-07 §5', + }, + { + name: 'Lifting equipment check', + position: supervisor, + form: 'recurring', + frequency: 'quarterly', + dueAnchor: 'period_start', + dueOffsetDays: 5, + leadDays: 14, + graceDays: 5, + description: 'Visual check and tag review of every sling, hoist and eyebolt on the line.', + reference: 'Works Instruction WI-204 §1', + }, + { + name: 'Contractor induction refresh', + position: supervisor, + form: 'recurring', + frequency: 'annual', + dueAnchor: 'period_end', + dueOffsetDays: -60, + // Same reasoning as the semi-annual audit above — a year's notice for a + // year's obligation, which is what lets it stagnate long before it is late. + leadDays: 120, + graceDays: 21, + description: 'Re-run the site induction for every contractor still holding a pass, and retire the passes nobody claimed.', + reference: 'Site Safety Standard SS-15 §3', + }, + { + name: 'Answer the duty phone', + position: supervisor, + form: 'standing', + description: 'The out-of-hours phone is carried and answered. There is no version of this that is ever finished.', + reference: 'Works Instruction WI-002 §1', + }, + { + name: 'Overtime justification summary', + position: supervisor, + form: 'recurring', + frequency: 'monthly', + dueAnchor: 'period_end', + dueOffsetDays: -2, + leadDays: 7, + graceDays: 3, + description: 'One line per overtime shift worked: why it was needed and what it covered.', + reference: 'People Policy PP-22 cl.6', + }, + + // ── Quality technician ──────────────────────────────────────────────── + { + name: 'Calibration verification', + position: technician, + form: 'recurring', + frequency: 'monthly', + dueAnchor: 'period_start', + dueOffsetDays: 6, + leadDays: 7, + graceDays: 2, + description: 'Verify each instrument against its reference standard and record the deviation, in range or not.', + reference: 'Quality Manual QM-31 §4', + }, + { + name: 'Retained sample review', + position: technician, + form: 'recurring', + frequency: 'fortnightly', + dueAnchor: 'period_start', + dueOffsetDays: 2, + leadDays: 5, + graceDays: 1, + description: 'Inspect the retained samples due for review and dispose of anything past its retention window.', + reference: 'Quality Manual QM-18 §2', + }, + { + name: 'Nonconformance log review', + position: technician, + form: 'recurring', + frequency: 'monthly', + dueAnchor: 'period_start', + dueOffsetDays: 1, + leadDays: 7, + graceDays: 2, + description: 'Review every nonconformance raised last month and confirm each one has an owner and a closing date.', + reference: 'Quality Manual QM-05 §3', + }, + { + name: 'Cleaning verification swabs', + position: technician, + form: 'recurring', + frequency: 'weekly', + dueAnchor: 'period_start', + dueOffsetDays: 3, + leadDays: 3, + graceDays: 1, + description: 'Swab the changeover points after the weekly clean and log the plate counts.', + reference: 'Quality Manual QM-22 §7', + }, + { + name: 'Instrument drift check', + position: technician, + form: 'recurring', + frequency: 'monthly', + dueAnchor: 'period_start', + dueOffsetDays: 8, + leadDays: 7, + graceDays: 2, + // The one item with no reference: not every duty discharges a written + // clause, and a catalog where the column is 100% full stops reading as + // real. Nothing downstream depends on it being set. + description: 'Compare this month\'s calibration deviations against the last three and note any instrument trending out.', + }, + { + name: 'Commissioning file handover', + position: technician, + form: 'one_off', + graceDays: 7, + description: 'Hand the commissioning file to operations: as-built drawings, test records, spares list, signed off.', + reference: 'Project Standard PS-06 §5', + // Retired from the catalog once the programme it belonged to closed — the + // duty already instantiated from it lives on. `active: false` is what a + // real catalog looks like after a year. + active: false, + }, +]; + +const ITEM_BY_NAME = new Map(CATALOG_ITEMS.map((i) => [i.name, i])); + +export const catalogItem = (name: string): DemoCatalogItem => { + const item = ITEM_BY_NAME.get(name); + /* c8 ignore next 3 -- a typo here would silently seed a cadence-less duty */ + if (!item) throw new Error(`demo fixture: no catalog item named ${JSON.stringify(name)}`); + return item; +}; + +/** + * The cadence fields a row of `form` may carry, and only those. + * + * One function for both objects: a value stripped here for `duly_catalog_item` + * is stripped identically for the `duly_duty` instantiated from it, so the two + * cannot disagree and neither can trip #61's validation rules. + */ +export interface Cadence { + frequency?: Frequency; + due_anchor?: 'period_start' | 'period_end'; + due_offset_days?: number; + lead_days?: number; + grace_days?: number; +} + +/** + * Keys are OMITTED rather than set to `undefined`. An explicit `undefined` is + * still an own property: it would be compared by the loader's no-op-replay + * check (churning the row on every boot) and it is not what the conditional + * `defaultValue` expressions expect to be handed. + */ +export const cadenceOf = (item: DemoCatalogItem): Cadence => { + const out: Cadence = {}; + if (item.form === 'standing') return out; + if (item.frequency !== undefined) out.frequency = item.frequency; + if (item.graceDays !== undefined) out.grace_days = item.graceDays; + if (item.form === 'one_off') return out; + if (item.dueAnchor !== undefined) out.due_anchor = item.dueAnchor; + if (item.dueOffsetDays !== undefined) out.due_offset_days = item.dueOffsetDays; + if (item.leadDays !== undefined) out.lead_days = item.leadDays; + return out; +}; + +// ───────────────────────────────────────────────────────────────────────── +// Duties — the catalog instantiated onto people +// ───────────────────────────────────────────────────────────────────────── + +export interface DemoDuty { + /** + * `duly_duty.name`, and the seed's natural key for it. + * + * **Unique across the fixture, and it has to be.** The seed loader resolves + * `duly_task.duty` as a natural key against `duly_duty.name` (`duly_duty`'s + * dataset declares `externalId: 'name'`), matching with `limit: 1`. Two + * duties sharing a name would not error — the second person's tasks would + * simply attach to the first person's duty, silently and permanently. + * + * So where one catalog item is held by several people, the duty is named for + * the SCOPE that person actually covers ("Line A", "Riverside", "Lab 2"). + * That is how the same obligation reads on a real site anyway, and it is + * what makes "What each team owes" legible. + */ + name: string; + /** The `duly_catalog_item.name` this was instantiated from; `null` for self-declared. */ + item: string | null; + owner: string; + source: 'catalog' | 'assigned' | 'self'; + status?: 'active' | 'paused' | 'retired'; + /** Self-declared duties carry their own cadence — there is no catalog row behind them. */ + own?: Partial & { form: Form }; +} + +/** + * ⚠️ `source` is stated on EVERY row, never left to the default. + * + * Since #54 both `duly_duty.source` and `duly_task.source` default to `self`, + * and every dataset measure is filtered to `catalog` + `assigned` + * (`src/datasets/governed.ts`). A governed duty that relied on the default + * would land unscored, and every dashboard measure would read zero — with no + * error anywhere, because an unscored duty is a perfectly legal thing to be. + */ +export const DUTIES: readonly DemoDuty[] = [ + // ── The account `objectstack dev` logs you in as ────────────────────── + // Deliberately given a real week: a monthly pair that keeps My week + // populated, a quarter that has already run three times, the semi-annual + // audit that goes stale, one standing duty, and one self-declared duty so + // the caliber split is visible on the evaluator's OWN screen. + { name: 'Emissions return — Northgate', item: 'Emissions return', owner: ADMIN, source: 'catalog' }, + { name: 'Waste transfer log review — Northgate', item: 'Waste transfer log review', owner: ADMIN, source: 'catalog' }, + { name: 'Permit condition review — Northgate', item: 'Permit condition review', owner: ADMIN, source: 'catalog' }, + { name: 'Site environmental audit — Northgate', item: 'Site environmental audit', owner: ADMIN, source: 'catalog' }, + { name: 'Keep the permit register current — Northgate', item: 'Keep the permit register current', owner: ADMIN, source: 'catalog' }, + { + name: 'Keep up with regulator bulletins', + item: null, + owner: ADMIN, + source: 'self', + own: { form: 'recurring', frequency: 'monthly', dueAnchor: 'period_start', dueOffsetDays: 7, leadDays: 7, graceDays: 0, description: 'Read the month\'s bulletins and note anything that changes what the site owes.' }, + }, + + // ── Northgate Quality ───────────────────────────────────────────────── + { name: 'Annual environmental statement — Ardenline', item: 'Annual environmental statement', owner: 'Priya Raman', source: 'catalog' }, + { name: 'Answer the duty phone — Northgate Quality', item: 'Answer the duty phone', owner: 'Priya Raman', source: 'catalog' }, + { + name: 'Monthly quality trend read', + item: null, + owner: 'Priya Raman', + source: 'self', + own: { form: 'recurring', frequency: 'monthly', dueAnchor: 'period_start', dueOffsetDays: 5, leadDays: 7, graceDays: 0, description: 'Half an hour with the month\'s nonconformances and calibration deviations, looking for the shape rather than the individual events.' }, + }, + { name: 'Calibration verification — Lab 1', item: 'Calibration verification', owner: 'Rosa Delgado', source: 'catalog' }, + { name: 'Retained sample review — Lab 1', item: 'Retained sample review', owner: 'Rosa Delgado', source: 'catalog' }, + { name: 'Nonconformance log review — Northgate Quality', item: 'Nonconformance log review', owner: 'Rosa Delgado', source: 'catalog' }, + { name: 'Calibration verification — Lab 2', item: 'Calibration verification', owner: 'Ibrahim Chaudhry', source: 'catalog' }, + { name: 'Instrument drift check — Lab 2', item: 'Instrument drift check', owner: 'Ibrahim Chaudhry', source: 'catalog' }, + { + name: 'Track my own training hours', + item: null, + owner: 'Ibrahim Chaudhry', + source: 'self', + own: { form: 'recurring', frequency: 'monthly', dueAnchor: 'period_start', dueOffsetDays: 3, leadDays: 5, graceDays: 0, description: 'Log the hours and what they were spent on, so the year-end return is not reconstructed from memory.' }, + }, + + // ── Northgate Operations ────────────────────────────────────────────── + { name: 'Shift handover record — Line A', item: 'Shift handover record', owner: 'Marek Dvorak', source: 'catalog' }, + { name: 'Line safety walk — Line A', item: 'Line safety walk', owner: 'Marek Dvorak', source: 'catalog' }, + { name: 'Line safety walk — Line B', item: 'Line safety walk', owner: 'Sami Okonkwo', source: 'catalog' }, + { name: 'Toolbox talk record — Line B', item: 'Toolbox talk record', owner: 'Sami Okonkwo', source: 'catalog' }, + { name: 'Contractor induction refresh — Northgate', item: 'Contractor induction refresh', owner: 'Sami Okonkwo', source: 'catalog' }, + { name: 'Lifting equipment check — Line C', item: 'Lifting equipment check', owner: 'Yuki Tanabe', source: 'catalog' }, + { name: 'Overtime justification summary — Northgate Operations', item: 'Overtime justification summary', owner: 'Yuki Tanabe', source: 'catalog' }, + + // ── Riverside Plant ─────────────────────────────────────────────────── + { name: 'Emissions return — Riverside', item: 'Emissions return', owner: 'Ana Ferreira', source: 'catalog' }, + { name: 'Permit condition review — Riverside', item: 'Permit condition review', owner: 'Ana Ferreira', source: 'catalog' }, + // The paused duty. `planForDuty` skips it with `not_active`, so it holds no + // tasks at all — which is the point: pausing stops the dispatcher, it does + // not hide the obligation. + { name: 'Waste transfer log review — Riverside', item: 'Waste transfer log review', owner: 'Ana Ferreira', source: 'catalog', status: 'paused' }, + { name: 'Line safety walk — Riverside', item: 'Line safety walk', owner: 'Greta Lindqvist', source: 'catalog' }, + { name: 'Toolbox talk record — Riverside', item: 'Toolbox talk record', owner: 'Greta Lindqvist', source: 'catalog' }, + { name: 'Nonconformance log review — Riverside', item: 'Nonconformance log review', owner: 'Elin Halvorsen', source: 'catalog' }, + + // ── Managers hold duties of their own ───────────────────────────────── + // A second standing duty instantiated from the same catalog item, at group + // level. Standing duties hold no tasks, so this costs the fixture nothing + // and keeps the top of the org chart from being a person with no duties. + { name: 'Keep the permit register current — Ardenline', item: 'Keep the permit register current', owner: 'Nadia Ilves', source: 'catalog' }, + { + name: 'Monthly site performance note', + item: null, + owner: 'Tomas Bergh', + source: 'self', + own: { form: 'recurring', frequency: 'monthly', dueAnchor: 'period_end', dueOffsetDays: -1, leadDays: 5, graceDays: 0, description: 'A page on how the site actually ran this month — written for myself, not for a report.' }, + }, + + // ── Central Office ──────────────────────────────────────────────────── + // A one-off: dispatched by hand, never by the scheduler. `planForDuty` + // returns `one_off` for it, so its single task is seeded directly in + // `task.seed.ts` alongside the assignment fan-out. + { name: 'Commissioning file handover — Riverside upgrade', item: 'Commissioning file handover', owner: 'Owen Pryce', source: 'catalog' }, +]; diff --git a/src/data/demo-history.ts b/src/data/demo-history.ts new file mode 100644 index 0000000..c3394c7 --- /dev/null +++ b/src/data/demo-history.ts @@ -0,0 +1,348 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { periodBounds, periodKeyFor, visibleFromFor } from '../functions/period.js'; +import { planDispatch, type DispatchDuty, type DutySkip, type TaskDraft } from '../jobs/dispatch.plan.js'; + +import { timezoneOf, unitOf } from './demo-org.js'; +import { DUTIES, cadenceOf, catalogItem, type DemoCatalogItem } from './demo-catalog.js'; + +/** + * Six months of history, produced by the DISPATCHER'S OWN PLANNER. + * + * ── Why `planDispatch` and not a second period walk ────────────────────── + * The card's hardest rule is that period keys come from `src/functions/period.ts` + * and are never typed by hand: `duly_task` is unique on + * `(duty, owner, period_key)`, so `2026-W4` where the engine says `2026-W04` + * is a second task for one obligation that nothing downstream can tell was + * meant to be the same. Calling the engine directly would satisfy that rule. + * Calling `planDispatch` satisfies something stronger — the seeded rows are + * *literally what the dispatcher would have produced*: same keys, same due + * dates, same lead windows. There is no second opinion about periods anywhere + * in this fixture because there is no second walk. + * + * Two invariants then fall out of the structure rather than being asserted on + * top of it: + * + * - **Standing duties hold zero tasks.** `planForDuty` returns + * `{ reason: 'standing' }` before it reads anything else, so a standing + * duty cannot produce a draft here. Impossible in this fixture, not merely + * absent from it. {@link SKIPS} carries the reasons so `test/seed.test.ts` + * can assert the mechanism and not just the outcome. + * - **Paused duties hold zero tasks**, by the same route (`not_active`). + * + * ── Two plans, unioned ─────────────────────────────────────────────────── + * A real system on any given morning holds both: everything the backfill ever + * created, AND whatever today's scheduled run would create for the periods now + * in flight or already inside their lead window. So this asks for both and + * unions them on the dispatch identity — which is exactly the state + * `pnpm dev` should open on. + * + * ── Relative, so the demo does not rot ─────────────────────────────────── + * Every instant below is derived from {@link NOW}, read once when this module + * loads. A literal `2026-07-18` stops being "stalled" as soon as the repo ages + * past it, and a demo that quietly decays into an empty "Not moving" view is + * the failure this card exists to prevent. The window is walked in CIVIL days + * through `visibleFromFor` — the period engine's own date shift — rather than + * by subtracting milliseconds, which is wrong twice a year in every zone that + * observes a summer-time change. + */ + +const DAY = 24 * 60 * 60 * 1000; +const HOUR = 60 * 60 * 1000; + +/** The fixture's clock. One read, at module load, so every derived instant agrees. */ +export const NOW = new Date(); + +/** + * Today as a civil date. The daily period key IS the local calendar day by the + * period engine's own spelling, so this is that module answering rather than a + * second copy of the zone arithmetic (the same idiom `dispatch.plan.ts` uses). + */ +export const TODAY = periodKeyFor('daily', NOW, 'UTC'); + +/** Roughly six months of backfill, as an inclusive civil-date window. */ +export const HISTORY_FROM = visibleFromFor(TODAY, 183); + +// ───────────────────────────────────────────────────────────────────────── +// The duties, in the shape the planner reads +// ───────────────────────────────────────────────────────────────────────── + +/** A self-declared duty has no catalog row behind it, so it carries its own cadence. */ +const itemFor = (item: string | null, own: Partial | undefined, name: string): DemoCatalogItem => + item !== null + ? catalogItem(item) + : ({ name, position: '', description: '', form: 'recurring', ...own } as DemoCatalogItem); + +export const DISPATCH_DUTIES: readonly DispatchDuty[] = DUTIES.map((duty) => { + const item = itemFor(duty.item, duty.own, duty.name); + const cadence = cadenceOf(item); + const unit = unitOf(duty.owner); + return { + // The planner treats `id` as opaque and copies it onto every draft's + // `duty`. Handing it the duty's NATURAL KEY is what makes the drafts + // seedable as they come out: the loader resolves `duly_task.duty` against + // `duly_duty.name`, which is precisely this string. + id: duty.name, + name: duty.name, + form: item.form, + status: duty.status ?? 'active', + owner: duty.owner, + business_unit: unit, + source: duty.source, + frequency: cadence.frequency ?? null, + due_anchor: cadence.due_anchor ?? null, + due_offset_days: cadence.due_offset_days ?? null, + lead_days: cadence.lead_days ?? null, + timezone: timezoneOf(unit), + // The same window `duty.seed.ts` writes onto `duly_duty.effective_from`. + // Stated HERE too, not only there: the planner clips every period whose + // due date falls outside a duty's effective window, so leaving it out + // would seed tasks the duty itself says predate it — a history that a + // re-run of the real dispatcher would refuse to reproduce. + effective_from: HISTORY_FROM, + }; +}); + +const TZ_BY_DUTY = new Map(DISPATCH_DUTIES.map((duty) => [duty.id, duty.timezone ?? 'UTC'])); + +// ───────────────────────────────────────────────────────────────────────── +// The plan +// ───────────────────────────────────────────────────────────────────────── + +const backfill = planDispatch({ + duties: DISPATCH_DUTIES, + now: NOW, + window: { from: HISTORY_FROM, to: TODAY }, +}); + +const live = planDispatch({ duties: DISPATCH_DUTIES, now: NOW, window: null }); + +/** The dispatch identity, spelled so no field value can be mistaken for a separator. */ +const identityOf = (draft: TaskDraft) => JSON.stringify([draft.duty, draft.owner, draft.period_key]); + +/** Backfill union today's scheduled run, deduplicated on `(duty, owner, period_key)`. */ +export const DRAFTS: readonly TaskDraft[] = (() => { + const seen = new Map(); + for (const draft of [...backfill.drafts, ...live.drafts]) { + if (!seen.has(identityOf(draft))) seen.set(identityOf(draft), draft); + } + return [...seen.values()]; +})(); + +/** + * Why each duty produced nothing, straight from the planner. + * + * Exported so `test/seed.test.ts` can assert the MECHANISM behind "standing + * duties have zero tasks" — that the planner refused them by form — rather + * than only observing that no such row happens to exist. + */ +export const SKIPS: readonly DutySkip[] = [...backfill.skipped]; + +// ───────────────────────────────────────────────────────────────────────── +// Turning drafts into history +// ───────────────────────────────────────────────────────────────────────── + +/** The instant a civil day begins in a zone — the period engine again, not a conversion invented here. */ +const startOfDay = (day: string, timezone: string): Date => periodBounds('daily', day, timezone).start; + +/** ISO instant, never later than the fixture's clock. Nothing in a demo happened tomorrow. */ +const iso = (instant: Date): string => new Date(Math.min(instant.getTime(), NOW.getTime())).toISOString(); + +/** + * The occurrences this fixture places by hand are addressed by DUTY and by + * position in that duty's series ("the newest one already past due", "the + * oldest one"), never by date, so they survive the calendar moving under them. + */ + +/** + * Still open, and past due. The **Late** view. + * + * Three of the four are being actively chased (touched inside the fortnight); + * one is not. Late and stalled are different populations, and showing them as + * overlapping-but-distinct is the whole argument for having both views: + * lateness reports a failure that has already happened, stagnation catches one + * that has not. + */ +const LATE_MOST_RECENT: Readonly> = { + 'Emissions return — Northgate': 'open', + 'Toolbox talk record — Line B': 'in_progress', + 'Line safety walk — Riverside': 'open', + // The overlap: late AND untouched since the day it was dispatched. + 'Calibration verification — Lab 1': 'open', +}; + +/** How long ago each actively-chased late row was last touched. */ +const CHASED_DAYS_AGO = [2, 6, 10] as const; + +/** Untouched since dispatch as well as late — the fourth Late row above. */ +const STALLED_LATE = 'Calibration verification — Lab 1'; + +/** + * Open, NOT yet due, and untouched since dispatch. The **Not moving** view + * doing the job it exists for. + * + * Both are long-lead obligations — a half-year audit noticed five months out, + * a yearly induction refresh noticed four months out — which is the only shape + * in which stagnation can fire before lateness can. A short-lead monthly task + * cannot be three weeks stale and still in date. + */ +const STALLED_IN_FLIGHT: readonly string[] = [ + 'Site environmental audit — Northgate', + 'Contractor induction refresh — Northgate', +]; + +/** One skipped occurrence, with a reason that is an answer rather than "n/a". */ +const SKIPPED_MOST_RECENT = 'Line safety walk — Line A'; +const SKIP_REASON = 'Line A was down for the rebuild for the whole period — there was no line to walk.'; + +/** One withdrawn occurrence. Cancelled work was never owed, so no measure counts it. */ +const CANCELLED_OLDEST = 'Retained sample review — Lab 1'; + +/** A few in-flight tasks somebody has actually started. */ +const IN_PROGRESS_IN_FLIGHT: readonly string[] = [ + 'Permit condition review — Northgate', + 'Nonconformance log review — Northgate Quality', + 'Shift handover record — Line A', +]; + +/** Notes, so a record detail view is not a wall of empty fields. */ +const NOTES: Readonly> = { + 'Emissions return — Northgate': 'Meter 3 was swapped mid-period — figures split across the two serials, both attached.', + 'Calibration verification — Lab 1': 'Waiting on the reference standard to come back from the calibration house.', + 'Site environmental audit — Northgate': 'Booked for the week of the shutdown so the lines are cold.', + 'Toolbox talk record — Line B': 'Two of the night shift still to attend; running a repeat session.', + 'Contractor induction refresh — Northgate': 'Pass list pulled from the gatehouse; fourteen to chase.', +}; + +export interface SeededTask extends Omit { + status: 'open' | 'in_progress' | 'done' | 'skipped' | 'cancelled'; + completed_at?: string; + skip_reason?: string; + note?: string; + /** Written by a SECOND seed pass — an insert can never carry it. See `task.seed.ts`. */ + last_update_at: string; +} + +const byDuty = new Map(); +for (const draft of DRAFTS) { + const series = byDuty.get(draft.duty); + if (series) series.push(draft); + else byDuty.set(draft.duty, [draft]); +} +for (const series of byDuty.values()) { + series.sort((a, b) => (a.due_date < b.due_date ? -1 : a.due_date > b.due_date ? 1 : 0)); +} + +const isPast = (draft: TaskDraft) => draft.due_date < TODAY; + +const mostRecentPast = new Map(); +const oldest = new Map(); +for (const [duty, series] of byDuty) { + const past = series.filter(isPast); + if (past.length > 0) mostRecentPast.set(duty, past[past.length - 1]!.period_key); + if (series.length > 0) oldest.set(duty, series[0]!.period_key); +} + +/** The earliest occurrence of a duty that is still in flight — the one its owner is looking at now. */ +const inFlightKey = (duty: string): string | undefined => + byDuty.get(duty)?.find((draft) => !isPast(draft))?.period_key; + +/** + * How long ago a task that is still moving was last touched. + * + * Two constraints, and the interesting one is the second: + * + * 1. **Never before the task was dispatched.** A row cannot have been worked + * on before it existed. This clamps the whole band for a freshly + * dispatched monthly. + * 2. **Never 14 days or more.** Anything that old lands in "Not moving", and + * which rows stagnate is a decision this fixture makes deliberately — + * see {@link STALLED_IN_FLIGHT} — not a side effect of a spread. + * + * Between those, the age is spread by how long the task has BEEN open rather + * than uniformly. A task dispatched five months ago and still being worked was + * realistically last touched a week or two back; one dispatched on Monday was + * touched this week. A uniform spread collapses to "everything was touched in + * the last few days" once constraint 1 clamps it, which makes the dashboard's + * nested >7d / >14d / >30d buckets read identically and look broken. + */ +const touchedDaysAgo = (draft: TaskDraft, dispatched: Date, index: number): number => { + const openFor = Math.floor((NOW.getTime() - dispatched.getTime()) / DAY); + if (openFor >= 14) return 8 + (index % 5); + return Math.max(0, Math.min(index % 6, openFor)); +}; + +/** + * Decide what actually happened to one dispatched task. + * + * Deterministic: every variation is a function of the draft's position in the + * fixture, never of a random number. Two boots of the same tree produce the + * same history, which is what lets `test/seed.test.ts` assert counts at all. + */ +const resolveDraft = (draft: TaskDraft, index: number): SeededTask => { + const timezone = TZ_BY_DUTY.get(draft.duty) ?? 'UTC'; + const dispatched = new Date(startOfDay(draft.visible_from, timezone).getTime() + 9 * HOUR); + const dueInstant = startOfDay(draft.due_date, timezone); + const note = NOTES[draft.duty]; + const withNote = (row: SeededTask): SeededTask => (note ? { ...row, note } : row); + + const isMostRecentPast = mostRecentPast.get(draft.duty) === draft.period_key; + const isOldest = oldest.get(draft.duty) === draft.period_key; + const isInFlightHead = inFlightKey(draft.duty) === draft.period_key; + const untouchedSinceDispatch = iso(dispatched); + + // ── The occurrences placed by hand ─────────────────────────────────── + if (isMostRecentPast && draft.duty in LATE_MOST_RECENT) { + return withNote({ + ...draft, + status: LATE_MOST_RECENT[draft.duty]!, + // Chased, but at different tempos — 2, 6 and 10 days. A month-overdue + // task that was last touched yesterday, every time, is not what being + // chased looks like; and spreading these across the fortnight is what + // puts anything at all in the dashboard's 7-to-14-day band, which would + // otherwise be empty and make its >7d and >14d tiles read identically. + last_update_at: + draft.duty === STALLED_LATE + ? untouchedSinceDispatch + : iso(new Date(NOW.getTime() - CHASED_DAYS_AGO[index % CHASED_DAYS_AGO.length]! * DAY)), + }); + } + if (isMostRecentPast && draft.duty === SKIPPED_MOST_RECENT) { + return { + ...draft, + status: 'skipped', + skip_reason: SKIP_REASON, + last_update_at: iso(new Date(dueInstant.getTime() - DAY + 15 * HOUR)), + }; + } + if (isOldest && draft.duty === CANCELLED_OLDEST) { + return { + ...draft, + status: 'cancelled', + last_update_at: iso(new Date(dueInstant.getTime() - 3 * DAY + 11 * HOUR)), + }; + } + + // ── Everything else already due is done ────────────────────────────── + if (isPast(draft)) { + // Some early, some on the day, some just over. A history that is uniformly + // on time reads as fabricated — and the grace days each duty grants exist + // precisely because real completion scatters around the due date. + const drift = [-3, -1, 0, 1][index % 4]!; + const completed = iso( + new Date(Math.max(dueInstant.getTime() + drift * DAY + 14 * HOUR, dispatched.getTime())), + ); + return withNote({ ...draft, status: 'done', completed_at: completed, last_update_at: completed }); + } + + // ── In flight ──────────────────────────────────────────────────────── + const stalled = isInFlightHead && STALLED_IN_FLIGHT.includes(draft.duty); + return withNote({ + ...draft, + status: isInFlightHead && IN_PROGRESS_IN_FLIGHT.includes(draft.duty) ? 'in_progress' : 'open', + last_update_at: stalled ? untouchedSinceDispatch : iso(new Date(NOW.getTime() - touchedDaysAgo(draft, dispatched, index) * DAY)), + }); +}; + +/** Every dispatched task the demo opens with, history and in-flight alike. */ +export const SEEDED_TASKS: readonly SeededTask[] = DRAFTS.map(resolveDraft); diff --git a/src/data/demo-org.ts b/src/data/demo-org.ts new file mode 100644 index 0000000..1fef55c --- /dev/null +++ b/src/data/demo-org.ts @@ -0,0 +1,145 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The demo organisation: a business-unit tree, the people in it, and the three + * positions their duties hang off. + * + * Everything here is INVENTED. No real company, person, site or regulation is + * named anywhere in this seed — the domain is under RFC 2606's reserved + * `.example` TLD, which can never resolve, and every "reference" a catalog item + * cites is an internal policy number belonging to a company that does not + * exist. That is a hard rule of this fixture, not a stylistic preference: a + * demo seed is copied, screenshotted and pasted into decks, and a real name in + * it eventually becomes a claim about a real organisation. + * + * ── Why the person you log in as is IN the org chart ────────────────────── + * `objectstack dev` seeds a loginable admin (`admin@objectos.ai` / `admin123`) + * whose `sys_user.name` is `Dev Admin`, via `plugin-auth`'s `maybeSeedDevAdmin`. + * That is the account an evaluator actually lands in, so every `{current_user_id}` + * view — My week, My duties, Sent by me, Work log — is scoped to it. A demo + * whose data belongs entirely to twelve OTHER people renders those four screens + * empty on first boot, which is the exact failure this seed exists to prevent. + * + * So `Dev Admin` is a full participant below: they own duties, tasks, an + * assignment and a work log. {@link ADMIN} is how the fixture refers to them. + * + * ⚠️ The `sys_user` seed row for `Dev Admin` carries its natural key and + * NOTHING ELSE, deliberately. Two orderings have to both come out right: + * + * - **`objectstack dev`, fresh DB.** `AuthPlugin` is registered before the + * app plugin (`serve.ts` mounts it at step 5d, the app's own plugins after), + * so the real admin account already exists when this seed runs. The loader + * finds it by `name`, `isNoOpReplay` compares only the fields the seed + * declares — just `name` — finds them equal, and SKIPS. Nothing is written + * to the live account. Add an `email` here and that stops being true: the + * row would be updated, and a changed email is a login the evaluator no + * longer has. + * - **A kernel with no auth plugin** (the vitest suites, `createStandaloneStack`). + * No such account exists, so the row is inserted and `owner: 'Dev Admin'` + * still resolves. Without it every task owned by the admin would be refused + * with `Owner is required`, because `duly_task.owner` is a natural-key + * reference into `sys_user.name` and `owner` is `required: true`. + */ + +/** Reserved by RFC 2606 — a domain that cannot be registered by anyone. */ +const DOMAIN = 'ardenline.example'; + +/** + * The `sys_user.name` of the account `objectstack dev` logs you in as. + * Matched by natural key; see the file header for why the row is name-only. + */ +export const ADMIN = 'Dev Admin'; + +// ───────────────────────────────────────────────────────────────────────── +// Business units — three levels, as ADR-0057 D2 models them +// ───────────────────────────────────────────────────────────────────────── + +export interface DemoUnit { + name: string; + code: string; + kind: 'company' | 'division' | 'department'; + parent: string | null; + /** `sys_business_unit.manager_user_id` — set at every level so a hierarchy scope has something to resolve. */ + manager: string; + /** IANA zone the duties in this unit compute their periods in. */ + timezone: string; +} + +export const UNITS: readonly DemoUnit[] = [ + { name: 'Ardenline Group', code: 'ARD', kind: 'company', parent: null, manager: 'Nadia Ilves', timezone: 'UTC' }, + { name: 'Northgate Plant', code: 'NGP', kind: 'division', parent: 'Ardenline Group', manager: 'Tomas Bergh', timezone: 'Europe/Berlin' }, + { name: 'Riverside Plant', code: 'RVP', kind: 'division', parent: 'Ardenline Group', manager: 'Elin Halvorsen', timezone: 'UTC' }, + { name: 'Central Office', code: 'CEN', kind: 'division', parent: 'Ardenline Group', manager: 'Nadia Ilves', timezone: 'UTC' }, + // The two teams under one of the sites — the third level. + { name: 'Northgate Operations', code: 'NGP-OPS', kind: 'department', parent: 'Northgate Plant', manager: 'Marek Dvorak', timezone: 'Europe/Berlin' }, + { name: 'Northgate Quality', code: 'NGP-QA', kind: 'department', parent: 'Northgate Plant', manager: 'Priya Raman', timezone: 'Europe/Berlin' }, +]; + +const UNIT_BY_NAME = new Map(UNITS.map((u) => [u.name, u])); + +/** The zone a unit's duties compute periods in. Unknown unit ⇒ `duly_duty.timezone`'s own default. */ +export const timezoneOf = (unit: string): string => UNIT_BY_NAME.get(unit)?.timezone ?? 'UTC'; + +// ───────────────────────────────────────────────────────────────────────── +// People +// ───────────────────────────────────────────────────────────────────────── + +export interface DemoPerson { + name: string; + /** `sys_user.manager_id`, by natural key. `null` only for the top of the chain. */ + manager: string | null; + unit: string; +} + +/** + * Twelve people, each with a manager and a unit, so `manager_id` and + * `primary_business_unit_id` are both populated and the chain actually + * terminates. `Dev Admin` is seeded separately (see the header) and is the + * thirteenth participant. + */ +export const PEOPLE: readonly DemoPerson[] = [ + { name: 'Nadia Ilves', manager: null, unit: 'Ardenline Group' }, + { name: 'Tomas Bergh', manager: 'Nadia Ilves', unit: 'Northgate Plant' }, + { name: 'Elin Halvorsen', manager: 'Nadia Ilves', unit: 'Riverside Plant' }, + { name: 'Owen Pryce', manager: 'Nadia Ilves', unit: 'Central Office' }, + { name: 'Marek Dvorak', manager: 'Tomas Bergh', unit: 'Northgate Operations' }, + { name: 'Priya Raman', manager: 'Tomas Bergh', unit: 'Northgate Quality' }, + { name: 'Sami Okonkwo', manager: 'Marek Dvorak', unit: 'Northgate Operations' }, + { name: 'Yuki Tanabe', manager: 'Marek Dvorak', unit: 'Northgate Operations' }, + { name: 'Rosa Delgado', manager: 'Priya Raman', unit: 'Northgate Quality' }, + { name: 'Ibrahim Chaudhry', manager: 'Priya Raman', unit: 'Northgate Quality' }, + { name: 'Ana Ferreira', manager: 'Elin Halvorsen', unit: 'Riverside Plant' }, + { name: 'Greta Lindqvist', manager: 'Elin Halvorsen', unit: 'Riverside Plant' }, +]; + +/** `firstname.lastname@ardenline.example`, deterministic from the display name. */ +export const emailOf = (name: string): string => + `${name.toLowerCase().replace(/[^a-z ]/g, '').split(' ').filter(Boolean).join('.')}@${DOMAIN}`; + +const UNIT_BY_PERSON = new Map(PEOPLE.map((p) => [p.name, p.unit])); + +/** + * The unit a person's work rolls up to. + * + * `Dev Admin` is not in {@link PEOPLE} — their `sys_user` row is name-only on + * purpose — so their unit is stated here instead. It reaches the data the same + * way a real one would: denormalised onto each duty and task at creation, which + * is what `duly_task.business_unit` is for. + */ +export const unitOf = (person: string): string => + person === ADMIN ? 'Northgate Quality' : (UNIT_BY_PERSON.get(person) ?? 'Ardenline Group'); + +// ───────────────────────────────────────────────────────────────────────── +// Positions +// ───────────────────────────────────────────────────────────────────────── + +/** + * `duly_catalog_item.position_code` is free text by design — a customer loads + * their catalog before modelling positions in the platform — so these are + * job-role codes, NOT the three `definePosition` names in `src/security/`. + */ +export const POSITIONS = { + compliance: 'plant_compliance_officer', + supervisor: 'shift_supervisor', + technician: 'quality_technician', +} as const; diff --git a/src/data/duty.seed.ts b/src/data/duty.seed.ts new file mode 100644 index 0000000..9862185 --- /dev/null +++ b/src/data/duty.seed.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineSeed } from '@objectstack/spec/data'; + +import { Duty } from '../objects/duty.object.js'; + +import { timezoneOf, unitOf } from './demo-org.js'; +import { DUTIES, cadenceOf, catalogItem, type DemoCatalogItem } from './demo-catalog.js'; +import { HISTORY_FROM } from './demo-history.js'; + +/** + * The catalog instantiated onto people — what each person actually owes. + * + * Thirty duties over thirteen people, in three calibers: + * + * `catalog` the organisation put it there, from a role catalog. Scored. + * `assigned` a manager handed it over. Scored. (Produced by the assignment + * fan-out, so no `assigned` DUTY exists — see `task.seed.ts`.) + * `self` the owner's own record-keeping. Surfaced everywhere, scored + * nowhere. Four of these, so the split is visible on screen. + * + * ⚠️ `source` is stated on every row and never left to the default. Since #54 + * it defaults to `self`, and every dataset measure filters to `catalog` + + * `assigned` (`src/datasets/governed.ts`) — so a governed duty that leaned on + * the default would land unscored and take every dashboard measure to zero, + * with nothing erroring anywhere, because an unscored duty is a perfectly + * legal thing to be. + * + * Also here, by design: + * - **Two standing duties**, which never dispatch and therefore hold zero + * tasks. Not "no tasks yet" — `planForDuty` refuses them by form before it + * reads anything else, so the fixture cannot produce one. + * - **One paused duty**, which holds zero tasks for a different reason + * (`not_active`). Pausing stops the dispatcher; it does not hide the + * obligation, and the duty stays on screen. + * - **One one-off**, dispatched by hand rather than by the scheduler. Its + * single task is seeded directly in `task.seed.ts`. + */ + +/** A self-declared duty has no catalog row behind it, so it carries its own cadence. */ +const itemFor = (item: string | null, own: Partial | undefined, name: string): DemoCatalogItem => + item !== null + ? catalogItem(item) + : ({ name, position: '', description: '', form: 'recurring', ...own } as DemoCatalogItem); + +export const dutySeed = defineSeed(Duty, { + externalId: 'name', + mode: 'upsert', + records: DUTIES.map((duty) => { + const item = itemFor(duty.item, duty.own, duty.name); + const unit = unitOf(duty.owner); + return { + name: duty.name, + description: item.description, + form: item.form, + owner: duty.owner, + business_unit: unit, + source: duty.source, + // Null for a self-declared duty: there is no catalog row to replay edits + // from, which is exactly what distinguishes it. + catalog_item: duty.item, + // Periods are resolved in the DUTY's own zone, not the server's — a + // global product cannot compute "the 5th of the month" without knowing + // whose month. The Northgate units run on Europe/Berlin, so the seed + // exercises the zone handling rather than leaving every row on UTC. + timezone: timezoneOf(unit), + status: duty.status ?? 'active', + // The history this seed backfills starts here, so the duties say so. + // Without it the effective window is open-ended and a later backfill + // would happily invent obligations that predate the demo. + effective_from: HISTORY_FROM, + ...cadenceOf(item), + }; + }), +}); diff --git a/src/data/index.ts b/src/data/index.ts index e93b490..0667ed8 100644 --- a/src/data/index.ts +++ b/src/data/index.ts @@ -13,4 +13,78 @@ // makes `name` optional and fails the assignment. A named array is `never[]` // while empty and infers correctly the moment something is pushed into it. -export const dulySeeds = []; +import type { Seed } from '@objectstack/spec/data'; + +import { assignmentSeed } from './assignment.seed.js'; +import { catalogSeed } from './catalog.seed.js'; +import { dutySeed } from './duty.seed.js'; +import { logEntrySeed } from './log-entry.seed.js'; +import { businessUnitSeed, userSeed } from './org.seed.js'; +import { + taskAdHocSeed, + taskAdHocTouchSeed, + taskHistorySeed, + taskHistoryTouchSeed, +} from './task.seed.js'; + +export { + assignmentSeed, + businessUnitSeed, + catalogSeed, + dutySeed, + logEntrySeed, + taskAdHocSeed, + taskAdHocTouchSeed, + taskHistorySeed, + taskHistoryTouchSeed, + userSeed, +}; + +/** + * The demo seed — what `pnpm dev` opens on with an empty database. + * + * ── Order ───────────────────────────────────────────────────────────────── + * The loader sorts datasets topologically by reference before it runs them, so + * this array is written for a READER rather than for the loader. Two things + * about it are load-bearing anyway: + * + * - **`sys_user` and `sys_business_unit` come first.** They are the targets + * every `duly_*` owner and unit reference resolves against. The topological + * sort would hoist them regardless; listing them first means nobody has to + * know that to see why the seed works. (#32: without the user rows, every + * task row is refused with `Owner is required` — measured, 0 inserted, 4 + * errored.) + * - **The two `mode: 'update'` task passes come LAST, after both inserts.** + * Datasets targeting the same object keep their relative order through the + * sort (it is stable), and these two only work if the rows they backdate + * already exist. They are what makes "Not moving" non-empty; see + * `task.seed.ts` for why an insert can never carry `last_update_at`. + * + * ── Environment ─────────────────────────────────────────────────────────── + * Every dataset takes `Seed.env`'s default — `['prod', 'dev', 'test']` — so + * the demo loads wherever the app is booted, which is what makes it testable + * as well as demonstrable. Scoping it to `dev` would be defensible for a + * shipping product; it is the wrong trade for an app whose entire purpose + * right now is to be looked at and evaluated. + */ +export const dulySeeds: Seed[] = [ + // 1. The org, first — everything below resolves its people and units here. + businessUnitSeed, + userSeed, + + // 2. What roles owe, and who owes it. + catalogSeed, + dutySeed, + + // 3. The work itself. + assignmentSeed, + taskHistorySeed, + taskAdHocSeed, + + // 4. The backdate passes. Last, and not optional. + taskHistoryTouchSeed, + taskAdHocTouchSeed, + + // 5. The personal work log — deliberately attached to nothing scoreable. + logEntrySeed, +]; diff --git a/src/data/log-entry.seed.ts b/src/data/log-entry.seed.ts new file mode 100644 index 0000000..dae6177 --- /dev/null +++ b/src/data/log-entry.seed.ts @@ -0,0 +1,81 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineSeed } from '@objectstack/spec/data'; + +import { visibleFromFor } from '../functions/period.js'; +import { LogEntry } from '../objects/log-entry.object.js'; + +import { ADMIN } from './demo-org.js'; +import { TODAY } from './demo-history.js'; + +/** + * The personal work log — fifteen entries for two people, mostly private. + * + * These exist to show the module AND to demonstrate what it deliberately + * cannot do. Nothing here has a due date, a status, a completion or a period, + * so there is no measure anywhere that could pick these rows up: every dataset + * in `src/datasets/` is `object: 'duly_task'`. Fifteen entries for one person + * and none for another says nothing about either of them, by construction — + * which is the whole reason the work log is a separate object rather than a + * "private" flag on `duly_task`. + * + * Both log-keepers are chosen for that reason too. One is the account + * `objectstack dev` logs you in as, so the Work log screen — owner-scoped, + * like every other personal view — is not blank on first boot. + * + * ── No `related_task`, and it is a mechanism rather than a preference ──── + * `duly_task`'s seed datasets are keyed on composites (`(duty, owner, + * period_key)` and `(subject, owner)`), and the loader can only resolve a + * natural key into an object through a single string field, falling back to a + * `name` column that `duly_task` does not declare. So a `related_task` value + * here would not resolve; it would be deferred to pass 2, fail there too, and + * be dropped with a warning. Left out rather than left broken. See + * `task.seed.ts` for why those keys are what they are. + */ + +/** `TODAY` shifted back by `days`, through the period engine's own civil-date shift. */ +const daysAgo = (days: number): string => visibleFromFor(TODAY, days); + +interface DemoLogEntry { + subject: string; + owner: string; + daysAgo: number; + category: 'coordination' | 'drafting' | 'incident' | 'meeting' | 'support' | 'other'; + visibility: 'private' | 'manager'; + detail?: string; +} + +/** Subjects are unique across the fixture — `subject` is this dataset's external id. */ +const ENTRIES: readonly DemoLogEntry[] = [ + // ── The account you are logged in as ────────────────────────────────── + { subject: 'Walked the new starter through the permit register', owner: ADMIN, daysAgo: 2, category: 'support', visibility: 'private' }, + { subject: 'Rewrote the sampling instruction after the lab query', owner: ADMIN, daysAgo: 4, category: 'drafting', visibility: 'private', detail: 'The old wording let two people read the hold time differently. Now it names the clock.' }, + { subject: 'Chased the carrier for three missing transfer notes', owner: ADMIN, daysAgo: 6, category: 'coordination', visibility: 'private' }, + { subject: 'Standing call with the regulator liaison', owner: ADMIN, daysAgo: 9, category: 'meeting', visibility: 'manager' }, + { subject: 'Out-of-hours callout: effluent alarm on the north outfall', owner: ADMIN, daysAgo: 13, category: 'incident', visibility: 'manager', detail: 'False alarm on a blocked float. Logged with maintenance; no discharge event.' }, + { subject: 'Drafted the shutdown environmental brief', owner: ADMIN, daysAgo: 18, category: 'drafting', visibility: 'private' }, + { subject: 'Sat in on the Riverside permit review to compare approaches', owner: ADMIN, daysAgo: 25, category: 'meeting', visibility: 'private' }, + { subject: 'Half a day rebuilding the meter reading spreadsheet', owner: ADMIN, daysAgo: 33, category: 'other', visibility: 'private', detail: 'It had grown three tabs nobody owned. Now one tab, one owner.' }, + + // ── A second log-keeper, so the module is not a single-person screen ── + { subject: 'Recalibrated the bench balance after the move', owner: 'Rosa Delgado', daysAgo: 1, category: 'other', visibility: 'private' }, + { subject: 'Covered the goods-in checks while Ibrahim was on leave', owner: 'Rosa Delgado', daysAgo: 5, category: 'support', visibility: 'private' }, + { subject: 'Traced the drift on the pH probe back to the buffer batch', owner: 'Rosa Delgado', daysAgo: 8, category: 'incident', visibility: 'manager', detail: 'Buffer was out of date. Quarantined the batch and reran the affected checks.' }, + { subject: 'Wrote up the retained-sample disposal procedure', owner: 'Rosa Delgado', daysAgo: 12, category: 'drafting', visibility: 'private' }, + { subject: 'Lab handover meeting with the night shift', owner: 'Rosa Delgado', daysAgo: 16, category: 'meeting', visibility: 'private' }, + { subject: 'Helped operations read the swab results', owner: 'Rosa Delgado', daysAgo: 22, category: 'support', visibility: 'private' }, + { subject: 'Sorted the supplier certificate folder into something findable', owner: 'Rosa Delgado', daysAgo: 30, category: 'coordination', visibility: 'private' }, +]; + +export const logEntrySeed = defineSeed(LogEntry, { + externalId: 'subject', + mode: 'upsert', + records: ENTRIES.map((entry) => ({ + subject: entry.subject, + detail: entry.detail, + owner: entry.owner, + logged_on: daysAgo(entry.daysAgo), + category: entry.category, + visibility: entry.visibility, + })), +}); diff --git a/src/data/org.seed.ts b/src/data/org.seed.ts new file mode 100644 index 0000000..402b3ff --- /dev/null +++ b/src/data/org.seed.ts @@ -0,0 +1,94 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Seed } from '@objectstack/spec/data'; + +import { ADMIN, PEOPLE, UNITS, emailOf } from './demo-org.js'; + +/** + * The org: `sys_business_unit` and `sys_user`. + * + * ── Why these two are plain `Seed` literals and not `defineSeed(...)` ───── + * `defineSeed` infers its record keys from an `ObjectSchema`, and both objects + * here are the PLATFORM's, declared in `@objectstack/platform-objects`, not in + * `src/objects/`. There is no schema in this repo to hand it. Inventing a local + * stand-in to satisfy the signature would be worse than typing the literal: it + * would read as this app's description of a table it does not own, and would + * silently stop matching the day the platform adds a column. Every `duly_*` + * dataset in this directory does use `defineSeed`. + * + * Field names are the platform's actual ones (`manager_id`, + * `primary_business_unit_id`, `manager_user_id`, `parent_business_unit_id`) — + * not guesses. `sys_user` has no `username` column, so there is none here. + * + * ── These must be seeded FIRST, and it is not a style preference ────────── + * `duly_task.owner` and `duly_duty.owner` are `Field.user` references, and the + * seed loader resolves them as NATURAL KEYS against `sys_user.name`. A name + * that matches no row does not resolve; `owner` is `required: true`; the whole + * task row is refused with `Owner is required`. Measured on #32: without the + * user dataset, `inserted: 0, errored: 4`. The loader's own topological sort + * puts these ahead of `duly_*` anyway (both are reference targets), but the + * barrel lists them first so the ordering is legible without knowing that. + * + * ── One asymmetry worth knowing before you read a test ──────────────────── + * References FROM a `duly_*` object INTO these two always resolve: the + * reference is declared on the `duly_*` schema, which this app owns, so the + * loader looks the target up in the database by name and finds it. + * + * References BETWEEN these two — `sys_user.manager_id`, + * `sys_user.primary_business_unit_id`, `sys_business_unit.manager_user_id` and + * `parent_business_unit_id` — only resolve where the platform objects are + * actually registered. Under `objectstack dev` they are (`serve` mounts + * `PlatformObjectsPlugin`), so the org chart and the manager chain link up. + * Under the bare `createStandaloneStack` kernel the vitest suites boot, they + * are not registered at all: the loader finds no field definitions for + * `sys_user`, builds no reference list for it, and writes the natural key + * through verbatim. That is why `test/seed.test.ts` asserts the manager chain + * from THIS module rather than from the seeded rows — the fixture is the + * contract; what a reference column resolves to is the runtime's business. + */ + +/** Three levels: one company, three sites, two teams under one of them. */ +export const businessUnitSeed: Seed = { + object: 'sys_business_unit', + externalId: 'name', + // Re-running the seed over a populated database must not duplicate. `upsert` + // matches on the natural key above and updates in place; the loader's + // no-op-replay check skips the write entirely when nothing has changed. + mode: 'upsert', + records: UNITS.map((unit) => ({ + name: unit.name, + code: unit.code, + kind: unit.kind, + parent_business_unit_id: unit.parent, + // Set at EVERY level, which is the point of seeding a tree at all: an + // ADR-0057 hierarchy scope has nothing to resolve against a tree whose + // nodes have no head. + manager_user_id: unit.manager, + active: true, + })), +}; + +/** + * Twelve people, plus the account you are logged in as. + * + * ⚠️ The `Dev Admin` row carries its natural key and nothing else. On a real + * `objectstack dev` boot that account already exists — `plugin-auth` seeds it + * before the app plugin starts — so the loader matches it by name, finds the + * one field the seed declares unchanged, and SKIPS without writing. Adding an + * `email` or a `manager_id` here would turn that skip into an UPDATE against a + * live credential-bearing account. See `demo-org.ts` for the full reasoning. + */ +export const userSeed: Seed = { + object: 'sys_user', + externalId: 'name', + mode: 'upsert', + records: [ + { name: ADMIN }, + ...PEOPLE.map((person) => ({ + name: person.name, + email: emailOf(person.name), + manager_id: person.manager, + primary_business_unit_id: person.unit, + })), + ], +}; diff --git a/src/data/task.seed.ts b/src/data/task.seed.ts new file mode 100644 index 0000000..6ba8751 --- /dev/null +++ b/src/data/task.seed.ts @@ -0,0 +1,147 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineSeed } from '@objectstack/spec/data'; + +import { Task } from '../objects/task.object.js'; + +import { unitOf } from './demo-org.js'; +import { AD_HOC_TASKS } from './demo-assignments.js'; +import { SEEDED_TASKS } from './demo-history.js'; + +/** + * The tasks — six months of history, today's in-flight work, and the backdate + * pass that makes "Not moving" mean something. + * + * ── Four datasets, and each one is load-bearing ────────────────────────── + * + * 1. {@link taskHistorySeed} the dispatched series, `mode: 'upsert'` + * 2. {@link taskAdHocSeed} assignment fan-out + the one-off, `upsert` + * 3. {@link taskHistoryTouchSeed} `mode: 'update'` — `last_update_at` only + * 4. {@link taskAdHocTouchSeed} the same, for the ad-hoc rows + * + * Datasets 3 and 4 are the half that is easy to leave out, and leaving them + * out fails SILENTLY. `completed_at` rides along on a system-context insert, + * so `done` history writes in one pass. `last_update_at` cannot: + * `task.hook.ts`'s `beforeInsert` stamps it unconditionally, and lifecycle + * hooks DO run on the seed path (`skipTriggers` suppresses record-change + * automation, not hooks), so every value supplied on the insert is overwritten + * with the boot clock. A second pass in `mode: 'update'`, matched on the same + * external id and carrying ONLY `last_update_at`, lands — because the + * `beforeUpdate` leg deliberately does not stamp on an administrative write. + * + * Skip passes 3 and 4 and every task in the database reads as touched at boot: + * "Not moving" is empty, "Recent activity" is one flat spike, and the seed + * reports complete success. That is the failure mode, and it is why the + * external ids below have to be exactly right — a key that does not match is + * indistinguishable from a pass that never ran. Established and pinned in #32 + * / PR #64; the worked shape is in `AGENTS.md`. + * + * ── Why the two insert datasets use DIFFERENT external ids ─────────────── + * An external id has to identify a row uniquely, or a re-run cannot tell an + * existing row from a new one and `upsert` silently duplicates. The two + * populations have genuinely different identities: + * + * dispatched `(duty, owner, period_key)` — the dispatch identity itself, + * the same triple `duly_task_dispatch_identity` is unique on. A + * task's subject repeats across every period of its duty, so + * `subject` alone would collide six times over. + * ad-hoc `(subject, owner)` — an assignment fan-out has NO `period_key` + * and no `duty`, so the triple above collapses to an empty key + * and matches nothing. Its four rows share one subject and are + * told apart by owner, which is exactly how the flow creates + * them. + * + * A composite key is empty — and therefore matches nothing, and inserts again + * on every boot — the moment any part of it is blank. That is the trap; each + * dataset uses the key that is total over its own rows. + * + * ── Consequence for `duly_log_entry.related_task` ──────────────────────── + * Because neither key is a single string field, the loader cannot resolve a + * natural key INTO `duly_task` (it falls back to probing a `name` column, + * which this object does not have). Log entries therefore carry no + * `related_task`; see `log-entry.seed.ts`. + */ + +const HISTORY_EXTERNAL_ID = ['duty', 'owner', 'period_key']; +const AD_HOC_EXTERNAL_ID = ['subject', 'owner']; + +/** + * Pass 1 — the dispatched series, as `planDispatch` produced it. + * + * ⚠️ `source` is written explicitly on every row. Since #54 it defaults to + * `self`, and every dataset measure filters to `catalog` + `assigned` + * (`src/datasets/governed.ts`) — so relying on the default would land the + * whole history unscored and read zero on every dashboard measure, with + * nothing erroring. + */ +export const taskHistorySeed = defineSeed(Task, { + externalId: HISTORY_EXTERNAL_ID, + mode: 'upsert', + records: SEEDED_TASKS.map((task) => ({ + subject: task.subject, + duty: task.duty, + owner: task.owner, + business_unit: task.business_unit, + source: task.source, + period_key: task.period_key, + due_date: task.due_date, + visible_from: task.visible_from, + status: task.status, + // Carried on the INSERT, from the seed loader's system context — the leg + // that is exempt from the readonly strip. A caller's identical write is + // still refused by `completed_at_required_when_done`, and + // `test/seed-history.test.ts` pins both halves. + completed_at: task.completed_at, + skip_reason: task.skip_reason, + note: task.note, + })), +}); + +/** Pass 2 — the assignment fan-out and the one-off duty's task. */ +export const taskAdHocSeed = defineSeed(Task, { + externalId: AD_HOC_EXTERNAL_ID, + mode: 'upsert', + records: AD_HOC_TASKS.map((task) => ({ + subject: task.subject, + duty: task.duty, + owner: task.owner, + business_unit: unitOf(task.owner), + assignment: task.assignment, + source: task.source, + due_date: task.dueDate, + visible_from: task.visibleFrom, + status: task.status, + completed_at: task.completedAt, + note: task.note, + })), +}); + +/** + * Pass 3 — backdate the dispatched series. + * + * Carries the external id (so the row can be found) and `last_update_at`, and + * nothing else. Deliberately nothing else: adding `status`, `note` or + * `skip_reason` here would put the hook's stamping leg back in play and + * overwrite the value this pass exists to set. + */ +export const taskHistoryTouchSeed = defineSeed(Task, { + externalId: HISTORY_EXTERNAL_ID, + mode: 'update', + records: SEEDED_TASKS.map((task) => ({ + duty: task.duty, + owner: task.owner, + period_key: task.period_key, + last_update_at: task.last_update_at, + })), +}); + +/** Pass 4 — the same, for the ad-hoc rows. */ +export const taskAdHocTouchSeed = defineSeed(Task, { + externalId: AD_HOC_EXTERNAL_ID, + mode: 'update', + records: AD_HOC_TASKS.map((task) => ({ + subject: task.subject, + owner: task.owner, + last_update_at: task.lastUpdateAt, + })), +}); diff --git a/test/seed.test.ts b/test/seed.test.ts new file mode 100644 index 0000000..4a99e17 --- /dev/null +++ b/test/seed.test.ts @@ -0,0 +1,470 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { AppPlugin, ObjectKernel, SeedLoaderService, createStandaloneStack } from '@objectstack/runtime'; +import { SeedLoaderRequestSchema } from '@objectstack/spec/data'; + +import stack from '../objectstack.config.js'; +import { FREQUENCIES, periodBounds, periodKeyFor, type Frequency } from '../src/functions/period.js'; +import { ADMIN, PEOPLE, UNITS } from '../src/data/demo-org.js'; +import { CATALOG_ITEMS, DUTIES } from '../src/data/demo-catalog.js'; +import { AD_HOC_TASKS, ASSIGNMENTS } from '../src/data/demo-assignments.js'; +import { SEEDED_TASKS, SKIPS, TODAY } from '../src/data/demo-history.js'; +import { dulySeeds } from '../src/data/index.js'; + +/** + * The demo seed, asserted against a REAL BOOTED KERNEL with the declarative + * seeder actually running — not against the fixture arrays. + * + * That distinction is the whole point of this suite. Everything in + * `src/data/demo-*.ts` is plain TypeScript and would pass any assertion made + * about it whether or not a single row ever reached the database. The failures + * this card exists to prevent all live on the other side of the loader: + * + * - a task refused because its `owner` natural key resolved to nothing + * (`Owner is required`, 0 inserted — measured on #32); + * - a `done` task refused because `completed_at` never made it past the + * readonly strip; + * - a `last_update_at` overwritten with the boot clock because the second + * seed pass was missing or its external id did not match — which empties + * the "Not moving" view while the seed reports complete success; + * - a standing or one-off row refused by #61's cadence rules, taking every + * duty and task under it with it. + * + * Every one of those reports success somewhere. So this suite reads the rows + * back. + * + * ── The acceptance criteria this pins, in the card's own words ──────────── + * "every view in the app renders non-trivial content" → `view populations` + * "My week, Late, Not moving and Calendar are each non-empty" → `view populations` + * "no duly_task exists whose duty is form: 'standing'" → `the standing invariant` + * "re-running the seed on a populated DB does not duplicate" → `idempotence` + */ + +const SYSTEM = { isSystem: true } as const; +const DAY = 24 * 60 * 60 * 1000; + +let kernel: any; +let data: any; +let ql: any; +let metadata: any; + +/** Every row of an object, read past RLS. */ +const all = async (object: string): Promise => + (await data.find(object, {}, { context: SYSTEM })) ?? []; + +const count = async (object: string): Promise => (await all(object)).length; + +/** The `sys_user.id` behind a seeded display name. */ +const userId = async (name: string): Promise => { + const rows = await data.find('sys_user', { where: { name } }, { context: SYSTEM }); + expect(rows?.length, `exactly one sys_user named ${name}`).toBe(1); + return String(rows[0].id); +}; + +beforeAll(async () => { + const { plugins } = await createStandaloneStack({ + databaseDriver: 'memory', + skipSeedData: true, + // Same guard as `test/task-hook.test.ts` and `test/seed-history.test.ts`: + // point the artifact lookup at a path that cannot exist, or a local + // `pnpm build` leaves `dist/objectstack.json` where the kernel loads + // metadata — objects, hooks AND the compiled seed — from the last BUILD + // rather than from the config imported above. + artifactPath: 'dist/objectstack.this-suite-must-not-load-an-artifact.json', + }); + kernel = new ObjectKernel(); + for (const plugin of plugins) await kernel.use(plugin); + // skipSeedData FALSE, and `stack` unmodified — the app's own `dulySeeds` + // going through the platform's own loader is exactly what is under test. + await kernel.use(new AppPlugin(stack as any, undefined, { skipSeedData: false })); + await kernel.bootstrap(); + data = kernel.getService('data'); + ql = kernel.getService('objectql'); + metadata = kernel.getService('metadata'); + + // The inline seed is raced against a budget (8s by default) rather than + // awaited by bootstrap, and continues in the background when it loses. So + // wait for the LAST dataset in the barrel to have landed rather than for a + // fixed delay — `duly_log_entry` sorts last both in the barrel and in the + // loader's topological order, and the two `mode: 'update'` backdate passes + // run before it. + const deadline = Date.now() + 120_000; + for (;;) { + const logs = await count('duly_log_entry'); + if (logs >= 15) break; + if (Date.now() > deadline) throw new Error(`seed did not settle: ${logs} log entries after 120s`); + await new Promise((resolve) => setTimeout(resolve, 250)); + } +}, 180_000); + +afterAll(async () => { + await kernel?.shutdown?.(); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('the seed lands', () => { + it('writes every object the demo needs', async () => { + expect(await count('sys_business_unit'), 'three-level unit tree').toBe(UNITS.length); + // Twelve people plus the account `objectstack dev` logs you in as. + expect(await count('sys_user')).toBe(PEOPLE.length + 1); + expect(await count('duly_catalog_item')).toBe(CATALOG_ITEMS.length); + expect(await count('duly_duty')).toBe(DUTIES.length); + expect(await count('duly_assignment')).toBe(ASSIGNMENTS.length); + expect(await count('duly_log_entry')).toBe(15); + }); + + it('seeds every task, none refused', async () => { + // The specific failure this guards: `duly_task.owner` is resolved as a + // natural key against `sys_user.name`, and a miss is not a dropped field — + // `owner` is required, so the WHOLE row is refused. #32 measured that as + // `inserted: 0, errored: 4`. A silently short count is what it looks like. + const tasks = await all('duly_task'); + expect(tasks.length).toBe(SEEDED_TASKS.length + AD_HOC_TASKS.length); + expect(tasks.every((task) => Boolean(task.owner)), 'every task resolved an owner').toBe(true); + }); + + it('every task states its caliber explicitly, and both calibers are present', async () => { + // Since #54 `source` defaults to `self`, and every dataset measure filters + // to catalog+assigned. A seed that leaned on the default would land wholly + // unscored — every dashboard measure zero, nothing erroring. + const tasks = await all('duly_task'); + const bySource = new Map(); + for (const task of tasks) bySource.set(task.source, (bySource.get(task.source) ?? 0) + 1); + expect([...bySource.keys()].sort()).toEqual(['assigned', 'catalog', 'self']); + const governed = (bySource.get('catalog') ?? 0) + (bySource.get('assigned') ?? 0); + expect(governed, 'the governed population every dataset measure reads').toBeGreaterThan(100); + // And self-declared work exists too, or the caliber split is invisible. + expect(bySource.get('self')).toBeGreaterThan(0); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('view populations — what an evaluator actually opens', () => { + /** + * These mirror the filters in `src/views/task.view.ts` field for field. They + * are not a restatement of the fixture: each one asks the database the same + * question the rendered view asks it, so an empty answer here is an empty + * screen there. + */ + + it('My week is not empty for the account you log in as', async () => { + // `my_week`: owner = {current_user_id}, status in (open, in_progress), + // visible_from <= {today}. `objectstack dev` logs you in as `Dev Admin`, + // so a demo whose data belongs to twelve other people renders this blank. + const me = await userId(ADMIN); + const mine = (await all('duly_task')).filter( + (task) => + String(task.owner) === me && + ['open', 'in_progress'].includes(task.status) && + String(task.visible_from) <= TODAY, + ); + expect(mine.length, 'My week must have content for the seeded admin').toBeGreaterThanOrEqual(3); + }); + + it('Late has 3-4 rows, as the card asks', async () => { + // `late`: due_date < {today} AND status in (open, in_progress). + const late = (await all('duly_task')).filter( + (task) => task.due_date && String(task.due_date) < TODAY && ['open', 'in_progress'].includes(task.status), + ); + expect(late.length).toBeGreaterThanOrEqual(3); + expect(late.length).toBeLessThanOrEqual(4); + }); + + it('Not moving has 2-3 rows — and the second seed pass is what puts them there', async () => { + // `stalled`: status in (open, in_progress) AND last_update_at < {14_days_ago}. + // + // This is the assertion that catches a missing or mis-keyed backdate pass. + // `beforeInsert` stamps `last_update_at` unconditionally and hooks run on + // the seed path, so WITHOUT the `mode: 'update'` datasets every row here + // reads as touched at boot, this count is 0, and nothing anywhere errors. + const threshold = Date.now() - 14 * DAY; + const stalled = (await all('duly_task')).filter( + (task) => + ['open', 'in_progress'].includes(task.status) && + task.last_update_at && + new Date(task.last_update_at as string).getTime() < threshold, + ); + expect(stalled.length, 'an empty "Not moving" view reads as a healthy team').toBeGreaterThanOrEqual(2); + expect(stalled.length).toBeLessThanOrEqual(3); + }); + + it('stagnation is NOT lateness — at least one stalled row is not yet due', async () => { + // The product's central claim: stagnation fires while intervening is still + // cheap, weeks before a due date makes the failure obvious. If every + // stalled row were also late, the two views would be one view with a + // different sort and the claim would be decoration. + const threshold = Date.now() - 14 * DAY; + const stalledNotLate = (await all('duly_task')).filter( + (task) => + ['open', 'in_progress'].includes(task.status) && + task.last_update_at && + new Date(task.last_update_at as string).getTime() < threshold && + String(task.due_date) >= TODAY, + ); + expect(stalledNotLate.length).toBeGreaterThanOrEqual(1); + }); + + it('Calendar, Schedule and Board all have something to draw', async () => { + const tasks = await all('duly_task'); + // `calendar` binds startDateField: 'due_date'. + expect(tasks.filter((task) => task.due_date).length).toBeGreaterThan(100); + // `schedule` (gantt) filters visible_from IS NOT NULL AND due_date IS NOT + // NULL — a row missing either draws nothing, so it must be a real + // population and not an accident of the filter. + expect(tasks.filter((task) => task.visible_from && task.due_date).length).toBeGreaterThan(100); + // `board` (kanban) groups by status. A board with one column is not a + // board; the seed has to populate several. + const statuses = new Set(tasks.map((task) => task.status)); + expect([...statuses].sort()).toEqual(['cancelled', 'done', 'in_progress', 'open', 'skipped']); + }); + + it('the majority of history is done, so the picture is plausible', async () => { + const tasks = await all('duly_task'); + const done = tasks.filter((task) => task.status === 'done'); + expect(done.length / tasks.length).toBeGreaterThan(0.5); + // Not 100% either — a perfect record reads as fabricated, and the Late and + // Not-moving views would have nothing in them. + expect(done.length / tasks.length).toBeLessThan(0.95); + }); + + it('one task is skipped, and it says why', async () => { + const skipped = (await all('duly_task')).filter((task) => task.status === 'skipped'); + expect(skipped.length).toBe(1); + // `skip_needs_reason` still runs on the seed path (`seedReplay` skips only + // state_machine rules), so a reasonless skip would have been refused — but + // assert the reason is a real answer rather than a placeholder. + expect(String(skipped[0].skip_reason).length).toBeGreaterThan(20); + }); + + it('every done task carries the completion instant the seed supplied', async () => { + // `completed_at` is readonly and there is no writer for it on the insert + // path. It lands only because the seed loader writes under + // `{ isSystem: true }`. If that ever stops being true this goes red here + // rather than as a wall of validation errors at boot. + const done = (await all('duly_task')).filter((task) => task.status === 'done'); + expect(done.length).toBeGreaterThan(100); + expect(done.every((task) => Boolean(task.completed_at))).toBe(true); + // And they are historical, not stamped at boot. + const oldest = Math.min(...done.map((task) => new Date(task.completed_at as string).getTime())); + expect(Date.now() - oldest, 'history should reach back months').toBeGreaterThan(120 * DAY); + }); + + it('unit rollups differ from each other', async () => { + const byUnit = new Map(); + for (const task of await all('duly_task')) { + const unit = String(task.business_unit ?? ''); + byUnit.set(unit, (byUnit.get(unit) ?? 0) + 1); + } + expect(byUnit.size, 'several units must carry work').toBeGreaterThanOrEqual(3); + // Distinct totals, or "By business unit" is a grid of identical numbers. + expect(new Set(byUnit.values()).size).toBeGreaterThan(1); + }); + + it('the Role catalog reads as an audit answer, not a to-do list', async () => { + const items = await all('duly_catalog_item'); + expect(items.length).toBe(20); + expect(new Set(items.map((item) => item.position_code)).size).toBe(3); + const withReference = items.filter((item) => item.regulation_ref); + expect(withReference.length / items.length, 'most items cite the clause they discharge').toBeGreaterThan(0.9); + }); + + it('the work log is present, mostly private, and attached to no metric', async () => { + const entries = await all('duly_log_entry'); + expect(entries.length).toBe(15); + expect(new Set(entries.map((entry) => String(entry.owner))).size).toBe(2); + const priv = entries.filter((entry) => entry.visibility === 'private'); + expect(priv.length / entries.length).toBeGreaterThan(0.5); + // Every dataset in `src/datasets/` is `object: 'duly_task'`, so nothing + // here can enter a measure. Assert the rows carry nothing scoreable + // either — no link into the governed population at all. + expect(entries.every((entry) => !entry.related_task)).toBe(true); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('the standing invariant', () => { + it('no task exists whose duty is standing', async () => { + const standing = (await all('duly_duty')).filter((duty) => duty.form === 'standing'); + expect(standing.length, 'the fixture must actually contain standing duties').toBeGreaterThanOrEqual(2); + const ids = new Set(standing.map((duty) => String(duty.id))); + const offenders = (await all('duly_task')).filter((task) => ids.has(String(task.duty))); + expect(offenders.map((task) => task.subject)).toEqual([]); + }); + + it('and the planner is WHY, not luck', async () => { + // The rows above being absent could equally mean the fixture happens not + // to mention them. It cannot: every task comes out of `planDispatch`, and + // `planForDuty` refuses a standing duty by form before reading anything + // else. The planner's own skip reasons are the proof. + const standingNames = DUTIES.filter((duty) => { + const seeded = SKIPS.find((skip) => skip.duty === duty.name); + return seeded?.reason === 'standing'; + }).map((duty) => duty.name); + expect(standingNames.length).toBeGreaterThanOrEqual(2); + for (const name of standingNames) { + expect(SEEDED_TASKS.some((task) => task.duty === name), `${name} produced a draft`).toBe(false); + } + // The paused duty holds none either, for its own reason. + expect(SKIPS.some((skip) => skip.reason === 'not_active')).toBe(true); + }); + + it('standing rows carry no cadence at all, and one-off carries no due timing (#61)', async () => { + const blank = (value: unknown) => value === null || value === undefined || value === ''; + for (const object of ['duly_catalog_item', 'duly_duty']) { + for (const row of await all(object)) { + if (row.form === 'standing') { + for (const field of ['frequency', 'due_anchor', 'due_offset_days', 'lead_days', 'grace_days']) { + expect(blank(row[field]), `${object} ${row.name}.${field} on a standing row`).toBe(true); + } + } + if (row.form === 'one_off') { + for (const field of ['due_anchor', 'due_offset_days', 'lead_days']) { + expect(blank(row[field]), `${object} ${row.name}.${field} on a one-off row`).toBe(true); + } + } + } + } + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('period keys come from the engine', () => { + it('every seeded key round-trips through periodKeyFor', async () => { + // The card's hardest rule, checked the only way that actually proves it: + // take each key back to the period it names, and ask the engine to spell + // that period. A hand-typed `2026-W4` does not survive this — the engine + // says `2026-W04`, and `duly_task` being unique on + // `(duty, owner, period_key)` makes those two different obligations. + const duties = new Map((await all('duly_duty')).map((duty) => [String(duty.id), duty])); + const keyed = (await all('duly_task')).filter((task) => task.period_key); + expect(keyed.length).toBeGreaterThan(100); + + for (const task of keyed) { + const duty = duties.get(String(task.duty)); + expect(duty, `task ${task.subject} resolved its duty`).toBeTruthy(); + const frequency = duty.frequency as Frequency; + expect(FREQUENCIES).toContain(frequency); + const timezone = String(duty.timezone ?? 'UTC'); + const key = String(task.period_key); + const bounds = periodBounds(frequency, key, timezone); + expect(periodKeyFor(frequency, bounds.start, timezone), `${task.subject} / ${key}`).toBe(key); + } + }); + + it('an assignment task has no period, because an assignment has none', async () => { + const assignments = new Set((await all('duly_assignment')).map((row) => String(row.id))); + const fanOut = (await all('duly_task')).filter((task) => assignments.has(String(task.assignment))); + expect(fanOut.length).toBe(AD_HOC_TASKS.filter((task) => task.assignment).length); + expect(fanOut.every((task) => !task.period_key)).toBe(true); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('assignments', () => { + it('one fans out to four people with mixed completion', async () => { + const rows = await all('duly_assignment'); + const readiness = rows.find((row) => row.subject === ASSIGNMENTS[0]!.subject); + expect(readiness).toBeTruthy(); + const children = (await all('duly_task')).filter((task) => String(task.assignment) === String(readiness.id)); + expect(children.length).toBe(4); + // Four independent rows, four owners — never one shared row with four + // names on it. + expect(new Set(children.map((task) => String(task.owner))).size).toBe(4); + expect(new Set(children.map((task) => task.status)).size).toBeGreaterThanOrEqual(3); + }); + + it('needs_collection is what gives the assigner a task, and only that', async () => { + const rows = await all('duly_assignment'); + const sweep = rows.find((row) => row.subject === ASSIGNMENTS[1]!.subject); + const readiness = rows.find((row) => row.subject === ASSIGNMENTS[0]!.subject); + expect(Boolean(sweep.needs_collection)).toBe(true); + expect(Boolean(readiness.needs_collection)).toBe(false); + + const tasks = await all('duly_task'); + const sweepChildren = tasks.filter((task) => String(task.assignment) === String(sweep.id)); + // Two assignees plus the assigner's own follow-up. + expect(sweepChildren.length).toBe(3); + expect(sweepChildren.some((task) => String(task.owner) === String(sweep.assigner))).toBe(true); + + // And the assignment that did NOT tick it gives its assigner nothing — + // a manager who hands out work does not inherit a to-do list from it. + const readinessChildren = tasks.filter((task) => String(task.assignment) === String(readiness.id)); + expect(readinessChildren.some((task) => String(task.owner) === String(readiness.assigner))).toBe(false); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('idempotence', () => { + it('re-running the whole seed over a populated database duplicates nothing', async () => { + // The card's fourth acceptance criterion, exercised through the platform's + // own loader with the same config the boot uses — not a proxy for it. + const objects = [ + 'sys_business_unit', + 'sys_user', + 'duly_catalog_item', + 'duly_duty', + 'duly_assignment', + 'duly_task', + 'duly_log_entry', + ]; + const before = new Map(); + for (const object of objects) before.set(object, await count(object)); + + const loader = new SeedLoaderService(ql, metadata, kernel.logger ?? console); + const request = SeedLoaderRequestSchema.parse({ + seeds: dulySeeds as any, + config: { defaultMode: 'upsert', multiPass: true }, + }); + const result = await loader.load(request); + expect(result.summary.totalInserted, 'a replay must insert nothing').toBe(0); + // "Inserted nothing" is only meaningful alongside "and it really ran". + // An env filter that dropped every dataset, or a request the loader + // refused, would also report zero inserts — and would prove nothing. + const declared = dulySeeds.reduce((total, dataset) => total + dataset.records.length, 0); + expect(result.summary.totalRecords, 'the replay must have walked every row').toBe(declared); + expect(result.summary.totalErrored).toBe(0); + + for (const object of objects) { + expect(await count(object), `${object} after replay`).toBe(before.get(object)); + } + }, 180_000); + + it('and the replay leaves the stalled rows stalled', async () => { + // The subtler half. A replay that re-INSERTED nothing but re-STAMPED + // `last_update_at` would leave the counts identical and the "Not moving" + // view empty — the same silent failure as omitting the backdate pass, + // arriving one boot later. + const threshold = Date.now() - 14 * DAY; + const stalled = (await all('duly_task')).filter( + (task) => + ['open', 'in_progress'].includes(task.status) && + task.last_update_at && + new Date(task.last_update_at as string).getTime() < threshold, + ); + expect(stalled.length).toBeGreaterThanOrEqual(2); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('nothing real is named', () => { + it('every seeded address is on a domain that cannot exist', async () => { + // RFC 2606 reserves `.example`. A demo seed is screenshotted and pasted + // into decks; a plausible-looking real domain in one eventually becomes a + // claim about a real organisation. + const addressed = (await all('sys_user')).filter((user) => user.email); + expect(addressed.length).toBe(PEOPLE.length); + expect(addressed.every((user) => String(user.email).endsWith('.example'))).toBe(true); + }); + + it('the account you log in as is left alone apart from its name', () => { + // The `Dev Admin` row exists so `owner: 'Dev Admin'` resolves in a kernel + // with no auth plugin. On a real `objectstack dev` boot that account is + // already there with credentials attached, and the loader's no-op-replay + // check must SKIP it rather than update it — which it only does while the + // seed record declares nothing but the field it is matched on. + const seed = dulySeeds.find((dataset) => dataset.object === 'sys_user'); + const admin = seed!.records.find((record: any) => record.name === ADMIN); + expect(Object.keys(admin as object)).toEqual(['name']); + }); +});