Skip to main content

Overview

EmptyClassroom uses a modern full-stack architecture with a Next.js frontend and FastAPI backend, deployed separately on Railway with Redis for caching.
The application is optimized for low latency and high availability with a 24-hour cache strategy to minimize API calls to BU’s scheduling system.

Architecture Diagram

┌─────────────────┐
│   Next.js App   │
│   (Frontend)    │
│                 │
│  - React UI     │
│  - API Routes   │
│  - SSR/CSR      │
└────────┬────────┘

         │ HTTPS

┌─────────────────┐      ┌──────────────┐
│  FastAPI Server │◄────►│    Redis     │
│    (Backend)    │      │   (Cache)    │
│                 │      │              │
│  - REST API     │      │  24hr TTL    │
│  - Data Parser  │      └──────────────┘
│  - Wake Logic   │
└────────┬────────┘

         │ HTTPS

┌─────────────────┐
│   BU 25Live     │
│  Scheduling API │
└─────────────────┘

Technology Stack

Next.js Frontend

Framework: Next.js 15 with App RouterLanguage: TypeScriptStyling: Tailwind CSSKey Features:
  • Server-side rendering (SSR) for initial page load
  • Client-side state management with React hooks
  • API route proxies to backend (/api/*)
  • Real-time cooldown countdown UI
Deployment: Vercel (recommended) or Railway

Frontend Architecture

API Route Proxies

The Next.js frontend uses server-side API routes to proxy requests to the FastAPI backend. This provides:
  1. CORS handling - Eliminates browser CORS issues
  2. Environment variable security - Backend URL not exposed to client
  3. Request/response transformation - Can modify data shape if needed
import { NextResponse } from 'next/server';

export async function GET(): Promise<NextResponse> {
    try {
        const response = await fetch('https://emptyclassroom-production.up.railway.app/api/open-classrooms');
        if (!response.ok) {
            return NextResponse.json(
                { error: `Failed to fetch data: ${response.status}` },
                { status: response.status }
            );
        }
        const data = await response.json();
        return NextResponse.json(data);
    } catch (error) {
        return NextResponse.json(
            { error: error },
            { status: 500 }
        );
    }
}

Component Structure

The main page (app/page.tsx) orchestrates:
1

Initial Data Fetch

On mount, fetch classroom availability and cooldown status via /api/open-classrooms and /api/cooldown-status
2

State Management

Manage UI state with React hooks:
  • lastUpdated - Timestamp of last data refresh
  • cooldownRemaining - Minutes until next refresh allowed
  • buildings - Classroom availability data by building
  • isRefreshing - Loading state for refresh button
3

Real-time Countdown

Use setInterval to update cooldown timer every second:
useEffect(() => {
  if (!cooldownExpiresAt) return;

  const interval = setInterval(() => {
    const remainingMs = Math.max(0, cooldownExpiresAt - Date.now());
    const remainingMinutes = remainingMs / (1000 * 60);

    if (remainingMinutes <= 0) {
      setCooldownRemaining(null);
      setCooldownExpiresAt(null);
    } else {
      setCooldownRemaining(remainingMinutes);
    }
  }, 1000);

  return () => clearInterval(interval);
}, [cooldownExpiresAt]);
4

Refresh Handler

When user clicks refresh, POST to /api/refresh and update all state accordingly

Backend Architecture

FastAPI Application Structure

The backend is organized into focused modules:

main.py

FastAPI app, endpoints, startup logic, cooldown enforcement

cache.py

Redis connection, cache update function

classroom_availability.py

BU API integration, data parsing, availability calculation

config.py

Environment variables, constants, classroom/building definitions

Main Application File

The FastAPI app (backend/main.py) handles:
backend/main.py (startup event)
@app.on_event('startup')
async def startup_event():
    # Wait for Redis to be ready
    for i in range(5):
        try:
            rd.ping()
            print('Redis connection established')
            break
        except Exception:
            print(f'Waiting for Redis to be ready... (attempt {i+1}/5)')
            await asyncio.sleep(1)

    try:
        print('App starting up - checking if refresh is needed')
        
        # Check if refresh needed
        if should_refresh_on_wake():
            print('App was sleeping or no recent data - fetching fresh data')
            await update_cache()
            
            # Set refresh timestamp
            now = datetime.now(pytz.timezone('America/New_York'))
            rd.set('classrooms:last_refresh', now.isoformat(), ex=CACHE_EXPIRY)
            print('Wake-up refresh completed successfully')
        else:
            print('Recent data available, skipping wake-up refresh')
            
    except Exception as e:
        print(f'Failed to handle wake-up refresh: {str(e)}')
The startup event implements wake-up refresh logic - if the app restarts and data wasn’t fetched today, it automatically refreshes the cache. See Caching Strategy for details.

API Endpoints

@app.get('/api/open-classrooms')
async def get_classroom_availability_by_building():
    try:
        # Check cache first
        cache = rd.get('classrooms:availability')
        
        if cache:
            print('Cache hit')
            availability_data = json.loads(cache)
        else:
            print('Cache miss - fetching new data')
            availability_data = await get_classroom_availability()
            rd.set('classrooms:availability', json.dumps(availability_data), ex=CACHE_EXPIRY)
            
            # Update last refresh timestamp when fetching new data
            now = datetime.now(pytz.timezone('America/New_York'))
            rd.set('classrooms:last_refresh', now.isoformat(), ex=CACHE_EXPIRY)

        # Organize response by building
        res = {}
        for building_code, building_data in BUILDINGS.items():
            res[building_code] = {
                "code": building_data["code"],
                "name": building_data["name"],
                "classrooms": []
            }
        
        # Add classroom data to buildings
        for classroom_id, classroom_data in CLASSROOMS.items():
            building_code = classroom_data["building_code"]
            if building_code in res:
                res[building_code]["classrooms"].append({
                    "id": classroom_data["id"],
                    "name": classroom_data["name"],
                    "availability": availability_data.get(classroom_id, [])
                })
        
        return {
            "buildings": res,
            "last_updated": last_updated
        }

TypeScript Type Definitions

The frontend uses strict TypeScript types for API responses:
app/types/buildings.ts
export interface Building {
  name: string;
  code: string;
  classrooms: Classroom[];
}

export interface Classroom {
  id: string;
  name: string;
  availability: TimeSlot[];
}

export interface TimeSlot {
  start: string;  // Format: "HH:MM:SS"
  end: string;    // Format: "HH:MM:SS"
}

export interface OpenClassroomsResponse {
  [buildingCode: string]: Building;
}

Configuration Management

Configuration is centralized in backend/config.py:
import os
from dotenv import load_dotenv

if os.getenv("RAILWAY_ENV") is None:
    load_dotenv()

REDIS_URL = os.getenv('REDIS_URL')
API_URL = os.getenv('API_URL')
Classroom and building configurations are hardcoded in config.py. To add new buildings or classrooms, update this file and redeploy the backend.

Deployment Architecture

Railway Configuration

Backend Service:
  • Start Command: uvicorn main:app --host=0.0.0.0 --port=${PORT:-5000}
  • Environment Variables:
    • REDIS_URL - Auto-injected by Railway Redis plugin
    • API_URL - BU 25Live API endpoint
    • RAILWAY_ENV - Auto-set by Railway
Redis Service:
  • Type: Railway Redis plugin
  • Persistence: Enabled
  • Eviction: No eviction (TTL-based expiry only)

CORS Configuration

The backend allows all origins for development:
backend/main.py
app.add_middleware(
    CORSMiddleware,
    allow_origins=['*'],
    allow_credentials=True,
    allow_methods=['*'],
    allow_headers=['*'],
)
For production, restrict allow_origins to your frontend domain(s) only.

Next Steps

Data Sources

Learn how we integrate with BU’s 25Live scheduling API

Caching Strategy

Understand the Redis caching implementation and wake-up logic

Build docs developers (and LLMs) love