Skip to content

Commit e671ea1

Browse files
os-warrenclaude
andcommitted
Spread in-flight touch ages by how long a task has been open
Keeps every non-designated row inside the fortnight while putting real values in the 7-to-14-day band, so the dashboard's nested >7d / >14d / >30d tiles read 6 / 3 / 2 rather than 3 / 3 / 2. Also corrects the fan-out comment after #72: the reason a seeded assignment does not fan out is the loader's own skipTriggers, not an unbound trigger. Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 91ba3c9 commit e671ea1

2 files changed

Lines changed: 50 additions & 17 deletions

File tree

‎src/data/demo-assignments.ts‎

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,28 @@ import { NOW, TODAY } from './demo-history.js';
99
* The two assignments, and the tasks their fan-out would have produced.
1010
*
1111
* ⚠️ **The fan-out tasks are seeded directly, and that is not a shortcut.**
12-
* `assignment.flow.ts` is a `record_change` flow, and booting this app prints
13-
* `record_change triggers are not bound`. The flow therefore does not fire — on
14-
* a seeded assignment or on one created by hand in the UI. Seeding an
15-
* assignment and waiting for its children would leave the Assignments screen
16-
* showing two rows with `task_count: 0` and nothing to open, which is exactly
17-
* the "renders an empty screen" failure this card exists to prevent.
12+
* `assignment.flow.ts` is a `record_change` flow, and the seed loader writes
13+
* with `SEED_OPTIONS = { isSystem: true, skipTriggers: true, seedReplay: true }`.
14+
* `skipTriggers` suppresses record-change AUTOMATION — that is its whole job —
15+
* so a seeded assignment never fans out, and it never will, however the
16+
* trigger plugins are wired. (#72 has since bound `record_change`, so the flow
17+
* does fire for an assignment created by hand in the UI. That does not change
18+
* anything here: it is the SEED path that is exempt.) Seeding an assignment
19+
* and waiting for its children would leave the Assignments screen showing two
20+
* rows with `task_count: 0` and nothing to open, which is exactly the "renders
21+
* an empty screen" failure this card exists to prevent.
1822
*
1923
* So the rows below are written to be **byte-identical to what
2024
* `assignment.flow.ts` would have created**, field for field: `subject` copied
2125
* from the assignment, `owner` the assignee, `business_unit` denormalised from
2226
* the owner, `assignment` the parent, `source: 'assigned'`, `visible_from`
2327
* equal to `due_date` (an assignment has no lead time to spread), `status:
2428
* 'open'` at creation — and NO `period_key`, because an assignment has no
25-
* period and the dispatch identity index does not apply to it. When the
26-
* trigger binding is fixed, the flow's own idempotency guard (it looks for an
27-
* existing task on `(assignment, owner)` before creating one) sees these rows
28-
* and creates nothing, so the seed and the flow do not fight.
29+
* period and the dispatch identity index does not apply to it. If one of these
30+
* assignments is ever re-saved by hand and the flow does fire, its own
31+
* idempotency guard (it looks for an existing task on `(assignment, owner)`
32+
* before creating one) sees these rows and creates nothing, so the seed and
33+
* the flow do not fight.
2934
*
3035
* The statuses below are then moved on from `open` by hand, because "mixed
3136
* completion" is the thing an assignment is worth looking at for.

‎src/data/demo-history.ts‎

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,9 @@ const LATE_MOST_RECENT: Readonly<Record<string, 'open' | 'in_progress'>> = {
171171
'Calibration verification — Lab 1': 'open',
172172
};
173173

174+
/** How long ago each actively-chased late row was last touched. */
175+
const CHASED_DAYS_AGO = [2, 6, 10] as const;
176+
174177
/** Untouched since dispatch as well as late — the fourth Late row above. */
175178
const STALLED_LATE = 'Calibration verification — Lab 1';
176179

@@ -244,6 +247,31 @@ for (const [duty, series] of byDuty) {
244247
const inFlightKey = (duty: string): string | undefined =>
245248
byDuty.get(duty)?.find((draft) => !isPast(draft))?.period_key;
246249

250+
/**
251+
* How long ago a task that is still moving was last touched.
252+
*
253+
* Two constraints, and the interesting one is the second:
254+
*
255+
* 1. **Never before the task was dispatched.** A row cannot have been worked
256+
* on before it existed. This clamps the whole band for a freshly
257+
* dispatched monthly.
258+
* 2. **Never 14 days or more.** Anything that old lands in "Not moving", and
259+
* which rows stagnate is a decision this fixture makes deliberately —
260+
* see {@link STALLED_IN_FLIGHT} — not a side effect of a spread.
261+
*
262+
* Between those, the age is spread by how long the task has BEEN open rather
263+
* than uniformly. A task dispatched five months ago and still being worked was
264+
* realistically last touched a week or two back; one dispatched on Monday was
265+
* touched this week. A uniform spread collapses to "everything was touched in
266+
* the last few days" once constraint 1 clamps it, which makes the dashboard's
267+
* nested >7d / >14d / >30d buckets read identically and look broken.
268+
*/
269+
const touchedDaysAgo = (draft: TaskDraft, dispatched: Date, index: number): number => {
270+
const openFor = Math.floor((NOW.getTime() - dispatched.getTime()) / DAY);
271+
if (openFor >= 14) return 8 + (index % 5);
272+
return Math.max(0, Math.min(index % 6, openFor));
273+
};
274+
247275
/**
248276
* Decide what actually happened to one dispatched task.
249277
*
@@ -268,10 +296,15 @@ const resolveDraft = (draft: TaskDraft, index: number): SeededTask => {
268296
return withNote({
269297
...draft,
270298
status: LATE_MOST_RECENT[draft.duty]!,
299+
// Chased, but at different tempos — 2, 6 and 10 days. A month-overdue
300+
// task that was last touched yesterday, every time, is not what being
301+
// chased looks like; and spreading these across the fortnight is what
302+
// puts anything at all in the dashboard's 7-to-14-day band, which would
303+
// otherwise be empty and make its >7d and >14d tiles read identically.
271304
last_update_at:
272305
draft.duty === STALLED_LATE
273306
? untouchedSinceDispatch
274-
: iso(new Date(NOW.getTime() - ((index % 9) + 1) * DAY)),
307+
: iso(new Date(NOW.getTime() - CHASED_DAYS_AGO[index % CHASED_DAYS_AGO.length]! * DAY)),
275308
});
276309
}
277310
if (isMostRecentPast && draft.duty === SKIPPED_MOST_RECENT) {
@@ -307,12 +340,7 @@ const resolveDraft = (draft: TaskDraft, index: number): SeededTask => {
307340
return withNote({
308341
...draft,
309342
status: isInFlightHead && IN_PROGRESS_IN_FLIGHT.includes(draft.duty) ? 'in_progress' : 'open',
310-
// Not stalled ⇒ touched inside the fortnight, spread across it so the
311-
// Recent-activity timeline reads as a stream rather than one boot-time
312-
// spike. Never earlier than the day the task was dispatched.
313-
last_update_at: stalled
314-
? untouchedSinceDispatch
315-
: iso(new Date(Math.max(NOW.getTime() - (index % 13) * DAY, dispatched.getTime()))),
343+
last_update_at: stalled ? untouchedSinceDispatch : iso(new Date(NOW.getTime() - touchedDaysAgo(draft, dispatched, index) * DAY)),
316344
});
317345
};
318346

0 commit comments

Comments
 (0)