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:
Patrick Plate
2026-04-04 08:51:15 +02:00
parent 4167e15ed9
commit 155d56e8e8
1598 changed files with 19429 additions and 23 deletions
+717
View File
@@ -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('"', "&quot;").replace("'", "&#39;")
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} &nbsp;·&nbsp; Last seen: <strong>{data["last_seen"] or ""}</strong></p>
<p class="since">DB: <code>{data["db_size_kb"]} KB</code> &nbsp;·&nbsp; {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':''}}>&#8592;</button>` +
`<span class="page-info">Page ${{page+1}} of ${{total}}</span>` +
`<button class="page-btn" ${{page===total-1?'disabled':''}}>&#8594;</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