Added weekend food truck information

You can now see the list of food trucks that are coming to RIT on the weekends and their hours. This data is scraped directly from the RIT Events website which means that accessing it isn't the best, but it works. The code behind it is really bad right now, but it works as expected currently and will be improved soon™️
This commit is contained in:
Campbell 2025-10-06 00:37:59 -04:00
parent dba5511ed5
commit dec8788276
Signed by: NinjaCheetah
GPG Key ID: 39C2500E1778B156
7 changed files with 287 additions and 4 deletions

View File

@ -6,6 +6,10 @@
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
371FE8FE2E937040005A6BBD /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = 371FE8FD2E937040005A6BBD /* SwiftSoup */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
376AE05B2E6495EB00AB698B /* RIT Dining.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "RIT Dining.app"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
@ -23,6 +27,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
371FE8FE2E937040005A6BBD /* SwiftSoup in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -65,6 +70,7 @@
);
name = "RIT Dining";
packageProductDependencies = (
371FE8FD2E937040005A6BBD /* SwiftSoup */,
);
productName = "RIT Dining";
productReference = 376AE05B2E6495EB00AB698B /* RIT Dining.app */;
@ -95,6 +101,7 @@
mainGroup = 376AE0522E6495EB00AB698B;
minimizedProjectReferenceProxies = 1;
packageReferences = (
371FE8FC2E937040005A6BBD /* XCRemoteSwiftPackageReference "SwiftSoup" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 376AE05C2E6495EB00AB698B /* Products */;
@ -258,7 +265,7 @@
CODE_SIGN_ENTITLEMENTS = "RIT Dining/RIT Dining.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 14;
CURRENT_PROJECT_VERSION = 15;
DEVELOPMENT_TEAM = 5GF7GKNTK4;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -293,7 +300,7 @@
CODE_SIGN_ENTITLEMENTS = "RIT Dining/RIT Dining.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 14;
CURRENT_PROJECT_VERSION = 15;
DEVELOPMENT_TEAM = 5GF7GKNTK4;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -342,6 +349,25 @@
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
371FE8FC2E937040005A6BBD /* XCRemoteSwiftPackageReference "SwiftSoup" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/scinfu/SwiftSoup.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.11.1;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
371FE8FD2E937040005A6BBD /* SwiftSoup */ = {
isa = XCSwiftPackageProductDependency;
package = 371FE8FC2E937040005A6BBD /* XCRemoteSwiftPackageReference "SwiftSoup" */;
productName = SwiftSoup;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 376AE0532E6495EB00AB698B /* Project object */;
}

View File

@ -6,6 +6,7 @@
//
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
@ -257,6 +258,8 @@ func parseLocationInfo(location: DiningLocationParser, forDate: Date?) -> Dining
}
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 {
@ -272,5 +275,114 @@ extension DiningLocation {
} 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
}
}
}
}
}
// 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

@ -0,0 +1,10 @@
//
// PushScheduler.swift
// RIT Dining
//
// Created by Campbell on 10/3/25.
//
import Foundation

View File

@ -96,3 +96,22 @@ func getOccupancyPercentage(mdoId: Int) async -> Result<Double, Error> {
return .failure(error)
}
}
func getFoodTruckPage() async -> Result<String, Error> {
let urlString = "https://www.rit.edu/events/weekend-food-trucks"
guard let url = URL(string: urlString) else {
return .failure(URLError(.badURL))
}
do {
let contents = try String(contentsOf: url)
let scheduleRegex = /<div class=\".*?field--name-field-event-description.*?\">([\s\S]*?)<\/div>/
if let match = contents.firstMatch(of: scheduleRegex) {
return .success(String(match.0))
}
return .success(contents)
} catch {
return .failure(error)
}
}

View File

@ -98,6 +98,9 @@ struct ContentView: View {
NavigationLink(destination: VisitingChefs()) {
Text("Upcoming Visiting Chefs")
}
NavigationLink(destination: FoodTruckView()) {
Text("Weekend Food Trucks")
}
})
Section(content: {
LocationList(

View File

@ -79,7 +79,7 @@ struct VisitingChef: Equatable, Hashable {
let description: String
var openTime: Date
var closeTime: Date
let status: VisitingChefStatus
var status: VisitingChefStatus
}
// A daily special at a location.
@ -99,7 +99,7 @@ struct DiningLocation: Identifiable, Hashable {
let date: Date
let diningTimes: [DiningTimes]?
var open: OpenStatus
let visitingChefs: [VisitingChef]?
var visitingChefs: [VisitingChef]?
let dailySpecials: [DailySpecial]?
}
@ -129,3 +129,12 @@ struct WeeklyHours: Hashable {
let date: Date
let timeStrings: [String]
}
// A weekend food trucks even representing when it's happening and what food trucks will be there.
struct FoodTruckEvent: Hashable {
let date: Date
let openTime: Date
let closeTime: Date
let location: String
let trucks: [String]
}

View File

@ -0,0 +1,104 @@
//
// FoodTruckView.swift
// RIT Dining
//
// Created by Campbell on 10/5/25.
//
import SwiftUI
import SafariServices
struct FoodTruckView: View {
@State private var foodTruckEvents: [FoodTruckEvent] = []
@State private var isLoading: Bool = true
@State private var loadFailed: Bool = false
@State private var rotationDegrees: Double = 0
@State private var showingSafari: Bool = false
private var animation: Animation {
.linear
.speed(0.1)
.repeatForever(autoreverses: false)
}
private func doFoodTruckStuff() async {
switch await getFoodTruckPage() {
case .success(let schedule):
foodTruckEvents = parseWeekendFoodTrucks(htmlString: schedule)
isLoading = false
case .failure(let error):
print(error)
loadFailed = true
}
}
var body: some View {
if isLoading {
VStack {
if loadFailed {
Image(systemName: "wifi.exclamationmark.circle")
.resizable()
.frame(width: 75, height: 75)
.foregroundStyle(.accent)
Text("An error occurred while fetching food truck data. Please check your network connection and try again.")
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
} else {
Image(systemName: "truck.box")
.resizable()
.frame(width: 75, height: 75)
.foregroundStyle(.accent)
.rotationEffect(.degrees(rotationDegrees))
.onAppear {
withAnimation(animation) {
rotationDegrees = 360.0
}
}
Text("One moment...")
.foregroundStyle(.secondary)
}
}
.task {
await doFoodTruckStuff()
}
.padding()
} else {
ScrollView {
VStack(alignment: .leading) {
HStack {
Text("Weekend Food Trucks")
.font(.title)
.fontWeight(.semibold)
Spacer()
Button(action: {
showingSafari = true
}) {
Image(systemName: "network")
.foregroundStyle(.accent)
.font(.title3)
}
}
ForEach(foodTruckEvents, id: \.self) { event in
Divider()
Text(visitingChefDateDisplay.string(from: event.date))
.font(.title2)
.fontWeight(.semibold)
Text("\(dateDisplay.string(from: event.openTime)) - \(dateDisplay.string(from: event.closeTime))")
.font(.title3)
ForEach(event.trucks, id: \.self) { truck in
Text(truck)
}
Spacer()
}
Spacer()
Text("Food truck data is sourced directly from the RIT Events website, and may not be presented correctly. Use the button in the top right to access the RIT Events website directly to see the original source of the information.")
.foregroundStyle(.secondary)
}
.padding(.horizontal, 8)
}
.sheet(isPresented: $showingSafari) {
SafariView(url: URL(string: "https://www.rit.edu/events/weekend-food-trucks")!)
}
}
}
}