Retire the "Oldest untouched task" tile: the top row is two stagnation numbers - #126
Retire the "Oldest untouched task" tile: the top row is two stagnation numbers#126os-warren wants to merge 2 commits into
Conversation
`duly_stagnation.oldest_last_update_at` is `min(last_update_at)`, and the "Oldest untouched task" metric tile bound it. A metric widget renders one value as it arrives, and for that measure what arrives is an instant: `POST /api/v1/analytics/dataset/query` answers `"2026-07-04T07:00:00.000Z"` under a `fields[]` entry typed `number` (measured on a real boot, 17.3.0). #122 asked for days-since instead. It is not expressible in the dataset layer, measured rather than assumed: - `AggregationFunction` is count | sum | avg | min | max | count_distinct — no date-difference member. - A derived measure is `{ op, of }` with `of: z.array(SnakeCaseIdentifier)` — other MEASURE NAMES only. No literal, no `now`, so `today - min(...)` has nothing on the left of the minus. - Measure-to-measure arithmetic over instants is dead too: a temporary `{ op: 'difference', of: [max_ts, min_ts] }` added to the real dataset, booted and queried, answered `null` — `computeDerived` coerces operands with `Number()` and an ISO string is `NaN`. A stored `days_stalled` is AGENTS.md rule 5's banned shape, so the card's own fallback ships: the tile goes and `not_moving_30d` takes the row. Its `h` drops 4 -> 3 in the same edit — at `w: 6, h: 4` it would equal the headline's area and fail the pin that no other number may compete with it. The MEASURE stays: it is a legitimate semantic-layer value (table column, report, API read) and this dataset already ships measures no widget binds. What is banned is putting an instant on a single-value tile, and test/dashboard.test.ts now walks the dashboards barrel for exactly that, resolving the measure's field type through the objects barrel rather than matching a name. The worst single case stays reachable in task.view.ts's `stalled` view, which already sorts `last_update_at` ascending. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
The first attempt at the widened subset tile was `w: 6, h: 3` — six columns because the card asked for them, three rows tall because six-by-four would equal the headline's area and fail the pin that no other number may compete with it. Screenshotted at 1440, that reordered the screen. The console's grid compacts vertically: the shorter tile left a gap, the on-time rate was pulled up into the right column beside the headline, and the overdue and completeness tiles rose to fill the left — so the documented reading order (the on-time rate directly under the headline) silently stopped being what the screen showed, with every gate still green. A row of tiles has to be uniform in height, which leaves width as the only free dimension: the headline takes seven columns and the subset tile five, the same 7/5 split the two charts below already use. The area rule holds with room to spare (28 vs 20). docs/i18n/01-dashboard-zh.png is re-captured from this build — it showed the retired tile — and 04-dashboard-zh-390.png is the same screen at 390px, which the docs did not have. Both are `pnpm demo:zh`, Chromium at locale zh-CN, signed in as the demo admin. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
|
Closing without merging — and thank you for verifying the premise first, which is what decides this. Re-measured on Not taking the guard test either, since it would forbid the tile we are keeping. The 390px capture and the analytics-door measurements are recorded here and on #122; objectstack#15768 stays open as the upstream follow-up. Generated by Claude Code |
Closes #122
Route taken: the card's fallback — the tile goes,
not_moving_30dtakes the row. The preferred route (a days-since integer) was measured against the real analytics door and is not expressible; so was a third route the card did not know about (format: 'relative'). Both measurements are below.The card measured Console
@objectstack/cli17.2.0 and reported the tile printing2026-07-04T07:00:00.000Zverbatim and overflowing at both viewports. Onmaintoday (17.3.0) that symptom is gone — the console runs the value through a locale date formatter (objectui#7178, fixed and shipped):2026-07-04T07:00:00.000Z2026年7月4日 07:00Jul 4, 2026, 07:00 AMscrollWidth == clientWidthat 1440 and 390What survives is the half the card's "done" section is actually written against: the tile answers with an instant, carrying a time-of-day that is the dispatch clock, in a row where every other tile is a count or a rate — and "days since" cannot replace it. That is what this PR fixes. If the maintainer now prefers keeping a formatted-date tile, this PR is one revert — say so and I will restore it rather than argue.
Why the preferred route is impossible (measured, not assumed)
Against
@objectstack/spec17.3.0 and a realpnpm demo:zhboot:AggregationFunctioniscount | sum | avg | min | max | count_distinct— no date-difference member.{ op, of }withop: ratio | sum | difference | productandof: z.array(SnakeCaseIdentifierSchema)— other measure NAMES only. No literal operand, nonowmeasure, sotoday − min(last_update_at)has nothing to put on the left of the minus.{ op: 'difference', of: ['newest_last_update_at', 'oldest_last_update_at'] }was added to the real dataset, booted, and queried throughPOST /api/v1/analytics/dataset/query(reverted before any commit —git checkoutconfirmed clean):{"rows":[{"oldest_last_update_at":"2026-07-04T07:00:00.000Z", "newest_last_update_at":"2026-09-05T06:36:02.452Z", "untouched_over_14d":3,"untouched_over_30d":3, "days_untouched_attempt":null}], "fields":[{"name":"oldest_last_update_at","type":"number","label":"Oldest touch"}, …]}null, because the executor'scomputeDerivedcoerces every operand withNumber()andNumber('2026-07-04T07:00:00.000Z')isNaN. Note thefields[]entry: the door types the measurenumberand hands back an ISO string — that mismatch is the defect underneath the symptom.5. And the obvious question — "why not just format it?" — was probed too
objectstack#14933 records that a date measure's
formatis read as a display style, with'relative'rendering "3 days ago". That would have answered this card without removing anything, so it was tried rather than reasoned about:format: 'relative'added to the real measure, rebuilt, booted, tile read in both locales.measure.format2026年7月4日 07:00Jul 4, 2026, 07:00 AM'relative'2026年7月4日 07:00— unchangedJul 4, 2026, 07:00 AM— unchangedThe style is not dropped on the wire — the door carries it (
"fields":[{"name":"oldest_last_update_at","type":"number","label":"Oldest touch","format":"relative"}]) — the dashboard metric tile simply does not honour it on the console shipped with cli 17.3.0. Probe reverted; tree clean.Filed upstream as objectstack-ai/objectstack#15768 with both halves (the
type: "number"mislabel on an instant, and the ignored style), including the note that it makes #14933's proposed one-line wording wrong for the tile path. AGENTS.md rule 9: the gap is reported, not written around.What changed
src/dashboards/duty-health.dashboard.ts— theoldest_touchwidget is gone, replaced in place by a removal note carrying the measurements above.not_moving_30dwidens into the row.h: 4equals the headline's area and fails the pin that no other number may compete with it. The first attempt —w: 6, h: 3— passed every gate and broke the screen: the console's grid compacts vertically, so the shorter tile left a gap, the on-time rate was pulled into the right column beside the headline, and the overdue and completeness tiles rose to fill the left. A row of tiles must be uniform in height, so width is the only free dimension: headline 7, subset tile 5 — the same split the two charts below already use. Area 28 vs 20, pin green with room.src/datasets/stagnation.dataset.ts— the measure stays. It is a legitimate semantic-layer value (a table column, a report, an API read), and this dataset already ships measures no widget binds (open_tasks,untouched_over_7d). What is banned — in a comment, and in a test — is binding an instant to a single-value tile.test/dashboard.test.ts— a new walk over the dashboards barrel: nometric/kpiwidget may bind a value-preserving aggregate (min/max/sum/avg) over adate/datetime/timefield. It resolves the measure's field type through the objects barrel rather than matching a name, records a boundary (not a pass) for a joined path or an unknown field, and ships nine synthetic self-tests — a guard written to stay green forever has no other evidence that it can fail.src/translations/zh-CN.ts— the widget's bundle keys are removed with the widget (test/i18n-coverage.test.tschecks both directions); the measure's label key stays with the measure. The >30d description gains the pointer below, in both languages.src/views/task.view.ts'sstalledview already sortslast_update_atascending and shows that column — its first row is the answer, with the task and the owner beside it, which a bare date on a tile never had. The >30d tile's description now says so.Reverse-verification: the retired tile was reinstated on the real dashboard (mutation confirmed on disk by
grep -c, restored by anEXIT/INT/TERMtrap, restore confirmed bygit diff --quiet).test/dashboard.test.tswent to2 failed | 50 passed, exit 1 — the two failures being the new guard and the "tile is gone" pin.Gates — all four green at
c22d68b, the final commitRun as one chain through the shared verify lock:
VERDICT command-exit 0 · held the lock 43s.Browser proof
pnpm demo:zh -- --port 4320on a clean.objectstack/data, Chromium atlocale: zh-CNwithaccept-language: zh-CN,zh;q=0.9, signed in throughPOST /api/v1/auth/sign-in/email. Both committed:docs/i18n/01-dashboard-zh.png. Re-captured rather than added: it showed the retired tile, and this PR is what made it stale. Top row is two numbers; the reading order below it is unchanged.docs/i18n/04-dashboard-zh-390.png. New; the docs had no mobile capture.No ISO timestamp in the page text at either width, and
document.scrollWidth == clientWidthat both — no horizontal overflow.🤖 Generated with Claude Code
https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p