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
+21 -12
View File
@@ -24,9 +24,10 @@ function loadBrand(){
function esc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#039;')}
function pad(n){return String(n).padStart(2,'0')}
function toDate(v){if(!v)return null;const d=new Date(v);return isNaN(d.getTime())?null:d}
function fmtDate(v){const d=toDate(v);return d?pad(d.getDate())+'.'+pad(d.getMonth()+1)+'.'+d.getFullYear():''}
function fmtShort(v){const d=toDate(v);return d?pad(d.getDate())+'.'+pad(d.getMonth()+1)+'.'+String(d.getFullYear()).slice(2):''}
function fmtLong(v){const d=toDate(v);return d?d.toLocaleDateString('ru-RU',{day:'numeric',month:'long',year:'numeric'}):''}
function dtIsDateOnly(v){return typeof v==='string'&&/^\d{4}-\d{2}-\d{2}/.test(v)}
function fmtDate(v){return dtIsDateOnly(v)?fmtDateOnlyIso(v):(v?fmtDateFull(v):'')}
function fmtShort(v){return dtIsDateOnly(v)?fmtShortIso(v):(v?fmtDateShort(v):'')}
function fmtLong(v){return dtIsDateOnly(v)?fmtDateOnlyLongIso(v):(v?fmtDateLong(v):'')}
function plural(n,one,few,many){const m10=n%10,m100=n%100;if(m100>=11&&m100<=14)return many;if(m10===1)return one;if(m10>=2&&m10<=4)return few;return many}
function words(n,one,few,many){return n+' '+plural(n,one,few,many)}
function isImageFile(name){return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name||'')}
@@ -41,14 +42,19 @@ function hideEl(el,on){if(el)el.classList.toggle('rep-hidden',!!on)}
function node(html){const t=document.createElement('template');t.innerHTML=html.trim();return t.content.firstElementChild}
function periodLabel(from,to){
if(!from&&!to)return '';
const a=toDate(from),b=toDate(to);
if(a&&b)return fmtDate(a)+' — '+fmtDate(b);
return fmtDate(a)||fmtDate(b);
if(from&&to)return fmtDate(from)+' — '+fmtDate(to);
return fmtDate(from)||fmtDate(to);
}
function dtIsoYear(v){
if(!v)return null;
if(dtIsDateOnly(v))return String(v).slice(0,4);
const d=toDate(v);
if(!d)return null;
try{return new Intl.DateTimeFormat('en-CA',{timeZone:DateTime.tz,year:'numeric'}).format(d).slice(0,4)}catch{return String(d.getFullYear())}
}
function yearLabel(from,to){
const a=toDate(from),b=toDate(to);
if(!a&&!b)return '';
const ay=a?a.getFullYear():null,by=b?b.getFullYear():null;
if(!from&&!to)return '';
const ay=dtIsoYear(from),by=dtIsoYear(to);
if(ay&&by)return ay===by?String(ay):ay+' — '+String(by).slice(2);
return String(ay||by);
}
@@ -268,12 +274,15 @@ function statSep(variant){
const extra=variant===1?' list__item--u-ip5wdmggw':(variant===2?' list__item--u-ixebr6v8q':'');
return '<div class="list__item list__item--u-isd3lg3iz'+extra+'"></div>';
}
function dtYearMonth(d){
try{return new Intl.DateTimeFormat('en-CA',{timeZone:DateTime.tz,year:'numeric',month:'2-digit'}).format(d).slice(0,7)}catch{return d.getFullYear()+'-'+d.getMonth()}
}
function renderAbout(data,pool){
const entries=data.entries||[];
const photos=(data.photos||[]).length;
const files=entries.reduce(function(n,e){return n+((e.files||[]).length)},0);
const months={};
entries.forEach(function(e){const d=toDate(e.created_at);if(d)months[d.getFullYear()+'-'+d.getMonth()]=1});
entries.forEach(function(e){const d=toDate(e.created_at);if(d)months[dtYearMonth(d)]=1});
const photosTotal=entries.filter(function(e){return e.photo_path}).length+photos;
setText('aboutTitle',(data.name&&String(data.name).trim())||'Отчёт');
setText('aboutLead',(data.message&&String(data.message).trim())||('Мы собрали '+words(entries.length,'запись','записи','записей')+' о работе за этот период — с фотографиями, файлами и описанием каждого занятия.'));
@@ -461,7 +470,7 @@ function renderFooter(data,heading,pool){
const label=btn.querySelector('.text-block-wrap-div');
if(label)label.textContent='Перейти по ссылке';
}
setText('footCopy',state.systemName+' © '+new Date().getFullYear());
setText('footCopy',state.systemName+' © '+nowYear());
setText('footBrand',heading);
const fbox=document.getElementById('footerPhotoBox');
const fimg=document.getElementById('footerPhoto');
@@ -563,5 +572,5 @@ function bindEvents(){
bindEvents();
makeNavLinks();
loadBrand().then(function(){loadReport()});
initDateTime().then(function(){return loadBrand()}).then(function(){loadReport()});
function closeLightbox(){const lb=document.getElementById('lightbox');if(lb)lb.classList.remove('is-open')}