Added dining location menus from FD MealPlanner API

Dining locations that also exist on FD MealPlanner now have a "View Menu" button under the favorite/OnDemand/map buttons that take you to a new view that pulls the location's menu from FD MealPlanner. You can view all of the menu items that have actually been added to that site, and tap them for more details (which will be expanded on later). Searching the item list is supported, with more filtering options coming in the next update. Meal periods can be browsed using the clock button in the top right for locations that are open more than once per day.
Other changes:
- App renamed from "RIT Dining" to "TigerDine" to not get me in trouble for an App Store release
- Slightly changed the way that dining locations' short descriptions and current open times are displayed in the detail view
- Fixed the box truck icon used in the food truck view being squished
This commit is contained in:
2025-11-08 22:33:20 -05:00
parent 2c512180d9
commit c7639de06b
17 changed files with 785 additions and 198 deletions

View File

@@ -0,0 +1,52 @@
//
// FDMealPlannerParsers.swift
// RIT Dining
//
// Created by Campbell on 11/3/25.
//
import Foundation
func parseFDMealPlannerMenu(menu: FDMealsParser) -> [FDMenuItem] {
var menuItems: [FDMenuItem] = []
if menu.result.isEmpty {
return menuItems
}
// We only need to operate on index 0, because the request code is designed to only get the menu for a single day so there
// will only be a single index to operate on.
if let allMenuRecipes = menu.result[0].allMenuRecipes {
for recipe in allMenuRecipes {
// englishAlternateName holds the proper name of the item, but it's blank for some items for some reason. If that's the
// case, then we should fall back on componentName, which is less user-friendly but works as a backup.
let realName = if recipe.englishAlternateName != "" {
recipe.englishAlternateName
} else {
recipe.componentName
}
let allergens = recipe.allergenName.components(separatedBy: ",")
// Get the list of dietary markers (Vegan, Vegetarian, Pork, Beef), and drop "Vegetarian" if "Vegan" is also included since
// that's kinda redundant.
var dietaryMarkers = recipe.recipeProductDietaryName != "" ? recipe.recipeProductDietaryName.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) } : []
if dietaryMarkers.contains("Vegan") {
dietaryMarkers.remove(at: dietaryMarkers.firstIndex(of: "Vegetarian")!)
}
let calories = Int(Double(recipe.calories)!.rounded())
let newItem = FDMenuItem(
id: recipe.componentId,
name: realName,
exactName: recipe.componentName,
category: recipe.category,
allergens: allergens,
calories: calories,
dietaryMarkers: dietaryMarkers,
ingredients: recipe.ingredientStatement,
price: recipe.sellingPrice,
servingSize: recipe.productMeasuringSize,
servingSizeUnit: recipe.productMeasuringSizeUnit
)
menuItems.append(newItem)
}
}
return menuItems
}

View File

@@ -0,0 +1,97 @@
//
// FoodTruckParsers.swift
// RIT Dining
//
// Created by Campbell on 11/3/25.
//
import Foundation
import SwiftSoup
// This code is actually miserable and might break sometimes. Sorry. Parse the HTML of the RIT food trucks web page and build
// a list of food trucks that are going to be there the next time they're there. This is not a good way to get this data but it's
// unfortunately the best way that I think I could make it happen. Sorry again for both my later self and anyone else who tries to
// work on this code.
func parseWeekendFoodTrucks(htmlString: String) -> [FoodTruckEvent] {
do {
let doc = try SwiftSoup.parse(htmlString)
var events: [FoodTruckEvent] = []
let now = Date()
let calendar = Calendar.current
let paragraphs = try doc.select("p:has(strong)")
for p in paragraphs {
let text = try p.text()
let parts = text.components(separatedBy: .whitespaces).joined(separator: " ")
let dateRegex = /(?:(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday),\s+[A-Za-z]+\s+\d+)/
let date = parts.firstMatch(of: dateRegex).map { String($0.0) } ?? ""
if date.isEmpty { continue }
let timeRegex = /(\d{1,2}(:\d{2})?\s*[-]\s*\d{1,2}(:\d{2})?\s*p\.m\.)/
let time = parts.firstMatch(of: timeRegex).map { String($0.0) } ?? ""
let locationRegex = /A-Z Lot/
let location = parts.firstMatch(of: locationRegex).map { String($0.0) } ?? ""
let year = Calendar.current.component(.year, from: Date())
let fullDateString = "\(date) \(year)"
let formatter = DateFormatter()
formatter.dateFormat = "EEEE, MMMM d yyyy"
formatter.locale = Locale(identifier: "en_US_POSIX")
let dateParsed = formatter.date(from: fullDateString) ?? now
let timeStrings = time.split(separator: "-", maxSplits: 1)
print("raw open range: \(timeStrings)")
var openTime = Date()
var 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)!
}
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 closeStringComponents = closeString.split(separator: ":", maxSplits: 1)
let closeTimeComponents = DateComponents(
hour: Int(closeStringComponents[0])! + 12,
minute: closeStringComponents.count > 1 ? Int(closeStringComponents[1]) : 0,
second: 0)
closeTime = calendar.date(
bySettingHour: closeTimeComponents.hour!,
minute: closeTimeComponents.minute!,
second: closeTimeComponents.second!,
of: now)!
}
if let ul = try p.nextElementSibling(), ul.tagName() == "ul" {
let trucks = try ul.select("li").array().map { try $0.text() }
events.append(FoodTruckEvent(
date: dateParsed,
openTime: openTime,
closeTime: closeTime,
location: location,
trucks: trucks
))
print(events)
}
}
return events
} catch {
print(error)
return []
}
}

