комит

This commit is contained in:
dipatrik10
2026-07-27 00:33:16 +03:00
parent 92ff87a917
commit 7e29c53ce9
21 changed files with 890 additions and 91 deletions

View File

@@ -1,37 +0,0 @@
# Скопируйте файл в .env и замените пустые значения локальными секретами.
POSTGRES_USER=myuser
POSTGRES_PASSWORD=supersecretpassword#<----
POSTGRES_DB=app_db
# Сгенерировать: openssl rand -base64 48
JWT_SECRET=#<----
JWT_ACCESS_TOKEN_TTL=15m
JWT_REFRESH_TOKEN_TTL=7d
JWT_REFRESH_COOKIE_NAME=magistr_refresh
JWT_REFRESH_COOKIE_SECURE=false
JWT_REFRESH_CLEANUP_ENABLED=true
JWT_REFRESH_CLEANUP_RETENTION=30d
JWT_REFRESH_CLEANUP_BATCH_SIZE=500
JWT_REFRESH_CLEANUP_MAX_BATCHES=20
JWT_REFRESH_CLEANUP_INTERVAL_MS=3600000
JWT_REFRESH_CLEANUP_INITIAL_DELAY_MS=60000
# Защита входа. Указывайте только адреса/сети фактических reverse proxy.
LOGIN_RATE_MAX_FAILURES=5
LOGIN_RATE_ATTEMPT_WINDOW=15m
LOGIN_RATE_BASE_BLOCK_DURATION=1m
LOGIN_RATE_MAX_BLOCK_DURATION=15m
LOGIN_AUDIT_CLEANUP_ENABLED=true
LOGIN_AUDIT_RETENTION=90d
LOGIN_AUDIT_CLEANUP_BATCH_SIZE=500
LOGIN_AUDIT_CLEANUP_MAX_BATCHES=20
LOGIN_AUDIT_CLEANUP_INTERVAL_MS=86400000
LOGIN_AUDIT_CLEANUP_INITIAL_DELAY_MS=120000
TRUSTED_PROXY_CIDRS=172.16.0.0/12
# Календарные бизнес-даты приложения.
BUSINESS_TIME_ZONE=Europe/Moscow
TZ=Europe/Moscow
# Локально Collector не входит в Compose. Укажите false при подключённом OTLP Collector.
OTEL_SDK_DISABLED=true

View File

@@ -114,7 +114,8 @@ public class AcademicCalendarGridService {
private void validateWeek(AcademicYear academicYear, LocalDate date, Integer weekNumber) { private void validateWeek(AcademicYear academicYear, LocalDate date, Integer weekNumber) {
long daysFromYearStart = ChronoUnit.DAYS.between(academicYear.getStartDate(), date); long daysFromYearStart = ChronoUnit.DAYS.between(academicYear.getStartDate(), date);
int expectedWeek = Math.toIntExact(daysFromYearStart / 7 + 1); int daysBeforeYearStartInFirstWeek = academicYear.getStartDate().getDayOfWeek().getValue() - 1;
int expectedWeek = Math.toIntExact((daysBeforeYearStartInFirstWeek + daysFromYearStart) / 7 + 1);
if (weekNumber != expectedWeek) { if (weekNumber != expectedWeek) {
throw new IllegalArgumentException("Номер недели не соответствует дате учебного года"); throw new IllegalArgumentException("Номер недели не соответствует дате учебного года");
} }

View File

@@ -0,0 +1,23 @@
-- Недели календарного учебного графика выровнены по ISO-неделе:
-- понедельник — первый день, воскресенье — последний.
UPDATE academic_calendar_days AS calendar_day
SET week_number = (
(
calendar_day.date
- academic_year.start_date
+ EXTRACT(ISODOW FROM academic_year.start_date)::INTEGER
- 1
) / 7
) + 1
FROM academic_calendars AS calendar
JOIN academic_years AS academic_year
ON academic_year.id = calendar.academic_year_id
WHERE calendar_day.calendar_id = calendar.id
AND calendar_day.week_number IS DISTINCT FROM (
(
calendar_day.date
- academic_year.start_date
+ EXTRACT(ISODOW FROM academic_year.start_date)::INTEGER
- 1
) / 7
) + 1;

View File

@@ -0,0 +1,218 @@
package com.magistr.app.migration;
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.MigrationVersion;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.sql.Connection;
import java.sql.Date;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers
@DisplayName("Миграция нумерации недель календарного графика")
class AcademicCalendarWeekNumberMigrationIntegrationTest {
@Container
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES);
@Test
@DisplayName("V2 переносит первый понедельник после неполной недели в неделю 2")
void migrationAlignsWeeksToMondayFor2026And2027() throws SQLException {
Flyway baseline = flyway("1");
baseline.clean();
baseline.migrate();
long calendar2026;
long calendar2027;
try (Connection connection = POSTGRES.createConnection("")) {
SeedReferences references = loadSeedReferences(connection);
long year2026 = insertAcademicYear(
connection,
"2026-2027",
LocalDate.of(2026, 9, 1),
LocalDate.of(2027, 6, 30)
);
calendar2026 = insertCalendar(connection, year2026, references, "График 2026-2027");
insertCalendarDay(connection, calendar2026, references.activityTypeId(),
LocalDate.of(2026, 9, 1), 1, 2);
insertCalendarDay(connection, calendar2026, references.activityTypeId(),
LocalDate.of(2026, 9, 7), 1, 1);
long year2027 = insertAcademicYear(
connection,
"2027-2028",
LocalDate.of(2027, 9, 1),
LocalDate.of(2028, 6, 30)
);
calendar2027 = insertCalendar(connection, year2027, references, "График 2027-2028");
insertCalendarDay(connection, calendar2027, references.activityTypeId(),
LocalDate.of(2027, 9, 1), 1, 3);
insertCalendarDay(connection, calendar2027, references.activityTypeId(),
LocalDate.of(2027, 9, 6), 1, 1);
}
Flyway latest = latestFlyway();
latest.migrate();
assertThat(latest.info().current()).isNotNull();
assertThat(latest.info().current().getVersion().getVersion()).isEqualTo("2");
try (Connection connection = POSTGRES.createConnection("")) {
assertThat(findWeekNumber(connection, calendar2026, LocalDate.of(2026, 9, 1))).isEqualTo(1);
assertThat(findWeekNumber(connection, calendar2026, LocalDate.of(2026, 9, 7))).isEqualTo(2);
assertThat(findWeekNumber(connection, calendar2027, LocalDate.of(2027, 9, 1))).isEqualTo(1);
assertThat(findWeekNumber(connection, calendar2027, LocalDate.of(2027, 9, 6))).isEqualTo(2);
}
}
private Flyway flyway(String targetVersion) {
return Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.target(MigrationVersion.fromVersion(targetVersion))
.cleanDisabled(false)
.load();
}
private Flyway latestFlyway() {
return Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.cleanDisabled(false)
.load();
}
private SeedReferences loadSeedReferences(Connection connection) throws SQLException {
long specialtyId = queryLong(connection, "SELECT id FROM specialties ORDER BY id LIMIT 1");
long profileId = queryLong(
connection,
"SELECT id FROM specialty_profiles WHERE specialty_id = ? ORDER BY id LIMIT 1",
specialtyId
);
long studyFormId = queryLong(connection, "SELECT id FROM education_forms ORDER BY id LIMIT 1");
long activityTypeId = queryLong(
connection,
"SELECT id FROM academic_calendar_activity_types WHERE code = 'Т'"
);
return new SeedReferences(specialtyId, profileId, studyFormId, activityTypeId);
}
private long insertAcademicYear(Connection connection,
String title,
LocalDate startDate,
LocalDate endDate) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO academic_years (title, start_date, end_date)
VALUES (?, ?, ?)
RETURNING id
""")) {
statement.setString(1, title);
statement.setDate(2, Date.valueOf(startDate));
statement.setDate(3, Date.valueOf(endDate));
return requiredLong(statement);
}
}
private long insertCalendar(Connection connection,
long academicYearId,
SeedReferences references,
String title) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO academic_calendars (
title,
academic_year_id,
specialty_id,
specialty_profile_id,
study_form_id,
course_count
)
VALUES (?, ?, ?, ?, ?, 1)
RETURNING id
""")) {
statement.setString(1, title);
statement.setLong(2, academicYearId);
statement.setLong(3, references.specialtyId());
statement.setLong(4, references.profileId());
statement.setLong(5, references.studyFormId());
return requiredLong(statement);
}
}
private void insertCalendarDay(Connection connection,
long calendarId,
long activityTypeId,
LocalDate date,
int weekNumber,
int dayOfWeek) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO academic_calendar_days (
calendar_id,
course_number,
date,
week_number,
day_of_week,
activity_type_id
)
VALUES (?, 1, ?, ?, ?, ?)
""")) {
statement.setLong(1, calendarId);
statement.setDate(2, Date.valueOf(date));
statement.setInt(3, weekNumber);
statement.setInt(4, dayOfWeek);
statement.setLong(5, activityTypeId);
statement.executeUpdate();
}
}
private int findWeekNumber(Connection connection,
long calendarId,
LocalDate date) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
SELECT week_number
FROM academic_calendar_days
WHERE calendar_id = ?
AND date = ?
""")) {
statement.setLong(1, calendarId);
statement.setDate(2, Date.valueOf(date));
try (ResultSet resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).isTrue();
return resultSet.getInt(1);
}
}
}
private long queryLong(Connection connection, String sql, Object... arguments) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(sql)) {
for (int index = 0; index < arguments.length; index++) {
statement.setObject(index + 1, arguments[index]);
}
return requiredLong(statement);
}
}
private long requiredLong(PreparedStatement statement) throws SQLException {
try (ResultSet resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).isTrue();
return resultSet.getLong(1);
}
}
private record SeedReferences(
long specialtyId,
long profileId,
long studyFormId,
long activityTypeId
) {
}
}

