refactor: format dates in a human-friendly way (#334)

This commit is contained in:
Nico 2026-01-10 13:02:46 +01:00 committed by GitHub
parent 1074d022b3
commit ef2d644146
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 125 additions and 43 deletions

View file

@ -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),
})}
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2">
{value && (
@ -77,7 +79,7 @@ export function CronInput({ value, onChange, error }: CronInputProps) {
{nextRuns.map((date, i) => (
<li key={date.toISOString()} className="text-xs font-mono flex items-center gap-2">
<span className="text-muted-foreground w-4">{i + 1}.</span>
{format(date, "PPP p")}
{formatDateTime(date)}
</li>
))}
</ul>

View file

@ -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",
)}
>

View file

@ -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
<div key={release.version} className="space-y-4">
<div className="flex items-center justify-between border-b pb-2">
<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 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>

View file

@ -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) => {
<TableCell>
<div className="flex items-center gap-2">
<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>
</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 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 }) => {
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Last backup</span>
<span className="font-medium">
{schedule.lastBackupAt ? new Date(schedule.lastBackupAt).toLocaleDateString() : "Never"}
</span>
<span className="font-medium">{formatTimeAgo(schedule.lastBackupAt)}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Next backup</span>
<span className="font-medium">
{schedule.nextBackupAt ? new Date(schedule.nextBackupAt).toLocaleDateString() : "N/A"}
</span>
<span className="font-medium">{formatShortDateTime(schedule.nextBackupAt)}</span>
</div>
</div>
</CardContent>

View file

@ -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) => {
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Last backup</p>
<p className="font-medium">
{schedule.lastBackupAt ? new Date(schedule.lastBackupAt).toLocaleString() : "Never"}
</p>
<p className="font-medium">{formatTimeAgo(schedule.lastBackupAt)}</p>
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Next backup</p>
<p className="font-medium">
{schedule.nextBackupAt ? new Date(schedule.nextBackupAt).toLocaleString() : "Never"}
</p>
<p className="font-medium">{formatShortDateTime(schedule.nextBackupAt)}</p>
</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 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) => {
<CardTitle>File Browser</CardTitle>
<CardDescription
className={cn({ hidden: !snapshot.time })}
>{`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`}</CardDescription>
>{`Viewing snapshot from ${formatDateTime(snapshot?.time)}`}</CardDescription>
</div>
<div className="flex gap-2">
<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 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 {
snapshots: ListSnapshotsResponse;
@ -76,12 +77,8 @@ export const SnapshotTimeline = (props: Props) => {
},
)}
>
<div className="text-xs font-semibold text-foreground">
{date.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
</div>
<div className="text-xs text-muted-foreground">
{date.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}
</div>
<div className="text-xs font-semibold text-foreground">{formatShortDate(date)}</div>
<div className="text-xs text-muted-foreground">{formatTime(date)}</div>
<div className="text-xs text-muted-foreground opacity-75">
<ByteSize bytes={snapshot.size} />
</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">
<span>{snapshots.length} snapshots</span>
<span>
{new Date(snapshots[0].time).toLocaleDateString()} -{" "}
{new Date(snapshots.at(-1)?.time ?? 0).toLocaleDateString()}
{formatDateWithMonth(snapshots[0].time)}&nbsp;-&nbsp;{formatDateWithMonth(snapshots.at(-1)?.time)}
</span>
</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 { 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
</div>
<div>
<span className="text-muted-foreground">Time:</span>
<p>{new Date(data.snapshot.time).toLocaleString()}</p>
<p>{formatDateTime(data.snapshot.time)}</p>
</div>
<Suspense fallback={<div>Loading...</div>}>
<Await resolve={loaderData.snapshot}>

View file

@ -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) => {
)}
<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 className="text-sm font-medium text-muted-foreground">Last checked</div>
<p className="mt-1 text-sm">
{repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"}
</p>
<p className="mt-1 text-sm">{formatTimeAgo(repository.lastChecked)}</p>
</div>
{config.cacert && (
<div>

View file

@ -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) => {
<CardContent>
<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.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" && (
<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="text-sm">Remount on error</span>