diff --git a/src/components/core/MonoText.tsx b/src/components/core/MonoText.tsx index 3d878f6e..ca10ebf1 100644 --- a/src/components/core/MonoText.tsx +++ b/src/components/core/MonoText.tsx @@ -1,10 +1,13 @@ import { Text } from '@radix-ui/themes'; -import type { ComponentProps } from 'react'; +import { forwardRef, type ComponentProps } from 'react'; type MonoTextProps = ComponentProps; -export function MonoText(props: MonoTextProps) { - return ( - - ); -} \ No newline at end of file +// forwardRef so Radix primitives (Tooltip, etc.) can anchor to it. +export const MonoText = forwardRef( + function MonoText(props, ref) { + return ( + + ); + } +); diff --git a/src/components/features/products/object-browser/DirectoryRow.test.tsx b/src/components/features/products/object-browser/DirectoryRow.test.tsx new file mode 100644 index 00000000..26baf03e --- /dev/null +++ b/src/components/features/products/object-browser/DirectoryRow.test.tsx @@ -0,0 +1,63 @@ +import { render, screen } from "@testing-library/react"; +import { Theme } from "@radix-ui/themes"; +import { DirectoryRow } from "./DirectoryRow"; +import type { Product } from "@/types"; + +jest.mock("next/navigation", () => ({ + useRouter: () => ({ refresh: jest.fn() }), +})); +jest.mock("@/components/features/uploader", () => ({ + useUploadManager: () => ({ + cancelUpload: jest.fn(), + retryUpload: jest.fn(), + getUploadsForScope: () => [], + deleteObject: jest.fn(), + deletePrefix: jest.fn(), + }), + useS3Credentials: () => ({ getCredentials: () => null }), +})); + +const product = { + account_id: "cholmes", + product_id: "overture", +} as Product; + +const renderRow = (item: Parameters[0]["item"]) => + render( + + + + ); + +describe("DirectoryRow last modified", () => { + it("shows a relative time with the UTC timestamp as its title", () => { + renderRow({ + name: "catalog.json", + path: "catalog.json", + size: 1234, + updated_at: new Date(Date.now() - 21 * 24 * 3600 * 1000).toISOString(), + isDirectory: false, + }); + expect(screen.getByText("3 weeks ago")).toHaveAttribute( + "title", + expect.stringContaining("UTC") + ); + }); + + it("omits it for directories, whose mtime is synthetic", () => { + renderRow({ + name: "tiles", + path: "tiles/", + size: 0, + updated_at: new Date().toISOString(), + isDirectory: true, + }); + expect(screen.queryByText(/ago$/)).toBeNull(); + }); +}); diff --git a/src/components/features/products/object-browser/DirectoryRow.tsx b/src/components/features/products/object-browser/DirectoryRow.tsx index 7b5cb144..57eddf3b 100644 --- a/src/components/features/products/object-browser/DirectoryRow.tsx +++ b/src/components/features/products/object-browser/DirectoryRow.tsx @@ -24,7 +24,7 @@ import { useRouter } from "next/navigation"; import { MonoText } from "@/components/core"; import type { FileNode } from "./utils"; import type { Product } from "@/types"; -import { formatBytes } from "@/lib/format"; +import { formatBytes, formatDate, formatRelativeTime } from "@/lib/format"; import { objectUrl } from "@/lib/urls"; import styles from "./ObjectBrowser.module.css"; import { useState } from "react"; @@ -213,6 +213,23 @@ export function DirectoryRow({ )} + {/* Last modified — directories have no real mtime, so files only. + suppressHydrationWarning: "now" differs between server and + client render, so a just-uploaded file can disagree by seconds. */} + {!item.isDirectory && item.updated_at && ( + + + {formatRelativeTime(item.updated_at)} + + + )} + {/* Upload control buttons */} {!item.isDirectory && isUploading && uploadItem && ( diff --git a/src/lib/format.test.ts b/src/lib/format.test.ts new file mode 100644 index 00000000..abb2ce2a --- /dev/null +++ b/src/lib/format.test.ts @@ -0,0 +1,21 @@ +import { formatRelativeTime } from "./format"; + +describe("formatRelativeTime", () => { + const now = new Date("2024-06-15T12:00:00Z"); + const ago = (ms: number) => new Date(now.getTime() - ms).toISOString(); + + it.each([ + [ago(30_000), "30 seconds ago"], + [ago(5 * 60_000), "5 minutes ago"], + [ago(3 * 3600_000), "3 hours ago"], + [ago(2 * 24 * 3600_000), "2 days ago"], + [ago(21 * 24 * 3600_000), "3 weeks ago"], + [ago(400 * 24 * 3600_000), "last year"], + ])("%s -> %s", (date, expected) => { + expect(formatRelativeTime(date, now)).toBe(expected); + }); + + it("returns empty string for an unparseable date", () => { + expect(formatRelativeTime("not a date", now)).toBe(""); + }); +}); diff --git a/src/lib/format.ts b/src/lib/format.ts index 092bb324..69a6cc4c 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -25,6 +25,34 @@ export function formatDateSSR(date: string): string { return `${day} ${month} ${year}`; } +const RELATIVE_UNITS: [Intl.RelativeTimeFormatUnit, number][] = [ + ['year', 365 * 24 * 3600], + ['month', 30 * 24 * 3600], + ['week', 7 * 24 * 3600], + ['day', 24 * 3600], + ['hour', 3600], + ['minute', 60], + ['second', 1], +]; + +/** + * Format a date string as a relative time, e.g. "3 weeks ago". + * @param date The date string to format + * @param now Reference point, for testing + * @returns A relative time string, or "" if the date is unparseable + */ +export function formatRelativeTime(date: string, now: Date = new Date()): string { + const seconds = (new Date(date).getTime() - now.getTime()) / 1000; + if (Number.isNaN(seconds)) return ''; + const [unit, perUnit] = + RELATIVE_UNITS.find(([, s]) => Math.abs(seconds) >= s) ?? + RELATIVE_UNITS[RELATIVE_UNITS.length - 1]; + return new Intl.RelativeTimeFormat('en', { numeric: 'auto' }).format( + Math.round(seconds / perUnit), + unit + ); +} + /** * Format a date string into a human-readable format with optional time * @param date The date string to format