Files
Maxim Dolgolyov 758e1bf6cb feat(dashboard): статус «идёт онлайн-урок» с присоединением
На дашборде ученика/учителя — баннер активной classroom-сессии: заголовок урока,
для учителя «N онлайн», для ученика «Присоединиться/Вернуться», ссылка на /classroom
(там сессия подхватывается автоматически). Данные — LS.crGetMySession (учитель → своя
сессия, ученик → сессия его класса/приглашения). Нет активной сессии → баннер скрыт.

Доска работает по WebSocket, дашборд — по SSE, поэтому добавлен отдельный SSE-сигнал
classroom_live (state started/ended) ученикам класса/приглашённым/учителю в createSession
и endSession (аддитивно, в try/catch — не ломает создание/завершение сессии). Баннер
живо появляется/исчезает по этому событию + обновляется при возврате на вкладку.

Verified: рендер баннера 10/10 (ученик/учитель/нет сессии, online-счёт без вышедших,
пустой title→«Онлайн-урок»); node --check sessions.js + инлайна dashboard; sse-путь резолвится.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 14:24:14 +03:00

4638 lines
243 KiB
HTML

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Личный кабинет — LearnSpace</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/ls.css" />
<style>
.container { max-width: 1600px; margin: 0 auto; padding: 24px 32px 100px; }
/* ── working area bg ── */
.sb-content { background: #f4f5f8; }
/* ── ZONE 1: Compact Header (sticky) ── */
.dash-header {
display: flex; align-items: center; gap: 18px;
height: 68px; padding: 0 28px;
background: rgba(255,255,255,0.93); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
border-bottom: 1px solid rgba(155,93,229,.1);
box-shadow: 0 2px 14px rgba(15,23,42,.05);
position: sticky; top: 0; z-index: 90;
}
.dh-avatar {
width: 48px; height: 48px; border-radius: 50%;
background: var(--grad-1);
display: flex; align-items: center; justify-content: center;
font-family: 'Unbounded', sans-serif; font-size: 0.92rem; font-weight: 800; color: #fff;
flex-shrink: 0;
box-shadow: 0 4px 16px rgba(155,93,229,.4);
}
.dh-text { flex: 1; min-width: 0; }
.dh-greeting {
font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800;
color: var(--text); letter-spacing: -0.02em;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.dh-sub { font-size: 0.78rem; color: var(--text-3); font-weight: 500; margin-top: 2px; }
.dh-stats {
display: flex; gap: 0; flex-shrink: 0;
background: rgba(155,93,229,.05);
border: 1.5px solid rgba(155,93,229,.14);
border-radius: 16px; padding: 8px 4px;
}
.stat-ring {
display: flex; flex-direction: row; align-items: center; gap: 9px;
padding: 0 14px;
}
.stat-ring + .stat-ring { border-left: 1px solid rgba(155,93,229,.12); }
.stat-ring .sr-text { display: flex; flex-direction: column; gap: 1px; }
.stat-ring .sr-val { font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 800; line-height: 1.1; }
.stat-ring .sr-label { font-size: 0.56rem; color: var(--text-3); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; }
/* ── ZONE 2: Action Banner + Cards ── */
.action-zone { margin-bottom: 22px; }
.action-banner {
border-radius: 16px; padding: 18px 24px;
display: flex; align-items: center; gap: 18px;
margin-bottom: 14px; color: #fff; text-decoration: none;
}
.action-banner.ab-urgent {
background: linear-gradient(135deg, #1a0a2e 0%, #5c1a3e 50%, #8b1a1a 100%);
}
.action-banner.ab-continue {
background: linear-gradient(135deg, #0a0818 0%, #1a1040 60%, #0d1f3c 100%);
}
.ab-icon { font-size: 1.6rem; flex-shrink: 0; }
.ab-body { flex: 1; min-width: 0; }
.ab-title { font-family: 'Unbounded', sans-serif; font-size: 0.88rem; font-weight: 800; margin-bottom: 4px; }
.ab-sub { font-size: 0.78rem; opacity: 0.7; }
.ab-countdown { text-align: right; flex-shrink: 0; }
.ab-countdown-val { font-family: 'Unbounded', sans-serif; font-size: 1.4rem; font-weight: 900; }
.ab-countdown-label { font-size: 0.68rem; opacity: 0.6; font-weight: 600; }
.ab-btn {
padding: 8px 22px; border: none; border-radius: 99px;
background: rgba(255,255,255,0.15); color: #fff; backdrop-filter: blur(8px);
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 700;
cursor: pointer; transition: background 0.15s; white-space: nowrap; flex-shrink: 0;
text-decoration: none;
}
.ab-btn:hover { background: rgba(255,255,255,0.25); }
/* ── Hero cards row (Reading · Lab of day · Pet) ── */
.hero-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; }
/* ── Live online-lesson banner ── */
.live-lesson {
display: flex; align-items: center; gap: 14px; text-decoration: none;
background: linear-gradient(100deg, #059652, #06D6A0); color: #fff;
border-radius: 16px; padding: 14px 20px; margin-bottom: 18px;
box-shadow: 0 6px 22px rgba(5,150,82,0.28); transition: transform .15s, box-shadow .15s;
}
.live-lesson:hover { transform: translateY(-1px); box-shadow: 0 10px 28px rgba(5,150,82,0.34); }
.ll-dot { width: 12px; height: 12px; border-radius: 50%; background: #fff; flex-shrink: 0;
box-shadow: 0 0 0 0 rgba(255,255,255,0.7); animation: llPulse 1.6s infinite; }
@keyframes llPulse {
0% { box-shadow: 0 0 0 0 rgba(255,255,255,0.6); }
70% { box-shadow: 0 0 0 10px rgba(255,255,255,0); }
100% { box-shadow: 0 0 0 0 rgba(255,255,255,0); }
}
.ll-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.ll-text b { font-family: 'Unbounded', sans-serif; font-size: 0.92rem; font-weight: 800;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ll-text span { font-size: 0.78rem; opacity: 0.92; }
.ll-cta { flex-shrink: 0; background: rgba(255,255,255,0.95); color: #059652;
font-weight: 800; font-size: 0.82rem; padding: 8px 16px; border-radius: 10px; white-space: nowrap; }
@media (max-width: 480px) {
.live-lesson { padding: 12px 14px; gap: 10px; }
.ll-cta { padding: 7px 12px; font-size: 0.78rem; }
}
.hero-card {
position: relative; border-radius: 18px; padding: 18px 20px;
display: flex; flex-direction: column; min-height: 196px;
text-decoration: none; color: inherit; overflow: hidden;
transition: transform 0.16s, box-shadow 0.16s;
}
.hero-card:hover { transform: translateY(-3px); box-shadow: 0 14px 34px rgba(15,23,42,0.16); }
.hc-tag {
display: inline-flex; align-items: center; gap: 7px;
font-size: 0.66rem; font-weight: 800; letter-spacing: .07em; text-transform: uppercase;
margin-bottom: 12px;
}
.hc-tag svg { width: 15px; height: 15px; }
.hc-h { font-family: 'Unbounded', sans-serif; font-size: 1.15rem; font-weight: 800; line-height: 1.15; }
.hc-p { font-size: 0.76rem; line-height: 1.45; margin-top: 7px; }
.hc-foot { margin-top: auto; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding-top: 14px; }
.hc-meta { font-size: 0.7rem; font-weight: 600; }
.hc-btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 15px; border-radius: 99px; flex-shrink: 0;
font-family: 'Manrope', sans-serif; font-size: 0.78rem; font-weight: 700;
border: none; cursor: pointer; transition: filter 0.15s, transform 0.12s;
}
.hc-btn svg { width: 14px; height: 14px; }
.hc-btn:hover { filter: brightness(1.08); }
.hc-btn:active { transform: translateY(1px); }
.hc-progress { height: 6px; border-radius: 99px; margin-top: 12px; overflow: hidden; }
.hc-progress > i { display: block; height: 100%; border-radius: 99px; }
/* Card 1 — Reading (warm gradient) */
.hc-read {
background: linear-gradient(140deg, #e8803a 0%, #c25020 55%, #8b3010 100%);
color: #fff;
}
.hc-read .hc-read-bg {
position: absolute; inset: 0; z-index: 0; pointer-events: none;
display: flex; align-items: center; justify-content: flex-end; overflow: hidden;
}
.hc-read .hc-read-bg svg { width: 200px; height: 200px; flex-shrink: 0; }
.hc-read > *:not(.hc-read-bg) { position: relative; z-index: 1; }
.hc-read .hc-tag {
background: rgba(255,255,255,.15); backdrop-filter: blur(8px);
padding: 3px 11px 3px 7px; border-radius: 99px;
color: rgba(255,255,255,.95); align-self: flex-start;
}
.hc-read .hc-p { color: rgba(255,255,255,.8); }
.hc-read .hc-foot-left { display: flex; align-items: center; gap: 7px; min-width: 0; }
.hc-read .hc-meta { color: rgba(255,255,255,.75); }
.hc-read .hc-pct {
font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 0.78rem;
color: #fff; opacity: .9;
}
.hc-read .hc-progress { height: 7px; background: rgba(255,255,255,.22); }
.hc-read .hc-progress > i {
background: rgba(255,255,255,.96);
box-shadow: 0 0 10px rgba(255,255,255,.45);
}
.hc-read .hc-btn { background: #fff; color: #b3531a; box-shadow: 0 2px 14px rgba(0,0,0,.2); }
/* Card 2 — Lab of day (dark) */
.hc-lab { background: linear-gradient(150deg, #16131f 0%, #1d1830 100%); color: #fff; border: 1px solid rgba(155,93,229,.18); }
.hc-lab .hc-bg { position: absolute; inset: 0; opacity: .5; z-index: 0; }
.hc-lab .hc-bg svg { width: 100%; height: 100%; }
.hc-lab > *:not(.hc-bg) { position: relative; z-index: 1; }
.hc-lab .hc-tag { color: #06D6E0; }
.hc-lab .hc-tag svg { stroke: #06D6E0; }
.hc-lab .hc-p { color: rgba(255,255,255,.74); }
.hc-lab .hc-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.hc-lab .hc-chip { font-size: 0.66rem; font-weight: 700; padding: 3px 9px; border-radius: 99px; background: rgba(255,255,255,.08); color: rgba(255,255,255,.82); }
.hc-lab .hc-chip.subj { background: rgba(6,214,224,.16); color: #06D6E0; }
.hc-lab .hc-meta { color: rgba(255,255,255,.6); }
.hc-lab .hc-btn { background: rgba(255,255,255,.12); color: #fff; backdrop-filter: blur(6px); }
/* Card 3 — Pet (warm cream, sparkle bg) */
.hc-pet {
background: linear-gradient(140deg, #fffdf7 0%, #fff8e7 100%);
border: 1.5px solid rgba(249,199,79,.28);
border-top: 4px solid #F9C74F;
}
.hc-pet .hc-pet-bg {
position: absolute; inset: 0; z-index: 0; pointer-events: none; overflow: hidden;
}
.hc-pet .hc-pet-bg svg { width: 100%; height: 100%; }
.hc-pet > *:not(.hc-pet-bg) { position: relative; z-index: 1; }
.hc-pet .hc-tag {
background: rgba(249,199,79,.18); padding: 3px 11px 3px 7px;
border-radius: 99px; color: #92400e; align-self: flex-start;
}
.hc-pet .hc-tag svg { stroke: #e08c1a; }
.hc-pet .hc-pet-top { display: flex; align-items: center; gap: 10px; }
.hc-pet .hc-pet-name { font-family: 'Unbounded', sans-serif; font-size: 1.15rem; font-weight: 800; }
.hc-pet .hc-pet-art {
width: 54px; height: 54px; margin-left: auto; flex-shrink: 0;
filter: drop-shadow(0 0 10px rgba(249,199,79,.7));
}
.hc-pet .hc-pet-art svg { width: 100%; height: 100%; }
.hc-pet .hc-xp-row {
display: flex; align-items: baseline; justify-content: space-between;
margin-top: 12px; font-size: 0.7rem; color: var(--text-3);
}
.hc-pet .hc-xp-row b { color: var(--text); font-weight: 800; }
.hc-pet .hc-progress { height: 7px; background: rgba(249,199,79,.2); }
.hc-pet .hc-progress > i {
background: linear-gradient(90deg, #F9C74F, #F98231);
box-shadow: 0 0 8px rgba(249,150,49,.45);
}
.hc-pet .hc-pet-chips { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; margin-top: 12px; }
.hc-pet .hc-pchip {
text-align: center; padding: 8px 4px; border-radius: 12px;
background: rgba(15,23,42,.04); border: 1px solid rgba(15,23,42,.06);
}
.hc-pet .hc-pchip b {
display: block; font-family: 'Unbounded', sans-serif;
font-size: 0.88rem; font-weight: 800; color: var(--text);
}
.hc-pet .hc-pchip span {
display: block; font-size: 0.58rem; font-weight: 700;
letter-spacing: .04em; text-transform: uppercase; color: var(--text-3); margin-top: 2px;
}
.hc-pet .hc-pchip.chip-streak { background: rgba(249,115,22,.09); border-color: rgba(249,115,22,.2); }
.hc-pet .hc-pchip.chip-streak b { color: #c2410c; }
.hc-pet .hc-pchip.chip-goal { background: rgba(16,185,129,.09); border-color: rgba(16,185,129,.2); }
.hc-pet .hc-pchip.chip-goal b { color: #047857; }
.hc-pet .hc-pchip.chip-mood { background: rgba(124,58,237,.09); border-color: rgba(124,58,237,.2); }
.hc-pet .hc-pchip.chip-mood b { color: #6d28d9; }
.hc-pet .hc-btn {
background: linear-gradient(135deg, #F9C74F 0%, #F98231 100%);
color: #7c2d00; box-shadow: 0 2px 12px rgba(249,150,49,.35);
margin-top: 12px; align-self: flex-start;
}
/* ── ZONE 3: Three-Column Grid ── */
.main-grid {
display: grid;
grid-template-columns: 1.3fr 0.8fr 1fr;
gap: 22px;
margin-bottom: 22px;
}
.full-row { margin-bottom: 22px; }
/* Bottom row: Activity · My submissions · Challenges side by side */
.bottom-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 22px;
margin-bottom: 22px;
align-items: stretch;
}
.bottom-grid > * { margin-bottom: 0; height: 100%; }
.qa-btn {
display: flex; align-items: center; gap: 8px;
padding: 12px 20px; border-radius: 14px;
border: 1.5px solid rgba(15,23,42,0.08);
background: #fff; color: var(--text);
font-family: 'Manrope', sans-serif; font-size: 0.84rem; font-weight: 700;
cursor: pointer; transition: all 0.18s; text-decoration: none;
box-shadow: 0 2px 8px rgba(15,23,42,0.04);
}
.qa-btn:hover { border-color: rgba(155,93,229,0.3); transform: translateY(-1px); box-shadow: 0 6px 20px rgba(15,23,42,0.1); }
.widget {
background: #fff;
border: 1.5px solid rgba(15,23,42,0.07);
border-radius: 20px;
padding: 20px;
box-shadow: 0 2px 8px rgba(15,23,42,0.04);
transition: box-shadow 0.2s;
}
.widget:hover { box-shadow: 0 6px 24px rgba(15,23,42,0.08); }
.w-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 16px;
}
.w-title {
font-family: 'Unbounded', sans-serif; font-size: 0.72rem; font-weight: 800;
color: var(--text-3); text-transform: uppercase; letter-spacing: 0.08em;
display: flex; align-items: center; gap: 8px;
}
.w-title::before {
content: ''; display: inline-block; width: 3px; height: 14px;
background: linear-gradient(180deg, #06D6E0, #9B5DE5);
border-radius: 99px; flex-shrink: 0;
}
.w-more {
font-size: 0.74rem; font-weight: 700; color: var(--violet);
text-decoration: none; cursor: pointer; background: none; border: none;
font-family: 'Manrope', sans-serif;
}
.w-more:hover { text-decoration: underline; }
/* ── last grades mini-list ── */
.grade-row {
display: flex; align-items: center; gap: 12px;
padding: 8px 0;
border-bottom: 1px solid rgba(15,23,42,0.05);
}
.grade-row:last-child { border-bottom: none; }
.grade-pct { font-family: 'Unbounded', sans-serif; font-size: 0.88rem; font-weight: 900; min-width: 42px; text-align: center; }
.grade-body { flex: 1; min-width: 0; }
.grade-subj { font-size: 0.82rem; font-weight: 700; color: var(--text); }
.grade-date { font-size: 0.7rem; color: var(--text-3); }
/* ── activity widget tabs ── */
.act-tabs { display: flex; gap: 4px; }
.act-tab {
padding: 3px 12px; border-radius: 99px; border: none;
font-family: 'Manrope', sans-serif; font-size: 0.68rem; font-weight: 700;
color: var(--text-3); background: transparent; cursor: pointer; transition: all 0.15s;
}
.act-tab:hover { color: var(--violet); }
.act-tab.active { background: var(--text); color: #fff; }
.act-scale-btns { display: inline-flex; gap: 2px; padding: 3px; border-radius: 10px; background: rgba(15,23,42,0.05); }
.act-scale-btn {
padding: 3px 11px; border-radius: 7px; border: none;
font-family: 'Manrope', sans-serif; font-size: 0.64rem; font-weight: 700;
color: var(--text-3); background: transparent; cursor: pointer; transition: all 0.15s;
}
.act-scale-btn:hover { color: var(--violet); }
.act-scale-btn.active { background: #fff; color: var(--violet); box-shadow: 0 1px 4px rgba(15,23,42,0.1); }
.act-pane { display: none; }
.act-pane.visible { display: block; }
/* ── mini heatmap (redesigned) ── */
.hm-months { display: flex; margin-bottom: 2px; padding-left: 22px; }
.hm-month-label { font-size: 0.56rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.02em; overflow: hidden; white-space: nowrap; }
.hm-body { display: flex; gap: 0; }
.hm-weekdays { display: flex; flex-direction: column; gap: 2px; width: 22px; flex-shrink: 0; padding-top: 1px; }
.hm-wd { font-size: 0.5rem; font-weight: 700; color: var(--text-3); height: 14px; line-height: 14px; }
.mini-heatmap { display: grid; grid-template-rows: repeat(7, 14px); grid-auto-flow: column; grid-auto-columns: 14px; gap: 2px; }
/* Hero stat row */
.hm-hero { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 13px; }
.hm-hero-num-row { display: flex; align-items: baseline; gap: 7px; flex-wrap: wrap; }
.hm-hero-num { font-family: 'Unbounded', sans-serif; font-size: 1.72rem; font-weight: 800; color: var(--text); line-height: 1; }
.hm-hero-unit { font-size: 0.76rem; font-weight: 700; color: var(--text-3); }
.hm-hero-sub { font-size: 0.72rem; color: var(--text-3); font-weight: 600; margin-top: 6px; }
.hm-hero-sub strong { color: var(--text); font-weight: 800; }
.hm-trend-pill { display: inline-flex; align-items: center; gap: 3px; padding: 3px 9px; border-radius: 99px;
font-size: 0.66rem; font-weight: 800; font-family: 'Manrope', sans-serif; white-space: nowrap; }
.hm-trend-pill svg { width: 12px; height: 12px; stroke-width: 2.6; }
.hm-trend-pill.up { background: rgba(5,150,82,0.1); color: #059652; }
.hm-trend-pill.down { background: rgba(224,51,94,0.1); color: #E0335E; }
.hm-trend-pill.flat { background: rgba(15,23,42,0.05); color: var(--text-3); }
.hm-empty { padding: 28px 16px; text-align: center; color: var(--text-3); }
.hm-empty-ic { color: var(--violet); opacity: .85; margin-bottom: 8px; }
.hm-empty-t { font-weight: 700; color: var(--text); font-size: 0.92rem; margin-bottom: 4px; }
.hm-empty-s { font-size: 0.78rem; line-height: 1.5; max-width: 320px; margin: 0 auto 12px; }
.hm-empty-cta { display: inline-block; padding: 8px 16px; border-radius: 9px; background: var(--violet); color: #fff; font-weight: 700; font-size: 0.82rem; text-decoration: none; }
.mhm-cell {
width: 14px; height: 14px; border-radius: 4px; background: rgba(15,23,42,0.05);
cursor: pointer; transition: transform 0.12s ease, box-shadow 0.12s ease;
animation: hmCellIn 0.3s ease both;
}
.mhm-cell.has-data { box-shadow: inset 0 0 0 0.5px rgba(15,23,42,0.06); }
.mhm-cell:hover { transform: scale(1.34); z-index: 2; position: relative; border-radius: 5px; }
.mhm-cell.has-data:hover { box-shadow: 0 3px 12px var(--cell-color, rgba(155,93,229,0.5)); }
@keyframes hmCellIn { from { opacity: 0; transform: scale(0.3); } to { opacity: 1; transform: scale(1); } }
/* Subject-colored cells */
.mhm-cell.s-bio { --cell-color: rgba(155,93,229,0.85); }
.mhm-cell.s-chem { --cell-color: rgba(6,214,160,0.85); }
.mhm-cell.s-math { --cell-color: rgba(6,182,212,0.85); }
.mhm-cell.s-phys { --cell-color: rgba(245,158,11,0.85); }
.mhm-cell.s-mix { --cell-color: rgba(155,93,229,0.6); }
/* Heatmap legend */
.hm-legend-row {
display: flex; align-items: center; flex-wrap: wrap; gap: 6px;
margin-top: 13px; padding-top: 11px; border-top: 1px solid rgba(15,23,42,0.06);
}
.hm-legend { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }
.hm-legend-chip {
display: inline-flex; align-items: center; gap: 5px; padding: 2px 9px;
border-radius: 99px; background: rgba(15,23,42,0.04);
font-size: 0.6rem; font-weight: 700; color: var(--text-3);
}
.hm-legend-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
/* Day popup */
.hm-day-popup {
position: fixed; z-index: 250; pointer-events: auto;
background: #fff; border: 1.5px solid rgba(15,23,42,0.1);
border-radius: 14px; padding: 14px 16px; min-width: 200px;
box-shadow: 0 12px 40px rgba(15,23,42,0.15);
font-family: 'Manrope', sans-serif;
animation: fadeIn 0.2s ease;
}
.hm-day-popup .hdp-date { font-size: 0.72rem; font-weight: 800; color: var(--text); margin-bottom: 8px; }
.hm-day-popup .hdp-row {
display: flex; align-items: center; gap: 8px;
padding: 5px 0; border-bottom: 1px solid rgba(15,23,42,0.04);
font-size: 0.75rem;
}
.hm-day-popup .hdp-row:last-child { border-bottom: none; }
.hm-day-popup .hdp-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.hm-day-popup .hdp-subj { flex: 1; font-weight: 600; color: var(--text); }
.hm-day-popup .hdp-score { font-family: 'Unbounded', sans-serif; font-size: 0.72rem; font-weight: 900; }
.hm-day-popup .hdp-mode { font-size: 0.66rem; color: var(--text-3); }
.hm-day-popup .hdp-empty { font-size: 0.75rem; color: var(--text-3); padding: 4px 0; }
/* Dark mode */
.app-layout.dark .act-tab.active { background: #E8ECF2; color: var(--text); }
.app-layout.dark .act-scale-btns { background: rgba(255,255,255,0.05); }
.app-layout.dark .act-scale-btn { color: #6B7A8E; }
.app-layout.dark .act-scale-btn.active { background: rgba(255,255,255,0.1); color: var(--violet); box-shadow: none; }
.app-layout.dark .mhm-cell { background: rgba(255,255,255,0.04); }
.app-layout.dark .mhm-cell.has-data { box-shadow: inset 0 0 0 0.5px rgba(255,255,255,0.07); }
.app-layout.dark .hm-hero-num, .app-layout.dark .hm-hero-sub strong { color: #E8ECF2; }
.app-layout.dark .hm-legend-row { border-color: rgba(255,255,255,0.07); }
.app-layout.dark .hm-legend-chip { background: rgba(255,255,255,0.05); }
.app-layout.dark .hm-day-popup { background: #1A1D27; border-color: rgba(255,255,255,0.08); box-shadow: 0 12px 40px rgba(0,0,0,0.4); }
.app-layout.dark .hm-day-popup .hdp-date, .app-layout.dark .hm-day-popup .hdp-subj { color: #E8ECF2; }
/* ── continue reading banner ── */
.continue-card {
display: flex; align-items: center; gap: 14px;
padding: 14px 16px; border-radius: 14px;
background: linear-gradient(135deg, rgba(155,93,229,0.05), rgba(6,214,224,0.04));
border: 1px solid rgba(155,93,229,0.12); cursor: pointer;
transition: all 0.15s; text-decoration: none; color: inherit;
}
.continue-card:hover { border-color: rgba(155,93,229,0.3); transform: translateX(3px); }
.cont-emoji { font-size: 1.5rem; flex-shrink: 0; }
.cont-info { flex: 1; min-width: 0; }
.cont-title { font-size: 0.85rem; font-weight: 700; color: var(--text); }
.cont-sub { font-size: 0.72rem; color: var(--text-3); margin-top: 2px; }
.cont-pct { font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 800; color: var(--violet); }
/* ── flashcard review widget ── */
.fcw-card { perspective: 1000px; cursor: pointer; }
.fcw-inner {
position: relative; transform-style: preserve-3d;
transition: transform 0.5s cubic-bezier(.34,1.1,.64,1); min-height: 118px;
}
.fcw-card.flipped .fcw-inner { transform: rotateY(180deg); }
.fcw-face {
position: absolute; inset: 0; backface-visibility: hidden; -webkit-backface-visibility: hidden;
border-radius: 14px; padding: 14px 16px; display: flex; flex-direction: column; gap: 6px;
border: 1.5px solid rgba(15,23,42,0.08); box-sizing: border-box;
}
.fcw-front { background: linear-gradient(135deg, rgba(155,93,229,0.06), rgba(6,214,224,0.05)); }
.fcw-back { background: linear-gradient(135deg, rgba(6,214,100,0.07), rgba(6,214,224,0.05)); transform: rotateY(180deg); }
.fcw-deck { font-size: 0.66rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.04em; color: var(--violet); }
.fcw-back .fcw-deck { color: #059652; }
.fcw-text { flex: 1; font-size: 0.92rem; font-weight: 600; color: var(--text); line-height: 1.35;
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.fcw-hint { font-size: 0.68rem; color: var(--text-3); display: flex; align-items: center; gap: 5px; }
.fcw-hint svg { width: 12px; height: 12px; }
.fcw-actions { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; }
.fcw-count { font-size: 0.72rem; color: var(--text-3); font-weight: 600; }
.fcw-btn {
display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; border-radius: 99px;
border: 1.5px solid rgba(155,93,229,0.3); background: rgba(155,93,229,0.06); color: var(--violet);
font-family: 'Manrope', sans-serif; font-size: 0.76rem; font-weight: 700; cursor: pointer;
transition: all 0.15s; text-decoration: none;
}
.fcw-btn:hover { background: rgba(155,93,229,0.14); border-color: var(--violet); }
.fcw-btn svg { width: 13px; height: 13px; stroke: currentColor; }
.fcw-empty { text-align: center; padding: 16px 12px; color: var(--text-3); }
.fcw-empty p { font-size: 0.82rem; margin-bottom: 10px; }
/* картинка на карточке виджета */
.fcw-inner.has-img { min-height: 172px; }
.fcw-img { align-self: center; max-width: 100%; max-height: 96px; object-fit: contain;
border-radius: 8px; box-shadow: 0 1px 5px rgba(15,23,42,0.12); }
.has-img .fcw-text { -webkit-line-clamp: 2; }
/* ── subjects progress bars ── */
.subj-prog-row {
display: flex; align-items: center; gap: 14px;
padding: 10px 0; border-bottom: 1px solid rgba(15,23,42,0.05);
}
.subj-prog-row:last-child { border-bottom: none; }
.sp-name { font-size: 0.82rem; font-weight: 700; min-width: 90px; color: var(--text); }
.sp-bar { flex: 1; height: 8px; border-radius: 99px; background: rgba(15,23,42,0.06); overflow: hidden; }
.sp-fill { height: 100%; border-radius: 99px; transition: width 0.8s cubic-bezier(.34,1.2,.64,1); }
.sp-pct { font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800; min-width: 40px; text-align: right; }
/* ── compact test cards (Column 2) ── */
.subj-mini-grid { display: flex; flex-direction: column; gap: 8px; }
.subj-mini-card {
display: flex; align-items: center; gap: 12px;
padding: 12px 14px; border-radius: 14px;
border: 1.5px solid rgba(15,23,42,0.07); background: #fff;
cursor: pointer; transition: all 0.18s;
box-shadow: 0 1px 4px rgba(15,23,42,0.04);
}
.subj-mini-card:hover { border-color: rgba(155,93,229,0.25); transform: translateX(3px); box-shadow: 0 6px 20px rgba(15,23,42,0.08); }
.smc-icon {
width: 38px; height: 38px; border-radius: 10px;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
color: #fff;
}
.smc-icon svg, .smc-icon i { width: 20px; height: 20px; stroke: #fff; stroke-width: 1.8; }
.smc-body { flex: 1; min-width: 0; }
.smc-name { font-size: 0.86rem; font-weight: 700; color: var(--text); }
.smc-meta { font-size: 0.72rem; color: var(--text-3); margin-top: 2px; }
.smc-arrow { width: 18px; height: 18px; color: var(--text-3); flex-shrink: 0; }
/* ── animations ── */
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
@keyframes fadeUp { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } }
/* B4: Animated stat ring fill */
@keyframes ringFill { from { stroke-dasharray: 0 200; } }
.stat-ring svg circle:nth-child(2) { animation: ringFill 0.9s cubic-bezier(.34,1.2,.64,1) both; }
.stat-ring { animation: fadeIn 0.5s ease both; }
.stat-ring:nth-child(1) { animation-delay: 0.1s; }
.stat-ring:nth-child(2) { animation-delay: 0.2s; }
.stat-ring:nth-child(3) { animation-delay: 0.3s; }
.stat-ring:nth-child(4) { animation-delay: 0.4s; }
/* A4: Deadline gradient bar on assignment rows */
.asgn-row.urgent::after, .asgn-row.over::after {
content: ''; position: absolute; bottom: 0; left: 0; right: 0; height: 3px;
border-radius: 0 0 14px 14px;
}
.asgn-row { position: relative; overflow: hidden; }
.asgn-row.urgent::after { background: linear-gradient(90deg, #F59E0B, #E83A1E); }
.asgn-row.over::after { background: linear-gradient(90deg, #E83A1E, #9B1B30); }
/* A2: Skeleton shimmer for widgets */
.widget-skeleton { padding: 20px; }
.sk-line { height: 12px; border-radius: 6px; background: linear-gradient(90deg, rgba(15,23,42,0.06) 25%, rgba(15,23,42,0.1) 50%, rgba(15,23,42,0.06) 75%); background-size: 200% 100%; animation: skShimmer 1.5s ease infinite; margin-bottom: 10px; }
.sk-line:nth-child(1) { width: 65%; }
.sk-line:nth-child(2) { width: 85%; }
.sk-line:nth-child(3) { width: 50%; }
.sk-line.sk-tall { height: 40px; width: 100%; border-radius: 10px; }
@keyframes skShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
/* B3: Heatmap tooltip */
.hm-tip {
position: fixed; z-index: 200; pointer-events: none;
background: var(--text); color: #fff; padding: 6px 12px; border-radius: 8px;
font-size: 0.72rem; font-weight: 600; font-family: 'Manrope', sans-serif;
box-shadow: 0 8px 24px rgba(0,0,0,0.25); white-space: nowrap;
opacity: 0; transition: opacity 0.15s;
}
.hm-tip.visible { opacity: 1; }
/* B2: Search assignments */
.assign-search {
width: 100%; padding: 8px 14px 8px 34px; border: 1.5px solid rgba(15,23,42,0.08);
border-radius: 10px; font-family: 'Manrope', sans-serif; font-size: 0.82rem;
color: var(--text); background: #f8f9fc; transition: border-color 0.2s;
box-sizing: border-box; margin-bottom: 10px;
}
.assign-search:focus { outline: none; border-color: var(--violet); background: #fff; }
.assign-search-wrap { position: relative; }
.assign-search-wrap::before {
content: ''; position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
width: 14px; height: 14px;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%238898AA' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'/%3E%3C/svg%3E") center/contain no-repeat;
}
/* B5: Deadline urgent toast */
.deadline-toast {
position: fixed; top: 12px; right: 12px; z-index: 300;
display: flex; align-items: center; gap: 12px;
padding: 14px 20px; border-radius: 14px;
background: linear-gradient(135deg, #2d0a0a, #4a1020);
color: #fff; box-shadow: 0 12px 40px rgba(0,0,0,0.3);
font-family: 'Manrope', sans-serif; font-size: 0.84rem; font-weight: 600;
animation: toastSlide 0.4s ease, toastSlide 0.4s ease 8s reverse forwards;
cursor: pointer;
}
.deadline-toast:hover { opacity: 0.9; }
@keyframes toastSlide { from { transform: translateX(120%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
.dt-icon { font-size: 1.3rem; }
.dt-text { flex: 1; }
.dt-time { font-family: 'Unbounded', sans-serif; font-weight: 900; }
.dt-close { background: none; border: none; color: rgba(255,255,255,0.5); cursor: pointer; font-size: 1.2rem; padding: 0 0 0 8px; }
/* C2: Streak calendar */
.streak-cal { display: grid; grid-template-columns: repeat(7, 1fr); gap: 3px; }
.sc-day {
aspect-ratio: 1; border-radius: 8px; display: flex; align-items: center; justify-content: center;
font-size: 0.62rem; font-weight: 700; color: var(--text-3); background: rgba(15,23,42,0.03);
transition: transform 0.12s, box-shadow 0.12s;
}
.sc-day.today { box-shadow: inset 0 0 0 1.5px var(--violet); color: var(--violet); font-weight: 800; }
.sc-day.active { background: rgba(155,93,229,0.18); color: #7c3aed; }
.sc-day.active.today { background: var(--violet); color: #fff; box-shadow: 0 3px 10px rgba(155,93,229,0.35); }
.sc-day.future { opacity: 0.3; }
.sc-day:hover { transform: scale(1.15); box-shadow: 0 4px 12px rgba(15,23,42,0.12); }
.sc-day.pulse { animation: scPulse 2s ease-in-out infinite; }
@keyframes scPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(155,93,229,0.3); }
50% { box-shadow: 0 0 0 5px rgba(155,93,229,0); }
}
.sc-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.sc-month { font-family: 'Unbounded', sans-serif; font-size: 0.7rem; font-weight: 800; color: var(--text); }
.sc-streak-badge { font-family: 'Unbounded', sans-serif; font-size: 0.68rem; font-weight: 800; color: #E8890B; display: inline-flex; align-items: center; gap: 4px; padding: 3px 10px; border-radius: 99px; background: rgba(245,158,11,0.13); }
.sc-weekdays { display: grid; grid-template-columns: repeat(7, 1fr); gap: 3px; margin-bottom: 3px; }
.sc-wd { font-size: 0.56rem; color: var(--text-3); font-weight: 700; text-align: center; text-transform: uppercase; }
/* C4: Keyboard shortcuts hint */
.kb-hint {
position: fixed; bottom: 16px; right: 16px; z-index: 100;
background: rgba(15,23,42,0.9); color: #fff; padding: 8px 14px;
border-radius: 10px; font-size: 0.72rem; font-family: 'Manrope', sans-serif;
backdrop-filter: blur(8px); display: none; gap: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
}
.kb-hint.visible { display: flex; }
.kb-key { background: rgba(255,255,255,0.15); padding: 2px 7px; border-radius: 4px; font-family: monospace; font-weight: 700; }
/* B1: Quick-start test modal */
.qs-subjects { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
.qs-subj-btn {
display: flex; align-items: center; gap: 8px;
padding: 10px 16px; border-radius: 12px;
border: 1.5px solid rgba(15,23,42,0.1); background: #fff;
cursor: pointer; transition: all 0.15s; flex: 1; min-width: 100px;
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 700; color: var(--text);
}
.qs-subj-btn:hover { border-color: rgba(155,93,229,0.3); }
.qs-subj-btn.active { border-color: var(--violet); background: rgba(155,93,229,0.06); color: var(--violet); }
.qs-subj-icon { width: 28px; height: 28px; border-radius: 8px; display: flex; align-items: center; justify-content: center; }
.qs-subj-icon svg { width: 14px; height: 14px; stroke: #fff; stroke-width: 2; }
.qs-options { display: flex; flex-direction: column; gap: 12px; }
.qs-row { display: flex; align-items: center; gap: 12px; }
.qs-label { font-size: 0.78rem; font-weight: 700; color: var(--text-3); min-width: 80px; }
.qs-select, .qs-input {
flex: 1; padding: 8px 12px; border: 1.5px solid rgba(15,23,42,0.1);
border-radius: 8px; font-family: 'Manrope', sans-serif; font-size: 0.82rem;
color: var(--text); background: #f8f9fc;
}
.qs-select:focus, .qs-input:focus { outline: none; border-color: var(--violet); }
/* ── Admin/Teacher compact layout ── */
.admin-grid {
display: grid;
grid-template-columns: 1.3fr 1fr 1fr;
gap: 18px;
margin-bottom: 18px;
}
.admin-grid .widget { padding: 18px; border-radius: 16px; }
.admin-grid .w-head { margin-bottom: 10px; }
/* Quick-action buttons grid — grouped layout */
.adm-actions { display: grid; grid-template-columns: 2fr 2fr 1fr; gap: 16px; }
.adm-act-group { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; }
@media (max-width: 900px) { .adm-actions { grid-template-columns: 1fr 1fr; } }
@media (max-width: 640px) { .adm-actions { grid-template-columns: 1fr; } }
.adm-act {
display: flex; align-items: center; gap: 10px;
padding: 12px 14px; border-radius: 12px;
border: 1.5px solid rgba(15,23,42,0.07); background: #fff;
font-family: 'Manrope', sans-serif; font-size: 0.8rem; font-weight: 700;
color: var(--text); cursor: pointer; transition: all 0.18s;
text-decoration: none; position: relative;
}
.adm-act:hover { border-color: rgba(155,93,229,0.25); transform: translateY(-2px) scale(1.01); box-shadow: 0 6px 20px rgba(15,23,42,0.10); }
.adm-act-icon {
width: 36px; height: 36px; border-radius: 9px;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.adm-act-icon svg, .adm-act-icon i { width: 20px; height: 20px; stroke: #fff; stroke-width: 2; }
.adm-act-badge {
position: absolute; top: 6px; right: 8px;
background: #E0335E; color: #fff; font-size: 0.62rem; font-weight: 800;
padding: 1px 5px; border-radius: 999px; font-family: 'Unbounded', sans-serif;
line-height: 1.4;
}
/* Compact stat chips row for admin header */
.adm-stat-chips { display: flex; gap: 10px; flex-shrink: 0; flex-wrap: wrap; }
.adm-stat-chip {
display: flex; align-items: center; gap: 8px;
padding: 6px 14px; border-radius: 10px;
background: rgba(15,23,42,0.03); border: 1px solid rgba(15,23,42,0.06);
font-family: 'Manrope', sans-serif; font-size: 0.75rem; font-weight: 600; color: var(--text-3);
}
.adm-stat-val {
font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 900;
}
/* Admin compact assignment row */
.admin-grid .asgn-row { height: 52px; padding: 0 12px 0 10px; border-radius: 10px; }
.admin-grid .ar-icon { width: 30px; height: 30px; border-radius: 8px; }
.admin-grid .ar-icon svg { width: 14px; height: 14px; }
.admin-grid .ar-title { font-size: 0.82rem; }
.admin-grid .ar-meta { font-size: 0.68rem; }
.admin-grid .ar-prog-text { font-size: 0.68rem; }
.admin-grid .ar-btn-ghost { font-size: 0.72rem; padding: 4px 12px; }
.admin-grid .asgn-expand { padding: 8px 12px 10px 50px; border-radius: 0 0 10px 10px; }
/* Recent sessions mini table */
.adm-sessions { display: flex; flex-direction: column; gap: 4px; }
.adm-sess-row {
display: flex; align-items: center; gap: 10px;
padding: 6px 0; border-bottom: 1px solid rgba(15,23,42,0.04);
font-size: 0.76rem;
}
.adm-sess-row:last-child { border-bottom: none; }
.adm-sess-name { flex: 1; font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.adm-sess-subj { color: var(--text-3); font-weight: 600; min-width: 60px; }
.adm-sess-pct { font-family: 'Unbounded', sans-serif; font-weight: 900; font-size: 0.74rem; min-width: 36px; text-align: right; }
/* Colored chip for session % */
.adm-sess-chip {
display: inline-flex; padding: 3px 9px; border-radius: 999px;
font-size: 0.74rem; font-weight: 700; min-width: 42px; justify-content: center;
font-family: 'Unbounded', sans-serif;
}
.adm-sess-ago { color: var(--text-3); font-size: 0.7rem; flex-shrink: 0; }
/* Hover on rows in admin-grid */
.admin-grid .adm-sess-row:hover,
.admin-grid .asgn-row:hover {
background: rgba(155,93,229,0.04);
cursor: pointer;
}
/* Thick colored progress bar */
.admin-grid .ar-prog-bar { height: 8px !important; border-radius: 4px; overflow: hidden; background: rgba(15,23,42,0.06); }
.admin-grid .ar-prog-fill { height: 100%; border-radius: 4px; }
/* Empty states */
.adm-empty { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 24px 12px; text-align: center; color: var(--text-3); }
.adm-empty i { display: block; }
.adm-empty-text { font-size: 0.82rem; }
.adm-empty-cta {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 16px; border-radius: 9px; font-size: 0.78rem; font-weight: 700;
background: var(--violet); color: #fff; text-decoration: none;
transition: opacity 0.15s;
}
.adm-empty-cta:hover { opacity: 0.85; }
/* Class badge on assignment row */
.asgn-class-badge {
display: inline-flex; padding: 1px 6px; border-radius: 6px;
font-size: 0.68rem; font-weight: 700;
background: rgba(155,93,229,0.08); color: #7c3aed;
margin-left: 4px; vertical-align: middle;
}
/* Urgency highlight */
.admin-grid .asgn-urgent { border-left: 3px solid var(--pink, #F15BB5); padding-left: 9px !important; }
.asgn-fire {
background: rgba(241,91,181,0.12); color: var(--pink, #F15BB5);
padding: 1px 7px; border-radius: 999px; font-size: 0.68rem; font-weight: 700; margin-left: 6px;
}
/* KPI row */
.dh-kpi-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 6px; }
.dh-kpi { font-size: 0.84rem; color: var(--text-3); }
.dh-kpi strong { color: var(--text); font-weight: 700; }
.dh-kpi.warn strong { color: var(--amber, #F59E0B); }
/* Admin assignment search */
.adm-asgn-search {
width: 100%; padding: 7px 10px; border: 1px solid var(--border);
border-radius: 8px; font: inherit; font-size: 0.82rem;
margin-bottom: 10px; background: var(--bg, #fff); color: var(--text);
box-sizing: border-box;
}
.adm-asgn-search:focus { outline: none; border-color: var(--violet); }
/* Dark mode for admin */
.app-layout.dark .adm-act { background: #1A1D27; border-color: rgba(255,255,255,0.06); color: #E8ECF2; }
.app-layout.dark .adm-stat-chip { background: rgba(255,255,255,0.04); border-color: rgba(255,255,255,0.06); }
.app-layout.dark .adm-sess-name { color: #E8ECF2; }
.app-layout.dark .adm-asgn-search { background: #1A1D27; color: #E8ECF2; border-color: rgba(255,255,255,0.1); }
.app-layout.dark .adm-empty-text { color: var(--text-3); }
.app-layout.dark .asgn-class-badge { background: rgba(155,93,229,0.15); color: #b07de0; }
/* C1: Teacher class summary widget */
.class-summary { display: flex; flex-direction: column; gap: 12px; }
.cs-card {
display: flex; align-items: center; gap: 14px;
padding: 14px 16px; border-radius: 14px;
background: rgba(155,93,229,0.04); border: 1px solid rgba(155,93,229,0.1);
}
.cs-avatar { width: 36px; height: 36px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 1.1rem; flex-shrink: 0; }
.cs-body { flex: 1; min-width: 0; }
.cs-name { font-size: 0.84rem; font-weight: 700; color: var(--text); }
.cs-stats { font-size: 0.72rem; color: var(--text-3); margin-top: 2px; display: flex; gap: 10px; }
.cs-stats span { display: flex; align-items: center; gap: 3px; }
.cs-bar { width: 60px; height: 5px; border-radius: 99px; background: rgba(15,23,42,0.07); overflow: hidden; flex-shrink: 0; }
.cs-fill { height: 100%; border-radius: 99px; background: var(--violet); }
/* ── section header ── */
.section-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 18px;
}
.section-title {
font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800;
color: var(--text-3); text-transform: uppercase; letter-spacing: 0.09em;
display: flex; align-items: center; gap: 8px;
}
.section-title::before {
content: '';
display: inline-block; width: 3px; height: 14px;
background: linear-gradient(180deg, #06D6E0, #9B5DE5);
border-radius: 99px; flex-shrink: 0;
}
.section-mb { margin-bottom: 40px; }
/* ── assign toolbar ── */
.assign-toolbar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; margin-bottom: 10px; }
.assign-chips { display: flex; gap: 6px; }
.assign-chip {
padding: 5px 16px; border-radius: 99px; border: 1.5px solid rgba(15,23,42,0.1);
background: #fff; color: #6B7A8E;
font-family: 'Manrope', sans-serif; font-size: 0.78rem; font-weight: 700;
cursor: pointer; transition: all 0.16s;
}
.assign-chip:hover { border-color: rgba(155,93,229,0.3); color: var(--violet); }
.assign-chip.active { background: var(--text); color: #fff; border-color: var(--text); }
.assign-stats { font-size: 0.75rem; color: var(--text-3); font-weight: 600; display: flex; gap: 14px; flex-wrap: wrap; }
.assign-stats .s-active { color: var(--violet); }
.assign-stats .s-overdue { color: var(--pink); }
.assign-stats .s-done { color: #059652; }
/* ── subject filter chips ── */
.subj-filter-row { display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 14px; }
.sf-chip {
padding: 3px 11px 3px 8px; border-radius: 99px; border: 1.5px solid rgba(15,23,42,0.09);
background: #fff; color: var(--text-3);
font-family: 'Manrope', sans-serif; font-size: 0.72rem; font-weight: 700;
cursor: pointer; transition: all 0.15s; display: inline-flex; align-items: center; gap: 5px;
white-space: nowrap;
}
.sf-chip svg { width: 13px; height: 13px; stroke: currentColor; stroke-width: 1.8; }
.sf-chip:hover { color: var(--violet); border-color: rgba(155,93,229,0.35); }
.sf-chip.active { color: #fff; border-color: transparent; }
/* ── group headers ── */
.assign-group-hdr {
display: flex; align-items: center; gap: 10px;
font-family: 'Unbounded', sans-serif; font-size: 0.72rem; font-weight: 800;
color: var(--text-3); text-transform: uppercase; letter-spacing: 0.08em;
margin: 8px 0 4px; cursor: pointer; user-select: none;
}
.assign-group-hdr::before { content: ''; display: inline-block; width: 3px; height: 13px; border-radius: 99px; flex-shrink: 0; }
.assign-group-hdr.grp-todo::before { background: linear-gradient(180deg, #FF4C29, #F15BB5); }
.assign-group-hdr.grp-done::before { background: linear-gradient(180deg, #06D664, #06D6E0); }
.assign-group-hdr .grp-count { background: rgba(15,23,42,0.07); border-radius: 99px; padding: 1px 8px; font-size: 0.68rem; }
.assign-group-hdr .grp-arrow { margin-left: auto; transition: transform 0.2s; font-size: 0.65rem; }
.assign-group-hdr.collapsed .grp-arrow { transform: rotate(-90deg); }
.assign-group-body.collapsed { display: none; }
.assign-group-body { margin-top: 6px; }
/* ── expand panel ── */
.asgn-wrap { }
.asgn-row.expanded { border-radius: 14px 14px 0 0; border-bottom-color: rgba(15,23,42,0.04); transform: none !important; box-shadow: none !important; }
.asgn-row.spotlight { background: linear-gradient(90deg, rgba(155,93,229,0.04) 0%, #fff 60%); }
.asgn-expand {
background: #f8f9fc;
border: 1.5px solid rgba(15,23,42,0.07); border-top: none;
border-radius: 0 0 14px 14px;
padding: 10px 14px 13px 62px;
display: none; flex-direction: column; gap: 10px;
margin-bottom: 0;
}
.asgn-expand.open { display: flex; }
.ae-row { display: flex; align-items: center; gap: 12px; }
.ae-pills { display: flex; gap: 6px; flex-wrap: wrap; flex: 1; }
.ae-pill { padding: 3px 10px; border-radius: 99px; background: rgba(15,23,42,0.06); color: #6B7A8E; font-size: 0.72rem; font-weight: 600; }
.ae-dl { flex: 1; }
.ae-dl-label { font-size: 0.70rem; color: var(--text-3); margin-bottom: 5px; display: flex; justify-content: space-between; }
.ae-dl-bar { height: 4px; border-radius: 99px; background: rgba(15,23,42,0.08); overflow: hidden; }
.ae-dl-fill { height: 100%; border-radius: 99px; transition: width 0.4s; }
.ae-btn {
padding: 8px 24px; border: none; border-radius: 99px;
background: var(--text); color: #fff;
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 700;
cursor: pointer; transition: background 0.15s; white-space: nowrap; flex-shrink: 0;
}
.ae-btn:hover { background: #1E2D4A; }
.ae-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.ae-btn-result {
padding: 8px 24px; border: 1.5px solid #059652; border-radius: 99px;
background: rgba(6,214,100,0.07); color: #059652;
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 700;
text-decoration: none; display: inline-flex; align-items: center;
white-space: nowrap; flex-shrink: 0; transition: background 0.15s;
}
.ae-btn-result:hover { background: rgba(6,214,100,0.14); }
/* ── assignment list ── */
.tests-list { display: flex; flex-direction: column; gap: 6px; }
/* ── compact assignment rows ── */
.asgn-row {
background: #fff;
border: 1.5px solid rgba(15,23,42,0.07);
border-left: 4px solid var(--ac, #9B5DE5);
border-radius: 14px;
padding: 0 14px 0 12px;
height: 62px;
display: flex; align-items: center; gap: 12px;
transition: transform 0.15s, box-shadow 0.15s;
}
.asgn-row:hover { transform: translateX(3px); box-shadow: 0 4px 20px rgba(15,23,42,0.09); }
.asgn-row.over { --ac: var(--pink); background: rgba(241,91,181,0.02); }
.asgn-row.urgent { --ac: #FF4C29; box-shadow: 0 0 0 2px rgba(255,76,41,0.08); }
.asgn-row.done { --ac: var(--green); }
.ar-icon {
width: 36px; height: 36px; border-radius: 10px;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.ar-icon svg { width: 18px; height: 18px; stroke: currentColor; stroke-width: 1.8; }
.ar-body { flex: 1; min-width: 0; }
.ar-title {
font-size: 0.88rem; font-weight: 700; color: var(--text);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.2;
}
.ar-meta {
font-size: 0.71rem; color: var(--text-3); margin-top: 3px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ar-meta .ar-tag-urgent { color: #E83A1E; font-weight: 700; }
.ar-meta .ar-tag-over { color: var(--pink); font-weight: 700; }
.ar-meta .ar-tag-hw { color: #7c3aed; font-weight: 700; }
.ar-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.ar-score { font-family: 'Unbounded', sans-serif; font-size: 0.85rem; font-weight: 900; min-width: 36px; text-align: right; }
.ar-score.hi { color: #059652; }
.ar-score.mid { color: var(--amber); }
.ar-score.lo { color: var(--pink); }
.ar-progress { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.ar-prog-bar { width: 72px; height: 4px; border-radius: 99px; background: rgba(15,23,42,0.08); overflow: hidden; }
.ar-prog-fill { height: 100%; border-radius: 99px; }
.ar-prog-text { font-size: 0.71rem; color: var(--text-3); white-space: nowrap; min-width: 36px; }
.ar-btn {
padding: 6px 16px; border: none; border-radius: 99px;
background: var(--text); color: #fff;
font-family: 'Manrope', sans-serif; font-size: 0.76rem; font-weight: 700;
cursor: pointer; transition: background 0.15s; white-space: nowrap;
}
.ar-btn:hover { background: #1E2D4A; }
.ar-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.ar-btn-result {
padding: 6px 16px; border: 1.5px solid #059652; border-radius: 99px;
background: rgba(6,214,100,0.06); color: #059652;
font-family: 'Manrope', sans-serif; font-size: 0.76rem; font-weight: 700;
text-decoration: none; display: inline-flex; align-items: center;
white-space: nowrap; transition: background 0.15s;
}
.ar-btn-result:hover { background: rgba(6,214,100,0.14); }
.ar-btn-ghost {
padding: 6px 16px; border: 1.5px solid rgba(15,23,42,0.12); border-radius: 99px;
background: transparent; color: var(--text-3);
font-family: 'Manrope', sans-serif; font-size: 0.76rem; font-weight: 600;
text-decoration: none; display: inline-flex; align-items: center;
white-space: nowrap; transition: all 0.15s;
}
.ar-btn-ghost:hover { border-color: var(--violet); color: var(--violet); }
/* ── history ── */
.history-list { display: flex; flex-direction: column; gap: 8px; }
.hist-item {
background: #fff;
border: 1.5px solid rgba(15,23,42,0.07);
border-radius: 16px; padding: 14px 20px;
display: flex; align-items: center; gap: 16px;
transition: all 0.18s ease;
text-decoration: none; color: inherit;
box-shadow: 0 1px 4px rgba(15,23,42,0.04);
}
.hist-item:hover {
border-color: rgba(15,23,42,0.12);
box-shadow: 0 6px 24px rgba(15,23,42,0.08);
transform: translateX(4px);
}
.hist-pct { font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 900; min-width: 52px; text-align: center; }
.hist-pct.hi { color: #059652; }
.hist-pct.mid { color: var(--amber); }
.hist-pct.lo { color: var(--pink); }
.hist-info { flex: 1; }
.hist-subj { font-size: 0.88rem; font-weight: 700; margin-bottom: 2px; color: var(--text); }
.hist-meta { font-size: 0.75rem; color: var(--text-3); }
.hist-score { font-size: 0.8rem; color: var(--text-2); font-weight: 600; white-space: nowrap; }
/* ── progress tab ── */
.chart-section-title {
font-family: 'Unbounded', sans-serif; font-size: 0.76rem; font-weight: 800;
color: var(--text-3); text-transform: uppercase; letter-spacing: 0.09em;
margin: 36px 0 16px;
display: flex; align-items: center; gap: 8px;
}
.chart-section-title::before {
content: ''; display: inline-block; width: 3px; height: 14px;
background: linear-gradient(180deg, #06D6E0, #9B5DE5);
border-radius: 99px;
}
.chart-section-title:first-child { margin-top: 0; }
.trend-wrap {
background: #fff; border: 1.5px solid rgba(15,23,42,0.08);
border-radius: 20px; padding: 24px 24px 16px;
box-shadow: 0 2px 8px rgba(15,23,42,0.05);
}
.trend-canvas-wrap { position: relative; height: 230px; }
.subj-charts-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(168px, 1fr)); gap: 14px; }
.subj-chart-card {
background: #fff; border: 1.5px solid rgba(15,23,42,0.08);
border-radius: 20px; padding: 22px 18px;
text-align: center; display: flex; flex-direction: column; align-items: center; gap: 6px;
box-shadow: 0 2px 8px rgba(15,23,42,0.05);
transition: all 0.2s;
}
.subj-chart-card:hover { border-color: rgba(15,23,42,0.14); transform: translateY(-3px); box-shadow: 0 10px 32px rgba(15,23,42,0.1); }
.subj-chart-name { font-family: 'Unbounded', sans-serif; font-size: 0.76rem; font-weight: 800; color: var(--text); }
.subj-chart-sessions { font-size: 0.72rem; color: var(--text-3); }
.canvas-wrap { position: relative; width: 130px; height: 130px; margin: 4px 0; }
/* ── weak topics ── */
.weak-list { display: flex; flex-direction: column; gap: 10px; }
.weak-item {
background: #fff; border: 1.5px solid rgba(15,23,42,0.07);
border-radius: 16px; padding: 16px 20px;
display: flex; align-items: center; gap: 18px;
cursor: pointer; transition: all 0.18s ease;
box-shadow: 0 1px 4px rgba(15,23,42,0.04);
}
.weak-item:hover { border-color: rgba(15,23,42,0.12); transform: translateX(4px); box-shadow: 0 6px 24px rgba(15,23,42,0.08); }
.weak-bar-wrap { flex: 1; }
.weak-name { font-size: 0.88rem; font-weight: 700; margin-bottom: 2px; color: var(--text); }
.weak-meta { font-size: 0.75rem; color: var(--text-3); }
.weak-bar { height: 7px; background: rgba(15,23,42,0.07); border-radius: 99px; overflow: hidden; margin-top: 8px; }
.weak-fill { height: 100%; border-radius: 99px; background: linear-gradient(90deg, #FF9F1C, var(--pink)); transition: width 0.8s cubic-bezier(0.34,1.2,0.64,1); }
.weak-pct { font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 900; color: #E0335E; min-width: 48px; text-align: right; }
/* ── submission strip in expand ── */
.ae-submit-row {
display: flex; align-items: center; gap: 10px;
padding-top: 8px; border-top: 1px solid rgba(15,23,42,0.06); flex-wrap: wrap;
}
.ae-submit-label { font-size: 0.72rem; color: var(--text-3); font-weight: 600; flex-shrink: 0; }
.ae-submit-status {
display: inline-flex; align-items: center; gap: 5px;
padding: 3px 10px; border-radius: 99px; font-size: 0.72rem; font-weight: 700;
}
.ae-submit-status.new { background: rgba(6,214,224,0.1); color: #06aab3; }
.ae-submit-status.resubmitted { background: rgba(6,214,224,0.1); color: #06aab3; }
.ae-submit-status.reviewed { background: rgba(5,150,82,0.1); color: #059652; }
.ae-submit-status.accepted { background: rgba(5,150,82,0.12); color: #059652; }
.ae-submit-status.revision { background: rgba(241,91,181,0.1); color: #c0306a; }
/* mini status chip on collapsed card */
.ar-sub-chip {
display: inline-flex; align-items: center; gap: 4px;
padding: 2px 8px; border-radius: 99px; font-size: 0.65rem; font-weight: 700;
flex-shrink: 0;
}
.ar-sub-chip.s-new, .ar-sub-chip.s-resubmitted { background: rgba(6,214,224,0.1); color: #06aab3; }
.ar-sub-chip.s-reviewed { background: rgba(5,150,82,0.1); color: #059652; }
.ar-sub-chip.s-accepted { background: rgba(5,150,82,0.12); color: #059652; }
.ar-sub-chip.s-revision { background: rgba(241,91,181,0.1); color: #c0306a; }
.ar-sub-chip.s-none { background: rgba(155,93,229,0.08); color: var(--violet); }
/* ── My submissions widget ── */
.my-subs-list { display: flex; flex-direction: column; gap: 6px; }
.ms-row {
display: flex; align-items: center; gap: 10px;
padding: 10px 14px; background: #fff;
border: 1.5px solid rgba(15,23,42,0.07); border-radius: 12px;
transition: transform 0.15s, box-shadow 0.15s;
}
.ms-row:hover { transform: translateX(3px); box-shadow: 0 4px 16px rgba(15,23,42,0.07); }
.ms-title { flex: 1; font-size: 0.82rem; font-weight: 600; color: var(--text);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ms-file { font-size: 0.7rem; color: var(--text-3); flex-shrink: 0; max-width: 120px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ms-grade { font-weight: 800; font-size: 0.78rem; flex-shrink: 0; }
.ms-more { display: inline-block; margin-top: 8px; font-size: 0.78rem; font-weight: 700; color: var(--violet); text-decoration: none; }
.ms-more:hover { text-decoration: underline; }
.ae-submit-note { font-size: 0.75rem; color: var(--text-2); flex: 1; font-style: italic; }
.ae-grade-badge {
display: inline-flex; align-items: center; gap: 3px;
padding: 3px 9px; border-radius: 99px; font-size: 0.72rem; font-weight: 800;
background: rgba(155,93,229,0.1); color: var(--violet); flex-shrink: 0;
}
.ae-grade-badge.high { background: rgba(5,150,82,0.12); color: #059652; }
.ae-grade-badge.mid { background: rgba(255,193,7,0.14); color: #c07c00; }
.ae-grade-badge.low { background: rgba(241,91,181,0.12); color: #c0306a; }
.ae-btn-submit {
padding: 5px 14px; border: 1.5px solid rgba(155,93,229,0.3); border-radius: 99px;
background: rgba(155,93,229,0.06); color: var(--violet);
font-family: 'Manrope', sans-serif; font-size: 0.74rem; font-weight: 700;
cursor: pointer; transition: all 0.15s; white-space: nowrap; flex-shrink: 0;
}
.ae-btn-submit:hover { background: rgba(155,93,229,0.14); border-color: var(--violet); }
.ae-btn-delete-sub {
padding: 5px 10px; border: 1.5px solid rgba(241,91,181,0.25); border-radius: 99px;
background: transparent; color: #c0306a;
font-family: 'Manrope', sans-serif; font-size: 0.70rem; font-weight: 600;
cursor: pointer; transition: all 0.15s; white-space: nowrap; flex-shrink: 0;
}
.ae-btn-delete-sub:hover { background: rgba(241,91,181,0.08); border-color: var(--pink); }
/* ── spinner / empty ── */
.spinner { width: 36px; height: 36px; border: 3px solid rgba(15,23,42,0.1); border-top-color: var(--violet); border-radius: 50%; animation: spin 0.75s linear infinite; margin: 48px auto; display: block; }
@keyframes spin { to { transform: rotate(360deg); } }
.empty { text-align: center; padding: 36px; color: var(--text-3); font-size: 0.88rem; }
/* ── empty CTA ── */
.empty-cta {
display: flex; flex-direction: column; align-items: center; gap: 16px;
padding: 60px 28px; text-align: center;
background: #fff;
border: 2px dashed rgba(155,93,229,0.2);
border-radius: 24px;
box-shadow: 0 2px 8px rgba(15,23,42,0.04);
}
.empty-cta-icon { font-size: 3.4rem; line-height: 1; filter: drop-shadow(0 4px 16px rgba(0,0,0,0.12)); }
.empty-cta-title { font-family: 'Unbounded', sans-serif; font-size: 1.05rem; font-weight: 800; color: var(--text); }
.empty-cta-desc { font-size: 0.88rem; color: #6B7A8E; max-width: 300px; line-height: 1.7; }
/* ── nav active ── */
.nav-active { background: rgba(155,93,229,0.08) !important; border-color: var(--violet) !important; color: var(--violet) !important; cursor: default; pointer-events: none; }
/* ── notification ── */
.notif-bell { position: relative; }
.notif-badge { position: absolute; top: -4px; right: -4px; min-width: 18px; height: 18px; padding: 0 4px; background: var(--pink); color: #fff; border-radius: 99px; font-size: 0.65rem; font-weight: 700; display: flex; align-items: center; justify-content: center; line-height: 1; }
.notif-drop-header { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px 10px; border-bottom: 1px solid var(--border); }
.notif-drop-title { font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800; }
.notif-read-all { background: none; border: none; font-size: 0.74rem; color: var(--violet); cursor: pointer; font-family: 'Manrope', sans-serif; font-weight: 600; }
.notif-item { display: flex; gap: 10px; padding: 11px 16px; border-bottom: 1px solid var(--border); cursor: pointer; transition: background var(--tr); text-decoration: none; color: inherit; }
.notif-item:last-child { border-bottom: none; }
.notif-item:hover { background: rgba(155,93,229,0.04); }
.notif-item.unread { background: rgba(155,93,229,0.05); }
.notif-item.unread:hover { background: rgba(155,93,229,0.09); }
.notif-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--violet); flex-shrink: 0; margin-top: 5px; }
.notif-dot.read { background: transparent; border: 1.5px solid var(--border-h); }
.notif-msg { font-size: 0.80rem; line-height: 1.45; flex: 1; }
.notif-time { font-size: 0.70rem; color: var(--text-3); margin-top: 2px; }
.notif-empty { padding: 28px 16px; text-align: center; color: var(--text-3); font-size: 0.84rem; }
/* ── join modal ── */
.modal-overlay { position: fixed; inset: 0; background: rgba(15,23,42,0.4); backdrop-filter: blur(6px); z-index: 200; display: none; align-items: center; justify-content: center; padding: 20px; }
.modal-overlay.open { display: flex; }
.modal { background: #fff; border-radius: 24px; padding: 36px; width: 100%; max-width: 420px; box-shadow: 0 32px 80px rgba(15,23,42,0.22); }
.modal-title { font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800; margin-bottom: 22px; }
.form-input { width: 100%; padding: 12px 16px; border: 1.5px solid rgba(15,23,42,0.15); border-radius: 12px; font-family: 'Manrope', sans-serif; font-size: 0.95rem; color: var(--text); background: #f8f9fc; transition: border-color 0.2s; box-sizing: border-box; }
.form-input:focus { outline: none; border-color: var(--violet); background: #fff; }
.modal-footer { display: flex; gap: 10px; justify-content: flex-end; margin-top: 22px; }
.btn-cancel { padding: 10px 22px; border: 1.5px solid rgba(15,23,42,0.15); border-radius: 999px; background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 600; color: var(--text-3); cursor: pointer; transition: all 0.18s; }
.btn-cancel:hover { border-color: rgba(15,23,42,0.3); color: var(--text); }
.btn-join { padding: 10px 28px; border: none; border-radius: 999px; background: var(--text); color: #fff; font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 700; cursor: pointer; box-shadow: 0 2px 10px rgba(15,23,42,0.2); transition: all 0.18s; }
.btn-join:hover { background: #1E2D4A; box-shadow: 0 6px 20px rgba(15,23,42,0.25); }
.btn-join:disabled { opacity: 0.45; cursor: not-allowed; box-shadow: none; }
/* ── history timeline ── */
.hist-date-sep {
font-family: 'Unbounded', sans-serif; font-size: 0.68rem; font-weight: 800;
color: var(--text-3); text-transform: uppercase; letter-spacing: 0.08em;
margin: 22px 0 10px; display: flex; align-items: center; gap: 12px;
}
.hist-date-sep:first-child { margin-top: 0; }
.hist-date-sep::after { content: ''; flex: 1; height: 1px; background: rgba(15,23,42,0.08); }
.hist-ring { flex-shrink: 0; }
/* ── stats charts ── */
.stats-section { margin-bottom: 18px; }
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
@media (max-width: 768px) { .stats-grid { grid-template-columns: 1fr; } }
.stats-chart-card {
background: #fff; border: 1.5px solid rgba(15,23,42,0.07);
border-radius: 18px; padding: 18px 20px;
}
.stats-chart-title {
font-size: 0.72rem; font-weight: 700; color: var(--text-3);
text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 12px;
}
.stats-bar-chart { display: flex; align-items: flex-end; gap: 4px; height: 100px; }
.stats-bar {
flex: 1; border-radius: 4px 4px 0 0; min-width: 6px; position: relative;
transition: height 0.3s; cursor: default;
}
.stats-bar:hover::after {
content: attr(data-tip); position: absolute; bottom: 100%; left: 50%;
transform: translateX(-50%); padding: 3px 8px; border-radius: 6px;
background: var(--text); color: #fff; font-size: 0.65rem; font-weight: 600;
white-space: nowrap; pointer-events: none;
}
.stats-bar-labels { display: flex; gap: 4px; margin-top: 4px; }
.stats-bar-labels span {
flex: 1; font-size: 0.55rem; color: #B0BEC5; text-align: center;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.stats-line-chart { position: relative; height: 100px; }
.stats-line-chart canvas { width: 100% !important; height: 100% !important; }
.stats-summary-chips {
display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px;
}
.stats-chip {
flex: 1; min-width: 80px; padding: 12px 14px; border-radius: 14px;
background: rgba(255,255,255,0.7); border: 1px solid rgba(15,23,42,0.06);
text-align: center;
}
.stats-chip-val {
font-family: 'Unbounded', sans-serif; font-size: 1.1rem; font-weight: 800;
color: var(--text); line-height: 1;
}
.stats-chip-lbl { font-size: 0.65rem; color: var(--text-3); margin-top: 3px; font-weight: 600; }
/* ── activity heatmap ── */
.heatmap-section {
background: #fff; border: 1.5px solid rgba(15,23,42,0.08);
border-radius: 20px; padding: 24px 28px; overflow-x: auto;
box-shadow: 0 2px 8px rgba(15,23,42,0.05);
}
.heatmap-grid { display: grid; grid-template-columns: repeat(52, 14px); grid-template-rows: repeat(7, 14px); gap: 3px; min-width: 780px; }
.hm-cell { width: 14px; height: 14px; border-radius: 3px; background: rgba(15,23,42,0.06); transition: transform 0.12s; cursor: default; }
.hm-cell[data-n="1"] { background: rgba(155,93,229,0.2); }
.hm-cell[data-n="2"] { background: rgba(155,93,229,0.42); }
.hm-cell[data-n="3"] { background: rgba(155,93,229,0.65); }
.hm-cell[data-n="4"] { background: rgba(155,93,229,0.88); }
.hm-cell:hover { transform: scale(1.5); z-index: 5; position: relative; }
.heatmap-legend { display: flex; align-items: center; gap: 5px; margin-top: 14px; font-size: 0.72rem; color: var(--text-3); }
.hm-leg { width: 14px; height: 14px; border-radius: 3px; }
/* ── subject card grid (legacy, kept for compatibility) ── */
.subj-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 18px; }
/* ── theory progress ── */
.theory-courses-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px,1fr)); gap: 14px; margin-bottom: 8px; }
.theory-course-card {
background: #fff; border: 1.5px solid rgba(15,23,42,0.07);
border-radius: 16px; padding: 16px 18px;
text-decoration: none; color: inherit;
display: flex; flex-direction: column; gap: 10px;
transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
box-shadow: 0 1px 4px rgba(15,23,42,0.04);
}
.theory-course-card:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(15,23,42,0.1); border-color: rgba(155,93,229,0.2); }
.tc-header { display: flex; align-items: center; gap: 10px; }
.tc-emoji { font-size: 1.6rem; line-height: 1; }
.tc-info { flex: 1; }
.tc-title { font-size: 0.88rem; font-weight: 700; color: var(--text); line-height: 1.35; }
.tc-subj { font-size: 0.7rem; font-weight: 700; color: var(--text-3); margin-top: 2px; text-transform: uppercase; letter-spacing: 0.06em; }
.tc-progress { display: flex; align-items: center; gap: 8px; }
.tc-bar { flex: 1; height: 5px; border-radius: 99px; background: rgba(15,23,42,0.07); }
.tc-fill { height: 100%; border-radius: 99px; background: var(--violet); }
.tc-pct { font-size: 0.72rem; font-weight: 700; color: var(--violet); flex-shrink: 0; }
.tc-meta { font-size: 0.74rem; color: var(--text-3); }
/* ── Leaderboard widget ── */
.lb-widget {
background: #fff; border: 1.5px solid rgba(15,23,42,0.08);
border-radius: 18px; padding: 20px 22px; margin-bottom: 22px;
box-shadow: 0 2px 8px rgba(15,23,42,0.04);
}
.lb-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.lb-title { font-family: 'Unbounded', sans-serif; font-size: 0.84rem; font-weight: 800; color: var(--text); }
.lb-tabs { display: flex; gap: 4px; }
.lb-tab {
padding: 5px 12px; border-radius: 99px; border: none;
font-family: 'Manrope', sans-serif; font-size: 0.72rem; font-weight: 700;
background: transparent; color: var(--text-3); cursor: pointer; transition: all 0.15s;
}
.lb-tab.active { background: linear-gradient(135deg, rgba(155,93,229,0.1), rgba(6,214,224,0.08)); color: var(--violet); }
.lb-list { display: flex; flex-direction: column; gap: 4px; }
.lb-row {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: 12px; transition: background 0.12s;
}
.lb-row:hover { background: rgba(155,93,229,0.04); }
.lb-row.me { background: linear-gradient(135deg, rgba(155,93,229,0.06), rgba(6,214,224,0.04)); border: 1px solid rgba(155,93,229,0.12); }
.lb-pos {
width: 24px; height: 24px; border-radius: 50%; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-family: 'Unbounded', sans-serif; font-size: 0.68rem; font-weight: 900;
background: rgba(15,23,42,0.06); color: var(--text-3);
}
.lb-pos.gold { background: linear-gradient(135deg, #FFD700, #FFA500); color: #fff; }
.lb-pos.silver { background: linear-gradient(135deg, #C0C0C0, #A0A0A0); color: #fff; }
.lb-pos.bronze { background: linear-gradient(135deg, #CD7F32, #B87333); color: #fff; }
.lb-avatar {
width: 32px; height: 32px; border-radius: 50%; flex-shrink: 0;
background: var(--grad-1);
display: flex; align-items: center; justify-content: center;
font-family: 'Unbounded', sans-serif; font-size: 0.6rem; font-weight: 800; color: #fff;
}
.lb-info { flex: 1; min-width: 0; }
.lb-name { font-size: 0.82rem; font-weight: 700; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lb-rank { font-size: 0.64rem; color: var(--text-3); font-weight: 600; }
.lb-xp { font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800; color: var(--violet); flex-shrink: 0; }
.lb-empty { text-align: center; padding: 20px; color: var(--text-3); font-size: 0.82rem; }
.lb-class-sel {
padding: 5px 10px; border-radius: 10px; border: 1.5px solid rgba(15,23,42,0.1);
background: #fff; font-family: 'Manrope', sans-serif; font-size: 0.72rem; font-weight: 600;
color: var(--text); cursor: pointer; outline: none;
}
/* ── Challenges widget ── */
.ch-widget { margin-bottom: 22px; }
.ch-list { display: flex; flex-direction: column; gap: 8px; }
.ch-item {
display: flex; align-items: center; gap: 14px;
padding: 14px 16px; border-radius: 14px;
background: #fff; border: 1.5px solid rgba(15,23,42,0.07);
transition: all 0.15s;
}
.ch-item.done { border-color: rgba(6,214,100,0.25); background: rgba(6,214,100,0.03); }
.ch-item.claimed { opacity: 0.55; }
.ch-icon { font-size: 1.3rem; flex-shrink: 0; }
.ch-body { flex: 1; min-width: 0; }
.ch-title { font-size: 0.84rem; font-weight: 700; color: var(--text); }
.ch-desc { font-size: 0.72rem; color: var(--text-3); margin-top: 2px; }
.ch-prog { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
.ch-bar { flex: 1; height: 5px; border-radius: 99px; background: rgba(15,23,42,0.07); overflow: hidden; }
.ch-fill { height: 100%; border-radius: 99px; background: linear-gradient(90deg, #9B5DE5, #06D6E0); transition: width 0.4s; }
.ch-pct { font-size: 0.68rem; font-weight: 700; color: var(--violet); flex-shrink: 0; }
.ch-reward { flex-shrink: 0; text-align: right; }
.ch-xp { font-family: 'Unbounded', sans-serif; font-size: 0.72rem; font-weight: 800; color: var(--violet); }
.ch-claim {
margin-top: 4px; padding: 4px 12px; border: none; border-radius: 99px;
background: var(--grad-1); color: #fff; font-family: 'Manrope', sans-serif;
font-size: 0.68rem; font-weight: 700; cursor: pointer; transition: transform 0.15s;
}
.ch-claim:hover { transform: translateY(-1px); }
.ch-claimed-badge { font-size: 0.68rem; color: #059652; font-weight: 700; margin-top: 4px; }
/* ── Gamification bar ── */
.gam-bar {
display: flex; align-items: center; gap: 18px;
padding: 16px 22px; border-radius: 18px; margin-bottom: 16px;
background:
radial-gradient(ellipse at 6% 50%, rgba(155,93,229,.14) 0%, transparent 38%),
linear-gradient(135deg, rgba(155,93,229,.07) 0%, rgba(6,214,224,.04) 100%);
border: 1.5px solid rgba(155,93,229,.18);
box-shadow: inset 0 1px 0 rgba(255,255,255,.85), 0 2px 14px rgba(155,93,229,.07);
}
.gam-level {
width: 56px; height: 56px; border-radius: 50%; flex-shrink: 0;
background: var(--grad-1);
display: flex; flex-direction: column; align-items: center; justify-content: center;
color: #fff; line-height: 1;
box-shadow: 0 4px 18px rgba(155,93,229,.45), 0 0 0 3px rgba(155,93,229,.18);
}
.gam-level-num { font-family: 'Unbounded', sans-serif; font-size: 1.2rem; font-weight: 900; }
.gam-level-lbl { font-size: 0.5rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; opacity: 0.82; }
.gam-main { flex: 1; min-width: 0; }
.gam-top { display: flex; align-items: baseline; gap: 8px; margin-bottom: 7px; }
.gam-rank { font-family: 'Unbounded', sans-serif; font-size: 0.84rem; font-weight: 800; color: var(--text); }
.gam-xp-text { font-size: 0.72rem; color: var(--text-3); font-weight: 600; }
.gam-progress { height: 10px; border-radius: 99px; background: rgba(155,93,229,.1); overflow: hidden; }
.gam-fill {
height: 100%; border-radius: 99px;
background: linear-gradient(90deg, #9B5DE5 0%, #4DC8D4 60%, #06D6E0 100%);
transition: width 0.6s ease;
box-shadow: 0 0 10px rgba(155,93,229,.35);
}
.gam-chips {
display: flex; gap: 10px; flex-shrink: 0;
padding-left: 18px;
border-left: 1.5px solid rgba(155,93,229,.14);
}
.gam-chip {
display: flex; flex-direction: column; align-items: center; gap: 3px;
padding: 8px 14px; border-radius: 14px;
background: rgba(255,255,255,.92); border: 1px solid rgba(155,93,229,.12);
min-width: 66px;
box-shadow: 0 2px 8px rgba(15,23,42,.05);
}
.gam-chip-icon { line-height: 1; }
.gam-chip-val { font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 800; color: var(--text); }
.gam-chip-lbl { font-size: 0.56rem; color: var(--text-3); font-weight: 700; text-transform: uppercase; letter-spacing: .04em; }
.gam-goal-ring { position: relative; width: 40px; height: 40px; }
.gam-goal-ring svg { width: 40px; height: 40px; }
/* ── Mobile responsive ── */
@media (max-width: 768px) {
/* Layout */
.container { padding: 20px 14px 80px; }
.widget { padding: 16px; }
.lb-widget { padding: 16px; }
/* Dash header */
.dash-header { flex-wrap: wrap; height: auto; padding: 14px 16px; gap: 12px; }
.dh-stats { flex-wrap: wrap; gap: 10px; width: 100%; justify-content: center; }
.dh-greeting { font-size: 0.88rem; }
/* Grids <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> single column */
.main-grid { grid-template-columns: 1fr; }
.action-cards { grid-template-columns: 1fr; }
.hero-row { grid-template-columns: 1fr; }
.admin-grid { grid-template-columns: 1fr; gap: 14px; }
.adm-actions { grid-template-columns: 1fr; gap: 10px; }
.adm-act-group { grid-template-columns: 1fr 1fr; }
/* KPI chips wrap to 2 cols */
.dh-kpi-row { gap: 8px; }
/* Session rows: wrap on small screens */
.adm-sess-row { flex-wrap: wrap; }
.adm-sess-subj { min-width: unset; }
/* Assignment title: allow wrapping */
.admin-grid .ar-title { white-space: normal; }
.theory-courses-grid { grid-template-columns: 1fr; }
.subj-charts-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
/* Gamification bar */
.gam-bar { flex-wrap: wrap; gap: 12px; }
.gam-chips { width: 100%; justify-content: center; }
/* Action banner */
.action-banner { flex-wrap: wrap; padding: 14px 16px; gap: 10px; }
.ab-countdown { text-align: left; }
.ab-countdown-val { font-size: 1.1rem; }
.ab-btn { width: 100%; text-align: center; justify-content: center; }
/* Assignment rows: wrap right-side, hide progress bar */
.asgn-row { height: auto; min-height: 54px; padding: 8px 10px; gap: 8px; flex-wrap: wrap; }
.ar-progress { display: none; }
.ar-right { gap: 6px; }
.ar-btn, .ar-btn-ghost, .ar-btn-result { font-size: 0.72rem; padding: 5px 12px; }
/* Expand panel: remove deep left indent */
.asgn-expand { padding: 10px 12px 12px; }
/* History + weak topics */
.hist-item { padding: 12px 14px; gap: 10px; }
.hist-pct { min-width: 44px; font-size: 0.88rem; }
.weak-item { padding: 12px 14px; gap: 12px; }
.weak-pct { min-width: 36px; font-size: 0.88rem; }
/* Empty CTA */
.empty-cta { padding: 40px 20px; }
.empty-cta-icon { font-size: 2.5rem; }
.empty-cta-title { font-size: 0.9rem; }
/* Trend chart */
.trend-wrap { padding: 16px 12px 12px; }
/* Heatmap popup: prevent off-screen overflow */
.hm-day-popup { min-width: 0; max-width: 90vw; }
/* Modal <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> bottom sheet */
.modal-overlay { align-items: flex-end; padding: 0; }
.modal { border-radius: 22px 22px 0 0; padding: 28px 20px 36px; max-height: 90vh; overflow-y: auto; -webkit-overflow-scrolling: touch; }
.modal-footer { flex-wrap: wrap; }
.modal-footer .btn-cancel,
.modal-footer .btn-primary { flex: 1; justify-content: center; }
/* Quick-start modal row label */
.qs-label { min-width: 60px; }
/* Leaderboard */
.lb-head { flex-direction: column; gap: 10px; align-items: flex-start; }
.lb-name { font-size: 0.76rem; }
/* Section headers */
.section-title { font-size: 0.74rem; }
/* Heatmap section */
.heatmap-section { padding: 18px 12px; }
/* Keyboard hints: hide on mobile */
.kb-hint { display: none !important; }
}
@media (max-width: 480px) {
.container { padding: 16px 10px 80px; }
.widget { padding: 14px; }
.lb-widget { padding: 14px; }
/* Header stats */
.dh-stats { gap: 0; flex-wrap: wrap; padding: 6px 4px; }
.stat-ring { padding: 4px 10px; gap: 7px; }
.dh-greeting { font-size: 0.82rem; }
/* Admin quick actions: narrow */
.adm-actions { gap: 8px; }
.adm-act-group { grid-template-columns: 1fr; }
.adm-act { padding: 10px 10px; font-size: 0.76rem; gap: 8px; }
/* Assignment rows tighter */
.asgn-row { gap: 6px; padding: 6px 8px; }
.ar-icon { width: 30px; height: 30px; }
.ar-icon svg { width: 14px; height: 14px; }
/* Stats chips min-width */
.stats-chip { min-width: 60px; }
/* Heatmap popup always right-anchored */
.hm-day-popup { right: 10px !important; left: auto !important; top: auto !important; }
}
/* ── Widget configurator FAB ── */
.dash-cfg-fab {
display: none; /* shown for students via JS */
position: fixed; bottom: 88px; right: 20px; z-index: 80;
width: 44px; height: 44px; border-radius: 50%;
background: var(--violet); border: none; cursor: pointer;
box-shadow: 0 4px 16px rgba(155,93,229,0.45);
align-items: center; justify-content: center;
transition: transform .15s, box-shadow .15s;
}
.dash-cfg-fab:hover { transform: scale(1.08); box-shadow: 0 6px 24px rgba(155,93,229,0.55); }
.dash-cfg-fab svg { width: 20px; height: 20px; color: #fff; flex-shrink: 0; }
.dash-cfg-panel {
display: none; position: fixed; bottom: 140px; right: 20px;
background: #1e1b2e; border: 1px solid rgba(155,93,229,0.3);
border-radius: 14px; padding: 14px; min-width: 220px; z-index: 81;
box-shadow: 0 8px 32px rgba(0,0,0,.45);
}
.dash-cfg-panel.open { display: block; }
.dash-cfg-title {
font-size: 0.72rem; font-weight: 700; color: var(--violet);
text-transform: uppercase; letter-spacing: .5px; margin-bottom: 10px;
}
.dash-cfg-row {
display: flex; align-items: center; justify-content: space-between;
padding: 7px 4px; border-radius: 6px; cursor: pointer;
}
.dash-cfg-row:hover { background: rgba(255,255,255,.05); }
.dash-cfg-row label { font-size: 0.83rem; color: #e2e8f0; cursor: pointer; flex: 1; }
.dash-cfg-row input[type=checkbox] { accent-color: var(--violet); width: 15px; height: 15px; cursor: pointer; }
.dash-cfg-wrap { display: contents; }
</style>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<div class="notif-drop" id="notif-drop"></div>
<div class="sb-content">
<!-- ZONE 1: Compact Header -->
<div class="dash-header">
<div class="dh-avatar" id="dh-avatar">LS</div>
<div class="dh-text">
<div class="dh-greeting" id="dh-greeting">Привет, <span id="user-name"></span></div>
<div class="dh-sub" id="dh-sub">Выбери тест и начни</div>
<div class="dh-kpi-row" id="dh-kpi-row" style="display:none">
<span class="dh-kpi"><strong id="kpi-classes"></strong> классов</span>
<span class="dh-kpi"><strong id="kpi-students"></strong> учеников</span>
<span class="dh-kpi"><strong id="kpi-active-asgn"></strong> активных заданий</span>
<span class="dh-kpi warn" id="kpi-pending-wrap" style="display:none"><strong id="kpi-pending"></strong> требуют внимания</span>
</div>
</div>
<div class="dh-stats" id="dh-stats" style="display:none">
<div class="stat-ring" id="sr-sessions"></div>
<div class="stat-ring" id="sr-avg"></div>
<div class="stat-ring" id="sr-streak"></div>
<div class="stat-ring" id="sr-pending"></div>
</div>
</div>
<!-- Widget configurator FAB (fixed, outside header flow) -->
<button class="dash-cfg-fab" id="dash-cfg-btn" onclick="toggleDashCfg(event)" title="Настроить виджеты">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
<div class="dash-cfg-panel" id="dash-cfg-panel">
<div class="dash-cfg-title">Показывать виджеты</div>
<div class="dash-cfg-row" onclick="toggleDashWidget('ch-section',this)"><label>Испытания недели</label><input type="checkbox" data-widget="ch-section" checked></div>
<div class="dash-cfg-row" onclick="toggleDashWidget('stats-section',this)"><label>Статистика</label><input type="checkbox" data-widget="stats-section" checked></div>
<div class="dash-cfg-row" onclick="toggleDashWidget('w-my-subs',this)"><label>Мои сдачи</label><input type="checkbox" data-widget="w-my-subs" checked></div>
</div>
<div class="container">
<!-- Live online-lesson status (student/teacher) -->
<a class="live-lesson" id="live-lesson-banner" href="/classroom" style="display:none">
<span class="ll-dot"></span>
<span class="ll-text"><b id="ll-title">Идёт онлайн-урок</b><span id="ll-sub"></span></span>
<span class="ll-cta" id="ll-cta">Присоединиться</span>
</a>
<!-- Gamification Bar (students only) -->
<div class="gam-bar" id="gam-bar" style="display:none">
<div class="gam-level">
<div class="gam-level-num" id="gam-lvl">1</div>
<div class="gam-level-lbl">уровень</div>
</div>
<div class="gam-main">
<div class="gam-top">
<div class="gam-rank" id="gam-rank">Новичок</div>
<div class="gam-xp-text" id="gam-xp-text">0 / 100 XP</div>
</div>
<div class="gam-progress"><div class="gam-fill" id="gam-fill" style="width:0%"></div></div>
</div>
<div class="gam-chips">
<div class="gam-chip">
<div class="gam-chip-icon" id="gam-streak-icon"></div>
<div class="gam-chip-val" id="gam-streak">0</div>
<div class="gam-chip-lbl">стрик</div>
</div>
<div class="gam-chip" style="cursor:pointer" onclick="cycleGoalTier()" title="Нажмите для смены сложности">
<div class="gam-goal-ring" id="gam-goal-ring"></div>
<div class="gam-chip-lbl" id="gam-goal-label">цель дня</div>
</div>
</div>
</div>
<!-- ZONE 2: Action Banner + Cards -->
<div class="action-zone">
<div class="action-banner" id="action-banner" style="display:none">
<!-- populated by JS -->
</div>
<div class="hero-row" id="hero-row" style="display:none">
<!-- Card 1 — Continue / start reading -->
<a class="hero-card hc-read" id="hc-read" href="/textbooks">
<div class="hc-read-bg" aria-hidden="true">
<svg viewBox="0 0 200 160" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M100 16 C82 12 44 16 20 26 L20 138 C44 128 82 124 100 128Z" fill="white" opacity="0.55"/>
<path d="M100 16 C118 12 156 16 180 26 L180 138 C156 128 118 124 100 128Z" fill="white" opacity="0.55"/>
<path d="M100 16 L100 128" stroke="rgba(0,0,0,0.08)" stroke-width="3"/>
<path d="M28 42 Q62 39 94 42" stroke="rgba(0,0,0,0.18)" stroke-width="2.5" stroke-linecap="round"/>
<path d="M28 55 Q62 52 94 55" stroke="rgba(0,0,0,0.14)" stroke-width="2" stroke-linecap="round"/>
<path d="M28 67 Q62 64 94 67" stroke="rgba(0,0,0,0.14)" stroke-width="2" stroke-linecap="round"/>
<path d="M28 79 Q62 76 94 79" stroke="rgba(0,0,0,0.14)" stroke-width="2" stroke-linecap="round"/>
<path d="M28 91 Q52 88 80 91" stroke="rgba(0,0,0,0.14)" stroke-width="2" stroke-linecap="round"/>
<path d="M106 42 Q140 39 172 42" stroke="rgba(0,0,0,0.18)" stroke-width="2.5" stroke-linecap="round"/>
<path d="M106 55 Q140 52 172 55" stroke="rgba(0,0,0,0.14)" stroke-width="2" stroke-linecap="round"/>
<path d="M106 67 Q140 64 172 67" stroke="rgba(0,0,0,0.14)" stroke-width="2" stroke-linecap="round"/>
<path d="M106 79 Q140 76 172 79" stroke="rgba(0,0,0,0.14)" stroke-width="2" stroke-linecap="round"/>
<path d="M120 91 Q140 88 172 91" stroke="rgba(0,0,0,0.14)" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
<span class="hc-tag">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5a2 2 0 0 1 2-2h6v17H6a2 2 0 0 0-2 2z"/><path d="M20 5a2 2 0 0 0-2-2h-6v17h6a2 2 0 0 1 2 2z"/></svg>
<span id="hc-read-tag">Начать чтение</span>
</span>
<div class="hc-h" id="hc-read-title">Учебники</div>
<div class="hc-p" id="hc-read-sub">Открой учебник и продолжи курс с того места, где остановился.</div>
<div class="hc-progress" id="hc-read-prog-wrap" style="display:none"><i id="hc-read-prog" style="width:0%"></i></div>
<div class="hc-foot">
<span class="hc-foot-left">
<span class="hc-meta" id="hc-read-meta">новый учебник</span>
<span class="hc-pct" id="hc-read-pct" style="display:none">0%</span>
</span>
<span class="hc-btn">Начать <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>
</div>
</a>
<!-- Card 2 — Lab of the day -->
<a class="hero-card hc-lab" id="hc-lab" href="/lab">
<div class="hc-bg" id="hc-lab-bg"></div>
<span class="hc-tag">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3h6M10 3v6l-5.4 9.3A1.5 1.5 0 0 0 5.9 21h12.2a1.5 1.5 0 0 0 1.3-2.3L14 9V3"/><path d="M7.5 15h9"/></svg>
Лаборатория дня
</span>
<div class="hc-h" id="hc-lab-title">Газовые законы</div>
<div class="hc-p" id="hc-lab-sub">Давление, объём и температура газа.</div>
<div class="hc-chips" id="hc-lab-chips">
<span class="hc-chip subj" id="hc-lab-subj">Физика</span>
<span class="hc-chip" id="hc-lab-time">~10 мин</span>
<span class="hc-chip" id="hc-lab-level">средне</span>
</div>
<div class="hc-foot">
<span class="hc-meta" id="hc-lab-meta">Освой: уравнение состояния газа</span>
<span class="hc-btn">Открыть <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>
</div>
</a>
<!-- Card 3 — Pet (synced with /pet module) -->
<a class="hero-card hc-pet" id="hc-pet" href="/pet">
<div class="hc-pet-bg" aria-hidden="true">
<svg viewBox="0 0 300 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M256 28 L259 16 L262 28 L274 31 L262 34 L259 46 L256 34 L244 31Z" fill="#F9C74F" opacity="0.32"/>
<path d="M282 148 L284 141 L286 148 L293 150 L286 152 L284 159 L282 152 L275 150Z" fill="#F98231" opacity="0.28"/>
<path d="M20 36 L22 29 L24 36 L31 38 L24 40 L22 47 L20 40 L13 38Z" fill="#F9C74F" opacity="0.22"/>
<path d="M12 118 L13.5 112 L15 118 L21 119.5 L15 121 L13.5 127 L12 121 L6 119.5Z" fill="#F9C74F" opacity="0.18"/>
<path d="M78 163 V170 M74.5 166.5 H81.5" stroke="#F98231" stroke-width="2.5" stroke-linecap="round" opacity="0.22"/>
<path d="M268 78 V84 M265 81 H271" stroke="#F9C74F" stroke-width="2" stroke-linecap="round" opacity="0.28"/>
<path d="M148 8 V14 M145 11 H151" stroke="#F9C74F" stroke-width="2" stroke-linecap="round" opacity="0.18"/>
<circle cx="238" cy="172" r="4" fill="#F9C74F" opacity="0.22"/>
<circle cx="52" cy="172" r="3" fill="#F98231" opacity="0.18"/>
<circle cx="295" cy="48" r="3" fill="#F9C74F" opacity="0.18"/>
<circle cx="8" cy="172" r="2.5" fill="#F9C74F" opacity="0.16"/>
<circle cx="128" cy="192" r="2" fill="#F98231" opacity="0.16"/>
</svg>
</div>
<span class="hc-tag">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="5.5" cy="11" r="2"/><circle cx="9.5" cy="6.5" r="2"/><circle cx="14.5" cy="6.5" r="2"/><circle cx="18.5" cy="11" r="2"/><path d="M8.2 16.4C8.2 14.3 9.9 13 12 13s3.8 1.3 3.8 3.4c0 1.7-1.3 2.8-2.6 3.2-.8.2-1.6.2-2.4 0-1.3-.4-2.6-1.5-2.6-3.2z"/></svg>
Питомец
</span>
<div class="hc-pet-top">
<div class="hc-pet-name" id="hc-pet-name">Квантик</div>
<div class="hc-pet-art" id="hc-pet-art"></div>
</div>
<div class="hc-xp-row"><span>Ур. <b id="hc-pet-lvl">1</b></span><span><b id="hc-pet-xp">0</b> / <span id="hc-pet-xpmax">500</span> XP</span></div>
<div class="hc-progress"><i id="hc-pet-prog" style="width:0%"></i></div>
<div class="hc-pet-chips">
<div class="hc-pchip chip-streak"><b id="hc-pet-streak">0</b><span>стрик</span></div>
<div class="hc-pchip chip-goal"><b id="hc-pet-goal">0/2</b><span>цель дня</span></div>
<div class="hc-pchip chip-mood"><b id="hc-pet-mood">бодр</b><span>настроение</span></div>
</div>
<span class="hc-btn">Ухаживать <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>
</a>
</div>
</div>
<!-- ADMIN ZONE: Quick actions (hidden for students) -->
<div id="admin-actions-zone" style="display:none;margin-bottom:18px">
<div class="adm-actions" id="adm-actions-grid">
<div class="adm-act-group">
<a class="adm-act" href="/classes">
<div class="adm-act-icon" style="background:#9B5DE5"><i data-lucide="graduation-cap"></i></div>
Мои классы
</a>
<a class="adm-act" href="/homework">
<div class="adm-act-icon" style="background:#F15BB5"><i data-lucide="file-check"></i></div>
Работы
</a>
</div>
<div class="adm-act-group">
<a class="adm-act" href="/board">
<div class="adm-act-icon" style="background:#06D6A0"><i data-lucide="layout-dashboard"></i></div>
Доска
</a>
<a class="adm-act" href="/library">
<div class="adm-act-icon" style="background:#F59E0B"><i data-lucide="book-open"></i></div>
Библиотека
</a>
</div>
<div class="adm-act-group">
<a class="adm-act" href="/admin">
<div class="adm-act-icon" style="background:#06B6D4"><i data-lucide="settings"></i></div>
Управление
</a>
</div>
</div>
</div>
<!-- ADMIN GRID: 3-column compact layout (hidden for students) -->
<div class="admin-grid" id="admin-grid" style="display:none">
<!-- Admin Col 1: Assignments (compact) -->
<div class="widget" id="w-admin-assignments">
<div class="w-head">
<div class="w-title">Задания</div>
<a class="w-more" href="/classes">Все классы <i data-lucide="chevron-right" style="width:13px;height:13px;vertical-align:-2px"></i></a>
</div>
<div id="admin-assignments-list"><div id="admin-assignments-sk"></div></div>
</div>
<!-- Admin Col 2: Classes -->
<div class="widget" id="w-admin-classes">
<div class="w-head">
<div class="w-title">Классы</div>
<a class="w-more" href="/classes">Все <i data-lucide="chevron-right" style="width:13px;height:13px;vertical-align:-2px"></i></a>
</div>
<div class="class-summary" id="admin-classes-body"></div>
</div>
<!-- Admin Col 3: Recent sessions -->
<div class="widget" id="w-admin-sessions">
<div class="w-head">
<div class="w-title">Последние сессии</div>
<a class="w-more" href="/admin">Все <i data-lucide="chevron-right" style="width:13px;height:13px;vertical-align:-2px"></i></a>
</div>
<div class="adm-sessions" id="admin-sessions-body"></div>
</div>
</div>
<!-- ADMIN COMMAND CENTER: full redesign overview (admin only) -->
<div id="admin-command-center" style="display:none"></div>
<!-- ZONE 3: Three-Column Grid -->
<div class="main-grid">
<!-- Col 1: Assignments -->
<div class="widget" id="w-assignments">
<div class="w-head">
<div class="w-title">Задания <span class="tab-badge" id="assignments-badge" style="display:none"></span></div>
<div class="assign-chips" id="assign-chips-wrap" style="display:none">
<button class="assign-chip active" onclick="setAssignFilter('all')">Все</button>
<button class="assign-chip" onclick="setAssignFilter('active')">Активные</button>
<button class="assign-chip" onclick="setAssignFilter('done')">Сдано</button>
</div>
</div>
<div class="assign-search-wrap">
<input class="assign-search" id="assign-search" type="text" placeholder="Поиск заданий…" oninput="filterAssignments(this.value)">
</div>
<div class="assign-stats" id="assign-stats" style="margin-bottom:8px"></div>
<div class="subj-filter-row" id="subj-filter-row" style="display:none"></div>
<div id="assignments-list"><div id="assignments-sk"></div></div>
</div>
<!-- Col 2: Tests / Teacher class summary -->
<div class="widget" id="w-teacher-summary" style="display:none">
<div class="w-head"><div class="w-title">Мои классы</div></div>
<div class="class-summary" id="teacher-summary-body"></div>
</div>
<div class="widget" id="w-tests">
<div class="w-head"><div class="w-title">Тесты</div></div>
<div class="subj-mini-grid" id="subjects-list"><div id="subjects-sk"></div></div>
<!-- Витрина: тесты, открытые учителем/админом ученикам -->
<div id="avail-tests-wrap" style="display:none;margin-top:14px">
<div style="font-size:.74rem;font-weight:800;letter-spacing:.04em;text-transform:uppercase;color:var(--text-3);margin:0 0 8px 2px">Доступные тесты</div>
<div class="subj-mini-grid" id="available-tests-list"></div>
</div>
</div>
<!-- Col 3: Progress -->
<div class="widget" id="w-progress-col">
<!-- Flashcard review (random card from pool) -->
<div id="w-flashcard" style="display:none;margin-bottom:18px">
<div class="w-head">
<div class="w-title">Повтори карточку</div>
<a class="w-more" href="/flashcards">Все карточки</a>
</div>
<div id="fcw-body"></div>
</div>
<!-- Day popup (floating) -->
<div class="hm-day-popup" id="hm-day-popup" style="display:none"></div>
<!-- Subject progress bars -->
<div id="w-subj-progress" style="display:none;margin-top:18px">
<div class="w-head"><div class="w-title">По предметам</div></div>
<div id="subj-progress-bars"></div>
</div>
<!-- Last results -->
<div id="w-last-results" style="display:none;margin-top:18px">
<div class="w-head">
<div class="w-title">Результаты</div>
<button class="w-more" onclick="toggleFullHistory()">Все</button>
</div>
<div id="last-results-list"></div>
</div>
</div>
</div>
<!-- ZONE 4: Bottom row — Activity · My submissions · Challenges -->
<div class="bottom-grid">
<!-- Combined Activity Widget (heatmap + streak calendar) -->
<div class="widget" id="w-activity" style="display:none">
<div class="w-head">
<div class="w-title">Активность</div>
<div class="act-tabs">
<button class="act-tab active" onclick="switchActTab('heatmap',this)">Карта</button>
<button class="act-tab" onclick="switchActTab('calendar',this)">Месяц</button>
</div>
</div>
<!-- Pane 1: Heatmap -->
<div class="act-pane visible" id="act-heatmap-pane">
<div id="activity-heatmap"></div>
</div>
<!-- Pane 2: Streak calendar -->
<div class="act-pane" id="act-cal-pane">
<div id="streak-cal-body"></div>
</div>
</div>
<!-- My submissions compact widget (student only) -->
<div class="widget" id="w-my-subs" style="display:none">
<div class="w-head">
<div class="w-title">Мои сдачи</div>
<a class="ae-btn-submit" href="/homework" style="text-decoration:none">Загрузить работу</a>
</div>
<div class="my-subs-list" id="my-subs-list"></div>
</div>
<!-- Challenges (students only) -->
<div class="widget ch-widget" id="ch-section" style="display:none">
<div class="w-head"><div class="w-title" id="ch-title">Испытания недели</div></div>
<div class="ch-list" id="ch-list"></div>
</div>
</div>
<!-- Statistics Charts (students only) -->
<div class="full-row stats-section" id="stats-section" style="display:none">
<div class="widget">
<div class="w-head"><div class="w-title">Статистика</div></div>
<div class="stats-summary-chips" id="stats-chips"></div>
<div class="stats-grid">
<div class="stats-chart-card">
<div class="stats-chart-title">Средний балл по неделям</div>
<div class="stats-bar-chart" id="stats-weekly-chart"></div>
<div class="stats-bar-labels" id="stats-weekly-labels"></div>
</div>
<div class="stats-chart-card">
<div class="stats-chart-title">Тренд последних сессий</div>
<div class="stats-bar-chart" id="stats-trend-chart"></div>
</div>
<div class="stats-chart-card">
<div class="stats-chart-title">По предметам</div>
<div id="stats-subjects-chart"></div>
</div>
<div class="stats-chart-card">
<div class="stats-chart-title">Прогресс курсов</div>
<div id="stats-courses-chart"></div>
</div>
</div>
</div>
</div>
<!-- Full width: Full history (hidden) -->
<div class="full-row">
<div class="widget" id="w-full-history" style="display:none">
<div class="w-head">
<div class="w-title">История сессий</div>
<button class="w-more" onclick="toggleFullHistory()">Свернуть</button>
</div>
<div id="history-wrap"><div id="history-sk"></div></div>
<div id="history-more-wrap" style="text-align:center;margin-top:16px;display:none">
<button class="qa-btn" id="history-more-btn" onclick="loadMoreHistory()" style="margin:0 auto">Загрузить ещё</button>
</div>
</div>
</div>
<!-- Full width: Full progress (hidden) -->
<div class="full-row">
<div class="widget" id="w-full-progress" style="display:none">
<div class="w-head"><div class="w-title">Детальный прогресс</div></div>
<div id="progress-wrap"></div>
</div>
</div>
</div><!-- /.container -->
<!-- Heatmap tooltip (floating) -->
<div class="hm-tip" id="hm-tip"></div>
<!-- Keyboard hints (floating) -->
<div class="kb-hint" id="kb-hint">
<span><span class="kb-key">N</span> Новый тест</span>
<span><span class="kb-key">T</span> Библиотека</span>
<span><span class="kb-key">B</span> Доска</span>
<span><span class="kb-key">?</span> Подсказки</span>
</div>
<!-- Join modal -->
<!-- Quick-start test modal -->
<script src="/js/api.js"></script>
<script src="/js/assignment-utils.js"></script>
<script src="/js/sound.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script src="/js/pet-sprite.js"></script>
<script src="/js/lab-previews.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="/js/dashboard-admin-center.js"></script>
<script>
const { user, isTeacher, isAdmin } = LS.initPage();
if (!user) throw new Error('Not logged in');
document.getElementById('user-name').textContent = user?.name?.split(' ')[0] || 'Студент';
// Hero-аватар: загруженная картинка (avatar_url) или инициалы — как в сайдбаре
LS.renderNavAvatar(document.getElementById('dh-avatar'), user);
LS.showBoardIfAllowed();
if (isTeacher) {
document.getElementById('btn-admin').style.display = '';
document.getElementById('btn-classes').style.display = '';
const h = new Date().getHours();
const gr = h < 6 ? 'Доброй ночи' : h < 12 ? 'Доброе утро' : h < 18 ? 'Добрый день' : 'Добрый вечер';
document.getElementById('dh-greeting').innerHTML = `${gr}, <span>${esc(user?.name?.split(' ')[0] || 'Администратор')}</span>`;
document.getElementById('dh-sub').textContent = user?.role === 'admin' ? 'Панель администратора' : 'Панель учителя';
// teacher/admin: hide student-only widgets, show admin compact layout
document.querySelectorAll('.action-zone,.main-grid,.bottom-grid,.full-row').forEach(el => { if (el) el.style.display = 'none'; });
if (isAdmin) {
// admin: full command center (redesign) instead of compact layout
const dh = document.querySelector('.dash-header'); if (dh) dh.style.display = 'none';
document.getElementById('admin-actions-zone').style.display = 'none';
document.getElementById('admin-grid').style.display = 'none';
document.getElementById('admin-command-center').style.display = '';
} else {
document.getElementById('admin-actions-zone').style.display = '';
document.getElementById('admin-grid').style.display = '';
}
} else {
// Приветствие по времени суток
const h = new Date().getHours();
const gr = h < 6 ? 'Доброй ночи' : h < 12 ? 'Доброе утро' : h < 18 ? 'Добрый день' : 'Добрый вечер';
document.getElementById('dh-greeting').innerHTML =
`${gr}, <span id="user-name2">${esc(user?.name?.split(' ')[0] || 'Студент')}</span>`;
reIcons();
}
// Auto-join from invite link (?join=CODE)
const joinCode = new URLSearchParams(location.search).get('join');
if (joinCode) { history.replaceState({}, '', location.pathname); openJoinModal(joinCode); }
const SUBJ = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика', other:'Задание' };
const ICONS = { bio:'dna', chem:'flask-conical', math:'calculator', phys:'zap', other:'file-check' };
const MODES = { exam:'Экзамен', practice:'Тренировка', repeat:'Обычный', ct:'ЦТ/ЦЭ', topic:'По теме', random:'Случайный' };
const SUBJ_COLORS = { bio:'#9B5DE5', chem:'#06D6A0', math:'#06B6D4', phys:'#F59E0B', other:'#7c3aed' };
// helper: inline Lucide icon for dynamic HTML (requires lucide.createIcons() after render)
function lci(name, style = '') {
return `<i data-lucide="${name}"${style ? ` style="${style}"` : ''}></i>`;
}
function reIcons() { if (window.lucide) lucide.createIcons(); }
/* ══ TOGGLE FULL HISTORY ═══════════════════════════════════════════ */
let _fullHistoryLoaded = false;
function toggleFullHistory() {
const full = document.getElementById('w-full-history');
const mini = document.getElementById('w-last-results');
const isOpen = full.style.display !== 'none';
full.style.display = isOpen ? 'none' : '';
mini.style.display = isOpen ? '' : 'none';
if (!isOpen && !_fullHistoryLoaded) {
_fullHistoryLoaded = true;
loadHistory();
}
}
/* ══ GAMIFICATION BAR ═════════════════════════════════════════════════ */
async function loadGamification() {
if (isTeacher) return;
try {
const g = await LS.getGamificationMe();
document.getElementById('gam-bar').style.display = '';
document.getElementById('gam-lvl').textContent = g.level || 1;
document.getElementById('gam-rank').textContent = g.rank || 'Новичок';
const xp = g.xp || 0, min = g.levelMin || 0, max = g.levelMax || 100;
const pct = max > min ? Math.round((xp - min) / (max - min) * 100) : 0;
// Animated XP count-up
const xpText = document.getElementById('gam-xp-text');
const xpTarget = xp - min, xpMax = max - min;
const fillEl = document.getElementById('gam-fill');
fillEl.style.width = '0%';
requestAnimationFrame(() => {
requestAnimationFrame(() => {
fillEl.style.width = Math.min(100, pct) + '%';
});
});
// Count-up animation for XP text
const countDur = 800;
const countStart = performance.now();
function countTick(now) {
const t = Math.min(1, (now - countStart) / countDur);
const ease = 1 - Math.pow(1 - t, 3);
const cur = Math.round(xpTarget * ease);
xpText.textContent = `${cur} / ${xpMax} XP`;
if (t < 1) requestAnimationFrame(countTick);
}
requestAnimationFrame(countTick);
document.getElementById('gam-streak').textContent = g.streak || 0;
document.getElementById('gam-streak-icon').innerHTML = lci('flame', 20);
// Daily goal ring
const dg = g.dailyGoal;
if (dg) {
const testsPct = dg.tests_target > 0 ? Math.min(100, Math.round(dg.tests_done / dg.tests_target * 100)) : 0;
const xpPct = dg.xp_target > 0 ? Math.min(100, Math.round(dg.xp_earned / dg.xp_target * 100)) : 0;
const avgPct = Math.round((testsPct + xpPct) / 2);
const r = 15, circ = 2 * Math.PI * r, dash = (avgPct / 100 * circ).toFixed(1);
const color = avgPct >= 100 ? '#059652' : 'var(--violet)';
document.getElementById('gam-goal-ring').innerHTML = `
<svg width="40" height="40" viewBox="0 0 40 40">
<circle cx="20" cy="20" r="${r}" fill="none" stroke="rgba(15,23,42,0.07)" stroke-width="4"/>
<circle cx="20" cy="20" r="${r}" fill="none" stroke="${color}" stroke-width="4"
stroke-dasharray="${dash} ${circ.toFixed(1)}" stroke-linecap="round" transform="rotate(-90 20 20)"/>
<text x="20" y="24" text-anchor="middle" font-family="Unbounded,sans-serif" font-size="8" font-weight="800" fill="${color}">${avgPct}%</text>
</svg>`;
// Show tier label
const tierLabels = { easy: 'Лёгкая', medium: 'Средняя', hard: 'Тяжёлая' };
document.getElementById('gam-goal-label').textContent = tierLabels[g.goalTier] || 'цель дня';
window._currentGoalTier = g.goalTier || 'medium';
}
// Apply avatar frame
if (g.avatarFrame && g.avatarFrame.css) {
const dhAv = document.getElementById('dh-avatar');
if (dhAv) dhAv.style.cssText += ';' + g.avatarFrame.css;
const navAv = document.getElementById('nav-avatar');
if (navAv) navAv.style.cssText += ';' + g.avatarFrame.css;
}
} catch { /* silent */ }
}
const _tierCycle = ['easy', 'medium', 'hard'];
const _tierLabels = { easy: 'Лёгкая', medium: 'Средняя', hard: 'Тяжёлая' };
let _cyclingGoal = false;
async function cycleGoalTier() {
if (_cyclingGoal) return;
_cyclingGoal = true;
const cur = window._currentGoalTier || 'medium';
const idx = _tierCycle.indexOf(cur);
const next = _tierCycle[(idx + 1) % _tierCycle.length];
try {
await LS.setGoalTier(next);
window._currentGoalTier = next;
document.getElementById('gam-goal-label').textContent = _tierLabels[next];
LS.toast(`Цель дня: ${_tierLabels[next]}`, 'info', 2000);
// Refresh gam bar to show updated targets (will apply from tomorrow)
} catch (e) { LS.toast(e.message, 'error'); }
finally { _cyclingGoal = false; }
}
/* ══ LEADERBOARD ═══════════════════════════════════════════════════ */
let _lbPeriod = 'week';
function setLbPeriod(p, btn) {
_lbPeriod = p;
btn.parentElement.querySelectorAll('.lb-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
loadLeaderboard();
}
async function loadLeaderboard() {
if (isTeacher) return;
try {
const sel = document.getElementById('lb-class-sel');
const classId = sel.value || undefined;
const resp = await LS.getLeaderboard({ period: _lbPeriod, class_id: classId });
const data = Array.isArray(resp) ? resp : (resp.rows || []);
const list = document.getElementById('lb-list');
const me = LS.getUser();
if (!data.length) {
list.innerHTML = '<div class="lb-empty">Пока нет данных</div>';
return;
}
list.innerHTML = data.slice(0, 10).map((u, i) => {
const pos = i + 1;
const posClass = pos === 1 ? 'gold' : pos === 2 ? 'silver' : pos === 3 ? 'bronze' : '';
const initials = (u.name || 'U').split(' ').slice(0, 2).map(w => (w[0] || '').toUpperCase()).join('') || 'U';
const isMe = me && (u.user_id || u.id) === me.id;
return `<div class="lb-row${isMe ? ' me' : ''}">
<div class="lb-pos ${posClass}">${pos}</div>
<div class="lb-avatar">${esc(initials)}</div>
<div class="lb-info">
<div class="lb-name">${esc(u.name)}${isMe ? ' (вы)' : ''}</div>
<div class="lb-rank">Ур. ${u.level || 1}</div>
</div>
<div class="lb-xp">${u.sort_xp || u.xp || 0} XP</div>
</div>`;
}).join('');
showWidget('lb-section');
document.getElementById('lb-title').innerHTML = lci('trophy', 18) + ' Рейтинг';
} catch { /* silent */ }
}
async function _populateLbClasses() {
try {
const classes = await LS.myClasses();
if (classes && classes.length) {
const sel = document.getElementById('lb-class-sel');
classes.forEach(c => {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.name;
sel.appendChild(opt);
});
}
} catch {}
}
/* ══ CHALLENGES ════════════════════════════════════════════════════ */
async function loadChallenges() {
if (isTeacher) return;
try {
const data = await LS.getChallenges();
if (!data || !data.length) return;
const list = document.getElementById('ch-list');
list.innerHTML = data.map(c => {
const pct = c.target > 0 ? Math.min(100, Math.round(c.progress / c.target * 100)) : 0;
const done = c.completed;
const claimed = c.claimed;
const icon = done ? lci('check-circle', 20) : c.type === 'topic_tests' ? lci('book-open', 20) : c.type === 'high_score' ? lci('target', 20) : c.type === 'perfect' ? lci('diamond', 20) : lci('footprints', 20);
return `<div class="ch-item${done ? ' done' : ''}${claimed ? ' claimed' : ''}">
<div class="ch-icon">${icon}</div>
<div class="ch-body">
<div class="ch-title">${esc(c.title)}</div>
<div class="ch-desc">${esc(c.description)}</div>
<div class="ch-prog">
<div class="ch-bar"><div class="ch-fill" style="width:${pct}%"></div></div>
<div class="ch-pct">${c.progress}/${c.target}</div>
</div>
</div>
<div class="ch-reward">
<div class="ch-xp">+${c.xp_reward} XP</div>
${done && !claimed ? `<button class="ch-claim" onclick="claimCh(${c.id},this)">Забрать</button>` : ''}
${claimed ? '<div class="ch-claimed-badge">Получено</div>' : ''}
</div>
</div>`;
}).join('');
showWidget('ch-section');
document.getElementById('ch-title').innerHTML = lci('target', 18) + ' Испытания недели';
} catch {}
}
async function claimCh(id, btn) {
btn.disabled = true;
btn.textContent = '...';
try {
const r = await LS.claimChallenge(id);
LS.toast(`+${r.xp} XP за испытание!`, 'success');
loadChallenges();
loadGamification();
} catch (e) { LS.toast(e.message, 'error'); btn.disabled = false; btn.textContent = 'Забрать'; }
}
/* ══ ANIMATED COUNTER ════════════════════════════════════════════════ */
function animateCounter(el, target, suffix = '') {
if (!el) return;
const duration = 700;
const start = performance.now();
const step = t => {
const p = Math.min((t - start) / duration, 1);
const e = 1 - Math.pow(1 - p, 3);
el.textContent = Math.round(target * e) + suffix;
if (p < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
}
/* ══ STAT RING SVG HELPER ════════════════════════════════════════════ */
function statRingSvg(value, max, color, label) {
const pct = max > 0 ? Math.min(100, Math.round(value / max * 100)) : 0;
const r = 13, circ = 2 * Math.PI * r;
const dash = (pct / 100 * circ).toFixed(1);
return `<svg width="36" height="36" viewBox="0 0 36 36">
<circle cx="18" cy="18" r="${r}" fill="none" stroke="rgba(15,23,42,0.08)" stroke-width="4"/>
<circle cx="18" cy="18" r="${r}" fill="none" stroke="${color}" stroke-width="4"
stroke-dasharray="${dash} ${circ.toFixed(1)}" stroke-linecap="round"
transform="rotate(-90 18 18)"/>
</svg>
<span class="sr-text">
<span class="sr-val" style="color:${color}">${value}</span>
<span class="sr-label">${label}</span>
</span>`;
}
/* ══ STATS STRIP ═════════════════════════════════════════════════════ */
async function loadStats() {
if (isTeacher) return;
document.getElementById('dh-stats').style.display = '';
try {
const data = await LS.getHistory(1, 60);
const rows = data.rows || [];
// Total sessions
const totalSessions = data.total || 0;
// Average %
const done = rows.filter(r => r.score !== null && r.total > 0);
const avg = done.length
? Math.round(done.reduce((s, r) => s + r.score / r.total * 100, 0) / done.length)
: 0;
const avgColor = avg >= 75 ? '#059652' : avg >= 50 ? '#F59E0B' : '#E0335E';
// Streak — consecutive days going back from today
const dayKeys = new Set(rows.map(r => {
const d = parseDate(r.started_at);
return d.getFullYear() + '-' + d.getMonth() + '-' + d.getDate();
}));
let streak = 0;
const now = new Date();
for (let i = 0; i <= 60; i++) {
const d = new Date(now);
d.setDate(d.getDate() - i);
const k = d.getFullYear() + '-' + d.getMonth() + '-' + d.getDate();
if (dayKeys.has(k)) { streak++; }
else if (i > 1) break;
}
document.getElementById('sr-sessions').innerHTML = statRingSvg(totalSessions, Math.max(totalSessions, 1), 'var(--violet)', 'сессий');
document.getElementById('sr-avg').innerHTML = statRingSvg(avg, 100, avgColor, 'средний %');
document.getElementById('sr-streak').innerHTML = statRingSvg(streak, Math.max(streak, 7), '#F59E0B', 'подряд');
document.getElementById('sr-pending').innerHTML = statRingSvg(0, 1, 'var(--cyan)', 'ожидают');
} catch { /* тихо */ }
}
function updatePendingStat(list) {
if (isTeacher) return;
const pending = (list || []).filter(a =>
!a.session_status && (!a.deadline || new Date(a.deadline) > new Date())
).length;
const el = document.getElementById('sr-pending');
if (el) el.innerHTML = statRingSvg(pending, Math.max(pending, 1), 'var(--cyan)', 'ожидают');
}
/* ══ ПРЕДМЕТЫ ════════════════════════════════════════════════════════ */
async function loadSubjects() {
const list = document.getElementById('subjects-list');
try {
const SUBJ_MODE_LABELS = { exam:'Экзамен', practice:'Пробный тест' };
// Прячем предметы, по которым нечего запустить (нет вопросов в банке и нет фикс-теста).
const subjects = (await LS.getSubjects())
.filter(s => (s.question_count || 0) > 0 || s.default_test_id);
if (!subjects.length) { list.innerHTML = '<div class="empty">Тесты пока недоступны</div>'; return; }
list.innerHTML = subjects.map((s, si) => {
let mode = s.default_mode || 'exam';
if (mode !== 'exam' && mode !== 'practice') mode = 'practice'; // старые topic/random → practice (старт сессии их не принимает)
const count = s.default_count || 25;
const testId = s.default_test_id || null;
const modeLabel = SUBJ_MODE_LABELS[mode] || mode;
const color = SUBJ_COLORS[s.slug] || '#9B5DE5';
const iconName = ICONS[s.slug] || 'book-open';
const srcLabel = testId ? 'Фиксированный тест' : `${count} вопросов`;
return `<div class="subj-mini-card stagger-item" style="--i:${si}" onclick="startSubjectTest('${s.slug}','${mode}',${count},${testId||'null'})">
<div class="smc-icon" style="background:${color}">${lci(iconName)}</div>
<div class="smc-body">
<div class="smc-name">${esc(s.name)}</div>
<div class="smc-meta">${modeLabel} · ${srcLabel}</div>
</div>
<i data-lucide="chevron-right" class="smc-arrow"></i>
</div>`;
}).join('');
reIcons();
} catch (e) {
list.innerHTML = `<div class="empty">Ошибка: ${esc(e.message)}</div>`;
}
}
function startSubjectTest(slug, mode = 'exam', count = 25, testId = null) {
let url = `/test-run?subject=${slug}&mode=${mode}&count=${count}`;
if (testId) url += `&test=${testId}`;
window.location.href = url;
}
/* Витрина доступных тестов (бэкенд ученику отдаёт только помеченные доступными). */
async function loadAvailableTests() {
const wrap = document.getElementById('avail-tests-wrap');
const list = document.getElementById('available-tests-list');
if (!wrap || !list) return;
try {
const tests = await LS.getTests();
if (!tests || !tests.length) { wrap.style.display = 'none'; return; }
const SUBJ_N = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика' };
list.innerHTML = tests.map((t, i) => {
const color = SUBJ_COLORS[t.subject_slug] || '#9B5DE5';
const iconName = ICONS[t.subject_slug] || 'book-open';
return `<div class="subj-mini-card stagger-item" style="--i:${i}" onclick="startSubjectTest('${t.subject_slug}','exam',25,${t.id})">
<div class="smc-icon" style="background:${color}">${lci(iconName)}</div>
<div class="smc-body">
<div class="smc-name">${esc(t.title)}</div>
<div class="smc-meta">${SUBJ_N[t.subject_slug] || t.subject_slug} · ${t.question_count} вопр.</div>
</div>
<i data-lucide="chevron-right" class="smc-arrow"></i>
</div>`;
}).join('');
wrap.style.display = '';
reIcons();
} catch { wrap.style.display = 'none'; }
}
/* ══ ЗАДАНИЯ ══════════════════════════════════════════════════════════ */
async function loadAssignments() {
try {
const [list, myClassesList] = isTeacher
? [await LS.teacherAssignments(), []]
: await Promise.all([LS.myAssignments(), LS.myClasses().catch(() => [])]);
if (!isTeacher) await loadMySubmissions();
const alreadyInClass = !isTeacher && myClassesList.length > 0;
if (!isTeacher) {
document.getElementById('btn-join').style.display = alreadyInClass ? 'none' : '';
}
if (!list.length) {
if (!isTeacher) {
document.getElementById('assignments-list').innerHTML = alreadyInClass
? `<div class="rich-empty">
<svg class="rich-empty-svg" width="110" height="84" viewBox="0 0 110 84" fill="none">
<rect x="22" y="18" width="66" height="54" rx="9" fill="rgba(155,93,229,0.07)" stroke="#9B5DE5" stroke-width="1.8"/>
<rect x="38" y="10" width="34" height="16" rx="5" fill="white" stroke="#9B5DE5" stroke-width="1.8"/>
<line x1="36" y1="40" x2="74" y2="40" stroke="#9B5DE5" stroke-width="2" stroke-linecap="round"/>
<line x1="36" y1="51" x2="68" y2="51" stroke="#06D6E0" stroke-width="2" stroke-linecap="round"/>
<line x1="36" y1="62" x2="58" y2="62" stroke="#06D6E0" stroke-width="2" stroke-linecap="round"/>
</svg>
<div class="rich-empty-title">Заданий пока нет</div>
<div class="rich-empty-sub">Учитель ещё не назначил задания. Они появятся здесь автоматически.</div>
</div>`
: `<div class="rich-empty">
<svg class="rich-empty-svg" width="110" height="84" viewBox="0 0 110 84" fill="none">
<circle cx="55" cy="42" r="28" fill="rgba(155,93,229,0.07)" stroke="#9B5DE5" stroke-width="1.8"/>
<path d="M44 42 L52 50 L66 34" stroke="#06D6E0" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<circle cx="55" cy="42" r="18" fill="none" stroke="rgba(155,93,229,0.25)" stroke-width="1" stroke-dasharray="4 3"/>
</svg>
<div class="rich-empty-title">Вы ещё не в классе</div>
<div class="rich-empty-sub">Попросите учителя поделиться кодом приглашения и вступите в класс, чтобы получать задания</div>
<button class="rich-empty-btn" onclick="openJoinModal()">Вступить в класс</button>
</div>`;
} else {
document.getElementById('assignments-list').innerHTML = `<div class="empty" style="padding:20px 0">Заданий пока нет.</div>`;
}
return;
}
// ── badge на кнопке таба ──
if (!isTeacher) {
const lsKey = 'ls_assign_seen_' + (LS.getUser()?.id || '');
const lastSeen = localStorage.getItem(lsKey);
const newCount = lastSeen
? list.filter(a => !a.session_status && parseDate(a.created_at) > parseDate(lastSeen)).length
: 0;
const badge = document.getElementById('assignments-badge');
if (newCount > 0) { badge.textContent = newCount; badge.style.display = ''; }
else badge.style.display = 'none';
localStorage.setItem(lsKey, new Date().toISOString());
updatePendingStat(list);
// show toolbar + subject chips for students
document.getElementById('assign-chips-wrap').style.display = '';
buildSubjChips(list);
}
_allAssignmentsList = list;
renderAssignmentsList();
if (!isTeacher) {
loadActionBanner(list);
showDeadlineToast(list);
}
} catch (e) {
console.error('Assignments error:', e.message);
document.getElementById('assignments-list').innerHTML =
`<div class="empty">Не удалось загрузить задания</div>`;
}
}
let _allAssignmentsList = [];
let _assignFilter = 'all';
function setAssignFilter(f) {
_assignFilter = f;
document.querySelectorAll('.assign-chip').forEach(c => c.classList.toggle('active', c.textContent.trim() === {all:'Все',active:'Активные',done:'Сдано'}[f]));
renderAssignmentsList();
}
function toggleAssignGroup(grpId) {
const hdr = document.getElementById('grp-hdr-' + grpId);
const body = document.getElementById('grp-body-' + grpId);
hdr .classList.toggle('collapsed');
body.classList.toggle('collapsed');
}
/* ── Urgency sort score (lower = shown first) — общий модуль ── */
function urgencyScore(a) { return AssignmentUtils.urgencyScore(a); }
/* ── Is assignment urgent for teacher (within 48h) ── */
function isTeacherUrgent(a) {
if (!a.deadline) return false;
const ms = parseDate(a.deadline).getTime() - Date.now();
return ms > 0 && ms < 48 * 3600 * 1000;
}
/* ── Relative timestamp helper ("12 мин назад", "сегодня", "вчера") ── */
function relativeAgo(dateStr) {
if (!dateStr) return '';
const d = parseDate(dateStr);
const diffMs = Date.now() - d.getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return 'только что';
if (diffMin < 60) return `${diffMin} мин назад`;
const diffH = Math.floor(diffMin / 60);
if (diffH < 24) return `${diffH} ч назад`;
const diffD = Math.floor(diffH / 24);
if (diffD === 1) return 'вчера';
if (diffD < 7) return `${diffD} дн назад`;
return d.toLocaleDateString('ru', { day: 'numeric', month: 'short' });
}
function buildAssignCard(a, idx, isFirst = false) {
const dl = a.deadline ? new Date(a.deadline).toLocaleDateString('ru',{day:'numeric',month:'short'}) : null;
const dlMs = a.deadline ? new Date(a.deadline) - Date.now() : Infinity;
const isUrgent = !a.session_status && dlMs > 0 && dlMs < 24 * 3600 * 1000;
const hoursLeft = isUrgent ? Math.ceil(dlMs / 3600000) : 0;
const sColor = SUBJ_COLORS[a.subject_slug] || '#9B5DE5';
const iconName = ICONS[a.subject_slug] || 'file-text';
const classStr = a.class_id ? esc(a.class_name) : 'Личное';
/* ── Teacher view ── */
if (isTeacher) {
const done = a.completed_count || 0;
const total = a.total_members || 0;
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
const barColor = pct >= 75 ? '#059652' : pct >= 50 ? '#F59E0B' : 'var(--pink)';
const urgent = isTeacherUrgent(a);
const titleHtml = esc(a.title)
+ (a.class_name && a.class_name !== 'Личное задание' ? `<span class="asgn-class-badge">${esc(a.class_name)}</span>` : '')
+ (urgent ? `<span class="asgn-fire">срочно</span>` : '');
const meta = [SUBJ[a.subject_slug] || a.subject_slug, dl ? `до ${dl}` : null].filter(Boolean).join(' · ');
const urgentCls = urgent ? ' asgn-urgent' : '';
return `<div class="asgn-wrap"><div class="asgn-row stagger-item${urgentCls}" style="--i:${idx};--ac:${sColor}" id="asgn-row-${a.id}" onclick="toggleAsgn(${a.id},event)">
<div class="ar-icon" style="background:${sColor}18;color:${sColor}">${lci(iconName)}</div>
<div class="ar-body"><div class="ar-title">${titleHtml}</div><div class="ar-meta">${meta}</div></div>
<div class="ar-progress">
<div class="ar-prog-bar"><div class="ar-prog-fill" style="width:${pct}%;background:${barColor}"></div></div>
<span class="ar-prog-text">${done} / ${total}</span>
</div>
<a class="ar-btn-ghost" href="/classes" onclick="event.stopPropagation()">Подробнее</a>
</div>
<div class="asgn-expand" id="asgn-exp-${a.id}">
<div class="ae-row">
<div class="ae-pills">
<span class="ae-pill">${lci('layers')} ${MODES[a.mode]||a.mode}</span>
<span class="ae-pill">${lci('list')} ${a.count} вопросов</span>
${a.is_homework ? `<span class="ae-pill">${lci('book-open')} Домашнее задание</span>` : ''}
</div>
<a class="ae-btn-result" href="/classes" onclick="event.stopPropagation()">Открыть результаты</a>
</div>
</div></div>`;
}
/* ── Upload-only homework (no test, no file) ── */
if (AssignmentUtils.type(a) === 'upload') {
const over = a.deadline && new Date(a.deadline) < new Date();
const sub = _mySubmissions.get(a.id);
const metaParts = [classStr, dl ? `до ${dl}` : null,
isUrgent ? `<span class="ar-tag-urgent"><i data-lucide="zap" style="width:10px;height:10px;vertical-align:-1px"></i> ${hoursLeft} ч</span>` : null].filter(Boolean);
const submitBtn = sub
? `<span class="ar-sub-chip s-${(SUB_STATUS[sub.status]||SUB_STATUS.new).cls}">${lci((SUB_STATUS[sub.status]||SUB_STATUS.new).icon,'width:11px;height:11px')} ${(SUB_STATUS[sub.status]||SUB_STATUS.new).label}</span>`
: `<a class="ar-btn" href="/homework" onclick="event.stopPropagation()" style="background:var(--violet);text-decoration:none;color:#fff">Сдать работу</a>`;
return `<div class="asgn-wrap"><div class="asgn-row stagger-item${over?' over':isUrgent?' urgent':''}" style="--i:${idx};--ac:#7c3aed" id="asgn-row-${a.id}" onclick="toggleAsgn(${a.id},event)">
<div class="ar-icon" style="background:rgba(124,58,237,0.1);color:#7c3aed">${lci('upload')}</div>
<div class="ar-body"><div class="ar-title">${esc(a.title)}</div><div class="ar-meta">${metaParts.join(' · ')}</div></div>
<div class="ar-right">${submitBtn}</div>
</div>
<div class="asgn-expand" id="asgn-exp-${a.id}">
<div class="ae-row">
<div class="ae-pills">
<span class="ae-pill">${lci('upload')} Загрузить работу</span>
${a.is_homework ? `<span class="ae-pill">${lci('book-open')} Домашнее задание</span>` : ''}
</div>
${!sub ? `<a class="ae-btn" href="/homework" onclick="event.stopPropagation()" style="background:var(--violet);text-decoration:none;color:#fff">Прикрепить файл</a>` : ''}
</div>
${buildSubmitStrip(a)}
</div></div>`;
}
/* ── File assignment ── */
if (a.file_id) {
const over = a.deadline && new Date(a.deadline) < new Date();
const metaParts = [classStr, SUBJ[a.subject_slug]||a.subject_slug, dl ? `до ${dl}` : null,
isUrgent ? `<span class="ar-tag-urgent"><i data-lucide="zap" style="width:10px;height:10px;vertical-align:-1px"></i> ${hoursLeft} ч</span>` : null].filter(Boolean);
return `<div class="asgn-wrap"><div class="asgn-row stagger-item${over?' over':isUrgent?' urgent':''}" style="--i:${idx};--ac:#05aab3" id="asgn-row-${a.id}" onclick="toggleAsgn(${a.id},event)">
<div class="ar-icon" style="background:rgba(6,214,224,0.1);color:#05aab3">${lci('paperclip')}</div>
<div class="ar-body"><div class="ar-title">${esc(a.title)}</div><div class="ar-meta">${metaParts.join(' · ')}</div></div>
<div class="ar-right">${buildSubChip(a)}<a class="ar-btn" href="${LS.downloadFileUrl(a.file_id)}" target="_blank" download onclick="event.stopPropagation()">Скачать</a></div>
</div>
<div class="asgn-expand" id="asgn-exp-${a.id}">
<div class="ae-row">
<div class="ae-pills">
<span class="ae-pill">${lci('file')} Файл для скачивания</span>
${a.file_title ? `<span class="ae-pill">${esc(a.file_title)}</span>` : ''}
</div>
<a class="ae-btn" href="${LS.downloadFileUrl(a.file_id)}" target="_blank" download onclick="event.stopPropagation()">Скачать файл</a>
</div>
${buildSubmitStrip(a)}
</div></div>`;
}
/* ── Textbook reading assignment ── */
if (a.textbook_id) {
const reqCount = a.textbook_required_count || 0;
const readCount = a.textbook_read_count || 0;
const allRead = !!a.textbook_all_read || !!a.completed_at;
const tbPct = reqCount > 0 ? Math.round(100 * readCount / reqCount) : 0;
const tbColorMap = { amber:'#d97706', blue:'#2563eb', green:'#059669', violet:'#7c3aed', pink:'#db2777' };
const tbColor = tbColorMap[a.textbook_color] || '#7c3aed';
const over = !allRead && a.deadline && new Date(a.deadline) < new Date();
const cardCls = allRead ? 'done' : over ? 'over' : isUrgent ? 'urgent' : '';
const parasText = a.textbook_paragraphs ? ${a.textbook_paragraphs}` : 'весь учебник';
const metaParts = [
classStr,
parasText,
a.is_homework ? `<span class="ar-tag-hw">ДЗ</span>` : null,
dl ? `до ${dl}` : null,
isUrgent ? `<span class="ar-tag-urgent"><i data-lucide="zap" style="width:10px;height:10px;vertical-align:-1px"></i> ${hoursLeft} ч</span>` : null,
over ? `<span class="ar-tag-over">просрочено</span>` : null,
].filter(Boolean);
// Find first required paragraph for deep-link
let firstHash = '';
if (a.textbook_paragraphs) {
const m = String(a.textbook_paragraphs).match(/^\s*(\d+)/);
if (m) firstHash = '#p' + m[1];
}
const openHref = `/textbook/${a.textbook_slug}${firstHash}`;
const actionBtn = allRead
? `<span class="ar-score hi" style="background:#06D6A018;color:#059669">${lci('check')} Прочитано</span>`
: `<a class="ar-btn" href="${openHref}" onclick="event.stopPropagation()" style="background:${tbColor};color:#fff;text-decoration:none">${readCount > 0 ? 'Продолжить' : 'Открыть'}</a>`;
return `<div class="asgn-wrap"><div class="asgn-row stagger-item ${cardCls}${isFirst && !allRead ? ' spotlight' : ''}" style="--i:${idx};--ac:${tbColor}" id="asgn-row-${a.id}" onclick="toggleAsgn(${a.id},event)">
<div class="ar-icon" style="background:${tbColor}18;color:${tbColor}">${lci('book-open-text')}</div>
<div class="ar-body"><div class="ar-title">${esc(a.title)}</div><div class="ar-meta">${metaParts.join(' · ')}</div></div>
<div class="ar-progress">
<div class="ar-prog-bar"><div class="ar-prog-fill" style="width:${tbPct}%;background:${tbColor}"></div></div>
<span class="ar-prog-text">${readCount} / ${reqCount} §</span>
</div>
<div class="ar-right">${actionBtn}</div>
</div>
<div class="asgn-expand" id="asgn-exp-${a.id}">
<div class="ae-row">
<div class="ae-pills">
<span class="ae-pill">${lci('book-open')} ${esc(a.textbook_title || 'Учебник')}</span>
<span class="ae-pill">${lci('layers')} ${parasText}</span>
${a.is_homework ? `<span class="ae-pill">${lci('book-open')} Домашнее задание</span>` : ''}
${allRead ? `<span class="ae-pill" style="color:#059669">${lci('check')} Завершено</span>` : ''}
</div>
<a class="ae-btn" href="${openHref}" onclick="event.stopPropagation()" style="background:${tbColor}">Открыть учебник</a>
</div>
</div></div>`;
}
/* ── Test assignment ── */
const isDone = a.session_status === 'completed';
const inProgress = a.session_status === 'in_progress';
const isRepeat = a.mode === 'repeat';
const maxAtt = a.max_attempts || 0;
const usedAtt = a.attempts_used ?? 0;
const attExhausted = maxAtt > 0 && usedAtt >= maxAtt;
const over = !isDone && !inProgress && a.deadline && new Date(a.deadline) < new Date();
const cardCls = attExhausted ? 'done' : (isDone && !isRepeat) ? 'done' : over ? 'over' : isUrgent ? 'urgent' : '';
const pct = a.percent;
const pctCls = pct >= 75 ? 'hi' : pct >= 50 ? 'mid' : 'lo';
const metaParts = [
classStr,
SUBJ[a.subject_slug] || a.subject_slug,
a.is_homework ? `<span class="ar-tag-hw">ДЗ</span>` : null,
dl ? `до ${dl}` : null,
isUrgent ? `<span class="ar-tag-urgent"><i data-lucide="zap" style="width:10px;height:10px;vertical-align:-1px"></i> ${hoursLeft} ч</span>` : null,
over && !isDone ? `<span class="ar-tag-over">просрочено</span>` : null
].filter(Boolean);
const scoreEl = (isDone && !isRepeat && pct !== null) ? `<span class="ar-score ${pctCls}">${pct}%</span>` : '';
const actionBtn = attExhausted
? `<span class="ar-tag-over" style="font-size:.73rem">Попытки исчерпаны</span>`
: (isDone && !isRepeat)
? `<a class="ar-btn-result" href="/test-result?session=${a.session_id}" onclick="event.stopPropagation()">Результат</a>`
: `<button class="ar-btn" onclick="startAssignment(event,${a.id},'${a.mode}')">${inProgress ? 'Продолжить' : isDone ? 'Повторить' : 'Начать'}</button>`;
/* Deadline urgency bar */
let dlBarHtml = '';
if (a.deadline && !isDone) {
const refMs = 7 * 24 * 3600 * 1000;
const fillPct = Math.min(100, Math.max(0, (1 - dlMs / refMs) * 100));
const barClr = fillPct >= 85 ? '#E83A1E' : fillPct >= 60 ? '#F59E0B' : '#06D6A0';
const daysLeft = dlMs > 0 ? Math.ceil(dlMs / 86400000) : null;
const dlText = over ? 'Просрочено' : daysLeft === 1 ? 'Остался 1 день' : daysLeft ? `${daysLeft} дн. до дедлайна` : 'Сегодня дедлайн';
dlBarHtml = `<div class="ae-dl">
<div class="ae-dl-label"><span>${dlText}</span><span>${dl}</span></div>
<div class="ae-dl-bar"><div class="ae-dl-fill" style="width:${fillPct}%;background:${barClr}"></div></div>
</div>`;
}
const expandActionBtn = attExhausted
? `<span style="font-size:.8rem;color:var(--text-3)">Лимит попыток исчерпан</span>`
: (isDone && !isRepeat)
? `<a class="ae-btn-result" href="/test-result?session=${a.session_id}" onclick="event.stopPropagation()">Смотреть результат</a>`
: `<button class="ae-btn" onclick="startAssignment(event,${a.id},'${a.mode}')">${inProgress ? 'Продолжить тест' : isDone ? 'Пройти снова' : 'Начать тест'}</button>`;
return `<div class="asgn-wrap"><div class="asgn-row stagger-item ${cardCls}${isFirst && !isDone ? ' spotlight' : ''}" style="--i:${idx};--ac:${sColor}" id="asgn-row-${a.id}" onclick="toggleAsgn(${a.id},event)">
<div class="ar-icon" style="background:${sColor}18;color:${sColor}">${lci(a.is_homework ? 'book-open' : iconName)}</div>
<div class="ar-body"><div class="ar-title">${esc(a.title)}</div><div class="ar-meta">${metaParts.join(' · ')}</div></div>
<div class="ar-right">${buildSubChip(a)}${scoreEl}${actionBtn}</div>
</div>
<div class="asgn-expand" id="asgn-exp-${a.id}">
<div class="ae-row">
<div class="ae-pills">
<span class="ae-pill">${lci('layers')} ${MODES[a.mode]||a.mode}</span>
<span class="ae-pill">${lci('list')} ${a.count} вопросов</span>
${a.is_homework ? `<span class="ae-pill">${lci('book-open')} Домашнее задание</span>` : ''}
${inProgress ? `<span class="ae-pill" style="color:#059652">${lci('play')} Начат</span>` : ''}
${maxAtt > 0 ? `<span class="ae-pill" style="color:${attExhausted?'#E83A1E':'var(--text-2)'}">${lci('repeat')} ${usedAtt}/${maxAtt} поп.</span>` : (usedAtt > 0 ? `<span class="ae-pill">${lci('repeat')} ${usedAtt} поп.</span>` : '')}
</div>
${expandActionBtn}
</div>
${dlBarHtml}
${buildSubmitStrip(a)}
</div></div>`;
}
function toggleAsgn(id, e) {
if (e.target.closest('a,button')) return;
const row = document.getElementById('asgn-row-' + id);
const exp = document.getElementById('asgn-exp-' + id);
if (!row || !exp) return;
const isOpen = exp.classList.contains('open');
document.querySelectorAll('.asgn-expand.open').forEach(el => {
el.classList.remove('open');
el.previousElementSibling?.classList.remove('expanded');
});
if (!isOpen) { exp.classList.add('open'); row.classList.add('expanded'); }
}
let _subjFilter = '';
function setSubjFilter(slug) {
_subjFilter = _subjFilter === slug ? '' : slug;
document.querySelectorAll('.sf-chip').forEach(c => c.classList.toggle('active', c.dataset.slug === _subjFilter));
if (_subjFilter) {
const chip = document.querySelector(`.sf-chip[data-slug="${_subjFilter}"]`);
if (chip) {
const color = SUBJ_COLORS[_subjFilter] || '#9B5DE5';
document.querySelectorAll('.sf-chip').forEach(c => c.style.background = '');
chip.style.background = color;
}
} else {
document.querySelectorAll('.sf-chip').forEach(c => c.style.background = '');
}
renderAssignmentsList();
}
function buildSubjChips(list) {
const slugs = [...new Set(list.map(a => a.subject_slug).filter(Boolean))];
const row = document.getElementById('subj-filter-row');
if (slugs.length < 2) { row.style.display = 'none'; return; }
row.style.display = 'flex';
row.innerHTML = slugs.map(slug => {
const color = SUBJ_COLORS[slug] || '#9B5DE5';
return `<button class="sf-chip" data-slug="${slug}" onclick="setSubjFilter('${slug}')" style="color:${color};border-color:${color}33">
${lci(ICONS[slug] || 'book')} ${SUBJ[slug] || slug}
</button>`;
}).join('');
reIcons();
}
function renderAssignmentsList() {
const now = new Date();
let list = _allAssignmentsList;
// Subject filter
if (_subjFilter) list = list.filter(a => a.subject_slug === _subjFilter);
// B2: Live search filter
if (_searchQuery) list = list.filter(a =>
(a.title || '').toLowerCase().includes(_searchQuery) ||
(SUBJ[a.subject_slug] || '').toLowerCase().includes(_searchQuery) ||
(a.class_name || '').toLowerCase().includes(_searchQuery)
);
// For teacher: sorted by deadline, flat
if (isTeacher) {
const sorted = [...list].sort((a, b) => urgencyScore(a) - urgencyScore(b));
document.getElementById('assignments-list').innerHTML =
`<div class="tests-list">${sorted.map((a, i) => buildAssignCard(a, i)).join('')}</div>`;
reIcons(); return;
}
// Classify (active/overdue/done) — тип и «сдано» из общего модуля AssignmentUtils.
function classify(a) {
const t = AssignmentUtils.type(a);
if (t === 'textbook') {
if (AssignmentUtils.isDone(a)) return 'done';
if (a.deadline && new Date(a.deadline) < now) return 'overdue';
return 'active';
}
if (t === 'test') {
if (AssignmentUtils.isDone(a)) return 'done';
if (a.deadline && new Date(a.deadline) < now && a.session_status !== 'in_progress') return 'overdue';
return 'active';
}
// upload / file: «сдано» по сабмишену здесь не считаем (как и раньше — статус
// показывает чип сдачи в карточке); upload просрочивается по дедлайну, file — всегда активен.
if (t === 'upload' && a.deadline && new Date(a.deadline) < now) return 'overdue';
return 'active';
}
const cActive = list.filter(a => classify(a) === 'active').sort((a,b) => urgencyScore(a) - urgencyScore(b));
const cOverdue = list.filter(a => classify(a) === 'overdue');
const cDone = list.filter(a => classify(a) === 'done');
// Stats
const statParts = [];
if (cActive.length) statParts.push(`<span class="s-active">${cActive.length} активных</span>`);
if (cOverdue.length) statParts.push(`<span class="s-overdue">${cOverdue.length} просрочено</span>`);
if (cDone.length) statParts.push(`<span class="s-done">${cDone.length} сдано</span>`);
document.getElementById('assign-stats').innerHTML = statParts.join('<span style="color:#DDE2EC"> · </span>');
// Status filter
let filtered;
if (_assignFilter === 'active') filtered = [...cActive, ...cOverdue];
else if (_assignFilter === 'done') filtered = cDone;
else filtered = null; // grouped
if (filtered !== null) {
if (!filtered.length) {
document.getElementById('assignments-list').innerHTML = `<div class="empty" style="padding:20px 0">Нет заданий в этой категории.</div>`;
return;
}
document.getElementById('assignments-list').innerHTML =
`<div class="tests-list">${filtered.map((a, i) => buildAssignCard(a, i, i === 0)).join('')}</div>`;
reIcons(); return;
}
// "Все": grouped view
const todoList = [...cActive, ...cOverdue];
const doneList = cDone;
let html = '';
if (todoList.length) {
html += `<div class="assign-group-hdr grp-todo" id="grp-hdr-todo" onclick="toggleAssignGroup('todo')">
Нужно выполнить <span class="grp-count">${todoList.length}</span>
<span class="grp-arrow"><svg class="ic" viewBox="0 0 24 24"><polyline points="2 9 12 21 22 9"/></svg></span>
</div>
<div class="tests-list assign-group-body" id="grp-body-todo">
${todoList.map((a, i) => buildAssignCard(a, i, i === 0)).join('')}
</div>`;
}
if (doneList.length) {
const startCollapsed = todoList.length > 0;
html += `<div class="assign-group-hdr grp-done${startCollapsed?' collapsed':''}" id="grp-hdr-done" onclick="toggleAssignGroup('done')" style="margin-top:${todoList.length?18:0}px">
Выполнено <span class="grp-count">${doneList.length}</span>
<span class="grp-arrow"><svg class="ic" viewBox="0 0 24 24"><polyline points="2 9 12 21 22 9"/></svg></span>
</div>
<div class="tests-list assign-group-body${startCollapsed?' collapsed':''}" id="grp-body-done">
${doneList.map((a, i) => buildAssignCard(a, todoList.length + i)).join('')}
</div>`;
}
if (!html) html = `<div class="empty" style="padding:20px 0">Заданий нет.</div>`;
document.getElementById('assignments-list').innerHTML = html;
reIcons();
}
async function startAssignment(e, id, mode) {
const btn = e.currentTarget;
btn.disabled = true; btn.textContent = '…';
try {
const r = await LS.startAssignment(id);
if (r.error && r.max_attempts) {
LS.toast(`Исчерпан лимит попыток (${r.attempts_used}/${r.max_attempts})`, 'warn');
btn.disabled = false; btn.textContent = 'Начать';
return;
}
const aMode = r.assignment_mode || mode || 'exam';
if (r.status === 'completed' && aMode !== 'repeat') {
window.location.href = `/test-result?session=${r.session_id}`;
} else {
window.location.href = `/test-run?session=${r.session_id}&assignment_mode=${aMode}`;
}
} catch (err) {
if (err.message?.includes('лимит попыток') || err.message?.includes('Исчерпан')) {
LS.toast(err.message, 'warn');
} else {
LS.toast('Ошибка: ' + err.message, 'error');
}
btn.disabled = false; btn.textContent = 'Начать';
}
}
/* ══ ИСТОРИЯ ══════════════════════════════════════════════════════════ */
let _histPage = 1, _histTotal = 0, _histList = null;
const HIST_LIMIT = 15;
function histRingSvg(pct, pc) {
const colorMap = { hi: 'var(--green)', mid: 'var(--amber)', lo: 'var(--pink)' };
const color = colorMap[pc] || 'var(--text-3)';
const circ = 106.8;
const dash = (pct / 100 * circ).toFixed(1);
return `<svg class="hist-ring" width="48" height="48" viewBox="0 0 48 48">
<circle cx="24" cy="24" r="17" fill="none" stroke="rgba(15,23,42,0.08)" stroke-width="4"/>
<circle cx="24" cy="24" r="17" fill="none" stroke="${color}" stroke-width="4"
stroke-dasharray="${dash} ${circ}" stroke-dashoffset="26.7" stroke-linecap="round"
transform="rotate(-90 24 24)"/>
<text x="24" y="28" text-anchor="middle" font-family="Unbounded,sans-serif" font-size="8" font-weight="800" fill="${color}">${pct}%</text>
</svg>`;
}
function histItemHtml(h, i = 0) {
const pct = h.score !== null ? Math.round((h.score / h.total) * 100) : null;
const pc = pct === null ? '' : pct >= 75 ? 'hi' : pct >= 50 ? 'mid' : 'lo';
const href = h.status === 'completed' ? `/test-result?session=${h.id}` : '#';
const ring = pct !== null
? histRingSvg(pct, pc)
: `<div class="hist-pct" style="width:48px;text-align:center;color:var(--text-3)">—</div>`;
return `<a class="hist-item stagger-item" style="--i:${i}" href="${href}">
${ring}
<div class="hist-info">
<div class="hist-subj">${esc(h.subject_name || 'Тест')}</div>
<div class="hist-meta">${MODES[h.mode] || h.mode}</div>
</div>
<div class="hist-score">${h.score ?? '—'} / ${h.total}</div>
</a>`;
}
function histGroupedHtml(rows) {
const groups = {};
rows.forEach(h => {
const key = parseDate(h.started_at).toLocaleDateString('ru', { day:'numeric', month:'long', year:'numeric' });
(groups[key] = groups[key] || []).push(h);
});
let idx = 0;
return Object.entries(groups).map(([date, items]) =>
`<div class="hist-date-sep">${date}</div>` + items.map(h => histItemHtml(h, idx++)).join('')
).join('');
}
async function loadHistory() {
const wrap = document.getElementById('history-wrap');
try {
const data = await LS.getHistory(1, HIST_LIMIT);
_histPage = 1;
_histTotal = data.total;
if (!data.rows.length) {
wrap.innerHTML = `<div class="rich-empty">
<svg class="rich-empty-svg" width="110" height="84" viewBox="0 0 110 84" fill="none">
<circle cx="55" cy="40" r="24" fill="rgba(155,93,229,0.07)" stroke="#9B5DE5" stroke-width="1.8"/>
<line x1="55" y1="40" x2="55" y2="23" stroke="#9B5DE5" stroke-width="2.5" stroke-linecap="round"/>
<line x1="55" y1="40" x2="67" y2="47" stroke="#06D6E0" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="55" cy="40" r="3" fill="#9B5DE5"/>
<circle cx="55" cy="40" r="8" fill="none" stroke="rgba(155,93,229,0.2)" stroke-width="1"/>
</svg>
<div class="rich-empty-title">История пуста</div>
<div class="rich-empty-sub">Пройди первый тест, и здесь появятся твои результаты</div>
</div>`;
return;
}
_histList = document.createElement('div');
_histList.className = 'history-list';
_histList.innerHTML = histGroupedHtml(data.rows);
wrap.innerHTML = '';
wrap.appendChild(_histList);
document.getElementById('history-more-wrap').style.display =
data.rows.length < data.total ? '' : 'none';
} catch { wrap.innerHTML = '<div class="empty">Не удалось загрузить историю</div>'; }
}
async function loadMoreHistory() {
const btn = document.getElementById('history-more-btn');
btn.disabled = true; btn.textContent = 'Загрузка…';
try {
const data = await LS.getHistory(_histPage + 1, HIST_LIMIT);
_histPage++;
const frag = document.createRange().createContextualFragment(histGroupedHtml(data.rows));
_histList.appendChild(frag);
const shown = _histPage * HIST_LIMIT;
document.getElementById('history-more-wrap').style.display = shown < _histTotal ? '' : 'none';
} catch { LS.toast('Не удалось загрузить историю', 'error'); }
finally { btn.disabled = false; btn.textContent = 'Загрузить ещё'; }
}
/* ══ ПРОГРЕСС TAB ════════════════════════════════════════════════════ */
// Chart.js: text in center of doughnut
const centerLabelPlugin = {
id: 'centerLabel',
beforeDraw(chart) {
const opts = chart.config.options.plugins.centerLabel;
if (!opts?.text) return;
const { ctx, chartArea: { left, right, top, bottom } } = chart;
const cx = (left + right) / 2, cy = (top + bottom) / 2;
ctx.save();
ctx.font = `900 ${opts.size || 18}px 'Unbounded', sans-serif`;
ctx.fillStyle = opts.color || '#0F172A';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(opts.text, cx, cy);
ctx.restore();
}
};
if (typeof Chart !== 'undefined') Chart.register(centerLabelPlugin);
async function loadProgress() {
const wrap = document.getElementById('progress-wrap');
wrap.innerHTML = '<div class="spinner"></div>';
try {
const [histData, weakTopics, courses] = await Promise.all([
LS.getHistory(1, 200),
LS.getWeakTopics(),
LS.api('/api/courses').catch(() => []),
]);
const rows = histData.rows || [];
wrap.innerHTML = '';
// Theory progress section
const activeCourses = (Array.isArray(courses) ? courses : [])
.filter(c => c.lessonCount > 0 && c.doneCount < c.lessonCount);
if (activeCourses.length > 0) {
renderTheoryProgress(wrap, activeCourses);
}
if (!rows.length) {
wrap.innerHTML += `<div class="rich-empty" style="margin-top:20px">
<svg class="rich-empty-svg" width="120" height="90" viewBox="0 0 120 90" fill="none">
<rect x="16" y="56" width="16" height="20" rx="3" fill="rgba(155,93,229,0.15)" stroke="#9B5DE5" stroke-width="1.5"/>
<rect x="40" y="40" width="16" height="36" rx="3" fill="rgba(155,93,229,0.25)" stroke="#9B5DE5" stroke-width="1.5"/>
<rect x="64" y="28" width="16" height="48" rx="3" fill="rgba(155,93,229,0.4)" stroke="#9B5DE5" stroke-width="1.5"/>
<rect x="88" y="16" width="16" height="60" rx="3" fill="rgba(155,93,229,0.6)" stroke="#9B5DE5" stroke-width="1.5"/>
<line x1="10" y1="76" x2="115" y2="76" stroke="rgba(15,23,42,0.18)" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="rich-empty-title">Нет данных по тестам</div>
<div class="rich-empty-sub">Пройди несколько тестов — и здесь появится твоя статистика прогресса</div>
</div>`;
if (!activeCourses.length) return;
reIcons();
return;
}
renderHeatmap(wrap, rows);
renderTrendChart(wrap, rows);
renderSubjectCharts(wrap, rows);
renderWeakInProgress(wrap, weakTopics);
} catch (e) {
wrap.innerHTML = `<div class="empty">Ошибка загрузки: ${esc(e.message)}</div>`;
}
}
function renderTheoryProgress(container, courses) {
const SUBJ_LABEL = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика', other:'Задание' };
const div = document.createElement('div');
div.innerHTML = `
<div class="chart-section-title">Теория — в процессе</div>
<div class="theory-courses-grid">
${courses.slice(0, 6).map(c => {
const pct = c.lessonCount > 0 ? Math.round(c.doneCount / c.lessonCount * 100) : 0;
const subj = SUBJ_LABEL[c.subjectSlug] || c.subjectSlug || '';
return `<a class="theory-course-card" href="/course?id=${c.id}">
<div class="tc-header">
<span class="tc-emoji">${c.coverEmoji || LS.icon('book-open',20)}</span>
<div class="tc-info">
<div class="tc-title">${esc(c.title)}</div>
${subj ? `<div class="tc-subj">${esc(subj)}</div>` : ''}
</div>
</div>
<div class="tc-progress">
<div class="tc-bar"><div class="tc-fill" style="width:${pct}%"></div></div>
<span class="tc-pct">${pct}%</span>
</div>
<div class="tc-meta">${c.doneCount} / ${c.lessonCount} уроков</div>
</a>`;
}).join('')}
</div>`;
container.appendChild(div);
reIcons();
}
function renderHeatmap(container, rows) {
const title = document.createElement('div');
title.className = 'chart-section-title';
title.textContent = 'Активность';
container.appendChild(title);
const section = document.createElement('div');
section.className = 'heatmap-section';
// Build day-count map for past 364 days (52 weeks)
const today = new Date(); today.setHours(0,0,0,0);
const dayCounts = {};
rows.forEach(r => {
const d = parseDate(r.started_at); d.setHours(0,0,0,0);
const key = d.toISOString().slice(0,10);
dayCounts[key] = (dayCounts[key] || 0) + 1;
});
// Start from 52 weeks ago, aligned to Sunday of that week
const startDay = new Date(today);
startDay.setDate(today.getDate() - 364);
// align to nearest past Sunday
startDay.setDate(startDay.getDate() - startDay.getDay());
const grid = document.createElement('div');
grid.className = 'heatmap-grid';
for (let col = 0; col < 52; col++) {
for (let row = 0; row < 7; row++) {
const d = new Date(startDay);
d.setDate(startDay.getDate() + col * 7 + row);
const key = d.toISOString().slice(0,10);
const n = dayCounts[key] || 0;
const lvl = n === 0 ? 0 : n === 1 ? 1 : n <= 3 ? 2 : n <= 5 ? 3 : 4;
const cell = document.createElement('div');
cell.className = 'hm-cell';
if (lvl > 0) cell.dataset.n = lvl;
cell.title = `${d.toLocaleDateString('ru',{day:'numeric',month:'short'})}: ${n} ${n===1?'тест':'тестов'}`;
grid.appendChild(cell);
}
}
section.appendChild(grid);
const legend = document.createElement('div');
legend.className = 'heatmap-legend';
legend.innerHTML = `<span>Меньше</span>
<div class="hm-leg" style="background:rgba(15,23,42,0.06)"></div>
<div class="hm-leg" data-n="1"></div>
<div class="hm-leg" data-n="2"></div>
<div class="hm-leg" data-n="3"></div>
<div class="hm-leg" data-n="4"></div>
<span>Больше</span>`;
legend.querySelectorAll('[data-n]').forEach(el => {
const n = el.dataset.n;
el.style.background = `rgba(155,93,229,${n*0.22})`;
});
section.appendChild(legend);
container.appendChild(section);
}
function renderTrendChart(container, rows) {
const completed = rows
.filter(r => r.score !== null && r.total > 0)
.slice(0, 20)
.reverse();
if (!completed.length) return;
const title = document.createElement('div');
title.className = 'chart-section-title';
title.textContent = 'Динамика результатов';
container.appendChild(title);
const wrap = document.createElement('div');
wrap.className = 'trend-wrap';
const cWrap = document.createElement('div');
cWrap.className = 'trend-canvas-wrap';
const canvas = document.createElement('canvas');
cWrap.appendChild(canvas);
wrap.appendChild(cWrap);
container.appendChild(wrap);
new Chart(canvas, {
type: 'line',
data: {
labels: completed.map(r =>
parseDate(r.started_at).toLocaleDateString('ru', { day: 'numeric', month: 'short' })
),
datasets: [{
label: '%',
data: completed.map(r => Math.round(r.score / r.total * 100)),
borderColor: '#9B5DE5',
backgroundColor: 'rgba(155,93,229,0.07)',
borderWidth: 2.5,
pointRadius: 5,
pointHoverRadius: 7,
pointBackgroundColor: '#9B5DE5',
pointBorderColor: '#fff',
pointBorderWidth: 2,
fill: true,
tension: 0.4,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: { label: ctx => ` ${ctx.raw}%` },
bodyFont: { family: 'Manrope' },
titleFont: { family: 'Manrope' },
}
},
scales: {
y: {
min: 0, max: 100,
grid: { color: 'rgba(15,23,42,0.05)' },
ticks: { callback: v => v + '%', font: { family: 'Manrope', size: 11 }, color: '#56687A' }
},
x: {
grid: { display: false },
ticks: { font: { family: 'Manrope', size: 11 }, color: '#56687A' }
}
}
}
});
}
function renderSubjectCharts(container, rows) {
const bySubj = {};
rows.forEach(r => {
if (!r.subject_slug) return;
if (!bySubj[r.subject_slug]) bySubj[r.subject_slug] = { name: r.subject_name, rows: [] };
bySubj[r.subject_slug].rows.push(r);
});
if (!Object.keys(bySubj).length) return;
const title = document.createElement('div');
title.className = 'chart-section-title';
title.textContent = 'По предметам';
container.appendChild(title);
const grid = document.createElement('div');
grid.className = 'subj-charts-grid';
container.appendChild(grid);
Object.entries(bySubj).forEach(([slug, data]) => {
const done = data.rows.filter(r => r.score !== null && r.total > 0);
const avg = done.length
? Math.round(done.reduce((s, r) => s + r.score / r.total * 100, 0) / done.length)
: 0;
const color = SUBJ_COLORS[slug] || '#9B5DE5';
const avgCls = avg >= 75 ? 'c-green' : avg >= 50 ? 'c-amber' : 'c-pink';
const card = document.createElement('div');
card.className = 'subj-chart-card';
card.innerHTML = `
<div class="subj-chart-name">${esc(data.name || SUBJ[slug] || slug)}</div>
<div class="canvas-wrap"><canvas id="chart-${slug}"></canvas></div>
<div class="subj-chart-sessions">${done.length} сессий</div>`;
grid.appendChild(card);
const canvas = card.querySelector('canvas');
new Chart(canvas, {
type: 'doughnut',
data: {
datasets: [{
data: [avg, 100 - avg],
backgroundColor: [color, 'rgba(15,23,42,0.06)'],
borderWidth: 0,
borderRadius: 6,
}]
},
options: {
responsive: true,
cutout: '70%',
plugins: {
legend: { display: false },
tooltip: { enabled: false },
centerLabel: { text: avg + '%', size: 16, color: color }
},
animation: { duration: 900, easing: 'easeInOutQuart' }
}
});
});
}
function renderWeakInProgress(container, topics) {
const title = document.createElement('div');
title.className = 'chart-section-title';
title.textContent = 'Темы, требующие внимания';
container.appendChild(title);
if (!topics.length) {
const el = document.createElement('div');
el.className = 'empty';
el.style.padding = '20px 0';
el.innerHTML = 'Слабых тем не обнаружено <i data-lucide="party-popper" style="width:15px;height:15px;vertical-align:-3px"></i>';
container.appendChild(el);
return;
}
const list = document.createElement('div');
list.className = 'weak-list';
list.innerHTML = topics.map(t => `
<div class="weak-item" onclick="window.location.href='/test-run?subject=${t.subject_slug}&mode=exam&count=15&topic=${t.topic_id}'">
<div class="weak-bar-wrap">
<div class="weak-name">${esc(t.topic)}</div>
<div class="weak-meta">${esc(t.subject_name)} · ${t.wrong} из ${t.total} неверно</div>
<div class="weak-bar"><div class="weak-fill" style="width:${t.error_pct}%"></div></div>
</div>
<div class="weak-pct">${t.error_pct}%</div>
</div>`).join('');
container.appendChild(list);
}
/* ══ УВЕДОМЛЕНИЯ — handled by notifications.js ══ */
/* ══ JOIN MODAL ════════════════════════════════════════════════════════ */
let _joinModal = null;
function openJoinModal(code) {
const body = `
<input class="form-input" id="join-code" placeholder="Код приглашения" value="${LS.esc(code || '')}" />
<div style="font-size:0.78rem;color:var(--text-3);margin-top:8px">Попросите учителя поделиться кодом или ссылкой</div>`;
_joinModal = LS.modal({
title: 'Вступить в класс', content: body, size: 'sm',
actions: [
{ label: 'Отмена', onClick: () => _joinModal.close() },
{ label: 'Вступить', primary: true, id: 'btn-do-join', onClick: doJoin },
],
});
}
async function doJoin() {
const code = document.getElementById('join-code').value.trim();
if (!code) return;
const btn = document.getElementById('btn-do-join');
btn.disabled = true;
try {
const r = await LS.joinClass(code);
_joinModal?.close();
LS.toast(`Вы вступили в класс «${r.class_name}»!`, 'success');
loadAssignments();
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); btn.disabled = false; }
}
/* ══ SUBMISSIONS (student) ════════════════════════════════════════════ */
let _mySubmissions = new Map(); // assignment_id -> latest submission
let _allSubmissions = []; // full list for "My submissions" widget
const SUB_STATUS = {
new: { label: 'На проверке', icon: 'clock', cls: 'new' },
resubmitted: { label: 'Повторно', icon: 'refresh-cw', cls: 'resubmitted' },
reviewed: { label: 'Проверено', icon: 'check-circle', cls: 'reviewed' },
accepted: { label: 'Принято', icon: 'check-circle', cls: 'accepted' },
revision: { label: 'На доработке', icon: 'alert-circle', cls: 'revision' },
};
async function loadMySubmissions() {
if (isTeacher) return;
try {
const rows = await LS.getMySubmissions();
_allSubmissions = rows;
_mySubmissions.clear();
rows.forEach(r => { if (r.assignment_id) _mySubmissions.set(r.assignment_id, r); });
renderMySubsWidget();
} catch (e) { console.error('[submissions]', e.message); renderMySubsWidget(); }
}
/* Mini status chip for collapsed assignment card */
function buildSubChip(a) {
if (isTeacher || !a.class_id) return '';
const sub = _mySubmissions.get(a.id);
if (sub) {
const st = SUB_STATUS[sub.status] || SUB_STATUS.new;
const gradeStr = sub.grade != null ? ` ${sub.grade}` : '';
return `<span class="ar-sub-chip s-${st.cls}">${lci(st.icon,'width:11px;height:11px')} ${st.label}${gradeStr}</span>`;
}
if (a.is_homework) return `<span class="ar-sub-chip s-none">${lci('upload','width:11px;height:11px')} Сдать</span>`;
return '';
}
function buildSubmitStrip(a) {
if (isTeacher || !a.class_id) return '';
const sub = _mySubmissions.get(a.id);
if (sub) {
const st = SUB_STATUS[sub.status] || SUB_STATUS.new;
const noteHtml = sub.teacher_note
? `<span class="ae-submit-note">"${esc(sub.teacher_note)}"</span>`
: '';
const gradeHtml = sub.grade != null ? (() => {
const gcls = sub.grade >= 80 ? 'high' : sub.grade >= 50 ? 'mid' : 'low';
const letter = sub.grade>=90?'A':sub.grade>=75?'B':sub.grade>=60?'C':sub.grade>=40?'D':'F';
return `<span class="ae-grade-badge ${gcls}">${sub.grade} <span style="opacity:.7">${letter}</span></span>`;
})() : '';
const canDelete = !['reviewed','accepted'].includes(sub.status);
const delBtn = canDelete
? `<button class="ae-btn-delete-sub" onclick="doDeleteSubmission(event,${sub.id},${a.id})">Отозвать</button>`
: '';
const resubBtn = sub.status === 'revision'
? `<a class="ae-btn-submit" href="/homework" onclick="event.stopPropagation()" style="text-decoration:none">Пересдать</a>`
: '';
return `<div class="ae-submit-row">
<span class="ae-submit-label">Работа:</span>
<span class="ae-submit-status ${st.cls}">${lci(st.icon,'width:14px;height:14px')} ${st.label}</span>
${gradeHtml}
<span style="font-size:0.72rem;color:var(--text-3);flex:1">${esc(sub.original_name)}</span>
${noteHtml}
${resubBtn}
${delBtn}
</div>`;
}
return `<div class="ae-submit-row">
<span class="ae-submit-label">Сдача работы:</span>
<a class="ae-btn-submit" href="/homework" onclick="event.stopPropagation()" style="text-decoration:none">
Прикрепить файл
</a>
</div>`;
}
/* Compact "My submissions" widget */
function renderMySubsWidget() {
const wrap = document.getElementById('w-my-subs');
const list = document.getElementById('my-subs-list');
if (!wrap || !list || isTeacher) return;
if (!_allSubmissions.length) {
list.innerHTML = `<div style="padding:18px 0;text-align:center;color:var(--text-3);font-size:0.82rem">
${lci('file-plus','width:24px;height:24px;opacity:0.4;display:block;margin:0 auto 8px')}
Сданных работ пока нет.<br>
<span style="font-size:0.74rem">Когда учитель назначит задание с прикреплением файла, вы сможете сдать его прямо здесь.</span>
</div>`;
showWidget('w-my-subs');
reIcons();
return;
}
const recent = _allSubmissions.slice(0, 5);
list.innerHTML = recent.map(s => {
const st = SUB_STATUS[s.status] || SUB_STATUS.new;
const gradeHtml = s.grade != null ? (() => {
const gcls = s.grade >= 80 ? 'high' : s.grade >= 50 ? 'mid' : 'low';
return `<span class="ms-grade" style="color:${gcls==='high'?'#059652':gcls==='mid'?'#c07c00':'#c0306a'}">${s.grade}</span>`;
})() : '';
return `<div class="ms-row">
<span class="ae-submit-status ${st.cls}" style="flex-shrink:0">${lci(st.icon,'width:12px;height:12px')} ${st.label}</span>
<span class="ms-title">${esc(s.assignment_title || s.original_name)}</span>
<span class="ms-file">${esc(s.original_name)}</span>
${gradeHtml}
</div>`;
}).join('');
showWidget('w-my-subs');
reIcons();
}
async function doDeleteSubmission(e, subId, assignId) {
e.stopPropagation();
const ok = await LS.confirm('Отозвать отправленную работу?', { confirmText: 'Отозвать', danger: true });
if (!ok) return;
try {
await LS.deleteSubmission(subId);
_mySubmissions.delete(assignId);
LS.toast('Работа отозвана', 'info');
renderAssignmentsList();
} catch (err) {
LS.toast('Ошибка: ' + err.message, 'error');
}
}
/* ══ ACTION BANNER (replaces loadDeadlineWidget) ══════════════════ */
function loadActionBanner(assignments) {
const banner = document.getElementById('action-banner');
if (!banner) return;
const threeDays = 3 * 24 * 3600 * 1000;
const upcoming = (assignments || [])
.filter(a => a.deadline && !a.session_status && new Date(a.deadline) > new Date())
.sort((a, b) => new Date(a.deadline) - new Date(b.deadline));
const urgent = upcoming.filter(a => (new Date(a.deadline) - Date.now()) < threeDays);
if (urgent.length) {
const a = urgent[0];
const dl = new Date(a.deadline);
const diffMs = dl - Date.now();
const days = Math.floor(diffMs / 86400000);
const hours = Math.floor((diffMs % 86400000) / 3600000);
const countdownText = days > 0 ? days : hours;
const countdownSub = days > 0 ? (days === 1 ? 'день' : days < 5 ? 'дня' : 'дней') : (hours === 1 ? 'час' : hours < 5 ? 'часа' : 'часов');
banner.className = 'action-banner ab-urgent';
banner.style.display = '';
banner.innerHTML = `
<span class="ab-icon"><svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2 2"/><path d="M5 3 2 6m20 0-3-3"/></svg></span>
<div class="ab-body">
<div class="ab-title">${esc(a.title)}</div>
<div class="ab-sub">${SUBJ[a.subject_slug] || ''} · до ${dl.toLocaleDateString('ru',{day:'numeric',month:'short'})}</div>
</div>
<div class="ab-countdown">
<div class="ab-countdown-val">${countdownText}</div>
<div class="ab-countdown-label">${countdownSub}</div>
</div>`;
} else {
banner.style.display = 'none';
}
}
/* ══ HERO: Reading card — данные и цвет из блока «Учебники» ═══════ */
// Палитра обложек учебников (зеркало .tb-cover из textbooks.html)
const TB_COVER = {
amber:'linear-gradient(135deg,#b45309 0%,#d97706 60%,#f59e0b 100%)',
blue:'linear-gradient(135deg,#1e40af 0%,#2563eb 60%,#3b82f6 100%)',
green:'linear-gradient(135deg,#047857 0%,#059669 60%,#10b981 100%)',
violet:'linear-gradient(135deg,#6d28d9 0%,#7c3aed 60%,#9333ea 100%)',
pink:'linear-gradient(135deg,#be185d 0%,#db2777 60%,#ec4899 100%)',
indigo:'linear-gradient(135deg,#3730a3 0%,#4f46e5 60%,#818cf8 100%)',
rose:'linear-gradient(135deg,#9f1239 0%,#e11d48 60%,#fb7185 100%)',
teal:'linear-gradient(135deg,#134e4a 0%,#0d9488 60%,#14b8a6 100%)',
cyan:'linear-gradient(135deg,#164e63 0%,#0891b2 60%,#22d3ee 100%)',
emerald:'linear-gradient(135deg,#064e3b 0%,#059669 60%,#34d399 100%)',
'amber-light':'linear-gradient(135deg,#92400e 0%,#d97706 60%,#fbbf24 100%)',
sky:'linear-gradient(135deg,#0c4a6e 0%,#0284c7 60%,#7dd3fc 100%)',
red:'linear-gradient(135deg,#7f1d1d 0%,#dc2626 60%,#f87171 100%)',
orange:'linear-gradient(135deg,#9a3412 0%,#ea580c 60%,#fb923c 100%)',
yellow:'linear-gradient(135deg,#854d0e 0%,#ca8a04 60%,#fde047 100%)',
};
function _renderReadCard(o) {
const card = document.getElementById('hc-read');
if (!card) return;
if (o.href) card.href = o.href;
if (TB_COVER[o.color]) card.style.background = TB_COVER[o.color];
document.getElementById('hc-read-tag').textContent = o.tag;
document.getElementById('hc-read-title').textContent = o.title;
document.getElementById('hc-read-sub').textContent = o.sub || '';
document.getElementById('hc-read-meta').textContent = o.meta || '';
const pw = document.getElementById('hc-read-prog-wrap');
const pe = document.getElementById('hc-read-pct');
if (o.showProg) {
if (pw) { pw.style.display = ''; document.getElementById('hc-read-prog').style.width = (o.pct || 0) + '%'; }
if (pe) { pe.style.display = ''; pe.textContent = '· ' + (o.pct || 0) + '%'; }
} else {
if (pw) pw.style.display = 'none';
if (pe) pe.style.display = 'none';
}
}
async function loadContinueWidget() {
const card = document.getElementById('hc-read');
if (!card) return;
let books;
try {
const r = await LS.api('/api/textbooks');
books = r && r.textbooks ? r.textbooks : [];
} catch { return; } // нет доступа/ошибка — оставляем дефолт «Учебники»
if (!books.length) return;
// Выбор учебника: тот, что в процессе (есть прочитанные §), приоритет
// последнему открытому; иначе — первый из каталога как рекомендованный.
const withProgress = books
.filter(b => (b.progress && (b.progress.last_para || (b.progress.read || []).length)))
.sort((a, b) => ((b.progress.read || []).length) - ((a.progress.read || []).length));
const inProgress = withProgress[0];
const b = inProgress || books[0];
const readCount = (b.progress && b.progress.read ? b.progress.read.length : 0);
const pct = b.para_count ? Math.round(100 * readCount / b.para_count) : 0;
const href = (b.progress && b.progress.last_para)
? `/textbook/${b.slug}#${b.progress.last_para}`
: `/textbook/${b.slug}`;
if (inProgress) {
_renderReadCard({
tag: 'Продолжить чтение', href, color: b.color,
title: b.title,
sub: b.description || `${b.grade} класс`,
meta: `${readCount} из ${b.para_count} § прочитано`,
pct, showProg: true,
});
} else {
_renderReadCard({
tag: 'Начать чтение', href, color: b.color,
title: b.title,
sub: b.description || `${b.grade} класс`,
meta: `${b.para_count} § · новый учебник`,
pct: 0, showProg: false,
});
}
}
/* ══ HERO: Lab of the day (deterministic daily pick) ═════════════ */
const LAB_OF_DAY = [
{ key:'isoprocess', href:'/lab?sim=molphys', title:'Газовые законы', sub:'Давление, объём и температура газа.', subj:'Физика', time:'~10 мин', level:'средне', goal:'уравнение состояния газа' },
{ key:'opticsbench', href:'/lab?sim=opticsbench', title:'Оптическая скамья', sub:'Собери систему линз и проследи ход лучей.', subj:'Физика', time:'~12 мин', level:'средне', goal:'построение изображения в линзе' },
{ key:'circuit', href:'/lab?sim=circuit', title:'Электрическая цепь', sub:'Закон Ома: ток, напряжение и сопротивление.', subj:'Физика', time:'~8 мин', level:'легко', goal:'расчёт цепи по закону Ома' },
{ key:'pendulum', href:'/lab?sim=pendulum', title:'Математический маятник', sub:'Период колебаний и зависимость от длины.', subj:'Физика', time:'~9 мин', level:'легко', goal:'формула периода маятника' },
{ key:'waves', href:'/lab?sim=waves', title:'Волны и колебания', sub:'Длина волны, частота и стоячие волны.', subj:'Физика', time:'~11 мин', level:'средне', goal:'связь v = λf' },
{ key:'stereo', href:'/lab?sim=stereo', title:'Стереометрия 3D', sub:'Сечения и объёмы пространственных фигур.', subj:'Геометрия', time:'~10 мин', level:'сложно', goal:'построение сечений' },
];
// Метаданные карточки (время/уровень/цель/подпись) — их нет в каталоге БД,
// поэтому держим curated-карту по id (источник — LAB_OF_DAY выше).
const LAB_META = {};
LAB_OF_DAY.forEach(l => { LAB_META[l.key] = l; });
const CAT_LABEL = { math: 'Математика', phys: 'Физика', chem: 'Химия', bio: 'Биология', game: 'Игра' };
// Синхронизировано с каталогом лаборатории (/api/lab/sims): крутим только
// среди ВКЛЮЧЁННЫХ симуляций, у которых есть превью; title/категория — из БД,
// поэтому переименования/выключения в админке отражаются здесь автоматически.
async function loadLabOfDay() {
const card = document.getElementById('hc-lab');
if (!card) return;
let pick = null;
try {
const r = await LS.api('/api/lab/sims');
const sims = (r && r.sims) || [];
const hasPreview = id => window.LabPreviews && LabPreviews[id];
let pool = sims.filter(s => s.enabled && hasPreview(s.id));
const feat = pool.filter(s => s.featured);
if (feat.length >= 2) pool = feat; // приоритет «рекомендуемых», если их достаточно
pool.sort((a, b) => (a.sort - b.sort) || (a.id < b.id ? -1 : 1)); // стабильный порядок для детерминизма
if (pool.length) {
const s = pool[Math.floor(Date.now() / 86400000) % pool.length];
const m = LAB_META[s.id] || {};
pick = {
href: '/lab?sim=' + s.id,
key: s.id,
title: s.title || m.title || s.id, // title из каталога (источник истины)
sub: m.sub || 'Открой симуляцию и поэкспериментируй.',
subj: m.subj || CAT_LABEL[s.cat] || 'Лаборатория',
time: m.time || '~10 мин',
level: m.level || 'средне',
goal: m.goal || (s.title || ''),
};
}
} catch (_) { /* каталог недоступен — упадём на статичный список ниже */ }
if (!pick) { // фолбэк: прежний детерминированный выбор из захардкоженного списка
pick = LAB_OF_DAY[Math.floor(Date.now() / 86400000) % LAB_OF_DAY.length];
}
card.href = pick.href;
const bg = document.getElementById('hc-lab-bg');
if (bg && window.LabPreviews && LabPreviews[pick.key]) bg.innerHTML = LabPreviews[pick.key];
document.getElementById('hc-lab-title').textContent = pick.title;
document.getElementById('hc-lab-sub').textContent = pick.sub;
document.getElementById('hc-lab-subj').textContent = pick.subj;
document.getElementById('hc-lab-time').textContent = pick.time;
document.getElementById('hc-lab-level').textContent = pick.level;
document.getElementById('hc-lab-meta').textContent = 'Освой: ' + pick.goal;
}
/* ══ HERO: Pet (synced with /pet module via /api/pet + PetSprite) ═ */
async function loadPetHero() {
const card = document.getElementById('hc-pet');
if (!card) return;
let d;
try { d = await LS.api('/api/pet'); }
catch { card.style.display = 'none'; return; } // фича питомца выключена
if (!d) { card.style.display = 'none'; return; }
// Sprite — единый рендер из pet-sprite.js (как на /pet)
const art = document.getElementById('hc-pet-art');
if (art && window.PetSprite) {
art.innerHTML = PetSprite.render(d.petLevel || 1, d.mood || 'neutral', d.accessories || [], d.petColor || 'purple', d.streakCurrent || 0);
}
document.getElementById('hc-pet-name').textContent = d.petName || 'Квантик';
document.getElementById('hc-pet-lvl').textContent = d.level || 1;
// XP — как в модуле /pet: полный XP / абсолютный порог следующего уровня
const cur = d.xpForCurrLevel || 0;
const next = d.xpForNextLevel; // null = макс
const pct = next ? Math.min(100, ((d.xp - cur) / (next - cur)) * 100) : 100;
document.getElementById('hc-pet-xp').textContent = (d.xp || 0).toLocaleString();
document.getElementById('hc-pet-xpmax').textContent = next ? next.toLocaleString() : 'MAX';
document.getElementById('hc-pet-prog').style.width = pct + '%';
// Стрик / цель дня / настроение
document.getElementById('hc-pet-streak').textContent = d.streakCurrent || 0;
const quests = d.quests || [];
const doneCnt = quests.filter(q => q.done).length;
document.getElementById('hc-pet-goal').textContent = quests.length ? `${doneCnt}/${quests.length}` : '—';
document.getElementById('hc-pet-mood').textContent = window.PetSprite ? PetSprite.moodLabel(d.mood) : (d.mood || 'бодр');
}
/* ══ ACTIVITY: data structure ══════════════════════════════════════ */
let _activityDays = {}; // { 'YYYY-MM-DD': { test, exam, cards, lesson, live, homework } }
let _hmScale = 12; // weeks to show
const ACT_TYPES = {
test: { label: 'Тесты', color: '#06B6D4' },
exam: { label: 'Экзамен', color: '#9B5DE5' },
cards: { label: 'Карты', color: '#06D6A0' },
lesson: { label: 'Уроки', color: '#F59E0B' },
live: { label: 'Онлайн', color: '#EC4899' },
homework: { label: 'Домашка', color: '#22C55E' },
};
const ACT_ORDER = ['test', 'exam', 'cards', 'lesson', 'live', 'homework'];
function _dayTotal(types) { let s = 0; for (const k in (types || {})) s += types[k] || 0; return s; }
function _domType(types) { let best = null, bn = -1; for (const k in (types || {})) { if (types[k] > bn) { bn = types[k]; best = k; } } return best; }
function _hexA(h, a) { const n = parseInt(String(h).slice(1), 16); return `rgba(${(n>>16)&255},${(n>>8)&255},${n&255},${a})`; }
const ICN_TREND_UP = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 17 9 11 13 15 21 7"/><polyline points="15 7 21 7 21 13"/></svg>';
const ICN_TREND_DOWN = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 7 9 13 13 9 21 17"/><polyline points="15 17 21 17 21 11"/></svg>';
const ICN_TREND_FLAT = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="12" x2="20" y2="12"/></svg>';
/* ══ WIDGET: Activity heatmap (all study types) ══════════════════════ */
async function loadActivityWidget() {
const w = document.getElementById('w-activity');
if (!w) return;
showWidget('w-activity');
try { const data = await LS.getActivity(); _activityDays = data.days || {}; }
catch (e) { _activityDays = {}; }
renderHeatmap();
renderStreakCalendar();
}
function renderHeatmap() {
const byDay = _activityDays;
const weeks = _hmScale;
const today = new Date(); today.setHours(0,0,0,0);
const host = document.getElementById('activity-heatmap');
if (!host) return;
// Empty state (new users / no activity yet)
if (!byDay || !Object.keys(byDay).length) {
host.innerHTML = `<div class="hm-empty">
<div class="hm-empty-ic">${lci('sparkles', 26)}</div>
<div class="hm-empty-t">Здесь появится твоя активность</div>
<div class="hm-empty-s">Читай учебники, решай тесты и экзамен, проходи карточки и уроки — карта заполнится.</div>
<a class="hm-empty-cta" href="/exam-prep/math9">Начать заниматься</a>
</div>`;
if (window.lucide) lucide.createIcons();
return;
}
const totalDays = weeks * 7;
const startDay = new Date(today);
startDay.setDate(today.getDate() - totalDays + 1);
const startDow = (startDay.getDay() + 6) % 7; // align to Monday
startDay.setDate(startDay.getDate() - startDow);
const mNames = ['Янв','Фев','Мар','Апр','Май','Июн','Июл','Авг','Сен','Окт','Ноя','Дек'];
const months = []; let lastMonth = -1;
for (let col = 0; col < weeks; col++) {
const d = new Date(startDay); d.setDate(startDay.getDate() + col * 7);
if (d.getMonth() !== lastMonth) { months.push({ col, label: mNames[d.getMonth()] }); lastMonth = d.getMonth(); }
}
let monthHtml = '';
months.forEach((m, i) => {
const nextCol = i < months.length - 1 ? months[i + 1].col : weeks;
monthHtml += `<span class="hm-month-label" style="width:${(nextCol - m.col) * 16}px">${m.label}</span>`;
});
const wdNames = ['Пн','','Ср','','Пт','',''];
let cellsHtml = '', cellIdx = 0, totalAll = 0, thisWeek = 0, lastWeek = 0;
const wkAgo = new Date(today); wkAgo.setDate(today.getDate() - 6);
const twoWkAgo = new Date(today); twoWkAgo.setDate(today.getDate() - 13);
for (let col = 0; col < weeks; col++) {
for (let row = 0; row < 7; row++) {
const d = new Date(startDay); d.setDate(startDay.getDate() + col * 7 + row);
const key = d.toISOString().slice(0,10);
const types = byDay[key];
const n = types ? _dayTotal(types) : 0;
const isFuture = d > today;
if (n && !isFuture) {
totalAll += n;
if (d >= wkAgo) thisWeek += n;
else if (d >= twoWkAgo) lastWeek += n;
}
let style;
if (n && !isFuture) {
const color = (ACT_TYPES[_domType(types)] || {}).color || '#9B5DE5';
const alpha = Math.min(1, 0.42 + Math.log2(n + 1) * 0.2);
style = ` style="background:${_hexA(color, alpha.toFixed(2))};--cell-color:${_hexA(color, 0.55)};animation-delay:${cellIdx * 4}ms"`;
} else if (isFuture) {
style = ` style="opacity:0.15;animation-delay:${cellIdx * 4}ms"`;
} else {
style = ` style="animation-delay:${cellIdx * 4}ms"`;
}
cellsHtml += `<div class="mhm-cell${n && !isFuture ? ' has-data' : ''}" data-key="${key}"${style}></div>`;
cellIdx++;
}
}
// Hero: total + weekly trend pill + scale control
const diff = thisWeek - lastWeek;
let trendCls, trendTxt, trendIcon;
if (diff > 0) { trendCls = 'up'; trendTxt = `+${diff}`; trendIcon = ICN_TREND_UP; }
else if (diff < 0) { trendCls = 'down'; trendTxt = `${diff}`; trendIcon = ICN_TREND_DOWN; }
else { trendCls = 'flat'; trendTxt = 'без изменений'; trendIcon = ICN_TREND_FLAT; }
const wkWord = weeks === 26 ? '6 мес.' : `${weeks} нед.`;
const scaleHtml = [[6,'6н'],[12,'12н'],[26,'6м']]
.map(([w,l]) => `<button class="act-scale-btn${w === weeks ? ' active' : ''}" onclick="setHmScale(${w},this)">${l}</button>`).join('');
const heroHtml =
`<div class="hm-hero"><div>` +
`<div class="hm-hero-num-row">` +
`<span class="hm-hero-num">${totalAll}</span>` +
`<span class="hm-hero-unit">занятий за ${wkWord}</span>` +
`<span class="hm-trend-pill ${trendCls}">${trendIcon}${trendTxt}</span>` +
`</div>` +
`<div class="hm-hero-sub">на этой неделе <strong>${thisWeek}</strong>, на прошлой <strong>${lastWeek}</strong></div>` +
`</div><div class="act-scale-btns">${scaleHtml}</div></div>`;
const legendHtml = `<div class="hm-legend-row"><div class="hm-legend">` +
ACT_ORDER.map(k => `<span class="hm-legend-chip"><span class="hm-legend-dot" style="background:${ACT_TYPES[k].color}"></span>${ACT_TYPES[k].label}</span>`).join('') +
`</div></div>`;
host.innerHTML =
heroHtml +
`<div class="hm-months">${monthHtml}</div>` +
`<div class="hm-body">` +
`<div class="hm-weekdays">${wdNames.map(w => `<div class="hm-wd">${w}</div>`).join('')}</div>` +
`<div class="mini-heatmap">${cellsHtml}</div>` +
`</div>` +
legendHtml;
setTimeout(() => { initHeatmapTooltip(); initHeatmapClick(); }, 50);
}
/* ══ Heatmap scale switch ══════════════════════════════════════════ */
function setHmScale(weeks) {
_hmScale = weeks;
renderHeatmap(); // rebuilds the scale control with the active state
}
/* ══ Activity tab switch ═══════════════════════════════════════════ */
function switchActTab(tab, btn) {
document.querySelectorAll('.act-tab').forEach(t => t.classList.remove('active'));
if (btn) btn.classList.add('active');
document.getElementById('act-heatmap-pane').classList.toggle('visible', tab === 'heatmap');
document.getElementById('act-cal-pane').classList.toggle('visible', tab === 'calendar');
}
/* Колонка прогресса (#w-progress-col) — это один .widget-бокс с тремя секциями
(карточка / по предметам / результаты). Если все секции скрыты (напр. флешкарты
отключены и нет данных) — прячем сам бокс, иначе висит пустая рамка. */
function syncProgressCol() {
const col = document.getElementById('w-progress-col');
if (!col) return;
const any = ['w-flashcard', 'w-subj-progress', 'w-last-results'].some(id => {
const e = document.getElementById(id);
return e && getComputedStyle(e).display !== 'none';
});
col.style.display = any ? '' : 'none';
}
/* Hero-ряд (чтение/лаборатория/питомец): карточки скрываются по фиче (через CSS).
Подгоняем число колонок под видимые карточки и прячем весь ряд, если пусто. */
function syncHeroRow() {
const row = document.getElementById('hero-row');
if (!row) return;
const vis = [...row.querySelectorAll('.hero-card')]
.filter(c => getComputedStyle(c).display !== 'none');
row.style.display = vis.length ? '' : 'none';
// ширину колонок под число карточек делает CSS (auto-fit), мобайл не трогаем.
}
/* ══ WIDGET: Last results (compact, 5 items) ══════════════════════ */
function loadLastResultsWidget(rows) {
const w = document.getElementById('w-last-results');
if (!w) return;
const completed = (rows || []).filter(r => r.score !== null && r.total > 0).slice(0, 5);
if (!completed.length) { w.style.display = 'none'; syncProgressCol(); return; }
w.style.display = '';
document.getElementById('last-results-list').innerHTML = completed.map(h => {
const pct = Math.round(h.score / h.total * 100);
const pc = pct >= 75 ? 'hi' : pct >= 50 ? 'mid' : 'lo';
const colorMap = { hi: '#059652', mid: '#F59E0B', lo: '#E0335E' };
return `<div class="grade-row">
<span class="grade-pct" style="color:${colorMap[pc]}">${pct}%</span>
<div class="grade-body">
<div class="grade-subj">${esc(h.subject_name || 'Тест')}</div>
<div class="grade-date">${parseDate(h.started_at).toLocaleDateString('ru',{day:'numeric',month:'short'})} · ${MODES[h.mode] || h.mode}</div>
</div>
</div>`;
}).join('');
syncProgressCol();
}
/* ══ WIDGET: Subject progress bars ════════════════════════════════ */
function loadSubjProgressWidget(rows) {
const w = document.getElementById('w-subj-progress');
if (!w) return;
const bySubj = {};
(rows || []).forEach(r => {
if (!r.subject_slug || r.score === null || r.total <= 0) return;
if (!bySubj[r.subject_slug]) bySubj[r.subject_slug] = { name: r.subject_name, scores: [] };
bySubj[r.subject_slug].scores.push(Math.round(r.score / r.total * 100));
});
const entries = Object.entries(bySubj);
if (!entries.length) { w.style.display = 'none'; syncProgressCol(); return; }
w.style.display = '';
document.getElementById('subj-progress-bars').innerHTML = entries.map(([slug, d]) => {
const avg = Math.round(d.scores.reduce((a, b) => a + b, 0) / d.scores.length);
const color = SUBJ_COLORS[slug] || '#9B5DE5';
return `<div class="subj-prog-row">
<span class="sp-name">${esc(d.name || SUBJ[slug] || slug)}</span>
<div class="sp-bar"><div class="sp-fill" style="width:${avg}%;background:${color}"></div></div>
<span class="sp-pct" style="color:${color}">${avg}%</span>
</div>`;
}).join('');
syncProgressCol();
}
/* ══ WIDGET: Theory progress ══════════════════════════════════════ */
async function loadTheoryWidget() {
const w = document.getElementById('w-theory-progress');
if (!w) return;
try {
const courses = await LS.api('/api/courses');
const active = (Array.isArray(courses) ? courses : [])
.filter(c => c.lessonCount > 0 && c.doneCount < c.lessonCount);
if (!active.length) { w.style.display = 'none'; return; }
showWidget('w-theory-progress');
const SUBJ_LABEL = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика', other:'Задание' };
document.getElementById('theory-progress-grid').innerHTML = active.slice(0, 6).map(c => {
const pct = Math.round(c.doneCount / c.lessonCount * 100);
const subj = SUBJ_LABEL[c.subjectSlug] || c.subjectSlug || '';
return `<a class="theory-course-card" href="/course?id=${c.id}">
<div class="tc-header">
<span class="tc-emoji">${c.coverEmoji || LS.icon('book-open',20)}</span>
<div class="tc-info">
<div class="tc-title">${esc(c.title)}</div>
${subj ? `<div class="tc-subj">${esc(subj)}</div>` : ''}
</div>
</div>
<div class="tc-progress">
<div class="tc-bar"><div class="tc-fill" style="width:${pct}%"></div></div>
<span class="tc-pct">${pct}%</span>
</div>
<div class="tc-meta">${c.doneCount} / ${c.lessonCount} уроков</div>
</a>`;
}).join('');
} catch { w.style.display = 'none'; }
}
/* ══ B2: LIVE SEARCH ASSIGNMENTS ═══════════════════════════════════ */
let _searchQuery = '';
function filterAssignments(q) {
_searchQuery = (q || '').toLowerCase().trim();
renderAssignmentsList();
}
/* ══ HEATMAP TOOLTIP (rich) ════════════════════════════════════════ */
function initHeatmapTooltip() {
const container = document.getElementById('activity-heatmap');
const tip = document.getElementById('hm-tip');
if (!container || !tip) return;
// Remove old listeners by cloning
const fresh = container.cloneNode(true);
container.parentNode.replaceChild(fresh, container);
fresh.addEventListener('mouseover', e => {
const cell = e.target.closest('.mhm-cell');
if (!cell) { tip.classList.remove('visible'); return; }
const key = cell.dataset.key;
const types = _activityDays[key];
const dateLabel = new Date(key + 'T00:00:00').toLocaleDateString('ru', {weekday:'short', day:'numeric', month:'long'});
if (_dayTotal(types)) {
const parts = ACT_ORDER.filter(k => types[k]).map(k => `${ACT_TYPES[k].label} ${types[k]}`);
tip.textContent = `${dateLabel}${parts.join(', ')}`;
} else {
tip.textContent = `${dateLabel} — нет активности`;
}
const r = cell.getBoundingClientRect();
tip.style.left = Math.min(r.left + r.width / 2, window.innerWidth - 180) + 'px';
tip.style.top = (r.top - 34) + 'px';
tip.classList.add('visible');
});
fresh.addEventListener('mouseleave', () => tip.classList.remove('visible'));
}
/* ══ HEATMAP CLICK <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> DAY POPUP ════════════════════════════════════ */
function initHeatmapClick() {
const container = document.getElementById('activity-heatmap');
if (!container) return;
container.addEventListener('click', e => {
const cell = e.target.closest('.mhm-cell');
const popup = document.getElementById('hm-day-popup');
if (!cell || !cell.classList.contains('has-data')) {
if (popup) popup.style.display = 'none';
return;
}
const key = cell.dataset.key;
const types = _activityDays[key];
if (!_dayTotal(types)) { popup.style.display = 'none'; return; }
const dateLabel = new Date(key + 'T00:00:00').toLocaleDateString('ru', {weekday:'long', day:'numeric', month:'long'});
let html = `<div class="hdp-date">${dateLabel}</div>`;
ACT_ORDER.filter(k => types[k]).forEach(k => {
html += `<div class="hdp-row">
<span class="hdp-dot" style="background:${ACT_TYPES[k].color}"></span>
<span class="hdp-subj">${ACT_TYPES[k].label}</span>
<span class="hdp-score">${types[k]}</span>
</div>`;
});
popup.innerHTML = html;
popup.style.display = '';
const r = cell.getBoundingClientRect();
popup.style.left = Math.min(r.right + 8, window.innerWidth - 220) + 'px';
popup.style.top = Math.max(8, r.top - 20) + 'px';
});
// Close popup on outside click
document.addEventListener('click', e => {
const popup = document.getElementById('hm-day-popup');
if (popup && !popup.contains(e.target) && !e.target.closest('.mhm-cell')) {
popup.style.display = 'none';
}
});
}
/* ══ B5: DEADLINE TOAST ════════════════════════════════════════════ */
function showDeadlineToast(assignments) {
const oneDay = 24 * 3600 * 1000;
const urgent = (assignments || []).filter(a =>
a.deadline && !a.session_status &&
new Date(a.deadline) > new Date() &&
(new Date(a.deadline) - Date.now()) < oneDay
);
if (!urgent.length) return;
const a = urgent[0];
const hours = Math.ceil((new Date(a.deadline) - Date.now()) / 3600000);
const toast = document.createElement('div');
toast.className = 'deadline-toast';
toast.innerHTML = `<span class="dt-icon">${LS.icon('flame',18)}</span>
<span class="dt-text"><strong>${esc(a.title)}</strong> — осталось <span class="dt-time">${hours}ч</span></span>
<button class="dt-close" onclick="this.parentElement.remove()"><svg class="ic" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>`;
toast.addEventListener('click', e => {
if (e.target.closest('.dt-close')) return;
toast.remove();
});
document.querySelector('.sb-content').appendChild(toast);
setTimeout(() => { if (toast.parentElement) toast.remove(); }, 9000);
}
/* ══ C2: STREAK CALENDAR ═══════════════════════════════════════════ */
function renderStreakCalendar() {
const body = document.getElementById('streak-cal-body');
if (!body) return;
const today = new Date(); today.setHours(0,0,0,0);
const activeDays = new Set();
for (const key in (_activityDays || {})) {
if (_dayTotal(_activityDays[key]) > 0) activeDays.add(key);
}
const year = today.getFullYear(), month = today.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const startDow = (firstDay.getDay() + 6) % 7;
const todayKey = today.toISOString().slice(0,10);
const todayHasSession = activeDays.has(todayKey);
// Current streak + best streak
let streak = 0, bestStreak = 0, cur = 0;
const allDays = [...activeDays].sort();
for (let i = 0; i <= 180; i++) {
const d = new Date(today); d.setDate(today.getDate() - i);
const k = d.toISOString().slice(0,10);
if (activeDays.has(k)) { if (i === 0 || streak > 0) streak++; }
else if (streak > 0) break;
}
// Best streak from all data
let tmpStreak = 0;
for (let i = 0; i <= 365; i++) {
const d = new Date(today); d.setDate(today.getDate() - i);
const k = d.toISOString().slice(0,10);
if (activeDays.has(k)) { tmpStreak++; bestStreak = Math.max(bestStreak, tmpStreak); }
else tmpStreak = 0;
}
const monthNames = ['Январь','Февраль','Март','Апрель','Май','Июнь','Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь'];
const wdNames = ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'];
let html = `<div class="sc-header">
<span class="sc-month">${monthNames[month]}</span>
<span style="display:flex;gap:10px;align-items:center">
<span class="sc-streak-badge">${lci('flame', 16)} ${streak}</span>
${bestStreak > streak ? `<span style="font-size:0.6rem;color:var(--text-3);font-weight:600">рекорд ${bestStreak}</span>` : ''}
</span>
</div>`;
html += `<div class="sc-weekdays">${wdNames.map(d => `<span class="sc-wd">${d}</span>`).join('')}</div>`;
html += '<div class="streak-cal">';
for (let i = 0; i < startDow; i++) html += '<div class="sc-day" style="visibility:hidden"></div>';
for (let d = 1; d <= lastDay.getDate(); d++) {
const dt = new Date(year, month, d);
const key = dt.toISOString().slice(0,10);
const isToday = key === todayKey;
const isActive = activeDays.has(key);
const isFuture = dt > today;
let cls = 'sc-day';
if (isToday) cls += ' today';
if (isActive) cls += ' active';
if (isFuture) cls += ' future';
// Pulse today if no session yet
if (isToday && !todayHasSession) cls += ' pulse';
// Shade active (non-today) days by how much was done
let st = '';
if (isActive && !isToday) {
const a = Math.min(0.42, 0.16 + Math.log2(_dayTotal(_activityDays[key]) + 1) * 0.1);
st = ` style="background:${_hexA('#9B5DE5', a.toFixed(2))}"`;
}
html += `<div class="${cls}"${st}>${d}</div>`;
}
html += '</div>';
body.innerHTML = html;
}
/* ══ B1: QUICK-START MODAL ═════════════════════════════════════════ */
let _qsSlug = null;
let _qsModal = null;
async function openQuickStart(slug) {
_qsSlug = slug || null;
let subjectsHtml = '';
try {
const subjects = await LS.getSubjects();
subjectsHtml = subjects.map(s => {
const color = SUBJ_COLORS[s.slug] || '#9B5DE5';
const active = s.slug === _qsSlug ? ' active' : '';
return `<button class="qs-subj-btn${active}" data-slug="${s.slug}">
<div class="qs-subj-icon" style="background:${color}">${lci(ICONS[s.slug]||'book-open')}</div>
${esc(s.name)}
</button>`;
}).join('');
} catch {}
const body = `
<div class="qs-subjects" id="qs-subjects">${subjectsHtml}</div>
<div class="qs-options">
<div class="qs-row">
<span class="qs-label">Режим</span>
<select class="qs-select" id="qs-mode">
<option value="exam">Экзамен</option>
<option value="practice">Тренировка</option>
<option value="random">Случайный</option>
</select>
</div>
<div class="qs-row">
<span class="qs-label">Вопросов</span>
<input class="qs-input" id="qs-count" type="number" min="5" max="50" value="25">
</div>
</div>`;
_qsModal = LS.modal({
title: 'Быстрый тест', content: body, size: 'sm',
actions: [
{ label: 'Отмена', onClick: () => _qsModal.close() },
{ label: 'Начать', primary: true, onClick: doQuickStart },
],
});
reIcons();
// Wire subject selection within modal
_qsModal.body.querySelectorAll('.qs-subj-btn').forEach(btn => {
btn.addEventListener('click', () => {
_qsSlug = btn.dataset.slug;
_qsModal.body.querySelectorAll('.qs-subj-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
}
function doQuickStart() {
if (!_qsSlug) { LS.toast('Выберите предмет', 'warn'); return; }
const mode = document.getElementById('qs-mode').value;
const count = parseInt(document.getElementById('qs-count').value) || 25;
_qsModal?.close();
window.location.href = `/test-run?subject=${_qsSlug}&mode=${mode}&count=${count}`;
}
/* ══ C4: KEYBOARD SHORTCUTS ════════════════════════════════════════ */
(function initKeyboard() {
document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
switch (e.key.toLowerCase()) {
case 'n': e.preventDefault(); openQuickStart(); break;
case 't': e.preventDefault(); window.location.href = '/library'; break;
case 'b': e.preventDefault(); window.location.href = '/board'; break;
case '?': e.preventDefault();
document.getElementById('kb-hint').classList.toggle('visible');
break;
}
});
})();
/* ══ ADMIN: Load stats into header ════════════════════════════════ */
async function loadAdminStats() {
if (!isTeacher) return;
try {
const data = await LS.api('/api/admin/stats');
const statsEl = document.getElementById('dh-stats');
statsEl.style.display = '';
statsEl.innerHTML = '';
// Show compact stat chips instead of rings for admin
const chips = [
{ val: data.totalUsers || 0, label: 'студентов', color: '#9B5DE5' },
{ val: data.totalTests || 0, label: 'тестов', color: '#06B6D4' },
{ val: data.avgScore != null ? data.avgScore + '%' : '—', label: 'средний', color: data.avgScore >= 75 ? '#059652' : data.avgScore >= 50 ? '#F59E0B' : '#E0335E' },
];
statsEl.innerHTML = chips.map(c =>
`<div class="adm-stat-chip"><span class="adm-stat-val" style="color:${c.color}">${c.val}</span>${c.label}</div>`
).join('');
} catch { /* silent */ }
}
/* ══ ADMIN: KPI chips in header ═════════════════════════════════════ */
async function loadTeacherKPIs() {
if (!isTeacher) return;
const kpiRow = document.getElementById('dh-kpi-row');
if (!kpiRow) return;
try {
const [classes, assignments] = await Promise.all([
LS.api('/api/classes'),
LS.teacherAssignments(),
]);
const classCount = (classes || []).length;
const studentCount = (classes || []).reduce((s, c) => s + (c.member_count || 0), 0);
const now = Date.now();
const activeAsgn = (assignments || []).filter(a =>
!a.deadline || parseDate(a.deadline).getTime() >= now
).length;
// Pending: overdue with submissions missing
const pending = (assignments || []).filter(a =>
a.deadline && parseDate(a.deadline).getTime() < now &&
(a.completed_count || 0) < (a.total_members || 0)
).length;
document.getElementById('kpi-classes').textContent = classCount;
document.getElementById('kpi-students').textContent = studentCount;
document.getElementById('kpi-active-asgn').textContent = activeAsgn;
kpiRow.style.display = '';
if (pending > 0) {
document.getElementById('kpi-pending').textContent = pending;
document.getElementById('kpi-pending-wrap').style.display = '';
}
// Add badge to «Мои классы» card
if (classCount > 0) {
const classCard = document.querySelector('.adm-act[href="/classes"]');
if (classCard && !classCard.querySelector('.adm-act-badge')) {
const badge = document.createElement('span');
badge.className = 'adm-act-badge';
badge.textContent = classCount;
classCard.appendChild(badge);
}
}
// Add badge to «Работы» card if pending
if (pending > 0) {
const workCard = document.querySelector('.adm-act[href="/homework"]');
if (workCard && !workCard.querySelector('.adm-act-badge')) {
const badge = document.createElement('span');
badge.className = 'adm-act-badge';
badge.textContent = pending;
workCard.appendChild(badge);
}
}
} catch { /* silent */ }
}
/* ══ ADMIN: Load classes into admin panel ═══════════════════════════ */
async function loadAdminClasses() {
if (!isTeacher) return;
const body = document.getElementById('admin-classes-body');
if (!body) return;
try {
const classes = await LS.api('/api/classes');
if (!classes || !classes.length) {
body.innerHTML = `<div class="adm-empty">
<div class="adm-empty-icon"><i data-lucide="users" style="width:32px;height:32px;color:var(--text-3)"></i></div>
<div class="adm-empty-text">Классов пока нет</div>
<a class="adm-empty-cta" href="/classes">+ Создать класс</a>
</div>`;
reIcons();
return;
}
const colors = ['#9B5DE5','#06D6A0','#F59E0B','#06B6D4','#E0335E'];
body.innerHTML = classes.slice(0, 5).map(c => {
const memberCount = c.member_count || c.members || 0;
const aCount = c.assignment_count || 0;
const bg = colors[(c.id || 0) % colors.length];
return `<a class="cs-card" href="/classes?id=${c.id}" style="text-decoration:none;color:inherit">
<div class="cs-avatar" style="${c.cover_emoji ? (()=>{const p=c.cover_emoji.split(':');const cl=p[1]||'#9B5DE5';return 'background:'+cl+'18;color:'+cl})() : 'background:'+bg}">${c.cover_emoji ? lci(c.cover_emoji.split(':')[0],'width:18px;height:18px') : (c.name||'?')[0]}</div>
<div class="cs-body">
<div class="cs-name">${esc(c.name)}</div>
<div class="cs-stats">
<span>${lci('users','width:12px;height:12px')} ${memberCount}</span>
<span>${lci('file-text','width:12px;height:12px')} ${aCount} заданий</span>
</div>
</div>
</a>`;
}).join('');
reIcons();
} catch {
body.innerHTML = '<div style="font-size:0.8rem;color:var(--text-3);padding:8px 0">Ошибка загрузки</div>';
}
}
/* ══ ADMIN: Load recent sessions ═══════════════════════════════════ */
async function loadAdminSessions() {
if (!isTeacher) return;
const body = document.getElementById('admin-sessions-body');
if (!body) return;
try {
const data = await LS.api('/api/admin/sessions?limit=8');
const rows = Array.isArray(data) ? data : (data.rows || []);
if (!rows.length) {
body.innerHTML = `<div class="adm-empty">
<div class="adm-empty-icon"><i data-lucide="clock" style="width:32px;height:32px;color:var(--text-3)"></i></div>
<div class="adm-empty-text">Сессий пока нет. Ученики не начинали тесты.</div>
</div>`;
reIcons();
return;
}
body.innerHTML = rows.slice(0, 8).map(s => {
const pct = s.total > 0 ? Math.round(s.score / s.total * 100) : 0;
const pc = pct >= 75 ? '#059652' : pct >= 50 ? '#F59E0B' : '#E0335E';
const ago = relativeAgo(s.started_at);
if (s.status !== 'completed') {
const chip = `<span class="adm-sess-chip" style="background:rgba(15,23,42,0.06);color:var(--text-3);border:1px solid rgba(15,23,42,0.10)">…</span>`;
return `<div class="adm-sess-row" onclick="window.location='/admin#sessions'" style="cursor:pointer">
<span class="adm-sess-name">${esc(s.user_name || s.email || '—')}</span>
<span class="adm-sess-subj">${esc(s.subject_name || '')}</span>
${ago ? `<span class="adm-sess-ago">${ago}</span>` : ''}
${chip}
</div>`;
}
const chip = `<span class="adm-sess-chip" style="background:${pc}22;color:${pc};border:1px solid ${pc}44">${pct}%</span>`;
return `<div class="adm-sess-row" onclick="window.location='/admin#sessions'" style="cursor:pointer">
<span class="adm-sess-name">${esc(s.user_name || s.email || '—')}</span>
<span class="adm-sess-subj">${esc(s.subject_name || '')}</span>
${ago ? `<span class="adm-sess-ago">${ago}</span>` : ''}
${chip}
</div>`;
}).join('');
} catch {
body.innerHTML = '<div style="font-size:0.8rem;color:var(--text-3);padding:8px 0">Ошибка</div>';
}
}
/* ══ ADMIN: Load assignments into admin compact panel ═══════════════ */
async function loadAdminAssignments() {
if (!isTeacher) return;
const listEl = document.getElementById('admin-assignments-list');
if (!listEl) return;
try {
const list = await LS.teacherAssignments();
if (!list.length) {
listEl.innerHTML = `<div class="adm-empty">
<div class="adm-empty-icon"><i data-lucide="inbox" style="width:32px;height:32px;color:var(--text-3)"></i></div>
<div class="adm-empty-text">Заданий пока нет</div>
<a class="adm-empty-cta" href="/classes">+ Создать первое задание</a>
</div>`;
reIcons();
return;
}
const sorted = [...list].sort((a, b) => urgencyScore(a) - urgencyScore(b));
// Save for search filtering
window._adminAssignmentsSorted = sorted;
listEl.innerHTML = `<input class="adm-asgn-search" id="adm-asgn-search" type="search" placeholder="Поиск задания..." oninput="filterAdminAssignments(this.value)"><div class="tests-list" id="admin-assignments-inner">${sorted.slice(0, 8).map((a, i) => buildAssignCard(a, i)).join('')}</div>`;
if (list.length > 8) {
listEl.innerHTML += `<div style="text-align:center;margin-top:8px"><a class="w-more" href="/classes">Ещё ${list.length - 8} заданий</a></div>`;
}
reIcons();
} catch {
listEl.innerHTML = '<div style="font-size:0.8rem;color:var(--text-3);padding:8px 0">Ошибка загрузки</div>';
}
}
/* ── Filter admin assignments by search query ── */
function filterAdminAssignments(q) {
const innerEl = document.getElementById('admin-assignments-inner');
if (!innerEl || !window._adminAssignmentsSorted) return;
const ql = q.toLowerCase().trim();
const filtered = ql
? window._adminAssignmentsSorted.filter(a =>
(a.title || '').toLowerCase().includes(ql) ||
(a.class_name || '').toLowerCase().includes(ql) ||
(SUBJ[a.subject_slug] || a.subject_slug || '').toLowerCase().includes(ql)
)
: window._adminAssignmentsSorted;
const show = filtered.slice(0, 8);
innerEl.innerHTML = show.length
? show.map((a, i) => buildAssignCard(a, i)).join('')
: '<div class="adm-empty"><div class="adm-empty-text">Ничего не найдено</div></div>';
reIcons();
}
/* ══ Load all student widgets ═════════════════════════════════════ */
/* Show a widget section, but respect the cfg-hidden flag */
function showWidget(id) {
const el = document.getElementById(id);
if (!el || el.dataset.cfgHidden) return;
el.style.display = '';
}
/* ── Dashboard widget visibility ──────────────────────────────────── */
const _DASH_WIDGETS = [
{ id: 'ch-section', label: 'Испытания недели' },
{ id: 'stats-section', label: 'Статистика' },
{ id: 'w-my-subs', label: 'Мои сдачи' },
{ id: 'w-activity', label: 'Активность' },
];
async function applyDashboardPrefs() {
if (isTeacher) return;
await LS.prefs.init();
const hidden = LS.prefs.get('dashboard.hidden', []);
// Show cfg button for students
const cfgBtn = document.getElementById('dash-cfg-btn');
if (cfgBtn) cfgBtn.style.display = 'flex';
// Sync checkboxes + visibility
_DASH_WIDGETS.forEach(w => {
const isHidden = hidden.includes(w.id);
// Update checkbox state
const cb = document.querySelector(`#dash-cfg-panel input[data-widget="${w.id}"]`);
if (cb) cb.checked = !isHidden;
// Apply visibility only if the element is already visible (don't override display:none set by data loaders)
if (isHidden) {
const el = document.getElementById(w.id);
if (el) el.dataset.cfgHidden = '1';
}
});
}
function _saveDashHidden() {
const hidden = _DASH_WIDGETS
.filter(w => document.querySelector(`#dash-cfg-panel input[data-widget="${w.id}"]`)?.checked === false)
.map(w => w.id);
LS.prefs.set('dashboard.hidden', hidden);
}
function toggleDashWidget(widgetId, row) {
// Don't let row click double-trigger when checkbox itself is clicked
const cb = row.querySelector('input[type=checkbox]');
if (!cb) return;
// If row was clicked (not the checkbox directly), toggle the checkbox
if (event && event.target !== cb) cb.checked = !cb.checked;
const el = document.getElementById(widgetId);
if (el) {
if (cb.checked) {
delete el.dataset.cfgHidden;
// Only show if data loader hasn't hidden it for another reason
if (!el.dataset.loaderHidden) el.style.display = '';
} else {
el.dataset.cfgHidden = '1';
el.style.display = 'none';
}
}
_saveDashHidden();
}
function toggleDashCfg(e) {
e.stopPropagation();
const panel = document.getElementById('dash-cfg-panel');
panel.classList.toggle('open');
if (panel.classList.contains('open')) {
setTimeout(() => document.addEventListener('click', _closeDashCfg, { once: true }), 0);
}
}
function _closeDashCfg() {
document.getElementById('dash-cfg-panel')?.classList.remove('open');
}
async function loadStudentWidgets() {
if (isTeacher) return;
loadActivityWidget(); // self-contained: own endpoint, renders heatmap + month calendar
try {
const histData = await LS.getHistory(1, 100);
const rows = histData.rows || [];
loadLastResultsWidget(rows);
loadSubjProgressWidget(rows);
} catch {}
const heroRow = document.getElementById('hero-row');
if (heroRow) heroRow.style.display = '';
loadContinueWidget();
loadLabOfDay();
loadPetHero();
loadFlashcardWidget();
syncHeroRow(); // спрятать карточки отключённых модулей и подогнать сетку
}
/* ══ WIDGET: Flashcard review (random card from pool) ════════════════ */
let _fcwTotal = 0;
async function loadFlashcardWidget() {
if (isTeacher) return;
const w = document.getElementById('w-flashcard');
if (!w || w.dataset.cfgHidden) return;
try {
const r = await LS.api('/api/flashcards/random');
renderFlashcardWidget(r);
w.style.display = '';
} catch { /* фича выключена или ошибка — оставляем скрытым */ }
syncProgressCol(); // если карточка скрыта и нет прогресса/результатов — спрятать бокс
}
function renderFlashcardWidget(r) {
const body = document.getElementById('fcw-body');
if (!body) return;
if (!r || !r.card) {
_fcwTotal = 0;
body.innerHTML = `<div class="fcw-empty">
<p>Пока нет карточек. Создавай их в любой точке системы кнопкой внизу справа или на странице карточек.</p>
<a class="fcw-btn" href="/flashcards">
${lci('plus','width:13px;height:13px')} Создать карточку
</a>
</div>`;
reIcons();
return;
}
_fcwTotal = r.total || 0;
const c = r.card;
const frontTxt = (c.front || '').trim() || (c.front_image ? '' : '(пусто)');
const back = (c.back || '').trim() || (c.back_image ? '' : '(ответ не заполнен)');
const col = c.deck_color || '#9B5DE5';
const hasImg = !!(c.front_image || c.back_image);
const fImg = c.front_image ? `<img class="fcw-img" src="${esc(c.front_image)}" alt="" />` : '';
const bImg = c.back_image ? `<img class="fcw-img" src="${esc(c.back_image)}" alt="" />` : '';
body.innerHTML = `
<div class="fcw-card" onclick="fcwFlip(this)">
<div class="fcw-inner${hasImg ? ' has-img' : ''}">
<div class="fcw-face fcw-front">
<div class="fcw-deck" style="color:${esc(col)}">${esc(c.deck_title || 'Карточка')}</div>
${fImg}
<div class="fcw-text">${esc(frontTxt)}</div>
<div class="fcw-hint">${lci('rotate-cw','width:12px;height:12px')} нажми, чтобы перевернуть</div>
</div>
<div class="fcw-face fcw-back">
<div class="fcw-deck">Ответ</div>
${bImg}
<div class="fcw-text">${esc(back)}</div>
</div>
</div>
</div>
<div class="fcw-actions">
<button class="fcw-btn" type="button" onclick="fcwNext(event)">
${lci('shuffle','width:13px;height:13px')} Другая
</button>
<span class="fcw-count">${_fcwTotal} ${_fcwTotal === 1 ? 'карточка' : (_fcwTotal % 10 >= 2 && _fcwTotal % 10 <= 4 && (_fcwTotal % 100 < 12 || _fcwTotal % 100 > 14) ? 'карточки' : 'карточек')} в пуле</span>
</div>`;
reIcons();
}
function fcwFlip(el) { el.classList.toggle('flipped'); }
async function fcwNext(e) {
e.stopPropagation();
try {
const r = await LS.api('/api/flashcards/random');
renderFlashcardWidget(r);
} catch {}
}
// Обновлять виджет, когда карточку добавили через глобальный FAB
window.addEventListener('flashcard:added', () => { if (!isTeacher) loadFlashcardWidget(); });
/* ══ INIT ═════════════════════════════════════════════════════════════ */
window.addEventListener('pageshow', e => { if (e.persisted) location.reload(); });
if (!isTeacher) {
document.getElementById('subjects-sk').innerHTML = LS.skeleton(4);
document.getElementById('assignments-sk').innerHTML = LS.skeleton(3);
}
/* ══ STATISTICS CHARTS ═══════════════════════════════════════════════ */
async function loadDashboardStats() {
try {
const data = await LS.getStudentStats();
if (!data || (!data.totals.sessions && !data.courseProgress.length)) return;
showWidget('stats-section');
// Summary chips
const chips = document.getElementById('stats-chips');
chips.innerHTML = `
<div class="stats-chip"><div class="stats-chip-val">${data.totals.sessions}</div><div class="stats-chip-lbl">Сессий</div></div>
<div class="stats-chip"><div class="stats-chip-val" style="color:${data.totals.avgPct >= 70 ? '#059652' : data.totals.avgPct >= 50 ? '#F59E0B' : '#EF476F'}">${data.totals.avgPct}%</div><div class="stats-chip-lbl">Ср. балл</div></div>
<div class="stats-chip"><div class="stats-chip-val">${data.totals.correct}/${data.totals.questions}</div><div class="stats-chip-lbl">Правильно</div></div>
<div class="stats-chip"><div class="stats-chip-val" style="color:#9B5DE5">${data.streak}</div><div class="stats-chip-lbl">Стрик (дн.)</div></div>
`;
// Weekly bar chart
renderWeeklyChart(data.weekly);
// Trend chart
renderTrendChart(data.trend);
// Subject chart
renderSubjectsChart(data.bySubject);
// Course progress
renderCourseProgressChart(data.courseProgress);
} catch (e) { console.warn('Stats error:', e); }
}
function renderWeeklyChart(weekly) {
const el = document.getElementById('stats-weekly-chart');
const labels = document.getElementById('stats-weekly-labels');
if (!weekly.length) { el.innerHTML = '<div style="color:#B0BEC5;font-size:.8rem;padding:20px;text-align:center">Нет данных</div>'; return; }
const max = Math.max(...weekly.map(w => w.avgPct), 10);
el.innerHTML = weekly.map(w => {
const h = Math.max(4, w.avgPct / max * 100);
const color = w.avgPct >= 70 ? '#06D664' : w.avgPct >= 50 ? '#FFB347' : '#EF476F';
return `<div class="stats-bar" style="height:${h}%;background:${color}" data-tip="${w.avgPct}% · ${w.sessions} сес."></div>`;
}).join('');
labels.innerHTML = weekly.map(w => {
const [yr, wk] = w.week.split('-').map(Number);
const jan4 = new Date(yr, 0, 4);
const mon = new Date(jan4);
mon.setDate(jan4.getDate() - ((jan4.getDay() || 7) - 1) + (wk - 1) * 7);
return `<span>${mon.toLocaleDateString('ru', { day:'numeric', month:'short' }).replace('.','')}</span>`;
}).join('');
}
function renderTrendChart(trend) {
const el = document.getElementById('stats-trend-chart');
if (!trend.length) { el.innerHTML = '<div style="color:#B0BEC5;font-size:.8rem;padding:20px;text-align:center">Нет данных</div>'; return; }
const max = 100;
el.innerHTML = trend.map(t => {
const h = Math.max(4, t.pct);
const color = SUBJ_COLORS[t.subject] || '#9B5DE5';
return `<div class="stats-bar" style="height:${h}%;background:${color};opacity:0.7" data-tip="${t.pct}%"></div>`;
}).join('');
}
function renderSubjectsChart(subjects) {
const el = document.getElementById('stats-subjects-chart');
if (!subjects.length) { el.innerHTML = '<div style="color:#B0BEC5;font-size:.8rem;padding:20px;text-align:center">Нет данных</div>'; return; }
el.innerHTML = subjects.map(s => {
const color = SUBJ_COLORS[s.slug] || '#9B5DE5';
const barW = Math.max(4, s.avgPct);
return `<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">
<div style="width:80px;font-size:.75rem;font-weight:700;color:#3D4F6B;text-align:right;flex-shrink:0">${esc(s.name)}</div>
<div style="flex:1;height:20px;background:rgba(15,23,42,0.04);border-radius:6px;overflow:hidden;position:relative">
<div style="height:100%;width:${barW}%;background:${color};border-radius:6px;transition:width .5s"></div>
</div>
<div style="font-size:.75rem;font-weight:800;color:${color};min-width:36px">${s.avgPct}%</div>
</div>`;
}).join('');
}
function renderCourseProgressChart(courses) {
const el = document.getElementById('stats-courses-chart');
if (!courses.length) { el.innerHTML = '<div style="color:#B0BEC5;font-size:.8rem;padding:20px;text-align:center">Нет курсов в процессе</div>'; return; }
el.innerHTML = courses.slice(0, 6).map(c => {
const color = SUBJ_COLORS[c.subjectSlug] || '#9B5DE5';
return `<a href="/course?id=${c.id}" style="display:flex;align-items:center;gap:10px;margin-bottom:8px;text-decoration:none;color:inherit">
<div style="font-size:1.1rem;width:28px;text-align:center;flex-shrink:0">${c.emoji || '<svg class="ic" viewBox="0 0 24 24"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>'}</div>
<div style="flex:1;min-width:0">
<div style="font-size:.75rem;font-weight:700;color:#0F172A;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(c.title)}</div>
<div style="height:6px;background:rgba(15,23,42,0.06);border-radius:99px;margin-top:3px;overflow:hidden">
<div style="height:100%;width:${c.pct}%;background:${color};border-radius:99px"></div>
</div>
</div>
<div style="font-size:.72rem;font-weight:800;color:${color};flex-shrink:0">${c.pct}%</div>
</a>`;
}).join('');
}
if (isAdmin) {
// Admin: command center (redesign) on real /api/admin/overview data
const ccEl = document.getElementById('admin-command-center');
if (ccEl && window.DashAdminCenter) DashAdminCenter.mount(ccEl);
} else if (isTeacher) {
// Teacher: compact admin layout
loadAdminStats();
loadTeacherKPIs();
loadAdminAssignments();
loadAdminClasses();
loadAdminSessions();
} else {
// Student: full layout
loadSubjects();
loadAvailableTests();
loadAssignments();
loadStats();
loadGamification();
loadChallenges();
loadStudentWidgets();
loadDashboardStats();
applyDashboardPrefs();
}
loadLiveLesson();
document.addEventListener('visibilitychange', () => { if (!document.hidden) loadLiveLesson(); });
LS.notif.init();
// Статус онлайн-урока: показываем баннер, если у ученика/учителя идёт активная сессия.
async function loadLiveLesson() {
const el = document.getElementById('live-lesson-banner');
if (!el) return;
let data;
try { data = await LS.crGetMySession(); } catch { el.style.display = 'none'; return; }
const s = data && data.session;
if (!s) { el.style.display = 'none'; return; }
const title = (s.title && s.title.trim()) ? s.title.trim() : 'Онлайн-урок';
document.getElementById('ll-title').textContent = (isTeacher ? 'Ваш урок идёт: ' : 'Идёт урок: ') + title;
let sub;
if (isTeacher) {
const online = Array.isArray(s.attendance) ? s.attendance.filter(a => !a.left_at).length : 0;
sub = online ? (online + ' онлайн') : 'ожидание учеников';
} else {
sub = data.wasJoined ? 'Вы участник — вернуться к доске' : 'Нажмите, чтобы присоединиться';
}
document.getElementById('ll-sub').textContent = sub;
document.getElementById('ll-cta').textContent = isTeacher
? 'Вернуться к доске'
: (data.wasJoined ? 'Вернуться' : 'Присоединиться');
el.style.display = '';
}
// Real-time SSE for page-specific events (notif handled by notifications.js)
LS.connectSSE(ev => {
if (ev.type === 'assignment') {
LS.toast(ev.message, 'info');
isTeacher ? loadAdminAssignments() : loadAssignments();
} else if (ev.type === 'classroom_live') {
loadLiveLesson();
if (ev.state === 'started' && !isTeacher && window.LS && LS.sfx) LS.sfx.play('user_joined');
} else if (ev.type === 'session') {
LS.toast(ev.message, 'info');
if (isTeacher) loadAdminSessions();
} else if (ev.type === 'achievement') {
if (window.LS && LS.sfx) LS.sfx.play('achievement');
} else if (ev.type === 'xp_update') {
if (window.LS && LS.sfx) {
if (ev.levelUp) LS.sfx.play('level_up');
else LS.sfx.play('xp_gain');
}
} else if (ev.type === 'coins') {
if (window.LS && LS.sfx) LS.sfx.play('coin');
}
});
if (window.lucide) lucide.createIcons();
</script>
</div>
</div>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
</body>
</html>