scheduleSon/frontend/public/static/script.js
vrubel c40a6569b4
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 20s
Add event colors (Catppuccin Mocha) and read-only week view for users
Admins can now pick a color per event from a Catppuccin Mocha palette;
existing events default to blue. Colors flow through to both the admin
timeline and the new "Неделя" tab in the user view, which reuses the
admin's weekly grid in a read-only mode. Also reworks event blocks to
show a single-line "start–end title" so short events no longer overflow,
tidies the admin toolbar, and expands mobile responsiveness across both
frontends.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 18:03:21 +00:00

259 lines
9.3 KiB
JavaScript
Raw Permalink 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.

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 = '<div class="empty-message">На этот день расписания нет.</div>';
return;
}
const tasksHtml = tasks.length ? `
<div class="tasks-list">
${tasks.map((task) => `<div class="task-item">${task.repeat_weekly ? 'Каждую неделю: ' : ''}${escapeHtml(task.title)}</div>`).join('')}
</div>
` : '';
const eventsHtml = events.length ? `
<div class="events-list">
${events.map((event) => `
<div class="event-item" style="--event-color:${eventColorHex(event.color)};">
<span class="event-time">${event.start_time}-${calculateEndTime(event.start_time, event.duration_min)}</span>
<span class="event-title">${escapeHtml(event.title)}</span>
</div>
`).join('')}
</div>
` : '';
container.innerHTML = `${tasksHtml}${eventsHtml}`;
}
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
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 `
<div class="timeline">
${hours.map((hour, index) => `
<div class="timeline-hour" style="top:${index * 60 * PIXELS_PER_MINUTE}px;">
<span class="timeline-hour-label">${String(hour).padStart(2, '0')}:00</span>
</div>
`).join('')}
${events.map((event) => renderEventBlock(event)).join('')}
</div>
`;
}
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 `
<article class="event-block ${event.repeat_weekly ? 'recurring' : ''}" style="top:${top}px;height:${height}px;--event-color:${eventColorHex(event.color)};">
<div class="event-line">
<span class="event-time">${event.start_time}${endTime}</span>
<span class="event-title">${escapeHtml(event.title)}</span>
</div>
</article>
`;
}
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 `
<section class="week-day-card ${dateStr === today ? 'is-today' : ''}">
<div class="week-day-header">${formatDate(dateStr)}</div>
<div class="week-task-list">
${tasks.map((task) => `<div class="week-task-item">${escapeHtml(task.title)}</div>`).join('')}
</div>
${renderTimeline(dateStr, events)}
</section>
`;
}).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);