mirror of
https://github.com/NinjaCheetah/RIT-Dining.git
synced 2025-10-19 06:36:18 -04:00
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:
parent
dba5511ed5
commit
dec8788276
@ -6,6 +6,10 @@
|
|||||||
objectVersion = 77;
|
objectVersion = 77;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
371FE8FE2E937040005A6BBD /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = 371FE8FD2E937040005A6BBD /* SwiftSoup */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
376AE05B2E6495EB00AB698B /* RIT Dining.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "RIT Dining.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
376AE05B2E6495EB00AB698B /* RIT Dining.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "RIT Dining.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
@ -23,6 +27,7 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
371FE8FE2E937040005A6BBD /* SwiftSoup in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@ -65,6 +70,7 @@
|
|||||||
);
|
);
|
||||||
name = "RIT Dining";
|
name = "RIT Dining";
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
|
371FE8FD2E937040005A6BBD /* SwiftSoup */,
|
||||||
);
|
);
|
||||||
productName = "RIT Dining";
|
productName = "RIT Dining";
|
||||||
productReference = 376AE05B2E6495EB00AB698B /* RIT Dining.app */;
|
productReference = 376AE05B2E6495EB00AB698B /* RIT Dining.app */;
|
||||||
@ -95,6 +101,7 @@
|
|||||||
mainGroup = 376AE0522E6495EB00AB698B;
|
mainGroup = 376AE0522E6495EB00AB698B;
|
||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
|
371FE8FC2E937040005A6BBD /* XCRemoteSwiftPackageReference "SwiftSoup" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = 376AE05C2E6495EB00AB698B /* Products */;
|
productRefGroup = 376AE05C2E6495EB00AB698B /* Products */;
|
||||||
@ -258,7 +265,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = "RIT Dining/RIT Dining.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "RIT Dining/RIT Dining.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 14;
|
CURRENT_PROJECT_VERSION = 15;
|
||||||
DEVELOPMENT_TEAM = 5GF7GKNTK4;
|
DEVELOPMENT_TEAM = 5GF7GKNTK4;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@ -293,7 +300,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = "RIT Dining/RIT Dining.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "RIT Dining/RIT Dining.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 14;
|
CURRENT_PROJECT_VERSION = 15;
|
||||||
DEVELOPMENT_TEAM = 5GF7GKNTK4;
|
DEVELOPMENT_TEAM = 5GF7GKNTK4;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@ -342,6 +349,25 @@
|
|||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
/* End XCConfigurationList section */
|
/* 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 */;
|
rootObject = 376AE0532E6495EB00AB698B /* Project object */;
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import SwiftSoup
|
||||||
|
|
||||||
func parseOpenStatus(openTime: Date, closeTime: Date) -> OpenStatus {
|
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
|
// 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 {
|
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() {
|
mutating func updateOpenStatus() {
|
||||||
var openStatus: OpenStatus = .closed
|
var openStatus: OpenStatus = .closed
|
||||||
if let diningTimes = diningTimes, !diningTimes.isEmpty {
|
if let diningTimes = diningTimes, !diningTimes.isEmpty {
|
||||||
@ -272,5 +275,114 @@ extension DiningLocation {
|
|||||||
} else {
|
} else {
|
||||||
self.open = .closed
|
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 []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
10
RIT Dining/Components/PushScheduler.swift
Normal file
10
RIT Dining/Components/PushScheduler.swift
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
//
|
||||||
|
// PushScheduler.swift
|
||||||
|
// RIT Dining
|
||||||
|
//
|
||||||
|
// Created by Campbell on 10/3/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
|
@ -96,3 +96,22 @@ func getOccupancyPercentage(mdoId: Int) async -> Result<Double, Error> {
|
|||||||
return .failure(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -98,6 +98,9 @@ struct ContentView: View {
|
|||||||
NavigationLink(destination: VisitingChefs()) {
|
NavigationLink(destination: VisitingChefs()) {
|
||||||
Text("Upcoming Visiting Chefs")
|
Text("Upcoming Visiting Chefs")
|
||||||
}
|
}
|
||||||
|
NavigationLink(destination: FoodTruckView()) {
|
||||||
|
Text("Weekend Food Trucks")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
Section(content: {
|
Section(content: {
|
||||||
LocationList(
|
LocationList(
|
||||||
|
@ -79,7 +79,7 @@ struct VisitingChef: Equatable, Hashable {
|
|||||||
let description: String
|
let description: String
|
||||||
var openTime: Date
|
var openTime: Date
|
||||||
var closeTime: Date
|
var closeTime: Date
|
||||||
let status: VisitingChefStatus
|
var status: VisitingChefStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
// A daily special at a location.
|
// A daily special at a location.
|
||||||
@ -99,7 +99,7 @@ struct DiningLocation: Identifiable, Hashable {
|
|||||||
let date: Date
|
let date: Date
|
||||||
let diningTimes: [DiningTimes]?
|
let diningTimes: [DiningTimes]?
|
||||||
var open: OpenStatus
|
var open: OpenStatus
|
||||||
let visitingChefs: [VisitingChef]?
|
var visitingChefs: [VisitingChef]?
|
||||||
let dailySpecials: [DailySpecial]?
|
let dailySpecials: [DailySpecial]?
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -129,3 +129,12 @@ struct WeeklyHours: Hashable {
|
|||||||
let date: Date
|
let date: Date
|
||||||
let timeStrings: [String]
|
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]
|
||||||
|
}
|
||||||
|
104
RIT Dining/Views/FoodTruckView.swift
Normal file
104
RIT Dining/Views/FoodTruckView.swift
Normal 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")!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user