refactor: format dates in a human-friendly way

This commit is contained in:
Nicolas Meienberger 2026-01-10 11:10:11 +01:00
parent 1074d022b3
commit e8216c4247
12 changed files with 125 additions and 43 deletions

View file

@ -1,9 +1,9 @@
import { CronExpressionParser } from "cron-parser"; import { CronExpressionParser } from "cron-parser";
import { format } from "date-fns";
import { AlertCircle, CheckCircle2 } from "lucide-react"; import { AlertCircle, CheckCircle2 } from "lucide-react";
import { useMemo } from "react"; import { useMemo } from "react";
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form"; import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input"; import { Input } from "~/client/components/ui/input";
import { formatDateTime } from "~/client/lib/datetime";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
interface CronInputProps { interface CronInputProps {
@ -51,7 +51,9 @@ export function CronInput({ value, onChange, error }: CronInputProps) {
placeholder="* * * * *" placeholder="* * * * *"
value={value} value={value}
onChange={(e) => onChange(e.target.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),
})}
/> />
<div className="absolute right-3 top-1/2 -translate-y-1/2"> <div className="absolute right-3 top-1/2 -translate-y-1/2">
{value && ( {value && (
@ -77,7 +79,7 @@ export function CronInput({ value, onChange, error }: CronInputProps) {
{nextRuns.map((date, i) => ( {nextRuns.map((date, i) => (
<li key={date.toISOString()} className="text-xs font-mono flex items-center gap-2"> <li key={date.toISOString()} className="text-xs font-mono flex items-center gap-2">
<span className="text-muted-foreground w-4">{i + 1}.</span> <span className="text-muted-foreground w-4">{i + 1}.</span>
{format(date, "PPP p")} {formatDateTime(date)}
</li> </li>
))} ))}
</ul> </ul>

View file

@ -15,7 +15,7 @@ export const OnOff = ({ isOn, toggle, enabledLabel, disabledLabel, disabled }: P
className={cn( className={cn(
"flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold uppercase tracking-wide transition-colors", "flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold uppercase tracking-wide transition-colors",
isOn 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", : "border-muted bg-muted/40 text-muted-foreground dark:border-muted/60 dark:bg-muted/10",
)} )}
> >

View file

@ -1,8 +1,8 @@
import { format } from "date-fns";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/client/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/client/components/ui/dialog";
import { ScrollArea } from "~/client/components/ui/scroll-area"; import { ScrollArea } from "~/client/components/ui/scroll-area";
import { formatDate } from "~/client/lib/datetime";
import type { UpdateInfoDto } from "~/server/modules/system/system.dto"; import type { UpdateInfoDto } from "~/server/modules/system/system.dto";
interface ReleaseNotesDialogProps { interface ReleaseNotesDialogProps {
@ -29,7 +29,7 @@ export function ReleaseNotesDialog({ open, onOpenChange, updates }: ReleaseNotes
<div key={release.version} className="space-y-4"> <div key={release.version} className="space-y-4">
<div className="flex items-center justify-between border-b pb-2"> <div className="flex items-center justify-between border-b pb-2">
<h3 className="text-lg font-bold text-foreground">{release.version}</h3> <h3 className="text-lg font-bold text-foreground">{release.version}</h3>
<span className="text-sm text-muted-foreground">{format(new Date(release.publishedAt), "PPP")}</span> <span className="text-sm text-muted-foreground">{formatDate(release.publishedAt)}</span>
</div> </div>
<div className="prose prose-sm dark:prose-invert max-w-none prose-pre:bg-muted prose-pre:text-muted-foreground prose-a:text-primary hover:prose-a:underline wrap-anywhere text-wrap prose-pre:whitespace-pre-wrap prose-pre:wrap-anywhere"> <div className="prose prose-sm dark:prose-invert max-w-none prose-pre:bg-muted prose-pre:text-muted-foreground prose-a:text-primary hover:prose-a:underline wrap-anywhere text-wrap prose-pre:whitespace-pre-wrap prose-pre:wrap-anywhere">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{release.body}</ReactMarkdown> <ReactMarkdown remarkPlugins={[remarkGfm]}>{release.body}</ReactMarkdown>

View file

@ -27,6 +27,7 @@ import {
} from "~/client/components/ui/dialog"; } from "~/client/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { formatDuration } from "~/utils/utils"; import { formatDuration } from "~/utils/utils";
import { formatDateTime } from "~/client/lib/datetime";
import { deleteSnapshotsMutation, tagSnapshotsMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { deleteSnapshotsMutation, tagSnapshotsMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { parseError } from "~/client/lib/errors"; import { parseError } from "~/client/lib/errors";
import type { BackupSchedule, Snapshot } from "../lib/types"; import type { BackupSchedule, Snapshot } from "../lib/types";
@ -185,7 +186,7 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => {
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" /> <Calendar className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">{new Date(snapshot.time).toLocaleString()}</span> <span className="text-sm">{formatDateTime(snapshot.time)}</span>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>

View file

@ -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;
}

View file

@ -3,6 +3,7 @@ import { Link } from "react-router";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import type { BackupSchedule } from "~/client/lib/types"; import type { BackupSchedule } from "~/client/lib/types";
import { BackupStatusDot } from "./backup-status-dot"; import { BackupStatusDot } from "./backup-status-dot";
import { formatShortDateTime, formatTimeAgo } from "~/client/lib/datetime";
export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => { export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => {
return ( return (
@ -36,15 +37,11 @@ export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => {
</div> </div>
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Last backup</span> <span className="text-muted-foreground">Last backup</span>
<span className="font-medium"> <span className="font-medium">{formatTimeAgo(schedule.lastBackupAt)}</span>
{schedule.lastBackupAt ? new Date(schedule.lastBackupAt).toLocaleDateString() : "Never"}
</span>
</div> </div>
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Next backup</span> <span className="text-muted-foreground">Next backup</span>
<span className="font-medium"> <span className="font-medium">{formatShortDateTime(schedule.nextBackupAt)}</span>
{schedule.nextBackupAt ? new Date(schedule.nextBackupAt).toLocaleDateString() : "N/A"}
</span>
</div> </div>
</div> </div>
</CardContent> </CardContent>

View file

@ -19,6 +19,7 @@ import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { parseError } from "~/client/lib/errors"; import { parseError } from "~/client/lib/errors";
import { Link } from "react-router"; import { Link } from "react-router";
import { formatShortDateTime, formatTimeAgo } from "~/client/lib/datetime";
type Props = { type Props = {
schedule: BackupSchedule; schedule: BackupSchedule;
@ -155,15 +156,11 @@ export const ScheduleSummary = (props: Props) => {
</div> </div>
<div> <div>
<p className="text-xs uppercase text-muted-foreground">Last backup</p> <p className="text-xs uppercase text-muted-foreground">Last backup</p>
<p className="font-medium"> <p className="font-medium">{formatTimeAgo(schedule.lastBackupAt)}</p>
{schedule.lastBackupAt ? new Date(schedule.lastBackupAt).toLocaleString() : "Never"}
</p>
</div> </div>
<div> <div>
<p className="text-xs uppercase text-muted-foreground">Next backup</p> <p className="text-xs uppercase text-muted-foreground">Next backup</p>
<p className="font-medium"> <p className="font-medium">{formatShortDateTime(schedule.nextBackupAt)}</p>
{schedule.nextBackupAt ? new Date(schedule.nextBackupAt).toLocaleString() : "Never"}
</p>
</div> </div>
<div> <div>

View file

@ -7,6 +7,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/cli
import { Button, buttonVariants } from "~/client/components/ui/button"; import { Button, buttonVariants } from "~/client/components/ui/button";
import type { Snapshot } from "~/client/lib/types"; import type { Snapshot } from "~/client/lib/types";
import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen"; 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 { useFileBrowser } from "~/client/hooks/use-file-browser";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
@ -90,7 +91,7 @@ export const SnapshotFileBrowser = (props: Props) => {
<CardTitle>File Browser</CardTitle> <CardTitle>File Browser</CardTitle>
<CardDescription <CardDescription
className={cn({ hidden: !snapshot.time })} className={cn({ hidden: !snapshot.time })}
>{`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`}</CardDescription> >{`Viewing snapshot from ${formatDateTime(snapshot?.time)}`}</CardDescription>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Link <Link

View file

@ -1,8 +1,9 @@
import { cn } from "~/client/lib/utils";
import { Card, CardContent } from "~/client/components/ui/card";
import { ByteSize } from "~/client/components/bytes-size";
import { useEffect } from "react"; import { useEffect } from "react";
import type { ListSnapshotsResponse } from "~/client/api-client"; import type { ListSnapshotsResponse } from "~/client/api-client";
import { ByteSize } from "~/client/components/bytes-size";
import { Card, CardContent } from "~/client/components/ui/card";
import { formatDateWithMonth, formatShortDate, formatTime } from "~/client/lib/datetime";
import { cn } from "~/client/lib/utils";
interface Props { interface Props {
snapshots: ListSnapshotsResponse; snapshots: ListSnapshotsResponse;
@ -76,12 +77,8 @@ export const SnapshotTimeline = (props: Props) => {
}, },
)} )}
> >
<div className="text-xs font-semibold text-foreground"> <div className="text-xs font-semibold text-foreground">{formatShortDate(date)}</div>
{date.toLocaleDateString("en-US", { month: "short", day: "numeric" })} <div className="text-xs text-muted-foreground">{formatTime(date)}</div>
</div>
<div className="text-xs text-muted-foreground">
{date.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}
</div>
<div className="text-xs text-muted-foreground opacity-75"> <div className="text-xs text-muted-foreground opacity-75">
<ByteSize bytes={snapshot.size} /> <ByteSize bytes={snapshot.size} />
</div> </div>
@ -98,8 +95,7 @@ export const SnapshotTimeline = (props: Props) => {
<div className="px-4 py-2 text-xs text-muted-foreground bg-card-header border-t border-border flex justify-between"> <div className="px-4 py-2 text-xs text-muted-foreground bg-card-header border-t border-border flex justify-between">
<span>{snapshots.length} snapshots</span> <span>{snapshots.length} snapshots</span>
<span> <span>
{new Date(snapshots[0].time).toLocaleDateString()} -{" "} {formatDateWithMonth(snapshots[0].time)}&nbsp;-&nbsp;{formatDateWithMonth(snapshots.at(-1)?.time)}
{new Date(snapshots.at(-1)?.time ?? 0).toLocaleDateString()}
</span> </span>
</div> </div>
</div> </div>

View file

@ -3,6 +3,7 @@ import { redirect, useParams, Link, Await } from "react-router";
import { listBackupSchedulesOptions, listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen"; import { listBackupSchedulesOptions, listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapshot-file-browser"; import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapshot-file-browser";
import { formatDateTime } from "~/client/lib/datetime";
import { getRepository, getSnapshotDetails } from "~/client/api-client"; import { getRepository, getSnapshotDetails } from "~/client/api-client";
import type { Route } from "./+types/snapshot-details"; import type { Route } from "./+types/snapshot-details";
import { Suspense } from "react"; import { Suspense } from "react";
@ -112,7 +113,7 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps
</div> </div>
<div> <div>
<span className="text-muted-foreground">Time:</span> <span className="text-muted-foreground">Time:</span>
<p>{new Date(data.snapshot.time).toLocaleString()}</p> <p>{formatDateTime(data.snapshot.time)}</p>
</div> </div>
<Suspense fallback={<div>Loading...</div>}> <Suspense fallback={<div>Loading...</div>}>
<Await resolve={loaderData.snapshot}> <Await resolve={loaderData.snapshot}>

View file

@ -19,6 +19,7 @@ import {
} from "~/client/components/ui/alert-dialog"; } from "~/client/components/ui/alert-dialog";
import type { Repository } from "~/client/lib/types"; import type { Repository } from "~/client/lib/types";
import { REPOSITORY_BASE } from "~/client/lib/constants"; 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 { updateRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
import type { CompressionMode, RepositoryConfig } from "~/schemas/restic"; import type { CompressionMode, RepositoryConfig } from "~/schemas/restic";
@ -131,13 +132,11 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
)} )}
<div> <div>
<div className="text-sm font-medium text-muted-foreground">Created at</div> <div className="text-sm font-medium text-muted-foreground">Created at</div>
<p className="mt-1 text-sm">{new Date(repository.createdAt).toLocaleString()}</p> <p className="mt-1 text-sm">{formatDateTime(repository.createdAt)}</p>
</div> </div>
<div> <div>
<div className="text-sm font-medium text-muted-foreground">Last checked</div> <div className="text-sm font-medium text-muted-foreground">Last checked</div>
<p className="mt-1 text-sm"> <p className="mt-1 text-sm">{formatTimeAgo(repository.lastChecked)}</p>
{repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"}
</p>
</div> </div>
{config.cacert && ( {config.cacert && (
<div> <div>

View file

@ -1,11 +1,11 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { formatDistanceToNow } from "date-fns";
import { Activity, HeartIcon } from "lucide-react"; import { Activity, HeartIcon } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { healthCheckVolumeMutation, updateVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { healthCheckVolumeMutation, updateVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { OnOff } from "~/client/components/onoff"; import { OnOff } from "~/client/components/onoff";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { formatTimeAgo } from "~/client/lib/datetime";
import type { Volume } from "~/client/lib/types"; import type { Volume } from "~/client/lib/types";
type Props = { type Props = {
@ -13,10 +13,6 @@ type Props = {
}; };
export const HealthchecksCard = ({ volume }: Props) => { export const HealthchecksCard = ({ volume }: Props) => {
const timeAgo = formatDistanceToNow(volume.lastHealthCheck, {
addSuffix: true,
});
const healthcheck = useMutation({ const healthcheck = useMutation({
...healthCheckVolumeMutation(), ...healthCheckVolumeMutation(),
onSuccess: (d) => { onSuccess: (d) => {
@ -55,9 +51,9 @@ export const HealthchecksCard = ({ volume }: Props) => {
<CardContent> <CardContent>
<div className="flex flex-col flex-1 justify-start"> <div className="flex flex-col flex-1 justify-start">
{volume.lastError && <span className="text-sm text-red-500 wrap-break-word">{volume.lastError}</span>} {volume.lastError && <span className="text-sm text-red-500 wrap-break-word">{volume.lastError}</span>}
{volume.status === "mounted" && <span className="text-md text-emerald-500">Healthy</span>} {volume.status === "mounted" && <span className="text-md text-green-500">Healthy</span>}
{volume.status !== "unmounted" && ( {volume.status !== "unmounted" && (
<span className="text-xs text-muted-foreground mb-4">Checked {timeAgo || "never"}</span> <span className="text-xs text-muted-foreground mb-4">Checked {formatTimeAgo(volume.lastHealthCheck)}</span>
)} )}
<span className="flex justify-between items-center gap-2"> <span className="flex justify-between items-center gap-2">
<span className="text-sm">Remount on error</span> <span className="text-sm">Remount on error</span>