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/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 d0214a2..39da9cf 100644 --- a/src/translations/authored-text.ts +++ b/src/translations/authored-text.ts @@ -266,6 +266,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 794d86d..f3a2d42 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..8203c49 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,19 +325,107 @@ 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: 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 + * 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', 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, + /** + * ⚠ 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/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..1e4e28c 100644 --- a/test/views.test.ts +++ b/test/views.test.ts @@ -179,6 +179,99 @@ 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 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 — 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. + * + * ⛔ `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 carries no swimlane key while the header row is broken upstream', () => { + const board = byName('board').view; + expect(board.kanban, 'the strict kanban schema has no swimlaneField — the key is `grouping`') + .not.toHaveProperty('swimlaneField'); + expect( + board.grouping, + '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) {