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:
@@ -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
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 & 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 & 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 (0–120 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 (0–100)',
|
||||
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 %}
|
||||
101
web/src/beanflows/dashboard/templates/positioning.html
Normal file
101
web/src/beanflows/dashboard/templates/positioning.html
Normal 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 %}
|
||||
266
web/src/beanflows/dashboard/templates/positioning_canvas.html
Normal file
266
web/src/beanflows/dashboard/templates/positioning_canvas.html
Normal 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 (0–100)</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 (0–100)',
|
||||
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>
|
||||
343
web/src/beanflows/dashboard/templates/pulse.html
Normal file
343
web/src/beanflows/dashboard/templates/pulse.html
Normal 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 & 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 %}
|
||||
87
web/src/beanflows/dashboard/templates/supply.html
Normal file
87
web/src/beanflows/dashboard/templates/supply.html
Normal file
@@ -0,0 +1,87 @@
|
||||
{% extends "dashboard_base.html" %}
|
||||
|
||||
{% block title %}Supply & 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 & Demand</h1>
|
||||
<p class="page-masthead-sub">USDA WASDE · coffee fundamentals · production, trade & 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 %}
|
||||
292
web/src/beanflows/dashboard/templates/supply_canvas.html
Normal file
292
web/src/beanflows/dashboard/templates/supply_canvas.html
Normal 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 & 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 & 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>
|
||||
81
web/src/beanflows/dashboard/templates/warehouse.html
Normal file
81
web/src/beanflows/dashboard/templates/warehouse.html
Normal 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 %}
|
||||
320
web/src/beanflows/dashboard/templates/warehouse_canvas.html
Normal file
320
web/src/beanflows/dashboard/templates/warehouse_canvas.html
Normal 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 (0–120d)</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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user