Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions umd-dining-api/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
32 changes: 20 additions & 12 deletions umd-dining-api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -164,6 +166,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}}
Expand All @@ -174,18 +180,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

Expand Down
7 changes: 5 additions & 2 deletions umd-dining-api/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
14 changes: 14 additions & 0 deletions umd-dining-api/tests/test_availability.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
45 changes: 45 additions & 0 deletions umd-dining-api/tests/test_trending.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions umd-dining-app/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions umd-dining-app/Views/FavoritesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down
4 changes: 2 additions & 2 deletions umd-dining-app/Views/FilterOverlay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)],
Expand Down
Loading