From ef2d64414665538e3652b12f0369f73a2825887d Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Sat, 10 Jan 2026 13:02:46 +0100 Subject: [PATCH] refactor: format dates in a human-friendly way (#334) --- app/client/components/cron-input.tsx | 8 +- app/client/components/onoff.tsx | 2 +- .../components/release-notes-dialog.tsx | 4 +- app/client/components/snapshots-table.tsx | 3 +- app/client/lib/datetime.ts | 92 +++++++++++++++++++ .../backups/components/backup-card.tsx | 9 +- .../backups/components/schedule-summary.tsx | 9 +- .../components/snapshot-file-browser.tsx | 3 +- .../backups/components/snapshot-timeline.tsx | 18 ++-- .../repositories/routes/snapshot-details.tsx | 3 +- app/client/modules/repositories/tabs/info.tsx | 7 +- .../volumes/components/healthchecks-card.tsx | 10 +- 12 files changed, 125 insertions(+), 43 deletions(-) create mode 100644 app/client/lib/datetime.ts diff --git a/app/client/components/cron-input.tsx b/app/client/components/cron-input.tsx index eb268fd7..c7e85e06 100644 --- a/app/client/components/cron-input.tsx +++ b/app/client/components/cron-input.tsx @@ -1,9 +1,9 @@ import { CronExpressionParser } from "cron-parser"; -import { format } from "date-fns"; import { AlertCircle, CheckCircle2 } from "lucide-react"; import { useMemo } from "react"; import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form"; import { Input } from "~/client/components/ui/input"; +import { formatDateTime } from "~/client/lib/datetime"; import { cn } from "~/client/lib/utils"; interface CronInputProps { @@ -51,7 +51,9 @@ export function CronInput({ value, onChange, error }: CronInputProps) { placeholder="* * * * *" value={value} onChange={(e) => onChange(e.target.value)} - className={cn("font-mono", { "border-destructive": error || (value && !isValid) })} + className={cn("font-mono", { + "border-destructive": error || (value && !isValid), + })} />
{value && ( @@ -77,7 +79,7 @@ export function CronInput({ value, onChange, error }: CronInputProps) { {nextRuns.map((date, i) => (
  • {i + 1}. - {format(date, "PPP p")} + {formatDateTime(date)}
  • ))} diff --git a/app/client/components/onoff.tsx b/app/client/components/onoff.tsx index 7fe95082..8161bc32 100644 --- a/app/client/components/onoff.tsx +++ b/app/client/components/onoff.tsx @@ -15,7 +15,7 @@ export const OnOff = ({ isOn, toggle, enabledLabel, disabledLabel, disabled }: P className={cn( "flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold uppercase tracking-wide transition-colors", isOn - ? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-200" + ? "border-green-200 bg-green-50 text-green-700 dark:border-green-500/40 dark:bg-green-500/10 dark:text-green-200" : "border-muted bg-muted/40 text-muted-foreground dark:border-muted/60 dark:bg-muted/10", )} > diff --git a/app/client/components/release-notes-dialog.tsx b/app/client/components/release-notes-dialog.tsx index 81e1f765..f4f77f80 100644 --- a/app/client/components/release-notes-dialog.tsx +++ b/app/client/components/release-notes-dialog.tsx @@ -1,8 +1,8 @@ -import { format } from "date-fns"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/client/components/ui/dialog"; import { ScrollArea } from "~/client/components/ui/scroll-area"; +import { formatDate } from "~/client/lib/datetime"; import type { UpdateInfoDto } from "~/server/modules/system/system.dto"; interface ReleaseNotesDialogProps { @@ -29,7 +29,7 @@ export function ReleaseNotesDialog({ open, onOpenChange, updates }: ReleaseNotes

    {release.version}

    - {format(new Date(release.publishedAt), "PPP")} + {formatDate(release.publishedAt)}
    {release.body} diff --git a/app/client/components/snapshots-table.tsx b/app/client/components/snapshots-table.tsx index 9ff31b8f..613c54eb 100644 --- a/app/client/components/snapshots-table.tsx +++ b/app/client/components/snapshots-table.tsx @@ -27,6 +27,7 @@ import { } from "~/client/components/ui/dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; import { formatDuration } from "~/utils/utils"; +import { formatDateTime } from "~/client/lib/datetime"; import { deleteSnapshotsMutation, tagSnapshotsMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { parseError } from "~/client/lib/errors"; import type { BackupSchedule, Snapshot } from "../lib/types"; @@ -185,7 +186,7 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => {
    - {new Date(snapshot.time).toLocaleString()} + {formatDateTime(snapshot.time)}
    diff --git a/app/client/lib/datetime.ts b/app/client/lib/datetime.ts new file mode 100644 index 00000000..1b107d48 --- /dev/null +++ b/app/client/lib/datetime.ts @@ -0,0 +1,92 @@ +import { formatDistanceToNow, isValid } from "date-fns"; + +// 1/10/2026, 2:30 PM +export function formatDateTime(date: Date | string | number | null | undefined): string { + if (!date) return "Never"; + const d = new Date(date); + if (!isValid(d)) return "Invalid Date"; + + return Intl.DateTimeFormat(navigator.languages, { + month: "numeric", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "numeric", + }).format(d); +} + +// Jan 10, 2026 +export function formatDateWithMonth(date: Date | string | number | null | undefined): string { + if (!date) return "Never"; + const d = new Date(date); + if (!isValid(d)) return "Invalid Date"; + return Intl.DateTimeFormat(navigator.languages, { + month: "short", + day: "numeric", + year: "numeric", + }).format(d); +} + +// 1/10/2026 +export function formatDate(date: Date | string | number | null | undefined): string { + if (!date) return "Never"; + const d = new Date(date); + if (!isValid(d)) return "Invalid Date"; + + return Intl.DateTimeFormat(navigator.languages, { + month: "numeric", + day: "numeric", + year: "numeric", + }).format(d); +} + +// 1/10 +export function formatShortDate(date: Date | string | number | null | undefined): string { + if (!date) return "Never"; + const d = new Date(date); + if (!isValid(d)) return "Invalid Date"; + + return Intl.DateTimeFormat(navigator.languages, { + month: "numeric", + day: "numeric", + }).format(d); +} + +// 1/10, 2:30 PM +export function formatShortDateTime(date: Date | string | number | null | undefined): string { + if (!date) return "Never"; + const d = new Date(date); + if (!isValid(d)) return "Invalid Date"; + + return Intl.DateTimeFormat(navigator.languages, { + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + }).format(d); +} + +// 2:30 PM +export function formatTime(date: Date | string | number | null | undefined): string { + if (!date) return "Never"; + const d = new Date(date); + if (!isValid(d)) return "Invalid Date"; + + return Intl.DateTimeFormat(navigator.languages, { + hour: "numeric", + minute: "numeric", + }).format(d); +} + +// 5 minutes ago +export function formatTimeAgo(date: Date | string | number | null | undefined): string { + if (!date) return "Never"; + const d = new Date(date); + if (!isValid(d)) return "Invalid Date"; + + const timeAgo = formatDistanceToNow(d, { + addSuffix: true, + }); + + return timeAgo; +} diff --git a/app/client/modules/backups/components/backup-card.tsx b/app/client/modules/backups/components/backup-card.tsx index 85249704..074138e0 100644 --- a/app/client/modules/backups/components/backup-card.tsx +++ b/app/client/modules/backups/components/backup-card.tsx @@ -3,6 +3,7 @@ import { Link } from "react-router"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; import type { BackupSchedule } from "~/client/lib/types"; import { BackupStatusDot } from "./backup-status-dot"; +import { formatShortDateTime, formatTimeAgo } from "~/client/lib/datetime"; export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => { return ( @@ -36,15 +37,11 @@ export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => {
    Last backup - - {schedule.lastBackupAt ? new Date(schedule.lastBackupAt).toLocaleDateString() : "Never"} - + {formatTimeAgo(schedule.lastBackupAt)}
    Next backup - - {schedule.nextBackupAt ? new Date(schedule.nextBackupAt).toLocaleDateString() : "N/A"} - + {formatShortDateTime(schedule.nextBackupAt)}
    diff --git a/app/client/modules/backups/components/schedule-summary.tsx b/app/client/modules/backups/components/schedule-summary.tsx index 16f1df03..1801f865 100644 --- a/app/client/modules/backups/components/schedule-summary.tsx +++ b/app/client/modules/backups/components/schedule-summary.tsx @@ -19,6 +19,7 @@ import { useMutation } from "@tanstack/react-query"; import { toast } from "sonner"; import { parseError } from "~/client/lib/errors"; import { Link } from "react-router"; +import { formatShortDateTime, formatTimeAgo } from "~/client/lib/datetime"; type Props = { schedule: BackupSchedule; @@ -155,15 +156,11 @@ export const ScheduleSummary = (props: Props) => {

    Last backup

    -

    - {schedule.lastBackupAt ? new Date(schedule.lastBackupAt).toLocaleString() : "Never"} -

    +

    {formatTimeAgo(schedule.lastBackupAt)}

    Next backup

    -

    - {schedule.nextBackupAt ? new Date(schedule.nextBackupAt).toLocaleString() : "Never"} -

    +

    {formatShortDateTime(schedule.nextBackupAt)}

    diff --git a/app/client/modules/backups/components/snapshot-file-browser.tsx b/app/client/modules/backups/components/snapshot-file-browser.tsx index 185b0ed3..e2efbb37 100644 --- a/app/client/modules/backups/components/snapshot-file-browser.tsx +++ b/app/client/modules/backups/components/snapshot-file-browser.tsx @@ -7,6 +7,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/cli import { Button, buttonVariants } from "~/client/components/ui/button"; import type { Snapshot } from "~/client/lib/types"; import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen"; +import { formatDateTime } from "~/client/lib/datetime"; import { useFileBrowser } from "~/client/hooks/use-file-browser"; import { cn } from "~/client/lib/utils"; @@ -90,7 +91,7 @@ export const SnapshotFileBrowser = (props: Props) => { File Browser {`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`} + >{`Viewing snapshot from ${formatDateTime(snapshot?.time)}`}
    { }, )} > -
    - {date.toLocaleDateString("en-US", { month: "short", day: "numeric" })} -
    -
    - {date.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })} -
    +
    {formatShortDate(date)}
    +
    {formatTime(date)}
    @@ -98,8 +95,7 @@ export const SnapshotTimeline = (props: Props) => {
    {snapshots.length} snapshots - {new Date(snapshots[0].time).toLocaleDateString()} -{" "} - {new Date(snapshots.at(-1)?.time ?? 0).toLocaleDateString()} + {formatDateWithMonth(snapshots[0].time)} - {formatDateWithMonth(snapshots.at(-1)?.time)}
    diff --git a/app/client/modules/repositories/routes/snapshot-details.tsx b/app/client/modules/repositories/routes/snapshot-details.tsx index ea10fb6a..2d740d1b 100644 --- a/app/client/modules/repositories/routes/snapshot-details.tsx +++ b/app/client/modules/repositories/routes/snapshot-details.tsx @@ -3,6 +3,7 @@ import { redirect, useParams, Link, Await } from "react-router"; import { listBackupSchedulesOptions, listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen"; import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card"; import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapshot-file-browser"; +import { formatDateTime } from "~/client/lib/datetime"; import { getRepository, getSnapshotDetails } from "~/client/api-client"; import type { Route } from "./+types/snapshot-details"; import { Suspense } from "react"; @@ -112,7 +113,7 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps
    Time: -

    {new Date(data.snapshot.time).toLocaleString()}

    +

    {formatDateTime(data.snapshot.time)}

    Loading...}> diff --git a/app/client/modules/repositories/tabs/info.tsx b/app/client/modules/repositories/tabs/info.tsx index e22b8c6a..6b63330d 100644 --- a/app/client/modules/repositories/tabs/info.tsx +++ b/app/client/modules/repositories/tabs/info.tsx @@ -19,6 +19,7 @@ import { } from "~/client/components/ui/alert-dialog"; import type { Repository } from "~/client/lib/types"; import { REPOSITORY_BASE } from "~/client/lib/constants"; +import { formatDateTime, formatTimeAgo } from "~/client/lib/datetime"; import { updateRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen"; import type { CompressionMode, RepositoryConfig } from "~/schemas/restic"; @@ -131,13 +132,11 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => { )}
    Created at
    -

    {new Date(repository.createdAt).toLocaleString()}

    +

    {formatDateTime(repository.createdAt)}

    Last checked
    -

    - {repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"} -

    +

    {formatTimeAgo(repository.lastChecked)}

    {config.cacert && (
    diff --git a/app/client/modules/volumes/components/healthchecks-card.tsx b/app/client/modules/volumes/components/healthchecks-card.tsx index 198efc46..86a3c88e 100644 --- a/app/client/modules/volumes/components/healthchecks-card.tsx +++ b/app/client/modules/volumes/components/healthchecks-card.tsx @@ -1,11 +1,11 @@ import { useMutation } from "@tanstack/react-query"; -import { formatDistanceToNow } from "date-fns"; import { Activity, HeartIcon } from "lucide-react"; import { toast } from "sonner"; import { healthCheckVolumeMutation, updateVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { OnOff } from "~/client/components/onoff"; import { Button } from "~/client/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; +import { formatTimeAgo } from "~/client/lib/datetime"; import type { Volume } from "~/client/lib/types"; type Props = { @@ -13,10 +13,6 @@ type Props = { }; export const HealthchecksCard = ({ volume }: Props) => { - const timeAgo = formatDistanceToNow(volume.lastHealthCheck, { - addSuffix: true, - }); - const healthcheck = useMutation({ ...healthCheckVolumeMutation(), onSuccess: (d) => { @@ -55,9 +51,9 @@ export const HealthchecksCard = ({ volume }: Props) => {
    {volume.lastError && {volume.lastError}} - {volume.status === "mounted" && Healthy} + {volume.status === "mounted" && Healthy} {volume.status !== "unmounted" && ( - Checked {timeAgo || "never"} + Checked {formatTimeAgo(volume.lastHealthCheck)} )} Remount on error