feat(ui): add 12h/24h time format option with locale-aware default (#342) (#346)

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:
ARUNAVO RAY authored and GitHub committed 2026-07-16 20:57:48 +05:30
1 parent f922bcc618
commit bdbfa762af
12 files changed
+508 -21

No files matched your search

+4
View File
@@ -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>
) => {
+7 -9
View File
@@ -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">
+3
View File
@@ -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>
);
}
+31
View File
@@ -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 };
}
+6 -5
View File
@@ -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
View File
@@ -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 {
+231
View File
@@ -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}/);
});
});
+159
View File
@@ -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));
}