From 91ba3c91e5e9e11bfcd2ade0f5f7acff2bb1bb1f Mon Sep 17 00:00:00 2001 From: Warren Buffett Date: Tue, 1 Sep 2026 08:50:46 +0000 Subject: [PATCH 1/2] Seed the demo: the product working on first boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pnpm dev` on an empty database now opens on a running system rather than five empty grids — a three-level business-unit tree, thirteen people, a twenty-item role catalog across three position codes, thirty-one duties and six months of dispatched history. History is produced by the dispatcher's own planner (`planDispatch`) rather than by a second period walk, so every period key is the engine's spelling by construction and "standing duties hold zero tasks" is structurally impossible to violate rather than merely absent from the fixture. `last_update_at` is written by a second `mode: 'update'` seed pass, per #32 / PR #64 — an insert can never carry it, and without that pass the "Not moving" view is empty while the seed reports success. Fixes #7 Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p Co-Authored-By: Claude Opus 5 --- src/data/assignment.seed.ts | 37 +++ src/data/catalog.seed.ts | 41 +++ src/data/demo-assignments.ts | 210 ++++++++++++++++ src/data/demo-catalog.ts | 467 ++++++++++++++++++++++++++++++++++ src/data/demo-history.ts | 320 ++++++++++++++++++++++++ src/data/demo-org.ts | 145 +++++++++++ src/data/duty.seed.ts | 75 ++++++ src/data/index.ts | 76 +++++- src/data/log-entry.seed.ts | 81 ++++++ src/data/org.seed.ts | 94 +++++++ src/data/task.seed.ts | 147 +++++++++++ test/seed.test.ts | 470 +++++++++++++++++++++++++++++++++++ 12 files changed, 2162 insertions(+), 1 deletion(-) create mode 100644 src/data/assignment.seed.ts create mode 100644 src/data/catalog.seed.ts create mode 100644 src/data/demo-assignments.ts create mode 100644 src/data/demo-catalog.ts create mode 100644 src/data/demo-history.ts create mode 100644 src/data/demo-org.ts create mode 100644 src/data/duty.seed.ts create mode 100644 src/data/log-entry.seed.ts create mode 100644 src/data/org.seed.ts create mode 100644 src/data/task.seed.ts create mode 100644 test/seed.test.ts 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..703dd9f --- /dev/null +++ b/src/data/demo-assignments.ts @@ -0,0 +1,210 @@ +// 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 booting this app prints + * `record_change triggers are not bound`. The flow therefore does not fire — on + * a seeded assignment or on one created by hand in the UI. 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. When the + * trigger binding is fixed, the flow's 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..9fc62e3 --- /dev/null +++ b/src/data/demo-history.ts @@ -0,0 +1,320 @@ +// 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', +}; + +/** 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; + +/** + * 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]!, + last_update_at: + draft.duty === STALLED_LATE + ? untouchedSinceDispatch + : iso(new Date(NOW.getTime() - ((index % 9) + 1) * 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', + // Not stalled ⇒ touched inside the fortnight, spread across it so the + // Recent-activity timeline reads as a stream rather than one boot-time + // spike. Never earlier than the day the task was dispatched. + last_update_at: stalled + ? untouchedSinceDispatch + : iso(new Date(Math.max(NOW.getTime() - (index % 13) * DAY, dispatched.getTime()))), + }); +}; + +/** 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']); + }); +}); From e671ea1ef12b5ce0bcfdf9cf080980ae516f4141 Mon Sep 17 00:00:00 2001 From: Warren Buffett Date: Tue, 1 Sep 2026 09:03:39 +0000 Subject: [PATCH 2/2] Spread in-flight touch ages by how long a task has been open Keeps every non-designated row inside the fortnight while putting real values in the 7-to-14-day band, so the dashboard's nested >7d / >14d / >30d tiles read 6 / 3 / 2 rather than 3 / 3 / 2. Also corrects the fan-out comment after #72: the reason a seeded assignment does not fan out is the loader's own skipTriggers, not an unbound trigger. Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p Co-Authored-By: Claude Opus 5 --- src/data/demo-assignments.ts | 25 ++++++++++++--------- src/data/demo-history.ts | 42 ++++++++++++++++++++++++++++++------ 2 files changed, 50 insertions(+), 17 deletions(-) diff --git a/src/data/demo-assignments.ts b/src/data/demo-assignments.ts index 703dd9f..4a0c772 100644 --- a/src/data/demo-assignments.ts +++ b/src/data/demo-assignments.ts @@ -9,12 +9,16 @@ 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 booting this app prints - * `record_change triggers are not bound`. The flow therefore does not fire — on - * a seeded assignment or on one created by hand in the UI. 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. + * `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 @@ -22,10 +26,11 @@ import { NOW, TODAY } from './demo-history.js'; * 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. When the - * trigger binding is fixed, the flow's 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. + * 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. diff --git a/src/data/demo-history.ts b/src/data/demo-history.ts index 9fc62e3..c3394c7 100644 --- a/src/data/demo-history.ts +++ b/src/data/demo-history.ts @@ -171,6 +171,9 @@ const LATE_MOST_RECENT: Readonly> = { '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'; @@ -244,6 +247,31 @@ for (const [duty, series] of byDuty) { 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. * @@ -268,10 +296,15 @@ const resolveDraft = (draft: TaskDraft, index: number): SeededTask => { 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() - ((index % 9) + 1) * DAY)), + : iso(new Date(NOW.getTime() - CHASED_DAYS_AGO[index % CHASED_DAYS_AGO.length]! * DAY)), }); } if (isMostRecentPast && draft.duty === SKIPPED_MOST_RECENT) { @@ -307,12 +340,7 @@ const resolveDraft = (draft: TaskDraft, index: number): SeededTask => { return withNote({ ...draft, status: isInFlightHead && IN_PROGRESS_IN_FLIGHT.includes(draft.duty) ? 'in_progress' : 'open', - // Not stalled ⇒ touched inside the fortnight, spread across it so the - // Recent-activity timeline reads as a stream rather than one boot-time - // spike. Never earlier than the day the task was dispatched. - last_update_at: stalled - ? untouchedSinceDispatch - : iso(new Date(Math.max(NOW.getTime() - (index % 13) * DAY, dispatched.getTime()))), + last_update_at: stalled ? untouchedSinceDispatch : iso(new Date(NOW.getTime() - touchedDaysAgo(draft, dispatched, index) * DAY)), }); };