From 4d80bb690b32e3a4f165726b7fe437d4d19c2d66 Mon Sep 17 00:00:00 2001 From: Torrei Whitfield Date: Tue, 5 May 2026 00:40:52 -0700 Subject: [PATCH] initial commit --- .gitignore | 3 + README.md | 20 ++ app.py | 330 +++++++++++++++++++++++++ requirements.txt | 1 + static/styles.css | 521 ++++++++++++++++++++++++++++++++++++++++ static/weather-mark.svg | 6 + templates/index.html | 126 ++++++++++ 7 files changed, 1007 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.py create mode 100644 requirements.txt create mode 100644 static/styles.css create mode 100644 static/weather-mark.svg create mode 100644 templates/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f0ccc32 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.venv/ +__pycache__/ +*.py[cod] diff --git a/README.md b/README.md new file mode 100644 index 0000000..c38481c --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# Flask Weather Website + +Small Flask app that geocodes a location and shows current conditions plus a 5-day forecast using Open-Meteo. + +## API + +- Forecast: https://api.open-meteo.com/v1/forecast +- Geocoding: https://geocoding-api.open-meteo.com/v1/search +- No API key required for basic, non-commercial usage. + +## Run + +```bash +python3 -m venv .venv +source .venv/bin/activate +python3 -m pip install -r requirements.txt +python3 app.py +``` + +Open http://127.0.0.1:5000. diff --git a/app.py b/app.py new file mode 100644 index 0000000..aa38868 --- /dev/null +++ b/app.py @@ -0,0 +1,330 @@ +import json +from datetime import datetime +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +from flask import Flask, render_template, request + + +app = Flask(__name__) + +DEFAULT_LOCATION = "Seattle" +GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search" +FORECAST_URL = "https://api.open-meteo.com/v1/forecast" +USER_AGENT = "SETI-Flask-Weather-Demo/1.0" + +CURRENT_FIELDS = [ + "temperature_2m", + "relative_humidity_2m", + "apparent_temperature", + "is_day", + "precipitation", + "weather_code", + "cloud_cover", + "wind_speed_10m", + "wind_direction_10m", + "wind_gusts_10m", +] + +DAILY_FIELDS = [ + "weather_code", + "temperature_2m_max", + "temperature_2m_min", + "precipitation_probability_max", + "precipitation_sum", + "wind_speed_10m_max", +] + +WEATHER_CODES = { + 0: "Clear sky", + 1: "Mainly clear", + 2: "Partly cloudy", + 3: "Overcast", + 45: "Fog", + 48: "Depositing rime fog", + 51: "Light drizzle", + 53: "Drizzle", + 55: "Heavy drizzle", + 56: "Light freezing drizzle", + 57: "Freezing drizzle", + 61: "Light rain", + 63: "Rain", + 65: "Heavy rain", + 66: "Light freezing rain", + 67: "Freezing rain", + 71: "Light snow", + 73: "Snow", + 75: "Heavy snow", + 77: "Snow grains", + 80: "Light rain showers", + 81: "Rain showers", + 82: "Heavy rain showers", + 85: "Light snow showers", + 86: "Snow showers", + 95: "Thunderstorm", + 96: "Thunderstorm with hail", + 99: "Severe thunderstorm with hail", +} + + +class WeatherServiceError(Exception): + """Raised when the weather or geocoding API cannot return usable data.""" + + +def fetch_json(url, params): + request_url = f"{url}?{urlencode(params)}" + api_request = Request(request_url, headers={"User-Agent": USER_AGENT}) + + try: + with urlopen(api_request, timeout=10) as response: + return json.loads(response.read().decode("utf-8")) + except HTTPError as exc: + raise WeatherServiceError(f"Weather API returned HTTP {exc.code}.") from exc + except (TimeoutError, URLError) as exc: + raise WeatherServiceError("Weather API is unreachable right now.") from exc + except json.JSONDecodeError as exc: + raise WeatherServiceError("Weather API returned invalid JSON.") from exc + + +def geocode_location(query): + cleaned_query = (query or DEFAULT_LOCATION).strip() or DEFAULT_LOCATION + search_terms = [cleaned_query] + if "," in cleaned_query: + search_terms.append(cleaned_query.split(",", 1)[0].strip()) + + results = [] + for search_term in search_terms: + if not search_term: + continue + + payload = fetch_json( + GEOCODE_URL, + { + "name": search_term, + "count": 1, + "language": "en", + "format": "json", + }, + ) + results = payload.get("results") or [] + if results: + break + + if not results: + raise WeatherServiceError(f"No location found for '{cleaned_query}'.") + + result = results[0] + display_parts = [ + result.get("name"), + result.get("admin1"), + result.get("country"), + ] + + return { + "name": result["name"], + "display": ", ".join(part for part in display_parts if part), + "latitude": result["latitude"], + "longitude": result["longitude"], + "timezone": result.get("timezone", "auto"), + } + + +def get_forecast(location): + payload = fetch_json( + FORECAST_URL, + { + "latitude": location["latitude"], + "longitude": location["longitude"], + "current": ",".join(CURRENT_FIELDS), + "daily": ",".join(DAILY_FIELDS), + "temperature_unit": "fahrenheit", + "wind_speed_unit": "mph", + "precipitation_unit": "inch", + "forecast_days": 5, + "timezone": "auto", + }, + ) + + return normalize_forecast(location, payload) + + +def normalize_forecast(location, payload): + current = payload.get("current") or {} + daily = payload.get("daily") or {} + current_code = current.get("weather_code") + + return { + "location": location, + "current": { + "time": format_timestamp(current.get("time")), + "temperature": format_number(current.get("temperature_2m"), "F"), + "apparent_temperature": format_number( + current.get("apparent_temperature"), + "F", + ), + "humidity": format_number(current.get("relative_humidity_2m"), "%"), + "precipitation": format_number( + current.get("precipitation"), + " in", + digits=2, + ), + "cloud_cover": format_number(current.get("cloud_cover"), "%"), + "wind": format_wind( + current.get("wind_speed_10m"), + current.get("wind_direction_10m"), + ), + "wind_gusts": format_number(current.get("wind_gusts_10m"), " mph"), + "label": weather_label(current_code), + "icon": weather_icon(current_code), + }, + "days": normalize_daily(daily), + "generation_ms": round(payload.get("generationtime_ms", 0), 2), + } + + +def normalize_daily(daily): + days = [] + dates = daily.get("time") or [] + + for index, date_value in enumerate(dates): + weather_code = daily_value(daily, "weather_code", index) + days.append( + { + "date": format_date(date_value), + "label": weather_label(weather_code), + "icon": weather_icon(weather_code), + "high": format_number( + daily_value(daily, "temperature_2m_max", index), + "F", + ), + "low": format_number( + daily_value(daily, "temperature_2m_min", index), + "F", + ), + "precip_probability": format_number( + daily_value(daily, "precipitation_probability_max", index), + "%", + ), + "precip_sum": format_number( + daily_value(daily, "precipitation_sum", index), + " in", + digits=2, + ), + "wind": format_number( + daily_value(daily, "wind_speed_10m_max", index), + " mph", + ), + } + ) + + return days + + +def daily_value(daily, field, index): + values = daily.get(field) or [] + if index >= len(values): + return None + return values[index] + + +def weather_label(code): + return WEATHER_CODES.get(code, "Forecast unavailable") + + +def weather_icon(code): + if code in {0, 1}: + return "sun" + if code in {2, 3, 45, 48}: + return "cloud" + if code in {51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82}: + return "rain" + if code in {71, 73, 75, 77, 85, 86}: + return "snow" + if code in {95, 96, 99}: + return "storm" + return "cloud" + + +def format_number(value, suffix="", digits=0): + if value is None: + return "--" + rounded = round(value, digits) + if digits == 0: + rounded = int(rounded) + return f"{rounded}{suffix}" + + +def format_wind(speed, direction): + if speed is None: + return "--" + return f"{format_number(speed, ' mph')} {compass_direction(direction)}" + + +def compass_direction(degrees): + if degrees is None: + return "" + + directions = [ + "N", + "NNE", + "NE", + "ENE", + "E", + "ESE", + "SE", + "SSE", + "S", + "SSW", + "SW", + "WSW", + "W", + "WNW", + "NW", + "NNW", + ] + index = round(degrees / 22.5) % 16 + return directions[index] + + +def format_timestamp(value): + if not value: + return "Latest model run" + try: + parsed = datetime.fromisoformat(value) + except ValueError: + return value + return parsed.strftime("%b %-d, %-I:%M %p") + + +def format_date(value): + try: + parsed = datetime.fromisoformat(value) + except ValueError: + return value + return parsed.strftime("%a, %b %-d") + + +@app.route("/") +def index(): + query = request.args.get("q", DEFAULT_LOCATION) + forecast = None + error = None + + try: + location = geocode_location(query) + forecast = get_forecast(location) + except WeatherServiceError as exc: + error = str(exc) + + return render_template( + "index.html", + default_location=DEFAULT_LOCATION, + error=error, + forecast=forecast, + query=query, + ) + + +if __name__ == "__main__": + app.run(host="127.0.0.1", port=5000, debug=True) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..840d434 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +Flask>=3.0,<4.0 diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..f1c3cfd --- /dev/null +++ b/static/styles.css @@ -0,0 +1,521 @@ +:root { + color-scheme: light; + --background: #f4f6f8; + --panel: #ffffff; + --ink: #172033; + --muted: #667085; + --line: #d8dee7; + --teal: #00796b; + --teal-dark: #00564d; + --amber: #f2a413; + --coral: #d45b45; + --rain: #3d7ea6; + --snow: #7b8794; + --shadow: 0 16px 40px rgb(23 32 51 / 10%); +} + +* { + box-sizing: border-box; +} + +body { + min-height: 100vh; + margin: 0; + background: linear-gradient(135deg, #f4f6f8 0%, #eef3f1 48%, #f8f4f0 100%); + color: var(--ink); + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; +} + +.page-shell { + width: min(1120px, calc(100% - 32px)); + margin: 0 auto; + padding: 32px 0 28px; +} + +.topbar, +.current-panel, +.day-card, +.alert { + border: 1px solid var(--line); + border-radius: 8px; + background: rgb(255 255 255 / 86%); + box-shadow: var(--shadow); + backdrop-filter: blur(14px); +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 18px; +} + +.brand { + display: flex; + align-items: center; + gap: 14px; + min-width: 0; +} + +.brand-icon { + width: 52px; + height: 52px; + flex: 0 0 auto; +} + +.eyebrow { + margin: 0 0 4px; + color: var(--teal-dark); + font-size: 0.78rem; + font-weight: 800; + letter-spacing: 0; + text-transform: uppercase; +} + +h1, +h2, +p { + margin-top: 0; +} + +h1 { + margin-bottom: 0; + font-size: clamp(1.35rem, 2.4vw, 2.2rem); + line-height: 1.1; + letter-spacing: 0; +} + +.search { + display: flex; + width: min(420px, 100%); + gap: 8px; +} + +.search input, +.search button { + min-height: 44px; + border-radius: 8px; + font: inherit; +} + +.search input { + width: 100%; + min-width: 0; + border: 1px solid var(--line); + padding: 0 14px; + color: var(--ink); + background: #ffffff; +} + +.search button { + border: 0; + padding: 0 18px; + background: var(--teal); + color: #ffffff; + font-weight: 800; + cursor: pointer; +} + +.search button:hover, +.search button:focus-visible { + background: var(--teal-dark); +} + +.alert { + margin-top: 16px; + padding: 16px; + color: #762315; + background: #fff4f1; +} + +.current-panel { + display: grid; + grid-template-columns: minmax(280px, 0.9fr) minmax(360px, 1.1fr); + gap: 22px; + margin-top: 20px; + padding: 22px; + overflow: hidden; +} + +.condition-sun { + border-color: rgb(242 164 19 / 45%); +} + +.condition-cloud { + border-color: rgb(123 135 148 / 40%); +} + +.condition-rain, +.condition-storm { + border-color: rgb(61 126 166 / 45%); +} + +.condition-snow { + border-color: rgb(123 135 148 / 40%); +} + +.current-summary { + display: flex; + align-items: center; + gap: 22px; + min-width: 0; +} + +.current-summary h2 { + margin-bottom: 4px; + font-size: clamp(3.2rem, 8vw, 5.8rem); + line-height: 0.95; + letter-spacing: 0; +} + +.condition-label { + margin-bottom: 0; + color: var(--muted); + font-size: 1.08rem; + font-weight: 700; +} + +.metrics, +.day-metrics { + display: grid; + gap: 10px; + margin: 0; +} + +.metrics { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.metrics div, +.day-metrics div { + min-width: 0; + border: 1px solid #e7ebf0; + border-radius: 8px; + padding: 12px; + background: #ffffff; +} + +dt { + color: var(--muted); + font-size: 0.78rem; + font-weight: 800; + letter-spacing: 0; + text-transform: uppercase; +} + +dd { + margin: 5px 0 0; + font-size: 1.05rem; + font-weight: 800; +} + +.forecast-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 14px; + margin-top: 16px; +} + +.day-card { + min-height: 255px; + padding: 16px; +} + +.day-header { + display: flex; + gap: 12px; + align-items: center; + min-height: 58px; +} + +.day-header h2 { + margin-bottom: 3px; + font-size: 1rem; + line-height: 1.2; + letter-spacing: 0; +} + +.day-header p { + margin-bottom: 0; + color: var(--muted); + font-size: 0.9rem; + font-weight: 700; + line-height: 1.3; +} + +.day-metrics { + grid-template-columns: 1fr; + margin-top: 14px; +} + +.day-metrics div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-height: 40px; + padding: 9px 10px; +} + +.day-metrics dd { + margin: 0; + text-align: right; + white-space: nowrap; +} + +.weather-art, +.mini-art { + position: relative; + display: grid; + place-items: center; + flex: 0 0 auto; + border-radius: 50%; +} + +.weather-art { + width: 118px; + height: 118px; +} + +.mini-art { + width: 42px; + height: 42px; +} + +.weather-art span, +.mini-art span, +.weather-art::before, +.mini-art::before, +.weather-art::after, +.mini-art::after { + content: ""; + position: absolute; + display: block; +} + +.sun, +.mini-art.sun { + background: rgb(242 164 19 / 18%); +} + +.sun span, +.mini-art.sun span { + width: 48%; + height: 48%; + border-radius: 50%; + background: var(--amber); + box-shadow: 0 0 0 10px rgb(242 164 19 / 16%); +} + +.cloud, +.mini-art.cloud { + background: rgb(123 135 148 / 14%); +} + +.cloud span, +.rain span, +.snow span, +.storm span, +.mini-art.cloud span, +.mini-art.rain span, +.mini-art.snow span, +.mini-art.storm span { + width: 58%; + height: 28%; + border-radius: 999px; + background: #7b8794; +} + +.cloud span::before, +.rain span::before, +.snow span::before, +.storm span::before, +.mini-art.cloud span::before, +.mini-art.rain span::before, +.mini-art.snow span::before, +.mini-art.storm span::before { + content: ""; + position: absolute; + left: 12%; + bottom: 35%; + width: 34%; + height: 85%; + border-radius: 50%; + background: inherit; +} + +.cloud span::after, +.rain span::after, +.snow span::after, +.storm span::after, +.mini-art.cloud span::after, +.mini-art.rain span::after, +.mini-art.snow span::after, +.mini-art.storm span::after { + content: ""; + position: absolute; + right: 10%; + bottom: 26%; + width: 42%; + height: 110%; + border-radius: 50%; + background: inherit; +} + +.rain, +.mini-art.rain { + background: rgb(61 126 166 / 14%); +} + +.rain span, +.mini-art.rain span { + background: var(--rain); +} + +.rain::after, +.mini-art.rain::after { + width: 38%; + height: 26%; + border-right: 3px solid var(--rain); + border-left: 3px solid var(--rain); + transform: translateY(75%) skewX(-15deg); +} + +.snow, +.mini-art.snow { + background: rgb(123 135 148 / 14%); +} + +.snow span, +.mini-art.snow span { + background: var(--snow); +} + +.snow::after, +.mini-art.snow::after { + width: 7px; + height: 7px; + border-radius: 50%; + background: #aeb8c4; + box-shadow: -14px 4px 0 #aeb8c4, 14px 4px 0 #aeb8c4; + transform: translateY(95%); +} + +.storm, +.mini-art.storm { + background: rgb(212 91 69 / 14%); +} + +.storm span, +.mini-art.storm span { + background: var(--coral); +} + +.storm::after, +.mini-art.storm::after { + width: 14%; + height: 32%; + background: var(--amber); + clip-path: polygon(45% 0, 100% 0, 68% 44%, 100% 44%, 22% 100%, 45% 55%, 8% 55%); + transform: translateY(70%); +} + +footer { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 14px; + color: var(--muted); + font-size: 0.9rem; +} + +footer a { + color: var(--teal-dark); + font-weight: 800; + text-decoration: none; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@media (max-width: 900px) { + .topbar, + .current-panel { + grid-template-columns: 1fr; + } + + .topbar { + display: grid; + } + + .search { + width: 100%; + } + + .metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .forecast-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 560px) { + .page-shell { + width: min(100% - 20px, 1120px); + padding-top: 10px; + } + + .brand { + align-items: flex-start; + } + + .brand-icon { + width: 44px; + height: 44px; + } + + .search { + display: grid; + } + + .current-summary { + align-items: flex-start; + } + + .weather-art { + width: 86px; + height: 86px; + } + + .current-summary h2 { + font-size: 3rem; + } + + .metrics, + .forecast-grid { + grid-template-columns: 1fr; + } + + footer { + justify-content: flex-start; + flex-wrap: wrap; + } +} diff --git a/static/weather-mark.svg b/static/weather-mark.svg new file mode 100644 index 0000000..2b9aa04 --- /dev/null +++ b/static/weather-mark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..f64cbb9 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,126 @@ + + + + + + Weather Forecast + + + +
+
+
+ +
+

