parent
6354705626
commit
9e7755f3d7
9 changed files with 2572 additions and 51 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
|
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_TIME_FORMAT,
|
||||||
formatDate,
|
formatDate,
|
||||||
formatDateTime,
|
formatDateTime,
|
||||||
formatDateWithMonth,
|
formatDateWithMonth,
|
||||||
|
|
@ -64,4 +65,38 @@ describe("datetime formatters", () => {
|
||||||
test("formats calendar values with an explicit locale and timezone", () => {
|
test("formats calendar values with an explicit locale and timezone", () => {
|
||||||
expect(formatShortDateTime(sampleDate, { locale: "en-US", timeZone: "UTC" })).toBe("1/10, 2:30 PM");
|
expect(formatShortDateTime(sampleDate, { locale: "en-US", timeZone: "UTC" })).toBe("1/10, 2:30 PM");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["MM/DD/YYYY", "1/10/2026"],
|
||||||
|
["DD/MM/YYYY", "10/1/2026"],
|
||||||
|
["YYYY/MM/DD", "2026/1/10"],
|
||||||
|
] as const)("formats numeric dates with %s order", (dateFormat, expected) => {
|
||||||
|
expect(formatDate(sampleDate, { locale: "en-US", timeZone: "UTC", dateFormat })).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["MM/DD/YYYY", "Jan 10, 2026"],
|
||||||
|
["DD/MM/YYYY", "10 Jan 2026"],
|
||||||
|
["YYYY/MM/DD", "2026 Jan 10"],
|
||||||
|
] as const)("formats month dates with %s order", (dateFormat, expected) => {
|
||||||
|
expect(formatDateWithMonth(sampleDate, { locale: "en-US", timeZone: "UTC", dateFormat })).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
[DEFAULT_TIME_FORMAT, "2:30 PM"],
|
||||||
|
["24h", "14:30"],
|
||||||
|
] as const)("formats times with %s clock", (timeFormat, expected) => {
|
||||||
|
expect(formatTime(sampleDate, { locale: "en-US", timeZone: "UTC", timeFormat })).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats combined values with custom date and time preferences", () => {
|
||||||
|
expect(
|
||||||
|
formatDateTime(sampleDate, {
|
||||||
|
locale: "en-US",
|
||||||
|
timeZone: "UTC",
|
||||||
|
dateFormat: "DD/MM/YYYY",
|
||||||
|
timeFormat: "24h",
|
||||||
|
}),
|
||||||
|
).toBe("10/1/2026, 14:30");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,35 @@
|
||||||
import { formatDistanceToNow, isValid } from "date-fns";
|
import { formatDistanceToNow, isValid } from "date-fns";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { authClient } from "~/client/lib/auth-client";
|
||||||
import { Route as RootRoute } from "~/routes/__root";
|
import { Route as RootRoute } from "~/routes/__root";
|
||||||
|
|
||||||
export type DateInput = Date | string | number | null | undefined;
|
export type DateInput = Date | string | number | null | undefined;
|
||||||
|
|
||||||
|
export const DATE_FORMATS = ["MM/DD/YYYY", "DD/MM/YYYY", "YYYY/MM/DD"] as const;
|
||||||
|
export type DateFormatPreference = (typeof DATE_FORMATS)[number];
|
||||||
|
export const DEFAULT_DATE_FORMAT: DateFormatPreference = "MM/DD/YYYY";
|
||||||
|
|
||||||
|
export const TIME_FORMATS = ["12h", "24h"] as const;
|
||||||
|
export type TimeFormatPreference = (typeof TIME_FORMATS)[number];
|
||||||
|
export const DEFAULT_TIME_FORMAT: TimeFormatPreference = "12h";
|
||||||
|
|
||||||
|
const DATE_PART_ORDERS = {
|
||||||
|
"MM/DD/YYYY": ["month", "day", "year"],
|
||||||
|
"DD/MM/YYYY": ["day", "month", "year"],
|
||||||
|
"YYYY/MM/DD": ["year", "month", "day"],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const SHORT_DATE_PART_ORDERS = {
|
||||||
|
"MM/DD/YYYY": ["month", "day"],
|
||||||
|
"DD/MM/YYYY": ["day", "month"],
|
||||||
|
"YYYY/MM/DD": ["month", "day"],
|
||||||
|
} as const;
|
||||||
|
|
||||||
type DateFormatOptions = {
|
type DateFormatOptions = {
|
||||||
locale?: string | string[];
|
locale?: string | string[];
|
||||||
timeZone?: string;
|
timeZone?: string;
|
||||||
|
dateFormat?: DateFormatPreference;
|
||||||
|
timeFormat?: TimeFormatPreference;
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatValidDate(date: DateInput, formatter: (date: Date) => string): string {
|
function formatValidDate(date: DateInput, formatter: (date: Date) => string): string {
|
||||||
|
|
@ -29,71 +52,97 @@ function getDateTimeFormat(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRequiredPart(parts: Intl.DateTimeFormatPart[], type: Intl.DateTimeFormatPartTypes) {
|
||||||
|
const value = parts.find((part) => part.type === type)?.value;
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`Missing ${type} in formatted date`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatConfiguredDate(date: Date, options: DateFormatOptions, includeYear: boolean) {
|
||||||
|
const dateFormat = options.dateFormat ?? DEFAULT_DATE_FORMAT;
|
||||||
|
const parts = getDateTimeFormat(options.locale, options.timeZone, {
|
||||||
|
month: "numeric",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
}).formatToParts(date);
|
||||||
|
const values = {
|
||||||
|
month: getRequiredPart(parts, "month"),
|
||||||
|
day: getRequiredPart(parts, "day"),
|
||||||
|
year: getRequiredPart(parts, "year"),
|
||||||
|
};
|
||||||
|
const order = includeYear ? DATE_PART_ORDERS[dateFormat] : SHORT_DATE_PART_ORDERS[dateFormat];
|
||||||
|
|
||||||
|
return order.map((part) => values[part]).join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatConfiguredDateWithMonth(date: Date, options: DateFormatOptions) {
|
||||||
|
const dateFormat = options.dateFormat ?? DEFAULT_DATE_FORMAT;
|
||||||
|
const parts = getDateTimeFormat(options.locale, options.timeZone, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
}).formatToParts(date);
|
||||||
|
const month = getRequiredPart(parts, "month");
|
||||||
|
const day = getRequiredPart(parts, "day");
|
||||||
|
const year = getRequiredPart(parts, "year");
|
||||||
|
|
||||||
|
if (dateFormat === "DD/MM/YYYY") {
|
||||||
|
return `${day} ${month} ${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateFormat === "YYYY/MM/DD") {
|
||||||
|
return `${year} ${month} ${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${month} ${day}, ${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatConfiguredTime(date: Date, options: DateFormatOptions) {
|
||||||
|
return getDateTimeFormat(options.locale, options.timeZone, {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "numeric",
|
||||||
|
hour12: (options.timeFormat ?? DEFAULT_TIME_FORMAT) === "12h",
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
// 1/10/2026, 2:30 PM
|
// 1/10/2026, 2:30 PM
|
||||||
export function formatDateTime(date: DateInput, options: DateFormatOptions = {}): string {
|
export function formatDateTime(date: DateInput, options: DateFormatOptions = {}): string {
|
||||||
return formatValidDate(date, (validDate) =>
|
return formatValidDate(
|
||||||
getDateTimeFormat(options.locale, options.timeZone, {
|
date,
|
||||||
month: "numeric",
|
(validDate) => `${formatConfiguredDate(validDate, options, true)}, ${formatConfiguredTime(validDate, options)}`,
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "numeric",
|
|
||||||
}).format(validDate),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Jan 10, 2026
|
// Jan 10, 2026
|
||||||
export function formatDateWithMonth(date: DateInput, options: DateFormatOptions = {}): string {
|
export function formatDateWithMonth(date: DateInput, options: DateFormatOptions = {}): string {
|
||||||
return formatValidDate(date, (validDate) =>
|
return formatValidDate(date, (validDate) => formatConfiguredDateWithMonth(validDate, options));
|
||||||
getDateTimeFormat(options.locale, options.timeZone, {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
}).format(validDate),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1/10/2026
|
// 1/10/2026
|
||||||
export function formatDate(date: DateInput, options: DateFormatOptions = {}): string {
|
export function formatDate(date: DateInput, options: DateFormatOptions = {}): string {
|
||||||
return formatValidDate(date, (validDate) =>
|
return formatValidDate(date, (validDate) => formatConfiguredDate(validDate, options, true));
|
||||||
getDateTimeFormat(options.locale, options.timeZone, {
|
|
||||||
month: "numeric",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
}).format(validDate),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1/10
|
// 1/10
|
||||||
export function formatShortDate(date: DateInput, options: DateFormatOptions = {}): string {
|
export function formatShortDate(date: DateInput, options: DateFormatOptions = {}): string {
|
||||||
return formatValidDate(date, (validDate) =>
|
return formatValidDate(date, (validDate) => formatConfiguredDate(validDate, options, false));
|
||||||
getDateTimeFormat(options.locale, options.timeZone, {
|
|
||||||
month: "numeric",
|
|
||||||
day: "numeric",
|
|
||||||
}).format(validDate),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1/10, 2:30 PM
|
// 1/10, 2:30 PM
|
||||||
export function formatShortDateTime(date: DateInput, options: DateFormatOptions = {}): string {
|
export function formatShortDateTime(date: DateInput, options: DateFormatOptions = {}): string {
|
||||||
return formatValidDate(date, (validDate) =>
|
return formatValidDate(
|
||||||
getDateTimeFormat(options.locale, options.timeZone, {
|
date,
|
||||||
month: "numeric",
|
(validDate) => `${formatConfiguredDate(validDate, options, false)}, ${formatConfiguredTime(validDate, options)}`,
|
||||||
day: "numeric",
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "numeric",
|
|
||||||
}).format(validDate),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2:30 PM
|
// 2:30 PM
|
||||||
export function formatTime(date: DateInput, options: DateFormatOptions = {}): string {
|
export function formatTime(date: DateInput, options: DateFormatOptions = {}): string {
|
||||||
return formatValidDate(date, (validDate) =>
|
return formatValidDate(date, (validDate) => formatConfiguredTime(validDate, options));
|
||||||
getDateTimeFormat(options.locale, options.timeZone, {
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "numeric",
|
|
||||||
}).format(validDate),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5 minutes ago
|
// 5 minutes ago
|
||||||
|
|
@ -113,23 +162,33 @@ export function formatTimeAgo(date: DateInput, now = Date.now()): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTimeFormat() {
|
export function useTimeFormat() {
|
||||||
const { locale, timeZone, now } = RootRoute.useLoaderData();
|
const { locale, timeZone, dateFormat, timeFormat, now } = RootRoute.useLoaderData();
|
||||||
|
const session = authClient.useSession();
|
||||||
const [currentNow, setCurrentNow] = useState(now);
|
const [currentNow, setCurrentNow] = useState(now);
|
||||||
|
const currentDateFormat = (session.data?.user.dateFormat ?? dateFormat) as DateFormatPreference;
|
||||||
|
const currentTimeFormat = (session.data?.user.timeFormat ?? timeFormat) as TimeFormatPreference;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentNow(Date.now());
|
const nextNow = Date.now();
|
||||||
|
setCurrentNow(nextNow === now ? now : nextNow);
|
||||||
}, [now]);
|
}, [now]);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
formatDateTime: (date: DateInput) => formatDateTime(date, { locale, timeZone }),
|
formatDateTime: (date: DateInput) =>
|
||||||
formatDateWithMonth: (date: DateInput) => formatDateWithMonth(date, { locale, timeZone }),
|
formatDateTime(date, { locale, timeZone, dateFormat: currentDateFormat, timeFormat: currentTimeFormat }),
|
||||||
formatDate: (date: DateInput) => formatDate(date, { locale, timeZone }),
|
formatDateWithMonth: (date: DateInput) =>
|
||||||
formatShortDate: (date: DateInput) => formatShortDate(date, { locale, timeZone }),
|
formatDateWithMonth(date, { locale, timeZone, dateFormat: currentDateFormat, timeFormat: currentTimeFormat }),
|
||||||
formatShortDateTime: (date: DateInput) => formatShortDateTime(date, { locale, timeZone }),
|
formatDate: (date: DateInput) =>
|
||||||
formatTime: (date: DateInput) => formatTime(date, { locale, timeZone }),
|
formatDate(date, { locale, timeZone, dateFormat: currentDateFormat, timeFormat: currentTimeFormat }),
|
||||||
|
formatShortDate: (date: DateInput) =>
|
||||||
|
formatShortDate(date, { locale, timeZone, dateFormat: currentDateFormat, timeFormat: currentTimeFormat }),
|
||||||
|
formatShortDateTime: (date: DateInput) =>
|
||||||
|
formatShortDateTime(date, { locale, timeZone, dateFormat: currentDateFormat, timeFormat: currentTimeFormat }),
|
||||||
|
formatTime: (date: DateInput) =>
|
||||||
|
formatTime(date, { locale, timeZone, dateFormat: currentDateFormat, timeFormat: currentTimeFormat }),
|
||||||
formatTimeAgo: (date: DateInput) => formatTimeAgo(date, currentNow),
|
formatTimeAgo: (date: DateInput) => formatTimeAgo(date, currentNow),
|
||||||
}),
|
}),
|
||||||
[locale, timeZone, currentNow],
|
[locale, timeZone, currentDateFormat, currentTimeFormat, currentNow],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,19 @@ import {
|
||||||
} from "~/client/components/ui/dialog";
|
} from "~/client/components/ui/dialog";
|
||||||
import { Input } from "~/client/components/ui/input";
|
import { Input } from "~/client/components/ui/input";
|
||||||
import { Label } from "~/client/components/ui/label";
|
import { Label } from "~/client/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs";
|
||||||
import { authClient } from "~/client/lib/auth-client";
|
import { authClient } from "~/client/lib/auth-client";
|
||||||
|
import {
|
||||||
|
DATE_FORMATS,
|
||||||
|
type DateFormatPreference,
|
||||||
|
formatDateTime,
|
||||||
|
TIME_FORMATS,
|
||||||
|
type TimeFormatPreference,
|
||||||
|
} from "~/client/lib/datetime";
|
||||||
import { logger } from "~/client/lib/logger";
|
import { logger } from "~/client/lib/logger";
|
||||||
import { type AppContext } from "~/context";
|
import { type AppContext } from "~/context";
|
||||||
|
import { Route as RootRoute } from "~/routes/__root";
|
||||||
import { TwoFactorSection } from "../components/two-factor-section";
|
import { TwoFactorSection } from "../components/two-factor-section";
|
||||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||||
import { SsoSettingsSection } from "~/client/modules/sso/components/sso-settings-section";
|
import { SsoSettingsSection } from "~/client/modules/sso/components/sso-settings-section";
|
||||||
|
|
@ -41,6 +50,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false);
|
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false);
|
||||||
const [downloadPassword, setDownloadPassword] = useState("");
|
const [downloadPassword, setDownloadPassword] = useState("");
|
||||||
const [isChangingPassword, setIsChangingPassword] = useState(false);
|
const [isChangingPassword, setIsChangingPassword] = useState(false);
|
||||||
|
const { locale, dateFormat, timeFormat } = RootRoute.useLoaderData();
|
||||||
|
|
||||||
const { tab } = useSearch({ from: "/(dashboard)/settings/" });
|
const { tab } = useSearch({ from: "/(dashboard)/settings/" });
|
||||||
const activeTab = tab || "account";
|
const activeTab = tab || "account";
|
||||||
|
|
@ -48,6 +58,12 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { activeMember, activeOrganization } = useOrganizationContext();
|
const { activeMember, activeOrganization } = useOrganizationContext();
|
||||||
const isOrgAdmin = activeMember?.role === "owner" || activeMember?.role === "admin";
|
const isOrgAdmin = activeMember?.role === "owner" || activeMember?.role === "admin";
|
||||||
|
const dateTimePreview = formatDateTime("2026-01-10T14:30:00.000Z", {
|
||||||
|
locale,
|
||||||
|
timeZone: "UTC",
|
||||||
|
dateFormat,
|
||||||
|
timeFormat,
|
||||||
|
});
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await authClient.signOut({
|
await authClient.signOut({
|
||||||
|
|
@ -141,6 +157,43 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDateTimeFormatChange = async (
|
||||||
|
nextDateFormat: DateFormatPreference,
|
||||||
|
nextTimeFormat: TimeFormatPreference,
|
||||||
|
) => {
|
||||||
|
await authClient.updateUser({
|
||||||
|
dateFormat: nextDateFormat,
|
||||||
|
timeFormat: nextTimeFormat,
|
||||||
|
fetchOptions: {
|
||||||
|
onError: ({ error }) => {
|
||||||
|
toast.error("Failed to update date and time format", {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
window.location.reload();
|
||||||
|
toast.success("Date and time preferences saved");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDateFormatChange = async (nextDateFormat: DateFormatPreference) => {
|
||||||
|
if (nextDateFormat === dateFormat) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleDateTimeFormatChange(nextDateFormat, timeFormat);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimeFormatChange = async (nextTimeFormat: TimeFormatPreference) => {
|
||||||
|
if (nextTimeFormat === timeFormat) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleDateTimeFormatChange(dateFormat, nextTimeFormat);
|
||||||
|
};
|
||||||
|
|
||||||
const onTabChange = (value: string) => {
|
const onTabChange = (value: string) => {
|
||||||
void navigate({ to: ".", search: () => ({ tab: value }) });
|
void navigate({ to: ".", search: () => ({ tab: value }) });
|
||||||
};
|
};
|
||||||
|
|
@ -174,6 +227,59 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
|
<div className="border-t border-border/50 bg-card-header p-6">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<SettingsIcon className="size-5" />
|
||||||
|
Date and Time Format
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="mt-1.5">
|
||||||
|
Choose how dates and times are shown throughout the app
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="space-y-4 max-w-2xl">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="date-format">Date format</Label>
|
||||||
|
<Select
|
||||||
|
value={dateFormat}
|
||||||
|
onValueChange={(value) => void handleDateFormatChange(value as DateFormatPreference)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="date-format">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DATE_FORMATS.map((value) => (
|
||||||
|
<SelectItem key={value} value={value}>
|
||||||
|
{value}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="time-format">Time format</Label>
|
||||||
|
<Select
|
||||||
|
value={timeFormat}
|
||||||
|
onValueChange={(value) => void handleTimeFormatChange(value as TimeFormatPreference)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="time-format">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{TIME_FORMATS.map((value) => (
|
||||||
|
<SelectItem key={value} value={value}>
|
||||||
|
{value}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Preview: {dateTimePreview}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
<div className="border-t border-border/50 bg-card-header p-6">
|
<div className="border-t border-border/50 bg-card-header p-6">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<KeyRound className="size-5" />
|
<KeyRound className="size-5" />
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ type User = {
|
||||||
username: string;
|
username: string;
|
||||||
name: string;
|
name: string;
|
||||||
hasDownloadedResticPassword: boolean;
|
hasDownloadedResticPassword: boolean;
|
||||||
|
dateFormat: string;
|
||||||
|
timeFormat: string;
|
||||||
twoFactorEnabled?: boolean | null;
|
twoFactorEnabled?: boolean | null;
|
||||||
role?: string | null | undefined;
|
role?: string | null | undefined;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE `users_table` ADD `date_format` text DEFAULT 'MM/DD/YYYY' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `users_table` ADD `time_format` text DEFAULT '12h' NOT NULL;
|
||||||
2301
app/drizzle/20260326180038_slimy_trish_tilby/snapshot.json
Normal file
2301
app/drizzle/20260326180038_slimy_trish_tilby/snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -10,6 +10,8 @@ import { ThemeProvider, THEME_COOKIE_NAME } from "~/client/components/theme-prov
|
||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { getCookie, getRequestHeaders } from "@tanstack/react-start/server";
|
import { getCookie, getRequestHeaders } from "@tanstack/react-start/server";
|
||||||
import { isAuthRoute } from "~/lib/auth-routes";
|
import { isAuthRoute } from "~/lib/auth-routes";
|
||||||
|
import { auth } from "~/server/lib/auth";
|
||||||
|
import type { DateFormatPreference, TimeFormatPreference } from "~/client/lib/datetime";
|
||||||
|
|
||||||
const fetchTheme = createServerFn({ method: "GET" }).handler(async () => {
|
const fetchTheme = createServerFn({ method: "GET" }).handler(async () => {
|
||||||
const themeCookie = getCookie(THEME_COOKIE_NAME);
|
const themeCookie = getCookie(THEME_COOKIE_NAME);
|
||||||
|
|
@ -17,11 +19,15 @@ const fetchTheme = createServerFn({ method: "GET" }).handler(async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchTimeConfig = createServerFn({ method: "GET" }).handler(async () => {
|
const fetchTimeConfig = createServerFn({ method: "GET" }).handler(async () => {
|
||||||
const acceptLanguage = getRequestHeaders().get("accept-language");
|
const headers = getRequestHeaders();
|
||||||
|
const acceptLanguage = headers.get("accept-language");
|
||||||
|
const session = await auth.api.getSession({ headers });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
locale: (acceptLanguage?.split(",")[0] || "en-US") as string,
|
locale: (acceptLanguage?.split(",")[0] || "en-US") as string,
|
||||||
timeZone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
timeZone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
||||||
|
dateFormat: (session?.user.dateFormat ?? "MM/DD/YYYY") as DateFormatPreference,
|
||||||
|
timeFormat: (session?.user.timeFormat ?? "12h") as TimeFormatPreference,
|
||||||
now: Date.now(),
|
now: Date.now(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ export const usersTable = sqliteTable("users_table", {
|
||||||
username: text().notNull().unique(),
|
username: text().notNull().unique(),
|
||||||
passwordHash: text("password_hash"),
|
passwordHash: text("password_hash"),
|
||||||
hasDownloadedResticPassword: int("has_downloaded_restic_password", { mode: "boolean" }).notNull().default(false),
|
hasDownloadedResticPassword: int("has_downloaded_restic_password", { mode: "boolean" }).notNull().default(false),
|
||||||
|
dateFormat: text("date_format").notNull().default("MM/DD/YYYY"),
|
||||||
|
timeFormat: text("time_format").notNull().default("12h"),
|
||||||
createdAt: int("created_at", { mode: "timestamp_ms" })
|
createdAt: int("created_at", { mode: "timestamp_ms" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(unixepoch() * 1000)`),
|
.default(sql`(unixepoch() * 1000)`),
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,14 @@ export const auth = betterAuth({
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
returned: true,
|
returned: true,
|
||||||
},
|
},
|
||||||
|
dateFormat: {
|
||||||
|
type: "string",
|
||||||
|
returned: true,
|
||||||
|
},
|
||||||
|
timeFormat: {
|
||||||
|
type: "string",
|
||||||
|
returned: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue