refactor(client): configure time formatting with timezone from server

This commit is contained in:
Nicolas Meienberger 2026-03-23 20:57:02 +01:00
parent 5a83d6ae86
commit 4c928cbc33
21 changed files with 141 additions and 55 deletions

View file

@ -3,7 +3,7 @@ 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 { useTimeFormat } from "~/client/lib/datetime";
import { cn } from "~/client/lib/utils";
interface CronInputProps {
@ -13,6 +13,7 @@ interface CronInputProps {
}
export function CronInput({ value, onChange, error }: CronInputProps) {
const { formatDateTime } = useTimeFormat();
const { isValid, nextRuns, parseError } = useMemo(() => {
if (!value) {
return { isValid: false, nextRuns: [], parseError: null };

View file

@ -4,6 +4,7 @@ import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-b
import { useFileBrowser } from "~/client/hooks/use-file-browser";
import { parseError } from "~/client/lib/errors";
import { normalizeAbsolutePath } from "@zerobyte/core/utils";
import { logger } from "~/client/lib/logger";
type LocalFileBrowserProps = FileBrowserUiProps & {
initialPath?: string;
@ -26,7 +27,7 @@ export const LocalFileBrowser = ({ initialPath = "/", enabled = true, ...uiProps
return await queryClient.ensureQueryData(browseFilesystemOptions({ query: { path } }));
},
prefetchFolder: (path) => {
void queryClient.prefetchQuery(browseFilesystemOptions({ query: { path } }));
void queryClient.prefetchQuery(browseFilesystemOptions({ query: { path } })).catch((e) => logger.error(e));
},
});

View file

@ -5,6 +5,7 @@ import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-b
import { useFileBrowser } from "~/client/hooks/use-file-browser";
import { parseError } from "~/client/lib/errors";
import { normalizeAbsolutePath } from "@zerobyte/core/utils";
import { logger } from "~/client/lib/logger";
type SnapshotTreeBrowserProps = FileBrowserUiProps & {
repositoryId: string;
@ -79,12 +80,14 @@ export const SnapshotTreeBrowser = ({
);
},
prefetchFolder: (path) => {
void queryClient.prefetchQuery(
listSnapshotFilesOptions({
path: { shortId: repositoryId, snapshotId },
query: { path, offset: 0, limit: pageSize },
}),
);
void queryClient
.prefetchQuery(
listSnapshotFilesOptions({
path: { shortId: repositoryId, snapshotId },
query: { path, offset: 0, limit: pageSize },
}),
)
.catch((e) => logger.error(e));
},
pathTransform: {
strip: stripBasePath,

View file

@ -3,6 +3,7 @@ import { listFilesOptions } from "~/client/api-client/@tanstack/react-query.gen"
import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-browsers/file-browser";
import { useFileBrowser, type FetchFolderResult } from "~/client/hooks/use-file-browser";
import { parseError } from "~/client/lib/errors";
import { logger } from "~/client/lib/logger";
type VolumeFileBrowserProps = FileBrowserUiProps & {
volumeId: string;
@ -29,12 +30,14 @@ export const VolumeFileBrowser = ({ volumeId, enabled = true, ...uiProps }: Volu
);
},
prefetchFolder: (path) => {
void queryClient.prefetchQuery(
listFilesOptions({
path: { shortId: volumeId },
query: { path },
}),
);
void queryClient
.prefetchQuery(
listFilesOptions({
path: { shortId: volumeId },
query: { path },
}),
)
.catch((e) => logger.error(e));
},
});

View file

@ -581,8 +581,10 @@ const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, childr
);
return (
<button
type="button"
<div
// oxlint-disable-next-line jsx_a11y/prefer-tag-over-role
role="button"
tabIndex={0}
className={cn("flex items-center gap-2 w-full pr-2 text-sm py-1.5 text-left", className)}
style={{ paddingLeft }}
onClick={onClick}
@ -591,7 +593,7 @@ const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, childr
>
{icon}
<div className="truncate w-full flex items-center gap-2">{children}</div>
</button>
</div>
);
});

View file

@ -2,7 +2,7 @@ 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 { useTimeFormat } from "~/client/lib/datetime";
import type { UpdateInfoDto } from "~/server/modules/system/system.dto";
interface ReleaseNotesDialogProps {
@ -12,6 +12,8 @@ interface ReleaseNotesDialogProps {
}
export function ReleaseNotesDialog({ open, onOpenChange, updates }: ReleaseNotesDialogProps) {
const { formatDate } = useTimeFormat();
if (!updates) return null;
return (

View file

@ -25,8 +25,8 @@ import {
DialogTitle,
} from "~/client/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { useTimeFormat } from "~/client/lib/datetime";
import { formatDuration } from "~/utils/utils";
import { formatDateTime } from "~/client/lib/datetime";
import {
deleteSnapshotsMutation,
listSnapshotsQueryKey,
@ -49,6 +49,7 @@ type Props = {
export const SnapshotsTable = ({ snapshots, repositoryId, backups, listSnapshotsQueryOptions }: Props) => {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { formatDateTime } = useTimeFormat();
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);

View file

@ -0,0 +1,12 @@
import { useTimeFormat, type DateInput } from "~/client/lib/datetime";
type Props = {
date: DateInput;
className?: string;
};
export function TimeAgo({ date, className }: Props) {
const { formatTimeAgo } = useTimeFormat();
return <span className={className}>{formatTimeAgo(date)}</span>;
}

View file

@ -60,4 +60,8 @@ describe("datetime formatters", () => {
nowSpy.mockRestore();
});
test("formats calendar values with an explicit locale and timezone", () => {
expect(formatShortDateTime(sampleDate, { locale: "en-US", timeZone: "UTC" })).toBe("1/10, 2:30 PM");
});
});

View file

@ -1,8 +1,13 @@
import { formatDistanceToNow, isValid } from "date-fns";
import { useMemo } from "react";
import { Route as RootRoute } from "~/routes/__root";
type DateInput = Date | string | number | null | undefined;
export type DateInput = Date | string | number | null | undefined;
const getLocales = () => (typeof navigator !== "undefined" ? navigator.languages : undefined);
type DateFormatOptions = {
locale?: string | string[];
timeZone?: string;
};
function formatValidDate(date: DateInput, formatter: (date: Date) => string): string {
if (!date) return "Never";
@ -13,10 +18,21 @@ function formatValidDate(date: DateInput, formatter: (date: Date) => string): st
return formatter(parsedDate);
}
function getDateTimeFormat(
locale: DateFormatOptions["locale"],
timeZone: DateFormatOptions["timeZone"],
options: Intl.DateTimeFormatOptions,
) {
return Intl.DateTimeFormat(locale, {
...options,
timeZone,
});
}
// 1/10/2026, 2:30 PM
export function formatDateTime(date: DateInput): string {
export function formatDateTime(date: DateInput, options: DateFormatOptions = {}): string {
return formatValidDate(date, (validDate) =>
Intl.DateTimeFormat(getLocales(), {
getDateTimeFormat(options.locale, options.timeZone, {
month: "numeric",
day: "numeric",
year: "numeric",
@ -27,9 +43,9 @@ export function formatDateTime(date: DateInput): string {
}
// Jan 10, 2026
export function formatDateWithMonth(date: DateInput): string {
export function formatDateWithMonth(date: DateInput, options: DateFormatOptions = {}): string {
return formatValidDate(date, (validDate) =>
Intl.DateTimeFormat(getLocales(), {
getDateTimeFormat(options.locale, options.timeZone, {
month: "short",
day: "numeric",
year: "numeric",
@ -38,9 +54,9 @@ export function formatDateWithMonth(date: DateInput): string {
}
// 1/10/2026
export function formatDate(date: DateInput): string {
export function formatDate(date: DateInput, options: DateFormatOptions = {}): string {
return formatValidDate(date, (validDate) =>
Intl.DateTimeFormat(getLocales(), {
getDateTimeFormat(options.locale, options.timeZone, {
month: "numeric",
day: "numeric",
year: "numeric",
@ -49,9 +65,9 @@ export function formatDate(date: DateInput): string {
}
// 1/10
export function formatShortDate(date: DateInput): string {
export function formatShortDate(date: DateInput, options: DateFormatOptions = {}): string {
return formatValidDate(date, (validDate) =>
Intl.DateTimeFormat(getLocales(), {
getDateTimeFormat(options.locale, options.timeZone, {
month: "numeric",
day: "numeric",
}).format(validDate),
@ -59,9 +75,9 @@ export function formatShortDate(date: DateInput): string {
}
// 1/10, 2:30 PM
export function formatShortDateTime(date: DateInput): string {
export function formatShortDateTime(date: DateInput, options: DateFormatOptions = {}): string {
return formatValidDate(date, (validDate) =>
Intl.DateTimeFormat(getLocales(), {
getDateTimeFormat(options.locale, options.timeZone, {
month: "numeric",
day: "numeric",
hour: "numeric",
@ -71,9 +87,9 @@ export function formatShortDateTime(date: DateInput): string {
}
// 2:30 PM
export function formatTime(date: DateInput): string {
export function formatTime(date: DateInput, options: DateFormatOptions = {}): string {
return formatValidDate(date, (validDate) =>
Intl.DateTimeFormat(getLocales(), {
getDateTimeFormat(options.locale, options.timeZone, {
hour: "numeric",
minute: "numeric",
}).format(validDate),
@ -91,3 +107,20 @@ export function formatTimeAgo(date: DateInput): string {
return timeAgo.replace("about ", "").replace("over ", "").replace("almost ", "").replace("less than ", "");
});
}
export function useTimeFormat() {
const { locale, timeZone } = RootRoute.useLoaderData();
return useMemo(
() => ({
formatDateTime: (date: DateInput) => formatDateTime(date, { locale, timeZone }),
formatDateWithMonth: (date: DateInput) => formatDateWithMonth(date, { locale, timeZone }),
formatDate: (date: DateInput) => formatDate(date, { locale, timeZone }),
formatShortDate: (date: DateInput) => formatShortDate(date, { locale, timeZone }),
formatShortDateTime: (date: DateInput) => formatShortDateTime(date, { locale, timeZone }),
formatTime: (date: DateInput) => formatTime(date, { locale, timeZone }),
formatTimeAgo,
}),
[locale, timeZone],
);
}

View file

@ -1,11 +1,14 @@
import { CalendarClock, Database, HardDrive } from "lucide-react";
import { Link } from "@tanstack/react-router";
import { TimeAgo } from "~/client/components/time-ago";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { useTimeFormat } from "~/client/lib/datetime";
import type { BackupSchedule } from "~/client/lib/types";
import { BackupStatusDot } from "./backup-status-dot";
import { formatShortDateTime, formatTimeAgo } from "~/client/lib/datetime";
import { Link } from "@tanstack/react-router";
export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => {
const { formatShortDateTime } = useTimeFormat();
return (
<Link key={schedule.shortId} to="/backups/$backupId" params={{ backupId: schedule.shortId }}>
<Card interactive key={schedule.shortId} className="flex flex-col h-full">
@ -42,7 +45,7 @@ export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => {
<div className="flex items-center text-sm gap-2">
<span className="text-muted-foreground shrink-0">Last backup</span>
<div className="flex-1 border-b border-dashed border-border/80 dark:border-border/50" />
<span className="text-foreground font-mono text-sm shrink-0">{formatTimeAgo(schedule.lastBackupAt)}</span>
<TimeAgo date={schedule.lastBackupAt} className="text-foreground font-mono text-sm shrink-0" />
</div>
<div className="flex items-center text-sm gap-2">
<span className="text-muted-foreground shrink-0">Next backup</span>

View file

@ -16,11 +16,12 @@ import type { BackupSchedule } from "~/client/lib/types";
import { BackupProgressCard } from "./backup-progress-card";
import { getBackupProgressOptions, runForgetMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { toast } from "sonner";
import { handleRepositoryError } from "~/client/lib/errors";
import { formatShortDateTime, formatTimeAgo } from "~/client/lib/datetime";
import { Link } from "@tanstack/react-router";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "~/client/components/ui/collapsible";
import { TimeAgo } from "~/client/components/time-ago";
import { useTimeFormat } from "~/client/lib/datetime";
import { cn } from "~/client/lib/utils";
type Props = {
@ -35,6 +36,7 @@ type Props = {
export const ScheduleSummary = (props: Props) => {
const { schedule, handleToggleEnabled, handleRunBackupNow, handleStopBackup, handleDeleteSchedule, setIsEditMode } =
props;
const { formatShortDateTime } = useTimeFormat();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showForgetConfirm, setShowForgetConfirm] = useState(false);
const [showStopConfirm, setShowStopConfirm] = useState(false);
@ -182,7 +184,7 @@ export const ScheduleSummary = (props: Props) => {
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Last backup</p>
<p className="font-medium">{formatTimeAgo(schedule.lastBackupAt)}</p>
<TimeAgo date={schedule.lastBackupAt} className="font-medium" />
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Next backup</p>

View file

@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Button, buttonVariants } from "~/client/components/ui/button";
import type { Snapshot } from "~/client/lib/types";
import { formatDateTime } from "~/client/lib/datetime";
import { useTimeFormat } from "~/client/lib/datetime";
import { cn } from "~/client/lib/utils";
import { Link } from "@tanstack/react-router";
import { SnapshotTreeBrowser } from "~/client/components/file-browsers/snapshot-tree-browser";
@ -62,6 +62,7 @@ const TreeBrowserFallback = () => (
export const SnapshotFileBrowser = (props: Props) => {
const { snapshot, repositoryId, backupId, basePath, onDeleteSnapshot, isDeletingSnapshot } = props;
const { formatDateTime } = useTimeFormat();
const scheduleShortId = !basePath ? backupId || snapshot.tags?.[0] : undefined;

View file

@ -4,7 +4,7 @@ import type { ListSnapshotsResponse } from "~/client/api-client";
import { ByteSize } from "~/client/components/bytes-size";
import { Card, CardContent } from "~/client/components/ui/card";
import { Button } from "~/client/components/ui/button";
import { formatDateWithMonth, formatShortDate, formatTime } from "~/client/lib/datetime";
import { useTimeFormat } from "~/client/lib/datetime";
import { cn } from "~/client/lib/utils";
import { RetentionCategoryBadges } from "~/client/components/retention-category-badges";
@ -45,6 +45,7 @@ interface Props {
export const SnapshotTimeline = (props: Props) => {
const { snapshots, snapshotId, loading, onSnapshotSelect, error, initialSortOrder = "asc" } = props;
const selectedRef = useRef<HTMLButtonElement>(null);
const { formatDateWithMonth, formatShortDate, formatTime } = useTimeFormat();
const [sortOrder, setSortOrder] = useState<SnapshotTimelineSortOrder>(initialSortOrder);
const sortedSnapshots = useMemo(() => getSortedSnapshots(snapshots, sortOrder), [snapshots, sortOrder]);
const snapshotRange = useMemo(() => getSnapshotRange(snapshots), [snapshots]);

View file

@ -1,6 +1,6 @@
import { AlertCircle, CheckCircle2 } from "lucide-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "~/client/components/ui/collapsible";
import { formatDateTime } from "~/client/lib/datetime";
import { useTimeFormat } from "~/client/lib/datetime";
import { cn, safeJsonParse } from "~/client/lib/utils";
import { Card, CardTitle } from "~/client/components/ui/card";
@ -23,6 +23,8 @@ type Props = {
};
export const DoctorReport = ({ result, repositoryStatus }: Props) => {
const { formatDateTime } = useTimeFormat();
return (
<Card className="px-6 py-6 flex flex-col gap-4 h-full">
<div className="flex items-center justify-between">

View file

@ -8,7 +8,7 @@ import type { GetSnapshotDetailsResponse } from "~/client/api-client/types.gen";
import { Button } from "~/client/components/ui/button";
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 { useTimeFormat } from "~/client/lib/datetime";
import { BackupSummaryCard } from "~/client/components/backup-summary-card";
import { useState } from "react";
import { Database } from "lucide-react";
@ -58,6 +58,7 @@ type Props = {
export function SnapshotDetailsPage({ repositoryId, snapshotId, initialSnapshot }: Props) {
const [showAllPaths, setShowAllPaths] = useState(false);
const { formatDateTime } = useTimeFormat();
const { data: repository } = useSuspenseQuery({
...getRepositoryOptions({ path: { shortId: repositoryId } }),

View file

@ -15,7 +15,6 @@ import {
} from "~/client/components/ui/alert-dialog";
import type { Repository } from "~/client/lib/types";
import type { GetRepositoryStatsResponse } from "~/client/api-client/types.gen";
import { formatDateTime, formatTimeAgo } from "~/client/lib/datetime";
import {
cancelDoctorMutation,
deleteRepositoryMutation,
@ -23,6 +22,8 @@ import {
unlockRepositoryMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import type { RepositoryConfig } from "@zerobyte/core/restic";
import { TimeAgo } from "~/client/components/time-ago";
import { useTimeFormat } from "~/client/lib/datetime";
import { DoctorReport } from "../components/doctor-report";
import { parseError } from "~/client/lib/errors";
import { useNavigate } from "@tanstack/react-router";
@ -41,6 +42,7 @@ const getEffectiveLocalPath = (repository: Repository): string | null => {
};
export const RepositoryInfoTabContent = ({ repository, initialStats }: Props) => {
const { formatDateTime } = useTimeFormat();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const navigate = useNavigate();
@ -206,7 +208,7 @@ export const RepositoryInfoTabContent = ({ repository, initialStats }: Props) =>
</div>
<div className="flex flex-col gap-1">
<div className="text-sm font-medium text-muted-foreground">Last Checked</div>
<p className="text-sm">{formatTimeAgo(repository.lastChecked)}</p>
<TimeAgo date={repository.lastChecked} className="text-sm" />
</div>
{hasLocalPath && (
<div className="flex flex-col gap-1 @medium:col-span-2">

View file

@ -29,7 +29,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~
import { Switch } from "~/client/components/ui/switch";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
import { useOrganizationContext } from "~/client/hooks/use-org-context";
import { formatDateWithMonth } from "~/client/lib/datetime";
import { useTimeFormat } from "~/client/lib/datetime";
import { getOrigin } from "~/client/functions/get-origin";
import { authClient } from "~/client/lib/auth-client";
import { cn } from "~/client/lib/utils";
@ -47,6 +47,7 @@ export function SsoSettingsSection({ initialSettings, initialOrigin }: Props) {
const { data } = useSuspenseQuery({ queryKey: ["app-origin"], queryFn: originQuery, initialData: initialOrigin });
const navigate = useNavigate();
const { formatDateWithMonth } = useTimeFormat();
const { activeOrganization } = useOrganizationContext();
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState<InvitationRole>("member");

View file

@ -5,8 +5,8 @@ import { healthCheckVolumeMutation, updateVolumeMutation } from "~/client/api-cl
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";
import { TimeAgo } from "~/client/components/time-ago";
type Props = {
volume: Volume;
@ -53,9 +53,11 @@ export const HealthchecksCard = ({ volume }: Props) => {
{volume.lastError && <span className="text-sm text-red-500 wrap-break-word">{volume.lastError}</span>}
{volume.status === "mounted" && <span className="text-md text-success">Healthy</span>}
{volume.status !== "unmounted" && (
<span className="text-xs text-muted-foreground mb-4">Checked {formatTimeAgo(volume.lastHealthCheck)}</span>
<span className="text-xs text-muted-foreground mb-4">
Checked <TimeAgo date={volume.lastHealthCheck} />
</span>
)}
<span className="flex justify-between items-center gap-2">
<div className="flex justify-between items-center gap-2">
<span className="text-sm">Remount on error</span>
<OnOff
isOn={volume.autoRemount}
@ -69,7 +71,7 @@ export const HealthchecksCard = ({ volume }: Props) => {
enabledLabel="Enabled"
disabledLabel="Paused"
/>
</span>
</div>
</div>
{volume.status !== "unmounted" && (
<div className="flex justify-center">

View file

@ -81,9 +81,9 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
const deleteVol = useMutation({
...deleteVolumeMutation(),
onSuccess: () => {
onSuccess: async () => {
toast.success("Volume deleted successfully");
void navigate({ to: "/volumes" });
await navigate({ to: "/volumes" });
},
onError: (error) => {
toast.error("Failed to delete volume", {

View file

@ -8,7 +8,7 @@ import { useServerEvents } from "~/client/hooks/use-server-events";
import { useEffect } from "react";
import { ThemeProvider, THEME_COOKIE_NAME } from "~/client/components/theme-provider";
import { createServerFn } from "@tanstack/react-start";
import { getCookie } from "@tanstack/react-start/server";
import { getCookie, getRequestHeaders } from "@tanstack/react-start/server";
import { isAuthRoute } from "~/lib/auth-routes";
const fetchTheme = createServerFn({ method: "GET" }).handler(async () => {
@ -16,13 +16,22 @@ const fetchTheme = createServerFn({ method: "GET" }).handler(async () => {
return (themeCookie === "light" ? "light" : "dark") as "light" | "dark";
});
const fetchTimeConfig = createServerFn({ method: "GET" }).handler(async () => {
const acceptLanguage = getRequestHeaders().get("accept-language");
return {
locale: (acceptLanguage?.split(",")[0] || "en-US") as string,
timeZone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
};
});
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
server: {
middleware: [apiClientMiddleware],
},
loader: async () => {
const theme = await fetchTheme();
return { theme };
const [theme, timeConfig] = await Promise.all([fetchTheme(), fetchTimeConfig()]);
return { theme, ...timeConfig };
},
head: () => ({
meta: [{ title: "Zerobyte - Open Source Backup Solution" }],