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}
У задач нет времени. Для цикличной задачи достаточно включить еженедельный повтор, и она будет появляться автоматически в каждом запросе расписания.
`); 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(`Занятия можно сделать цикличными на каждый выбранный день недели. Дальше серия будет достраиваться автоматически по диапазону, который вы открываете в календаре.
`); 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); } });