Enhance UI, add Campus Map, and integrate Neo4j features#6
Conversation
…affold - Notification model + check_favorites_and_notify Celery task (4AM daily) - vision/log-meal endpoint via Google Gemini Vision (langchain) - Gym/protein boost in item_scorer, profile passed through meal planner - Neo4j connection settings + neo4j_models.py client skeleton - New URL route for vision endpoint
Basicbackend
- Fix fetchWaitTimes to call real API with mock fallback - Fix isOpen() with proper time comparison against operating_hours - Add isClosingSoon() badge for halls closing within 30 min - Add skeleton loading states for hall grid and menu items - Add toast notifications for favorites (no emojis) - Remove duplicate nav header from DashboardView - Add greeting and formatted date header to Dashboard - Add refresh button with spin animation for wait times - Add isLoading/planGenerated props to MealTimeline with distinct empty states - Strip card wrapper from WaitTimeWidget (owned by Dashboard now)
- Rebuild Login page with proper Tailwind classes (was using undefined CSS classes like .card, .input, .btn-primary causing broken UI) - Improve login error messages: distinguish 401 wrong credentials vs network down vs server error - Add loading spinner to sign-in button - Strip backdrop-blur/bg-white/5 glassmorphism from App.vue sidebar, DashboardView, DiningHallsView, MealTimeline - Replace with clean slate-800/slate-700 solid cards - Center all page content with max-w-* mx-auto - Sidebar: cleaner bg-slate-950, no blur, tighter sizing - DiningHallsView: simplified hall cards, cleaner menu item rows
- Delete neo4j_models.py - Remove Neo4j settings from settings.py - Remove Neo4j service from docker-compose.yml - Remove neo4j from requirements.txt - Strip Neo4j from .env.example and README
- CampusMap.vue: interactive Mapbox GL map with dining hall markers, user geolocation, and walking route on click - CampusMapView.vue: full page view with hall picker grid - Route /map added to router, nav link added to sidebar - VITE_MAPBOX_TOKEN env var for frontend, MAPBOX_ACCESS_TOKEN in backend .env.example - Updated README and docker-compose with Mapbox setup
- Add useTheme composable with localStorage persistence - Enable Tailwind darkMode: 'class' - Add sun/moon toggle button in sidebar - Full light mode support across App, DiningHalls, Dashboard, MealTimeline, WaitTimeWidget - Replace dynamic area dropdown with fixed North/Central/West Campus options - Group halls by region with divider headers when All Areas is selected - Flat grid shown when specific region is filtered
…dark mode styling
There was a problem hiding this comment.
Pull request overview
This PR modernizes the frontend UI (including dark mode), adds a Campus Map experience backed by Mapbox, and introduces new backend capabilities (Gemini Vision meal logging + daily favorite-meal notifications + a small personalization tweak in scoring).
Changes:
- Frontend UI refresh across core views (login, dining halls, dashboard) + dark-mode toggle and Tailwind dark-mode configuration.
- New Campus Map route/view/component using Mapbox GL + environment/config plumbing for Mapbox tokens.
- Backend additions: Vision endpoint/service for image-based meal logging; Celery Beat task + Notification model for favorite-meal alerts; meal scoring updated to incorporate profile context.
Reviewed changes
Copilot reviewed 27 out of 28 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
| frontend/tailwind.config.js | Enables class-based dark mode. |
| frontend/src/views/Login.vue | Login UI redesign + more granular error messaging. |
| frontend/src/views/DiningHallsView.vue | New layout/filtering/cards/skeletons + favorites toast + updated “open” logic. |
| frontend/src/views/DashboardView.vue | Dashboard redesign + greeting/date + manual wait-time refresh. |
| frontend/src/views/CampusMapView.vue | New Campus Map page that embeds the Mapbox map component. |
| frontend/src/stores/mainStore.js | Switches wait-time fetching to real API with fallback demo data. |
| frontend/src/router/index.js | Adds /map route and restructures routes (removes login route/guard). |
| frontend/src/composables/useTheme.js | Theme state + toggling via dark class. |
| frontend/src/components/WaitTimeWidget.vue | Styling updates + empty-state message. |
| frontend/src/components/MealTimeline.vue | Adds loading skeleton + clearer empty states. |
| frontend/src/components/CampusMap.vue | New Mapbox GL map w/ hall markers and walking directions. |
| frontend/src/api/index.js | Axios client simplified; auth interceptor logic removed. |
| frontend/src/App.vue | New sidebar styling + theme toggle + Campus Map nav link. |
| frontend/package.json | Adds mapbox-gl dependency. |
| frontend/package-lock.json | Locks transitive deps for Mapbox GL. |
| frontend/.env.example | Adds VITE_MAPBOX_TOKEN example. |
| docker-compose.yml | Adds env wiring for Google API key + Mapbox token into services. |
| backend/requirements.txt | Adds langchain-core dependency. |
| backend/bigredmacro/settings.py | Adds Celery Beat crontab schedule for favorite notifications. |
| backend/api/views.py | Adds authenticated Vision meal logging endpoint. |
| backend/api/urls.py | Routes Vision endpoint. |
| backend/api/tasks.py | Adds daily “check favorites and notify” task. |
| backend/api/services/vision_service.py | Gemini Vision integration via LangChain. |
| backend/api/services/meal_planner.py | Passes profile into scoring path. |
| backend/api/models.py | Adds Notification document model. |
| backend/api/ml/item_scorer.py | Adds “gym/high-protein” scoring boost based on profile. |
| backend/.env.example | Documents SECRET_KEY, GOOGLE_API_KEY, MAPBOX_ACCESS_TOKEN. |
| README.md | Overhauls setup instructions and documents new env vars/flows. |
Files not reviewed (1)
- frontend/package-lock.json: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| function hallsByRegion(regionKey) { | ||
| return halls.value.filter(hall => { | ||
| // Status match | ||
| const area = (hall.campus_area || '').toLowerCase() | ||
| const key = regionKey.toLowerCase() | ||
| if (!area.includes(key)) return false | ||
| const open = isOpen(hall) | ||
| if (filterStatus.value === 'open' && !open) return false | ||
| if (filterStatus.value === 'closed' && open) return false | ||
|
|
||
| // Area match | ||
| if (filterArea.value !== 'all' && hall.campus_area !== filterArea.value) return false | ||
|
|
||
| return true | ||
| }) | ||
| }) | ||
| } | ||
|
|
||
| const filteredHalls = computed(() => | ||
| halls.value.filter(hall => { | ||
| const open = isOpen(hall) | ||
| if (filterStatus.value === 'open' && !open) return false | ||
| if (filterStatus.value === 'closed' && open) return false | ||
| const area = (hall.campus_area || '').toLowerCase() | ||
| const selected = filterArea.value.toLowerCase() | ||
| if (filterArea.value !== 'all' && !area.includes(selected)) return false |
There was a problem hiding this comment.
hallsByRegion() / filteredHalls rely on hall.campus_area, but the backend DiningHallSerializer currently does not include campus_area in the /dining-halls/ payload. That means area.includes(...) will evaluate against an empty string and the grouped view (and any non-"all" area filter) will show zero halls. Either include campus_area in the backend serializer/endpoint response, or adjust the frontend to use a field that is actually returned.
| import axios from 'axios' | ||
| import { useAuthStore } from '@/stores/auth' | ||
|
|
||
| const api = axios.create({ | ||
| baseURL: '/api', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| }) |
There was a problem hiding this comment.
The axios client no longer attaches the JWT access token (and no longer auto-refreshes on 401). Since the backend is configured to use JWTAuthentication by default, all authenticated API calls (e.g., /dining-halls/, /profile/, /wait-times/) will start failing with 401. Restore an Authorization header mechanism (e.g., interceptor reading the Pinia auth store/localStorage) and, if needed, the refresh-on-401 retry logic, or explicitly pass auth headers in every API wrapper.
| const popup = new mapboxgl.Popup({ offset: 20, closeButton: false }) | ||
| .setHTML(` | ||
| <div style="font-family: system-ui; padding: 4px 0;"> | ||
| <strong style="color: #1e293b;">${hall.name}</strong> | ||
| <p style="margin: 4px 0 0; color: #64748b; font-size: 12px;">${hall.campus_area || ''} Campus</p> | ||
| </div> | ||
| `) |
There was a problem hiding this comment.
The Mapbox popup is built with setHTML(...) and interpolates hall.name / hall.campus_area directly into an HTML string. If any of those values ever come from an untrusted source, this becomes an XSS vector. Prefer popup.setText(...) for plain text, or build DOM nodes and use setDOMContent(...) (ensuring textContent is used for dynamic values).
| const popup = new mapboxgl.Popup({ offset: 20, closeButton: false }) | |
| .setHTML(` | |
| <div style="font-family: system-ui; padding: 4px 0;"> | |
| <strong style="color: #1e293b;">${hall.name}</strong> | |
| <p style="margin: 4px 0 0; color: #64748b; font-size: 12px;">${hall.campus_area || ''} Campus</p> | |
| </div> | |
| `) | |
| const popupContent = document.createElement('div') | |
| popupContent.style.fontFamily = 'system-ui' | |
| popupContent.style.padding = '4px 0' | |
| const popupTitle = document.createElement('strong') | |
| popupTitle.style.color = '#1e293b' | |
| popupTitle.textContent = hall.name || '' | |
| const popupSubtitle = document.createElement('p') | |
| popupSubtitle.style.margin = '4px 0 0' | |
| popupSubtitle.style.color = '#64748b' | |
| popupSubtitle.style.fontSize = '12px' | |
| popupSubtitle.textContent = `${hall.campus_area || ''} Campus` | |
| popupContent.appendChild(popupTitle) | |
| popupContent.appendChild(popupSubtitle) | |
| const popup = new mapboxgl.Popup({ offset: 20, closeButton: false }) | |
| .setDOMContent(popupContent) |
| <!-- Map --> | ||
| <CampusMap | ||
| :halls="halls" | ||
| :access-token="mapboxToken" | ||
| map-height="500px" | ||
| @hall-click="onHallClick" | ||
| /> |
There was a problem hiding this comment.
mapboxToken falls back to an empty string, but CampusMap requires accessToken and initMap() unconditionally uses it. When the token isn't configured, Mapbox GL typically throws at runtime and the view becomes unusable. Add a guard UI (e.g., render a setup message instead of the map when the token is missing) or make the prop optional and no-op init when blank.
| b64_image = request.data.get("image") | ||
| if not b64_image: | ||
| return Response({"detail": "No image provided."}, status=status.HTTP_400_BAD_REQUEST) | ||
|
|
||
| description = identify_food_from_image(b64_image) | ||
| if "Error" in description or "Unknown" in description: | ||
| return Response({"detail": description}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) | ||
|
|
There was a problem hiding this comment.
log_meal_from_image treats any Vision failure (including missing API key) as a 500 and returns the raw error/description string back to the client. This both makes client handling harder (config errors vs. bad input vs. upstream issues) and can leak internal exception details. Consider: (1) validating/limiting the input image payload, (2) returning 400 for invalid input, 503 (or 502) for upstream Vision failures, and (3) returning a generic error message while logging the detailed exception server-side.
| function parseTimeToday(timeStr) { | ||
| const [hours, minutes] = timeStr.split(':').map(Number) | ||
| const d = new Date() | ||
| d.setHours(hours, minutes, 0, 0) | ||
| return d | ||
| } | ||
|
|
||
| function getTodayName() { | ||
| return new Date().toLocaleDateString('en-US', { weekday: 'long' }) | ||
| } | ||
|
|
||
| function isOpen(hall) { | ||
| // A crude demo checker | ||
| return !!hall.operating_hours && Object.keys(hall.operating_hours).length > 0; | ||
| if (!hall.operating_hours || typeof hall.operating_hours !== 'object') return false | ||
| const todayHours = hall.operating_hours[getTodayName()] | ||
| if (!todayHours?.open || !todayHours?.close) return false | ||
| const now = new Date() | ||
| return now >= parseTimeToday(todayHours.open) && now < parseTimeToday(todayHours.close) | ||
| } | ||
|
|
||
| function isClosingSoon(hall) { | ||
| if (!hall.operating_hours || typeof hall.operating_hours !== 'object') return false | ||
| const todayHours = hall.operating_hours[getTodayName()] | ||
| if (!todayHours?.close) return false | ||
| const diffMs = parseTimeToday(todayHours.close) - new Date() | ||
| return diffMs > 0 && diffMs <= 30 * 60 * 1000 | ||
| } |
There was a problem hiding this comment.
The new open/closing-soon logic assumes hall.operating_hours is keyed by weekday name and contains {open, close} fields. In the backend model/serializer, operating_hours is a dict keyed by meal period (e.g., "lunch": "11:00am–2:00pm"), so this will always return false (and parseTimeToday will produce NaN for strings like "11:00am"). Adjust the frontend to match the actual operating_hours format (parse the "start–end" strings per period, and consider a hall open if any period window contains now).
| const regions = [ | ||
| { key: 'North', label: 'North Campus' }, | ||
| { key: 'Central', label: 'Central Campus' }, | ||
| { key: 'West', label: 'West Campus' }, |
There was a problem hiding this comment.
When "All Areas" is selected, halls are displayed only for regions listed in regions. The list currently omits "East", but the backend recognizes East campus locations; any East halls will silently disappear from the grouped view (and also can't be selected in the area dropdown). Add an East entry (or derive regions dynamically from the fetched data).
| { key: 'West', label: 'West Campus' }, | |
| { key: 'West', label: 'West Campus' }, | |
| { key: 'East', label: 'East Campus' }, |
| const toast = ref({ visible: false, message: '' }) | ||
| let toastTimer = null |
There was a problem hiding this comment.
toastTimer is a module-scoped timeout that is never cleared on component unmount. If the user navigates away quickly, the timer can still fire and mutate reactive state after unmount. Clear the timeout in an onUnmounted hook (and set toastTimer = null) to avoid lingering timers.
| api_key = os.getenv("GOOGLE_API_KEY") | ||
| if not api_key: | ||
| return "Unknown Food (API Key missing)" | ||
|
|
||
| llm = ChatGoogleGenerativeAI(model="gemini-1.5-flash", google_api_key=api_key) | ||
|
|
||
| msg = HumanMessage( | ||
| content=[ | ||
| {"type": "text", "text": "What food item is in this image? Provide a brief, concise description outlining the main components (e.g., 'Grilled chicken salad with tomatoes'). Only reply with the description, no conversational text."}, | ||
| {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{base64_image}"}}, | ||
| ] | ||
| ) | ||
|
|
||
| try: | ||
| response = llm.invoke([msg]) | ||
| return response.content.strip() | ||
| except Exception as e: | ||
| return f"Error identifying food: {str(e)}" |
There was a problem hiding this comment.
identify_food_from_image returns exception text directly in the function result ("Error identifying food: ..."), which is later forwarded to clients. This can expose implementation details and makes error handling rely on brittle substring checks. Prefer raising a typed exception (or returning a structured result) so the API layer can map failures to appropriate HTTP statuses and return a safe, consistent error payload.
| ] | ||
| try { | ||
| const res = await getWaitTimes() | ||
| waitTimes.value = res.data |
There was a problem hiding this comment.
fetchWaitTimes() assigns res.data directly to waitTimes, but the backend /wait-times/ response uses fields like dining_hall_name and estimated_wait_minutes (see WaitTimeSerializer). WaitTimeWidget.vue expects { name, wait_time }, so once the real API call succeeds the widget will display undefined/break (e.g., hall.name.charAt(0)). Map the API response into the UI shape, or update the widget to use the backend field names consistently.
| waitTimes.value = res.data | |
| const waitTimeData = Array.isArray(res.data) ? res.data : [] | |
| waitTimes.value = waitTimeData.map((hall) => ({ | |
| id: hall.id, | |
| name: hall.name ?? hall.dining_hall_name, | |
| wait_time: hall.wait_time ?? hall.estimated_wait_minutes, | |
| capacity: hall.capacity | |
| })) |
No description provided.