dashboard: JTBD-driven restructure — Pulse, Supply, Positioning, Warehouse

Replace monolithic Overview (8 charts, 24 metric cards, no filters) with
a JTBD-driven 5-page dashboard optimised for the data-drop moment.

Navigation (sidebar + mobile nav):
- Pulse      /dashboard/         — full-picture overview, 10-second read
- Supply     /dashboard/supply   — USDA WASDE deep dive, range + metric filters
- Positioning /dashboard/positioning — KC=F price + CFTC COT, range filter
- Warehouse  /dashboard/warehouse — ICE certified stocks, range + view filters
- Origins    /dashboard/countries — unchanged (HTMX already live)
- Settings                       — unchanged

New templates:
- pulse.html: 4 metric cards + freshness bar + 2×2 sparkline grid
- supply.html + supply_canvas.html: HTMX partial with 5Y/10Y/Max and
  Production/Exports/Imports/Stocks filter pills; free plan gated at 5Y
- positioning.html + positioning_canvas.html: price chart + COT dual-axis;
  client-side MA toggles (no server round-trip)
- warehouse.html + warehouse_canvas.html: Daily Stocks / Aging / By Port
  view switcher; only active view's queries fire

routes.py:
- RANGE_MAP dict maps URL param → {days, weeks, months, years}
- _safe() helper absorbs asyncio.gather exceptions with defaults
- index() rewritten: 8 lightweight queries, renders pulse.html
- supply(), positioning(), warehouse() routes added; HX-Request detection
  returns canvas partial; full request returns page shell

input.css:
- All cc-* component classes moved from countries.html inline style to
  global stylesheet (cc-chart-card, cc-trow 3-col grid, cc-empty, etc.)
- filter-bar, filter-pills, filter-pill, canvas-loading, freshness-badge
- cc-chart-body canvas max-height 340px (prevents gigantic charts on 4K)

_feedback_widget.html:
- Mobile: collapses to circular icon button at bottom:72px to clear 5-item
  nav bar; "Feedback" label hidden on mobile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-22 01:27:44 +01:00
parent 0d78a22023
commit 090fcb4fdb
13 changed files with 2009 additions and 683 deletions

View File

