diff --git a/README.md b/README.md index 3c10e3c..961bd4c 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,10 @@ All modules from the original plan are built: - **Settings** (admin-only) — notification channels (Gotify, ntfy, SMTP, generic webhook) with per-channel test buttons, per-event toggles (DNS record added/updated/deleted, daily secret-expiry reminder with a - configurable time/timezone), and badge-color customization for both DNS - providers and integration types. + configurable time/timezone), badge-color customization for both DNS + providers and integration types, and a date/time display format + (date order, 12/24-hour clock) applied consistently to every table in + the app. All six integrations follow the same config-in-UI + encrypted-credentials pattern, added (and edited — e.g. to rotate an expired API token without diff --git a/server/src/routes/settings.ts b/server/src/routes/settings.ts index 211aa42..282798a 100644 --- a/server/src/routes/settings.ts +++ b/server/src/routes/settings.ts @@ -27,6 +27,13 @@ settingsRouter.get("/integration-colors", asyncHandler(async (_req, res) => { res.json({ integrationColors }); })); +// Display prefs (date/time format) affect every page, so any signed-in user +// can read them — same non-secret rationale as the badge-color endpoints. +settingsRouter.get("/display", asyncHandler(async (_req, res) => { + const { display } = await getSettings(); + res.json({ display }); +})); + const updateSchema = z.object({ gotify: z.object({ enabled: z.boolean(), url: z.string(), token: z.string(), priority: z.number() }).partial().optional(), ntfy: z.object({ enabled: z.boolean(), url: z.string(), topic: z.string(), token: z.string(), priority: z.number() }).partial().optional(), @@ -57,6 +64,7 @@ const updateSchema = z.object({ .optional(), providerColors: z.record(z.string()).optional(), integrationColors: z.record(z.string()).optional(), + display: z.object({ dateFormat: z.enum(["ymd", "dmy", "mdy"]), timeFormat: z.enum(["24h", "12h"]) }).partial().optional(), }); settingsRouter.put("/", requireRole("admin"), asyncHandler(async (req, res) => { diff --git a/server/src/services/settingsStore.ts b/server/src/services/settingsStore.ts index 8e16ce6..662fcc1 100644 --- a/server/src/services/settingsStore.ts +++ b/server/src/services/settingsStore.ts @@ -46,6 +46,14 @@ export interface NotificationEvents { export type ProviderColors = Record; export type IntegrationColors = Record; +export type DateFormat = "ymd" | "dmy" | "mdy"; +export type TimeFormat = "24h" | "12h"; + +export interface DisplaySettings { + dateFormat: DateFormat; + timeFormat: TimeFormat; +} + export interface AppSettings { gotify: GotifySettings; ntfy: NtfySettings; @@ -54,6 +62,7 @@ export interface AppSettings { notifications: NotificationEvents; providerColors: ProviderColors; integrationColors: IntegrationColors; + display: DisplaySettings; } const DEFAULTS: AppSettings = { @@ -71,6 +80,7 @@ const DEFAULTS: AppSettings = { }, providerColors: {}, integrationColors: {}, + display: { dateFormat: "ymd", timeFormat: "24h" }, }; const KEYS = Object.keys(DEFAULTS) as (keyof AppSettings)[]; diff --git a/web/src/App.tsx b/web/src/App.tsx index c76482d..c2009c9 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -14,8 +14,10 @@ import Integrations from "./pages/Integrations"; import Settings from "./pages/Settings"; import NotificationSettings from "./pages/settings/NotificationSettings"; import BadgeSettings from "./pages/settings/BadgeSettings"; +import DateTimeSettings from "./pages/settings/DateTimeSettings"; import CacheSettings from "./pages/settings/CacheSettings"; import AppShell from "./layout/AppShell"; +import { setDateTimeSettings } from "./utils/date"; const roleRank: Record = { viewer: 0, operator: 1, admin: 2 }; @@ -49,6 +51,7 @@ export default function App() { setUser(null); }, ); + api.settings.display().then((res) => setDateTimeSettings(res.display)).catch(() => {}); }, []); if (user === undefined) { @@ -96,6 +99,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 611f1d5..0545dbc 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -119,6 +119,14 @@ export interface NotificationEvents { timezone: string; } +export type DateFormat = "ymd" | "dmy" | "mdy"; +export type TimeFormat = "24h" | "12h"; + +export interface DisplaySettings { + dateFormat: DateFormat; + timeFormat: TimeFormat; +} + export interface AppSettings { gotify: GotifySettings; ntfy: NtfySettings; @@ -127,6 +135,7 @@ export interface AppSettings { notifications: NotificationEvents; providerColors: Record; integrationColors: Record; + display: DisplaySettings; } export type AppSettingsPatch = { [K in keyof AppSettings]?: Partial }; @@ -655,6 +664,7 @@ export const api = { get: () => request<{ settings: AppSettings }>("/api/settings"), providerColors: () => request<{ providerColors: Record }>("/api/settings/provider-colors"), integrationColors: () => request<{ integrationColors: Record }>("/api/settings/integration-colors"), + display: () => request<{ display: DisplaySettings }>("/api/settings/display"), update: (data: AppSettingsPatch) => request<{ settings: AppSettings }>("/api/settings", { method: "PUT", body: JSON.stringify(data) }), testGotify: (data: { url: string; token: string; priority?: number }) => diff --git a/web/src/pages/AuditLog.tsx b/web/src/pages/AuditLog.tsx index 9176350..cb4d015 100644 --- a/web/src/pages/AuditLog.tsx +++ b/web/src/pages/AuditLog.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { api, type AuditLogEntry } from "../api/client"; +import { formatDateTime } from "../utils/date"; function targetLabel(e: AuditLogEntry): string { if (!e.targetType) return "—"; @@ -47,7 +48,7 @@ export default function AuditLog() { {entries?.map((e) => ( - {new Date(e.createdAt).toLocaleString()} + {formatDateTime(new Date(e.createdAt))} {e.actorLabel ?? "—"} {e.category} diff --git a/web/src/pages/Dns.tsx b/web/src/pages/Dns.tsx index 22f1379..c07fd35 100644 --- a/web/src/pages/Dns.tsx +++ b/web/src/pages/Dns.tsx @@ -9,6 +9,7 @@ import { type DnsZone, } from "../api/client"; import DnsProviderForm from "../components/DnsProviderForm"; +import { formatDateTime } from "../utils/date"; const PROVIDER_LABELS: Record = { cloudflare: "Cloudflare", @@ -315,7 +316,7 @@ export default function Dns({ user }: { user: CurrentUser }) { {z.name} {z.recordCount} - {z.syncedAt ? new Date(z.syncedAt).toLocaleString() : "never"} + {z.syncedAt ? formatDateTime(new Date(z.syncedAt)) : "never"} ))} diff --git a/web/src/pages/Integrations.tsx b/web/src/pages/Integrations.tsx index 6db0cb6..48e0e6b 100644 --- a/web/src/pages/Integrations.tsx +++ b/web/src/pages/Integrations.tsx @@ -17,6 +17,7 @@ import { } from "../api/client"; import IntegrationForm from "../components/IntegrationForm"; import IntegrationEditForm from "../components/IntegrationEditForm"; +import { formatDateTime } from "../utils/date"; function proxmoxStatusBadge(status: string) { return status === "running" ? ( @@ -547,7 +548,7 @@ export default function Integrations({ user }: { user: CurrentUser }) { )} {!d.authorized && Unauthorized} - {d.lastSeen ? new Date(d.lastSeen).toLocaleString() : "—"} + {d.lastSeen ? formatDateTime(new Date(d.lastSeen)) : "—"} {canEdit && (
@@ -775,7 +776,7 @@ export default function Integrations({ user }: { user: CurrentUser }) { {t.projectName} {t.lastTask - ? new Date(t.lastTask.end ?? t.lastTask.start ?? t.lastTask.created ?? "").toLocaleString() + ? formatDateTime(new Date(t.lastTask.end ?? t.lastTask.start ?? t.lastTask.created ?? "")) : "—"} {semaphoreStatusBadge(t)} diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 4af936b..cdb2ee8 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -3,6 +3,7 @@ import { NavLink, Outlet } from "react-router-dom"; const SUB_NAV = [ { to: "/settings/notifications", label: "Notifications" }, { to: "/settings/badges", label: "Badges" }, + { to: "/settings/date-time", label: "Date & Time" }, { to: "/settings/cache", label: "Cache" }, ]; diff --git a/web/src/pages/Users.tsx b/web/src/pages/Users.tsx index d06bd43..2eca8f7 100644 --- a/web/src/pages/Users.tsx +++ b/web/src/pages/Users.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { api, type UserRecord, type UserRole } from "../api/client"; +import { formatDateTime } from "../utils/date"; const ROLES: UserRole[] = ["viewer", "operator", "admin"]; @@ -64,7 +65,7 @@ export default function Users() { ))} - {u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString() : "never"} + {u.lastLoginAt ? formatDateTime(new Date(u.lastLoginAt)) : "never"} ))} {users?.length === 0 && ( diff --git a/web/src/pages/settings/DateTimeSettings.tsx b/web/src/pages/settings/DateTimeSettings.tsx new file mode 100644 index 0000000..24b3bc9 --- /dev/null +++ b/web/src/pages/settings/DateTimeSettings.tsx @@ -0,0 +1,110 @@ +import { useEffect, useState } from "react"; +import { api, type AppSettings, type DateFormat, type TimeFormat } from "../../api/client"; +import { setDateTimeSettings } from "../../utils/date"; + +const DATE_FORMAT_OPTIONS: { value: DateFormat; label: string; example: string }[] = [ + { value: "ymd", label: "Year-Month-Day", example: "2026-09-15" }, + { value: "dmy", label: "Day/Month/Year", example: "15/09/2026" }, + { value: "mdy", label: "Month/Day/Year", example: "09/15/2026" }, +]; + +function previewTime(timeFormat: TimeFormat): string { + return timeFormat === "12h" ? "2:30:00 PM" : "14:30:00"; +} + +export default function DateTimeSettings() { + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [dateFormat, setDateFormatState] = useState("ymd"); + const [timeFormat, setTimeFormatState] = useState("24h"); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [saveError, setSaveError] = useState(null); + + useEffect(() => { + api.settings + .get() + .then((res: { settings: AppSettings }) => { + setDateFormatState(res.settings.display.dateFormat); + setTimeFormatState(res.settings.display.timeFormat); + }) + .catch((err) => setLoadError(err instanceof Error ? err.message : String(err))) + .finally(() => setLoading(false)); + }, []); + + async function handleSave() { + setSaving(true); + setSaveError(null); + setSaved(false); + try { + const res = await api.settings.update({ display: { dateFormat, timeFormat } }); + setDateTimeSettings(res.settings.display); + setSaved(true); + setTimeout(() => setSaved(false), 3000); + } catch (err) { + setSaveError(err instanceof Error ? err.message : String(err)); + } finally { + setSaving(false); + } + } + + if (loading) return
Loading…
; + + const dateExample = DATE_FORMAT_OPTIONS.find((o) => o.value === dateFormat)?.example ?? ""; + + return ( + <> +
+

Date & Time

+
+ {saveError && {saveError}} + {saved && ✓ Settings saved} + +
+
+ + {loadError &&
{loadError}
} + +
+
+

Display format

+
+
+

+ Controls how dates and times are shown throughout the app — tables previously mixed formats depending on + the page; this makes them consistent everywhere. +

+
+
+ + +
+
+ + +
+
+
+
Preview
+
+ + {dateExample} {previewTime(timeFormat)} + +
+
+
+
+ + ); +} diff --git a/web/src/utils/date.ts b/web/src/utils/date.ts index e6ba9e7..f5c7b6b 100644 --- a/web/src/utils/date.ts +++ b/web/src/utils/date.ts @@ -1,12 +1,52 @@ -/** Formats a date as "YYYY-MM-DD HH:mm:ss" on a 24-hour clock. */ +import type { DateFormat, TimeFormat } from "../api/client"; + +export interface DateTimeSettings { + dateFormat: DateFormat; + timeFormat: TimeFormat; +} + +let current: DateTimeSettings = { dateFormat: "ymd", timeFormat: "24h" }; + +/** Called once at app startup (and whenever the setting is changed) so every formatDateTime() call site picks it up without prop-drilling. */ +export function setDateTimeSettings(settings: DateTimeSettings): void { + current = settings; +} + +function pad(n: number): string { + return String(n).padStart(2, "0"); +} + +/** Formats a date using the app-wide date/time display settings (defaults to "YYYY-MM-DD HH:mm:ss" on a 24-hour clock). */ export function formatDateTime(date: Date): string { - return date.toLocaleString("sv-SE", { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, - }); + const year = date.getFullYear(); + const month = pad(date.getMonth() + 1); + const day = pad(date.getDate()); + + let datePart: string; + switch (current.dateFormat) { + case "dmy": + datePart = `${day}/${month}/${year}`; + break; + case "mdy": + datePart = `${month}/${day}/${year}`; + break; + case "ymd": + default: + datePart = `${year}-${month}-${day}`; + break; + } + + const minutes = pad(date.getMinutes()); + const seconds = pad(date.getSeconds()); + let timePart: string; + if (current.timeFormat === "12h") { + const hour24 = date.getHours(); + const period = hour24 >= 12 ? "PM" : "AM"; + const hour12 = hour24 % 12 || 12; + timePart = `${hour12}:${minutes}:${seconds} ${period}`; + } else { + timePart = `${pad(date.getHours())}:${minutes}:${seconds}`; + } + + return `${datePart} ${timePart}`; }