Часовой пояс и формат времени (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.
136 lines
4.7 KiB
JavaScript
136 lines
4.7 KiB
JavaScript
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);
|
|
} |