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
+4 -5
View File
@@ -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() {
+1
View File
@@ -54,6 +54,7 @@
</div>
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script>
<script src="js/audit.js"></script>
</body>
+1
View File
@@ -66,6 +66,7 @@
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script>
<script src="js/bans.js"></script>
<style>
+1
View File
@@ -64,6 +64,7 @@
</div>
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script>
<script src="js/branches.js"></script>
<style>
+1
View File
@@ -74,6 +74,7 @@
</div>
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script>
<script src="js/dashboard.js"></script>
</body>
+1
View File
@@ -37,6 +37,7 @@
</div>
</div>
</div>
<script src="/js/datetime.js"></script>
<script src="/js/error.js"></script>
</body>
</html>
+1
View File
@@ -41,6 +41,7 @@
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script>
<script src="js/files.js"></script>
</body>
+1
View File
@@ -136,6 +136,7 @@
</div>
<script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script>
<script src="vendor/jszip.min.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>
<script src="js/datetime.js"></script>
<script src="js/index.js"></script>
</body>
</html>
+1
View File
@@ -265,6 +265,7 @@
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script>
<script src="js/journal.js"></script>
</body>
+2 -1
View File
@@ -111,7 +111,8 @@ const FIELD_LABELS = {
};
function fmtTime(t) {
try { return new Date(t).toLocaleString('ru'); } catch { return t || ''; }
const f = fmtFull(t);
return f || t || '';
}
function parseTarget(t) {
+2 -2
View File
@@ -41,8 +41,8 @@ function renderPage() {
<tr>
<td><b>${esc(b.ip)}</b></td>
<td><span class="reason-badge ${rcls}">${esc(reason)}</span></td>
<td>${new Date(b.created_at).toLocaleString('ru')}</td>
<td>${new Date(b.banned_until).toLocaleString('ru')}</td>
<td>${fmtFull(b.created_at)}</td>
<td>${fmtFull(b.banned_until)}</td>
<td><button class="row-btn" data-unban="${esc(b.ip)}" title="Разблокировать">✕</button></td>
</tr>`;
}).join('');
+7 -7
View File
@@ -30,22 +30,22 @@ function renderStats(s, disk) {
function last14days() {
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;
}
function renderActivity(activity) {
const map = {};
activity.forEach(a => { map[a.d] = a.n; });
const days = last14days().map(d => {
const iso = d.toISOString().slice(0, 10);
return { iso, label: d.toLocaleDateString('ru', { day: '2-digit', month: '2-digit' }), n: map[iso] || 0 };
const days = last14days().map(iso => {
return { iso, label: fmtDayMonthIso(iso), n: map[iso] || 0 };
});
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 => `
<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>`).join('');
}
@@ -60,7 +60,7 @@ function renderRecent(rows) {
: `<div class="thumb"><i data-lucide="graduation-cap"></i></div>`}
<div class="r-info">
<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>
<a class="r-link" href="journal.html" title="В журнал">→</a>
</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="file-name"><a ${target}>${esc(f.name)}</a></div>
${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 class="f-actions">${action}</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-files="${g.id}" data-group-name="${esc(g.name)}" title="Скачать ZIP-архив файлов группы за период">ZIP</button>
</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>`;
}).join('') + `</div>`;
@@ -162,11 +162,11 @@ async function 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) {
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;
return `
<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.group_name,
e.description.replace(/\n/g, ' '),
new Date(e.created_at).toLocaleString('ru'),
fmtFull(e.created_at),
`${location.origin}/?nameInput=${encodeURIComponent(e.student_name)}`
]);
});
@@ -318,8 +318,7 @@ function closeGroupFilesModal() {
}
function todayISOLocal() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
return todayIso();
}
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;
const d = new Date();
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)}`;
}
@@ -281,6 +281,7 @@ async function resolveModuleId(name) {
}
(async () => {
await initDateTime();
try {
const list = await fetchModules('', 50);
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="desc">${esc(e.description)}${aiBadge(e)}</div>
${filesHTML(e.files)}
<div class="time">${new Date(e.created_at).toLocaleString('ru')}</div>
<div class="time">${fmtFull(e.created_at)}</div>
</div>
<button class="edit" data-edit="${e.id}" title="Редактировать">✎</button>
${mp ? `<button class="edit" data-enhance="${e.id}" title="Улучшить фото">✦</button>` : ''}
@@ -148,7 +148,7 @@ function cardEntryHTML(e) {
${filesHTML(e.files)}
</div>
<div class="foot">
<span>${new Date(e.created_at).toLocaleString('ru')}</span>
<span>${fmtFull(e.created_at)}</span>
<span>
<button class="edit" data-edit="${e.id}" title="Редактировать">✎</button>
${mp ? `<button class="edit" data-enhance="${e.id}" title="Улучшить фото">✦</button>` : ''}
@@ -220,7 +220,7 @@ function setView(v) {
}
function setDefaultDates() {
const today = new Date().toISOString().slice(0, 10);
const today = todayIso();
document.getElementById('dateFrom').value = today;
document.getElementById('dateTo').value = today;
}
@@ -895,7 +895,7 @@ async function loadPhotoHistory() {
</div>
<div style="flex:1;min-width:0">
<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>
${err}
</div>
@@ -1229,7 +1229,7 @@ function renderEditAiStatus(e) {
el.style.display = 'block';
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}`];
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>`;
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>`;
@@ -1416,7 +1416,7 @@ async function exportCSV() {
e.group_name,
e.module_name || '',
e.description.replace(/\n/g, ' '),
new Date(e.created_at).toLocaleString('ru'),
fmtFull(e.created_at),
e.photo_path ? `${API}${e.photo_path}` : ''
]);
});
+2 -4
View File
@@ -7,14 +7,12 @@ let groupsLoaded = false;
function lessonDateText(iso) {
const s = String(iso || '').slice(0, 10);
if (!s) return '';
const d = new Date(s + 'T00:00:00');
if (Number.isNaN(d.getTime())) return s;
return d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: '2-digit' });
return fmtShortIso(s) || s;
}
function lessonWhenText(r) {
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;
}
+2 -2
View File
@@ -41,10 +41,10 @@ async function loadLinks() {
${l.group_name ? `Группа: <b>${esc(l.group_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>` : ' · весь период'}
${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>` : ''}
${l.access_password_hash ? ` · <span style="color:#2563eb">🔒 пароль</span>` : ''}
· создана ${new Date(l.created_at).toLocaleString('ru')}
· создана ${fmtFull(l.created_at)}
</div>
</div>
<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>
<div class="ph-title">${esc(p.title || 'Без описания')}</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>` : ''}
</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 pad(n){return String(n).padStart(2,'0')}
function toDate(v){if(!v)return null;const d=new Date(v);return isNaN(d.getTime())?null:d}
function fmtDate(v){const d=toDate(v);return d?pad(d.getDate())+'.'+pad(d.getMonth()+1)+'.'+d.getFullYear():''}
function fmtShort(v){const d=toDate(v);return d?pad(d.getDate())+'.'+pad(d.getMonth()+1)+'.'+String(d.getFullYear()).slice(2):''}
function fmtLong(v){const d=toDate(v);return d?d.toLocaleDateString('ru-RU',{day:'numeric',month:'long',year:'numeric'}):''}
function dtIsDateOnly(v){return typeof v==='string'&&/^\d{4}-\d{2}-\d{2}/.test(v)}
function fmtDate(v){return dtIsDateOnly(v)?fmtDateOnlyIso(v):(v?fmtDateFull(v):'')}
function fmtShort(v){return dtIsDateOnly(v)?fmtShortIso(v):(v?fmtDateShort(v):'')}
function fmtLong(v){return dtIsDateOnly(v)?fmtDateOnlyLongIso(v):(v?fmtDateLong(v):'')}
function plural(n,one,few,many){const m10=n%10,m100=n%100;if(m100>=11&&m100<=14)return many;if(m10===1)return one;if(m10>=2&&m10<=4)return few;return many}
function words(n,one,few,many){return n+' '+plural(n,one,few,many)}
function isImageFile(name){return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name||'')}
@@ -41,14 +42,19 @@ function hideEl(el,on){if(el)el.classList.toggle('rep-hidden',!!on)}
function node(html){const t=document.createElement('template');t.innerHTML=html.trim();return t.content.firstElementChild}
function periodLabel(from,to){
if(!from&&!to)return '';
const a=toDate(from),b=toDate(to);
if(a&&b)return fmtDate(a)+' — '+fmtDate(b);
return fmtDate(a)||fmtDate(b);
if(from&&to)return fmtDate(from)+' — '+fmtDate(to);
return fmtDate(from)||fmtDate(to);
}
function dtIsoYear(v){
if(!v)return null;
if(dtIsDateOnly(v))return String(v).slice(0,4);
const d=toDate(v);
if(!d)return null;
try{return new Intl.DateTimeFormat('en-CA',{timeZone:DateTime.tz,year:'numeric'}).format(d).slice(0,4)}catch{return String(d.getFullYear())}
}
function yearLabel(from,to){
const a=toDate(from),b=toDate(to);
if(!a&&!b)return '';
const ay=a?a.getFullYear():null,by=b?b.getFullYear():null;
if(!from&&!to)return '';
const ay=dtIsoYear(from),by=dtIsoYear(to);
if(ay&&by)return ay===by?String(ay):ay+' — '+String(by).slice(2);
return String(ay||by);
}
@@ -268,12 +274,15 @@ function statSep(variant){
const extra=variant===1?' list__item--u-ip5wdmggw':(variant===2?' list__item--u-ixebr6v8q':'');
return '<div class="list__item list__item--u-isd3lg3iz'+extra+'"></div>';
}
function dtYearMonth(d){
try{return new Intl.DateTimeFormat('en-CA',{timeZone:DateTime.tz,year:'numeric',month:'2-digit'}).format(d).slice(0,7)}catch{return d.getFullYear()+'-'+d.getMonth()}
}
function renderAbout(data,pool){
const entries=data.entries||[];
const photos=(data.photos||[]).length;
const files=entries.reduce(function(n,e){return n+((e.files||[]).length)},0);
const months={};
entries.forEach(function(e){const d=toDate(e.created_at);if(d)months[d.getFullYear()+'-'+d.getMonth()]=1});
entries.forEach(function(e){const d=toDate(e.created_at);if(d)months[dtYearMonth(d)]=1});
const photosTotal=entries.filter(function(e){return e.photo_path}).length+photos;
setText('aboutTitle',(data.name&&String(data.name).trim())||'Отчёт');
setText('aboutLead',(data.message&&String(data.message).trim())||('Мы собрали '+words(entries.length,'запись','записи','записей')+' о работе за этот период — с фотографиями, файлами и описанием каждого занятия.'));
@@ -461,7 +470,7 @@ function renderFooter(data,heading,pool){
const label=btn.querySelector('.text-block-wrap-div');
if(label)label.textContent='Перейти по ссылке';
}
setText('footCopy',state.systemName+' © '+new Date().getFullYear());
setText('footCopy',state.systemName+' © '+nowYear());
setText('footBrand',heading);
const fbox=document.getElementById('footerPhotoBox');
const fimg=document.getElementById('footerPhoto');
@@ -563,5 +572,5 @@ function bindEvents(){
bindEvents();
makeNavLinks();
loadBrand().then(function(){loadReport()});
initDateTime().then(function(){return loadBrand()}).then(function(){loadReport()});
function closeLightbox(){const lb=document.getElementById('lightbox');if(lb)lb.classList.remove('is-open')}
+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 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 BAN_REASONS = {
'honeypot': 'Антиспам-поле',
@@ -14,6 +14,67 @@ let logoOriginal = '';
let currentSystemName = '';
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() {
const img = document.getElementById('logoPreviewImg');
const fb = document.getElementById('logoPreviewFallback');
@@ -89,6 +150,15 @@ function humanizeMinutes(min) {
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() {
const dirtyCount = DIRTY_FIELDS.filter(id => {
const el = document.getElementById(id);
@@ -123,6 +193,7 @@ function revertChanges() {
renderLogoPreview();
clearFieldError('spamInterval');
updateSpamUi();
renderTimePreview();
updateDirty();
showToast('Изменения отменены');
}
@@ -189,6 +260,7 @@ async function renderNotifyTypes(s) {
}
async function loadSettings() {
fillTimezoneOptions();
const [sRes, stRes] = await Promise.all([
fetch(`${API}/api/settings`, { headers: hdr() }),
fetch(`${API}/api/stats`, { headers: hdr() }),
@@ -202,6 +274,11 @@ async function loadSettings() {
renderLogoPreview();
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';
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('footerRight').value = s.footer_right || '';
document.getElementById('aiPrompt').value = s.ai_prompt || '';
@@ -272,7 +349,7 @@ async function loadSystemInfo(btn) {
const data = await res.json();
renderSystemInfo(data);
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');
if (live) live.textContent = stamp;
const stackLive = document.getElementById('stackLive');
@@ -804,6 +881,8 @@ async function saveSettings() {
headers: hdrJson(),
body: JSON.stringify({ settings: {
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))),
footer_left: document.getElementById('footerLeft').value.trim(),
footer_right: document.getElementById('footerRight').value.trim(),
@@ -828,6 +907,8 @@ async function saveSettings() {
} })
});
if (res.ok) {
dtApply({ timezone: document.getElementById('timezone').value, time_format: document.getElementById('timeFormat').value });
renderTimePreview();
showToast('Настройки сохранены');
logoOriginal = currentLogo;
snapshotDirty();
@@ -939,7 +1020,7 @@ async function loadBans() {
el.innerHTML = bans.map(b => `
<span class="chip ban-chip" data-chip="${esc(b.ip)}">
<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>
</span>`).join('');
}
@@ -1091,6 +1172,7 @@ window.addEventListener('beforeunload', e => {
try {
await loadSettings();
bindDirty();
bindTimePreview();
if (authStatus) authStatus.classList.add('loaded');
} catch {
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 showEntryDate = !!data.show_entry_date;
const dayKey = e => {
const d = new Date(e.created_at);
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
};
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' });
try {
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 => fmtDateOnlyLongIso(k);
const cardHTML = e => {
const body = [
!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>
${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>
`;
};
@@ -288,6 +286,7 @@ document.addEventListener('click', e => {
});
(async()=>{
await initDateTime();
await loadShare();
})();
+1 -1
View File
@@ -226,7 +226,7 @@ async function exportStudentProfile(id) {
return;
}
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}`);
} catch (err) {
showToast('Ошибка при формировании профиля');
+4 -4
View File
@@ -32,7 +32,7 @@ function entryHTML(e) {
<div class="group">${esc(e.group_name)}</div>
<div class="desc">${esc(e.description)}</div>
${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>
<button class="restore-btn" data-restore="${e.id}" title="Восстановить">Восстановить</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="name">${esc(g.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>
<button class="restore-btn" data-restore-group="${g.id}" title="Восстановить">Восстановить</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="desc">${esc(e.description)}</div>
${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>
<button class="restore-btn" data-unschedule="${e.id}" title="Вернуть в корзину">В корзину</button>
</div>
@@ -78,7 +78,7 @@ function pendingGroupHTML(g) {
<div class="info">
<div class="name">${esc(g.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>
<button class="restore-btn" data-unschedule-group="${g.id}" title="Вернуть в корзину">В корзину</button>
</div>
+1 -1
View File
@@ -70,7 +70,7 @@ function fmtDur(ms) {
function fmtTime(t) {
if (!t) return '—';
try { return new Date(t).toLocaleString('ru'); } catch { return String(t); }
return fmtFull(t) || String(t);
}
function ago(t) {
+1
View File
@@ -26,6 +26,7 @@
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script>
<script src="js/lessons.js"></script>
</body>
+1
View File
@@ -91,6 +91,7 @@
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script>
<script src="js/links.js"></script>
</body>
+1
View File
@@ -83,6 +83,7 @@
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script>
<script src="js/modules.js"></script>
<style>
+1
View File
@@ -37,6 +37,7 @@
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script>
<script src="js/notifications.js"></script>
</body>
+1
View File
@@ -43,6 +43,7 @@
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script>
<script src="js/photos.js"></script>
</body>
+1
View File
@@ -280,6 +280,7 @@
</div>
</div>
<script src="/js/datetime.js"></script>
<script src="/js/report.js"></script>
</body>
</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-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-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-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>
@@ -91,6 +92,30 @@
</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="card-head">
<div class="ic"><i data-lucide="shield"></i></div>
@@ -412,6 +437,7 @@
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script>
<script src="js/settings.js"></script>
</body>
+1
View File
@@ -124,6 +124,7 @@ body{font-family:'Segoe UI',system-ui,-apple-system,Roboto,sans-serif;background
</div>
</div>
<script src="/js/datetime.js"></script>
<script src="/js/share.js"></script>
</body>
</html>
+1
View File
@@ -215,6 +215,7 @@
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script>
<script src="vendor/jszip.min.js"></script>
<script src="js/students.js"></script>
+1
View File
@@ -46,6 +46,7 @@
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script>
<script src="js/trash.js"></script>
</body>
+1
View File
@@ -83,6 +83,7 @@
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script>
<script src="js/users.js"></script>
<style>
+1
View File
@@ -187,6 +187,7 @@
</div>
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script>
<script src="js/worker.js"></script>
</body>