Open-Meteo forecast

+

{{ forecast.location.display if forecast else default_location }}

+
+
+ + +
+ + {% if error %} + + {% endif %} + + {% if forecast %} +
+
+ + +
+

{{ forecast.current.time }}

+

{{ forecast.current.temperature }}

+

{{ forecast.current.label }}

+
+
+ +
+
+
Feels like
+
{{ forecast.current.apparent_temperature }}
+
+
+
Humidity
+
{{ forecast.current.humidity }}
+
+
+
Wind
+
{{ forecast.current.wind }}
+
+
+
Gusts
+
{{ forecast.current.wind_gusts }}
+
+
+
Clouds
+
{{ forecast.current.cloud_cover }}
+
+
+
Precip
+
{{ forecast.current.precipitation }}
+
+
+
+ +
+ {% for day in forecast.days %} +
+
+ +
+

{{ day.date }}

+

{{ day.label }}

+
+
+ +
+
+
High
+
{{ day.high }}
+
+
+
Low
+
{{ day.low }}
+
+
+
Rain chance
+
{{ day.precip_probability }}
+
+
+
Rain total
+
{{ day.precip_sum }}
+
+
+
Max wind
+
{{ day.wind }}
+
+
+
+ {% endfor %} +
+ + + {% endif %} +
+ +