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"