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
View File
@@ -123,6 +123,27 @@ async function main() {
const restoredCheck = await api('/api/public-settings');
ok('cache invalidation: restore visible', restoredCheck.data.system_name === before.data.system_name, restoredCheck.data.system_name);
const tzBad = await api('/api/settings', { token, method: 'PUT', body: { settings: { timezone: 'Nope/Nope' } } });
ok('timezone: невалидная зона отклонена', tzBad.status === 400, tzBad.status);
const fmtBad = await api('/api/settings', { token, method: 'PUT', body: { settings: { time_format: '36h' } } });
ok('time_format: невалидный формат отклонён', fmtBad.status === 400, fmtBad.status);
const pubTz = await api('/api/public-settings');
ok('public-settings отдаёт timezone/time_format', !!pubTz.data.timezone && ['24h', '12h'].includes(pubTz.data.time_format), {
timezone: pubTz.data.timezone,
time_format: pubTz.data.time_format,
});
const noFilter = await api('/api/files?limit=5', { token });
ok('files без фильтров: 200 (без висящих bind-параметров)', noFilter.status === 200, noFilter.status);
const withFilter = await api('/api/files?limit=5&date_from=2026-01-01&date_to=2026-12-31', { token });
ok('files с фильтром дат: 200', withFilter.status === 200, withFilter.status);
const entriesNoFilter = await api('/api/entries?limit=5', { token });
ok('entries без фильтров: 200', entriesNoFilter.status === 200, entriesNoFilter.status);
const entriesWithFilter = await api('/api/entries?limit=5&date_from=2026-01-01&date_to=2026-12-31', { token });
ok('entries с фильтром дат: 200', entriesWithFilter.status === 200, entriesWithFilter.status);
const photosFilter = await api('/api/photos?limit=5&date_from=2026-01-01&date_to=2026-12-31', { token });
ok('photos с фильтром дат: 200', photosFilter.status === 200, photosFilter.status);
const groupCountBefore = Array.isArray(groups.data) ? groups.data.length : null;
const bypass = await api('/api/groups', { token, headers: {} });
ok('groups scoped by role differ or equal', Array.isArray(bypass.data));