Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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

<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->

## Acceptance Criteria
<!-- AC:BEGIN -->
- [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.
<!-- AC:END -->

## Implementation Plan

<!-- SECTION:PLAN:BEGIN -->
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.
<!-- SECTION:PLAN:END -->

## Implementation Notes

<!-- SECTION:NOTES:BEGIN -->
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.
<!-- SECTION:NOTES:END -->

## Final Summary

<!-- SECTION:FINAL_SUMMARY:BEGIN -->
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`
<!-- SECTION:FINAL_SUMMARY:END -->

## Definition of Done
<!-- DOD:BEGIN -->
- [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
<!-- DOD:END -->
22 changes: 13 additions & 9 deletions src/formatters/task-plain-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Expand Down Expand Up @@ -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<Task["comments"]>[number]): string {
function formatCommentHeader(
comment: NonNullable<Task["comments"]>[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(" - ");
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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("");
}
Expand Down
5 changes: 3 additions & 2 deletions src/mcp/tools/documents/handlers.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(", ")}`);
Expand Down
5 changes: 3 additions & 2 deletions src/mcp/utils/document-response.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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));
Expand Down
14 changes: 9 additions & 5 deletions src/test/cli-plain-output.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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) {
Expand All @@ -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 <id> --plain shortcut", async () => {
Expand All @@ -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:");
Expand Down Expand Up @@ -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:");
Expand Down Expand Up @@ -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:");
Expand Down
2 changes: 2 additions & 0 deletions src/test/mcp-documents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand All @@ -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");
});

Expand Down
3 changes: 2 additions & 1 deletion src/test/mcp-tasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
50 changes: 50 additions & 0 deletions src/test/utc-date-display.test.ts
Original file line number Diff line number Diff line change
@@ -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)");
});
});
2 changes: 1 addition & 1 deletion src/ui/task-viewer-with-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("");
Expand Down
69 changes: 69 additions & 0 deletions src/utils/utc-date-display.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading