I’ve always been fascinated by history and weather data.

In this project, I built an app that weaves together these data: hourly weather forecasts from Open-Meteo and “on this day” historical events from History.muffinlabs.

Each hour becomes its story card, pairing the temperature, precipitation, and wind speed I’m experiencing now (or the weather of any date I choose) with fascinating moments from the past.

At the same time, I will showcase modern Python techniques - async HTTP requests, clean data models, and lightweight templates - while delivering a playful, visually engaging experience.

By the end of this tutorial, you’ll see how I used FastAPI and Jinja2 to turn raw JSON into a dynamic timeline of weather and history that feels informative and fun.

You can download the full source code here:

Download Source Code


SPONSORED By Python's Magic Methods - Beyond init and str

CTA Image

This book offers an in-depth exploration of Python's magic methods, examining the mechanics and applications that make these features essential to Python's design.

Get the eBook


Prerequisites & Setup

Before diving into the code, here’s what you’ll need to have in place on your machine—and how I got my environment ready:

Languages & Frameworks

  • Python 3.10+: I used Python 3.11 for this project, but any 3.10 or newer interpreter works perfectly.
  • FastAPI: powers the async web server and routing.
  • aiohttp: handles all of my non-blocking HTTP requests to the weather and history APIs.
  • Jinja2: renders the HTML templates

Installing Dependencies

Create a virtual environment (recommended):

python -m venv .venv
source .venv/bin/activate

requirements.txt

I keep the key packages in a simple requirements.txt:

fastapi
uvicorn
jinja2
python-multipart
requests
python-dateutil
aiohttp

Install:

pip install -r requirements.txt

Obtaining Free Access

One of my favorite parts of this project is that no API keys or signup flows are required:

  • Open-Meteo: their forecast and archive endpoints are fully open and free - just pass latitude/longitude in your query.
  • History.muffinlabs.com: the “on this day” API is also public and doesn’t require authentication.

Project Structure

Here’s how I’ve organized the code for this app - each file serves a clear purpose to keep things modular and maintainable:

/app
  ├─ utils.py
  ├─ data_models.py
  ├─ main.py
  └─ templates/
       ├─ base.html  
       └─ index.html

utils.py

Contains all of my asynchronous helper functions:

  • search_location() for geocoding via Open-Meteo
  • fetch_current_weather() and fetch_weather_data() to pull today’s and historical forecasts
  • fetch_historical_events() for “on this day” data
  • distribute_events() and create_story_cards() to assemble the per-hour story cards

data_models.py

Defines the data structures I use throughout the app, all as @dataclass:

  • WeatherData, DailyWeatherData for weather
  • HistoricalEvent, HistoricalData for events, births, deaths
  • StoryCard to bundle one hour’s weather + historical snippets

main.py

The FastAPI application entry point:

  • Mounts static files and sets up Jinja2 templates
  • Defines three routes:
  • - GET / renders the home form
    • GET /search-location powers the HTMX drop-down for live location suggestions
    • POST / handles form submission, orchestrates data fetching, and renders the story cards

templates/base.html

The HTML skeleton with imports (Bootstrap, HTMX) and a {% block content %} placeholder for child pages.

templates/index.html

Extends base.html to provide:

  • A date picker and location input (with HTMX dropdown)
  • A loop over the story_cards context variable to display each hour’s weather details alongside its historical events

This layout keeps concerns separated - data models, external-API logic, web-server routes, and presentation - so you can work on each part independently.


Defining the Data Models (data_models.py)

In data_models.py, I lean on Python’s @dataclass decorator to give structure and type safety to every piece of data flowing through the app.

Here’s how I break it down, first the Weather data:

@dataclass
class WeatherData:
    time: datetime
    temperature: float
    precipitation: float
    wind_speed: float
    weather_code: int

@dataclass
class DailyWeatherData:
    sunrise: datetime
    sunset: datetime

WeatherData captures each hour’s snapshot: the timestamp plus temperature (°C), precipitation (mm), wind speed (m/s), and the weather code for icon lookups.

DailyWeatherData holds just the sunrise and sunset times for the selected date, so I can show this info in the corresponding hour card.

Then the Historical data:

@dataclass
class HistoricalEvent:
    year: int
    text: str
    wikipedia_url: Optional[str] = None

@dataclass
class HistoricalData:
    events: List[HistoricalEvent]
    births: List[HistoricalEvent]
    deaths: List[HistoricalEvent]

HistoricalEvent represents a single “on this day” entry—year, descriptive text, and an optional Wikipedia link.

HistoricalData bundles together three lists of those events (major occurrences, notable births, and deaths) for easy passing around.

Finally, I merge the two into a story card:

@dataclass
class StoryCard:
    weather: WeatherData
    daily: DailyWeatherData
    events: List[HistoricalEvent]
    births: List[HistoricalEvent]
    deaths: List[HistoricalEvent]

StoryCard ties one hour’s weather (weather) and the day’s sunrise/sunset (daily) to up to three randomly-distributed events, births, and deaths.

By keeping these models pure and decoupled, the utility functions simply fill in instances of these classes - and the FastAPI endpoints and templates can treat each card as a single, cohesive unit.


Utility Module Deep-Dive (utils.py)

In utils.py, I encapsulate all the API calls and data‐shaping logic into a set of clear, reusable functions.

Here’s how each piece works.

Location Search

class LocationData(TypedDict):
    name: str
    latitude: float
    longitude: float
    country: str
    timezone: str

async def search_location(query: str) -> List[LocationData]:
    """Search for locations using Open-Meteo's Geocoding API."""
    params = {
        "name": query,
        "count": 5,
        "language": "en",
        "format": "json"
    }

    try:
        async with aiohttp.ClientSession() as session:
            async with session.get(GEOCODING_API_URL, params=params) as response:
                response.raise_for_status()
                data = await response.json()

                if data.get("results"):
                    return [
                        LocationData(
                            name=result["name"],
                            latitude=result["latitude"],
                            longitude=result["longitude"],
                            country=result["country"],
                            timezone=result["timezone"]
                        )
                        for result in data["results"]
                    ]
                return []
    except Exception:
        return []

Code description:

  • It calls Open-Meteo’s geocoding endpoint with the user’s query and requests for up to five matches.
  • The function returns a list of LocationData TypedDicts, each containing name, latitude, longitude, country, and timezone.

Fetching Weather

Function fetch_current_weather(...):

async def fetch_current_weather(latitude: float, longitude: float) -> Tuple[List[WeatherData], DailyWeatherData]:
    """Fetch current weather data from Open-Meteo API."""
    today = datetime.now().date()

    params = {
        "latitude": latitude,
        "longitude": longitude,
        "hourly": "temperature_2m,precipitation,wind_speed_10m,weather_code",
        "daily": "sunrise,sunset",
        "timezone": "auto",
        "start_date": today.strftime("%Y-%m-%d"),
        "end_date": today.strftime("%Y-%m-%d")
    }

    async with aiohttp.ClientSession() as session:
        async with session.get(OPEN_METEO_CURRENT_URL, params=params) as response:
            response.raise_for_status()
            data = await response.json()

    # Parse hourly data
    weather_data = []
    for i in range(len(data["hourly"]["time"])):
        weather_data.append(WeatherData(
            time=datetime.fromisoformat(data["hourly"]["time"][i]),
            temperature=data["hourly"]["temperature_2m"][i] or 0.0,
            precipitation=data["hourly"]["precipitation"][i] or 0.0,
            wind_speed=data["hourly"]["wind_speed_10m"][i] or 0.0,
            weather_code=data["hourly"]["weather_code"][i] or 0
        ))

    # Parse daily data
    daily_data = DailyWeatherData(
        sunrise=datetime.fromisoformat(data["daily"]["sunrise"][0]),
        sunset=datetime.fromisoformat(data["daily"]["sunset"][0])
    )

    return weather_data, daily_data

Code description:

  • Fetches today’s hourly temperature, precipitation, wind speed, and weather code.
  • Parses the single‐day “daily” response into sunrise/sunset times.

Function fetch_weather_data(...):

async def fetch_weather_data(latitude: float, longitude: float, date: datetime) -> Tuple[List[WeatherData], DailyWeatherData]:
    """Fetch weather data from Open-Meteo API, using current API for today's data."""
    today = datetime.now().date()
    selected_date = date.date()

    if selected_date == today:
        return await fetch_current_weather(latitude, longitude)

    # For historical data, use the archive API
    params = {
        "latitude": latitude,
        "longitude": longitude,
        "start_date": date.strftime("%Y-%m-%d"),
        "end_date": date.strftime("%Y-%m-%d"),
        "hourly": "temperature_2m,precipitation,wind_speed_10m,weather_code",
        "daily": "sunrise,sunset",
        "timezone": "auto"
    }

    async with aiohttp.ClientSession() as session:
        async with session.get(OPEN_METEO_BASE_URL, params=params) as response:
            response.raise_for_status()
            data = await response.json()

    # Parse hourly data
    weather_data = []
    for i in range(len(data["hourly"]["time"])):
        weather_data.append(WeatherData(
            time=datetime.fromisoformat(data["hourly"]["time"][i]),
            temperature=data["hourly"]["temperature_2m"][i] or 0.0,
            precipitation=data["hourly"]["precipitation"][i] or 0.0,
            wind_speed=data["hourly"]["wind_speed_10m"][i] or 0.0,
            weather_code=data["hourly"]["weather_code"][i] or 0
        ))

    # Parse daily data
    daily_data = DailyWeatherData(
        sunrise=datetime.fromisoformat(data["daily"]["sunrise"][0]),
        sunset=datetime.fromisoformat(data["daily"]["sunset"][0])
    )

    return weather_data, daily_data

Code description:

  • If the requested date is today, it calls the previouly defined fetch_current_weather.
  • Otherwise, It calls Open-Meteo’s archive API to retrieve historical data for that single date.

Fetching Historical Events

async def fetch_historical_events(date: datetime) -> HistoricalData:
    """Fetch historical events from History.muffinlabs.com API."""
    url = f"{HISTORY_API_BASE_URL}/{date.month}/{date.day}"

    async with aiohttp.ClientSession() as session:
        async with session.get(url) as response:
            response.raise_for_status()
            data = await response.json()

    def parse_events(event_list: List[Dict[str, Any]]) -> List[HistoricalEvent]:
        return [
            HistoricalEvent(
                year=event["year"],
                text=event["text"],
                wikipedia_url=event.get("links", [{}])[0].get("link")
            )
            for event in event_list
        ]

    return HistoricalData(
        events=parse_events(data["data"]["Events"]),
        births=parse_events(data["data"]["Births"]),
        deaths=parse_events(data["data"]["Deaths"])
    )

Code description:

  • Calls the History.muffinlabs endpoint for that month/day.
  • Converts each event, birth, and death into a HistoricalEvent(year, text, wikipedia_url) and bundles them into a HistoricalData object.

Story-Card Logic

Function distribute_events(...):

def distribute_events(events: List[HistoricalEvent], num_cards: int) -> List[List[HistoricalEvent]]:
    """Distribute events evenly across cards, with max 3 random events per card.
    Avoids repeating events unless there aren't enough unique events."""
    if not events:
        return [[] for _ in range(num_cards)]

    # Sort events by year to maintain some historical context
    sorted_events = sorted(events, key=lambda x: x.year)

    # Calculate how many events we need in total (max 3 per card)
    total_events_needed = min(3 * num_cards, len(sorted_events))

    # If we have enough unique events, distribute them without repetition
    if len(sorted_events) >= total_events_needed:
        # Create a list for each card
        distributed_events = []
        event_index = 0

        for _ in range(num_cards):
            # Get up to 3 events for this card
            card_events = []
            for _ in range(3):
                if event_index < len(sorted_events):
                    card_events.append(sorted_events[event_index])
                    event_index += 1
                else:
                    break

            distributed_events.append(card_events)

        return distributed_events

    # If we don't have enough unique events, we'll need to repeat some
    else:
        # Create list for each card
        distributed_events = []
        for _ in range(num_cards):
            # Select 3 random events, allowing repetition
            card_events = random.choices(sorted_events, k=min(3, len(sorted_events)))
            distributed_events.append(card_events)

        return distributed_events

