From 4e8292bd03add0b2f027dc015e07efc90d5dc826 Mon Sep 17 00:00:00 2001 From: MarekWo Date: Fri, 31 Jul 2026 17:42:34 +0200 Subject: [PATCH] refactor(i18n): consolidate date/time formatting (stage 0b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prerequisite for string extraction. formatTime() existed twice with subtly different behaviour, and both hardcoded 'Yesterday ' — a string that would have survived a naive extraction pass and stayed English forever. app/static/js/datetime-utils.js now owns the today/yesterday/older logic, with options for the two variants that actually differed: app.js needs an absolute date in archive views, dm.js needs the short "5 Aug" form because DM rows are narrower. Both keep a thin local formatTime() wrapper, so their ~15 call sites are untouched. formatTimeAgo() moves over as-is (it only ever existed in app.js). repeater-manage.js's fmtInt() no longer forces toLocaleString('en-US'), so thousands separators follow the reader's locale. Kept as a function declaration rather than a const alias so it stays hoisted, like the code it replaced. Policy, documented in the module header: numeric formatting follows the BROWSER locale, only the words are translated. Tying the clock to the UI language would flip a Polish operator to "09:53 AM" the moment they switched the interface to English, and mesh operators want 24-hour time whatever language the menus are in. First five real catalog entries: common.yesterday, just_now, minutes_ago, hours_ago, days_ago. days_ago exercises the plural machinery — Polish needs "1 dzień temu" / "3 dni temu" / "5 dni temu" where English has one form. Verified in the browser in both languages: all four helpers global, both formatTime wrappers still routing through the shared core, Polish plural categories correct, and the clock and number formats provably unchanged by the UI language. Live chat timestamps render identically to before. Co-Authored-By: Claude Opus 5 --- app/static/js/app.js | 39 ++------------- app/static/js/datetime-utils.js | 81 ++++++++++++++++++++++++++++++++ app/static/js/dm.js | 19 ++------ app/static/js/repeater-manage.js | 6 ++- app/static/js/sw.js | 1 + app/templates/_head_i18n.html | 1 + app/translations/en.json | 9 ++++ app/translations/pl.json | 11 +++++ 8 files changed, 114 insertions(+), 53 deletions(-) create mode 100644 app/static/js/datetime-utils.js diff --git a/app/static/js/app.js b/app/static/js/app.js index 016f957..f1e48d3 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -4591,45 +4591,14 @@ function scrollToBottom() { } /** - * Format timestamp + * Format a message timestamp. + * Archive views always show the full date, since "Today" is meaningless there. */ function formatTime(timestamp) { - const date = new Date(timestamp * 1000); - - // When viewing archive, always show full date + time - if (currentArchiveDate) { - return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - } - - // When viewing live messages, compare calendar dates - const now = new Date(); - const yesterday = new Date(now); - yesterday.setDate(yesterday.getDate() - 1); - - if (date.toDateString() === now.toDateString()) { - // Today - show time only - return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - } else if (date.toDateString() === yesterday.toDateString()) { - // Yesterday - return 'Yesterday ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - } else { - // Older - show date and time - return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - } + return formatTimestamp(timestamp, { absolute: !!currentArchiveDate }); } -/** - * Format a unix timestamp as relative time (e.g., "5 min ago", "2h ago") - */ -function formatTimeAgo(timestamp) { - const now = Math.floor(Date.now() / 1000); - const diff = now - timestamp; - if (diff < 60) return 'just now'; - if (diff < 3600) return `${Math.floor(diff / 60)} min ago`; - if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; - if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`; - return new Date(timestamp * 1000).toLocaleDateString(); -} +// formatTimeAgo() comes from datetime-utils.js. /** * Update character counter (counts UTF-8 bytes, not characters) diff --git a/app/static/js/datetime-utils.js b/app/static/js/datetime-utils.js new file mode 100644 index 0000000..59815df --- /dev/null +++ b/app/static/js/datetime-utils.js @@ -0,0 +1,81 @@ +/** + * Shared date/time and number formatting. + * + * Policy: numeric formatting follows the BROWSER locale, only the words are translated. + * + * That split is deliberate. Tying the clock to the UI language would flip a Polish + * operator to "09:53 AM" the moment they switched the interface to English, and mesh + * operators want 24-hour time regardless of what language the menus are in. The browser + * locale already reflects how the user wants dates and numbers written, so leave it + * alone and translate only "Yesterday", "just now" and friends. + * + * Loaded via _head_i18n.html on every page, after the translation runtime. + */ + +/** HH:MM in the browser's locale. */ +function formatClock(date) { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +/** + * Format a unix timestamp as today / yesterday / an older date. + * + * @param {number} timestamp - unix seconds + * @param {Object} [opts] + * @param {boolean} [opts.absolute] - always show the full date, never today/yesterday + * @param {'full'|'short'} [opts.dateStyle] - how older dates are written + * @returns {string} + */ +function formatTimestamp(timestamp, opts) { + if (!timestamp) return ''; + const { absolute = false, dateStyle = 'full' } = opts || {}; + + const date = new Date(timestamp * 1000); + const longDate = () => (dateStyle === 'short' + ? date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + : date.toLocaleDateString()); + + if (absolute) return `${longDate()} ${formatClock(date)}`; + + const now = new Date(); + const yesterday = new Date(now); + yesterday.setDate(yesterday.getDate() - 1); + + if (date.toDateString() === now.toDateString()) { + return formatClock(date); + } + if (date.toDateString() === yesterday.toDateString()) { + return `${t('common.yesterday')} ${formatClock(date)}`; + } + return `${longDate()} ${formatClock(date)}`; +} + +/** + * Format a unix timestamp as relative time ("5 min ago", "2h ago"). + * @param {number} timestamp - unix seconds + * @returns {string} + */ +function formatTimeAgo(timestamp) { + const diff = Math.floor(Date.now() / 1000) - timestamp; + + if (diff < 60) return t('common.just_now'); + if (diff < 3600) return t('common.minutes_ago', { count: Math.floor(diff / 60) }); + if (diff < 86400) return t('common.hours_ago', { count: Math.floor(diff / 3600) }); + if (diff < 604800) return tn('common.days_ago', Math.floor(diff / 86400)); + return new Date(timestamp * 1000).toLocaleDateString(); +} + +/** + * Thousands-separated integer in the browser's locale, em dash for missing values. + * @param {number|null} n + * @returns {string} + */ +function formatInt(n) { + if (n == null || isNaN(n)) return '—'; + return Number(n).toLocaleString(); +} + +window.formatClock = formatClock; +window.formatTimestamp = formatTimestamp; +window.formatTimeAgo = formatTimeAgo; +window.formatInt = formatInt; diff --git a/app/static/js/dm.js b/app/static/js/dm.js index b357f7a..55b6556 100644 --- a/app/static/js/dm.js +++ b/app/static/js/dm.js @@ -1783,24 +1783,11 @@ function updateLastRefresh() { } /** - * Format timestamp to readable time + * Format timestamp to readable time. + * Uses the short date form ("5 Aug") — DM rows are narrower than channel messages. */ function formatTime(timestamp) { - if (!timestamp) return ''; - - const date = new Date(timestamp * 1000); - const now = new Date(); - const yesterday = new Date(now); - yesterday.setDate(yesterday.getDate() - 1); - - if (date.toDateString() === now.toDateString()) { - return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - } else if (date.toDateString() === yesterday.toDateString()) { - return 'Yesterday ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - } else { - return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + - ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - } + return formatTimestamp(timestamp, { dateStyle: 'short' }); } /** diff --git a/app/static/js/repeater-manage.js b/app/static/js/repeater-manage.js index e75257e..6f84e7a 100644 --- a/app/static/js/repeater-manage.js +++ b/app/static/js/repeater-manage.js @@ -278,9 +278,11 @@ function fmtDuration(seconds) { return parts.join(' '); } +// Delegates to datetime-utils.js; no longer forces en-US thousands separators, so +// numbers follow the reader's locale. Kept as a declaration (not const) so it stays +// hoisted, like the implementation it replaced. function fmtInt(n) { - if (n == null || isNaN(n)) return '—'; - return Number(n).toLocaleString('en-US'); + return formatInt(n); } function batteryPercent(mv) { diff --git a/app/static/js/sw.js b/app/static/js/sw.js index ca6ebad..99a5c50 100644 --- a/app/static/js/sw.js +++ b/app/static/js/sw.js @@ -8,6 +8,7 @@ const ASSETS_TO_CACHE = [ '/static/js/message-utils.js', '/static/js/filter-utils.js', '/static/js/i18n-runtime.js', + '/static/js/datetime-utils.js', '/static/js/console.js', '/static/images/android-chrome-192x192.png', '/static/images/android-chrome-512x512.png', diff --git a/app/templates/_head_i18n.html b/app/templates/_head_i18n.html index 0cfc7cd..6219595 100644 --- a/app/templates/_head_i18n.html +++ b/app/templates/_head_i18n.html @@ -8,3 +8,4 @@ (including the 6 iframes) share a single cached fetch. #} + diff --git a/app/translations/en.json b/app/translations/en.json index f11dbee..fc55f5d 100644 --- a/app/translations/en.json +++ b/app/translations/en.json @@ -1,4 +1,13 @@ { + "common.days_ago": { + "one": "{count}d ago", + "other": "{count}d ago" + }, + "common.hours_ago": "{count}h ago", + "common.just_now": "just now", + "common.minutes_ago": "{count} min ago", + "common.yesterday": "Yesterday", + "meta.language_english_name": "English", "meta.language_name": "English", "meta.translator": "mc-webui" diff --git a/app/translations/pl.json b/app/translations/pl.json index 3e12af8..9d76015 100644 --- a/app/translations/pl.json +++ b/app/translations/pl.json @@ -1,4 +1,15 @@ { + "common.days_ago": { + "one": "{count} dzień temu", + "few": "{count} dni temu", + "many": "{count} dni temu", + "other": "{count} dnia temu" + }, + "common.hours_ago": "{count} godz. temu", + "common.just_now": "przed chwilą", + "common.minutes_ago": "{count} min temu", + "common.yesterday": "Wczoraj", + "meta.language_english_name": "Polish", "meta.language_name": "Polski", "meta.translator": "mc-webui"