From e38d24601523e370397532de3c1896b36a45841f Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 7 Sep 2026 13:33:30 -0400 Subject: [PATCH] fix(#4439): render pending-todo 'created' as date-only, matching [date] (#4494) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(#4439): failing-first regression coverage for pending-todo date rendering renderPendingTodoBullet currently echoes the todo's `created` frontmatter verbatim into the rendered bullet's [date] bracket, so a full ISO-8601 timestamp (the actual stored shape, per add-todo.md's create_file step) leaks through instead of the date-only format documented in docs/reference/state-md.md and docs/COMMANDS.md. This commit adds the regression coverage only; the renderer fix lands in the next commit. Co-Authored-By: Claude Sonnet 5 * fix(#4439): render pending-todo 'created' as date-only, matching [date] renderPendingTodoBullet echoed the todo's created frontmatter verbatim into the rendered STATE.md bullet. That value is always a full ISO-8601 timestamp by design (add-todo.md's create_file step writes init.todos' timestamp field), but docs/reference/state-md.md and docs/COMMANDS.md document the bullet as `- [date] ...` — a short calendar date. Added pendingTodoDateOnly(), a pure display-only formatter: a value starting with a well-formed YYYY-MM-DD is shortened to just that; anything else ('unknown', a malformed string) passes through unchanged. The stored frontmatter and the JSON todos[].created field are untouched. Co-Authored-By: Claude Sonnet 5 * test(#4439): cover a non-4-digit-year near-miss for the date-only formatter Standards review flagged a gap: the malformed-date coverage exercised a non-padded month/day but not a non-4-digit year, the other way the input can look almost-but-not-quite like YYYY-MM-DD. Same pass-through code path as the existing malformed-date test; no behavior change. Co-Authored-By: Claude Sonnet 5 * docs(#4439): changeset for the pending-todo date-only bullet fix Co-Authored-By: Claude Sonnet 5 * docs(#4439): backfill changeset PR number Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: sim Co-authored-by: Claude Sonnet 5 --- .changeset/steady-sloths-parade.md | 5 +++++ src/init.cts | 18 +++++++++++++++++- tests/state-todos-render.test.cjs | 24 +++++++++++++++++++++++- 3 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 .changeset/steady-sloths-parade.md diff --git a/.changeset/steady-sloths-parade.md b/.changeset/steady-sloths-parade.md new file mode 100644 index 000000000..0ce35307f --- /dev/null +++ b/.changeset/steady-sloths-parade.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4494 +--- +**Pending-todo bullets in STATE.md now show a date, not a full timestamp** — `renderPendingTodosMarkdown` was echoing the todo's `created` frontmatter verbatim (a full ISO-8601 instant) into the rendered `[…]` bracket, instead of the date-only `[date]` format documented in `docs/reference/state-md.md` and `docs/COMMANDS.md`. (#4439) diff --git a/src/init.cts b/src/init.cts index 3203e97a0..81b167380 100644 --- a/src/init.cts +++ b/src/init.cts @@ -2308,8 +2308,24 @@ function pendingTodoLinkTarget(todo: Record, projectRoot: strin return rel; } +/** + * #4439: the stored `created:` frontmatter (and the JSON `todos[].created` + * field it round-trips through) is always a full ISO-8601 timestamp, by + * design — this is the display-only seam that reformats it to the + * date-only `[date]` bullet documented in docs/reference/state-md.md and + * docs/COMMANDS.md. A value that doesn't start with a well-formed + * `YYYY-MM-DD` (the 'unknown' fallback, or any other non-conforming + * string) passes through unchanged rather than being mangled. + */ +function pendingTodoDateOnly(value: string): string { + const match = value.match(/^\d{4}-\d{2}-\d{2}/); + return match ? match[0] : value; +} + function renderPendingTodoBullet(todo: Record, projectRoot?: string): string { - const date = sanitizePendingTodoInline(pendingTodoFieldAsString(todo['created'], 'unknown')); + const date = pendingTodoDateOnly( + sanitizePendingTodoInline(pendingTodoFieldAsString(todo['created'], 'unknown')), + ); let area = sanitizePendingTodoInline(pendingTodoFieldAsString(todo['area'], 'general')); let title = sanitizePendingTodoInline(pendingTodoFieldAsString(todo['title'], 'Untitled')); // Strip trailing "." so the fixed "Needs ....` template below never diff --git a/tests/state-todos-render.test.cjs b/tests/state-todos-render.test.cjs index 1852fb06f..51e80fadc 100644 --- a/tests/state-todos-render.test.cjs +++ b/tests/state-todos-render.test.cjs @@ -103,6 +103,27 @@ test('renderPendingTodosMarkdown: boundary — 240 chars (limit) is not truncate assert.equal(line, `- [2026-09-01] [api] ${title} — [todo file](${todo.path})`); }); +test('renderPendingTodosMarkdown: full ISO-8601 \'created\' renders a date-only bracket (#4439)', () => { + const line = renderPendingTodosMarkdown([makeTodo({ created: '2026-09-01T00:00:00.000Z', needs: undefined })]); + assert.match(line, /^- \[2026-09-01\] \[api\]/); + assert.doesNotMatch(line, /2026-09-01T/, 'full ISO timestamp must not leak into the rendered bullet'); +}); + +test('renderPendingTodosMarkdown: \'created\' fallback (\'unknown\') is not sliced by the date-only formatter', () => { + const line = renderPendingTodosMarkdown([makeTodo({ created: undefined, needs: undefined })]); + assert.match(line, /^- \[unknown\] \[api\]/); +}); + +test('renderPendingTodosMarkdown: a malformed non-padded date is passed through unchanged, not mis-sliced', () => { + const line = renderPendingTodosMarkdown([makeTodo({ created: '2026-9-1', needs: undefined })]); + assert.match(line, /^- \[2026-9-1\] \[api\]/); +}); + +test('renderPendingTodosMarkdown: a non-4-digit year is not mis-sliced (near-miss YYYY-MM-DD shape)', () => { + const line = renderPendingTodosMarkdown([makeTodo({ created: '202-09-01T00:00:00.000Z', needs: undefined })]); + assert.match(line, /^- \[202-09-01T00:00:00\.000Z\] \[api\]/); +}); + test('renderPendingTodosMarkdown: boundary — 241 chars (limit+1) truncates, needs dropped first', () => { const base = makeTodo({ needs: 'x', title: 'X' }); const probe = renderPendingTodosMarkdown([base]); @@ -142,7 +163,7 @@ test('property: rendered body always has one line per todo, each line <= 240 cha // than a vacuous one; the pathological "cap not achievable" case is // covered separately by the fixed "pathological" unit test above. const todoArb = fc.record({ - created: fc.constantFrom('2026-01-01', '2025-12-31', 'unknown'), + created: fc.constantFrom('2026-01-01', '2025-12-31', 'unknown', '2026-01-01T00:00:00.000Z', '2026-9-1'), area: fc.string({ minLength: 0, maxLength: 40 }), title: fc.string({ minLength: 0, maxLength: 500 }), path: fc @@ -320,6 +341,7 @@ test('cmdInitTodos: real todo file produces a rendered bullet via the CLI', (t) const json = runQueryInitTodos(dir); assert.equal(json.pending_read_ok, true); assert.match(json.pending_todos_markdown, /Fix retry logic/); + assert.match(json.pending_todos_markdown, /^- \[2026-09-01\] \[api\]/m); // The Needs clause is asserted on the STRUCTURED field, not the rendered // bullet: the bullet embeds the todo file's ABSOLUTE path, so its total // length varies by runner tmpdir (macOS CI's /private/var/folders/… plus