View File

@@ -16,7 +16,7 @@ enum InvalidHTTPError: Error {
// Get information for all dining locations.
func getAllDiningInfo(date: String?) async -> Result<DiningLocationsParser, Error> {
// The endpoint requires that you specify a date, so get today's.
let dateString: String = date ?? getAPIFriendlyDateString(date: Date())
let dateString: String = date ?? getTCAPIFriendlyDateString(date: Date())
let urlString = "https://tigercenter.rit.edu/tigerCenterApi/tc/dining-all?date=\(dateString)"
guard let url = URL(string: urlString) else {
@@ -41,7 +41,7 @@ func getAllDiningInfo(date: String?) async -> Result<DiningLocationsParser, Erro
// Get information for just one dining location based on its location ID.
func getSingleDiningInfo(date: String?, locId: Int) async -> Result<DiningLocationParser, Error> {
// The current date and the location ID are required to get information for just one location.
let dateString: String = date ?? getAPIFriendlyDateString(date: Date())
let dateString: String = date ?? getTCAPIFriendlyDateString(date: Date())
let urlString = "https://tigercenter.rit.edu/tigerCenterApi/tc/dining-single?date=\(dateString)&locId=\(locId)"
print("making request to \(urlString)")
@@ -97,6 +97,7 @@ func getOccupancyPercentage(mdoId: Int) async -> Result<Double, Error> {
}
}
// Get the RIT Events food truck page and strip it down to just the part of the HTML that we'll need to parse with SwiftSoup.
func getFoodTruckPage() async -> Result<String, Error> {
let urlString = "https://www.rit.edu/events/weekend-food-trucks"
@@ -115,3 +116,72 @@ func getFoodTruckPage() async -> Result<String, Error> {
return .failure(error)
}
}
func searchFDMealPlannerLocations() async -> Result<FDSearchResponseParser, Error> {
let urlString = "https://locations.fdmealplanner.com/api/v1/location-data-webapi/search-locationByAccount?AccountShortName=RIT&pageIndex=1&pageSize=0"
guard let url = URL(string: urlString) else {
return .failure(URLError(.badURL))
}
do {
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
return .failure(InvalidHTTPError.invalid)
}
let decoded = try JSONDecoder().decode(FDSearchResponseParser.self, from: data)
return .success(decoded)
} catch {
return .failure(error)
}
}
func getFDMealPlannerOpenings(locationId: Int) async -> Result<FDMealPeriodsParser, Error> {
let urlString = "https://apiservicelocatorstenantrit.fdmealplanner.com/api/v1/data-locator-webapi/20/mealPeriods?LocationId=\(locationId)"
print("making request to \(urlString)")
guard let url = URL(string: urlString) else {
return .failure(URLError(.badURL))
}
do {
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
return .failure(InvalidHTTPError.invalid)
}
let decoded = try JSONDecoder().decode(FDMealPeriodsParser.self, from: data)
return .success(decoded)
} catch {
return .failure(error)
}
}
func getFDMealPlannerMenu(locationId: Int, accountId: Int, mealPeriodId: Int) async -> Result<FDMealsParser, Error> {
let dateString = getFDMPAPIFriendlyDateString(date: Date())
let urlString = "https://apiservicelocatorstenantrit.fdmealplanner.com/api/v1/data-locator-webapi/20/meals?menuId=0&accountId=\(accountId)&locationId=\(locationId)&mealPeriodId=\(mealPeriodId)&tenantId=20&monthId=\(Calendar.current.component(.month, from: Date()))&startDate=\(dateString)&endDate=\(dateString)"
print("making request to \(urlString)")
guard let url = URL(string: urlString) else {
return .failure(URLError(.badURL))
}
do {
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
return .failure(InvalidHTTPError.invalid)
}
let decoded = try JSONDecoder().decode(FDMealsParser.self, from: data)
return .success(decoded)
} catch {
return .failure(error)
}
}

View File

@@ -22,7 +22,7 @@ struct SafariView: UIViewControllerRepresentable {
func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {}
}
func getAPIFriendlyDateString(date: Date) -> String {
func getTCAPIFriendlyDateString(date: Date) -> String {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .iso8601)
formatter.locale = Locale(identifier: "en_US_POSIX")
@@ -31,6 +31,15 @@ func getAPIFriendlyDateString(date: Date) -> String {
return formatter.string(from: date)
}
func getFDMPAPIFriendlyDateString(date: Date) -> String {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .iso8601)
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone.current
formatter.dateFormat = "yyyy/MM/dd"
return formatter.string(from: date)
}
// The common date formatter that I'm using everywhere that open periods are shown within the app.
let dateDisplay: DateFormatter = {
let display = DateFormatter()

View File

@@ -1,12 +1,11 @@
//
// Parsers.swift
// TigerCenterParsers.swift
// RIT Dining
//
// Created by Campbell on 9/19/25.
//
import Foundation
import SwiftSoup
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
@@ -38,11 +37,22 @@ func parseLocationInfo(location: DiningLocationParser, forDate: Date?) -> Dining
// 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
}
// 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,
@@ -88,6 +98,7 @@ func parseLocationInfo(location: DiningLocationParser, forDate: Date?) -> Dining
return DiningLocation(
id: location.id,
mdoId: location.mdoId,
fdmpIds: fdmpIds,
name: location.name,
summary: location.summary,
desc: desc,
@@ -246,6 +257,7 @@ func parseLocationInfo(location: DiningLocationParser, forDate: Date?) -> Dining
return DiningLocation(
id: location.id,
mdoId: location.mdoId,
fdmpIds: fdmpIds,
name: location.name,
summary: location.summary,
desc: desc,
@@ -298,91 +310,3 @@ extension DiningLocation {
}
}
}
// This code is actually miserable and might break sometimes. Sorry. Parse the HTML of the RIT food trucks web page and build
// a list of food trucks that are going to be there the next time they're there. This is not a good way to get this data but it's
// unfortunately the best way that I think I could make it happen. Sorry again for both my later self and anyone else who tries to
// work on this code.
func parseWeekendFoodTrucks(htmlString: String) -> [FoodTruckEvent] {
do {
let doc = try SwiftSoup.parse(htmlString)
var events: [FoodTruckEvent] = []
let now = Date()
let calendar = Calendar.current
let paragraphs = try doc.select("p:has(strong)")
for p in paragraphs {
let text = try p.text()
let parts = text.components(separatedBy: .whitespaces).joined(separator: " ")
let dateRegex = /(?:(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday),\s+[A-Za-z]+\s+\d+)/
let date = parts.firstMatch(of: dateRegex).map { String($0.0) } ?? ""
if date.isEmpty { continue }
let timeRegex = /(\d{1,2}(:\d{2})?\s*[-]\s*\d{1,2}(:\d{2})?\s*p\.m\.)/
let time = parts.firstMatch(of: timeRegex).map { String($0.0) } ?? ""
let locationRegex = /A-Z Lot/
let location = parts.firstMatch(of: locationRegex).map { String($0.0) } ?? ""
let year = Calendar.current.component(.year, from: Date())
let fullDateString = "\(date) \(year)"
let formatter = DateFormatter()
formatter.dateFormat = "EEEE, MMMM d yyyy"
formatter.locale = Locale(identifier: "en_US_POSIX")
let dateParsed = formatter.date(from: fullDateString) ?? now
let timeStrings = time.split(separator: "-", maxSplits: 1)
print("raw open range: \(timeStrings)")
var openTime = Date()
var 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)!
}
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 closeStringComponents = closeString.split(separator: ":", maxSplits: 1)
let closeTimeComponents = DateComponents(
hour: Int(closeStringComponents[0])! + 12,
minute: closeStringComponents.count > 1 ? Int(closeStringComponents[1]) : 0,
second: 0)
closeTime = calendar.date(
bySettingHour: closeTimeComponents.hour!,
minute: closeTimeComponents.minute!,
second: closeTimeComponents.second!,
of: now)!
}
if let ul = try p.nextElementSibling(), ul.tagName() == "ul" {
let trucks = try ul.select("li").array().map { try $0.text() }
events.append(FoodTruckEvent(
date: dateParsed,
openTime: openTime,
closeTime: closeTime,
location: location,
trucks: trucks
))
print(events)
}
}
return events
} catch {
print(error)
return []
}
}