Skip to content

Commit be8aee6

Browse files
nesquenaclaude
andcommitted
fix: handle fractional-hour timezone offsets correctly (nesquena#1144)
The Etc/GMT-X mapping in _serverTzOptions can only express whole-hour offsets — IANA Etc/GMT zones don't support fractional hours. Users in India (+0530), Iran (+0330/+0430), Newfoundland (-0330), Nepal (+0545), Sri Lanka (+0530), Afghanistan (+0430), and Burma (+0630) — collectively ~1.5 billion people — would see timestamps off by 30 minutes after this PR's whole-hour-only TZ conversion. That's actively WORSE than the pre-PR behaviour for them: pre-PR they saw browser-tz times (consistent with their wall clock), post-PR they'd see times 30 min off from the server. Fix: introduce _formatInServerTz(date, options) that uses offset arithmetic — shift the timestamp by the server's offset, then format with timeZone:'UTC' so no further conversion is applied. The formatted output reads as the wall-clock time in the server's timezone for ANY offset, including fractional. _serverTzOptions is kept for whole-hour fast-path callers that spread its result; updated to return undefined for fractional offsets so it can't silently produce off-by-30-min output via the spread path. ui.js: _formatMessageFooterTimestamp and tsTitle in renderMessages now go through _formatInServerTz with a fallback to bare toLocaleString when sessions.js hasn't loaded yet. Tests: - test_message_footer_timestamp_uses_server_tz: stubs _formatInServerTz with the same offset-arithmetic logic as sessions.js, asserts UTC+8 conversion produces the right wall-clock hour. - test_message_footer_timestamp_handles_fractional_offset: NEW. Stubs _formatInServerTz with +0530 (IST), asserts 02:00 UTC → 07:30 IST and explicitly NOT 07:00 (the broken Etc/GMT-5 result). - test_ui_js_message_timestamp_uses_server_tz: relaxed to accept either _formatInServerTz or _serverTzOptions reference. - test_sessions_js_has_format_in_server_tz_helper: NEW. Verifies the function exists and uses offset arithmetic + UTC formatting. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
1 parent 2b91f28 commit be8aee6

3 files changed

Lines changed: 138 additions & 27 deletions

File tree

static/sessions.js

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -778,15 +778,43 @@ function _serverNowMs() {
778778

779779
function _serverTzOptions() {
780780
// Build a timeZone option from _serverTz (e.g. "+0800" → "Etc/GMT-8").
781-
// Falls back to undefined (uses browser timezone) if _serverTz is not set.
781+
// Falls back to undefined (uses browser timezone) when:
782+
// - _serverTz is not set or is UTC (no offset to apply)
783+
// - _serverTz is malformed
784+
// - _serverTz has a fractional-hour component (India +0530, Iran +0330,
785+
// Newfoundland -0330, Nepal +0545, etc.) — IANA Etc/GMT zones cannot
786+
// express half/quarter-hour offsets; use _formatInServerTz() instead
787+
// for correct fractional-offset formatting.
782788
if (!_serverTz || _serverTz === '+0000' || _serverTz === '-0000') return undefined;
783789
const m = _serverTz.match(/^([+-])(\d{2})(\d{2})$/);
784790
if (!m) return undefined;
791+
if (m[3] !== '00') return undefined; // fractional offset — caller must use _formatInServerTz
785792
// IANA Etc/GMT uses inverted sign: UTC+8 → "Etc/GMT-8"
786793
const sign = m[1] === '+' ? '-' : '+';
787794
return { timeZone: `Etc/GMT${sign}${parseInt(m[2])}` };
788795
}
789796

797+
function _formatInServerTz(date, options) {
798+
// Format `date` in the server's wall-clock timezone, including correct
799+
// handling of fractional-hour offsets that Etc/GMT cannot express.
800+
//
801+
// Strategy: shift the timestamp by the server's offset, then format with
802+
// timeZone:'UTC' so no further conversion is applied — the formatted
803+
// output reads as the wall-clock time in the server's timezone.
804+
//
805+
// Falls back to plain `date.toLocaleString(undefined, options)` (browser
806+
// timezone) when _serverTz is absent, UTC, or malformed.
807+
if (!_serverTz || _serverTz === '+0000' || _serverTz === '-0000') {
808+
return date.toLocaleString(undefined, options);
809+
}
810+
const m = _serverTz.match(/^([+-])(\d{2})(\d{2})$/);
811+
if (!m) return date.toLocaleString(undefined, options);
812+
const sign = m[1] === '+' ? 1 : -1;
813+
const offsetMin = sign * (parseInt(m[2]) * 60 + parseInt(m[3]));
814+
const adjusted = new Date(date.getTime() + offsetMin * 60 * 1000);
815+
return adjusted.toLocaleString(undefined, { ...options, timeZone: 'UTC' });
816+
}
817+
790818
function _localDayOrdinal(timestampMs) {
791819
const date = new Date(timestampMs);
792820
return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86400000);

static/ui.js

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2148,17 +2148,16 @@ function _formatMessageFooterTimestamp(tsVal){
21482148
if(!tsVal) return '';
21492149
const date=new Date(tsVal*1000);
21502150
const now=new Date();
2151-
const tzOpts=(typeof _serverTzOptions==='function')?_serverTzOptions():undefined;
2151+
// Use _formatInServerTz when available — it correctly handles fractional-hour
2152+
// offsets like India +0530 that Etc/GMT cannot express. Falls back to plain
2153+
// toLocaleString when sessions.js hasn't loaded yet.
2154+
const fmt=(typeof _formatInServerTz==='function')?_formatInServerTz:null;
21522155
if(_isSameLocalDay(date, now)){
2153-
return date.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit', ...tzOpts});
2154-
}
2155-
return date.toLocaleString([], {
2156-
month:'short',
2157-
day:'numeric',
2158-
hour:'numeric',
2159-
minute:'2-digit',
2160-
...tzOpts,
2161-
});
2156+
const opts={hour:'2-digit', minute:'2-digit'};
2157+
return fmt?fmt(date,opts):date.toLocaleTimeString([], opts);
2158+
}
2159+
const opts={month:'short', day:'numeric', hour:'numeric', minute:'2-digit'};
2160+
return fmt?fmt(date,opts):date.toLocaleString([], opts);
21622161
}
21632162
function _compressionStatusCardHtml({
21642163
statusLabel,
@@ -2363,8 +2362,10 @@ function renderMessages(){
23632362
const retryBtn = isLastAssistant ? `<button class="msg-action-btn" title="${t('regenerate')}" onclick="regenerateResponse(this)">${li('rotate-ccw',13)}</button>` : '';
23642363
const copyBtn = `<button class="msg-copy-btn msg-action-btn" title="${t('copy')}" onclick="copyMsg(this)">${li('copy',13)}</button>`;
23652364
const tsVal=m._ts||m.timestamp;
2366-
const _tzo=(typeof _serverTzOptions==='function')?_serverTzOptions():undefined;
2367-
const tsTitle=tsVal?new Date(tsVal*1000).toLocaleString([],_tzo):'';
2365+
// _formatInServerTz handles fractional-hour offsets (India +0530 etc.)
2366+
// correctly via offset arithmetic; bare toLocaleString is the browser-tz fallback.
2367+
const _fmtSv=(typeof _formatInServerTz==='function')?_formatInServerTz:null;
2368+
const tsTitle=tsVal?(_fmtSv?_fmtSv(new Date(tsVal*1000),{}):new Date(tsVal*1000).toLocaleString()):'';
23682369
const tsTime=_formatMessageFooterTimestamp(tsVal);
23692370
const timeHtml = tsTime ? `<span class="msg-time" title="${esc(tsTitle)}">${tsTime}</span>` : '';
23702371
const footHtml = `<div class="msg-foot">${timeHtml}<span class="msg-actions">${editBtn}${copyBtn}${retryBtn}</span></div>`;

tests/test_issue1144_session_time_sync.py

Lines changed: 96 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -326,34 +326,89 @@ def _extract_is_same_local_day() -> str:
326326

327327

328328
def test_message_footer_timestamp_uses_server_tz():
329-
"""_formatMessageFooterTimestamp should use _serverTzOptions for display."""
329+
"""_formatMessageFooterTimestamp should use _formatInServerTz for display."""
330330
is_same_day_fn = _extract_is_same_local_day()
331331
fmt_fn = _extract_ui_function("_formatMessageFooterTimestamp")
332332
script = textwrap.dedent(
333333
f"""
334334
process.env.TZ = 'America/New_York';
335335
let _serverTimeDelta = 0;
336336
let _serverTz = '+0800';
337-
function _serverTzOptions() {{
338-
if (!_serverTz || _serverTz === '+0000' || _serverTz === '-0000') return undefined;
337+
// Stub _formatInServerTz with the same offset-arithmetic semantics
338+
// as the real implementation in sessions.js.
339+
function _formatInServerTz(date, options) {{
340+
if (!_serverTz || _serverTz === '+0000' || _serverTz === '-0000') {{
341+
return date.toLocaleString(undefined, options);
342+
}}
339343
const m = _serverTz.match(/^([+-])(\\d{{2}})(\\d{{2}})$/);
340-
if (!m) return undefined;
341-
const sign = m[1] === '+' ? '-' : '+';
342-
return {{ timeZone: `Etc/GMT${{sign}}${{parseInt(m[2])}}` }};
344+
if (!m) return date.toLocaleString(undefined, options);
345+
const sign = m[1] === '+' ? 1 : -1;
346+
const offsetMin = sign * (parseInt(m[2]) * 60 + parseInt(m[3]));
347+
const adjusted = new Date(date.getTime() + offsetMin * 60 * 1000);
348+
return adjusted.toLocaleString(undefined, {{ ...options, timeZone: 'UTC' }});
343349
}}
344350
{is_same_day_fn}
345351
{fmt_fn}
346-
// Timestamp for 2026-04-28 10:00:00 UTC = 18:00 UTC+8
352+
// Timestamp for 2026-03-29 02:00:00 UTC = 10:00 in UTC+8
347353
const tsVal = 1774749600;
348354
const result = _formatMessageFooterTimestamp(tsVal);
349355
process.stdout.write(JSON.stringify({{ formatted: result }}));
350356
"""
351357
)
352358
proc = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
353359
data = json.loads(proc.stdout)
354-
# Should display in Etc/GMT-8 timezone (UTC+8), not America/New_York
355-
# 2026-03-29 02:00 UTC = 10:00 AM in Etc/GMT-8
356-
assert "10:00 AM" in data["formatted"]
360+
# Should display in UTC+8, not America/New_York.
361+
# 2026-03-29 02:00 UTC = 10:00 in UTC+8
362+
assert "10:00 AM" in data["formatted"], (
363+
f"Expected '10:00 AM' (UTC+8 wall-clock) in {data['formatted']!r}"
364+
)
365+
366+
367+
def test_message_footer_timestamp_handles_fractional_offset():
368+
"""_formatMessageFooterTimestamp must correctly format in IST (+0530) and
369+
other half-hour offsets — Etc/GMT can't express these but offset
370+
arithmetic in _formatInServerTz handles them correctly. Affects ~1.5B
371+
users in India, Iran, Newfoundland, Nepal, Sri Lanka, etc."""
372+
is_same_day_fn = _extract_is_same_local_day()
373+
fmt_fn = _extract_ui_function("_formatMessageFooterTimestamp")
374+
script = textwrap.dedent(
375+
f"""
376+
process.env.TZ = 'UTC';
377+
let _serverTimeDelta = 0;
378+
let _serverTz = '+0530'; // India IST
379+
function _formatInServerTz(date, options) {{
380+
if (!_serverTz || _serverTz === '+0000' || _serverTz === '-0000') {{
381+
return date.toLocaleString(undefined, options);
382+
}}
383+
const m = _serverTz.match(/^([+-])(\\d{{2}})(\\d{{2}})$/);
384+
if (!m) return date.toLocaleString(undefined, options);
385+
const sign = m[1] === '+' ? 1 : -1;
386+
const offsetMin = sign * (parseInt(m[2]) * 60 + parseInt(m[3]));
387+
const adjusted = new Date(date.getTime() + offsetMin * 60 * 1000);
388+
return adjusted.toLocaleString(undefined, {{ ...options, timeZone: 'UTC' }});
389+
}}
390+
{is_same_day_fn}
391+
{fmt_fn}
392+
// 2026-03-29 02:00:00 UTC = 07:30 IST (UTC+5:30)
393+
const tsVal = 1774749600;
394+
const result = _formatMessageFooterTimestamp(tsVal);
395+
process.stdout.write(JSON.stringify({{ formatted: result }}));
396+
"""
397+
)
398+
proc = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
399+
data = json.loads(proc.stdout)
400+
# 2026-03-29 02:00 UTC = 07:30 IST. Old Etc/GMT-5 mapping would have shown 07:00.
401+
# Accept either "07:30" or "7:30" (en-US uses hour:'numeric' for non-same-day).
402+
formatted = data["formatted"]
403+
assert "07:30" in formatted or "7:30" in formatted, (
404+
f"Expected '7:30' (IST = UTC+5:30 wall-clock) in {formatted!r}; "
405+
"Etc/GMT-5 path would show 7:00 — off by 30 min."
406+
)
407+
# And explicitly NOT the broken Etc/GMT-5 output (07:00 / 7:00 with 0 minutes).
408+
assert ":00" not in formatted.split("M")[0], (
409+
f"Output contains ':00' which would be the off-by-30-min Etc/GMT-5 result; "
410+
f"got {formatted!r}"
411+
)
357412

358413

359414
def test_message_footer_timestamp_falls_back_without_server_tz():
@@ -404,7 +459,34 @@ def test_sessions_js_uses_server_now_in_time_functions():
404459

405460

406461
def test_ui_js_message_timestamp_uses_server_tz():
407-
"""ui.js _formatMessageFooterTimestamp should reference _serverTzOptions."""
408-
assert "_serverTzOptions" in UI_JS
409-
# tsTitle in message rendering should also use it
410-
assert "_tzo=" in UI_JS
462+
"""ui.js timestamp formatters should reference the server-tz helpers
463+
so they pick up the server's wall-clock time (with correct fractional
464+
offset handling) rather than always rendering in browser TZ."""
465+
# _formatInServerTz is the canonical helper that handles both whole-hour
466+
# and fractional offsets (e.g. India +0530). _serverTzOptions is the
467+
# whole-hour fast path; either reference indicates server-tz awareness.
468+
assert "_formatInServerTz" in UI_JS or "_serverTzOptions" in UI_JS, (
469+
"ui.js must reference one of the server-tz helpers so message "
470+
"timestamps render in the server's wall-clock time"
471+
)
472+
473+
474+
def test_sessions_js_has_format_in_server_tz_helper():
475+
"""_formatInServerTz must exist and use offset arithmetic so fractional
476+
offsets (India +0530, Iran +0330, etc.) format correctly."""
477+
assert "function _formatInServerTz" in SESSIONS_JS, (
478+
"_formatInServerTz must be defined to handle fractional-hour "
479+
"offsets that Etc/GMT cannot express"
480+
)
481+
# Find the function body
482+
start = SESSIONS_JS.find("function _formatInServerTz")
483+
end = SESSIONS_JS.find("\n}", start) + 2
484+
body = SESSIONS_JS[start:end]
485+
# Offset arithmetic + timeZone:'UTC' is the correct strategy
486+
assert "timeZone: 'UTC'" in body or 'timeZone: "UTC"' in body, (
487+
"_formatInServerTz must format in UTC after applying the offset "
488+
"via arithmetic — that's how fractional offsets work correctly"
489+
)
490+
assert "60 * 1000" in body or "* 60_000" in body, (
491+
"_formatInServerTz must convert the offset minutes to milliseconds"
492+
)

0 commit comments

Comments
 (0)