diff --git a/progress.txt b/progress.txt index b9ab81f..d44a06d 100644 --- a/progress.txt +++ b/progress.txt @@ -46,3 +46,9 @@ Each entry documents: date, feature, decisions, files changed, tests, and concer - Changes: Added alphabetical sorting to all list views, refactored status bar to use highlighted shortcut format (inverse text), renamed `n:run` to `c:create` across keyboard handler, action handler, and help overlay - Decisions: Used `` for shortcut highlighting; merged "run" and "create" actions under single "create" action routed by active tab; label "exit" for stop action to embed "x" shortcut - Issues: None + +[2026-02-13] #16 feat: Display human-readable relative dates in container, image, and volume views +- Files: src/utils/format-date.ts, src/__tests__/format-date.test.ts, src/components/ContainersView.tsx, src/components/ImagesView.tsx, src/components/DetailView.tsx +- Changes: Created `formatRelativeDate` utility that shows "just now", "X minutes/hours/days ago" for recent dates and short date format for older dates. Applied in ContainersView, ImagesView (via column render functions), and DetailView (auto-detects ISO date strings). +- Decisions: Added optional `now` parameter for testability instead of fake timers; used UTC timezone for short date fallback to avoid locale issues; kept raw ISO strings in data model, format only at display time +- Issues: None diff --git a/src/__tests__/format-date.test.ts b/src/__tests__/format-date.test.ts new file mode 100644 index 0000000..c4295d9 --- /dev/null +++ b/src/__tests__/format-date.test.ts @@ -0,0 +1,48 @@ +import { formatRelativeDate } from "../utils/format-date.js"; + +const NOW = new Date("2026-02-13T12:00:00.000Z").getTime(); + +describe("formatRelativeDate", () => { + it('should return "just now" for dates less than 60 seconds ago', () => { + expect(formatRelativeDate("2026-02-13T11:59:30.000Z", NOW)).toBe("just now"); + expect(formatRelativeDate("2026-02-13T11:59:59.000Z", NOW)).toBe("just now"); + }); + + it("should return minutes ago for dates less than 1 hour ago", () => { + expect(formatRelativeDate("2026-02-13T11:59:00.000Z", NOW)).toBe("1 minute ago"); + expect(formatRelativeDate("2026-02-13T11:55:00.000Z", NOW)).toBe("5 minutes ago"); + expect(formatRelativeDate("2026-02-13T11:01:00.000Z", NOW)).toBe("59 minutes ago"); + }); + + it("should return hours ago for dates less than 1 day ago", () => { + expect(formatRelativeDate("2026-02-13T11:00:00.000Z", NOW)).toBe("1 hour ago"); + expect(formatRelativeDate("2026-02-13T09:00:00.000Z", NOW)).toBe("3 hours ago"); + expect(formatRelativeDate("2026-02-12T13:00:00.000Z", NOW)).toBe("23 hours ago"); + }); + + it("should return days ago for dates less than 7 days ago", () => { + expect(formatRelativeDate("2026-02-12T12:00:00.000Z", NOW)).toBe("1 day ago"); + expect(formatRelativeDate("2026-02-10T12:00:00.000Z", NOW)).toBe("3 days ago"); + expect(formatRelativeDate("2026-02-07T12:00:00.000Z", NOW)).toBe("6 days ago"); + }); + + it("should return short date for dates 7 or more days ago", () => { + expect(formatRelativeDate("2026-02-06T12:00:00.000Z", NOW)).toBe("Feb 6, 2026"); + expect(formatRelativeDate("2026-01-01T00:00:00.000Z", NOW)).toBe("Jan 1, 2026"); + expect(formatRelativeDate("2024-06-15T00:00:00.000Z", NOW)).toBe("Jun 15, 2024"); + }); + + it("should return empty string for undefined or empty input", () => { + expect(formatRelativeDate(undefined, NOW)).toBe(""); + expect(formatRelativeDate("", NOW)).toBe(""); + }); + + it("should return the original string for invalid dates", () => { + expect(formatRelativeDate("not-a-date", NOW)).toBe("not-a-date"); + expect(formatRelativeDate("abc123", NOW)).toBe("abc123"); + }); + + it("should return the original string for future dates", () => { + expect(formatRelativeDate("2026-03-01T00:00:00.000Z", NOW)).toBe("2026-03-01T00:00:00.000Z"); + }); +}); diff --git a/src/components/ContainersView.tsx b/src/components/ContainersView.tsx index 28dd365..de6b1d7 100644 --- a/src/components/ContainersView.tsx +++ b/src/components/ContainersView.tsx @@ -2,6 +2,7 @@ import React from "react"; import { Box, Text } from "ink"; import { Table } from "./Table.js"; import type { Container, ContainerStatus } from "../types/index.js"; +import { formatRelativeDate } from "../utils/format-date.js"; interface ContainersViewProps { containers: Container[]; @@ -49,7 +50,12 @@ export function ContainersView({ width: 20, render: (c: Container) => formatPorts(c), }, - { key: "created", header: "CREATED", width: 20 }, + { + key: "created", + header: "CREATED", + width: 20, + render: (c: Container) => formatRelativeDate(c.created), + }, ]; return ( diff --git a/src/components/DetailView.tsx b/src/components/DetailView.tsx index 730dd7d..a01914e 100644 --- a/src/components/DetailView.tsx +++ b/src/components/DetailView.tsx @@ -1,5 +1,6 @@ import React from "react"; import { Box, Text } from "ink"; +import { formatRelativeDate } from "../utils/format-date.js"; interface DetailViewProps { title: string; @@ -24,10 +25,13 @@ export function DetailView({ title, data }: DetailViewProps): React.ReactElement } if (typeof value === "string") { - if (value.length > 50) { - return "{value.substring(0, 50)}..."; + const display = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value) + ? formatRelativeDate(value) + : value; + if (display.length > 50) { + return "{display.substring(0, 50)}..."; } - return "{value}"; + return "{display}"; } if (Array.isArray(value)) { diff --git a/src/components/ImagesView.tsx b/src/components/ImagesView.tsx index fb6f5f2..26dca8a 100644 --- a/src/components/ImagesView.tsx +++ b/src/components/ImagesView.tsx @@ -2,6 +2,7 @@ import React from "react"; import { Box, Text } from "ink"; import { Table } from "./Table.js"; import type { Image } from "../types/index.js"; +import { formatRelativeDate } from "../utils/format-date.js"; interface ImagesViewProps { images: Image[]; @@ -19,7 +20,12 @@ export function ImagesView({ { key: "tag", header: "TAG", width: 20 }, { key: "id", header: "IMAGE ID", width: 15 }, { key: "size", header: "SIZE", width: 12 }, - { key: "created", header: "CREATED", width: 20 }, + { + key: "created", + header: "CREATED", + width: 20, + render: (i: Image) => formatRelativeDate(i.created), + }, ]; return ( diff --git a/src/utils/format-date.ts b/src/utils/format-date.ts new file mode 100644 index 0000000..89b021c --- /dev/null +++ b/src/utils/format-date.ts @@ -0,0 +1,41 @@ +const SECOND = 1000; +const MINUTE = 60 * SECOND; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; +const WEEK = 7 * DAY; + +export function formatRelativeDate(isoString: string | undefined, now?: number): string { + if (!isoString) return ""; + + const date = new Date(isoString); + if (isNaN(date.getTime())) return isoString; + + const nowMs = now ?? Date.now(); + const diff = nowMs - date.getTime(); + + if (diff < 0) return isoString; + + if (diff < MINUTE) return "just now"; + + if (diff < HOUR) { + const mins = Math.floor(diff / MINUTE); + return `${mins} minute${mins === 1 ? "" : "s"} ago`; + } + + if (diff < DAY) { + const hours = Math.floor(diff / HOUR); + return `${hours} hour${hours === 1 ? "" : "s"} ago`; + } + + if (diff < WEEK) { + const days = Math.floor(diff / DAY); + return `${days} day${days === 1 ? "" : "s"} ago`; + } + + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + timeZone: "UTC", + }); +}