Add a Date & Time setting and fix inconsistent date formats app-wide
Different tables used different date formats: Servers & Tasks used a fixed "YYYY-MM-DD HH:mm:ss" (24h), while Audit Log, DNS, Integrations (Tailscale/Semaphore), and Users called plain .toLocaleString() with no options, which renders using the browser's own locale — different per browser/OS, and inconsistent with the other pages' fixed format. - New Settings -> Date & Time page: pick date order (YYYY-MM-DD, DD/MM/YYYY, MM/DD/YYYY) and 12h vs 24h clock, with a live preview. - utils/date.ts's formatDateTime() now reads these settings instead of being hardcoded to sv-SE/24h; the setting is fetched once at app startup (alongside /api/me) via a new non-secret GET /api/settings/display (any signed-in user, same rationale as the badge-color endpoints) and applied immediately on save too, without needing a page reload. - Switched every remaining raw new Date(...).toLocaleString() call (Audit Log, DNS zone sync time, Tailscale/Semaphore last-seen, Users' last login) over to the shared formatter, so every table now renders dates identically. Verified the formatter's date-order x 12h logic against all six combinations plus the midnight/noon 12h edge cases, and the settings endpoints end-to-end (defaults, partial updates, validation rejection, audit logging) against the real dev server. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f7357141eb
commit
f92f8de96e
12 files changed
+206
-17
No files matched your search
@@ -49,8 +49,10 @@ All modules from the original plan are built:
|
|||||||
- **Settings** (admin-only) — notification channels (Gotify, ntfy, SMTP,
|
- **Settings** (admin-only) — notification channels (Gotify, ntfy, SMTP,
|
||||||
generic webhook) with per-channel test buttons, per-event toggles (DNS
|
generic webhook) with per-channel test buttons, per-event toggles (DNS
|
||||||
record added/updated/deleted, daily secret-expiry reminder with a
|
record added/updated/deleted, daily secret-expiry reminder with a
|
||||||
configurable time/timezone), and badge-color customization for both DNS
|
configurable time/timezone), badge-color customization for both DNS
|
||||||
providers and integration types.
|
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
|
All six integrations follow the same config-in-UI + encrypted-credentials
|
||||||
pattern, added (and edited — e.g. to rotate an expired API token without
|
pattern, added (and edited — e.g. to rotate an expired API token without
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ settingsRouter.get("/integration-colors", asyncHandler(async (_req, res) => {
|
|||||||
res.json({ integrationColors });
|
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({
|
const updateSchema = z.object({
|
||||||
gotify: z.object({ enabled: z.boolean(), url: z.string(), token: z.string(), priority: z.number() }).partial().optional(),
|
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(),
|
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(),
|
.optional(),
|
||||||
providerColors: z.record(z.string()).optional(),
|
providerColors: z.record(z.string()).optional(),
|
||||||
integrationColors: 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) => {
|
settingsRouter.put("/", requireRole("admin"), asyncHandler(async (req, res) => {
|
||||||
|
|||||||
@@ -46,6 +46,14 @@ export interface NotificationEvents {
|
|||||||
export type ProviderColors = Record<string, string>;
|
export type ProviderColors = Record<string, string>;
|
||||||
export type IntegrationColors = Record<string, string>;
|
export type IntegrationColors = Record<string, string>;
|
||||||
|
|
||||||
|
export type DateFormat = "ymd" | "dmy" | "mdy";
|
||||||
|
export type TimeFormat = "24h" | "12h";
|
||||||
|
|
||||||
|
export interface DisplaySettings {
|
||||||
|
dateFormat: DateFormat;
|
||||||
|
timeFormat: TimeFormat;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
gotify: GotifySettings;
|
gotify: GotifySettings;
|
||||||
ntfy: NtfySettings;
|
ntfy: NtfySettings;
|
||||||
@@ -54,6 +62,7 @@ export interface AppSettings {
|
|||||||
notifications: NotificationEvents;
|
notifications: NotificationEvents;
|
||||||
providerColors: ProviderColors;
|
providerColors: ProviderColors;
|
||||||
integrationColors: IntegrationColors;
|
integrationColors: IntegrationColors;
|
||||||
|
display: DisplaySettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULTS: AppSettings = {
|
const DEFAULTS: AppSettings = {
|
||||||
@@ -71,6 +80,7 @@ const DEFAULTS: AppSettings = {
|
|||||||
},
|
},
|
||||||
providerColors: {},
|
providerColors: {},
|
||||||
integrationColors: {},
|
integrationColors: {},
|
||||||
|
display: { dateFormat: "ymd", timeFormat: "24h" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const KEYS = Object.keys(DEFAULTS) as (keyof AppSettings)[];
|
const KEYS = Object.keys(DEFAULTS) as (keyof AppSettings)[];
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ import Integrations from "./pages/Integrations";
|
|||||||
import Settings from "./pages/Settings";
|
import Settings from "./pages/Settings";
|
||||||
import NotificationSettings from "./pages/settings/NotificationSettings";
|
import NotificationSettings from "./pages/settings/NotificationSettings";
|
||||||
import BadgeSettings from "./pages/settings/BadgeSettings";
|
import BadgeSettings from "./pages/settings/BadgeSettings";
|
||||||
|
import DateTimeSettings from "./pages/settings/DateTimeSettings";
|
||||||
import CacheSettings from "./pages/settings/CacheSettings";
|
import CacheSettings from "./pages/settings/CacheSettings";
|
||||||
import AppShell from "./layout/AppShell";
|
import AppShell from "./layout/AppShell";
|
||||||
|
import { setDateTimeSettings } from "./utils/date";
|
||||||
|
|
||||||
const roleRank: Record<UserRole, number> = { viewer: 0, operator: 1, admin: 2 };
|
const roleRank: Record<UserRole, number> = { viewer: 0, operator: 1, admin: 2 };
|
||||||
|
|
||||||
@@ -49,6 +51,7 @@ export default function App() {
|
|||||||
setUser(null);
|
setUser(null);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
api.settings.display().then((res) => setDateTimeSettings(res.display)).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (user === undefined) {
|
if (user === undefined) {
|
||||||
@@ -96,6 +99,7 @@ export default function App() {
|
|||||||
<Route index element={<Navigate to="notifications" replace />} />
|
<Route index element={<Navigate to="notifications" replace />} />
|
||||||
<Route path="notifications" element={<NotificationSettings />} />
|
<Route path="notifications" element={<NotificationSettings />} />
|
||||||
<Route path="badges" element={<BadgeSettings />} />
|
<Route path="badges" element={<BadgeSettings />} />
|
||||||
|
<Route path="date-time" element={<DateTimeSettings />} />
|
||||||
<Route path="cache" element={<CacheSettings />} />
|
<Route path="cache" element={<CacheSettings />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -119,6 +119,14 @@ export interface NotificationEvents {
|
|||||||
timezone: string;
|
timezone: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DateFormat = "ymd" | "dmy" | "mdy";
|
||||||
|
export type TimeFormat = "24h" | "12h";
|
||||||
|
|
||||||
|
export interface DisplaySettings {
|
||||||
|
dateFormat: DateFormat;
|
||||||
|
timeFormat: TimeFormat;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
gotify: GotifySettings;
|
gotify: GotifySettings;
|
||||||
ntfy: NtfySettings;
|
ntfy: NtfySettings;
|
||||||
@@ -127,6 +135,7 @@ export interface AppSettings {
|
|||||||
notifications: NotificationEvents;
|
notifications: NotificationEvents;
|
||||||
providerColors: Record<string, string>;
|
providerColors: Record<string, string>;
|
||||||
integrationColors: Record<string, string>;
|
integrationColors: Record<string, string>;
|
||||||
|
display: DisplaySettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AppSettingsPatch = { [K in keyof AppSettings]?: Partial<AppSettings[K]> };
|
export type AppSettingsPatch = { [K in keyof AppSettings]?: Partial<AppSettings[K]> };
|
||||||
@@ -655,6 +664,7 @@ export const api = {
|
|||||||
get: () => request<{ settings: AppSettings }>("/api/settings"),
|
get: () => request<{ settings: AppSettings }>("/api/settings"),
|
||||||
providerColors: () => request<{ providerColors: Record<string, string> }>("/api/settings/provider-colors"),
|
providerColors: () => request<{ providerColors: Record<string, string> }>("/api/settings/provider-colors"),
|
||||||
integrationColors: () => request<{ integrationColors: Record<string, string> }>("/api/settings/integration-colors"),
|
integrationColors: () => request<{ integrationColors: Record<string, string> }>("/api/settings/integration-colors"),
|
||||||
|
display: () => request<{ display: DisplaySettings }>("/api/settings/display"),
|
||||||
update: (data: AppSettingsPatch) =>
|
update: (data: AppSettingsPatch) =>
|
||||||
request<{ settings: AppSettings }>("/api/settings", { method: "PUT", body: JSON.stringify(data) }),
|
request<{ settings: AppSettings }>("/api/settings", { method: "PUT", body: JSON.stringify(data) }),
|
||||||
testGotify: (data: { url: string; token: string; priority?: number }) =>
|
testGotify: (data: { url: string; token: string; priority?: number }) =>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api, type AuditLogEntry } from "../api/client";
|
import { api, type AuditLogEntry } from "../api/client";
|
||||||
|
import { formatDateTime } from "../utils/date";
|
||||||
|
|
||||||
function targetLabel(e: AuditLogEntry): string {
|
function targetLabel(e: AuditLogEntry): string {
|
||||||
if (!e.targetType) return "—";
|
if (!e.targetType) return "—";
|
||||||
@@ -47,7 +48,7 @@ export default function AuditLog() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{entries?.map((e) => (
|
{entries?.map((e) => (
|
||||||
<tr key={e.id}>
|
<tr key={e.id}>
|
||||||
<td>{new Date(e.createdAt).toLocaleString()}</td>
|
<td>{formatDateTime(new Date(e.createdAt))}</td>
|
||||||
<td>{e.actorLabel ?? "—"}</td>
|
<td>{e.actorLabel ?? "—"}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className="badge bg-blue-lt">{e.category}</span>
|
<span className="badge bg-blue-lt">{e.category}</span>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type DnsZone,
|
type DnsZone,
|
||||||
} from "../api/client";
|
} from "../api/client";
|
||||||
import DnsProviderForm from "../components/DnsProviderForm";
|
import DnsProviderForm from "../components/DnsProviderForm";
|
||||||
|
import { formatDateTime } from "../utils/date";
|
||||||
|
|
||||||
const PROVIDER_LABELS: Record<DnsProviderType, string> = {
|
const PROVIDER_LABELS: Record<DnsProviderType, string> = {
|
||||||
cloudflare: "Cloudflare",
|
cloudflare: "Cloudflare",
|
||||||
@@ -315,7 +316,7 @@ export default function Dns({ user }: { user: CurrentUser }) {
|
|||||||
<td>{z.name}</td>
|
<td>{z.name}</td>
|
||||||
<td>{z.recordCount}</td>
|
<td>{z.recordCount}</td>
|
||||||
<td className="text-secondary">
|
<td className="text-secondary">
|
||||||
{z.syncedAt ? new Date(z.syncedAt).toLocaleString() : "never"}
|
{z.syncedAt ? formatDateTime(new Date(z.syncedAt)) : "never"}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from "../api/client";
|
} from "../api/client";
|
||||||
import IntegrationForm from "../components/IntegrationForm";
|
import IntegrationForm from "../components/IntegrationForm";
|
||||||
import IntegrationEditForm from "../components/IntegrationEditForm";
|
import IntegrationEditForm from "../components/IntegrationEditForm";
|
||||||
|
import { formatDateTime } from "../utils/date";
|
||||||
|
|
||||||
function proxmoxStatusBadge(status: string) {
|
function proxmoxStatusBadge(status: string) {
|
||||||
return status === "running" ? (
|
return status === "running" ? (
|
||||||
@@ -547,7 +548,7 @@ export default function Integrations({ user }: { user: CurrentUser }) {
|
|||||||
)}
|
)}
|
||||||
{!d.authorized && <span className="badge bg-yellow-lt text-yellow">Unauthorized</span>}
|
{!d.authorized && <span className="badge bg-yellow-lt text-yellow">Unauthorized</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-secondary">{d.lastSeen ? new Date(d.lastSeen).toLocaleString() : "—"}</td>
|
<td className="text-secondary">{d.lastSeen ? formatDateTime(new Date(d.lastSeen)) : "—"}</td>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<td>
|
<td>
|
||||||
<div className="btn-list flex-nowrap">
|
<div className="btn-list flex-nowrap">
|
||||||
@@ -775,7 +776,7 @@ export default function Integrations({ user }: { user: CurrentUser }) {
|
|||||||
<td className="text-secondary">{t.projectName}</td>
|
<td className="text-secondary">{t.projectName}</td>
|
||||||
<td className="text-secondary">
|
<td className="text-secondary">
|
||||||
{t.lastTask
|
{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 ?? ""))
|
||||||
: "—"}
|
: "—"}
|
||||||
</td>
|
</td>
|
||||||
<td>{semaphoreStatusBadge(t)}</td>
|
<td>{semaphoreStatusBadge(t)}</td>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { NavLink, Outlet } from "react-router-dom";
|
|||||||
const SUB_NAV = [
|
const SUB_NAV = [
|
||||||
{ to: "/settings/notifications", label: "Notifications" },
|
{ to: "/settings/notifications", label: "Notifications" },
|
||||||
{ to: "/settings/badges", label: "Badges" },
|
{ to: "/settings/badges", label: "Badges" },
|
||||||
|
{ to: "/settings/date-time", label: "Date & Time" },
|
||||||
{ to: "/settings/cache", label: "Cache" },
|
{ to: "/settings/cache", label: "Cache" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api, type UserRecord, type UserRole } from "../api/client";
|
import { api, type UserRecord, type UserRole } from "../api/client";
|
||||||
|
import { formatDateTime } from "../utils/date";
|
||||||
|
|
||||||
const ROLES: UserRole[] = ["viewer", "operator", "admin"];
|
const ROLES: UserRole[] = ["viewer", "operator", "admin"];
|
||||||
|
|
||||||
@@ -64,7 +65,7 @@ export default function Users() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td>{u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString() : "never"}</td>
|
<td>{u.lastLoginAt ? formatDateTime(new Date(u.lastLoginAt)) : "never"}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{users?.length === 0 && (
|
{users?.length === 0 && (
|
||||||
|
|||||||
@@ -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<string | null>(null);
|
||||||
|
const [dateFormat, setDateFormatState] = useState<DateFormat>("ymd");
|
||||||
|
const [timeFormat, setTimeFormatState] = useState<TimeFormat>("24h");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(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 <div className="text-secondary">Loading…</div>;
|
||||||
|
|
||||||
|
const dateExample = DATE_FORMAT_OPTIONS.find((o) => o.value === dateFormat)?.example ?? "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="d-flex align-items-center justify-content-between mb-3">
|
||||||
|
<h3 className="mb-0">Date & Time</h3>
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
{saveError && <span className="text-danger small">{saveError}</span>}
|
||||||
|
{saved && <span className="text-success small">✓ Settings saved</span>}
|
||||||
|
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? "Saving…" : "Save Settings"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadError && <div className="alert alert-danger">{loadError}</div>}
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">Display format</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<p className="text-secondary">
|
||||||
|
Controls how dates and times are shown throughout the app — tables previously mixed formats depending on
|
||||||
|
the page; this makes them consistent everywhere.
|
||||||
|
</p>
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<label className="form-label">Date format</label>
|
||||||
|
<select className="form-select" value={dateFormat} onChange={(e) => setDateFormatState(e.target.value as DateFormat)}>
|
||||||
|
{DATE_FORMAT_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label} ({opt.example})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<label className="form-label">Clock</label>
|
||||||
|
<select className="form-select" value={timeFormat} onChange={(e) => setTimeFormatState(e.target.value as TimeFormat)}>
|
||||||
|
<option value="24h">24-hour (14:30:00)</option>
|
||||||
|
<option value="12h">12-hour (2:30:00 PM)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="text-secondary small">Preview</div>
|
||||||
|
<div>
|
||||||
|
<code>
|
||||||
|
{dateExample} {previewTime(timeFormat)}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+50
-10
@@ -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 {
|
export function formatDateTime(date: Date): string {
|
||||||
return date.toLocaleString("sv-SE", {
|
const year = date.getFullYear();
|
||||||
year: "numeric",
|
const month = pad(date.getMonth() + 1);
|
||||||
month: "2-digit",
|
const day = pad(date.getDate());
|
||||||
day: "2-digit",
|
|
||||||
hour: "2-digit",
|
let datePart: string;
|
||||||
minute: "2-digit",
|
switch (current.dateFormat) {
|
||||||
second: "2-digit",
|
case "dmy":
|
||||||
hour12: false,
|
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}`;
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user