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 <noreply@anthropic.com>
This commit is contained in:
Bashy 2026-06-04 21:07:42 +03:00
parent 9af6326be2
commit 30242ae470
2 changed files with 22 additions and 12 deletions

View file

@ -14,7 +14,7 @@ require ROOT . '/lib/bootstrap.php';
$lock = fopen(sys_get_temp_dir() . '/hackman_social.lock', 'c'); $lock = fopen(sys_get_temp_dir() . '/hackman_social.lock', 'c');
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) exit(0); 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(" $stmt = $db->prepare("
SELECT t.id AS target_id, t.post_id, t.project_id, t.platform, t.target_key, t.include_image, SELECT t.id AS target_id, t.post_id, t.project_id, t.platform, t.target_key, t.include_image,

View file

@ -3840,8 +3840,18 @@ window.addEventListener('DOMContentLoaded', () => {
d.setSeconds(0, 0); d.setSeconds(0, 0);
return localDTStr(d); 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) { 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 abs = Math.abs(diff);
const suf = diff > 0 ? '' : ' ago'; const suf = diff > 0 ? '' : ' ago';
const pre = diff > 0 ? 'in ' : ''; const pre = diff > 0 ? 'in ' : '';
@ -3851,7 +3861,7 @@ window.addEventListener('DOMContentLoaded', () => {
return pre + Math.round(abs / 86400) + ' d' + suf; return pre + Math.round(abs / 86400) + ' d' + suf;
} }
function fmtDT(ts) { function fmtDT(ts) {
return ts.substring(0, 16).replace('T', ' '); return localDTStr(utcToDate(ts)).replace('T', ' ');
} }
// ── Emoji picker ───────────────────────────────────────────────────────────── // ── Emoji picker ─────────────────────────────────────────────────────────────
@ -4013,7 +4023,7 @@ window.addEventListener('DOMContentLoaded', () => {
// ── Build edit pane HTML ────────────────────────────────────────────────────── // ── Build edit pane HTML ──────────────────────────────────────────────────────
function buildEditPane(post) { function buildEditPane(post) {
const isNew = !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 content = post?.content || '';
const imgPath = post?.image_path || ''; const imgPath = post?.image_path || '';
const imgUrl = post?.image_url || ''; const imgUrl = post?.image_url || '';
@ -4076,21 +4086,21 @@ window.addEventListener('DOMContentLoaded', () => {
async function doAutosave(card) { async function doAutosave(card) {
const post = card._post; const post = card._post;
if (!post) return; if (!post) return;
const content = card.querySelector('.sc-content').value.trim(); const content = card.querySelector('.sc-content').value.trim();
const scheduledAt = card.querySelector('.sc-dt').value; const scheduledAt = card.querySelector('.sc-dt').value;
const imagePath = card.querySelector('.sc-img-path').value; const imagePath = card.querySelector('.sc-img-path').value;
const targets = collectTargetsFrom(card.querySelector('.sc-targets')); const targets = collectTargetsFrom(card.querySelector('.sc-targets'));
if (!content || !scheduledAt || !targets.length) return; if (!content || !scheduledAt || !targets.length) return;
setSaveIndicator(card, 'saving'); 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 r = await fetch('/api/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const d = await r.json(); const d = await r.json();
if (d.error) { setSaveIndicator(card, 'error'); showCardError(card, d.error); } if (d.error) { setSaveIndicator(card, 'error'); showCardError(card, d.error); }
else { else {
setSaveIndicator(card, 'saved'); setSaveIndicator(card, 'saved');
// keep in-memory post up to date so cancel reverts to latest saved state card._post = { ...post, content, scheduled_at: scheduledUTC, image_path: imagePath };
card._post = { ...post, content, scheduled_at: scheduledAt.replace('T', ' '), image_path: imagePath };
} }
} }
@ -4218,7 +4228,7 @@ window.addEventListener('DOMContentLoaded', () => {
clearTimeout(card._autosaveTimer); clearTimeout(card._autosaveTimer);
setSaveIndicator(card, 'saving'); 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; if (!isNew) body.id = post.id;
const r = await fetch('/api/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const r = await fetch('/api/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });