chore: reorganize into polyglot monorepo (workshop)
- Move bigmind/ -> mcp/bigmind/ - Move webscraper/ -> mcp/webscraper/ - Move mss-failsafe/ -> java/mss-failsafe/ - Move Wellmann-Shop/ -> java/wellmann-shop/ (normalize to kebab-case) - Add .roo/ IDE config files to tracking - Add plans/REPO_STRATEGY.md (monorepo strategy document) - Expand .gitignore: Java/Maven, Node/TS, coverage, uv.lock - Rewrite README.md as navigation index - Update .roo/mcp.json webscraper path to mcp/webscraper/
This commit is contained in:
@@ -0,0 +1,717 @@
|
||||
"""BigMind Profile Page Renderers — HTML generation for the profile web page.
|
||||
|
||||
All rendering functions live here so web.py stays thin (Flask server only).
|
||||
"""
|
||||
|
||||
import html as _html
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
|
||||
def _render_achievements(achievements: list) -> str:
|
||||
"""Render the Achievement Gallery grid."""
|
||||
if not achievements:
|
||||
return '<p class="muted">No achievements data.</p>'
|
||||
|
||||
unlocked_count = sum(1 for a in achievements if a["unlocked"])
|
||||
total = len(achievements)
|
||||
|
||||
def _card(a: dict) -> str:
|
||||
locked_cls = "" if a["unlocked"] else " locked"
|
||||
date_html = (
|
||||
f'<div class="ach-date">{a["unlocked_at"]}</div>'
|
||||
if a["unlocked"] and a.get("unlocked_at") else ""
|
||||
)
|
||||
countdown_html = ""
|
||||
if not a["unlocked"] and a.get("extra"):
|
||||
countdown_html = f'<div class="ach-countdown">{a["extra"]}</div>'
|
||||
|
||||
# Escape values for data attributes
|
||||
def _esc(s):
|
||||
return (s or "").replace('"', """).replace("'", "'")
|
||||
|
||||
lock_overlay = "" if a["unlocked"] else '<span class="ach-lock">🔒</span>'
|
||||
|
||||
return (
|
||||
f'<div class="ach-card{locked_cls} ach-trigger"'
|
||||
f' data-icon="{_esc(a["icon"])}"'
|
||||
f' data-name="{_esc(a["name"])}"'
|
||||
f' data-desc="{_esc(a["description"])}"'
|
||||
f' data-unlocked="{1 if a["unlocked"] else 0}"'
|
||||
f' data-date="{_esc(a.get("unlocked_at") or "")}"'
|
||||
f' data-condition="{_esc(a.get("condition") or "")}"'
|
||||
f' data-extra="{_esc(a.get("extra") or "")}">'
|
||||
f'<div class="ach-icon">{a["icon"]}{lock_overlay}</div>'
|
||||
f'<div class="ach-name">{a["name"]}</div>'
|
||||
f'{date_html}'
|
||||
f'{countdown_html}'
|
||||
f'</div>'
|
||||
)
|
||||
|
||||
cards_html = "".join(_card(a) for a in achievements)
|
||||
return (
|
||||
f'<p class="ach-summary">{unlocked_count} / {total} achievements unlocked</p>'
|
||||
f'<div class="ach-grid">{cards_html}</div>'
|
||||
)
|
||||
|
||||
|
||||
def _render_html(data: dict) -> str:
|
||||
badges_html = "".join(
|
||||
f'<div class="badge" title="{b["description"]}">'
|
||||
f'<span class="badge-emoji">{b["emoji"]}</span>'
|
||||
f'<span class="badge-label">{b["label"]}</span>'
|
||||
f'</div>'
|
||||
for b in data["earned_badges"]
|
||||
) or '<p class="muted">No badges yet — keep going!</p>'
|
||||
|
||||
topics_html = "".join(
|
||||
f'<div class="topic-bar">'
|
||||
f'<span class="topic-name">{t}</span>'
|
||||
f'<div class="topic-track"><div class="topic-fill" style="width:{min(100, count*8)}%"></div></div>'
|
||||
f'<span class="topic-count">{count}</span>'
|
||||
f'</div>'
|
||||
for t, count in data["top_topics"]
|
||||
) or '<p class="muted">No topics recorded yet.</p>'
|
||||
|
||||
def _fmt_tokens(n: int) -> str:
|
||||
"""Format a token count as a human-readable string (e.g. 1.2M, 250K)."""
|
||||
if n >= 1_000_000:
|
||||
return f"{n / 1_000_000:.1f}M"
|
||||
if n >= 1_000:
|
||||
return f"{n / 1_000:.0f}K"
|
||||
return str(n)
|
||||
|
||||
def _session_row(s: dict) -> str:
|
||||
tok = s.get("session_tokens_saved") or 0
|
||||
tok_html = (
|
||||
f'<span title="{tok:,} tokens saved this session" '
|
||||
f'style="color:var(--green);font-size:10px;white-space:nowrap;flex-shrink:0">'
|
||||
f'💰 {_fmt_tokens(tok)}</span>'
|
||||
) if tok > 0 else ""
|
||||
return (
|
||||
f'<div class="session-row session-toggle" data-id="{s.get("id","")}" data-has-tier2="{1 if s.get("has_tier2") else 0}">'
|
||||
f'<span class="session-date">{(s.get("started_at") or "")[:10]}</span>'
|
||||
f'<span class="session-liner">{_html.escape(s.get("one_liner", "")[:90])}</span>'
|
||||
f'{tok_html}'
|
||||
f'<span class="session-arrow" style="color:var(--muted);margin-left:auto;font-size:11px;flex-shrink:0">{"📄 " if s.get("has_tier2") else ""}▶</span>'
|
||||
f'</div>'
|
||||
f'<div class="session-expand" id="exp-{s.get("id","")}">'
|
||||
f'<em style="color:var(--muted)">Click to load…</em>'
|
||||
f'</div>'
|
||||
)
|
||||
|
||||
sessions_html = "".join(_session_row(s) for s in data["recent_sessions"]) or '<p class="muted">No sessions yet.</p>'
|
||||
|
||||
heatmap_html = _render_heatmap(data["heatmap"])
|
||||
|
||||
hyp_accuracy = ""
|
||||
if data["total_hypotheses"] > 0:
|
||||
pct = round(data["confirmed_hypotheses"] / data["total_hypotheses"] * 100)
|
||||
hyp_accuracy = f'{pct}% accuracy ({data["confirmed_hypotheses"]}/{data["total_hypotheses"]} confirmed)'
|
||||
else:
|
||||
hyp_accuracy = "No hypotheses yet"
|
||||
|
||||
status_emoji = {"open": "💭", "confirmed": "✅", "refuted": "❌", "abandoned": "🚫"}
|
||||
open_hyps = [h for h in data["hypotheses"] if h["status"] == "open"]
|
||||
concluded_hyps = [h for h in data["hypotheses"] if h["status"] != "open"]
|
||||
|
||||
def _hyp_card(h):
|
||||
st = h["status"]
|
||||
conf = round(h["confidence"] * 100)
|
||||
date = (h.get("created_at") or "")[:10]
|
||||
res = f'<div class="hyp-resolution">→ {_html.escape(h["resolution"])}</div>' if h.get("resolution") else ""
|
||||
return (
|
||||
f'<div class="hyp-card {st}">'
|
||||
f'<div class="hyp-header">'
|
||||
f'<span class="hyp-status {st}">{status_emoji.get(st, "")} {st}</span>'
|
||||
f'<span class="hyp-date">{date}</span>'
|
||||
f'<span class="hyp-confidence">{conf}% confidence</span>'
|
||||
f'</div>'
|
||||
f'<div class="hyp-text">{_html.escape(h["hypothesis"])}</div>'
|
||||
f'{res}'
|
||||
f'</div>'
|
||||
)
|
||||
|
||||
open_hyps_html = "".join(_hyp_card(h) for h in open_hyps) or '<p class="muted">No open hypotheses.</p>'
|
||||
concluded_hyps_html = "".join(_hyp_card(h) for h in concluded_hyps) or '<p class="muted">No concluded hypotheses yet.</p>'
|
||||
|
||||
role_html = f'<p class="role">{data["role"]}</p>' if data["role"] else ""
|
||||
since_html = f'Active since <strong>{data["first_session_date"]}</strong>' if data["first_session_date"] else "No sessions yet"
|
||||
|
||||
total_tokens_saved = (data.get("token_stats") or {}).get("total_tokens_saved") or 0
|
||||
total_tokens_fmt = _fmt_tokens(total_tokens_saved)
|
||||
|
||||
live_sessions_html = _render_live_sessions(data.get("live_sessions", []))
|
||||
achievements_html = _render_achievements(data.get("achievements", []))
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="refresh" content="30">
|
||||
<title>🧠 Lumen — BigMind Profile</title>
|
||||
<style>
|
||||
:root {{
|
||||
--bg: #0d1117; --surface: #161b22; --border: #30363d;
|
||||
--text: #e6edf3; --muted: #8b949e; --accent: #58a6ff;
|
||||
--green: #3fb950; --yellow: #d29922; --red: #f85149;
|
||||
--purple: #bc8cff; --orange: #ffa657;
|
||||
}}
|
||||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||
body {{ background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 14px; line-height: 1.6; }}
|
||||
a {{ color: var(--accent); text-decoration: none; }}
|
||||
.container {{ max-width: 960px; margin: 0 auto; padding: 32px 16px; }}
|
||||
|
||||
/* Header */
|
||||
.header {{ display: flex; align-items: center; gap: 24px; margin-bottom: 32px; padding-bottom: 24px; border-bottom: 1px solid var(--border); }}
|
||||
.avatar {{ width: 80px; height: 80px; border-radius: 50%; background: linear-gradient(135deg, var(--accent), var(--purple)); display: flex; align-items: center; justify-content: center; font-size: 36px; flex-shrink: 0; }}
|
||||
.header-info h1 {{ font-size: 24px; font-weight: 700; }}
|
||||
.role {{ color: var(--muted); font-size: 13px; margin-top: 2px; }}
|
||||
.since {{ color: var(--muted); font-size: 12px; margin-top: 6px; }}
|
||||
|
||||
/* Stats grid */
|
||||
.stats-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-bottom: 28px; }}
|
||||
.stat-card {{ background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 16px; text-align: center; }}
|
||||
.stat-value {{ font-size: 28px; font-weight: 700; color: var(--accent); }}
|
||||
.stat-label {{ font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 4px; }}
|
||||
|
||||
/* Sections */
|
||||
.section {{ background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 20px; margin-bottom: 20px; }}
|
||||
.section h2 {{ font-size: 15px; font-weight: 600; margin-bottom: 16px; color: var(--text); }}
|
||||
.muted {{ color: var(--muted); font-size: 13px; }}
|
||||
|
||||
/* Badges */
|
||||
.badges {{ display: flex; flex-wrap: wrap; gap: 10px; }}
|
||||
.badge {{ background: var(--bg); border: 1px solid var(--border); border-radius: 20px; padding: 6px 14px; display: flex; align-items: center; gap: 6px; cursor: default; transition: border-color 0.2s; }}
|
||||
.badge:hover {{ border-color: var(--accent); }}
|
||||
.badge-emoji {{ font-size: 16px; }}
|
||||
.badge-label {{ font-size: 12px; font-weight: 500; }}
|
||||
|
||||
/* Heatmap */
|
||||
.heatmap {{ overflow-x: auto; }}
|
||||
.heatmap-grid {{ display: flex; gap: 3px; }}
|
||||
.heatmap-week {{ display: flex; flex-direction: column; gap: 3px; }}
|
||||
.heatmap-cell {{ width: 11px; height: 11px; border-radius: 2px; background: var(--border); }}
|
||||
.heatmap-cell.l1 {{ background: #0e4429; }}
|
||||
.heatmap-cell.l2 {{ background: #006d32; }}
|
||||
.heatmap-cell.l3 {{ background: #26a641; }}
|
||||
.heatmap-cell.l4 {{ background: #39d353; }}
|
||||
.heatmap-legend {{ display: flex; align-items: center; gap: 4px; margin-top: 8px; font-size: 11px; color: var(--muted); }}
|
||||
.heatmap-legend .heatmap-cell {{ flex-shrink: 0; }}
|
||||
|
||||
/* Topics */
|
||||
.topic-bar {{ display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }}
|
||||
.topic-name {{ width: 120px; font-size: 12px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }}
|
||||
.topic-track {{ flex: 1; height: 8px; background: var(--border); border-radius: 4px; overflow: hidden; }}
|
||||
.topic-fill {{ height: 100%; background: var(--accent); border-radius: 4px; }}
|
||||
.topic-count {{ width: 24px; text-align: right; font-size: 12px; color: var(--muted); }}
|
||||
|
||||
/* Sessions feed */
|
||||
.session-row {{ display: flex; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px; }}
|
||||
.session-row:last-child {{ border-bottom: none; }}
|
||||
.session-date {{ color: var(--muted); white-space: nowrap; flex-shrink: 0; }}
|
||||
.session-liner {{ color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
|
||||
|
||||
/* Thought journal */
|
||||
.hyp-stat {{ font-size: 20px; font-weight: 700; color: var(--green); }}
|
||||
.hyp-list {{ margin-top: 16px; display: flex; flex-direction: column; gap: 12px; }}
|
||||
.hyp-card {{ background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 12px 14px; }}
|
||||
.hyp-card.open {{ border-left: 3px solid var(--yellow); }}
|
||||
.hyp-card.confirmed {{ border-left: 3px solid var(--green); }}
|
||||
.hyp-card.refuted {{ border-left: 3px solid var(--red); }}
|
||||
.hyp-card.abandoned {{ border-left: 3px solid var(--muted); }}
|
||||
.hyp-header {{ display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }}
|
||||
.hyp-status {{ font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }}
|
||||
.hyp-status.open {{ color: var(--yellow); }}
|
||||
.hyp-status.confirmed {{ color: var(--green); }}
|
||||
.hyp-status.refuted {{ color: var(--red); }}
|
||||
.hyp-status.abandoned {{ color: var(--muted); }}
|
||||
.hyp-confidence {{ font-size: 11px; color: var(--muted); margin-left: auto; }}
|
||||
.hyp-date {{ font-size: 11px; color: var(--muted); }}
|
||||
.hyp-text {{ font-size: 13px; color: var(--text); line-height: 1.5; }}
|
||||
.hyp-resolution {{ font-size: 12px; color: var(--muted); margin-top: 6px; padding-top: 6px; border-top: 1px solid var(--border); font-style: italic; }}
|
||||
.pagination {{ display: flex; align-items: center; gap: 8px; margin-top: 14px; }}
|
||||
.page-btn {{ background: var(--surface); border: 1px solid var(--border); border-radius: 4px; color: var(--text); padding: 4px 10px; font-size: 12px; cursor: pointer; }}
|
||||
.page-btn:hover {{ border-color: var(--accent); color: var(--accent); }}
|
||||
.page-btn:disabled {{ opacity: 0.3; cursor: default; border-color: var(--border); color: var(--muted); }}
|
||||
.page-info {{ font-size: 12px; color: var(--muted); }}
|
||||
|
||||
/* Footer */
|
||||
.footer {{ text-align: center; color: var(--muted); font-size: 11px; margin-top: 32px; }}
|
||||
|
||||
/* Two-col layout */
|
||||
.two-col {{ display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }}
|
||||
@media (max-width: 600px) {{ .two-col {{ grid-template-columns: 1fr; }} }}
|
||||
|
||||
/* Live Sessions panel */
|
||||
.live-dot {{ display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 6px; flex-shrink: 0; }}
|
||||
.live-dot.green {{ background: var(--green); box-shadow: 0 0 6px var(--green); }}
|
||||
.live-dot.amber {{ background: var(--yellow); }}
|
||||
.live-dot.grey {{ background: var(--muted); }}
|
||||
.live-session-row {{ display: flex; flex-direction: column; gap: 4px; padding: 10px 0; border-bottom: 1px solid var(--border); }}
|
||||
.live-session-row:last-child {{ border-bottom: none; }}
|
||||
.live-session-header {{ display: flex; align-items: center; gap: 8px; font-size: 13px; }}
|
||||
.live-ide {{ font-weight: 600; color: var(--accent); }}
|
||||
.live-idle {{ font-size: 11px; color: var(--muted); margin-left: auto; }}
|
||||
.live-focus {{ font-size: 12px; color: var(--text); padding-left: 16px; }}
|
||||
.live-files {{ font-size: 11px; color: var(--muted); padding-left: 16px; }}
|
||||
.live-header-summary {{ font-size: 12px; color: var(--muted); margin-bottom: 10px; }}
|
||||
|
||||
/* Session Explorer */
|
||||
.session-toggle {{ cursor: pointer; user-select: none; }}
|
||||
.session-toggle:hover .session-liner {{ color: var(--accent); }}
|
||||
.session-expand {{ display: none; padding: 10px 12px; background: var(--bg); border-left: 2px solid var(--border); margin: 4px 0 4px 0; border-radius: 0 4px 4px 0; font-size: 12px; line-height: 1.6; }}
|
||||
.session-expand.open {{ display: block; }}
|
||||
.session-expand h4 {{ color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; margin-top: 8px; }}
|
||||
.session-expand h4:first-child {{ margin-top: 0; }}
|
||||
|
||||
/* Achievement Gallery (Feature 4) */
|
||||
.ach-summary {{ font-size: 12px; color: var(--muted); margin-bottom: 14px; }}
|
||||
.ach-grid {{ display: flex; flex-wrap: wrap; gap: 12px; }}
|
||||
.ach-card {{
|
||||
position: relative; background: var(--bg); border: 1px solid var(--border);
|
||||
border-radius: 10px; padding: 14px 10px 10px; width: 90px; text-align: center;
|
||||
cursor: default; transition: border-color 0.2s, transform 0.15s;
|
||||
}}
|
||||
.ach-card:not(.locked):hover {{ border-color: var(--accent); transform: translateY(-2px); }}
|
||||
.ach-card.locked {{ opacity: 0.35; filter: grayscale(0.6); }}
|
||||
.ach-card.locked:hover {{ opacity: 0.55; border-color: var(--muted); }}
|
||||
.ach-icon {{ font-size: 28px; line-height: 1; margin-bottom: 6px; position: relative; display: inline-block; }}
|
||||
.ach-lock {{ position: absolute; bottom: -4px; right: -6px; font-size: 12px; }}
|
||||
.ach-name {{ font-size: 10px; font-weight: 600; color: var(--text); line-height: 1.3; word-break: break-word; }}
|
||||
.ach-date {{ font-size: 9px; color: var(--muted); margin-top: 3px; }}
|
||||
.ach-countdown {{ font-size: 9px; color: var(--yellow); margin-top: 3px; font-weight: 500; }}
|
||||
/* Achievement popup panel */
|
||||
#ach-popup {{
|
||||
display: none; position: fixed; z-index: 200;
|
||||
background: #1c2128; border: 1px solid var(--border);
|
||||
border-radius: 10px; padding: 16px 18px; width: 260px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.6); pointer-events: none;
|
||||
transition: opacity 0.12s ease;
|
||||
}}
|
||||
#ach-popup.pinned {{ pointer-events: auto; }}
|
||||
#ach-popup.visible {{ display: block; }}
|
||||
.ap-icon {{ font-size: 40px; text-align: center; margin-bottom: 8px; }}
|
||||
.ap-name {{ font-size: 15px; font-weight: 700; text-align: center; margin-bottom: 6px; }}
|
||||
.ap-badge {{
|
||||
display: inline-block; font-size: 11px; font-weight: 600; padding: 2px 8px;
|
||||
border-radius: 12px; margin: 0 auto 10px; text-align: center; width: 100%;
|
||||
}}
|
||||
.ap-badge.unlocked {{ background: rgba(63,185,80,.15); color: var(--green); }}
|
||||
.ap-badge.locked {{ background: rgba(139,148,158,.12); color: var(--muted); }}
|
||||
.ap-desc {{ font-size: 12px; color: var(--text); line-height: 1.5; margin-bottom: 8px; }}
|
||||
.ap-meta {{ font-size: 11px; color: var(--muted); border-top: 1px solid var(--border); padding-top: 8px; }}
|
||||
.ap-close {{ position: absolute; top: 8px; right: 10px; background: none; border: none;
|
||||
color: var(--muted); font-size: 14px; cursor: pointer; line-height: 1; }}
|
||||
.ap-close:hover {{ color: var(--text); }}
|
||||
|
||||
/* Search widget */
|
||||
.search-bar {{ display: flex; gap: 8px; margin-bottom: 14px; }}
|
||||
.search-input {{ flex: 1; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); padding: 8px 12px; font-size: 13px; outline: none; }}
|
||||
.search-input:focus {{ border-color: var(--accent); }}
|
||||
.search-btn {{ background: var(--accent); color: var(--bg); border: none; border-radius: 6px; padding: 8px 16px; font-size: 13px; cursor: pointer; font-weight: 600; }}
|
||||
.search-btn:hover {{ opacity: 0.85; }}
|
||||
.search-results {{ display: flex; flex-direction: column; gap: 8px; min-height: 40px; }}
|
||||
.search-result-item {{ background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px; }}
|
||||
.search-result-type {{ font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }}
|
||||
.search-result-text {{ font-size: 12px; color: var(--text); line-height: 1.5; }}
|
||||
.search-result-date {{ font-size: 11px; color: var(--muted); margin-top: 4px; }}
|
||||
mark {{ background: rgba(88,166,255,0.25); color: var(--accent); border-radius: 2px; padding: 0 2px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="avatar">🧠</div>
|
||||
<div class="header-info">
|
||||
<h1>Lumen</h1>
|
||||
<p class="role">AI Assistant · <span style="color:var(--muted)">{data["display_name"]}'s BigMind</span></p>
|
||||
{role_html}
|
||||
<p class="since">{since_html} · Last seen: <strong>{data["last_seen"] or "—"}</strong></p>
|
||||
<p class="since">DB: <code>{data["db_size_kb"]} KB</code> · {data["open_sessions"]} session(s) open now</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card"><div class="stat-value">{data["total_sessions"]}</div><div class="stat-label">Sessions</div></div>
|
||||
<div class="stat-card"><div class="stat-value">{data["active_days"]}</div><div class="stat-label">Active Days</div></div>
|
||||
<div class="stat-card"><div class="stat-value">{data["total_facts"]}</div><div class="stat-label">Facts Stored</div></div>
|
||||
<div class="stat-card"><div class="stat-value">{data["total_chunks"]}</div><div class="stat-label">Memory Chunks</div></div>
|
||||
<div class="stat-card"><div class="stat-value">{data["total_hypotheses"]}</div><div class="stat-label">Hypotheses</div></div>
|
||||
<div class="stat-card"><div class="stat-value">{sum(1 for a in data.get("achievements",[]) if a["unlocked"])}</div><div class="stat-label">Achievements</div></div>
|
||||
<div class="stat-card" title="Total tokens saved via memory hits, grep, targeted reads"><div class="stat-value" style="color:var(--green)">{total_tokens_fmt}</div><div class="stat-label">Tokens Saved</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Achievement Gallery (Feature 4) -->
|
||||
<div class="section">
|
||||
<h2>🏆 Achievements</h2>
|
||||
{achievements_html}
|
||||
</div>
|
||||
|
||||
<!-- Activity heatmap -->
|
||||
<div class="section">
|
||||
<h2>📅 Activity — Last 52 Weeks</h2>
|
||||
<div class="heatmap">{heatmap_html}</div>
|
||||
</div>
|
||||
|
||||
<!-- Two-col: topics + stats -->
|
||||
<div class="two-col">
|
||||
<div class="section">
|
||||
<h2>🏷️ Top Topics</h2>
|
||||
{topics_html}
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>💭 Thought Journal</h2>
|
||||
<div class="hyp-stat">{hyp_accuracy}</div>
|
||||
<p class="muted" style="margin-top:8px">{data["open_hypotheses"]} hypothesis(es) still open</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Thought Journal -->
|
||||
<div class="section">
|
||||
<h2>💭 Open Thoughts</h2>
|
||||
<div class="hyp-list" id="open-hyps">{open_hyps_html}</div>
|
||||
<div class="pagination" id="open-pager"></div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>📖 Concluded Thoughts</h2>
|
||||
<div class="hyp-list" id="concluded-hyps">{concluded_hyps_html}</div>
|
||||
<div class="pagination" id="concluded-pager"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function paginate(listId, pagerId, pageSize) {{
|
||||
const list = document.getElementById(listId);
|
||||
const pager = document.getElementById(pagerId);
|
||||
const cards = Array.from(list.children);
|
||||
if (cards.length <= pageSize) return;
|
||||
let page = 0;
|
||||
const total = Math.ceil(cards.length / pageSize);
|
||||
function render() {{
|
||||
cards.forEach((c, i) => c.style.display = (i >= page*pageSize && i < (page+1)*pageSize) ? '' : 'none');
|
||||
pager.innerHTML =
|
||||
`<button class="page-btn" ${{page===0?'disabled':''}}>←</button>` +
|
||||
`<span class="page-info">Page ${{page+1}} of ${{total}}</span>` +
|
||||
`<button class="page-btn" ${{page===total-1?'disabled':''}}>→</button>`;
|
||||
pager.querySelectorAll('.page-btn')[0].onclick = () => {{ if(page>0){{page--;render();}} }};
|
||||
pager.querySelectorAll('.page-btn')[1].onclick = () => {{ if(page<total-1){{page++;render();}} }};
|
||||
}}
|
||||
render();
|
||||
}}
|
||||
paginate('open-hyps', 'open-pager', 5);
|
||||
paginate('concluded-hyps', 'concluded-pager', 5);
|
||||
</script>
|
||||
|
||||
<!-- Live Sessions (Feature 7) -->
|
||||
<div class="section">
|
||||
<h2>🔴 Live Sessions</h2>
|
||||
{live_sessions_html}
|
||||
</div>
|
||||
|
||||
<!-- Ask Lumen Search (Feature 3) -->
|
||||
<div class="section">
|
||||
<h2>🔍 Search Lumen's Memory</h2>
|
||||
<div class="search-bar">
|
||||
<input class="search-input" id="lumen-search" type="text" placeholder="Search facts, sessions, memory chunks…" autocomplete="off">
|
||||
<button class="search-btn" onclick="runSearch()">Ask</button>
|
||||
</div>
|
||||
<div class="search-results" id="search-results"></div>
|
||||
</div>
|
||||
|
||||
<!-- Recent sessions (Feature 2: click-to-expand) -->
|
||||
<div class="section">
|
||||
<h2>📖 Recent Sessions</h2>
|
||||
{sessions_html}
|
||||
</div>
|
||||
|
||||
<!-- Session + Search JS placed HERE so it runs after all DOM elements exist -->
|
||||
<script>
|
||||
// ── Session click-to-expand ───────────────────────────────────────────────
|
||||
document.querySelectorAll('.session-toggle').forEach(function(row) {{
|
||||
row.addEventListener('click', function() {{
|
||||
var sid = row.dataset.id;
|
||||
var expDiv = document.getElementById('exp-' + sid);
|
||||
if (!expDiv) return;
|
||||
var isOpen = expDiv.classList.contains('open');
|
||||
var arrow = row.querySelector('.session-arrow');
|
||||
if (isOpen) {{
|
||||
expDiv.classList.remove('open');
|
||||
if (arrow) arrow.textContent = (row.dataset.hasTier2==='1' ? '📄 ' : '') + '▶';
|
||||
return;
|
||||
}}
|
||||
expDiv.classList.add('open');
|
||||
if (arrow) arrow.textContent = (row.dataset.hasTier2==='1' ? '📄 ' : '') + '▼';
|
||||
if (expDiv.dataset.loaded) return;
|
||||
expDiv.dataset.loaded = '1';
|
||||
fetch('/api/session/' + sid)
|
||||
.then(function(r) {{ return r.json(); }})
|
||||
.then(function(d) {{
|
||||
if (!d || (!d.summary && !d.error)) {{
|
||||
expDiv.innerHTML = '<em style="color:var(--muted)">No detailed summary for this session.</em>';
|
||||
return;
|
||||
}}
|
||||
if (d.error) {{
|
||||
expDiv.innerHTML = '<em style="color:var(--muted)">' + d.error + '</em>';
|
||||
return;
|
||||
}}
|
||||
var html = '';
|
||||
if (d.summary) {{
|
||||
html += '<h4>📋 Summary</h4><div style="color:var(--text)">' + d.summary + '</div>';
|
||||
}}
|
||||
if (d.key_facts) {{
|
||||
html += '<h4>🔖 Key facts</h4><div style="color:var(--muted)">' + d.key_facts + '</div>';
|
||||
}}
|
||||
if (d.code_refs) {{
|
||||
html += '<h4>📁 Code refs</h4><div style="color:var(--muted)">' + d.code_refs + '</div>';
|
||||
}}
|
||||
expDiv.innerHTML = html || '<em style="color:var(--muted)">No detailed summary for this session.</em>';
|
||||
}})
|
||||
.catch(function() {{
|
||||
expDiv.innerHTML = '<em style="color:var(--red)">Failed to load session detail.</em>';
|
||||
}});
|
||||
}});
|
||||
}});
|
||||
|
||||
// ── Ask Lumen search ──────────────────────────────────────────────────────
|
||||
var _searchTimer = null;
|
||||
var _searchEl = document.getElementById('lumen-search');
|
||||
if (_searchEl) {{
|
||||
_searchEl.addEventListener('keydown', function(e) {{
|
||||
if (e.key === 'Enter') {{ clearTimeout(_searchTimer); runSearch(); }}
|
||||
}});
|
||||
_searchEl.addEventListener('input', function() {{
|
||||
clearTimeout(_searchTimer);
|
||||
_searchTimer = setTimeout(runSearch, 400);
|
||||
}});
|
||||
}}
|
||||
|
||||
function runSearch() {{
|
||||
var q = (_searchEl || document.getElementById('lumen-search')).value.trim();
|
||||
var out = document.getElementById('search-results');
|
||||
if (!q) {{ out.innerHTML = ''; return; }}
|
||||
out.innerHTML = '<p class="muted">Searching…</p>';
|
||||
fetch('/api/search?q=' + encodeURIComponent(q))
|
||||
.then(function(r) {{ return r.json(); }})
|
||||
.then(function(results) {{
|
||||
if (!results || results.length === 0) {{
|
||||
out.innerHTML = '<p class="muted">Nothing in memory about that yet.</p>';
|
||||
return;
|
||||
}}
|
||||
var icons = {{ fact: '📌', chunk: '💬', session: '📅' }};
|
||||
out.innerHTML = results.map(function(r) {{
|
||||
var text = r.content || '';
|
||||
var highlighted = text.replace(
|
||||
new RegExp('(' + q.replace(/[.*+?^${{}}()|[\\]\\\\]/g, '\\\\$&') + ')', 'gi'),
|
||||
'<mark>$1</mark>'
|
||||
);
|
||||
return '<div class="search-result-item">' +
|
||||
'<div class="search-result-type">' + (icons[r.type] || '🔍') + ' ' + r.type + '</div>' +
|
||||
'<div class="search-result-text">' + highlighted + '</div>' +
|
||||
(r.date ? '<div class="search-result-date">' + r.date + '</div>' : '') +
|
||||
'</div>';
|
||||
}}).join('');
|
||||
}})
|
||||
.catch(function() {{
|
||||
out.innerHTML = '<p style="color:var(--red)">Search failed.</p>';
|
||||
}});
|
||||
}}
|
||||
</script>
|
||||
|
||||
|
||||
<div class="footer">BigMind · {data["generated_at"]} · auto-refreshes every 30s</div>
|
||||
</div>
|
||||
|
||||
<!-- Achievement popup (shared, reused for every card) -->
|
||||
<div id="ach-popup">
|
||||
<button class="ap-close" id="ach-popup-close" title="Close">✕</button>
|
||||
<div class="ap-icon" id="ap-icon"></div>
|
||||
<div class="ap-name" id="ap-name"></div>
|
||||
<div class="ap-badge" id="ap-badge"></div>
|
||||
<div class="ap-desc" id="ap-desc"></div>
|
||||
<div class="ap-meta" id="ap-meta"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── Achievement popup (hover + click) ─────────────────────────────────────
|
||||
(function() {{
|
||||
var popup = document.getElementById('ach-popup');
|
||||
var pinned = false; // true = user clicked, popup stays until dismissed
|
||||
|
||||
function showPopup(card, pin) {{
|
||||
var d = card.dataset;
|
||||
document.getElementById('ap-icon').textContent = d.icon;
|
||||
document.getElementById('ap-name').textContent = d.name;
|
||||
var badge = document.getElementById('ap-badge');
|
||||
if (d.unlocked === '1') {{
|
||||
badge.textContent = '✅ Unlocked';
|
||||
badge.className = 'ap-badge unlocked';
|
||||
}} else {{
|
||||
badge.textContent = '🔒 Locked';
|
||||
badge.className = 'ap-badge locked';
|
||||
}}
|
||||
document.getElementById('ap-desc').textContent = d.desc;
|
||||
var meta = document.getElementById('ap-meta');
|
||||
if (d.unlocked === '1' && d.date) {{
|
||||
meta.textContent = 'Unlocked on ' + d.date;
|
||||
}} else if (d.extra) {{
|
||||
meta.textContent = d.extra;
|
||||
}} else if (d.condition) {{
|
||||
meta.textContent = '→ ' + d.condition;
|
||||
}} else {{
|
||||
meta.textContent = '';
|
||||
}}
|
||||
// Position near card
|
||||
var rect = card.getBoundingClientRect();
|
||||
var pw = 260, ph = 180;
|
||||
var left = rect.left + rect.width / 2 - pw / 2;
|
||||
var top = rect.top - ph - 12 + window.scrollY;
|
||||
if (left + pw > window.innerWidth - 8) left = window.innerWidth - pw - 8;
|
||||
if (left < 8) left = 8;
|
||||
if (top - window.scrollY < 8) top = rect.bottom + 12 + window.scrollY;
|
||||
popup.style.left = left + 'px';
|
||||
popup.style.top = top + 'px';
|
||||
popup.classList.add('visible');
|
||||
if (pin) {{ popup.classList.add('pinned'); pinned = true; }}
|
||||
}}
|
||||
|
||||
function hidePopup() {{
|
||||
if (pinned) return;
|
||||
popup.classList.remove('visible');
|
||||
}}
|
||||
|
||||
function forceHide() {{
|
||||
pinned = false;
|
||||
popup.classList.remove('visible', 'pinned');
|
||||
}}
|
||||
|
||||
// Close button
|
||||
document.getElementById('ach-popup-close').addEventListener('click', function(e) {{
|
||||
e.stopPropagation();
|
||||
forceHide();
|
||||
}});
|
||||
|
||||
// Wire all cards
|
||||
document.querySelectorAll('.ach-trigger').forEach(function(card) {{
|
||||
card.addEventListener('mouseenter', function() {{
|
||||
if (!pinned) showPopup(card, false);
|
||||
}});
|
||||
card.addEventListener('mouseleave', function() {{
|
||||
hidePopup();
|
||||
}});
|
||||
card.addEventListener('click', function(e) {{
|
||||
e.stopPropagation();
|
||||
if (pinned) {{ forceHide(); return; }}
|
||||
showPopup(card, true);
|
||||
}});
|
||||
}});
|
||||
|
||||
// Click outside to dismiss pinned popup
|
||||
document.addEventListener('click', function() {{
|
||||
if (pinned) forceHide();
|
||||
}});
|
||||
popup.addEventListener('click', function(e) {{ e.stopPropagation(); }});
|
||||
}})();
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def _render_live_sessions(sessions: list) -> str:
|
||||
"""Render the Live Sessions panel rows."""
|
||||
if not sessions:
|
||||
return '<p class="muted">No active sessions detected.</p>'
|
||||
|
||||
active = [s for s in sessions if (s.get("idle_minutes") or 9999) < 10]
|
||||
amber = [s for s in sessions if 10 <= (s.get("idle_minutes") or 9999) < 60]
|
||||
idle = [s for s in sessions if (s.get("idle_minutes") or 9999) >= 60]
|
||||
|
||||
summary = f'{len(active)} active / {len(amber)+len(idle)} idle'
|
||||
html = f'<p class="live-header-summary">{summary}</p>'
|
||||
|
||||
for s in sessions:
|
||||
idle_min = s.get("idle_minutes")
|
||||
if idle_min is None:
|
||||
dot_cls = "grey"
|
||||
idle_label = "unknown"
|
||||
elif idle_min < 10:
|
||||
dot_cls = "green"
|
||||
idle_label = f"Updated {idle_min}min ago"
|
||||
elif idle_min < 60:
|
||||
dot_cls = "amber"
|
||||
idle_label = f"Updated {idle_min}min ago"
|
||||
else:
|
||||
hours = idle_min // 60
|
||||
dot_cls = "grey"
|
||||
idle_label = f"Updated {hours}h ago — likely idle"
|
||||
|
||||
sid_short = (s.get("session_id") or "")[:8]
|
||||
ide = _html.escape(s.get("ide_hint") or "unknown IDE")
|
||||
raw_focus = s.get("focus")
|
||||
focus = _html.escape(raw_focus) if raw_focus else "<em style='color:var(--muted)'>[no focus set]</em>"
|
||||
files = s.get("files") or []
|
||||
files_html = ""
|
||||
if files:
|
||||
files_html = f'<div class="live-files">Files: {_html.escape(", ".join(files[:5]))}</div>'
|
||||
|
||||
html += (
|
||||
f'<div class="live-session-row">'
|
||||
f'<div class="live-session-header">'
|
||||
f'<span class="live-dot {dot_cls}"></span>'
|
||||
f'<span style="font-family:monospace;font-size:12px;color:var(--muted)">{sid_short}</span>'
|
||||
f'<span class="live-ide">{ide}</span>'
|
||||
f'<span class="live-idle">{idle_label}</span>'
|
||||
f'</div>'
|
||||
f'<div class="live-focus">{focus}</div>'
|
||||
f'{files_html}'
|
||||
f'</div>'
|
||||
)
|
||||
return html
|
||||
|
||||
|
||||
def _render_heatmap(heatmap: dict) -> str:
|
||||
today = datetime.now(timezone.utc).date()
|
||||
start_day = today - timedelta(days=363)
|
||||
|
||||
# Align to Monday of the start week
|
||||
start_day = start_day - timedelta(days=start_day.weekday())
|
||||
|
||||
weeks = []
|
||||
current = start_day
|
||||
while current <= today:
|
||||
week_cells = []
|
||||
for _ in range(7):
|
||||
day_str = str(current)
|
||||
count = heatmap.get(day_str, 0)
|
||||
if current > today:
|
||||
css = "heatmap-cell"
|
||||
elif count == 0:
|
||||
css = "heatmap-cell"
|
||||
elif count == 1:
|
||||
css = "heatmap-cell l1"
|
||||
elif count == 2:
|
||||
css = "heatmap-cell l2"
|
||||
elif count <= 4:
|
||||
css = "heatmap-cell l3"
|
||||
else:
|
||||
css = "heatmap-cell l4"
|
||||
week_cells.append(f'<div class="{css}" title="{day_str}: {count} session(s)"></div>')
|
||||
current += timedelta(days=1)
|
||||
weeks.append('<div class="heatmap-week">' + "".join(week_cells) + "</div>")
|
||||
|
||||
legend = (
|
||||
'<div class="heatmap-legend">'
|
||||
'<span>Less</span>'
|
||||
'<div class="heatmap-cell"></div>'
|
||||
'<div class="heatmap-cell l1"></div>'
|
||||
'<div class="heatmap-cell l2"></div>'
|
||||
'<div class="heatmap-cell l3"></div>'
|
||||
'<div class="heatmap-cell l4"></div>'
|
||||
'<span>More</span>'
|
||||
'</div>'
|
||||
)
|
||||
return '<div class="heatmap-grid">' + "".join(weeks) + "</div>" + legend
|
||||
|
||||
|
||||
Reference in New Issue
Block a user