feat(datetime): timezone/time_format в настройках + единый хелпер дат

Часовой пояс и формат времени (24h/12h) перенесены из браузера в настройки
приложения: валидация IANA-зоны, сид в db/init.sql + db/migration.sql, отдача
в GET /api/public-settings, раздел #sec-datetime в настройках с живым превью.

Границы суток в SQL переведены на tzDayStart/tzDayEnd + bindTz ($TZ$) — раньше
$n::date по TIMESTAMPTZ считал дни в UTC (у контейнера TimeZone=UTC) и молча
сдвигал выборку на день; хардкод Europe/Moscow вычищен, now() заменён на
tzWall().

Новый public/js/datetime.js: три семейства хелперов (instant / чистая
DATE-строка без Date() / чистая TIME-строка), подключён на всех страницах
включая публичные share/report/index, initDateTime() встроен в checkAuth().
Прямые toLocale*/getFullYear/toISOString().slice(0,10) в public/ выпилены.

Попутно: bindTz добавляет параметр только при наличии $TZ$ в SQL (иначе запрос
виснет вечно без global error handler) — регрессия закрыта в api.smoketest.js;
починены off-by-one месяца в report.js и группировка по дате в share.js.
This commit is contained in:
dev
2026-10-04 10:49:48 +03:00
parent 19b2365b4c
commit 3c127b895e
44 changed files with 456 additions and 105 deletions
+136
View File
@@ -0,0 +1,136 @@
const DateTime = { tz: '', hour12: false, ready: false, listeners: [] };
const DT_FALLBACK_TZ = 'Europe/Moscow';
const DT_CACHE = new Map();
function dtValidTz(tz) {
if (!tz || typeof tz !== 'string') return false;
try { new Intl.DateTimeFormat('ru-RU', { timeZone: tz }); return true; } catch { return false; }
}
function dtBrowserTz() {
try { return Intl.DateTimeFormat().resolvedOptions().timeZone || DT_FALLBACK_TZ; } catch { return DT_FALLBACK_TZ; }
}
function dtFmt(opts) {
const key = DateTime.tz + '|' + (DateTime.hour12 ? '12' : '24') + '|' + JSON.stringify(opts);
let f = DT_CACHE.get(key);
if (!f) {
f = new Intl.DateTimeFormat('ru-RU', Object.assign({ timeZone: DateTime.tz, hour12: DateTime.hour12 }, opts));
DT_CACHE.set(key, f);
}
return f;
}
function dtDate(v) {
if (!v) return null;
const d = v instanceof Date ? v : new Date(v);
return Number.isNaN(d.getTime()) ? null : d;
}
function fmtDt(v, opts) {
const d = dtDate(v);
return d ? dtFmt(opts).format(d) : '';
}
function dtFmtParts() {
return { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' };
}
function fmtFull(v) { return fmtDt(v, dtFmtParts()); }
function fmtDateTime(v) { return fmtDt(v, { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); }
function fmtDateTimeY(v) { return fmtDt(v, { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }); }
function fmtDateFull(v) { return fmtDt(v, { day: '2-digit', month: '2-digit', year: 'numeric' }); }
function fmtDayMonth(v) { return fmtDt(v, { day: '2-digit', month: '2-digit' }); }
function fmtDateShort(v) { return fmtDt(v, { day: '2-digit', month: '2-digit', year: '2-digit' }); }
function fmtDateLong(v) { return fmtDt(v, { day: 'numeric', month: 'long', year: 'numeric' }); }
function fmtTimeOnly(v) { return fmtDt(v, { hour: '2-digit', minute: '2-digit' }); }
function dtIsoMatch(v) {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(v == null ? '' : v));
return m ? { y: m[1], m: m[2], d: m[3] } : null;
}
function dtPad(n) { return String(n).padStart(2, '0'); }
function fmtDateOnlyIso(v) {
const p = dtIsoMatch(v);
return p ? `${p.d}.${p.m}.${p.y}` : '';
}
function fmtDayMonthIso(v) {
const p = dtIsoMatch(v);
return p ? `${p.d}.${p.m}` : '';
}
function fmtShortIso(v) {
const p = dtIsoMatch(v);
return p ? `${p.d}.${p.m}.${p.y.slice(2)}` : '';
}
function fmtHmStr(v) {
const m = /^(\d{1,2}):(\d{2})/.exec(String(v == null ? '' : v));
if (!m) return '';
const h = Number(m[1]);
const mm = m[2];
if (!DateTime.hour12) return `${dtPad(h)}:${mm}`;
const ap = h < 12 ? 'AM' : 'PM';
const h12 = h % 12 === 0 ? 12 : h % 12;
return `${h12}:${mm} ${ap}`;
}
function fmtDateOnlyLongIso(v) {
const p = dtIsoMatch(v);
if (!p) return '';
const months = ['января','февраля','марта','апреля','мая','июня','июля','августа','сентября','октября','ноября','декабря'];
return `${Number(p.d)} ${months[Number(p.m) - 1] || ''} ${p.y}`;
}
function dtZoneDate(d) {
try { return new Intl.DateTimeFormat('en-CA', { timeZone: DateTime.tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(d); } catch { return ''; }
}
function todayIso() { return dtZoneDate(new Date()); }
function todayDow() {
const names = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
try { return names.indexOf(new Intl.DateTimeFormat('en-US', { timeZone: DateTime.tz, weekday: 'short' }).format(new Date())); } catch { return new Date().getDay(); }
}
function nowHm() {
try { return new Intl.DateTimeFormat('en-GB', { timeZone: DateTime.tz, hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date()); } catch { return ''; }
}
function nowYear() {
try { return new Intl.DateTimeFormat('en-CA', { timeZone: DateTime.tz, year: 'numeric' }).format(new Date()); } catch { return String(new Date().getFullYear()); }
}
function isoAddDays(iso, n) {
const p = dtIsoMatch(iso);
if (!p) return '';
const d = new Date(Date.UTC(Number(p.y), Number(p.m) - 1, Number(p.d)));
d.setUTCDate(d.getUTCDate() + n);
return d.toISOString().slice(0, 10);
}
function dtApply(s) {
if (!s || typeof s !== 'object') return;
if (dtValidTz(s.timezone)) DateTime.tz = s.timezone;
DateTime.hour12 = s.time_format === '12h';
DateTime.listeners.forEach(fn => { try { fn(DateTime); } catch {} });
}
async function initDateTime() {
DateTime.tz = dtBrowserTz();
DateTime.hour12 = false;
try {
const res = await fetch('/api/public-settings');
if (res.ok) dtApply(await res.json());
} catch {}
DateTime.ready = true;
return DateTime;
}
function onDateTimeChange(fn) {
if (typeof fn === 'function') DateTime.listeners.push(fn);
}