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
6 changes: 6 additions & 0 deletions progress.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Text inverse>` 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
48 changes: 48 additions & 0 deletions src/__tests__/format-date.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
8 changes: 7 additions & 1 deletion src/components/ContainersView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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 (
Expand Down
10 changes: 7 additions & 3 deletions src/components/DetailView.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from "react";
import { Box, Text } from "ink";
import { formatRelativeDate } from "../utils/format-date.js";

interface DetailViewProps {
title: string;
Expand All @@ -24,10 +25,13 @@ export function DetailView({ title, data }: DetailViewProps): React.ReactElement
}

if (typeof value === "string") {
if (value.length > 50) {
return <Text color="green">"{value.substring(0, 50)}..."</Text>;
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 <Text color="green">"{display.substring(0, 50)}..."</Text>;
}
return <Text color="green">"{value}"</Text>;
return <Text color="green">"{display}"</Text>;
}

if (Array.isArray(value)) {
Expand Down
8 changes: 7 additions & 1 deletion src/components/ImagesView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -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 (
Expand Down
41 changes: 41 additions & 0 deletions src/utils/format-date.ts
Original file line number Diff line number Diff line change
@@ -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",
});
}