Building Weather & History Story Cards with Python and FastAPI
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 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 repr

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:
SPONSORED By Python's Magic Methods - Beyond init and str
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.
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()
andfetch_weather_data()
to pull today’s and historical forecasts -
fetch_historical_events()
for “on this day” data -
distribute_events()
andcreate_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 containingname
,latitude
,longitude
,country
, andtimezone
.
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 aHistoricalData
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 singleDailyWeatherData
) into a list ofStoryCard
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 thetemplates/
folder, for usingTemplateResponse
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 toutils.py
. - Finally, It re-render
index.html
, this time supplying thestory_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:
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/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet">
/* 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="container mt-4">
{% block content %}{% endblock %}
{% block scripts %}{% endblock %}
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 (likeindex.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
{% 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 %}
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 simpleso 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:
Once 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
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 Search
Test a historical date
- Pick a famous past date—say, 20-07-1969 (the Apollo 11 Moon landing).
Moon Landing
Note: 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/DevAsService
Follow me on Instagram: https://www.instagram.com/devasservice/
Follow me on TikTok: https://www.tiktok.com/@devasservice
Follow me on YouTube: https://www.youtube.com/@DevAsService