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 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-26 02:39:29 +01:00
parent 89c9f89c8e
commit 494f7ff1ee

View File

@@ -25,16 +25,13 @@
color: var(--color-stone); color: var(--color-stone);
} }
/* 2×2 sparkline grid */ /* Sparkline grid — auto-fit for 4 or 5 cards */
.spark-grid { .spark-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem; gap: 1rem;
margin-top: 1.5rem; margin-top: 1.5rem;
} }
@media (max-width: 600px) {
.spark-grid { grid-template-columns: 1fr; }
}
.spark-card { .spark-card {
background: white; background: white;
@@ -94,7 +91,7 @@
<p>Global coffee market — full picture in 10 seconds{% if user.name %} · Welcome back, {{ user.name }}{% endif %}</p> <p>Global coffee market — full picture in 10 seconds{% if user.name %} · Welcome back, {{ user.name }}{% endif %}</p>
</div> </div>
<!-- 4 metric cards --> <!-- Metric cards -->
<div class="grid-4 mb-6 pulse-in"> <div class="grid-4 mb-6 pulse-in">
<div class="metric-card"> <div class="metric-card">
<div class="metric-label">KC=F Close</div> <div class="metric-label">KC=F Close</div>
@@ -139,6 +136,17 @@
</div> </div>
<div class="metric-sub">ending stocks / consumption{% if stu_latest %} · {{ stu_latest.market_year }}{% endif %}</div> <div class="metric-sub">ending stocks / consumption{% if stu_latest %} · {{ stu_latest.market_year }}{% endif %}</div>
</div> </div>
<div class="metric-card">
<div class="metric-label">Crop Stress Index</div>
<div class="metric-value {% if weather_stress_latest and weather_stress_latest.avg_crop_stress_index > 40 %}text-danger{% elif weather_stress_latest and weather_stress_latest.avg_crop_stress_index > 20 %}text-copper{% else %}text-bean-green{% endif %}">
{% if weather_stress_latest %}{{ "{:.0f}".format(weather_stress_latest.avg_crop_stress_index) }}/100{% else %}--{% endif %}
</div>
<div class="metric-sub">
avg · {% if weather_stress_latest %}{{ weather_stress_latest.locations_under_stress }} stressed{% endif %}
{% if weather_stress_latest %} · {{ weather_stress_latest.observation_date }}{% endif %}
</div>
</div>
</div> </div>
<!-- Freshness bar --> <!-- Freshness bar -->
@@ -156,6 +164,9 @@
<a href="{{ url_for('public.methodology') }}" class="freshness-badge"> <a href="{{ url_for('public.methodology') }}" class="freshness-badge">
<strong>ICE Stocks</strong> {% if ice_stocks_latest %}{{ ice_stocks_latest.report_date }}{% else %}—{% endif %} <strong>ICE Stocks</strong> {% if ice_stocks_latest %}{{ ice_stocks_latest.report_date }}{% else %}—{% endif %}
</a> </a>
<a href="{{ url_for('dashboard.weather') }}" class="freshness-badge">
<strong>Weather</strong> {% if weather_stress_latest %}{{ weather_stress_latest.observation_date }}{% else %}—{% endif %}
</a>
</div> </div>
<!-- 2×2 sparkline grid --> <!-- 2×2 sparkline grid -->
@@ -209,6 +220,18 @@
<div class="spark-meta">USDA WASDE · 1,000 60-kg bags · production vs distribution</div> <div class="spark-meta">USDA WASDE · 1,000 60-kg bags · production vs distribution</div>
</div> </div>
<!-- Weather Crop Stress 90d -->
<div class="spark-card">
<div class="spark-card-head">
<div class="spark-title">Crop Stress Index — 90 days</div>
<a href="{{ url_for('dashboard.weather') }}" class="spark-detail-link">View details →</a>
</div>
<div class="spark-chart-wrap">
<canvas id="sparkWeather"></canvas>
</div>
<div class="spark-meta">Open-Meteo ERA5 · avg across 12 origins · 0 = no stress · 100 = severe</div>
</div>
</div> </div>
{% if plan == "free" %} {% if plan == "free" %}
@@ -338,6 +361,27 @@
options: sparkOpts(C.copper, false), 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),
});
}
}()); }());
</script> </script>
{% endblock %} {% endblock %}