feat: Enhance session handling in timetable and UI

- Introduced session types (exams, credits, consultations, coursework defenses) with distinct icons and colors for better visibility in the UI.
- Updated PlansScreen, ScheduleScreen, and WeekGrid to display session information appropriately.
- Modified CalendarView and PlansView to include session indicators in the calendar and plans.
- Enhanced LessonCard and CompactLesson to show session details and adjust styling based on session presence.
- Added new SessionMark component for consistent session representation across Android and iOS.
- Updated server logic to handle session entries from external documents, ensuring accurate timetable representation.
- Improved date parsing to accommodate session-related dates in various formats.
- Added tests to verify session handling and date parsing functionality.
This commit is contained in:
2026-10-19 20:49:13 +03:00
parent db9a3f8243
commit 15851b26cd
21 changed files with 421 additions and 42 deletions

View File

@@ -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=<id>&group=<группа>&from=ГГГГ-ММ-ДД&days=N` (до 62 дней) или с `&range=semester` — весь текущий семестр от первого до последнего дня; приложение хранит его целиком и работает с ним без сети. Ответ содержит дни с неделями, номерами пар и пометкой `uncertain` для нераспознанных пометок, плюс строки Word (`weekly`) и подгруппы. Поэтому правила белых и зелёных недель в Kotlin не дублируются.
Сборка (JDK 17, Android SDK 37):

View File

@@ -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,

View File

@@ -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,
)
}
}

View File

@@ -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<Slot> = { 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<Slot>, 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<Slot>, 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,
)
}
}
}

View File

@@ -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) {

View File

@@ -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<Color, Color> {
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))
}
}

View File

@@ -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),

View File

@@ -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 : [])
}
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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: ", ")))
}
}

View File

@@ -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();

View File

@@ -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) {

View File

@@ -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/,

View File

@@ -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,

1
shared/weeks.d.ts vendored
View File

@@ -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;

View File

@@ -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 = /(?<![\d.:])\d{1,2}\.\d{1,2}(?:\.(?:\d{4}|\d{2}))?(?:\s*[;,]\s*\d{1,2}\.\d{1,2}(?:\.(?:\d{4}|\d{2}))?)+\.?(?![\d:])/gu;
export function parseTextDates(raw, year) {
if (!raw || !year) return [];
const pattern = new RegExp(`(\\d{1,2})\\s+(${MONTHS.join("|")})[а-я]*`, "giu");
const dates = [];
for (const list of raw.matchAll(DATE_LIST))
for (const m of list[0].matchAll(/(\d{1,2})\.(\d{1,2})(?:\.(\d{4}|\d{2}))?/g)) {
const day = Number(m[1]),
month = Number(m[2]);
if (day < 1 || day > 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;

View File

@@ -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"));
});

View File

@@ -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);
});