From 7a20650e4e4efcad9c1193590dd204b692c83910 Mon Sep 17 00:00:00 2001 From: Advay Monga Date: Mon, 21 Sep 2026 20:04:58 -0400 Subject: [PATCH 1/2] Unclip bottom buttons; keep Trending populated in quiet months iOS: the custom tab bar floats over tab content, and its safe-area clearance doesn't reach the NavigationStacks inside each tab, so Filter's Apply button and Favorites' Save & Continue sat half under the bar. Both now add CustomTabBar.clearance themselves. API: Trending needs 2+ favorites in 30 days, which nothing meets between semesters. When fewer than 5 dishes qualify, fall back to the past year. Co-Authored-By: Claude Fable 5.1 --- umd-dining-api/routes.py | 28 +++++++++------ umd-dining-api/tests/conftest.py | 7 ++-- umd-dining-api/tests/test_trending.py | 45 ++++++++++++++++++++++++ umd-dining-app/ContentView.swift | 4 +++ umd-dining-app/Views/FavoritesView.swift | 1 + umd-dining-app/Views/FilterOverlay.swift | 4 +-- 6 files changed, 74 insertions(+), 15 deletions(-) create mode 100644 umd-dining-api/tests/test_trending.py diff --git a/umd-dining-api/routes.py b/umd-dining-api/routes.py index 75aec04..d811df7 100644 --- a/umd-dining-api/routes.py +++ b/umd-dining-api/routes.py @@ -164,6 +164,10 @@ async def _resolve_availability(rec_nums: list, today: Optional[str] = None) -> # --- Trending cache (global, 5-min TTL) --- _trending_cache: dict = {'data': set(), 'expires': 0} _trending_lock = asyncio.Lock() +# Recent favorites when there are enough of them; otherwise fall back to the past year +# so Trending isn't empty between semesters. +TRENDING_WINDOWS_DAYS = (30, 365) +TRENDING_MIN_DISHES = 5 # --- Guest response cache (5-min TTL) --- _guest_menu_cache: dict = {} # {cache_key: {'data': response_dict, 'expires': float}} @@ -174,18 +178,20 @@ async def _get_trending(): async with _trending_lock: if time.time() < _trending_cache['expires']: return _trending_cache['data'] - cutoff = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat() - pipeline = [ - {'$match': {'added_at': {'$gte': cutoff}}}, - {'$group': {'_id': '$rec_num', 'count': {'$sum': 1}}}, - # one person's favorite isn't a trend - {'$match': {'count': {'$gte': 2}}}, - {'$sort': {'count': -1}}, - {'$limit': 50} - ] result = set() - async for doc in db.favorites.aggregate(pipeline): - result.add(doc['_id']) + for days in TRENDING_WINDOWS_DAYS: + cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat() + pipeline = [ + {'$match': {'added_at': {'$gte': cutoff}}}, + {'$group': {'_id': '$rec_num', 'count': {'$sum': 1}}}, + # one person's favorite isn't a trend + {'$match': {'count': {'$gte': 2}}}, + {'$sort': {'count': -1}}, + {'$limit': 50} + ] + result = {doc['_id'] async for doc in db.favorites.aggregate(pipeline)} + if len(result) >= TRENDING_MIN_DISHES: + break _trending_cache.update({'data': result, 'expires': time.time() + 300}) return result diff --git a/umd-dining-api/tests/conftest.py b/umd-dining-api/tests/conftest.py index 12bd2d3..afef4b2 100644 --- a/umd-dining-api/tests/conftest.py +++ b/umd-dining-api/tests/conftest.py @@ -83,9 +83,12 @@ async def update_one(self, query, update, upsert=False): return None def aggregate(self, pipeline): - """Pipelines aren't interpreted: yields whatever the test put in .aggregate_docs.""" + """Pipelines aren't interpreted: yields .aggregate_docs, or calls it with the pipeline.""" + docs = getattr(self, "aggregate_docs", []) + docs = docs(pipeline) if callable(docs) else docs + async def gen(): - for d in getattr(self, "aggregate_docs", []): + for d in docs: yield d return gen() diff --git a/umd-dining-api/tests/test_trending.py b/umd-dining-api/tests/test_trending.py new file mode 100644 index 0000000..7dbc1e8 --- /dev/null +++ b/umd-dining-api/tests/test_trending.py @@ -0,0 +1,45 @@ +"""Trending: recent favorites first, the past year when recent activity is thin.""" +from datetime import datetime, timedelta, timezone + +import pytest + +import routes +from conftest import FakeDB + + +def window_days(pipeline): + cutoff = datetime.fromisoformat(pipeline[0]["$match"]["added_at"]["$gte"]) + return round((datetime.now(timezone.utc) - cutoff) / timedelta(days=1)) + + +@pytest.fixture +def db(monkeypatch): + fake = FakeDB() + monkeypatch.setattr(routes, "db", fake) + monkeypatch.setitem(routes._trending_cache, "expires", 0) + return fake + + +def dishes(n, prefix="D"): + return [{"_id": f"{prefix}{i}", "count": 2} for i in range(n)] + + +async def test_busy_month_uses_recent_favorites_only(db): + seen = [] + db.favorites.aggregate_docs = lambda p: seen.append(window_days(p)) or dishes(8) + assert await routes._get_trending() == {f"D{i}" for i in range(8)} + assert seen == [30] + + +async def test_quiet_month_falls_back_to_the_past_year(db): + db.favorites.aggregate_docs = lambda p: dishes(1, "NEW") if window_days(p) == 30 else dishes(12, "OLD") + assert await routes._get_trending() == {f"OLD{i}" for i in range(12)} + + +async def test_a_single_favorite_is_never_a_trend(db): + pipelines = [] + db.favorites.aggregate_docs = lambda p: pipelines.append(p) or [] + assert await routes._get_trending() == set() + assert len(pipelines) == 2 # tried the recent window, then the past year + for pipeline in pipelines: + assert {"$match": {"count": {"$gte": 2}}} in pipeline diff --git a/umd-dining-app/ContentView.swift b/umd-dining-app/ContentView.swift index 47b04e0..75bb2ed 100644 --- a/umd-dining-app/ContentView.swift +++ b/umd-dining-app/ContentView.swift @@ -94,6 +94,10 @@ private struct TabBarItem: View { // Shared with DiningHallPickerView struct CustomTabBar: View { + /// Height above the bottom safe area. The bar floats over tab content, so views that pin + /// something to the bottom edge add this much room. + static let clearance: CGFloat = 52 + @Binding var selectedTab: Int var body: some View { diff --git a/umd-dining-app/Views/FavoritesView.swift b/umd-dining-app/Views/FavoritesView.swift index f2d7aab..5ebdb93 100644 --- a/umd-dining-app/Views/FavoritesView.swift +++ b/umd-dining-app/Views/FavoritesView.swift @@ -98,6 +98,7 @@ struct FavoritesView: View { .buttonStyle(.plain) .padding(.horizontal, 16) .padding(.vertical, 12) + .padding(.bottom, CustomTabBar.clearance) .background(Color.umdBackground) } .task(id: favorites.sortedFoods.map { $0.recNum }) { diff --git a/umd-dining-app/Views/FilterOverlay.swift b/umd-dining-app/Views/FilterOverlay.swift index 7cc0b17..1e8317a 100644 --- a/umd-dining-app/Views/FilterOverlay.swift +++ b/umd-dining-app/Views/FilterOverlay.swift @@ -76,7 +76,7 @@ struct FilterOverlay: View { } .padding(.horizontal, 16) .padding(.top, 16) - .padding(.bottom, 100) // space for sticky button + .padding(.bottom, 100 + CustomTabBar.clearance) // space for sticky button } .background(Color(.systemBackground)) .navigationTitle("Filter") @@ -132,7 +132,7 @@ struct FilterOverlay: View { } .buttonStyle(.plain) .padding(.horizontal, 16) - .padding(.bottom, 24) + .padding(.bottom, 12 + CustomTabBar.clearance) .background( LinearGradient( colors: [Color(.systemBackground).opacity(0), Color(.systemBackground)], From b0a80ff94090fd1caf016960083f4d2be9cf79ef Mon Sep 17 00:00:00 2001 From: Advay Monga Date: Mon, 21 Sep 2026 20:11:15 -0400 Subject: [PATCH 2/2] Use campus time for "today" _today_str() used the server clock, which is UTC on Elastic Beanstalk. From 8 PM Eastern the API treated tomorrow as today, so during dinner food pages showed "Unavailable" for dishes being served and search's available-today ordering was wrong. Use America/New_York; add tzdata so the zone is always resolvable. Co-Authored-By: Claude Fable 5.1 --- umd-dining-api/requirements.txt | 1 + umd-dining-api/routes.py | 4 +++- umd-dining-api/tests/test_availability.py | 14 ++++++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/umd-dining-api/requirements.txt b/umd-dining-api/requirements.txt index 53a7453..7d0f2f6 100644 --- a/umd-dining-api/requirements.txt +++ b/umd-dining-api/requirements.txt @@ -11,5 +11,6 @@ pymongo==4.16.0 python-dotenv==1.2.1 requests==2.32.5 slowapi==0.1.9 +tzdata==2026.4 uvicorn[standard]==0.34.0 uvloop==0.21.0 diff --git a/umd-dining-api/routes.py b/umd-dining-api/routes.py index d811df7..32d5410 100644 --- a/umd-dining-api/routes.py +++ b/umd-dining-api/routes.py @@ -8,6 +8,7 @@ import uuid from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo from typing import Optional import jwt as pyjwt @@ -61,7 +62,8 @@ def _ensure_string(value, field_name='field'): def _today_str() -> str: - return datetime.now().strftime('%-m/%-d/%Y') + # 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') async def _resolve_availability(rec_nums: list, today: Optional[str] = None) -> dict: diff --git a/umd-dining-api/tests/test_availability.py b/umd-dining-api/tests/test_availability.py index c15c859..40ba192 100644 --- a/umd-dining-api/tests/test_availability.py +++ b/umd-dining-api/tests/test_availability.py @@ -75,3 +75,17 @@ async def test_every_requested_rec_num_is_present_in_the_result(self, monkeypatc async def test_unparseable_stored_date_is_skipped(self, monkeypatch): result = await resolve(["A"], [menu("A", "not-a-date")], monkeypatch) assert result["A"]["unavailable_this_week"] is True + + +class TestToday: + def test_today_is_campus_time_not_server_time(self, monkeypatch): + from datetime import datetime, timezone + + class Frozen(datetime): + @classmethod + def now(cls, tz=None): + # 8:30 PM Eastern on 9/21 is already 9/22 in UTC, where the server runs + return datetime(2026, 9, 22, 0, 30, tzinfo=timezone.utc).astimezone(tz) + + monkeypatch.setattr(routes, "datetime", Frozen) + assert routes._today_str() == "9/21/2026"