mirror of
https://github.com/NinjaCheetah/RIT-Dining.git
synced 2025-10-19 06:36:18 -04:00
Added occupancy info to DetailView
The details page for a location now shows an indicator of how busy a location is based on data from the RIT maps API, which kindly offers the current and maximum occupancies for locations around campus. This is displayed the same way that the RIT website displays it, with 0-5 person icons that are filled in based on the percentage occupied the location is. This API can be *really* slow sometimes, so this data is fetched indepdently of the main load, because it could end up massively slowing down the app to not display the main info until after the occupancy data loads. A small spinner is used to indicate that occupancy data is loading, and the indicators are partially transparent until the data is loaded. Also fixed a bug where locations with multiple opening periods could have exceptions for the same time period, resulting in duplicated time slots. Thanks Brick City Cafe! This is fixed by making sure that times are not already present in the openTimes/closeTimes arrays before adding them.
This commit is contained in:
parent
b8a9c5cdd6
commit
c505de4b5a
@ -257,7 +257,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
DEVELOPMENT_TEAM = 5GF7GKNTK4;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@ -291,7 +291,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
DEVELOPMENT_TEAM = 5GF7GKNTK4;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
|
@ -79,7 +79,6 @@ struct ContentView: View {
|
||||
case .success(let locations):
|
||||
for i in 0..<locations.locations.count {
|
||||
let diningInfo = parseLocationInfo(location: locations.locations[i])
|
||||
print(diningInfo.name)
|
||||
DispatchQueue.global().sync {
|
||||
newDiningLocations.append(diningInfo)
|
||||
}
|
||||
|
@ -61,10 +61,13 @@ func getSingleDiningInfo(date: String?, locationId: Int, completionHandler: @esc
|
||||
let request = URLRequest(url: url)
|
||||
|
||||
URLSession.shared.dataTask(with: request) { data, response, error in
|
||||
guard case .none = error else { return }
|
||||
if let error = error {
|
||||
completionHandler(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = data else {
|
||||
print("Data error.")
|
||||
completionHandler(.failure(URLError(.badServerResponse)))
|
||||
return
|
||||
}
|
||||
|
||||
@ -78,235 +81,84 @@ func getSingleDiningInfo(date: String?, locationId: Int, completionHandler: @esc
|
||||
}.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
|
||||
// Get the occupancy information for a location using its TigerCenter API location ID.
|
||||
// This function is very messy but as the comment at the top of the file says, all of this async API access code is rough.
|
||||
func getOccupancyPercentage(locationId: Int, completionHandler: @escaping (Result<Double, Error>) -> Void) {
|
||||
// We need to use the TigerCenter location ID to get the maps API ID.
|
||||
var url_string = "https://maps.rit.edu/api/api-dining.php?id=\(locationId)"
|
||||
print("making request to \(url_string)")
|
||||
|
||||
guard let url = URL(string: url_string) else {
|
||||
print("Invalid URL")
|
||||
return
|
||||
}
|
||||
} else if openTime <= calendar.date(byAdding: .minute, value: 30, to: now)! && closeTime > now {
|
||||
openStatus = .openingSoon
|
||||
} else {
|
||||
openStatus = .closed
|
||||
let request = URLRequest(url: url)
|
||||
|
||||
var mapsId: Int = 0
|
||||
|
||||
URLSession.shared.dataTask(with: request) { data, response, error in
|
||||
if let error = error {
|
||||
completionHandler(.failure(error))
|
||||
return
|
||||
}
|
||||
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)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode(MapsMiddlemanParser.self, from: data)
|
||||
mapsId = Int(decoded.properties.mdoid)!
|
||||
|
||||
// Use the newly-acquired maps ID to request the occupancy information for the location.
|
||||
url_string = "https://maps.rit.edu/proxySearch/densityMapDetail.php?mdo=\(mapsId)"
|
||||
print("making request to \(url_string)")
|
||||
|
||||
guard let url = URL(string: url_string) else {
|
||||
print("Invalid URL")
|
||||
return
|
||||
}
|
||||
let occ_request = URLRequest(url: url)
|
||||
|
||||
URLSession.shared.dataTask(with: occ_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
|
||||
}
|
||||
|
||||
do {
|
||||
let occupancy = try JSONDecoder().decode([DiningOccupancyParser].self, from: data)
|
||||
if !occupancy.isEmpty {
|
||||
print("current occupancy: \(occupancy[0].count)")
|
||||
print("maximum occupancy: \(occupancy[0].max_occ)")
|
||||
let occupancyPercentage = Double(occupancy[0].count) / Double(occupancy[0].max_occ) * 100
|
||||
print("occupancy percentage: \(occupancyPercentage)%")
|
||||
completionHandler(.success(occupancyPercentage))
|
||||
} else {
|
||||
completionHandler(.failure(DecodingError.dataCorrupted(DecodingError.Context(codingPath: [], debugDescription: "Failed to decode JSON"))))
|
||||
}
|
||||
} catch {
|
||||
completionHandler(.failure(DecodingError.dataCorrupted(DecodingError.Context(codingPath: [], debugDescription: "Failed to decode JSON"))))
|
||||
}
|
||||
}.resume()
|
||||
} catch {
|
||||
completionHandler(.failure(DecodingError.dataCorrupted(DecodingError.Context(codingPath: [], debugDescription: "Failed to decode JSON"))))
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
246
RIT Dining/Data/Parsers.swift
Normal file
246
RIT Dining/Data/Parsers.swift
Normal file
@ -0,0 +1,246 @@
|
||||
//
|
||||
// Parsers.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) -> 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. 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) {
|
||||
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)
|
||||
}
|
@ -71,7 +71,7 @@ enum VisitingChefStatus {
|
||||
case leavingSoon
|
||||
}
|
||||
|
||||
// A visitng chef present at a location.
|
||||
// A visiting chef present at a location.
|
||||
struct VisitingChef: Equatable, Hashable {
|
||||
let name: String
|
||||
let description: String
|
||||
@ -80,6 +80,7 @@ struct VisitingChef: Equatable, Hashable {
|
||||
let status: VisitingChefStatus
|
||||
}
|
||||
|
||||
// A daily special at a location.
|
||||
struct DailySpecial: Equatable, Hashable {
|
||||
let name: String
|
||||
let type: String
|
||||
@ -97,3 +98,36 @@ struct DiningLocation: Identifiable, Hashable {
|
||||
let visitingChefs: [VisitingChef]?
|
||||
let dailySpecials: [DailySpecial]?
|
||||
}
|
||||
|
||||
// Parser used to parse the data from the maps.rit.edu/api/api-dining.php used as a middleman to translate the IDs from TigerCenter
|
||||
// to the IDs used for the maps API.
|
||||
struct MapsMiddlemanParser: Decodable {
|
||||
// Properties of the location, which are all I need.
|
||||
struct Properties: Decodable {
|
||||
let name: String
|
||||
let url: String
|
||||
let id: String
|
||||
let mdoid: String
|
||||
}
|
||||
let properties: Properties
|
||||
}
|
||||
|
||||
// Parser to read the occupancy data for a location.
|
||||
struct DiningOccupancyParser: Decodable {
|
||||
// Represents a per-hour occupancy rating.
|
||||
struct HourlyOccupancy: Decodable {
|
||||
let hour: Int
|
||||
let today: Int
|
||||
let today_max: Int
|
||||
let one_week_ago: Int
|
||||
let one_week_ago_max: Int
|
||||
let average: Int
|
||||
}
|
||||
let count: Int
|
||||
let location: String
|
||||
let building: String
|
||||
let mdo_id: Int
|
||||
let max_occ: Int
|
||||
let open_status: String
|
||||
let intra_loc_hours: [HourlyOccupancy]
|
||||
}
|
||||
|
@ -16,6 +16,8 @@ struct DetailView: View {
|
||||
@State private var openString: String = ""
|
||||
@State private var week: [Date] = []
|
||||
@State private var weeklyHours: [[String]] = []
|
||||
@State private var occupancyLoading: Bool = true
|
||||
@State private var occupancyPercentage: Double = 0.0
|
||||
private let daysOfWeek = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
|
||||
|
||||
private var animation: Animation {
|
||||
@ -75,6 +77,30 @@ struct DetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func getOccupancy() {
|
||||
// Only fetch occupancy data if the location is actually open right now. Otherwise, just exit early and hide the spinner.
|
||||
if location.open == .open || location.open == .closingSoon {
|
||||
DispatchQueue.main.async {
|
||||
getOccupancyPercentage(locationId: location.id) { result in
|
||||
switch result {
|
||||
case .success(let occupancy):
|
||||
DispatchQueue.main.sync {
|
||||
occupancyPercentage = occupancy
|
||||
occupancyLoading = false
|
||||
}
|
||||
case .failure(let error):
|
||||
print(error)
|
||||
DispatchQueue.main.sync {
|
||||
occupancyLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
occupancyLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if isLoading {
|
||||
VStack {
|
||||
@ -148,6 +174,24 @@ struct DetailView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
HStack(spacing: 0) {
|
||||
ForEach(Range(1...5), id: \.self) { index in
|
||||
if occupancyPercentage > (20 * Double(index)) {
|
||||
Image(systemName: "person.fill")
|
||||
} else {
|
||||
Image(systemName: "person")
|
||||
}
|
||||
}
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.frame(width: 18, height: 18)
|
||||
.opacity(occupancyLoading ? 1 : 0)
|
||||
.onAppear {
|
||||
getOccupancy()
|
||||
}
|
||||
}
|
||||
.foregroundStyle(Color.accent.opacity(occupancyLoading ? 0.5 : 1.0))
|
||||
.font(.title3)
|
||||
.padding(.bottom, 12)
|
||||
if let visitingChefs = location.visitingChefs, !visitingChefs.isEmpty {
|
||||
VStack(alignment: .leading) {
|
||||
|
Loading…
x
Reference in New Issue
Block a user