Code description:

  • Evenly assigns up to three unique events per hour card.
  • If there aren’t enough events to fill every card uniquely, it allows random repeats.

Function create_story_cards(...):

def create_story_cards(weather_data: List[WeatherData], daily_data: DailyWeatherData, historical_data: HistoricalData) -> List[StoryCard]:
    """Create story cards combining weather and historical data for each hour."""
    num_cards = len(weather_data)

    # Distribute events, births, and deaths across cards
    distributed_events = distribute_events(historical_data.events, num_cards)
    distributed_births = distribute_events(historical_data.births, num_cards)
    distributed_deaths = distribute_events(historical_data.deaths, num_cards)

    # Create cards with distributed historical data
    story_cards = []
    for i, weather in enumerate(weather_data):
        story_cards.append(
            StoryCard(
                weather=weather,
                daily=daily_data,
                events=distributed_events[i],
                births=distributed_births[i],
                deaths=distributed_deaths[i]
            )
        )

    return story_cards

Code description:

  • It generates three parallel lists - one for events, births, and deaths - each matched to the number of hourly data points.
  • Then they are zipped together with each hour’s WeatherData (and the single DailyWeatherData) into a list of StoryCard instances, ready for rendering.

FastAPI Back-End (main.py)

In this section, I’ll walk through how I wire up the FastAPI server, define routes, and handle errors in main.py.

App Initialization

Right at the top of main.py, I set up the FastAPI app, configure Jinja2 and basic logging:

app = FastAPI()
templates = Jinja2Templates(directory="templates")

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

Code description:

  • Jinja2Templates points to the templates/ folder, for using TemplateResponse later.
  • Basic logs are setup with INFO level

Routes

Route GET / → render home form:

@app.get("/", response_class=HTMLResponse)
async def home(request: Request):
    return templates.TemplateResponse(
        "index.html",
        {
            "request": request,
            "error": None,
            "today": datetime.now().strftime("%Y-%m-%d"),
            "selected_date": datetime.now().strftime("%Y-%m-%d"),
            "location_data": None,
            "locations": []
        }
    )

Code description:

  • When users first open the page, it renders index.html with an empty form.
  • The current date is passed as selected_date to the template, so the date-picker can default to today’s date.

Route GET /search-location → HTMX endpoint for live location suggestions

@app.get("/search-location", response_class=HTMLResponse)
async def search_location_endpoint(request: Request, location: str):
    if len(location) < 2:
        return ""

    locations = await search_location(location)
    if not locations:
        return ""

    # Create HTML for location results
    html = ""
    for location in locations:
        location_text = f"{location['name']} ({location['latitude']}, {location['longitude']}) {location['country']}"
        html += f"""
        "dropdown-item" href="#" 
           hx-on:click="document.getElementById('locationInput').value = '{location_text}'; return false;"
           hx-on:click="document.getElementById('locationResults').style.display = 'none';">
            "fw-bold">{location['name']}, {location['country']}
            "text-muted small">({location['latitude']:.4f}, {location['longitude']:.4f})
        
        """

    return html

Code description:

  • As the user types in the location field, HTMX fires requests to this endpoint.
  • It return only the dropdown items’ HTML, which HTMX injects under the input.

Route POST / → parse form, validate date/location, call utils, render story cards:

@app.post("/", response_class=HTMLResponse)
async def get_weather_history(
    request: Request,
    location: str = Form(...),
    date: str = Form(...)
):
    try:
        # Parse location data from the form
        try:
            location_data = {
                "name": location.split(" (")[0],
                "latitude": float(location.split("(")[1].split(",")[0]),
                "longitude": float(location.split(",")[1].split(")")[0]),
                "country": location.split(")")[1].strip(),
                "timezone": "auto"  # We'll use auto timezone
            }
        except (IndexError, ValueError):
            raise HTTPException(status_code=400, detail="Invalid location format")

        # Parse date
        try:
            selected_date = datetime.strptime(date, "%Y-%m-%d")
        except ValueError:
            raise HTTPException(status_code=400, detail="Invalid date format")

        # Check if the date is in the future
        today = datetime.now().date()
        if selected_date.date() > today:
            raise HTTPException(status_code=400, detail="Cannot fetch weather data for future dates")

        # Fetch weather and historical data
        weather_data, daily_data = await fetch_weather_data(
            location_data["latitude"],
            location_data["longitude"],
            selected_date
        )
        historical_data = await fetch_historical_events(selected_date)

        # Create story cards
        story_cards = create_story_cards(weather_data, daily_data, historical_data)

        # Prepare template context
        context = {
            "request": request,
            "story_cards": story_cards,
            "selected_date": selected_date.strftime("%Y-%m-%d"),
            "location_data": location_data,
            "weather_data": weather_data,
            "today": datetime.now().strftime("%Y-%m-%d"),
            "error": None,
            "locations": [],
            "is_current_day": selected_date.date() == today
        }

        return templates.TemplateResponse("index.html", context)

    except HTTPException as e:
        return templates.TemplateResponse(
            "index.html",
            {
                "request": request,
                "error": e.detail,
                "today": datetime.now().strftime("%Y-%m-%d"),
                "selected_date": date,
                "location_data": None,
                "locations": []
            }
        )
    except Exception as e:
        logger.error(f"Error processing request: {str(e)}")
        return templates.TemplateResponse(
            "index.html",
            {
                "request": request,
                "error": "An unexpected error occurred",
                "today": datetime.now().strftime("%Y-%m-%d"),
                "selected_date": date,
                "location_data": None,
                "locations": []
            }
        )

Code description:

  • It parse the form inputs (location, date), validate them, then orchestrate the calls to utils.py.
  • Finally, It re-render index.html, this time supplying the story_cards list for display.

Error Handling

Throughout the POST handler, it raises exception against user errors and unexpected failures:

Form validation: if the location string can’t be parsed into latitude/longitude:

raise HTTPException(status_code=400, detail="Invalid location format")

Future‐date guard: It compares the chosen date against datetime.now().date():

raise HTTPException(status_code=400, detail="Cannot fetch weather data for future dates")

Generic exceptions: It wraps the entire logic in a try/except Exception as e, logging the errors for debugging, and return the template with an error message so users see a friendly notice rather than a 500.


Front-End with Jinja2 Templates

I built the UI using simple Jinja2 templates, with Bootstrap for styling and HTMX for dynamic behaviors.

Template base.html

This is my “skeleton” layout that the index.html extends. In the , it pulls in Bootstrap’s CSS/JS and HTMX:

</span>
 lang="en">

     charset="UTF-8">
     name="viewport" content="width=device-width, initial-scale=1.0">
    {% block title %}Historical Weather Storybook{% endblock %}
    
     href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
    
     href="https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.css" rel="stylesheet">
    
    <span class="na">src="https://unpkg.com/[email protected]">
    
    
        /* HTMX Loading Indicator */
        .htmx-indicator {
            display: none;
            position: absolute;
            right: 10px;
            top: 50%;
            transform: translateY(-50%);
        }
        .htmx-request .htmx-indicator {
            display: inline-block;
        }
        .htmx-request.htmx-indicator {
            display: inline-block;
        }

        .weather-card {
            transition: transform 0.2s;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        }
        .weather-card:hover {
            transform: translateY(-5px);
            box-shadow: 0 4px 8px rgba(0,0,0,0.2);
        }
        .location-dropdown {
            max-width: calc(100% - 2rem);
            width: auto;
            min-width: 300px;
            max-height: 300px;
            overflow-y: auto;
            margin-top: 0.25rem;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        }
        .location-dropdown .dropdown-item {
            padding: 0.5rem 1rem;
            white-space: normal;
            border-bottom: 1px solid rgba(0,0,0,0.05);
        }
        .location-dropdown .dropdown-item:last-child {
            border-bottom: none;
        }
        .location-dropdown .dropdown-item:hover {
            background-color: rgba(13, 110, 253, 0.05);
        }
        .sun-event {
            background-color: rgba(255, 193, 7, 0.1);
            padding: 0.75rem 1rem;
            border-radius: 0.25rem;
            border-left: 4px solid #ffc107;
        }
        .sun-event:hover {
            transform: scale(1.02);
        }
        .sunrise {
            background: linear-gradient(135deg, rgba(255, 193, 7, 0.1), rgba(255, 152, 0, 0.1));
            border-left: 4px solid #ffc107;
        }
        .sunset {
            background: linear-gradient(135deg, rgba(255, 87, 34, 0.1), rgba(255, 152, 0, 0.1));
            border-left: 4px solid #ff5722;
        }
        .sun-icon {
            font-size: 1.5rem;
            width: 2.5rem;
            height: 2.5rem;
            display: flex;
            align-items: center;
            justify-content: center;
            border-radius: 50%;
            background: rgba(255, 255, 255, 0.9);
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        }
        .sunrise .sun-icon {
            color: #ffc107;
        }
        .sunset .sun-icon {
            color: #ff5722;
        }
        .sun-info {
            flex: 1;
        }
        .sun-title {
            font-size: 0.875rem;
            color: #6c757d;
            margin-bottom: 0.25rem;
            font-weight: 600;
        }
        .sun-time {
            font-size: 1.125rem;
            font-weight: 700;
        }
        .sunrise .sun-time {
            color: #ffc107;
        }
        .sunset .sun-time {
            color: #ff5722;
        }
        .historical-event {
            border-left: 4px solid #0d6efd;
            padding-left: 1rem;
            margin-bottom: 1rem;
            background-color: rgba(13, 110, 253, 0.05);
            padding: 0.75rem 1rem;
            border-radius: 0.25rem;
        }
        .historical-event.birth {
            border-left-color: #198754;
            background-color: rgba(25, 135, 84, 0.05);
        }
        .historical-event.death {
            border-left-color: #6c757d;
            background-color: rgba(108, 117, 125, 0.05);
        }
        .weather-info {
            background-color: rgba(13, 110, 253, 0.05);
            padding: 1rem;
            border-radius: 0.25rem;
        }
        .card-header {
            background: linear-gradient(45deg, #0d6efd, #0a58ca);
        }
        .btn-link {
            padding: 0;
            font-size: 0.875rem;
            display: block;
            margin-top: 0.25rem;
        }
    

 class="bg-light">
     class="navbar navbar-expand-lg navbar-dark bg-primary">
         class="container">
             class="navbar-brand" href="/">
                 class="bi bi-clock-history">
                Historical Weather Storybook
            
        
    

     class="container mt-4">
        {% block content %}{% endblock %}
    

    
    <span class="na">src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js">
    
    {% block scripts %}{% endblock %}

 



    Enter fullscreen mode
    


    Exit fullscreen mode
    




Code description:

Bootstrap gives me responsive grid and ready-to-use components.

HTMX enables small, server-rendered HTML fragments to be fetched and swapped in without a full page reload.
It exposes a {% block content %} so child templates (like index.html) fill in the main area.
Additional CSS is also added to "beautify" the pages

  
  
  Template index.html
This template extends base.html and provides:
Date & Location Form

HTMX-powered dropdown for live geocoding suggestions

Loop over story_cards to render each hour’s weather + history


{% extends "base.html" %}

{% block content %}
 class="row">
     class="col-md-8 offset-md-2">
         class="card">
             class="card-body">
                 class="card-title">Select Date and Location
                 id="dateForm" method="POST" action="/" class="row g-3">
                     class="col-md-6">
                         for="datePicker" class="form-label">Date
                         type="date" class="form-control" id="datePicker" name="date" 
                               min="1940-01-01" max="{{ today }}" value="{{ selected_date }}">
                    
                     class="col-md-6">
                         for="locationInput" class="form-label">Location
                         class="input-group">
                             type="text" class="form-control" id="locationInput" name="location"
                                   placeholder="Enter city name..." 
                                   value="{% if location_data %}{{ location_data.name }} ({{ location_data.latitude }}, {{ location_data.longitude }}) {{ location_data.country }}{% endif %}"
                                   required
                                   hx-get="/search-location"
                                   hx-trigger="keyup changed delay:300ms"
                                   hx-target="#locationResults"
                                   hx-indicator=".htmx-indicator"
                                   hx-on:focus="document.getElementById('locationResults').style.display = 'block'"
                                   hx-on:blur="setTimeout(() => document.getElementById('locationResults').style.display = 'none', 200)">
                             class="htmx-indicator">
                                 class="spinner-border spinner-border-sm text-primary" role="status">
                                     class="visually-hidden">Loading...
                                
                            
                             class="btn btn-outline-secondary" type="button">
                                 class="bi bi-search">
                            
                        
                         id="locationResults" class="dropdown-menu location-dropdown" style="display: none;">
                            
                        
                        {% if location_data %}
                         class="form-text text-success mt-2">
                             class="bi bi-geo-alt">
                            {{ location_data.name }}, {{ location_data.country }}
                            ({{ "%.4f"|format(location_data.latitude) }}, {{ "%.4f"|format(location_data.longitude) }})
                        
                        {% endif %}
                    
                     class="col-12">
                         type="submit" class="btn btn-primary">
                             class="bi bi-search"> Show Weather & Events
                        
                    
                
            
        
    


{% if error %}
 class="row mt-3">
     class="col-md-8 offset-md-2">
         class="alert alert-danger" role="alert">
             class="bi bi-exclamation-triangle"> {{ error }}
        
    

{% endif %}

{% if weather_data %}
 class="row mt-4">
     class="col-12">
         class="d-flex justify-content-between align-items-center mb-3">
            
                 class="bi bi-clock-history"> 
                {% if is_current_day %}
                Current Weather for {{ location_data.name }}, {{ location_data.country }}
                {% else %}
                Weather Timeline for {{ location_data.name }}, {{ location_data.country }}
                {% endif %}
            
             class="text-muted">{{ selected_date }}
        

         class="row">
            {% for card in story_cards %}
             class="col-md-4 mb-4">
                 class="card weather-card h-100">
                     class="card-header bg-primary text-white">
                         class="card-title mb-0">{{ card.weather.time.strftime('%I:%M %p') }}
                    
                     class="card-body">
                         class="weather-info mb-4">
                             class="text-muted">Weather Conditions
                             class="d-flex justify-content-between align-items-center mb-2">
                                 class="bi bi-thermometer"> Temperature
                                {{ "%.1f"|format(card.weather.temperature) }}°C
                            
                             class="d-flex justify-content-between align-items-center mb-2">
                                 class="bi bi-cloud-rain"> Precipitation
                                {{ "%.1f"|format(card.weather.precipitation) }}mm
                            
                             class="d-flex justify-content-between align-items-center">
                                 class="bi bi-wind"> Wind Speed
                                {{ "%.1f"|format(card.weather.wind_speed) }} km/h
                            
                        

                        {% if card.weather.time.hour == card.daily.sunrise.hour %}
                         class="sun-event sunrise mb-3">
                             class="d-flex align-items-center">
                                 class="sun-icon me-3">
                                     class="bi bi-sunrise-fill">
                                
                                 class="sun-info">
                                     class="sun-title">Sunrise
                                     class="sun-time">{{ card.daily.sunrise.strftime('%I:%M %p') }}
                                
                            
                        
                        {% endif %}

                        {% if card.weather.time.hour == card.daily.sunset.hour %}
                         class="sun-event sunset mb-3">
                             class="d-flex align-items-center">
                                 class="sun-icon me-3">
                                     class="bi bi-sunset-fill">
                                
                                 class="sun-info">
                                     class="sun-title">Sunset
                                     class="sun-time">{{ card.daily.sunset.strftime('%I:%M %p') }}
                                
                            
                        
                        {% endif %}

                        {% if card.events %}
                         class="historical-section mb-3">
                             class="text-primary">Historical Events
                            {% for event in card.events %}
                             class="historical-event">
                                {{ event.year }}: {{ event.text }}
                                {% if event.wikipedia_url %}
                                 href="{{ event.wikipedia_url }}" target="_blank" class="btn btn-sm btn-link">Read more
                                {% endif %}
                            
                            {% endfor %}
                        
                        {% endif %}

                        {% if card.births %}
                         class="historical-section mb-3">
                             class="text-success">Notable Births
                            {% for birth in card.births %}
                             class="historical-event birth">
                                {{ birth.year }}: {{ birth.text }}
                                {% if birth.wikipedia_url %}
                                 href="{{ birth.wikipedia_url }}" target="_blank" class="btn btn-sm btn-link">Read more
                                {% endif %}
                            
                            {% endfor %}
                        
                        {% endif %}

                        {% if card.deaths %}
                         class="historical-section">
                             class="text-secondary">Notable Deaths
                            {% for death in card.deaths %}
                             class="historical-event death">
                                {{ death.year }}: {{ death.text }}
                                {% if death.wikipedia_url %}
                                 href="{{ death.wikipedia_url }}" target="_blank" class="btn btn-sm btn-link">Read more
                                {% endif %}
                            
                            {% endfor %}
                        
                        {% endif %}
                    
                
            
            {% endfor %}
        
    

{% endif %}
{% endblock %}



    Enter fullscreen mode
    


    Exit fullscreen mode
    




Code description
The location input uses HTMX attributes (hx-get, hx-trigger, etc.) to fetch live location suggestions as the user types.
The main.py route returns each result wrapped in a simple  so clicking it fills the input and hides the dropdown.
When story_cards is non-empty, it loop through and render each as a Bootstrap card with weather stats on top, then a list of “on this day” events, and sunrise/sunset in the corresponding hour card.

  
  
  Running & Testing the App
You can download the full source code here:Download Source CodeOnce all code is in place, here’s how you can run the app and make sure everything works as expected:Start the server
Open a terminal, and in the project root (where main.py lives), run:

uvicorn main:app --reload



    Enter fullscreen mode
    


    Exit fullscreen mode
    




The --reload flag watches for file changes and restarts automatically—perfect for iterative development.Try a “today” search
Open the browser to http://127.0.0.1:8000/ and leave the date picker at today’s date.
Type in a city name (e.g. “Paris”) and select the suggestion.
Clicking Show Weather & Events should yield a set of hourly cards for the current day, each showing the hour, temperature, precipitation, wind speed, and a few historical “on this day” events.
Today SearchTest a historical date
Pick a famous past date—say, 20-07-1969 (the Apollo 11 Moon landing).
Moon LandingNote: The hours don't match any particular event, as described before, all events are randomly distributed by the hour cards.Edge cases & error handling
Try entering an invalid location string or leaving it blank to see the validation message.
Location Error
Picking a future date (e.g., tomorrow) to confirm that the error appears.
Date Error
  
  
  Conclusion
Building this Weather & History Story Cards app was a great reminder of how seamlessly you can put together different public APIs into a cohesive, playful experience.In just a few hundred lines of Python and a handful of templates , it can pull down hourly temperature and forecast data from Open-Meteo, combine it with “on this day” events from History.muffinlabs, and serve it all up as richly formatted story cards.I hope this tutorial has shown you not only how to structure async API calls, data models, and FastAPI routes, but also how to think creatively about presenting raw data in a human-centric way.Follow me on Twitter: https://twitter.com/DevAsServiceFollow me on Instagram: https://www.instagram.com/devasservice/Follow me on TikTok: https://www.tiktok.com/@devasserviceFollow me on YouTube: https://www.youtube.com/@DevAsService