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
+15
View File
@@ -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` версий на отчёт - **История версий**: `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` - **Хуки фронтенда**: `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 — для `<input type="time">`), `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 ### 4. API Patterns
- **Middleware**: `requireAuth` — читает `X-Auth-Token`, 401 без валидной активной сессии. `requireAdmin` — самодостаточный (внутри вызывает `requireAuth`, если `req.user` ещё нет), 403 при `role !== 'admin'`. `optionalAuth` — для публичных страниц с персонализацией - **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` и фильтр не добавляется - **Филиалы**: `branchScope(user)` / `branchWhere(user, alias)` — для не-admin `user.branch_ids` (из `user_branches`) ограничивают выборку; у `admin` `ids = null` и фильтр не добавляется
@@ -310,6 +324,7 @@ guards against.
| `Dockerfile` | App image build | | `Dockerfile` | App image build |
| `public/*.html` | Frontend pages | | `public/*.html` | Frontend pages |
| `public/admin.js` | Shared frontend logic, модалка отчёта о занятии (`openLessonModal`) | | `public/admin.js` | Shared frontend logic, модалка отчёта о занятии (`openLessonModal`) |
| `public/js/datetime.js` | Единая точка форматирования дат/времени: часовой пояс + 24h/12h. Подключается на всех страницах |
| `public/lessons.html` | Отчёты о занятиях: список, фильтры, редактирование | | `public/lessons.html` | Отчёты о занятиях: список, фильтры, редактирование |
| `scripts/backup.sh` | Host-level backup script (DB dump + storage export) | | `scripts/backup.sh` | Host-level backup script (DB dump + storage export) |
| `scripts/restore.sh` | Host-level restore script (DB dump + storage import) | | `scripts/restore.sh` | Host-level restore script (DB dump + storage import) |
+21
View File
@@ -123,6 +123,27 @@ async function main() {
const restoredCheck = await api('/api/public-settings'); const restoredCheck = await api('/api/public-settings');
ok('cache invalidation: restore visible', restoredCheck.data.system_name === before.data.system_name, restoredCheck.data.system_name); 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 groupCountBefore = Array.isArray(groups.data) ? groups.data.length : null;
const bypass = await api('/api/groups', { token, headers: {} }); const bypass = await api('/api/groups', { token, headers: {} });
ok('groups scoped by role differ or equal', Array.isArray(bypass.data)); ok('groups scoped by role differ or equal', Array.isArray(bypass.data));
+4
View File
@@ -131,6 +131,10 @@ INSERT INTO settings (key, value) VALUES ('camera_enabled', 'true')
ON CONFLICT (key) DO NOTHING; ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('trash_purge_days', '30') INSERT INTO settings (key, value) VALUES ('trash_purge_days', '30')
ON CONFLICT (key) DO NOTHING; 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 ( CREATE TABLE IF NOT EXISTS share_links (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
+2
View File
@@ -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 ('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;
+4 -5
View File
@@ -18,6 +18,7 @@ async function checkAuth() {
const res = await fetch(`${API}/api/auth/me`, { headers: hdr() }); const res = await fetch(`${API}/api/auth/me`, { headers: hdr() });
if (!res.ok) { cleanupSession(); return false; } if (!res.ok) { cleanupSession(); return false; }
currentUser = await res.json(); currentUser = await res.json();
await initDateTime();
return true; return true;
} catch { cleanupSession(); return false; } } catch { cleanupSession(); return false; }
} }
@@ -199,7 +200,7 @@ function notifyTime(ts) {
if (diff < 60000) return 'только что'; if (diff < 60000) return 'только что';
if (diff < 3600000) return `${Math.floor(diff / 60000)} мин назад`; if (diff < 3600000) return `${Math.floor(diff / 60000)} мин назад`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)} ч назад`; 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) { function setNotifyUnread(n) {
@@ -490,13 +491,11 @@ let lessonModalGroups = null;
let lessonModalGroupsPromise = null; let lessonModalGroupsPromise = null;
function lessonTodayIso() { function lessonTodayIso() {
const d = new Date(); return todayIso();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
} }
function lessonNowTime() { function lessonNowTime() {
const d = new Date(); return nowHm();
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
} }
async function loadLessonGroups() { async function loadLessonGroups() {
+1
View File
@@ -54,6 +54,7 @@
</div> </div>
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script> <script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script> <script src="admin.js"></script>
<script src="js/audit.js"></script> <script src="js/audit.js"></script>
</body> </body>
+1
View File
@@ -66,6 +66,7 @@
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script> <script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script> <script src="admin.js"></script>
<script src="js/bans.js"></script> <script src="js/bans.js"></script>
<style> <style>
+1
View File
@@ -64,6 +64,7 @@
</div> </div>
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script> <script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script> <script src="admin.js"></script>
<script src="js/branches.js"></script> <script src="js/branches.js"></script>
<style> <style>
+1
View File
@@ -74,6 +74,7 @@
</div> </div>
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script> <script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script> <script src="admin.js"></script>
<script src="js/dashboard.js"></script> <script src="js/dashboard.js"></script>
</body> </body>
+1
View File
@@ -37,6 +37,7 @@
</div> </div>
</div> </div>
</div> </div>
<script src="/js/datetime.js"></script>
<script src="/js/error.js"></script> <script src="/js/error.js"></script>
</body> </body>
</html> </html>
+1
View File
@@ -41,6 +41,7 @@
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script> <script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script> <script src="admin.js"></script>
<script src="js/files.js"></script> <script src="js/files.js"></script>
</body> </body>
+1
View File
@@ -136,6 +136,7 @@
</div> </div>
<script src="vendor/lucide.min.js"></script> <script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script> <script src="admin.js"></script>
<script src="vendor/jszip.min.js"></script> <script src="vendor/jszip.min.js"></script>
<script src="js/groups.js"></script> <script src="js/groups.js"></script>
+1
View File
@@ -184,6 +184,7 @@ nav a:hover{color:var(--text)}
<div class="toast" id="toast">Готово!</div> <div class="toast" id="toast">Готово!</div>
<script src="js/datetime.js"></script>
<script src="js/index.js"></script> <script src="js/index.js"></script>
</body> </body>
</html> </html>
+1
View File
@@ -265,6 +265,7 @@
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script> <script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script> <script src="admin.js"></script>
<script src="js/journal.js"></script> <script src="js/journal.js"></script>
</body> </body>
+2 -1
View File
@@ -111,7 +111,8 @@ const FIELD_LABELS = {
}; };
function fmtTime(t) { function fmtTime(t) {
try { return new Date(t).toLocaleString('ru'); } catch { return t || ''; } const f = fmtFull(t);
return f || t || '';
} }
function parseTarget(t) { function parseTarget(t) {
+2 -2
View File
@@ -41,8 +41,8 @@ function renderPage() {
<tr> <tr>
<td><b>${esc(b.ip)}</b></td> <td><b>${esc(b.ip)}</b></td>
<td><span class="reason-badge ${rcls}">${esc(reason)}</span></td> <td><span class="reason-badge ${rcls}">${esc(reason)}</span></td>
<td>${new Date(b.created_at).toLocaleString('ru')}</td> <td>${fmtFull(b.created_at)}</td>
<td>${new Date(b.banned_until).toLocaleString('ru')}</td> <td>${fmtFull(b.banned_until)}</td>
<td><button class="row-btn" data-unban="${esc(b.ip)}" title="Разблокировать">✕</button></td> <td><button class="row-btn" data-unban="${esc(b.ip)}" title="Разблокировать">✕</button></td>
</tr>`; </tr>`;
}).join(''); }).join('');
+7 -7
View File
@@ -30,22 +30,22 @@ function renderStats(s, disk) {
function last14days() { function last14days() {
const out = []; 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; return out;
} }
function renderActivity(activity) { function renderActivity(activity) {
const map = {}; const map = {};
activity.forEach(a => { map[a.d] = a.n; }); activity.forEach(a => { map[a.d] = a.n; });
const days = last14days().map(d => { const days = last14days().map(iso => {
const iso = d.toISOString().slice(0, 10); return { iso, label: fmtDayMonthIso(iso), n: map[iso] || 0 };
return { iso, label: d.toLocaleDateString('ru', { day: '2-digit', month: '2-digit' }), n: map[iso] || 0 };
}); });
const max = Math.max(1, ...days.map(x => x.n)); 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 => ` document.getElementById('activityChart').innerHTML = days.map(x => `
<div class="bar-col" title="${x.iso} — ${x.n} ${plural(x.n, 'запись', 'записи', 'записей')}"> <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 class="bar-day">${x.label}</div>
</div>`).join(''); </div>`).join('');
} }
@@ -60,7 +60,7 @@ function renderRecent(rows) {
: `<div class="thumb"><i data-lucide="graduation-cap"></i></div>`} : `<div class="thumb"><i data-lucide="graduation-cap"></i></div>`}
<div class="r-info"> <div class="r-info">
<div class="r-name">${esc(e.student_name)}</div> <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> </div>
<a class="r-link" href="journal.html" title="В журнал">→</a> <a class="r-link" href="journal.html" title="В журнал">→</a>
</div>`).join(''); </div>`).join('');
+136
View File
@@ -0,0 +1,136 @@
const DateTime = { tz: '', hour12: false, ready: false, listeners: [] };
const DT_FALLBACK_TZ = 'Europe/Moscow';
const DT_CACHE = new Map();
function dtValidTz(tz) {
if (!tz || typeof tz !== 'string') return false;
try { new Intl.DateTimeFormat('ru-RU', { timeZone: tz }); return true; } catch { return false; }
}
function dtBrowserTz() {
try { return Intl.DateTimeFormat().resolvedOptions().timeZone || DT_FALLBACK_TZ; } catch { return DT_FALLBACK_TZ; }
}
function dtFmt(opts) {
const key = DateTime.tz + '|' + (DateTime.hour12 ? '12' : '24') + '|' + JSON.stringify(opts);
let f = DT_CACHE.get(key);
if (!f) {
f = new Intl.DateTimeFormat('ru-RU', Object.assign({ timeZone: DateTime.tz, hour12: DateTime.hour12 }, opts));
DT_CACHE.set(key, f);
}
return f;
}
function dtDate(v) {
if (!v) return null;
const d = v instanceof Date ? v : new Date(v);
return Number.isNaN(d.getTime()) ? null : d;
}
function fmtDt(v, opts) {
const d = dtDate(v);
return d ? dtFmt(opts).format(d) : '';
}
function dtFmtParts() {
return { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' };
}
function fmtFull(v) { return fmtDt(v, dtFmtParts()); }
function fmtDateTime(v) { return fmtDt(v, { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); }
function fmtDateTimeY(v) { return fmtDt(v, { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }); }
function fmtDateFull(v) { return fmtDt(v, { day: '2-digit', month: '2-digit', year: 'numeric' }); }
function fmtDayMonth(v) { return fmtDt(v, { day: '2-digit', month: '2-digit' }); }
function fmtDateShort(v) { return fmtDt(v, { day: '2-digit', month: '2-digit', year: '2-digit' }); }
function fmtDateLong(v) { return fmtDt(v, { day: 'numeric', month: 'long', year: 'numeric' }); }
function fmtTimeOnly(v) { return fmtDt(v, { hour: '2-digit', minute: '2-digit' }); }
function dtIsoMatch(v) {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(v == null ? '' : v));
return m ? { y: m[1], m: m[2], d: m[3] } : null;
}
function dtPad(n) { return String(n).padStart(2, '0'); }
function fmtDateOnlyIso(v) {
const p = dtIsoMatch(v);
return p ? `${p.d}.${p.m}.${p.y}` : '';
}
function fmtDayMonthIso(v) {
const p = dtIsoMatch(v);
return p ? `${p.d}.${p.m}` : '';
}
function fmtShortIso(v) {
const p = dtIsoMatch(v);
return p ? `${p.d}.${p.m}.${p.y.slice(2)}` : '';
}
function fmtHmStr(v) {
const m = /^(\d{1,2}):(\d{2})/.exec(String(v == null ? '' : v));
if (!m) return '';
const h = Number(m[1]);
const mm = m[2];
if (!DateTime.hour12) return `${dtPad(h)}:${mm}`;
const ap = h < 12 ? 'AM' : 'PM';
const h12 = h % 12 === 0 ? 12 : h % 12;
return `${h12}:${mm} ${ap}`;
}
function fmtDateOnlyLongIso(v) {
const p = dtIsoMatch(v);
if (!p) return '';
const months = ['января','февраля','марта','апреля','мая','июня','июля','августа','сентября','октября','ноября','декабря'];
return `${Number(p.d)} ${months[Number(p.m) - 1] || ''} ${p.y}`;
}
function dtZoneDate(d) {
try { return new Intl.DateTimeFormat('en-CA', { timeZone: DateTime.tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(d); } catch { return ''; }
}
function todayIso() { return dtZoneDate(new Date()); }
function todayDow() {
const names = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
try { return names.indexOf(new Intl.DateTimeFormat('en-US', { timeZone: DateTime.tz, weekday: 'short' }).format(new Date())); } catch { return new Date().getDay(); }
}
function nowHm() {
try { return new Intl.DateTimeFormat('en-GB', { timeZone: DateTime.tz, hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date()); } catch { return ''; }
}
function nowYear() {
try { return new Intl.DateTimeFormat('en-CA', { timeZone: DateTime.tz, year: 'numeric' }).format(new Date()); } catch { return String(new Date().getFullYear()); }
}
function isoAddDays(iso, n) {
const p = dtIsoMatch(iso);
if (!p) return '';
const d = new Date(Date.UTC(Number(p.y), Number(p.m) - 1, Number(p.d)));
d.setUTCDate(d.getUTCDate() + n);
return d.toISOString().slice(0, 10);
}
function dtApply(s) {
if (!s || typeof s !== 'object') return;
if (dtValidTz(s.timezone)) DateTime.tz = s.timezone;
DateTime.hour12 = s.time_format === '12h';
DateTime.listeners.forEach(fn => { try { fn(DateTime); } catch {} });
}
async function initDateTime() {
DateTime.tz = dtBrowserTz();
DateTime.hour12 = false;
try {
const res = await fetch('/api/public-settings');
if (res.ok) dtApply(await res.json());
} catch {}
DateTime.ready = true;
return DateTime;
}
function onDateTimeChange(fn) {
if (typeof fn === 'function') DateTime.listeners.push(fn);
}
+1 -1
View File
@@ -51,7 +51,7 @@ function fileCardHTML(f) {
<div class="info"> <div class="info">
<div class="file-name"><a ${target}>${esc(f.name)}</a></div> <div class="file-name"><a ${target}>${esc(f.name)}</a></div>
${meta} ${meta}
<div class="time">${new Date(f.created_at).toLocaleString('ru')}${f.size ? ' · ' + humanSize(f.size) : ''}</div> <div class="time">${fmtFull(f.created_at)}${f.size ? ' · ' + humanSize(f.size) : ''}</div>
</div> </div>
<div class="f-actions">${action}</div> <div class="f-actions">${action}</div>
</div> </div>
+5 -6
View File
@@ -67,7 +67,7 @@ async function loadGroups() {
<button class="btn-link" data-export-urls="${g.id}" data-group-name="${esc(g.name)}" title="Скачать .url файлы учеников группы">URL</button> <button class="btn-link" data-export-urls="${g.id}" data-group-name="${esc(g.name)}" title="Скачать .url файлы учеников группы">URL</button>
<button class="btn-link" data-export-files="${g.id}" data-group-name="${esc(g.name)}" title="Скачать ZIP-архив файлов группы за период">ZIP</button> <button class="btn-link" data-export-files="${g.id}" data-group-name="${esc(g.name)}" title="Скачать ZIP-архив файлов группы за период">ZIP</button>
</div> </div>
<div class="sched-status">${schedSet ? `${DAYS[g.day_of_week]} ${g.time_start.slice(0,5)}–${g.time_end.slice(0,5)}` : 'Не настроено'}</div> <div class="sched-status">${schedSet ? `${DAYS[g.day_of_week]} ${fmtHmStr(g.time_start)}–${fmtHmStr(g.time_end)}` : 'Не настроено'}</div>
</div> </div>
</div>`; </div>`;
}).join('') + `</div>`; }).join('') + `</div>`;
@@ -162,11 +162,11 @@ async function loadGroupPhotos() {
function goPhotoPage(p) { photoPage = p; loadGroupPhotos(); } function goPhotoPage(p) { photoPage = p; loadGroupPhotos(); }
function fmtPhotoDate(d) { return d ? d.split('-').reverse().join('.') : ''; } function fmtPhotoDate(d) { return fmtDateOnlyIso(d); }
function photoHTML(p) { function photoHTML(p) {
const taken = p.taken_at ? `Дата: ${fmtPhotoDate(p.taken_at)}` : ''; const taken = p.taken_at ? `Дата: ${fmtPhotoDate(p.taken_at)}` : '';
const uploaded = `Загружено: ${new Date(p.created_at).toLocaleDateString('ru')}`; const uploaded = `Загружено: ${fmtDateFull(p.created_at)}`;
const isCover = currentCover && p.photo_path === currentCover; const isCover = currentCover && p.photo_path === currentCover;
return ` return `
<div class="photo-item" data-photo-id="${p.id}" data-photo-path="${esc(p.photo_path)}" draggable="true"> <div class="photo-item" data-photo-id="${p.id}" data-photo-path="${esc(p.photo_path)}" draggable="true">
@@ -274,7 +274,7 @@ async function exportGroup(id, name) {
e.student_name, e.student_name,
e.group_name, e.group_name,
e.description.replace(/\n/g, ' '), e.description.replace(/\n/g, ' '),
new Date(e.created_at).toLocaleString('ru'), fmtFull(e.created_at),
`${location.origin}/?nameInput=${encodeURIComponent(e.student_name)}` `${location.origin}/?nameInput=${encodeURIComponent(e.student_name)}`
]); ]);
}); });
@@ -318,8 +318,7 @@ function closeGroupFilesModal() {
} }
function todayISOLocal() { function todayISOLocal() {
const d = new Date(); return todayIso();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
} }
const GROUP_FILES_PHOTO_BOXES = ['groupFilesMainPhoto', 'groupFilesGallery', 'groupFilesOriginals']; const GROUP_FILES_PHOTO_BOXES = ['groupFilesMainPhoto', 'groupFilesGallery', 'groupFilesOriginals'];
+2 -1
View File
@@ -166,7 +166,7 @@ function pasteFileName(file) {
if (base && !/^(image|photo|picture|blob|file|screenshot|снимок|скриншот|изображение|файл)$/i.test(stem)) return base; if (base && !/^(image|photo|picture|blob|file|screenshot|снимок|скриншот|изображение|файл)$/i.test(stem)) return base;
const d = new Date(); const d = new Date();
const pad = n => String(n).padStart(2, '0'); const pad = n => String(n).padStart(2, '0');
const stamp = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`; const stamp = `${todayIso()} ${nowHm().replace(':', '-')}-${pad(d.getSeconds())}`;
return `Вставка ${stamp}${extFromFile(file)}`; return `Вставка ${stamp}${extFromFile(file)}`;
} }
@@ -281,6 +281,7 @@ async function resolveModuleId(name) {
} }
(async () => { (async () => {
await initDateTime();
try { try {
const list = await fetchModules('', 50); const list = await fetchModules('', 50);
if (list.length) { if (list.length) {
+6 -6
View File
@@ -124,7 +124,7 @@ function gridEntryHTML(e) {
<div class="group">${esc(e.group_name)}${e.module_name ? ' · ' + esc(e.module_name) : ''}</div> <div class="group">${esc(e.group_name)}${e.module_name ? ' · ' + esc(e.module_name) : ''}</div>
<div class="desc">${esc(e.description)}${aiBadge(e)}</div> <div class="desc">${esc(e.description)}${aiBadge(e)}</div>
${filesHTML(e.files)} ${filesHTML(e.files)}
<div class="time">${new Date(e.created_at).toLocaleString('ru')}</div> <div class="time">${fmtFull(e.created_at)}</div>
</div> </div>
<button class="edit" data-edit="${e.id}" title="Редактировать">✎</button> <button class="edit" data-edit="${e.id}" title="Редактировать">✎</button>
${mp ? `<button class="edit" data-enhance="${e.id}" title="Улучшить фото">✦</button>` : ''} ${mp ? `<button class="edit" data-enhance="${e.id}" title="Улучшить фото">✦</button>` : ''}
@@ -148,7 +148,7 @@ function cardEntryHTML(e) {
${filesHTML(e.files)} ${filesHTML(e.files)}
</div> </div>
<div class="foot"> <div class="foot">
<span>${new Date(e.created_at).toLocaleString('ru')}</span> <span>${fmtFull(e.created_at)}</span>
<span> <span>
<button class="edit" data-edit="${e.id}" title="Редактировать">✎</button> <button class="edit" data-edit="${e.id}" title="Редактировать">✎</button>
${mp ? `<button class="edit" data-enhance="${e.id}" title="Улучшить фото">✦</button>` : ''} ${mp ? `<button class="edit" data-enhance="${e.id}" title="Улучшить фото">✦</button>` : ''}
@@ -220,7 +220,7 @@ function setView(v) {
} }
function setDefaultDates() { function setDefaultDates() {
const today = new Date().toISOString().slice(0, 10); const today = todayIso();
document.getElementById('dateFrom').value = today; document.getElementById('dateFrom').value = today;
document.getElementById('dateTo').value = today; document.getElementById('dateTo').value = today;
} }
@@ -895,7 +895,7 @@ async function loadPhotoHistory() {
</div> </div>
<div style="flex:1;min-width:0"> <div style="flex:1;min-width:0">
<div style="font-size:.85rem">${esc(PHOTO_JOB_ACTION_LABELS[r.action] || r.action)} <div style="font-size:.85rem">${esc(PHOTO_JOB_ACTION_LABELS[r.action] || r.action)}
<span style="color:var(--muted);font-size:.75rem;margin-left:6px">${esc(PHOTO_JOB_STATUS_LABELS[r.status] || r.status)} · ${esc(new Date(time).toLocaleString('ru'))}</span> <span style="color:var(--muted);font-size:.75rem;margin-left:6px">${esc(PHOTO_JOB_STATUS_LABELS[r.status] || r.status)} · ${esc(fmtFull(time))}</span>
</div> </div>
${err} ${err}
</div> </div>
@@ -1229,7 +1229,7 @@ function renderEditAiStatus(e) {
el.style.display = 'block'; el.style.display = 'block';
const changed = e.description_ai && e.description_original && e.description_ai !== e.description_original; const changed = e.description_ai && e.description_original && e.description_ai !== e.description_original;
const parts = [AI_STATUS_LABELS[e.ai_status] || `ИИ: ${e.ai_status}`]; const parts = [AI_STATUS_LABELS[e.ai_status] || `ИИ: ${e.ai_status}`];
if (e.ai_checked_at) parts.push(new Date(e.ai_checked_at).toLocaleString('ru')); if (e.ai_checked_at) parts.push(fmtFull(e.ai_checked_at));
let html = `<span>${esc(parts.join(' · '))}</span>`; let html = `<span>${esc(parts.join(' · '))}</span>`;
if (changed) html += ` <button type="button" id="aiRevertBtn" class="ai-btn" style="font-size:.75rem" title="Вернуть исходный текст (до ИИ)">↩ Оригинал</button>`; if (changed) html += ` <button type="button" id="aiRevertBtn" class="ai-btn" style="font-size:.75rem" title="Вернуть исходный текст (до ИИ)">↩ Оригинал</button>`;
html += ` <button type="button" id="aiRecheckBtn" class="ai-btn" style="font-size:.75rem" title="Проверить заново">⟳ Проверить</button>`; html += ` <button type="button" id="aiRecheckBtn" class="ai-btn" style="font-size:.75rem" title="Проверить заново">⟳ Проверить</button>`;
@@ -1416,7 +1416,7 @@ async function exportCSV() {
e.group_name, e.group_name,
e.module_name || '', e.module_name || '',
e.description.replace(/\n/g, ' '), e.description.replace(/\n/g, ' '),
new Date(e.created_at).toLocaleString('ru'), fmtFull(e.created_at),
e.photo_path ? `${API}${e.photo_path}` : '' e.photo_path ? `${API}${e.photo_path}` : ''
]); ]);
}); });
+2 -4
View File
@@ -7,14 +7,12 @@ let groupsLoaded = false;
function lessonDateText(iso) { function lessonDateText(iso) {
const s = String(iso || '').slice(0, 10); const s = String(iso || '').slice(0, 10);
if (!s) return ''; if (!s) return '';
const d = new Date(s + 'T00:00:00'); return fmtShortIso(s) || s;
if (Number.isNaN(d.getTime())) return s;
return d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: '2-digit' });
} }
function lessonWhenText(r) { function lessonWhenText(r) {
const date = lessonDateText(r.lesson_date); const date = lessonDateText(r.lesson_date);
const time = r.lesson_time ? String(r.lesson_time).slice(0, 5) : ''; const time = r.lesson_time ? fmtHmStr(r.lesson_time) : '';
return time ? `${date}, ${time}` : date; return time ? `${date}, ${time}` : date;
} }
+2 -2
View File
@@ -41,10 +41,10 @@ async function loadLinks() {
${l.group_name ? `Группа: <b>${esc(l.group_name)}</b>` : ''} ${l.group_name ? `Группа: <b>${esc(l.group_name)}</b>` : ''}
${l.student_name ? `${l.group_name ? ' · ' : ''}Ученик: <b>${esc(l.student_name)}</b>` : ''} ${l.student_name ? `${l.group_name ? ' · ' : ''}Ученик: <b>${esc(l.student_name)}</b>` : ''}
${l.date_from || l.date_to ? ` · период: <b>${l.date_from ? l.date_from.split('-').reverse().join('.') : '…'}</b> — <b>${l.date_to ? l.date_to.split('-').reverse().join('.') : '…'}</b>` : ' · весь период'} ${l.date_from || l.date_to ? ` · период: <b>${l.date_from ? l.date_from.split('-').reverse().join('.') : '…'}</b> — <b>${l.date_to ? l.date_to.split('-').reverse().join('.') : '…'}</b>` : ' · весь период'}
${expired ? ` · <span style="color:#ef4444;font-weight:600">срок действия истёк ${new Date(l.expires_at).toLocaleDateString('ru')}</span>` : l.expires_at ? ` · истекает ${new Date(l.expires_at).toLocaleDateString('ru')}` : ''} ${expired ? ` · <span style="color:#ef4444;font-weight:600">срок действия истёк ${fmtDateFull(l.expires_at)}</span>` : l.expires_at ? ` · истекает ${fmtDateFull(l.expires_at)}` : ''}
${namesHidden ? ` · <span style="color:#f59e0b">имена скрыты</span>` : ''} ${namesHidden ? ` · <span style="color:#f59e0b">имена скрыты</span>` : ''}
${l.access_password_hash ? ` · <span style="color:#2563eb">🔒 пароль</span>` : ''} ${l.access_password_hash ? ` · <span style="color:#2563eb">🔒 пароль</span>` : ''}
· создана ${new Date(l.created_at).toLocaleString('ru')} · создана ${fmtFull(l.created_at)}
</div> </div>
</div> </div>
<div style="display:flex;gap:8px;flex-shrink:0;align-items:center"> <div style="display:flex;gap:8px;flex-shrink:0;align-items:center">
+1 -1
View File
@@ -36,7 +36,7 @@ function photoCardHTML(p) {
<span class="ph-badge">${esc(p.source_label)}</span> <span class="ph-badge">${esc(p.source_label)}</span>
<div class="ph-title">${esc(p.title || 'Без описания')}</div> <div class="ph-title">${esc(p.title || 'Без описания')}</div>
${meta.length ? `<div class="ph-meta">${meta.join(' · ')}</div>` : ''} ${meta.length ? `<div class="ph-meta">${meta.join(' · ')}</div>` : ''}
<div class="ph-time">${new Date(p.created_at).toLocaleString('ru')}</div> <div class="ph-time">${fmtFull(p.created_at)}</div>
${link ? `<a class="ph-link" href="${link}">Открыть источник →</a>` : ''} ${link ? `<a class="ph-link" href="${link}">Открыть источник →</a>` : ''}
</div> </div>
</div> </div>
+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 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 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 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 dtIsDateOnly(v){return typeof v==='string'&&/^\d{4}-\d{2}-\d{2}/.test(v)}
function fmtShort(v){const d=toDate(v);return d?pad(d.getDate())+'.'+pad(d.getMonth()+1)+'.'+String(d.getFullYear()).slice(2):''} function fmtDate(v){return dtIsDateOnly(v)?fmtDateOnlyIso(v):(v?fmtDateFull(v):'')}
function fmtLong(v){const d=toDate(v);return d?d.toLocaleDateString('ru-RU',{day:'numeric',month:'long',year:'numeric'}):''} 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 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 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||'')} 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 node(html){const t=document.createElement('template');t.innerHTML=html.trim();return t.content.firstElementChild}
function periodLabel(from,to){ function periodLabel(from,to){
if(!from&&!to)return ''; if(!from&&!to)return '';
const a=toDate(from),b=toDate(to); if(from&&to)return fmtDate(from)+' — '+fmtDate(to);
if(a&&b)return fmtDate(a)+' — '+fmtDate(b); return fmtDate(from)||fmtDate(to);
return fmtDate(a)||fmtDate(b); }
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){ function yearLabel(from,to){
const a=toDate(from),b=toDate(to); if(!from&&!to)return '';
if(!a&&!b)return ''; const ay=dtIsoYear(from),by=dtIsoYear(to);
const ay=a?a.getFullYear():null,by=b?b.getFullYear():null;
if(ay&&by)return ay===by?String(ay):ay+' — '+String(by).slice(2); if(ay&&by)return ay===by?String(ay):ay+' — '+String(by).slice(2);
return String(ay||by); 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':''); 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>'; 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){ function renderAbout(data,pool){
const entries=data.entries||[]; const entries=data.entries||[];
const photos=(data.photos||[]).length; const photos=(data.photos||[]).length;
const files=entries.reduce(function(n,e){return n+((e.files||[]).length)},0); const files=entries.reduce(function(n,e){return n+((e.files||[]).length)},0);
const months={}; 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; const photosTotal=entries.filter(function(e){return e.photo_path}).length+photos;
setText('aboutTitle',(data.name&&String(data.name).trim())||'Отчёт'); setText('aboutTitle',(data.name&&String(data.name).trim())||'Отчёт');
setText('aboutLead',(data.message&&String(data.message).trim())||('Мы собрали '+words(entries.length,'запись','записи','записей')+' о работе за этот период — с фотографиями, файлами и описанием каждого занятия.')); 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'); const label=btn.querySelector('.text-block-wrap-div');
if(label)label.textContent='Перейти по ссылке'; if(label)label.textContent='Перейти по ссылке';
} }
setText('footCopy',state.systemName+' © '+new Date().getFullYear()); setText('footCopy',state.systemName+' © '+nowYear());
setText('footBrand',heading); setText('footBrand',heading);
const fbox=document.getElementById('footerPhotoBox'); const fbox=document.getElementById('footerPhotoBox');
const fimg=document.getElementById('footerPhoto'); const fimg=document.getElementById('footerPhoto');
@@ -563,5 +572,5 @@ function bindEvents(){
bindEvents(); bindEvents();
makeNavLinks(); 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')} function closeLightbox(){const lb=document.getElementById('lightbox');if(lb)lb.classList.remove('is-open')}
+86 -4
View File
@@ -1,6 +1,6 @@
const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'lessonAiPrompt', 'lessonAiEnabled', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled', 'photoAiFaceMode', 'photoAiFaceModel', 'photoAiDevicePref', 'trashPurgeDays', 'notifyEnabled', 'notifyRetentionDays']; const DIRTY_FIELDS = ['systemName', 'timezone', 'timeFormat', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'lessonAiPrompt', 'lessonAiEnabled', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled', 'photoAiFaceMode', 'photoAiFaceModel', 'photoAiDevicePref', 'trashPurgeDays', 'notifyEnabled', 'notifyRetentionDays'];
const NOTIFY_IDS = []; const NOTIFY_IDS = [];
const SECTION_IDS = ['sec-system', 'sec-stack', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-lesson-ai', 'sec-backup', 'sec-trash', 'sec-form', 'sec-notify', 'sec-photo', 'sec-photo-ai', 'sec-bans']; const SECTION_IDS = ['sec-system', 'sec-stack', 'sec-brand', 'sec-datetime', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-lesson-ai', 'sec-backup', 'sec-trash', 'sec-form', 'sec-notify', 'sec-photo', 'sec-photo-ai', 'sec-bans'];
const SPAM_MAX = 10080; const SPAM_MAX = 10080;
const BAN_REASONS = { const BAN_REASONS = {
'honeypot': 'Антиспам-поле', 'honeypot': 'Антиспам-поле',
@@ -14,6 +14,67 @@ let logoOriginal = '';
let currentSystemName = ''; let currentSystemName = '';
let photoAiService = null; let photoAiService = null;
function fillTimezoneOptions() {
const sel = document.getElementById('timezone');
if (!sel || sel.options.length) return;
let zones = [];
try { zones = Intl.supportedValuesOf('timeZone'); } catch { zones = []; }
if (!zones.length) zones = [DateTime.tz || 'Europe/Moscow'];
const browserTz = (() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { return ''; } })();
const current = sel.dataset.value || DateTime.tz;
sel.innerHTML = zones.map(z => {
const off = tzOffsetLabel(z);
const label = off ? `${z} (UTC${off})` : z;
return `<option value="${esc(z)}">${esc(label)}</option>`;
}).join('');
const extra = browserTz && !zones.includes(browserTz) ? [browserTz] : [];
extra.forEach(z => { sel.insertAdjacentHTML('afterbegin', `<option value="${esc(z)}">${esc(z)}</option>`); });
if (current) sel.value = current;
}
function tzOffsetLabel(tz) {
try {
const parts = new Intl.DateTimeFormat('ru-RU', { timeZone: tz, timeZoneName: 'longOffset' }).formatToParts(new Date());
const name = parts.find(p => p.type === 'timeZoneName');
return name ? String(name.value).replace('GMT', '') : '';
} catch { return ''; }
}
function previewTz() {
const sel = document.getElementById('timezone');
return sel && dtValidTz(sel.value) ? sel.value : DateTime.tz;
}
function previewHour12() {
const el = document.getElementById('timeFormat');
return !!el && el.value === '12h';
}
function renderTimePreview() {
const box = document.getElementById('timePreview');
if (!box) return;
const tz = previewTz();
const h12 = previewHour12();
const now = new Date();
const sample = new Date(now.getTime() + 3661 * 1000);
box.innerHTML = `<span class="chip">Сейчас: ${esc(fmtIn(sample, tz, h12))}</span>`
+ `<span class="chip-sub">${esc(fmtIn(now, tz, h12))}</span>`
+ `<span class="chip-sub">Расписание 18:30 → ${esc(fmtHmIn('18:30:00', h12))}</span>`
+ `<span class="chip-sub">Отчёт за занятие 09:05 → ${esc(fmtHmIn('09:05:00', h12))}</span>`;
}
function fmtIn(date, tz, h12) {
return new Intl.DateTimeFormat('ru-RU', { timeZone: tz, day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', hour12: h12 }).format(date);
}
function fmtHmIn(v, h12) {
const saved = DateTime.hour12;
DateTime.hour12 = h12;
const out = fmtHmStr(v);
DateTime.hour12 = saved;
return out;
}
function renderLogoPreview() { function renderLogoPreview() {
const img = document.getElementById('logoPreviewImg'); const img = document.getElementById('logoPreviewImg');
const fb = document.getElementById('logoPreviewFallback'); const fb = document.getElementById('logoPreviewFallback');
@@ -89,6 +150,15 @@ function humanizeMinutes(min) {
return parts.join(' '); return parts.join(' ');
} }
function bindTimePreview() {
['timezone', 'timeFormat'].forEach(id => {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener('change', renderTimePreview);
el.addEventListener('input', renderTimePreview);
});
}
function updateDirty() { function updateDirty() {
const dirtyCount = DIRTY_FIELDS.filter(id => { const dirtyCount = DIRTY_FIELDS.filter(id => {
const el = document.getElementById(id); const el = document.getElementById(id);
@@ -123,6 +193,7 @@ function revertChanges() {
renderLogoPreview(); renderLogoPreview();
clearFieldError('spamInterval'); clearFieldError('spamInterval');
updateSpamUi(); updateSpamUi();
renderTimePreview();
updateDirty(); updateDirty();
showToast('Изменения отменены'); showToast('Изменения отменены');
} }
@@ -189,6 +260,7 @@ async function renderNotifyTypes(s) {
} }
async function loadSettings() { async function loadSettings() {
fillTimezoneOptions();
const [sRes, stRes] = await Promise.all([ const [sRes, stRes] = await Promise.all([
fetch(`${API}/api/settings`, { headers: hdr() }), fetch(`${API}/api/settings`, { headers: hdr() }),
fetch(`${API}/api/stats`, { headers: hdr() }), fetch(`${API}/api/stats`, { headers: hdr() }),
@@ -202,6 +274,11 @@ async function loadSettings() {
renderLogoPreview(); renderLogoPreview();
if (s.spam_interval_min !== undefined) document.getElementById('spamInterval').value = s.spam_interval_min; if (s.spam_interval_min !== undefined) document.getElementById('spamInterval').value = s.spam_interval_min;
document.getElementById('trashPurgeDays').value = s.trash_purge_days !== undefined ? s.trash_purge_days : '30'; document.getElementById('trashPurgeDays').value = s.trash_purge_days !== undefined ? s.trash_purge_days : '30';
const tzSel = document.getElementById('timezone');
const storedTz = String(s.timezone || '');
tzSel.value = Array.from(tzSel.options).some(o => o.value === storedTz) ? storedTz : DateTime.tz;
document.getElementById('timeFormat').value = ['24h', '12h'].includes(s.time_format) ? s.time_format : '24h';
renderTimePreview();
document.getElementById('footerLeft').value = s.footer_left || ''; document.getElementById('footerLeft').value = s.footer_left || '';
document.getElementById('footerRight').value = s.footer_right || ''; document.getElementById('footerRight').value = s.footer_right || '';
document.getElementById('aiPrompt').value = s.ai_prompt || ''; document.getElementById('aiPrompt').value = s.ai_prompt || '';
@@ -272,7 +349,7 @@ async function loadSystemInfo(btn) {
const data = await res.json(); const data = await res.json();
renderSystemInfo(data); renderSystemInfo(data);
renderStackInfo(data.stack); renderStackInfo(data.stack);
const stamp = 'обновлено ' + new Date().toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' }); const stamp = 'обновлено ' + fmtTimeOnly(new Date());
const live = document.getElementById('systemLive'); const live = document.getElementById('systemLive');
if (live) live.textContent = stamp; if (live) live.textContent = stamp;
const stackLive = document.getElementById('stackLive'); const stackLive = document.getElementById('stackLive');
@@ -804,6 +881,8 @@ async function saveSettings() {
headers: hdrJson(), headers: hdrJson(),
body: JSON.stringify({ settings: { body: JSON.stringify({ settings: {
system_name: document.getElementById('systemName').value.trim(), system_name: document.getElementById('systemName').value.trim(),
timezone: document.getElementById('timezone').value,
time_format: document.getElementById('timeFormat').value,
spam_interval_min: String(Math.round(Number(v))), spam_interval_min: String(Math.round(Number(v))),
footer_left: document.getElementById('footerLeft').value.trim(), footer_left: document.getElementById('footerLeft').value.trim(),
footer_right: document.getElementById('footerRight').value.trim(), footer_right: document.getElementById('footerRight').value.trim(),
@@ -828,6 +907,8 @@ async function saveSettings() {
} }) } })
}); });
if (res.ok) { if (res.ok) {
dtApply({ timezone: document.getElementById('timezone').value, time_format: document.getElementById('timeFormat').value });
renderTimePreview();
showToast('Настройки сохранены'); showToast('Настройки сохранены');
logoOriginal = currentLogo; logoOriginal = currentLogo;
snapshotDirty(); snapshotDirty();
@@ -939,7 +1020,7 @@ async function loadBans() {
el.innerHTML = bans.map(b => ` el.innerHTML = bans.map(b => `
<span class="chip ban-chip" data-chip="${esc(b.ip)}"> <span class="chip ban-chip" data-chip="${esc(b.ip)}">
<b>${esc(b.ip)}</b> <b>${esc(b.ip)}</b>
<span class="chip-sub">${esc(BAN_REASONS[b.reason] || b.reason || '—')} · до ${new Date(b.banned_until).toLocaleString('ru')}</span> <span class="chip-sub">${esc(BAN_REASONS[b.reason] || b.reason || '—')} · до ${fmtFull(b.banned_until)}</span>
<button type="button" class="unban-btn" data-unban="${esc(b.ip)}" title="Разблокировать" aria-label="Разблокировать ${esc(b.ip)}">✕</button> <button type="button" class="unban-btn" data-unban="${esc(b.ip)}" title="Разблокировать" aria-label="Разблокировать ${esc(b.ip)}">✕</button>
</span>`).join(''); </span>`).join('');
} }
@@ -1091,6 +1172,7 @@ window.addEventListener('beforeunload', e => {
try { try {
await loadSettings(); await loadSettings();
bindDirty(); bindDirty();
bindTimePreview();
if (authStatus) authStatus.classList.add('loaded'); if (authStatus) authStatus.classList.add('loaded');
} catch { } catch {
if (authStatus) { authStatus.classList.remove('loaded'); authStatus.classList.add('err'); authStatus.textContent = 'Ошибка загрузки настроек'; } if (authStatus) { authStatus.classList.remove('loaded'); authStatus.classList.add('err'); authStatus.textContent = 'Ошибка загрузки настроек'; }
+6 -7
View File
@@ -240,13 +240,11 @@ function renderShare(data, password) {
const showStudentMessage = !!data.show_student_message; const showStudentMessage = !!data.show_student_message;
const showEntryDate = !!data.show_entry_date; const showEntryDate = !!data.show_entry_date;
const dayKey = e => { const dayKey = e => {
const d = new Date(e.created_at); try {
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; return new Intl.DateTimeFormat('en-CA', { timeZone: DateTime.tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(e.created_at));
}; } catch { return ''; }
const dayLabel = k => {
const [y,m,d] = k.split('-').map(Number);
return new Date(y, m-1, d).toLocaleDateString('ru-RU', { day:'numeric', month:'long', year:'numeric' });
}; };
const dayLabel = k => fmtDateOnlyLongIso(k);
const cardHTML = e => { const cardHTML = e => {
const body = [ const body = [
!isAnonymized ? `<div class="name">${esc(e.student_name)}</div>` : '', !isAnonymized ? `<div class="name">${esc(e.student_name)}</div>` : '',
@@ -262,7 +260,7 @@ function renderShare(data, password) {
: `<div class="no-photo"><svg viewBox="0 0 240 160" preserveAspectRatio="xMidYMid meet" aria-hidden="true"><circle cx="105" cy="48" r="15"/><path d="M74 140 C74 106 88 92 105 92 C122 92 136 106 136 140 Z"/><circle cx="145" cy="58" r="18"/><path d="M108 144 C108 104 124 88 145 88 C166 88 182 104 182 144 Z"/></svg></div>`} : `<div class="no-photo"><svg viewBox="0 0 240 160" preserveAspectRatio="xMidYMid meet" aria-hidden="true"><circle cx="105" cy="48" r="15"/><path d="M74 140 C74 106 88 92 105 92 C122 92 136 106 136 140 Z"/><circle cx="145" cy="58" r="18"/><path d="M108 144 C108 104 124 88 145 88 C166 88 182 104 182 144 Z"/></svg></div>`}
</div> </div>
${body ? `<div class="body">${body}</div>` : ''} ${body ? `<div class="body">${body}</div>` : ''}
${showEntryDate ? `<div class="foot">${new Date(e.created_at).toLocaleString('ru',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})}</div>` : ''} ${showEntryDate ? `<div class="foot">${fmtDateTimeY(e.created_at)}</div>` : ''}
</div> </div>
`; `;
}; };
@@ -288,6 +286,7 @@ document.addEventListener('click', e => {
}); });
(async()=>{ (async()=>{
await initDateTime();
await loadShare(); await loadShare();
})(); })();
+1 -1
View File
@@ -226,7 +226,7 @@ async function exportStudentProfile(id) {
return; return;
} }
const blob = await res.blob(); const blob = await res.blob();
downloadBlob(blob, `profile_${safeWindowsName(s.name)}_${new Date().toISOString().slice(0, 10)}.zip`); downloadBlob(blob, `profile_${safeWindowsName(s.name)}_${todayIso()}.zip`);
showToast(`Готово: ${s.name}`); showToast(`Готово: ${s.name}`);
} catch (err) { } catch (err) {
showToast('Ошибка при формировании профиля'); showToast('Ошибка при формировании профиля');
+4 -4
View File
@@ -32,7 +32,7 @@ function entryHTML(e) {
<div class="group">${esc(e.group_name)}</div> <div class="group">${esc(e.group_name)}</div>
<div class="desc">${esc(e.description)}</div> <div class="desc">${esc(e.description)}</div>
${filesHTML(e.files)} ${filesHTML(e.files)}
<div class="trash-time">Удалена: ${new Date(e.deleted_at).toLocaleString('ru')}</div> <div class="trash-time">Удалена: ${fmtFull(e.deleted_at)}</div>
</div> </div>
<button class="restore-btn" data-restore="${e.id}" title="Восстановить">Восстановить</button> <button class="restore-btn" data-restore="${e.id}" title="Восстановить">Восстановить</button>
<button class="del-btn" data-hard="${e.id}" title="Пометить на удаление (через ${purgeDays} дней будет удалена безвозвратно)">&times;</button> <button class="del-btn" data-hard="${e.id}" title="Пометить на удаление (через ${purgeDays} дней будет удалена безвозвратно)">&times;</button>
@@ -47,7 +47,7 @@ function groupHTML(g) {
<div class="info"> <div class="info">
<div class="name">${esc(g.name)}</div> <div class="name">${esc(g.name)}</div>
${g.branch_name ? `<div class="group">${esc(g.branch_name)}</div>` : ''} ${g.branch_name ? `<div class="group">${esc(g.branch_name)}</div>` : ''}
<div class="trash-time">Удалена: ${new Date(g.deleted_at).toLocaleString('ru')}</div> <div class="trash-time">Удалена: ${fmtFull(g.deleted_at)}</div>
</div> </div>
<button class="restore-btn" data-restore-group="${g.id}" title="Восстановить">Восстановить</button> <button class="restore-btn" data-restore-group="${g.id}" title="Восстановить">Восстановить</button>
<button class="del-btn" data-hard-group="${g.id}" title="Пометить на удаление (через ${purgeDays} дней будет удалена безвозвратно вместе с записями и фото)">&times;</button> <button class="del-btn" data-hard-group="${g.id}" title="Пометить на удаление (через ${purgeDays} дней будет удалена безвозвратно вместе с записями и фото)">&times;</button>
@@ -64,7 +64,7 @@ function pendingEntryHTML(e) {
<div class="group">${esc(e.group_name)}</div> <div class="group">${esc(e.group_name)}</div>
<div class="desc">${esc(e.description)}</div> <div class="desc">${esc(e.description)}</div>
${filesHTML(e.files)} ${filesHTML(e.files)}
<div class="trash-time warn">Удаление через ${purgeDays} дн.: ${new Date(e.purge_at).toLocaleString('ru')}</div> <div class="trash-time warn">Удаление через ${purgeDays} дн.: ${fmtFull(e.purge_at)}</div>
</div> </div>
<button class="restore-btn" data-unschedule="${e.id}" title="Вернуть в корзину">В корзину</button> <button class="restore-btn" data-unschedule="${e.id}" title="Вернуть в корзину">В корзину</button>
</div> </div>
@@ -78,7 +78,7 @@ function pendingGroupHTML(g) {
<div class="info"> <div class="info">
<div class="name">${esc(g.name)}</div> <div class="name">${esc(g.name)}</div>
${g.branch_name ? `<div class="group">${esc(g.branch_name)}</div>` : ''} ${g.branch_name ? `<div class="group">${esc(g.branch_name)}</div>` : ''}
<div class="trash-time warn">Удаление через ${purgeDays} дн.: ${new Date(g.purge_at).toLocaleString('ru')}</div> <div class="trash-time warn">Удаление через ${purgeDays} дн.: ${fmtFull(g.purge_at)}</div>
</div> </div>
<button class="restore-btn" data-unschedule-group="${g.id}" title="Вернуть в корзину">В корзину</button> <button class="restore-btn" data-unschedule-group="${g.id}" title="Вернуть в корзину">В корзину</button>
</div> </div>
+1 -1
View File
@@ -70,7 +70,7 @@ function fmtDur(ms) {
function fmtTime(t) { function fmtTime(t) {
if (!t) return '—'; if (!t) return '—';
try { return new Date(t).toLocaleString('ru'); } catch { return String(t); } return fmtFull(t) || String(t);
} }
function ago(t) { function ago(t) {
+1
View File
@@ -26,6 +26,7 @@
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script> <script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script> <script src="admin.js"></script>
<script src="js/lessons.js"></script> <script src="js/lessons.js"></script>
</body> </body>
+1
View File
@@ -91,6 +91,7 @@
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script> <script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script> <script src="admin.js"></script>
<script src="js/links.js"></script> <script src="js/links.js"></script>
</body> </body>
+1
View File
@@ -83,6 +83,7 @@
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script> <script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script> <script src="admin.js"></script>
<script src="js/modules.js"></script> <script src="js/modules.js"></script>
<style> <style>
+1
View File
@@ -37,6 +37,7 @@
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script> <script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script> <script src="admin.js"></script>
<script src="js/notifications.js"></script> <script src="js/notifications.js"></script>
</body> </body>
+1
View File
@@ -43,6 +43,7 @@
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script> <script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script> <script src="admin.js"></script>
<script src="js/photos.js"></script> <script src="js/photos.js"></script>
</body> </body>
+1
View File
@@ -280,6 +280,7 @@
</div> </div>
</div> </div>
<script src="/js/datetime.js"></script>
<script src="/js/report.js"></script> <script src="/js/report.js"></script>
</body> </body>
</html> </html>
+26
View File
@@ -22,6 +22,7 @@
<a href="#sec-system" data-nav="sec-system" class="active"><i data-lucide="bar-chart-3"></i> Система</a> <a href="#sec-system" data-nav="sec-system" class="active"><i data-lucide="bar-chart-3"></i> Система</a>
<a href="#sec-stack" data-nav="sec-stack"><i data-lucide="server"></i> Стек</a> <a href="#sec-stack" data-nav="sec-stack"><i data-lucide="server"></i> Стек</a>
<a href="#sec-brand" data-nav="sec-brand"><i data-lucide="palette"></i> Брендинг</a> <a href="#sec-brand" data-nav="sec-brand"><i data-lucide="palette"></i> Брендинг</a>
<a href="#sec-datetime" data-nav="sec-datetime"><i data-lucide="clock"></i> Дата и время</a>
<a href="#sec-antispam" data-nav="sec-antispam"><i data-lucide="shield"></i> Антиспам</a> <a href="#sec-antispam" data-nav="sec-antispam"><i data-lucide="shield"></i> Антиспам</a>
<a href="#sec-share" data-nav="sec-share"><i data-lucide="link"></i> Публичные ссылки</a> <a href="#sec-share" data-nav="sec-share"><i data-lucide="link"></i> Публичные ссылки</a>
<a href="#sec-form" data-nav="sec-form"><i data-lucide="file-text"></i> Форма ответа</a> <a href="#sec-form" data-nav="sec-form"><i data-lucide="file-text"></i> Форма ответа</a>
@@ -91,6 +92,30 @@
</div> </div>
</div> </div>
<div class="settings-card" id="sec-datetime">
<div class="card-head">
<div class="ic"><i data-lucide="clock"></i></div>
<h3>Дата и время</h3>
</div>
<p class="hint">Часовой пояс, в котором показываются даты и время во всех разделах, и формат времени. На сервере дата в базе остаётся той же — меняется только отображение.</p>
<div class="settings-row">
<div class="settings-field">
<label for="timezone">Часовой пояс</label>
<select id="timezone" class="settings-input"></select>
<span class="hint">Определяет, какое время «сейчас» в расписании групп и границы дней в фильтрах</span>
</div>
<div class="settings-field">
<label for="timeFormat">Формат времени</label>
<select id="timeFormat" class="settings-input">
<option value="24h">24 часа (00:30)</option>
<option value="12h">12 часов AM/PM (12:30 PM)</option>
</select>
<span class="hint">Поля ввода времени остаются в формате 24 часов</span>
</div>
</div>
<div class="data-stats" id="timePreview"></div>
</div>
<div class="settings-card" id="sec-antispam"> <div class="settings-card" id="sec-antispam">
<div class="card-head"> <div class="card-head">
<div class="ic"><i data-lucide="shield"></i></div> <div class="ic"><i data-lucide="shield"></i></div>
@@ -412,6 +437,7 @@
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script> <script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script> <script src="admin.js"></script>
<script src="js/settings.js"></script> <script src="js/settings.js"></script>
</body> </body>
+1
View File
@@ -124,6 +124,7 @@ body{font-family:'Segoe UI',system-ui,-apple-system,Roboto,sans-serif;background
</div> </div>
</div> </div>
<script src="/js/datetime.js"></script>
<script src="/js/share.js"></script> <script src="/js/share.js"></script>
</body> </body>
</html> </html>
+1
View File
@@ -215,6 +215,7 @@
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script> <script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script> <script src="admin.js"></script>
<script src="vendor/jszip.min.js"></script> <script src="vendor/jszip.min.js"></script>
<script src="js/students.js"></script> <script src="js/students.js"></script>
+1
View File
@@ -46,6 +46,7 @@
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script> <script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script> <script src="admin.js"></script>
<script src="js/trash.js"></script> <script src="js/trash.js"></script>
</body> </body>
+1
View File
@@ -83,6 +83,7 @@
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script> <script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script> <script src="admin.js"></script>
<script src="js/users.js"></script> <script src="js/users.js"></script>
<style> <style>
+1
View File
@@ -187,6 +187,7 @@
</div> </div>
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script> <script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script> <script src="admin.js"></script>
<script src="js/worker.js"></script> <script src="js/worker.js"></script>
</body> </body>
+79 -40
View File
@@ -1088,6 +1088,35 @@ async function trashPurgeDays() {
return Number.isFinite(v) && v >= 1 && v <= 3650 ? v : 30; 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() { async function purgeScheduledDeletions() {
const erefs = await pool.query('SELECT id FROM entries WHERE purge_at IS NOT NULL AND purge_at <= now()'); 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) { 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) => { app.get('/api/public-settings', apiLimiter, async (_, res) => {
const out = await cacheWrap('public-settings', PUBLIC_TTL_MS, async () => { 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 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' }; 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 = {}; const result = {};
for (const k of keys) result[k] = await getSetting(k, defaults[k] || ''); 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); 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) { if (key === 'lesson_ai_prompt' && String(value).length > 8000) {
return res.status(400).json({ error: 'lesson_ai_prompt длиннее 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))) { if (key.startsWith('notify_') && key !== 'notify_retention_days' && !['true', 'false'].includes(String(value))) {
return res.status(400).json({ error: `${key} должен быть true или false` }); return res.status(400).json({ error: `${key} должен быть true или false` });
} }
@@ -2771,10 +2806,10 @@ app.get('/api/share/:token', fileLimiter, async (req, res) => {
const params = []; const params = [];
if (l.group_id) { params.push(l.group_id); conditions.push(`e.group_id = $${params.length}`); } 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.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_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 < ($${params.length}::date + interval '1 day')`); } if (l.date_to) { params.push(l.date_to); conditions.push(`e.created_at < ${tzDayEnd(params.length)}`); }
conditions.push('e.deleted_at IS NULL'); 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( const { rows: entries } = await pool.query(
`SELECT e.*, g.name AS group_name FROM entries e `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`, 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 = []; const params = [];
if (l.group_id) { params.push(l.group_id); conditions.push(`e.group_id = $${params.length}`); } 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.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_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 < ($${params.length}::date + interval '1 day')`); } if (l.date_to) { params.push(l.date_to); conditions.push(`e.created_at < ${tzDayEnd(params.length)}`); }
params.push(fileToken); params.push(fileToken);
const bound = bindTz(conditions.join(' AND '), params, await appTimezone());
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT pf.path, pf.name FROM project_files pf `SELECT pf.path, pf.name FROM project_files pf
JOIN entries e ON e.id = pf.entry_id JOIN entries e ON e.id = pf.entry_id
WHERE pf.token = $${params.length} AND ${conditions.join(' AND ')}`, WHERE pf.token = $${params.length - 1} AND ${bound.sql}`,
params bound.params
); );
if (!rows.length) return res.status(404).json({ error: 'Not found' }); if (!rows.length) return res.status(404).json({ error: 'Not found' });
const r = rows[0]; const r = rows[0];
@@ -2920,10 +2956,10 @@ app.get('/api/groups/active', apiLimiter, async (_, res) => {
AND day_of_week IS NOT NULL AND day_of_week IS NOT NULL
AND time_start IS NOT NULL AND time_start IS NOT NULL
AND time_end IS NOT NULL AND time_end IS NOT NULL
AND day_of_week = EXTRACT(DOW FROM (now() AT TIME ZONE 'Europe/Moscow'))::int AND day_of_week = EXTRACT(DOW FROM (now() AT TIME ZONE $1))::int
AND (now() AT TIME ZONE 'Europe/Moscow')::time BETWEEN time_start AND time_end AND (now() AT TIME ZONE $1)::time BETWEEN time_start AND time_end
ORDER BY id ORDER BY id
`); `, [await appTimezone()]);
return rows; return rows;
}); });
res.json(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 }); res.json({ ok: true, photo_path: rows[0].photo_path });
}); });
function fmtLongDate(iso) { function fmtLongDate(iso, tz) {
if (!iso) return ''; 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) => { 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'); const bw = branchWhere(req.user, 'g');
if (dateFrom) { if (dateFrom) {
params.push(dateFrom); params.push(dateFrom);
conds.push(`e.created_at >= $${params.length}::date`); conds.push(`e.created_at >= ${tzDayStart(params.length)}`);
} }
if (dateTo) { if (dateTo) {
params.push(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) { if (bw.params.length) {
const start = params.length + 1; const start = params.length + 1;
conds.push(`g.branch_id IN (${bw.params.map((_, i) => '$' + (start + i)).join(',')})`); conds.push(`g.branch_id IN (${bw.params.map((_, i) => '$' + (start + i)).join(',')})`);
params.push(...bw.params); params.push(...bw.params);
} }
const condStr = conds.join(' AND '); const condStr = bindTz(conds.join(' AND '), params, await appTimezone()).sql;
const whereStr = ' WHERE ' + condStr; const whereStr = ' WHERE ' + condStr;
const [studRes, entriesRes, photosRes, mainsRes, filesRes, modulesRes, groupPhotosRes] = await Promise.all([ const [studRes, entriesRes, photosRes, mainsRes, filesRes, modulesRes, groupPhotosRes] = await Promise.all([
pool.query( pool.query(
@@ -4343,7 +4379,8 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
let period = null; let period = null;
if (hasPeriod) { 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 = { const reportData = {
@@ -4438,13 +4475,13 @@ app.get('/api/groups/:id/export/files', requireAuth, async (req, res) => {
const params = [gid]; const params = [gid];
if (dateFrom) { if (dateFrom) {
params.push(dateFrom); params.push(dateFrom);
conds.push(`e.created_at >= $${params.length}::date`); conds.push(`e.created_at >= ${tzDayStart(params.length)}`);
} }
if (dateTo) { if (dateTo) {
params.push(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([ const [studentsRes, filesRes, photosRes, entryPhotosRes, originalsRes] = await Promise.all([
pool.query('SELECT id, name FROM students WHERE group_id = $1 ORDER BY name', [gid]), pool.query('SELECT id, name FROM students WHERE group_id = $1 ORDER BY name', [gid]),
includeFiles includeFiles
@@ -4602,8 +4639,8 @@ app.get('/api/entries', requireAuth, async (req, res) => {
else conditions.push('e.deleted_at IS NULL'); else conditions.push('e.deleted_at IS NULL');
if (group_id) { params.push(group_id); conditions.push(`e.group_id = $${params.length}`); } 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 (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_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 < ($${params.length}::date + interval '1 day')`); } 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 (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 (search) { params.push(`%${search}%`); conditions.push(`(e.student_name ILIKE $${params.length} OR e.description ILIKE $${params.length})`); }
if (req.user.role !== 'admin') { if (req.user.role !== 'admin') {
@@ -4618,7 +4655,7 @@ app.get('/api/entries', requireAuth, async (req, res) => {
conditions.push(`g.branch_id IN (${ph})`); 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( const { rows: crows } = await pool.query(
`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id${where}`, `SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id${where}`,
params 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 (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 (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 (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_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 < ($${params.length}::date + interval '1 day')`); } if (date_to) { params.push(date_to); conditions.push(`e.created_at < ${tzDayEnd(params.length)}`); }
if (req.user.role !== 'admin') { if (req.user.role !== 'admin') {
if (group_id && !(await groupBelongsToBranches(req.user, group_id))) { if (group_id && !(await groupBelongsToBranches(req.user, group_id))) {
return res.status(403).json({ error: 'Нет доступа к этой группе' }); 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})`); 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( 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}`, `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 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 (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 (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 (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_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 < ($${params.length}::date + interval '1 day')`); } if (date_to) { params.push(date_to); conditions.push(`t.created_at < ${tzDayEnd(params.length)}`); }
if (req.user.role !== 'admin') { if (req.user.role !== 'admin') {
if (group_id && !(await groupBelongsToBranches(req.user, group_id))) { if (group_id && !(await groupBelongsToBranches(req.user, group_id))) {
return res.status(403).json({ error: 'Нет доступа к этой группе' }); 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})`); 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 ( const from = `FROM (
SELECT 'entry'::text AS source_type, 'Главное фото записи'::text AS source_label, 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, 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) => { app.get('/api/stats', requireAuth, async (req, res) => {
const payload = await cacheWrap('stats:' + scopeKey(req.user), STATS_TTL_MS, async () => { const payload = await cacheWrap('stats:' + scopeKey(req.user), STATS_TTL_MS, async () => {
const tz = await appTimezone();
const isAdmin = req.user.role === 'admin'; const isAdmin = req.user.role === 'admin';
const s = branchScope(req.user); const s = branchScope(req.user);
let groupFilter; 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${gTrash('visible')}`, groupsParams),
pool.query(`SELECT count(*)::int AS n FROM groups g${groupsWhere}`, 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(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 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), 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) => { app.get('/api/dashboard', requireAuth, async (req, res) => {
const payload = await cacheWrap('dashboard:' + scopeKey(req.user), STATS_TTL_MS, async () => { const payload = await cacheWrap('dashboard:' + scopeKey(req.user), STATS_TTL_MS, async () => {
const DAYS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб']; const DAYS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
const tz = await appTimezone();
const isAdmin = req.user.role === 'admin'; const isAdmin = req.user.role === 'admin';
const s = branchScope(req.user); const s = branchScope(req.user);
const branchIds = isAdmin ? [] : s.ids; 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 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(*)::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(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 }; 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( 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 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} WHERE e.deleted_at IS NULL AND e.created_at >= (${tzWall()} - interval '13 days')::date${whereEntry}
GROUP BY 1 ORDER BY 1`, GROUP BY 1 ORDER BY 1`.replace(/\$TZ\$/g, '$' + (whereEntry ? whereGroupParams.length : 0) + 1),
whereEntry ? whereGroupParams : [] (whereEntry ? whereGroupParams : []).concat([tz])
), ),
pool.query( pool.query(
`SELECT g.* FROM groups g `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 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 g.day_of_week = EXTRACT(DOW FROM ${tzWall()})::int
AND (now() AT TIME ZONE 'Europe/Moscow')::time BETWEEN g.time_start AND g.time_end${whereGroup} AND ${tzWall()}::time BETWEEN g.time_start AND g.time_end${whereGroup}
ORDER BY g.id`, ORDER BY g.id`.replace(/\$TZ\$/g, '$' + (whereGroup ? whereGroupParams.length : 0) + 1),
whereGroup ? whereGroupParams : [] (whereGroup ? whereGroupParams : []).concat([tz])
), ),
pool.query( pool.query(
`SELECT e.id, e.student_name, e.photo_path, e.created_at, g.name AS group_name `SELECT e.id, e.student_name, e.photo_path, e.created_at, g.name AS group_name