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
+86 -4
View File
@@ -1,6 +1,6 @@
const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'lessonAiPrompt', 'lessonAiEnabled', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled', 'photoAiFaceMode', 'photoAiFaceModel', 'photoAiDevicePref', 'trashPurgeDays', 'notifyEnabled', 'notifyRetentionDays'];
const DIRTY_FIELDS = ['systemName', 'timezone', 'timeFormat', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'lessonAiPrompt', 'lessonAiEnabled', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled', 'photoAiFaceMode', 'photoAiFaceModel', 'photoAiDevicePref', 'trashPurgeDays', 'notifyEnabled', 'notifyRetentionDays'];
const NOTIFY_IDS = [];
const SECTION_IDS = ['sec-system', 'sec-stack', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-lesson-ai', 'sec-backup', 'sec-trash', 'sec-form', 'sec-notify', 'sec-photo', 'sec-photo-ai', 'sec-bans'];
const SECTION_IDS = ['sec-system', 'sec-stack', 'sec-brand', 'sec-datetime', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-lesson-ai', 'sec-backup', 'sec-trash', 'sec-form', 'sec-notify', 'sec-photo', 'sec-photo-ai', 'sec-bans'];
const SPAM_MAX = 10080;
const BAN_REASONS = {
'honeypot': 'Антиспам-поле',
@@ -14,6 +14,67 @@ let logoOriginal = '';
let currentSystemName = '';
let photoAiService = null;
function fillTimezoneOptions() {
const sel = document.getElementById('timezone');
if (!sel || sel.options.length) return;
let zones = [];
try { zones = Intl.supportedValuesOf('timeZone'); } catch { zones = []; }
if (!zones.length) zones = [DateTime.tz || 'Europe/Moscow'];
const browserTz = (() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { return ''; } })();
const current = sel.dataset.value || DateTime.tz;
sel.innerHTML = zones.map(z => {
const off = tzOffsetLabel(z);
const label = off ? `${z} (UTC${off})` : z;
return `<option value="${esc(z)}">${esc(label)}</option>`;
}).join('');
const extra = browserTz && !zones.includes(browserTz) ? [browserTz] : [];
extra.forEach(z => { sel.insertAdjacentHTML('afterbegin', `<option value="${esc(z)}">${esc(z)}</option>`); });
if (current) sel.value = current;
}
function tzOffsetLabel(tz) {
try {
const parts = new Intl.DateTimeFormat('ru-RU', { timeZone: tz, timeZoneName: 'longOffset' }).formatToParts(new Date());
const name = parts.find(p => p.type === 'timeZoneName');
return name ? String(name.value).replace('GMT', '') : '';
} catch { return ''; }
}
function previewTz() {
const sel = document.getElementById('timezone');
return sel && dtValidTz(sel.value) ? sel.value : DateTime.tz;
}
function previewHour12() {
const el = document.getElementById('timeFormat');
return !!el && el.value === '12h';
}
function renderTimePreview() {
const box = document.getElementById('timePreview');
if (!box) return;
const tz = previewTz();
const h12 = previewHour12();
const now = new Date();
const sample = new Date(now.getTime() + 3661 * 1000);
box.innerHTML = `<span class="chip">Сейчас: ${esc(fmtIn(sample, tz, h12))}</span>`
+ `<span class="chip-sub">${esc(fmtIn(now, tz, h12))}</span>`
+ `<span class="chip-sub">Расписание 18:30 → ${esc(fmtHmIn('18:30:00', h12))}</span>`
+ `<span class="chip-sub">Отчёт за занятие 09:05 → ${esc(fmtHmIn('09:05:00', h12))}</span>`;
}
function fmtIn(date, tz, h12) {
return new Intl.DateTimeFormat('ru-RU', { timeZone: tz, day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', hour12: h12 }).format(date);
}
function fmtHmIn(v, h12) {
const saved = DateTime.hour12;
DateTime.hour12 = h12;
const out = fmtHmStr(v);
DateTime.hour12 = saved;
return out;
}
function renderLogoPreview() {
const img = document.getElementById('logoPreviewImg');
const fb = document.getElementById('logoPreviewFallback');
@@ -89,6 +150,15 @@ function humanizeMinutes(min) {
return parts.join(' ');
}
function bindTimePreview() {
['timezone', 'timeFormat'].forEach(id => {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener('change', renderTimePreview);
el.addEventListener('input', renderTimePreview);
});
}
function updateDirty() {
const dirtyCount = DIRTY_FIELDS.filter(id => {
const el = document.getElementById(id);
@@ -123,6 +193,7 @@ function revertChanges() {
renderLogoPreview();
clearFieldError('spamInterval');
updateSpamUi();
renderTimePreview();
updateDirty();
showToast('Изменения отменены');
}
@@ -189,6 +260,7 @@ async function renderNotifyTypes(s) {
}
async function loadSettings() {
fillTimezoneOptions();
const [sRes, stRes] = await Promise.all([
fetch(`${API}/api/settings`, { headers: hdr() }),
fetch(`${API}/api/stats`, { headers: hdr() }),
@@ -202,6 +274,11 @@ async function loadSettings() {
renderLogoPreview();
if (s.spam_interval_min !== undefined) document.getElementById('spamInterval').value = s.spam_interval_min;
document.getElementById('trashPurgeDays').value = s.trash_purge_days !== undefined ? s.trash_purge_days : '30';
const tzSel = document.getElementById('timezone');
const storedTz = String(s.timezone || '');
tzSel.value = Array.from(tzSel.options).some(o => o.value === storedTz) ? storedTz : DateTime.tz;
document.getElementById('timeFormat').value = ['24h', '12h'].includes(s.time_format) ? s.time_format : '24h';
renderTimePreview();
document.getElementById('footerLeft').value = s.footer_left || '';
document.getElementById('footerRight').value = s.footer_right || '';
document.getElementById('aiPrompt').value = s.ai_prompt || '';
@@ -272,7 +349,7 @@ async function loadSystemInfo(btn) {
const data = await res.json();
renderSystemInfo(data);
renderStackInfo(data.stack);
const stamp = 'обновлено ' + new Date().toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' });
const stamp = 'обновлено ' + fmtTimeOnly(new Date());
const live = document.getElementById('systemLive');
if (live) live.textContent = stamp;
const stackLive = document.getElementById('stackLive');
@@ -804,6 +881,8 @@ async function saveSettings() {
headers: hdrJson(),
body: JSON.stringify({ settings: {
system_name: document.getElementById('systemName').value.trim(),
timezone: document.getElementById('timezone').value,
time_format: document.getElementById('timeFormat').value,
spam_interval_min: String(Math.round(Number(v))),
footer_left: document.getElementById('footerLeft').value.trim(),
footer_right: document.getElementById('footerRight').value.trim(),
@@ -828,6 +907,8 @@ async function saveSettings() {
} })
});
if (res.ok) {
dtApply({ timezone: document.getElementById('timezone').value, time_format: document.getElementById('timeFormat').value });
renderTimePreview();
showToast('Настройки сохранены');
logoOriginal = currentLogo;
snapshotDirty();
@@ -939,7 +1020,7 @@ async function loadBans() {
el.innerHTML = bans.map(b => `
<span class="chip ban-chip" data-chip="${esc(b.ip)}">
<b>${esc(b.ip)}</b>
<span class="chip-sub">${esc(BAN_REASONS[b.reason] || b.reason || '—')} · до ${new Date(b.banned_until).toLocaleString('ru')}</span>
<span class="chip-sub">${esc(BAN_REASONS[b.reason] || b.reason || '—')} · до ${fmtFull(b.banned_until)}</span>
<button type="button" class="unban-btn" data-unban="${esc(b.ip)}" title="Разблокировать" aria-label="Разблокировать ${esc(b.ip)}">✕</button>
</span>`).join('');
}
@@ -1091,6 +1172,7 @@ window.addEventListener('beforeunload', e => {
try {
await loadSettings();
bindDirty();
bindTimePreview();
if (authStatus) authStatus.classList.add('loaded');
} catch {
if (authStatus) { authStatus.classList.remove('loaded'); authStatus.classList.add('err'); authStatus.textContent = 'Ошибка загрузки настроек'; }