diff --git a/umd-dining-api/hours.py b/umd-dining-api/hours.py new file mode 100644 index 0000000..36dc70a --- /dev/null +++ b/umd-dining-api/hours.py @@ -0,0 +1,248 @@ +"""Dining hall hours from UMD Dining's own schedule. + +dining.umd.edu/hours/open-now renders hours client-side from a public Google +Sheet (gviz JSON). Row 0 holds the dates ("venue", "1/1/2026", ...); each later +row is one " | " with cells like "7am-10:30am", "Closed" or blank. + +Parsed into {hall_id: {"YYYY-MM-DD": [{"meal", "open", "close"}]}}. A date with +an empty list is a known closed day; a missing date means the sheet doesn't say +(blank/TBD), so clients should fall back to their own default schedule. +""" +import json +import re +import threading +import time +from datetime import date, datetime, timezone + +import requests + +SHEET_URL = ('https://docs.google.com/spreadsheets/d/1vdWskGO2-aJfKLSW8-3zMaj_nx4SBJHF3OvMEy4-ZNo' + '/gviz/tq?gid=479022338') +SOURCE_PAGE = 'https://dining.umd.edu/hours/open-now' + +# Sheet venue name -> app hall id +HALL_IDS = { + 'south campus': '16', + 'yahentamitsi': '19', + '251 north': '51', +} + +REFRESH_SECONDS = 3600 +RETRY_SECONDS = 300 # after a failed refresh, keep serving the last copy and retry sooner + +CLOSED = 'closed' + +_TIME_RE = re.compile(r'^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$') +_DATE_RE = re.compile(r'^(\d{1,2})/(\d{1,2})/(\d{4})$') +_GVIZ_DATE_RE = re.compile(r'^Date\((\d{4}),(\d{1,2}),(\d{1,2})') + + +class HoursUnavailable(Exception): + """No schedule could be fetched and there's no cached copy to serve.""" + + +# --------------------------------------------------------------------------- +# Parsing +# --------------------------------------------------------------------------- + +def _parse_time(text: str): + """'7am' / '10:30 pm' / 'noon' -> (hour, minute, meridiem or None). None if unparseable.""" + t = text.strip().lower().replace('.', '').replace(' ', '') + if t == 'noon': + return 12, 0, 'pm' + if t == 'midnight': + return 12, 0, 'am' + m = _TIME_RE.match(t) + if not m: + return None + hour, minute = int(m.group(1)), int(m.group(2) or 0) + if not 1 <= hour <= 12 or minute > 59: + return None + return hour, minute, m.group(3) + + +def _to_minutes(hour: int, minute: int, meridiem: str) -> int: + return (hour % 12 + (12 if meridiem == 'pm' else 0)) * 60 + minute + + +def parse_window(text): + """One sheet cell -> (open_min, close_min), CLOSED, or None (unknown: blank, TBD, garbage).""" + if text is None: + return None + t = str(text).strip().lower() + if not t: + return None + if t.startswith('closed'): + return CLOSED + t = t.replace('\u2013', '-').replace('\u2014', '-').replace(' to ', '-') + parts = t.split('-') + if len(parts) != 2: + return None + start, end = _parse_time(parts[0]), _parse_time(parts[1]) + # The close time must say am/pm; "7-10:30" is too ambiguous to guess at + if start is None or end is None or end[2] is None: + return None + close = _to_minutes(*end) + if close == 0: + close = 24 * 60 # "...-12am" / "...-midnight" + opening = _to_minutes(start[0], start[1], start[2] or end[2]) + if start[2] is None and opening >= close: + opening = _to_minutes(start[0], start[1], 'am') # "10-2pm" means 10am + if opening >= close: + return None + return opening, close + + +def _cell_value(cell): + if not cell: + return None + v = cell.get('v') + return v if v is not None else cell.get('f') + + +def _parse_header_date(value): + if value is None: + return None + s = str(value).strip() + m = _DATE_RE.match(s) + if m: + month, day, year = map(int, m.groups()) + else: + m = _GVIZ_DATE_RE.match(s) # typed date column: months are 0-based + if not m: + return None + year, month, day = int(m.group(1)), int(m.group(2)) + 1, int(m.group(3)) + try: + return date(year, month, day) + except ValueError: + return None + + +def _fmt(minutes: int) -> str: + return f'{minutes // 60:02d}:{minutes % 60:02d}' + + +def unwrap_gviz(text: str) -> dict: + """Strip the google.visualization.Query.setResponse( ... ); wrapper.""" + start, end = text.find('('), text.rfind(')') + if start == -1 or end <= start: + raise ValueError('not a gviz response') + return json.loads(text[start + 1:end]) + + +def parse_sheet(text: str) -> dict: + """gviz text -> {hall_id: {date: [windows]}} for every date the sheet knows about.""" + payload = unwrap_gviz(text) + table = payload.get('table') or {} + rows = table.get('rows') or [] + if not rows: + raise ValueError('sheet has no rows') + header = [_parse_header_date(_cell_value(c)) for c in rows[0].get('c') or []] + body = rows[1:] + if not any(header): + # gviz sometimes promotes the header row into the column labels + header = [_parse_header_date((c or {}).get('label')) for c in table.get('cols') or []] + body = rows + if not any(header): + raise ValueError('sheet has no date header') + + # {hall_id: {date: [(open, close, meal)], or None once any meal cell that day is unknown}} + days: dict = {} + for row in body: + cells = row.get('c') or [] + label = _cell_value(cells[0]) if cells else None + if not isinstance(label, str) or '|' not in label: + continue + venue, meal = (p.strip() for p in label.split('|', 1)) + hall_id = HALL_IDS.get(venue.lower()) + if not hall_id or not meal: + continue + by_date = days.setdefault(hall_id, {}) + for i, d in enumerate(header): + if d is None: + continue + key = d.isoformat() + window = parse_window(_cell_value(cells[i]) if i < len(cells) else None) + if window is None: + # A day is only known when every meal is: "Closed" breakfast + TBD dinner isn't closed + by_date[key] = None + continue + day = by_date.setdefault(key, []) + if day is not None and window != CLOSED: + day.append((window[0], window[1], meal)) + + schedule = { + hall_id: { + d: [{'meal': meal, 'open': _fmt(o), 'close': _fmt(c)} for o, c, meal in sorted(windows)] + for d, windows in by_date.items() if windows is not None + } + for hall_id, by_date in days.items() + } + # Nothing known anywhere (blanked sheet, unknown halls) is a broken fetch, not a good copy + if not any(schedule.values()): + raise ValueError('sheet has no known days for any hall') + return schedule + + +# --------------------------------------------------------------------------- +# Fetch + cache +# --------------------------------------------------------------------------- + +def fetch_sheet_text() -> str: + resp = requests.get(SHEET_URL, timeout=10) + resp.raise_for_status() + return resp.text + + +class HoursCache: + """In-process cache: refresh at most hourly, keep the last good copy on failure.""" + + def __init__(self, fetcher=fetch_sheet_text, clock=time.time): + self._fetcher = fetcher + self._clock = clock + self._lock = threading.Lock() + self._copy = None # (schedule, fetched_at), swapped as a unit so readers never see a mix + self._expires = 0.0 + + def get(self): + """Returns (schedule, fetched_at). Blocking — call off the event loop.""" + if self._clock() < self._expires: + return self._serve() + # Stale-while-revalidate: if another request is already refreshing, don't queue behind it + if not self._lock.acquire(blocking=self._copy is None): + return self._copy + try: + if self._clock() < self._expires: # another request refreshed (or failed) while we waited + return self._serve() + try: + schedule = parse_sheet(self._fetcher()) + except Exception as e: + # Back off either way, so a dead sheet doesn't cost every request a 10s timeout + self._expires = self._clock() + RETRY_SECONDS + if self._copy is None: + raise HoursUnavailable(str(e)) + print(f"Hours refresh failed, serving copy from {self._copy[1]}: {e}") + return self._copy + fetched_at = datetime.fromtimestamp(self._clock(), timezone.utc).isoformat(timespec='seconds') + self._copy = (schedule, fetched_at) + self._expires = self._clock() + REFRESH_SECONDS + return self._copy + finally: + self._lock.release() + + def _serve(self): + # Within the expiry window: the good copy, or nothing during a failure backoff + if self._copy is None: + raise HoursUnavailable('hours fetch failed recently; retrying soon') + return self._copy + + +cache = HoursCache() + + +def window_range(schedule: dict, dates: list) -> dict: + """Slice the schedule to the given ISO dates; dates the sheet doesn't cover are omitted.""" + return { + hall_id: {d: by_date[d] for d in dates if d in by_date} + for hall_id, by_date in schedule.items() + } diff --git a/umd-dining-api/routes.py b/umd-dining-api/routes.py index 32d5410..dd29ce3 100644 --- a/umd-dining-api/routes.py +++ b/umd-dining-api/routes.py @@ -23,6 +23,7 @@ from ranker import rank_items from search_ranker import rank_search_results from embeddings import generate_embedding_async +import hours router = APIRouter() @@ -61,9 +62,13 @@ def _ensure_string(value, field_name='field'): return value -def _today_str() -> str: +def _campus_today(): # Campus time, not server time: the server runs on UTC, which is already "tomorrow" during dinner - return datetime.now(ZoneInfo('America/New_York')).strftime('%-m/%-d/%Y') + return datetime.now(ZoneInfo('America/New_York')).date() + + +def _today_str() -> str: + return _campus_today().strftime('%-m/%-d/%Y') async def _resolve_availability(rec_nums: list, today: Optional[str] = None) -> dict: @@ -1544,6 +1549,45 @@ async def remove_intake(request: Request, body: RemoveIntakeBody = Body(...), us return {'success': True} +# --------------------------------------------------------------------------- +# Hall hours (UMD Dining's published schedule) +# --------------------------------------------------------------------------- + +@router.get('/api/hours') +# Anonymous, so keyed by IP: a whole dorm can share one NAT address. Served from memory, so cheap +@limiter.limit("300/minute") +async def get_hours( + request: Request, + start: Optional[str] = Query(default=None, description='YYYY-MM-DD; defaults to today on campus'), + days: int = Query(default=14, ge=1, le=60), +): + if start: + try: + start_date = datetime.strptime(start, '%Y-%m-%d').date() + except ValueError: + raise HTTPException(status_code=400, detail='start must be YYYY-MM-DD') + # The sheet only covers about a year; this also keeps start + days from overflowing + if abs((start_date - _campus_today()).days) > 730: + raise HTTPException(status_code=400, detail='start must be within two years of today') + else: + start_date = _campus_today() + try: + # Blocking fetch on a cache miss (at most hourly) — keep it off the event loop + schedule, fetched_at = await asyncio.to_thread(hours.cache.get) + except hours.HoursUnavailable: + raise HTTPException(status_code=503, detail='hours unavailable') + dates = [(start_date + timedelta(days=i)).isoformat() for i in range(days)] + # Per hall: date -> meal windows; [] = closed all day, missing date = unknown + return { + 'success': True, + 'start': dates[0], + 'days': days, + 'halls': hours.window_range(schedule, dates), + 'source': hours.SOURCE_PAGE, + 'fetched_at': fetched_at, + } + + # --------------------------------------------------------------------------- # Announcements # --------------------------------------------------------------------------- diff --git a/umd-dining-api/tests/test_hours.py b/umd-dining-api/tests/test_hours.py new file mode 100644 index 0000000..b5cb6ad --- /dev/null +++ b/umd-dining-api/tests/test_hours.py @@ -0,0 +1,241 @@ +"""Hall hours: gviz parsing, the hourly cache, and /api/hours.""" +import json +import threading + +import pytest +from fastapi import HTTPException + +import hours +import routes + + +def gviz(rows, labels=()): + """Wrap rows of plain values the way Google's gviz endpoint does.""" + table = {"cols": [{"label": label} for label in labels], + "rows": [{"c": [None if v is None else {"v": v} for v in r]} for r in rows]} + body = json.dumps({"version": "0.6", "status": "ok", "table": table}) + return f"/*O_o*/\ngoogle.visualization.Query.setResponse({body});" + + +# Fall break 2026: Yahentamitsi closed Sat 10/10, dinner only Tue 10/13 +SHEET = gviz([ + ["venue", "10/10/2026", "10/13/2026", "10/14/2026", "1/25/2027"], + ["Yahentamitsi | Breakfast", "Closed ", "Closed ", "7am-10:30am", "TBD"], + ["Yahentamitsi | Lunch", "Closed ", "Closed", "10:30am-4pm", "TBD"], + ["Yahentamitsi | Dinner", "Closed ", "4pm-9pm", "4pm-9pm", None], + ["251 North | Breakfast", "8am-10:30am", "8am-10:30am", "8am-10:30am", "TBD"], + ["251 North | Dinner", "4pm-9pm", "4pm-10pm", "4pm-10pm", "TBD"], + ["Mystery Hall | Lunch", "11am-2pm", "11am-2pm", "11am-2pm", "11am-2pm"], + [None, None, None, None, None], +]) + + +@pytest.mark.parametrize("text, expected", [ + ("7am-10:30am", (420, 630)), + ("4pm-9pm", (960, 1260)), + ("12pm-1:30pm", (720, 810)), + ("Noon-2pm", (720, 840)), + ("10-2pm", (600, 840)), # start without am/pm borrows, then flips to am + ("11 a.m. – 1:30 p.m.", (660, 810)), + ("4pm-12am", (960, 1440)), # closing at midnight is end of day, not minute 0 + ("Closed", hours.CLOSED), + ("Closed ", hours.CLOSED), + ("CLOSED for break", hours.CLOSED), +]) +def test_parse_window(text, expected): + assert hours.parse_window(text) == expected + + +@pytest.mark.parametrize("text", [None, "", " ", "TBD", "—", "12pm", "7-9", "9pm-7am", "25am-3pm"]) +def test_unknown_cells_are_not_guessed(text): + assert hours.parse_window(text) is None + + +def test_parse_sheet_closed_dinner_only_and_unknown_days(): + s = hours.parse_sheet(SHEET) + assert set(s) == {"19", "51"} # unknown venues and blank rows skipped + assert s["19"]["2026-10-10"] == [] # closed all day + assert s["19"]["2026-10-13"] == [{"meal": "Dinner", "open": "16:00", "close": "21:00"}] + assert [w["meal"] for w in s["19"]["2026-10-14"]] == ["Breakfast", "Lunch", "Dinner"] + assert "2027-01-25" not in s["19"] # all TBD/blank -> unknown, not closed + assert s["51"]["2026-10-13"][-1] == {"meal": "Dinner", "open": "16:00", "close": "22:00"} + + +def test_one_unknown_meal_makes_the_whole_day_unknown(): + sheet = gviz([ + ["venue", "10/10/2026", "10/11/2026", "10/12/2026", "10/13/2026"], + ["Yahentamitsi | Breakfast", "Closed", "Closed", "Closed", "Closed"], + ["Yahentamitsi | Lunch", "TBD", "Closed", "", "Closed"], + ["Yahentamitsi | Dinner", "4pm-9pm", "Closed", "Closed"], # short row: 10/13 has no cell + ]) + s = hours.parse_sheet(sheet) + assert s["19"] == {"2026-10-11": []} # only the day every meal cell speaks for + + +def test_parse_sheet_handles_typed_date_headers(): + sheet = gviz([["venue", "Date(2026,9,10)"], ["Yahentamitsi | Dinner", "4pm-9pm"]]) + assert hours.parse_sheet(sheet) == {"19": {"2026-10-10": [{"meal": "Dinner", "open": "16:00", "close": "21:00"}]}} + + +def test_parse_sheet_falls_back_to_column_labels_for_dates(): + sheet = gviz([["Yahentamitsi | Dinner", "4pm-9pm"]], labels=["venue", "10/10/2026"]) + assert hours.parse_sheet(sheet) == {"19": {"2026-10-10": [{"meal": "Dinner", "open": "16:00", "close": "21:00"}]}} + + +@pytest.mark.parametrize("text", [ + "sign in", + gviz([]), + gviz([["venue", "1/1/2026"], ["Foo | Lunch", "1pm-2pm"]]), + gviz([["venue", "1/1/2026"], ["Yahentamitsi | Lunch", ""], ["251 North | Lunch", "TBD"]]), # blanked + gviz([["venue", "someday"], ["Yahentamitsi | Lunch", "1pm-2pm"]], labels=["", "A"]), # no dates +]) +def test_parse_sheet_rejects_unusable_responses(text): + with pytest.raises(ValueError): + hours.parse_sheet(text) + + +def test_window_range_omits_dates_outside_the_sheet(): + s = hours.parse_sheet(SHEET) + out = hours.window_range(s, ["2026-10-13", "2030-01-01"]) + assert out["19"] == {"2026-10-13": s["19"]["2026-10-13"]} + + +class Clock: + def __init__(self): + self.now = 1_800_000_000.0 + + def __call__(self): + return self.now + + +def test_cache_refreshes_hourly_and_serves_last_copy_on_failure(): + clock, calls = Clock(), [] + + def fetcher(): + calls.append(clock.now) + if len(calls) == 2: + raise ConnectionError("sheet down") + return SHEET + + cache = hours.HoursCache(fetcher=fetcher, clock=clock) + first, fetched_at = cache.get() + clock.now += 60 + assert cache.get() == (first, fetched_at) and len(calls) == 1 # cached + + clock.now += hours.REFRESH_SECONDS + assert cache.get() == (first, fetched_at) and len(calls) == 2 # failed refresh -> last good copy + + clock.now += 60 + cache.get() + assert len(calls) == 2 # backs off instead of hammering + clock.now += hours.RETRY_SECONDS + _, refreshed_at = cache.get() + assert len(calls) == 3 and refreshed_at != fetched_at + + +def test_cache_without_a_good_copy_raises(): + cache = hours.HoursCache(fetcher=lambda: "not gviz", clock=Clock()) + with pytest.raises(hours.HoursUnavailable): + cache.get() + + +def test_cache_keeps_the_good_copy_when_the_sheet_comes_back_blank(): + clock, calls = Clock(), [] + blank = gviz([["venue", "10/10/2026"], ["Yahentamitsi | Lunch", None]]) + + def fetcher(): + calls.append(clock.now) + return SHEET if len(calls) == 1 else blank + + cache = hours.HoursCache(fetcher=fetcher, clock=clock) + good = cache.get() + clock.now += hours.REFRESH_SECONDS + assert cache.get() == good and len(calls) == 2 + clock.now += 60 + assert cache.get() == good and len(calls) == 2 # backed off, not stored + + +def test_cache_backs_off_after_a_failed_first_fetch(): + clock, calls = Clock(), [] + + def fetcher(): + calls.append(clock.now) + if len(calls) == 1: + raise ConnectionError("sheet down") + return SHEET + + cache = hours.HoursCache(fetcher=fetcher, clock=clock) + with pytest.raises(hours.HoursUnavailable): + cache.get() + clock.now += 60 + with pytest.raises(hours.HoursUnavailable): + cache.get() + assert len(calls) == 1 # no fetch during the backoff + clock.now += hours.RETRY_SECONDS + assert cache.get()[0] == hours.parse_sheet(SHEET) and len(calls) == 2 + + +def test_stale_copy_is_served_while_another_request_refreshes(): + clock, refreshing, release = Clock(), threading.Event(), threading.Event() + calls = [] + + def fetcher(): + calls.append(clock.now) + if len(calls) == 2: # the refresh hangs until the test lets it finish + refreshing.set() + release.wait(5) + return SHEET + + cache = hours.HoursCache(fetcher=fetcher, clock=clock) + stale = cache.get() + clock.now += hours.REFRESH_SECONDS + t = threading.Thread(target=cache.get) + t.start() + assert refreshing.wait(5) + assert cache.get() == stale and len(calls) == 2 # didn't block or fetch again + release.set() + t.join(5) + assert not t.is_alive() + + +@pytest.fixture +def sheet_cache(monkeypatch): + cache = hours.HoursCache(fetcher=lambda: SHEET, clock=Clock()) + monkeypatch.setattr(hours, "cache", cache) + return cache + + +async def get_hours(**kwargs): + kwargs = {"request": None, "start": None, "days": 14, **kwargs} + # __wrapped__ skips the slowapi decorator, which needs a live Request + return await routes.get_hours.__wrapped__(**kwargs) + + +async def test_route_returns_the_requested_window(sheet_cache): + body = await get_hours(start="2026-10-10", days=4) + assert body["success"] and body["start"] == "2026-10-10" and body["days"] == 4 + assert body["halls"]["19"] == { + "2026-10-10": [], + "2026-10-13": [{"meal": "Dinner", "open": "16:00", "close": "21:00"}], + } + assert body["source"] == hours.SOURCE_PAGE and body["fetched_at"] + + +async def test_route_rejects_bad_start(sheet_cache): + with pytest.raises(HTTPException) as exc: + await get_hours(start="10/10/2026") + assert exc.value.status_code == 400 + + +@pytest.mark.parametrize("start", ["9999-12-31", "0001-01-01"]) +async def test_route_rejects_a_start_far_from_today(sheet_cache, start): + with pytest.raises(HTTPException) as exc: + await get_hours(start=start, days=60) + assert exc.value.status_code == 400 + + +async def test_route_is_503_when_the_sheet_never_loaded(monkeypatch): + monkeypatch.setattr(hours, "cache", hours.HoursCache(fetcher=lambda: "", clock=Clock())) + with pytest.raises(HTTPException) as exc: + await get_hours() + assert exc.value.status_code == 503 diff --git a/umd-dining-app/Models/DiningHallHours.swift b/umd-dining-app/Models/DiningHallHours.swift index 9d8be53..aa0ff28 100644 --- a/umd-dining-app/Models/DiningHallHours.swift +++ b/umd-dining-app/Models/DiningHallHours.swift @@ -1,5 +1,15 @@ import Foundation +extension Calendar { + /// Hall hours are campus hours: Gregorian in America/New_York, whatever the device's zone or calendar. + nonisolated static let campus: Calendar = { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = TimeZone(identifier: "America/New_York")! + cal.locale = .autoupdatingCurrent + return cal + }() +} + struct MealWindow { let name: String let openMinute: Int // minutes from midnight @@ -15,8 +25,8 @@ struct MealWindow { private func minuteToString(_ m: Int) -> String { let hour = m / 60 let min = m % 60 - let period = hour < 12 ? "AM" : "PM" - let displayHour = hour == 0 ? 12 : hour > 12 ? hour - 12 : hour + let period = hour < 12 || hour == 24 ? "AM" : "PM" // 24:00 is midnight at the end of the day + let displayHour = hour % 12 == 0 ? 12 : hour % 12 return min == 0 ? "\(displayHour) \(period)" : "\(displayHour):\(String(format: "%02d", min)) \(period)" } } @@ -42,12 +52,12 @@ struct DiningHallSchedule { let dayCloseTime: String? // last meal's close = when the hall closes for the day let nextMeal: String? let nextOpenTime: String? + var nextOpenDay: String? = nil // "tomorrow" / "Tue" when the next opening isn't today static let closed = Status(isOpen: false, isClosingSoon: false, currentMeal: nil, closeTime: nil, dayCloseTime: nil, nextMeal: nil, nextOpenTime: nil) } - func currentStatus(at date: Date = .now) -> Status { - let cal = Calendar.current + func currentStatus(at date: Date = .now, calendar cal: Calendar = .campus) -> Status { let weekday = cal.component(.weekday, from: date) // 1=Sun, 6=Fri, 7=Sat let isWeekend = weekday == 1 || weekday == 7 let isFriday = weekday == 6 @@ -79,7 +89,61 @@ struct DiningHallSchedule { return .closed } - // Hardcoded hours — week of 4/27–5/3 (verify each semester against UMD Dining schedule) + /// Status from live hours (hall's "yyyy-MM-dd" -> windows; [] = closed all day). + /// nil when today isn't in the data, so the caller falls back to the hardcoded week. + static func status(days: [String: [MealWindow]], at date: Date, calendar: Calendar = .campus) -> Status? { + guard let windows = days[dateKey(date, calendar: calendar)] else { return nil } + let minute = calendar.component(.hour, from: date) * 60 + calendar.component(.minute, from: date) + + if let i = windows.firstIndex(where: { $0.contains(minute: minute) }) { + // Back-to-back meals (Lunch 10:30–4, Dinner 4–9) are one open stretch; + // a gap between meals (summer hours) closes the hall until the next one + // Running max, so a window nested inside an earlier one can't cut the stretch short + var end = i + var last = windows[i] + while end + 1 < windows.count && windows[end + 1].openMinute <= last.closeMinute { + end += 1 + if windows[end].closeMinute > last.closeMinute { last = windows[end] } + } + return Status( + isOpen: true, + isClosingSoon: last.closeMinute - minute <= 30, + currentMeal: windows[i].name, + closeTime: windows[i].closeTimeString, + dayCloseTime: last.closeTimeString, + nextMeal: nil, + nextOpenTime: nil + ) + } + if let next = windows.first(where: { $0.openMinute > minute }) { + return Status(isOpen: false, isClosingSoon: false, currentMeal: nil, closeTime: nil, + dayCloseTime: windows.last?.closeTimeString, nextMeal: next.name, nextOpenTime: next.openTimeString) + } + // Done for today: look ahead to the next open day, stopping where the data runs out + for offset in 1...7 { + guard let day = calendar.date(byAdding: .day, value: offset, to: date), + let later = days[dateKey(day, calendar: calendar)] else { break } + if let first = later.first { + return Status(isOpen: false, isClosingSoon: false, currentMeal: nil, closeTime: nil, + dayCloseTime: nil, nextMeal: first.name, nextOpenTime: first.openTimeString, + nextOpenDay: offset == 1 ? "tomorrow" : weekdayName(day, calendar: calendar)) + } + } + return .closed + } + + static func dateKey(_ date: Date, calendar: Calendar = .campus) -> String { + let c = calendar.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d", c.year ?? 0, c.month ?? 0, c.day ?? 0) + } + + private static func weekdayName(_ date: Date, calendar: Calendar) -> String { + let weekday = calendar.component(.weekday, from: date) // 1 = Sun + return calendar.shortWeekdaySymbols[weekday - 1] + } + + // Hardcoded hours — week of 4/27–5/3. Fallback only: HallHoursService serves UMD's live + // schedule when it has data for the day (verify each semester against UMD Dining schedule) static let all: [String: DiningHallSchedule] = [ "19": DiningHallSchedule( hallId: "19", diff --git a/umd-dining-app/Services/DiningAPIService.swift b/umd-dining-app/Services/DiningAPIService.swift index 7466094..8dbf06d 100644 --- a/umd-dining-app/Services/DiningAPIService.swift +++ b/umd-dining-app/Services/DiningAPIService.swift @@ -499,6 +499,14 @@ actor DiningAPIService { } } + // MARK: - Hall hours + + func fetchHallHours(days: Int = 21) async throws -> HallHoursResponse { + // No start date: the server defaults to today on campus + let data = try await fetch("\(baseURL)/hours?days=\(days)") + return try JSONDecoder().decode(HallHoursResponse.self, from: data) + } + // MARK: - Announcements func fetchAnnouncement() async -> AnnouncementData? { diff --git a/umd-dining-app/Services/HallHoursService.swift b/umd-dining-app/Services/HallHoursService.swift new file mode 100644 index 0000000..e589a05 --- /dev/null +++ b/umd-dining-app/Services/HallHoursService.swift @@ -0,0 +1,114 @@ +import Foundation + +/// /api/hours: UMD Dining's published schedule, per hall and date. +/// A date with [] is closed all day; a missing date means the schedule doesn't say. +struct HallHoursResponse: Codable, Sendable { + struct Window: Codable, Sendable { + let meal: String + let open: String // "HH:mm", campus time; close may be "24:00" + let close: String + } + + let halls: [String: [String: [Window]]] + let fetchedAt: String? + + enum CodingKeys: String, CodingKey { + case halls + case fetchedAt = "fetched_at" + } + + /// hallId -> "yyyy-MM-dd" -> windows sorted by open time. Unparseable windows are + /// dropped; a date whose windows were all unparseable is dropped too (unknown, not closed). + var mealWindows: [String: [String: [MealWindow]]] { + halls.mapValues { days in + days.compactMapValues { raw in + let windows = raw.compactMap { w -> MealWindow? in + guard let open = Self.minutes(w.open), let close = Self.minutes(w.close), open < close else { return nil } + return MealWindow(name: w.meal, openMinute: open, closeMinute: close) + } + if windows.isEmpty && !raw.isEmpty { return nil } + return windows.sorted { $0.openMinute < $1.openMinute } + } + } + } + + static func minutes(_ hhmm: String) -> Int? { + let parts = hhmm.split(separator: ":") + guard parts.count == 2, let h = Int(parts[0]), let m = Int(parts[1]), + (0..<24).contains(h) || (h == 24 && m == 0), (0..<60).contains(m) else { return nil } + return h * 60 + m + } +} + +/// Live hall hours with an offline copy; falls back to the hardcoded +/// DiningHallSchedule for any day the live data doesn't cover. +@MainActor +@Observable +class HallHoursService { + static let shared = HallHoursService() + + private(set) var days: [String: [String: [MealWindow]]] = [:] + private var lastAttempt: Date? + private var lastAttemptSucceeded = false + private var isRefreshing = false + + private static let cacheKey = "hallHoursCache" + private static let refreshInterval: TimeInterval = 3600 + private static let retryInterval: TimeInterval = 300 + + private let defaults: UserDefaults + private let fetch: () async throws -> HallHoursResponse + private let now: () -> Date + + init(defaults: UserDefaults = .standard, + fetch: @escaping () async throws -> HallHoursResponse = { try await DiningAPIService.shared.fetchHallHours() }, + now: @escaping () -> Date = { .now }) { + self.defaults = defaults + self.fetch = fetch + self.now = now + if let data = defaults.data(forKey: Self.cacheKey), + let cached = try? JSONDecoder().decode(HallHoursResponse.self, from: data) { + days = cached.mealWindows + } + } + + func status(for hallId: String, at date: Date = .now) -> DiningHallSchedule.Status { + if let hallDays = days[hallId], + let live = DiningHallSchedule.status(days: hallDays, at: date) { + return live + } + return DiningHallSchedule.all[hallId]?.currentStatus(at: date) ?? .closed + } + + /// Hourly after a success, every 5 min after a failure; failures keep whatever is cached + func refreshIfNeeded() async { + guard !isRefreshing else { return } + if let lastAttempt, + now().timeIntervalSince(lastAttempt) < (lastAttemptSucceeded ? Self.refreshInterval : Self.retryInterval) { + return + } + // Recorded before awaiting, so overlapping calls and failures are throttled too + isRefreshing = true + defer { isRefreshing = false } + let previous = (lastAttempt, lastAttemptSucceeded) + lastAttempt = now() + lastAttemptSucceeded = false + do { + apply(try await fetch()) + } catch { + // Cancelled isn't failed: the server wasn't the problem, so the next call may retry at once + if Task.isCancelled || error is CancellationError || (error as? URLError)?.code == .cancelled { + (lastAttempt, lastAttemptSucceeded) = previous + } + } + } + + func apply(_ response: HallHoursResponse) { + lastAttempt = now() + lastAttemptSucceeded = true + days = response.mealWindows + if let data = try? JSONEncoder().encode(response) { + defaults.set(data, forKey: Self.cacheKey) + } + } +} diff --git a/umd-dining-app/UMD_DiningApp.swift b/umd-dining-app/UMD_DiningApp.swift index ecaf430..de0c5d4 100644 --- a/umd-dining-app/UMD_DiningApp.swift +++ b/umd-dining-app/UMD_DiningApp.swift @@ -69,5 +69,12 @@ struct UMD_DiningApp: App { } } .modelContainer(for: [DailyLog.self, TrackedEntry.self]) + .onChange(of: scenePhase, initial: true) { + // Launch and every return to the foreground (throttled inside). Unstructured so no + // view disappearing can cancel it + if scenePhase == .active { + Task { await HallHoursService.shared.refreshIfNeeded() } + } + } } } diff --git a/umd-dining-app/Views/DiningHallPickerView.swift b/umd-dining-app/Views/DiningHallPickerView.swift index db08b7e..0ef291b 100644 --- a/umd-dining-app/Views/DiningHallPickerView.swift +++ b/umd-dining-app/Views/DiningHallPickerView.swift @@ -81,12 +81,14 @@ struct DiningHallCard: View { let onTap: () -> Void private var status: DiningHallSchedule.Status { - DiningHallSchedule.all[hallId]?.currentStatus() ?? .closed + HallHoursService.shared.status(for: hallId) } private var statusText: String { if status.isOpen, let close = status.dayCloseTime, let meal = status.currentMeal { return "Open until \(close) · \(meal)" + } else if let nextTime = status.nextOpenTime, let day = status.nextOpenDay { + return "Opens \(day) at \(nextTime)" } else if let nextTime = status.nextOpenTime { return "Opens at \(nextTime)" } else { diff --git a/umd-dining-app/Views/HomeView.swift b/umd-dining-app/Views/HomeView.swift index 6aa578f..c2cc9f8 100644 --- a/umd-dining-app/Views/HomeView.swift +++ b/umd-dining-app/Views/HomeView.swift @@ -289,7 +289,7 @@ struct HomeView: View { ] private func hallCardPage(for hallId: String) -> some View { - let status = DiningHallSchedule.all[hallId]?.currentStatus() + let status = HallHoursService.shared.status(for: hallId) let imageAlignment = Self.hallImageAlignments[hallId] ?? .center return GeometryReader { geo in ZStack { @@ -322,9 +322,7 @@ struct HomeView: View { VStack { HStack { Spacer() - if let status { - statusBadge(status) - } + statusBadge(status) } .padding(12) Spacer() @@ -338,12 +336,12 @@ struct HomeView: View { Text(viewModel.diningHallName(for: hallId)) .font(.inter(size: 20, weight: .bold)) .foregroundStyle(.white) - if let status, let close = status.dayCloseTime, status.isOpen { + if let close = status.dayCloseTime, status.isOpen { Text("Closes at \(close)") .font(.inter(size: 13, weight: .medium)) .foregroundStyle(.white.opacity(0.85)) - } else if let status, !status.isOpen, let next = status.nextOpenTime { - Text("Opens at \(next)") + } else if !status.isOpen, let next = status.nextOpenTime { + Text(status.nextOpenDay.map { "Opens \($0) at \(next)" } ?? "Opens at \(next)") .font(.inter(size: 13, weight: .medium)) .foregroundStyle(.white.opacity(0.85)) } diff --git a/umd-dining-appTests/HallHoursTests.swift b/umd-dining-appTests/HallHoursTests.swift new file mode 100644 index 0000000..4378fb2 --- /dev/null +++ b/umd-dining-appTests/HallHoursTests.swift @@ -0,0 +1,252 @@ +import Testing +import Foundation +@testable import UMD_Dining + +/// Campus calendar so the tests don't depend on the simulator's time zone. +private let campus: Calendar = { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = TimeZone(identifier: "America/New_York")! + cal.locale = Locale(identifier: "en_US") + return cal +}() + +private func at(_ month: Int, _ day: Int, _ hour: Int, _ minute: Int = 0) -> Date { + campus.date(from: DateComponents(year: 2026, month: month, day: day, hour: hour, minute: minute))! +} + +/// Fall break 2026 as /api/hours returns it: Yahentamitsi closed 10/10–10/12, +/// dinner only 10/13, full day 10/14. 251 North has a summer-style gap on 10/12. +private let json = """ +{ + "success": true, "start": "2026-10-10", "days": 5, + "source": "https://dining.umd.edu/hours/open-now", "fetched_at": "2026-10-10T12:00:00+00:00", + "halls": { + "19": { + "2026-10-10": [], "2026-10-11": [], "2026-10-12": [], + "2026-10-13": [{"meal": "Dinner", "open": "16:00", "close": "21:00"}], + "2026-10-14": [{"meal": "Lunch", "open": "10:30", "close": "16:00"}, + {"meal": "Breakfast", "open": "07:00", "close": "10:30"}, + {"meal": "Dinner", "open": "16:00", "close": "21:00"}] + }, + "51": { + "2026-10-12": [{"meal": "Lunch", "open": "11:00", "close": "13:30"}, + {"meal": "Dinner", "open": "17:00", "close": "19:00"}], + "2026-10-13": [{"meal": "Dinner", "open": "bogus", "close": "21:00"}] + } + } +} +""" + +private func liveDays() throws -> [String: [String: [MealWindow]]] { + try JSONDecoder().decode(HallHoursResponse.self, from: Data(json.utf8)).mealWindows +} + +private func status(_ hall: String, _ date: Date) throws -> DiningHallSchedule.Status? { + DiningHallSchedule.status(days: try liveDays()[hall] ?? [:], at: date, calendar: campus) +} + +struct HallHoursDecodingTests { + + @Test func decodesWindowsSortedByOpenTime() throws { + let days = try liveDays() + #expect(days["19"]?["2026-10-10"]?.isEmpty == true) + #expect(days["19"]?["2026-10-14"]?.map(\.name) == ["Breakfast", "Lunch", "Dinner"]) + let dinner = try #require(days["19"]?["2026-10-13"]?.first) + #expect(dinner.openMinute == 16 * 60 && dinner.closeMinute == 21 * 60) + } + + @Test func aDateWithOnlyUnparseableWindowsIsUnknownNotClosed() throws { + #expect(try liveDays()["51"]?["2026-10-13"] == nil) + } + + @Test func formatsMidnightNoonAndMorning() { + let w = MealWindow(name: "Late", openMinute: 0, closeMinute: 24 * 60) + #expect(w.openTimeString == "12 AM" && w.closeTimeString == "12 AM") + #expect(MealWindow(name: "Lunch", openMinute: 12 * 60, closeMinute: 12 * 60 + 30).closeTimeString == "12:30 PM") + #expect(MealWindow(name: "Dinner", openMinute: 9 * 60 + 5, closeMinute: 23 * 60).openTimeString == "9:05 AM") + #expect(MealWindow(name: "Dinner", openMinute: 9 * 60 + 5, closeMinute: 23 * 60).closeTimeString == "11 PM") + } + + @Test func parsesHourMinuteStrings() { + #expect(HallHoursResponse.minutes("07:30") == 450) + #expect(HallHoursResponse.minutes("24:00") == 1440) + #expect(HallHoursResponse.minutes("24:01") == nil) + #expect(HallHoursResponse.minutes("24:30") == nil) + #expect(HallHoursResponse.minutes("7pm") == nil) + #expect(HallHoursResponse.minutes("25:00") == nil) + } +} + +struct HallHoursStatusTests { + + @Test func closedDayLooksAheadToTheNextOpening() throws { + let s = try #require(try status("19", at(10, 11, 12))) + #expect(!s.isOpen && s.currentMeal == nil) + #expect(s.nextMeal == "Dinner" && s.nextOpenTime == "4 PM" && s.nextOpenDay == "Tue") + } + + @Test func nextOpeningTomorrowSaysTomorrow() throws { + let s = try #require(try status("19", at(10, 12, 20))) + #expect(s.nextOpenDay == "tomorrow" && s.nextOpenTime == "4 PM") + } + + @Test func dinnerOnlyDayIsClosedUntilDinnerThenOpen() throws { + let morning = try #require(try status("19", at(10, 13, 9))) + #expect(!morning.isOpen && morning.nextOpenTime == "4 PM" && morning.nextOpenDay == nil) + let evening = try #require(try status("19", at(10, 13, 18))) + #expect(evening.isOpen && evening.currentMeal == "Dinner" && evening.dayCloseTime == "9 PM") + #expect(!evening.isClosingSoon) + } + + @Test func backToBackMealsStayOpenUntilTheDayCloses() throws { + let s = try #require(try status("19", at(10, 14, 11))) + #expect(s.isOpen && s.currentMeal == "Lunch" && s.dayCloseTime == "9 PM" && !s.isClosingSoon) + } + + @Test func closingSoonInTheLastHalfHour() throws { + #expect(try status("19", at(10, 14, 20, 40))?.isClosingSoon == true) + #expect(try status("19", at(10, 14, 20, 20))?.isClosingSoon == false) + } + + @Test func betweenMealsShowsTheNextMealToday() throws { + let gap = try #require(try status("51", at(10, 12, 15))) + #expect(!gap.isOpen && gap.nextMeal == "Dinner" && gap.nextOpenTime == "5 PM" && gap.nextOpenDay == nil) + // A gap after lunch means the hall closes at 1:30, not at dinner's close + let lunch = try #require(try status("51", at(10, 12, 13, 10))) + #expect(lunch.isOpen && lunch.dayCloseTime == "1:30 PM" && lunch.isClosingSoon) + } + + @Test func lookAheadStopsWhereTheDataEnds() throws { + let s = try #require(try status("19", at(10, 14, 22))) + #expect(!s.isOpen && s.nextOpenTime == nil && s.nextOpenDay == nil) + } + + @Test func dayMissingFromTheDataReturnsNil() throws { + #expect(try status("19", at(10, 20, 12)) == nil) + } + + @Test func aNestedWindowDoesNotShortenTheDay() { + let day = [ + MealWindow(name: "Brunch", openMinute: 10 * 60, closeMinute: 21 * 60), + MealWindow(name: "Lunch", openMinute: 11 * 60, closeMinute: 14 * 60), + MealWindow(name: "Dinner", openMinute: 16 * 60, closeMinute: 20 * 60) + ] + let s = DiningHallSchedule.status(days: ["2026-10-14": day], at: at(10, 14, 12), calendar: campus) + #expect(s?.isOpen == true && s?.dayCloseTime == "9 PM" && s?.isClosingSoon == false) + } +} + +struct CampusCalendarTests { + + /// Late evening on campus is already tomorrow in UTC; keys and hours follow campus time + @Test func defaultsUseCampusTimeNotTheDeviceZone() throws { + let night = at(10, 13, 20, 45) + var utc = Calendar(identifier: .gregorian) + utc.timeZone = TimeZone(identifier: "UTC")! + #expect(DiningHallSchedule.dateKey(night, calendar: utc) == "2026-10-14") + #expect(DiningHallSchedule.dateKey(night) == "2026-10-13") + #expect(DiningHallSchedule.status(days: try liveDays()["19"]!, at: night)?.isClosingSoon == true) + + let fallback = DiningHallSchedule.all["19"]! + #expect(fallback.currentStatus(at: night).currentMeal == "Dinner") + #expect(!fallback.currentStatus(at: night, calendar: utc).isOpen) + } + + @Test(arguments: [Calendar.Identifier.buddhist, .japanese]) + func nonGregorianDeviceCalendarStillMatchesKeys(_ identifier: Calendar.Identifier) throws { + let date = at(10, 13, 18) + var device = Calendar(identifier: identifier) + device.timeZone = campus.timeZone + // What Calendar.current would give on such a device: an era year, so no key would match + #expect(DiningHallSchedule.dateKey(date, calendar: device) != "2026-10-13") + #expect(Calendar.campus.identifier == .gregorian) + #expect(DiningHallSchedule.dateKey(date) == "2026-10-13") + #expect(DiningHallSchedule.status(days: try liveDays()["19"]!, at: date)?.isOpen == true) + } +} + +@MainActor +struct HallHoursServiceTests { + + private func freshDefaults() -> UserDefaults { + let name = "HallHoursTests-\(UUID().uuidString)" + let d = UserDefaults(suiteName: name)! + d.removePersistentDomain(forName: name) + return d + } + + @Test func fallsBackToHardcodedScheduleWithoutLiveData() { + let service = HallHoursService(defaults: freshDefaults()) + let date = at(10, 11, 12) + let expected = DiningHallSchedule.all["19"]!.currentStatus(at: date) + let s = service.status(for: "19", at: date) + #expect(s.isOpen == expected.isOpen && s.currentMeal == expected.currentMeal) + } + + @Test func liveDataOverridesAndSurvivesRelaunch() throws { + let defaults = freshDefaults() + let response = try JSONDecoder().decode(HallHoursResponse.self, from: Data(json.utf8)) + HallHoursService(defaults: defaults).apply(response) + + let relaunched = HallHoursService(defaults: defaults) // reads the offline copy + #expect(!relaunched.status(for: "19", at: at(10, 11, 12)).isOpen) + // 16 isn't in the live data at all -> hardcoded + let date = at(10, 11, 12) + #expect(relaunched.status(for: "16", at: date).isOpen == DiningHallSchedule.all["16"]!.currentStatus(at: date).isOpen) + } + + @Test func refreshRetriesFiveMinutesAfterAFailureThenHourly() async throws { + let response = try JSONDecoder().decode(HallHoursResponse.self, from: Data(json.utf8)) + var clock = at(10, 11, 12) + var calls = 0 + var failing = true + let service = HallHoursService(defaults: freshDefaults(), fetch: { + calls += 1 + if failing { throw URLError(.notConnectedToInternet) } + return response + }, now: { clock }) + + await service.refreshIfNeeded() + await service.refreshIfNeeded() + #expect(calls == 1) // a failure throttles too + clock += 301 + failing = false + await service.refreshIfNeeded() + #expect(calls == 2 && service.days["19"] != nil) + clock += 301 + await service.refreshIfNeeded() + #expect(calls == 2) // hourly once it has data + clock += 3600 + await service.refreshIfNeeded() + #expect(calls == 3) + } + + @Test func aCancelledFetchDoesNotBlockAnImmediateRetry() async throws { + let response = try JSONDecoder().decode(HallHoursResponse.self, from: Data(json.utf8)) + var calls = 0 + let service = HallHoursService(defaults: freshDefaults(), fetch: { + calls += 1 + if calls == 1 { throw CancellationError() } // e.g. the view that started it went away + return response + }, now: { at(10, 11, 12) }) + await service.refreshIfNeeded() + await service.refreshIfNeeded() // same instant: not throttled, not in flight + #expect(calls == 2 && service.days["19"] != nil) + } + + @Test func overlappingRefreshesDoNotFetchTwice() async { + var clock = at(10, 11, 12) + var calls = 0 + var service: HallHoursService! + service = HallHoursService(defaults: freshDefaults(), fetch: { + calls += 1 + if calls == 1 { + clock += 3600 // even past the throttle, a refresh is already in flight + await service.refreshIfNeeded() + } + throw URLError(.timedOut) + }, now: { clock }) + await service.refreshIfNeeded() + #expect(calls == 1) + } +}