const MOSCOW_TIMEZONE = 'Europe/Moscow'; const WEEKDAYS = ['Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота', 'Воскресенье']; const MONTHS = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря']; const SLOT_MINUTES = 15; const START_HOUR = 8; const END_HOUR = 20; const PIXELS_PER_MINUTE = 0.8; const CATPPUCCIN_MOCHA = { rosewater: '#f5e0dc', flamingo: '#f2cdcd', pink: '#f5c2e7', mauve: '#cba6f7', red: '#f38ba8', maroon: '#eba0ac', peach: '#fab387', yellow: '#f9e2af', green: '#a6e3a1', teal: '#94e2d5', sky: '#89dceb', sapphire: '#74c7ec', blue: '#89b4fa', lavender: '#b4befe' }; const DEFAULT_EVENT_COLOR = 'blue'; function eventColorHex(colorKey) { return CATPPUCCIN_MOCHA[colorKey] || CATPPUCCIN_MOCHA[DEFAULT_EVENT_COLOR]; } let currentWeekStart = null; function getMoscowDateString() { const formatter = new Intl.DateTimeFormat('en-CA', { timeZone: MOSCOW_TIMEZONE, year: 'numeric', month: '2-digit', day: '2-digit' }); return formatter.format(new Date()); } 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 timeToMinutes(time) { const [hour, minute] = time.split(':').map(Number); return hour * 60 + minute; } function formatDate(dateStr) { const date = parseISODate(dateStr); return `${WEEKDAYS[(date.getDay() + 6) % 7]}, ${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 renderDay(container, titleEl, dateStr, items, options = {}) { let titleText = formatDate(dateStr); if (options.today) { titleText = `Сегодня, ${titleText.toLowerCase()}`; } else if (options.tomorrow) { titleText = `Завтра, ${titleText.toLowerCase()}`; } titleEl.textContent = titleText; const tasks = items.filter((item) => item.kind === 'task'); const events = items .filter((item) => item.kind === 'event') .sort((a, b) => a.start_time.localeCompare(b.start_time)); if (!tasks.length && !events.length) { container.innerHTML = '
На этот день расписания нет.
'; return; } const tasksHtml = tasks.length ? `
${tasks.map((task) => `
${task.repeat_weekly ? 'Каждую неделю: ' : ''}${escapeHtml(task.title)}
`).join('')}
` : ''; const eventsHtml = events.length ? `
${events.map((event) => `
${event.start_time}-${calculateEndTime(event.start_time, event.duration_min)} ${escapeHtml(event.title)}
`).join('')}
` : ''; container.innerHTML = `${tasksHtml}${eventsHtml}`; } function escapeHtml(value) { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } async function loadSchedule() { const today = getMoscowDateString(); const tomorrow = addDays(today, 1); const response = await fetch(`/api/schedule?from_date=${today}&to_date=${tomorrow}`); if (!response.ok) { throw new Error('Не удалось загрузить расписание'); } const data = await response.json(); renderDay( document.getElementById('today-content'), document.getElementById('today-title'), today, data.items.filter((item) => item.date === today), { today: true } ); renderDay( document.getElementById('tomorrow-content'), document.getElementById('tomorrow-title'), tomorrow, data.items.filter((item) => item.date === tomorrow), { tomorrow: true } ); } function getWeekDates() { return Array.from({ length: 7 }, (_, index) => addDays(currentWeekStart, index)); } 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)}
`; } async function loadWeekSchedule() { if (!currentWeekStart) { currentWeekStart = startOfWeek(); } const dates = getWeekDates(); const fromDate = dates[0]; const toDate = dates[dates.length - 1]; const today = getMoscowDateString(); const response = await fetch(`/api/schedule?from_date=${fromDate}&to_date=${toDate}`); if (!response.ok) { throw new Error('Не удалось загрузить расписание'); } const data = await response.json(); const grid = document.getElementById('week-grid'); grid.innerHTML = dates.map((dateStr) => { const dayItems = data.items.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)); return `
${formatDate(dateStr)}
${tasks.map((task) => `
${escapeHtml(task.title)}
`).join('')}
${renderTimeline(dateStr, events)}
`; }).join(''); document.getElementById('week-range').textContent = `${formatDate(dates[0])} - ${formatDate(dates[dates.length - 1])}`; } function setActiveTab(tab) { const isWeek = tab === 'week'; document.getElementById('view-daily').hidden = isWeek; document.getElementById('view-week').hidden = !isWeek; document.getElementById('tab-daily').classList.toggle('is-active', !isWeek); document.getElementById('tab-week').classList.toggle('is-active', isWeek); } document.addEventListener('DOMContentLoaded', () => { document.getElementById('tab-daily').addEventListener('click', () => setActiveTab('daily')); document.getElementById('tab-week').addEventListener('click', async () => { setActiveTab('week'); try { await loadWeekSchedule(); } catch (error) { console.error(error); } }); document.getElementById('week-prev').addEventListener('click', async () => { currentWeekStart = addDays(currentWeekStart, -7); await loadWeekSchedule().catch((error) => console.error(error)); }); document.getElementById('week-next').addEventListener('click', async () => { currentWeekStart = addDays(currentWeekStart, 7); await loadWeekSchedule().catch((error) => console.error(error)); }); }); loadSchedule().catch((error) => console.error(error)); setInterval(() => loadSchedule().catch((error) => console.error(error)), 5 * 60 * 1000);