mirror of
https://github.com/NinjaCheetah/RIT-Dining.git
synced 2026-01-17 12:05:57 -05:00
- Maps button now works properly, I figured out the URL scheme needed to direct to a map location - Notifications will now be automatically rescheduled when changing the notification offset
317 lines
15 KiB
Swift
317 lines
15 KiB
Swift
//
|
|
// TigerCenterParsers.swift
|
|
// RIT Dining
|
|
//
|
|
// Created by Campbell on 9/19/25.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
func parseOpenStatus(openTime: Date, closeTime: Date) -> OpenStatus {
|
|
// This can probably be done a little cleaner but it's okay for now. If the location is open but the close date is within the next
|
|
// 30 minutes, label it as closing soon, and do the opposite if it's closed but the open date is within the next 30 minutes.
|
|
let calendar = Calendar.current
|
|
let now = Date()
|
|
var openStatus: OpenStatus = .closed
|
|
if now >= openTime && now <= closeTime {
|
|
// This is basically just for Bytes, it checks the case where the open and close times are exactly 24 hours apart, which is
|
|
// only true for 24-hour locations.
|
|
if closeTime == calendar.date(byAdding: .day, value: 1, to: openTime)! {
|
|
openStatus = .open
|
|
} else if closeTime < calendar.date(byAdding: .minute, value: 30, to: now)! {
|
|
openStatus = .closingSoon
|
|
} else {
|
|
openStatus = .open
|
|
}
|
|
} else if openTime <= calendar.date(byAdding: .minute, value: 30, to: now)! && closeTime > now {
|
|
openStatus = .openingSoon
|
|
} else {
|
|
openStatus = .closed
|
|
}
|
|
return openStatus
|
|
}
|
|
|
|
func parseLocationInfo(location: DiningLocationParser, forDate: Date?) -> DiningLocation {
|
|
print("beginning parse for \(location.name)")
|
|
|
|
// The descriptions sometimes have HTML <br /> tags despite also having \n. Those need to be removed.
|
|
let desc = location.description.replacingOccurrences(of: "<br />", with: "")
|
|
|
|
// Check if this location's ID is in the TigerCenter -> FD MealPlanner ID map and save those IDs if it is.
|
|
let fdmpIds: FDMPIds? = if tCtoFDMPMap.keys.contains(location.id) {
|
|
FDMPIds(
|
|
locationId: tCtoFDMPMap[location.id]!.0,
|
|
accountId: tCtoFDMPMap[location.id]!.1
|
|
)
|
|
} else {
|
|
nil
|
|
}
|
|
|
|
// Generate a maps URL from the mdoId key. This is required because the mapsUrl served by TigerCenter is not compatible with
|
|
// the new RIT map that was deployed in December 2025.
|
|
let mapsUrl = "https://maps.rit.edu/?mdo_id=\(location.mdoId)"
|
|
|
|
// Early return if there are no events, good for things like the food trucks which can very easily have no openings in a week.
|
|
if location.events.isEmpty {
|
|
return DiningLocation(
|
|
id: location.id,
|
|
mdoId: location.mdoId,
|
|
fdmpIds: fdmpIds,
|
|
name: location.name,
|
|
summary: location.summary,
|
|
desc: desc,
|
|
mapsUrl: mapsUrl,
|
|
date: forDate ?? Date(),
|
|
diningTimes: nil,
|
|
open: .closed,
|
|
visitingChefs: nil,
|
|
dailySpecials: nil)
|
|
}
|
|
|
|
var openStrings: [String] = []
|
|
var closeStrings: [String] = []
|
|
|
|
// Dining locations have a regular schedule, but then they also have exceptions listed for days like weekends or holidays. If there
|
|
// are exceptions, use those times for the day, otherwise we can just use the default times. Also check for repeats! The response data
|
|
// can include those somtimes, for reasons:tm:
|
|
for event in location.events {
|
|
if let exceptions = event.exceptions, !exceptions.isEmpty {
|
|
// Only save the exception times if the location is actually open during those times, and if these times aren't a repeat.
|
|
// I've seen repeats for Brick City Cafe specifically, where both the breakfast and lunch standard open periods had
|
|
// exceptions listing the same singluar brunch period. That feels like a stupid choice but oh well.
|
|
if exceptions[0].open, !openStrings.contains(exceptions[0].startTime), !closeStrings.contains(exceptions[0].endTime) {
|
|
openStrings.append(exceptions[0].startTime)
|
|
closeStrings.append(exceptions[0].endTime)
|
|
}
|
|
} else {
|
|
if !openStrings.contains(event.startTime), !closeStrings.contains(event.endTime) {
|
|
// Verify that the current weekday falls within the schedule. The regular event schedule specifies which days of the
|
|
// week it applies to, and if the current day isn't in that list and there are no exceptions, that means there are no
|
|
// hours for this location.
|
|
if event.daysOfWeek.contains(weekdayFromDate.string(from: forDate ?? Date()).uppercased()) {
|
|
openStrings.append(event.startTime)
|
|
closeStrings.append(event.endTime)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Early return if there are no valid opening times, most likely because the day's exceptions dictate that the location is closed.
|
|
// Mostly comes into play on holidays.
|
|
if openStrings.isEmpty || closeStrings.isEmpty {
|
|
return DiningLocation(
|
|
id: location.id,
|
|
mdoId: location.mdoId,
|
|
fdmpIds: fdmpIds,
|
|
name: location.name,
|
|
summary: location.summary,
|
|
desc: desc,
|
|
mapsUrl: mapsUrl,
|
|
date: forDate ?? Date(),
|
|
diningTimes: nil,
|
|
open: .closed,
|
|
visitingChefs: nil,
|
|
dailySpecials: nil)
|
|
}
|
|
|
|
// I hate all of this date component nonsense.
|
|
var openDates: [Date] = []
|
|
var closeDates: [Date] = []
|
|
|
|
let calendar = Calendar.current
|
|
let now = Date()
|
|
|
|
for i in 0..<openStrings.count {
|
|
let openParts = openStrings[i].split(separator: ":").map { Int($0) ?? 0 }
|
|
let openTimeComponents = DateComponents(hour: openParts[0], minute: openParts[1], second: openParts[2])
|
|
|
|
let closeParts = closeStrings[i].split(separator: ":").map { Int($0) ?? 0 }
|
|
let closeTimeComponents = DateComponents(hour: closeParts[0], minute: closeParts[1], second: closeParts[2])
|
|
|
|
openDates.append(calendar.date(
|
|
bySettingHour: openTimeComponents.hour!,
|
|
minute: openTimeComponents.minute!,
|
|
second: openTimeComponents.second!,
|
|
of: now)!)
|
|
|
|
closeDates.append(calendar.date(
|
|
bySettingHour: closeTimeComponents.hour!,
|
|
minute: closeTimeComponents.minute!,
|
|
second: closeTimeComponents.second!,
|
|
of: now)!)
|
|
}
|
|
var diningTimes: [DiningTimes] = []
|
|
for i in 0..<openDates.count {
|
|
diningTimes.append(DiningTimes(openTime: openDates[i], closeTime: closeDates[i]))
|
|
}
|
|
|
|
// If the closing time is less than or equal to the opening time, it's probably midnight and means either open until midnight
|
|
// or open 24/7, in the case of Bytes.
|
|
for i in diningTimes.indices {
|
|
if diningTimes[i].closeTime <= diningTimes[i].openTime {
|
|
diningTimes[i].closeTime = calendar.date(byAdding: .day, value: 1, to: diningTimes[i].closeTime)!
|
|
}
|
|
}
|
|
|
|
// Sometimes the openings are not in order, for some reason. I'm observing this with Brick City, where for some reason the early opening
|
|
// is event 1, and the later opening is event 0. This is silly so let's reverse it.
|
|
diningTimes.sort { $0.openTime < $1.openTime }
|
|
|
|
// This can probably be done a little cleaner but it's okay for now. If the location is open but the close date is within the next
|
|
// 30 minutes, label it as closing soon, and do the opposite if it's closed but the open date is within the next 30 minutes.
|
|
var openStatus: OpenStatus = .closed
|
|
for i in diningTimes.indices {
|
|
openStatus = parseOpenStatus(openTime: diningTimes[i].openTime, closeTime: diningTimes[i].closeTime)
|
|
// If the first event pass came back closed, loop again in case a later event has a different status. This is mostly to
|
|
// accurately catch Gracie's multiple open periods each day.
|
|
if openStatus != .closed {
|
|
break
|
|
}
|
|
}
|
|
|
|
// Parse the "menus" array and keep track of visiting chefs at this location, if there are any. If not then we can just save nil.
|
|
// The time formats used for visiting chefs are inconsistent and suck so that part of this code might be kind of rough. I can
|
|
// probably make it a little better but I think most of the blame goes to TigerCenter here.
|
|
// Also save the daily specials. This is more of a footnote because that's just taking a string and saving it as two strings.
|
|
let visitingChefs: [VisitingChef]?
|
|
let dailySpecials: [DailySpecial]?
|
|
if !location.menus.isEmpty {
|
|
var chefs: [VisitingChef] = []
|
|
var specials: [DailySpecial] = []
|
|
for menu in location.menus {
|
|
if menu.category == "Visiting Chef" {
|
|
print("found visiting chef: \(menu.name)")
|
|
var name: String = menu.name
|
|
let splitString = name.split(separator: "(", maxSplits: 1)
|
|
name = String(splitString[0]).trimmingCharacters(in: .whitespaces)
|
|
// Time parsing nonsense starts here. Extracts the time from a string like "Chef (4-7p.m.)", splits it at the "-",
|
|
// strips the non-numerical characters from each part, parses it as a number and adds 12 hours as needed, then creates
|
|
// a Date instance for that time on today's date.
|
|
let timeStrings = String(splitString[1]).replacingOccurrences(of: ")", with: "").split(separator: "-", maxSplits: 1)
|
|
print("raw open range: \(timeStrings)")
|
|
let openTime: Date
|
|
let closeTime: Date
|
|
if let openString = timeStrings.first?.trimmingCharacters(in: .whitespaces) {
|
|
// If the time is NOT in the morning, add 12 hours.
|
|
let openHour = if openString.contains("a.m") {
|
|
Int(openString.filter("0123456789".contains))!
|
|
} else {
|
|
Int(openString)! + 12
|
|
}
|
|
let openTimeComponents = DateComponents(hour: openHour, minute: 0, second: 0)
|
|
openTime = calendar.date(
|
|
bySettingHour: openTimeComponents.hour!,
|
|
minute: openTimeComponents.minute!,
|
|
second: openTimeComponents.second!,
|
|
of: forDate ?? now)!
|
|
} else {
|
|
break
|
|
}
|
|
if let closeString = timeStrings.last?.filter("0123456789".contains) {
|
|
// I've chosen to assume that no visiting chef will ever close in the morning. This could bad choice but I have
|
|
// yet to see any evidence of a visiting chef leaving before noon so far.
|
|
let closeHour = Int(closeString)! + 12
|
|
let closeTimeComponents = DateComponents(hour: closeHour, minute: 0, second: 0)
|
|
closeTime = calendar.date(
|
|
bySettingHour: closeTimeComponents.hour!,
|
|
minute: closeTimeComponents.minute!,
|
|
second: closeTimeComponents.second!,
|
|
of: forDate ?? now)!
|
|
} else {
|
|
break
|
|
}
|
|
|
|
// Parse the chef's status, mapping the OpenStatus to a VisitingChefStatus.
|
|
let visitngChefStatus: VisitingChefStatus = switch parseOpenStatus(openTime: openTime, closeTime: closeTime) {
|
|
case .open:
|
|
.hereNow
|
|
case .closed:
|
|
if now < openTime {
|
|
.arrivingLater
|
|
} else {
|
|
.gone
|
|
}
|
|
case .openingSoon:
|
|
.arrivingSoon
|
|
case .closingSoon:
|
|
.leavingSoon
|
|
}
|
|
|
|
chefs.append(VisitingChef(
|
|
name: name,
|
|
description: menu.description ?? "No description available", // Some don't have descriptions, apparently.
|
|
openTime: openTime,
|
|
closeTime: closeTime,
|
|
status: visitngChefStatus))
|
|
} else if menu.category == "Daily Specials" {
|
|
print("found daily special: \(menu.name)")
|
|
let splitString = menu.name.split(separator: "(", maxSplits: 1)
|
|
specials.append(DailySpecial(
|
|
name: String(splitString[0]),
|
|
type: String(splitString.count > 1 ? String(splitString[1]) : "").replacingOccurrences(of: ")", with: "")))
|
|
}
|
|
}
|
|
visitingChefs = chefs
|
|
dailySpecials = specials
|
|
} else {
|
|
visitingChefs = nil
|
|
dailySpecials = nil
|
|
}
|
|
|
|
return DiningLocation(
|
|
id: location.id,
|
|
mdoId: location.mdoId,
|
|
fdmpIds: fdmpIds,
|
|
name: location.name,
|
|
summary: location.summary,
|
|
desc: desc,
|
|
mapsUrl: mapsUrl,
|
|
date: forDate ?? Date(),
|
|
diningTimes: diningTimes,
|
|
open: openStatus,
|
|
visitingChefs: visitingChefs,
|
|
dailySpecials: dailySpecials)
|
|
}
|
|
|
|
extension DiningLocation {
|
|
// Updates the open status of a location and of its visiting chefs, so that the labels in the UI update automatically as
|
|
// time progresses and locations open/close/etc.
|
|
mutating func updateOpenStatus() {
|
|
var openStatus: OpenStatus = .closed
|
|
if let diningTimes = diningTimes, !diningTimes.isEmpty {
|
|
for i in diningTimes.indices {
|
|
openStatus = parseOpenStatus(openTime: diningTimes[i].openTime, closeTime: diningTimes[i].closeTime)
|
|
// If the first event pass came back closed, loop again in case a later event has a different status. This is mostly to
|
|
// accurately catch Gracie's multiple open periods each day.
|
|
if openStatus != .closed {
|
|
break
|
|
}
|
|
}
|
|
self.open = openStatus
|
|
} else {
|
|
self.open = .closed
|
|
}
|
|
if let visitingChefs = visitingChefs, !visitingChefs.isEmpty {
|
|
let now = Date()
|
|
for i in visitingChefs.indices {
|
|
self.visitingChefs![i].status = switch parseOpenStatus(
|
|
openTime: visitingChefs[i].openTime,
|
|
closeTime: visitingChefs[i].closeTime) {
|
|
case .open:
|
|
.hereNow
|
|
case .closed:
|
|
if now < visitingChefs[i].openTime {
|
|
.arrivingLater
|
|
} else {
|
|
.gone
|
|
}
|
|
case .openingSoon:
|
|
.arrivingSoon
|
|
case .closingSoon:
|
|
.leavingSoon
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|