RIT-Dining/RIT Dining/FetchData.swift
NinjaCheetah 9d16be646a
Various minor fixes and improvements
- Visiting chef screen now shows "No visiting chefs today" instead of nothing when there are, get this, no visiting chefs today
- 24-hour locations (Bytes) will no longer show as "Closing Soon" for the last 30 minutes of the day only to reset back to "Open" at midnight
- The app will now display a network error instead of loading indefinitely if an error occurs while fetching dining data
- Other minor spacing and layout changes
2025-09-14 23:36:11 -04:00

313 lines
14 KiB
Swift

//
// FetchData.swift
// RIT Dining
//
// Created by Campbell on 8/31/25.
//
import Foundation
enum InvalidHTTPError: Error {
case invalid
}
// This API requesting code came from another project of mine and was used to fetch the GitHub API for update checking. I just copied it
// here, but it can probably be made simpler for this use case.
// Get information for all dining locations.
func getAllDiningInfo(date: String?, completionHandler: @escaping (Result<DiningLocationsParser, Error>) -> Void) {
// The endpoint requires that you specify a date, so get today's.
let date_string: String = date ?? getAPIFriendlyDateString(date: Date())
let url_string = "https://tigercenter.rit.edu/tigerCenterApi/tc/dining-all?date=\(date_string)"
guard let url = URL(string: url_string) else {
print("Invalid URL")
return
}
let request = URLRequest(url: url)
URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completionHandler(.failure(error))
return
}
guard let data = data else {
completionHandler(.failure(URLError(.badServerResponse)))
return
}
guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else {
completionHandler(.failure(InvalidHTTPError.invalid))
return
}
let decoded: Result<DiningLocationsParser, Error> = Result(catching: { try JSONDecoder().decode(DiningLocationsParser.self, from: data) })
completionHandler(decoded)
}.resume()
}
// Get information for just one dining location based on its location ID.
func getSingleDiningInfo(date: String?, locationId: Int, completionHandler: @escaping (Result<DiningLocationParser, Error>) -> Void) {
// The current date and the location ID are required to get information for just one location.
let date_string: String = date ?? getAPIFriendlyDateString(date: Date())
let url_string = "https://tigercenter.rit.edu/tigerCenterApi/tc/dining-single?date=\(date_string)&locId=\(locationId)"
print("making request to \(url_string)")
guard let url = URL(string: url_string) else {
print("Invalid URL")
return
}
let request = URLRequest(url: url)
URLSession.shared.dataTask(with: request) { data, response, error in
guard case .none = error else { return }
guard let data = data else {
print("Data error.")
return
}
guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else {
completionHandler(.failure(InvalidHTTPError.invalid))
return
}
let decoded: Result<DiningLocationParser, Error> = Result(catching: { try JSONDecoder().decode(DiningLocationParser.self, from: data) })
completionHandler(decoded)
}.resume()
}
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) -> 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: "")
// 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,
name: location.name,
summary: location.summary,
desc: desc,
mapsUrl: location.mapsUrl,
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.
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.
if exceptions[0].open {
openStrings.append(exceptions[0].startTime)
closeStrings.append(exceptions[0].endTime)
}
} else {
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,
name: location.name,
summary: location.summary,
desc: desc,
mapsUrl: location.mapsUrl,
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])
// 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: 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: 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,
name: location.name,
summary: location.summary,
desc: desc,
mapsUrl: location.mapsUrl,
diningTimes: diningTimes,
open: openStatus,
visitingChefs: visitingChefs,
dailySpecials: dailySpecials)
}