View File

@@ -49,10 +49,11 @@ class AcademicCalendarGridServiceTest {
private AcademicCalendarGridService service; private AcademicCalendarGridService service;
private AcademicCalendarActivityType activityType; private AcademicCalendarActivityType activityType;
private AcademicYear academicYear;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
AcademicYear academicYear = new AcademicYear(); academicYear = new AcademicYear();
academicYear.setId(1L); academicYear.setId(1L);
academicYear.setTitle("2025-2026"); academicYear.setTitle("2025-2026");
academicYear.setStartDate(YEAR_START); academicYear.setStartDate(YEAR_START);
@@ -197,6 +198,48 @@ class AcademicCalendarGridServiceTest {
}); });
} }
@Test
void alignsFirstWeekToMondayWhenAcademicYearStartsOnTuesday() {
LocalDate yearStart = LocalDate.of(2026, 9, 1);
academicYear.setStartDate(yearStart);
academicYear.setEndDate(LocalDate.of(2027, 6, 30));
service.replaceGrid(CALENDAR_ID, List.of(
validRow(yearStart, 1, 2),
validRow(LocalDate.of(2026, 9, 6), 1, 7),
validRow(LocalDate.of(2026, 9, 7), 2, 1),
validRow(LocalDate.of(2026, 9, 14), 3, 1)
));
@SuppressWarnings("unchecked")
ArgumentCaptor<List<AcademicCalendarDay>> replacementCaptor = ArgumentCaptor.forClass(List.class);
verify(calendarDayRepository).saveAll(replacementCaptor.capture());
assertThat(replacementCaptor.getValue())
.extracting(AcademicCalendarDay::getWeekNumber)
.containsExactly(1, 1, 2, 3);
}
@Test
void alignsFirstWeekToMondayWhenAcademicYearStartsOnWednesday() {
LocalDate yearStart = LocalDate.of(2027, 9, 1);
academicYear.setStartDate(yearStart);
academicYear.setEndDate(LocalDate.of(2028, 6, 30));
service.replaceGrid(CALENDAR_ID, List.of(
validRow(yearStart, 1, 3),
validRow(LocalDate.of(2027, 9, 5), 1, 7),
validRow(LocalDate.of(2027, 9, 6), 2, 1),
validRow(LocalDate.of(2027, 9, 13), 3, 1)
));
@SuppressWarnings("unchecked")
ArgumentCaptor<List<AcademicCalendarDay>> replacementCaptor = ArgumentCaptor.forClass(List.class);
verify(calendarDayRepository).saveAll(replacementCaptor.capture());
assertThat(replacementCaptor.getValue())
.extracting(AcademicCalendarDay::getWeekNumber)
.containsExactly(1, 1, 2, 3);
}
private AcademicCalendarGridDayDto validRow(LocalDate date, int weekNumber, int dayOfWeek) { private AcademicCalendarGridDayDto validRow(LocalDate date, int weekNumber, int dayOfWeek) {
return row(date, weekNumber, dayOfWeek, ACTIVITY_TYPE_ID); return row(date, weekNumber, dayOfWeek, ACTIVITY_TYPE_ID);
} }

View File

@@ -63,7 +63,7 @@
1. В разделе кафедр и специальностей администратор создаёт специальность и её профили. 1. В разделе кафедр и специальностей администратор создаёт специальность и её профили.
2. В разделе групп администратор создаёт группу, выбирая специальность и профиль обучения. 2. В разделе групп администратор создаёт группу, выбирая специальность и профиль обучения.
3. В разделе календарного графика администратор создаёт учебный год и семестры. 3. В разделе календарного графика администратор создаёт учебный год и семестры.
4. Там же создаётся календарный учебный график: название, учебный год, специальность, профиль, форма обучения из общего справочника и количество курсов. 4. Там же создаётся календарный учебный график: администратор выбирает учебный год, специальность, профиль, форму обучения из общего справочника и количество курсов. Название вручную не вводится — frontend формирует его в порядке `код специальности — профиль обучения — форма обучения — учебный год`.
5. Администратор открывает сетку графика, заполняет дни вручную или массово через диапазон дат. 5. Администратор открывает сетку графика, заполняет дни вручную или массово через диапазон дат.
6. На вкладке дисциплин графика администратор вручную привязывает дисциплины к учебным семестрам: например, для четырёх курсов доступны семестры `1..8`. 6. На вкладке дисциплин графика администратор вручную привязывает дисциплины к учебным семестрам: например, для четырёх курсов доступны семестры `1..8`.
7. В разделе групп администратор назначает календарный график группе на нужный учебный год; вместе с назначением виден список дисциплин по семестрам. 7. В разделе групп администратор назначает календарный график группе на нужный учебный год; вместе с назначением виден список дисциплин по семестрам.

View File

