const MOSCOW_TIMEZONE = 'Europe/Moscow'; const WEEKDAYS = ['Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота', 'Воскресенье']; const MONTHS = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря']; const VIEW_WEEKS = 4; const SLOT_MINUTES = 15; const START_HOUR = 8; const END_HOUR = 20; const PIXELS_PER_MINUTE = 0.8; let currentRangeStart = null; let scheduleItems = []; let itemIndex = new Map(); function getMoscowDateString() { const now = new Date(); const formatter = new Intl.DateTimeFormat('en-CA', { timeZone: MOSCOW_TIMEZONE, year: 'numeric', month: '2-digit', day: '2-digit' }); return formatter.format(now); } function parseISODate(value) { const [year, month, day] = value.split('-').map(Number); return new Date(year, month - 1, day); } function formatISODate(date) { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; } function addDays(dateStr, days) { const date = parseISODate(dateStr); date.setDate(date.getDate() + days); return formatISODate(date); } function startOfWeek(dateStr = null) { const base = parseISODate(dateStr || getMoscowDateString()); const jsDay = base.getDay(); const diff = jsDay === 0 ? -6 : 1 - jsDay; base.setDate(base.getDate() + diff); return formatISODate(base); } function getVisibleDates() { return Array.from({ length: VIEW_WEEKS * 7 }, (_, index) => addDays(currentRangeStart, index)); } function formatDayLabel(dateStr) { const date = parseISODate(dateStr); const weekday = WEEKDAYS[(date.getDay() + 6) % 7]; return { title: `${weekday}, ${date.getDate()} ${MONTHS[date.getMonth()]}`, short: weekday.slice(0, 2), monthDay: `${date.getDate()} ${MONTHS[date.getMonth()]}` }; } function calculateEndTime(startTime, durationMin) { const [hour, minute] = startTime.split(':').map(Number); const totalMinutes = hour * 60 + minute + durationMin; const endHour = Math.floor(totalMinutes / 60); const endMinute = totalMinutes % 60; return `${String(endHour).padStart(2, '0')}:${String(endMinute).padStart(2, '0')}`; } function timeToMinutes(time) { const [hour, minute] = time.split(':').map(Number); return hour * 60 + minute; } function minutesToTime(totalMinutes) { const hour = Math.floor(totalMinutes / 60); const minute = totalMinutes % 60; return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`; } function rebuildItemIndex() { itemIndex = new Map(); scheduleItems.forEach((item) => { itemIndex.set(`${item.kind}:${item.id}:${item.source_date || item.date}`, item); }); } async function loadSchedule() { if (!currentRangeStart) { currentRangeStart = startOfWeek(); } const fromDate = currentRangeStart; const toDate = addDays(currentRangeStart, VIEW_WEEKS * 7 - 1); const response = await fetch(`/api/schedule?from_date=${fromDate}&to_date=${toDate}`); if (!response.ok) { const error = await response.json().catch(() => ({})); throw new Error(error.detail || 'Не удалось загрузить расписание'); } const data = await response.json(); scheduleItems = data.items || []; rebuildItemIndex(); renderSchedule(); } function renderSchedule() { const grid = document.getElementById('schedule-grid'); const dates = getVisibleDates(); const today = getMoscowDateString(); grid.innerHTML = dates.map((dateStr) => { const dayItems = scheduleItems.filter((item) => item.date === dateStr); const tasks = dayItems.filter((item) => item.kind === 'task'); const events = dayItems .filter((item) => item.kind === 'event') .sort((a, b) => a.start_time.localeCompare(b.start_time)); const label = formatDayLabel(dateStr); const weekNumber = Math.floor(dates.indexOf(dateStr) / 7) + 1; return `

${label.title}

