Часовой пояс и формат времени (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.
154 lines
6.2 KiB
JavaScript
154 lines
6.2 KiB
JavaScript
function plural(n, one, few, many) {
|
||
n = Math.abs(n) % 100; const n1 = n % 10;
|
||
if (n > 10 && n < 20) return many;
|
||
if (n1 > 1 && n1 < 5) return few;
|
||
if (n1 === 1) return one;
|
||
return many;
|
||
}
|
||
|
||
function renderStats(s, disk) {
|
||
let diskHtml = '';
|
||
if (disk) {
|
||
const pct = disk.used_pct;
|
||
const color = pct > 90 ? '#ef4444' : pct > 70 ? '#f59e0b' : '#16a34a';
|
||
diskHtml = `
|
||
<div class="stat">
|
||
<div>${disk.used} <span class="stat-sub">/ ${disk.total}</span></div>
|
||
<div class="label">Диск: свободно ${disk.free} (${100 - pct}%)</div>
|
||
<div class="stat-bar"><i style="width:${pct}%;background:${color}"></i></div>
|
||
</div>`;
|
||
}
|
||
document.getElementById('stats').innerHTML = `
|
||
<div class="stat"><div class="num">${s.entries}</div><div class="label">Всего записей</div></div>
|
||
<div class="stat"><div class="num">${s.students}</div><div class="label">Учеников</div></div>
|
||
<div class="stat"><div class="num">${s.groups}</div><div class="label">Групп</div></div>
|
||
<div class="stat"><div class="num">${s.today}</div><div class="label">Сегодня</div></div>
|
||
<div class="stat"><div class="num" style="color:${s.trash ? '#ef4444' : 'var(--muted)'}">${s.trash}</div><div class="label"><a href="trash.html">В корзине →</a></div></div>
|
||
${diskHtml}
|
||
`;
|
||
}
|
||
|
||
function last14days() {
|
||
const out = [];
|
||
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(iso => {
|
||
return { iso, label: fmtDayMonthIso(iso), n: map[iso] || 0 };
|
||
});
|
||
const max = Math.max(1, ...days.map(x => x.n));
|
||
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 === todayKey ? 'today' : ''}" style="height:${Math.round(x.n / max * 100)}%"></div>
|
||
<div class="bar-day">${x.label}</div>
|
||
</div>`).join('');
|
||
}
|
||
|
||
function renderRecent(rows) {
|
||
const el = document.getElementById('recentList');
|
||
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Записей пока нет</div>'; return; }
|
||
el.innerHTML = rows.map(e => `
|
||
<div class="recent-item">
|
||
${e.photo_path
|
||
? `<img src="${thumbSrc(e.photo_path)}" data-img="${API}${e.photo_path}" loading="lazy" decoding="async" alt="">`
|
||
: `<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)} · ${fmtDateTime(e.created_at)}</div>
|
||
</div>
|
||
<a class="r-link" href="journal.html" title="В журнал">→</a>
|
||
</div>`).join('');
|
||
}
|
||
|
||
function renderActive(rows) {
|
||
const el = document.getElementById('activeList');
|
||
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Сейчас нет идущих групп</div>'; return; }
|
||
el.innerHTML = rows.map(g => `
|
||
<div class="active-item">
|
||
<span class="a-name">${esc(g.name)}</span>
|
||
<span class="a-right">
|
||
<span class="a-time">${g.day_label} ${g.time_start}–${g.time_end}</span>
|
||
<span class="a-now">сейчас</span>
|
||
</span>
|
||
</div>`).join('');
|
||
}
|
||
|
||
function renderTop(rows) {
|
||
const el = document.getElementById('topList');
|
||
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Пока нет данных</div>'; return; }
|
||
el.innerHTML = rows.map((x, i) => `
|
||
<div class="top-item">
|
||
<span class="top-rank ${i > 2 ? 'alt' : ''}">${i + 1}</span>
|
||
<span class="top-name">${esc(x.student_name)}</span>
|
||
<span class="top-count">${x.n}</span>
|
||
</div>`).join('');
|
||
}
|
||
|
||
function renderPhotos(rows) {
|
||
const el = document.getElementById('dashPhotos');
|
||
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Фото групп пока нет</div>'; return; }
|
||
el.innerHTML = rows.map(p => `
|
||
<div class="dash-photo" title="${esc(p.group_name)}">
|
||
<img src="${thumbSrc(p.photo_path)}" data-img="${API}${p.photo_path}" loading="lazy" decoding="async" alt="">
|
||
${p.caption ? `<div class="pcap">${esc(p.caption)}</div>` : ''}
|
||
</div>`).join('');
|
||
}
|
||
|
||
function renderLessons(rows) {
|
||
const el = document.getElementById('lessonList');
|
||
if (!el) return;
|
||
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Отчётов пока нет</div>'; return; }
|
||
el.innerHTML = rows.map(r => {
|
||
const date = String(r.lesson_date || '').slice(0, 10).split('-').reverse().join('.');
|
||
const time = r.lesson_time ? String(r.lesson_time).slice(0, 5) : '';
|
||
return `
|
||
<div class="lesson-dash-item" data-lesson="${r.id}">
|
||
<div class="lesson-dash-head">
|
||
<span class="lesson-dash-group">${esc(r.group_name)}</span>
|
||
<span class="lesson-dash-when">${esc(date + (time ? ' · ' + time : ''))}</span>
|
||
</div>
|
||
<div class="lesson-dash-text">${esc(r.text)}</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
async function loadDashboard() {
|
||
const res = await fetch(`${API}/api/dashboard`, { headers: hdr() });
|
||
if (!res.ok) { document.getElementById('stats').innerHTML = '<div class="dash-empty">Не удалось загрузить данные</div>'; return; }
|
||
const d = await res.json();
|
||
renderStats(d.stats, d.disk);
|
||
renderActivity(d.activity);
|
||
renderRecent(d.recent_entries);
|
||
renderActive(d.active_groups);
|
||
renderTop(d.top_students);
|
||
renderPhotos(d.photos);
|
||
renderLessons(d.recent_lessons || []);
|
||
renderIcons();
|
||
}
|
||
|
||
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
|
||
|
||
const lessonBtn = document.getElementById('lessonReportBtn');
|
||
if (lessonBtn) lessonBtn.addEventListener('click', () => openLessonModal());
|
||
|
||
function onLessonReportSaved() {
|
||
loadDashboard();
|
||
}
|
||
|
||
document.addEventListener('click', e => {
|
||
const item = e.target.closest('[data-lesson]');
|
||
if (item && !e.target.closest('button')) openLessonModal({ id: item.dataset.lesson });
|
||
});
|
||
|
||
(async () => {
|
||
if (await checkAuth()) {
|
||
buildSidebar(document.body.dataset.page);
|
||
loadDashboard();
|
||
}
|
||
})(); |