From 07b813198aa66bd2de5fa478da16f161bb8cf7bf Mon Sep 17 00:00:00 2001 From: Deeman Date: Thu, 26 Feb 2026 02:39:07 +0100 Subject: [PATCH 1/6] feat(transform): add serving.weather_daily with rolling analytics and crop stress index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Incremental serving model for 12 coffee-growing locations. Adds: - Rolling aggregates: precip_sum_7d/30d, temp_mean_30d, temp_anomaly, water_balance_7d - Gaps-and-islands streak counters: drought_streak_days, heat_streak_days, vpd_streak_days - Composite crop_stress_index 0–100 (drought 30%, water deficit 25%, heat 20%, VPD 15%, frost 10%) - lookback 90: ensures rolling windows and streak counters see sufficient history on daily runs Co-Authored-By: Claude Opus 4.6 --- .../models/serving/weather_daily.sql | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 transform/sqlmesh_materia/models/serving/weather_daily.sql diff --git a/transform/sqlmesh_materia/models/serving/weather_daily.sql b/transform/sqlmesh_materia/models/serving/weather_daily.sql new file mode 100644 index 0000000..ec1bc25 --- /dev/null +++ b/transform/sqlmesh_materia/models/serving/weather_daily.sql @@ -0,0 +1,187 @@ +/* Serving mart: daily weather analytics for 12 coffee-growing regions. */ +/* Source: foundation.fct_weather_daily (already has seed join for location metadata). */ +/* Adds rolling aggregates, water balance, gaps-and-islands streak counters, */ +/* and a composite crop stress index (0–100) as a single severity gauge. */ +/* Grain: (location_id, observation_date) */ +/* Lookback 90: rolling windows reach up to 30 days, streak counters can extend */ +/* up to ~90 days; without lookback a daily run sees only 1 row and all window */ +/* functions degrade to single-row values. */ +MODEL ( + name serving.weather_daily, + kind INCREMENTAL_BY_TIME_RANGE ( + time_column observation_date, + lookback 90 + ), + grain (location_id, observation_date), + start '2020-01-01', + cron '@daily' +); + +WITH base AS ( + SELECT + observation_date, + location_id, + location_name, + country, + lat, + lon, + variety, + temp_min_c, + temp_max_c, + temp_mean_c, + precipitation_mm, + humidity_max_pct, + cloud_cover_mean_pct, + wind_max_speed_ms, + et0_mm, + vpd_max_kpa, + is_frost, + is_heat_stress, + is_drought, + is_high_vpd, + in_growing_season, + /* Rolling precipitation — w7 = trailing 7 days, w30 = trailing 30 days */ + SUM(precipitation_mm) OVER w7 AS precip_sum_7d_mm, + SUM(precipitation_mm) OVER w30 AS precip_sum_30d_mm, + /* Rolling temperature baseline */ + AVG(temp_mean_c) OVER w30 AS temp_mean_30d_c, + /* Temperature anomaly: today vs trailing 30-day mean */ + temp_mean_c - AVG(temp_mean_c) OVER w30 AS temp_anomaly_c, + /* Water balance: net daily water gain/loss (precipitation minus evapotranspiration) */ + precipitation_mm - et0_mm AS water_balance_mm, + SUM(precipitation_mm - et0_mm) OVER w7 AS water_balance_7d_mm, + /* Gaps-and-islands group markers for streak counting. */ + /* Pattern: ROW_NUMBER() - running_count_of_true creates a stable group ID */ + /* for each consecutive run of TRUE. Rows where flag=FALSE get a unique group ID */ + /* (so their streak length stays 0 after the CASE in with_streaks). */ + ROW_NUMBER() OVER ( + PARTITION BY location_id + ORDER BY observation_date + ) - SUM( + CASE WHEN is_drought THEN 1 ELSE 0 END + ) OVER ( + PARTITION BY location_id + ORDER BY observation_date + ROWS UNBOUNDED PRECEDING + ) AS _drought_group, + ROW_NUMBER() OVER ( + PARTITION BY location_id + ORDER BY observation_date + ) - SUM( + CASE WHEN is_heat_stress THEN 1 ELSE 0 END + ) OVER ( + PARTITION BY location_id + ORDER BY observation_date + ROWS UNBOUNDED PRECEDING + ) AS _heat_group, + ROW_NUMBER() OVER ( + PARTITION BY location_id + ORDER BY observation_date + ) - SUM( + CASE WHEN is_high_vpd THEN 1 ELSE 0 END + ) OVER ( + PARTITION BY location_id + ORDER BY observation_date + ROWS UNBOUNDED PRECEDING + ) AS _vpd_group + FROM foundation.fct_weather_daily + WHERE + observation_date BETWEEN @start_ds AND @end_ds + WINDOW + w7 AS ( + PARTITION BY location_id + ORDER BY observation_date + ROWS BETWEEN 6 PRECEDING AND CURRENT ROW + ), + w30 AS ( + PARTITION BY location_id + ORDER BY observation_date + ROWS BETWEEN 29 PRECEDING AND CURRENT ROW + ) +), with_streaks AS ( + SELECT + base.*, + /* Drought streak: number of consecutive dry days ending on observation_date. */ + /* Returns 0 when flag is FALSE (not a drought day). */ + CASE + WHEN NOT is_drought + THEN 0 + ELSE ROW_NUMBER() OVER ( + PARTITION BY location_id, _drought_group + ORDER BY observation_date + ) + END AS drought_streak_days, + /* Heat stress streak: consecutive days with temp_max > 35°C */ + CASE + WHEN NOT is_heat_stress + THEN 0 + ELSE ROW_NUMBER() OVER ( + PARTITION BY location_id, _heat_group + ORDER BY observation_date + ) + END AS heat_streak_days, + /* VPD stress streak: consecutive days with vpd_max > 1.5 kPa */ + CASE + WHEN NOT is_high_vpd + THEN 0 + ELSE ROW_NUMBER() OVER ( + PARTITION BY location_id, _vpd_group + ORDER BY observation_date + ) + END AS vpd_streak_days + FROM base +) +SELECT + observation_date, + location_id, + location_name, + country, + lat, + lon, + variety, + temp_min_c, + temp_max_c, + temp_mean_c, + precipitation_mm, + humidity_max_pct, + cloud_cover_mean_pct, + wind_max_speed_ms, + et0_mm, + vpd_max_kpa, + is_frost, + is_heat_stress, + is_drought, + is_high_vpd, + in_growing_season, + ROUND(precip_sum_7d_mm, 2) AS precip_sum_7d_mm, + ROUND(precip_sum_30d_mm, 2) AS precip_sum_30d_mm, + ROUND(temp_mean_30d_c, 2) AS temp_mean_30d_c, + ROUND(temp_anomaly_c, 2) AS temp_anomaly_c, + ROUND(water_balance_mm, 2) AS water_balance_mm, + ROUND(water_balance_7d_mm, 2) AS water_balance_7d_mm, + drought_streak_days, + heat_streak_days, + vpd_streak_days, + /* Composite crop stress index (0–100). + Weights: drought streak 30%, water deficit 25%, heat streak 20%, + VPD streak 15%, frost (binary) 10%. + Each component is normalized to [0,1] then capped before weighting: + drought: 14 days = fully stressed + water: 20mm 7d deficit = fully stressed + heat: 7 days = fully stressed + vpd: 7 days = fully stressed + frost: binary (Arabica highland catastrophic event) */ + ROUND( + GREATEST(0.0, LEAST(100.0, + LEAST(1.0, drought_streak_days / 14.0) * 30.0 + + LEAST(1.0, GREATEST(0.0, -water_balance_7d_mm) / 20.0) * 25.0 + + LEAST(1.0, heat_streak_days / 7.0) * 20.0 + + LEAST(1.0, vpd_streak_days / 7.0) * 15.0 + + CASE WHEN is_frost THEN 10.0 ELSE 0.0 END + )), + 1 + ) AS crop_stress_index +FROM with_streaks +ORDER BY + location_id, + observation_date From 127881f7d846c8506437111668015aeda5303a50 Mon Sep 17 00:00:00 2001 From: Deeman Date: Thu, 26 Feb 2026 02:39:12 +0100 Subject: [PATCH 2/6] feat(web): add weather analytics query functions to analytics.py Adds ALLOWED_WEATHER_METRICS frozenset and 5 new query functions: - get_weather_locations(): 12 locations with latest stress index for map/cards - get_weather_location_series(): time series for one location (dynamic metrics) - get_weather_stress_latest(): global snapshot for Pulse metric card - get_weather_stress_trend(): daily global avg/max for chart and sparkline - get_weather_active_alerts(): locations with active stress flags Co-Authored-By: Claude Opus 4.6 --- web/src/beanflows/analytics.py | 182 +++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) diff --git a/web/src/beanflows/analytics.py b/web/src/beanflows/analytics.py index c054c9c..409b309 100644 --- a/web/src/beanflows/analytics.py +++ b/web/src/beanflows/analytics.py @@ -531,3 +531,185 @@ async def get_country_comparison( """, [commodity_code, *country_codes], ) + + +# ============================================================================= +# Weather Queries +# ============================================================================= + +# Columns safe for user-facing weather series queries (prevents SQL injection) +ALLOWED_WEATHER_METRICS = frozenset({ + "temp_min_c", + "temp_max_c", + "temp_mean_c", + "precipitation_mm", + "humidity_max_pct", + "et0_mm", + "vpd_max_kpa", + "precip_sum_7d_mm", + "precip_sum_30d_mm", + "temp_mean_30d_c", + "temp_anomaly_c", + "water_balance_mm", + "water_balance_7d_mm", + "drought_streak_days", + "heat_streak_days", + "vpd_streak_days", + "crop_stress_index", +}) + + +def _validate_weather_metrics(metrics: list[str]) -> list[str]: + valid = [m for m in metrics if m in ALLOWED_WEATHER_METRICS] + assert valid, f"No valid weather metrics in {metrics}. Allowed: {sorted(ALLOWED_WEATHER_METRICS)}" + return valid + + +async def get_weather_locations() -> list[dict]: + """12 locations with latest-day stress index and flags, sorted by severity. + + Used by the map and location card grid (overview mode). + """ + return await fetch_analytics( + """ + WITH latest AS ( + SELECT MAX(observation_date) AS max_date + FROM serving.weather_daily + ) + SELECT + w.location_id, + w.location_name, + w.country, + w.lat, + w.lon, + w.variety, + w.observation_date, + w.crop_stress_index, + w.drought_streak_days, + w.heat_streak_days, + w.vpd_streak_days, + w.precip_sum_7d_mm, + w.precip_sum_30d_mm, + w.temp_anomaly_c, + w.is_frost, + w.is_heat_stress, + w.is_drought, + w.is_high_vpd + FROM serving.weather_daily AS w, latest + WHERE w.observation_date = latest.max_date + ORDER BY w.crop_stress_index DESC + """ + ) + + +async def get_weather_location_series( + location_id: str, + metrics: list[str] | None = None, + days: int = 365, +) -> list[dict]: + """Time series for one location. limit defaults to 1 year of daily data.""" + assert 1 <= days <= 3650, "days must be between 1 and 3650" + if metrics is None: + metrics = [ + "temp_mean_c", "precipitation_mm", "crop_stress_index", + "precip_sum_7d_mm", "water_balance_7d_mm", "temp_anomaly_c", + ] + metrics = _validate_weather_metrics(metrics) + cols = ", ".join(metrics) + + return await fetch_analytics( + f""" + SELECT observation_date, location_id, location_name, country, + is_frost, is_heat_stress, is_drought, is_high_vpd, + {cols} + FROM serving.weather_daily + WHERE location_id = ? + ORDER BY observation_date DESC + LIMIT ? + """, + [location_id, days], + ) + + +async def get_weather_stress_latest() -> dict | None: + """Global stress snapshot for the latest day. + + Returns avg/max stress index, count of locations under stress (index > 20), + worst location name, and observation date. Aggregates 12 rows in DuckDB. + """ + rows = await fetch_analytics( + """ + WITH latest AS ( + SELECT MAX(observation_date) AS max_date + FROM serving.weather_daily + ) + SELECT + latest.max_date AS observation_date, + ROUND(AVG(w.crop_stress_index), 1) AS avg_crop_stress_index, + MAX(w.crop_stress_index) AS max_crop_stress_index, + COUNT(*) FILTER (WHERE w.crop_stress_index > 20) AS locations_under_stress, + ARG_MAX(w.location_name, w.crop_stress_index) AS worst_location_name, + ARG_MAX(w.country, w.crop_stress_index) AS worst_location_country, + COUNT(*) FILTER (WHERE w.is_frost) AS frost_count, + COUNT(*) FILTER (WHERE w.is_drought) AS drought_count + FROM serving.weather_daily AS w, latest + WHERE w.observation_date = latest.max_date + """ + ) + return rows[0] if rows else None + + +async def get_weather_stress_trend(days: int = 365) -> list[dict]: + """Daily global stress time series — avg and max across all 12 locations. + + Used by the global stress chart and Pulse sparkline. + """ + assert 1 <= days <= 3650, "days must be between 1 and 3650" + return await fetch_analytics( + """ + SELECT + observation_date, + ROUND(AVG(crop_stress_index), 1) AS avg_crop_stress_index, + MAX(crop_stress_index) AS max_crop_stress_index, + COUNT(*) FILTER (WHERE crop_stress_index > 20) AS locations_under_stress + FROM serving.weather_daily + GROUP BY observation_date + ORDER BY observation_date DESC + LIMIT ? + """, + [days], + ) + + +async def get_weather_active_alerts() -> list[dict]: + """Locations with active stress flags on the latest observation date. + + Sorted by crop_stress_index descending. + """ + return await fetch_analytics( + """ + WITH latest AS ( + SELECT MAX(observation_date) AS max_date + FROM serving.weather_daily + ) + SELECT + w.location_id, + w.location_name, + w.country, + w.variety, + w.observation_date, + w.crop_stress_index, + w.drought_streak_days, + w.heat_streak_days, + w.vpd_streak_days, + w.precip_sum_7d_mm, + w.is_frost, + w.is_heat_stress, + w.is_drought, + w.is_high_vpd + FROM serving.weather_daily AS w, latest + WHERE w.observation_date = latest.max_date + AND (w.is_frost OR w.is_heat_stress OR w.is_drought OR w.is_high_vpd) + ORDER BY w.crop_stress_index DESC + """ + ) From a8cfd68edac71889bcfea22210a28feeda83ce0a Mon Sep 17 00:00:00 2001 From: Deeman Date: Thu, 26 Feb 2026 02:39:19 +0100 Subject: [PATCH 3/6] feat(web): add Weather dashboard page with Leaflet map, location cards, and stress charts - routes.py: add weather() route (range/location params, asyncio.gather, HTMX support) - weather.html: page shell loading Leaflet + Chart.js, HTMX canvas scaffold - weather_canvas.html: HTMX partial with overview (map, metric cards, global stress chart, alert table, location card grid) and detail view (stress+precip chart, temp+water chart) - dashboard_base.html: add Weather to sidebar (after Warehouse) and mobile bottom nav (replaces Origins; Origins remains in desktop sidebar) Co-Authored-By: Claude Opus 4.6 --- web/src/beanflows/dashboard/routes.py | 85 +++- .../dashboard/templates/dashboard_base.html | 22 +- .../dashboard/templates/weather.html | 204 ++++++++ .../dashboard/templates/weather_canvas.html | 459 ++++++++++++++++++ 4 files changed, 760 insertions(+), 10 deletions(-) create mode 100644 web/src/beanflows/dashboard/templates/weather.html create mode 100644 web/src/beanflows/dashboard/templates/weather_canvas.html 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 %} + +{% 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 #} + + + From 89c9f89c8edc02479bb2dcceaf93351d96d182e6 Mon Sep 17 00:00:00 2001 From: Deeman Date: Thu, 26 Feb 2026 02:39:24 +0100 Subject: [PATCH 4/6] feat(web): add weather API endpoints (locations, series, stress, alerts) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 4 REST endpoints under /api/v1/weather/: - GET /weather/locations — 12 locations with latest stress, sorted by severity - GET /weather/locations/ — daily series for one location (?metrics, ?days) - GET /weather/stress — global daily stress trend (?days) - GET /weather/alerts — locations with active crop stress flags All endpoints use @api_key_required(scopes=["read"]) and return {"data": ...}. Co-Authored-By: Claude Opus 4.6 --- web/src/beanflows/api/routes.py | 53 +++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/web/src/beanflows/api/routes.py b/web/src/beanflows/api/routes.py index a5ab769..6a6b0dd 100644 --- a/web/src/beanflows/api/routes.py +++ b/web/src/beanflows/api/routes.py @@ -298,6 +298,59 @@ async def commodity_ice_stocks_by_port(code: str): return jsonify({"commodity": code, "data": data}) +@bp.route("/weather/locations") +@api_key_required(scopes=["read"]) +async def weather_locations(): + """12 coffee-growing locations with latest-day stress index, sorted by severity.""" + data = await analytics.get_weather_locations() + return jsonify({"data": data}) + + +@bp.route("/weather/locations/") +@api_key_required(scopes=["read"]) +async def weather_location_series(location_id: str): + """Daily weather time series for one location. + + Query params: + metrics — repeated param, e.g. ?metrics=crop_stress_index&metrics=precipitation_mm + days — trailing days (default 365, max 3650) + """ + raw_metrics = request.args.getlist("metrics") or None + if raw_metrics: + metrics = [m for m in raw_metrics if m in analytics.ALLOWED_WEATHER_METRICS] + if not metrics: + return jsonify({"error": f"No valid metrics. Allowed: {sorted(analytics.ALLOWED_WEATHER_METRICS)}"}), 400 + else: + metrics = None + + days = min(int(request.args.get("days", 365)), 3650) + data = await analytics.get_weather_location_series(location_id, metrics=metrics, days=days) + if not data: + return jsonify({"error": f"No data found for location_id {location_id!r}"}), 404 + return jsonify({"location_id": location_id, "data": data}) + + +@bp.route("/weather/stress") +@api_key_required(scopes=["read"]) +async def weather_stress(): + """Daily global crop stress trend (avg + max across 12 origins). + + Query params: + days — trailing days (default 365, max 3650) + """ + days = min(int(request.args.get("days", 365)), 3650) + data = await analytics.get_weather_stress_trend(days=days) + return jsonify({"data": data}) + + +@bp.route("/weather/alerts") +@api_key_required(scopes=["read"]) +async def weather_alerts(): + """Locations with active crop stress flags on the latest observation date.""" + data = await analytics.get_weather_active_alerts() + return jsonify({"data": data}) + + @bp.route("/commodities//metrics.csv") @api_key_required(scopes=["read"]) async def commodity_metrics_csv(code: int): From 494f7ff1ee75a7b5f4ad58cc3d2440c5b2979e93 Mon Sep 17 00:00:00 2001 From: Deeman Date: Thu, 26 Feb 2026 02:39:29 +0100 Subject: [PATCH 5/6] feat(web): integrate crop stress into Pulse page - index() route: add get_weather_stress_latest() and get_weather_stress_trend(90d) to asyncio.gather; pass weather_stress_latest and weather_stress_trend to template - pulse.html: add 5th metric card (Crop Stress Index, color-coded green/copper/danger) - pulse.html: add 5th sparkline card (90d avg stress trend) linking to /dashboard/weather - pulse.html: update spark-grid to auto-fit (minmax 280px) to accommodate 5 cards - pulse.html: add Weather freshness badge to the freshness bar Co-Authored-By: Claude Opus 4.6 --- .../beanflows/dashboard/templates/pulse.html | 56 +++++++++++++++++-- 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/web/src/beanflows/dashboard/templates/pulse.html b/web/src/beanflows/dashboard/templates/pulse.html index 54779d3..9373b4f 100644 --- a/web/src/beanflows/dashboard/templates/pulse.html +++ b/web/src/beanflows/dashboard/templates/pulse.html @@ -25,16 +25,13 @@ color: var(--color-stone); } -/* 2×2 sparkline grid */ +/* Sparkline grid — auto-fit for 4 or 5 cards */ .spark-grid { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1rem; margin-top: 1.5rem; } -@media (max-width: 600px) { - .spark-grid { grid-template-columns: 1fr; } -} .spark-card { background: white; @@ -94,7 +91,7 @@

Global coffee market — full picture in 10 seconds{% if user.name %} · Welcome back, {{ user.name }}{% endif %}

- +
KC=F Close
@@ -139,6 +136,17 @@
ending stocks / consumption{% if stu_latest %} · {{ stu_latest.market_year }}{% endif %}
+ +
+
Crop Stress Index
+
+ {% if weather_stress_latest %}{{ "{:.0f}".format(weather_stress_latest.avg_crop_stress_index) }}/100{% else %}--{% endif %} +
+
+ avg · {% if weather_stress_latest %}{{ weather_stress_latest.locations_under_stress }} stressed{% endif %} + {% if weather_stress_latest %} · {{ weather_stress_latest.observation_date }}{% endif %} +
+
@@ -156,6 +164,9 @@ ICE Stocks {% if ice_stocks_latest %}{{ ice_stocks_latest.report_date }}{% else %}—{% endif %} + + Weather {% if weather_stress_latest %}{{ weather_stress_latest.observation_date }}{% else %}—{% endif %} + @@ -209,6 +220,18 @@
USDA WASDE · 1,000 60-kg bags · production vs distribution
+ +
+
+
Crop Stress Index — 90 days
+ View details → +
+
+ +
+
Open-Meteo ERA5 · avg across 12 origins · 0 = no stress · 100 = severe
+
+ {% if plan == "free" %} @@ -338,6 +361,27 @@ options: sparkOpts(C.copper, false), }); } + + // Weather crop stress sparkline (90d) + var weatherRaw = {{ weather_stress_trend | tojson }}; + if (weatherRaw && weatherRaw.length > 0) { + var weatherAsc = weatherRaw.slice().reverse(); + new Chart(document.getElementById('sparkWeather'), { + type: 'line', + data: { + labels: weatherAsc.map(function (r) { return r.observation_date; }), + datasets: [{ + data: weatherAsc.map(function (r) { return r.avg_crop_stress_index; }), + borderColor: C.copper, + backgroundColor: C.copper + '22', + fill: true, + tension: 0.3, + borderWidth: 2, + }], + }, + options: sparkOpts(C.copper, true), + }); + } }()); {% endblock %} From 86284968817902c08a6611b76814100b4447c757 Mon Sep 17 00:00:00 2001 From: Deeman Date: Thu, 26 Feb 2026 02:52:45 +0100 Subject: [PATCH 6/6] feat(dev): open browser automatically on dev server ready MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Polls /auth/dev-login until the app responds, then opens an incognito/private window — same pattern as padelnomics. Tries flatpak Chrome → flatpak Firefox → system Chrome → Chromium → Firefox in that order. Co-Authored-By: Claude Opus 4.6 --- web/scripts/dev_run.sh | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/web/scripts/dev_run.sh b/web/scripts/dev_run.sh index df3d11f..dbfefa1 100644 --- a/web/scripts/dev_run.sh +++ b/web/scripts/dev_run.sh @@ -165,4 +165,27 @@ run_with_label "$COLOR_APP" "app " uv run --package beanflows python -m bea run_with_label "$COLOR_WORKER" "worker" uv run --package beanflows python -m beanflows.worker run_with_label "$COLOR_CSS" "css " make css-watch +# Open a private/incognito browser window once the server is ready. +# Polls /auth/dev-login until it responds (up to 10 seconds), then launches. +( + DEV_URL="http://localhost:5001/auth/dev-login?email=pro@beanflows.coffee" + for i in $(seq 1 20); do + sleep 0.5 + if curl -s -o /dev/null -w "%{http_code}" "$DEV_URL" 2>/dev/null | grep -qE "^[23]"; then + break + fi + done + if flatpak info com.google.Chrome >/dev/null 2>&1; then + flatpak run com.google.Chrome --incognito "$DEV_URL" &>/dev/null & + elif flatpak info org.mozilla.firefox >/dev/null 2>&1; then + flatpak run org.mozilla.firefox --private-window "$DEV_URL" &>/dev/null & + elif command -v google-chrome >/dev/null 2>&1; then + google-chrome --incognito "$DEV_URL" &>/dev/null & + elif command -v chromium >/dev/null 2>&1; then + chromium --incognito "$DEV_URL" &>/dev/null & + elif command -v firefox >/dev/null 2>&1; then + firefox --private-window "$DEV_URL" &>/dev/null & + fi +) & + wait