diff --git a/vision.md b/vision.md
index 1d4f47d..4f65501 100644
--- a/vision.md
+++ b/vision.md
@@ -57,13 +57,13 @@ We move fast, ship incrementally, and prioritize value over vanity metrics.
**Languages:**
- SQL (primary transformation language)
-- Python (orchestration, extraction, APIs)
+- Python (Web,orchestration, extraction, APIs)
- C (performance-critical extensions)
**Infrastructure:**
-- **Storage:** Cloudflare R2 (not S3)
-- **Compute:** Hetzner bare metal (not AWS/GCP)
-- **Database:** DuckDB (not Spark/Snowflake)
+- **Storage:** Baremetal nvme drives (backup cloudflare r2)
+- **Compute:** Hetzner bare metal (not AWS/GCP, maybe later for ephemeral pipelines if needed)
+- **Database:** Sqlite/DuckDB
- **Orchestration:** SQLMesh + custom Python (not Airflow)
**Development:**
diff --git a/web/src/beanflows/dashboard/routes.py b/web/src/beanflows/dashboard/routes.py
index a281e3b..479cca5 100644
--- a/web/src/beanflows/dashboard/routes.py
+++ b/web/src/beanflows/dashboard/routes.py
@@ -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
diff --git a/web/src/beanflows/dashboard/templates/dashboard_base.html b/web/src/beanflows/dashboard/templates/dashboard_base.html
index 7ade5be..2cd3f84 100644
--- a/web/src/beanflows/dashboard/templates/dashboard_base.html
+++ b/web/src/beanflows/dashboard/templates/dashboard_base.html
@@ -44,12 +44,35 @@
+
+
+
-
-
-
-
+
- Overview
+ Pulse
+
+
+
+
+
+
+
+ Supply
+
+
+
+
+
+
+ Position
+
+
+
+
+
+
+
+ Warehouse
@@ -110,14 +156,6 @@
Origins
-
-
-
-
-
- Settings
-
diff --git a/web/src/beanflows/dashboard/templates/index.html b/web/src/beanflows/dashboard/templates/index.html
deleted file mode 100644
index f05afe1..0000000
--- a/web/src/beanflows/dashboard/templates/index.html
+++ /dev/null
@@ -1,605 +0,0 @@
-{% extends "dashboard_base.html" %}
-
-{% block title %}Dashboard — {{ config.APP_NAME }}{% endblock %}
-
-{% block head %}
-
-{% endblock %}
-
-{% block content %}
-
-
-
-
-
-
-
Global Production (latest year)
-
{{ "{:,.0f}".format(latest.get("production", 0)) }}
-
1,000 60-kg bags
-
-
-
-
Stock-to-Use Ratio
-
- {% if stu_trend %}
- {{ "{:.1f}".format(stu_trend[-1].get("stock_to_use_ratio_pct", 0)) }}%
- {% else %}
- --
- {% endif %}
-
-
Ending stocks / consumption
-
-
-
-
Trade Balance
-
{{ "{:,.0f}".format(latest.get("exports", 0) - latest.get("imports", 0)) }}
-
Exports minus imports
-
-
-
-
Your Plan
-
{{ plan | title }}
-
- {% if plan == "free" %}
-
Upgrade for full history
- {% else %}
- {{ stats.api_calls }} API calls (30d)
- {% endif %}
-
-
-
-
-
-
-
Global Supply & Demand
- {% if plan == "free" %}
-
Showing last 5 years.
Upgrade for full 18+ year history.
- {% endif %}
-
-
-
-
-
-
Stock-to-Use Ratio Trend
-
-
-
-
-
-
-
Top Producing Countries
-
-
-
-
-
Year-over-Year Production Change
-
-
-
-
- Country
- Production (1k bags)
- YoY %
-
-
-
- {% for row in yoy %}
-
- {{ row.country_name }}
- {{ "{:,.0f}".format(row.production) }}
-
- {% if row.production_yoy_pct is not none %}
- {{ "{:+.1f}%".format(row.production_yoy_pct) }}
- {% else %}
- --
- {% endif %}
-
-
- {% endfor %}
-
-
-
-
-
-
-
- {% if plan != "free" %}
-
- {% else %}
- CSV export available on Trader and Analyst plans.
Upgrade
- {% endif %}
-
-
- {% if cot_latest %}
-
-
Speculative Positioning — Coffee C Futures
-
CFTC Commitment of Traders · Managed Money net position (hedge funds & CTAs) · as of {{ cot_latest.report_date }}
-
-
-
Managed Money Net
-
- {{ "{:+,d}".format(cot_latest.managed_money_net | int) }}
-
-
contracts (long − short)
-
-
-
COT Index (26w)
-
{{ "{:.0f}".format(cot_latest.cot_index_26w) }}
-
0 = most bearish · 100 = most bullish
-
-
-
Net % of Open Interest
-
{{ "{:+.1f}".format(cot_latest.managed_money_net_pct_of_oi) }}%
-
managed money positioning
-
-
-
Open Interest
-
{{ "{:,d}".format(cot_latest.open_interest | int) }}
-
total contracts outstanding
-
-
-
-
- {% endif %}
-
-
- {% if price_latest %}
-
-
Coffee C Futures Price — KC=F
-
ICE Coffee C Arabica · Daily close price · Source: Yahoo Finance
-
-
-
Latest Close
-
{{ "{:.2f}".format(price_latest.close) }}
-
¢/lb · as of {{ price_latest.trade_date }}
-
-
-
Daily Change
-
- {% if price_latest.daily_return_pct is not none %}
- {{ "{:+.2f}%".format(price_latest.daily_return_pct) }}
- {% else %}--{% endif %}
-
-
vs previous close
-
-
-
52-Week High
-
{{ "{:.2f}".format(price_latest.high_52w) }}
-
¢/lb
-
-
-
52-Week Low
-
{{ "{:.2f}".format(price_latest.low_52w) }}
-
¢/lb
-
-
-
-
- {% endif %}
-
-
- {% if ice_stocks_latest %}
-
-
ICE Certified Warehouse Stocks
-
Physical Arabica certified for delivery against ICE Coffee C futures · as of {{ ice_stocks_latest.report_date }}
-
-
-
Certified Stocks
-
{{ "{:,.0f}".format(ice_stocks_latest.total_certified_bags) }}
-
60-kg bags
-
-
-
Week-over-Week
-
- {% if ice_stocks_latest.wow_change_bags is not none %}
- {{ "{:+,d}".format(ice_stocks_latest.wow_change_bags | int) }}
- {% else %}--{% endif %}
-
-
bags vs previous day
-
-
-
30-Day Average
-
{{ "{:,.0f}".format(ice_stocks_latest.avg_30d_bags) }}
-
60-kg bags
-
-
-
Drawdown from 52w High
-
- {% if ice_stocks_latest.drawdown_from_52w_high_pct is not none %}
- {{ "{:.1f}%".format(ice_stocks_latest.drawdown_from_52w_high_pct) }}
- {% else %}--{% endif %}
-
-
below 52-week peak
-
-
-
-
- {% endif %}
-
-
- {% 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 %}
-
-
Certified Stock Aging Report
-
Age distribution of certified Arabica stocks by delivery port · as of {{ ice_aging_latest[0].report_date }}
-
-
-
Total Certified
-
{{ "{:,.0f}".format(aging_total) }}
-
60-kg bags
-
-
-
Young Stock (0–120 days)
-
{{ "{:.1f}%".format(youngest_pct) }}
-
freshest certified supply
-
-
-
Old Stock (720+ days)
-
{{ "{:.1f}%".format(oldest_pct) }}
-
at risk of cert expiry
-
-
-
Age Buckets
-
{{ ice_aging_latest | length }}
-
tracking ranges
-
-
-
-
- {% endif %}
-
-
- {% if ice_stocks_by_port_latest %}
-
-
Warehouse Stocks by Delivery Port
-
End-of-month certified stocks at each ICE delivery port · Nov 1996 to present · as of {{ ice_stocks_by_port_latest.report_date }}
-
-
-
Total Certified
-
{{ "{:,.0f}".format(ice_stocks_by_port_latest.total_bags) }}
-
60-kg bags (latest month)
-
-
-
Month-over-Month
-
- {% 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 %}
-
-
bags vs prior month
-
-
-
12-Month Average
-
{{ "{:,.0f}".format(ice_stocks_by_port_latest.avg_12m_bags) }}
-
60-kg bags
-
-
-
History
-
30 yrs
-
since Nov 1996
-
-
-
-
- {% endif %}
-
-
-
-{% endblock %}
-
-{% block scripts %}
-
-{% endblock %}
diff --git a/web/src/beanflows/dashboard/templates/positioning.html b/web/src/beanflows/dashboard/templates/positioning.html
new file mode 100644
index 0000000..e312b20
--- /dev/null
+++ b/web/src/beanflows/dashboard/templates/positioning.html
@@ -0,0 +1,101 @@
+{% extends "dashboard_base.html" %}
+
+{% block title %}Positioning — {{ config.APP_NAME }}{% endblock %}
+
+{% block head %}
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
Market Positioning
+
CFTC COT · managed money net position · KC=F price action
+
+
+
+
+
+
+
+
+
+ {% include "positioning_canvas.html" %}
+
+
+{% endblock %}
+
+{% block scripts %}
+
+{% endblock %}
diff --git a/web/src/beanflows/dashboard/templates/positioning_canvas.html b/web/src/beanflows/dashboard/templates/positioning_canvas.html
new file mode 100644
index 0000000..8ca9049
--- /dev/null
+++ b/web/src/beanflows/dashboard/templates/positioning_canvas.html
@@ -0,0 +1,266 @@
+{#
+ positioning_canvas.html — HTMX partial for /dashboard/positioning
+#}
+
+{% if price_latest or cot_latest %}
+
+
+
+
+
KC=F Close
+
+ {% if price_latest %}{{ "{:.2f}".format(price_latest.close) }}{% else %}--{% endif %}
+
+
¢/lb
+ {% if price_latest and price_latest.daily_return_pct is not none %}
+ · {{ "{:+.2f}%".format(price_latest.daily_return_pct) }}
+ {% endif %}
+
+
+
+
+
MM Net Position
+
+ {% if cot_latest %}{{ "{:+,d}".format(cot_latest.managed_money_net | int) }}{% else %}--{% endif %}
+
+
contracts (long − short)
+
+
+
+
COT Index 26w
+
+ {% if cot_latest %}{{ "{:.0f}".format(cot_latest.cot_index_26w) }}{% else %}--{% endif %}
+
+
0 = most bearish · 100 = most bullish
+
+
+
+
Open Interest
+
+ {% if cot_latest %}{{ "{:,d}".format(cot_latest.open_interest | int) }}{% else %}--{% endif %}
+
+
total contracts outstanding
+
+
+
+
+{% if price_series %}
+
+
+
+
KC=F Coffee Futures Price
+
ICE Coffee C Arabica · daily close + moving averages · ¢/lb
+
+
¢/lb
+
+
+
+
+
+{% endif %}
+
+
+{% if cot_trend %}
+
+
+
+
Managed Money Net Position + COT Index
+
CFTC Commitment of Traders · weekly · area = net contracts · dashed = COT index (0–100)
+
+
+
+
+
+
+{% endif %}
+
+{% else %}
+
+
+
+
No Data Available Yet
+
Positioning data is updating. Check back shortly.
+
+
+{% endif %}
+
+
+
diff --git a/web/src/beanflows/dashboard/templates/pulse.html b/web/src/beanflows/dashboard/templates/pulse.html
new file mode 100644
index 0000000..54779d3
--- /dev/null
+++ b/web/src/beanflows/dashboard/templates/pulse.html
@@ -0,0 +1,343 @@
+{% extends "dashboard_base.html" %}
+
+{% block title %}Pulse — {{ config.APP_NAME }}{% endblock %}
+
+{% block head %}
+
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
KC=F Close
+
+ {% if price_latest %}{{ "{:.2f}".format(price_latest.close) }}{% else %}--{% endif %}
+
+
+ ¢/lb
+ {% if price_latest and price_latest.daily_return_pct is not none %}
+ · {{ "{:+.2f}%".format(price_latest.daily_return_pct) }}
+ {% endif %}
+ {% if price_latest %} · {{ price_latest.trade_date }}{% endif %}
+
+
+
+
+
MM Net Position
+
+ {% if cot_latest %}{{ "{:+,d}".format(cot_latest.managed_money_net | int) }}{% else %}--{% endif %}
+
+
contracts · {% if cot_latest %}COT Index {{ "{:.0f}".format(cot_latest.cot_index_26w) }}/100 · {{ cot_latest.report_date }}{% else %}CFTC COT{% endif %}
+
+
+
+
Certified Stocks
+
+ {% if ice_stocks_latest %}{{ "{:,.0f}".format(ice_stocks_latest.total_certified_bags) }}{% else %}--{% endif %}
+
+
+ 60-kg bags
+ {% if ice_stocks_latest and ice_stocks_latest.wow_change_bags is not none %}
+ · {{ "{:+,d}".format(ice_stocks_latest.wow_change_bags | int) }} WoW
+ {% endif %}
+ {% if ice_stocks_latest %} · {{ ice_stocks_latest.report_date }}{% endif %}
+
+
+
+
+
Stock-to-Use Ratio
+
+ {% if stu_latest %}{{ "{:.1f}".format(stu_latest.stock_to_use_ratio_pct) }}%{% else %}--{% endif %}
+
+
ending stocks / consumption{% if stu_latest %} · {{ stu_latest.market_year }}{% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
ICE Coffee C Arabica · ¢/lb daily close
+
+
+
+
+
+
+
+
+
Physical Arabica certified for delivery · 60-kg bags
+
+
+
+
+
+
+
+
+
CFTC Managed Money net position · 0 = max bearish · 100 = max bullish
+
+
+
+
+
+
+
+
+
USDA WASDE · 1,000 60-kg bags · production vs distribution
+
+
+
+
+{% if plan == "free" %}
+
+ Free plan shows limited history.
Upgrade for full historical depth on all pages.
+
+{% endif %}
+
+{% endblock %}
+
+{% block scripts %}
+
+{% endblock %}
diff --git a/web/src/beanflows/dashboard/templates/supply.html b/web/src/beanflows/dashboard/templates/supply.html
new file mode 100644
index 0000000..c7d4272
--- /dev/null
+++ b/web/src/beanflows/dashboard/templates/supply.html
@@ -0,0 +1,87 @@
+{% extends "dashboard_base.html" %}
+
+{% block title %}Supply & Demand — {{ config.APP_NAME }}{% endblock %}
+
+{% block head %}
+
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+ {% for r, label in [("5y","5Y"),("10y","10Y"),("max","Max")] %}
+ {{ label }}
+ {% endfor %}
+
+
+
+
+ {% for m, label in [("production","Production"),("exports","Exports"),("imports","Imports"),("ending_stocks","Stocks")] %}
+ {{ label }}
+ {% endfor %}
+
+
+
+
+
+ {% include "supply_canvas.html" %}
+
+
+{% endblock %}
+
+{% block scripts %}
+
+{% endblock %}
diff --git a/web/src/beanflows/dashboard/templates/supply_canvas.html b/web/src/beanflows/dashboard/templates/supply_canvas.html
new file mode 100644
index 0000000..2887b8c
--- /dev/null
+++ b/web/src/beanflows/dashboard/templates/supply_canvas.html
@@ -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 %}
+
+
+
+
+
+
Global Supply & Demand
+
USDA WASDE · 1,000 60-kg bags · market year
+
+
1k bags
+
+
+
+
+
+
+
+
+
+
+
Stock-to-Use Ratio
+
Ending stocks as % of total distribution · lower = tighter market
+
+
%
+
+
+
+
+
+
+
+
+
+
+
+
Top Countries — {{ metric.replace("_"," ").title() }}
+
USDA · latest market year · 1,000 60-kg bags
+
+
+
+
+
+
+
+
+
+ YoY Production Change
+ {% if yoy %}{{ yoy[0].market_year }} {% endif %}
+
+
+
+ Country
+ YoY %
+
+ {% for row in yoy[:12] %}
+
+
{{ loop.index }}
+
+ {{ row.country_name }}
+
+
+ {% if row.production_yoy_pct is not none %}{{ "{:+.1f}%".format(row.production_yoy_pct) }}{% else %}—{% endif %}
+
+
+ {% endfor %}
+
+
+
+{% if plan == "free" %}
+
+ Free plan shows last 5 years.
Upgrade for 10Y / full history and CSV export.
+
+{% else %}
+
+{% endif %}
+
+{% else %}
+
+
+
+
+
+
+
+
+
+
No Data Available Yet
+
USDA supply & demand data is updating. Check back shortly.
+
+
+{% endif %}
+
+
+
diff --git a/web/src/beanflows/dashboard/templates/warehouse.html b/web/src/beanflows/dashboard/templates/warehouse.html
new file mode 100644
index 0000000..6bc8587
--- /dev/null
+++ b/web/src/beanflows/dashboard/templates/warehouse.html
@@ -0,0 +1,81 @@
+{% extends "dashboard_base.html" %}
+
+{% block title %}Warehouse — {{ config.APP_NAME }}{% endblock %}
+
+{% block head %}
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+ {% for r, label in [("3m","3M"),("1y","1Y"),("5y","5Y"),("max","Max")] %}
+ {{ label }}
+ {% endfor %}
+
+
+
+
+ {% for v, label in [("stocks","Daily Stocks"),("aging","Aging"),("byport","By Port")] %}
+ {{ label }}
+ {% endfor %}
+
+
+
+
+
+ {% include "warehouse_canvas.html" %}
+
+
+{% endblock %}
+
+{% block scripts %}
+
+{% endblock %}
diff --git a/web/src/beanflows/dashboard/templates/warehouse_canvas.html b/web/src/beanflows/dashboard/templates/warehouse_canvas.html
new file mode 100644
index 0000000..62cf3f1
--- /dev/null
+++ b/web/src/beanflows/dashboard/templates/warehouse_canvas.html
@@ -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'] %}
+
+
+
+
+{% if view == "stocks" %}
+
+ {% if stocks_latest %}
+
+
+
+
Certified Stocks
+
{{ "{:,.0f}".format(stocks_latest.total_certified_bags) }}
+
60-kg bags · {{ stocks_latest.report_date }}
+
+
+
Week-over-Week
+
+ {% if stocks_latest.wow_change_bags is not none %}{{ "{:+,d}".format(stocks_latest.wow_change_bags | int) }}{% else %}--{% endif %}
+
+
bags vs previous report
+
+
+
30-Day Average
+
{{ "{:,.0f}".format(stocks_latest.avg_30d_bags) }}
+
60-kg bags
+
+
+
Drawdown from 52w High
+
+ {% if stocks_latest.drawdown_from_52w_high_pct is not none %}{{ "{:.1f}%".format(stocks_latest.drawdown_from_52w_high_pct) }}{% else %}--{% endif %}
+
+
below 52-week peak
+
+
+ {% endif %}
+
+ {% if stocks_trend %}
+
+
+
+
Daily Certified Stocks
+
ICE certified for Coffee C delivery · 60-kg bags
+
+
bags
+
+
+
+
+
+ {% else %}
+
+
No Data Available Yet
+
Daily certified stock data is updating. Check back shortly.
+
+ {% 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 %}
+
+
+
+
Total Certified
+
{{ "{:,.0f}".format(aging_total) }}
+
60-kg bags · {{ aging_latest[0].report_date }}
+
+
+
Young Stock (0–120d)
+
{{ "{:.1f}%".format(youngest_pct) }}
+
freshest certified supply
+
+
+
Old Stock (720+ days)
+
{{ "{:.1f}%".format(oldest_pct) }}
+
at risk of cert expiry
+
+
+
Age Buckets
+
{{ aging_latest | length }}
+
tracking ranges
+
+
+
+
+
+
+
Stock Aging Distribution
+
Latest aging report · by delivery port · 60-kg bags
+
+
+
+
+
+
+ {% else %}
+
+
No Data Available Yet
+
Aging distribution data is updating. Check back shortly.
+
+ {% endif %}
+
+{% elif view == "byport" %}
+
+ {% if byport_latest %}
+
+
+
Total Certified
+
{{ "{:,.0f}".format(byport_latest.total_bags) }}
+
60-kg bags · {{ byport_latest.report_date }}
+
+
+
Month-over-Month
+
+ {% if byport_latest.mom_change_bags is not none %}{{ "{:+,d}".format(byport_latest.mom_change_bags | int) }}{% else %}--{% endif %}
+
+
bags vs prior month
+
+
+
12-Month Average
+
{{ "{:,.0f}".format(byport_latest.avg_12m_bags) }}
+
60-kg bags
+
+
+
History
+
30 yrs
+
since Nov 1996
+
+
+ {% endif %}
+
+ {% if byport_trend %}
+
+
+
+
Warehouse Stocks by Delivery Port
+
End-of-month certified stocks · stacked area · 60-kg bags
+
+
bags
+
+
+
+
+
+ {% else %}
+
+
No Data Available Yet
+
By-port warehouse data is updating. Check back shortly.
+
+ {% endif %}
+
+{% endif %}
+
+
+
diff --git a/web/src/beanflows/static/css/input.css b/web/src/beanflows/static/css/input.css
index 31951b0..1b6ab4e 100644
--- a/web/src/beanflows/static/css/input.css
+++ b/web/src/beanflows/static/css/input.css
@@ -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;
diff --git a/web/src/beanflows/templates/_feedback_widget.html b/web/src/beanflows/templates/_feedback_widget.html
index 807221b..31d6892 100644
--- a/web/src/beanflows/templates/_feedback_widget.html
+++ b/web/src/beanflows/templates/_feedback_widget.html
@@ -15,7 +15,7 @@
- Feedback
+ Feedback
#feedback-popover.open { display: block; }
+ @media (max-width: 767px) {
+ #feedback-widget { bottom: 72px; }
+ .feedback-label { display: none; }
+ #feedback-btn { border-radius: 50%; padding: 10px; }
+ }