import Foundation /// The university's time zone: dates and "now" never follow the phone's zone. let minsk = TimeZone(identifier: "Europe/Minsk")! /// A Gregorian calendar in Minsk time, weeks from Monday. let minskCalendar: Calendar = { var c = Calendar(identifier: .gregorian) c.timeZone = minsk c.firstWeekday = 2 c.locale = Locale(identifier: "ru_RU") return c }() /// A calendar date without a time, like java.time.LocalDate: "2026-10-18". struct CivilDate: Hashable, Comparable, Codable, Identifiable, CustomStringConvertible { var year: Int var month: Int var day: Int init(year: Int, month: Int, day: Int) { self.year = year self.month = month self.day = day } init?(_ iso: String) { let parts = iso.prefix(10).split(separator: "-").compactMap { Int($0) } guard parts.count == 3 else { return nil } self.init(year: parts[0], month: parts[1], day: parts[2]) } init(_ date: Date) { let c = minskCalendar.dateComponents([.year, .month, .day], from: date) self.init(year: c.year!, month: c.month!, day: c.day!) } static func today(_ now: Date = Date()) -> CivilDate { CivilDate(now) } /// Noon in Minsk, so adding days never trips over a daylight saving change. var date: Date { minskCalendar.date(from: DateComponents(year: year, month: month, day: day, hour: 12))! } var iso: String { String(format: "%04d-%02d-%02d", year, month, day) } var description: String { iso } var id: String { iso } func adding(days: Int) -> CivilDate { CivilDate(minskCalendar.date(byAdding: .day, value: days, to: date)!) } func adding(months: Int) -> CivilDate { CivilDate(minskCalendar.date(byAdding: .month, value: months, to: date)!) } /// 1 = Monday … 7 = Sunday, as in java.time. var weekday: Int { let w = minskCalendar.component(.weekday, from: date) // 1 = Sunday return w == 1 ? 7 : w - 1 } var monday: CivilDate { adding(days: 1 - weekday) } var firstOfMonth: CivilDate { CivilDate(year: year, month: month, day: 1) } func days(to other: CivilDate) -> Int { minskCalendar.dateComponents([.day], from: date, to: other.date).day ?? 0 } func at(_ time: ClockTime) -> Date { minskCalendar.date(from: DateComponents(year: year, month: month, day: day, hour: time.hour, minute: time.minute))! } static func < (a: CivilDate, b: CivilDate) -> Bool { (a.year, a.month, a.day) < (b.year, b.month, b.day) } // Codable as the ISO string, the way the server and the Android cache write dates. init(from decoder: Decoder) throws { let s = try decoder.singleValueContainer().decode(String.self) guard let d = CivilDate(s) else { throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Не дата: \(s)")) } self = d } func encode(to encoder: Encoder) throws { var c = encoder.singleValueContainer() try c.encode(iso) } } /// A time of day in minutes since midnight: "08:30". struct ClockTime: Hashable, Comparable, CustomStringConvertible { var minutes: Int init(minutes: Int) { self.minutes = minutes } init(hour: Int, minute: Int) { minutes = hour * 60 + minute } init?(_ hhmm: String) { let parts = hhmm.split(separator: ":").compactMap { Int($0) } guard parts.count >= 2 else { return nil } minutes = parts[0] * 60 + parts[1] } static func now(_ date: Date = Date()) -> ClockTime { let c = minskCalendar.dateComponents([.hour, .minute], from: date) return ClockTime(hour: c.hour!, minute: c.minute!) } var hour: Int { minutes / 60 } var minute: Int { minutes % 60 } var description: String { String(format: "%02d:%02d", hour, minute) } func adding(minutes m: Int) -> ClockTime { ClockTime(minutes: minutes + m) } static func < (a: ClockTime, b: ClockTime) -> Bool { a.minutes < b.minutes } } // MARK: - Russian labels private let monthsGenitive = ["января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря"] private let monthsNominative = ["Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", "Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь"] private let monthsShort = ["Янв.", "Февр.", "Март", "Апр.", "Май", "Июнь", "Июль", "Авг.", "Сент.", "Окт.", "Нояб.", "Дек."] private let weekdaysFull = ["Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота", "Воскресенье"] private let weekdaysShort = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"] extension CivilDate { /// "18 октября" var longLabel: String { "\(day) \(monthsGenitive[month - 1])" } /// "Пн" var shortWeekday: String { weekdaysShort[weekday - 1] } /// "Понедельник" var weekdayName: String { weekdaysFull[weekday - 1] } /// "Октябрь 2026" var monthTitle: String { "\(monthsNominative[month - 1]) \(year)" } /// "Окт." var shortMonth: String { monthsShort[month - 1] } /// "18.10" var numeric: String { String(format: "%02d.%02d", day, month) } } /// "Понедельник" for 1…7. func dayName(_ day: Int) -> String { weekdaysFull[max(0, min(6, day - 1))] } /// "12–18 октября" or "28 сентября – 4 октября". func weekSpan(_ start: CivilDate, _ end: CivilDate) -> String { start.month == end.month ? "\(start.day)–\(end.longLabel)" : "\(start.longLabel) – \(end.longLabel)" } /// Russian plural: 1 пара, 2 пары, 5 пар. func plural(_ n: Int, _ one: String, _ few: String, _ many: String) -> String { let tail = n % 100 if (11...14).contains(tail) { return many } switch n % 10 { case 1: return one case 2...4: return few default: return many } } /// "1 пара", "3 пары". func pairs(_ n: Int) -> String { "\(n) \(plural(n, "пара", "пары", "пар"))" } /// "45 мин", "1 ч 20 мин". func minutesLabel(_ m: Int) -> String { if m < 60 { return "\(m) мин" } return "\(m / 60) ч" + (m % 60 > 0 ? " \(m % 60) мин" : "") } /// "Окт. · Неделя 7": the title of a shown week. func weekTitle(_ date: CivilDate, _ n: Int?) -> String { n.map { "\(date.shortMonth) · Неделя \($0)" } ?? date.shortMonth }