""" environments/trace_env/tools/dashboard_renderer.py Dashboard Renderer for Trace. Converts parsed transaction data โ†’ beautiful HTML dashboard. Works for ALL transaction categories: ride | food | shopping | payment | subscription | travel | banking | utility | education | healthcare Usage: from environments.trace_env.tools.dashboard_renderer import render_dashboard html = render_dashboard(parsed_result) # Save to file or return via a new FastAPI endpoint """ from __future__ import annotations import json import re from datetime import datetime def render_dashboard(parsed_result: dict) -> str: """ Render a full HTML dashboard from parse_transactions_bulk() output. Args: parsed_result: output of transaction_parser.parse_transactions_bulk() Returns: Complete HTML string ready to open in browser or serve via FastAPI. """ transactions = parsed_result.get("transactions", []) summary = parsed_result.get("summary", {}) episode = parsed_result.get("episode", {}) cards_html = "\n".join(_render_card(t, i) for i, t in enumerate(transactions)) summary_html = _render_summary(summary) episode_html = _render_episode_bar(episode) chart_data = _build_chart_data(summary) sheet_url = parsed_result.get("sheet_url") sheets_html = "" if sheet_url: sheets_html = f"""
๐Ÿ“Š
Live Google Sheets Ledger Connected
The summary above includes all historical data synced from your spreadsheet.
Open Sheet โ€บ
""" prompt = parsed_result.get("prompt", "") import html as html_lib prompt_escaped = html_lib.escape(prompt) prompt_html = f"""
{f'Clear' if prompt else ''}
""" return f""" Trace ยท Transaction Dashboard
Gmail Transaction Intelligence
REAL DATA ยท LIVE
{prompt_html} {episode_html} {summary_html} {sheets_html} {_render_chart(summary)}
Retrieved Transactions
{len(transactions)} emails
{cards_html}
""" # โ”€โ”€ Card renderer โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def _render_card(t: dict, idx: int) -> str: cfg = t["category_config"] icon = cfg["icon"] color = cfg["color"] label = cfg["label"] delay = idx * 80 details_html = _render_details(t) amounts_html = _render_amounts(t) image_html = _render_image_analysis(t) tags_html = _render_tags(t) total_display = t["total"] or "โ€”" order_display = f'#{t["order_id"]}' if t["order_id"] else "" reimbursable = 'โœ“ Reimbursable' if t["reimbursable"] else "" return f"""
{icon}
{t['vendor']}
{label} ยท {t['date'][:22] if t['date'] else 'โ€”'}
{total_display}
{order_display}
{t['subject'] or '(No subject)'}
Details
{details_html}
{amounts_html}
{image_html}
""" def _render_details(t: dict) -> str: details = t.get("details", {}) category = t["category"] rows = [] if category == "ride": for k, v in [("Distance", details.get("distance")), ("Duration", details.get("duration")), ("Plate", details.get("license_plate")), ("From", details.get("from")), ("To", details.get("to"))]: if v: rows.append(f'
{k}{v}
') elif category == "food": if details.get("restaurant"): rows.append(f'
Restaurant{details["restaurant"]}
') if details.get("items"): items_str = ", ".join(details["items"][:3]) rows.append(f'
Items{items_str}
') if details.get("delivery_fee"): rows.append(f'
Delivery{details["delivery_fee"]}
') elif category == "payment": for k, v in [("To", details.get("to")), ("From", details.get("from")), ("UPI Ref", details.get("upi_ref")), ("Status", details.get("status"))]: if v: rows.append(f'
{k}{v}
') elif category == "shopping": if details.get("items"): for item in details["items"][:4]: rows.append(f'
ยท{item}
') if details.get("delivery_date"): rows.append(f'
Delivery{details["delivery_date"]}
') else: # Generic: show snippet snippet = t.get("snippet", "")[:150] if snippet: rows.append(f'
{snippet}
') if not rows: snippet = t.get("snippet", "")[:150] rows.append(f'
{snippet or "No details extracted."}
') return "\n".join(rows) def _render_amounts(t: dict) -> str: amounts = t.get("amounts", []) if not amounts or len(amounts) <= 1: return "" items = "".join(f'{a}' for a in amounts[:6]) return f'
All Amounts
{items}
' def _render_image_analysis(t: dict) -> str: analyses = t.get("image_analyses", []) if not analyses: return "" items = [] for a in analyses[:2]: summary = a.get("summary", "")[:120] doc_type = a.get("doc_type", "unknown") items.append(f'
{doc_type} {summary}
') return f'
๐Ÿ–ผ Image Analysis (Ollama)
{"".join(items)}
' def _render_tags(t: dict) -> str: cfg = t["category_config"] return f'{cfg["icon"]} {cfg["label"]}' def _render_amounts_summary(t: dict) -> str: return t.get("total") or (t.get("amounts", ["โ€”"])[0] if t.get("amounts") else "โ€”") # โ”€โ”€ Summary strip โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def _render_summary(summary: dict) -> str: total = summary.get("total_spend", 0) count = summary.get("count", 0) by_cat = summary.get("by_category", {}) top_cat = max(by_cat, key=by_cat.get) if by_cat else "โ€”" top_spend = by_cat.get(top_cat, 0) return f"""
Total Spend
โ‚น{total:,.2f}
Across {count} transactions
Transactions
{count}
Gmail emails parsed
Top Category
{top_cat.title()}
โ‚น{top_spend:,.2f} spent
Categories
{len(by_cat)}
Detected
""" # โ”€โ”€ Spend chart โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def _render_chart(summary: dict) -> str: by_cat = summary.get("by_category", {}) if not by_cat: return "" total = sum(by_cat.values()) or 1 from environments.trace_env.tools.transaction_parser import CATEGORY_CONFIG bars = "" for cat, amount in list(by_cat.items())[:8]: cfg = CATEGORY_CONFIG.get(cat, CATEGORY_CONFIG["unknown"]) pct = (amount / total) * 100 bars += f"""
{cfg['icon']} {cat.title()}
โ‚น{amount:,.0f}
""" return f"""
Spend by Category
{bars}
""" # โ”€โ”€ Episode bar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def _render_episode_bar(episode: dict) -> str: if not episode: return "" steps = ["PLAN", "RETRIEVE", "MEMORIZE", "VERIFY", "ANSWER"] current_step = episode.get("step", 1) reward = episode.get("reward", 0) chips = "" for i, s in enumerate(steps): if i < current_step: cls = "done" elif i == current_step: cls = "active" else: cls = "" sep = 'โ€บ' if i < len(steps) - 1 else "" chips += f'
{s}
{sep}' return f"""
Episode ยท Step {current_step} ยท Reward {reward:.3f}
{chips}
""" # โ”€โ”€ Insights panel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def _render_insights(transactions: list, summary: dict) -> str: insights = [] total = summary.get("total_spend", 0) if total > 0: insights.append(("๐Ÿ’ก", "good", f"Total retrievable spend: โ‚น{total:,.2f} across {summary.get('count', 0)} transactions.")) reimb = [t for t in transactions if t.get("reimbursable")] if reimb: reimb_total = sum( float(re.sub(r'[^\d.]', '', t["total"])) for t in reimb if t.get("total") and re.sub(r'[^\d.]', '', t["total"]).replace('.', '').isdigit() ) insights.append(("๐Ÿงพ", "good", f"{len(reimb)} reimbursable receipts found totaling โ‚น{reimb_total:,.2f}.")) by_cat = summary.get("by_category", {}) if by_cat: top = list(by_cat.items())[0] insights.append(("๐Ÿ“Š", "info", f"Highest spending category: {top[0].title()} at โ‚น{top[1]:,.2f}.")) cash_payments = [t for t in transactions if t.get("payment_method") == "Cash"] if cash_payments: insights.append(("โš ๏ธ", "warn", f"{len(cash_payments)} cash payment(s) detected โ€” no digital trail for these transactions.")) rows = "".join(f'
{ic}
{text}
' for ic, s, text in insights) return f"""
๐Ÿง  Trace Intelligence Insights
{rows}
""" # โ”€โ”€ CSS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def _css() -> str: return """ @import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Fraunces:ital,wght@0,300;0,600;1,300&display=swap'); *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } :root { --bg: #f0ebe3; --card: #ffffff; --border: #d4cec7; --ink: #1a1a1a; --muted: #6b6560; --cream: #f0ebe3; } body { font-family: 'DM Mono', monospace; background: var(--bg); color: var(--ink); } .app { max-width: 900px; margin: 0 auto; padding: 24px 20px; } /* Header */ .header { display: flex; justify-content: space-between; align-items: flex-end; border-bottom: 2px solid var(--ink); padding-bottom: 14px; margin-bottom: 20px; } .logo { font-family: 'Fraunces', serif; font-size: 26px; font-weight: 600; } .subtitle { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 1px; } .badge { font-size: 10px; background: var(--ink); color: white; padding: 4px 10px; border-radius: 2px; letter-spacing: 1px; } /* Prompt Bar */ .prompt-bar { margin-bottom: 20px; } .prompt-form { display: flex; gap: 8px; width: 100%; background: var(--card); padding: 6px; border: 1px solid var(--border); border-radius: 4px; align-items: center; box-shadow: 0 2px 8px rgba(0,0,0,0.03); } .prompt-input { flex: 1; border: none; outline: none; font-family: 'DM Mono', monospace; font-size: 13px; padding: 8px 10px; background: transparent; color: var(--ink); } .prompt-input::placeholder { color: var(--muted); opacity: 0.7; } .prompt-btn { background: var(--ink); color: white; border: none; border-radius: 2px; padding: 8px 16px; font-family: 'DM Mono', monospace; font-size: 12px; cursor: pointer; transition: background 0.2s; font-weight: 500; } .prompt-btn:hover { background: #333; } .prompt-clear { font-size: 11px; color: var(--muted); text-decoration: none; padding: 0 8px; } .prompt-clear:hover { color: var(--ink); text-decoration: underline; } /* Episode bar */ .episode-bar { background: var(--card); border: 1px solid var(--border); border-radius: 4px; padding: 12px 16px; margin-bottom: 20px; } .ep-label { font-size: 9px; text-transform: uppercase; letter-spacing: 1.5px; color: var(--muted); margin-bottom: 10px; } .steps-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } .step-chip { font-size: 10px; padding: 4px 10px; border-radius: 2px; border: 1px solid var(--border); background: var(--cream); } .step-chip.done { background: #d4f0e0; border-color: #1a7a4a; color: #1a7a4a; } .step-chip.active { background: #fdf0d5; border-color: #c97b1a; color: #c97b1a; } .step-sep { color: var(--border); font-size: 12px; } /* Summary */ .summary-strip { display: grid; grid-template-columns: repeat(4,1fr); gap: 12px; margin-bottom: 20px; } .sum-card { background: var(--card); border: 1px solid var(--border); border-radius: 4px; padding: 14px 16px; } .sum-card.total { border-top: 3px solid var(--ink); } .sum-label { font-size: 9px; text-transform: uppercase; letter-spacing: 1.5px; color: var(--muted); margin-bottom: 6px; } .sum-value { font-family: 'Fraunces', serif; font-size: 22px; font-weight: 600; line-height: 1; } .sum-sub { font-size: 10px; color: var(--muted); margin-top: 4px; } /* Chart */ .chart-section { margin-bottom: 24px; } .chart { background: var(--card); border: 1px solid var(--border); border-radius: 4px; padding: 16px; } .chart-row { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; } .chart-row:last-child { margin-bottom: 0; } .chart-label { width: 120px; font-size: 11px; flex-shrink: 0; } .chart-bar-wrap { flex: 1; height: 8px; background: var(--cream); border-radius: 2px; overflow: hidden; } .chart-bar { height: 100%; border-radius: 2px; transition: width 1s cubic-bezier(0.4,0,0.2,1); } .chart-amount { width: 80px; text-align: right; font-size: 11px; font-weight: 500; } /* Sheets Banner */ .sheets-banner { display: flex; align-items: center; gap: 14px; background: #e8f0fe; border: 1px solid #d2e3fc; border-radius: 4px; padding: 14px 18px; margin-bottom: 24px; cursor: pointer; transition: background 0.2s, transform 0.2s; } .sheets-banner:hover { background: #d2e3fc; transform: translateY(-1px); } .sheets-icon { font-size: 24px; } .sheets-text { flex: 1; } .sheets-title { font-family: 'Fraunces', serif; font-size: 16px; font-weight: 600; color: #174ea6; } .sheets-sub { font-size: 11px; color: #1967d2; margin-top: 4px; } .sheets-btn { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: #174ea6; background: rgba(23,78,166,0.1); padding: 6px 12px; border-radius: 2px; } /* Section title */ .section-title { display: flex; align-items: center; gap: 12px; font-size: 9px; text-transform: uppercase; letter-spacing: 2px; color: var(--muted); margin-bottom: 14px; } .section-line { flex: 1; height: 1px; background: var(--border); } .count { color: var(--ink); } /* Cards */ .cards { display: flex; flex-direction: column; gap: 16px; } .card { background: var(--card); border: 1px solid var(--border); border-radius: 4px; overflow: hidden; animation: slideIn 0.4s ease both; } @keyframes slideIn { from { opacity:0; transform: translateY(12px); } to { opacity:1; transform:translateY(0); } } .card-header { display: flex; justify-content: space-between; align-items: center; padding: 14px 18px; color: white; } .card-header-left { display: flex; align-items: center; gap: 12px; } .card-icon { font-size: 22px; } .card-vendor { font-family: 'Fraunces', serif; font-size: 16px; font-style: italic; } .card-meta { font-size: 10px; opacity: 0.65; margin-top: 2px; } .card-total { font-family: 'Fraunces', serif; font-size: 22px; font-weight: 600; text-align: right; } .order-id { font-size: 10px; opacity: 0.6; text-align: right; display: block; margin-top: 2px; } .card-subject { padding: 10px 18px; font-size: 11px; border-bottom: 1px solid var(--border); background: #fafaf8; color: var(--muted); } .card-body { display: grid; grid-template-columns: 1fr 1fr; } .card-section { padding: 14px 18px; border-right: 1px solid var(--border); } .card-section:last-child { border-right: none; } .section-header { font-size: 9px; text-transform: uppercase; letter-spacing: 1.5px; color: var(--muted); margin-bottom: 10px; } .detail-row { display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px dashed var(--border); font-size: 11px; } .detail-row:last-child { border-bottom: none; } .dk { color: var(--muted); } .dv { font-weight: 500; text-align: right; max-width: 60%; } .detail-snippet { font-size: 11px; color: var(--muted); line-height: 1.5; } .amounts-row { display: flex; flex-wrap: wrap; gap: 6px; } .amount-chip { font-size: 10px; background: #f0ebe3; border: 1px solid var(--border); padding: 3px 8px; border-radius: 2px; } .card-image-analysis { padding: 12px 18px; border-top: 1px solid var(--border); background: #fafaf8; } .img-analysis { font-size: 11px; color: var(--muted); line-height: 1.5; margin-top: 6px; } .img-type { background: var(--ink); color: white; font-size: 9px; padding: 1px 6px; border-radius: 2px; margin-right: 6px; text-transform: uppercase; } .card-footer { display: flex; flex-wrap: wrap; gap: 6px; padding: 10px 18px; border-top: 1px solid var(--border); background: #fafaf8; } .tag { font-size: 9px; padding: 3px 8px; border-radius: 2px; text-transform: uppercase; letter-spacing: 0.5px; border: 1px solid var(--border); background: var(--cream); color: var(--muted); } .tag.reimb { background: #d4f0e0; border-color: #1a7a4a; color: #1a7a4a; } .tag.pay { background: #fdf0d5; border-color: #c97b1a; color: #c97b1a; } /* Insights */ .insights-panel { background: var(--card); border: 1px solid var(--border); border-radius: 4px; overflow: hidden; margin-top: 24px; } .insights-header { background: var(--ink); color: white; padding: 12px 18px; font-size: 10px; text-transform: uppercase; letter-spacing: 2px; } .insight-row { display: flex; align-items: flex-start; gap: 10px; padding: 12px 18px; border-top: 1px solid var(--border); font-size: 11px; line-height: 1.6; } .insight-icon { width: 24px; height: 24px; border-radius: 2px; display: flex; align-items: center; justify-content: center; font-size: 13px; flex-shrink: 0; } .insight-icon.good { background: #d4f0e0; } .insight-icon.warn { background: #fdf0d5; } .insight-icon.info { background: #e8f0fe; } @media (max-width: 600px) { .summary-strip { grid-template-columns: 1fr 1fr; } .card-body { grid-template-columns: 1fr; } .card-section { border-right: none; border-bottom: 1px solid var(--border); } } """ # โ”€โ”€ JS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def _js(chart_data: dict) -> str: return f""" // Animate chart bars on load document.addEventListener('DOMContentLoaded', function() {{ const bars = document.querySelectorAll('.chart-bar'); bars.forEach(b => {{ const w = b.style.width; b.style.width = '0%'; setTimeout(() => {{ b.style.width = w; }}, 300); }}); }}); """ def _build_chart_data(summary: dict) -> dict: return summary.get("by_category", {}) def _clean_email(email_str: str) -> str: """Extract just the email address from 'Name ' format.""" match = re.search(r'<(.+?)>', email_str) if match: return match.group(1) return email_str[:30]