13659fd414
The .ach-image div had correct CSS dimensions (64x64) and background-size:cover but was missing the inline style="background-image: url(...)" — so the div rendered as an empty circle. Fixed by extracting img_url variable and applying it as style attribute in the f-string. All 39 achievement PNGs now load. 303/303 tests passing.
926 lines
40 KiB
Python
926 lines
40 KiB
Python
"""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 = '<span class="ach-lock">🔒</span>' if not a["unlocked"] else ''
|
||
|
||
if a.get("image"):
|
||
tier = a["id"].rsplit("_", 1)[-1]
|
||
img_url = _esc(a["image"])
|
||
visual_html = f'<div class="ach-image tier-{tier}" style="background-image: url({img_url});">{lock_overlay}</div>'
|
||
else:
|
||
visual_html = f'<div class="ach-icon">{a["icon"]}{lock_overlay}</div>'
|
||
|
||
return (
|
||
f'<div class="ach-card{locked_cls} ach-trigger" data-image="{_esc(a.get("image") or "")}"'
|
||
f' data-icon="{_esc(a["icon"] or "")}"'
|
||
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'{visual_html}'
|
||
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; }}
|
||
|
||
/* Nav bar */
|
||
.nav {{ display: flex; gap: 8px; margin-bottom: 20px; }}
|
||
.nav-link {{ background: var(--surface); border: 1px solid var(--border); border-radius: 6px; color: var(--muted); padding: 6px 14px; font-size: 12px; font-weight: 500; text-decoration: none; transition: border-color 0.2s, color 0.2s; }}
|
||
.nav-link:hover {{ border-color: var(--accent); color: var(--accent); }}
|
||
.nav-link.active {{ border-color: var(--accent); color: var(--accent); background: rgba(88,166,255,0.08); }}
|
||
|
||
/* 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; overflow: hidden; }}
|
||
.avatar img {{ width: 80px; height: 80px; border-radius: 50%; object-fit: cover; display: block; }}
|
||
.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-image {{
|
||
width: 64px;
|
||
height: 64px;
|
||
border-radius: 50%;
|
||
margin: 0 auto 8px;
|
||
background-size: cover;
|
||
background-position: center;
|
||
position: relative;
|
||
}}
|
||
|
||
.tier-bronze {{
|
||
box-shadow: 0 0 8px rgba(205, 127, 50, 0.7);
|
||
border: 3px solid #cd7f32;
|
||
}}
|
||
|
||
.tier-silver {{
|
||
box-shadow: 0 0 8px rgba(170, 169, 173, 0.7);
|
||
border: 3px solid #aaa9ad;
|
||
}}
|
||
|
||
.tier-gold {{
|
||
box-shadow: 0 0 12px rgba(255, 215, 0, 0.8);
|
||
border: 3px solid #ffd700;
|
||
}}
|
||
|
||
.tier-platinum {{
|
||
box-shadow: 0 0 12px rgba(229, 228, 226, 0.8);
|
||
border: 3px solid #e5e4e2;
|
||
}}
|
||
|
||
.ach-card.locked::after {{
|
||
content: '🔒';
|
||
position: absolute;
|
||
top: 8px;
|
||
right: 8px;
|
||
font-size: 20px;
|
||
opacity: 0.8;
|
||
z-index: 1;
|
||
}}
|
||
|
||
.ach-card.locked .ach-icon,
|
||
.ach-card.locked .ach-image {{
|
||
opacity: 0.5;
|
||
}}
|
||
.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; }}
|
||
.ap-image {{
|
||
width: 80px;
|
||
height: 80px;
|
||
border-radius: 50%;
|
||
object-fit: cover;
|
||
display: block;
|
||
margin: 0 auto 8px;
|
||
}}
|
||
|
||
/* 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-image {{
|
||
width: 80px;
|
||
height: 80px;
|
||
border-radius: 50%;
|
||
object-fit: cover;
|
||
display: block;
|
||
margin: 0 auto 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">
|
||
|
||
<!-- Nav -->
|
||
<nav class="nav">
|
||
<a class="nav-link active" href="/">🧠 Profile</a>
|
||
<a class="nav-link" href="/gallery">🖼️ Gallery</a>
|
||
</nav>
|
||
|
||
<!-- Header -->
|
||
<div class="header">
|
||
<div class="avatar">
|
||
<img src="/profile-image" alt="Lumen" onerror="this.parentElement.innerHTML='🧠'">
|
||
</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;
|
||
var tier = d.id.split('_').pop();
|
||
if (d.image) {{
|
||
document.getElementById('ap-icon').innerHTML = '<img class="ap-image tier-' + tier + '" src="' + d.image + '" alt="' + d.name + '">';
|
||
}} else {{
|
||
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_gallery_html(images: list) -> str:
|
||
"""Render the full gallery page listing all AI-generated images."""
|
||
|
||
def _fmt_size(b: int | None) -> str:
|
||
if not b:
|
||
return ""
|
||
if b >= 1_048_576:
|
||
return f"{b/1_048_576:.1f} MB"
|
||
return f"{b/1_024:.0f} KB"
|
||
|
||
if images:
|
||
cards = []
|
||
for img in images:
|
||
fn = _html.escape(img.get("filename") or "")
|
||
prompt = _html.escape((img.get("prompt") or "")[:120])
|
||
tags = _html.escape(img.get("tags") or "")
|
||
model = _html.escape(img.get("model") or "")
|
||
date = (img.get("created_at") or "")[:10]
|
||
w = img.get("width") or 0
|
||
h = img.get("height") or 0
|
||
size = _fmt_size(img.get("file_size_bytes"))
|
||
dim = f"{w}×{h}" if w and h else ""
|
||
meta_parts = [p for p in [dim, size, model] if p]
|
||
meta_html = " · ".join(meta_parts)
|
||
tag_html = f'<div class="gal-tags">{tags}</div>' if tags else ""
|
||
prompt_html = f'<div class="gal-prompt">{prompt}</div>' if prompt else ""
|
||
cards.append(
|
||
f'<div class="gal-card">'
|
||
f'<a href="/gallery/image/{fn}" target="_blank">'
|
||
f'<img class="gal-img" src="/gallery/image/{fn}" alt="{fn}" loading="lazy">'
|
||
f'</a>'
|
||
f'<div class="gal-info">'
|
||
f'{prompt_html}'
|
||
f'{tag_html}'
|
||
f'<div class="gal-meta">{meta_html}</div>'
|
||
f'<div class="gal-date">{date}</div>'
|
||
f'</div>'
|
||
f'</div>'
|
||
)
|
||
gallery_body = f'<p class="gal-count">{len(images)} image(s) in gallery</p><div class="gal-grid">{"".join(cards)}</div>'
|
||
else:
|
||
gallery_body = '<p class="muted">No images in gallery yet. Use the mcp-image-gen server to generate images and register them here.</p>'
|
||
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>🖼️ Lumen — Image Gallery</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: 1100px; margin: 0 auto; padding: 32px 16px; }}
|
||
|
||
/* Nav */
|
||
.nav {{ display: flex; gap: 8px; margin-bottom: 20px; }}
|
||
.nav-link {{ background: var(--surface); border: 1px solid var(--border); border-radius: 6px; color: var(--muted); padding: 6px 14px; font-size: 12px; font-weight: 500; text-decoration: none; transition: border-color 0.2s, color 0.2s; }}
|
||
.nav-link:hover {{ border-color: var(--accent); color: var(--accent); }}
|
||
.nav-link.active {{ border-color: var(--accent); color: var(--accent); background: rgba(88,166,255,0.08); }}
|
||
|
||
h1 {{ font-size: 22px; font-weight: 700; margin-bottom: 6px; }}
|
||
.gal-count {{ color: var(--muted); font-size: 13px; margin-bottom: 20px; }}
|
||
.muted {{ color: var(--muted); font-size: 13px; }}
|
||
|
||
/* Gallery grid */
|
||
.gal-grid {{
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||
gap: 16px;
|
||
}}
|
||
.gal-card {{
|
||
background: var(--surface); border: 1px solid var(--border);
|
||
border-radius: 10px; overflow: hidden;
|
||
transition: border-color 0.2s, transform 0.15s;
|
||
}}
|
||
.gal-card:hover {{ border-color: var(--accent); transform: translateY(-2px); }}
|
||
.gal-img {{
|
||
width: 100%; aspect-ratio: 1/1; object-fit: cover; display: block;
|
||
background: var(--border);
|
||
}}
|
||
.gal-info {{ padding: 12px 14px; }}
|
||
.gal-prompt {{ font-size: 12px; color: var(--text); margin-bottom: 6px; line-height: 1.4;
|
||
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }}
|
||
.gal-tags {{ font-size: 11px; color: var(--purple); margin-bottom: 4px; }}
|
||
.gal-meta {{ font-size: 11px; color: var(--muted); }}
|
||
.gal-date {{ font-size: 10px; color: var(--muted); margin-top: 4px; }}
|
||
|
||
.footer {{ text-align: center; color: var(--muted); font-size: 11px; margin-top: 32px; }}
|
||
.section {{ background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 20px; margin-bottom: 20px; }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
|
||
<!-- Nav -->
|
||
<nav class="nav">
|
||
<a class="nav-link" href="/">🧠 Profile</a>
|
||
<a class="nav-link active" href="/gallery">🖼️ Gallery</a>
|
||
</nav>
|
||
|
||
<h1>🖼️ Lumen's Image Gallery</h1>
|
||
<div class="section">
|
||
{gallery_body}
|
||
</div>
|
||
|
||
<div class="footer">BigMind · AI-Generated Images · <a href="/">← Back to Profile</a></div>
|
||
</div>
|
||
</body>
|
||
</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
|
||
|
||
|