diff --git a/README.md b/README.md index f0fb89e..61fe8aa 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ npm run sync Это не универсальный парсер любых документов Word: без заголовков групп, дней и времени импорт возвращает понятную ошибку. -**Даты занятий рассчитываются по университетской таблице «Расписание белых и зеленых недель»** из корня каталога: неделя 1 начинается 01.09.2026 (вторник), нечётные недели белые, чётные зелёные. Пометка в белой или зелёной строке относится только к неделям этого цвета: `1-13н` в белой строке — недели 1, 3, …, 13, а в объединённой строке — каждая неделя. `с 3н.` означает «до конца таблицы». Текстовые даты вроде «15 октября» привязываются к своему дню. Нераспознанные пометки (`2,6-12-16н`) не скрываются, а помечаются для сверки с Word; исходная пометка всегда видна. Разбор пометок общий для сервера и браузера (`shared/weeks.js`), поэтому старые сохранённые расписания тоже получают даты. Вне периода таблицы приложение показывает все строки Word. Номера подгрупп соответствуют столбцам внутри группы; именованных подгрупп в этих шаблонах нет. +**Даты занятий рассчитываются по университетской таблице «Расписание белых и зеленых недель»** из корня каталога: неделя 1 начинается 01.09.2026 (вторник), нечётные недели белые, чётные зелёные. Пометка в белой или зелёной строке относится только к неделям этого цвета: `1-13н` в белой строке — недели 1, 3, …, 13, а в объединённой строке — каждая неделя. `с 3н.` означает «до конца таблицы». Текстовые даты вроде «15 октября» и списки дат числами («10.09;15.10;19.11;17.12», не меньше двух дат, чтобы время пары не путалось с датой) привязываются к своему дню. Нераспознанные пометки (`2,6-12-16н`) не скрываются, а помечаются для сверки с Word; исходная пометка всегда видна. Разбор пометок общий для сервера и браузера (`shared/weeks.js`), поэтому старые сохранённые расписания тоже получают даты. Вне периода таблицы приложение показывает все строки Word. Номера подгрупп соответствуют столбцам внутри группы; именованных подгрупп в этих шаблонах нет. Поля предмета, преподавателя и аудитории извлекаются по типовым обозначениям; сложные многострочные записи могут содержать несколько преподавателей/аудиторий. Поэтому полный текст всегда доступен в деталях. Если в ячейке явно указано особое время, например `16.50` для кураторского часа, оно имеет приоритет; окончание не выдумывается. @@ -135,6 +135,11 @@ DOMAIN=schedule.example.by docker compose --profile https up -d --build - Напоминания перед парой за 5–30 минут — локальные будильники из сохранённого расписания, без push-сервера. С разрешением «Будильники и напоминания» приходят точно в срок, без него — в окне ±5 минут. - Офлайн: последнее расписание хранится на устройстве; каждые 6 часов WorkManager обновляет его, виджет и напоминания. +**Экзамены, зачёты, консультации и защиты курсовых** попадают в «Расписание», «Календарь» и «Планы», даже если университет публикует их отдельным документом («Расписание зачетов»): `/api/timetable` (API 6) добавляет к дням семестра события сессии группы из всех документов каталога и продлевает период до последнего из них (не дальше 60 дней после семестра). +- **Как выделены.** Янтарный цвет (красный читался бы как ошибка), значок вида события и рамка вокруг карточки. +- **В «Планах»** они видны всегда, даже с выключенными «Парами дня». +- **В календаре** день с экзаменом или зачётом отмечен янтарным квадратом, а точка планов стала фиолетовой. + Даты считает сервер: `GET /api/timetable?doc=&group=<группа>&from=ГГГГ-ММ-ДД&days=N` (до 62 дней) или с `&range=semester` — весь текущий семестр от первого до последнего дня; приложение хранит его целиком и работает с ним без сети. Ответ содержит дни с неделями, номерами пар и пометкой `uncertain` для нераспознанных пометок, плюс строки Word (`weekly`) и подгруппы. Поэтому правила белых и зелёных недель в Kotlin не дублируются. Сборка (JDK 17, Android SDK 37): diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/CalendarScreen.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/CalendarScreen.kt index ea288f9..5cbf948 100644 --- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/CalendarScreen.kt +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/CalendarScreen.kt @@ -242,6 +242,7 @@ fun CalendarScreen(vm: MainViewModel, nav: AppNav) { selected = selected, hasLessons = { d -> t != null && vm.slotsOn(t, d, subgroup).isNotEmpty() }, hasPlans = { d -> notes.any { it.date == d.toString() && (!it.task || !it.done) } }, + hasSession = { d -> t != null && vm.slotsOn(t, d, subgroup).any { s -> s.lessons.any { it.session != null } } }, weekOf = { w -> t?.days?.firstOrNull { it.date == w.toString() }?.week }, onSelect = { selected = it }, ) @@ -348,6 +349,7 @@ private fun MonthGrid( selected: LocalDate, hasLessons: (LocalDate) -> Boolean, hasPlans: (LocalDate) -> Boolean, + hasSession: (LocalDate) -> Boolean, weekOf: (LocalDate) -> Week?, onSelect: (LocalDate) -> Unit, ) { @@ -372,6 +374,7 @@ private fun MonthGrid( val isToday = d == today() val lessons = inMonth && hasLessons(d) val plans = inMonth && hasPlans(d) + val session = inMonth && hasSession(d) Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier @@ -388,7 +391,7 @@ private fun MonthGrid( .semantics(mergeDescendants = true) { contentDescription = "${d.weekdayName()}, ${d.longLabel()}" + (if (isToday) ", сегодня" else "") + (if (lessons) ", есть пары" else "") + - (if (plans) ", есть планы" else "") + (if (plans) ", есть планы" else "") + (if (session) ", экзамен или зачёт" else "") }, ) { Box( @@ -408,9 +411,10 @@ private fun MonthGrid( }, ) } - // Two small marks: lessons in the calm tone, plans in the accent. + // Small marks: lessons in the calm tone, an exam or credit in amber, plans in the accent. Row(horizontalArrangement = Arrangement.spacedBy(3.dp), modifier = Modifier.height(8.dp).padding(top = 2.dp)) { - if (lessons) Box(Modifier.size(5.dp).background(MaterialTheme.colorScheme.onSurfaceVariant, CircleShape)) + if (session) Box(Modifier.size(6.dp).background(sessionColors().accent, MaterialTheme.shapes.extraSmall)) + else if (lessons) Box(Modifier.size(5.dp).background(MaterialTheme.colorScheme.onSurfaceVariant, CircleShape)) if (plans) Box(Modifier.size(5.dp).background(MaterialTheme.colorScheme.tertiary, CircleShape)) } } @@ -517,13 +521,18 @@ private fun DayAgendaContent( lessons.forEachIndexed { i, (slot, l) -> item(key = "${slot.start}|${l.key}") { val color = subjectColor(l.subject) + val session = l.session TimelineItem( - marker = { Box(Modifier.size(20.dp).background(color.container, CircleShape)) }, - over = "${slot.start} → ${slot.end}", + marker = { + if (session != null) SessionDot(session, 24.dp) + else Box(Modifier.size(20.dp).background(color.container, CircleShape)) + }, + over = listOfNotNull(session?.label, "${slot.start} → ${slot.end}").joinToString(" · "), + overColor = if (session != null) sessionColors().accent else null, title = l.subject, under = listOfNotNull( if (l.distance) "дистанционно" else l.rooms.joinToString(", ").ifEmpty { null }?.let { "ауд. $it" }, - l.type.ifEmpty { null }?.lowercase(), + l.type.ifEmpty { null }?.takeIf { session == null }?.lowercase(), ).joinToString(" · ").ifEmpty { null }, last = i == lessons.lastIndex, onClick = { onLesson(l) }, @@ -577,6 +586,7 @@ private fun TimelineItem( title: String, under: String? = null, done: Boolean = false, + overColor: Color? = null, last: Boolean, onClick: () -> Unit, ) { @@ -610,7 +620,7 @@ private fun TimelineItem( } Column(Modifier.weight(1f).padding(start = 16.dp, top = 14.dp, bottom = 10.dp)) { over?.let { - TimeText(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) + TimeText(it, style = MaterialTheme.typography.bodyMedium, color = overColor ?: MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) } Text( title, diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/HomeScreen.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/HomeScreen.kt index 58938f7..b738f32 100644 --- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/HomeScreen.kt +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/HomeScreen.kt @@ -505,6 +505,7 @@ private fun FeaturedLesson(lesson: Lesson, slot: Slot, clock: LocalTime, onClick SubjectDot(lesson.subject, 14.dp, Modifier.padding(top = 8.dp)) Spacer(Modifier.width(16.dp)) Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + lesson.session?.let { SessionBadge(it) } Text(lesson.subject, style = MaterialTheme.typography.titleLargeEmphasized, maxLines = 3, overflow = TextOverflow.Ellipsis) Row(verticalAlignment = Alignment.CenterVertically) { TimeText(slot.timeLabel(), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) @@ -543,18 +544,20 @@ fun CompactLesson(lesson: Lesson, slot: Slot, onClick: (() -> Unit)?) { ), verticalAlignment = Alignment.CenterVertically, ) { - SubjectDot(lesson.subject, 10.dp) - Spacer(Modifier.width(18.dp)) + val session = lesson.session + if (session != null) SessionDot(session, 20.dp, Modifier.padding(end = 4.dp)) else SubjectDot(lesson.subject, 10.dp) + Spacer(Modifier.width(if (session != null) 10.dp else 18.dp)) Column { Text(lesson.subject, style = MaterialTheme.typography.titleSmallEmphasized, maxLines = 2, overflow = TextOverflow.Ellipsis) TimeText( listOfNotNull( + session?.label, slot.timeLabel(), if (lesson.distance) "дистанционно" else lesson.rooms.joinToString(", ").takeIf { show.rooms && it.isNotEmpty() }?.let { "ауд. $it" }, - lesson.type.takeIf { show.tags && it.isNotEmpty() }?.lowercase(), + lesson.type.takeIf { show.tags && it.isNotEmpty() && session == null }?.lowercase(), ).joinToString(" · "), style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = if (session != null) sessionColors().accent else MaterialTheme.colorScheme.onSurfaceVariant, ) } } diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/PlansScreen.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/PlansScreen.kt index 1922e7a..dec0ed5 100644 --- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/PlansScreen.kt +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/PlansScreen.kt @@ -145,7 +145,14 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) { val open = all.count { (it.task && !it.done) || (!it.task && parseDate(it.date) >= first) } val lessonsOf: (LocalDate) -> List = { d -> val t = timetable - if (prefs.lessons && t != null) vm.slotsOn(t, d, settings?.subgroup) else emptyList() + when { + t == null -> emptyList() + prefs.lessons -> vm.slotsOn(t, d, settings?.subgroup) + // Exams, credits and consultations are deadlines too: they stay in the plans. + else -> vm.slotsOn(t, d, settings?.subgroup) + .map { it.copy(lessons = it.lessons.filter { l -> l.session != null }) } + .filter { it.lessons.isNotEmpty() } + } } val agenda = prefs.layout == PlansLayout.Agenda val week = timetable?.days?.firstOrNull { it.date == date.monday().toString() }?.week @@ -374,8 +381,10 @@ private fun LessonsSummary(slots: List, modifier: Modifier = Modifier, col Column(Modifier.padding(horizontal = 14.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { slots.forEach { slot -> slot.lessons.forEach { l -> + val session = l.session androidx.compose.foundation.layout.Row(verticalAlignment = Alignment.CenterVertically) { - Box(Modifier.width(3.dp).height(14.dp).background(subjectColor(l.subject).container, CircleShape)) + if (session != null) SessionDot(session, 18.dp) + else Box(Modifier.width(3.dp).height(14.dp).background(subjectColor(l.subject).container, CircleShape)) Spacer(Modifier.width(8.dp)) Text( "${slot.start}–${slot.end}", @@ -383,7 +392,14 @@ private fun LessonsSummary(slots: List, modifier: Modifier = Modifier, col color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.width(10.dp)) - Text(l.subject, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + (session?.let { "${it.label}: " } ?: "") + l.subject + + (l.subgroup?.let { " · ${it.lowercase().replace("подгруппа", "подгр.")}" } ?: ""), + style = if (session != null) MaterialTheme.typography.bodyMediumEmphasized else MaterialTheme.typography.bodyMedium, + color = if (session != null) sessionColors().accent else MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } } } diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/ScheduleScreen.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/ScheduleScreen.kt index 102d54f..b4a97b2 100644 --- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/ScheduleScreen.kt +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/ScheduleScreen.kt @@ -435,8 +435,11 @@ fun LessonCard( ) { val color = subjectColor(lesson.subject) val show = LocalDisplay.current + val session = lesson.session val content: @Composable () -> Unit = { Column(Modifier.padding(horizontal = 20.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + // Exams, credits and consultations announce themselves before the subject. + session?.let { SessionBadge(it, Modifier.padding(bottom = 2.dp)) } Row(verticalAlignment = Alignment.Top) { Text(lesson.subject, style = MaterialTheme.typography.titleMediumEmphasized, modifier = Modifier.weight(1f)) status?.let { @@ -461,7 +464,7 @@ fun LessonCard( lesson.subgroup?.let { Meta(Icons.Rounded.Groups, it) } } val tags = if (!show.tags) emptyList() else listOfNotNull( - lesson.type.ifEmpty { null }, + lesson.type.ifEmpty { null }?.takeIf { session == null }, if (lesson.variant != "all") (if (lesson.variant == "upper") "белые недели" else "зелёные недели") else null, if (lesson.groups.size > 1) "общее занятие" else null, ) @@ -481,7 +484,11 @@ fun LessonCard( if (date != null) NotesHint(LocalNotes.current.forLesson(lesson.subject, date, lesson.start), color.onSoft) } } - val border = if (running) BorderStroke(2.dp, color.onSoft) else null + val border = when { + running -> BorderStroke(2.dp, color.onSoft) + session != null -> BorderStroke(2.dp, session.outline()) + else -> null + } if (onClick != null) Surface( onClick = onClick, shape = MaterialTheme.shapes.large, @@ -531,7 +538,8 @@ private fun TypeTag(type: String) { val (bg, fg) = when (type) { "Лекция" -> scheme.primaryContainer to scheme.onPrimaryContainer "Практика" -> scheme.tertiaryContainer to scheme.onTertiaryContainer - "Экзамен", "Зачёт" -> scheme.errorContainer to scheme.onErrorContainer + "Экзамен", "Зачёт", "Защита курсовой работы" -> sessionColors().let { it.strong to it.onStrong } + "Консультация" -> sessionColors().let { it.soft to it.onSoft } else -> scheme.secondaryContainer to scheme.onSecondaryContainer } Surface(shape = MaterialTheme.shapes.small, color = bg, contentColor = fg) { diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/SessionMark.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/SessionMark.kt new file mode 100644 index 0000000..9f4d468 --- /dev/null +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/SessionMark.kt @@ -0,0 +1,109 @@ +package org.xtimms.psu.schedule.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CoPresent +import androidx.compose.material.icons.rounded.Forum +import androidx.compose.material.icons.rounded.Quiz +import androidx.compose.material.icons.rounded.TaskAlt +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.xtimms.psu.schedule.data.Lesson +import org.xtimms.psu.schedule.ui.theme.harmonizeWith + +/** + * The session's events: exams, credits, consultations and coursework defences. They wear an + * amber of their own — red would read as an error, and the tertiary accent already marks plans + * in the calendar — and each kind its own icon, so they stand out on every screen. + */ +enum class SessionKind(val label: String, val icon: ImageVector, val strong: Boolean) { + Exam("Экзамен", Icons.Rounded.Quiz, strong = true), + Credit("Зачёт", Icons.Rounded.TaskAlt, strong = true), + Defence("Защита курсовой", Icons.Rounded.CoPresent, strong = true), + Consultation("Консультация", Icons.Rounded.Forum, strong = false), +} + +val Lesson.session: SessionKind? + get() = when (type) { + "Экзамен" -> SessionKind.Exam + "Зачёт" -> SessionKind.Credit + "Защита курсовой работы" -> SessionKind.Defence + "Консультация" -> SessionKind.Consultation + else -> null + } + +/** + * The session amber as an M3 tonal palette (seed #8A5100): `accent` for text, marks and outlines, + * `strong`/`onStrong` for filled badges, `soft`/`onSoft` for tonal ones. Harmonized with the + * theme's primary like the subject colours, so it also sits well with wallpaper colours. + */ +class SessionColors(val accent: Color, val strong: Color, val onStrong: Color, val soft: Color, val onSoft: Color) + +@Composable +fun sessionColors(): SessionColors { + val scheme = MaterialTheme.colorScheme + val dark = scheme.surface.luminance() < 0.5f + val p = scheme.primary + return if (dark) SessionColors( + accent = Color(0xFFFFB95C).harmonizeWith(p), + strong = Color(0xFFFFB95C).harmonizeWith(p), + onStrong = Color(0xFF482900).harmonizeWith(p), + soft = Color(0xFF693C00).harmonizeWith(p), + onSoft = Color(0xFFFFDDB8).harmonizeWith(p), + ) else SessionColors( + accent = Color(0xFF8A5100).harmonizeWith(p), + strong = Color(0xFF8A5100).harmonizeWith(p), + onStrong = Color.White, + soft = Color(0xFFFFDDB8).harmonizeWith(p), + onSoft = Color(0xFF2C1600).harmonizeWith(p), + ) +} + +/** Fill and content: exams, credits and defences strong, consultations tonal. */ +@Composable +fun SessionKind.colors(): Pair { + val c = sessionColors() + return if (strong) c.strong to c.onStrong else c.soft to c.onSoft +} + +/** The outline a session event's card or block gets. */ +@Composable +fun SessionKind.outline(): Color = sessionColors().accent + +/** "Зачёт" with its icon, as a pill above the subject. */ +@Composable +fun SessionBadge(kind: SessionKind, modifier: Modifier = Modifier) { + val (bg, fg) = kind.colors() + Surface(shape = CircleShape, color = bg, contentColor = fg, modifier = modifier) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp, end = 10.dp, top = 3.dp, bottom = 3.dp)) { + Icon(kind.icon, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(5.dp)) + Text(kind.label, style = MaterialTheme.typography.labelLargeEmphasized) + } + } +} + +/** A round marker with the kind's icon, in place of a subject's dot. */ +@Composable +fun SessionDot(kind: SessionKind, size: Dp = 20.dp, modifier: Modifier = Modifier) { + val (bg, fg) = kind.colors() + Box(modifier.size(size).background(bg, CircleShape), contentAlignment = Alignment.Center) { + Icon(kind.icon, contentDescription = null, tint = fg, modifier = Modifier.size(size * 0.65f)) + } +} diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/WeekGrid.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/WeekGrid.kt index b84b653..c4ea5cc 100644 --- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/WeekGrid.kt +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/WeekGrid.kt @@ -174,6 +174,7 @@ fun WeekGrid( @Composable private fun LessonBlock(lesson: Lesson, slot: Slot, wide: Boolean, notes: Int, onClick: () -> Unit, modifier: Modifier) { val color = subjectColor(lesson.subject) + val session = lesson.session val room = when { lesson.distance -> "дист." LocalDisplay.current.rooms -> lesson.rooms.joinToString(", ") @@ -184,6 +185,7 @@ private fun LessonBlock(lesson: Lesson, slot: Slot, wide: Boolean, notes: Int, o shape = MaterialTheme.shapes.small, color = color.container, contentColor = color.content, + border = session?.let { androidx.compose.foundation.BorderStroke(2.dp, it.outline()) }, modifier = modifier.semantics { contentDescription = "${lesson.subject}, ${slot.start}–${slot.end}" + (if (room.isNotEmpty()) ", ауд. $room" else "") + (lesson.type.takeIf { it.isNotEmpty() }?.let { ", $it" } ?: "") + @@ -194,6 +196,7 @@ private fun LessonBlock(lesson: Lesson, slot: Slot, wide: Boolean, notes: Int, o if (notes > 0) Box(Modifier.fillMaxSize().padding(5.dp), contentAlignment = Alignment.TopEnd) { Box(Modifier.size(8.dp).background(color.content, CircleShape)) } + session?.let { Box(Modifier.fillMaxSize().padding(3.dp), contentAlignment = Alignment.BottomEnd) { SessionDot(it, 18.dp) } } Column(Modifier.padding(horizontal = 6.dp, vertical = 4.dp).clearAndSetSemantics {}, verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( if (wide) lesson.subject else abbreviate(lesson.subject), diff --git a/ios/PSUSchedule/Views/CalendarView.swift b/ios/PSUSchedule/Views/CalendarView.swift index 3f308ad..734cc25 100644 --- a/ios/PSUSchedule/Views/CalendarView.swift +++ b/ios/PSUSchedule/Views/CalendarView.swift @@ -164,6 +164,7 @@ private struct MonthGrid: View { let isToday = d == model.today let lessons = model.timetable.map { !model.slots($0, on: d).isEmpty } ?? false let plans = model.notes.contains { $0.date == d.iso } + let session = model.timetable.map { model.slots($0, on: d).contains { $0.lessons.contains { $0.session != nil } } } ?? false let color: Color = isSelected ? .onBrand : isToday ? .brand : inMonth ? .primary : Color(.tertiaryLabel) return Button { onSelect(d) } label: { VStack(spacing: 3) { @@ -173,8 +174,10 @@ private struct MonthGrid: View { .frame(width: 34, height: 34) .background { if isSelected { Circle().fill(Color.brand) } else if isToday { Circle().strokeBorder(Color.brand, lineWidth: 1.5) } } HStack(spacing: 3) { - Circle().fill(lessons ? Color.secondary : .clear).frame(width: 4, height: 4) - if plans { Circle().fill(Color.orange).frame(width: 4, height: 4) } + // Lessons in grey, an exam or credit in amber, plans in purple. + if session { RoundedRectangle(cornerRadius: 1.5).fill(Color.sessionAccent).frame(width: 6, height: 6) } + else { Circle().fill(lessons ? Color.secondary : .clear).frame(width: 4, height: 4) } + if plans { Circle().fill(Color.purple).frame(width: 4, height: 4) } } .frame(height: 4) } @@ -183,7 +186,7 @@ private struct MonthGrid: View { .contentShape(.rect) } .buttonStyle(.plain) - .accessibilityLabel(d.weekdayName + ", " + d.longLabel + (lessons ? ", есть пары" : "") + (plans ? ", есть планы" : "")) + .accessibilityLabel(d.weekdayName + ", " + d.longLabel + (lessons ? ", есть пары" : "") + (plans ? ", есть планы" : "") + (session ? ", экзамен или зачёт" : "")) .accessibilityAddTraits(isSelected ? .isSelected : []) } } diff --git a/ios/PSUSchedule/Views/HomeView.swift b/ios/PSUSchedule/Views/HomeView.swift index ffd5f34..c8b66eb 100644 --- a/ios/PSUSchedule/Views/HomeView.swift +++ b/ios/PSUSchedule/Views/HomeView.swift @@ -305,6 +305,7 @@ private struct FeaturedLesson: View { HStack(alignment: .top, spacing: 14) { SubjectDot(subject: lesson.subject, size: 14).padding(.top, 6) VStack(alignment: .leading, spacing: 6) { + if let session = lesson.session { SessionBadge(kind: session) } Text(lesson.subject).font(.title3.bold()).lineLimit(3).foregroundStyle(.primary) HStack(spacing: 10) { Text(slot.timeLabel).font(.body.monospacedDigit()).foregroundStyle(.secondary) diff --git a/ios/PSUSchedule/Views/LessonViews.swift b/ios/PSUSchedule/Views/LessonViews.swift index 9a8be21..4ca9fac 100644 --- a/ios/PSUSchedule/Views/LessonViews.swift +++ b/ios/PSUSchedule/Views/LessonViews.swift @@ -20,6 +20,8 @@ struct LessonCard: View { var body: some View { WithSubjectColor(subject: lesson.subject) { c in VStack(alignment: .leading, spacing: 6) { + // Exams, credits and consultations announce themselves before the subject. + if let session = lesson.session { SessionBadge(kind: session).padding(.bottom, 2) } HStack(alignment: .top, spacing: 8) { Text(lesson.subject).font(.headline).frame(maxWidth: .infinity, alignment: .leading) if let status { @@ -66,6 +68,7 @@ struct LessonCard: View { .background(c.soft, in: .rect(cornerRadius: 22, style: .continuous)) .overlay { if running { RoundedRectangle(cornerRadius: 22, style: .continuous).strokeBorder(c.onSoft, lineWidth: 2) } + else if lesson.session != nil { RoundedRectangle(cornerRadius: 22, style: .continuous).strokeBorder(Color.sessionAccent, lineWidth: 2) } } .contentShape(.rect(cornerRadius: 22)) } @@ -74,7 +77,7 @@ struct LessonCard: View { func tagsOf(_ lesson: Lesson) -> [String] { var tags: [String] = [] - if !lesson.type.isEmpty { tags.append(lesson.type) } + if !lesson.type.isEmpty && lesson.session == nil { tags.append(lesson.type) } if lesson.variant != "all" { tags.append(lesson.variant == "upper" ? "белые недели" : "зелёные недели") } if lesson.groups.count > 1 { tags.append("общее занятие") } return tags @@ -104,11 +107,11 @@ struct CompactLesson: View { var slot: Slot var body: some View { - HStack(spacing: 14) { - SubjectDot(subject: lesson.subject, size: 10) + HStack(spacing: lesson.session == nil ? 14 : 10) { + if let session = lesson.session { SessionDot(kind: session, size: 22) } else { SubjectDot(subject: lesson.subject, size: 10) } VStack(alignment: .leading, spacing: 2) { Text(lesson.subject).font(.subheadline.weight(.semibold)).lineLimit(2).foregroundStyle(.primary) - Text(details).font(.subheadline.monospacedDigit()).foregroundStyle(.secondary) + Text(details).font(.subheadline.monospacedDigit()).foregroundStyle(lesson.session == nil ? Color.secondary : Color.sessionAccent) } Spacer(minLength: 0) } @@ -117,9 +120,11 @@ struct CompactLesson: View { private var details: String { var parts = [slot.timeLabel] + if let session = lesson.session { parts.insert(session.label, at: 0) } + if let sub = lesson.subgroup, lesson.session != nil { parts.append(sub.lowercased().replacingOccurrences(of: "подгруппа", with: "подгр.")) } if lesson.distance { parts.append("дистанционно") } else if show.rooms && !lesson.rooms.isEmpty { parts.append("ауд. " + lesson.rooms.joined(separator: ", ")) } - if show.tags && !lesson.type.isEmpty { parts.append(lesson.type.lowercased()) } + if show.tags && !lesson.type.isEmpty && lesson.session == nil { parts.append(lesson.type.lowercased()) } return parts.joined(separator: " · ") } } @@ -213,7 +218,7 @@ private struct TypeTag: View { let color: Color = switch type { case "Лекция": .brand case "Практика": .purple - case "Экзамен", "Зачёт": .red + case "Экзамен", "Зачёт", "Защита курсовой работы", "Консультация": .sessionAccent default: .gray } Text(type.isEmpty ? "Занятие" : type) diff --git a/ios/PSUSchedule/Views/PlansView.swift b/ios/PSUSchedule/Views/PlansView.swift index 6626a9f..2b029c6 100644 --- a/ios/PSUSchedule/Views/PlansView.swift +++ b/ios/PSUSchedule/Views/PlansView.swift @@ -86,7 +86,13 @@ struct PlansView: View { if !overdue.isEmpty { out.append(PlanDay(date: nil, slots: [], notes: overdue)) } var d = today while d <= last { - let slots = prefs.lessons ? model.timetable.map { model.slots($0, on: d) } ?? [] : [] + // Exams, credits and consultations are deadlines too: they stay even with "Пары дня" off. + let all = model.timetable.map { model.slots($0, on: d) } ?? [] + let slots = prefs.lessons ? all : all.compactMap { slot -> Slot? in + var s = slot + s.lessons = slot.lessons.filter { $0.session != nil } + return s.lessons.isEmpty ? nil : s + } let list = (byDay[d.iso] ?? []).sorted { (($0.task && $0.done) ? 1 : 0, $0.start) < (($1.task && $1.done) ? 1 : 0, $1.start) } out.append(PlanDay(date: d, slots: slots, notes: list)) d = d.adding(days: 1) @@ -206,7 +212,7 @@ private struct PlanStrip: View { .foregroundStyle(isSelected ? Color.onBrand : d < model.today ? .secondary : .primary) .frame(width: 32, height: 32) .background { if isSelected { Circle().fill(Color.brand) } } - Circle().fill(has ? Color.orange : .clear).frame(width: 5, height: 5) + Circle().fill(has ? Color.purple : .clear).frame(width: 5, height: 5) } .frame(maxWidth: .infinity) .contentShape(.rect) diff --git a/ios/PSUSchedule/Views/SessionMark.swift b/ios/PSUSchedule/Views/SessionMark.swift new file mode 100644 index 0000000..6cf136f --- /dev/null +++ b/ios/PSUSchedule/Views/SessionMark.swift @@ -0,0 +1,90 @@ +import SwiftUI +import UIKit + +/// The session's events: exams, credits, consultations and coursework defences. They wear an +/// amber of their own — red would read as an error — and each kind its own symbol, so they +/// stand out from ordinary lessons in the schedule, the calendar and the plans. +enum SessionKind { + case exam, credit, defence, consultation + + init?(_ type: String) { + switch type { + case "Экзамен": self = .exam + case "Зачёт": self = .credit + case "Защита курсовой работы": self = .defence + case "Консультация": self = .consultation + default: return nil + } + } + + var label: String { + switch self { + case .exam: "Экзамен" + case .credit: "Зачёт" + case .defence: "Защита курсовой" + case .consultation: "Консультация" + } + } + + var symbol: String { + switch self { + case .exam: "pencil.and.list.clipboard" + case .credit: "checkmark.seal.fill" + case .defence: "graduationcap.fill" + case .consultation: "bubble.left.and.bubble.right.fill" + } + } + + /// Exams, credits and defences are filled; consultations are tonal. + var strong: Bool { self != .consultation } + + var fill: Color { strong ? .sessionStrong : .sessionSoft } + var content: Color { strong ? .onSessionStrong : .onSessionSoft } +} + +extension Lesson { + var session: SessionKind? { SessionKind(type) } +} + +private func dynamic(_ light: UInt32, _ dark: UInt32) -> Color { + func ui(_ hex: UInt32) -> UIColor { + UIColor(red: CGFloat((hex >> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255, blue: CGFloat(hex & 0xFF) / 255, alpha: 1) + } + return Color(UIColor { $0.userInterfaceStyle == .dark ? ui(dark) : ui(light) }) +} + +extension Color { + /// The session amber (an M3 tonal palette from #8A5100): text, marks and outlines. + static let sessionAccent = dynamic(0x8A5100, 0xFFB95C) + static let sessionStrong = dynamic(0x8A5100, 0xFFB95C) + static let onSessionStrong = dynamic(0xFFFFFF, 0x482900) + static let sessionSoft = dynamic(0xFFDDB8, 0x693C00) + static let onSessionSoft = dynamic(0x2C1600, 0xFFDDB8) +} + +/// "Зачёт" with its symbol, as a capsule above the subject. +struct SessionBadge: View { + var kind: SessionKind + var body: some View { + Label(kind.label, systemImage: kind.symbol) + .font(.footnote.weight(.semibold)) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .foregroundStyle(kind.content) + .background(kind.fill, in: .capsule) + } +} + +/// A round marker with the kind's symbol, in place of a subject's dot. +struct SessionDot: View { + var kind: SessionKind + var size: CGFloat = 20 + var body: some View { + Image(systemName: kind.symbol) + .font(.system(size: size * 0.5, weight: .bold)) + .foregroundStyle(kind.content) + .frame(width: size, height: size) + .background(kind.fill, in: .circle) + .accessibilityHidden(true) + } +} diff --git a/ios/PSUSchedule/Views/WeekGrid.swift b/ios/PSUSchedule/Views/WeekGrid.swift index bf86012..aae93ea 100644 --- a/ios/PSUSchedule/Views/WeekGrid.swift +++ b/ios/PSUSchedule/Views/WeekGrid.swift @@ -129,8 +129,17 @@ private struct LessonBlock: View { .padding(4) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background(c.strong, in: .rect(cornerRadius: 8, style: .continuous)) + // Exams and credits: an amber outline and the kind's symbol in the corner. + .overlay(alignment: .bottomTrailing) { + if let session = lesson.session { + ZStack(alignment: .bottomTrailing) { + RoundedRectangle(cornerRadius: 8, style: .continuous).strokeBorder(Color.sessionAccent, lineWidth: 2) + SessionDot(kind: session, size: 16).padding(3) + } + } + } } .accessibilityElement(children: .ignore) - .accessibilityLabel("\(lesson.subject), \(lesson.start)" + (lesson.rooms.isEmpty ? "" : ", ауд. " + lesson.rooms.joined(separator: ", "))) + .accessibilityLabel((lesson.session.map { "\($0.label): " } ?? "") + "\(lesson.subject), \(lesson.start)" + (lesson.rooms.isEmpty ? "" : ", ауд. " + lesson.rooms.joined(separator: ", "))) } } diff --git a/server/directory.js b/server/directory.js index 8cc9fed..21772eb 100644 --- a/server/directory.js +++ b/server/directory.js @@ -350,7 +350,32 @@ export async function directoryCalendar() { } export { todayIso }; -export const EXAM_TYPES = ["Экзамен", "Зачёт", "Консультация"]; +export const EXAM_TYPES = ["Экзамен", "Зачёт", "Консультация", "Защита курсовой работы"]; + +/** The index as it is now, without waiting for a first build (which this starts). */ +export function readyIndex() { + getIndex().catch(() => {}); + return index; +} + +/** + * Dated session events of a group (exams, credits, consultations, coursework defences) from + * every catalog document except `exceptDocId`, as index entries, so a timetable can show the + * group's credits even when the university publishes them in a separate "Расписание зачетов". + */ +export function sessionEntriesOf(idx, group, faculty, exceptDocId) { + const seen = new Map(); + for (const entry of idx?.entries || []) { + const { doc, lesson } = entry; + if (doc.id === exceptDocId || !EXAM_TYPES.includes(lesson.type) || !lesson.groups.includes(group)) continue; + if (!lesson.date && !lesson.dates?.length) continue; + if (faculty && doc.schedule.source.faculty && doc.schedule.source.faculty !== faculty) continue; + const subgroup = lesson.subgroups?.find((s) => s.group === group)?.label || ""; + const key = [lesson.date || lesson.dates.join(","), lesson.start, normalize(lesson.subject), lesson.type, subgroup].join("|"); + if (!seen.has(key)) seen.set(key, entry); + } + return [...seen.values()]; +} /** Dated exams, credits and consultations of a group across every document. */ export function examsOf(idx, group, faculty) { const seen = new Map(); diff --git a/server/index.js b/server/index.js index 74bf1b2..2ce90b5 100644 --- a/server/index.js +++ b/server/index.js @@ -16,6 +16,8 @@ import { freeRooms, examsOf, findGroups, + readyIndex, + sessionEntriesOf, directoryCalendar, todayIso, } from "./directory.js"; @@ -42,8 +44,9 @@ app.use("/api", (_req, res, next) => { // Raised whenever an endpoint is added or changed, so a deployment is easy to verify: // 2 — /api/timetable for the Android client; 3 — its range=semester; // 4 — compression, ETag revalidation, throttled refresh and import limits; -// 5 — error codes (`code` beside `error`, see server/errors.js), timetable 304 before any work. -const API_VERSION = 5; +// 5 — error codes (`code` beside `error`, see server/errors.js), timetable 304 before any work; +// 6 — the timetable carries the group's exams and credits from other catalog documents. +const API_VERSION = 6; // Liveness plus what the server currently reads from the university, without network calls. app.get("/api/health", async (_req, res) => { @@ -118,25 +121,30 @@ app.get("/api/timetable", async (req, res, next) => { const calendar = await directoryCalendar(); const range = req.query.range === "semester" ? "semester" : undefined; const today = todayIso(); + // The group's exams and credits from other documents ("Расписание зачетов"); a server + // still building its first index answers without them rather than wait. + const idx = readyIndex(); // The answer follows only from these: the document's version, the parser and the server // that read it, the week table, the request and the date. An unchanged one comes back // as an empty 304 before the document is opened or a single day is computed. const etag = 'W/"tt-' + createHash("sha1") - .update([API_VERSION, PARSER_VERSION, file.id, file.modified, calendar?.updatedAt, group, from, req.query.days, range, today].join("|")) + .update([API_VERSION, PARSER_VERSION, file.id, file.modified, calendar?.updatedAt, idx?.builtAt, group, from, req.query.days, range, today].join("|")) .digest("base64url") .slice(0, 22) + '"'; res.set("ETag", etag); if (req.fresh) return res.status(304).end(); + const schedule = await scheduleFor(file); res.json( timetable({ file, - schedule: await scheduleFor(file), + schedule, calendar, group, from, days: req.query.days, range, today, + session: sessionEntriesOf(idx, group, file.faculty || schedule.source.faculty, file.id), }), ); } catch (e) { diff --git a/server/parser.js b/server/parser.js index 8b65f6c..a9979e4 100644 --- a/server/parser.js +++ b/server/parser.js @@ -6,9 +6,9 @@ import { mkdtemp, readFile, writeFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createHash } from "node:crypto"; -import { parseWeekRanges, parseTextDates } from "../shared/weeks.js"; +import { parseWeekRanges, parseTextDates, DATE_LIST } from "../shared/weeks.js"; const run = promisify(execFile); -export const PARSER_VERSION = 10; +export const PARSER_VERSION = 11; const days = [ "понедельник", "вторник", @@ -86,6 +86,8 @@ export function details(raw) { .trim() .replace(/[–—,\-]+$/, "") .trim(); + // A typo'd date before the subject ("19,11, 03.12 Физика") leaves a stray comma behind. + subject = subject.replace(/^[\s,;.]+/, ""); if (!subject) subject = rest; const defence = /^защита/i.test(leading?.[1] || ""); const token = @@ -408,7 +410,8 @@ export function parseDocx(buffer, source = {}) { cell.merge === "restart" && rows[i + 1]?.find((c) => c.col === cell.col)?.merge === "continue"; for (const alternative of alternatives(raw)) { - const data = details(alternative); + // "Единый день информирования 10.09;15.10;19.11;17.12": the dates are not part of the subject. + const data = details(alternative.replace(DATE_LIST, "").replace(/[ \t]{2,}/g, " ").trim()); if (credits && data.type === "Занятие") data.type = "Зачёт"; const dateCandidate = alternative.match( /^(\d{2})[.](\d{2})(?:[.](\d{4}))?\s/, diff --git a/server/timetable.js b/server/timetable.js index 66eb838..bdf914e 100644 --- a/server/timetable.js +++ b/server/timetable.js @@ -37,7 +37,7 @@ function lessonOut(lesson, group, people, date, status, calendar) { * days: one entry per date from `from`, with the lessons that take place that day. * weekly: the Word rows by weekday, for when dates cannot be calculated. */ -export function timetable({ file, schedule, calendar, group, from, days, range, today }) { +export function timetable({ file, schedule, calendar, group, from, days, range, today, session: extra = [] }) { if (!schedule.groups.includes(group)) { throw apiError(404, "group_not_found", `В документе нет группы ${group}.`); } @@ -58,13 +58,19 @@ export function timetable({ file, schedule, calendar, group, from, days, range, : null; // range=semester: every day of the semester `today` falls in, so a client can keep // the whole term offline; a session schedule keeps its own span. Without either it - // keeps the plain from/days window. + // keeps the plain from/days window. Exams and credits from other documents may follow + // the last week of the semester: the span then runs on to the last of them (up to 60 days). if (range === "semester") { const current = semesterView(calendar, schedule, today || from); const span = current?.semester || session; if (span) { from = span.start; - const length = Math.round((Date.parse(span.end) - Date.parse(from)) / DAY_MS) + 1; + const limit = addDays(span.end, 60); + const end = [span.end, ...extra.flatMap((e) => (e.lesson.date ? [e.lesson.date] : e.lesson.dates))] + .filter((d) => d >= from && d <= limit) + .sort() + .at(-1); + const length = Math.round((Date.parse(end) - Date.parse(from)) / DAY_MS) + 1; count = Math.min(Math.max(length, 1), MAX_SEMESTER_DAYS); } } @@ -86,6 +92,10 @@ export function timetable({ file, schedule, calendar, group, from, days, range, if (status === "yes" || status === "maybe") dayLessons.push(lessonOut(entry.lesson, group, entry, date, status, calendar)); } + // Session events printed with their own date need no week table. + for (const entry of extra) + if (entry.lesson.date === date || entry.lesson.dates?.includes(date)) + dayLessons.push(lessonOut(entry.lesson, group, entry, date, "yes", calendar)); dayLessons.sort((a, b) => a.start.localeCompare(b.start)); out.push({ date, @@ -117,7 +127,12 @@ export function timetable({ file, schedule, calendar, group, from, days, range, note: view || session ? null : "Семестр этого расписания не найден в таблице недель: даты не рассчитаны, смотрите строки Word (weekly).", - subgroups: subgroupsOf(schedule, group), + subgroups: [ + ...new Set([ + ...subgroupsOf(schedule, group), + ...extra.map((e) => e.lesson.subgroups?.find((s) => s.group === group)?.label).filter(Boolean), + ]), + ].sort((a, b) => a.localeCompare(b, "ru", { numeric: true })), bells: calendar?.bells || [], from, days: out, diff --git a/shared/weeks.d.ts b/shared/weeks.d.ts index 7ed4d7a..29f1857 100644 --- a/shared/weeks.d.ts +++ b/shared/weeks.d.ts @@ -53,6 +53,7 @@ interface WeekLesson { } export function parseWeekRanges(condition: string): WeekRange[] | null; export function parseTextDates(raw: string, year: string | number): string[]; +export const DATE_LIST: RegExp; export function isoDate(time: number): string; export function addDays(iso: string, days: number): string; export function weekday(iso: string): number; diff --git a/shared/weeks.js b/shared/weeks.js index 840f1e9..110273b 100644 --- a/shared/weeks.js +++ b/shared/weeks.js @@ -47,10 +47,24 @@ export function parseWeekRanges(condition) { } /** "10 сентября, 15 октября" → ISO dates inside the academic year that starts in `year`. */ +/** + * A list of numeric dates, "10.09;15.10;19.11;17.12" or "03.10, 17.10": at least two, so a + * time such as "10.05" on its own is never taken for 10 May. + */ +export const DATE_LIST = /(? 31 || month < 1 || month > 12) continue; + const y = m[3] ? Number(m[3].length === 2 ? `20${m[3]}` : m[3]) : month >= 8 ? Number(year) : Number(year) + 1; + dates.push(`${y}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`); + } for (const m of raw.matchAll(pattern)) { const month = MONTHS.findIndex((stem) => new RegExp(`^${stem}`, "iu").test(m[2])) + 1; diff --git a/test/timetable.test.js b/test/timetable.test.js index 11810d9..61580b9 100644 --- a/test/timetable.test.js +++ b/test/timetable.test.js @@ -83,3 +83,33 @@ test("Сессия с датами в Word не зависит от таблиц const exam = t.days.find((d) => d.date === "2026-06-18").lessons.find((l) => l.type === "Экзамен"); assert.deepEqual([exam.start, exam.end, exam.pair], ["08:30", "09:55", 1]); }); + +test("Зачёты из отдельного документа попадают в дни расписания группы", async () => { + const { buildIndex, sessionEntriesOf } = await import("../server/directory.js"); + // The 26-АРХ credit schedule, readdressed to 23-БА, plus one exam after the semester's end. + const credits = parseDocx(fixture("isf-credits-26-arh"), { name: "Зачеты.docx" }); + const readdress = (l) => ({ ...l, groups: ["23-БА"], subgroups: l.subgroups.map((s) => ({ ...s, group: "23-БА" })) }); + const lessons = credits.lessons.map(readdress); + lessons.push({ ...lessons[1], id: "late", date: "2027-01-15", type: "Экзамен", subject: "Бизнес-анализ" }); + const idx = buildIndex([ + { id: "fef", file, schedule }, + { id: "credits", file: { id: "credits", name: "Зачеты.docx" }, schedule: { ...credits, lessons, groups: ["23-БА"] } }, + ]); + const session = sessionEntriesOf(idx, "23-БА", null, "fef"); + assert.equal(session.length, 10); + const t = timetable({ file, schedule, calendar, group: "23-БА", from: "2026-10-19", range: "semester", today: "2026-10-19", session }); + const on = (date) => + t.days.find((d) => d.date === date).lessons + .filter((l) => ["Зачёт", "Экзамен", "Консультация"].includes(l.type)) + .map((l) => [l.start, l.type, l.subject, l.subgroup]); + // Both subgroups' credits, at their own time and with their subgroup. + assert.deepEqual(on("2026-12-22"), [ + ["17:00", "Зачёт", "Рисунок", "Подгруппа 1"], + ["17:00", "Зачёт", "Рисунок", "Подгруппа 2"], + ]); + assert.deepEqual(on("2026-10-27"), [["17:00", "Консультация", "Введение в архитектурное проектирование", null]]); + // The span runs past the semester (3 January) to the last exam. + assert.equal(t.days.at(-1).date, "2027-01-15"); + assert.deepEqual(on("2027-01-15"), [[lessons.at(-1).start, "Экзамен", "Бизнес-анализ", null]]); + assert.ok(t.subgroups.includes("Подгруппа 2")); +}); diff --git a/test/weeks.test.js b/test/weeks.test.js index f0cf565..f11656a 100644 --- a/test/weeks.test.js +++ b/test/weeks.test.js @@ -121,3 +121,18 @@ test("Парсер: 23-БА получает номера недель и дат for (const n of [a, b ?? a]) assert.equal(n % 2, l.variant === "upper" ? 1 : 0, l.raw); }); + +test("Даты числами: «10.09;15.10;19.11;17.12», но не время пары", () => { + // ИСФ пишет дни информирования списком дат, как в 23-АРХ. + assert.deepEqual(parseTextDates("Единый день информирования 10.09;15.10;19.11;17.12", 2026), [ + "2026-09-10", + "2026-10-15", + "2026-11-19", + "2026-12-17", + ]); + assert.deepEqual(parseTextDates("03.10, 17.10. Физика", 2026), ["2026-10-03", "2026-10-17"]); + assert.deepEqual(parseTextDates("12.01.2027; 19.01.2027 Экзамен", 2026), ["2027-01-12", "2027-01-19"]); + // One number pair alone or a time range is a time, not a date. + for (const time of ["10.05 Лекция", "10.05-11.30", "16.50 Информационный час", "09.11 по 06.12 практика"]) + assert.deepEqual(parseTextDates(time, 2026), [], time); +});