mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-15 07:57:54 +00:00
Date / time format
This commit is contained in:
@@ -448,6 +448,12 @@ class AccountApi {
|
||||
if (account.language) {
|
||||
await i18n.changeLanguage(account.language);
|
||||
}
|
||||
if (account.date_format) {
|
||||
await prefs.setDateFormat(account.date_format);
|
||||
}
|
||||
if (account.time_format) {
|
||||
await prefs.setTimeFormat(account.time_format);
|
||||
}
|
||||
if (account.notification) {
|
||||
if (account.notification.sound) {
|
||||
await prefs.setSound(account.notification.sound);
|
||||
|
||||
@@ -13,7 +13,7 @@ vi.mock("./Session", () => ({
|
||||
}));
|
||||
vi.mock("./SubscriptionManager", () => ({ default: { syncFromRemote: vi.fn() } }));
|
||||
vi.mock("./Prefs", () => ({
|
||||
default: { setSound: vi.fn(), setDeleteAfter: vi.fn(), setMinPriority: vi.fn() },
|
||||
default: { setSound: vi.fn(), setDeleteAfter: vi.fn(), setMinPriority: vi.fn(), setDateFormat: vi.fn(), setTimeFormat: vi.fn() },
|
||||
THEME: { DARK: "dark", LIGHT: "light", SYSTEM: "system" },
|
||||
}));
|
||||
vi.mock("i18next", () => ({ default: { changeLanguage: vi.fn() } }));
|
||||
@@ -190,6 +190,8 @@ describe("AccountApi.sync", () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
ok({
|
||||
language: "de",
|
||||
date_format: "iso8601",
|
||||
time_format: "24h",
|
||||
notification: { sound: "ding", delete_after: 3600, min_priority: 3 },
|
||||
subscriptions: [{ topic: "t" }],
|
||||
reservations: [{ topic: "t" }],
|
||||
@@ -199,6 +201,8 @@ describe("AccountApi.sync", () => {
|
||||
await accountApi.sync();
|
||||
|
||||
expect(i18n.changeLanguage).toHaveBeenCalledWith("de");
|
||||
expect(prefs.setDateFormat).toHaveBeenCalledWith("iso8601");
|
||||
expect(prefs.setTimeFormat).toHaveBeenCalledWith("24h");
|
||||
expect(prefs.setSound).toHaveBeenCalledWith("ding");
|
||||
expect(prefs.setDeleteAfter).toHaveBeenCalledWith(3600);
|
||||
expect(prefs.setMinPriority).toHaveBeenCalledWith(3);
|
||||
|
||||
+46
-1
@@ -6,16 +6,43 @@ export const THEME = {
|
||||
SYSTEM: "system",
|
||||
};
|
||||
|
||||
// How the date component is displayed. SYSTEM follows the browser's locale (i.e. the OS region/
|
||||
// format settings, not the chosen UI translation language); ISO8601 forces YYYY-MM-DD; DMY and MDY
|
||||
// force the day/month order regardless of locale (e.g. for an English UI that defaults to US order).
|
||||
export const DATE_FORMAT = {
|
||||
SYSTEM: "system",
|
||||
ISO8601: "iso8601",
|
||||
DMY: "dmy",
|
||||
DMY_DOT: "dmy_dot",
|
||||
MDY: "mdy",
|
||||
};
|
||||
|
||||
// How the time component (clock) is displayed. Orthogonal to DATE_FORMAT and to the UI language --
|
||||
// SYSTEM lets the browser locale decide, H12/H24 force a 12- or 24-hour clock. ISO 8601 dates are
|
||||
// always 24-hour, so this setting is ignored in that mode.
|
||||
export const TIME_FORMAT = {
|
||||
SYSTEM: "system",
|
||||
H12: "12h",
|
||||
H24: "24h",
|
||||
};
|
||||
|
||||
// Default values the getters return when a pref is unset; also used by PrefCache (PrefCache.jsx).
|
||||
export const PREF_DEFAULTS = {
|
||||
sound: "ding",
|
||||
minPriority: 1,
|
||||
deleteAfter: 604800, // one week
|
||||
theme: THEME.SYSTEM,
|
||||
dateFormat: DATE_FORMAT.SYSTEM,
|
||||
timeFormat: TIME_FORMAT.SYSTEM,
|
||||
webPushEnabled: false,
|
||||
};
|
||||
|
||||
class Prefs {
|
||||
// Guards against values the current build doesn't know about (e.g. a pref synced from a newer
|
||||
// client, or a renamed enum). An unknown value would leave the Settings <Select> with no matching
|
||||
// option, so we fall back to the default instead.
|
||||
const isKnownValue = (constants, value) => Object.values(constants).includes(value);
|
||||
|
||||
export class Prefs {
|
||||
constructor(dbImpl) {
|
||||
this.db = dbImpl;
|
||||
}
|
||||
@@ -64,6 +91,24 @@ class Prefs {
|
||||
async setTheme(mode) {
|
||||
await this.db.prefs.put({ key: "theme", value: mode });
|
||||
}
|
||||
|
||||
async dateFormat() {
|
||||
const dateFormat = await this.db.prefs.get("dateFormat");
|
||||
return isKnownValue(DATE_FORMAT, dateFormat?.value) ? dateFormat.value : PREF_DEFAULTS.dateFormat;
|
||||
}
|
||||
|
||||
async setDateFormat(format) {
|
||||
await this.db.prefs.put({ key: "dateFormat", value: format });
|
||||
}
|
||||
|
||||
async timeFormat() {
|
||||
const timeFormat = await this.db.prefs.get("timeFormat");
|
||||
return isKnownValue(TIME_FORMAT, timeFormat?.value) ? timeFormat.value : PREF_DEFAULTS.timeFormat;
|
||||
}
|
||||
|
||||
async setTimeFormat(format) {
|
||||
await this.db.prefs.put({ key: "timeFormat", value: format });
|
||||
}
|
||||
}
|
||||
|
||||
const prefs = new Prefs(db());
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Prefs, DATE_FORMAT, TIME_FORMAT, PREF_DEFAULTS } from "./Prefs";
|
||||
|
||||
// The module instantiates `new Prefs(db())` at import; stub db() so that doesn't touch IndexedDB.
|
||||
vi.mock("./db", () => ({ default: () => ({}) }));
|
||||
|
||||
// Minimal fake of the Dexie `prefs` table: get() returns a stored {key, value} or undefined.
|
||||
const fakeDb = (stored) => ({
|
||||
prefs: {
|
||||
get: async (key) => (key in stored ? { key, value: stored[key] } : undefined),
|
||||
put: async () => {},
|
||||
},
|
||||
});
|
||||
|
||||
describe("Prefs date/time format validation", () => {
|
||||
it("returns the stored value when it is a known format", async () => {
|
||||
const prefs = new Prefs(fakeDb({ dateFormat: DATE_FORMAT.ISO8601, timeFormat: TIME_FORMAT.H24 }));
|
||||
expect(await prefs.dateFormat()).toBe(DATE_FORMAT.ISO8601);
|
||||
expect(await prefs.timeFormat()).toBe(TIME_FORMAT.H24);
|
||||
});
|
||||
|
||||
it("falls back to the default for an unknown stored value (e.g. synced from a newer client)", async () => {
|
||||
const prefs = new Prefs(fakeDb({ dateFormat: "newer-client-format", timeFormat: "36h" }));
|
||||
expect(await prefs.dateFormat()).toBe(PREF_DEFAULTS.dateFormat);
|
||||
expect(await prefs.timeFormat()).toBe(PREF_DEFAULTS.timeFormat);
|
||||
});
|
||||
|
||||
it("falls back to the default when unset", async () => {
|
||||
const prefs = new Prefs(fakeDb({}));
|
||||
expect(await prefs.dateFormat()).toBe(PREF_DEFAULTS.dateFormat);
|
||||
expect(await prefs.timeFormat()).toBe(PREF_DEFAULTS.timeFormat);
|
||||
});
|
||||
});
|
||||
+59
-6
@@ -7,7 +7,7 @@ import pop from "../sounds/pop.mp3";
|
||||
import popSwoosh from "../sounds/pop-swoosh.mp3";
|
||||
import config from "./config";
|
||||
import emojisMapped from "./emojisMapped";
|
||||
import { THEME } from "./Prefs";
|
||||
import { THEME, DATE_FORMAT, TIME_FORMAT } from "./Prefs";
|
||||
|
||||
export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`;
|
||||
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
|
||||
@@ -151,14 +151,67 @@ export const hashCode = (s) => {
|
||||
*/
|
||||
export const getKebabCaseLangStr = (language) => (typeof language === "string" && language.length > 0 ? language.replace(/_/g, "-") : "en");
|
||||
|
||||
export const formatShortDateTime = (timestamp, language) =>
|
||||
new Intl.DateTimeFormat(getKebabCaseLangStr(language), {
|
||||
dateStyle: "short",
|
||||
// Date/time display is intentionally NOT tied to the UI translation language; it follows the
|
||||
// dedicated dateFormat/timeFormat prefs (see Prefs.js). SYSTEM modes pass no locale to Intl so it
|
||||
// uses the browser's default (the user's OS region/format settings). DMY/MDY force the day/month
|
||||
// order; ISO8601 forces an unambiguous, locale-independent and always-24-hour format. The clock
|
||||
// (12h/24h) is chosen independently via timeFormat. All variants render local time.
|
||||
const pad2 = (n) => String(n).padStart(2, "0");
|
||||
|
||||
// Per-format date builders (only ISO zero-pads; the rest drop leading zeros). The router below
|
||||
// (formatDate) picks one of these for a fixed-order format, or falls back to the browser locale.
|
||||
const formatDateIso = (date) => `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
||||
const formatDateDmy = (date) => `${date.getDate()}/${date.getMonth() + 1}/${date.getFullYear()}`;
|
||||
const formatDateDmyDot = (date) => `${date.getDate()}.${date.getMonth() + 1}.${date.getFullYear()}`;
|
||||
const formatDateMdy = (date) => `${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}`;
|
||||
|
||||
// ISO date-time is always YYYY-MM-DD HH:mm (24-hour), independent of the timeFormat pref.
|
||||
const formatDateTimeIso = (date) => `${formatDateIso(date)} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
|
||||
|
||||
// undefined lets the locale decide the clock; true/false force a 12- or 24-hour clock. Passing
|
||||
// hour12: undefined to Intl is equivalent to omitting it (and avoids an object spread that would
|
||||
// otherwise widen the sibling dateStyle/timeStyle string literals).
|
||||
const hour12For = (timeFormat) => {
|
||||
if (timeFormat === TIME_FORMAT.H12) return true;
|
||||
if (timeFormat === TIME_FORMAT.H24) return false;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// The date router: delegates to a fixed-order builder, or falls back to the browser locale (SYSTEM).
|
||||
export const formatDate = (timestamp, dateFormat = DATE_FORMAT.SYSTEM) => {
|
||||
const date = new Date(timestamp * 1000);
|
||||
switch (dateFormat) {
|
||||
case DATE_FORMAT.ISO8601:
|
||||
return formatDateIso(date);
|
||||
case DATE_FORMAT.DMY:
|
||||
return formatDateDmy(date);
|
||||
case DATE_FORMAT.DMY_DOT:
|
||||
return formatDateDmyDot(date);
|
||||
case DATE_FORMAT.MDY:
|
||||
return formatDateMdy(date);
|
||||
default:
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "short" }).format(date);
|
||||
}
|
||||
};
|
||||
|
||||
export const formatTime = (timestamp, timeFormat = TIME_FORMAT.SYSTEM) =>
|
||||
new Intl.DateTimeFormat(undefined, {
|
||||
timeStyle: "short",
|
||||
hour12: hour12For(timeFormat),
|
||||
}).format(new Date(timestamp * 1000));
|
||||
|
||||
export const formatShortDate = (timestamp, language) =>
|
||||
new Intl.DateTimeFormat(getKebabCaseLangStr(language), { dateStyle: "short" }).format(new Date(timestamp * 1000));
|
||||
export const formatDateTime = (timestamp, dateFormat = DATE_FORMAT.SYSTEM, timeFormat = TIME_FORMAT.SYSTEM) => {
|
||||
if (dateFormat === DATE_FORMAT.ISO8601) {
|
||||
return formatDateTimeIso(new Date(timestamp * 1000));
|
||||
} else if ([DATE_FORMAT.DMY, DATE_FORMAT.DMY_DOT, DATE_FORMAT.MDY].includes(dateFormat)) {
|
||||
return `${formatDate(timestamp, dateFormat)} ${formatTime(timestamp, timeFormat)}`;
|
||||
}
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
hour12: hour12For(timeFormat),
|
||||
}).format(new Date(timestamp * 1000));
|
||||
};
|
||||
|
||||
export const formatShortDuration = (ms, language) => {
|
||||
const seconds = Math.round(ms / 1000);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { THEME } from "./Prefs";
|
||||
import { THEME, DATE_FORMAT, TIME_FORMAT } from "./Prefs";
|
||||
import {
|
||||
topicUrl,
|
||||
topicUrlWs,
|
||||
@@ -24,6 +24,9 @@ import {
|
||||
formatNumber,
|
||||
formatPrice,
|
||||
formatShortDuration,
|
||||
formatDate,
|
||||
formatDateTime,
|
||||
formatTime,
|
||||
getKebabCaseLangStr,
|
||||
darkModeEnabled,
|
||||
urlB64ToUint8Array,
|
||||
@@ -151,3 +154,65 @@ describe("misc pure helpers", () => {
|
||||
expect(Array.from(urlB64ToUint8Array("AQID"))).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("date/time formatting", () => {
|
||||
// 2026-03-08 14:30 in the runner's local timezone (the same wall-clock time Intl renders).
|
||||
const ts = Math.floor(new Date(2026, 2, 8, 14, 30, 0).getTime() / 1000);
|
||||
|
||||
it("ISO 8601 mode renders YYYY-MM-DD HH:mm regardless of locale", () => {
|
||||
expect(formatDateTime(ts, DATE_FORMAT.ISO8601)).toBe("2026-03-08 14:30");
|
||||
expect(formatDate(ts, DATE_FORMAT.ISO8601)).toBe("2026-03-08");
|
||||
});
|
||||
|
||||
it("ISO 8601 mode zero-pads single-digit months, days, hours and minutes", () => {
|
||||
const early = Math.floor(new Date(2026, 0, 5, 9, 7, 0).getTime() / 1000);
|
||||
expect(formatDateTime(early, DATE_FORMAT.ISO8601)).toBe("2026-01-05 09:07");
|
||||
});
|
||||
|
||||
it("default mode follows the browser locale, not the UI translation language", () => {
|
||||
// The format must depend only on the browser's default locale -- passing a language
|
||||
// tag (the old behavior) must NOT change the output anymore.
|
||||
const browserDefault = new Intl.DateTimeFormat(undefined, { dateStyle: "short", timeStyle: "short" }).format(new Date(ts * 1000));
|
||||
expect(formatDateTime(ts, DATE_FORMAT.SYSTEM)).toBe(browserDefault);
|
||||
expect(formatDateTime(ts, "fr")).toBe(browserDefault);
|
||||
expect(formatDateTime(ts, "en_US")).toBe(browserDefault);
|
||||
expect(formatDateTime(ts, undefined)).toBe(browserDefault);
|
||||
});
|
||||
|
||||
it("only ISO 8601 zero-pads; DMY, dot-separated DMY and MDY all drop leading zeros", () => {
|
||||
expect(formatDate(ts, DATE_FORMAT.DMY)).toBe("8/3/2026");
|
||||
expect(formatDate(ts, DATE_FORMAT.DMY_DOT)).toBe("8.3.2026");
|
||||
expect(formatDate(ts, DATE_FORMAT.MDY)).toBe("3/8/2026");
|
||||
// Double-digit day/month are left intact.
|
||||
const ts2 = Math.floor(new Date(2026, 11, 25, 14, 30, 0).getTime() / 1000);
|
||||
expect(formatDate(ts2, DATE_FORMAT.DMY)).toBe("25/12/2026");
|
||||
expect(formatDate(ts2, DATE_FORMAT.DMY_DOT)).toBe("25.12.2026");
|
||||
expect(formatDate(ts2, DATE_FORMAT.MDY)).toBe("12/25/2026");
|
||||
// The same ordering prefixes the datetime variant.
|
||||
expect(formatDateTime(ts, DATE_FORMAT.DMY)).toMatch(/^8\/3\/2026 /);
|
||||
expect(formatDateTime(ts, DATE_FORMAT.DMY_DOT)).toMatch(/^8\.3\.2026 /);
|
||||
expect(formatDateTime(ts, DATE_FORMAT.MDY)).toMatch(/^3\/8\/2026 /);
|
||||
});
|
||||
|
||||
it("formatTime renders only the time, honoring the 12/24-hour choice", () => {
|
||||
const local24 = new Intl.DateTimeFormat(undefined, { timeStyle: "short", hour12: false }).format(new Date(ts * 1000));
|
||||
expect(formatTime(ts, TIME_FORMAT.H24)).toBe(local24);
|
||||
expect(formatTime(ts, TIME_FORMAT.H24)).toContain("14:30");
|
||||
expect(formatTime(ts, TIME_FORMAT.H12)).toContain("2:30");
|
||||
});
|
||||
|
||||
it("time format selects 12-hour vs 24-hour independently of the date format", () => {
|
||||
const local24 = new Intl.DateTimeFormat(undefined, { timeStyle: "short", hour12: false }).format(new Date(ts * 1000));
|
||||
expect(formatDateTime(ts, DATE_FORMAT.DMY, TIME_FORMAT.H24)).toBe(`8/3/2026 ${local24}`);
|
||||
|
||||
const h12 = formatDateTime(ts, DATE_FORMAT.SYSTEM, TIME_FORMAT.H12);
|
||||
const h24 = formatDateTime(ts, DATE_FORMAT.SYSTEM, TIME_FORMAT.H24);
|
||||
expect(h24).toContain("14:30");
|
||||
expect(h12).toContain("2:30");
|
||||
expect(h12).not.toBe(h24);
|
||||
});
|
||||
|
||||
it("ISO 8601 is always 24-hour regardless of the time format", () => {
|
||||
expect(formatDateTime(ts, DATE_FORMAT.ISO8601, TIME_FORMAT.H12)).toBe("2026-03-08 14:30");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user