- 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>
230 lines
12 KiB
Swift
230 lines
12 KiB
Swift
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) }
|
||
}
|
||
}
|