@@ -90,89 +90,245 @@ async def delete_api_key(key_id: int, user_id: int) -> bool:
# Routes
# =============================================================================
# Time range param → query parameter mapping
RANGE_MAP = {
"3m": {"days": 90, "weeks": 13, "months": 3, "years": 3},
"6m": {"days": 180, "weeks": 26, "months": 6, "years": 3},
"1y": {"days": 365, "weeks": 52, "months": 12, "years": 5},
"2y": {"days": 730, "weeks": 104, "months": 24, "years": 5},
"5y": {"days": 1825, "weeks": 260, "months": 60, "years": 5},
"10y": {"days": 3650, "weeks": 520, "months": 120, "years": 10},
"max": {"days": 3650, "weeks": 1040, "months": 360, "years": None},
}
def _safe(results: list, defaults: list):
"""Map asyncio.gather results, replacing exceptions with defaults and logging them."""
out = []
for i, (r, d) in enumerate(zip(results, defaults)):
if isinstance(r, Exception):
current_app.logger.warning("Analytics query %d failed: %s", i, r)
out.append(d)
else:
out.append(r)
return out
@bp.route("/")
@login_required
async def index():
"""Coffee analytics dashboard."""
"""Pulse — one-screen snapshot across all data domains."""
user = g.user
stats = await get_user_stats(g.user["id"])
plan = (g.get("subscription") or {}).get("plan", "free")
# Fetch all analytics data in parallel (empty lists/None if DB not available)
if analytics._conn is not None:
results = await asyncio.gather(
analytics.get_price_latest(analytics.COFFEE_TICKER),
analytics.get_cot_positioning_latest(analytics.COFFEE_CFTC_CODE),
analytics.get_ice_stocks_latest(),
analytics.get_stock_to_use_trend(analytics.COFFEE_COMMODITY_CODE),
analytics.get_price_time_series(analytics.COFFEE_TICKER, limit=90),
analytics.get_ice_stocks_trend(days=90),
analytics.get_cot_index_trend(analytics.COFFEE_CFTC_CODE, weeks=26),
analytics.get_global_time_series(
analytics.COFFEE_COMMODITY_CODE,
["production", "total_distribution"],
start_year=(None if plan != "free" else None),
),
return_exceptions=True,
)
defaults = [None, None, None, [], [], [], [], []]
(
price_latest, cot_latest, ice_stocks_latest,
stu_trend, price_series, ice_stocks_trend, cot_trend, time_series,
) = _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 = [], [], [], [], []
# Latest stock-to-use for the metric card
stu_latest = stu_trend[-1] if stu_trend else None
# Free plan: cap supply sparkline to last 5 years
if plan == "free" and time_series:
max_year = time_series[-1]["market_year"]
time_series = [r for r in time_series if r["market_year"] >= max_year - 5]
return await render_template(
"pulse.html",
user=user,
plan=plan,
price_latest=price_latest,
cot_latest=cot_latest,
ice_stocks_latest=ice_stocks_latest,
stu_latest=stu_latest,
price_series=price_series,
ice_stocks_trend=ice_stocks_trend,
cot_trend=cot_trend,
time_series=time_series,
)
@bp.route("/supply")
@login_required
async def supply():
"""Supply & demand deep-dive — USDA PSD fundamentals."""
plan = (g.get("subscription") or {}).get("plan", "free")
range_key = request.args.get("range", "5y")
if range_key not in RANGE_MAP:
range_key = "5y"
# Free plan capped at 5y on supply page
if plan == "free" and range_key in ("10y", "max"):
range_key = "5y"
metric = request.args.get("metric", "production")
if metric not in analytics.ALLOWED_METRICS:
metric = "production"
rng = RANGE_MAP[range_key]
start_year = None
if rng["years"] is not None:
import datetime
current_year = datetime.date.today().year
start_year = current_year - rng["years"]
if analytics._conn is not None:
results = await asyncio.gather(
analytics.get_global_time_series(
analytics.COFFEE_COMMODITY_CODE,
["production", "exports", "imports", "ending_stocks", "total_distribution"],
start_year=start_year,
),
analytics.get_top_countries(analytics.COFFEE_COMMODITY_CODE, "production", limit=10),
analytics.get_stock_to_use_trend(analytics.COFFEE_COMMODITY_CODE),
analytics.get_supply_demand_balance(analytics.COFFEE_COMMODITY_CODE),
analytics.get_top_countries(analytics.COFFEE_COMMODITY_CODE, metric, limit=10),
analytics.get_production_yoy_by_country(analytics.COFFEE_COMMODITY_CODE, limit=15),
analytics.get_cot_positioning_latest(analytics.COFFEE_CFTC_CODE),
analytics.get_cot_index_trend(analytics.COFFEE_CFTC_CODE, weeks=104),
analytics.get_price_time_series(analytics.COFFEE_TICKER, limit=504),
analytics.get_price_latest(analytics.COFFEE_TICKER),
analytics.get_ice_stocks_trend(days=365),
analytics.get_ice_stocks_latest(),
analytics.get_ice_aging_latest(),
analytics.get_ice_stocks_by_port_trend(months=120),
analytics.get_ice_stocks_by_port_latest(),
return_exceptions=True,
)
defaults = [[], [], [], [], [], None, [], [], None, [], None, [], [], None]
(
time_series, top_producers, stu_trend, balance, yoy,
cot_latest, cot_trend,
price_series, price_latest,
ice_stocks_trend, ice_stocks_latest,
ice_aging_latest, ice_stocks_by_port, ice_stocks_by_port_latest,
) = [
r if not isinstance(r, Exception) else (
current_app.logger.warning("Analytics query %d failed: %s", i, r) or defaults[i]
)
for i, r in enumerate(results)
]
defaults = [[], [], [], [], []]
time_series, stu_trend, balance, top_countries, yoy = _safe(results, defaults)
# Apply year filter to stu/balance (get_global_time_series already filters)
if start_year is not None:
stu_trend = [r for r in stu_trend if r["market_year"] >= start_year]
balance = [r for r in balance if r["market_year"] >= start_year]
else:
time_series, top_producers, stu_trend, balance, yoy = [], [], [], [], []
cot_latest, cot_trend = None, []
price_series, price_latest = [], None
ice_stocks_trend, ice_stocks_latest = [], None
ice_aging_latest, ice_stocks_by_port, ice_stocks_by_port_latest = [], [], None
time_series, stu_trend, balance, top_countries, yoy = [], [], [], [], []
# Latest global snapshot for key metric cards
latest = time_series[-1] if time_series else {}
# Apply free plan history limit (last 5 years)
if plan == "free" and time_series:
max_year = time_series[-1]["market_year"]
cutoff_year = max_year - 5
time_series = [r for r in time_series if r["market_year"] >= cutoff_year]
stu_trend = [r for r in stu_trend if r["market_year"] >= cutoff_year]
balance = [r for r in balance if r["market_year"] >= cutoff_year]
return await render_template(
"index.html",
user=user,
stats=stats,
ctx = dict(
plan=plan,
latest=latest,
range_key=range_key,
metric=metric,
time_series=time_series,
top_producers=top_producers,
stu_trend=stu_trend,
balance=balance,
top_countries=top_countries,
yoy=yoy,
)
if request.headers.get("HX-Request"):
return await render_template("supply_canvas.html", **ctx)
return await render_template("supply.html", user=g.user, **ctx)
@bp.route("/positioning")
@login_required
async def positioning():
"""Market positioning deep-dive — CFTC COT + KC=F price."""
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"
rng = RANGE_MAP[range_key]
price_limit = rng["days"]
cot_weeks = rng["weeks"]
if analytics._conn is not None:
results = await asyncio.gather(
analytics.get_price_latest(analytics.COFFEE_TICKER),
analytics.get_price_time_series(analytics.COFFEE_TICKER, limit=price_limit),
analytics.get_cot_positioning_latest(analytics.COFFEE_CFTC_CODE),
analytics.get_cot_index_trend(analytics.COFFEE_CFTC_CODE, weeks=cot_weeks),
return_exceptions=True,
)
defaults = [None, [], None, []]
price_latest, price_series, cot_latest, cot_trend = _safe(results, defaults)
else:
price_latest, price_series, cot_latest, cot_trend = None, [], None, []
ctx = dict(
plan=plan,
range_key=range_key,
price_latest=price_latest,
price_series=price_series,
cot_latest=cot_latest,
cot_trend=cot_trend,
price_series=price_series,
price_latest=price_latest,
ice_stocks_trend=ice_stocks_trend,
ice_stocks_latest=ice_stocks_latest,
ice_aging_latest=ice_aging_latest,
ice_stocks_by_port=ice_stocks_by_port,
ice_stocks_by_port_latest=ice_stocks_by_port_latest,
)
if request.headers.get("HX-Request"):
return await render_template("positioning_canvas.html", **ctx)
return await render_template("positioning.html", user=g.user, **ctx)
@bp.route("/warehouse")
@login_required
async def warehouse():
"""Warehouse deep-dive — ICE physical stocks."""
plan = (g.get("subscription") or {}).get("plan", "free")
range_key = request.args.get("range", "1y")
view = request.args.get("view", "stocks")
if range_key not in RANGE_MAP:
range_key = "1y"
if view not in ("stocks", "aging", "byport"):
view = "stocks"
rng = RANGE_MAP[range_key]
stocks_latest = stocks_trend = aging_latest = byport_latest = byport_trend = None
stocks_trend = aging_latest = byport_trend = []
if analytics._conn is not None:
if view == "stocks":
results = await asyncio.gather(
analytics.get_ice_stocks_latest(),
analytics.get_ice_stocks_trend(days=rng["days"]),
return_exceptions=True,
)
stocks_latest, stocks_trend = _safe(results, [None, []])
elif view == "aging":
aging_latest = await asyncio.gather(
analytics.get_ice_aging_latest(),
return_exceptions=True,
)
aging_latest = _safe(aging_latest, [[]])[0]
elif view == "byport":
results = await asyncio.gather(
analytics.get_ice_stocks_by_port_latest(),
analytics.get_ice_stocks_by_port_trend(months=rng["months"]),
return_exceptions=True,
)
byport_latest, byport_trend = _safe(results, [None, []])
ctx = dict(
plan=plan,
range_key=range_key,
view=view,
stocks_latest=stocks_latest,
stocks_trend=stocks_trend,
aging_latest=aging_latest,
byport_latest=byport_latest,
byport_trend=byport_trend,
)
if request.headers.get("HX-Request"):
return await render_template("warehouse_canvas.html", **ctx)
return await render_template("warehouse.html", user=g.user, **ctx)
@bp.route("/countries")
@login_required

View File

@@ -44,12 +44,35 @@
<a href="{{ url_for('dashboard.index') }}"
class="sidebar-item{% if request.path == '/dashboard/' %} active{% endif %}">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="3" width="7" height="7" rx="1.5"/>
<rect x="14" y="3" width="7" height="7" rx="1.5"/>
<rect x="3" y="14" width="7" height="7" rx="1.5"/>
<rect x="14" y="14" width="7" height="7" rx="1.5"/>
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
Overview
Pulse
</a>
<a href="{{ url_for('dashboard.supply') }}"
class="sidebar-item{% if request.path.startswith('/dashboard/supply') %} active{% endif %}">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24" aria-hidden="true">
<line x1="18" y1="20" x2="18" y2="10"/>
<line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/>
</svg>
Supply
</a>
<a href="{{ url_for('dashboard.positioning') }}"
class="sidebar-item{% if request.path.startswith('/dashboard/positioning') %} active{% endif %}">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24" aria-hidden="true">
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/>
<polyline points="17 6 23 6 23 12"/>
</svg>
Positioning
</a>
<a href="{{ url_for('dashboard.warehouse') }}"
class="sidebar-item{% if request.path.startswith('/dashboard/warehouse') %} active{% endif %}">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24" aria-hidden="true">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
<line x1="12" y1="22.08" x2="12" y2="12"/>
</svg>
Warehouse
</a>
<a href="{{ url_for('dashboard.countries') }}"
class="sidebar-item{% if request.path.startswith('/dashboard/countries') %} active{% endif %}">
@@ -93,12 +116,35 @@
<a href="{{ url_for('dashboard.index') }}"
class="mobile-nav-item{% if request.path == '/dashboard/' %} active{% endif %}">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24">
<rect x="3" y="3" width="7" height="7" rx="1.5"/>
<rect x="14" y="3" width="7" height="7" rx="1.5"/>
<rect x="3" y="14" width="7" height="7" rx="1.5"/>
<rect x="14" y="14" width="7" height="7" rx="1.5"/>
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
<span>Overview</span>
<span>Pulse</span>
</a>
<a href="{{ url_for('dashboard.supply') }}"
class="mobile-nav-item{% if request.path.startswith('/dashboard/supply') %} active{% endif %}">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24">
<line x1="18" y1="20" x2="18" y2="10"/>
<line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/>
</svg>
<span>Supply</span>
</a>
<a href="{{ url_for('dashboard.positioning') }}"
class="mobile-nav-item{% if request.path.startswith('/dashboard/positioning') %} active{% endif %}">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24">
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/>
<polyline points="17 6 23 6 23 12"/>
</svg>
<span>Position</span>
</a>
<a href="{{ url_for('dashboard.warehouse') }}"
class="mobile-nav-item{% if request.path.startswith('/dashboard/warehouse') %} active{% endif %}">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
<line x1="12" y1="22.08" x2="12" y2="12"/>
</svg>
<span>Warehouse</span>
</a>
<a href="{{ url_for('dashboard.countries') }}"
class="mobile-nav-item{% if request.path.startswith('/dashboard/countries') %} active{% endif %}">
@@ -110,14 +156,6 @@
</svg>
<span>Origins</span>
</a>
<a href="{{ url_for('dashboard.settings') }}"
class="mobile-nav-item{% if request.path.startswith('/dashboard/settings') %} active{% endif %}">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="3"/>
<path d="M12 2v2M12 20v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M2 12h2M20 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</svg>
<span>Settings</span>
</a>
</nav>
<!-- HTMX -->

View File

@@ -1,605 +0,0 @@
{% extends "dashboard_base.html" %}
{% block title %}Dashboard — {{ config.APP_NAME }}{% endblock %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
{% endblock %}
{% block content %}
<!-- Page Header -->
<div class="page-header">
<h1>Coffee Dashboard</h1>
<p>Welcome back{% if user.name %}, {{ user.name }}{% endif %}! Global coffee market data from USDA PSD.</p>
</div>
<!-- Key Metric Cards -->
<div class="grid-4 mb-8">
<div class="metric-card">
<div class="metric-label">Global Production (latest year)</div>
<div class="metric-value">{{ "{:,.0f}".format(latest.get("production", 0)) }}</div>
<div class="metric-sub">1,000 60-kg bags</div>
</div>
<div class="metric-card">
<div class="metric-label">Stock-to-Use Ratio</div>
<div class="metric-value">
{% if stu_trend %}
{{ "{:.1f}".format(stu_trend[-1].get("stock_to_use_ratio_pct", 0)) }}%
{% else %}
--
{% endif %}
</div>
<div class="metric-sub">Ending stocks / consumption</div>
</div>
<div class="metric-card">
<div class="metric-label">Trade Balance</div>
<div class="metric-value">{{ "{:,.0f}".format(latest.get("exports", 0) - latest.get("imports", 0)) }}</div>
<div class="metric-sub">Exports minus imports</div>
</div>
<div class="metric-card">
<div class="metric-label">Your Plan</div>
<div class="metric-value">{{ plan | title }}</div>
<div class="metric-sub">
{% if plan == "free" %}
<a href="{{ url_for('billing.pricing') }}">Upgrade for full history</a>
{% else %}
{{ stats.api_calls }} API calls (30d)
{% endif %}
</div>
</div>
</div>
<!-- Global Supply/Demand Time Series -->
<div class="chart-container mb-8">
<h2 class="text-xl mb-1">Global Supply &amp; Demand</h2>
{% if plan == "free" %}
<div class="plan-gate mb-4">Showing last 5 years. <a href="{{ url_for('billing.pricing') }}">Upgrade</a> for full 18+ year history.</div>
{% endif %}
<canvas id="supplyDemandChart"></canvas>
</div>
<!-- Stock-to-Use Ratio -->
<div class="chart-container mb-8">
<h2 class="text-xl mb-4">Stock-to-Use Ratio Trend</h2>
<canvas id="stuChart"></canvas>
</div>
<!-- Two-column: Top Producers + YoY Table -->
<div class="grid-2 mb-8">
<div class="chart-container">
<h2 class="text-xl mb-4">Top Producing Countries</h2>
<canvas id="topProducersChart"></canvas>
</div>
<div class="card">
<h2 class="text-xl mb-4">Year-over-Year Production Change</h2>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Country</th>
<th class="text-right">Production (1k bags)</th>
<th class="text-right">YoY %</th>
</tr>
</thead>
<tbody>
{% for row in yoy %}
<tr>
<td>{{ row.country_name }}</td>
<td class="text-right">{{ "{:,.0f}".format(row.production) }}</td>
<td class="text-right {% if row.production_yoy_pct and row.production_yoy_pct > 0 %}text-bean-green{% elif row.production_yoy_pct and row.production_yoy_pct < 0 %}text-danger{% endif %}">
{% if row.production_yoy_pct is not none %}
{{ "{:+.1f}%".format(row.production_yoy_pct) }}
{% else %}
--
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- CSV Export (plan-gated) -->
{% if plan != "free" %}
<div class="mb-8">
<a href="{{ url_for('api.commodity_metrics_csv', code=711100) }}" class="btn-outline">Export CSV</a>
</div>
{% else %}
<div class="plan-gate mb-8">CSV export available on Trader and Analyst plans. <a href="{{ url_for('billing.pricing') }}">Upgrade</a></div>
{% endif %}
<!-- Speculative Positioning (CFTC COT) -->
{% if cot_latest %}
<div class="chart-container mb-8">
<h2 class="text-xl mb-1">Speculative Positioning — Coffee C Futures</h2>
<p class="text-muted mb-4">CFTC Commitment of Traders · Managed Money net position (hedge funds &amp; CTAs) · as of {{ cot_latest.report_date }}</p>
<div class="grid-4 mb-4">
<div class="metric-card">
<div class="metric-label">Managed Money Net</div>
<div class="metric-value {% if cot_latest.managed_money_net > 0 %}text-green{% else %}text-red{% endif %}">
{{ "{:+,d}".format(cot_latest.managed_money_net | int) }}
</div>
<div class="metric-sub">contracts (long short)</div>
</div>
<div class="metric-card">
<div class="metric-label">COT Index (26w)</div>
<div class="metric-value">{{ "{:.0f}".format(cot_latest.cot_index_26w) }}</div>
<div class="metric-sub">0 = most bearish · 100 = most bullish</div>
</div>
<div class="metric-card">
<div class="metric-label">Net % of Open Interest</div>
<div class="metric-value">{{ "{:+.1f}".format(cot_latest.managed_money_net_pct_of_oi) }}%</div>
<div class="metric-sub">managed money positioning</div>
</div>
<div class="metric-card">
<div class="metric-label">Open Interest</div>
<div class="metric-value">{{ "{:,d}".format(cot_latest.open_interest | int) }}</div>
<div class="metric-sub">total contracts outstanding</div>
</div>
</div>
<canvas id="cotPositioningChart"></canvas>
</div>
{% endif %}
<!-- Coffee Futures Price (KC=F) -->
{% if price_latest %}
<div class="chart-container mb-8">
<h2 class="text-xl mb-1">Coffee C Futures Price — KC=F</h2>
<p class="text-muted mb-4">ICE Coffee C Arabica · Daily close price · Source: Yahoo Finance</p>
<div class="grid-4 mb-4">
<div class="metric-card">
<div class="metric-label">Latest Close</div>
<div class="metric-value">{{ "{:.2f}".format(price_latest.close) }}</div>
<div class="metric-sub">¢/lb · as of {{ price_latest.trade_date }}</div>
</div>
<div class="metric-card">
<div class="metric-label">Daily Change</div>
<div class="metric-value {% if price_latest.daily_return_pct and price_latest.daily_return_pct > 0 %}text-green{% elif price_latest.daily_return_pct and price_latest.daily_return_pct < 0 %}text-red{% endif %}">
{% if price_latest.daily_return_pct is not none %}
{{ "{:+.2f}%".format(price_latest.daily_return_pct) }}
{% else %}--{% endif %}
</div>
<div class="metric-sub">vs previous close</div>
</div>
<div class="metric-card">
<div class="metric-label">52-Week High</div>
<div class="metric-value">{{ "{:.2f}".format(price_latest.high_52w) }}</div>
<div class="metric-sub">¢/lb</div>
</div>
<div class="metric-card">
<div class="metric-label">52-Week Low</div>
<div class="metric-value">{{ "{:.2f}".format(price_latest.low_52w) }}</div>
<div class="metric-sub">¢/lb</div>
</div>
</div>
<canvas id="priceChart"></canvas>
</div>
{% endif %}
<!-- ICE Certified Warehouse Stocks -->
{% if ice_stocks_latest %}
<div class="chart-container mb-8">
<h2 class="text-xl mb-1">ICE Certified Warehouse Stocks</h2>
<p class="text-muted mb-4">Physical Arabica certified for delivery against ICE Coffee C futures · as of {{ ice_stocks_latest.report_date }}</p>
<div class="grid-4 mb-4">
<div class="metric-card">
<div class="metric-label">Certified Stocks</div>
<div class="metric-value">{{ "{:,.0f}".format(ice_stocks_latest.total_certified_bags) }}</div>
<div class="metric-sub">60-kg bags</div>
</div>
<div class="metric-card">
<div class="metric-label">Week-over-Week</div>
<div class="metric-value {% if ice_stocks_latest.wow_change_bags and ice_stocks_latest.wow_change_bags > 0 %}text-green{% elif ice_stocks_latest.wow_change_bags and ice_stocks_latest.wow_change_bags < 0 %}text-red{% endif %}">
{% if ice_stocks_latest.wow_change_bags is not none %}
{{ "{:+,d}".format(ice_stocks_latest.wow_change_bags | int) }}
{% else %}--{% endif %}
</div>
<div class="metric-sub">bags vs previous day</div>
</div>
<div class="metric-card">
<div class="metric-label">30-Day Average</div>
<div class="metric-value">{{ "{:,.0f}".format(ice_stocks_latest.avg_30d_bags) }}</div>
<div class="metric-sub">60-kg bags</div>
</div>
<div class="metric-card">
<div class="metric-label">Drawdown from 52w High</div>
<div class="metric-value {% if ice_stocks_latest.drawdown_from_52w_high_pct and ice_stocks_latest.drawdown_from_52w_high_pct < -10 %}text-red{% endif %}">
{% if ice_stocks_latest.drawdown_from_52w_high_pct is not none %}
{{ "{:.1f}%".format(ice_stocks_latest.drawdown_from_52w_high_pct) }}
{% else %}--{% endif %}
</div>
<div class="metric-sub">below 52-week peak</div>
</div>
</div>
<canvas id="iceStocksChart"></canvas>
</div>
{% endif %}
<!-- ICE Certified Stock Aging Report -->
{% if ice_aging_latest %}
{% set aging_total = ice_aging_latest | sum(attribute='total_bags') %}
{% set youngest = ice_aging_latest | selectattr('age_bucket_start_days', 'equalto', 0) | list %}
{% set oldest = ice_aging_latest | selectattr('age_bucket_start_days', 'ge', 720) | list %}
{% set youngest_pct = (youngest | sum(attribute='total_bags') / aging_total * 100) if aging_total > 0 else 0 %}
{% set oldest_pct = (oldest | sum(attribute='total_bags') / aging_total * 100) if aging_total > 0 else 0 %}
<div class="chart-container mb-8">
<h2 class="text-xl mb-1">Certified Stock Aging Report</h2>
<p class="text-muted mb-4">Age distribution of certified Arabica stocks by delivery port · as of {{ ice_aging_latest[0].report_date }}</p>
<div class="grid-4 mb-4">
<div class="metric-card">
<div class="metric-label">Total Certified</div>
<div class="metric-value">{{ "{:,.0f}".format(aging_total) }}</div>
<div class="metric-sub">60-kg bags</div>
</div>
<div class="metric-card">
<div class="metric-label">Young Stock (0120 days)</div>
<div class="metric-value text-green">{{ "{:.1f}%".format(youngest_pct) }}</div>
<div class="metric-sub">freshest certified supply</div>
</div>
<div class="metric-card">
<div class="metric-label">Old Stock (720+ days)</div>
<div class="metric-value {% if oldest_pct > 20 %}text-red{% endif %}">{{ "{:.1f}%".format(oldest_pct) }}</div>
<div class="metric-sub">at risk of cert expiry</div>
</div>
<div class="metric-card">
<div class="metric-label">Age Buckets</div>
<div class="metric-value">{{ ice_aging_latest | length }}</div>
<div class="metric-sub">tracking ranges</div>
</div>
</div>
<canvas id="iceAgingChart"></canvas>
</div>
{% endif %}
<!-- ICE Historical Warehouse Stocks by Port -->
{% if ice_stocks_by_port_latest %}
<div class="chart-container mb-8">
<h2 class="text-xl mb-1">Warehouse Stocks by Delivery Port</h2>
<p class="text-muted mb-4">End-of-month certified stocks at each ICE delivery port · Nov 1996 to present · as of {{ ice_stocks_by_port_latest.report_date }}</p>
<div class="grid-4 mb-4">
<div class="metric-card">
<div class="metric-label">Total Certified</div>
<div class="metric-value">{{ "{:,.0f}".format(ice_stocks_by_port_latest.total_bags) }}</div>
<div class="metric-sub">60-kg bags (latest month)</div>
</div>
<div class="metric-card">
<div class="metric-label">Month-over-Month</div>
<div class="metric-value {% if ice_stocks_by_port_latest.mom_change_bags and ice_stocks_by_port_latest.mom_change_bags > 0 %}text-green{% elif ice_stocks_by_port_latest.mom_change_bags and ice_stocks_by_port_latest.mom_change_bags < 0 %}text-red{% endif %}">
{% if ice_stocks_by_port_latest.mom_change_bags is not none %}
{{ "{:+,d}".format(ice_stocks_by_port_latest.mom_change_bags | int) }}
{% else %}--{% endif %}
</div>
<div class="metric-sub">bags vs prior month</div>
</div>
<div class="metric-card">
<div class="metric-label">12-Month Average</div>
<div class="metric-value">{{ "{:,.0f}".format(ice_stocks_by_port_latest.avg_12m_bags) }}</div>
<div class="metric-sub">60-kg bags</div>
</div>
<div class="metric-card">
<div class="metric-label">History</div>
<div class="metric-value">30 yrs</div>
<div class="metric-sub">since Nov 1996</div>
</div>
</div>
<canvas id="iceByPortChart"></canvas>
</div>
{% endif %}
<!-- Quick Actions -->
<div class="grid-3">
<a href="{{ url_for('dashboard.countries') }}" class="btn-outline text-center">Country Comparison</a>
<a href="{{ url_for('dashboard.settings') }}" class="btn-outline text-center">Settings</a>
<a href="{{ url_for('dashboard.settings') }}#api-keys" class="btn-outline text-center">API Keys</a>
</div>
{% endblock %}
{% block scripts %}
<script>
// Brand chart colors
const CHART_COLORS = {
copper: '#B45309',
roast: '#4A2C1A',
beanGreen: '#15803D',
forest: '#064E3B',
stone: '#78716C',
espresso: '#2C1810',
warning: '#D97706',
danger: '#EF4444',
parchment: '#E8DFD5',
latte: '#F5F0EB',
};
const CHART_PALETTE = [
CHART_COLORS.copper,
CHART_COLORS.beanGreen,
CHART_COLORS.roast,
CHART_COLORS.forest,
CHART_COLORS.stone,
CHART_COLORS.warning,
CHART_COLORS.danger,
CHART_COLORS.espresso,
];
// Chart.js defaults
Chart.defaults.font.family = "'DM Sans', sans-serif";
Chart.defaults.color = CHART_COLORS.stone;
Chart.defaults.borderColor = CHART_COLORS.parchment;
// -- Supply/Demand Chart --
const tsData = {{ time_series | tojson }};
if (tsData.length > 0) {
new Chart(document.getElementById('supplyDemandChart'), {
type: 'line',
data: {
labels: tsData.map(r => r.market_year),
datasets: [
{label: 'Production', data: tsData.map(r => r.production), borderColor: CHART_PALETTE[0], tension: 0.3},
{label: 'Exports', data: tsData.map(r => r.exports), borderColor: CHART_PALETTE[1], tension: 0.3},
{label: 'Imports', data: tsData.map(r => r.imports), borderColor: CHART_PALETTE[2], tension: 0.3},
{label: 'Ending Stocks', data: tsData.map(r => r.ending_stocks), borderColor: CHART_PALETTE[3], tension: 0.3},
{label: 'Total Distribution', data: tsData.map(r => r.total_distribution), borderColor: CHART_PALETTE[4], tension: 0.3},
]
},
options: {
responsive: true,
plugins: {legend: {position: 'bottom'}},
scales: {y: {title: {display: true, text: '1,000 60-kg bags'}, beginAtZero: false}}
}
});
}
// -- Stock-to-Use Chart --
const stuData = {{ stu_trend | tojson }};
if (stuData.length > 0) {
new Chart(document.getElementById('stuChart'), {
type: 'line',
data: {
labels: stuData.map(r => r.market_year),
datasets: [{
label: 'Stock-to-Use Ratio (%)',
data: stuData.map(r => r.stock_to_use_ratio_pct),
borderColor: CHART_COLORS.copper,
backgroundColor: 'rgba(180, 83, 9, 0.08)',
fill: true,
tension: 0.3
}]
},
options: {
responsive: true,
plugins: {legend: {display: false}},
scales: {y: {title: {display: true, text: 'Stock-to-Use (%)'}, beginAtZero: false}}
}
});
}
// -- COT Positioning Chart --
const cotRaw = {{ cot_trend | tojson }};
if (cotRaw && cotRaw.length > 0) {
const cotData = [...cotRaw].reverse(); // query returns DESC, chart needs ASC
new Chart(document.getElementById('cotPositioningChart'), {
type: 'line',
data: {
labels: cotData.map(r => r.report_date),
datasets: [
{
label: 'Managed Money Net (contracts)',
data: cotData.map(r => r.managed_money_net),
borderColor: CHART_PALETTE[0],
backgroundColor: CHART_PALETTE[0] + '22',
fill: true,
tension: 0.3,
yAxisID: 'y'
},
{
label: 'COT Index 26w (0100)',
data: cotData.map(r => r.cot_index_26w),
borderColor: CHART_PALETTE[2],
borderDash: [5, 4],
tension: 0.3,
pointRadius: 0,
yAxisID: 'y1'
}
]
},
options: {
responsive: true,
interaction: {mode: 'index', intersect: false},
plugins: {legend: {position: 'bottom'}},
scales: {
x: {ticks: {maxTicksLimit: 12}},
y: {
title: {display: true, text: 'Net Contracts'},
position: 'left'
},
y1: {
title: {display: true, text: 'COT Index'},
position: 'right',
min: 0,
max: 100,
grid: {drawOnChartArea: false}
}
}
}
});
}
// -- Coffee Prices Chart (close + 20d MA + 50d MA) --
const priceRaw = {{ price_series | tojson }};
if (priceRaw && priceRaw.length > 0) {
const priceData = [...priceRaw].reverse(); // query returns DESC, chart needs ASC
new Chart(document.getElementById('priceChart'), {
type: 'line',
data: {
labels: priceData.map(r => r.trade_date),
datasets: [
{
label: 'Close (¢/lb)',
data: priceData.map(r => r.close),
borderColor: CHART_COLORS.copper,
backgroundColor: CHART_COLORS.copper + '18',
fill: true,
tension: 0.2,
pointRadius: 0,
yAxisID: 'y'
},
{
label: '20d MA',
data: priceData.map(r => r.sma_20d),
borderColor: CHART_COLORS.beanGreen,
borderDash: [4, 3],
tension: 0.2,
pointRadius: 0,
yAxisID: 'y'
},
{
label: '50d MA',
data: priceData.map(r => r.sma_50d),
borderColor: CHART_COLORS.roast,
borderDash: [8, 4],
tension: 0.2,
pointRadius: 0,
yAxisID: 'y'
}
]
},
options: {
responsive: true,
interaction: {mode: 'index', intersect: false},
plugins: {legend: {position: 'bottom'}},
scales: {
x: {ticks: {maxTicksLimit: 12}},
y: {title: {display: true, text: '¢/lb'}, beginAtZero: false}
}
}
});
}
// -- ICE Warehouse Stocks Chart --
const iceRaw = {{ ice_stocks_trend | tojson }};
if (iceRaw && iceRaw.length > 0) {
const iceData = [...iceRaw].reverse(); // query returns DESC, chart needs ASC
new Chart(document.getElementById('iceStocksChart'), {
type: 'line',
data: {
labels: iceData.map(r => r.report_date),
datasets: [
{
label: 'Certified Stocks (bags)',
data: iceData.map(r => r.total_certified_bags),
borderColor: CHART_COLORS.roast,
backgroundColor: CHART_COLORS.roast + '18',
fill: true,
tension: 0.2,
pointRadius: 0
},
{
label: '30d Average',
data: iceData.map(r => r.avg_30d_bags),
borderColor: CHART_COLORS.stone,
borderDash: [5, 4],
tension: 0.2,
pointRadius: 0
}
]
},
options: {
responsive: true,
interaction: {mode: 'index', intersect: false},
plugins: {legend: {position: 'bottom'}},
scales: {
x: {ticks: {maxTicksLimit: 12}},
y: {title: {display: true, text: '60-kg bags'}, beginAtZero: false}
}
}
});
}
// -- ICE Aging Report Chart --
const agingRaw = {{ ice_aging_latest | tojson }};
if (agingRaw && agingRaw.length > 0) {
const ports = ['antwerp_bags', 'hamburg_bremen_bags', 'houston_bags', 'miami_bags', 'new_orleans_bags', 'new_york_bags'];
const portLabels = ['Antwerp', 'Hamburg/Bremen', 'Houston', 'Miami', 'New Orleans', 'New York'];
new Chart(document.getElementById('iceAgingChart'), {
type: 'bar',
data: {
labels: agingRaw.map(r => r.age_bucket),
datasets: ports.map((col, i) => ({
label: portLabels[i],
data: agingRaw.map(r => r[col] || 0),
backgroundColor: CHART_PALETTE[i],
borderWidth: 0,
}))
},
options: {
responsive: true,
interaction: {mode: 'index', intersect: false},
plugins: {legend: {position: 'bottom'}},
scales: {
x: {stacked: true, ticks: {maxRotation: 45}},
y: {stacked: true, title: {display: true, text: '60-kg bags'}, beginAtZero: true}
}
}
});
}
// -- ICE Historical Stocks by Port Chart --
const byPortRaw = {{ ice_stocks_by_port | tojson }};
if (byPortRaw && byPortRaw.length > 0) {
const byPortData = [...byPortRaw].reverse();
const portCols = ['antwerp_bags', 'hamburg_bremen_bags', 'new_york_bags', 'new_orleans_bags', 'houston_bags', 'miami_bags', 'barcelona_bags', 'virginia_bags'];
const portNames = ['Antwerp', 'Hamburg/Bremen', 'New York', 'New Orleans', 'Houston', 'Miami', 'Barcelona', 'Virginia'];
new Chart(document.getElementById('iceByPortChart'), {
type: 'line',
data: {
labels: byPortData.map(r => r.report_date),
datasets: portCols.map((col, i) => ({
label: portNames[i],
data: byPortData.map(r => r[col] || 0),
borderColor: CHART_PALETTE[i],
backgroundColor: CHART_PALETTE[i] + '22',
fill: true,
tension: 0.2,
pointRadius: 0,
}))
},
options: {
responsive: true,
interaction: {mode: 'index', intersect: false},
plugins: {legend: {position: 'bottom'}},
scales: {
x: {stacked: true, ticks: {maxTicksLimit: 15}},
y: {stacked: true, title: {display: true, text: '60-kg bags'}, beginAtZero: true}
}
}
});
}
// -- Top Producers Horizontal Bar --
const topData = {{ top_producers | tojson }};
if (topData.length > 0) {
new Chart(document.getElementById('topProducersChart'), {
type: 'bar',
data: {
labels: topData.map(r => r.country_name),
datasets: [{
label: 'Production',
data: topData.map(r => r.production),
backgroundColor: CHART_COLORS.copper
}]
},
options: {
indexAxis: 'y',
responsive: true,
plugins: {legend: {display: false}},
scales: {x: {title: {display: true, text: '1,000 60-kg bags'}, beginAtZero: true}}
}
});
}
</script>
{% endblock %}

View File

@@ -0,0 +1,101 @@
{% extends "dashboard_base.html" %}
{% block title %}Positioning — {{ config.APP_NAME }}{% endblock %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
{% endblock %}
{% block content %}
<!-- Masthead -->
<div class="page-masthead">
<div>
<h1>Market Positioning</h1>
<p class="page-masthead-sub">CFTC COT · managed money net position · KC=F price action</p>
</div>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;">
<a href="{{ url_for('public.methodology') }}" class="freshness-badge">
<strong>CFTC COT</strong>
{% if cot_latest %}{{ cot_latest.report_date }}{% else %}—{% endif %}
</a>
<a href="{{ url_for('public.methodology') }}" class="freshness-badge">
<strong>KC=F</strong>
{% if price_latest %}{{ price_latest.trade_date }}{% else %}—{% endif %}
</a>
</div>
</div>
<!-- Filter bar -->
<div class="filter-bar" id="pos-filter-bar">
<!-- Time range -->
<div class="filter-pills" id="range-pills">
{% for r, label in [("6m","6M"),("1y","1Y"),("2y","2Y"),("5y","5Y")] %}
<button type="button"
class="filter-pill {{ 'active' if range_key == r }}"
onclick="setRange('{{ r }}')">{{ label }}</button>
{% endfor %}
</div>
<!-- MA toggles (client-side only) -->
<label class="filter-check">
<input type="checkbox" id="ma20-toggle" checked onchange="toggleMA('sma20')"> 20d MA
</label>
<label class="filter-check">
<input type="checkbox" id="ma50-toggle" checked onchange="toggleMA('sma50')"> 50d MA
</label>
</div>
<!-- HTMX canvas -->
<div id="positioning-canvas">
{% include "positioning_canvas.html" %}
</div>
{% endblock %}
{% block scripts %}
<script>
var POSITIONING_URL = '{{ url_for("dashboard.positioning") }}';
var currentRange = {{ range_key | tojson }};
function setRange(val) {
currentRange = val;
var url = POSITIONING_URL + '?range=' + currentRange;
window.history.pushState({}, '', url);
document.getElementById('positioning-canvas').classList.add('canvas-loading');
htmx.ajax('GET', url, { target: '#positioning-canvas', swap: 'innerHTML' });
}
// MA toggles: client-side only — update Chart.js dataset visibility
function toggleMA(key) {
var chart = Chart.getChart('priceChart');
if (!chart) return;
var idx = key === 'sma20' ? 1 : 2;
chart.data.datasets[idx].hidden = !chart.data.datasets[idx].hidden;
chart.update();
}
document.addEventListener('htmx:afterSwap', function (e) {
if (e.detail.target.id === 'positioning-canvas') {
document.getElementById('positioning-canvas').classList.remove('canvas-loading');
// Re-apply MA checkbox state after swap
var ma20 = document.getElementById('ma20-toggle');
var ma50 = document.getElementById('ma50-toggle');
var chart = Chart.getChart('priceChart');
if (chart && ma20 && ma50) {
chart.data.datasets[1].hidden = !ma20.checked;
chart.data.datasets[2].hidden = !ma50.checked;
chart.update();
}
}
});
window.addEventListener('popstate', function () {
var p = new URLSearchParams(window.location.search);
currentRange = p.get('range') || '1y';
var url = POSITIONING_URL + '?range=' + currentRange;
document.getElementById('positioning-canvas').classList.add('canvas-loading');
htmx.ajax('GET', url, { target: '#positioning-canvas', swap: 'innerHTML' });
});
</script>
{% endblock %}

View File

@@ -0,0 +1,266 @@
{#
positioning_canvas.html — HTMX partial for /dashboard/positioning
#}
{% if price_latest or cot_latest %}
<!-- 4 metric cards -->
<div class="grid-4 mb-6">
<div class="metric-card">
<div class="metric-label">KC=F Close</div>
<div class="metric-value">
{% if price_latest %}{{ "{:.2f}".format(price_latest.close) }}{% else %}--{% endif %}
</div>
<div class="metric-sub">¢/lb
{% if price_latest and price_latest.daily_return_pct is not none %}
· <span class="{% if price_latest.daily_return_pct > 0 %}text-bean-green{% elif price_latest.daily_return_pct < 0 %}text-danger{% endif %}">{{ "{:+.2f}%".format(price_latest.daily_return_pct) }}</span>
{% endif %}
</div>
</div>
<div class="metric-card">
<div class="metric-label">MM Net Position</div>
<div class="metric-value {% if cot_latest and cot_latest.managed_money_net > 0 %}text-bean-green{% elif cot_latest and cot_latest.managed_money_net < 0 %}text-danger{% endif %}">
{% if cot_latest %}{{ "{:+,d}".format(cot_latest.managed_money_net | int) }}{% else %}--{% endif %}
</div>
<div class="metric-sub">contracts (long short)</div>
</div>
<div class="metric-card">
<div class="metric-label">COT Index 26w</div>
<div class="metric-value">
{% if cot_latest %}{{ "{:.0f}".format(cot_latest.cot_index_26w) }}{% else %}--{% endif %}
</div>
<div class="metric-sub">0 = most bearish · 100 = most bullish</div>
</div>
<div class="metric-card">
<div class="metric-label">Open Interest</div>
<div class="metric-value">
{% if cot_latest %}{{ "{:,d}".format(cot_latest.open_interest | int) }}{% else %}--{% endif %}
</div>
<div class="metric-sub">total contracts outstanding</div>
</div>
</div>
<!-- Price chart -->
{% if price_series %}
<div class="cc-chart-card mb-4">
<div class="cc-chart-top">
<div>
<div class="cc-chart-title">KC=F Coffee Futures Price</div>
<div class="cc-chart-meta">ICE Coffee C Arabica · daily close + moving averages · ¢/lb</div>
</div>
<span class="cc-chart-unit">¢/lb</span>
</div>
<div class="cc-chart-body">
<canvas id="priceChart"></canvas>
</div>
</div>
{% endif %}
<!-- COT chart -->
{% if cot_trend %}
<div class="cc-chart-card">
<div class="cc-chart-top">
<div>
<div class="cc-chart-title">Managed Money Net Position + COT Index</div>
<div class="cc-chart-meta">CFTC Commitment of Traders · weekly · area = net contracts · dashed = COT index (0100)</div>
</div>
</div>
<div class="cc-chart-body">
<canvas id="cotChart"></canvas>
</div>
</div>
{% endif %}
{% else %}
<div class="cc-empty">
<div class="cc-empty-ring">
<svg width="22" height="22" fill="none" stroke="var(--color-copper)"
stroke-width="1.75" stroke-linecap="round" viewBox="0 0 24 24">
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/>
<polyline points="17 6 23 6 23 12"/>
</svg>
</div>
<div class="cc-empty-title">No Data Available Yet</div>
<p class="cc-empty-body">Positioning data is updating. Check back shortly.</p>
</div>
{% endif %}
<!-- State sync + chart init -->
<script>
(function () {
var range = {{ range_key | tojson }};
// Sync range pills
document.querySelectorAll('#range-pills .filter-pill').forEach(function (btn) {
btn.classList.toggle('active', btn.textContent.trim().toLowerCase() === range);
});
if (typeof currentRange !== 'undefined') currentRange = range;
var C = {
copper: '#B45309', green: '#15803D', roast: '#4A2C1A', stone: '#78716C',
espresso: '#2C1810',
};
var AXES_STYLE = {
grid: { color: 'rgba(232,223,213,0.45)', drawTicks: false },
border: { display: false },
ticks: { font: { family: "'DM Sans', sans-serif", size: 11 }, color: '#78716C' },
};
// Price chart
var priceRaw = {{ price_series | tojson }};
var priceCanvas = document.getElementById('priceChart');
if (priceCanvas && priceRaw.length > 0) {
var priceAsc = priceRaw.slice().reverse();
var existing = Chart.getChart(priceCanvas);
if (existing) existing.destroy();
new Chart(priceCanvas, {
type: 'line',
data: {
labels: priceAsc.map(function (r) { return r.trade_date; }),
datasets: [
{
label: 'Close (¢/lb)',
data: priceAsc.map(function (r) { return r.close; }),
borderColor: C.copper,
backgroundColor: C.copper + '18',
fill: true,
tension: 0.2,
pointRadius: 0,
borderWidth: 2.5,
},
{
label: '20d MA',
data: priceAsc.map(function (r) { return r.sma_20d; }),
borderColor: C.green,
borderDash: [4, 3],
tension: 0.2,
pointRadius: 0,
borderWidth: 1.75,
},
{
label: '50d MA',
data: priceAsc.map(function (r) { return r.sma_50d; }),
borderColor: C.roast,
borderDash: [8, 4],
tension: 0.2,
pointRadius: 0,
borderWidth: 1.75,
},
],
},
options: {
responsive: true,
aspectRatio: 2.5,
interaction: { mode: 'index', intersect: false },
animation: { duration: 350 },
plugins: {
legend: {
position: 'bottom',
labels: { boxWidth: 10, boxHeight: 10, borderRadius: 5, useBorderRadius: true, padding: 16,
font: { family: "'DM Sans', sans-serif", size: 11.5 }, color: '#57534E' },
},
tooltip: {
backgroundColor: '#2C1810', titleColor: 'rgba(255,255,255,0.5)',
bodyColor: 'rgba(255,255,255,0.9)', borderColor: 'rgba(255,255,255,0.07)',
borderWidth: 1, padding: 12, cornerRadius: 10,
},
},
scales: {
x: Object.assign({}, AXES_STYLE, { ticks: Object.assign({}, AXES_STYLE.ticks, { maxTicksLimit: 12 }) }),
y: Object.assign({}, AXES_STYLE, {
beginAtZero: false,
title: { display: true, text: '¢/lb', font: { size: 10 }, color: '#78716C' },
ticks: Object.assign({}, AXES_STYLE.ticks, {
font: { family: "ui-monospace, 'Cascadia Code', monospace", size: 11 },
callback: function (v) { return v.toFixed(0); },
}),
}),
},
},
});
}
// COT chart
var cotRaw = {{ cot_trend | tojson }};
var cotCanvas = document.getElementById('cotChart');
if (cotCanvas && cotRaw.length > 0) {
var cotAsc = cotRaw.slice().reverse();
var existing = Chart.getChart(cotCanvas);
if (existing) existing.destroy();
new Chart(cotCanvas, {
type: 'line',
data: {
labels: cotAsc.map(function (r) { return r.report_date; }),
datasets: [
{
label: 'MM Net (contracts)',
data: cotAsc.map(function (r) { return r.managed_money_net; }),
borderColor: C.copper,
backgroundColor: C.copper + '22',
fill: true,
tension: 0.3,
pointRadius: 0,
borderWidth: 2.5,
yAxisID: 'y',
},
{
label: 'COT Index 26w (0100)',
data: cotAsc.map(function (r) { return r.cot_index_26w; }),
borderColor: C.roast,
borderDash: [5, 4],
tension: 0.3,
pointRadius: 0,
borderWidth: 1.75,
yAxisID: 'y1',
},
],
},
options: {
responsive: true,
aspectRatio: 2.5,
interaction: { mode: 'index', intersect: false },
animation: { duration: 350 },
plugins: {
legend: {
position: 'bottom',
labels: { boxWidth: 10, boxHeight: 10, borderRadius: 5, useBorderRadius: true, padding: 16,
font: { family: "'DM Sans', sans-serif", size: 11.5 }, color: '#57534E' },
},
tooltip: {
backgroundColor: '#2C1810', titleColor: 'rgba(255,255,255,0.5)',
bodyColor: 'rgba(255,255,255,0.9)', borderColor: 'rgba(255,255,255,0.07)',
borderWidth: 1, padding: 12, cornerRadius: 10,
},
},
scales: {
x: Object.assign({}, AXES_STYLE, { ticks: Object.assign({}, AXES_STYLE.ticks, { maxTicksLimit: 12 }) }),
y: Object.assign({}, AXES_STYLE, {
position: 'left',
title: { display: true, text: 'Net Contracts', font: { size: 10 }, color: '#78716C' },
ticks: Object.assign({}, AXES_STYLE.ticks, {
font: { family: "ui-monospace, 'Cascadia Code', monospace", size: 11 },
callback: function (v) { return v >= 1000 ? (v / 1000).toFixed(0) + 'k' : v; },
}),
}),
y1: Object.assign({}, AXES_STYLE, {
position: 'right',
min: 0, max: 100,
title: { display: true, text: 'COT Index', font: { size: 10 }, color: '#78716C' },
grid: { drawOnChartArea: false },
ticks: Object.assign({}, AXES_STYLE.ticks, {
font: { family: "ui-monospace, 'Cascadia Code', monospace", size: 11 },
}),
}),
},
},
});
}
}());
</script>

View File

@@ -0,0 +1,343 @@
{% extends "dashboard_base.html" %}
{% block title %}Pulse — {{ config.APP_NAME }}{% endblock %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<style>
/* ── Pulse page ── */
.pulse-masthead {
margin-bottom: 1.5rem;
padding-bottom: 1.25rem;
border-bottom: 1.5px solid var(--color-parchment);
}
.pulse-masthead h1 {
font-family: var(--font-display);
font-size: 2rem;
font-weight: 800;
color: var(--color-espresso);
line-height: 1;
letter-spacing: -0.02em;
margin: 0 0 0.25rem;
}
.pulse-masthead p {
font-size: 0.875rem;
color: var(--color-stone);
}
/* 2×2 sparkline grid */
.spark-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-top: 1.5rem;
}
@media (max-width: 600px) {
.spark-grid { grid-template-columns: 1fr; }
}
.spark-card {
background: white;
border: 1px solid var(--color-parchment);
border-radius: 1.25rem;
padding: 1.125rem 1.25rem 0.875rem;
box-shadow: 0 1px 3px rgba(44,24,16,0.05);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.spark-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
}
.spark-title {
font-family: var(--font-display);
font-size: 0.875rem;
font-weight: 700;
color: var(--color-espresso);
line-height: 1.2;
}
.spark-detail-link {
font-size: 0.6875rem;
font-weight: 600;
color: var(--color-copper);
text-decoration: none;
white-space: nowrap;
flex-shrink: 0;
}
.spark-detail-link:hover { color: var(--color-copper-hover); }
.spark-chart-wrap {
position: relative;
height: 100px;
}
.spark-chart-wrap canvas { max-height: 100px !important; }
.spark-meta {
font-size: 0.6875rem;
color: var(--color-stone);
}
@keyframes pulse-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
.pulse-in { animation: pulse-in 0.25s ease both; }
.pulse-in-1 { animation: pulse-in 0.25s 0.05s ease both; }
.pulse-in-2 { animation: pulse-in 0.25s 0.1s ease both; }
.pulse-in-3 { animation: pulse-in 0.25s 0.15s ease both; }
</style>
{% endblock %}
{% block content %}
<!-- Masthead -->
<div class="pulse-masthead pulse-in">
<h1>Pulse</h1>
<p>Global coffee market — full picture in 10 seconds{% if user.name %} · Welcome back, {{ user.name }}{% endif %}</p>
</div>
<!-- 4 metric cards -->
<div class="grid-4 mb-6 pulse-in">
<div class="metric-card">
<div class="metric-label">KC=F Close</div>
<div class="metric-value">
{% if price_latest %}{{ "{:.2f}".format(price_latest.close) }}{% else %}--{% endif %}
</div>
<div class="metric-sub">
¢/lb
{% if price_latest and price_latest.daily_return_pct is not none %}
· <span class="{% if price_latest.daily_return_pct > 0 %}text-bean-green{% elif price_latest.daily_return_pct < 0 %}text-danger{% endif %}">{{ "{:+.2f}%".format(price_latest.daily_return_pct) }}</span>
{% endif %}
{% if price_latest %} · {{ price_latest.trade_date }}{% endif %}
</div>
</div>
<div class="metric-card">
<div class="metric-label">MM Net Position</div>
<div class="metric-value {% if cot_latest and cot_latest.managed_money_net > 0 %}text-bean-green{% elif cot_latest and cot_latest.managed_money_net < 0 %}text-danger{% endif %}">
{% if cot_latest %}{{ "{:+,d}".format(cot_latest.managed_money_net | int) }}{% else %}--{% endif %}
</div>
<div class="metric-sub">contracts · {% if cot_latest %}COT Index {{ "{:.0f}".format(cot_latest.cot_index_26w) }}/100 · {{ cot_latest.report_date }}{% else %}CFTC COT{% endif %}</div>
</div>
<div class="metric-card">
<div class="metric-label">Certified Stocks</div>
<div class="metric-value">
{% if ice_stocks_latest %}{{ "{:,.0f}".format(ice_stocks_latest.total_certified_bags) }}{% else %}--{% endif %}
</div>
<div class="metric-sub">
60-kg bags
{% if ice_stocks_latest and ice_stocks_latest.wow_change_bags is not none %}
· <span class="{% if ice_stocks_latest.wow_change_bags > 0 %}text-bean-green{% elif ice_stocks_latest.wow_change_bags < 0 %}text-danger{% endif %}">{{ "{:+,d}".format(ice_stocks_latest.wow_change_bags | int) }} WoW</span>
{% endif %}
{% if ice_stocks_latest %} · {{ ice_stocks_latest.report_date }}{% endif %}
</div>
</div>
<div class="metric-card">
<div class="metric-label">Stock-to-Use Ratio</div>
<div class="metric-value">
{% if stu_latest %}{{ "{:.1f}".format(stu_latest.stock_to_use_ratio_pct) }}%{% else %}--{% endif %}
</div>
<div class="metric-sub">ending stocks / consumption{% if stu_latest %} · {{ stu_latest.market_year }}{% endif %}</div>
</div>
</div>
<!-- Freshness bar -->
<div class="freshness-bar pulse-in-1">
<span style="font-size:0.75rem;font-weight:600;color:var(--color-stone);text-transform:uppercase;letter-spacing:0.06em;">Data as of:</span>
<a href="{{ url_for('public.methodology') }}" class="freshness-badge">
<strong>USDA PSD</strong> {% if stu_latest %}{{ stu_latest.market_year }}{% else %}—{% endif %}
</a>
<a href="{{ url_for('public.methodology') }}" class="freshness-badge">
<strong>CFTC COT</strong> {% if cot_latest %}{{ cot_latest.report_date }}{% else %}—{% endif %}
</a>
<a href="{{ url_for('public.methodology') }}" class="freshness-badge">
<strong>KC=F Price</strong> {% if price_latest %}{{ price_latest.trade_date }}{% else %}—{% endif %}
</a>
<a href="{{ url_for('public.methodology') }}" class="freshness-badge">
<strong>ICE Stocks</strong> {% if ice_stocks_latest %}{{ ice_stocks_latest.report_date }}{% else %}—{% endif %}
</a>
</div>
<!-- 2×2 sparkline grid -->
<div class="spark-grid pulse-in-2">
<!-- Price 90d -->
<div class="spark-card">
<div class="spark-card-head">
<div class="spark-title">KC=F Price — 90 days</div>
<a href="{{ url_for('dashboard.positioning') }}" class="spark-detail-link">View details →</a>
</div>
<div class="spark-chart-wrap">
<canvas id="sparkPrice"></canvas>
</div>
<div class="spark-meta">ICE Coffee C Arabica · ¢/lb daily close</div>
</div>
<!-- ICE Stocks 90d -->
<div class="spark-card">
<div class="spark-card-head">
<div class="spark-title">ICE Certified Stocks — 90 days</div>
<a href="{{ url_for('dashboard.warehouse') }}" class="spark-detail-link">View details →</a>
</div>
<div class="spark-chart-wrap">
<canvas id="sparkStocks"></canvas>
</div>
<div class="spark-meta">Physical Arabica certified for delivery · 60-kg bags</div>
</div>
<!-- COT 26 weeks -->
<div class="spark-card">
<div class="spark-card-head">
<div class="spark-title">COT Index — 26 weeks</div>
<a href="{{ url_for('dashboard.positioning') }}" class="spark-detail-link">View details →</a>
</div>
<div class="spark-chart-wrap">
<canvas id="sparkCot"></canvas>
</div>
<div class="spark-meta">CFTC Managed Money net position · 0 = max bearish · 100 = max bullish</div>
</div>
<!-- Supply/Demand 5yr -->
<div class="spark-card">
<div class="spark-card-head">
<div class="spark-title">Global Supply &amp; Demand — 5 years</div>
<a href="{{ url_for('dashboard.supply') }}" class="spark-detail-link">View details →</a>
</div>
<div class="spark-chart-wrap">
<canvas id="sparkSupply"></canvas>
</div>
<div class="spark-meta">USDA WASDE · 1,000 60-kg bags · production vs distribution</div>
</div>
</div>
{% if plan == "free" %}
<div class="plan-gate mt-6 pulse-in-3">
Free plan shows limited history. <a href="{{ url_for('billing.pricing') }}">Upgrade</a> for full historical depth on all pages.
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
(function () {
const C = {
copper: '#B45309', green: '#15803D', roast: '#4A2C1A', stone: '#78716C',
parchment: '#E8DFD5', espresso: '#2C1810',
};
function sparkOpts(color, filled) {
return {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 400 },
interaction: { mode: 'index', intersect: false },
plugins: { legend: { display: false }, tooltip: { enabled: false } },
elements: { point: { radius: 0 } },
scales: {
x: { display: false },
y: {
display: false,
grace: '10%',
},
},
};
}
// Price sparkline (90d)
var priceRaw = {{ price_series | tojson }};
if (priceRaw && priceRaw.length > 0) {
var priceAsc = priceRaw.slice().reverse();
new Chart(document.getElementById('sparkPrice'), {
type: 'line',
data: {
labels: priceAsc.map(function (r) { return r.trade_date; }),
datasets: [{
data: priceAsc.map(function (r) { return r.close; }),
borderColor: C.copper,
backgroundColor: C.copper + '22',
fill: true,
tension: 0.3,
borderWidth: 2,
}],
},
options: sparkOpts(C.copper, true),
});
}
// ICE Stocks sparkline (90d)
var stocksRaw = {{ ice_stocks_trend | tojson }};
if (stocksRaw && stocksRaw.length > 0) {
var stocksAsc = stocksRaw.slice().reverse();
new Chart(document.getElementById('sparkStocks'), {
type: 'line',
data: {
labels: stocksAsc.map(function (r) { return r.report_date; }),
datasets: [{
data: stocksAsc.map(function (r) { return r.total_certified_bags; }),
borderColor: C.roast,
backgroundColor: C.roast + '22',
fill: true,
tension: 0.3,
borderWidth: 2,
}],
},
options: sparkOpts(C.roast, true),
});
}
// COT sparkline (26w)
var cotRaw = {{ cot_trend | tojson }};
if (cotRaw && cotRaw.length > 0) {
var cotAsc = cotRaw.slice().reverse();
new Chart(document.getElementById('sparkCot'), {
type: 'line',
data: {
labels: cotAsc.map(function (r) { return r.report_date; }),
datasets: [{
data: cotAsc.map(function (r) { return r.managed_money_net; }),
borderColor: C.green,
backgroundColor: C.green + '22',
fill: true,
tension: 0.3,
borderWidth: 2,
}],
},
options: sparkOpts(C.green, true),
});
}
// Supply/Demand sparkline (5yr)
var supplyRaw = {{ time_series | tojson }};
if (supplyRaw && supplyRaw.length > 0) {
new Chart(document.getElementById('sparkSupply'), {
type: 'line',
data: {
labels: supplyRaw.map(function (r) { return r.market_year; }),
datasets: [
{
label: 'Production',
data: supplyRaw.map(function (r) { return r.production; }),
borderColor: C.copper,
tension: 0.3,
borderWidth: 2,
fill: false,
},
{
label: 'Distribution',
data: supplyRaw.map(function (r) { return r.total_distribution; }),
borderColor: C.roast,
borderDash: [4, 3],
tension: 0.3,
borderWidth: 2,
fill: false,
},
],
},
options: sparkOpts(C.copper, false),
});
}
}());
</script>
{% endblock %}

View File

@@ -0,0 +1,87 @@
{% extends "dashboard_base.html" %}
{% block title %}Supply &amp; Demand — {{ config.APP_NAME }}{% endblock %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<style>
.canvas-section { display: flex; flex-direction: column; gap: 1.5rem; }
</style>
{% endblock %}
{% block content %}
<!-- Masthead -->
<div class="page-masthead">
<div>
<h1>Global Supply &amp; Demand</h1>
<p class="page-masthead-sub">USDA WASDE · coffee fundamentals · production, trade &amp; stocks</p>
</div>
<a href="{{ url_for('public.methodology') }}" class="freshness-badge">
<strong>USDA PSD</strong>
{% if time_series %}{{ time_series[-1].market_year }}{% else %}—{% endif %}
</a>
</div>
<!-- Filter bar -->
<div class="filter-bar" id="supply-filter-bar">
<!-- Time range -->
<div class="filter-pills" id="range-pills">
{% for r, label in [("5y","5Y"),("10y","10Y"),("max","Max")] %}
<button type="button"
class="filter-pill {{ 'active' if range_key == r }}"
{% if plan == 'free' and r in ('10y','max') %}disabled title="Upgrade for full history"{% endif %}
onclick="setFilter('range', '{{ r }}')">{{ label }}</button>
{% endfor %}
</div>
<!-- Metric selector -->
<div class="filter-pills" id="metric-pills">
{% for m, label in [("production","Production"),("exports","Exports"),("imports","Imports"),("ending_stocks","Stocks")] %}
<button type="button"
class="filter-pill {{ 'active' if metric == m }}"
onclick="setFilter('metric', '{{ m }}')">{{ label }}</button>
{% endfor %}
</div>
</div>
<!-- HTMX canvas -->
<div id="supply-canvas" class="canvas-section">
{% include "supply_canvas.html" %}
</div>
{% endblock %}
{% block scripts %}
<script>
var SUPPLY_URL = '{{ url_for("dashboard.supply") }}';
var currentRange = {{ range_key | tojson }};
var currentMetric = {{ metric | tojson }};
function setFilter(key, val) {
if (key === 'range') currentRange = val;
else currentMetric = val;
var url = SUPPLY_URL + '?range=' + currentRange + '&metric=' + currentMetric;
window.history.pushState({}, '', url);
document.getElementById('supply-canvas').classList.add('canvas-loading');
htmx.ajax('GET', url, { target: '#supply-canvas', swap: 'innerHTML' });
}
document.addEventListener('htmx:afterSwap', function (e) {
if (e.detail.target.id === 'supply-canvas') {
document.getElementById('supply-canvas').classList.remove('canvas-loading');
}
});
// Restore filter state on browser back/forward
window.addEventListener('popstate', function () {
var p = new URLSearchParams(window.location.search);
currentRange = p.get('range') || '5y';
currentMetric = p.get('metric') || 'production';
var url = SUPPLY_URL + '?range=' + currentRange + '&metric=' + currentMetric;
document.getElementById('supply-canvas').classList.add('canvas-loading');
htmx.ajax('GET', url, { target: '#supply-canvas', swap: 'innerHTML' });
});
</script>
{% endblock %}

View File

@@ -0,0 +1,292 @@
{#
supply_canvas.html — HTMX partial for /dashboard/supply
Returned on HX-Request; also included by supply.html on initial load.
#}
{% set C = {
'copper': '#B45309', 'green': '#15803D', 'roast': '#4A2C1A',
'forest': '#064E3B', 'stone': '#78716C', 'warning': '#D97706',
'danger': '#EF4444', 'espresso': '#2C1810',
} %}
{% if time_series %}
<!-- Section 1: Supply/demand multi-line chart -->
<div class="cc-chart-card">
<div class="cc-chart-top">
<div>
<div class="cc-chart-title">Global Supply &amp; Demand</div>
<div class="cc-chart-meta">USDA WASDE · 1,000 60-kg bags · market year</div>
</div>
<span class="cc-chart-unit">1k bags</span>
</div>
<div class="cc-chart-body">
<canvas id="supplyDemandChart"></canvas>
</div>
</div>
<!-- Section 2: Stock-to-use ratio -->
<div class="cc-chart-card">
<div class="cc-chart-top">
<div>
<div class="cc-chart-title">Stock-to-Use Ratio</div>
<div class="cc-chart-meta">Ending stocks as % of total distribution · lower = tighter market</div>
</div>
<span class="cc-chart-unit">%</span>
</div>
<div class="cc-chart-body">
<canvas id="stuChart"></canvas>
</div>
</div>
<!-- Section 3: Top producers + YoY table -->
<div class="grid-2">
<div class="cc-chart-card">
<div class="cc-chart-top">
<div>
<div class="cc-chart-title">Top Countries — {{ metric.replace("_"," ").title() }}</div>
<div class="cc-chart-meta">USDA · latest market year · 1,000 60-kg bags</div>
</div>
</div>
<div class="cc-chart-body">
<canvas id="topCountriesChart"></canvas>
</div>
</div>
<div class="cc-table-card">
<div class="cc-table-top">
<span class="cc-table-label">YoY Production Change</span>
{% if yoy %}<span class="cc-table-year">{{ yoy[0].market_year }}</span>{% endif %}
</div>
<div class="cc-trow cc-trow-head">
<span></span>
<span class="cc-t-head">Country</span>
<span class="cc-t-head" style="text-align:right">YoY %</span>
</div>
{% for row in yoy[:12] %}
<div class="cc-trow">
<span class="cc-t-rank">{{ loop.index }}</span>
<div class="cc-t-country">
<span class="cc-t-name">{{ row.country_name }}</span>
</div>
<span class="cc-t-value {% if row.production_yoy_pct and row.production_yoy_pct > 0 %}text-bean-green{% elif row.production_yoy_pct and row.production_yoy_pct < 0 %}text-danger{% endif %}">
{% if row.production_yoy_pct is not none %}{{ "{:+.1f}%".format(row.production_yoy_pct) }}{% else %}—{% endif %}
</span>
</div>
{% endfor %}
</div>
</div>
{% if plan == "free" %}
<div class="plan-gate">
Free plan shows last 5 years. <a href="{{ url_for('billing.pricing') }}">Upgrade</a> for 10Y / full history and CSV export.
</div>
{% else %}
<div>
<a href="{{ url_for('api.commodity_metrics_csv', code=711100) }}" class="btn-outline">Export CSV</a>
</div>
{% endif %}
{% else %}
<div class="cc-empty">
<div class="cc-empty-ring">
<svg width="22" height="22" fill="none" stroke="var(--color-copper)"
stroke-width="1.75" stroke-linecap="round" viewBox="0 0 24 24">
<line x1="18" y1="20" x2="18" y2="10"/>
<line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/>
</svg>
</div>
<div class="cc-empty-title">No Data Available Yet</div>
<p class="cc-empty-body">USDA supply &amp; demand data is updating. Check back shortly.</p>
</div>
{% endif %}
<!-- State sync + chart init -->
<script>
(function () {
var range = {{ range_key | tojson }};
var metric = {{ metric | tojson }};
// Sync filter pills
document.querySelectorAll('#range-pills .filter-pill').forEach(function (btn) {
btn.classList.toggle('active', btn.textContent.trim().toLowerCase().replace('y','y') === range || btn.textContent.trim().toLowerCase() === range);
});
document.querySelectorAll('#metric-pills .filter-pill').forEach(function (btn) {
btn.classList.toggle('active', btn.textContent.trim().toLowerCase().replace(/ /g,'_') === metric
|| (btn.textContent.trim() === 'Stocks' && metric === 'ending_stocks'));
});
// Update page-level state
if (typeof currentRange !== 'undefined') currentRange = range;
if (typeof currentMetric !== 'undefined') currentMetric = metric;
var C = {
copper: '#B45309', green: '#15803D', roast: '#4A2C1A',
forest: '#064E3B', stone: '#78716C', warning: '#D97706',
danger: '#EF4444', espresso: '#2C1810',
};
var CHART_OPTS_BASE = {
responsive: true,
aspectRatio: 2.5,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: {
position: 'bottom',
labels: {
boxWidth: 10, boxHeight: 10, borderRadius: 5, useBorderRadius: true,
padding: 16,
font: { family: "'DM Sans', sans-serif", size: 11.5 },
color: '#57534E',
},
},
tooltip: {
backgroundColor: '#2C1810', titleColor: 'rgba(255,255,255,0.5)',
bodyColor: 'rgba(255,255,255,0.9)', borderColor: 'rgba(255,255,255,0.07)',
borderWidth: 1, padding: 12, cornerRadius: 10,
callbacks: {
label: function (ctx) {
return ' ' + ctx.dataset.label + ': ' + Number(ctx.raw || 0).toLocaleString();
},
},
},
},
scales: {
x: {
grid: { color: 'rgba(232,223,213,0.45)', drawTicks: false },
ticks: { font: { family: "'DM Sans', sans-serif", size: 11 }, color: '#78716C', maxTicksLimit: 8 },
border: { display: false },
},
y: {
grid: { color: 'rgba(232,223,213,0.45)', drawTicks: false },
ticks: {
font: { family: "ui-monospace, 'Cascadia Code', monospace", size: 11 },
color: '#78716C',
callback: function (v) {
return v >= 1000000 ? (v / 1000000).toFixed(1) + 'M'
: v >= 1000 ? (v / 1000).toFixed(0) + 'k' : v;
},
},
border: { display: false },
beginAtZero: false,
},
},
};
// Supply/demand chart
var tsData = {{ time_series | tojson }};
var sdCanvas = document.getElementById('supplyDemandChart');
if (sdCanvas && tsData.length > 0) {
var existing = Chart.getChart(sdCanvas);
if (existing) existing.destroy();
new Chart(sdCanvas, {
type: 'line',
data: {
labels: tsData.map(function (r) { return r.market_year; }),
datasets: [
{ label: 'Production', data: tsData.map(function (r) { return r.production; }), borderColor: C.copper, tension: 0.3, pointRadius: 0, borderWidth: 2.5 },
{ label: 'Exports', data: tsData.map(function (r) { return r.exports; }), borderColor: C.green, tension: 0.3, pointRadius: 0, borderWidth: 2 },
{ label: 'Imports', data: tsData.map(function (r) { return r.imports; }), borderColor: C.roast, tension: 0.3, pointRadius: 0, borderWidth: 2 },
{ label: 'Ending Stocks', data: tsData.map(function (r) { return r.ending_stocks; }), borderColor: C.forest, tension: 0.3, pointRadius: 0, borderWidth: 2 },
{ label: 'Distribution', data: tsData.map(function (r) { return r.total_distribution; }), borderColor: C.stone, tension: 0.3, pointRadius: 0, borderWidth: 2, borderDash: [5,4] },
],
},
options: Object.assign({}, CHART_OPTS_BASE, {
plugins: Object.assign({}, CHART_OPTS_BASE.plugins, {
legend: Object.assign({}, CHART_OPTS_BASE.plugins.legend),
}),
scales: Object.assign({}, CHART_OPTS_BASE.scales, {
y: Object.assign({}, CHART_OPTS_BASE.scales.y, {
title: { display: true, text: '1,000 60-kg bags', font: { size: 10 }, color: '#78716C' },
}),
}),
}),
});
}
// STU chart
var stuData = {{ stu_trend | tojson }};
var stuCanvas = document.getElementById('stuChart');
if (stuCanvas && stuData.length > 0) {
var existing = Chart.getChart(stuCanvas);
if (existing) existing.destroy();
new Chart(stuCanvas, {
type: 'line',
data: {
labels: stuData.map(function (r) { return r.market_year; }),
datasets: [{
label: 'Stock-to-Use (%)',
data: stuData.map(function (r) { return r.stock_to_use_ratio_pct; }),
borderColor: C.copper,
backgroundColor: C.copper + '18',
fill: true,
tension: 0.3,
pointRadius: 0,
borderWidth: 2.5,
}],
},
options: Object.assign({}, CHART_OPTS_BASE, {
plugins: Object.assign({}, CHART_OPTS_BASE.plugins, {
legend: Object.assign({}, CHART_OPTS_BASE.plugins.legend, { display: false }),
}),
scales: Object.assign({}, CHART_OPTS_BASE.scales, {
y: Object.assign({}, CHART_OPTS_BASE.scales.y, {
title: { display: true, text: 'Stock-to-Use (%)', font: { size: 10 }, color: '#78716C' },
ticks: Object.assign({}, CHART_OPTS_BASE.scales.y.ticks, {
callback: function (v) { return v.toFixed(1) + '%'; },
}),
}),
}),
}),
});
}
// Top countries chart
var topData = {{ top_countries | tojson }};
var topCanvas = document.getElementById('topCountriesChart');
if (topCanvas && topData.length > 0) {
var existing = Chart.getChart(topCanvas);
if (existing) existing.destroy();
new Chart(topCanvas, {
type: 'bar',
data: {
labels: topData.map(function (r) { return r.country_name; }),
datasets: [{
label: metric.replace(/_/g, ' '),
data: topData.map(function (r) { return r[metric]; }),
backgroundColor: C.copper,
borderRadius: 4,
}],
},
options: {
indexAxis: 'y',
responsive: true,
plugins: { legend: { display: false } },
scales: {
x: {
title: { display: true, text: '1,000 60-kg bags', font: { size: 10 }, color: '#78716C' },
beginAtZero: true,
grid: { color: 'rgba(232,223,213,0.45)', drawTicks: false },
ticks: {
font: { family: "ui-monospace, 'Cascadia Code', monospace", size: 11 },
color: '#78716C',
callback: function (v) {
return v >= 1000 ? (v / 1000).toFixed(0) + 'k' : v;
},
},
border: { display: false },
},
y: {
grid: { display: false },
ticks: { font: { family: "'DM Sans', sans-serif", size: 11 }, color: '#57534E' },
border: { display: false },
},
},
},
});
}
}());
</script>

View File

@@ -0,0 +1,81 @@
{% extends "dashboard_base.html" %}
{% block title %}Warehouse — {{ config.APP_NAME }}{% endblock %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
{% endblock %}
{% block content %}
<!-- Masthead -->
<div class="page-masthead">
<div>
<h1>ICE Warehouse Stocks</h1>
<p class="page-masthead-sub">Physical Arabica certified for delivery against ICE Coffee C futures</p>
</div>
<a href="{{ url_for('public.methodology') }}" class="freshness-badge">
<strong>ICE</strong>
{% if stocks_latest %}{{ stocks_latest.report_date }}{% elif byport_latest %}{{ byport_latest.report_date }}{% else %}—{% endif %}
</a>
</div>
<!-- Filter bar -->
<div class="filter-bar" id="wh-filter-bar">
<!-- Time range -->
<div class="filter-pills" id="range-pills">
{% for r, label in [("3m","3M"),("1y","1Y"),("5y","5Y"),("max","Max")] %}
<button type="button"
class="filter-pill {{ 'active' if range_key == r }}"
onclick="setFilter('range','{{ r }}')">{{ label }}</button>
{% endfor %}
</div>
<!-- View selector -->
<div class="filter-pills" id="view-pills">
{% for v, label in [("stocks","Daily Stocks"),("aging","Aging"),("byport","By Port")] %}
<button type="button"
class="filter-pill {{ 'active' if view == v }}"
onclick="setFilter('view','{{ v }}')">{{ label }}</button>
{% endfor %}
</div>
</div>
<!-- HTMX canvas -->
<div id="warehouse-canvas">
{% include "warehouse_canvas.html" %}
</div>
{% endblock %}
{% block scripts %}
<script>
var WAREHOUSE_URL = '{{ url_for("dashboard.warehouse") }}';
var currentRange = {{ range_key | tojson }};
var currentView = {{ view | tojson }};
function setFilter(key, val) {
if (key === 'range') currentRange = val;
else currentView = val;
var url = WAREHOUSE_URL + '?range=' + currentRange + '&view=' + currentView;
window.history.pushState({}, '', url);
document.getElementById('warehouse-canvas').classList.add('canvas-loading');
htmx.ajax('GET', url, { target: '#warehouse-canvas', swap: 'innerHTML' });
}
document.addEventListener('htmx:afterSwap', function (e) {
if (e.detail.target.id === 'warehouse-canvas') {
document.getElementById('warehouse-canvas').classList.remove('canvas-loading');
}
});
window.addEventListener('popstate', function () {
var p = new URLSearchParams(window.location.search);
currentRange = p.get('range') || '1y';
currentView = p.get('view') || 'stocks';
var url = WAREHOUSE_URL + '?range=' + currentRange + '&view=' + currentView;
document.getElementById('warehouse-canvas').classList.add('canvas-loading');
htmx.ajax('GET', url, { target: '#warehouse-canvas', swap: 'innerHTML' });
});
</script>
{% endblock %}

View File

@@ -0,0 +1,320 @@
{#
warehouse_canvas.html — HTMX partial for /dashboard/warehouse
Only runs queries for the active view.
#}
{% set PORT_COLS = ['new_york_bags','new_orleans_bags','houston_bags','miami_bags','antwerp_bags','hamburg_bremen_bags','barcelona_bags','virginia_bags'] %}
{% set PORT_NAMES = ['New York','New Orleans','Houston','Miami','Antwerp','Hamburg/Bremen','Barcelona','Virginia'] %}
{% set PORT_COLORS = ['#B45309','#15803D','#7C3AED','#0284C7','#BE185D','#0F766E','#C2410C','#1D4ED8'] %}
<!-- State sync (runs immediately, before charts) -->
<script>
(function () {
var range = {{ range_key | tojson }};
var view = {{ view | tojson }};
document.querySelectorAll('#range-pills .filter-pill').forEach(function (btn) {
btn.classList.toggle('active', btn.textContent.trim().toLowerCase() === range);
});
document.querySelectorAll('#view-pills .filter-pill').forEach(function (btn) {
var label = btn.textContent.trim().toLowerCase().replace(/ /g,'');
btn.classList.toggle('active',
(view === 'stocks' && label === 'dailystocks') ||
(view === 'aging' && label === 'aging') ||
(view === 'byport' && label === 'byport'));
});
if (typeof currentRange !== 'undefined') currentRange = range;
if (typeof currentView !== 'undefined') currentView = view;
}());
</script>
{% if view == "stocks" %}
{% if stocks_latest %}
<!-- Metric cards -->
<div class="grid-4 mb-6">
<div class="metric-card">
<div class="metric-label">Certified Stocks</div>
<div class="metric-value">{{ "{:,.0f}".format(stocks_latest.total_certified_bags) }}</div>
<div class="metric-sub">60-kg bags · {{ stocks_latest.report_date }}</div>
</div>
<div class="metric-card">
<div class="metric-label">Week-over-Week</div>
<div class="metric-value {% if stocks_latest.wow_change_bags and stocks_latest.wow_change_bags > 0 %}text-bean-green{% elif stocks_latest.wow_change_bags and stocks_latest.wow_change_bags < 0 %}text-danger{% endif %}">
{% if stocks_latest.wow_change_bags is not none %}{{ "{:+,d}".format(stocks_latest.wow_change_bags | int) }}{% else %}--{% endif %}
</div>
<div class="metric-sub">bags vs previous report</div>
</div>
<div class="metric-card">
<div class="metric-label">30-Day Average</div>
<div class="metric-value">{{ "{:,.0f}".format(stocks_latest.avg_30d_bags) }}</div>
<div class="metric-sub">60-kg bags</div>
</div>
<div class="metric-card">
<div class="metric-label">Drawdown from 52w High</div>
<div class="metric-value {% if stocks_latest.drawdown_from_52w_high_pct and stocks_latest.drawdown_from_52w_high_pct < -10 %}text-danger{% endif %}">
{% if stocks_latest.drawdown_from_52w_high_pct is not none %}{{ "{:.1f}%".format(stocks_latest.drawdown_from_52w_high_pct) }}{% else %}--{% endif %}
</div>
<div class="metric-sub">below 52-week peak</div>
</div>
</div>
{% endif %}
{% if stocks_trend %}
<div class="cc-chart-card">
<div class="cc-chart-top">
<div>
<div class="cc-chart-title">Daily Certified Stocks</div>
<div class="cc-chart-meta">ICE certified for Coffee C delivery · 60-kg bags</div>
</div>
<span class="cc-chart-unit">bags</span>
</div>
<div class="cc-chart-body">
<canvas id="stocksChart"></canvas>
</div>
</div>
{% else %}
<div class="cc-empty">
<div class="cc-empty-title">No Data Available Yet</div>
<p class="cc-empty-body">Daily certified stock data is updating. Check back shortly.</p>
</div>
{% endif %}
{% elif view == "aging" %}
{% if aging_latest %}
{% set aging_total = aging_latest | sum(attribute='total_bags') %}
{% set youngest = aging_latest | selectattr('age_bucket_start_days', 'equalto', 0) | list %}
{% set oldest = aging_latest | selectattr('age_bucket_start_days', 'ge', 720) | list %}
{% set youngest_pct = (youngest | sum(attribute='total_bags') / aging_total * 100) if aging_total > 0 else 0 %}
{% set oldest_pct = (oldest | sum(attribute='total_bags') / aging_total * 100) if aging_total > 0 else 0 %}
<div class="grid-4 mb-6">
<div class="metric-card">
<div class="metric-label">Total Certified</div>
<div class="metric-value">{{ "{:,.0f}".format(aging_total) }}</div>
<div class="metric-sub">60-kg bags · {{ aging_latest[0].report_date }}</div>
</div>
<div class="metric-card">
<div class="metric-label">Young Stock (0120d)</div>
<div class="metric-value text-bean-green">{{ "{:.1f}%".format(youngest_pct) }}</div>
<div class="metric-sub">freshest certified supply</div>
</div>
<div class="metric-card">
<div class="metric-label">Old Stock (720+ days)</div>
<div class="metric-value {% if oldest_pct > 20 %}text-danger{% endif %}">{{ "{:.1f}%".format(oldest_pct) }}</div>
<div class="metric-sub">at risk of cert expiry</div>
</div>
<div class="metric-card">
<div class="metric-label">Age Buckets</div>
<div class="metric-value">{{ aging_latest | length }}</div>
<div class="metric-sub">tracking ranges</div>
</div>
</div>
<div class="cc-chart-card">
<div class="cc-chart-top">
<div>
<div class="cc-chart-title">Stock Aging Distribution</div>
<div class="cc-chart-meta">Latest aging report · by delivery port · 60-kg bags</div>
</div>
</div>
<div class="cc-chart-body">
<canvas id="agingChart"></canvas>
</div>
</div>
{% else %}
<div class="cc-empty">
<div class="cc-empty-title">No Data Available Yet</div>
<p class="cc-empty-body">Aging distribution data is updating. Check back shortly.</p>
</div>
{% endif %}
{% elif view == "byport" %}
{% if byport_latest %}
<div class="grid-4 mb-6">
<div class="metric-card">
<div class="metric-label">Total Certified</div>
<div class="metric-value">{{ "{:,.0f}".format(byport_latest.total_bags) }}</div>
<div class="metric-sub">60-kg bags · {{ byport_latest.report_date }}</div>
</div>
<div class="metric-card">
<div class="metric-label">Month-over-Month</div>
<div class="metric-value {% if byport_latest.mom_change_bags and byport_latest.mom_change_bags > 0 %}text-bean-green{% elif byport_latest.mom_change_bags and byport_latest.mom_change_bags < 0 %}text-danger{% endif %}">
{% if byport_latest.mom_change_bags is not none %}{{ "{:+,d}".format(byport_latest.mom_change_bags | int) }}{% else %}--{% endif %}
</div>
<div class="metric-sub">bags vs prior month</div>
</div>
<div class="metric-card">
<div class="metric-label">12-Month Average</div>
<div class="metric-value">{{ "{:,.0f}".format(byport_latest.avg_12m_bags) }}</div>
<div class="metric-sub">60-kg bags</div>
</div>
<div class="metric-card">
<div class="metric-label">History</div>
<div class="metric-value">30 yrs</div>
<div class="metric-sub">since Nov 1996</div>
</div>
</div>
{% endif %}
{% if byport_trend %}
<div class="cc-chart-card">
<div class="cc-chart-top">
<div>
<div class="cc-chart-title">Warehouse Stocks by Delivery Port</div>
<div class="cc-chart-meta">End-of-month certified stocks · stacked area · 60-kg bags</div>
</div>
<span class="cc-chart-unit">bags</span>
</div>
<div class="cc-chart-body">
<canvas id="byPortChart"></canvas>
</div>
</div>
{% else %}
<div class="cc-empty">
<div class="cc-empty-title">No Data Available Yet</div>
<p class="cc-empty-body">By-port warehouse data is updating. Check back shortly.</p>
</div>
{% endif %}
{% endif %}
<!-- Chart init -->
<script>
(function () {
var C = {
copper: '#B45309', green: '#15803D', roast: '#4A2C1A', stone: '#78716C',
};
var PALETTE = ['#B45309','#15803D','#7C3AED','#0284C7','#BE185D','#0F766E','#C2410C','#1D4ED8'];
var AXES = {
x: {
grid: { color: 'rgba(232,223,213,0.45)', drawTicks: false },
border: { display: false },
ticks: { font: { family: "'DM Sans', sans-serif", size: 11 }, color: '#78716C', maxTicksLimit: 12 },
},
y: {
grid: { color: 'rgba(232,223,213,0.45)', drawTicks: false },
border: { display: false },
beginAtZero: false,
ticks: {
font: { family: "ui-monospace, 'Cascadia Code', monospace", size: 11 }, color: '#78716C',
callback: function (v) { return v >= 1000000 ? (v/1000000).toFixed(1)+'M' : v >= 1000 ? (v/1000).toFixed(0)+'k' : v; },
},
},
};
var TOOLTIP = {
backgroundColor: '#2C1810', titleColor: 'rgba(255,255,255,0.5)',
bodyColor: 'rgba(255,255,255,0.9)', borderColor: 'rgba(255,255,255,0.07)',
borderWidth: 1, padding: 12, cornerRadius: 10,
};
var LEGEND = {
position: 'bottom',
labels: { boxWidth: 10, boxHeight: 10, borderRadius: 5, useBorderRadius: true, padding: 16,
font: { family: "'DM Sans', sans-serif", size: 11.5 }, color: '#57534E' },
};
// Daily stocks chart
var stocksCanvas = document.getElementById('stocksChart');
var stocksRaw = {{ stocks_trend | tojson }};
if (stocksCanvas && stocksRaw.length > 0) {
var stocksAsc = stocksRaw.slice().reverse();
new Chart(stocksCanvas, {
type: 'line',
data: {
labels: stocksAsc.map(function (r) { return r.report_date; }),
datasets: [
{
label: 'Certified Stocks',
data: stocksAsc.map(function (r) { return r.total_certified_bags; }),
borderColor: C.roast, backgroundColor: C.roast + '18', fill: true,
tension: 0.2, pointRadius: 0, borderWidth: 2.5,
},
{
label: '30d Average',
data: stocksAsc.map(function (r) { return r.avg_30d_bags; }),
borderColor: C.stone, borderDash: [5, 4], tension: 0.2, pointRadius: 0, borderWidth: 1.75,
},
],
},
options: {
responsive: true, aspectRatio: 2.5, interaction: { mode: 'index', intersect: false }, animation: { duration: 350 },
plugins: { legend: LEGEND, tooltip: TOOLTIP },
scales: Object.assign({}, AXES, {
y: Object.assign({}, AXES.y, { title: { display: true, text: '60-kg bags', font: { size: 10 }, color: '#78716C' } }),
}),
},
});
}
// Aging chart
var agingCanvas = document.getElementById('agingChart');
var agingRaw = {{ aging_latest | tojson }};
if (agingCanvas && agingRaw.length > 0) {
var portCols = ['antwerp_bags','hamburg_bremen_bags','houston_bags','miami_bags','new_orleans_bags','new_york_bags'];
var portNames = ['Antwerp','Hamburg/Bremen','Houston','Miami','New Orleans','New York'];
new Chart(agingCanvas, {
type: 'bar',
data: {
labels: agingRaw.map(function (r) { return r.age_bucket; }),
datasets: portCols.map(function (col, i) {
return {
label: portNames[i],
data: agingRaw.map(function (r) { return r[col] || 0; }),
backgroundColor: PALETTE[i],
borderWidth: 0,
};
}),
},
options: {
responsive: true, aspectRatio: 2.5, interaction: { mode: 'index', intersect: false }, animation: { duration: 350 },
plugins: { legend: LEGEND, tooltip: TOOLTIP },
scales: {
x: Object.assign({}, AXES.x, { stacked: true, ticks: Object.assign({}, AXES.x.ticks, { maxRotation: 45 }) }),
y: Object.assign({}, AXES.y, { stacked: true, beginAtZero: true,
title: { display: true, text: '60-kg bags', font: { size: 10 }, color: '#78716C' } }),
},
},
});
}
// By-port chart
var byPortCanvas = document.getElementById('byPortChart');
var byPortRaw = {{ byport_trend | tojson }};
if (byPortCanvas && byPortRaw.length > 0) {
var byPortAsc = byPortRaw.slice().reverse();
var portCols = ['new_york_bags','new_orleans_bags','houston_bags','miami_bags','antwerp_bags','hamburg_bremen_bags','barcelona_bags','virginia_bags'];
var portNames = ['New York','New Orleans','Houston','Miami','Antwerp','Hamburg/Bremen','Barcelona','Virginia'];
new Chart(byPortCanvas, {
type: 'line',
data: {
labels: byPortAsc.map(function (r) { return r.report_date; }),
datasets: portCols.map(function (col, i) {
return {
label: portNames[i],
data: byPortAsc.map(function (r) { return r[col] || 0; }),
borderColor: PALETTE[i],
backgroundColor: PALETTE[i] + '22',
fill: true,
tension: 0.2,
pointRadius: 0,
borderWidth: 2,
};
}),
},
options: {
responsive: true, aspectRatio: 2.5, interaction: { mode: 'index', intersect: false }, animation: { duration: 350 },
plugins: { legend: LEGEND, tooltip: TOOLTIP },
scales: {
x: Object.assign({}, AXES.x, { stacked: true }),
y: Object.assign({}, AXES.y, { stacked: true, beginAtZero: true,
title: { display: true, text: '60-kg bags', font: { size: 10 }, color: '#78716C' } }),
},
},
});
}
}());
</script>

View File

@@ -556,6 +556,248 @@
@apply font-mono;
}
/* ── Chart card (shared across dashboard pages) ── */
.cc-chart-card {
background: white;
border: 1px solid var(--color-parchment);
border-radius: 1.25rem;
overflow: hidden;
box-shadow: 0 1px 3px rgba(44,24,16,0.05), 0 4px 20px rgba(44,24,16,0.03);
}
.cc-chart-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 1.25rem 1.5rem 0;
gap: 1rem;
}
.cc-chart-title {
font-family: var(--font-display);
font-size: 1.0625rem;
font-weight: 700;
color: var(--color-espresso);
line-height: 1.2;
}
.cc-chart-meta { font-size: 0.6875rem; color: var(--color-stone); margin-top: 3px; }
.cc-chart-unit {
font-family: var(--font-mono);
font-size: 0.6875rem;
color: var(--color-stone);
background: var(--color-latte);
border: 1px solid var(--color-parchment);
padding: 2px 8px;
border-radius: 100px;
white-space: nowrap;
flex-shrink: 0;
align-self: flex-start;
margin-top: 4px;
}
.cc-chart-body { padding: 1rem 1.5rem 1.5rem; }
.cc-chart-body canvas { max-height: 340px !important; }
/* ── Data table card ── */
.cc-table-card {
background: white;
border: 1px solid var(--color-parchment);
border-radius: 1.25rem;
overflow: hidden;
box-shadow: 0 1px 3px rgba(44,24,16,0.05);
}
.cc-table-top {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.875rem 1.5rem;
border-bottom: 1px solid var(--color-parchment);
}
.cc-table-label {
font-size: 0.625rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--color-stone);
font-weight: 700;
}
.cc-table-year {
font-family: var(--font-mono);
font-size: 0.6875rem;
color: var(--color-stone);
background: var(--color-latte);
border: 1px solid var(--color-parchment);
padding: 2px 7px;
border-radius: 100px;
}
.cc-trow {
display: grid;
grid-template-columns: 2rem 1fr 5.5rem;
align-items: center;
padding: 0.5625rem 1.5rem;
border-bottom: 1px solid rgba(232,223,213,0.5);
transition: background 0.1s;
gap: 0.5rem;
}
.cc-trow:last-child { border-bottom: none; }
.cc-trow:hover { background: var(--color-latte); }
.cc-trow-head { padding-top: 0.375rem; padding-bottom: 0.375rem; border-bottom: 1px solid var(--color-parchment); }
.cc-trow-head:hover { background: none; }
.cc-t-rank { font-family: var(--font-mono); font-size: 0.6875rem; color: var(--color-stone); text-align: right; }
.cc-t-country { display: flex; align-items: center; gap: 0.5rem; min-width: 0; }
.cc-t-name { font-size: 0.8125rem; font-weight: 500; color: var(--color-espresso); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cc-t-value { font-family: var(--font-mono); font-size: 0.8125rem; font-weight: 600; color: var(--color-espresso); text-align: right; }
.cc-t-head { font-size: 0.625rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--color-stone); font-weight: 600; }
/* ── Empty state ── */
.cc-empty {
background: white;
border: 1px solid var(--color-parchment);
border-radius: 1.25rem;
padding: 4.5rem 2rem;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
}
.cc-empty-ring {
width: 52px;
height: 52px;
border-radius: 50%;
background: var(--color-latte);
border: 2px solid var(--color-parchment);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.25rem;
}
.cc-empty-title { font-family: var(--font-display); font-size: 1.125rem; font-weight: 700; color: var(--color-espresso); margin-bottom: 0.5rem; }
.cc-empty-body { font-size: 0.875rem; color: var(--color-stone); line-height: 1.65; max-width: 300px; }
@keyframes cc-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
.cc-in { animation: cc-in 0.25s ease both; }
.cc-in-2 { animation: cc-in 0.25s 0.07s ease both; }
/* ── Filter bar (deep-dive pages) ── */
.filter-bar {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 1.5rem;
padding-bottom: 1.25rem;
border-bottom: 1px solid var(--color-parchment);
}
.filter-pills {
display: flex;
background: var(--color-latte);
border: 1px solid var(--color-parchment);
border-radius: 0.875rem;
padding: 3px;
gap: 0;
}
.filter-pill {
padding: 0.3125rem 1rem;
border-radius: 0.625rem;
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-stone-dark);
border: none;
background: none;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
font-family: var(--font-sans);
}
.filter-pill:hover:not(.active) { background: white; color: var(--color-espresso); }
.filter-pill.active {
background: var(--color-copper);
color: white;
font-weight: 600;
box-shadow: 0 2px 8px rgba(180, 83, 9, 0.3);
}
.filter-check {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-stone-dark);
cursor: pointer;
user-select: none;
}
.filter-check input[type="checkbox"] {
accent-color: var(--color-copper);
width: 14px;
height: 14px;
cursor: pointer;
}
/* Canvas loading state */
.canvas-loading {
opacity: 0.45;
pointer-events: none;
transition: opacity 0.15s;
}
/* Freshness badges */
.freshness-bar {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 1.75rem;
}
.freshness-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.625rem;
background: var(--color-latte);
border: 1px solid var(--color-parchment);
border-radius: 100px;
font-size: 0.6875rem;
font-weight: 500;
color: var(--color-stone);
text-decoration: none;
white-space: nowrap;
transition: background 0.12s, color 0.12s;
}
.freshness-badge:hover { background: var(--color-parchment); color: var(--color-espresso); }
.freshness-badge strong { color: var(--color-espresso); font-weight: 600; }
/* Sparkline chart container */
.spark-chart {
position: relative;
max-height: 160px;
overflow: hidden;
}
.spark-chart canvas {
max-height: 160px !important;
}
/* Page masthead (deep-dive pages) */
.page-masthead {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 1.25rem;
padding-bottom: 1.25rem;
border-bottom: 1.5px solid var(--color-parchment);
gap: 1rem;
flex-wrap: wrap;
}
.page-masthead h1 {
font-family: var(--font-display);
font-size: 2rem;
font-weight: 800;
color: var(--color-espresso);
line-height: 1;
letter-spacing: -0.02em;
margin: 0;
}
.page-masthead-sub {
font-size: 0.875rem;
color: var(--color-stone);
margin-top: 0.25rem;
}
/* HTMX loading indicators */
.htmx-indicator {
display: none;

View File

@@ -15,7 +15,7 @@
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
Feedback
<span class="feedback-label">Feedback</span>
</button>
<div
@@ -70,6 +70,11 @@
<style>
#feedback-popover.open { display: block; }
@media (max-width: 767px) {
#feedback-widget { bottom: 72px; }
.feedback-label { display: none; }
#feedback-btn { border-radius: 50%; padding: 10px; }
}
</style>
<script>