feat: улучшить учебные периоды и просмотр расписаний

This commit is contained in:
Zuev
2026-08-15 00:20:00 +03:00
parent 855d191517
commit 52d516dd8f
22 changed files with 1107 additions and 250 deletions

View File

@@ -1014,9 +1014,9 @@
border-radius: var(--radius-sm);
background: rgba(10, 10, 15, 0.97);
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.35);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
top: 0;
left: 0;
transform: none;
}
.schedule-visual-context-menu[hidden] {
@@ -1325,6 +1325,152 @@
font-size: 0.82rem;
}
.academic-year-builder-card {
overflow: hidden;
}
.academic-year-builder-description {
margin: 0.28rem 0 0;
font-size: 0.84rem;
}
.academic-year-period-grid {
display: grid;
grid-template-columns: repeat(2, minmax(220px, 1fr));
gap: 0.8rem;
margin-top: 1rem;
}
#academic-year-end[readonly] {
color: var(--text-secondary);
background: color-mix(in srgb, var(--bg-input) 82%, var(--accent) 5%);
cursor: default;
}
.academic-year-field-hint {
display: block;
margin-top: 0.4rem;
font-size: 0.72rem;
line-height: 1.35;
}
.academic-year-semester-builder {
margin-top: 1rem;
padding: 0.85rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--bg-input) 78%, transparent);
}
.academic-year-semester-builder-head,
.academic-year-semester-card-title,
.academic-year-form-actions {
display: flex;
align-items: center;
}
.academic-year-semester-builder-head {
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
.academic-year-semester-builder-head h3,
.academic-year-semester-builder-head p {
margin: 0;
}
.academic-year-semester-builder-head h3 {
font-size: 0.96rem;
}
.academic-year-semester-builder-head p {
margin-top: 0.2rem;
font-size: 0.78rem;
}
.academic-year-required-badge {
flex: 0 0 auto;
padding: 0.2rem 0.5rem;
border: 1px solid color-mix(in srgb, var(--accent) 42%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 12%, transparent);
color: var(--accent-hover);
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.academic-year-semester-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.academic-year-semester-card {
min-width: 0;
padding: 0.75rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-card);
}
.academic-year-semester-card-title {
gap: 0.5rem;
margin-bottom: 0.65rem;
color: var(--text-primary);
}
.academic-year-semester-dot {
width: 10px;
height: 10px;
border-radius: 50%;
box-shadow: 0 0 0 4px color-mix(in srgb, currentColor 14%, transparent);
}
.academic-year-semester-dot-autumn {
color: #f59e0b;
background: #f59e0b;
}
.academic-year-semester-dot-spring {
color: #22c55e;
background: #22c55e;
}
.academic-year-semester-dates {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
}
.academic-year-form-actions {
justify-content: flex-end;
margin-top: 0.85rem;
}
@media (max-width: 900px) {
.academic-year-semester-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.academic-year-period-grid,
.academic-year-semester-dates {
grid-template-columns: 1fr;
}
.academic-year-semester-builder-head {
align-items: flex-start;
}
.academic-year-form-actions .btn {
width: 100%;
}
}
.schedule-inline-table {
margin-top: 1rem;
}
@@ -2148,6 +2294,20 @@
transition: background var(--transition), border-color var(--transition), box-shadow var(--transition), filter var(--transition);
}
.calendar-activity-color-10b981 { --activity-color: #10b981; }
.calendar-activity-color-f59e0b { --activity-color: #f59e0b; }
.calendar-activity-color-38bdf8 { --activity-color: #38bdf8; }
.calendar-activity-color-a78bfa { --activity-color: #a78bfa; }
.calendar-activity-color-fb7185 { --activity-color: #fb7185; }
.calendar-activity-color-f97316 { --activity-color: #f97316; }
.calendar-activity-color-22c55e { --activity-color: #22c55e; }
.calendar-activity-color-ef4444 { --activity-color: #ef4444; }
.calendar-activity-color-dc2626 { --activity-color: #dc2626; }
.calendar-activity-color-eab308 { --activity-color: #eab308; }
.calendar-activity-color-06b6d4 { --activity-color: #06b6d4; }
.calendar-activity-color-64748b { --activity-color: #64748b; }
.calendar-activity-color-475569 { --activity-color: #475569; }
.calendar-day:hover,
.calendar-day:focus-visible {
background: color-mix(in srgb, var(--activity-color, var(--accent)) 24%, var(--bg-card));
@@ -2311,7 +2471,7 @@
}
.calendar-total-chip {
--chip-color: var(--accent);
--chip-color: var(--activity-color, var(--accent));
display: inline-flex;
align-items: center;
gap: 0.35rem;

View File

@@ -54,25 +54,107 @@ export function calculateCalendarTooltipPosition({
};
}
export function academicYearDefaults(startYearValue) {
const value = String(startYearValue || '').trim();
if (!/^\d{4}$/.test(value)) {
throw new TypeError('Год начала учебного года должен состоять из четырёх цифр');
}
const startYear = Number(value);
if (startYear < 1000 || startYear > 9998) {
throw new TypeError('Год начала учебного года должен быть в диапазоне от 1000 до 9998');
}
const endYear = startYear + 1;
const start = new Date(Date.UTC(startYear, 8, 1));
const autumnEnd = new Date(Date.UTC(endYear, 0, 31));
const springStart = new Date(Date.UTC(endYear, 1, 1));
const end = new Date(Date.UTC(endYear, 5, 30));
return {
startYear,
endYear,
title: `${startYear}-${endYear}`,
startDate: toIsoDateUtc(start),
endDate: toIsoDateUtc(end),
semesters: [
{
semesterType: 'autumn',
startDate: toIsoDateUtc(start),
endDate: toIsoDateUtc(autumnEnd)
},
{
semesterType: 'spring',
startDate: toIsoDateUtc(springStart),
endDate: toIsoDateUtc(end)
}
]
};
}
export function validateAcademicYearSemesters(semesters, yearStart, yearEnd) {
if (!Array.isArray(semesters) || semesters.length === 0) {
throw new Error('Учебный год нельзя создать без семестров');
}
const sorted = [...semesters].sort((a, b) => String(a.startDate).localeCompare(String(b.startDate)));
const types = new Set();
sorted.forEach((semester, index) => {
if (!semester?.semesterType || !semester.startDate || !semester.endDate) {
throw new Error('Заполните тип и даты каждого семестра');
}
if (types.has(semester.semesterType)) {
throw new Error('Типы семестров не должны повторяться');
}
types.add(semester.semesterType);
if (semester.endDate < semester.startDate) {
throw new Error('Дата окончания семестра не может быть раньше даты начала');
}
if (semester.startDate < yearStart || semester.endDate > yearEnd) {
throw new Error('Семестры должны находиться в границах учебного года');
}
if (index > 0 && sorted[index - 1].endDate >= semester.startDate) {
throw new Error('Периоды семестров не должны пересекаться');
}
});
}
function toIsoDateUtc(date) {
return [
date.getUTCFullYear(),
String(date.getUTCMonth() + 1).padStart(2, '0'),
String(date.getUTCDate()).padStart(2, '0')
].join('-');
}
export function activityColorCode(activity) {
const color = String(activity?.colorCode || '').trim();
return /^#[0-9a-f]{6}$/i.test(color) ? color : '#64748b';
}
export function activityColorClass(activity) {
return `calendar-activity-color-${activityColorCode(activity).slice(1).toLowerCase()}`;
}
export async function initAcademicCalendar() {
const academicYearForm = document.getElementById('academic-year-form');
const academicYearFormTitle = document.getElementById('academic-year-form-title');
const academicYearIdInput = document.getElementById('academic-year-id');
const academicYearTitleInput = document.getElementById('academic-year-title');
const academicYearStartInput = document.getElementById('academic-year-start');
const academicYearEndInput = document.getElementById('academic-year-end');
const academicYearResetButton = document.getElementById('academic-year-reset');
const academicYearsTbody = document.getElementById('academic-years-tbody');
const academicYearsRefreshButton = document.getElementById('academic-years-refresh');
const semesterForm = document.getElementById('semester-form');
const semesterFormTitle = document.getElementById('semester-form-title');
const semesterIdInput = document.getElementById('semester-id');
const semesterYearSelect = document.getElementById('semester-year');
const semesterTypeSelect = document.getElementById('semester-type');
const semesterStartInput = document.getElementById('semester-start');
const semesterEndInput = document.getElementById('semester-end');
const semesterResetButton = document.getElementById('semester-reset');
const academicYearSemesterInputs = {
autumn: {
id: document.getElementById('academic-year-autumn-id'),
start: document.getElementById('academic-year-autumn-start'),
end: document.getElementById('academic-year-autumn-end')
},
spring: {
id: document.getElementById('academic-year-spring-id'),
start: document.getElementById('academic-year-spring-start'),
end: document.getElementById('academic-year-spring-end')
}
};
const calendarForm = document.getElementById('academic-calendar-form');
const calendarFormTitle = document.getElementById('academic-calendar-form-title');
@@ -119,7 +201,6 @@ export async function initAcademicCalendar() {
const subjectCount = document.getElementById('calendar-subject-count');
let academicYears = [];
let semesters = [];
let specialties = [];
let profilesBySpecialty = new Map();
let studyForms = [];
@@ -158,10 +239,10 @@ export async function initAcademicCalendar() {
function bindEvents() {
[
academicYearStartInput,
academicYearEndInput,
semesterStartInput,
semesterEndInput,
academicYearSemesterInputs.autumn.start,
academicYearSemesterInputs.autumn.end,
academicYearSemesterInputs.spring.start,
academicYearSemesterInputs.spring.end,
fillStartInput,
fillEndInput
].forEach(bindDateInputMask);
@@ -172,7 +253,7 @@ export async function initAcademicCalendar() {
academicYearsRefreshButton?.addEventListener('click', loadYears);
calendarsRefreshButton?.addEventListener('click', loadCalendars);
academicYearResetButton?.addEventListener('click', resetAcademicYearForm);
semesterResetButton?.addEventListener('click', resetSemesterForm);
academicYearStartInput?.addEventListener('input', handleAcademicYearStartInput);
calendarResetButton?.addEventListener('click', resetCalendarForm);
calendarSpecialtySelect?.addEventListener('change', () => populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value));
editorLoadButton?.addEventListener('click', loadCalendarGrid);
@@ -244,7 +325,6 @@ export async function initAcademicCalendar() {
calendarDayDialogTimeClearButton?.addEventListener('click', clearTimeSlotDateAssignment);
academicYearForm?.addEventListener('submit', saveAcademicYear);
semesterForm?.addEventListener('submit', saveSemester);
calendarForm?.addEventListener('submit', saveCalendar);
academicYearsTbody?.addEventListener('click', handleAcademicYearTableClick);
@@ -269,7 +349,6 @@ export async function initAcademicCalendar() {
academicYearsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
try {
academicYears = await api.get('/api/admin/calendar/years');
semesters = academicYears.flatMap(year => year.semesters || []);
renderAcademicYears();
populateYearSelects();
} catch (error) {
@@ -319,17 +398,19 @@ export async function initAcademicCalendar() {
event.preventDefault();
hideAlert('academic-year-alert');
try {
const defaults = applyAcademicYearDateDefaults(true);
const { startDate, endDate } = defaults;
const payload = {
title: academicYearTitleInput.value.trim(),
startDate: readDateInputIso(academicYearStartInput, 'Начало учебного года'),
endDate: readDateInputIso(academicYearEndInput, 'Окончание учебного года')
startDate,
endDate,
semesters: ['autumn', 'spring'].map(type => readAcademicYearSemester(type))
};
if (!payload.title || !payload.startDate || !payload.endDate) throw new Error('Заполните название и даты учебного года');
validateAcademicYearSemesters(payload.semesters, startDate, endDate);
const id = academicYearIdInput.value;
await (id
? api.put('/api/admin/calendar/years/' + id, payload)
: api.post('/api/admin/calendar/years', payload));
showAlert('academic-year-alert', 'Учебный год сохранён', 'success');
showAlert('academic-year-alert', 'Учебный год и семестры сохранены', 'success');
resetAcademicYearForm();
await loadYears();
await loadCalendars();
@@ -346,28 +427,16 @@ export async function initAcademicCalendar() {
if (editYearButton) {
const year = academicYears.find(item => item.id == editYearButton.dataset.id);
if (!year) return;
academicYearFormTitle.textContent = 'Редактирование учебного года';
academicYearIdInput.value = year.id;
academicYearTitleInput.value = year.title || '';
setDateInputIso(academicYearStartInput, year.startDate);
setDateInputIso(academicYearEndInput, year.endDate);
hideAlert('academic-year-alert');
academicYearForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
fillAcademicYearForm(year);
return;
}
if (editSemesterButton) {
const semester = semesters.find(item => item.id == editSemesterButton.dataset.id);
if (!semester) return;
semesterFormTitle.textContent = `Редактирование семестра: ${semesterTypeTitle(semester)}`;
semesterIdInput.value = semester.id;
semesterYearSelect.value = semester.academicYearId || '';
semesterTypeSelect.value = normalizeSemesterType(semester.semesterType);
setDateInputIso(semesterStartInput, semester.startDate);
setDateInputIso(semesterEndInput, semester.endDate);
syncSelects(semesterYearSelect, semesterTypeSelect);
hideAlert('semester-alert');
semesterForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
const year = academicYears.find(item => (item.semesters || [])
.some(semester => String(semester.id) === String(editSemesterButton.dataset.id)));
if (!year) return;
const semester = year.semesters.find(item => String(item.id) === String(editSemesterButton.dataset.id));
fillAcademicYearForm(year, normalizeSemesterType(semester?.semesterType));
return;
}
@@ -386,42 +455,73 @@ export async function initAcademicCalendar() {
function resetAcademicYearForm() {
academicYearForm.reset();
academicYearFormTitle.textContent = 'Учебный год';
academicYearFormTitle.textContent = 'Учебный год и семестры';
academicYearIdInput.value = '';
Object.values(academicYearSemesterInputs).forEach(inputs => {
inputs.id.value = '';
});
hideAlert('academic-year-alert');
}
async function saveSemester(event) {
event.preventDefault();
hideAlert('semester-alert');
const yearId = semesterYearSelect.value;
try {
const payload = {
semesterType: semesterTypeSelect.value,
startDate: readDateInputIso(semesterStartInput, 'Начало семестра'),
endDate: readDateInputIso(semesterEndInput, 'Окончание семестра')
};
if (!yearId || !payload.semesterType || !payload.startDate || !payload.endDate) {
throw new Error('Заполните учебный год, тип и даты семестра');
}
const id = semesterIdInput.value;
await (id
? api.put('/api/admin/calendar/semesters/' + id, payload)
: api.post(`/api/admin/calendar/years/${yearId}/semesters`, payload));
showAlert('semester-alert', 'Семестр сохранён', 'success');
resetSemesterForm();
await loadYears();
} catch (error) {
showAlert('semester-alert', error.message || 'Ошибка сохранения семестра', 'error');
function fillAcademicYearForm(year, focusedSemesterType = '') {
academicYearFormTitle.textContent = `Редактирование учебного года ${year.title || ''}`.trim();
academicYearIdInput.value = year.id;
academicYearStartInput.value = String(year.startDate || '').slice(0, 4);
academicYearEndInput.value = String(year.endDate || '').slice(0, 4);
['autumn', 'spring'].forEach(type => {
const semester = (year.semesters || []).find(item => normalizeSemesterType(item.semesterType) === type);
const inputs = academicYearSemesterInputs[type];
inputs.id.value = semester?.id || '';
setDateInputIso(inputs.start, semester?.startDate || '');
setDateInputIso(inputs.end, semester?.endDate || '');
});
hideAlert('academic-year-alert');
academicYearForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
if (focusedSemesterType && academicYearSemesterInputs[focusedSemesterType]) {
academicYearSemesterInputs[focusedSemesterType].start.focus({ preventScroll: true });
}
}
function resetSemesterForm() {
semesterForm.reset();
semesterFormTitle.textContent = 'Семестр';
semesterIdInput.value = '';
syncSelects(semesterYearSelect, semesterTypeSelect);
hideAlert('semester-alert');
function handleAcademicYearStartInput() {
academicYearStartInput.value = academicYearStartInput.value.replace(/\D/g, '').slice(0, 4);
if (academicYearStartInput.value.length !== 4) {
academicYearEndInput.value = '';
return;
}
try {
applyAcademicYearDateDefaults();
} catch (_) {
academicYearEndInput.value = '';
}
}
function applyAcademicYearDateDefaults(preserveSemesterDates = false) {
const defaults = academicYearDefaults(academicYearStartInput.value);
academicYearEndInput.value = String(defaults.endYear);
const semesterDefaults = {
autumn: defaults.semesters[0],
spring: defaults.semesters[1]
};
['autumn', 'spring'].forEach(type => {
const inputs = academicYearSemesterInputs[type];
if (!preserveSemesterDates || !inputs.start.value) {
setDateInputIso(inputs.start, semesterDefaults[type].startDate);
}
if (!preserveSemesterDates || !inputs.end.value) {
setDateInputIso(inputs.end, semesterDefaults[type].endDate);
}
});
return defaults;
}
function readAcademicYearSemester(type) {
const inputs = academicYearSemesterInputs[type];
return {
id: inputs.id.value ? Number(inputs.id.value) : null,
semesterType: type,
startDate: readDateInputIso(inputs.start, `Начало ${SEMESTER_LABELS[type]} семестра`),
endDate: readDateInputIso(inputs.end, `Окончание ${SEMESTER_LABELS[type]} семестра`)
};
}
async function loadStudyForms() {
@@ -895,7 +995,7 @@ export async function initAcademicCalendar() {
const activity = activityById(selected) || activityTypes[0] || {};
const code = activity.code || row.activityCode || 'Т';
return `
<td class="calendar-day"
<td class="calendar-day ${activityColorClass(activity)}"
data-course="${row.courseNumber}"
data-date="${row.date}"
data-week="${row.weekNumber}"
@@ -967,9 +1067,11 @@ export async function initAcademicCalendar() {
function updateCalendarDayDialogSummary(day) {
const activity = activityById(day.querySelector('.calendar-day-activity').value);
if (!activity) {
applyActivityColorClass(calendarDayDialogActivitySummary, null);
calendarDayDialogActivitySummary.innerHTML = '';
return;
}
applyActivityColorClass(calendarDayDialogActivitySummary, activity);
calendarDayDialogActivitySummary.innerHTML = `
<span class="calendar-day-dialog-code">${escapeHtml(activity.code)}</span>
<div>
@@ -987,6 +1089,7 @@ export async function initAcademicCalendar() {
const activity = activityById(commonCalendarDayActivityValue(days));
if (!activity) {
applyActivityColorClass(calendarDayDialogActivitySummary, null);
calendarDayDialogActivitySummary.innerHTML = `
<span class="calendar-day-dialog-code">...</span>
<div>
@@ -997,6 +1100,7 @@ export async function initAcademicCalendar() {
return;
}
applyActivityColorClass(calendarDayDialogActivitySummary, activity);
calendarDayDialogActivitySummary.innerHTML = `
<span class="calendar-day-dialog-code">${escapeHtml(activity.code)}</span>
<div>
@@ -1091,6 +1195,7 @@ export async function initAcademicCalendar() {
const select = day.querySelector('.calendar-day-activity');
const activity = activityById(select.value);
const code = activity?.code || '-';
applyActivityColorClass(day, activity);
day.querySelector('.calendar-day-code').textContent = code;
day.setAttribute('aria-label', `${formatShortDate(day.dataset.date)} ${shortDayLabel(day.dataset.day)}, ${day.dataset.week} неделя, код ${code}`);
}
@@ -1102,6 +1207,8 @@ export async function initAcademicCalendar() {
}
const tooltip = ensureCalendarDayTooltip();
tooltip.innerHTML = renderCalendarDayTooltip(day);
const activity = activityById(day.querySelector('.calendar-day-activity')?.value);
applyActivityColorClass(tooltip, activity);
tooltip.classList.add('visible');
positionCalendarDayTooltip(tooltip, day, event);
}
@@ -1285,7 +1392,9 @@ export async function initAcademicCalendar() {
calendarGrid.querySelectorAll('.calendar-day-activity').forEach(select => {
const activity = activityById(select.value);
const key = activity ? activity.code : '-';
counts.set(key, (counts.get(key) || 0) + 1);
const current = counts.get(key) || { count: 0, colorClass: activityColorClass(activity) };
current.count += 1;
counts.set(key, current);
});
const selectedDays = selectedCalendarDays.length
? selectedCalendarDays
@@ -1296,19 +1405,18 @@ export async function initAcademicCalendar() {
const selectedCode = selectedActivity?.code || null;
calendarTotals.innerHTML = Array.from(counts.entries())
.sort(([a], [b]) => a.localeCompare(b, 'ru'))
.map(([code, count]) => {
.map(([code, entry]) => {
const selectedClass = selectedCode === code ? ' is-selected' : '';
const suffix = selectedCode === code ? '<span class="calendar-total-selected">выбрано</span>' : '';
return `<span class="calendar-total-chip${selectedClass}">${escapeHtml(code)}: ${count}${suffix}</span>`;
return `<span class="calendar-total-chip ${entry.colorClass}${selectedClass}">${escapeHtml(code)}: ${entry.count}${suffix}</span>`;
}).join('');
}
function populateYearSelects() {
const yearOptions = '<option value="">Выберите учебный год</option>' +
academicYears.map(year => `<option value="${year.id}">${escapeHtml(year.title)}</option>`).join('');
semesterYearSelect.innerHTML = yearOptions;
calendarYearSelect.innerHTML = yearOptions;
syncSelects(semesterYearSelect, calendarYearSelect);
syncSelects(calendarYearSelect);
}
function populateSpecialtySelects() {
@@ -1474,6 +1582,16 @@ export async function initAcademicCalendar() {
return activityTypes.find(type => String(type.id) === String(id));
}
function applyActivityColorClass(element, activity) {
if (!element) return;
Array.from(element.classList)
.filter(className => className.startsWith('calendar-activity-color-'))
.forEach(className => element.classList.remove(className));
if (activity) {
element.classList.add(activityColorClass(activity));
}
}
function timeAssignmentForDate(date) {
return timeSlotDateAssignments.find(item => item.date === date);
}

View File

@@ -421,6 +421,11 @@ export async function initScheduleView() {
function renderDesktopSchedule(section, range) {
const slots = collectSlots(section.lessons);
const semester = selectedSemester();
if (semester) {
renderDesktopSemesterSchedule(section, slots, semester);
return;
}
const lessonsByDateSlot = groupLessonsByDateSlot(section.lessons);
const blocks = buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons);
if (!blocks.length || !slots.length) {
@@ -432,6 +437,50 @@ export async function initScheduleView() {
.join('');
}
function renderDesktopSemesterSchedule(section, slots, semester) {
if (!slots.length) {
setTableState('Занятия не найдены');
return;
}
const lessonMatrix = buildSemesterLessonMatrix(section.lessons);
const dayHeaders = DAY_ORDER.map(day => `
<th class="room-day-cell">
<strong>${escapeHtml(dayShort(day))}</strong>
<span>${escapeHtml(DAY_FULL_LABELS[day] || '')}</span>
</th>
`).join('');
const rows = slots.map(slot => `
<tr>
<td class="room-time-cell">
<strong>${escapeHtml(slot.orderLabel)}</strong>
<span>${escapeHtml(slot.timeLabel)}</span>
${slot.id ? `<small>ID ${escapeHtml(slot.id)}</small>` : ''}
</td>
${DAY_ORDER.map(day => renderSemesterSlotCell(day, slot, lessonMatrix)).join('')}
</tr>
`).join('');
tables.innerHTML = `
<section class="schedule-view-week-panel schedule-view-semester-panel">
<div class="room-week-heading">
<h3>${escapeHtml(section.title)}</h3>
<span>${escapeHtml(semesterScheduleCaption(semester))}</span>
</div>
<div class="workload-grid-container schedule-view-grid-container">
<table class="workload-table room-week-table schedule-view-matrix-table">
<thead>
<tr>
<th class="room-time-heading">Время</th>
${dayHeaders}
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
</section>
`;
}
function renderMobileDayTabs() {
dayTabs.hidden = false;
dayTabs.innerHTML = DAY_ORDER.map(day => `
@@ -451,6 +500,11 @@ export async function initScheduleView() {
function renderMobileSchedule(section, range) {
const slots = collectSlots(section.lessons);
const semester = selectedSemester();
if (semester) {
renderMobileSemesterSchedule(section, slots, semester);
return;
}
const lessonsByDateSlot = groupLessonsByDateSlot(section.lessons);
const blocks = buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons);
if (!blocks.length || !slots.length) {
@@ -460,6 +514,34 @@ export async function initScheduleView() {
tables.innerHTML = blocks.map(block => renderMobileDayBlock(block, slots, lessonsByDateSlot)).join('');
}
function renderMobileSemesterSchedule(section, slots, semester) {
if (!slots.length) {
setTableState('Занятия не найдены');
return;
}
const lessonMatrix = buildSemesterLessonMatrix(section.lessons);
const rows = slots.map(slot => `
<article class="schedule-view-mobile-slot">
<div class="schedule-view-mobile-slot-time">
<strong>${escapeHtml(slot.orderLabel)}</strong>
<span>${escapeHtml(slot.timeLabel)}</span>
</div>
<div class="schedule-view-mobile-slot-content">
${renderSlotContent(slotStatesForSemester(state.mobileDay, slot, lessonMatrix))}
</div>
</article>
`).join('');
tables.innerHTML = `
<section class="schedule-view-mobile-day schedule-view-mobile-semester">
<div class="schedule-view-mobile-day-heading">
<strong>${escapeHtml(DAY_FULL_LABELS[state.mobileDay] || dayShort(state.mobileDay))}</strong>
<span>${escapeHtml(semesterScheduleCaption(semester))}</span>
</div>
${rows}
</section>
`;
}
function renderMobileDayBlock(block, slots, lessonsByDateSlot) {
const dayDates = block.datesByDay[state.mobileDay] || {};
const rows = slots.map(slot => {
@@ -537,6 +619,12 @@ export async function initScheduleView() {
return `<td class="room-combined-cell${singleCellClass}">${renderSlotContent(states)}</td>`;
}
function renderSemesterSlotCell(day, slot, lessonMatrix) {
const states = slotStatesForSemester(day, slot, lessonMatrix);
const singleCellClass = slotStatesEqual(states[0].state, states[1].state) ? ' room-single-cell' : '';
return `<td class="room-combined-cell${singleCellClass}">${renderSlotContent(states)}</td>`;
}
function slotStatesForDates(datesByParity, slot, lessonsByDateSlot) {
return CELL_PARITY_LAYOUT.map(({ parity, label }) => ({
parity,
@@ -545,6 +633,54 @@ export async function initScheduleView() {
}));
}
function slotStatesForSemester(day, slot, lessonMatrix) {
return CELL_PARITY_LAYOUT.map(({ parity, label }) => {
const lessons = lessonMatrix.get(`${day}:${slot.key}:${parity}`) || [];
return {
parity,
label,
state: lessons.length ? { kind: 'busy', lessons } : { kind: 'free', lessons: [] }
};
});
}
function buildSemesterLessonMatrix(lessons) {
const weekRanges = new Map();
lessons.forEach(lesson => {
const signature = lessonContentSignature(lesson);
const weekNumber = Number(lesson.weekNumber);
if (!Number.isFinite(weekNumber)) return;
const range = weekRanges.get(signature) || { startWeek: weekNumber, endWeek: weekNumber };
range.startWeek = Math.min(range.startWeek, weekNumber);
range.endWeek = Math.max(range.endWeek, weekNumber);
weekRanges.set(signature, range);
});
const matrix = new Map();
lessons.forEach(lesson => {
const day = Number(lesson.dayOfWeek) || isoDay(lesson.date);
const parity = ['ODD', 'EVEN'].includes(lesson.parity)
? lesson.parity
: (Number(lesson.weekNumber) % 2 === 0 ? 'EVEN' : 'ODD');
const signature = lessonContentSignature(lesson);
const key = `${day}:${lessonSlotKey(lesson)}:${parity}`;
if (!matrix.has(key)) matrix.set(key, new Map());
if (!matrix.get(key).has(signature)) {
const range = weekRanges.get(signature);
matrix.get(key).set(signature, {
...lesson,
displayStartWeek: range?.startWeek,
displayEndWeek: range?.endWeek
});
}
});
return new Map(Array.from(matrix.entries()).map(([key, values]) => [
key,
Array.from(values.values()).sort((a, b) => naturalCompare(a.subjectName, b.subjectName))
]));
}
function renderSlotContent(states) {
if (slotStatesEqual(states[0].state, states[1].state)) {
return renderUnifiedSlot(states[0].state);
@@ -640,6 +776,9 @@ export async function initScheduleView() {
}
function getLessonWeeksText(lesson) {
if (lesson?.displayStartWeek != null && lesson?.displayEndWeek != null) {
return formatLessonWeeksText(lesson.displayStartWeek, lesson.displayEndWeek);
}
if (!lesson || lesson.weekNumber == null || lesson.lessonTypeAcademicHours == null) {
return '';
}
@@ -667,42 +806,7 @@ export async function initScheduleView() {
if (startWeek <= 0) startWeek = 1;
if (endWeek < startWeek) endWeek = startWeek;
return formatLessonWeeksText(startWeek, endWeek, parity, semesterWeeksForLesson(lesson));
}
function formatLessonWeeksText(startWeek, endWeek, parity, semesterWeeks) {
const semesterEndWeek = lastApplicableSemesterWeek(semesterWeeks, parity);
const semesterStartWeek = firstApplicableSemesterWeek(parity);
if (semesterEndWeek && startWeek <= semesterStartWeek && endWeek >= semesterEndWeek) return '';
if (semesterEndWeek && endWeek >= semesterEndWeek) return `(с ${startWeek} нед.)`;
return startWeek === endWeek
? `(${startWeek} нед.)`
: `(с ${startWeek} по ${endWeek} нед.)`;
}
function semesterWeeksForLesson(lesson) {
const semester = (state.dictionaries.semesters || []).find(item => dateInRange(lesson.date, item.startDate, item.endDate));
if (!semester?.startDate || !semester?.endDate) return DEFAULT_SEMESTER_WEEKS;
const start = parseIsoDate(semester.startDate);
const end = parseIsoDate(semester.endDate);
const days = Math.max(1, Math.round((end - start) / 86400000) + 1);
return Math.max(1, Math.ceil(days / 7));
}
function dateInRange(date, startDate, endDate) {
return Boolean(date && startDate && endDate && date >= startDate && date <= endDate);
}
function firstApplicableSemesterWeek(parity) {
return parity === 'EVEN' ? 2 : 1;
}
function lastApplicableSemesterWeek(maxWeeks, parity) {
const totalWeeks = Number(maxWeeks || 0);
if (!totalWeeks) return 0;
if (parity === 'ODD' && totalWeeks % 2 === 0) return totalWeeks - 1;
if (parity === 'EVEN' && totalWeeks % 2 !== 0) return totalWeeks - 1;
return totalWeeks;
return formatLessonWeeksText(startWeek, endWeek);
}
function renderLessonCard(lesson) {
@@ -801,8 +905,8 @@ export async function initScheduleView() {
function selectCurrentSemester() {
if (!semesterSelect) return;
const currentSemester = findSemesterForDate(state.dictionaries.semesters, dateInput.value);
semesterSelect.value = currentSemester ? String(currentSemester.id) : '';
const defaultSemester = findDefaultSemester(state.dictionaries.semesters, dateInput.value);
semesterSelect.value = defaultSemester ? String(defaultSemester.id) : '';
synchronizeDateWithSemester();
updatePeriodLabel();
}
@@ -1107,11 +1211,42 @@ export function findSemesterForDate(semesters, date) {
return (semesters || []).find(semester => dateWithinSemester(date, semester)) || null;
}
export function findDefaultSemester(semesters, date) {
const availableSemesters = semesters || [];
return findSemesterForDate(availableSemesters, date) || availableSemesters[0] || null;
}
export function scheduleRangeForSemester(selectedDate, semester) {
const anchorDate = semester && !dateWithinSemester(selectedDate, semester)
? semester.startDate
: selectedDate;
return twoWeekRange(anchorDate);
if (semester?.startDate && semester?.endDate) {
return {
startDate: semester.startDate,
endDate: semester.endDate
};
}
return twoWeekRange(selectedDate);
}
export function formatLessonWeeksText(startWeek, endWeek) {
const start = Number(startWeek);
const end = Number(endWeek);
if (!Number.isFinite(start) || !Number.isFinite(end)) return '';
return start === end
? `(${start} нед.)`
: `(с ${start} по ${end} нед.)`;
}
function semesterScheduleCaption(semester) {
const weeks = semesterWeekCount(semester);
const weekLabel = weeks === 1 ? '1 неделя' : `недели 1–${weeks}`;
return `Весь семестр · ${weekLabel}`;
}
function semesterWeekCount(semester) {
if (!semester?.startDate || !semester?.endDate) return DEFAULT_SEMESTER_WEEKS;
const start = parseIsoDate(semester.startDate);
const end = parseIsoDate(semester.endDate);
const days = Math.max(1, Math.round((end - start) / 86400000) + 1);
return Math.max(1, Math.ceil(days / 7));
}
function dateWithinSemester(date, semester) {

View File

@@ -29,6 +29,33 @@ const SEMESTER_LABELS = {
const WHOLE_GROUP_SUBGROUP_VALUE = '__whole_group__';
const ACADEMIC_HOURS_PER_SLOT = 2;
const VISUAL_MENU_GAP = 8;
const VISUAL_MENU_VIEWPORT_PADDING = 8;
export function calculateVisualContextMenuPosition({
anchorRect,
menuWidth,
menuHeight,
viewportWidth,
viewportHeight
}) {
const maxLeft = Math.max(VISUAL_MENU_VIEWPORT_PADDING, viewportWidth - menuWidth - VISUAL_MENU_VIEWPORT_PADDING);
const maxTop = Math.max(VISUAL_MENU_VIEWPORT_PADDING, viewportHeight - menuHeight - VISUAL_MENU_VIEWPORT_PADDING);
let left = anchorRect.right + VISUAL_MENU_GAP;
let top = anchorRect.top;
if (left > maxLeft) {
left = anchorRect.left - menuWidth - VISUAL_MENU_GAP;
}
if (top > maxTop) {
top = anchorRect.bottom - menuHeight;
}
return {
left: Math.round(Math.max(VISUAL_MENU_VIEWPORT_PADDING, Math.min(left, maxLeft))),
top: Math.round(Math.max(VISUAL_MENU_VIEWPORT_PADDING, Math.min(top, maxTop)))
};
}
export function scheduleSlotActionLabel(slotCount) {
return slotCount === 1 ? 'Очистить' : 'Удалить';
@@ -160,6 +187,7 @@ export async function initSchedule() {
let syncingVisualPeriod = false;
let pendingConflictResolution = null;
let visualContextTarget = null;
let visualContextMenuPositionAnimation = null;
let pendingSlotMove = null;
let creatingDraft = false;
@@ -207,6 +235,7 @@ export async function initSchedule() {
visualSemesterSelect?.addEventListener('change', handleVisualSemesterChange);
visualGroupFilter?.addEventListener('change', handleVisualGroupFilterChange);
visualStage?.addEventListener('click', handleVisualStageClick);
visualDrawer?.addEventListener('scroll', closeVisualContextMenu, true);
conflictCloseButton?.addEventListener('click', closeConflictModal);
conflictCancelButton?.addEventListener('click', closeConflictModal);
conflictModal?.addEventListener('click', (event) => {
@@ -555,6 +584,7 @@ export async function initSchedule() {
function closeVisualDrawer() {
if (!visualDrawer) return;
closeVisualContextMenu();
visualDrawer.classList.remove('open');
visualDrawer.setAttribute('aria-hidden', 'true');
visualToggleButton?.classList.remove('open');
@@ -882,6 +912,23 @@ export async function initSchedule() {
};
if (!visualContextMenu) return;
visualContextMenu.hidden = false;
const anchorRect = button.getBoundingClientRect();
const position = calculateVisualContextMenuPosition({
anchorRect,
menuWidth: visualContextMenu.offsetWidth,
menuHeight: visualContextMenu.offsetHeight,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight
});
visualContextMenuPositionAnimation?.cancel();
const positionedTransform = `translate3d(${position.left}px, ${position.top}px, 0)`;
visualContextMenuPositionAnimation = visualContextMenu.animate(
[
{ transform: positionedTransform },
{ transform: positionedTransform }
],
{ duration: 1, fill: 'forwards' }
);
visualContextMenu.querySelector('button')?.focus();
}
@@ -889,6 +936,8 @@ export async function initSchedule() {
visualContextTarget = null;
if (!visualContextMenu) return;
visualContextMenu.hidden = true;
visualContextMenuPositionAnimation?.cancel();
visualContextMenuPositionAnimation = null;
}
async function handleVisualContextMenuClick(event) {

View File

@@ -5,25 +5,77 @@
</div>
<section class="academic-calendar-tab-panel active" id="academic-calendar-tab-manage" data-calendar-tab-panel="manage" role="tabpanel">
<div class="card create-card">
<h2 id="academic-year-form-title">Учебный год</h2>
<div class="card create-card academic-year-builder-card">
<div class="card-header-row">
<div>
<h2 id="academic-year-form-title">Учебный год и семестры</h2>
<p class="muted academic-year-builder-description">Учебный год создаётся сразу с осенним и весенним семестрами.</p>
</div>
<button type="button" class="btn btn-md btn-ghost" id="academic-year-reset">Очистить</button>
</div>
<form id="academic-year-form" novalidate>
<input type="hidden" id="academic-year-id">
<div class="form-row">
<div class="academic-year-period-grid">
<div class="form-group">
<label for="academic-year-title">Название</label>
<input type="text" id="academic-year-title" placeholder="2026-2027" required>
<label for="academic-year-start">Год начала учебного года</label>
<input type="text" id="academic-year-start" inputmode="numeric" maxlength="4" pattern="[0-9]{4}" placeholder="2026" autocomplete="off" aria-describedby="academic-year-start-hint" required>
<small class="muted academic-year-field-hint" id="academic-year-start-hint">Введите четыре цифры — дата начала будет 1 сентября.</small>
</div>
<div class="form-group">
<label for="academic-year-start">Начало</label>
<input type="text" id="academic-year-start" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
<label for="academic-year-end">Год окончания учебного года</label>
<input type="text" id="academic-year-end" inputmode="numeric" maxlength="4" placeholder="2027" readonly aria-readonly="true" required>
<small class="muted academic-year-field-hint">Подставляется автоматически и формирует название учебного года.</small>
</div>
<div class="form-group">
<label for="academic-year-end">Окончание</label>
<input type="text" id="academic-year-end" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
</div>
<section class="academic-year-semester-builder" aria-labelledby="academic-year-semesters-title">
<div class="academic-year-semester-builder-head">
<div>
<h3 id="academic-year-semesters-title">Семестры</h3>
<p class="muted">Проверьте даты: оба семестра будут сохранены вместе с учебным годом.</p>
</div>
<span class="academic-year-required-badge">Обязательно</span>
</div>
<button type="submit" class="btn btn-md btn-primary">Сохранить год</button>
<button type="button" class="btn btn-md btn-ghost" id="academic-year-reset">Очистить</button>
<div class="academic-year-semester-grid">
<article class="academic-year-semester-card" data-academic-year-semester="autumn">
<input type="hidden" id="academic-year-autumn-id">
<div class="academic-year-semester-card-title">
<span class="academic-year-semester-dot academic-year-semester-dot-autumn" aria-hidden="true"></span>
<strong>Осенний семестр</strong>
</div>
<div class="academic-year-semester-dates">
<div class="form-group">
<label for="academic-year-autumn-start">Начало</label>
<input type="text" id="academic-year-autumn-start" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
</div>
<div class="form-group">
<label for="academic-year-autumn-end">Окончание</label>
<input type="text" id="academic-year-autumn-end" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
</div>
</div>
</article>
<article class="academic-year-semester-card" data-academic-year-semester="spring">
<input type="hidden" id="academic-year-spring-id">
<div class="academic-year-semester-card-title">
<span class="academic-year-semester-dot academic-year-semester-dot-spring" aria-hidden="true"></span>
<strong>Весенний семестр</strong>
</div>
<div class="academic-year-semester-dates">
<div class="form-group">
<label for="academic-year-spring-start">Начало</label>
<input type="text" id="academic-year-spring-start" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
</div>
<div class="form-group">
<label for="academic-year-spring-end">Окончание</label>
<input type="text" id="academic-year-spring-end" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
</div>
</div>
</article>
</div>
</section>
<div class="academic-year-form-actions">
<button type="submit" class="btn btn-md btn-primary">Сохранить учебный год и семестры</button>
</div>
<div class="form-alert" id="academic-year-alert" role="alert"></div>
</form>
@@ -53,39 +105,6 @@
</div>
</div>
<div class="card create-card">
<h2 id="semester-form-title">Семестр</h2>
<form id="semester-form" novalidate>
<input type="hidden" id="semester-id">
<div class="form-row">
<div class="form-group">
<label for="semester-year">Учебный год</label>
<select id="semester-year" required>
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="semester-type">Тип семестра</label>
<select id="semester-type" required>
<option value="autumn">Осенний</option>
<option value="spring">Весенний</option>
</select>
</div>
<div class="form-group">
<label for="semester-start">Начало</label>
<input type="text" id="semester-start" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
</div>
<div class="form-group">
<label for="semester-end">Окончание</label>
<input type="text" id="semester-end" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
</div>
<button type="submit" class="btn btn-md btn-primary">Сохранить семестр</button>
<button type="button" class="btn btn-md btn-ghost" id="semester-reset">Очистить</button>
</div>
<div class="form-alert" id="semester-alert" role="alert"></div>
</form>
</div>
<div class="card create-card">
<div class="card-header-row">
<h2 id="academic-calendar-form-title">Календарный учебный график</h2>

View File

@@ -158,10 +158,10 @@
</div>
</div>
<div class="schedule-visual-context-menu" id="schedule-visual-context-menu" hidden>
<button type="button" data-action="edit-rule">Открыть правило</button>
<button type="button" data-action="move-slot">Изменить день и пару</button>
<button type="button" data-action="delete-rule" class="danger">Удалить правило</button>
<div class="schedule-visual-context-menu" id="schedule-visual-context-menu" role="menu" hidden>
<button type="button" role="menuitem" data-action="edit-rule">Открыть правило</button>
<button type="button" role="menuitem" data-action="move-slot">Изменить день и пару</button>
<button type="button" role="menuitem" data-action="delete-rule" class="danger">Удалить правило</button>
</div>
<div class="modal-overlay schedule-move-slot-modal" id="schedule-move-slot-modal" role="dialog" aria-modal="true" aria-hidden="true">

View File

@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFile } from 'node:fs/promises';
import {
academicYearDefaults,
activityColorClass,
activityColorCode,
validateAcademicYearSemesters
} from '../admin/js/views/academic-calendar.js';
test('окончание, название и границы семестров рассчитываются по году начала', () => {
assert.deepEqual(academicYearDefaults('2026'), {
startYear: 2026,
endYear: 2027,
title: '2026-2027',
startDate: '2026-09-01',
endDate: '2027-06-30',
semesters: [
{ semesterType: 'autumn', startDate: '2026-09-01', endDate: '2027-01-31' },
{ semesterType: 'spring', startDate: '2027-02-01', endDate: '2027-06-30' }
]
});
});
test('год начала должен состоять ровно из четырёх цифр', () => {
assert.throws(() => academicYearDefaults('26'), /четырёх цифр/);
assert.throws(() => academicYearDefaults('202A'), /четырёх цифр/);
assert.throws(() => academicYearDefaults('9999'), /диапазоне/);
});
test('учебный год нельзя подготовить без семестров', () => {
assert.throws(
() => validateAcademicYearSemesters([], '2026-09-01', '2027-06-30'),
/Учебный год нельзя создать без семестров/
);
});
test('пересекающиеся семестры отклоняются до отправки формы', () => {
assert.throws(
() => validateAcademicYearSemesters([
{ semesterType: 'autumn', startDate: '2026-09-01', endDate: '2027-02-10' },
{ semesterType: 'spring', startDate: '2027-02-01', endDate: '2027-06-30' }
], '2026-09-01', '2027-06-30'),
/Периоды семестров не должны пересекаться/
);
});
test('цвет обозначения берётся из справочника и имеет безопасный запасной вариант', () => {
assert.equal(activityColorCode({ colorCode: '#f59e0b' }), '#f59e0b');
assert.equal(activityColorClass({ colorCode: '#f59e0b' }), 'calendar-activity-color-f59e0b');
assert.equal(activityColorCode({ colorCode: 'red; background: black' }), '#64748b');
});
test('форма создания не содержит поля названия и включает оба семестра', async () => {
const view = await readFile(new URL('../admin/views/academic-calendar.html', import.meta.url), 'utf8');
assert.doesNotMatch(view, /id="academic-year-title"/);
assert.match(view, /id="academic-year-start"[^>]+maxlength="4"[^>]+pattern="\[0-9\]\{4\}"/);
assert.match(view, /id="academic-year-end"[^>]+maxlength="4"[^>]+readonly/);
assert.match(view, /id="academic-year-autumn-start"/);
assert.match(view, /id="academic-year-spring-start"/);
assert.match(view, /Сохранить учебный год и семестры/);
});

View File

@@ -2,7 +2,9 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
findDefaultSemester,
findSemesterForDate,
formatLessonWeeksText,
scheduleRangeForSemester,
semesterOptionLabel
} from '../admin/js/views/schedule-view.js';
@@ -31,20 +33,29 @@ test('семестр определяется по выбранной дате
assert.equal(findSemesterForDate(semesters, '2026-07-01'), null);
});
test('при выборе другого семестра диапазон начинается с его первой учебной недели', () => {
test('вне учебного периода по умолчанию выбирается последний доступный семестр', () => {
assert.equal(findDefaultSemester([springSemester, autumnSemester], '2026-08-14')?.id, 12);
});
test('при выборе семестра диапазон охватывает его целиком', () => {
assert.deepEqual(scheduleRangeForSemester('2026-07-21', autumnSemester), {
startDate: '2025-09-01',
endDate: '2025-09-14'
endDate: '2026-01-31'
});
});
test('внутри выбранного семестра сохраняется текущая двухнедельная точка просмотра', () => {
test('дата внутри выбранного семестра не сужает его до двух недель', () => {
assert.deepEqual(scheduleRangeForSemester('2026-03-18', springSemester), {
startDate: '2026-03-16',
endDate: '2026-03-29'
startDate: '2026-02-09',
endDate: '2026-06-30'
});
});
test('диапазон недель занятия всегда показывается явно', () => {
assert.equal(formatLessonWeeksText(1, 18), '(с 1 по 18 нед.)');
assert.equal(formatLessonWeeksText(7, 7), '(7 нед.)');
});
test('название семестра содержит учебный год, тип и границы', () => {
assert.equal(
semesterOptionLabel(springSemester),

View File

@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFile } from 'node:fs/promises';
import { calculateVisualContextMenuPosition } from '../admin/js/views/schedule.js';
test('меню пары открывается справа от кнопки с тремя точками', () => {
assert.deepEqual(calculateVisualContextMenuPosition({
anchorRect: { left: 100, right: 120, top: 80, bottom: 100 },
menuWidth: 220,
menuHeight: 120,
viewportWidth: 1280,
viewportHeight: 720
}), { left: 128, top: 80 });
});
test('у края экрана меню раскрывается влево и остаётся в viewport', () => {
assert.deepEqual(calculateVisualContextMenuPosition({
anchorRect: { left: 1240, right: 1260, top: 680, bottom: 700 },
menuWidth: 220,
menuHeight: 160,
viewportWidth: 1280,
viewportHeight: 720
}), { left: 1012, top: 540 });
});
test('контекстное меню больше не центрируется стилями', async () => {
const styles = await readFile(new URL('../admin/css/components.css', import.meta.url), 'utf8');
const menuRule = styles.match(/\.schedule-visual-context-menu\s*\{([\s\S]*?)\}/)?.[1] || '';
assert.doesNotMatch(menuRule, /translate\(-50%,\s*-50%\)/);
assert.match(menuRule, /position:\s*fixed/);
});