494 lines
22 KiB
JavaScript
494 lines
22 KiB
JavaScript
'use strict';
|
||
/* ──────────────────────────────────────────────────────────────────
|
||
Dashboard view — landing screen of /exam-prep/:examKey
|
||
F1: track info + global counts + cumulative progress
|
||
F4: streak, last attempts, 7d accuracy, activity heatmap, recent mocks
|
||
────────────────────────────────────────────────────────────────── */
|
||
|
||
(async function () {
|
||
const { info } = await EP.boot();
|
||
|
||
const main = document.getElementById('ep-main');
|
||
if (!info?.track) {
|
||
main.innerHTML = `<div class="ep-empty">
|
||
<i data-lucide="alert-triangle"></i>
|
||
<h4>Не удалось загрузить данные экзамена</h4>
|
||
<p>Проверьте, что миграция применена и трек math9 включён.</p>
|
||
</div>`;
|
||
if (window.lucide) lucide.createIcons();
|
||
return;
|
||
}
|
||
|
||
const { track, counts, progress } = info;
|
||
const solvedPct = counts.total
|
||
? Math.round((progress.tasks_solved / counts.total) * 100)
|
||
: 0;
|
||
const accAll = progress.total_attempts
|
||
? Math.round((progress.correct_attempts / progress.total_attempts) * 100)
|
||
: null;
|
||
|
||
// Fetch F4 live aggregates + F10 plan in parallel with rendering shell.
|
||
const dashPromise = EP.api.getDashboard(track.exam_key).catch(() => null);
|
||
const planPromise = EP.api.getPlan(track.exam_key).catch(() => null);
|
||
|
||
main.innerHTML = `
|
||
<div class="ep-stats" id="dh-top-stats">
|
||
<div class="ep-stat">
|
||
<div class="ep-stat-label">Решено задач</div>
|
||
<div class="ep-stat-value ep-violet">${progress.tasks_solved} <span style="font-size:.7em;color:var(--text-3);font-weight:600">/ ${counts.total}</span></div>
|
||
<div class="ep-bar"><div class="ep-bar-fill" style="width:${solvedPct}%"></div></div>
|
||
<div class="ep-stat-sub">${solvedPct}% от банка</div>
|
||
</div>
|
||
<div class="ep-stat">
|
||
<div class="ep-stat-label">Точность (всё время)</div>
|
||
<div class="ep-stat-value ${accAll == null ? '' : accAll >= 70 ? 'ep-good' : 'ep-warn'}">${accAll == null ? '—' : accAll + '%'}</div>
|
||
<div class="ep-stat-sub">${progress.correct_attempts} верно из ${progress.total_attempts} попыток</div>
|
||
</div>
|
||
<div class="ep-stat" id="dh-streak"><div class="ep-stat-label">Серия дней</div><div class="ep-stat-value"><span class="ep-skel" style="width:42px;height:1em"> </span></div></div>
|
||
<div class="ep-stat" id="dh-acc7"><div class="ep-stat-label">Точность 7 дней</div><div class="ep-stat-value"><span class="ep-skel" style="width:42px;height:1em"> </span></div></div>
|
||
</div>
|
||
|
||
<div class="ep-card">
|
||
<h3>С чего начать</h3>
|
||
<div class="ep-card-hint">${escapeHtml(stripTags(track.intro_html || ''))}</div>
|
||
<div class="ep-cta-row">
|
||
<a class="ep-btn ep-btn-primary" href="/exam-prep/${track.exam_key}/practice">
|
||
<i data-lucide="play"></i> Тренировка
|
||
</a>
|
||
<a class="ep-btn" href="/exam-prep/${track.exam_key}/variants">
|
||
<i data-lucide="layout-grid"></i> Все варианты
|
||
</a>
|
||
<a class="ep-btn" href="/exam-prep/${track.exam_key}/mock">
|
||
<i data-lucide="timer"></i> Пробный экзамен
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="ep-card" id="dh-plan"><div class="ep-empty" style="padding:24px"><span class="ep-skel" style="width:200px;height:1em"> </span></div></div>
|
||
|
||
<div class="dh-row">
|
||
<div class="ep-card dh-recent">
|
||
<h3>Последние попытки</h3>
|
||
<div id="dh-recent-list" class="dh-recent-list"><div class="ep-empty" style="padding:24px"><span class="ep-skel" style="width:200px;height:1em"> </span></div></div>
|
||
</div>
|
||
<div class="ep-card dh-heatmap-card">
|
||
<h3>Активность · 28 дней</h3>
|
||
<div id="dh-heatmap"><div class="ep-empty" style="padding:24px"><span class="ep-skel" style="width:200px;height:1em"> </span></div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="ep-card" id="dh-mocks">
|
||
<h3>Пробники</h3>
|
||
<div id="dh-mocks-list"><div class="ep-empty" style="padding:24px"><span class="ep-skel" style="width:200px;height:1em"> </span></div></div>
|
||
</div>
|
||
|
||
<div class="ep-card">
|
||
<h3>Банк задач</h3>
|
||
<div class="ep-card-hint">Всего ${counts.total} задач в ${track.variants_count} вариантах.</div>
|
||
<div class="ep-stats" style="margin-bottom:0">
|
||
<div class="ep-stat">
|
||
<div class="ep-stat-label">Тестовая часть (А)</div>
|
||
<div class="ep-stat-value">${counts.mc}</div>
|
||
<div class="ep-stat-sub">выбор варианта а–д</div>
|
||
</div>
|
||
<div class="ep-stat">
|
||
<div class="ep-stat-label">Краткий ответ</div>
|
||
<div class="ep-stat-value">${counts.open}</div>
|
||
<div class="ep-stat-sub">число / дробь / пара</div>
|
||
</div>
|
||
<div class="ep-stat">
|
||
<div class="ep-stat-label">Развёрнутые</div>
|
||
<div class="ep-stat-value">${counts.long}</div>
|
||
<div class="ep-stat-sub">выражения, графики</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="ep-card" id="dh-weak">
|
||
<h3>Слабые темы</h3>
|
||
<div id="dh-weak-list"><div class="ep-empty" style="padding:20px"><span class="ep-skel" style="width:200px;height:1em"> </span></div></div>
|
||
</div>
|
||
`;
|
||
|
||
if (window.lucide) lucide.createIcons();
|
||
|
||
// Stitch in live aggregates once they arrive.
|
||
const dash = await dashPromise;
|
||
if (dash) {
|
||
renderStreak(dash.streak);
|
||
renderAcc7(dash.accuracy_7d);
|
||
renderRecent(dash.recent_attempts, track.exam_key);
|
||
renderHeatmap(dash.heatmap);
|
||
renderMocks(dash.recent_mocks, track.exam_key);
|
||
renderWeakTopics(dash.weak_topics || [], track.exam_key);
|
||
if (window.lucide) lucide.createIcons();
|
||
}
|
||
|
||
// F10: plan widget
|
||
const planPayload = await planPromise;
|
||
renderPlanWidget(track.exam_key, planPayload?.plan || null);
|
||
if (window.lucide) lucide.createIcons();
|
||
})();
|
||
|
||
/* ════════════════════════════════════════════════════════════════
|
||
Widgets
|
||
════════════════════════════════════════════════════════════════ */
|
||
|
||
function renderStreak(streak) {
|
||
const el = document.getElementById('dh-streak');
|
||
if (!el) return;
|
||
const label = streak === 0 ? 'Нет серии'
|
||
: streak === 1 ? '1 день подряд'
|
||
: `${streak} ${pluralRu(streak, ['день', 'дня', 'дней'])} подряд`;
|
||
el.innerHTML = `
|
||
<div class="ep-stat-label">Серия</div>
|
||
<div class="ep-stat-value ${streak >= 3 ? 'ep-good' : ''}">${streak}</div>
|
||
<div class="ep-stat-sub">${label}${streak >= 3 ? ' · так держать!' : ''}</div>`;
|
||
}
|
||
|
||
function renderAcc7(a) {
|
||
const el = document.getElementById('dh-acc7');
|
||
if (!el) return;
|
||
const valClass = a.pct == null ? '' : a.pct >= 70 ? 'ep-good' : 'ep-warn';
|
||
el.innerHTML = `
|
||
<div class="ep-stat-label">Точность 7 дней</div>
|
||
<div class="ep-stat-value ${valClass}">${a.pct == null ? '—' : a.pct + '%'}</div>
|
||
<div class="ep-stat-sub">${a.correct} верно из ${a.attempts} попыток</div>`;
|
||
}
|
||
|
||
function renderRecent(items, examKey) {
|
||
const el = document.getElementById('dh-recent-list');
|
||
if (!el) return;
|
||
if (!items.length) {
|
||
el.innerHTML = `<div class="ep-empty" style="padding:20px">
|
||
<i data-lucide="hourglass"></i>
|
||
<p>Ещё нет попыток. Откройте <a href="/exam-prep/${examKey}/practice" style="color:var(--violet)">тренажёр</a>.</p>
|
||
</div>`;
|
||
return;
|
||
}
|
||
el.innerHTML = items.map(it => {
|
||
const mark = it.is_correct === 1 ? '<span class="dh-mark dh-mark-ok">✓</span>'
|
||
: it.is_correct === 0 ? '<span class="dh-mark dh-mark-bad">✗</span>'
|
||
: it.solution_viewed ? '<span class="dh-mark dh-mark-view">i</span>'
|
||
: '<span class="dh-mark dh-mark-pending">·</span>';
|
||
const when = relativeTime(it.created_at);
|
||
const modeLabel = it.mode === 'mock' ? 'Пробник'
|
||
: it.mode === 'variant' ? 'Вариант'
|
||
: it.mode === 'practice' ? 'Тренажёр'
|
||
: it.mode === 'topic' ? 'Тема'
|
||
: '';
|
||
const variantBadge = it.variant != null
|
||
? `<a class="dh-recent-loc" href="/exam-prep/${examKey}/variants?v=${it.variant}" title="Открыть вариант ${it.variant}">В${it.variant}·№${it.task_idx}</a>`
|
||
: `<span class="dh-recent-loc">№${it.task_idx}</span>`;
|
||
return `<div class="dh-recent-item">
|
||
${mark}
|
||
${variantBadge}
|
||
<div class="dh-recent-preview">${escapeHtml(it.preview)}</div>
|
||
<div class="dh-recent-meta"><span class="dh-mode">${modeLabel}</span><span class="dh-time">${when}</span></div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderHeatmap(items) {
|
||
const el = document.getElementById('dh-heatmap');
|
||
if (!el) return;
|
||
// Build 28 cells ending today.
|
||
const byDay = new Map(items.map(d => [d.day, d]));
|
||
const cells = [];
|
||
const today = new Date();
|
||
for (let i = 27; i >= 0; i--) {
|
||
const d = new Date(today);
|
||
d.setUTCDate(today.getUTCDate() - i);
|
||
const key = toIsoDate(d);
|
||
const v = byDay.get(key);
|
||
const a = v ? v.attempts : 0;
|
||
let lvl = 0;
|
||
if (a >= 1) lvl = 1;
|
||
if (a >= 5) lvl = 2;
|
||
if (a >= 12) lvl = 3;
|
||
if (a >= 25) lvl = 4;
|
||
const tip = v ? `${key}: ${v.attempts} попыток, ${v.correct} верно` : `${key}: 0`;
|
||
cells.push(`<div class="dh-cell dh-l${lvl}" title="${tip}"></div>`);
|
||
}
|
||
el.innerHTML = `
|
||
<div class="dh-heatmap">${cells.join('')}</div>
|
||
<div class="dh-heatmap-legend">
|
||
<span>меньше</span>
|
||
<span class="dh-cell dh-l0"></span>
|
||
<span class="dh-cell dh-l1"></span>
|
||
<span class="dh-cell dh-l2"></span>
|
||
<span class="dh-cell dh-l3"></span>
|
||
<span class="dh-cell dh-l4"></span>
|
||
<span>больше</span>
|
||
</div>`;
|
||
}
|
||
|
||
/* ─── Plan widget (F10) ──────────────────────────────────────── */
|
||
|
||
function renderPlanWidget(examKey, plan) {
|
||
const el = document.getElementById('dh-plan');
|
||
if (!el) return;
|
||
|
||
if (!plan) {
|
||
el.innerHTML = `
|
||
<h3>План подготовки</h3>
|
||
<div class="ep-card-hint">Укажите дату экзамена — посчитаем, сколько задач нужно делать в день, чтобы успеть.</div>
|
||
<div class="ep-cta-row">
|
||
<button class="ep-btn ep-btn-primary" id="dh-plan-create">
|
||
<i data-lucide="calendar-plus"></i> Создать план
|
||
</button>
|
||
</div>`;
|
||
el.querySelector('#dh-plan-create').onclick = () => openPlanModal(examKey, null);
|
||
return;
|
||
}
|
||
|
||
const daysLeft = plan.days_left;
|
||
const tasksLeft = plan.tasks_left;
|
||
const tgt = plan.daily_target || 10;
|
||
const doneToday = plan.today.solved;
|
||
const pctToday = tgt ? Math.min(100, Math.round((doneToday / tgt) * 100)) : 0;
|
||
|
||
const daysClass = daysLeft <= 7 ? 'ep-warn' : daysLeft <= 30 ? 'ep-violet' : '';
|
||
const daysLabel = daysLeft < 0
|
||
? `${Math.abs(daysLeft)} ${pluralRu(Math.abs(daysLeft), ['день','дня','дней'])} назад`
|
||
: daysLeft === 0
|
||
? 'сегодня экзамен'
|
||
: `${daysLeft} ${pluralRu(daysLeft, ['день','дня','дней'])}`;
|
||
const examDateFmt = formatRuDate(plan.exam_date);
|
||
const expired = daysLeft < 0;
|
||
|
||
el.innerHTML = `
|
||
<div class="dh-plan-head">
|
||
<h3>План подготовки</h3>
|
||
<button class="dh-plan-edit" id="dh-plan-edit" title="Изменить план">
|
||
<i data-lucide="pencil"></i>
|
||
</button>
|
||
</div>
|
||
<div class="dh-plan-grid">
|
||
<div class="dh-plan-stat">
|
||
<div class="ep-stat-label">До экзамена</div>
|
||
<div class="ep-stat-value ${daysClass}">${daysLeft < 0 ? '—' : daysLeft}</div>
|
||
<div class="ep-stat-sub">${daysLabel} · ${examDateFmt}</div>
|
||
</div>
|
||
<div class="dh-plan-stat">
|
||
<div class="ep-stat-label">Сегодня</div>
|
||
<div class="ep-stat-value ${doneToday >= tgt ? 'ep-good' : ''}">${doneToday} <span style="font-size:.7em;color:var(--text-3);font-weight:600">/ ${tgt}</span></div>
|
||
<div class="ep-bar"><div class="ep-bar-fill" style="width:${pctToday}%"></div></div>
|
||
<div class="ep-stat-sub">${doneToday >= tgt ? 'цель на сегодня выполнена!' : `осталось ${tgt - doneToday}`}</div>
|
||
</div>
|
||
<div class="dh-plan-stat">
|
||
<div class="ep-stat-label">Осталось задач</div>
|
||
<div class="ep-stat-value">${tasksLeft}</div>
|
||
<div class="ep-stat-sub">${daysLeft > 0 ? `~${Math.ceil(tasksLeft / daysLeft)} в день, чтобы успеть` : 'дата прошла'}</div>
|
||
</div>
|
||
</div>
|
||
${expired ? `<div class="dh-plan-expired">Дата экзамена в прошлом. Перенесите план или удалите его.</div>` : ''}
|
||
<div class="ep-cta-row" style="margin-top:14px">
|
||
<a class="ep-btn ep-btn-primary" href="/exam-prep/${examKey}/practice">
|
||
<i data-lucide="play"></i> Сделать ${Math.max(1, tgt - doneToday)} задач сейчас
|
||
</a>
|
||
</div>`;
|
||
el.querySelector('#dh-plan-edit').onclick = () => openPlanModal(examKey, plan);
|
||
}
|
||
|
||
function openPlanModal(examKey, plan) {
|
||
const today = toIsoDate(new Date());
|
||
const examDate = plan?.exam_date || addDaysIso(today, 60); // default: 2 months out
|
||
const dailyTarget = plan?.daily_target || 10;
|
||
|
||
const body = `
|
||
<div class="dh-plan-form">
|
||
<div class="dh-plan-field">
|
||
<label>Дата экзамена</label>
|
||
<input type="date" id="dh-plan-date" value="${examDate}" min="${today}" class="dh-plan-input" />
|
||
</div>
|
||
<div class="dh-plan-field">
|
||
<label>Задач в день (5–50)</label>
|
||
<input type="number" id="dh-plan-target" value="${dailyTarget}" min="5" max="50" class="dh-plan-input" />
|
||
<div class="dh-plan-hint">Если оставить пусто или вне диапазона — посчитаем автоматом.</div>
|
||
</div>
|
||
<div class="dh-plan-field" id="dh-plan-derived"></div>
|
||
</div>`;
|
||
|
||
const actions = [
|
||
{ label: 'Отмена', onClick: () => m.close() },
|
||
];
|
||
if (plan) {
|
||
actions.push({
|
||
label: 'Удалить план',
|
||
onClick: async () => {
|
||
if (!confirm('Удалить план? Прогресс сохранится.')) return;
|
||
try {
|
||
await EP.api.deletePlan(examKey);
|
||
m.close();
|
||
location.reload();
|
||
} catch (e) {
|
||
m.setError(`Не удалось удалить: ${e.message || e}`);
|
||
}
|
||
},
|
||
});
|
||
}
|
||
actions.push({
|
||
label: plan ? 'Сохранить' : 'Создать план',
|
||
primary: true,
|
||
onClick: async () => {
|
||
const date = m.body.querySelector('#dh-plan-date').value;
|
||
const target = Number(m.body.querySelector('#dh-plan-target').value) || 10;
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||
m.setError('Введите дату экзамена');
|
||
return;
|
||
}
|
||
try {
|
||
await EP.api.savePlan(examKey, {
|
||
exam_date: date,
|
||
daily_target: Math.max(5, Math.min(50, target)),
|
||
});
|
||
m.close();
|
||
location.reload();
|
||
} catch (e) {
|
||
m.setError(`Не удалось сохранить: ${e.message || e}`);
|
||
}
|
||
},
|
||
});
|
||
|
||
const m = LS.modal({
|
||
title: plan ? 'Изменить план' : 'Новый план подготовки',
|
||
content: body,
|
||
size: 'sm',
|
||
actions,
|
||
});
|
||
|
||
// Live-preview derived numbers
|
||
const dateInp = m.body.querySelector('#dh-plan-date');
|
||
const tgtInp = m.body.querySelector('#dh-plan-target');
|
||
const derived = m.body.querySelector('#dh-plan-derived');
|
||
function updateDerived() {
|
||
const d = dateInp.value;
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) { derived.innerHTML = ''; return; }
|
||
const left = daysFromTodayIso(d);
|
||
const tgt = Math.max(5, Math.min(50, Number(tgtInp.value) || 10));
|
||
derived.innerHTML = `
|
||
<div class="dh-plan-derived-card">
|
||
<span>До экзамена: <b>${left < 0 ? Math.abs(left) + ' дн. назад' : left + ' ' + pluralRu(left, ['день','дня','дней'])}</b></span>
|
||
<span>При ${tgt} задач/день: <b>${left > 0 ? left * tgt : 0} задач максимум</b></span>
|
||
</div>`;
|
||
}
|
||
dateInp.oninput = updateDerived;
|
||
tgtInp.oninput = updateDerived;
|
||
updateDerived();
|
||
}
|
||
|
||
function addDaysIso(iso, n) {
|
||
const [y, m, d] = iso.split('-').map(Number);
|
||
const dt = new Date(Date.UTC(y, m - 1, d));
|
||
dt.setUTCDate(dt.getUTCDate() + n);
|
||
return toIsoDate(dt);
|
||
}
|
||
function daysFromTodayIso(iso) {
|
||
const [y, m, d] = iso.split('-').map(Number);
|
||
const t = Date.UTC(y, m - 1, d);
|
||
const n = new Date();
|
||
const today = Date.UTC(n.getUTCFullYear(), n.getUTCMonth(), n.getUTCDate());
|
||
return Math.round((t - today) / 86400000);
|
||
}
|
||
function formatRuDate(iso) {
|
||
if (!iso) return '';
|
||
const [y, m, d] = iso.split('-').map(Number);
|
||
const months = ['янв','фев','мар','апр','мая','июн','июл','авг','сен','окт','ноя','дек'];
|
||
return `${d} ${months[m - 1]} ${y}`;
|
||
}
|
||
|
||
function renderWeakTopics(items, examKey) {
|
||
const el = document.getElementById('dh-weak-list');
|
||
if (!el) return;
|
||
if (!items.length) {
|
||
el.innerHTML = `<div class="ep-empty" style="padding:20px">
|
||
<i data-lucide="check-circle-2"></i>
|
||
<p>Слабых тем не выявлено. Решите больше задач, чтобы увидеть приоритеты.</p>
|
||
</div>`;
|
||
return;
|
||
}
|
||
el.innerHTML = `
|
||
<div class="dh-weak-rows">
|
||
${items.map(w => {
|
||
const tasksLeft = Math.max(0, w.total_tasks - w.solved_tasks);
|
||
return `<a class="dh-weak-row" href="/exam-prep/${examKey}/topics/${encodeURIComponent(w.slug)}">
|
||
<div class="dh-weak-info">
|
||
<div class="dh-weak-title">${escapeHtml(w.title)}</div>
|
||
<div class="dh-weak-meta">${w.correct}/${w.attempts} попыток · ${tasksLeft} задач не взято</div>
|
||
</div>
|
||
<div class="dh-weak-accuracy">${w.accuracy}%</div>
|
||
<div class="dh-weak-cta">Прокачать <i data-lucide="arrow-right"></i></div>
|
||
</a>`;
|
||
}).join('')}
|
||
</div>
|
||
<div class="ep-cta-row" style="margin-top:14px">
|
||
<a class="ep-btn ep-btn-primary" href="/exam-prep/${examKey}/practice?strategy=weak">
|
||
<i data-lucide="target"></i> Тренировать слабые темы
|
||
</a>
|
||
</div>`;
|
||
}
|
||
|
||
function renderMocks(items, examKey) {
|
||
const el = document.getElementById('dh-mocks-list');
|
||
if (!el) return;
|
||
if (!items.length) {
|
||
el.innerHTML = `<div class="ep-empty" style="padding:20px">
|
||
<i data-lucide="timer-off"></i>
|
||
<p>Пробников пока не было. <a href="/exam-prep/${examKey}/mock" style="color:var(--violet)">Запустить первый</a>?</p>
|
||
</div>`;
|
||
return;
|
||
}
|
||
el.innerHTML = items.map(m => {
|
||
const isActive = m.status === 'active';
|
||
const title = m.source === 'variant' ? `Вариант ${m.variant}` : `Случайные ${m.total_tasks}`;
|
||
const when = relativeTime(m.started_at);
|
||
const score = isActive
|
||
? `<span class="dh-mock-active">В процессе</span>`
|
||
: `<span class="dh-mock-score">${m.score != null ? m.score + ' баллов' : '—'} · ${m.total_correct}/${m.total_tasks}</span>`;
|
||
return `<a class="dh-mock-row" href="/exam-prep/${examKey}/mock/${m.id}">
|
||
<span class="dh-mock-title">${title}</span>
|
||
${score}
|
||
<span class="dh-mock-when">${when}</span>
|
||
</a>`;
|
||
}).join('');
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════════════════════
|
||
Utils
|
||
════════════════════════════════════════════════════════════════ */
|
||
|
||
function pluralRu(n, forms) {
|
||
const mod10 = n % 10;
|
||
const mod100 = n % 100;
|
||
if (mod100 >= 11 && mod100 <= 14) return forms[2];
|
||
if (mod10 === 1) return forms[0];
|
||
if (mod10 >= 2 && mod10 <= 4) return forms[1];
|
||
return forms[2];
|
||
}
|
||
|
||
function relativeTime(ts) {
|
||
if (!ts) return '';
|
||
const diff = (Date.now() - Number(ts)) / 1000;
|
||
if (diff < 60) return 'только что';
|
||
if (diff < 3600) return `${Math.floor(diff/60)} мин назад`;
|
||
if (diff < 86400) return `${Math.floor(diff/3600)} ч назад`;
|
||
if (diff < 7 * 86400) return `${Math.floor(diff/86400)} ${pluralRu(Math.floor(diff/86400), ['день','дня','дней'])} назад`;
|
||
const d = new Date(Number(ts));
|
||
return `${String(d.getDate()).padStart(2,'0')}.${String(d.getMonth()+1).padStart(2,'0')}`;
|
||
}
|
||
|
||
function toIsoDate(d) {
|
||
const y = d.getUTCFullYear();
|
||
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
|
||
const day = String(d.getUTCDate()).padStart(2, '0');
|
||
return `${y}-${m}-${day}`;
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]));
|
||
}
|
||
function stripTags(s) {
|
||
return String(s || '').replace(/<[^>]+>/g, '');
|
||
}
|