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:
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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 : [])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
90
ios/PSUSchedule/Views/SessionMark.swift
Normal file
90
ios/PSUSchedule/Views/SessionMark.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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: ", ")))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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/,
|
||||
|
||||
@@ -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
1
shared/weeks.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user