Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/data/demo-history.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<Record<string, string>> = {
'Emissions return — Northgate': 'Meter 3 was swapped mid-period — figures split across the two serials, both attached.',
Expand All@@ -236,6 +260,8 @@ export interface SeededTask extends Omit<TaskDraft, 'status'> {
*/
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;
}
Expand DownExpand Up@@ -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) {
Expand DownExpand Up@@ -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]! }),
});
};

Expand Down
10 changes: 8 additions & 2 deletions src/data/task.seed.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
})),
});
Expand DownExpand Up@@ -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, {
Expand Down
24 changes: 21 additions & 3 deletions src/hooks/task.hook.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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 ─────────────────────
Expand All@@ -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];
Expand All@@ -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 '
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/data/demo-history.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<Record<string, string>> = {
'Emissions return — Northgate': 'Meter 3 was swapped mid-period — figures split across the two serials, both attached.',
Expand All@@ -236,6 +260,8 @@ export interface SeededTask extends Omit<TaskDraft, 'status'> {
*/
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;
}
Expand DownExpand Up@@ -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) {
Expand DownExpand Up@@ -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]! }),
});
};

Expand Down
10 changes: 8 additions & 2 deletions src/data/task.seed.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
})),
});
Expand DownExpand Up@@ -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, {
Expand Down
24 changes: 21 additions & 3 deletions src/hooks/task.hook.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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 ─────────────────────
Expand All@@ -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];
Expand All@@ -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 '
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/data/demo-history.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<Record<string, string>> = {
'Emissions return — Northgate': 'Meter 3 was swapped mid-period — figures split across the two serials, both attached.',
Expand All@@ -236,6 +260,8 @@ export interface SeededTask extends Omit<TaskDraft, 'status'> {
*/
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;
}
Expand DownExpand Up@@ -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) {
Expand DownExpand Up@@ -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]! }),
});
};

Expand Down
10 changes: 8 additions & 2 deletions src/data/task.seed.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
})),
});
Expand DownExpand Up@@ -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, {
Expand Down
24 changes: 21 additions & 3 deletions src/hooks/task.hook.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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 ─────────────────────
Expand All@@ -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];
Expand All@@ -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 '
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/data/demo-history.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<Record<string, string>> = {
'Emissions return — Northgate': 'Meter 3 was swapped mid-period — figures split across the two serials, both attached.',
Expand All@@ -236,6 +260,8 @@ export interface SeededTask extends Omit<TaskDraft, 'status'> {
*/
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;
}
Expand DownExpand Up@@ -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) {
Expand DownExpand Up@@ -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]! }),
});
};

Expand Down
10 changes: 8 additions & 2 deletions src/data/task.seed.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
})),
});
Expand DownExpand Up@@ -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, {
Expand Down
24 changes: 21 additions & 3 deletions src/hooks/task.hook.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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 ─────────────────────
Expand All@@ -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];
Expand All@@ -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 '
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/data/demo-history.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<Record<string, string>> = {
'Emissions return — Northgate': 'Meter 3 was swapped mid-period — figures split across the two serials, both attached.',
Expand All@@ -236,6 +260,8 @@ export interface SeededTask extends Omit<TaskDraft, 'status'> {
*/
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;
}
Expand DownExpand Up@@ -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) {
Expand DownExpand Up@@ -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]! }),
});
};

Expand Down
10 changes: 8 additions & 2 deletions src/data/task.seed.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
})),
});
Expand DownExpand Up@@ -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, {
Expand Down
24 changes: 21 additions & 3 deletions src/hooks/task.hook.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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 ─────────────────────
Expand All@@ -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];
Expand All@@ -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 '
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/data/demo-history.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<Record<string, string>> = {
'Emissions return — Northgate': 'Meter 3 was swapped mid-period — figures split across the two serials, both attached.',
Expand All@@ -236,6 +260,8 @@ export interface SeededTask extends Omit<TaskDraft, 'status'> {
*/
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;
}
Expand DownExpand Up@@ -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) {
Expand DownExpand Up@@ -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]! }),
});
};

Expand Down
10 changes: 8 additions & 2 deletions src/data/task.seed.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
})),
});
Expand DownExpand Up@@ -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, {
Expand Down
24 changes: 21 additions & 3 deletions src/hooks/task.hook.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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 ─────────────────────
Expand All@@ -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];
Expand All@@ -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 '
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/data/demo-history.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<Record<string, string>> = {
'Emissions return — Northgate': 'Meter 3 was swapped mid-period — figures split across the two serials, both attached.',
Expand All@@ -236,6 +260,8 @@ export interface SeededTask extends Omit<TaskDraft, 'status'> {
*/
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;
}
Expand DownExpand Up@@ -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) {
Expand DownExpand Up@@ -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]! }),
});
};

Expand Down
10 changes: 8 additions & 2 deletions src/data/task.seed.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
})),
});
Expand DownExpand Up@@ -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, {
Expand Down
24 changes: 21 additions & 3 deletions src/hooks/task.hook.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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 ─────────────────────
Expand All@@ -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];
Expand All@@ -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 '
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/data/demo-history.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<Record<string, string>> = {
'Emissions return — Northgate': 'Meter 3 was swapped mid-period — figures split across the two serials, both attached.',
Expand All@@ -236,6 +260,8 @@ export interface SeededTask extends Omit<TaskDraft, 'status'> {
*/
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;
}
Expand DownExpand Up@@ -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) {
Expand DownExpand Up@@ -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]! }),
});
};

Expand Down
10 changes: 8 additions & 2 deletions src/data/task.seed.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
})),
});
Expand DownExpand Up@@ -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, {
Expand Down
24 changes: 21 additions & 3 deletions src/hooks/task.hook.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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 ─────────────────────
Expand All@@ -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];
Expand All@@ -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 '
Expand Down
Loading
Loading