From c43a8a11b0b2e82b51c0ecbe0d1bd25507cf0eda Mon Sep 17 00:00:00 2001 From: Warren Date: Wed, 2 Sep 2026 04:38:48 +0000 Subject: [PATCH 1/3] Give the frontline a progress phrase, attachments and a card face MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `duly_task.progress` is four preset phrases plus the free-text `note` beside it, so reporting progress is one tap instead of a typed sentence; the hook stamps `last_update_at` when it changes, because picking a phrase is somebody working the task. `duly_task.attachments` is the platform file field, optional on every path and never a completion gate — pinned in the metadata and against a booted engine. The list, the board card and the record page's three sections are the deck's p7/p16/p17 layout. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p --- src/hooks/task.hook.ts | 24 ++++- src/objects/task.object.ts | 157 +++++++++++++++++++++++++++++- src/translations/authored-text.ts | 32 ++++++ src/translations/zh-CN.ts | 26 ++++- src/views/task.view.ts | 114 +++++++++++++++++++++- test/invariants.test.ts | 84 ++++++++++++++++ test/task-hook.test.ts | 113 +++++++++++++++++++++ test/views.test.ts | 71 ++++++++++++++ 8 files changed, 608 insertions(+), 13 deletions(-) diff --git a/src/hooks/task.hook.ts b/src/hooks/task.hook.ts index 4299013..51c6b43 100644 --- a/src/hooks/task.hook.ts +++ b/src/hooks/task.hook.ts @@ -171,7 +171,7 @@ import type { Hook, HookContext } from '@objectstack/spec/data'; * * ── The same path and `last_update_at`: write nothing, do not refuse ───── * The stagnation stamp is row-conditional too, in the other direction: it - * fires when THIS row's `status`, `note` or `skip_reason` differs from THIS + * fires when THIS row's `status`, `progress`, `note` or `skip_reason` differs from THIS * row's pre-image. D3 applies unchanged — that value goes into the one shared * payload and is written to every matched row, so a single genuine edit inside * a 200-row bulk write refreshes all 200 clocks and the stalled list quietly @@ -389,6 +389,23 @@ const stampTaskLifecycle = (ctx: HookContext): void => { // radius; do it only for something a person changes BECAUSE they worked the // task. // + // `progress` (#108) is on the list for exactly that reason, and it is the + // clearest case of it: the column exists so that reporting progress is ONE + // TAP instead of a typed sentence, and the phrase a person picks is a person + // saying "I am on this". Leaving it off would have made the product's own + // headline gesture the one interaction that does not count as movement — a + // task nudged every week would keep drifting into "Not moving", which is the + // list a manager is supposed to trust. It is the same category as `note` + // beside it (a human sentence about the work) and NOT the category of + // `owner` or `due_date` (somebody administering the row). + // + // ⛔ `attachments` is deliberately NOT here, and the asymmetry is the point. + // A file arriving is real, but the column is optional by product invariant + // and nothing may make it feel otherwise; an upload that silently reset the + // stagnation clock would turn "attach something" into the cheapest way to + // look busy, which is one step from the evidence gate the invariant forbids. + // A person who attaches a file and means it has a phrase to pick beside it. + // // Compared against the pre-image rather than merely tested for presence: a // re-save carrying an unchanged `status` is not progress. // ── …and nothing at all on the shared-payload path ───────────────────── @@ -407,7 +424,7 @@ const stampTaskLifecycle = (ctx: HookContext): void => { // in `test/bulk-stagnation-premise.test.ts`. if (ctx.dispatch?.mode === 'per-row') return; - for (const field of ['status', 'note', 'skip_reason']) { + for (const field of ['status', 'progress', 'note', 'skip_reason']) { if (!(field in input)) continue; const next = input[field]; const prior = previous[field]; @@ -428,7 +445,8 @@ export const TaskLifecycleHook: Hook = { description: 'Server-owned columns on duly_task: completed_at and the completed_late verdict on the ' + 'transition into and out of done, late_after filled at insert for the paths the ' - + 'dispatcher does not stamp, and last_update_at only when status, note or skip_reason ' + + 'dispatcher does not stamp, and last_update_at only when status, progress, note or ' + + 'skip_reason ' + 'actually changed — never on an administrative write, which would reset the stagnation ' + 'signal. Both lateness stamps are write-once: a later change to the duty\'s grace never ' + 'moves them. On a predicate (bulk) write the row-conditional stamps are handled by ' diff --git a/src/objects/task.object.ts b/src/objects/task.object.ts index 2d80d78..3aac6d9 100644 --- a/src/objects/task.object.ts +++ b/src/objects/task.object.ts @@ -26,7 +26,36 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * category as `completed_at` and `visible_from`, which sit beside them. * * `progress_percent`: a number nobody can verify, which becomes the number - * everyone reports on. Progress lives in `status` and in `last_update_at`. + * everyone reports on. Progress lives in `status`, in `last_update_at`, and — + * since #108 — in the `progress` PHRASE below, which is a sentence a person + * chose and not a quantity anyone can average. + * + * ── `progress` is a phrase, NOT a measurement ──────────────────────────── + * Four preset phrases + the free-text `note` beside it. It exists because the + * frontline gesture the product promises is one tap: "已下发各部门" is what + * the person would have typed, so it is offered as an option instead. It is + * SELF-REPORTED, and nothing derives a number from it: + * + * the on-time verdict is `completed_late`, stamped by the server from + * `completed_at` against `late_after` — never from this column. + * + * ⛔ Do not build a rate, a rollup or a ranking on `progress`. The `on_time` + * option is a person's own words about their own work; reading it as evidence + * would make the phrase a scored field, and then nobody picks the honest one. + * `duly_stagnation` and the duty-health measures deliberately do not name it. + * + * ── `attachments` is never a gate ──────────────────────────────────────── + * A product invariant, not a preference (AGENTS.md — "Completion never + * requires evidence, a note, or a percentage"). The field is optional on every + * path, no validation rule names it, and `test/invariants.test.ts` plus + * `test/task-hook.test.ts` pin both halves — the metadata AND a real booted + * engine completing a task that carries no file at all. + * + * `enable.files` (below) and this field are two different affordances and both + * are wanted: `enable.files` is the record's own attachment area, and + * `attachments` is a COLUMN, which is what lets the list show a paperclip and + * the record form put the files inside the "Progress and attachments" group + * next to the phrase they belong to. */ export const Task = ObjectSchema.create({ name: 'duly_task', @@ -37,9 +66,38 @@ export const Task = ObjectSchema.create({ sharingModel: 'private', + /** + * The record page, in three sections (#108 · deck p7). + * + * ── How a group reaches the screen, measured on 17.2.0 ────────────────── + * Field → group mapping is derived from `Field.group` matching a `key` here; + * in-group order is the traversal order of `fields` below, and a field whose + * `group` is unset lands in a trailing ungrouped bucket. The console's form + * runs the spec's own `deriveFieldGroupLayout` and turns each group into a + * section whose `name` is this `key` — which is also what makes the label + * translatable: it is resolved as `objects.duly_task._sections..label`. + * (`translateObject` does NOT rewrite `fieldGroups[].label` server-side; the + * console resolves it from the bundle. `src/translations/authored-text.ts` + * carries the measurement.) + * + * ── The rule for which group a field is in ────────────────────────────── + * `history` is exactly the SERVER-OWNED stamps — every `readonly` column and + * nothing else. That is a rule rather than a taste, so it is pinned in + * `test/invariants.test.ts`: a new readonly stamp that is not filed here + * would otherwise appear in the middle of the edit form, reading as a field + * somebody forgot to make editable. Collapsed by default because it is the + * audit trail, not the day's work. + */ + fieldGroups: [ + { key: 'basics', label: 'Basics', icon: 'clipboard-list' }, + { key: 'progress', label: 'Progress and attachments', icon: 'message-square' }, + { key: 'history', label: 'History', icon: 'history', collapse: 'collapsed' }, + ], + fields: { subject: Field.text({ label: 'Task', + group: 'basics', required: true, searchable: true, maxLength: 255, @@ -48,22 +106,26 @@ export const Task = ObjectSchema.create({ duty: Field.lookup('duly_duty', { label: 'Duty', + group: 'basics', description: 'Empty for a bare one-off that was never modelled as a duty.', }), owner: Field.user({ label: 'Owner', + group: 'basics', required: true, defaultValue: 'current_user', }), business_unit: Field.lookup('sys_business_unit', { label: 'Business unit', + group: 'basics', description: 'Denormalised from the owner at dispatch so rollups survive a later transfer.', }), assignment: Field.lookup('duly_assignment', { label: 'Assignment', + group: 'basics', description: 'Set when this task came out of a manager fan-out. One assignment, N independent tasks.', }), @@ -71,6 +133,7 @@ export const Task = ObjectSchema.create({ // column; `self` never enters an on-time rate or a comparison. source: Field.select({ label: 'Source', + group: 'basics', required: true, options: [ { label: 'Role catalog', value: 'catalog', color: '#16515F' }, @@ -95,14 +158,16 @@ export const Task = ObjectSchema.create({ // producer agrees on the spelling. period_key: Field.text({ label: 'Period', + group: 'basics', maxLength: 16, description: 'Empty for one-off tasks, which have no period.', }), - due_date: Field.date({ label: 'Due' }), + due_date: Field.date({ label: 'Due', group: 'basics' }), visible_from: Field.date({ label: 'Shows up on', + group: 'basics', description: 'due_date minus the duty lead time. Before this the task exists but stays out of the way.', }), @@ -143,12 +208,14 @@ export const Task = ObjectSchema.create({ */ late_after: Field.date({ label: 'Late after', + group: 'history', readonly: true, description: 'The due date plus the grace the duty granted when this task was dispatched. Open past this day, or completed after it, is late. Stamped once, at dispatch — editing the duty\'s grace afterwards does not move it.', }), status: Field.select({ label: 'Status', + group: 'basics', required: true, // [ADR-0052 §5b] Status changes land on the record timeline with no hook // code. This is the entire audit story for "who closed this and when". @@ -164,6 +231,7 @@ export const Task = ObjectSchema.create({ skip_reason: Field.text({ label: 'Why skipped', + group: 'basics', maxLength: 255, description: 'A skipped task is a legitimate outcome — "the plant was down, there was nothing to return". Recording why is what keeps skip from being a synonym for done.', }), @@ -173,6 +241,7 @@ export const Task = ObjectSchema.create({ // strip lets through. completed_at: Field.datetime({ label: 'Completed at', + group: 'history', readonly: true, }), @@ -196,6 +265,7 @@ export const Task = ObjectSchema.create({ */ completed_late: Field.boolean({ label: 'Completed late', + group: 'history', readonly: true, description: 'True when the task was completed after its late-after date. Stamped once, at completion, against the grace in force then — a later change to the duty\'s grace never moves it.', }), @@ -206,16 +276,90 @@ export const Task = ObjectSchema.create({ * Completion percentage tells you about work that already finished. * `last_update_at` tells you about work that is quietly going nowhere — * weeks before a due date makes it obvious. Server-owned: stamped on every - * status change and note edit by `task.hook.ts`. + * status change, progress phrase or note edit by `task.hook.ts`. */ last_update_at: Field.datetime({ label: 'Last touched', + group: 'history', readonly: true, }), + /** + * The one-tap progress phrase (#108 · deck p7 ③). + * + * Four phrases, chosen because they are what people already write in the + * note: the work is finished on time, it has been passed down to the + * departments, it is waiting on somebody else, or it is simply in hand. + * `note` stays beside it as "write your own" — this replaces nothing, it + * removes the typing from the four cases that repeat. + * + * ── No default, deliberately ──────────────────────────────────────── + * A dispatched task starts with NO progress reported, and blank is the + * honest reading of that. A default would put a phrase nobody said onto + * every row the dispatcher creates, and the list column would then show + * the same words against 186 tasks — which is worse than an empty column, + * because it looks like news. `source` carries a default for the opposite + * reason: every task genuinely has a caliber the moment it exists. + * + * ── It moves the stagnation clock, and that is the point ──────────── + * `task.hook.ts` stamps `last_update_at` when this changes. Picking a + * phrase IS somebody working the task, which is exactly the test that + * hook's list applies — unlike a re-owner or a re-date, which are + * administrative and deliberately absent from it. + * + * ⛔ Not a metric. See the module header: the on-time verdict is + * `completed_late`, never this. + */ + progress: Field.select({ + label: 'Latest progress', + group: 'progress', + description: 'A phrase the owner picked, in their own words — never evidence, never scored, and never required. The on-time verdict is `completed_late`, which the server stamps at completion.', + options: [ + { label: 'Finished on time', value: 'on_time', color: '#35674D' }, + { label: 'Passed down to the departments', value: 'distributed', color: '#2E7C8E' }, + { label: 'Waiting on a reply', value: 'awaiting_feedback', color: '#8C6512' }, + { label: 'In hand', value: 'in_hand', color: '#576B73' }, + ], + }), + note: Field.textarea({ label: 'Note', - description: 'Optional. Never required to complete a task — an evidence gate turns a 5-second tick into a 5-minute chore, and the list stops being used.', + group: 'progress', + description: 'Optional. Never required to complete a task — an evidence gate turns a 5-second tick into a 5-minute chore, and the list stops being used. The four phrases people write most often are one tap away in `progress`.', + }), + + /** + * Files the owner chose to attach — and NEVER a completion requirement. + * + * ── The invariant, stated where somebody would break it ───────────── + * "Completion never requires evidence, a note, or a percentage" + * (AGENTS.md). So: not `required`, no `requiredWhen`, and no validation + * rule anywhere names this column. A task goes to `done` with zero files + * and always will — pinned in `test/invariants.test.ts` (the metadata) and + * in `test/task-hook.test.ts` (a booted engine actually doing it). The + * moment an evidence gate exists, the 5-second tick becomes a 5-minute + * chore and the list stops being used; that is the whole product. + * + * ── Why the platform's own file field, with no configuration ──────── + * Measured on 17.2.0 rather than assumed: `file` is a first-class + * `FieldType`, it is in `MULTI_CAPABLE_TYPES` so `multiple: true` makes it + * an array, and `storage` is in `PLATFORM_ALWAYS_ON_CAPABILITIES` — the + * CLI's serve command mounts `StorageServicePlugin` from + * `@objectstack/service-storage` whether or not a stack asks for it, so + * there is nothing to declare in `objectstack.config.ts` and nothing to + * configure. Uploads were then driven in a browser against `pnpm demo`; + * the PR body carries that half. + * + * ⛔ No `accept` list. Restricting the file types is a gate nobody asked + * for, on a field whose entire contract is that it is optional — the + * frontline photograph of a signed sheet is exactly the case a + * well-meant `accept: ['.pdf']` would refuse. + */ + attachments: Field.file({ + label: 'Attachments', + group: 'progress', + multiple: true, + description: 'Optional, always. Attach a photo, a signed sheet, a return — or nothing. Completing a task never requires one, and nothing checks for one.', }), }, @@ -223,7 +367,10 @@ export const Task = ObjectSchema.create({ trackHistory: true, searchable: true, apiEnabled: true, - // Attachments are opt-in per record, never a completion requirement. + // The record's own attachment area. Opt-in per record, never a completion + // requirement — and NOT the same thing as the `attachments` COLUMN above, + // which is what a list column and a form group can address. Both are + // wanted; see the module header. files: true, }, diff --git a/src/translations/authored-text.ts b/src/translations/authored-text.ts index 3993035..eae255a 100644 --- a/src/translations/authored-text.ts +++ b/src/translations/authored-text.ts @@ -230,6 +230,38 @@ const VERDICTS: Readonly> = { const value = str(c.parent.value); return id(c) && value ? ['objects', id(c)!, 'fields', c.path[1]!, 'options', value] : undefined; }), + 'object.fields{}.group': machine('field-group key — matches an `object.fieldGroups[].key`'), + /** + * A record page's section headings (#108). + * + * ── Which bundle slot, and why it is `_sections` ───────────────────── + * Measured on 17.2.0 rather than inferred, because the two halves of the + * platform disagree and only one of them is what a user sees: + * + * `translateObject(obj, bundle, { locale })` does NOT rewrite + * `fieldGroups[].label`. Fed a bundle carrying `_sections.basics.label` + * it returns the group's authored English untouched (the object's own + * `label` and every field `label` beside it do translate). + * + * The CONSOLE does translate it, and that is the surface with a reader. + * Its form runs the spec's `deriveFieldGroupLayout` and emits one section + * per group with `name` set to the group's `key`, then resolves the + * heading through `sectionLabel(object, name, fallback)` — which reads + * `objects.._sections..label`, the slot + * `ObjectTranslationDataSchema` declares for exactly this shape. + * + * So the key below is real and the heading is Chinese in a zh-CN console. + * It is NOT `untranslatable`: a verdict of that kind would be wrong (a key + * exists and works) and would put an entry in the exemption list that the + * gate then has to be told to ignore. + */ + 'object.fieldGroups[].label': translate((c) => { + const group = str(c.parent.key); + return id(c) && group ? ['objects', id(c)!, '_sections', group, 'label'] : undefined; + }), + 'object.fieldGroups[].key': machine('field-group key — the section name the layout is derived on'), + 'object.fieldGroups[].icon': machine('icon name'), + 'object.fieldGroups[].collapse': machine('section collapse behaviour'), 'object.fields{}.options[].value': machine('the stored option value — the option key itself'), 'object.fields{}.options[].color': machine('option colour'), 'object.fields{}.type': machine('field type'), diff --git a/src/translations/zh-CN.ts b/src/translations/zh-CN.ts index cf2b1b9..6f547aa 100644 --- a/src/translations/zh-CN.ts +++ b/src/translations/zh-CN.ts @@ -251,10 +251,34 @@ export const dulyChinese = defineTranslationBundle({ help: '完成时间晚于「逾期起算日」时为是。此值在完成的那一刻写入一次,按当时生效的宽限期判定——之后修改职责的宽限期不会改动它。', }, last_update_at: { label: '最后更新' }, + // #108 一线界面。四条预设短语用方案 p7 的原话,不另行翻译——这四句 + // 正是一线的人本来会打出来的字,把它们改写成更「书面」的说法,等于 + // 把一次点击又换回一次打字。 + progress: { + label: '最新进展', + help: '负责人自己选的一句话——不是举证,不计分,也从不强制填写。是否按期的判定在「逾期完成」,由服务端在完成的那一刻写入。', + options: { + on_time: '已按期完成', + distributed: '已下发各部门', + awaiting_feedback: '待反馈', + in_hand: '处理中', + }, + }, note: { label: '备注', - help: '选填。完成任务从不要求填写——一道举证关卡会把 5 秒钟的勾选变成 5 分钟的差事,然后这份清单就没人用了。', + help: '选填。完成任务从不要求填写——一道举证关卡会把 5 秒钟的勾选变成 5 分钟的差事,然后这份清单就没人用了。最常写的那四句话,在「最新进展」里点一下就行。', }, + attachments: { + label: '附件', + help: '始终选填。可以传一张照片、一份签字表、一份申报表——也可以什么都不传。完成任务从不需要附件,也没有任何地方会检查。', + }, + }, + // 记录页的三个分区(#108)。键名就是 `fieldGroups[].key`,控制台按 + // `objects.duly_task._sections..label` 解析这三个标题。 + _sections: { + basics: { label: '基本' }, + progress: { label: '进展与附件' }, + history: { label: '历史' }, }, _views: { my_week: { label: '我的本周' }, diff --git a/src/views/task.view.ts b/src/views/task.view.ts index 4c77ddc..7f413cc 100644 --- a/src/views/task.view.ts +++ b/src/views/task.view.ts @@ -4,6 +4,32 @@ import { P, defineView } from '@objectstack/spec'; const data = { provider: 'object' as const, object: 'duly_task' }; +/** + * The columns every task grid carries. + * + * `progress` and `attachments` were appended by #108 rather than woven into + * the order, which keeps the blast radius of that card to two extra columns on + * four lenses instead of a re-ordering nobody asked for. `my_week` — the + * frontline screen the deck's p16 draws — states its own order below. + * + * ── Why the deck's "最新进展 (= progress 或 note)" is TWO things, not one ── + * The card asks for one column showing the progress phrase or, failing that, + * the note. There is no authorable way to say that, and every way of faking it + * is worse than the honest pair: + * + * a stored `latest_progress` needs a writer on every note and phrase edit + * — the maintained-flag shape AGENTS.md rule 5 + * forbids, and it lies the day the writer skips. + * a formula field is virtual, so a filter naming it silently + * matches nothing (rule 5 again), and a formula + * over a select renders the STORED value — + * `on_time`, not "Finished on time". + * + * So the grid carries `progress`, which is the tappable one and the one that + * is short enough to be a column; `note` is a paragraph and lives on the + * record, in the same "Progress and attachments" group. Nothing is hidden: the + * phrase is what the frontline person is being asked for. + */ const columns = [ { field: 'subject' }, { field: 'status' }, @@ -11,6 +37,8 @@ const columns = [ { field: 'period_key' }, { field: 'owner' }, { field: 'source' }, + { field: 'progress' }, + { field: 'attachments' }, ]; /** @@ -126,6 +154,16 @@ const bulkActions = [ * it stays correct without a maintenance writer behind it. */ export const TaskViews = defineView({ + /** + * ── Which lenses are inline-editable, and why it is not all of them ───── + * `my_week` and `board` are the OWNER's screens and both take inline edits + * (#108). `list` here and the three manager lenses below — `late`, + * `stalled`, `by_unit` — deliberately do not: "Managers do not enter status. + * Assigning is their only write" is a product invariant, and a status or + * progress cell that edits in place on a team lens is an invitation to break + * it one row at a time. Nothing is lost — the row action and the record page + * are still there for a person editing their own task. + */ list: { label: 'All tasks', type: 'grid', @@ -136,11 +174,44 @@ export const TaskViews = defineView({ }, listViews: { + /** + * The frontline screen (deck p16). Column order is the deck's, read left + * to right the way the work is: what state it is in, what it is, who put + * it there, when it is owed, what the last word on it was, and whether + * anything is attached. + * + * ── `inlineEdit` is what makes the phrase one tap ──────────────────── + * Without it the row is read-only and reporting progress costs a record + * page. With it the grid renders the select in the cell and the write is + * the ordinary data-plane update under the caller's own permissions — the + * same authority as the row action, no handler anywhere. `status` and + * `progress` are the two columns worth touching from here; the rest are + * server-owned or administrative, and a user who may not write a column + * gets the platform's refusal rather than a silent no-op. + * + * ── The due column needs no `format` key ───────────────────────────── + * Measured on @objectstack/console 17.2.0: the date cell defaults to + * `format: 'relative'` and derives "due-like" from the FIELD NAME (a + * `/(^|_)(due|deadline|…)(_|$)/` test, which `due_date` matches), so it + * already renders `Tomorrow` / `In 3 days` / `Overdue 5d` inside a + * ±7-day window and an absolute date outside it. The card's fallback — a + * `late_after` column standing in for "逾期 N 天" — is therefore NOT + * needed here, and `late_after` stays where it earns its place, on the + * `late` lens that filters by it. + */ my_week: { label: 'My week', type: 'grid', data, - columns: [{ field: 'subject' }, { field: 'status' }, { field: 'due_date' }, { field: 'source' }], + columns: [ + { field: 'status' }, + { field: 'subject' }, + { field: 'source' }, + { field: 'due_date' }, + { field: 'progress' }, + { field: 'attachments' }, + ], + inlineEdit: true, filter: [ { field: 'owner', operator: 'equals', value: '{current_user_id}' }, { field: 'status', operator: 'in', value: ['open', 'in_progress'] }, @@ -254,17 +325,52 @@ export const TaskViews = defineView({ * No `summarizeField`: it renders a per-column SUM, and there is no number * on a task worth totalling. The nearest candidate would be a count, and * counts are never ranked or compared here. + * + * ── The card face is `kanban.columns`, and `cardFields` is not authorable ─ + * Measured, because the deck (p17) asks for it by the renderer's name. + * `KanbanConfigSchema` in `@objectstack/spec/ui` is a STRICT object with + * exactly `groupByField`, `summarizeField` and `columns` — so + * `kanban.cardFields` is refused by `pnpm validate` rather than silently + * ignored, which is the good failure and the opposite of the gantt block's + * passthrough trap documented below. The console's view relay then reads + * `cardFields: kanban.cardFields || kanban.columns || …`, so the authorable + * spelling IS `columns` and it lands on the card. Nothing to file. + * + * ⛔ SWIMLANES ARE NOT AUTHORABLE — do not add a `swimlaneField` here. + * The renderer supports them (`ObjectKanban` takes `swimlaneField` and + * derives one from a relayed `grouping.fields[0].field`), but no authoring + * route reaches it on this build: the strict schema above has no such key, + * and the `ObjectView` relay this app's views go through does not forward a + * view-level `grouping` to the kanban branch. Filed at + * objectstack-ai/objectui — see the PR body. Grouping by source is + * therefore expressed as what IS authorable and true today: `source` on the + * card face, and the `by_unit` lens for a grouped read. */ board: { label: 'Board', type: 'kanban', data, - columns: [{ field: 'subject' }, { field: 'due_date' }, { field: 'owner' }, { field: 'source' }], + // The projection is built from `columns` alone — `kanban.columns` does + // not contribute to it on the `ObjectView` relay, so a card field that + // is not here arrives `undefined` and renders blank with nothing in + // error. Same lesson as `business_unit` on `by_unit` below. + columns: [ + { field: 'subject' }, + { field: 'due_date' }, + { field: 'owner' }, + { field: 'source' }, + { field: 'progress' }, + ], kanban: { groupByField: 'status', // The card face, in reading order: what it is, when it is owed, whose - // it is, and where it came from. - columns: ['subject', 'due_date', 'owner', 'source'], + // it is, where it came from, and the last word on it (#108, deck p17). + // + // `owner` stays even though the deck lists only source · due · + // progress: this lens carries no owner filter, so on an account that + // can see other people's rows a face without a name is ambiguous + // rather than clean. + columns: ['subject', 'due_date', 'owner', 'source', 'progress'], }, inlineEdit: true, sort: [{ field: 'due_date', order: 'asc' }], diff --git a/test/invariants.test.ts b/test/invariants.test.ts index 38022ee..4c6d996 100644 --- a/test/invariants.test.ts +++ b/test/invariants.test.ts @@ -46,6 +46,90 @@ describe('product invariants', () => { expect(Task.fields.note.required).not.toBe(true); }); + /** + * #108 — the attachment column is optional on every path there is. + * + * Three ways an evidence gate could arrive, and none of them may: the field + * itself going `required`, a conditional `requiredWhen` that fires on `done` + * (which is how it would actually be written), or a validation rule naming + * the column. `test/task-hook.test.ts` completes a fileless task against a + * real engine; this is the metadata half, which is where the mistake would + * be MADE. + */ + it('an attachment is never required, and no rule reads one', () => { + const attachments = Task.fields.attachments as { + required?: boolean; requiredWhen?: unknown; multiple?: boolean; type?: string; + }; + expect(attachments, 'duly_task must carry an attachments column').toBeDefined(); + expect(attachments.type, 'the platform file field, not a text column of URLs').toBe('file'); + expect(attachments.multiple, 'more than one file, or the first one becomes the record').toBe(true); + expect(attachments.required, 'an evidence gate turns the tick into a chore').not.toBe(true); + expect( + attachments.requiredWhen, + 'a `requiredWhen` on done is the evidence gate written the way it would really be written', + ).toBeUndefined(); + + for (const rule of Task.validations ?? []) { + expect( + JSON.stringify(rule), + `validation "${rule.name}" reads attachments — completion never requires evidence`, + ).not.toContain('attachments'); + } + }); + + /** + * #108 — the progress phrase is a phrase, not a score and not a default. + * + * A default would put words nobody said on every dispatched row, and the + * list column would read as news on 186 tasks at once. Blank is the honest + * value for "nobody has reported anything yet". + */ + it('the progress phrase starts blank and is never required', () => { + const progress = Task.fields.progress as { + required?: boolean; options?: Array<{ value: string; default?: boolean }>; + }; + expect(progress.required, 'nobody is made to file a status line').not.toBe(true); + expect((progress.options ?? []).map((o) => o.value)) + .toEqual(['on_time', 'distributed', 'awaiting_feedback', 'in_hand']); + expect( + (progress.options ?? []).filter((o) => o.default), + 'a dispatched task has reported nothing yet — no option may claim the default', + ).toEqual([]); + }); + + /** + * #108 — the record page's `history` group is exactly the server-owned + * stamps, and every field is filed somewhere. + * + * Both halves matter and both are silent when wrong. A field whose `group` + * names no declared key is not an error: the platform drops it into an + * unnamed trailing bucket below the last section, so it renders — just in + * the wrong place, under no heading. And a new readonly stamp left out of + * `history` lands in the middle of the edit form, reading as a field + * somebody forgot to make editable. + */ + it('every field is filed under a declared group, and history is exactly the readonly stamps', () => { + const groups = new Set((Task.fieldGroups ?? []).map((g) => g.key)); + expect(groups, 'the three sections the deck asks for').toEqual( + new Set(['basics', 'progress', 'history']), + ); + + const entries = Object.entries(Task.fields) as Array<[string, { group?: string; readonly?: boolean }]>; + for (const [name, field] of entries) { + expect( + field.group && groups.has(field.group) ? field.group : undefined, + `duly_task.${name} has no group, or names one that is not declared — it renders ` + + 'in an unnamed bucket after the last section, with nothing in error', + ).toBeDefined(); + } + + const readonlyFields = entries.filter(([, f]) => f.readonly === true).map(([name]) => name).sort(); + const historyFields = entries.filter(([, f]) => f.group === 'history').map(([name]) => name).sort(); + expect(historyFields, 'the history section IS the set of server-owned stamps') + .toEqual(readonlyFields); + expect(readonlyFields.length, 'and it is not vacuously empty').toBeGreaterThan(3); + }); + it('no MAINTAINED lateness flag exists on the task', () => { // The banned shape is a flag whose truth changes with the clock: it needs a // writer every midnight and lies the night it does not run. `late_after` diff --git a/test/task-hook.test.ts b/test/task-hook.test.ts index f5c6f8f..e962086 100644 --- a/test/task-hook.test.ts +++ b/test/task-hook.test.ts @@ -197,6 +197,55 @@ describe('completed_at', () => { }); }); +/** + * The evidence gate that must never exist (#108). + * + * "Completion never requires evidence, a note, or a percentage" is a product + * invariant, and `attachments` is the field most likely to grow one by + * accident — a `requiredWhen: 'record.status == "done"'` looks like diligence + * and would turn the 5-second tick into a 5-minute chore. + * + * `test/invariants.test.ts` pins the METADATA half (not required, no rule + * names the column). This is the other half, and it is the one that cannot be + * argued with: a real booted engine, a task carrying no file at all, going to + * `done` and staying there. + */ +describe('attachments never gate completion', () => { + it('completes a task that carries no attachment at all', async () => { + const task = await newTask(); + expect(task.attachments ?? null, 'the fixture must genuinely have no file').toBeFalsy(); + + const done = await data.update('duly_task', { id: task.id, status: 'done' }); + + expect(done.status).toBe('done'); + expect(done.completed_at, 'the completion still stamps normally').toBeTruthy(); + expect(done.attachments ?? null, 'and nothing invented a file to satisfy a gate').toBeFalsy(); + }); + + it('completes one that DOES carry files, without treating them as evidence', async () => { + // The other direction, so the assertion above cannot pass by attachments + // being broken rather than optional. + const task = await newTask({ attachments: ['file_one', 'file_two'] }); + expect(task.attachments, 'a multi-file column stores an array').toEqual(['file_one', 'file_two']); + + const done = await data.update('duly_task', { id: task.id, status: 'done' }); + expect(done.status).toBe('done'); + expect(done.attachments).toEqual(['file_one', 'file_two']); + }); + + it('lets a done task be saved again with its files removed', async () => { + // A gate would most plausibly appear here — refusing to let the last file + // off a completed task. It must not. + const task = await newTask({ attachments: ['file_only'] }); + await data.update('duly_task', { id: task.id, status: 'done' }); + + const stripped = await data.update('duly_task', { id: task.id, attachments: [] }); + + expect(stripped.status, 'the task stays done').toBe('done'); + expect(stripped.attachments ?? [], 'and the files are gone, with no refusal').toEqual([]); + }); +}); + describe('last_update_at — the stagnation signal', () => { it('advances when the note is edited', async () => { const task = await newTask(); @@ -218,6 +267,70 @@ describe('last_update_at — the stagnation signal', () => { expect(moved.last_update_at as string > before).toBe(true); }); + /** + * #108 — the product's headline gesture has to count as movement. + * + * Reporting progress is ONE TAP: the owner picks a preset phrase instead of + * typing a sentence. If that did not move the clock, a task nudged every + * week would still drift into "Not moving" (`status in (open, in_progress) + * AND last_update_at < {14_days_ago}`) — the one list a manager is told to + * trust, going wrong precisely for the people who are keeping it up to date. + * + * Red before `progress` joined the hook's field list, and measured that way + * rather than assumed: with the list back at `['status','note', + * 'skip_reason']` this assertion reads `false` — the clock does not move. + */ + it('advances when a progress phrase is picked', async () => { + const task = await newTask(); + const before = task.last_update_at as string; + + await tick(); + const reported = await data.update('duly_task', { id: task.id, progress: 'distributed' }); + + expect( + reported.last_update_at as string > before, + 'picking a preset progress phrase IS somebody working the task', + ).toBe(true); + }); + + it('advances when the phrase CHANGES, and not when it is re-sent', async () => { + // Same pre-image comparison the rest of the list gets: a form that + // re-submits the whole record is not progress on it. + const task = await newTask({ progress: 'in_hand' }); + const before = task.last_update_at as string; + + await tick(); + const resent = await data.update('duly_task', { id: task.id, progress: 'in_hand' }); + expect(resent.last_update_at, 're-sending the same phrase is not a touch').toBe(before); + + await tick(); + const changed = await data.update('duly_task', { id: task.id, progress: 'awaiting_feedback' }); + expect(changed.last_update_at as string > before).toBe(true); + }); + + /** + * The deliberate asymmetry (#108). A file arriving is real, but + * `attachments` is optional by product invariant and nothing may make it + * feel otherwise — an upload that silently refreshed the stagnation clock + * would make "attach something" the cheapest way to look busy, one step from + * the evidence gate the invariant forbids. + */ + it('does NOT advance when a file is attached', async () => { + const task = await newTask(); + const before = task.last_update_at as string; + + await tick(); + const attached = await data.update('duly_task', { + id: task.id, + attachments: ['file_abc123'], + }); + + expect( + attached.last_update_at, + 'an attachment is not a status report — the phrase beside it is', + ).toBe(before); + }); + it('advances when a skip reason is recorded', async () => { const task = await newTask(); const before = task.last_update_at as string; diff --git a/test/views.test.ts b/test/views.test.ts index 7edb420..d25f7ca 100644 --- a/test/views.test.ts +++ b/test/views.test.ts @@ -179,6 +179,77 @@ describe('the lenses say what the product means', () => { expect(byName('schedule').view.gantt as Rec).not.toHaveProperty('colorField'); }); + /** + * #108 — the frontline screen the deck's p16 draws. + * + * The column SET and its ORDER are both the card's, so this is not a + * restatement of the file: a reorder here is a product change and should + * have to be argued for. The two new columns are the ones the whole card is + * about — a list without them is the list we already had. + */ + it('my_week carries the deck\'s columns, in the deck\'s order', () => { + const fields = ((byName('my_week').view.columns as Rec[]) ?? []).map((c) => String(c.field)); + expect(fields).toEqual(['status', 'subject', 'source', 'due_date', 'progress', 'attachments']); + }); + + /** + * Inline edit is what makes reporting progress ONE TAP rather than a record + * page — and it is deliberately not on the manager lenses, because + * "Managers do not enter status; assigning is their only write" is a product + * invariant and an editable status cell on a team lens breaks it one row at + * a time. + */ + it('the owner\'s lenses edit in place; the manager\'s lenses do not', () => { + for (const name of ['my_week', 'board']) { + expect(byName(name).view.inlineEdit, `${name} is an owner screen — the phrase is one tap`).toBe(true); + } + for (const name of ['late', 'stalled', 'by_unit']) { + expect( + byName(name).view.inlineEdit, + `${name} is a manager lens — inline status entry is the invariant being broken quietly`, + ).not.toBe(true); + } + }); + + /** + * #108 / deck p17 — the card face carries where it came from, when it is + * owed, and the last word on it. Every card field must also be in the view's + * own `columns`: the projection is built from those alone, so a face field + * missing from them arrives `undefined` and renders blank with nothing in + * error (the same defect `business_unit` had on `by_unit`). + */ + it('the board card shows source, due and progress — and projects them', () => { + const board = byName('board').view; + const face = ((board.kanban as Rec).columns as string[]) ?? []; + for (const field of ['source', 'due_date', 'progress']) { + expect(face, `the deck asks for ${field} on the card`).toContain(field); + } + const projected = new Set(((board.columns as Rec[]) ?? []).map((c) => String(c.field))); + for (const field of face) { + expect( + projected, + `\`${field}\` is on the card face but not in \`columns\` — it will arrive undefined`, + ).toContain(field); + } + }); + + /** + * ⛔ Swimlanes are not authorable on this build, and an invented key is how + * that would be "fixed". `KanbanConfigSchema` is strict — `swimlaneField` + * fails `pnpm validate` — but a view-level `grouping` on a kanban view does + * NOT: it parses, ships, and is read by nothing on the relay these views go + * through. That is the gantt `taskListWidth` trap in a different block, so + * it is pinned rather than left to a comment. Filed at objectstack-ai/objectui. + */ + it('the board declares no swimlane key that nothing reads', () => { + const board = byName('board').view; + expect(board.kanban).not.toHaveProperty('swimlaneField'); + expect( + board.grouping, + 'a `grouping` block on the kanban view is inert here — see the view file', + ).toBeUndefined(); + }); + /** Item counts are never ranked or compared — not as a sort, not as a total. */ it('no view orders or totals anything by a count', () => { for (const { where, view } of allViews) { From ff7660a1e551eabc73082c981486dbb54c7a2ab2 Mon Sep 17 00:00:00 2001 From: Warren Date: Wed, 2 Sep 2026 05:15:06 +0000 Subject: [PATCH 2/3] Scope the board to its viewer, seed the phrases, record the swimlane finding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The board is the owner's own: dragging a card writes `status`, and "managers do not enter status" has to be true of the data and not only of where the nav puts the link. It also keeps the lens inside one fetched page — unscoped, the first 100 of 186 rows sorted by due date were all `done` and the Open and In-progress columns rendered empty. Swimlanes by source are authorable (`grouping`, not `kanban.swimlaneField`) and are deliberately left off: measured in a browser, turning them on renders the status column-header row at height 0. The demo's in-flight tasks carry a phrase so the new column and card face show something; stalled and done rows stay blank, and each blank says something. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p --- src/data/demo-history.ts | 31 ++++++++++++++++++ src/data/task.seed.ts | 10 ++++-- src/views/task.view.ts | 71 +++++++++++++++++++++++++++++++++++----- test/views.test.ts | 39 +++++++++++++++++----- 4 files changed, 131 insertions(+), 20 deletions(-) diff --git a/src/data/demo-history.ts b/src/data/demo-history.ts index 792fbfe..e3f55bd 100644 --- a/src/data/demo-history.ts +++ b/src/data/demo-history.ts @@ -210,6 +210,30 @@ const IN_PROGRESS_IN_FLIGHT: readonly string[] = [ 'Shift handover record — Line A', ]; +/** + * The progress phrase each in-flight owner has reported (#108). + * + * The "最新进展" column and the board card face are the point of that card, and + * a column that is empty on every row demonstrates nothing — so the in-flight + * population carries phrases, spread deterministically by position the same + * way every other variation in this fixture is. + * + * Two populations are deliberately left BLANK, and each blank says something: + * + * - **Stalled rows.** "Untouched since dispatch" and "the owner reported + * progress" are contradictory claims about the same row. A phrase here + * would undercut the one lens the product says is its most valuable. + * - **Done rows.** A completed task's report IS its status. Stamping + * `on_time` on 151 finished rows would also put a self-reported phrase + * beside `completed_late: true` wherever the drift ran over — a screen + * disagreeing with itself, and an invitation to read the phrase as the + * on-time verdict, which `task.object.ts` says in as many words it is not. + */ +const IN_FLIGHT_PROGRESS = ['in_hand', 'distributed', 'awaiting_feedback'] as const; + +/** What a late task that IS being chased says about itself. */ +const CHASED_PROGRESS = 'awaiting_feedback' as const; + /** 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.', @@ -236,6 +260,8 @@ export interface SeededTask extends Omit { */ skip_reason?: string; note?: string; + /** A preset phrase the owner picked (#108) — absent on stalled and done rows. */ + progress?: (typeof IN_FLIGHT_PROGRESS)[number] | typeof CHASED_PROGRESS | 'on_time'; /** Written by a SECOND seed pass — an insert can never carry it. See `task.seed.ts`. */ last_update_at: string; } @@ -322,6 +348,9 @@ const resolveDraft = (draft: TaskDraft, index: number): SeededTask => { draft.duty === STALLED_LATE ? untouchedSinceDispatch : iso(new Date(NOW.getTime() - CHASED_DAYS_AGO[index % CHASED_DAYS_AGO.length]! * DAY)), + // The three that are being chased say so; the stalled one says nothing, + // which is the whole difference between the Late and Not-moving lenses. + ...(draft.duty === STALLED_LATE ? {} : { progress: CHASED_PROGRESS }), }); } if (isMostRecentPast && draft.duty === SKIPPED_MOST_RECENT) { @@ -362,6 +391,8 @@ const resolveDraft = (draft: TaskDraft, index: number): SeededTask => { ...draft, status: isInFlightHead && IN_PROGRESS_IN_FLIGHT.includes(draft.duty) ? 'in_progress' : 'open', last_update_at: stalled ? untouchedSinceDispatch : iso(new Date(NOW.getTime() - touchedDaysAgo(draft, dispatched, index) * DAY)), + // A stalled row has had nothing said about it — see IN_FLIGHT_PROGRESS. + ...(stalled ? {} : { progress: IN_FLIGHT_PROGRESS[index % IN_FLIGHT_PROGRESS.length]! }), }); }; diff --git a/src/data/task.seed.ts b/src/data/task.seed.ts index 9e854de..683b87a 100644 --- a/src/data/task.seed.ts +++ b/src/data/task.seed.ts @@ -104,6 +104,12 @@ export const taskHistorySeed = defineSeed(Task, { // conclusion, which is the only way the demo and a live completion cannot // disagree. skip_reason: task.skip_reason, + // The preset phrase its owner reported (#108). Absent on the stalled and + // the done rows by design — `demo-history.ts` says why each blank is a + // blank. Carried on THIS pass and never on pass 3: `progress` is in the + // hook's `last_update_at` field list, so writing it in the backdating pass + // would re-stamp the clock that pass exists to set. + progress: task.progress, note: task.note, })), }); @@ -131,8 +137,8 @@ export const taskAdHocSeed = defineSeed(Task, { * 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 + * nothing else. Deliberately nothing else: adding `status`, `progress`, `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, { diff --git a/src/views/task.view.ts b/src/views/task.view.ts index 7f413cc..cf57ed1 100644 --- a/src/views/task.view.ts +++ b/src/views/task.view.ts @@ -336,15 +336,41 @@ export const TaskViews = defineView({ * `cardFields: kanban.cardFields || kanban.columns || …`, so the authorable * spelling IS `columns` and it lands on the card. Nothing to file. * - * ⛔ SWIMLANES ARE NOT AUTHORABLE — do not add a `swimlaneField` here. - * The renderer supports them (`ObjectKanban` takes `swimlaneField` and - * derives one from a relayed `grouping.fields[0].field`), but no authoring - * route reaches it on this build: the strict schema above has no such key, - * and the `ObjectView` relay this app's views go through does not forward a - * view-level `grouping` to the kanban branch. Filed at - * objectstack-ai/objectui — see the PR body. Grouping by source is - * therefore expressed as what IS authorable and true today: `source` on the - * card face, and the `by_unit` lens for a grouped read. + * ── Swimlanes: AUTHORABLE, and deliberately left off. Measured. ───────── + * The deck's p17 asks for "泳道或分组按来源". Both halves of the answer are + * measurements against a live `pnpm demo`, not readings of the source: + * + * 1. **The key is the view-level `grouping`, not `kanban.swimlaneField`.** + * The strict schema above rejects `swimlaneField` outright (a failed + * `pnpm validate`, which is the good failure). The console's relay + * derives the kanban swimlane from + * `swimlaneField || grouping.fields[0].field`, so + * `grouping: { fields: [{ field: 'source' }] }` on THIS view turns them + * on. Authored once, confirmed in a browser: lanes rendered, headed + * `▼ CATALOG (19)`. + * + * ⚠ Reading the console's OTHER relay (`ObjectView`, whose kanban + * branch forwards no `grouping` at all) says the opposite. A comment + * written off that reading — and the upstream bug it would have filed — + * was already drafted here and was wrong. These views go through the + * other path. Measure this one in a browser before changing it. + * + * 2. **Turning them on costs the column headers**, which is why the key is + * not authored. In swimlane mode the status header row renders at + * HEIGHT 0: the titles are in the DOM (`Open`, `In progress`, `Done`, + * `Skipped`, `Cancelled`, at y=233) inside a + * `flex … pl-36 overflow-x-auto` container whose measured height is + * `0`, and a hit test at a title's own coordinates returns the lane's + * collapse BUTTON behind it. Nothing paints. With the key absent the + * same board renders them as real `

