refactor: format dates in a human-friendly way (#334)
This commit is contained in:
parent
1074d022b3
commit
ef2d644146
12 changed files with 125 additions and 43 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
92
app/client/lib/datetime.ts
Normal file
92
app/client/lib/datetime.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)} - {formatDateWithMonth(snapshots.at(-1)?.time)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue