mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-10-30 22:21:58 +01:00
Timestamps now follow the browser locale by default (previously hardcoded en-US/12-hour), with a clock toggle in the header for Auto / 12-hour / 24-hour, persisted in localStorage. Fixes #342
This commit is contained in:
1 parent
f922bcc618
commit
bdbfa762af
12 files changed
+508
-21
No files matched your search
@@ -6,6 +6,7 @@ import { Button } from '../ui/button';
|
||||
import { RefreshCw, Check, X, Loader2, Import } from 'lucide-react';
|
||||
import { Card } from '../ui/card';
|
||||
import { formatDate, getStatusColor } from '@/lib/utils';
|
||||
import { useTimeFormat } from '@/hooks/useTimeFormat';
|
||||
import { Skeleton } from '../ui/skeleton';
|
||||
import type { FilterParams } from '@/types/filter';
|
||||
import {
|
||||
@@ -32,6 +33,9 @@ export default function ActivityList({
|
||||
filter,
|
||||
setFilter,
|
||||
}: ActivityListProps) {
|
||||
// Re-render timestamps when the user changes the 12h/24h preference.
|
||||
useTimeFormat();
|
||||
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { ScheduleConfig, DatabaseCleanupConfig } from "@/types/config";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { useTimeFormat } from "@/hooks/useTimeFormat";
|
||||
import {
|
||||
buildClockCronExpression,
|
||||
getNextCronOccurrence,
|
||||
@@ -89,6 +90,9 @@ export function AutomationSettings({
|
||||
isAutoSavingSchedule,
|
||||
isAutoSavingCleanup,
|
||||
}: AutomationSettingsProps) {
|
||||
// Re-render timestamps when the user changes the 12h/24h preference.
|
||||
useTimeFormat();
|
||||
|
||||
const browserTimezone =
|
||||
typeof Intl !== "undefined"
|
||||
? Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "../ui/checkbox";
|
||||
import type { ScheduleConfig } from "@/types/config";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { useTimeFormat } from "@/hooks/useTimeFormat";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -24,6 +25,9 @@ export function ScheduleConfigForm({
|
||||
onAutoSave,
|
||||
isAutoSaving = false,
|
||||
}: ScheduleConfigFormProps) {
|
||||
// Re-render timestamps when the user changes the 12h/24h preference.
|
||||
useTimeFormat();
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||
) => {
|
||||
|
||||
@@ -17,6 +17,8 @@ import { usePageVisibility } from "@/hooks/usePageVisibility";
|
||||
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
||||
import { useNavigation } from "@/components/layout/MainLayout";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
import { formatShortDateTime } from "@/lib/utils/time-format";
|
||||
import { useTimeFormat } from "@/hooks/useTimeFormat";
|
||||
|
||||
// Helper function to format last sync time
|
||||
function formatLastSyncTime(date: Date | null): string {
|
||||
@@ -45,17 +47,11 @@ function formatLastSyncTime(date: Date | null): string {
|
||||
}
|
||||
|
||||
// Helper function to format full timestamp
|
||||
// Locale-aware and respects the user's 12h/24h time format preference.
|
||||
function formatFullTimestamp(date: Date | null): string {
|
||||
if (!date) return "";
|
||||
|
||||
return new Date(date).toLocaleString("en-US", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
year: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: true
|
||||
}).replace(',', '');
|
||||
|
||||
return formatShortDateTime(date).replace(',', '');
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
@@ -64,6 +60,8 @@ export function Dashboard() {
|
||||
const isPageVisible = usePageVisibility();
|
||||
const { isFullyConfigured } = useConfigStatus();
|
||||
const { navigationKey } = useNavigation();
|
||||
// Re-render timestamps when the user changes the 12h/24h preference.
|
||||
useTimeFormat();
|
||||
|
||||
const [repositories, setRepositories] = useState<Repository[]>([]);
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
|
||||
@@ -4,12 +4,16 @@ import { formatDate, getStatusColor } from "@/lib/utils";
|
||||
import { Button } from "../ui/button";
|
||||
import { Activity, Clock } from "lucide-react";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
import { useTimeFormat } from "@/hooks/useTimeFormat";
|
||||
|
||||
interface RecentActivityProps {
|
||||
activities: MirrorJob[];
|
||||
}
|
||||
|
||||
export function RecentActivity({ activities }: RecentActivityProps) {
|
||||
// Re-render timestamps when the user changes the 12h/24h preference.
|
||||
useTimeFormat();
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { ModeToggle } from "@/components/theme/ModeToggle";
|
||||
import { TimeFormatToggle } from "@/components/layout/TimeFormatToggle";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
|
||||
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
||||
@@ -125,6 +126,8 @@ export function Header({ currentPage, onNavigate, onMenuClick, onToggleCollapse,
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<TimeFormatToggle />
|
||||
|
||||
<ModeToggle />
|
||||
|
||||
{isLoading ? <AuthButtonsSkeleton /> : <AccountMenu />}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Clock, Check } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useTimeFormat } from "@/hooks/useTimeFormat";
|
||||
import type { TimeFormatPreference } from "@/lib/utils/time-format";
|
||||
|
||||
const OPTIONS: { value: TimeFormatPreference; label: string }[] = [
|
||||
{ value: "auto", label: "Auto (browser locale)" },
|
||||
{ value: "12h", label: "12-hour" },
|
||||
{ value: "24h", label: "24-hour" },
|
||||
];
|
||||
|
||||
export function TimeFormatToggle() {
|
||||
const { preference, setPreference } = useTimeFormat();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="has-[>svg]:px-3"
|
||||
title="Time format"
|
||||
>
|
||||
<Clock className="h-[1.2rem] w-[1.2rem]" />
|
||||
<span className="sr-only">Toggle time format</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{OPTIONS.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onClick={() => setPreference(option.value)}
|
||||
>
|
||||
<Check
|
||||
className={`h-4 w-4 ${
|
||||
preference === option.value ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
import {
|
||||
getTimeFormatPreference,
|
||||
setTimeFormatPreference,
|
||||
subscribeToTimeFormatChange,
|
||||
type TimeFormatPreference,
|
||||
} from "@/lib/utils/time-format";
|
||||
|
||||
const getServerSnapshot = (): TimeFormatPreference => "auto";
|
||||
|
||||
/**
|
||||
* Subscribe a component to the user's 12h/24h time format preference.
|
||||
*
|
||||
* Any component that renders clock times should call this hook (even if it
|
||||
* only needs the re-render) so timestamps update immediately when the user
|
||||
* changes the preference — navigation is SPA-style, so components stay
|
||||
* mounted across pages.
|
||||
*/
|
||||
export function useTimeFormat() {
|
||||
const preference = useSyncExternalStore(
|
||||
subscribeToTimeFormatChange,
|
||||
getTimeFormatPreference,
|
||||
getServerSnapshot
|
||||
);
|
||||
|
||||
const setPreference = useCallback((next: TimeFormatPreference) => {
|
||||
setTimeFormatPreference(next);
|
||||
}, []);
|
||||
|
||||
return { preference, setPreference };
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { jsonResponse, formatDate, formatDateShort, truncate, safeParse, parseErrorMessage, showErrorToast } from "./utils";
|
||||
import { formatDateTime } from "./utils/time-format";
|
||||
|
||||
describe("jsonResponse", () => {
|
||||
test("creates a Response with JSON content", () => {
|
||||
@@ -44,10 +45,11 @@ describe("formatDate", () => {
|
||||
const date = new Date("2023-01-15T12:30:45Z");
|
||||
const formatted = formatDate(date);
|
||||
|
||||
// The exact format might depend on the locale, so we'll check for parts
|
||||
// The exact format depends on the system locale and the user's time
|
||||
// format preference, so check for locale-independent parts and that it
|
||||
// delegates to the shared time-format utility.
|
||||
expect(formatted).toContain("2023");
|
||||
expect(formatted).toContain("January");
|
||||
expect(formatted).toContain("15");
|
||||
expect(formatted).toBe(formatDateTime(date));
|
||||
});
|
||||
|
||||
test("formats a date string", () => {
|
||||
@@ -55,8 +57,7 @@ describe("formatDate", () => {
|
||||
const formatted = formatDate(dateStr);
|
||||
|
||||
expect(formatted).toContain("2023");
|
||||
expect(formatted).toContain("January");
|
||||
expect(formatted).toContain("15");
|
||||
expect(formatted).toBe(formatDateTime(dateStr));
|
||||
});
|
||||
|
||||
test("returns 'Never' for null or undefined", () => {
|
||||
|
||||
+3
-7
@@ -3,6 +3,7 @@ import { twMerge } from "tailwind-merge";
|
||||
import { httpRequest, HttpError } from "@/lib/http-client";
|
||||
import type { RepoStatus } from "@/types/Repository";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
import { formatDateTime } from "@/lib/utils/time-format";
|
||||
|
||||
export const API_BASE = withBase("/api");
|
||||
|
||||
@@ -23,13 +24,8 @@ export function generateRandomString(length: number): string {
|
||||
|
||||
export function formatDate(date?: Date | string | null): string {
|
||||
if (!date) return "Never";
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(date));
|
||||
// Locale-aware and respects the user's 12h/24h time format preference.
|
||||
return formatDateTime(date);
|
||||
}
|
||||
|
||||
export function formatDateShort(date?: Date | string | null): string | undefined {
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { describe, test, expect, beforeAll, beforeEach, afterAll } from "bun:test";
|
||||
import {
|
||||
TIME_FORMAT_STORAGE_KEY,
|
||||
isTimeFormatPreference,
|
||||
getTimeFormatPreference,
|
||||
setTimeFormatPreference,
|
||||
subscribeToTimeFormatChange,
|
||||
resolveHour12,
|
||||
formatDateTime,
|
||||
formatShortDateTime,
|
||||
formatTime,
|
||||
} from "./time-format";
|
||||
|
||||
// A fixed instant; assertions below are timezone-agnostic (they check the
|
||||
// hour cycle / AM-PM marker, not exact clock values).
|
||||
const FIXED_DATE = new Date("2023-01-15T12:30:45Z");
|
||||
const MERIDIEM = /AM|PM/i;
|
||||
|
||||
// The Bun test runtime has no DOM. Install minimal localStorage/window shims
|
||||
// for the persistence and subscription tests, and remove them afterwards.
|
||||
const g = globalThis as any;
|
||||
const createdLocalStorage = typeof g.localStorage === "undefined";
|
||||
const createdWindow = typeof g.window === "undefined";
|
||||
|
||||
beforeAll(() => {
|
||||
if (createdLocalStorage) {
|
||||
const store = new Map<string, string>();
|
||||
g.localStorage = {
|
||||
getItem: (key: string) => (store.has(key) ? store.get(key)! : null),
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, String(value));
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
clear: () => {
|
||||
store.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
if (createdWindow) {
|
||||
const target = new EventTarget();
|
||||
g.window = target;
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (createdLocalStorage) delete g.localStorage;
|
||||
if (createdWindow) delete g.window;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
g.localStorage.removeItem(TIME_FORMAT_STORAGE_KEY);
|
||||
});
|
||||
|
||||
describe("isTimeFormatPreference", () => {
|
||||
test("accepts valid preferences", () => {
|
||||
expect(isTimeFormatPreference("auto")).toBe(true);
|
||||
expect(isTimeFormatPreference("12h")).toBe(true);
|
||||
expect(isTimeFormatPreference("24h")).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects invalid values", () => {
|
||||
expect(isTimeFormatPreference("24")).toBe(false);
|
||||
expect(isTimeFormatPreference("")).toBe(false);
|
||||
expect(isTimeFormatPreference(null)).toBe(false);
|
||||
expect(isTimeFormatPreference(undefined)).toBe(false);
|
||||
expect(isTimeFormatPreference(12)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveHour12", () => {
|
||||
test("maps preferences onto Intl's hour12 option", () => {
|
||||
expect(resolveHour12("12h")).toBe(true);
|
||||
expect(resolveHour12("24h")).toBe(false);
|
||||
expect(resolveHour12("auto")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("preference persistence", () => {
|
||||
test("defaults to 'auto' when nothing is stored", () => {
|
||||
expect(getTimeFormatPreference()).toBe("auto");
|
||||
});
|
||||
|
||||
test("round-trips a stored preference", () => {
|
||||
setTimeFormatPreference("24h");
|
||||
expect(getTimeFormatPreference()).toBe("24h");
|
||||
setTimeFormatPreference("12h");
|
||||
expect(getTimeFormatPreference()).toBe("12h");
|
||||
});
|
||||
|
||||
test("falls back to 'auto' for corrupted stored values", () => {
|
||||
g.localStorage.setItem(TIME_FORMAT_STORAGE_KEY, "bogus");
|
||||
expect(getTimeFormatPreference()).toBe("auto");
|
||||
});
|
||||
});
|
||||
|
||||
describe("subscribeToTimeFormatChange", () => {
|
||||
test("notifies on preference change and stops after unsubscribe", () => {
|
||||
let calls = 0;
|
||||
const unsubscribe = subscribeToTimeFormatChange(() => {
|
||||
calls++;
|
||||
});
|
||||
|
||||
setTimeFormatPreference("24h");
|
||||
expect(calls).toBe(1);
|
||||
|
||||
setTimeFormatPreference("12h");
|
||||
expect(calls).toBe(2);
|
||||
|
||||
unsubscribe();
|
||||
setTimeFormatPreference("auto");
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
test("notifies on cross-tab storage events for our key only", () => {
|
||||
let calls = 0;
|
||||
const unsubscribe = subscribeToTimeFormatChange(() => {
|
||||
calls++;
|
||||
});
|
||||
|
||||
g.window.dispatchEvent(
|
||||
Object.assign(new Event("storage"), { key: TIME_FORMAT_STORAGE_KEY })
|
||||
);
|
||||
expect(calls).toBe(1);
|
||||
|
||||
g.window.dispatchEvent(
|
||||
Object.assign(new Event("storage"), { key: "theme" })
|
||||
);
|
||||
expect(calls).toBe(1);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTime", () => {
|
||||
test("forces 24-hour time regardless of a 12-hour locale", () => {
|
||||
const formatted = formatTime(FIXED_DATE, {
|
||||
locale: "en-US",
|
||||
preference: "24h",
|
||||
});
|
||||
expect(formatted).not.toMatch(MERIDIEM);
|
||||
expect(formatted).toMatch(/^\d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
test("forces 12-hour time regardless of a 24-hour locale", () => {
|
||||
const formatted = formatTime(FIXED_DATE, {
|
||||
locale: "de-DE",
|
||||
preference: "12h",
|
||||
});
|
||||
expect(formatted).toMatch(MERIDIEM);
|
||||
});
|
||||
|
||||
test("'auto' follows the locale convention (12h for en-US)", () => {
|
||||
const formatted = formatTime(FIXED_DATE, {
|
||||
locale: "en-US",
|
||||
preference: "auto",
|
||||
});
|
||||
expect(formatted).toMatch(MERIDIEM);
|
||||
});
|
||||
|
||||
test("'auto' follows the locale convention (24h for de-DE)", () => {
|
||||
const formatted = formatTime(FIXED_DATE, {
|
||||
locale: "de-DE",
|
||||
preference: "auto",
|
||||
});
|
||||
expect(formatted).not.toMatch(MERIDIEM);
|
||||
});
|
||||
|
||||
test("uses the stored preference when none is passed", () => {
|
||||
setTimeFormatPreference("24h");
|
||||
const formatted = formatTime(FIXED_DATE, { locale: "en-US" });
|
||||
expect(formatted).not.toMatch(MERIDIEM);
|
||||
expect(formatted).toMatch(/^\d{2}:\d{2}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDateTime", () => {
|
||||
test("includes the full date and respects the 24h preference", () => {
|
||||
const formatted = formatDateTime(FIXED_DATE, {
|
||||
locale: "en-US",
|
||||
preference: "24h",
|
||||
});
|
||||
expect(formatted).toContain("January");
|
||||
expect(formatted).toContain("2023");
|
||||
expect(formatted).not.toMatch(MERIDIEM);
|
||||
expect(formatted).toMatch(/\d{2}:\d{2}/);
|
||||
});
|
||||
|
||||
test("includes the full date and respects the 12h preference", () => {
|
||||
const formatted = formatDateTime(FIXED_DATE, {
|
||||
locale: "en-US",
|
||||
preference: "12h",
|
||||
});
|
||||
expect(formatted).toContain("January");
|
||||
expect(formatted).toContain("2023");
|
||||
expect(formatted).toMatch(MERIDIEM);
|
||||
});
|
||||
|
||||
test("accepts ISO strings and epoch milliseconds", () => {
|
||||
const fromString = formatDateTime("2023-01-15T12:30:45Z", {
|
||||
locale: "en-US",
|
||||
preference: "24h",
|
||||
});
|
||||
const fromNumber = formatDateTime(FIXED_DATE.getTime(), {
|
||||
locale: "en-US",
|
||||
preference: "24h",
|
||||
});
|
||||
expect(fromString).toBe(fromNumber);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatShortDateTime", () => {
|
||||
test("renders a compact numeric date with time", () => {
|
||||
const formatted = formatShortDateTime(FIXED_DATE, {
|
||||
locale: "en-US",
|
||||
preference: "12h",
|
||||
});
|
||||
expect(formatted).toMatch(/\d{2}\/\d{2}\/\d{2}/);
|
||||
expect(formatted).toMatch(MERIDIEM);
|
||||
});
|
||||
|
||||
test("respects the 24h preference", () => {
|
||||
const formatted = formatShortDateTime(FIXED_DATE, {
|
||||
locale: "en-US",
|
||||
preference: "24h",
|
||||
});
|
||||
expect(formatted).not.toMatch(MERIDIEM);
|
||||
expect(formatted).toMatch(/\d{2}:\d{2}/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Shared time/date formatting with a user-configurable 12h/24h preference.
|
||||
*
|
||||
* All timestamp rendering in the UI should go through this module so that:
|
||||
* - By default ("auto") times follow the browser locale's convention instead
|
||||
* of a hardcoded locale (previously "en-US" forced 12-hour AM/PM for everyone).
|
||||
* - Users can explicitly force 12-hour or 24-hour time. The preference is a
|
||||
* pure display concern, so it is persisted client-side in localStorage
|
||||
* (same mechanism as the theme preference) rather than in the database.
|
||||
*/
|
||||
|
||||
export type TimeFormatPreference = "auto" | "12h" | "24h";
|
||||
|
||||
export const TIME_FORMAT_STORAGE_KEY = "timeFormat";
|
||||
export const TIME_FORMAT_CHANGE_EVENT = "gitea-mirror:time-format-change";
|
||||
|
||||
export function isTimeFormatPreference(
|
||||
value: unknown
|
||||
): value is TimeFormatPreference {
|
||||
return value === "auto" || value === "12h" || value === "24h";
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the persisted preference. Safe to call during SSR / in tests where
|
||||
* localStorage does not exist (falls back to "auto").
|
||||
*/
|
||||
export function getTimeFormatPreference(): TimeFormatPreference {
|
||||
if (typeof localStorage === "undefined") return "auto";
|
||||
try {
|
||||
const stored = localStorage.getItem(TIME_FORMAT_STORAGE_KEY);
|
||||
return isTimeFormatPreference(stored) ? stored : "auto";
|
||||
} catch {
|
||||
return "auto";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the preference and notify listeners in this tab. Other tabs are
|
||||
* notified via the native "storage" event.
|
||||
*/
|
||||
export function setTimeFormatPreference(preference: TimeFormatPreference): void {
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.setItem(TIME_FORMAT_STORAGE_KEY, preference);
|
||||
}
|
||||
} catch {
|
||||
// Persisting is best-effort (e.g. storage disabled); still notify listeners.
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(TIME_FORMAT_CHANGE_EVENT, { detail: preference })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to preference changes (same tab via custom event, other tabs via
|
||||
* the "storage" event). Returns an unsubscribe function. Compatible with
|
||||
* React's useSyncExternalStore.
|
||||
*/
|
||||
export function subscribeToTimeFormatChange(callback: () => void): () => void {
|
||||
if (typeof window === "undefined") return () => {};
|
||||
const onCustom = () => callback();
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key === TIME_FORMAT_STORAGE_KEY) callback();
|
||||
};
|
||||
window.addEventListener(TIME_FORMAT_CHANGE_EVENT, onCustom);
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => {
|
||||
window.removeEventListener(TIME_FORMAT_CHANGE_EVENT, onCustom);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
};
|
||||
}
|
||||
|
||||
/** Map a preference onto Intl's hour12 option ("auto" defers to the locale). */
|
||||
export function resolveHour12(
|
||||
preference: TimeFormatPreference
|
||||
): boolean | undefined {
|
||||
if (preference === "12h") return true;
|
||||
if (preference === "24h") return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface FormatDateTimeOptions {
|
||||
/** Override the stored preference (mainly for tests). */
|
||||
preference?: TimeFormatPreference;
|
||||
/** Override the browser/system locale (mainly for tests). */
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
function buildOptions(
|
||||
base: Intl.DateTimeFormatOptions,
|
||||
preference: TimeFormatPreference
|
||||
): Intl.DateTimeFormatOptions {
|
||||
const hour12 = resolveHour12(preference);
|
||||
return hour12 === undefined ? base : { ...base, hour12 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Full date + time, e.g. "January 15, 2023, 12:30 PM" / "15. Januar 2023, 12:30".
|
||||
* Locale defaults to the browser locale; hour cycle follows the user preference.
|
||||
*/
|
||||
export function formatDateTime(
|
||||
date: Date | string | number,
|
||||
options: FormatDateTimeOptions = {}
|
||||
): string {
|
||||
const preference = options.preference ?? getTimeFormatPreference();
|
||||
return new Intl.DateTimeFormat(
|
||||
options.locale,
|
||||
buildOptions(
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
},
|
||||
preference
|
||||
)
|
||||
).format(new Date(date));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact date + time, e.g. "01/15/23, 12:30 PM" / "15.01.23, 12:30".
|
||||
* Used where space is tight (dashboard status cards).
|
||||
*/
|
||||
export function formatShortDateTime(
|
||||
date: Date | string | number,
|
||||
options: FormatDateTimeOptions = {}
|
||||
): string {
|
||||
const preference = options.preference ?? getTimeFormatPreference();
|
||||
return new Intl.DateTimeFormat(
|
||||
options.locale,
|
||||
buildOptions(
|
||||
{
|
||||
year: "2-digit",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
},
|
||||
preference
|
||||
)
|
||||
).format(new Date(date));
|
||||
}
|
||||
|
||||
/**
|
||||
* Time only, e.g. "12:30 PM" / "12:30".
|
||||
*/
|
||||
export function formatTime(
|
||||
date: Date | string | number,
|
||||
options: FormatDateTimeOptions = {}
|
||||
): string {
|
||||
const preference = options.preference ?? getTimeFormatPreference();
|
||||
return new Intl.DateTimeFormat(
|
||||
options.locale,
|
||||
buildOptions({ hour: "2-digit", minute: "2-digit" }, preference)
|
||||
).format(new Date(date));
|
||||
}
|
||||
Reference in new issue
Block a user