`s — `OPEN 5 · IN PROGRESS 1 · + * DONE 20 · SKIPPED 0`. + * + * A kanban whose columns are unlabelled is not a board, and this screen + * is a p0 pre-sales demo. So: lanes off, `source` on the card face + * instead (every card says which caliber it is), and the defect filed + * at objectstack-ai/objectui — see the PR body. Re-enabling is the one + * line in item 1 the day that lands; `test/views.test.ts` records the + * decision so it cannot be flipped by accident. */ board: { label: 'Board', @@ -373,6 +399,33 @@ export const TaskViews = defineView({ columns: ['subject', 'due_date', 'owner', 'source', 'progress'], }, inlineEdit: true, + /** + * ⚠ LOAD-BEARING, for two independent reasons — do not widen it. + * `test/views.test.ts` fails if it is dropped. + * + * 1. **The product invariant.** Dragging a card writes `status` on the + * task under it. "Managers do not enter status; assigning is their + * only write" — so a board that shows other people's rows hands every + * viewer a one-gesture way to break that, with no confirmation and no + * trace beyond the field history. The board already sits under "My + * work" for this reason (pinned in `test/views.test.ts`); this makes + * its DATA agree with its placement instead of relying on where the + * nav happens to put the link. + * + * 2. **The page cliff, measured on the #75 seed.** The kanban fetches + * one page and the footer says so — "100 records · Showing first 100 + * records." Sorted by `due_date asc` over 186 tasks, those first 100 + * are ALL `done`: the Open and In-progress columns rendered "No + * cards / 0" on a board whose whole job is live work, and the + * swimlane showed one lane because the page held one caliber. Scoped + * to the viewer it is a handful of rows — every status, every lane, + * every count true. Same shape, and the same reasoning, as the scope + * on `by_unit` and `schedule` below. + * + * ⛔ Not a page-size raise: that moves the cliff instead of removing it. + * The durable fix for the mechanism is objectstack-ai/objectui#7189. + */ + filter: [{ field: 'owner', operator: 'equals', value: '{current_user_id}' }], sort: [{ field: 'due_date', order: 'asc' }], }, diff --git a/test/views.test.ts b/test/views.test.ts index d25f7ca..22def90 100644 --- a/test/views.test.ts +++ b/test/views.test.ts @@ -234,22 +234,43 @@ describe('the lenses say what the product means', () => { }); /** - * ⛔ Swimlanes are not authorable on this build, and an invented key is how - * that would be "fixed". `KanbanConfigSchema` is strict — `swimlaneField` - * fails `pnpm validate` — but a view-level `grouping` on a kanban view does - * NOT: it parses, ships, and is read by nothing on the relay these views go - * through. That is the gantt `taskListWidth` trap in a different block, so - * it is pinned rather than left to a comment. Filed at objectstack-ai/objectui. + * Swimlanes are OFF on purpose, and this pin is the decision rather than a + * restatement of the file. + * + * They are authorable — `grouping: { fields: [{ field: 'source' }] }` on + * this view turns them on, confirmed in a browser. Turning them on also + * renders the status column-header row at height 0 on console 17.2.0, so the + * board loses `OPEN / IN PROGRESS / DONE / SKIPPED` entirely. The view file + * carries the measurement and the upstream filing; what must not happen is + * somebody adding the key back because the deck asks for lanes, without + * knowing it takes the column titles with it. + * + * ⛔ `kanban.swimlaneField` is a different mistake and fails `pnpm validate` + * — the schema is strict. Pinned so the failure has an explanation attached. */ - it('the board declares no swimlane key that nothing reads', () => { + it('the board carries no swimlane key while the header row is broken upstream', () => { const board = byName('board').view; - expect(board.kanban).not.toHaveProperty('swimlaneField'); + expect(board.kanban, 'the strict kanban schema has no swimlaneField — the key is `grouping`') + .not.toHaveProperty('swimlaneField'); expect( board.grouping, - 'a `grouping` block on the kanban view is inert here — see the view file', + 'swimlanes render the status column headers at height 0 — see the view file before re-adding', ).toBeUndefined(); }); + /** + * The board writes `status` on every card drag, so its rows must be the + * viewer's own — "Managers do not enter status" is a product invariant, and + * a board of other people's tasks is a one-gesture way past it. It is also + * what keeps the lens inside one fetched page; the view file carries the + * measurement. + */ + it('the board shows only the viewer\'s own tasks', () => { + expect(byName('board').view.filter).toEqual([ + { field: 'owner', operator: 'equals', value: '{current_user_id}' }, + ]); + }); + /** Item counts are never ranked or compared — not as a sort, not as a total. */ it('no view orders or totals anything by a count', () => { for (const { where, view } of allViews) { From 3b717e081a62ef03f1b8b6ba725dadd03c01cd17 Mon Sep 17 00:00:00 2001 From: Warren Date: Wed, 2 Sep 2026 05:16:56 +0000 Subject: [PATCH 3/3] Point the swimlane finding at objectui#7303 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p --- src/views/task.view.ts | 6 +++--- test/views.test.ts | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/views/task.view.ts b/src/views/task.view.ts index cf57ed1..8203c49 100644 --- a/src/views/task.view.ts +++ b/src/views/task.view.ts @@ -368,9 +368,9 @@ export const TaskViews = defineView({ * A kanban whose columns are unlabelled is not a board, and this screen * is a p0 pre-sales demo. So: lanes off, `source` on the card face * instead (every card says which caliber it is), and the defect filed - * at objectstack-ai/objectui — see the PR body. Re-enabling is the one - * line in item 1 the day that lands; `test/views.test.ts` records the - * decision so it cannot be flipped by accident. + * as objectstack-ai/objectui#7303. Re-enabling is the one line in item + * 1 the day that lands; `test/views.test.ts` records the decision so it + * cannot be flipped by accident. */ board: { label: 'Board', diff --git a/test/views.test.ts b/test/views.test.ts index 22def90..1e4e28c 100644 --- a/test/views.test.ts +++ b/test/views.test.ts @@ -240,8 +240,9 @@ describe('the lenses say what the product means', () => { * They are authorable — `grouping: { fields: [{ field: 'source' }] }` on * this view turns them on, confirmed in a browser. Turning them on also * renders the status column-header row at height 0 on console 17.2.0, so the - * board loses `OPEN / IN PROGRESS / DONE / SKIPPED` entirely. The view file - * carries the measurement and the upstream filing; what must not happen is + * board loses `OPEN / IN PROGRESS / DONE / SKIPPED` entirely — filed as + * objectstack-ai/objectui#7303. The view file carries the measurement; what + * must not happen is * somebody adding the key back because the deck asks for lanes, without * knowing it takes the column titles with it. *