diff --git a/web/src/beanflows/dashboard/routes.py b/web/src/beanflows/dashboard/routes.py index 9ca9cd6..8a26057 100644 --- a/web/src/beanflows/dashboard/routes.py +++ b/web/src/beanflows/dashboard/routes.py @@ -7,7 +7,16 @@ import secrets from datetime import datetime from pathlib import Path -from quart import Blueprint, current_app, flash, g, jsonify, redirect, render_template, request, url_for +from quart import ( + Blueprint, + current_app, + flash, + g, + redirect, + render_template, + request, + url_for, +) from .. import analytics from ..auth.routes import login_required, update_user @@ -135,16 +144,20 @@ async def index(): ["production", "total_distribution"], start_year=(None if plan != "free" else None), ), + analytics.get_weather_stress_latest(), + analytics.get_weather_stress_trend(days=90), return_exceptions=True, ) - defaults = [None, None, None, [], [], [], [], []] + defaults = [None, None, None, [], [], [], [], [], None, []] ( price_latest, cot_latest, ice_stocks_latest, stu_trend, price_series, ice_stocks_trend, cot_trend, time_series, + weather_stress_latest, weather_stress_trend, ) = _safe(results, defaults) else: price_latest, cot_latest, ice_stocks_latest = None, None, None stu_trend, price_series, ice_stocks_trend, cot_trend, time_series = [], [], [], [], [] + weather_stress_latest, weather_stress_trend = None, [] # Latest stock-to-use for the metric card stu_latest = stu_trend[-1] if stu_trend else None @@ -166,6 +179,8 @@ async def index(): ice_stocks_trend=ice_stocks_trend, cot_trend=cot_trend, time_series=time_series, + weather_stress_latest=weather_stress_latest, + weather_stress_trend=weather_stress_trend, ) @@ -372,6 +387,72 @@ async def countries(): ) +@bp.route("/weather") +@login_required +async def weather(): + """Weather & crop stress deep-dive — 12 coffee-growing origins.""" + plan = (g.get("subscription") or {}).get("plan", "free") + + range_key = request.args.get("range", "1y") + if range_key not in RANGE_MAP: + range_key = "1y" + location_id = request.args.get("location", "").strip() + + rng = RANGE_MAP[range_key] + days = rng["days"] + + if analytics._db_path: + if location_id: + results = await asyncio.gather( + analytics.get_weather_stress_latest(), + analytics.get_weather_locations(), + analytics.get_weather_stress_trend(days=days), + analytics.get_weather_active_alerts(), + analytics.get_weather_location_series(location_id, days=days), + return_exceptions=True, + ) + defaults = [None, [], [], [], []] + stress_latest, locations, stress_trend, active_alerts, location_series = _safe( + results, defaults + ) + else: + results = await asyncio.gather( + analytics.get_weather_stress_latest(), + analytics.get_weather_locations(), + analytics.get_weather_stress_trend(days=days), + analytics.get_weather_active_alerts(), + return_exceptions=True, + ) + defaults = [None, [], [], []] + stress_latest, locations, stress_trend, active_alerts = _safe(results, defaults) + location_series = [] + else: + stress_latest = None + locations = stress_trend = active_alerts = location_series = [] + + # Resolve the selected location's metadata for the detail header + location_detail = None + if location_id and locations: + matches = [loc for loc in locations if loc["location_id"] == location_id] + location_detail = matches[0] if matches else None + + ctx = dict( + plan=plan, + range_key=range_key, + location_id=location_id, + location_detail=location_detail, + stress_latest=stress_latest, + locations=locations, + stress_trend=stress_trend, + active_alerts=active_alerts, + location_series=location_series, + ) + + if request.headers.get("HX-Request"): + return await render_template("weather_canvas.html", **ctx) + return await render_template("weather.html", user=g.user, **ctx) + + @bp.route("/settings", methods=["GET", "POST"]) @login_required @csrf_protect diff --git a/web/src/beanflows/dashboard/templates/dashboard_base.html b/web/src/beanflows/dashboard/templates/dashboard_base.html index 2cd3f84..b2492ba 100644 --- a/web/src/beanflows/dashboard/templates/dashboard_base.html +++ b/web/src/beanflows/dashboard/templates/dashboard_base.html @@ -74,6 +74,14 @@ Warehouse + + + Weather + Warehouse - - - - - - + + + + - Origins + Weather diff --git a/web/src/beanflows/dashboard/templates/weather.html b/web/src/beanflows/dashboard/templates/weather.html new file mode 100644 index 0000000..5c80b1c --- /dev/null +++ b/web/src/beanflows/dashboard/templates/weather.html @@ -0,0 +1,204 @@ +{% extends "dashboard_base.html" %} + +{% block title %}Weather — {{ config.APP_NAME }}{% endblock %} + +{% block head %} + + + + + +{% endblock %} + +{% block content %} + + +
+
+

Weather & Crop Stress

+

12 coffee-growing origins · Open-Meteo ERA5 reanalysis

