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
+79 -40
View File
@@ -1088,6 +1088,35 @@ async function trashPurgeDays() {
return Number.isFinite(v) && v >= 1 && v <= 3650 ? v : 30;
}
const DEFAULT_TIMEZONE = (() => {
const fromEnv = String(process.env.TZ || '').trim();
return validTimezone(fromEnv) ? fromEnv : 'Europe/Moscow';
})();
function validTimezone(tz) {
if (!tz || typeof tz !== 'string') return false;
try { new Intl.DateTimeFormat('ru-RU', { timeZone: tz }); return true; } catch { return false; }
}
async function appTimezone() {
const v = String(await getSetting('timezone', DEFAULT_TIMEZONE) || '').trim();
return validTimezone(v) ? v : DEFAULT_TIMEZONE;
}
async function appHour12() {
return (await getSetting('time_format', '24h')) === '12h';
}
function tzDayStart(idx) { return `($${idx}::date::timestamp AT TIME ZONE $TZ$)`; }
function tzDayEnd(idx) { return `($${idx}::date::timestamp + interval '1 day') AT TIME ZONE $TZ$`; }
function tzWall() { return `(now() AT TIME ZONE $TZ$)`; }
function bindTz(sql, params, tz) {
if (!sql.includes('$TZ$')) return { sql, params };
params.push(tz);
return { sql: sql.replace(/\$TZ\$/g, '$' + params.length), params };
}
async function purgeScheduledDeletions() {
const erefs = await pool.query('SELECT id FROM entries WHERE purge_at IS NOT NULL AND purge_at <= now()');
for (const r of erefs.rows) {
@@ -1780,8 +1809,8 @@ app.get('/api/settings', requireAdmin, async (_, res) => {
app.get('/api/public-settings', apiLimiter, async (_, res) => {
const out = await cacheWrap('public-settings', PUBLIC_TTL_MS, async () => {
const keys = ['system_name', 'system_logo', 'footer_left', 'footer_right', 'share_show_student_message', 'share_show_entry_date', 'share_show_student_names', 'share_show_group_photos', 'cookie_notice_text', 'spam_interval_min', 'photo_capture_resolution', 'photo_capture_quality', 'photo_enhance_engine', 'camera_enabled', 'photo_ai_face_mode', 'photo_ai_face_model', 'photo_ai_device_pref'];
const defaults = { system_name: 'WhatIDo', system_logo: '', spam_interval_min: '30', photo_capture_resolution: '640x480', photo_capture_quality: '0.92', photo_enhance_engine: 'auto', camera_enabled: 'true', photo_ai_face_mode: PHOTO_AI_DEFAULT_FACE_MODE, photo_ai_face_model: PHOTO_AI_FACE_MODEL, photo_ai_device_pref: 'auto' };
const keys = ['system_name', 'system_logo', 'footer_left', 'footer_right', 'share_show_student_message', 'share_show_entry_date', 'share_show_student_names', 'share_show_group_photos', 'cookie_notice_text', 'spam_interval_min', 'photo_capture_resolution', 'photo_capture_quality', 'photo_enhance_engine', 'camera_enabled', 'photo_ai_face_mode', 'photo_ai_face_model', 'photo_ai_device_pref', 'timezone', 'time_format'];
const defaults = { system_name: 'WhatIDo', system_logo: '', spam_interval_min: '30', photo_capture_resolution: '640x480', photo_capture_quality: '0.92', photo_enhance_engine: 'auto', camera_enabled: 'true', photo_ai_face_mode: PHOTO_AI_DEFAULT_FACE_MODE, photo_ai_face_model: PHOTO_AI_FACE_MODEL, photo_ai_device_pref: 'auto', timezone: DEFAULT_TIMEZONE, time_format: '24h' };
const result = {};
for (const k of keys) result[k] = await getSetting(k, defaults[k] || '');
const rm = /^(\d{2,5})x(\d{2,5})$/.exec(result.photo_capture_resolution);
@@ -1859,6 +1888,12 @@ app.put('/api/settings', requireAdmin, async (req, res) => {
if (key === 'lesson_ai_prompt' && String(value).length > 8000) {
return res.status(400).json({ error: 'lesson_ai_prompt длиннее 8000 символов' });
}
if (key === 'timezone' && !validTimezone(String(value).trim())) {
return res.status(400).json({ error: 'timezone должен быть корректным часовым поясом IANA, например Europe/Moscow' });
}
if (key === 'time_format' && !['24h', '12h'].includes(String(value))) {
return res.status(400).json({ error: 'time_format должен быть 24h или 12h' });
}
if (key.startsWith('notify_') && key !== 'notify_retention_days' && !['true', 'false'].includes(String(value))) {
return res.status(400).json({ error: `${key} должен быть true или false` });
}
@@ -2771,10 +2806,10 @@ app.get('/api/share/:token', fileLimiter, async (req, res) => {
const params = [];
if (l.group_id) { params.push(l.group_id); conditions.push(`e.group_id = $${params.length}`); }
if (l.student_name) { params.push(l.student_name); conditions.push(`e.student_name = $${params.length}`); }
if (l.date_from) { params.push(l.date_from); conditions.push(`e.created_at >= $${params.length}::date`); }
if (l.date_to) { params.push(l.date_to); conditions.push(`e.created_at < ($${params.length}::date + interval '1 day')`); }
if (l.date_from) { params.push(l.date_from); conditions.push(`e.created_at >= ${tzDayStart(params.length)}`); }
if (l.date_to) { params.push(l.date_to); conditions.push(`e.created_at < ${tzDayEnd(params.length)}`); }
conditions.push('e.deleted_at IS NULL');
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '';
const where = conditions.length ? ' WHERE ' + bindTz(conditions.join(' AND '), params, await appTimezone()).sql : '';
const { rows: entries } = await pool.query(
`SELECT e.*, g.name AS group_name FROM entries e
JOIN groups g ON g.id = e.group_id${where} ORDER BY e.created_at DESC`,
@@ -2857,14 +2892,15 @@ app.get('/api/share/:shareToken/files/:fileToken', fileLimiter, async (req, res)
const params = [];
if (l.group_id) { params.push(l.group_id); conditions.push(`e.group_id = $${params.length}`); }
if (l.student_name) { params.push(l.student_name); conditions.push(`e.student_name = $${params.length}`); }
if (l.date_from) { params.push(l.date_from); conditions.push(`e.created_at >= $${params.length}::date`); }
if (l.date_to) { params.push(l.date_to); conditions.push(`e.created_at < ($${params.length}::date + interval '1 day')`); }
if (l.date_from) { params.push(l.date_from); conditions.push(`e.created_at >= ${tzDayStart(params.length)}`); }
if (l.date_to) { params.push(l.date_to); conditions.push(`e.created_at < ${tzDayEnd(params.length)}`); }
params.push(fileToken);
const bound = bindTz(conditions.join(' AND '), params, await appTimezone());
const { rows } = await pool.query(
`SELECT pf.path, pf.name FROM project_files pf
JOIN entries e ON e.id = pf.entry_id
WHERE pf.token = $${params.length} AND ${conditions.join(' AND ')}`,
params
WHERE pf.token = $${params.length - 1} AND ${bound.sql}`,
bound.params
);
if (!rows.length) return res.status(404).json({ error: 'Not found' });
const r = rows[0];
@@ -2920,10 +2956,10 @@ app.get('/api/groups/active', apiLimiter, async (_, res) => {
AND day_of_week IS NOT NULL
AND time_start IS NOT NULL
AND time_end IS NOT NULL
AND day_of_week = EXTRACT(DOW FROM (now() AT TIME ZONE 'Europe/Moscow'))::int
AND (now() AT TIME ZONE 'Europe/Moscow')::time BETWEEN time_start AND time_end
AND day_of_week = EXTRACT(DOW FROM (now() AT TIME ZONE $1))::int
AND (now() AT TIME ZONE $1)::time BETWEEN time_start AND time_end
ORDER BY id
`);
`, [await appTimezone()]);
return rows;
});
res.json(rows);
@@ -4101,9 +4137,9 @@ app.put('/api/students/:id/photos/:pid/main', requireAuth, async (req, res) => {
res.json({ ok: true, photo_path: rows[0].photo_path });
});
function fmtLongDate(iso) {
function fmtLongDate(iso, tz) {
if (!iso) return '';
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' });
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric', timeZone: validTimezone(tz) ? tz : DEFAULT_TIMEZONE });
}
app.get('/api/export/student', requireAuth, async (req, res) => {
@@ -4141,18 +4177,18 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
const bw = branchWhere(req.user, 'g');
if (dateFrom) {
params.push(dateFrom);
conds.push(`e.created_at >= $${params.length}::date`);
conds.push(`e.created_at >= ${tzDayStart(params.length)}`);
}
if (dateTo) {
params.push(dateTo);
conds.push(`e.created_at < ($${params.length}::date + interval '1 day')`);
conds.push(`e.created_at < ${tzDayEnd(params.length)}`);
}
if (bw.params.length) {
const start = params.length + 1;
conds.push(`g.branch_id IN (${bw.params.map((_, i) => '$' + (start + i)).join(',')})`);
params.push(...bw.params);
}
const condStr = conds.join(' AND ');
const condStr = bindTz(conds.join(' AND '), params, await appTimezone()).sql;
const whereStr = ' WHERE ' + condStr;
const [studRes, entriesRes, photosRes, mainsRes, filesRes, modulesRes, groupPhotosRes] = await Promise.all([
pool.query(
@@ -4343,7 +4379,8 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
let period = null;
if (hasPeriod) {
period = `Период: ${dateFrom ? fmtLongDate(dateFrom + 'T00:00:00') : 'начало'} — ${dateTo ? fmtLongDate(dateTo + 'T00:00:00') : 'сегодня'}`;
const exportTz = await appTimezone();
period = `Период: ${dateFrom ? fmtLongDate(dateFrom + 'T00:00:00', exportTz) : 'начало'} — ${dateTo ? fmtLongDate(dateTo + 'T00:00:00', exportTz) : 'сегодня'}`;
}
const reportData = {
@@ -4438,13 +4475,13 @@ app.get('/api/groups/:id/export/files', requireAuth, async (req, res) => {
const params = [gid];
if (dateFrom) {
params.push(dateFrom);
conds.push(`e.created_at >= $${params.length}::date`);
conds.push(`e.created_at >= ${tzDayStart(params.length)}`);
}
if (dateTo) {
params.push(dateTo);
conds.push(`e.created_at < ($${params.length}::date + interval '1 day')`);
conds.push(`e.created_at < ${tzDayEnd(params.length)}`);
}
const condStr = conds.join(' AND ');
const condStr = bindTz(conds.join(' AND '), params, await appTimezone()).sql;
const [studentsRes, filesRes, photosRes, entryPhotosRes, originalsRes] = await Promise.all([
pool.query('SELECT id, name FROM students WHERE group_id = $1 ORDER BY name', [gid]),
includeFiles
@@ -4602,8 +4639,8 @@ app.get('/api/entries', requireAuth, async (req, res) => {
else conditions.push('e.deleted_at IS NULL');
if (group_id) { params.push(group_id); conditions.push(`e.group_id = $${params.length}`); }
if (module_id) { params.push(module_id); conditions.push(`e.module_id = $${params.length}`); }
if (date_from) { params.push(date_from); conditions.push(`e.created_at >= $${params.length}::date`); }
if (date_to) { params.push(date_to); conditions.push(`e.created_at < ($${params.length}::date + interval '1 day')`); }
if (date_from) { params.push(date_from); conditions.push(`e.created_at >= ${tzDayStart(params.length)}`); }
if (date_to) { params.push(date_to); conditions.push(`e.created_at < ${tzDayEnd(params.length)}`); }
if (student_name) { params.push(student_name); conditions.push(`e.student_name = $${params.length}`); }
if (search) { params.push(`%${search}%`); conditions.push(`(e.student_name ILIKE $${params.length} OR e.description ILIKE $${params.length})`); }
if (req.user.role !== 'admin') {
@@ -4618,7 +4655,7 @@ app.get('/api/entries', requireAuth, async (req, res) => {
conditions.push(`g.branch_id IN (${ph})`);
}
}
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '';
const where = conditions.length ? ' WHERE ' + bindTz(conditions.join(' AND '), params, await appTimezone()).sql : '';
const { rows: crows } = await pool.query(
`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id${where}`,
params
@@ -4838,8 +4875,8 @@ app.get('/api/files', requireAuth, async (req, res) => {
if (search) { params.push(`%${search}%`); conditions.push(`pf.name ILIKE $${params.length}`); }
if (student_name) { params.push(student_name); conditions.push(`e.student_name = $${params.length}`); }
if (group_id) { params.push(group_id); conditions.push(`e.group_id = $${params.length}`); }
if (date_from) { params.push(date_from); conditions.push(`e.created_at >= $${params.length}::date`); }
if (date_to) { params.push(date_to); conditions.push(`e.created_at < ($${params.length}::date + interval '1 day')`); }
if (date_from) { params.push(date_from); conditions.push(`e.created_at >= ${tzDayStart(params.length)}`); }
if (date_to) { params.push(date_to); conditions.push(`e.created_at < ${tzDayEnd(params.length)}`); }
if (req.user.role !== 'admin') {
if (group_id && !(await groupBelongsToBranches(req.user, group_id))) {
return res.status(403).json({ error: 'Нет доступа к этой группе' });
@@ -4852,7 +4889,7 @@ app.get('/api/files', requireAuth, async (req, res) => {
conditions.push(`g.branch_id IN (${ph})`);
}
}
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '';
const where = conditions.length ? ' WHERE ' + bindTz(conditions.join(' AND '), params, await appTimezone()).sql : '';
const { rows: crows } = await pool.query(
`SELECT count(*)::int AS n FROM project_files pf JOIN entries e ON e.id = pf.entry_id JOIN groups g ON g.id = e.group_id${where}`,
params
@@ -4975,8 +5012,8 @@ app.get('/api/photos', requireAuth, async (req, res) => {
if (search) { params.push(`%${search}%`); conditions.push(`t.title ILIKE $${params.length}`); }
if (student_name) { params.push(student_name); conditions.push(`t.student_name = $${params.length}`); }
if (group_id) { params.push(group_id); conditions.push(`t.group_id = $${params.length}`); }
if (date_from) { params.push(date_from); conditions.push(`t.created_at >= $${params.length}::date`); }
if (date_to) { params.push(date_to); conditions.push(`t.created_at < ($${params.length}::date + interval '1 day')`); }
if (date_from) { params.push(date_from); conditions.push(`t.created_at >= ${tzDayStart(params.length)}`); }
if (date_to) { params.push(date_to); conditions.push(`t.created_at < ${tzDayEnd(params.length)}`); }
if (req.user.role !== 'admin') {
if (group_id && !(await groupBelongsToBranches(req.user, group_id))) {
return res.status(403).json({ error: 'Нет доступа к этой группе' });
@@ -4989,7 +5026,7 @@ app.get('/api/photos', requireAuth, async (req, res) => {
conditions.push(`t.branch_id IN (${ph})`);
}
}
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '';
const where = conditions.length ? ' WHERE ' + bindTz(conditions.join(' AND '), params, await appTimezone()).sql : '';
const from = `FROM (
SELECT 'entry'::text AS source_type, 'Главное фото записи'::text AS source_label,
e.photo_path AS path, e.id AS source_id, e.student_name, e.group_id,
@@ -5053,6 +5090,7 @@ app.get('/api/photos', requireAuth, async (req, res) => {
app.get('/api/stats', requireAuth, async (req, res) => {
const payload = await cacheWrap('stats:' + scopeKey(req.user), STATS_TTL_MS, async () => {
const tz = await appTimezone();
const isAdmin = req.user.role === 'admin';
const s = branchScope(req.user);
let groupFilter;
@@ -5076,7 +5114,7 @@ app.get('/api/stats', requireAuth, async (req, res) => {
pool.query(`SELECT count(*)::int AS n FROM groups g${gTrash('visible')}`, groupsParams),
pool.query(`SELECT count(*)::int AS n FROM groups g${groupsWhere}`, groupsParams),
pool.query(`SELECT count(DISTINCT e.student_name)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL${groupFilter.where}`, groupFilter.params),
pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL AND e.created_at >= now()::date${groupFilter.where}`, groupFilter.params),
(() => { const p = groupFilter.params.slice(); p.push(tz); return pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL AND e.created_at >= ${tzWall()}::date${groupFilter.where}`.replace(/\$TZ\$/g, '$' + p.length), p); })(),
pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.purge_at IS NOT NULL${groupFilter.where}`, groupFilter.params),
pool.query(`SELECT count(*)::int AS n FROM groups g${gTrash('pending')}`, groupsParams),
]);
@@ -5197,6 +5235,7 @@ app.get('/api/system-info', requireAdmin, async (_, res) => {
app.get('/api/dashboard', requireAuth, async (req, res) => {
const payload = await cacheWrap('dashboard:' + scopeKey(req.user), STATS_TTL_MS, async () => {
const DAYS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
const tz = await appTimezone();
const isAdmin = req.user.role === 'admin';
const s = branchScope(req.user);
const branchIds = isAdmin ? [] : s.ids;
@@ -5213,24 +5252,24 @@ app.get('/api/dashboard', requireAuth, async (req, res) => {
pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NOT NULL${ew ? whereEntry : ''}`, ew ? whereGroupParams : []),
pool.query(`SELECT count(*)::int AS n FROM groups g${isAdmin ? '' : (branchIds.length ? ` WHERE g.branch_id IN (${branchIds.map((_, i) => `$${i + 1}`).join(',')})` : ' WHERE 1 = 0')}`, isAdmin ? [] : branchIds),
pool.query(`SELECT count(DISTINCT e.student_name)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL${ew ? whereEntry : ''}`, ew ? whereGroupParams : []),
pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL AND e.created_at >= now()::date${ew ? whereEntry : ''}`, ew ? whereGroupParams : []),
(() => { const p = (ew ? whereGroupParams : []).slice(); p.push(tz); return pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL AND e.created_at >= ${tzWall()}::date${ew ? whereEntry : ''}`.replace(/\$TZ\$/g, '$' + p.length), p); })(),
]);
return { entries: entries.rows[0].n, trash: trash.rows[0].n, groups: groups.rows[0].n, students: students.rows[0].n, today: today.rows[0].n };
})(),
pool.query(
`SELECT to_char(e.created_at, 'YYYY-MM-DD') AS d, count(*)::int AS n
`SELECT to_char(e.created_at AT TIME ZONE $TZ$, 'YYYY-MM-DD') AS d, count(*)::int AS n
FROM entries e JOIN groups g ON g.id = e.group_id
WHERE e.deleted_at IS NULL AND e.created_at >= (now() - interval '13 days')::date${whereEntry}
GROUP BY 1 ORDER BY 1`,
whereEntry ? whereGroupParams : []
WHERE e.deleted_at IS NULL AND e.created_at >= (${tzWall()} - interval '13 days')::date${whereEntry}
GROUP BY 1 ORDER BY 1`.replace(/\$TZ\$/g, '$' + (whereEntry ? whereGroupParams.length : 0) + 1),
(whereEntry ? whereGroupParams : []).concat([tz])
),
pool.query(
`SELECT g.* FROM groups g
WHERE g.day_of_week IS NOT NULL AND g.time_start IS NOT NULL AND g.time_end IS NOT NULL
AND g.day_of_week = EXTRACT(DOW FROM (now() AT TIME ZONE 'Europe/Moscow'))::int
AND (now() AT TIME ZONE 'Europe/Moscow')::time BETWEEN g.time_start AND g.time_end${whereGroup}
ORDER BY g.id`,
whereGroup ? whereGroupParams : []
AND g.day_of_week = EXTRACT(DOW FROM ${tzWall()})::int
AND ${tzWall()}::time BETWEEN g.time_start AND g.time_end${whereGroup}
ORDER BY g.id`.replace(/\$TZ\$/g, '$' + (whereGroup ? whereGroupParams.length : 0) + 1),
(whereGroup ? whereGroupParams : []).concat([tz])
),
pool.query(
`SELECT e.id, e.student_name, e.photo_path, e.created_at, g.name AS group_name