morozovka-portal/app/templates/schedule.html

496 lines
30 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% block title %}Расписание — Morozovka Portal{% endblock %}
{% block main_class %}container container-wide{% endblock %}
{% block head %}
<style>
h1 { color: #58a6ff; margin: 0 0 15px; }
.back-link { display: inline-block; margin-bottom: 15px; color: #8b949e; text-decoration: none; }
.back-link:hover { color: #58a6ff; }
.banner { padding: 12px 18px; border-radius: 8px; margin-bottom: 15px; font-size: .9rem; }
.banner-msg { background: rgba(63,185,80,.15); border: 1px solid #238636; color: #3fb950; }
.banner-error { background: rgba(248,81,73,.15); border: 1px solid #f85149; color: #f85149; }
.status-banner { padding: 18px 20px; border-radius: 10px; text-align: center; margin-bottom: 20px; }
.status-on_place { background: rgba(63,185,80,.15); border: 1px solid #238636; color: #3fb950; }
.status-delayed { background: rgba(210,153,34,.15); border: 1px solid #d29922; color: #d29922; }
.status-away, .status-cancelled { background: rgba(248,81,73,.15); border: 1px solid #f85149; color: #f85149; }
.status-free { background: rgba(139,148,158,.15); border: 1px solid #8b949e; color: #8b949e; }
.status-title { font-size: 1.3rem; font-weight: bold; margin-bottom: 4px; }
.status-message { font-size: .95rem; opacity: .9; }
.calendar { background: #161b22; border: 1px solid #30363d; border-radius: 12px; overflow: hidden; }
.cal-toolbar { display: flex; align-items: center; justify-content: space-between; padding: 14px 20px; border-bottom: 1px solid #30363d; gap: 15px; flex-wrap: wrap; }
.cal-nav { display: flex; gap: 8px; align-items: center; }
.btn-icon { background: #21262d; border: 1px solid #30363d; color: #e6edf3; width: 36px; height: 36px; border-radius: 6px; cursor: pointer; font-size: 1.1rem; display: inline-flex; align-items: center; justify-content: center; }
.btn-icon:hover { background: #30363d; }
.btn-secondary { background: #21262d; border: 1px solid #30363d; color: #e6edf3; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: .9rem; }
.btn-secondary:hover { background: #30363d; }
.cal-title { font-size: 1.2rem; margin: 0; color: #e6edf3; font-weight: 600; flex: 1; text-align: center; min-width: 150px; }
.view-switcher { display: flex; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; overflow: hidden; }
.view-switcher button { background: transparent; border: none; color: #8b949e; padding: 8px 14px; cursor: pointer; font-weight: 600; font-size: .9rem; transition: all .15s; }
.view-switcher button:hover { color: #e6edf3; }
.view-switcher button.active { background: #58a6ff; color: #fff; }
.cal-filters { display: flex; gap: 10px; padding: 12px 20px; border-bottom: 1px solid #30363d; flex-wrap: wrap; background: #0d1117; }
.filter-chip { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; background: #21262d; border: 1px solid #30363d; border-radius: 20px; cursor: pointer; font-size: .85rem; color: #8b949e; user-select: none; transition: all .15s; }
.filter-chip:hover { border-color: #58a6ff; }
.filter-chip input { display: none; }
.filter-chip:has(input:checked) { background: rgba(88,166,255,.1); border-color: #58a6ff; color: #e6edf3; }
.month-header { display: grid; grid-template-columns: repeat(7,1fr); background: #0d1117; border-bottom: 1px solid #30363d; }
.month-header-cell { padding: 10px; text-align: center; color: #8b949e; font-weight: 600; font-size: .75rem; text-transform: uppercase; }
.month-grid { display: grid; grid-template-columns: repeat(7,1fr); grid-auto-rows: minmax(110px,auto); }
.month-cell { border-right: 1px solid #30363d; border-bottom: 1px solid #30363d; padding: 6px; overflow: hidden; position: relative; min-height: 110px; }
.month-cell:nth-child(7n) { border-right: none; }
.month-cell.other-month { background: rgba(0,0,0,.15); }
.month-cell.other-month .month-day-num { color: #4d5560; }
.month-cell.today { background: rgba(88,166,255,.05); }
.month-cell.today .month-day-num { background: #58a6ff; color: #fff; border-radius: 50%; width: 24px; height: 24px; display: inline-flex; align-items: center; justify-content: center; }
.month-day-num { font-size: .85rem; color: #8b949e; font-weight: 600; margin-bottom: 6px; }
.month-event { display: flex; gap: 4px; font-size: .7rem; padding: 2px 5px; border-radius: 3px; margin-bottom: 3px; cursor: pointer; overflow: hidden; white-space: nowrap; border-left: 3px solid; }
.month-event:hover { opacity: .85; }
.month-event .event-time { font-weight: 600; opacity: .75; }
.month-event .event-title { overflow: hidden; text-overflow: ellipsis; }
.month-more { font-size: .7rem; color: #58a6ff; padding: 2px 4px; }
.week-view, .day-view { display: flex; flex-direction: column; }
.week-header { display: grid; grid-template-columns: 50px repeat(7,1fr); background: #0d1117; border-bottom: 1px solid #30363d; }
.week-day-header { padding: 10px 4px; text-align: center; border-left: 1px solid #30363d; }
.week-day-header.today .day-num { background: #58a6ff; color: #fff; border-radius: 50%; width: 26px; height: 26px; display: inline-flex; align-items: center; justify-content: center; margin: 0 auto; }
.day-name { font-size: .7rem; color: #8b949e; text-transform: uppercase; font-weight: 600; margin-bottom: 4px; }
.day-num { font-size: 1.1rem; font-weight: 600; color: #e6edf3; }
.week-body { display: grid; grid-template-columns: 50px repeat(7,1fr); overflow-y: auto; max-height: 560px; position: relative; }
.day-body { display: grid; grid-template-columns: 50px 1fr; overflow-y: auto; max-height: 600px; position: relative; }
.time-gutter-col { background: #0d1117; border-right: 1px solid #30363d; }
.hour-label { height: 40px; padding: 2px 6px; font-size: .7rem; color: #6e7681; text-align: right; }
.day-col { position: relative; border-left: 1px solid #30363d; }
.day-col-single { border-left: none; }
.hour-cell { height: 40px; border-bottom: 1px solid #21262d; }
.week-event { position: absolute; background: rgba(88,166,255,.2); border-left: 3px solid #58a6ff; border-radius: 4px; padding: 3px 6px; font-size: .7rem; color: #e6edf3; overflow: hidden; cursor: pointer; z-index: 2; min-height: 18px; transition: filter .15s; }
.week-event:hover { filter: brightness(1.3); z-index: 10; }
.we-time { font-size: .65rem; opacity: .85; font-weight: 600; }
.we-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; }
.we-loc { font-size: .65rem; opacity: .7; margin-top: 1px; }
.we-desc { font-size: .7rem; opacity: .8; margin-top: 4px; line-height: 1.3; }
.now-line { position: absolute; left: 0; right: 0; height: 2px; background: #f85149; z-index: 20; pointer-events: none; }
.now-line::before { content: ''; position: absolute; left: -4px; top: -3px; width: 8px; height: 8px; background: #f85149; border-radius: 50%; }
.event-type-lecture { background: rgba(88,166,255,.18); border-left-color: #58a6ff; }
.event-type-workshop { background: rgba(163,113,247,.18); border-left-color: #a371f7; }
.event-type-meeting { background: rgba(210,153,34,.18); border-left-color: #d29922; }
.event-type-personal { background: rgba(139,148,158,.18); border-left-color: #8b949e; }
.month-event.event-type-lecture { background: rgba(88,166,255,.15); color: #58a6ff; }
.month-event.event-type-workshop { background: rgba(163,113,247,.15); color: #a371f7; }
.month-event.event-type-meeting { background: rgba(210,153,34,.15); color: #d29922; }
.month-event.event-type-personal { background: rgba(139,148,158,.15); color: #8b949e; }
.event-popup { position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%); background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 25px; max-width: 420px; width: 90%; z-index: 1000; box-shadow: 0 20px 50px rgba(0,0,0,.6); }
.event-popup.hidden { display: none; }
.event-popup h3 { margin: 0 0 12px; color: #58a6ff; }
.popup-close { position: absolute; top: 12px; right: 15px; cursor: pointer; color: #8b949e; font-size: 1.1rem; }
.popup-close:hover { color: #f85149; }
#popup-meta { color: #8b949e; font-size: .9rem; line-height: 1.8; }
#popup-desc { margin-top: 15px; color: #c9d1d9; line-height: 1.5; white-space: pre-wrap; }
.subscribe-box { background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 20px 25px; margin-top: 20px; }
.subscribe-box h3 { margin: 0 0 8px; color: #58a6ff; font-size: 1.1rem; }
.subscribe-box p { color: #8b949e; margin: 0 0 15px; font-size: .9rem; line-height: 1.5; }
.sub-form { display: flex; flex-direction: column; gap: 12px; max-width: 500px; }
.sub-checkbox { display: flex; align-items: center; gap: 8px; color: #c9d1d9; font-size: .9rem; cursor: pointer; }
.sub-checkbox input { width: 16px; height: 16px; }
.btn-primary { background: #238636; color: #fff; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; font-weight: bold; font-size: .95rem; display: inline-block; text-decoration: none; }
.btn-primary:hover { background: #2ea043; text-decoration: none; }
.btn-danger { background: #da3633; color: #fff; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; font-weight: bold; font-size: .95rem; }
.btn-danger:hover { background: #f85149; }
.sub-active-badge { display: inline-block; padding: 4px 10px; background: rgba(63,185,80,.15); border: 1px solid #238636; color: #3fb950; border-radius: 20px; font-size: .8rem; margin-bottom: 10px; }
.chat-fab { position: fixed; bottom: 25px; right: 25px; width: 60px; height: 60px; border-radius: 50%; background: linear-gradient(135deg,#58a6ff,#a371f7); color: #fff; border: none; font-size: 1.5rem; cursor: pointer; box-shadow: 0 6px 20px rgba(88,166,255,.4); z-index: 999; transition: transform .2s; display: flex; align-items: center; justify-content: center; }
.chat-fab:hover { transform: scale(1.08); }
.chat-window { position: fixed; bottom: 95px; right: 25px; width: 360px; height: 500px; background: #161b22; border: 1px solid #30363d; border-radius: 12px; display: flex; flex-direction: column; z-index: 998; box-shadow: 0 20px 50px rgba(0,0,0,.6); overflow: hidden; }
.chat-window.hidden { display: none; }
.chat-header { padding: 14px 18px; background: #0d1117; border-bottom: 1px solid #30363d; font-weight: 600; display: flex; justify-content: space-between; align-items: center; }
.chat-close { background: none; border: none; color: #8b949e; cursor: pointer; font-size: 1rem; }
.chat-close:hover { color: #f85149; }
.chat-messages { flex: 1; padding: 15px; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; }
.msg { max-width: 85%; padding: 8px 12px; border-radius: 10px; font-size: .9rem; line-height: 1.4; white-space: pre-wrap; word-wrap: break-word; }
.msg.bot { background: #21262d; border: 1px solid #30363d; align-self: flex-start; }
.msg.user { background: #1f6feb; color: #fff; align-self: flex-end; }
.chat-form { display: flex; padding: 10px; border-top: 1px solid #30363d; background: #0d1117; gap: 8px; }
.chat-form input { flex: 1; padding: 10px 12px; background: #21262d; border: 1px solid #30363d; color: #fff; border-radius: 6px; outline: none; font-size: .9rem; }
.chat-form input:focus { border-color: #58a6ff; }
.chat-form button { padding: 10px 16px; background: #238636; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; }
.chat-form button:hover { background: #2ea043; }
@media (max-width: 768px) {
.cal-title { font-size: 1rem; }
.view-switcher button { padding: 6px 10px; font-size: .8rem; }
.chat-window { width: calc(100vw - 20px); right: 10px; bottom: 90px; height: 60vh; }
.month-cell { min-height: 70px; }
.month-event .event-title { display: none; }
.week-body { max-height: 400px; }
}
</style>
{% endblock %}
{% block content %}
<a href="/" class="back-link">← На главную</a>
<h1>Расписание</h1>
{% if msg %}<div class="banner banner-msg">{{ msg }}</div>{% endif %}
{% if error %}<div class="banner banner-error">{{ error }}</div>{% endif %}
{% if status %}
<div class="status-banner status-{{ status.status }}">
<div class="status-title">
{% if status.status == 'on_place' %}На месте
{% elif status.status == 'delayed' %}Опаздываю на {{ status.delay_minutes }} мин
{% elif status.status == 'away' %}Вне вуза
{% elif status.status == 'cancelled' %}Отменено
{% else %}Свободен / Вне расписания{% endif %}
</div>
{% if status.message %}<div class="status-message">{{ status.message }}</div>{% endif %}
</div>
{% endif %}
<div class="calendar">
<div class="cal-toolbar">
<div class="cal-nav">
<button class="btn-icon" id="cal-prev">←</button>
<button class="btn-secondary" id="cal-today">Сегодня</button>
<button class="btn-icon" id="cal-next">→</button>
</div>
<h2 class="cal-title" id="cal-title">—</h2>
<div class="view-switcher">
<button data-view="day">День</button>
<button data-view="week" class="active">Неделя</button>
<button data-view="month">Месяц</button>
</div>
</div>
<div class="cal-filters" id="cal-filters">
<label class="filter-chip"><input type="checkbox" data-type="lecture" checked><span>📚 Лекции</span></label>
<label class="filter-chip"><input type="checkbox" data-type="workshop" checked><span>🛠 Мастер-классы</span></label>
<label class="filter-chip"><input type="checkbox" data-type="meeting" checked><span>👥 Встречи</span></label>
<label class="filter-chip"><input type="checkbox" data-type="personal" checked><span>🔒 Личное</span></label>
</div>
<div class="cal-body" id="cal-body"></div>
</div>
<div class="subscribe-box">
{% if not current_user %}
<h3>📬 Уведомления о расписании</h3>
<p>Войдите в аккаунт, чтобы подписаться на уведомления об изменениях статуса и событий.</p>
<a href="/login" class="btn-primary">Войти</a>
{% elif not current_user.email %}
<h3>📬 Уведомления о расписании</h3>
<p>В вашем профиле не указан email — укажите его, чтобы получать уведомления.</p>
<a href="/profile" class="btn-primary">Открыть профиль</a>
{% elif my_subscription and my_subscription.is_confirmed %}
<h3>📬 Вы подписаны на уведомления</h3>
<p>
<span class="sub-active-badge">✅ {{ current_user.email }}</span>
{% if my_subscription.notify_status %}<span style="margin-left: 8px;">✓ статус</span>{% endif %}
{% if my_subscription.notify_events %}<span style="margin-left: 8px;">✓ события</span>{% endif %}
</p>
<form method="POST" action="/subscribe/toggle">
<input type="hidden" name="action" value="unsubscribe">
<input type="hidden" name="next" value="/schedule">
<button type="submit" class="btn-danger">Отписаться</button>
</form>
{% else %}
<h3>📬 Подписаться на уведомления</h3>
<p>Получайте email, когда меняется статус или расписание. Email берётся из аккаунта — подтверждение не нужно.</p>
<form method="POST" action="/subscribe/toggle" class="sub-form">
<input type="hidden" name="action" value="subscribe">
<input type="hidden" name="next" value="/schedule">
<label class="sub-checkbox"><input type="checkbox" name="notify_status" checked> Изменения статуса</label>
<label class="sub-checkbox"><input type="checkbox" name="notify_events" checked> Изменения событий</label>
<div><button type="submit" class="btn-primary">Подписаться на {{ current_user.email }}</button></div>
</form>
{% endif %}
</div>
<div id="event-popup" class="event-popup hidden">
<span class="popup-close" id="popup-close">✕</span>
<h3 id="popup-title"></h3>
<div id="popup-meta"></div>
<div id="popup-desc"></div>
</div>
<button id="chat-toggle" class="chat-fab" aria-label="Открыть чат">💬</button>
<div id="chat-window" class="chat-window hidden">
<div class="chat-header">
<span>🤖 Ассистент</span>
<button id="chat-close" class="chat-close">✕</button>
</div>
<div class="chat-messages" id="chat-messages">
<div class="msg bot">Привет! Спроси меня: «Где сейчас?», «Когда следующая пара?», «Что на неделе?»</div>
</div>
<form id="chat-form" class="chat-form">
<input id="chat-input" placeholder="Ваш вопрос..." autocomplete="off">
<button type="submit">→</button>
</form>
</div>
{% endblock %}
{% block scripts %}
<script>
const EVENTS = {{ events_json|tojson }};
const MSK_OFFSET_MS = 3 * 60 * 60 * 1000;
const state = { view: 'week', cursor: new Date(), filters: new Set(['lecture','workshop','meeting','personal']) };
function inMSK(d) { return new Date(d.getTime() + MSK_OFFSET_MS); }
function fromMSK(y, m, d, h, mn) { return new Date(Date.UTC(y, m, d, h||0, mn||0) - MSK_OFFSET_MS); }
function mskParts(d) {
const m = inMSK(d);
return { year: m.getUTCFullYear(), month: m.getUTCMonth(), day: m.getUTCDate(),
hour: m.getUTCHours(), minute: m.getUTCMinutes(), weekday: m.getUTCDay() };
}
function startOfMSKDay(d) { const p = mskParts(d); return fromMSK(p.year, p.month, p.day); }
function startOfMSKWeek(d) {
const sod = startOfMSKDay(d);
const dow = mskParts(sod).weekday;
return new Date(sod.getTime() - ((dow + 6) % 7) * 86400000);
}
function startOfMSKMonth(d) { const p = mskParts(d); return fromMSK(p.year, p.month, 1); }
function addDays(d, n) { return new Date(d.getTime() + n * 86400000); }
function addMonths(d, n) {
const p = mskParts(d);
const target = new Date(Date.UTC(p.year, p.month + n, 1));
return fromMSK(target.getUTCFullYear(), target.getUTCMonth(), 1);
}
function fmtTime(d) { const p = mskParts(d); return String(p.hour).padStart(2,'0')+':'+String(p.minute).padStart(2,'0'); }
function fmtDate(d) { const p = mskParts(d); return String(p.day).padStart(2,'0')+'.'+String(p.month+1).padStart(2,'0'); }
function fmtDateFull(d) {
const p = mskParts(d);
const days = ['вс','пн','вт','ср','чт','пт','сб'];
return days[p.weekday] + ', ' + String(p.day).padStart(2,'0') + '.' + String(p.month+1).padStart(2,'0') + '.' + p.year;
}
const MONTHS = ['Январь','Февраль','Март','Апрель','Май','Июнь','Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь'];
const MONTHS_GEN = ['января','февраля','марта','апреля','мая','июня','июля','августа','сентября','октября','ноября','декабря'];
function getFilteredEvents() {
return EVENTS.filter(e => state.filters.has(e.type))
.map(e => Object.assign({}, e, { _start: new Date(e.start), _end: new Date(e.end) }));
}
function eventsForDay(dayStart, dayEnd) {
return getFilteredEvents().filter(e => e._start < dayEnd && e._end > dayStart).sort((a,b) => a._start - b._start);
}
function layoutOverlaps(events, dayStart, dayEnd) {
const clipped = events.map(e => {
const s = e._start < dayStart ? dayStart : e._start;
const en = e._end > dayEnd ? dayEnd : e._end;
return Object.assign({}, e, { _clipStart: s, _clipEnd: en });
});
const cols = []; const placed = [];
for (const ev of clipped) {
let col = -1;
for (let c = 0; c < cols.length; c++) {
if (!cols[c].some(o => ev._clipStart < o._clipEnd && ev._clipEnd > o._clipStart)) { col = c; break; }
}
if (col === -1) { col = cols.length; cols.push([]); }
cols[col].push(ev);
placed.push({ event: ev, col: col });
}
const total = cols.length || 1;
return placed.map(p => Object.assign({}, p, { leftPct: (p.col/total)*100, widthPct: (1/total)*100 }));
}
function escapeHtml(s) {
return String(s || '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function render() {
const body = document.getElementById('cal-body');
document.getElementById('cal-title').textContent = getTitle();
if (state.view === 'month') renderMonth(body);
else if (state.view === 'week') renderWeek(body);
else renderDay(body);
}
function getTitle() {
const p = mskParts(state.cursor);
if (state.view === 'month') return MONTHS[p.month] + ' ' + p.year;
if (state.view === 'week') {
const ws = startOfMSKWeek(state.cursor); const we = addDays(ws, 6);
const wp = mskParts(ws); const wep = mskParts(we);
if (wp.month === wep.month) return wp.day + '–' + wep.day + ' ' + MONTHS_GEN[wp.month] + ' ' + wp.year;
return wp.day + ' ' + MONTHS_GEN[wp.month] + ' – ' + wep.day + ' ' + MONTHS_GEN[wep.month] + ' ' + wep.year;
}
return fmtDateFull(state.cursor);
}
function renderMonth(container) {
const first = startOfMSKMonth(state.cursor);
const gridStart = startOfMSKWeek(first);
const days = ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'];
const today = startOfMSKDay(new Date());
let html = '<div class="month-view"><div class="month-header">';
for (const d of days) html += '<div class="month-header-cell">' + d + '</div>';
html += '</div><div class="month-grid">';
for (let i = 0; i < 42; i++) {
const cs = addDays(gridStart, i);
const ce = addDays(cs, 1);
const p = mskParts(cs);
const isOther = p.month !== mskParts(first).month;
const isToday = cs.getTime() === today.getTime();
const evs = eventsForDay(cs, ce);
html += '<div class="month-cell' + (isOther ? ' other-month' : '') + (isToday ? ' today' : '') + '">';
html += '<div class="month-day-num">' + p.day + '</div>';
for (const ev of evs.slice(0, 3)) {
html += '<div class="month-event event-type-' + ev.type + '" data-event-id="' + ev.id + '"><span class="event-time">' + fmtTime(ev._start) + '</span> <span class="event-title">' + escapeHtml(ev.title) + '</span></div>';
}
if (evs.length > 3) html += '<div class="month-more">+' + (evs.length - 3) + ' ещё</div>';
html += '</div>';
}
html += '</div></div>';
container.innerHTML = html;
attachEventHandlers();
}
function renderWeek(container) {
const weekStart = startOfMSKWeek(state.cursor);
const today = startOfMSKDay(new Date());
let html = '<div class="week-view"><div class="week-header"><div></div>';
const names = ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'];
for (let i = 0; i < 7; i++) {
const ds = addDays(weekStart, i);
const p = mskParts(ds);
const isToday = ds.getTime() === today.getTime();
html += '<div class="week-day-header' + (isToday ? ' today' : '') + '"><div class="day-name">' + names[i] + '</div><div class="day-num">' + p.day + '</div></div>';
}
html += '</div><div class="week-body"><div class="time-gutter-col">';
for (let h = 0; h < 24; h++) html += '<div class="hour-label">' + String(h).padStart(2,'0') + ':00</div>';
html += '</div>';
for (let i = 0; i < 7; i++) {
const ds = addDays(weekStart, i);
const de = addDays(ds, 1);
const evs = eventsForDay(ds, de);
const laid = layoutOverlaps(evs, ds, de);
html += '<div class="day-col">';
for (let h = 0; h < 24; h++) html += '<div class="hour-cell"></div>';
const dayMs = 86400000;
for (const p of laid) {
const top = ((p.event._clipStart - ds) / dayMs) * 100;
const height = Math.max(((p.event._clipEnd - p.event._clipStart) / dayMs) * 100, 1.5);
html += '<div class="week-event event-type-' + p.event.type + '" style="top:' + top + '%;height:' + height + '%;left:' + p.leftPct + '%;width:calc(' + p.widthPct + '% - 2px);" data-event-id="' + p.event.id + '">';
html += '<div class="we-time">' + fmtTime(p.event._start) + '–' + fmtTime(p.event._end) + '</div>';
html += '<div class="we-title">' + escapeHtml(p.event.title) + '</div>';
if (p.event.location) html += '<div class="we-loc">📍 ' + escapeHtml(p.event.location) + '</div>';
html += '</div>';
}
if (ds.getTime() === today.getTime()) {
const now = new Date();
if (now >= ds && now < de) {
const nowFrac = (now - ds) / dayMs;
html += '<div class="now-line" style="top:' + (nowFrac * 100) + '%;"></div>';
}
}
html += '</div>';
}
html += '</div></div>';
container.innerHTML = html;
attachEventHandlers();
if (!container.dataset.scrolled) {
const nowParts = mskParts(new Date());
const wb = container.querySelector('.week-body');
if (wb) wb.scrollTop = Math.max(0, (nowParts.hour - 1) * 40);
container.dataset.scrolled = '1';
}
}
function renderDay(container) {
const ds = startOfMSKDay(state.cursor);
const de = addDays(ds, 1);
const evs = eventsForDay(ds, de);
const laid = layoutOverlaps(evs, ds, de);
let html = '<div class="day-view"><div class="day-body"><div class="time-gutter-col">';
for (let h = 0; h < 24; h++) html += '<div class="hour-label">' + String(h).padStart(2,'0') + ':00</div>';
html += '</div><div class="day-col day-col-single">';
for (let h = 0; h < 24; h++) html += '<div class="hour-cell"></div>';
const dayMs = 86400000;
for (const p of laid) {
const top = ((p.event._clipStart - ds) / dayMs) * 100;
const height = Math.max(((p.event._clipEnd - p.event._clipStart) / dayMs) * 100, 1.5);
html += '<div class="week-event event-type-' + p.event.type + '" style="top:' + top + '%;height:' + height + '%;left:' + p.leftPct + '%;width:calc(' + p.widthPct + '% - 8px);" data-event-id="' + p.event.id + '">';
html += '<div class="we-time">' + fmtTime(p.event._start) + '–' + fmtTime(p.event._end) + '</div>';
html += '<div class="we-title">' + escapeHtml(p.event.title) + '</div>';
if (p.event.location) html += '<div class="we-loc">📍 ' + escapeHtml(p.event.location) + '</div>';
if (p.event.description) html += '<div class="we-desc">' + escapeHtml(p.event.description) + '</div>';
html += '</div>';
}
const now = new Date();
if (now >= ds && now < de) {
const nowFrac = (now - ds) / dayMs;
html += '<div class="now-line" style="top:' + (nowFrac * 100) + '%;"></div>';
}
html += '</div></div></div>';
container.innerHTML = html;
attachEventHandlers();
}
function attachEventHandlers() {
document.querySelectorAll('[data-event-id]').forEach(el => {
el.addEventListener('click', e => { e.stopPropagation(); showEventPopup(parseInt(el.dataset.eventId)); });
});
}
function showEventPopup(id) {
const ev = EVENTS.find(e => e.id === id);
if (!ev) return;
const start = new Date(ev.start); const end = new Date(ev.end);
const types = { lecture:'📚 Лекция', workshop:'🛠 Мастер-класс', meeting:'👥 Встреча', personal:'🔒 Личное' };
document.getElementById('popup-title').textContent = ev.title;
let meta = '<div>' + (types[ev.type] || ev.type) + '</div>';
meta += '<div>🕐 ' + fmtDate(start) + ' ' + fmtTime(start) + ' – ' + fmtTime(end) + '</div>';
if (ev.location) meta += '<div>📍 ' + escapeHtml(ev.location) + '</div>';
document.getElementById('popup-meta').innerHTML = meta;
document.getElementById('popup-desc').textContent = ev.description || '';
document.getElementById('event-popup').classList.remove('hidden');
}
document.getElementById('cal-prev').addEventListener('click', () => {
if (state.view === 'month') state.cursor = addMonths(state.cursor, -1);
else if (state.view === 'week') state.cursor = addDays(state.cursor, -7);
else state.cursor = addDays(state.cursor, -1);
render();
});
document.getElementById('cal-next').addEventListener('click', () => {
if (state.view === 'month') state.cursor = addMonths(state.cursor, 1);
else if (state.view === 'week') state.cursor = addDays(state.cursor, 7);
else state.cursor = addDays(state.cursor, 1);
render();
});
document.getElementById('cal-today').addEventListener('click', () => { state.cursor = new Date(); render(); });
document.querySelectorAll('.view-switcher button').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.view-switcher button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
state.view = btn.dataset.view;
render();
});
});
document.querySelectorAll('#cal-filters input').forEach(cb => {
cb.addEventListener('change', () => {
if (cb.checked) state.filters.add(cb.dataset.type);
else state.filters.delete(cb.dataset.type);
render();
});
});
document.getElementById('popup-close').addEventListener('click', () => {
document.getElementById('event-popup').classList.add('hidden');
});
document.addEventListener('click', e => {
const popup = document.getElementById('event-popup');
if (!popup.classList.contains('hidden') && !popup.contains(e.target) && !e.target.closest('[data-event-id]')) {
popup.classList.add('hidden');
}
});
const chatWindow = document.getElementById('chat-window');
document.getElementById('chat-toggle').addEventListener('click', () => chatWindow.classList.toggle('hidden'));
document.getElementById('chat-close').addEventListener('click', () => chatWindow.classList.add('hidden'));
document.getElementById('chat-form').addEventListener('submit', async e => {
e.preventDefault();
const input = document.getElementById('chat-input');
const text = input.value.trim(); if (!text) return;
addMsg(text, 'user'); input.value = '';
const loader = addMsg('...', 'bot');
try {
const res = await fetch('/api/assistant', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text }),
});
const data = await res.json();
loader.textContent = data.reply || 'Ошибка';
} catch (e) { loader.textContent = 'Ошибка соединения'; }
});
function addMsg(text, sender) {
const div = document.createElement('div');
div.className = 'msg ' + sender; div.textContent = text;
const c = document.getElementById('chat-messages');
c.appendChild(div); c.scrollTop = c.scrollHeight;
return div;
}
render();
</script>
{% endblock %}