From 090fcb4fdb9e64d8b2202306d0a6191dab731be8 Mon Sep 17 00:00:00 2001 From: Deeman Date: Sun, 22 Feb 2026 01:27:44 +0100 Subject: [PATCH] =?UTF-8?q?dashboard:=20JTBD-driven=20restructure=20?= =?UTF-8?q?=E2=80=94=20Pulse,=20Supply,=20Positioning,=20Warehouse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- vision.md | 8 +- web/src/beanflows/dashboard/routes.py | 266 ++++++-- .../dashboard/templates/dashboard_base.html | 74 ++- .../beanflows/dashboard/templates/index.html | 605 ------------------ .../dashboard/templates/positioning.html | 101 +++ .../templates/positioning_canvas.html | 266 ++++++++ .../beanflows/dashboard/templates/pulse.html | 343 ++++++++++ .../beanflows/dashboard/templates/supply.html | 87 +++ .../dashboard/templates/supply_canvas.html | 292 +++++++++ .../dashboard/templates/warehouse.html | 81 +++ .../dashboard/templates/warehouse_canvas.html | 320 +++++++++ web/src/beanflows/static/css/input.css | 242 +++++++ .../beanflows/templates/_feedback_widget.html | 7 +- 13 files changed, 2009 insertions(+), 683 deletions(-) delete mode 100644 web/src/beanflows/dashboard/templates/index.html create mode 100644 web/src/beanflows/dashboard/templates/positioning.html create mode 100644 web/src/beanflows/dashboard/templates/positioning_canvas.html create mode 100644 web/src/beanflows/dashboard/templates/pulse.html create mode 100644 web/src/beanflows/dashboard/templates/supply.html create mode 100644 web/src/beanflows/dashboard/templates/supply_canvas.html create mode 100644 web/src/beanflows/dashboard/templates/warehouse.html create mode 100644 web/src/beanflows/dashboard/templates/warehouse_canvas.html 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 + + + + Positioning + + + + Warehouse @@ -93,12 +116,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

-
- - - - - - - - - - {% for row in yoy %} - - - - - - {% endfor %} - -
CountryProduction (1k bags)YoY %
{{ row.country_name }}{{ "{:,.0f}".format(row.production) }} - {% if row.production_yoy_pct is not none %} - {{ "{:+.1f}%".format(row.production_yoy_pct) }} - {% else %} - -- - {% endif %} -
-
-
-
- - - {% 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 %} + + + + + +
+ +
+ {% for r, label in [("6m","6M"),("1y","1Y"),("2y","2Y"),("5y","5Y")] %} + + {% endfor %} +
+ + + + +
+ + +
+ {% 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 %} + + +
+

Pulse

+

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

+
+ + +
+
+
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 %}
+
+
+ + + + + +
+ + +
+
+
KC=F Price — 90 days
+ View details → +
+
+ +
+
ICE Coffee C Arabica · ¢/lb daily close
+
+ + +
+
+
ICE Certified Stocks — 90 days
+ View details → +
+
+ +
+
Physical Arabica certified for delivery · 60-kg bags
+
+ + +
+
+
COT Index — 26 weeks
+ View details → +
+
+ +
+
CFTC Managed Money net position · 0 = max bearish · 100 = max bullish
+
+ + +
+
+
Global Supply & Demand — 5 years
+ View details → +
+
+ +
+
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 %} + + +
+
+

Global Supply & Demand

+

USDA WASDE · coffee fundamentals · production, trade & stocks

+
+ + USDA PSD + {% if time_series %}{{ time_series[-1].market_year }}{% else %}—{% endif %} + +
+ + +
+ +
+ {% for r, label in [("5y","5Y"),("10y","10Y"),("max","Max")] %} + + {% endfor %} +
+ + +
+ {% for m, label in [("production","Production"),("exports","Exports"),("imports","Imports"),("ending_stocks","Stocks")] %} + + {% 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 %} +
+ Export CSV +
+{% 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 %} + + +
+
+

ICE Warehouse Stocks

+

Physical Arabica certified for delivery against ICE Coffee C futures

+
+ + ICE + {% if stocks_latest %}{{ stocks_latest.report_date }}{% elif byport_latest %}{{ byport_latest.report_date }}{% else %}—{% endif %} + +
+ + +
+ +
+ {% for r, label in [("3m","3M"),("1y","1Y"),("5y","5Y"),("max","Max")] %} + + {% endfor %} +
+ + +
+ {% for v, label in [("stocks","Daily Stocks"),("aging","Aging"),("byport","By Port")] %} + + {% 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-popover.open { display: block; } + @media (max-width: 767px) { + #feedback-widget { bottom: 72px; } + .feedback-label { display: none; } + #feedback-btn { border-radius: 50%; padding: 10px; } + }