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