Files
psu-schedule/ios/PSUSchedule/Views/SubjectsView.swift
Zakhar Timoshenko 10c85e3bb9 feat: read "Занятия в Полоцке" as a day's town, show it in all clients
- Parser 14 (server/places.js): "Занятия в Полоцке/Новополоцке/г. Новополоцк" over a day's first row is no longer a lesson but a note of the day for its column (`places`); lessons of that day get `place`. A lesson below the note in the same cell keeps its own subject.
- A town on its own line ("Новополоцк", "г.Новополоцк") or after a room ("246(Новополоцк)") becomes the lesson's `place` and leaves the subject and room. "История Полоцка" is not a town.
- API 14: `place` on lessons and `places` on days of /api/timetable, `place` in the directory. The day notes are shared by the server and the PWA (shared/places.js); the same note in every subgroup reads as the whole group's.
- Android, PWA and iOS: a "Занятия в Полоцке" card above the day's lessons (day pages, calendar, overview, week view, desk stand), "Полоцк" under the date in the week grid, "249Ж · Полоцк" on lesson cards, a "Город" row in details, the town in widgets, tile, reminders and change lists.
- Snapshot lines show the town and the day notes; tests for notes, towns in cells and the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 01:26:54 +03:00

230 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Charts
import SwiftUI
/// Everything the loaded weeks say about one subject.
private struct SubjectSummary: Identifiable {
var name: String
var types: [String]
var teachers: [String]
var rooms: [String]
var upcoming: [(Slot, Lesson)]
var weekDone: Int
var weekTotal: Int
var id: String { subjectKey(name) }
}
private func unique(_ items: [String]) -> [String] {
var seen = Set<String>()
return items.filter { seen.insert($0).inserted }
}
/// Subjects soonest first, with the week's progress, teachers and rooms.
struct SubjectsView: View {
@Environment(AppModel.self) private var model
@Environment(Router.self) private var router
@State private var open: String?
var body: some View {
Group {
if let t = model.timetable, t.semester != nil {
let subjects = summaries(t)
ScrollView {
VStack(alignment: .leading, spacing: 16) {
WeekLoad(timetable: t, subjects: subjects.count)
HStack(alignment: .lastTextBaseline) {
Text("Все предметы").font(.title2.bold())
Spacer()
Text("по ближайшему занятию").font(.subheadline).foregroundStyle(.secondary)
}
.padding(.top, 8)
LazyVGrid(columns: [GridItem(.adaptive(minimum: 320), spacing: 12, alignment: .top)], spacing: 12) {
ForEach(subjects) { s in
Button { open = s.id } label: { SubjectCard(summary: s) }.buttonStyle(.plain)
}
}
}
.padding(16)
.frame(maxWidth: 1200)
.frame(maxWidth: .infinity)
}
.sheet(item: Binding(get: { open.flatMap { id in subjects.first { $0.id == id } } }, set: { open = $0?.id })) { s in
SubjectDetails(summary: s).presentationDetents([.medium, .large])
}
} else if model.timetable != nil {
EmptyState(title: "Предметы недоступны", text: "Для этого расписания даты не рассчитаны: откройте вкладку «Расписание».", symbol: "calendar.badge.exclamationmark")
} else {
EmptyState(title: "Предметов пока нет", text: "Выберите группу — предметы появятся из её расписания.", symbol: "books.vertical", action: ("Выбрать группу", { router.groups = true }))
}
}
.background(Color(.systemGroupedBackground))
.navigationTitle("Предметы")
}
private func summaries(_ t: Timetable) -> [SubjectSummary] {
let date = model.today
let clock = model.clock
let monday = date.monday
let entries = t.dates.flatMap { model.slots(t, on: $0) }.flatMap { slot in slot.lessons.map { (slot, $0) } }
let grouped = Dictionary(grouping: entries) { subjectKey($0.1.subject) }
return grouped.values.map { items in
let lessons = items.map(\.1)
let week = items.filter { $0.0.date.monday == monday }
return SubjectSummary(
name: lessons[0].subject,
types: unique(lessons.map { $0.type.isEmpty ? "Занятие" : $0.type }),
teachers: unique(lessons.flatMap(\.teachers)),
rooms: unique(lessons.flatMap(\.rooms)),
upcoming: items.filter { $0.0.date > date || ($0.0.date == date && $0.0.end > clock) },
weekDone: week.filter { $0.0.date < date || ($0.0.date == date && $0.0.end <= clock) }.count,
weekTotal: week.count
)
}
// Soonest first; subjects with nothing ahead go to the end.
.sorted { a, b in
switch (a.upcoming.first?.0, b.upcoming.first?.0) {
case let (x?, y?): (x.date, x.start, a.name) < (y.date, y.start, b.name)
case (.some, nil): true
case (nil, .some): false
default: a.name < b.name
}
}
}
}
/// Pairs per weekday of the current week as a bar chart, today's bar in the strong tone.
private struct WeekLoad: View {
@Environment(AppModel.self) private var model
var timetable: Timetable
var subjects: Int
var body: some View {
let date = model.today
let monday = date.monday
let days = (0..<7).map { monday.adding(days: $0) }.map { ($0, model.slots(timetable, on: $0).count) }
.filter { $0.0.weekday < 7 || $0.1 > 0 }
let total = days.reduce(0) { $0 + $1.1 }
let most = days.map(\.1).max() ?? 0
let busiest = days.filter { $0.1 == most && most > 0 }.map { $0.0.weekdayName.lowercased() }
SectionCard(symbol: "chart.bar", title: "Эта неделя") {
VStack(alignment: .leading, spacing: 2) {
Text("\(pairs(total)) · \(subjects) \(plural(subjects, "предмет", "предмета", "предметов"))").font(.title3.bold())
if !busiest.isEmpty {
Text("Больше всего — \(busiest.joined(separator: " и ")), \(pairs(most))").font(.subheadline).foregroundStyle(.secondary)
}
}
Chart(days, id: \.0) { day in
BarMark(x: .value("День", day.0.shortWeekday), y: .value("Пары", day.1), width: .ratio(0.55))
.foregroundStyle(day.0 == date ? Color.brand : Color.brand.opacity(day.0 < date ? 0.3 : 0.55))
.clipShape(.rect(cornerRadius: 6))
.annotation(position: .top) {
Text(day.1 > 0 ? "\(day.1)" : "—").font(.caption.weight(.semibold)).foregroundStyle(day.0 == date ? Color.brand : .secondary)
}
}
.chartYAxis(.hidden)
.chartYScale(domain: 0...max(1, most + 1))
.frame(height: 120)
.accessibilityLabel(days.map { "\($0.0.weekdayName): \($0.1)" }.joined(separator: ", "))
}
}
}
private struct SubjectCard: View {
@Environment(AppModel.self) private var model
var summary: SubjectSummary
var body: some View {
WithSubjectColor(subject: summary.name) { c in
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 12) {
Text(abbreviate(summary.name))
.font(.footnote.weight(.bold))
.lineLimit(1)
.minimumScaleFactor(0.6)
.padding(.horizontal, 4)
.foregroundStyle(c.onStrong)
.frame(width: 44, height: 44)
.background(c.strong, in: .rect(cornerRadius: 12, style: .continuous))
VStack(alignment: .leading, spacing: 2) {
Text(summary.name).font(.headline).lineLimit(3)
Text(summary.types.joined(separator: ", ")).font(.subheadline).foregroundStyle(.secondary)
}
}
if let first = summary.upcoming.first {
let (slot, lesson) = first
Meta(symbol: "clock", text: nextLabel(slot) + (withPlace(lesson.rooms.isEmpty ? nil : "ауд. " + lesson.rooms.joined(separator: ", "), lesson.place).map { " · " + $0 } ?? ""))
} else {
Meta(symbol: "checkmark.circle", text: "Занятий в этом семестре больше нет")
}
if !summary.teachers.isEmpty { Meta(symbol: "person", text: summary.teachers.joined(separator: ", ")) }
if summary.weekTotal > 0 {
ProgressView(value: Double(summary.weekDone), total: Double(summary.weekTotal)) {
Text("На этой неделе: \(summary.weekDone) из \(summary.weekTotal)").font(.subheadline)
}
.tint(c.strong)
}
let stats = model.marks.stats(for: summary.name)
if stats.total > 0 {
Meta(symbol: "person.badge.clock", text: "Посещено \(stats.present) из \(stats.total)")
}
let tasks = model.notes.filter { subjectKey($0.subject) == summary.id && $0.task && !$0.done }.count
if tasks > 0 { Meta(symbol: "checklist", text: "\(tasks) \(plural(tasks, "задание", "задания", "заданий"))") }
}
.foregroundStyle(.primary)
.card(padding: 18)
}
}
private func nextLabel(_ slot: Slot) -> String {
let today = model.today
let day = slot.date == today ? "сегодня" : slot.date == today.adding(days: 1) ? "завтра" : slot.date.shortWeekday.lowercased() + ", " + slot.date.longLabel
return "Далее \(day), \(slot.start)"
}
}
/// A subject in full: teachers, rooms, upcoming lessons, attendance and tasks.
private struct SubjectDetails: View {
@Environment(AppModel.self) private var model
@Environment(\.dismiss) private var dismiss
var summary: SubjectSummary
@State private var details: LessonRef?
@State private var editor: EditorRequest?
var body: some View {
NavigationStack {
List {
Section {
LabeledContent("Виды занятий", value: summary.types.joined(separator: ", "))
if !summary.teachers.isEmpty { LabeledContent("Преподаватели", value: summary.teachers.joined(separator: ", ")) }
if !summary.rooms.isEmpty { LabeledContent("Аудитории", value: summary.rooms.joined(separator: ", ")) }
let stats = model.marks.stats(for: summary.name)
if stats.total > 0 { LabeledContent("Посещаемость", value: "\(stats.present) из \(stats.total)") }
}
let notes = model.notes.filter { subjectKey($0.subject) == summary.id }
if !notes.isEmpty {
Section("Задания и заметки") {
ForEach(notes) { note in NoteRow(note: note, showSubject: false) { editor = EditorRequest(editing: note) } }
}
}
Section("Ближайшие занятия") {
if summary.upcoming.isEmpty { Text("Занятий в этом семестре больше нет").foregroundStyle(.secondary) }
ForEach(Array(summary.upcoming.prefix(12).enumerated()), id: \.offset) { _, item in
Button { details = LessonRef(lesson: item.1, date: item.0.date) } label: {
VStack(alignment: .leading, spacing: 2) {
Text(item.0.date.weekdayName + ", " + item.0.date.longLabel).font(.subheadline.weight(.semibold))
Text([item.0.timeLabel, item.1.type.isEmpty ? nil : item.1.type.lowercased(), withPlace(item.1.rooms.isEmpty ? nil : "ауд. " + item.1.rooms.joined(separator: ", "), item.1.place)].compactMap { $0 }.joined(separator: " · "))
.font(.subheadline).foregroundStyle(.secondary)
}
}
.foregroundStyle(.primary)
}
}
}
.navigationTitle(summary.name)
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Готово", systemImage: "checkmark") { dismiss() } } }
}
.sheet(item: $details) { LessonDetails(ref: $0).presentationDetents([.medium, .large]) }
.sheet(item: $editor) { NoteEditor(request: $0) }
}
}