Неделя ${weekNumber} в текущем окне
${dateStr === today ? 'Сегодня' : label.short}
${tasks.map(renderTaskCard).join('')}
Занятия
${renderTimeline(dateStr, events)}
`; }).join(''); document.getElementById('week-range').textContent = `${formatDayLabel(dates[0]).title} - ${formatDayLabel(dates[dates.length - 1]).title}`; bindInteractiveHandlers(); } function renderTaskCard(task) { return `
${escapeHtml(task.title)}
${task.repeat_weekly ? 'Цикличная задача, обновления можно применить ко всей серии.' : 'Разовая задача.'}
`; } function renderTimeline(dateStr, events) { const hours = Array.from({ length: END_HOUR - START_HOUR + 1 }, (_, index) => START_HOUR + index); return `
${hours.map((hour, index) => `
${String(hour).padStart(2, '0')}:00
`).join('')} ${events.map((event) => renderEventBlock(event)).join('')}
`; } function renderEventBlock(event) { const startMinutes = timeToMinutes(event.start_time) - START_HOUR * 60; const top = Math.max(0, startMinutes * PIXELS_PER_MINUTE); const height = Math.max(42, event.duration_min * PIXELS_PER_MINUTE); const endTime = calculateEndTime(event.start_time, event.duration_min); return `
${event.start_time} - ${endTime}
${escapeHtml(event.title)}
${event.repeat_weekly ? 'Цикличное занятие' : 'Разовое занятие'}
`; } function bindInteractiveHandlers() { document.removeEventListener('click', clearActiveItems); document.addEventListener('click', clearActiveItems); document.querySelectorAll('[data-action="edit"]').forEach((button) => { button.addEventListener('click', (event) => { event.stopPropagation(); openEditModal(button.dataset.kind, Number(button.dataset.id), button.dataset.date); }); }); document.querySelectorAll('[data-action="delete"]').forEach((button) => { button.addEventListener('click', (event) => { event.stopPropagation(); deleteItem(button.dataset.kind, Number(button.dataset.id), button.dataset.date); }); }); document.querySelectorAll('.task-item').forEach((item) => { item.addEventListener('click', (event) => { event.stopPropagation(); toggleActiveItem(item); }); }); document.querySelectorAll('.event-block').forEach((block) => { block.addEventListener('click', (event) => { event.stopPropagation(); toggleActiveItem(block); }); }); document.querySelectorAll('.day-card').forEach((card, index) => { card.addEventListener('click', (event) => { if (event.target.closest('button, .event-block, .task-item')) { return; } const targetDate = getVisibleDates()[index]; if (event.ctrlKey || event.metaKey) { showTaskModal({ date: targetDate }); return; } showEventModal({ date: targetDate }); }); }); document.querySelectorAll('.event-block').forEach((block) => { block.addEventListener('dragstart', handleDragStart); block.addEventListener('dragend', () => block.classList.remove('dragging')); }); document.querySelectorAll('.timeline-dropzone').forEach((zone) => { zone.addEventListener('dragover', (event) => event.preventDefault()); zone.addEventListener('drop', handleEventDrop); }); } function clearActiveItems() { document.querySelectorAll('.task-item.is-active, .event-block.is-active').forEach((item) => { item.classList.remove('is-active'); }); } function toggleActiveItem(element) { const isActive = element.classList.contains('is-active'); clearActiveItems(); if (!isActive) { element.classList.add('is-active'); } } function handleDragStart(event) { const block = event.currentTarget; block.classList.add('dragging'); event.dataTransfer.setData('text/plain', JSON.stringify({ id: Number(block.dataset.id), occurrenceDate: block.dataset.date })); } async function handleEventDrop(event) { event.preventDefault(); const raw = event.dataTransfer.getData('text/plain'); if (!raw) { return; } const payload = JSON.parse(raw); const item = itemIndex.get(`event:${payload.id}:${payload.occurrenceDate}`); if (!item) { return; } const rect = event.currentTarget.getBoundingClientRect(); const relativeY = Math.max(0, Math.min(rect.height, event.clientY - rect.top)); const minutesFromStart = Math.round((relativeY / PIXELS_PER_MINUTE) / SLOT_MINUTES) * SLOT_MINUTES; const clampedMinutes = Math.min((END_HOUR - START_HOUR) * 60 - item.duration_min, Math.max(0, minutesFromStart)); const newTime = minutesToTime(START_HOUR * 60 + clampedMinutes); const newDate = event.currentTarget.dataset.dropDate; let scope = null; if (item.repeat_weekly) { scope = promptRecurringScope('Переместить только это занятие или всю серию?', true); if (!scope) { return; } } await saveItemUpdate('event', item.id, { occurrence_date: item.source_date || item.date, date: newDate, start_time: newTime, duration_min: item.duration_min, scope }); } function promptRecurringScope(message, allowCancel = false) { const answer = window.prompt(`${message}\nВведите "1" для одного вхождения, "2" для всей серии.${allowCancel ? ' Оставьте пустым для отмены.' : ''}`, '2'); if (!answer && allowCancel) { return null; } if (answer === '1') { return 'one_date'; } return 'series'; } function showModal(content) { document.getElementById('modal-body').innerHTML = content; document.getElementById('modal').style.display = 'block'; } function closeModal() { document.getElementById('modal').style.display = 'none'; } function buildScopeSelector(item) { if (!item.repeat_weekly) { return ''; } return `
`; } function showTaskModal(initial = {}, item = null) { const isEdit = Boolean(item); showModal(`
${isEdit ? buildScopeSelector(item) : ''}
`); document.getElementById('cancel-task').addEventListener('click', closeModal); document.getElementById('task-form').addEventListener('submit', async (event) => { event.preventDefault(); const payload = { date: document.getElementById('task-date').value, title: document.getElementById('task-title').value.trim(), repeat_weekly: document.getElementById('task-repeat-weekly').checked }; if (isEdit) { payload.occurrence_date = item.source_date || item.date; payload.scope = item.repeat_weekly ? document.querySelector('input[name="edit-scope"]:checked').value : null; await saveItemUpdate('task', item.id, payload); } else { await createItem('task', payload); } }); } function showEventModal(initial = {}, item = null) { const isEdit = Boolean(item); const duration = initial.duration_min || 60; showModal(`
${isEdit ? buildScopeSelector(item) : ''}
`); document.getElementById('cancel-event').addEventListener('click', closeModal); document.getElementById('event-form').addEventListener('submit', async (event) => { event.preventDefault(); const payload = { date: document.getElementById('event-date').value, start_time: document.getElementById('event-start-time').value, duration_min: Number(document.getElementById('event-duration').value), title: document.getElementById('event-title').value.trim(), repeat_weekly: document.getElementById('event-repeat-weekly').checked }; if (isEdit) { payload.occurrence_date = item.source_date || item.date; payload.scope = item.repeat_weekly ? document.querySelector('input[name="edit-scope"]:checked').value : null; await saveItemUpdate('event', item.id, payload); } else { await createItem('event', payload); } }); } async function createItem(kind, payload) { const response = await fetch(`/api/events?kind=${kind}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!response.ok) { const error = await response.json().catch(() => ({})); alert(error.detail || 'Не удалось создать запись'); return; } closeModal(); await loadSchedule(); } async function saveItemUpdate(kind, id, payload) { const response = await fetch(`/api/events/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!response.ok) { const error = await response.json().catch(() => ({})); alert(error.detail || 'Не удалось изменить запись'); return; } closeModal(); await loadSchedule(); } async function openEditModal(kind, id, occurrenceDate) { const item = itemIndex.get(`${kind}:${id}:${occurrenceDate}`); if (!item) { return; } if (kind === 'task') { showTaskModal(item, item); } else { showEventModal(item, item); } } async function deleteItem(kind, id, occurrenceDate) { const item = itemIndex.get(`${kind}:${id}:${occurrenceDate}`); if (!item) { return; } let scope = null; if (item.repeat_weekly) { scope = promptRecurringScope('Удалить только это вхождение или всю серию?', true); if (!scope) { return; } } const url = new URL(`/api/events/${id}`, window.location.origin); if (scope) { url.searchParams.set('scope', scope); } if (item.repeat_weekly) { url.searchParams.set('occurrence_date', occurrenceDate); } const response = await fetch(url.toString(), { method: 'DELETE' }); if (!response.ok) { const error = await response.json().catch(() => ({})); alert(error.detail || 'Не удалось удалить запись'); return; } await loadSchedule(); } function escapeHtml(value) { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function escapeAttribute(value) { return escapeHtml(value); } document.addEventListener('DOMContentLoaded', async () => { currentRangeStart = startOfWeek(); document.getElementById('prev-week').addEventListener('click', async () => { currentRangeStart = addDays(currentRangeStart, -7); await loadSchedule(); }); document.getElementById('next-week').addEventListener('click', async () => { currentRangeStart = addDays(currentRangeStart, 7); await loadSchedule(); }); document.getElementById('today-btn').addEventListener('click', async () => { currentRangeStart = startOfWeek(); await loadSchedule(); }); document.getElementById('add-task-btn').addEventListener('click', () => showTaskModal()); document.getElementById('add-event-btn').addEventListener('click', () => showEventModal()); document.querySelector('.close').addEventListener('click', closeModal); window.addEventListener('click', (event) => { if (event.target.id === 'modal') { closeModal(); } }); try { await loadSchedule(); } catch (error) { console.error(error); alert(error.message); } });