@@ -559,7 +559,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
**Тело создания/обновления графика:** **Тело создания/обновления графика:**
```json ```json
{ {
"title": "09.03.04 очная форма 2025-2026", "title": "09.03.04 — Программная инженерия — Очная — 2025-2026",
"academicYearId": 1, "academicYearId": 1,
"specialtyId": 2, "specialtyId": 2,
"specialtyProfileId": 3, "specialtyProfileId": 3,
@@ -570,6 +570,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
`studyFormId` берётся из общего справочника форм обучения `GET /api/education-forms`; отдельного справочника форм для календарных графиков нет. `studyFormId` берётся из общего справочника форм обучения `GET /api/education-forms`; отдельного справочника форм для календарных графиков нет.
`courseCount` должен быть в диапазоне `1..8`. `courseCount` должен быть в диапазоне `1..8`.
Административный frontend формирует `title` автоматически из кода специальности, профиля, формы обучения и учебного года; отдельного поля ручного ввода в интерфейсе нет.
При `PUT /api/admin/academic-calendars/{id}` backend блокирует график и повторно проверяет При `PUT /api/admin/academic-calendars/{id}` backend блокирует график и повторно проверяет
все сохранённые назначения, дневную сетку и дисциплины. Изменение года, специальности, все сохранённые назначения, дневную сетку и дисциплины. Изменение года, специальности,
@@ -594,7 +595,9 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
удаления прежних строк backend проверяет весь список: он должен быть непустым и не удаления прежних строк backend проверяет весь список: он должен быть непустым и не
содержать `null`, курс должен входить в `1..courseCount`, дата — в учебный год, содержать `null`, курс должен входить в `1..courseCount`, дата — в учебный год,
`dayOfWeek` — совпадать с ISO-днём даты, а `weekNumber`с номером семидневного периода `dayOfWeek` — совпадать с ISO-днём даты, а `weekNumber`с номером семидневного периода
от начала учебного года. Ключ `(courseNumber, date)` не должен повторяться, каждый `понедельник–воскресенье`. Неделя 1 содержит дату начала учебного года; дни этой недели
до начала года не входят в JSON и отображаются пустыми ячейками. Ключ
`(courseNumber, date)` не должен повторяться, каждый
`activityTypeId` или `activityCode` должен существовать. При любом `400` старая сетка `activityTypeId` или `activityCode` должен существовать. При любом `400` старая сетка
остаётся без изменений; `calendarId` из строки не переопределяет ID в URL. остаётся без изменений; `calendarId` из строки не переопределяет ID в URL.

View File

@@ -166,7 +166,10 @@ Bearer-токен проверяется на backend. Frontend-скрытие
уникальность `(course, date)`, соответствие даты учебному году, номеру недели и ISO-дню, уникальность `(course, date)`, соответствие даты учебному году, номеру недели и ISO-дню,
и разрешает все коды активностей. Только после этого прежняя сетка удаляется и новый набор и разрешает все коды активностей. Только после этого прежняя сетка удаляется и новый набор
записывается одной транзакцией. Ошибка любой строки сохраняет прежнюю сетку целиком, а кэш записывается одной транзакцией. Ошибка любой строки сохраняет прежнюю сетку целиком, а кэш
расписания очищается только после commit. расписания очищается только после commit. Недели дневной сетки выровнены по ISO-неделе
`понедельник–воскресенье`: неделя 1 содержит первый день учебного года, а позиции до него
остаются пустыми. Поэтому при старте года во вторник следующий понедельник относится уже
к неделе 2.
Генератор `ScheduleGeneratorService` рендерит расписание по запросу: Генератор `ScheduleGeneratorService` рендерит расписание по запросу:
1. Определяет семестр для каждой даты диапазона. 1. Определяет семестр для каждой даты диапазона.

View File

@@ -775,7 +775,7 @@ V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` дл
| `calendar_id` | BIGINT FK → academic_calendars (CASCADE) | Календарный график | | `calendar_id` | BIGINT FK → academic_calendars (CASCADE) | Календарный график |
| `course_number` | INT | Номер курса | | `course_number` | INT | Номер курса |
| `date` | DATE | Дата учебного года | | `date` | DATE | Дата учебного года |
| `week_number` | INT | Номер недели от начала учебного года | | `week_number` | INT | Номер периода `понедельник–воскресенье`; неделя 1 содержит начало учебного года |
| `day_of_week` | INT CHECK(17) | День недели ISO | | `day_of_week` | INT CHECK(17) | День недели ISO |
| `activity_type_id` | BIGINT FK → academic_calendar_activity_types | Код активности | | `activity_type_id` | BIGINT FK → academic_calendar_activity_types | Код активности |
@@ -920,13 +920,14 @@ lifecycle ресурсов, эффективная сетка целевого
| Файл | Описание | | Файл | Описание |
|------|----------| |------|----------|
| `V1__init.sql` | Полная baseline-схема: справочники, роли, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, история кафедр, календарные графики, динамическое расписание, точечные изменения с переносом даты, seed, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии | | `V1__init.sql` | Полная baseline-схема: справочники, роли, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, история кафедр, календарные графики, динамическое расписание, точечные изменения с переносом даты, seed, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии |
| `V2__align_academic_calendar_weeks_to_monday.sql` | Перенумерация сохранённых дней календарного графика по периодам `понедельник–воскресенье`, чтобы неполная первая неделя не заполнялась датами следующей недели |
### Этап разработки ### Этап разработки
По прямому решению владельца проекта все миграции V2V7 объединены в V1, поскольку По прямому решению владельца проекта прежние разработческие миграции V2V7 были объединены
клиентских tenant-БД ещё нет. После изменения контрольной суммы V1 локальную базу нужно в baseline `V1`. Новая миграция `V2__align_academic_calendar_weeks_to_monday.sql` создана
пересоздать целиком; накатывание этой редакции поверх БД со старой записью V1 в после фиксации baseline и накатывается поверх существующих tenant-БД без изменения
`flyway_schema_history` не поддерживается. контрольной суммы V1.
### Полный сброс БД (локально) ### Полный сброс БД (локально)

View File

@@ -26,6 +26,9 @@ frontend/
├── scripts/ ├── scripts/
│ └── build-vendor.mjs # Сборка `/vendor/otel.js` через esbuild │ └── build-vendor.mjs # Сборка `/vendor/otel.js` через esbuild
├── tests/ ├── tests/
│ ├── academic-calendar-grid.test.mjs # Неполная первая неделя календарного графика
│ ├── academic-calendar-title.test.mjs # Составное название календарного графика
│ ├── academic-calendar-tooltip.test.mjs # Позиционирование подсказки возле курсора и границ viewport
│ ├── auth-session.test.mjs # Login/refresh/reload/logout и single-flight refresh │ ├── auth-session.test.mjs # Login/refresh/reload/logout и single-flight refresh
│ ├── dashboard-conflicts.test.mjs # Регрессии дат и состояний проверки конфликтов │ ├── dashboard-conflicts.test.mjs # Регрессии дат и состояний проверки конфликтов
│ ├── schedule-overrides.test.mjs # Действия, роли, недельный выбор и подбор времени разовой правки │ ├── schedule-overrides.test.mjs # Действия, роли, недельный выбор и подбор времени разовой правки
@@ -51,6 +54,7 @@ frontend/
│ │ ├── role-capabilities.js # Единая матрица вкладок admin/settings по ролям │ │ ├── role-capabilities.js # Единая матрица вкладок admin/settings по ролям
│ │ ├── api.js # HTTP-обёртка (fetch + Authorization) │ │ ├── api.js # HTTP-обёртка (fetch + Authorization)
│ │ ├── dashboard-conflicts.js # Чистые функции дат, загрузки и состояний Red Zone │ │ ├── dashboard-conflicts.js # Чистые функции дат, загрузки и состояний Red Zone
│ │ ├── date-input.js # Маска ДД.ММ.ГГГГ и преобразование дат в ISO
│ │ ├── utils.js # Утилиты │ │ ├── utils.js # Утилиты
│ │ └── views/ # Модули представлений │ │ └── views/ # Модули представлений
│ │ ├── dashboard.js # Дашборд │ │ ├── dashboard.js # Дашборд
@@ -64,6 +68,8 @@ frontend/
│ │ ├── schedule-view.js # Просмотр расписаний и запуск разовой правки из карточки │ │ ├── schedule-view.js # Просмотр расписаний и запуск разовой правки из карточки
│ │ ├── schedule-override-panel.js # Боковая панель и реестр разовых изменений │ │ ├── schedule-override-panel.js # Боковая панель и реестр разовых изменений
│ │ ├── schedule.js # Конструктор правил расписания │ │ ├── schedule.js # Конструктор правил расписания
│ │ ├── academic-calendar-grid.js # Расчёт ISO-недели дневной сетки
│ │ ├── academic-calendar-title.js # Название из кода, профиля, формы и года
│ │ ├── academic-calendar.js # Календарные учебные графики │ │ ├── academic-calendar.js # Календарные учебные графики
│ │ └── auditorium-workload.js # Загруженность аудиторий, преподавателей и кафедр │ │ └── auditorium-workload.js # Загруженность аудиторий, преподавателей и кафедр
│ ├── views/ # HTML-шаблоны представлений │ ├── views/ # HTML-шаблоны представлений
@@ -178,9 +184,9 @@ frontend/
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`. - Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
- Вкладка `university-structure` содержит внутренние разделы `Кафедры`, `Специальности` и `Профили`, оформленные тем же визуальным паттерном вкладок, что и `academic-calendar`: администратор создаёт профили как из общего списка, так и через кнопку `Профили` у конкретной специальности. - Вкладка `university-structure` содержит внутренние разделы `Кафедры`, `Специальности` и `Профили`, оформленные тем же визуальным паттерном вкладок, что и `academic-calendar`: администратор создаёт профили как из общего списка, так и через кнопку `Профили` у конкретной специальности.
- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары открывает контекстное меню: можно открыть полное правило в форме, изменить только день и базовую пару выбранного слота через компактную модалку или удалить правило целиком. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними. - Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары открывает контекстное меню: можно открыть полное правило в форме, изменить только день и базовую пару выбранного слота через компактную модалку или удалить правило целиком. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними.
- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий и внутри себя разделена на три вкладки: `Графики` для учебных годов, семестров и карточек календарных графиков, `Сетки` для редактора дневной сетки, `Дисциплины` для ручной привязки дисциплин из `/api/subjects` к номерам учебных семестров графика. Администратор выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. - Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий и внутри себя разделена на три вкладки: `Графики` для учебных годов, семестров и карточек календарных графиков, `Сетки` для редактора дневной сетки, `Дисциплины` для ручной привязки дисциплин из `/api/subjects` к номерам учебных семестров графика. Отдельного поля названия у календарного графика нет: frontend формирует его как `код специальности — профиль обучения — форма обучения — учебный год`, показывает одинаково в таблицах и селектах и отправляет вычисленное значение в `title` для совместимости с API. Администратор выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. Все шесть полей дат принимают до восьми цифр, автоматически показывают их в формате `ДД.ММ.ГГГГ`, ограничивают год четырьмя цифрами и перед отправкой преобразуют значение в ISO `ГГГГ-ММ-ДД`.
- При сохранении правила во вкладке `schedule` frontend различает `409 Conflict` от остальных ошибок API. Если backend возвращает `conflictRule`, открывается широкое модальное окно разрешения конфликта: пользователь видит новое и ранее созданное правило, чипы причины конфликта из `conflictReasons` и подсветку соответствующих полей (`teacher`, `classroom`, `group`). Пользователь меняет у конфликтующего правила день, чётность, пару, преподавателя или аудиторию, после чего frontend сохраняет конфликтующее правило через `PUT /api/admin/schedule-rules/{id}` и автоматически повторяет исходный `POST` или `PUT`. Если после переноса появляется следующий конфликт, показывается следующее конфликтующее правило без потери исходного черновика. - При сохранении правила во вкладке `schedule` frontend различает `409 Conflict` от остальных ошибок API. Если backend возвращает `conflictRule`, открывается широкое модальное окно разрешения конфликта: пользователь видит новое и ранее созданное правило, чипы причины конфликта из `conflictReasons` и подсветку соответствующих полей (`teacher`, `classroom`, `group`). Пользователь меняет у конфликтующего правила день, чётность, пару, преподавателя или аудиторию, после чего frontend сохраняет конфликтующее правило через `PUT /api/admin/schedule-rules/{id}` и автоматически повторяет исходный `POST` или `PUT`. Если после переноса появляется следующий конфликт, показывается следующее конфликтующее правило без потери исходного черновика.
- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой, а код активности в подсказке центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно. - Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Недели выровнены по интервалам `понедельник–воскресенье`: если учебный год начинается не в понедельник, предшествующие позиции первой недели остаются пустыми, а следующий понедельник открывает неделю 2. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении возле курсора отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой. У границ viewport подсказка автоматически раскрывается в противоположную сторону и остаётся полностью видимой, а код активности в ней центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно.
- Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление. - Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление.
- Вкладка `users` поддерживает роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`; удаление пользователя работает как архивирование. - Вкладка `users` поддерживает роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`; удаление пользователя работает как архивирование.

View File

@@ -2167,8 +2167,8 @@
visibility: hidden; visibility: hidden;
transform: translateY(4px) scale(0.98); transform: translateY(4px) scale(0.98);
transition: opacity 0.14s ease, transform 0.14s ease, visibility 0.14s ease; transition: opacity 0.14s ease, transform 0.14s ease, visibility 0.14s ease;
right: 12px; left: 0;
bottom: 12px; top: 0;
} }
.calendar-day-tooltip.visible { .calendar-day-tooltip.visible {

View File

@@ -0,0 +1,180 @@
const DISPLAY_DATE_PATTERN = /^(\d{2})\.(\d{2})\.(\d{4})$/;
const ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
const boundDateInputs = new WeakSet();
export function formatDateInputValue(value) {
const text = String(value || '');
const isoMatch = text.match(ISO_DATE_PATTERN);
if (isoMatch) {
return `${isoMatch[3]}.${isoMatch[2]}.${isoMatch[1]}`;
}
const digits = text.replace(/\D/g, '').slice(0, 8);
if (digits.length <= 2) return digits;
if (digits.length <= 4) return `${digits.slice(0, 2)}.${digits.slice(2)}`;
return `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
}
export function dateInputValueToIso(value) {
const text = String(value || '').trim();
if (!text) return '';
const match = text.match(DISPLAY_DATE_PATTERN);
if (!match) {
throw new TypeError('Введите дату полностью в формате ДД.ММ.ГГГГ (год — 4 цифры)');
}
const [, dayText, monthText, yearText] = match;
const day = Number(dayText);
const month = Number(monthText);
const year = Number(yearText);
if (year < 1 || month < 1 || month > 12 || day < 1 || day > daysInMonth(month, year)) {
throw new TypeError('Введите существующую дату в формате ДД.ММ.ГГГГ');
}
return `${yearText}-${monthText}-${dayText}`;
}
export function isoDateToInputValue(value) {
const text = String(value || '').trim();
if (!text) return '';
const match = text.match(ISO_DATE_PATTERN);
if (!match) {
throw new TypeError('Ожидалась дата в формате ГГГГ-ММ-ДД');
}
const [, year, month, day] = match;
dateInputValueToIso(`${day}.${month}.${year}`);
return `${day}.${month}.${year}`;
}
export function bindDateInputMask(input) {
if (!input || boundDateInputs.has(input)) return;
boundDateInputs.add(input);
input.addEventListener('input', () => {
const rawValue = input.value;
const caret = input.selectionStart ?? rawValue.length;
const digitsBeforeCaret = rawValue.slice(0, caret).replace(/\D/g, '').length;
const caretWasAtEnd = caret === rawValue.length;
const formattedValue = formatDateInputValue(rawValue);
input.value = formattedValue;
clearDateInputError(input);
const nextCaret = caretPositionAfterDigits(formattedValue, digitsBeforeCaret, caretWasAtEnd);
input.setSelectionRange?.(nextCaret, nextCaret);
});
input.addEventListener('keydown', event => {
if (event.key === 'Backspace'
&& input.selectionStart === input.selectionEnd
&& input.selectionStart > 0
&& input.value[input.selectionStart - 1] === '.') {
event.preventDefault();
const nextCaret = input.selectionStart - 1;
input.setSelectionRange(nextCaret, nextCaret);
}
if (event.key === 'Delete'
&& input.selectionStart === input.selectionEnd
&& input.value[input.selectionStart] === '.') {
event.preventDefault();
const nextCaret = input.selectionStart + 1;
input.setSelectionRange(nextCaret, nextCaret);
}
});
input.addEventListener('blur', () => {
if (!input.value) {
clearDateInputError(input);
return;
}
try {
const isoValue = dateInputValueToIso(input.value);
input.value = isoDateToInputValue(isoValue);
clearDateInputError(input);
} catch (error) {
setDateInputError(input, error.message);
}
});
input.form?.addEventListener('reset', () => clearDateInputError(input));
}
export function readDateInputIso(input, label = 'Дата') {
try {
const value = dateInputValueToIso(input?.value);
clearDateInputError(input);
return value;
} catch (error) {
const message = `${label}: ${lowercaseFirst(error.message)}`;
setDateInputError(input, message);
input?.focus();
throw new Error(message);
}
}
export function setDateInputIso(input, value) {
if (!input) return;
input.value = isoDateToInputValue(value);
clearDateInputError(input);
}
function daysInMonth(month, year) {
const monthLengths = [
31,
isLeapYear(year) ? 29 : 28,
31,
30,
31,
30,
31,
31,
30,
31,
30,
31
];
return monthLengths[month - 1] || 0;
}
function isLeapYear(year) {
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
}
function caretPositionAfterDigits(value, digitCount, preferEnd) {
if (digitCount <= 0) return 0;
let seenDigits = 0;
for (let index = 0; index < value.length; index += 1) {
if (!/\d/.test(value[index])) continue;
seenDigits += 1;
if (seenDigits === digitCount) {
let position = index + 1;
if (preferEnd) {
while (value[position] === '.') position += 1;
}
return position;
}
}
return value.length;
}
function setDateInputError(input, message) {
if (!input) return;
input.setCustomValidity?.(message);
input.setAttribute?.('aria-invalid', 'true');
}
function clearDateInputError(input) {
if (!input) return;
input.setCustomValidity?.('');
input.removeAttribute?.('aria-invalid');
}
function lowercaseFirst(value) {
const text = String(value || '');
return text ? text[0].toLowerCase() + text.slice(1) : text;
}

View File

@@ -0,0 +1,39 @@
const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
export function academicCalendarWeekNumber(academicYearStartIso, dateIso) {
const academicYearStart = parseIsoDate(academicYearStartIso);
const date = parseIsoDate(dateIso);
const daysFromYearStart = (date.timestamp - academicYearStart.timestamp) / MILLISECONDS_PER_DAY;
if (!Number.isInteger(daysFromYearStart) || daysFromYearStart < 0) {
throw new RangeError('Дата ячейки не может быть раньше начала учебного года');
}
const daysBeforeYearStartInFirstWeek = academicYearStart.dayOfWeek - 1;
return Math.floor((daysBeforeYearStartInFirstWeek + daysFromYearStart) / 7) + 1;
}
function parseIsoDate(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value));
if (!match) {
throw new TypeError('Ожидалась дата в формате ГГГГ-ММ-ДД');
}
const [, yearText, monthText, dayText] = match;
const year = Number(yearText);
const month = Number(monthText);
const day = Number(dayText);
const timestamp = Date.UTC(year, month - 1, day);
const date = new Date(timestamp);
if (date.getUTCFullYear() !== year
|| date.getUTCMonth() !== month - 1
|| date.getUTCDate() !== day) {
throw new TypeError('Ожидалась дата в формате ГГГГ-ММ-ДД');
}
const jsDayOfWeek = date.getUTCDay();
return {
timestamp,
dayOfWeek: jsDayOfWeek === 0 ? 7 : jsDayOfWeek
};
}

View File

@@ -0,0 +1,21 @@
const TITLE_SEPARATOR = ' — ';
function normalizeTitlePart(value) {
return String(value ?? '').trim();
}
export function buildAcademicCalendarTitle({
specialtyCode,
specialtyProfileName,
studyFormName,
academicYearTitle
} = {}) {
const parts = [
specialtyCode,
specialtyProfileName,
studyFormName,
academicYearTitle
].map(normalizeTitlePart);
return parts.every(Boolean) ? parts.join(TITLE_SEPARATOR) : '';
}

View File

@@ -1,5 +1,8 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { bindDateInputMask, readDateInputIso, setDateInputIso } from '../date-input.js';
import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js'; import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js';
import { academicCalendarWeekNumber } from './academic-calendar-grid.js';
import { buildAcademicCalendarTitle } from './academic-calendar-title.js';
const DAY_OPTIONS = [ const DAY_OPTIONS = [
{ value: 1, label: 'Понедельник', shortLabel: 'Пн' }, { value: 1, label: 'Понедельник', shortLabel: 'Пн' },
@@ -16,6 +19,41 @@ const SEMESTER_LABELS = {
spring: 'весенний' spring: 'весенний'
}; };
const CALENDAR_TOOLTIP_OFFSET = 14;
const CALENDAR_TOOLTIP_VIEWPORT_PADDING = 12;
export function calculateCalendarTooltipPosition({
pointerX,
pointerY,
tooltipWidth,
tooltipHeight,
viewportWidth,
viewportHeight
}) {
const maxLeft = Math.max(
CALENDAR_TOOLTIP_VIEWPORT_PADDING,
viewportWidth - tooltipWidth - CALENDAR_TOOLTIP_VIEWPORT_PADDING
);
const maxTop = Math.max(
CALENDAR_TOOLTIP_VIEWPORT_PADDING,
viewportHeight - tooltipHeight - CALENDAR_TOOLTIP_VIEWPORT_PADDING
);
let left = pointerX + CALENDAR_TOOLTIP_OFFSET;
let top = pointerY + CALENDAR_TOOLTIP_OFFSET;
if (left > maxLeft) {
left = pointerX - tooltipWidth - CALENDAR_TOOLTIP_OFFSET;
}
if (top > maxTop) {
top = pointerY - tooltipHeight - CALENDAR_TOOLTIP_OFFSET;
}
return {
left: Math.round(Math.max(CALENDAR_TOOLTIP_VIEWPORT_PADDING, Math.min(left, maxLeft))),
top: Math.round(Math.max(CALENDAR_TOOLTIP_VIEWPORT_PADDING, Math.min(top, maxTop)))
};
}
export async function initAcademicCalendar() { export async function initAcademicCalendar() {
const academicYearForm = document.getElementById('academic-year-form'); const academicYearForm = document.getElementById('academic-year-form');
const academicYearFormTitle = document.getElementById('academic-year-form-title'); const academicYearFormTitle = document.getElementById('academic-year-form-title');
@@ -39,7 +77,6 @@ export async function initAcademicCalendar() {
const calendarForm = document.getElementById('academic-calendar-form'); const calendarForm = document.getElementById('academic-calendar-form');
const calendarFormTitle = document.getElementById('academic-calendar-form-title'); const calendarFormTitle = document.getElementById('academic-calendar-form-title');
const calendarIdInput = document.getElementById('academic-calendar-id'); const calendarIdInput = document.getElementById('academic-calendar-id');
const calendarTitleInput = document.getElementById('academic-calendar-title');
const calendarYearSelect = document.getElementById('academic-calendar-year'); const calendarYearSelect = document.getElementById('academic-calendar-year');
const calendarSpecialtySelect = document.getElementById('academic-calendar-specialty'); const calendarSpecialtySelect = document.getElementById('academic-calendar-specialty');
const calendarProfileSelect = document.getElementById('academic-calendar-profile'); const calendarProfileSelect = document.getElementById('academic-calendar-profile');
@@ -100,6 +137,7 @@ export async function initAcademicCalendar() {
let suppressCalendarDayClick = false; let suppressCalendarDayClick = false;
let isSyncingCalendarDayDialog = false; let isSyncingCalendarDayDialog = false;
let calendarDayTooltip = null; let calendarDayTooltip = null;
let calendarDayTooltipPositionAnimation = null;
bindEvents(); bindEvents();
@@ -119,6 +157,15 @@ export async function initAcademicCalendar() {
} }
function bindEvents() { function bindEvents() {
[
academicYearStartInput,
academicYearEndInput,
semesterStartInput,
semesterEndInput,
fillStartInput,
fillEndInput
].forEach(bindDateInputMask);
calendarTabButtons.forEach(button => { calendarTabButtons.forEach(button => {
button.addEventListener('click', () => activateCalendarTab(button.dataset.calendarTab)); button.addEventListener('click', () => activateCalendarTab(button.dataset.calendarTab));
}); });
@@ -271,12 +318,12 @@ export async function initAcademicCalendar() {
async function saveAcademicYear(event) { async function saveAcademicYear(event) {
event.preventDefault(); event.preventDefault();
hideAlert('academic-year-alert'); hideAlert('academic-year-alert');
const payload = {
title: academicYearTitleInput.value.trim(),
startDate: academicYearStartInput.value,
endDate: academicYearEndInput.value
};
try { try {
const payload = {
title: academicYearTitleInput.value.trim(),
startDate: readDateInputIso(academicYearStartInput, 'Начало учебного года'),
endDate: readDateInputIso(academicYearEndInput, 'Окончание учебного года')
};
if (!payload.title || !payload.startDate || !payload.endDate) throw new Error('Заполните название и даты учебного года'); if (!payload.title || !payload.startDate || !payload.endDate) throw new Error('Заполните название и даты учебного года');
const id = academicYearIdInput.value; const id = academicYearIdInput.value;
await (id await (id
@@ -302,8 +349,8 @@ export async function initAcademicCalendar() {
academicYearFormTitle.textContent = 'Редактирование учебного года'; academicYearFormTitle.textContent = 'Редактирование учебного года';
academicYearIdInput.value = year.id; academicYearIdInput.value = year.id;
academicYearTitleInput.value = year.title || ''; academicYearTitleInput.value = year.title || '';
academicYearStartInput.value = year.startDate || ''; setDateInputIso(academicYearStartInput, year.startDate);
academicYearEndInput.value = year.endDate || ''; setDateInputIso(academicYearEndInput, year.endDate);
hideAlert('academic-year-alert'); hideAlert('academic-year-alert');
academicYearForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); academicYearForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
return; return;
@@ -316,8 +363,8 @@ export async function initAcademicCalendar() {
semesterIdInput.value = semester.id; semesterIdInput.value = semester.id;
semesterYearSelect.value = semester.academicYearId || ''; semesterYearSelect.value = semester.academicYearId || '';
semesterTypeSelect.value = normalizeSemesterType(semester.semesterType); semesterTypeSelect.value = normalizeSemesterType(semester.semesterType);
semesterStartInput.value = semester.startDate || ''; setDateInputIso(semesterStartInput, semester.startDate);
semesterEndInput.value = semester.endDate || ''; setDateInputIso(semesterEndInput, semester.endDate);
syncSelects(semesterYearSelect, semesterTypeSelect); syncSelects(semesterYearSelect, semesterTypeSelect);
hideAlert('semester-alert'); hideAlert('semester-alert');
semesterForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); semesterForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
@@ -348,12 +395,12 @@ export async function initAcademicCalendar() {
event.preventDefault(); event.preventDefault();
hideAlert('semester-alert'); hideAlert('semester-alert');
const yearId = semesterYearSelect.value; const yearId = semesterYearSelect.value;
const payload = {
semesterType: semesterTypeSelect.value,
startDate: semesterStartInput.value,
endDate: semesterEndInput.value
};
try { try {
const payload = {
semesterType: semesterTypeSelect.value,
startDate: readDateInputIso(semesterStartInput, 'Начало семестра'),
endDate: readDateInputIso(semesterEndInput, 'Окончание семестра')
};
if (!yearId || !payload.semesterType || !payload.startDate || !payload.endDate) { if (!yearId || !payload.semesterType || !payload.startDate || !payload.endDate) {
throw new Error('Заполните учебный год, тип и даты семестра'); throw new Error('Заполните учебный год, тип и даты семестра');
} }
@@ -420,7 +467,7 @@ export async function initAcademicCalendar() {
} }
calendarsTbody.innerHTML = calendars.map(calendar => ` calendarsTbody.innerHTML = calendars.map(calendar => `
<tr> <tr>
<td>${escapeHtml(calendar.title)}</td> <td>${escapeHtml(buildAcademicCalendarTitle(calendar) || '-')}</td>
<td>${escapeHtml(calendar.academicYearTitle || '-')}</td> <td>${escapeHtml(calendar.academicYearTitle || '-')}</td>
<td>${escapeHtml(`${calendar.specialtyCode || ''} ${calendar.specialtyName || ''}`.trim() || '-')}</td> <td>${escapeHtml(`${calendar.specialtyCode || ''} ${calendar.specialtyName || ''}`.trim() || '-')}</td>
<td>${escapeHtml(calendar.specialtyProfileName || '-')}</td> <td>${escapeHtml(calendar.specialtyProfileName || '-')}</td>
@@ -441,7 +488,7 @@ export async function initAcademicCalendar() {
hideAlert('academic-calendar-alert'); hideAlert('academic-calendar-alert');
const payload = { const payload = {
id: calendarIdInput.value ? Number(calendarIdInput.value) : null, id: calendarIdInput.value ? Number(calendarIdInput.value) : null,
title: calendarTitleInput.value.trim(), title: buildCalendarTitleFromForm(),
academicYearId: Number(calendarYearSelect.value), academicYearId: Number(calendarYearSelect.value),
specialtyId: Number(calendarSpecialtySelect.value), specialtyId: Number(calendarSpecialtySelect.value),
specialtyProfileId: Number(calendarProfileSelect.value), specialtyProfileId: Number(calendarProfileSelect.value),
@@ -457,7 +504,8 @@ export async function initAcademicCalendar() {
const saved = id const saved = id
? await api.put('/api/admin/academic-calendars/' + id, payload) ? await api.put('/api/admin/academic-calendars/' + id, payload)
: await api.post('/api/admin/academic-calendars', payload); : await api.post('/api/admin/academic-calendars', payload);
showAlert('academic-calendar-alert', `График "${escapeHtml(saved.title)}" сохранён`, 'success'); const savedTitle = buildAcademicCalendarTitle(saved) || payload.title;
showAlert('academic-calendar-alert', `График "${escapeHtml(savedTitle)}" сохранён`, 'success');
resetCalendarForm(); resetCalendarForm();
await loadCalendars(); await loadCalendars();
} catch (error) { } catch (error) {
@@ -477,7 +525,6 @@ export async function initAcademicCalendar() {
if (!calendar) return; if (!calendar) return;
calendarFormTitle.textContent = 'Редактирование календарного графика'; calendarFormTitle.textContent = 'Редактирование календарного графика';
calendarIdInput.value = calendar.id; calendarIdInput.value = calendar.id;
calendarTitleInput.value = calendar.title || '';
calendarYearSelect.value = calendar.academicYearId || ''; calendarYearSelect.value = calendar.academicYearId || '';
calendarSpecialtySelect.value = calendar.specialtyId || ''; calendarSpecialtySelect.value = calendar.specialtyId || '';
populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value, calendar.specialtyProfileId); populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value, calendar.specialtyProfileId);
@@ -721,12 +768,19 @@ export async function initAcademicCalendar() {
function applyCalendarFill() { function applyCalendarFill() {
hideAlert('calendar-editor-alert'); hideAlert('calendar-editor-alert');
const activityId = fillActivitySelect.value; const activityId = fillActivitySelect.value;
const start = fillStartInput.value; if (!activityId || !fillStartInput.value || !fillEndInput.value) {
const end = fillEndInput.value;
if (!activityId || !start || !end) {
showAlert('calendar-editor-alert', 'Выберите даты и код активности для заполнения', 'error'); showAlert('calendar-editor-alert', 'Выберите даты и код активности для заполнения', 'error');
return; return;
} }
let start;
let end;
try {
start = readDateInputIso(fillStartInput, 'Дата начала диапазона');
end = readDateInputIso(fillEndInput, 'Дата окончания диапазона');
} catch (error) {
showAlert('calendar-editor-alert', error.message, 'error');
return;
}
if (end < start) { if (end < start) {
showAlert('calendar-editor-alert', 'Дата окончания не может быть раньше даты начала', 'error'); showAlert('calendar-editor-alert', 'Дата окончания не может быть раньше даты начала', 'error');
return; return;
@@ -1051,12 +1105,33 @@ export async function initAcademicCalendar() {
const tooltip = ensureCalendarDayTooltip(); const tooltip = ensureCalendarDayTooltip();
tooltip.innerHTML = renderCalendarDayTooltip(day); tooltip.innerHTML = renderCalendarDayTooltip(day);
tooltip.classList.add('visible'); tooltip.classList.add('visible');
positionCalendarDayTooltip(tooltip, day, event);
} }
function hideCalendarDayTooltip() { function hideCalendarDayTooltip() {
calendarDayTooltip?.classList.remove('visible'); calendarDayTooltip?.classList.remove('visible');
} }
function positionCalendarDayTooltip(tooltip, day, event) {
const dayRect = day.getBoundingClientRect();
const pointerX = Number.isFinite(event?.clientX) ? event.clientX : dayRect.right;
const pointerY = Number.isFinite(event?.clientY) ? event.clientY : dayRect.bottom;
const tooltipRect = tooltip.getBoundingClientRect();
const { left, top } = calculateCalendarTooltipPosition({
pointerX,
pointerY,
tooltipWidth: tooltipRect.width,
tooltipHeight: tooltipRect.height,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight
});
calendarDayTooltipPositionAnimation?.cancel();
calendarDayTooltipPositionAnimation = tooltip.animate(
[{ left: `${left}px`, top: `${top}px` }],
{ duration: 0, fill: 'forwards' }
);
}
function ensureCalendarDayTooltip() { function ensureCalendarDayTooltip() {
if (calendarDayTooltip) return calendarDayTooltip; if (calendarDayTooltip) return calendarDayTooltip;
calendarDayTooltip = document.createElement('div'); calendarDayTooltip = document.createElement('div');
@@ -1156,13 +1231,13 @@ export async function initAcademicCalendar() {
const rows = []; const rows = [];
const dates = enumerateDates(calendar.academicYearStartDate, calendar.academicYearEndDate); const dates = enumerateDates(calendar.academicYearStartDate, calendar.academicYearEndDate);
for (let course = 1; course <= calendar.courseCount; course += 1) { for (let course = 1; course <= calendar.courseCount; course += 1) {
dates.forEach((date, index) => { dates.forEach(date => {
rows.push({ rows.push({
id: null, id: null,
calendarId: calendar.id, calendarId: calendar.id,
courseNumber: course, courseNumber: course,
date: date.iso, date: date.iso,
weekNumber: Math.floor(index / 7) + 1, weekNumber: academicCalendarWeekNumber(calendar.academicYearStartDate, date.iso),
dayOfWeek: date.dayOfWeek, dayOfWeek: date.dayOfWeek,
activityTypeId: theoryActivityId(), activityTypeId: theoryActivityId(),
activityCode: 'Т' activityCode: 'Т'
@@ -1285,7 +1360,9 @@ export async function initAcademicCalendar() {
function populateEditorCalendarSelect() { function populateEditorCalendarSelect() {
const current = editorCalendarSelect.value; const current = editorCalendarSelect.value;
editorCalendarSelect.innerHTML = '<option value="">Выберите график</option>' + editorCalendarSelect.innerHTML = '<option value="">Выберите график</option>' +
calendars.map(calendar => `<option value="${calendar.id}">${escapeHtml(calendar.title)}</option>`).join(''); calendars.map(calendar =>
`<option value="${calendar.id}">${escapeHtml(buildAcademicCalendarTitle(calendar) || '-')}</option>`
).join('');
if (current && calendars.some(calendar => String(calendar.id) === String(current))) { if (current && calendars.some(calendar => String(calendar.id) === String(current))) {
editorCalendarSelect.value = current; editorCalendarSelect.value = current;
} }
@@ -1297,7 +1374,7 @@ export async function initAcademicCalendar() {
const current = subjectCalendarSelect.value; const current = subjectCalendarSelect.value;
subjectCalendarSelect.innerHTML = calendars.length subjectCalendarSelect.innerHTML = calendars.length
? '<option value="">Выберите график</option>' + calendars.map(calendar => ? '<option value="">Выберите график</option>' + calendars.map(calendar =>
`<option value="${calendar.id}">${escapeHtml(calendar.title)}</option>` `<option value="${calendar.id}">${escapeHtml(buildAcademicCalendarTitle(calendar) || '-')}</option>`
).join('') ).join('')
: '<option value="">Нет графиков</option>'; : '<option value="">Нет графиков</option>';
if (current && calendars.some(calendar => String(calendar.id) === String(current))) { if (current && calendars.some(calendar => String(calendar.id) === String(current))) {
@@ -1425,11 +1502,40 @@ export async function initAcademicCalendar() {
} }
function specialityLabel(speciality) { function specialityLabel(speciality) {
const code = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || speciality.id; const code = specialityCode(speciality) || speciality.id;
const name = speciality.specialityName || speciality.name || ''; const name = speciality.specialityName || speciality.name || '';
return name ? `${code} - ${name}` : String(code); return name ? `${code} - ${name}` : String(code);
} }
function specialityCode(speciality) {
return speciality?.specialityCode
|| speciality?.specialtyCode
|| speciality?.speciality_code
|| speciality?.specialty_code
|| '';
}
function buildCalendarTitleFromForm() {
const specialty = specialties.find(item =>
String(item.id) === String(calendarSpecialtySelect.value)
);
const profile = (profilesBySpecialty.get(String(calendarSpecialtySelect.value)) || [])
.find(item => String(item.id) === String(calendarProfileSelect.value));
const studyForm = studyForms.find(item =>
String(item.id) === String(calendarStudyFormSelect.value)
);
const academicYear = academicYears.find(item =>
String(item.id) === String(calendarYearSelect.value)
);
return buildAcademicCalendarTitle({
specialtyCode: specialityCode(specialty),
specialtyProfileName: profile?.name,
studyFormName: studyForm?.name,
academicYearTitle: academicYear?.title
});
}
function dayLabel(value) { function dayLabel(value) {
return DAY_OPTIONS.find(day => String(day.value) === String(value))?.label || '-'; return DAY_OPTIONS.find(day => String(day.value) === String(value))?.label || '-';
} }

View File

@@ -1,5 +1,6 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js'; import { escapeHtml, showAlert, hideAlert } from '../utils.js';
import { buildAcademicCalendarTitle } from './academic-calendar-title.js';
async function fetchEducationForms() { async function fetchEducationForms() {
try { try {
@@ -384,7 +385,7 @@ export async function initGroups() {
: []; : [];
manageCalendarSelect.innerHTML = matching.length manageCalendarSelect.innerHTML = matching.length
? '<option value="">Выберите график</option>' + matching.map(calendar => ? '<option value="">Выберите график</option>' + matching.map(calendar =>
`<option value="${calendar.id}">${escapeHtml(calendar.title)}</option>` `<option value="${calendar.id}">${escapeHtml(buildAcademicCalendarTitle(calendar) || '-')}</option>`
).join('') ).join('')
: '<option value="">Нет подходящих графиков</option>'; : '<option value="">Нет подходящих графиков</option>';
syncSelects(manageCalendarSelect); syncSelects(manageCalendarSelect);
@@ -767,7 +768,7 @@ export async function initGroups() {
manageCalendarTbody.innerHTML = currentAssignments.map(assignment => ` manageCalendarTbody.innerHTML = currentAssignments.map(assignment => `
<tr> <tr>
<td>${escapeHtml(assignment.academicYearTitle)}</td> <td>${escapeHtml(assignment.academicYearTitle)}</td>
<td>${escapeHtml(assignment.calendarTitle)}</td> <td>${escapeHtml(assignmentCalendarTitle(assignment))}</td>
<td>${renderAssignmentSubjects(assignment.subjects || [])}</td> <td>${renderAssignmentSubjects(assignment.subjects || [])}</td>
<td> <td>
<button class="btn btn-sm btn-danger btn-delete-assignment" data-id="${assignment.id}">Удалить</button> <button class="btn btn-sm btn-danger btn-delete-assignment" data-id="${assignment.id}">Удалить</button>
@@ -776,6 +777,13 @@ export async function initGroups() {
`).join(''); `).join('');
} }
function assignmentCalendarTitle(assignment) {
const calendar = calendars.find(item =>
String(item.id) === String(assignment.calendarId)
);
return buildAcademicCalendarTitle(calendar) || assignment.calendarTitle || '-';
}
function renderAssignmentSubjects(subjects) { function renderAssignmentSubjects(subjects) {
if (!subjects.length) { if (!subjects.length) {
return '<span class="muted">Не привязаны</span>'; return '<span class="muted">Не привязаны</span>';

View File

@@ -16,11 +16,11 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="academic-year-start">Начало</label> <label for="academic-year-start">Начало</label>
<input type="date" id="academic-year-start" required> <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>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="academic-year-end">Окончание</label> <label for="academic-year-end">Окончание</label>
<input type="date" id="academic-year-end" required> <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> </div>
<button type="submit" class="btn btn-md btn-primary">Сохранить год</button> <button type="submit" class="btn btn-md btn-primary">Сохранить год</button>
<button type="button" class="btn btn-md btn-ghost" id="academic-year-reset">Очистить</button> <button type="button" class="btn btn-md btn-ghost" id="academic-year-reset">Очистить</button>
@@ -73,11 +73,11 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="semester-start">Начало</label> <label for="semester-start">Начало</label>
<input type="date" id="semester-start" required> <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>
<div class="form-group"> <div class="form-group">
<label for="semester-end">Окончание</label> <label for="semester-end">Окончание</label>
<input type="date" id="semester-end" required> <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> </div>
<button type="submit" class="btn btn-md btn-primary">Сохранить семестр</button> <button type="submit" class="btn btn-md btn-primary">Сохранить семестр</button>
<button type="button" class="btn btn-md btn-ghost" id="semester-reset">Очистить</button> <button type="button" class="btn btn-md btn-ghost" id="semester-reset">Очистить</button>
@@ -94,10 +94,6 @@
<form id="academic-calendar-form" novalidate> <form id="academic-calendar-form" novalidate>
<input type="hidden" id="academic-calendar-id"> <input type="hidden" id="academic-calendar-id">
<div class="form-row"> <div class="form-row">
<div class="form-group">
<label for="academic-calendar-title">Название</label>
<input type="text" id="academic-calendar-title" placeholder="09.03.04 очная форма 2025-2026" required>
</div>
<div class="form-group"> <div class="form-group">
<label for="academic-calendar-year">Учебный год</label> <label for="academic-calendar-year">Учебный год</label>
<select id="academic-calendar-year" required> <select id="academic-calendar-year" required>
@@ -183,11 +179,11 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="calendar-fill-start">С даты</label> <label for="calendar-fill-start">С даты</label>
<input type="date" id="calendar-fill-start"> <input type="text" id="calendar-fill-start" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="calendar-fill-end">По дату</label> <label for="calendar-fill-end">По дату</label>
<input type="date" id="calendar-fill-end"> <input type="text" id="calendar-fill-end" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="calendar-fill-activity">Код</label> <label for="calendar-fill-activity">Код</label>

View File

@@ -0,0 +1,29 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { academicCalendarWeekNumber } from '../admin/js/views/academic-calendar-grid.js';
test('в 2026 году первая неделя оставляет понедельник перед 1 сентября пустым', () => {
const yearStart = '2026-09-01';
assert.equal(academicCalendarWeekNumber(yearStart, '2026-09-01'), 1);
assert.equal(academicCalendarWeekNumber(yearStart, '2026-09-06'), 1);
assert.equal(academicCalendarWeekNumber(yearStart, '2026-09-07'), 2);
assert.equal(academicCalendarWeekNumber(yearStart, '2026-09-14'), 3);
});
test('в 2027 году первая неделя оставляет понедельник и вторник перед 1 сентября пустыми', () => {
const yearStart = '2027-09-01';
assert.equal(academicCalendarWeekNumber(yearStart, '2027-09-01'), 1);
assert.equal(academicCalendarWeekNumber(yearStart, '2027-09-05'), 1);
assert.equal(academicCalendarWeekNumber(yearStart, '2027-09-06'), 2);
assert.equal(academicCalendarWeekNumber(yearStart, '2027-09-13'), 3);
});
test('учебный год с понедельника сохраняет полную первую неделю', () => {
const yearStart = '2025-09-01';
assert.equal(academicCalendarWeekNumber(yearStart, '2025-09-07'), 1);
assert.equal(academicCalendarWeekNumber(yearStart, '2025-09-08'), 2);
});

View File

@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildAcademicCalendarTitle } from '../admin/js/views/academic-calendar-title.js';
test('название календарного графика собирается в заданном порядке', () => {
assert.equal(buildAcademicCalendarTitle({
specialtyCode: '09.03.04',
specialtyProfileName: 'Программная инженерия',
studyFormName: 'Очная',
academicYearTitle: '2025-2026'
}), '09.03.04 — Программная инженерия — Очная — 2025-2026');
});
test('части названия очищаются от лишних пробелов', () => {
assert.equal(buildAcademicCalendarTitle({
specialtyCode: ' 09.03.04 ',
specialtyProfileName: ' Без профиля ',
studyFormName: ' Заочная ',
academicYearTitle: ' 2026-2027 '
}), '09.03.04 — Без профиля — Заочная — 2026-2027');
});
test('неполный набор полей не образует вводящее в заблуждение название', () => {
assert.equal(buildAcademicCalendarTitle({
specialtyCode: '09.03.04',
specialtyProfileName: '',
studyFormName: 'Очная',
academicYearTitle: '2025-2026'
}), '');
});

View File

@@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { calculateCalendarTooltipPosition } from '../admin/js/views/academic-calendar.js';
test('подсказка располагается с отступом справа и снизу от курсора', () => {
assert.deepEqual(calculateCalendarTooltipPosition({
pointerX: 100,
pointerY: 80,
tooltipWidth: 280,
tooltipHeight: 160,
viewportWidth: 1280,
viewportHeight: 720
}), { left: 114, top: 94 });
});
test('у правого нижнего края подсказка раскрывается влево и вверх', () => {
assert.deepEqual(calculateCalendarTooltipPosition({
pointerX: 1260,
pointerY: 700,
tooltipWidth: 280,
tooltipHeight: 160,
viewportWidth: 1280,
viewportHeight: 720
}), { left: 966, top: 526 });
});
test('подсказка не выходит за минимальные отступы узкого viewport', () => {
assert.deepEqual(calculateCalendarTooltipPosition({
pointerX: 190,
pointerY: 110,
tooltipWidth: 176,
tooltipHeight: 96,
viewportWidth: 200,
viewportHeight: 120
}), { left: 12, top: 12 });
});

View File

@@ -0,0 +1,91 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import {
bindDateInputMask,
dateInputValueToIso,
formatDateInputValue,
isoDateToInputValue
} from '../admin/js/date-input.js';
test('маска расставляет точки при последовательном вводе восьми цифр', () => {
assert.equal(formatDateInputValue('0'), '0');
assert.equal(formatDateInputValue('01'), '01');
assert.equal(formatDateInputValue('010'), '01.0');
assert.equal(formatDateInputValue('0109'), '01.09');
assert.equal(formatDateInputValue('01092'), '01.09.2');
assert.equal(formatDateInputValue('01092026'), '01.09.2026');
});
test('маска удаляет посторонние символы и ограничивает год четырьмя цифрами', () => {
assert.equal(formatDateInputValue('01-09/2026'), '01.09.2026');
assert.equal(formatDateInputValue('010920261234'), '01.09.2026');
});
test('привязанная маска расставляет точки прямо во время ввода', () => {
const input = new DateInputStub();
bindDateInputMask(input);
typeText(input, '01092026');
assert.equal(input.value, '01.09.2026');
assert.equal(input.selectionStart, 10);
typeText(input, '12');
assert.equal(input.value, '01.09.2026');
assert.equal(input.selectionStart, 10);
});
test('дата преобразуется между форматом поля и ISO без потери значения', () => {
assert.equal(dateInputValueToIso('01.09.2026'), '2026-09-01');
assert.equal(isoDateToInputValue('2026-09-01'), '01.09.2026');
});
test('неполный, шестизначный год и несуществующие даты отклоняются', () => {
assert.throws(() => dateInputValueToIso('01.09.202'), /год — 4 цифры/);
assert.throws(() => dateInputValueToIso('01.09.202612'), /год — 4 цифры/);
assert.throws(() => dateInputValueToIso('31.02.2026'), /существующую дату/);
assert.equal(dateInputValueToIso('29.02.2024'), '2024-02-29');
assert.throws(() => dateInputValueToIso('29.02.2025'), /существующую дату/);
});
test('все поля дат календарного графика подключены к числовой маске', async () => {
const template = await readFile(new URL('../admin/views/academic-calendar.html', import.meta.url), 'utf8');
assert.equal(template.match(/\bdata-date-input\b/g)?.length, 6);
assert.equal(template.match(/\bmaxlength="10"/g)?.length, 6);
assert.doesNotMatch(template, /type="date"/);
});
class DateInputStub extends EventTarget {
constructor() {
super();
this.value = '';
this.selectionStart = 0;
this.selectionEnd = 0;
this.form = null;
}
setSelectionRange(start, end) {
this.selectionStart = start;
this.selectionEnd = end;
}
setCustomValidity() {}
setAttribute() {}
removeAttribute() {}
focus() {}
}
function typeText(input, text) {
for (const character of text) {
const start = input.selectionStart;
const end = input.selectionEnd;
input.value = input.value.slice(0, start) + character + input.value.slice(end);
input.selectionStart = start + 1;
input.selectionEnd = start + 1;
input.dispatchEvent(new Event('input'));
}
}