diff --git a/App/AppContainer.swift b/App/AppContainer.swift
index e6ffbb4..fa33554 100644
--- a/App/AppContainer.swift
+++ b/App/AppContainer.swift
@@ -56,7 +56,7 @@ final class AppContainer {
studIPService = studIPClient
studIPAnnouncementService = studIPClient
studIPScheduleService = studIPClient
- canteenService = OpenMensaClient()
+ canteenService = StudierendenwerkOldenburgClient()
if inMemory {
preferences = InMemoryStudyPreferences()
studIPAccountStore = InMemoryStudIPAccountStore()
diff --git a/Data/Mensa/OpenMensaClient.swift b/Data/Mensa/OpenMensaClient.swift
deleted file mode 100644
index c0c359d..0000000
--- a/Data/Mensa/OpenMensaClient.swift
+++ /dev/null
@@ -1,45 +0,0 @@
-import Foundation
-
-/// Loads canteens and menus from the OpenMensa API v2. No account is needed.
-struct OpenMensaClient: CanteenService {
- static let baseURL = URL(string: "https://openmensa.org/api/v2/")!
-
- var session: URLSession = .shared
- var calendar: Calendar = .current
-
- func canteens(nearLatitude latitude: Double, longitude: Double, radius: Double) async throws -> [Canteen] {
- let data = try await get("canteens", query: [
- URLQueryItem(name: "near[lat]", value: String(latitude)),
- URLQueryItem(name: "near[lng]", value: String(longitude)),
- URLQueryItem(name: "near[dist]", value: String(radius)),
- ])
- return try OpenMensaParser.canteens(data)
- }
-
- func menu(canteenID: Int, on date: Date) async throws -> CanteenMenu {
- let day = OpenMensaParser.dayString(date, calendar: calendar)
- let isClosed = try OpenMensaParser.isClosed(try await get("canteens/\(canteenID)/days/\(day)"))
- let meals = isClosed ? [] : try OpenMensaParser.meals(try await get("canteens/\(canteenID)/days/\(day)/meals"))
- return CanteenMenu(canteenID: canteenID, date: date, isClosed: isClosed, meals: meals)
- }
-
- private func get(_ path: String, query: [URLQueryItem] = []) async throws -> Data {
- var components = URLComponents(url: Self.baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false)
- if !query.isEmpty { components?.queryItems = query }
- guard let url = components?.url else { throw MensaError.unavailable }
- var request = URLRequest(url: url)
- request.timeoutInterval = 20
- request.setValue("application/json", forHTTPHeaderField: "Accept")
- let data: Data
- let response: URLResponse
- do {
- (data, response) = try await session.data(for: request)
- } catch {
- throw MensaError.offline
- }
- guard let http = response as? HTTPURLResponse else { throw MensaError.unavailable }
- // OpenMensa answers 404 for days without a published menu.
- guard (200...299).contains(http.statusCode) else { throw MensaError.unavailable }
- return data
- }
-}
diff --git a/Data/Mensa/OpenMensaParser.swift b/Data/Mensa/OpenMensaParser.swift
deleted file mode 100644
index 43ced60..0000000
--- a/Data/Mensa/OpenMensaParser.swift
+++ /dev/null
@@ -1,55 +0,0 @@
-import Foundation
-
-/// Reads OpenMensa API v2 responses (https://openmensa.org/api/v2).
-enum OpenMensaParser {
- private struct CanteenDTO: Decodable {
- let id: Int
- let name: String
- let city: String?
- let address: String?
- }
-
- private struct DayDTO: Decodable {
- let closed: Bool
- }
-
- private struct MealDTO: Decodable {
- struct Prices: Decodable {
- let students: Double?
- }
-
- let id: Int
- let name: String
- let category: String?
- let prices: Prices?
- let notes: [String]?
- }
-
- static func canteens(_ data: Data) throws -> [Canteen] {
- try JSONDecoder().decode([CanteenDTO].self, from: data).map {
- Canteen(id: $0.id, name: $0.name, city: $0.city ?? "", address: $0.address ?? "")
- }
- }
-
- static func isClosed(_ data: Data) throws -> Bool {
- try JSONDecoder().decode(DayDTO.self, from: data).closed
- }
-
- static func meals(_ data: Data) throws -> [Meal] {
- try JSONDecoder().decode([MealDTO].self, from: data).map {
- Meal(
- id: $0.id,
- name: $0.name,
- category: $0.category ?? "Essen",
- studentPrice: $0.prices?.students,
- notes: $0.notes ?? []
- )
- }
- }
-
- /// OpenMensa addresses days as `yyyy-MM-dd` in the canteen's (German) time zone.
- static func dayString(_ date: Date, calendar: Calendar) -> String {
- let components = calendar.dateComponents([.year, .month, .day], from: date)
- return String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0)
- }
-}
diff --git a/Data/Mensa/StudierendenwerkMenuParser.swift b/Data/Mensa/StudierendenwerkMenuParser.swift
new file mode 100644
index 0000000..a7494c6
--- /dev/null
+++ b/Data/Mensa/StudierendenwerkMenuParser.swift
@@ -0,0 +1,103 @@
+import Foundation
+
+/// Reads the menu HTML of the Studierendenwerk Oldenburg (`func=make_spl` of its menu system).
+///
+/// The page lists a heading per serving counter (`.gruppenname`, e.g. „AUSGABE EINS“, „SUPPEN“)
+/// followed by its meals (`.splMeal`). Each meal shows its name, the prices
+/// „Studierende / Bedienstete / Gäste“ and icons such as „vegan“. A closed day is a single `.nodata` row.
+enum StudierendenwerkMenuParser {
+ /// Heading for meals listed before the first counter heading.
+ static let defaultCategory = "Gerichte"
+
+ static func isClosed(_ html: String) -> Bool {
+ html.contains("class='row nodata'") || html.contains("class=\"row nodata\"")
+ }
+
+ static func meals(_ html: String) -> [Meal] {
+ var meals: [Meal] = []
+ var category = defaultCategory
+ // Headings and meals in page order; each meal's HTML runs up to the next marker.
+ let markers = matches(of: #"col-xs-4 gruppenname'>([^<]*)<|
String? {
+ guard let raw = matches(of: #"
(.*?)"#, in: block).first?.group(1),
+ let amount = matches(of: #"(\d+),(\d{2})"#, in: prices).first,
+ let euros = amount.group(1), let cents = amount.group(2) else { return nil }
+ return Double("\(euros).\(cents)")
+ }
+
+ /// Icon titles such as „vegan“ or „mit Rindfleisch“, each once.
+ static func notes(in block: String) -> [String] {
+ var notes: [String] = []
+ for match in matches(of: #"class='iconLarge' title='([^']*)'"#, in: block) {
+ guard let title = match.group(1).map(plainText), !title.isEmpty, !notes.contains(title) else { continue }
+ notes.append(title)
+ }
+ return notes
+ }
+
+ /// „AUSGABE EINS“ → „Ausgabe Eins“; an empty heading keeps the default.
+ static func categoryTitle(_ heading: String) -> String {
+ let text = plainText(heading)
+ return text.isEmpty ? defaultCategory : text.lowercased().capitalized
+ }
+
+ // MARK: Helpers
+
+ private static func plainText(_ html: String) -> String {
+ var text = html.replacingOccurrences(of: #"<[^>]+>"#, with: "", options: .regularExpression)
+ let entities = [" ": " ", "&": "&", "€": "€", """: "\"", "'": "'", "<": "<", ">": ">"]
+ for (entity, replacement) in entities {
+ text = text.replacingOccurrences(of: entity, with: replacement)
+ }
+ return text.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ private struct Match {
+ let range: Range
+ let groups: [String?]
+
+ func group(_ index: Int) -> String? { index <= groups.count ? groups[index - 1] : nil }
+ }
+
+ private static func matches(of pattern: String, in text: String) -> [Match] {
+ guard let regex = try? NSRegularExpression(pattern: pattern, options: [.dotMatchesLineSeparators]) else { return [] }
+ return regex.matches(in: text, range: NSRange(text.startIndex..., in: text)).compactMap { result in
+ guard let range = Range(result.range, in: text) else { return nil }
+ let groups = (1.. [Canteen] {
+ Self.universityCanteens
+ }
+
+ func menu(canteenID: Int, on date: Date) async throws -> CanteenMenu {
+ var request = URLRequest(url: Self.endpoint)
+ request.httpMethod = "POST"
+ request.timeoutInterval = 20
+ request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
+ request.httpBody = Data(Self.formBody(locationID: canteenID, date: date, calendar: calendar).utf8)
+ let data: Data
+ let response: URLResponse
+ do {
+ (data, response) = try await session.data(for: request)
+ } catch {
+ throw MensaError.offline
+ }
+ guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode),
+ let html = String(data: data, encoding: .utf8) ?? String(data: data, encoding: .isoLatin1)
+ else { throw MensaError.unavailable }
+ let isClosed = StudierendenwerkMenuParser.isClosed(html)
+ let meals = isClosed ? [] : StudierendenwerkMenuParser.meals(html)
+ return CanteenMenu(canteenID: canteenID, date: date, isClosed: isClosed, meals: meals)
+ }
+
+ /// The menu system also needs the Mondays of the day's week and of the following week.
+ static func formBody(locationID: Int, date: Date, calendar: Calendar) -> String {
+ let monday = weekStart(of: date, calendar: calendar)
+ let nextMonday = calendar.date(byAdding: .day, value: 7, to: monday) ?? monday
+ return [
+ "func=make_spl",
+ "locId=\(locationID)",
+ "date=\(dayString(date, calendar: calendar))",
+ "lang=de",
+ "startThisWeek=\(dayString(monday, calendar: calendar))",
+ "startNextWeek=\(dayString(nextMonday, calendar: calendar))",
+ ].joined(separator: "&")
+ }
+
+ /// Monday of the week containing `date`, also for Sundays.
+ static func weekStart(of date: Date, calendar: Calendar) -> Date {
+ var mondayFirst = calendar
+ mondayFirst.firstWeekday = 2
+ return mondayFirst.dateInterval(of: .weekOfYear, for: date)?.start ?? calendar.startOfDay(for: date)
+ }
+
+ /// `yyyy-MM-dd` in the canteen's (German) time zone.
+ static func dayString(_ date: Date, calendar: Calendar) -> String {
+ let components = calendar.dateComponents([.year, .month, .day], from: date)
+ return String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0)
+ }
+}
diff --git a/Domain/Entities/Mensa.swift b/Domain/Entities/Mensa.swift
index 2a6c24b..df2c788 100644
--- a/Domain/Entities/Mensa.swift
+++ b/Domain/Entities/Mensa.swift
@@ -1,6 +1,6 @@
import Foundation
-/// A canteen listed by OpenMensa.
+/// A canteen whose menu the app can show.
struct Canteen: Identifiable, Hashable, Codable, Sendable {
let id: Int
var name: String
diff --git a/Domain/Repositories/CanteenService.swift b/Domain/Repositories/CanteenService.swift
index 155dd78..43914b4 100644
--- a/Domain/Repositories/CanteenService.swift
+++ b/Domain/Repositories/CanteenService.swift
@@ -1,10 +1,10 @@
import Foundation
-/// Port to a canteen menu source (OpenMensa).
+/// Port to a canteen menu source (the Studierendenwerk Oldenburg).
@MainActor
protocol CanteenService {
- /// Canteens within `radius` kilometres of a coordinate.
- func canteens(nearLatitude latitude: Double, longitude: Double, radius: Double) async throws -> [Canteen]
+ /// The canteens the source offers menus for, the default one first.
+ func canteens() async throws -> [Canteen]
/// The menu of a canteen on a day. Throws `MensaError.unavailable` when the canteen publishes nothing for it.
func menu(canteenID: Int, on date: Date) async throws -> CanteenMenu
}
diff --git a/Domain/Repositories/StudyPreferences.swift b/Domain/Repositories/StudyPreferences.swift
index 7a31a52..ad15f48 100644
--- a/Domain/Repositories/StudyPreferences.swift
+++ b/Domain/Repositories/StudyPreferences.swift
@@ -20,7 +20,7 @@ protocol StudySettingsStore: AnyObject {
var automaticBackups: Bool { get set }
/// Import the dates of linked Stud.IP courses into the calendar during a Stud.IP sync.
var importsStudIPTimetable: Bool { get set }
- /// OpenMensa canteen shown on „Heute“; `nil` hides the menu.
+ /// Canteen shown on „Heute“; `nil` hides the menu.
var canteenID: Int? { get set }
var notificationSettings: NotificationSettings { get set }
var studyTimerSettings: StudyTimerSettings { get set }
diff --git a/Domain/UseCases/MensaUseCases.swift b/Domain/UseCases/MensaUseCases.swift
index d3e0c7d..7a23b37 100644
--- a/Domain/UseCases/MensaUseCases.swift
+++ b/Domain/UseCases/MensaUseCases.swift
@@ -12,14 +12,6 @@ enum MensaSchedule {
}
}
-/// Canteens of the Studierendenwerk Oldenburg area, nearest first.
-enum MensaLocation {
- /// University of Oldenburg, campus Haarentor.
- static let oldenburgLatitude = 53.1478
- static let oldenburgLongitude = 8.1814
- static let searchRadius = 15.0
-}
-
/// Loads a menu and keeps it for the rest of the day, so „Heute“ does not ask the server on every visit.
@MainActor
final class LoadCanteenMenuUseCase {
diff --git a/Presentation/Mensa/MensaCard.swift b/Presentation/Mensa/MensaCard.swift
index 60927a1..17ce63b 100644
--- a/Presentation/Mensa/MensaCard.swift
+++ b/Presentation/Mensa/MensaCard.swift
@@ -106,7 +106,7 @@ struct MensaSettingsSection: View {
} header: {
Text("Mensa")
} footer: {
- Text("Der Speiseplan kommt von OpenMensa. Nach dem Mittagessen zeigt „Heute“ schon den Plan für morgen.")
+ Text("Der Speiseplan kommt vom Studierendenwerk Oldenburg. Nach dem Mittagessen zeigt „Heute“ schon den Plan für morgen.")
}
.task { await viewModel.loadCanteens() }
}
diff --git a/Presentation/Mensa/MensaViewModel.swift b/Presentation/Mensa/MensaViewModel.swift
index 476637e..f002882 100644
--- a/Presentation/Mensa/MensaViewModel.swift
+++ b/Presentation/Mensa/MensaViewModel.swift
@@ -53,6 +53,7 @@ final class MensaViewModel {
}
func refresh() async {
+ await loadCanteens()
guard let canteenID else {
state = .off
return
@@ -68,16 +69,19 @@ final class MensaViewModel {
}
}
- /// Canteens around Oldenburg for the settings, nearest first.
+ /// The canteens for the settings, the default one first.
func loadCanteens() async {
guard canteens.isEmpty, !isLoadingCanteens else { return }
isLoadingCanteens = true
defer { isLoadingCanteens = false }
- canteens = (try? await service.canteens(
- nearLatitude: MensaLocation.oldenburgLatitude,
- longitude: MensaLocation.oldenburgLongitude,
- radius: MensaLocation.searchRadius
- )) ?? []
+ canteens = (try? await service.canteens()) ?? []
+ adoptSupportedCanteen()
+ }
+
+ /// A canteen chosen from an earlier source (OpenMensa) is no longer offered; the default one replaces it.
+ private func adoptSupportedCanteen() {
+ guard let canteenID, let fallback = canteens.first, !canteens.contains(where: { $0.id == canteenID }) else { return }
+ settings.canteenID = fallback.id
}
static func priceText(_ price: Double?) -> String? {
diff --git a/StudyAppTests/MensaTests.swift b/StudyAppTests/MensaTests.swift
index 9d28e74..140bacc 100644
--- a/StudyAppTests/MensaTests.swift
+++ b/StudyAppTests/MensaTests.swift
@@ -2,31 +2,21 @@ import Foundation
import Testing
@testable import StudyApp
-struct OpenMensaParserTests {
- @Test func readsCanteens() throws {
- let json = #"[{"id":1084,"name":"Oldenburg, Mensa Uhlhornsweg","city":"Oldenburg","address":"Uhlhornsweg 49-55, 26129 Oldenburg","coordinates":[53.14,8.18]}]"#
- let canteens = try OpenMensaParser.canteens(Data(json.utf8))
- #expect(canteens == [Canteen(id: 1084, name: "Oldenburg, Mensa Uhlhornsweg", city: "Oldenburg", address: "Uhlhornsweg 49-55, 26129 Oldenburg")])
- }
-
- @Test func readsMealsWithStudentPriceAndNotes() throws {
- let json = #"""
- [{"id":1,"name":"Spaghetti Bolognese","category":"Pasta","prices":{"students":2.5,"employees":4.1,"pupils":null,"others":5.0},"notes":["Rind"]},
- {"id":2,"name":"Gemüsecurry","category":"Vegan","prices":{"students":null},"notes":[]},
- {"id":3,"name":"Salatbar","prices":null}]
- """#
- let meals = try OpenMensaParser.meals(Data(json.utf8))
- #expect(meals.map(\.studentPrice) == [2.5, nil, nil])
- #expect(meals.map(\.category) == ["Pasta", "Vegan", "Essen"])
- #expect(meals.first?.notes == ["Rind"])
+@MainActor
+struct StudierendenwerkOldenburgClientTests {
+ @Test func offersTheUniversityCanteensWithUhlhornswegFirst() {
+ #expect(StudierendenwerkOldenburgClient.universityCanteens.map(\.name) == ["Mensa Uhlhornsweg", "Mensa Wechloy"])
+ #expect(StudierendenwerkOldenburgClient.universityCanteens.map(\.id) == [2, 3])
}
- @Test func readsClosedDaysAndFormatsDates() throws {
- #expect(try OpenMensaParser.isClosed(Data(#"{"date":"2026-10-28","closed":true}"#.utf8)))
+ @Test func asksForTheDayWithTheMondaysOfItsWeekAndTheNext() throws {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = try #require(TimeZone(identifier: "Europe/Berlin"))
- let date = try #require(calendar.date(from: DateComponents(year: 2026, month: 3, day: 9, hour: 23, minute: 30)))
- #expect(OpenMensaParser.dayString(date, calendar: calendar) == "2026-03-09")
+ let sunday = try #require(calendar.date(from: DateComponents(year: 2026, month: 11, day: 1, hour: 23, minute: 30)))
+
+ let body = StudierendenwerkOldenburgClient.formBody(locationID: 3, date: sunday, calendar: calendar)
+
+ #expect(body == "func=make_spl&locId=3&date=2026-11-01&lang=de&startThisWeek=2026-10-26&startNextWeek=2026-11-02")
}
}
@@ -36,7 +26,9 @@ final class FakeCanteenService: CanteenService {
var error: MensaError?
private(set) var requests = 0
- func canteens(nearLatitude latitude: Double, longitude: Double, radius: Double) async throws -> [Canteen] { [] }
+ var canteenList: [Canteen] = []
+
+ func canteens() async throws -> [Canteen] { canteenList }
func menu(canteenID: Int, on date: Date) async throws -> CanteenMenu {
requests += 1
@@ -118,6 +110,22 @@ struct MensaViewModelTests {
#expect(viewModel.state == .unavailable(MensaError.offline.localizedDescription))
}
+ @Test func replacesACanteenThatIsNoLongerOffered() async throws {
+ service.canteenList = [
+ Canteen(id: 2, name: "Mensa Uhlhornsweg", city: "Oldenburg", address: ""),
+ Canteen(id: 3, name: "Mensa Wechloy", city: "Oldenburg", address: ""),
+ ]
+ settings.canteenID = 1084 // chosen from OpenMensa before
+ let viewModel = try makeViewModel()
+
+ await viewModel.refresh()
+ #expect(settings.canteenID == 2)
+
+ settings.canteenID = 3
+ await viewModel.refresh()
+ #expect(settings.canteenID == 3)
+ }
+
@Test func formatsStudentPrices() {
// The currency style separates the amount with a no-break space.
#expect(MensaViewModel.priceText(2.5)?.replacingOccurrences(of: "\u{00A0}", with: " ") == "2,50 €")
diff --git a/StudyAppTests/StudierendenwerkMenuTests.swift b/StudyAppTests/StudierendenwerkMenuTests.swift
new file mode 100644
index 0000000..0a3cc58
--- /dev/null
+++ b/StudyAppTests/StudierendenwerkMenuTests.swift
@@ -0,0 +1,120 @@
+import Foundation
+import Testing
+@testable import StudyApp
+
+@MainActor
+struct StudierendenwerkMenuParserTests {
+ /// Excerpt of the real answer for Mensa Wechloy: meals before the first heading, then „SUPPEN“.
+ static let wechloy = #"""
+
+
+
PREIS STUDIERENDE | BEDIENSTETE | GÄSTE
+
+
+
+
+
Zwiebelquiche(Mi, We)
+
+
+
+
+

+

+
+
+
+
3,30 / 5,20 / 5,40 €
+
+
+
+
+
+
PREIS STUDIERENDE | BEDIENSTETE | GÄSTE
+
3,30 / 5,20 / 5,40 €
+
+
+
+
+
SUPPEN
+
PREIS STUDIERENDE | BEDIENSTETE | GÄSTE
+
+
+
+
+
Mexikanische Maiscremesuppe(2, Kn, Sl)
+
+
+
+
+

+

+
+
+
Mexikanische Maiscremesuppe(2, Kn, Sl)
+
+
0,60 / 1,25 / 1,35 €
+
+
+
+
+
+
PREIS STUDIERENDE | BEDIENSTETE | GÄSTE
+
0,60 / 1,25 / 1,35 €
+
+
+
+
+ """#
+
+ @Test func readsMealsWithCategoryPriceAndNotes() {
+ let meals = StudierendenwerkMenuParser.meals(Self.wechloy)
+
+ #expect(meals.map(\.name) == ["Zwiebelquiche", "Mexikanische Maiscremesuppe"])
+ #expect(meals.map(\.category) == ["Gerichte", "Suppen"])
+ #expect(meals.map(\.studentPrice) == [3.3, 0.6])
+ #expect(meals.map(\.id) == [0, 1])
+ #expect(!StudierendenwerkMenuParser.isClosed(Self.wechloy))
+ }
+
+ @Test func readsCounterHeadingsMaximumPricesAndSeveralIcons() {
+ let html = """
+
+
+
+
Chili con Carne, Fladenbrot(Kn, We)
+
max. 2,50* / 5,00 / 5,20 €
+

\
+

+

+
+ """
+
+ let meal = StudierendenwerkMenuParser.meals(html).first
+
+ #expect(meal?.name == "Chili con Carne, Fladenbrot")
+ #expect(meal?.category == "Ausgabe Drei")
+ #expect(meal?.studentPrice == 2.5)
+ #expect(meal?.notes == ["mit Rindfleisch"])
+ }
+
+ @Test func recognisesClosedDays() {
+ let html = """
+
+
Die Mensa hat an diesem Tag geschlossen.
+ """
+
+ #expect(StudierendenwerkMenuParser.isClosed(html))
+ #expect(StudierendenwerkMenuParser.meals(html).isEmpty)
+ }
+}
diff --git a/docs/FEATURES.md b/docs/FEATURES.md
index 902d000..9c7b738 100644
--- a/docs/FEATURES.md
+++ b/docs/FEATURES.md
@@ -102,9 +102,11 @@ Code: `App/Intents/`, `Domain/UseCases/QuickInfoUseCases.swift`
## Mensa
-Settings → Allgemein → Mensa picks a canteen near the University of Oldenburg (list from OpenMensa, nearest first). „Heute“ then shows its menu grouped by category with the student price; after 15:00 it shows tomorrow's menu. Closed days and missing menus are shown as such; the menu of a day is cached, and the app works without it offline.
+Settings → Allgemein → Mensa picks one of the university's canteens: **Mensa Uhlhornsweg** (Campus Haarentor) or **Mensa Wechloy**. „Heute“ then shows its menu grouped by counter with the student price and hints such as „vegan“; after 15:00 it shows tomorrow's menu. Closed days and missing menus are shown as such; the menu of a day is cached, and the app works without it offline.
-Code: `Domain/UseCases/MensaUseCases.swift`, `Data/Mensa/` (OpenMensa API v2), `Presentation/Mensa/`
+The menu comes from the Studierendenwerk Oldenburg (studierendenwerk-oldenburg.de/essen/speiseplaene-mensen). Its pages embed a menu system that returns a day as HTML (`POST inc/ajax-php_konnektor.inc.php`, `func=make_spl`, location 2 = Uhlhornsweg, 3 = Wechloy); `StudierendenwerkMenuParser` reads counters, meals, prices and icons from it. A canteen chosen from OpenMensa in an earlier version is replaced by Mensa Uhlhornsweg.
+
+Code: `Domain/UseCases/MensaUseCases.swift`, `Data/Mensa/` (Studierendenwerk Oldenburg), `Presentation/Mensa/`
## Spotlight