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
+7 -7
View File
@@ -30,22 +30,22 @@ function renderStats(s, disk) {
function last14days() {
const out = [];
for (let i = 13; i >= 0; i--) { const d = new Date(); d.setDate(d.getDate() - i); out.push(d); }
const base = todayIso();
for (let i = 13; i >= 0; i--) out.push(isoAddDays(base, -i));
return out;
}
function renderActivity(activity) {
const map = {};
activity.forEach(a => { map[a.d] = a.n; });
const days = last14days().map(d => {
const iso = d.toISOString().slice(0, 10);
return { iso, label: d.toLocaleDateString('ru', { day: '2-digit', month: '2-digit' }), n: map[iso] || 0 };
const days = last14days().map(iso => {
return { iso, label: fmtDayMonthIso(iso), n: map[iso] || 0 };
});
const max = Math.max(1, ...days.map(x => x.n));
const todayIso = new Date().toISOString().slice(0, 10);
const todayKey = todayIso();
document.getElementById('activityChart').innerHTML = days.map(x => `
<div class="bar-col" title="${x.iso} — ${x.n} ${plural(x.n, 'запись', 'записи', 'записей')}">
<div class="bar ${x.iso === todayIso ? 'today' : ''}" style="height:${Math.round(x.n / max * 100)}%"></div>
<div class="bar ${x.iso === todayKey ? 'today' : ''}" style="height:${Math.round(x.n / max * 100)}%"></div>
<div class="bar-day">${x.label}</div>
</div>`).join('');
}
@@ -60,7 +60,7 @@ function renderRecent(rows) {
: `<div class="thumb"><i data-lucide="graduation-cap"></i></div>`}
<div class="r-info">
<div class="r-name">${esc(e.student_name)}</div>
<div class="r-meta">${esc(e.group_name)} · ${new Date(e.created_at).toLocaleString('ru', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}</div>
<div class="r-meta">${esc(e.group_name)} · ${fmtDateTime(e.created_at)}</div>
</div>
<a class="r-link" href="journal.html" title="В журнал">→</a>
</div>`).join('');