diff --git a/AGENTS.md b/AGENTS.md index 5fbaaed..860095a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,6 +122,20 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo - **История версий**: `GET /api/lesson-reports/:id/versions`, восстановление — `POST /api/lesson-reports/:id/versions/:versionId/restore`, откат к тексту тьютора — `POST /api/lesson-reports/:id/ai/revert`. Хранится последние `LESSON_AI_VERSION_LIMIT` версий на отчёт - **Хуки фронтенда**: `openLessonVersions(id)` и `restoreLessonVersion(...)` живут в `public/admin.js` (модалка доступна с журнала, отчётов и дашборда), список и бейджи статусов — в `public/js/lessons.js` +### 3e. Дата, время и часовой пояс (`server.js`, `public/js/datetime.js`) +- **Настройки**: `timezone` (IANA, валидируется через `validTimezone`) и `time_format` (`'24h'` | `'12h'`). Сид — в `db/init.sql` + `db/migration.sql`. `DEFAULT_TIMEZONE` берётся из `process.env.TZ`, иначе `Europe/Moscow` +- **Единая точка**: `public/js/datetime.js` подключается на **каждой** странице (`public/*.html`) перед `admin.js`/`js/*.js`, включая публичные `share.html`, `report.html`, `index.html`. Инициализация — `await initDateTime()` (грузят `timezone`/`time_format` из `GET /api/public-settings`). На админ-страницах вызов встроен в `checkAuth()` в `admin.js` +- **Запрещено** в `public/`: прямые `toLocaleString`/`toLocaleDateString`/`toLocaleTimeString`, `new Date().getFullYear()` и `new Date().toISOString().slice(0,10)` для показа/вычисления дат. Только хелперы `datetime.js` +- **Три семейства данных — не путать**: + - *instant* (`TIMESTAMPTZ`, ISO c `Z`) → `fmtFull` / `fmtDateTime` / `fmtDateFull` / `fmtDayMonth` / `fmtDateShort` / `fmtDateLong` / `fmtTimeOnly`. Зона применяется + - *чистая DATE-строка* `'YYYY-MM-DD'` (`lesson_date`, `taken_at`, `date_from`) → `fmtDateOnlyIso` / `fmtShortIso` / `fmtDayMonthIso` / `fmtDateOnlyLongIso`. **Без `Date()`** — иначе `new Date('YYYY-MM-DD')` (UTC-полночь) сдвинет дату на день назад + - *чистая TIME-строка* `'HH:MM(:SS)'` (`lesson_time`, `time_start`/`time_end`) → `fmtHmStr`. Учитывает 12h/24h, зону не применяет +- **Текущие значения**: `todayIso()`, `nowHm()` (всегда 24h — для ``), `nowYear()`, `todayDow()`, `isoAddDays(iso, n)` (арифметика по ISO без зоны) +- **Границы дней в SQL**: `TIMESTAMPTZ`-колонки фильтруются **только** через `tzDayStart`/`tzDayEnd` + `bindTz` (плейсхолдер `$TZ$` → `$N`, зона добавляется в `params` последней). Прямой `$n::date` по `created_at` считает дни в UTC (у контейнера `TimeZone=UTC`) и молча ломает границы — такого кода быть не должно. `DATE`-колонки (`lr.lesson_date`) сравниваются напрямую, зона не нужна +- **`now()` по зоне**: `tzWall()` для «сейчас» и для `day_of_week`/расписания. Хардкод `'Europe/Moscow'` в SQL запрещён +- **`bindTz` добавляет параметр только если в SQL есть `$TZ$`**: иначе Postgres отвечает `bind message supplies 1 parameters, but prepared statement requires 0`, а без global error handler запрос **висит вечно** (страница остаётся «Загрузка...»). Поэтому запрос с фильтрами дат работает, а без них — падает: проверяй оба варианта. Регрессия закрыта в `api.smoketest.js` +- **`TZ` в compose** (`docker-compose.yml`, 5 мест) — только фолбэк для `DEFAULT_TIMEZONE`; фактическая зона берётся из настройки + ### 4. API Patterns - **Middleware**: `requireAuth` — читает `X-Auth-Token`, 401 без валидной активной сессии. `requireAdmin` — самодостаточный (внутри вызывает `requireAuth`, если `req.user` ещё нет), 403 при `role !== 'admin'`. `optionalAuth` — для публичных страниц с персонализацией - **Филиалы**: `branchScope(user)` / `branchWhere(user, alias)` — для не-admin `user.branch_ids` (из `user_branches`) ограничивают выборку; у `admin` `ids = null` и фильтр не добавляется @@ -310,6 +324,7 @@ guards against. | `Dockerfile` | App image build | | `public/*.html` | Frontend pages | | `public/admin.js` | Shared frontend logic, модалка отчёта о занятии (`openLessonModal`) | +| `public/js/datetime.js` | Единая точка форматирования дат/времени: часовой пояс + 24h/12h. Подключается на всех страницах | | `public/lessons.html` | Отчёты о занятиях: список, фильтры, редактирование | | `scripts/backup.sh` | Host-level backup script (DB dump + storage export) | | `scripts/restore.sh` | Host-level restore script (DB dump + storage import) | diff --git a/api.smoketest.js b/api.smoketest.js index 0577b5a..2075878 100644 --- a/api.smoketest.js +++ b/api.smoketest.js @@ -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)); diff --git a/db/init.sql b/db/init.sql index 6e73438..7f2ef8a 100644 --- a/db/init.sql +++ b/db/init.sql @@ -131,6 +131,10 @@ INSERT INTO settings (key, value) VALUES ('camera_enabled', 'true') ON CONFLICT (key) DO NOTHING; INSERT INTO settings (key, value) VALUES ('trash_purge_days', '30') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('timezone', 'Europe/Moscow') +ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('time_format', '24h') +ON CONFLICT (key) DO NOTHING; CREATE TABLE IF NOT EXISTS share_links ( id SERIAL PRIMARY KEY, diff --git a/db/migration.sql b/db/migration.sql index 43ce1bd..644b313 100644 --- a/db/migration.sql +++ b/db/migration.sql @@ -429,3 +429,5 @@ WHERE key = 'lesson_ai_prompt' AND value IN ( ); INSERT INTO settings (key, value) VALUES ('notify_lesson_ai_formatted', 'false') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('timezone', 'Europe/Moscow') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('time_format', '24h') ON CONFLICT (key) DO NOTHING; diff --git a/public/admin.js b/public/admin.js index 141ea79..2fdca0f 100644 --- a/public/admin.js +++ b/public/admin.js @@ -18,6 +18,7 @@ async function checkAuth() { const res = await fetch(`${API}/api/auth/me`, { headers: hdr() }); if (!res.ok) { cleanupSession(); return false; } currentUser = await res.json(); + await initDateTime(); return true; } catch { cleanupSession(); return false; } } @@ -199,7 +200,7 @@ function notifyTime(ts) { if (diff < 60000) return 'только что'; if (diff < 3600000) return `${Math.floor(diff / 60000)} мин назад`; if (diff < 86400000) return `${Math.floor(diff / 3600000)} ч назад`; - return d.toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); + return fmtDateTime(d); } function setNotifyUnread(n) { @@ -490,13 +491,11 @@ let lessonModalGroups = null; let lessonModalGroupsPromise = null; function lessonTodayIso() { - const d = new Date(); - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + return todayIso(); } function lessonNowTime() { - const d = new Date(); - return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; + return nowHm(); } async function loadLessonGroups() { diff --git a/public/audit.html b/public/audit.html index 1eae9e7..fbf9c66 100644 --- a/public/audit.html +++ b/public/audit.html @@ -54,6 +54,7 @@
+