+
+ {% if stress_latest %} + + Weather {{ stress_latest.observation_date }} + + {% endif %} +
+ + +
+
+ {% for r, label in [("3m","3M"),("6m","6M"),("1y","1Y"),("2y","2Y"),("5y","5Y")] %} + + {% endfor %} +
+ + {% if location_id %} + ← All Locations + {% endif %} +
+ + +
+ {% include "weather_canvas.html" %} +
+ +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/web/src/beanflows/dashboard/templates/weather_canvas.html b/web/src/beanflows/dashboard/templates/weather_canvas.html new file mode 100644 index 0000000..7d662a2 --- /dev/null +++ b/web/src/beanflows/dashboard/templates/weather_canvas.html @@ -0,0 +1,459 @@ +{# + weather_canvas.html — HTMX partial for /dashboard/weather + Renders overview (no location) or location detail view. +#} + + + + +{% if location_id and location_detail %} +{# ════════════════════════════════════════════════════════════ #} +{# DETAIL VIEW — single location #} +{# ════════════════════════════════════════════════════════════ #} + +{% set loc = location_detail %} +{% set latest = location_series[0] if location_series else none %} + + +
+
+ {{ loc.location_name }} + {{ loc.country }} + {{ loc.variety }} +
+
{{ loc.lat | round(2) }}°, {{ loc.lon | round(2) }}°
+
+ + +
+
+
Stress Index
+
+ {% if latest %}{{ "{:.0f}".format(latest.crop_stress_index) }}/100{% else %}--{% endif %} +
+
crop stress composite{% if latest %} · {{ latest.observation_date }}{% endif %}
+
+
+
7-Day Precipitation
+
+ {% if latest %}{{ "{:.1f}".format(latest.precip_sum_7d_mm) }} mm{% else %}--{% endif %} +
+
rolling 7-day total
+
+
+
30-Day Precipitation
+
+ {% if latest %}{{ "{:.0f}".format(latest.precip_sum_30d_mm) }} mm{% else %}--{% endif %} +
+
rolling 30-day total
+
+
+
Temp Anomaly
+
+ {% if latest and latest.temp_anomaly_c is not none %}{{ "{:+.1f}°C".format(latest.temp_anomaly_c) }}{% else %}--{% endif %} +
+
vs trailing 30-day mean
+
+
+ +{% if location_series %} + + +
+
+
+
Crop Stress Index & Precipitation
+
Daily · stress index (left) · precipitation mm (right)
+
+ composite +
+
+ +
+
+ + +
+
+
+
Temperature & Water Balance
+
Daily mean °C (left) · 7-day water balance mm (right)
+
+ °C / mm +
+
+ +
+
+ +{% else %} +
+
No Data Available
+

Weather data for this location is still loading.

+
+{% endif %} + +{% else %} +{# ════════════════════════════════════════════════════════════ #} +{# OVERVIEW — all 12 locations #} +{# ════════════════════════════════════════════════════════════ #} + + +
+
+
Avg Crop Stress Index
+
+ {% if stress_latest %}{{ "{:.0f}".format(stress_latest.avg_crop_stress_index) }}/100{% else %}--{% endif %} +
+
across 12 origins{% if stress_latest %} · {{ stress_latest.observation_date }}{% endif %}
+
+
+
Locations Under Stress
+
+ {% if stress_latest %}{{ stress_latest.locations_under_stress }}/12{% else %}--{% endif %} +
+
stress index > 20
+
+
+
Worst Origin
+
+ {% if stress_latest and stress_latest.worst_location_name %}{{ stress_latest.worst_location_name }}{% else %}--{% endif %} +
+
{% if stress_latest and stress_latest.max_crop_stress_index %}peak stress {{ "{:.0f}".format(stress_latest.max_crop_stress_index) }}/100{% endif %}
+
+
+
Active Alerts
+
+ {{ active_alerts | length }} +
+
drought / heat / frost / VPD
+
+
+ + +
+ + +{% if stress_trend %} +
+
+
+
Global Crop Stress Index
+
Daily average across all 12 origins
+
+ 0–100 +
+
+ +
+
+{% endif %} + + +{% if active_alerts %} +
+
+
Active Weather Alerts
+
Locations with at least one active stress flag today
+
+
+ Location + Country + Stress + Drought + Heat + Frost + 7d Rain +
+ {% for a in active_alerts %} + + {{ a.location_name }} + {{ a.country }} + + {{ "{:.0f}".format(a.crop_stress_index) }} + + + {% if a.is_drought %} {{ a.drought_streak_days }}d + {% else %}{% endif %} + + + {% if a.is_heat_stress %} {{ a.heat_streak_days }}d + {% else %}{% endif %} + + + {% if a.is_frost %} + {% else %}{% endif %} + + {{ "{:.1f}".format(a.precip_sum_7d_mm) }} mm + + {% endfor %} +
+{% endif %} + + +{% if locations %} +
+ {% for loc in locations %} + {% set stress = loc.crop_stress_index | float %} + {% set stress_class = 'high' if stress > 40 else ('medium' if stress > 20 else 'low') %} + +
{{ loc.location_name }}
+
{{ loc.country }} · {{ loc.variety }}
+
+
+
+
+ {{ "{:.0f}".format(stress) }} +
+
+ {% endfor %} +
+{% endif %} + +{% endif %}{# end overview/detail #} + + +