From dec878827631ef26063f4b449e72ec7f9daf4797 Mon Sep 17 00:00:00 2001 From: NinjaCheetah <58050615+NinjaCheetah@users.noreply.github.com> Date: Mon, 6 Oct 2025 00:37:59 -0400 Subject: [PATCH] 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:tm: --- RIT Dining.xcodeproj/project.pbxproj | 30 +++++- RIT Dining/Components/Parsers.swift | 112 ++++++++++++++++++++++ RIT Dining/Components/PushScheduler.swift | 10 ++ RIT Dining/Components/Requests.swift | 19 ++++ RIT Dining/ContentView.swift | 3 + RIT Dining/Data/Types.swift | 13 ++- RIT Dining/Views/FoodTruckView.swift | 104 ++++++++++++++++++++ 7 files changed, 287 insertions(+), 4 deletions(-) create mode 100644 RIT Dining/Components/PushScheduler.swift create mode 100644 RIT Dining/Views/FoodTruckView.swift diff --git a/RIT Dining.xcodeproj/project.pbxproj b/RIT Dining.xcodeproj/project.pbxproj index 2a0d267..2dab951 100644 --- a/RIT Dining.xcodeproj/project.pbxproj +++ b/RIT Dining.xcodeproj/project.pbxproj @@ -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 */; } diff --git a/RIT Dining/Components/Parsers.swift b/RIT Dining/Components/Parsers.swift index cfcff57..8cb2621 100644 --- a/RIT Dining/Components/Parsers.swift +++ b/RIT Dining/Components/Parsers.swift @@ -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 [] } } diff --git a/RIT Dining/Components/PushScheduler.swift b/RIT Dining/Components/PushScheduler.swift new file mode 100644 index 0000000..15460bc --- /dev/null +++ b/RIT Dining/Components/PushScheduler.swift @@ -0,0 +1,10 @@ +// +// PushScheduler.swift +// RIT Dining +// +// Created by Campbell on 10/3/25. +// + +import Foundation + + diff --git a/RIT Dining/Components/Requests.swift b/RIT Dining/Components/Requests.swift index a6d3ec4..2a79f01 100644 --- a/RIT Dining/Components/Requests.swift +++ b/RIT Dining/Components/Requests.swift @@ -96,3 +96,22 @@ func getOccupancyPercentage(mdoId: Int) async -> Result { return .failure(error) } } + +func getFoodTruckPage() async -> Result { + 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 = /
([\s\S]*?)<\/div>/ + if let match = contents.firstMatch(of: scheduleRegex) { + return .success(String(match.0)) + } + return .success(contents) + } catch { + return .failure(error) + } +} diff --git a/RIT Dining/ContentView.swift b/RIT Dining/ContentView.swift index 3c09f10..7ae18bf 100644 --- a/RIT Dining/ContentView.swift +++ b/RIT Dining/ContentView.swift @@ -98,6 +98,9 @@ struct ContentView: View { NavigationLink(destination: VisitingChefs()) { Text("Upcoming Visiting Chefs") } + NavigationLink(destination: FoodTruckView()) { + Text("Weekend Food Trucks") + } }) Section(content: { LocationList( diff --git a/RIT Dining/Data/Types.swift b/RIT Dining/Data/Types.swift index 785826f..00f370d 100644 --- a/RIT Dining/Data/Types.swift +++ b/RIT Dining/Data/Types.swift @@ -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] +} diff --git a/RIT Dining/Views/FoodTruckView.swift b/RIT Dining/Views/FoodTruckView.swift new file mode 100644 index 0000000..4395307 --- /dev/null +++ b/RIT Dining/Views/FoodTruckView.swift @@ -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")!) + } + } + } +}