From 0fc290d9398b97185451e9d980ffd437a20670a8 Mon Sep 17 00:00:00 2001 From: Alex Gavrilescu Date: Mon, 8 Jun 2026 00:07:11 +0200 Subject: [PATCH] BACK-471 - Display task dates as UTC --- ...ntly-as-UTC-in-TUI-plain-output-and-MCP.md | 75 +++++++++++++++++++ src/formatters/task-plain-text.ts | 22 +++--- src/mcp/tools/documents/handlers.ts | 5 +- src/mcp/utils/document-response.ts | 5 +- src/test/cli-plain-output.test.ts | 14 ++-- src/test/mcp-documents.test.ts | 2 + src/test/mcp-tasks.test.ts | 3 +- src/test/utc-date-display.test.ts | 50 +++++++++++++ src/ui/task-viewer-with-search.ts | 2 +- src/utils/utc-date-display.ts | 69 +++++++++++++++++ 10 files changed, 227 insertions(+), 20 deletions(-) create mode 100644 backlog/tasks/back-471 - Display-task-dates-consistently-as-UTC-in-TUI-plain-output-and-MCP.md create mode 100644 src/test/utc-date-display.test.ts create mode 100644 src/utils/utc-date-display.ts diff --git a/backlog/tasks/back-471 - Display-task-dates-consistently-as-UTC-in-TUI-plain-output-and-MCP.md b/backlog/tasks/back-471 - Display-task-dates-consistently-as-UTC-in-TUI-plain-output-and-MCP.md new file mode 100644 index 000000000..d8beef720 --- /dev/null +++ b/backlog/tasks/back-471 - Display-task-dates-consistently-as-UTC-in-TUI-plain-output-and-MCP.md @@ -0,0 +1,75 @@ +--- +id: BACK-471 +title: 'Display task dates consistently as UTC in TUI, plain output, and MCP' +status: Done +assignee: + - '@codex' +created_date: '2026-06-07 22:01' +updated_date: '2026-06-07 22:07' +labels: [] +dependencies: [] +modified_files: + - src/utils/utc-date-display.ts + - src/formatters/task-plain-text.ts + - src/ui/task-viewer-with-search.ts + - src/mcp/utils/document-response.ts + - src/mcp/tools/documents/handlers.ts + - src/test/utc-date-display.test.ts + - src/test/cli-plain-output.test.ts + - src/test/mcp-tasks.test.ts + - src/test/mcp-documents.test.ts +priority: medium +ordinal: 31000 +--- + +## Description + + +Backlog-generated task timestamps are stored as UTC strings without an explicit timezone suffix. Update display-only formatting so terminal UI surfaces render task and comment dates consistently as UTC, and machine-readable text surfaces for CLI --plain and MCP include an explicit `(UTC)` suffix after displayed task dates. Do not change stored task markdown/frontmatter values or Web local-time rendering behavior. + + +## Acceptance Criteria + +- [x] #1 Terminal UI task detail, task list/card, overview, and comment date displays use a shared UTC date formatter instead of relying on raw or local Date rendering. +- [x] #2 CLI plain task output appends `(UTC)` to displayed task and comment dates while preserving the existing stored task file values. +- [x] #3 MCP task/document response text appends `(UTC)` to displayed date fields where dates are shown to agents. +- [x] #4 Date-only values remain date-only but are still identified as UTC where the plain/MCP display includes a suffix. +- [x] #5 Tests cover UTC formatting for TUI/plain/MCP paths and verify stored markdown dates are not rewritten just for display. + + +## Implementation Plan + + +1. Add or reuse a shared UTC display formatter for task/document text surfaces, preserving stored markdown/frontmatter values. +2. Route TUI task detail, list/overview, and comment metadata through UTC formatting without changing Web local-time rendering. +3. Route CLI --plain and MCP response date fields through the same formatter with an explicit `(UTC)` suffix. +4. Add focused tests for UTC datetime/date-only formatting and plain/MCP output expectations, then run type-check, Biome, and targeted tests. + + +## Implementation Notes + + +Implemented a shared UTC display formatter that preserves Backlog's timezone-less UTC storage text, normalizes explicit timezone inputs to UTC, and appends `(UTC)` for plain/MCP text surfaces. + +Updated task plain output, MCP task responses via the plain formatter, MCP document response/list metadata, and TUI task detail comment dates to use the shared formatter. Terminal task detail Created/Updated already flowed through the same formatter; terminal boards/lists/overview do not display dates. + +Verified display-only behavior by reading the task markdown after `task view --plain` and asserting `(UTC)` was not written to storage. + + +## Final Summary + + +Implemented display-only UTC date formatting for terminal/plain/MCP date surfaces. Added a shared UTC display helper, routed task plain output/comment headers and TUI task detail comments through it, and suffixed MCP document/task response dates with `(UTC)` where dates are shown. Verified stored markdown dates remain unchanged by plain output. + +Verification: +- `bunx tsc --noEmit` +- `bun run check .` +- `bun test src/test/utc-date-display.test.ts src/test/cli-plain-output.test.ts src/test/mcp-tasks.test.ts src/test/mcp-documents.test.ts` + + +## Definition of Done + +- [x] #1 bunx tsc --noEmit passes when TypeScript touched +- [x] #2 bun run check . passes when formatting/linting touched +- [x] #3 bun test (or scoped test) passes + diff --git a/src/formatters/task-plain-text.ts b/src/formatters/task-plain-text.ts index 8dd7cc9f6..e23937fae 100644 --- a/src/formatters/task-plain-text.ts +++ b/src/formatters/task-plain-text.ts @@ -3,15 +3,16 @@ import type { ChecklistItem } from "../ui/checklist.ts"; import { transformCodePathsPlain } from "../ui/code-path.ts"; import { formatStatusWithIcon } from "../ui/status-icon.ts"; import { sortByTaskId } from "../utils/task-sorting.ts"; +import { formatUtcDateForDisplay, type UtcDateDisplayOptions } from "../utils/utc-date-display.ts"; export type TaskPlainTextOptions = { filePathOverride?: string; }; -export function formatDateForDisplay(dateStr: string): string { - if (!dateStr) return ""; - const hasTime = dateStr.includes(" ") || dateStr.includes("T"); - return hasTime ? dateStr : dateStr; +const plainDateDisplayOptions: UtcDateDisplayOptions = { appendUtcLabel: true }; + +export function formatDateForDisplay(dateStr: string, options: UtcDateDisplayOptions = {}): string { + return formatUtcDateForDisplay(dateStr, options); } function buildChecklistItems(items: Task["acceptanceCriteriaItems"]): ChecklistItem[] { @@ -58,13 +59,16 @@ function formatSubtaskLines(subtasks: Array<{ id: string; title: string }>): str return sorted.map((subtask) => `- ${subtask.id} - ${subtask.title}`); } -function formatCommentHeader(comment: NonNullable[number]): string { +function formatCommentHeader( + comment: NonNullable[number], + dateOptions: UtcDateDisplayOptions = {}, +): string { const parts = [`#${comment.index}`]; if (comment.author) { parts.push(comment.author); } if (comment.createdDate) { - parts.push(comment.createdDate); + parts.push(formatDateForDisplay(comment.createdDate, dateOptions)); } return parts.join(" - "); } @@ -101,9 +105,9 @@ export function formatTaskPlainText(task: Task, options: TaskPlainTextOptions = lines.push(`Reporter: ${reporter}`); } - lines.push(`Created: ${formatDateForDisplay(task.createdDate)}`); + lines.push(`Created: ${formatDateForDisplay(task.createdDate, plainDateDisplayOptions)}`); if (task.updatedDate) { - lines.push(`Updated: ${formatDateForDisplay(task.updatedDate)}`); + lines.push(`Updated: ${formatDateForDisplay(task.updatedDate, plainDateDisplayOptions)}`); } if (task.labels?.length) { @@ -195,7 +199,7 @@ export function formatTaskPlainText(task: Task, options: TaskPlainTextOptions = lines.push("Comments:"); lines.push("-".repeat(50)); for (const comment of comments) { - lines.push(formatCommentHeader(comment)); + lines.push(formatCommentHeader(comment, plainDateDisplayOptions)); lines.push(transformCodePathsPlain(comment.body.trim())); lines.push(""); } diff --git a/src/mcp/tools/documents/handlers.ts b/src/mcp/tools/documents/handlers.ts index d52671b7b..7b7bc1bae 100644 --- a/src/mcp/tools/documents/handlers.ts +++ b/src/mcp/tools/documents/handlers.ts @@ -1,4 +1,5 @@ import type { Document, DocumentSearchResult } from "../../../types/index.ts"; +import { formatUtcDateForDisplay } from "../../../utils/utc-date-display.ts"; import { BacklogToolError } from "../../errors/mcp-errors.ts"; import type { McpServer } from "../../server.ts"; import type { CallToolResult } from "../../types.ts"; @@ -41,10 +42,10 @@ export class DocumentHandlers { const metadata: string[] = [ `type: ${document.type}`, `path: ${document.path ?? "(unknown)"}`, - `created: ${document.createdDate}`, + `created: ${formatUtcDateForDisplay(document.createdDate, { appendUtcLabel: true })}`, ]; if (document.updatedDate) { - metadata.push(`updated: ${document.updatedDate}`); + metadata.push(`updated: ${formatUtcDateForDisplay(document.updatedDate, { appendUtcLabel: true })}`); } if (document.tags && document.tags.length > 0) { metadata.push(`tags: ${document.tags.join(", ")}`); diff --git a/src/mcp/utils/document-response.ts b/src/mcp/utils/document-response.ts index f97692655..acef31843 100644 --- a/src/mcp/utils/document-response.ts +++ b/src/mcp/utils/document-response.ts @@ -1,4 +1,5 @@ import type { Document } from "../../types/index.ts"; +import { formatUtcDateForDisplay } from "../../utils/utc-date-display.ts"; import type { CallToolResult } from "../types.ts"; function formatTags(tags?: string[]): string { @@ -13,11 +14,11 @@ function buildDocumentText(document: Document, options?: { includeContent?: bool `Document ${document.id} - ${document.title}`, `Type: ${document.type}`, `Path: ${document.path ?? "(unknown)"}`, - `Created: ${document.createdDate}`, + `Created: ${formatUtcDateForDisplay(document.createdDate, { appendUtcLabel: true })}`, ]; if (document.updatedDate) { - lines.push(`Updated: ${document.updatedDate}`); + lines.push(`Updated: ${formatUtcDateForDisplay(document.updatedDate, { appendUtcLabel: true })}`); } lines.push(formatTags(document.tags)); diff --git a/src/test/cli-plain-output.test.ts b/src/test/cli-plain-output.test.ts index f9e150fea..4dccaf69e 100644 --- a/src/test/cli-plain-output.test.ts +++ b/src/test/cli-plain-output.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { mkdir, rm } from "node:fs/promises"; +import { mkdir, readFile, rm } from "node:fs/promises"; import { join } from "node:path"; import { $ } from "bun"; import { Core } from "../index.ts"; @@ -112,7 +112,7 @@ describe("CLI plain output for AI agents", () => { // Should contain the formatted task output expect(result.stdout.toString()).toContain("Task TASK-1 - Test task for plain output"); expect(result.stdout.toString()).toContain("Status: ○ To Do"); - expect(result.stdout.toString()).toContain("Created: 2025-06-18"); + expect(result.stdout.toString()).toContain("Created: 2025-06-18 (UTC)"); expect(result.stdout.toString()).toContain("Subtasks (2):"); const [subtask1, subtask2] = SUBTASKS; if (subtask1 && subtask2) { @@ -128,6 +128,10 @@ describe("CLI plain output for AI agents", () => { // Should not contain TUI escape codes expect(result.stdout.toString()).not.toContain("[?1049h"); expect(result.stdout.toString()).not.toContain("\x1b"); + + const taskFile = await readFile(join(TEST_DIR, "backlog/tasks/task-1 - Test-task-for-plain-output.md"), "utf8"); + expect(taskFile).toContain("created_date: '2025-06-18'"); + expect(taskFile).not.toContain("(UTC)"); }); it("should output plain text with task --plain shortcut", async () => { @@ -151,7 +155,7 @@ describe("CLI plain output for AI agents", () => { // Should contain the formatted task output expect(result.stdout.toString()).toContain("Task TASK-1 - Test task for plain output"); expect(result.stdout.toString()).toContain("Status: ○ To Do"); - expect(result.stdout.toString()).toContain("Created: 2025-06-18"); + expect(result.stdout.toString()).toContain("Created: 2025-06-18 (UTC)"); expect(result.stdout.toString()).toContain("Description:"); expect(result.stdout.toString()).toContain("Test description"); expect(result.stdout.toString()).toContain("Definition of Done:"); @@ -189,7 +193,7 @@ describe("CLI plain output for AI agents", () => { // Should contain the formatted draft output expect(result.stdout.toString()).toContain("Task DRAFT-1 - Test draft for plain output"); expect(result.stdout.toString()).toContain("Status: ○ Draft"); - expect(result.stdout.toString()).toMatch(/Created:\s+\d{4}-\d{2}-\d{2}/); + expect(result.stdout.toString()).toMatch(/Created:\s+\d{4}-\d{2}-\d{2} \d{2}:\d{2} \(UTC\)/); expect(result.stdout.toString()).toContain("Description:"); expect(result.stdout.toString()).toContain("Test draft description"); expect(result.stdout.toString()).toContain("Definition of Done:"); @@ -219,7 +223,7 @@ describe("CLI plain output for AI agents", () => { // Should contain the formatted draft output expect(result.stdout.toString()).toContain("Task DRAFT-1 - Test draft for plain output"); expect(result.stdout.toString()).toContain("Status: ○ Draft"); - expect(result.stdout.toString()).toMatch(/Created:\s+\d{4}-\d{2}-\d{2}/); + expect(result.stdout.toString()).toMatch(/Created:\s+\d{4}-\d{2}-\d{2} \d{2}:\d{2} \(UTC\)/); expect(result.stdout.toString()).toContain("Description:"); expect(result.stdout.toString()).toContain("Test draft description"); expect(result.stdout.toString()).toContain("Definition of Done:"); diff --git a/src/test/mcp-documents.test.ts b/src/test/mcp-documents.test.ts index b05f8c006..2a53374ac 100644 --- a/src/test/mcp-documents.test.ts +++ b/src/test/mcp-documents.test.ts @@ -64,6 +64,7 @@ describe("MCP document tools", () => { expect(createText).toContain("Document created successfully."); expect(createText).toContain("Document doc-1 - Engineering Guidelines"); expect(createText).toContain("Path: guides/doc-1 - Engineering-Guidelines.md"); + expect(createText).toMatch(/Created: \d{4}-\d{2}-\d{2} \d{2}:\d{2} \(UTC\)/); expect(createText).toContain("Tags: engineering"); expect(createText).toContain("# Overview"); @@ -75,6 +76,7 @@ describe("MCP document tools", () => { expect(listText).toContain("Documents:"); expect(listText).toContain("doc-1 - Engineering Guidelines"); expect(listText).toContain("path: guides/doc-1 - Engineering-Guidelines.md"); + expect(listText).toMatch(/created: \d{4}-\d{2}-\d{2} \d{2}:\d{2} \(UTC\)/); expect(listText).toContain("tags: engineering"); }); diff --git a/src/test/mcp-tasks.test.ts b/src/test/mcp-tasks.test.ts index fe2f5955f..20f4870c5 100644 --- a/src/test/mcp-tasks.test.ts +++ b/src/test/mcp-tasks.test.ts @@ -169,8 +169,9 @@ describe("MCP task tools (MVP)", () => { }, }); const editText = getText(editResult.content); + expect(editText).toMatch(/Created: \d{4}-\d{2}-\d{2} \d{2}:\d{2} \(UTC\)/); expect(editText).toContain("Comments:"); - expect(editText).toContain("#1 - @mcp"); + expect(editText).toMatch(/#1 - @mcp - \d{4}-\d{2}-\d{2} \d{2}:\d{2} \(UTC\)/); expect(editText).toContain("MCP comment body"); const viewResult = await mcpServer.testInterface.callTool({ diff --git a/src/test/utc-date-display.test.ts b/src/test/utc-date-display.test.ts new file mode 100644 index 000000000..1aa7ecf3b --- /dev/null +++ b/src/test/utc-date-display.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "bun:test"; +import { formatTaskPlainText } from "../formatters/task-plain-text.ts"; +import type { Task } from "../types/index.ts"; +import { formatUtcDateForDisplay } from "../utils/utc-date-display.ts"; + +describe("UTC date display", () => { + it("formats stored UTC task dates without applying the local timezone", () => { + expect(formatUtcDateForDisplay("2026-06-07 21:54")).toBe("2026-06-07 21:54"); + expect(formatUtcDateForDisplay("2026-06-07T21:54")).toBe("2026-06-07 21:54"); + expect(formatUtcDateForDisplay("2026-06-07")).toBe("2026-06-07"); + }); + + it("normalizes explicit timezone datetimes to UTC display text", () => { + expect(formatUtcDateForDisplay("2026-06-07T23:54+02:00")).toBe("2026-06-07 21:54"); + expect(formatUtcDateForDisplay("2026-06-07 16:24-0530")).toBe("2026-06-07 21:54"); + }); + + it("appends a UTC label for plain text surfaces", () => { + expect(formatUtcDateForDisplay("2026-06-07", { appendUtcLabel: true })).toBe("2026-06-07 (UTC)"); + expect(formatUtcDateForDisplay("2026-06-07 21:54", { appendUtcLabel: true })).toBe("2026-06-07 21:54 (UTC)"); + }); + + it("adds UTC labels to plain task and comment date fields", () => { + const task: Task = { + id: "TASK-1", + title: "UTC plain output", + status: "To Do", + assignee: [], + createdDate: "2026-06-07", + updatedDate: "2026-06-07 21:54", + labels: [], + dependencies: [], + description: "Task description", + comments: [ + { + index: 1, + author: "@reviewer", + createdDate: "2026-06-07T23:54+02:00", + body: "Review note", + }, + ], + }; + + const output = formatTaskPlainText(task); + + expect(output).toContain("Created: 2026-06-07 (UTC)"); + expect(output).toContain("Updated: 2026-06-07 21:54 (UTC)"); + expect(output).toContain("#1 - @reviewer - 2026-06-07 21:54 (UTC)"); + }); +}); diff --git a/src/ui/task-viewer-with-search.ts b/src/ui/task-viewer-with-search.ts index a3d222571..f392501bf 100644 --- a/src/ui/task-viewer-with-search.ts +++ b/src/ui/task-viewer-with-search.ts @@ -1482,7 +1482,7 @@ function generateDetailContent( for (const comment of comments) { const parts = [`#${comment.index}`]; if (comment.author) parts.push(comment.author); - if (comment.createdDate) parts.push(comment.createdDate); + if (comment.createdDate) parts.push(formatDateForDisplay(comment.createdDate)); bodyContent.push(`{bold}${parts.join(" - ")}{/bold}`); bodyContent.push(transformCodePaths(comment.body.trim())); bodyContent.push(""); diff --git a/src/utils/utc-date-display.ts b/src/utils/utc-date-display.ts new file mode 100644 index 000000000..80d6f0d03 --- /dev/null +++ b/src/utils/utc-date-display.ts @@ -0,0 +1,69 @@ +export type UtcDateDisplayOptions = { + appendUtcLabel?: boolean; +}; + +const dateOnlyPattern = /^\d{4}-\d{2}-\d{2}$/; +const timezoneLessDateTimePattern = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::\d{2}(?:\.\d+)?)?$/; +const explicitTimezoneDateTimePattern = + /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)(Z|[+-]\d{2}:?\d{2})$/i; +const utcLabelPattern = /\s+\(UTC\)$/i; + +function padDatePart(value: number): string { + return String(value).padStart(2, "0"); +} + +function normalizeTimezoneOffset(timezone: string): string { + if (/^[+-]\d{4}$/.test(timezone)) { + return `${timezone.slice(0, 3)}:${timezone.slice(3)}`; + } + return timezone.toUpperCase() === "Z" ? "Z" : timezone; +} + +function formatExplicitTimezoneDateTime(value: string): string | null { + const match = value.match(explicitTimezoneDateTimePattern); + if (!match) return null; + + const datePart = match[1]; + const timePart = match[2]; + const timezone = match[3]; + if (!datePart || !timePart || !timezone) return null; + + const date = new Date(`${datePart}T${timePart}${normalizeTimezoneOffset(timezone)}`); + if (Number.isNaN(date.getTime())) return null; + + const year = date.getUTCFullYear(); + const month = padDatePart(date.getUTCMonth() + 1); + const day = padDatePart(date.getUTCDate()); + const hours = padDatePart(date.getUTCHours()); + const minutes = padDatePart(date.getUTCMinutes()); + return `${year}-${month}-${day} ${hours}:${minutes}`; +} + +function normalizeUtcDateDisplay(value: string): string { + const explicitTimezoneDateTime = formatExplicitTimezoneDateTime(value); + if (explicitTimezoneDateTime) return explicitTimezoneDateTime; + + const dateTimeMatch = value.match(timezoneLessDateTimePattern); + if (dateTimeMatch) { + const year = dateTimeMatch[1]; + const month = dateTimeMatch[2]; + const day = dateTimeMatch[3]; + const hours = dateTimeMatch[4]; + const minutes = dateTimeMatch[5]; + if (year && month && day && hours && minutes) { + return `${year}-${month}-${day} ${hours}:${minutes}`; + } + } + + if (dateOnlyPattern.test(value)) return value; + + return value; +} + +export function formatUtcDateForDisplay(dateStr: string | undefined, options: UtcDateDisplayOptions = {}): string { + const value = (dateStr ?? "").trim().replace(utcLabelPattern, "").trim(); + if (!value) return ""; + + const displayValue = normalizeUtcDateDisplay(value); + return options.appendUtcLabel ? `${displayValue} (UTC)` : displayValue; +}