From 30242ae4701109cf3957ca64133dd4a33caeae5f Mon Sep 17 00:00:00 2001 From: Bashy Date: Thu, 4 Jun 2026 21:07:42 +0300 Subject: [PATCH] Fix timezone mismatch: store/compare scheduled_at in UTC JS was sending browser-local time; dispatcher compared against server UTC, so posts scheduled in UTC+3 appeared 3h in the future to the dispatcher. - JS helpers: utcToDate, utcToLocalInput, localInputToUTC - All scheduled_at values sent to server are now converted to UTC - Stored values are displayed converted back to browser local time - relTime and fmtDT parse stored UTC timestamps correctly - dispatch-social.php: gmdate() for $now (UTC) + sent_at Co-Authored-By: Claude Sonnet 4.6 --- bin/dispatch-social.php | 2 +- web/assets/js/app.js | 32 +++++++++++++++++++++----------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/bin/dispatch-social.php b/bin/dispatch-social.php index cf8ed8c..dd983c2 100755 --- a/bin/dispatch-social.php +++ b/bin/dispatch-social.php @@ -14,7 +14,7 @@ require ROOT . '/lib/bootstrap.php'; $lock = fopen(sys_get_temp_dir() . '/hackman_social.lock', 'c'); if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) exit(0); -$now = date('Y-m-d H:i:s'); +$now = gmdate('Y-m-d H:i:s'); $stmt = $db->prepare(" SELECT t.id AS target_id, t.post_id, t.project_id, t.platform, t.target_key, t.include_image, diff --git a/web/assets/js/app.js b/web/assets/js/app.js index 99f2e55..49316db 100644 --- a/web/assets/js/app.js +++ b/web/assets/js/app.js @@ -3840,8 +3840,18 @@ window.addEventListener('DOMContentLoaded', () => { d.setSeconds(0, 0); return localDTStr(d); } + // ts is stored UTC ("2026-06-04 17:51:00") — parse with Z so JS treats it as UTC + function utcToDate(ts) { return new Date(ts.replace(' ', 'T') + 'Z'); } + // Convert stored UTC string → "YYYY-MM-DDTHH:MM" in browser local time (for datetime-local input) + function utcToLocalInput(ts) { return localDTStr(utcToDate(ts)); } + // Convert datetime-local value (local time) → "YYYY-MM-DD HH:MM:00" UTC for the server + function localInputToUTC(dtLocal) { + const d = new Date(dtLocal); // browser parses datetime-local as local time + const p = n => String(n).padStart(2, '0'); + return `${d.getUTCFullYear()}-${p(d.getUTCMonth()+1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:00`; + } function relTime(ts) { - const diff = (new Date(ts.replace(' ', 'T')) - Date.now()) / 1000; + const diff = (utcToDate(ts) - Date.now()) / 1000; const abs = Math.abs(diff); const suf = diff > 0 ? '' : ' ago'; const pre = diff > 0 ? 'in ' : ''; @@ -3851,7 +3861,7 @@ window.addEventListener('DOMContentLoaded', () => { return pre + Math.round(abs / 86400) + ' d' + suf; } function fmtDT(ts) { - return ts.substring(0, 16).replace('T', ' '); + return localDTStr(utcToDate(ts)).replace('T', ' '); } // ── Emoji picker ───────────────────────────────────────────────────────────── @@ -4013,7 +4023,7 @@ window.addEventListener('DOMContentLoaded', () => { // ── Build edit pane HTML ────────────────────────────────────────────────────── function buildEditPane(post) { const isNew = !post; - const dt = post ? post.scheduled_at.replace(' ', 'T').substring(0, 16) : defaultScheduleTime(); + const dt = post ? utcToLocalInput(post.scheduled_at) : defaultScheduleTime(); const content = post?.content || ''; const imgPath = post?.image_path || ''; const imgUrl = post?.image_url || ''; @@ -4076,21 +4086,21 @@ window.addEventListener('DOMContentLoaded', () => { async function doAutosave(card) { const post = card._post; if (!post) return; - const content = card.querySelector('.sc-content').value.trim(); - const scheduledAt = card.querySelector('.sc-dt').value; - const imagePath = card.querySelector('.sc-img-path').value; - const targets = collectTargetsFrom(card.querySelector('.sc-targets')); + const content = card.querySelector('.sc-content').value.trim(); + const scheduledAt = card.querySelector('.sc-dt').value; + const imagePath = card.querySelector('.sc-img-path').value; + const targets = collectTargetsFrom(card.querySelector('.sc-targets')); if (!content || !scheduledAt || !targets.length) return; setSaveIndicator(card, 'saving'); - const body = { action: 'update', id: post.id, content, scheduled_at: scheduledAt, image_path: imagePath, targets }; + const scheduledUTC = localInputToUTC(scheduledAt); + const body = { action: 'update', id: post.id, content, scheduled_at: scheduledUTC, image_path: imagePath, targets }; const r = await fetch('/api/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const d = await r.json(); if (d.error) { setSaveIndicator(card, 'error'); showCardError(card, d.error); } else { setSaveIndicator(card, 'saved'); - // keep in-memory post up to date so cancel reverts to latest saved state - card._post = { ...post, content, scheduled_at: scheduledAt.replace('T', ' '), image_path: imagePath }; + card._post = { ...post, content, scheduled_at: scheduledUTC, image_path: imagePath }; } } @@ -4218,7 +4228,7 @@ window.addEventListener('DOMContentLoaded', () => { clearTimeout(card._autosaveTimer); setSaveIndicator(card, 'saving'); - const body = { action: isNew ? 'create' : 'update', content, scheduled_at: scheduledAt, image_path: imagePath, targets }; + const body = { action: isNew ? 'create' : 'update', content, scheduled_at: localInputToUTC(scheduledAt), image_path: imagePath, targets }; if (!isNew) body.id = post.id; const r = await fetch('/api/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });