feat: implement changes tracking for university documents

- Added a new data structure `ScheduleChanges` to represent changes when a university document is republished.
- Implemented methods to read and write changes to a local JSON file.
- Enhanced the `Repository` class to detect changes between the current and fresh timetable versions.
- Updated the `MainViewModel` to expose changes to the UI and handle marking changes as seen.
- Created UI components for displaying changes in both Home and Settings screens.
- Added support for changes in iOS views, including a new `ChangesCard` and `ChangesSheet`.
- Implemented logic to compare timetable versions and identify added, removed, and changed lessons.
This commit is contained in:
2026-10-19 23:57:10 +03:00
parent 68f9b95090
commit f8984e72d9
12 changed files with 693 additions and 0 deletions

View File

@@ -24,6 +24,8 @@ final class AppModel {
private(set) var exams: ExamsResponse?
private(set) var allNotes: [Note]
private(set) var allMarks: [Mark]
/** What changed when the university republished a document, per document and group. */
private(set) var allChanges: [ScheduleChanges]
private(set) var status = Status()
/** Moves every half minute while the app is open: the clock "Идёт · ещё 20 мин" counts from. */
private(set) var now = Date()
@@ -39,6 +41,7 @@ final class AppModel {
exams = Storage.read(ExamsResponse.self, "exams.json")
allNotes = Storage.read([Note].self, "notes.json") ?? []
allMarks = Storage.read([Mark].self, "attendance.json") ?? []
allChanges = Storage.read([ScheduleChanges].self, "changes.json") ?? []
Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(30))
@@ -62,6 +65,21 @@ final class AppModel {
t.slots(on: date, subgroup: subgroup, hidden: hidden)
}
/** The open group's last republished document: what changed for it. */
var changes: ScheduleChanges? {
guard let t = timetable else { return nil }
return allChanges.first { $0.document == t.document.id && $0.group == t.group }
}
/** The overview's card was opened or closed: the record stays for "Последние изменения". */
func markChangesSeen(_ record: ScheduleChanges) {
guard !record.seen else { return }
for i in allChanges.indices where allChanges[i].document == record.document && allChanges[i].group == record.group {
allChanges[i].seen = true
}
Storage.write(allChanges, "changes.json")
}
/** Upcoming exams of the open group, soonest first; the cache of another group is ignored. */
var upcomingExams: [Exam] {
guard let t = timetable, let e = exams, e.group == t.group else { return [] }
@@ -110,6 +128,11 @@ final class AppModel {
status.loading = true
do {
let fresh = try await block()
// Also in a background refresh: the card waits for the student on the overview.
if let record = ScheduleChanges.between(timetable, fresh) {
allChanges = Array(([record] + allChanges.filter { $0.document != record.document || $0.group != record.group }).prefix(12))
Storage.write(allChanges, "changes.json")
}
timetable = fresh
Storage.write(fresh, "timetable.json")
lastFailed = nil

View File

@@ -0,0 +1,114 @@
import SwiftUI
extension ScheduleChanges: Identifiable {
var id: String { "\(document)|\(group)|\(at.timeIntervalSince1970)" }
}
/**
* The overview's card after the university republished the group's document: how many lessons
* changed, "Показать" for the list, and a close button. Both mark the record as seen.
*/
struct ChangesCard: View {
var record: ScheduleChanges
var onShow: () -> Void
var onClose: () -> Void
var body: some View {
HStack(alignment: .top, spacing: 14) {
Image(systemName: "arrow.triangle.2.circlepath.doc.on.clipboard")
.font(.body.weight(.semibold))
.foregroundStyle(Color.onBrand)
.frame(width: 40, height: 40)
.background(Color.brand, in: .circle)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text("Расписание обновлено · \(changesLabel(record.count))").font(.headline)
Text("Для \(record.group)" + (fileDateLabel(record.to).map { " · файл от \($0)" } ?? ""))
.font(.subheadline).foregroundStyle(.secondary)
Button("Показать", action: onShow)
.buttonStyle(.glassProminent)
.padding(.top, 6)
}
.frame(maxWidth: .infinity, alignment: .leading)
Button("Скрыть сообщение об изменениях", systemImage: "xmark", action: onClose)
.labelStyle(.iconOnly)
.buttonStyle(.glass)
.buttonBorderShape(.circle)
}
.card(padding: 18)
}
}
/// The list behind the card: changed, added and removed lessons, as in the PWA.
struct ChangesSheet: View {
@Environment(\.dismiss) private var dismiss
var record: ScheduleChanges
var body: some View {
NavigationStack {
List {
Section {
Text("\(record.group) · \(record.name.replacingOccurrences(of: #"\.docx?$"#, with: "", options: [.regularExpression, .caseInsensitive]))"
+ (fileDateLabel(record.to).map { " · файл от \($0)" } ?? "")
+ ". Сравнение с прошлой версией, сохранённой на этом устройстве.")
.font(.subheadline).foregroundStyle(.secondary)
}
if !record.changed.isEmpty {
Section("Изменено · \(record.changed.count)") {
ForEach(record.changed, id: \.self) { change in
VStack(alignment: .leading, spacing: 4) {
when(change.after)
Text(change.after.lesson.subject).font(.headline)
ForEach(change.fields, id: \.self) { f in
(Text("\(f.label): ").foregroundStyle(.secondary)
+ Text(f.from).strikethrough().foregroundStyle(.secondary)
+ Text(" → ").foregroundStyle(.secondary)
+ Text(f.to))
.font(.subheadline)
.accessibilityLabel("\(f.label): было \(f.from), стало \(f.to)")
}
if change.fields.isEmpty {
Text("Изменился текст ячейки Word").font(.subheadline).foregroundStyle(.secondary)
}
}
.padding(.vertical, 2)
}
}
}
if !record.added.isEmpty {
Section("Добавлено · \(record.added.count)") {
ForEach(record.added, id: \.self) { row in
item(row, details: [row.lesson.type, row.lesson.teachers.joined(separator: ", "),
row.lesson.rooms.map { "ауд. \($0)" }.joined(separator: ", "), row.lesson.condition])
}
}
}
if !record.removed.isEmpty {
Section("Удалено · \(record.removed.count)") {
ForEach(record.removed, id: \.self) { row in
item(row, removed: true, details: [row.lesson.type, row.lesson.condition])
}
}
}
Section {} footer: { Text("Сверяйтесь с документом Word: при сомнениях он главнее.") }
}
.navigationTitle("Изменения")
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Готово", systemImage: "checkmark") { dismiss() } } }
}
}
private func when(_ row: ChangedLesson) -> some View {
let day = row.date.flatMap { CivilDate($0) }.map { "\($0.shortWeekday), \($0.longLabel)" } ?? dayName(row.day)
return Text(day + " · " + row.lesson.start).font(.subheadline.weight(.semibold)).foregroundStyle(Color.brand)
}
private func item(_ row: ChangedLesson, removed: Bool = false, details: [String]) -> some View {
VStack(alignment: .leading, spacing: 4) {
when(row).foregroundStyle(removed ? .secondary : Color.brand)
Text(row.lesson.subject).font(.headline).strikethrough(removed).foregroundStyle(removed ? .secondary : .primary)
let text = details.filter { !$0.trimmed.isEmpty }.joined(separator: " · ")
if !text.isEmpty { Text(text).font(.subheadline).foregroundStyle(.secondary) }
}
.padding(.vertical, 2)
}
}

View File

@@ -9,6 +9,7 @@ struct HomeView: View {
@State private var details: LessonRef?
@State private var editor: EditorRequest?
@State private var session = false
@State private var changes: ScheduleChanges?
var body: some View {
NavigationStack {
@@ -26,6 +27,7 @@ struct HomeView: View {
.sheet(item: $details) { LessonDetails(ref: $0).presentationDetents([.medium, .large]) }
.sheet(item: $editor) { NoteEditor(request: $0) }
.sheet(isPresented: $session) { SessionSheet(exams: model.upcomingExams).presentationDetents([.medium, .large]) }
.sheet(item: $changes) { ChangesSheet(record: $0).presentationDetents([.medium, .large]) }
}
@ViewBuilder private var content: some View {
@@ -75,6 +77,16 @@ struct HomeView: View {
let next = (1...7).lazy.map { today.adding(days: $0) }.map { ($0, model.slots(t, on: $0)) }.first { !$0.1.isEmpty }
return VStack(alignment: .leading, spacing: 16) {
StatusBanner()
// The university republished the document: what changed for the group, until seen.
if let record = model.changes, !record.seen {
ChangesCard(record: record, onShow: {
changes = record
model.markChangesSeen(record)
}, onClose: {
withAnimation { model.markChangesSeen(record) }
})
.transition(.opacity.combined(with: .scale(scale: 0.96)))
}
WeekReport(timetable: t)
SessionCard(timetable: t) { session = true }
DayTitle(label: "Сегодня", date: today)

View File

@@ -3,6 +3,7 @@ import SwiftUI
struct SettingsView: View {
@Environment(AppModel.self) private var model
@Environment(\.dismiss) private var dismiss
@State private var changes: ScheduleChanges?
var body: some View {
let s = model.settings
@@ -63,6 +64,13 @@ struct SettingsView: View {
Section {
LabeledContent("Документ", value: model.timetable?.document.name ?? "Группа не выбрана")
if let sem = model.timetable?.semester { LabeledContent("Семестр", value: sem.label) }
// The last republication of the document, also after its card was closed.
if let record = model.changes {
Button { changes = record } label: {
LabeledContent("Последние изменения", value: changesLabel(record.count) + " · " + CivilDate(record.at).longLabel)
.foregroundStyle(.primary)
}
}
LabeledContent("Сервер", value: model.api.base.absoluteString)
} header: {
Text("О расписании")
@@ -73,6 +81,7 @@ struct SettingsView: View {
.navigationTitle("Настройки")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Готово", systemImage: "checkmark") { dismiss() } } }
.sheet(item: $changes) { ChangesSheet(record: $0).presentationDetents([.medium, .large]) }
}
}

151
ios/Shared/Changes.swift Normal file
View File

@@ -0,0 +1,151 @@
import Foundation
// What changed for the group when the university republished its document, as the PWA reports it
// (shared/lessons.js diffSchedules) and the Android client's Changes.kt: the new timetable is
// compared with the copy on the device.
/// A Word row of the timetable: a weekday's lesson, or a lesson on its own date.
struct ChangedLesson: Codable, Hashable {
var day: Int
var date: String?
var lesson: Lesson
}
struct FieldChange: Codable, Hashable {
var label: String
var from: String
var to: String
}
struct LessonChange: Codable, Hashable {
var before: ChangedLesson
var after: ChangedLesson
var fields: [FieldChange]
}
struct ScheduleChanges: Codable, Hashable {
var document: String
var group: String
var name: String
/// When the change was found.
var at: Date
/// Dates of the two versions of the file, as the server sends them.
var from: String?
var to: String?
var added: [ChangedLesson] = []
var removed: [ChangedLesson] = []
var changed: [LessonChange] = []
/// The card on the overview was opened or closed.
var seen = false
var count: Int { added.count + removed.count + changed.count }
/**
* Compares a newer version of the same document with the copy on the device: only a
* republished file counts, not a new parser or another document of the group.
*/
static func between(_ previous: Timetable?, _ fresh: Timetable, now: Date = Date()) -> ScheduleChanges? {
guard let previous, previous.document.id == fresh.document.id, previous.group == fresh.group,
let was = previous.document.modified, let then = fresh.document.modified, was != then
else { return nil }
let (added, removed, changed) = diff(previous, fresh)
let record = ScheduleChanges(
document: fresh.document.id, group: fresh.group, name: fresh.document.name, at: now,
from: was, to: then, added: added, removed: removed, changed: changed
)
return record.count > 0 ? record : nil
}
}
/// "3 изменения".
func changesLabel(_ n: Int) -> String { "\(n) \(plural(n, "изменение", "изменения", "изменений"))" }
/// The date of a file version as the server sends it ("Mon, 19 Oct 2026 08:00:00 GMT").
func fileDateLabel(_ value: String?) -> String? {
guard let value else { return nil }
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
return f.date(from: value).map { CivilDate($0).longLabel }
}
/**
* The timetable's rows: the weekly Word rows, plus lessons printed with their own date (the
* server's key then starts with that date). Dates computed from week marks are not compared one
* by one, or one changed mark would read as a dozen changes.
*/
private func rows(_ t: Timetable) -> [ChangedLesson] {
let weekly = t.weekly.flatMap { d in d.lessons.map { ChangedLesson(day: d.day, date: nil, lesson: $0) } }
var seen = Set<String>()
let dated = t.days.flatMap { d in
d.lessons.filter { $0.key.range(of: #"^\d{4}-\d{2}-\d{2}\|"#, options: .regularExpression) != nil }
.map { ChangedLesson(day: d.weekday, date: d.date, lesson: $0) }
}.filter { seen.insert($0.lesson.key).inserted }
return weekly + dated
}
private func norm(_ s: String) -> String {
s.lowercased().split(whereSeparator: \.isWhitespace).joined(separator: " ")
}
/// Looser identity for pairing a lesson whose day, time or week mark changed.
private func looseKey(_ l: Lesson) -> String {
[norm(l.subject), l.variant, l.subgroup ?? "", norm(l.type)].joined(separator: "|")
}
private func whenLabel(_ row: ChangedLesson) -> String {
row.date.flatMap { CivilDate($0) }.map { "\($0.weekdayName), \($0.longLabel)" } ?? dayName(row.day)
}
private func fields(_ a: ChangedLesson, _ b: ChangedLesson) -> [FieldChange] {
let pairs: [(String, String, String)] = [
("День", whenLabel(a), whenLabel(b)),
("Начало", a.lesson.start, b.lesson.start),
("Окончание", a.lesson.end ?? "", b.lesson.end ?? ""),
("Недели", a.lesson.condition, b.lesson.condition),
("Вид занятия", a.lesson.type, b.lesson.type),
("Преподаватель", a.lesson.teachers.joined(separator: ", "), b.lesson.teachers.joined(separator: ", ")),
("Аудитория", a.lesson.rooms.joined(separator: ", "), b.lesson.rooms.joined(separator: ", ")),
]
let shown = { (s: String) in s.trimmed.isEmpty ? "не указано" : s }
return pairs.filter { $0.1.trimmed != $0.2.trimmed }.map { FieldChange(label: $0.0, from: shown($0.1), to: shown($0.2)) }
}
private func order(_ a: ChangedLesson, _ b: ChangedLesson) -> Bool {
((a.date ?? ""), a.day, a.lesson.start) < ((b.date ?? ""), b.day, b.lesson.start)
}
/// Added, removed and changed rows between two versions of the group's timetable.
private func diff(_ before: Timetable, _ after: Timetable) -> ([ChangedLesson], [ChangedLesson], [LessonChange]) {
var old: [String: [ChangedLesson]] = [:]
var order0: [String] = []
for row in rows(before) {
if old[row.lesson.key] == nil { order0.append(row.lesson.key) }
old[row.lesson.key, default: []].append(row)
}
var changed: [LessonChange] = []
var unmatched: [ChangedLesson] = []
for row in rows(after) {
if var list = old[row.lesson.key], !list.isEmpty {
let was = list.removeFirst()
old[row.lesson.key] = list
let f = fields(was, row)
if !f.isEmpty { changed.append(LessonChange(before: was, after: row, fields: f)) }
} else {
unmatched.append(row)
}
}
var left = order0.flatMap { old[$0] ?? [] }
// Pair the rest by subject on the same day first, then by subject alone (moved to another day).
for sameDay in [true, false] {
for row in unmatched {
guard let index = left.firstIndex(where: {
looseKey($0.lesson) == looseKey(row.lesson) && (!sameDay || ($0.date ?? "\($0.day)") == (row.date ?? "\(row.day)"))
}) else { continue }
let was = left.remove(at: index)
if let i = unmatched.firstIndex(of: row) { unmatched.remove(at: i) }
changed.append(LessonChange(before: was, after: row, fields: fields(was, row)))
}
}
return (unmatched.sorted(by: order), left.sorted(by: order), changed.sorted { order($0.after, $1.after) })
}