From c97ff390adc9c012b70116c84e655b1e6c3acdda Mon Sep 17 00:00:00 2001 From: scarf Date: Wed, 25 Feb 2026 02:16:36 +0900 Subject: [PATCH 1/4] feat(web): add datetime format preference model and formatters Introduce persisted date/time format preference values and extend date formatting helpers to support locale and ISO 8601 modes. Co-authored-by: chatgpt-codex-connector[bot] <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> Assisted-by: openai/gpt-5.3-codex on opencode --- web/src/app/Prefs.js | 14 ++++++++++++++ web/src/app/utils.js | 28 ++++++++++++++++++++++------ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/web/src/app/Prefs.js b/web/src/app/Prefs.js index 4f28f87e..06a6873f 100644 --- a/web/src/app/Prefs.js +++ b/web/src/app/Prefs.js @@ -6,6 +6,11 @@ export const THEME = { SYSTEM: "system", }; +export const DATE_TIME_FORMAT = { + LOCALE: "locale", + ISO_8601: "iso8601", +}; + class Prefs { constructor(dbImpl) { this.db = dbImpl; @@ -55,6 +60,15 @@ class Prefs { async setTheme(mode) { await this.db.prefs.put({ key: "theme", value: mode }); } + + async dateTimeFormat() { + const dateTimeFormat = await this.db.prefs.get("dateTimeFormat"); + return dateTimeFormat?.value ?? DATE_TIME_FORMAT.LOCALE; + } + + async setDateTimeFormat(mode) { + await this.db.prefs.put({ key: "dateTimeFormat", value: mode }); + } } const prefs = new Prefs(db()); diff --git a/web/src/app/utils.js b/web/src/app/utils.js index 8e27365b..2f1dc5a0 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -8,7 +8,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 { DATE_TIME_FORMAT, THEME } from "./Prefs"; export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`; export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, ""); @@ -140,14 +140,30 @@ export const hashCode = (s) => { */ export const getKebabCaseLangStr = (language) => language.replace(/_/g, "-"); -export const formatShortDateTime = (timestamp, language) => - new Intl.DateTimeFormat(getKebabCaseLangStr(language), { +const pad2 = (value) => `${value}`.padStart(2, "0"); + +const formatIsoDate = (date) => `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`; + +const formatIsoDateTime = (date) => `${formatIsoDate(date)} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`; + +export const formatShortDateTime = (timestamp, language, dateTimeFormat = DATE_TIME_FORMAT.LOCALE) => { + const date = new Date(timestamp * 1000); + if (dateTimeFormat === DATE_TIME_FORMAT.ISO_8601) { + return formatIsoDateTime(date); + } + return new Intl.DateTimeFormat(getKebabCaseLangStr(language), { dateStyle: "short", timeStyle: "short", - }).format(new Date(timestamp * 1000)); + }).format(date); +}; -export const formatShortDate = (timestamp, language) => - new Intl.DateTimeFormat(getKebabCaseLangStr(language), { dateStyle: "short" }).format(new Date(timestamp * 1000)); +export const formatShortDate = (timestamp, language, dateTimeFormat = DATE_TIME_FORMAT.LOCALE) => { + const date = new Date(timestamp * 1000); + if (dateTimeFormat === DATE_TIME_FORMAT.ISO_8601) { + return formatIsoDate(date); + } + return new Intl.DateTimeFormat(getKebabCaseLangStr(language), { dateStyle: "short" }).format(date); +}; export const formatBytes = (bytes, decimals = 2) => { if (bytes === 0) return "0 bytes"; From 871018c92018e3afce6f22f2fa896f0b50856086 Mon Sep 17 00:00:00 2001 From: scarf Date: Wed, 25 Feb 2026 02:16:43 +0900 Subject: [PATCH 2/4] feat(web): apply datetime format preference across UI Read the stored datetime display mode in web UI surfaces and consistently format notification, account, token, and upgrade dates using the selected mode. Co-authored-by: chatgpt-codex-connector[bot] <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> Assisted-by: openai/gpt-5.3-codex on opencode --- web/src/components/Account.jsx | 12 ++++++---- web/src/components/Notifications.jsx | 19 +++++++++++----- web/src/components/Preferences.jsx | 29 +++++++++++++++++++++++- web/src/components/SubscriptionPopup.jsx | 9 ++++++-- web/src/components/UpgradeDialog.jsx | 5 +++- 5 files changed, 60 insertions(+), 14 deletions(-) diff --git a/web/src/components/Account.jsx b/web/src/components/Account.jsx index bc5e3000..c51f3b64 100644 --- a/web/src/components/Account.jsx +++ b/web/src/components/Account.jsx @@ -1,5 +1,6 @@ import * as React from "react"; import { useContext, useState } from "react"; +import { useLiveQuery } from "dexie-react-hooks"; import { Alert, CardActions, @@ -56,6 +57,7 @@ import { Paragraph } from "./styles"; import { IncorrectPasswordError, UnauthorizedError } from "../app/errors"; import { ProChip } from "./SubscriptionPopup"; import session from "../app/Session"; +import prefs from "../app/Prefs"; const Account = () => { if (!session.exists()) { @@ -234,6 +236,7 @@ const ChangePasswordDialog = (props) => { const AccountType = () => { const { t, i18n } = useTranslation(); const { account } = useContext(AccountContext); + const dateTimeFormat = useLiveQuery(async () => prefs.dateTimeFormat()); const [upgradeDialogKey, setUpgradeDialogKey] = useState(0); const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false); const [showPortalError, setShowPortalError] = useState(false); @@ -291,7 +294,7 @@ const AccountType = () => { {account.billing?.paid_until && !account.billing?.cancel_at && ( @@ -336,7 +339,7 @@ const AccountType = () => { {account.billing?.cancel_at > 0 && ( {t("account_basics_tier_canceled_subscription", { - date: formatShortDate(account.billing.cancel_at, i18n.language), + date: formatShortDate(account.billing.cancel_at, i18n.language, dateTimeFormat), })} )} @@ -807,6 +810,7 @@ const Tokens = () => { const TokensTable = (props) => { const { t, i18n } = useTranslation(); + const dateTimeFormat = useLiveQuery(async () => prefs.dateTimeFormat()); const [snackOpen, setSnackOpen] = useState(false); const [upsertDialogKey, setUpsertDialogKey] = useState(0); const [upsertDialogOpen, setUpsertDialogOpen] = useState(false); @@ -880,11 +884,11 @@ const TokensTable = (props) => { {token.token !== session.token() && (token.label || "-")} - {token.expires ? formatShortDateTime(token.expires, i18n.language) : {t("account_tokens_table_never_expires")}} + {token.expires ? formatShortDateTime(token.expires, i18n.language, dateTimeFormat) : {t("account_tokens_table_never_expires")}}
- {formatShortDateTime(token.last_access, i18n.language)} + {formatShortDateTime(token.last_access, i18n.language, dateTimeFormat)} { const { t } = useTranslation(); const pageSize = 20; const { notifications } = props; + const dateTimeFormat = useLiveQuery(async () => prefs.dateTimeFormat()); const [snackOpen, setSnackOpen] = useState(false); const [maxCount, setMaxCount] = useState(pageSize); const count = Math.min(notifications.length, maxCount); @@ -139,7 +141,12 @@ const NotificationList = (props) => { > {notifications.slice(0, count).map((notification) => ( - setSnackOpen(true)} /> + setSnackOpen(true)} + /> ))} { const NotificationItem = (props) => { const { t, i18n } = useTranslation(); - const { notification } = props; + const { notification, dateTimeFormat } = props; const { attachment } = notification; - const date = formatShortDateTime(notification.time, i18n.language); + const date = formatShortDateTime(notification.time, i18n.language, dateTimeFormat); const otherTags = unmatchedTags(notification.tags); const tags = otherTags.length > 0 ? otherTags.join(", ") : null; const handleDelete = async () => { @@ -309,7 +316,7 @@ const NotificationItem = (props) => { {maybeActionErrors(notification)} - {attachment && } + {attachment && } {tags && ( {t("notifications_tags")}: {tags} @@ -355,7 +362,7 @@ const NotificationItem = (props) => { const Attachment = (props) => { const { t, i18n } = useTranslation(); - const { attachment } = props; + const { attachment, dateTimeFormat } = props; const expired = attachment.expires && attachment.expires < Date.now() / 1000; const expires = attachment.expires && attachment.expires > Date.now() / 1000; const displayableImage = !expired && isImage(attachment); @@ -373,7 +380,7 @@ const Attachment = (props) => { if (expires) { infos.push( t("notifications_attachment_link_expires", { - date: formatShortDateTime(attachment.expires, i18n.language), + date: formatShortDateTime(attachment.expires, i18n.language, dateTimeFormat), }) ); } diff --git a/web/src/components/Preferences.jsx b/web/src/components/Preferences.jsx index 3ef62189..687e1aee 100644 --- a/web/src/components/Preferences.jsx +++ b/web/src/components/Preferences.jsx @@ -43,7 +43,7 @@ import accountApi, { Permission, Role } from "../app/AccountApi"; import { Pref, PrefGroup } from "./Pref"; import { AccountContext } from "./App"; import { Paragraph } from "./styles"; -import prefs, { THEME } from "../app/Prefs"; +import prefs, { DATE_TIME_FORMAT, THEME } from "../app/Prefs"; import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons"; import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs"; import { UnauthorizedError } from "../app/errors"; @@ -524,6 +524,7 @@ const Appearance = () => { + @@ -615,6 +616,32 @@ const Language = () => { ); }; +const DateTimeFormat = () => { + const { t } = useTranslation(); + const labelId = "prefDateTimeFormat"; + const dateTimeFormat = useLiveQuery(async () => prefs.dateTimeFormat()); + + const handleChange = async (ev) => { + await prefs.setDateTimeFormat(ev.target.value); + }; + + const description = + dateTimeFormat === DATE_TIME_FORMAT.ISO_8601 + ? t("prefs_appearance_datetime_format_description_iso8601") + : t("prefs_appearance_datetime_format_description_locale"); + + return ( + + + + + + ); +}; + const Reservations = () => { const { t } = useTranslation(); const { account } = useContext(AccountContext); diff --git a/web/src/components/SubscriptionPopup.jsx b/web/src/components/SubscriptionPopup.jsx index 1a6a689c..37a67956 100644 --- a/web/src/components/SubscriptionPopup.jsx +++ b/web/src/components/SubscriptionPopup.jsx @@ -1,5 +1,6 @@ import * as React from "react"; import { useContext, useState } from "react"; +import { useLiveQuery } from "dexie-react-hooks"; import { Button, TextField, @@ -42,9 +43,11 @@ import api from "../app/Api"; import { AccountContext } from "./App"; import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs"; import { UnauthorizedError } from "../app/errors"; +import prefs from "../app/Prefs"; export const SubscriptionPopup = (props) => { const { t } = useTranslation(); + const dateTimeFormat = useLiveQuery(async () => prefs.dateTimeFormat()); const { account } = useContext(AccountContext); const navigate = useNavigate(); const [displayNameDialogOpen, setDisplayNameDialogOpen] = useState(false); @@ -119,13 +122,15 @@ export const SubscriptionPopup = (props) => { const message = shuffle([ `Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime( nowSeconds, - "en-US" + "en-US", + dateTimeFormat )} right now. Is that early or late?`, `So I heard you like ntfy? If that's true, go to GitHub and star it, or to the Play store and rate it. Thanks! Oh yeah, this is a test notification.`, `It's almost like you want to hear what I have to say. I'm not even a machine. I'm just a sentence that Phil typed on a random Thursday.`, `Alright then, it's ${formatShortDateTime( nowSeconds, - "en-US" + "en-US", + dateTimeFormat )} already. Boy oh boy, where did the time go? I hope you're alright, friend.`, `There are nine million bicycles in Beijing That's a fact; It's a thing we can't deny. I wonder if that's true ...`, `I'm really excited that you're trying out ntfy. Did you know that there are a few public topics, such as ntfy.sh/stats and ntfy.sh/announcements.`, diff --git a/web/src/components/UpgradeDialog.jsx b/web/src/components/UpgradeDialog.jsx index 712c47ec..67c64acf 100644 --- a/web/src/components/UpgradeDialog.jsx +++ b/web/src/components/UpgradeDialog.jsx @@ -1,5 +1,6 @@ import * as React from "react"; import { useContext, useEffect, useState } from "react"; +import { useLiveQuery } from "dexie-react-hooks"; import { Dialog, DialogContent, @@ -32,6 +33,7 @@ import { AccountContext } from "./App"; import routes from "./routes"; import session from "../app/Session"; import accountApi, { SubscriptionInterval } from "../app/AccountApi"; +import prefs from "../app/Prefs"; const Feature = (props) => {props.children}; @@ -63,6 +65,7 @@ const Banner = { const UpgradeDialog = (props) => { const theme = useTheme(); const { t, i18n } = useTranslation(); + const dateTimeFormat = useLiveQuery(async () => prefs.dateTimeFormat()); const { account } = useContext(AccountContext); // May be undefined! const [error, setError] = useState(""); const [tiers, setTiers] = useState(null); @@ -233,7 +236,7 @@ const UpgradeDialog = (props) => { From 605566a8b376a3b9f1ae01281c57207d4ecf1111 Mon Sep 17 00:00:00 2001 From: scarf Date: Wed, 25 Feb 2026 02:16:50 +0900 Subject: [PATCH 3/4] feat(i18n): add English labels for datetime format setting Add English translation keys for the new date and time format preference and its available options. Co-authored-by: chatgpt-codex-connector[bot] <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> Assisted-by: openai/gpt-5.3-codex on opencode --- web/public/static/langs/en.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 19fe2195..5b2cfcdb 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -363,6 +363,11 @@ "prefs_users_dialog_password_label": "Password", "prefs_appearance_title": "Appearance", "prefs_appearance_language_title": "Language", + "prefs_appearance_datetime_format_title": "Date and time format", + "prefs_appearance_datetime_format_description_locale": "Use your selected language's local date and time format", + "prefs_appearance_datetime_format_description_iso8601": "Use ISO 8601 format (YYYY-MM-DD HH:mm)", + "prefs_appearance_datetime_format_locale": "Locale (default)", + "prefs_appearance_datetime_format_iso8601": "ISO 8601 (YYYY-MM-DD HH:mm)", "prefs_appearance_theme_title": "Theme", "prefs_appearance_theme_system": "System (default)", "prefs_appearance_theme_dark": "Dark mode", From 09a8b3e2f3199358201ab6b2f43658651de51840 Mon Sep 17 00:00:00 2001 From: scarf Date: Wed, 25 Feb 2026 02:20:49 +0900 Subject: [PATCH 4/4] fix(web): harden datetime preference and locale usage Validate persisted datetime format values before use and make test message timestamp formatting honor the active app language in locale mode. Co-authored-by: chatgpt-codex-connector[bot] <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> Assisted-by: openai/gpt-5.3-codex on opencode --- web/src/app/Prefs.js | 5 ++++- web/src/components/SubscriptionPopup.jsx | 6 +++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/web/src/app/Prefs.js b/web/src/app/Prefs.js index 06a6873f..744d473d 100644 --- a/web/src/app/Prefs.js +++ b/web/src/app/Prefs.js @@ -63,7 +63,10 @@ class Prefs { async dateTimeFormat() { const dateTimeFormat = await this.db.prefs.get("dateTimeFormat"); - return dateTimeFormat?.value ?? DATE_TIME_FORMAT.LOCALE; + if (Object.values(DATE_TIME_FORMAT).includes(dateTimeFormat?.value)) { + return dateTimeFormat.value; + } + return DATE_TIME_FORMAT.LOCALE; } async setDateTimeFormat(mode) { diff --git a/web/src/components/SubscriptionPopup.jsx b/web/src/components/SubscriptionPopup.jsx index 37a67956..b26adb89 100644 --- a/web/src/components/SubscriptionPopup.jsx +++ b/web/src/components/SubscriptionPopup.jsx @@ -46,7 +46,7 @@ import { UnauthorizedError } from "../app/errors"; import prefs from "../app/Prefs"; export const SubscriptionPopup = (props) => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const dateTimeFormat = useLiveQuery(async () => prefs.dateTimeFormat()); const { account } = useContext(AccountContext); const navigate = useNavigate(); @@ -122,14 +122,14 @@ export const SubscriptionPopup = (props) => { const message = shuffle([ `Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime( nowSeconds, - "en-US", + i18n.language, dateTimeFormat )} right now. Is that early or late?`, `So I heard you like ntfy? If that's true, go to GitHub and star it, or to the Play store and rate it. Thanks! Oh yeah, this is a test notification.`, `It's almost like you want to hear what I have to say. I'm not even a machine. I'm just a sentence that Phil typed on a random Thursday.`, `Alright then, it's ${formatShortDateTime( nowSeconds, - "en-US", + i18n.language, dateTimeFormat )} already. Boy oh boy, where did the time go? I hope you're alright, friend.`, `There are nine million bicycles in Beijing That's a fact; It's a thing we can't deny. I wonder if that's true ...`,