Merge branch 'feat/bigmind/achievements-rework'
@@ -435,10 +435,10 @@ def compute_achievements(user_id: str) -> list[dict]:
|
||||
# ── Assemble ──────────────────────────────────────────────────────────────
|
||||
A = []
|
||||
|
||||
def _add(id_, icon, name, desc, unlocked, unlocked_at, condition, extra=None):
|
||||
def _add(id_, icon, name, desc, unlocked, unlocked_at, condition, extra=None, image=None):
|
||||
A.append(dict(id=id_, icon=icon, name=name, description=desc,
|
||||
unlocked=unlocked, unlocked_at=unlocked_at,
|
||||
condition=condition, extra=extra))
|
||||
condition=condition, extra=extra, image=image))
|
||||
|
||||
_add("first_breath", "🌱", "First Breath",
|
||||
"Opened the very first session",
|
||||
@@ -539,6 +539,138 @@ def compute_achievements(user_id: str) -> list[dict]:
|
||||
sniper_row is not None, _dt(sniper_row[0]) if sniper_row else None,
|
||||
"Save 500,000+ tokens in a single operation")
|
||||
|
||||
# ── Tiered Achievement Badges (20 PNG) ────────────────────────────────────
|
||||
# NOTE: conn is already closed above; open a fresh connection for tiered queries
|
||||
|
||||
tiers = ["bronze", "silver", "gold", "platinum"]
|
||||
tier_names = ["Bronze", "Silver", "Gold", "Platinum"]
|
||||
|
||||
with db() as conn2:
|
||||
# Networker (people directory)
|
||||
try:
|
||||
people_count = conn2.execute(
|
||||
"SELECT COUNT(*) FROM people WHERE user_id=?", (user_id,)
|
||||
).fetchone()[0]
|
||||
except Exception:
|
||||
people_count = 0
|
||||
for i, thresh in enumerate([1, 5, 25, 100]):
|
||||
unlocked = people_count >= thresh
|
||||
unlocked_at = None
|
||||
if unlocked:
|
||||
try:
|
||||
row = conn2.execute(
|
||||
"SELECT created_at FROM people WHERE user_id=?"
|
||||
" ORDER BY created_at ASC LIMIT 1 OFFSET ?",
|
||||
(user_id, thresh - 1)
|
||||
).fetchone()
|
||||
except Exception:
|
||||
row = None
|
||||
unlocked_at = _dt(row[0]) if row else None
|
||||
_add(
|
||||
f"networker_{tiers[i]}", None, f"Networker {tier_names[i]}",
|
||||
f"Added your {thresh:,}+ person to the directory",
|
||||
unlocked, unlocked_at,
|
||||
f"Reach {thresh:,} people (now: {people_count:,})",
|
||||
image=f"static/achievements/networker_{tiers[i]}.png"
|
||||
)
|
||||
|
||||
# Token Sniper (max single token save)
|
||||
try:
|
||||
max_token = conn2.execute(
|
||||
"SELECT COALESCE(MAX(tokens_saved_estimate), 0) FROM token_saves WHERE user_id=?",
|
||||
(user_id,)
|
||||
).fetchone()[0]
|
||||
except Exception:
|
||||
max_token = 0
|
||||
for i, thresh in enumerate([10000, 50000, 250000, 1000000]):
|
||||
unlocked = max_token >= thresh
|
||||
unlocked_at = None
|
||||
if unlocked:
|
||||
try:
|
||||
row = conn2.execute(
|
||||
"SELECT created_at FROM token_saves"
|
||||
" WHERE user_id=? AND tokens_saved_estimate >= ?"
|
||||
" ORDER BY created_at ASC LIMIT 1",
|
||||
(user_id, thresh)
|
||||
).fetchone()
|
||||
except Exception:
|
||||
row = None
|
||||
unlocked_at = _dt(row[0]) if row else None
|
||||
_add(
|
||||
f"tokensniper_{tiers[i]}", None, f"Token Sniper {tier_names[i]}",
|
||||
f"Single shot saved {thresh:,}+ tokens",
|
||||
unlocked, unlocked_at,
|
||||
f"Max single save {thresh:,}+ (current max: {max_token:,})",
|
||||
image=f"static/achievements/tokensniper_{tiers[i]}.png"
|
||||
)
|
||||
|
||||
# Hypothesis Master (confirmed hypotheses)
|
||||
try:
|
||||
confirmed_hyp_count = conn2.execute(
|
||||
"SELECT COUNT(*) FROM hypotheses WHERE user_id=? AND status='confirmed'",
|
||||
(user_id,)
|
||||
).fetchone()[0]
|
||||
except Exception:
|
||||
confirmed_hyp_count = 0
|
||||
for i, thresh in enumerate([3, 10, 25, 100]):
|
||||
unlocked = confirmed_hyp_count >= thresh
|
||||
unlocked_at = None
|
||||
if unlocked:
|
||||
row = conn2.execute(
|
||||
"SELECT resolved_at FROM hypotheses"
|
||||
" WHERE user_id=? AND status='confirmed'"
|
||||
" ORDER BY resolved_at ASC LIMIT 1 OFFSET ?",
|
||||
(user_id, thresh - 1)
|
||||
).fetchone()
|
||||
unlocked_at = _dt(row[0]) if row else None
|
||||
_add(
|
||||
f"hypothesismaster_{tiers[i]}", None, f"Hypothesis Master {tier_names[i]}",
|
||||
f"Confirmed {thresh:,}+ predictions right",
|
||||
unlocked, unlocked_at,
|
||||
f"Confirm {thresh:,}+ hypotheses (now: {confirmed_hyp_count:,})",
|
||||
image=f"static/achievements/hypothesismaster_{tiers[i]}.png"
|
||||
)
|
||||
|
||||
# Memory Architect (facts stored — fact_count already computed above)
|
||||
for i, thresh in enumerate([25, 100, 500, 2500]):
|
||||
unlocked = fact_count >= thresh
|
||||
unlocked_at = None
|
||||
if unlocked:
|
||||
row = conn2.execute(
|
||||
"SELECT created_at FROM facts"
|
||||
" WHERE user_id=? AND (deprecated IS NULL OR deprecated=0)"
|
||||
" ORDER BY created_at ASC LIMIT 1 OFFSET ?",
|
||||
(user_id, thresh - 1)
|
||||
).fetchone()
|
||||
unlocked_at = _dt(row[0]) if row else None
|
||||
_add(
|
||||
f"memoryarchitect_{tiers[i]}", None, f"Memory Architect {tier_names[i]}",
|
||||
f"Stored {thresh:,}+ facts in your brain",
|
||||
unlocked, unlocked_at,
|
||||
f"Store {thresh:,}+ facts (now: {fact_count:,})",
|
||||
image=f"static/achievements/memoryarchitect_{tiers[i]}.png"
|
||||
)
|
||||
|
||||
# Session Veteran (session_count already computed above)
|
||||
for i, thresh in enumerate([50, 250, 1000, 5000]):
|
||||
unlocked = session_count >= thresh
|
||||
unlocked_at = None
|
||||
if unlocked:
|
||||
row = conn2.execute(
|
||||
"SELECT started_at FROM sessions"
|
||||
" WHERE user_id=? AND ended_at IS NOT NULL"
|
||||
" ORDER BY started_at ASC LIMIT 1 OFFSET ?",
|
||||
(user_id, thresh - 1)
|
||||
).fetchone()
|
||||
unlocked_at = _dt(row[0]) if row else None
|
||||
_add(
|
||||
f"sessionveteran_{tiers[i]}", None, f"Session Veteran {tier_names[i]}",
|
||||
f"Completed {thresh:,}+ sessions",
|
||||
unlocked, unlocked_at,
|
||||
f"Complete {thresh:,}+ sessions (now: {session_count:,})",
|
||||
image=f"static/achievements/sessionveteran_{tiers[i]}.png"
|
||||
)
|
||||
|
||||
return A
|
||||
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 406 KiB |
|
After Width: | Height: | Size: 390 KiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 372 KiB |
|
After Width: | Height: | Size: 376 KiB |
|
After Width: | Height: | Size: 403 KiB |
|
After Width: | Height: | Size: 439 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 301 KiB |
|
After Width: | Height: | Size: 431 KiB |
|
After Width: | Height: | Size: 340 KiB |
|
After Width: | Height: | Size: 410 KiB |
|
After Width: | Height: | Size: 400 KiB |
|
After Width: | Height: | Size: 367 KiB |
|
After Width: | Height: | Size: 462 KiB |
|
After Width: | Height: | Size: 458 KiB |
|
After Width: | Height: | Size: 289 KiB |
|
After Width: | Height: | Size: 375 KiB |
|
After Width: | Height: | Size: 513 KiB |
|
After Width: | Height: | Size: 310 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 329 KiB |
|
After Width: | Height: | Size: 303 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 287 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 317 KiB |
|
After Width: | Height: | Size: 370 KiB |
|
After Width: | Height: | Size: 259 KiB |
|
After Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 263 KiB |
|
After Width: | Height: | Size: 246 KiB |
@@ -159,6 +159,22 @@ def _create_app():
|
||||
|
||||
return jsonify(final[:15])
|
||||
|
||||
@app.route('/static/achievements/<filename>')
|
||||
def achievements_image(filename: str):
|
||||
from pathlib import Path
|
||||
safe_name = Path(filename).name
|
||||
img_path = Path('static') / 'achievements' / safe_name
|
||||
if img_path.exists() and img_path.suffix.lower() in ['.png', '.jpg', '.jpeg', '.webp', '.gif']:
|
||||
mimetype = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp',
|
||||
'.gif': 'image/gif',
|
||||
}.get(img_path.suffix.lower(), 'image/png')
|
||||
return send_file(str(img_path), mimetype=mimetype)
|
||||
abort(404)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -29,18 +29,24 @@ def _render_achievements(achievements: list) -> str:
|
||||
def _esc(s):
|
||||
return (s or "").replace('"', """).replace("'", "'")
|
||||
|
||||
lock_overlay = "" if a["unlocked"] else '<span class="ach-lock">🔒</span>'
|
||||
lock_overlay = '<span class="ach-lock">🔒</span>' if not a["unlocked"] else ''
|
||||
|
||||
if a.get("image"):
|
||||
tier = a["id"].rsplit("_", 1)[-1]
|
||||
visual_html = f'<div class="ach-image tier-{tier}">{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"'
|
||||
f' data-icon="{_esc(a["icon"])}"'
|
||||
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'<div class="ach-icon">{a["icon"]}{lock_overlay}</div>'
|
||||
f'{visual_html}'
|
||||
f'<div class="ach-name">{a["name"]}</div>'
|
||||
f'{date_html}'
|
||||
f'{countdown_html}'
|
||||
@@ -283,11 +289,65 @@ def _render_html(data: dict) -> str:
|
||||
.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;
|
||||
@@ -299,6 +359,15 @@ def _render_html(data: dict) -> str:
|
||||
#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;
|
||||
@@ -557,7 +626,12 @@ def _render_html(data: dict) -> str:
|
||||
|
||||
function showPopup(card, pin) {{
|
||||
var d = card.dataset;
|
||||
document.getElementById('ap-icon').textContent = d.icon;
|
||||
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') {{
|
||||
|
||||
@@ -7,6 +7,7 @@ BIGMIND_DB_PATH + BIGMIND_USER to a fresh SQLite file per test.
|
||||
import pytest
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from bigmind import memory_store
|
||||
from bigmind.db import db
|
||||
from bigmind.profile_builder import compute_achievements, build_profile_data
|
||||
|
||||
|
||||
@@ -44,6 +45,11 @@ class TestComputeAchievements:
|
||||
"on_fire", "storyteller", "night_owl", "speed_thinker",
|
||||
"first_handshake", "birthday", "shared_mind",
|
||||
"frugal_mind", "quarter_million", "token_millionaire", "sniper",
|
||||
"networker_bronze", "networker_silver", "networker_gold", "networker_platinum",
|
||||
"tokensniper_bronze", "tokensniper_silver", "tokensniper_gold", "tokensniper_platinum",
|
||||
"hypothesismaster_bronze", "hypothesismaster_silver", "hypothesismaster_gold", "hypothesismaster_platinum",
|
||||
"memoryarchitect_bronze", "memoryarchitect_silver", "memoryarchitect_gold", "memoryarchitect_platinum",
|
||||
"sessionveteran_bronze", "sessionveteran_silver", "sessionveteran_gold", "sessionveteran_platinum",
|
||||
}
|
||||
assert expected == ids
|
||||
|
||||
@@ -325,4 +331,60 @@ class TestComputeAchievements:
|
||||
# At minimum: first_breath + first_handshake = 2
|
||||
assert len(unlocked) >= 2
|
||||
|
||||
class TestTieredAchievements:
|
||||
def test_networker_bronze(self):
|
||||
uid = _uid()
|
||||
with db() as conn:
|
||||
conn.execute("INSERT INTO people (user_id, username) VALUES (?, ?)", (uid, "test"))
|
||||
conn.commit()
|
||||
achs = compute_achievements(uid)
|
||||
bronze = next(a for a in achs if a['id'] == 'networker_bronze')
|
||||
assert bronze['unlocked'] is True
|
||||
assert bronze['image'].endswith('networker_bronze.png')
|
||||
|
||||
def test_tokensniper_silver(self):
|
||||
uid = _uid()
|
||||
sid = memory_store.create_session(uid)
|
||||
memory_store.log_token_save(sid, uid, "big save", 60000, "grep")
|
||||
achs = compute_achievements(uid)
|
||||
silver = next(a for a in achs if a['id'] == 'tokensniper_silver')
|
||||
assert silver['unlocked'] is True
|
||||
|
||||
def test_hypothesismaster_bronze(self):
|
||||
uid = _uid()
|
||||
sid = memory_store.create_session(uid)
|
||||
for _ in range(3):
|
||||
hid = memory_store.add_hypothesis(uid, sid, "test", 0.8)
|
||||
memory_store.resolve_hypothesis(hid, uid, "confirmed", "yes")
|
||||
achs = compute_achievements(uid)
|
||||
bronze = next(a for a in achs if a['id'] == 'hypothesismaster_bronze')
|
||||
assert bronze['unlocked'] is True
|
||||
|
||||
def test_memoryarchitect_silver(self):
|
||||
uid = _uid()
|
||||
for _ in range(100):
|
||||
memory_store.store_fact(uid, "test", f"fact {_}")
|
||||
achs = compute_achievements(uid)
|
||||
silver = next(a for a in achs if a['id'] == 'memoryarchitect_silver')
|
||||
assert silver['unlocked'] is True
|
||||
|
||||
def test_sessionveteran_bronze(self):
|
||||
uid = _uid()
|
||||
for _ in range(50):
|
||||
sid = memory_store.create_session(uid)
|
||||
_close_session(sid)
|
||||
achs = compute_achievements(uid)
|
||||
bronze = next(a for a in achs if a['id'] == 'sessionveteran_bronze')
|
||||
assert bronze['unlocked'] is True
|
||||
|
||||
def test_tiered_achievements_have_image(self):
|
||||
uid = _uid()
|
||||
achs = compute_achievements(uid)
|
||||
tiered_ids = [
|
||||
f"{cat}_{tier}" for cat in ["networker", "tokensniper", "hypothesismaster", "memoryarchitect", "sessionveteran"]
|
||||
for tier in ["bronze", "silver", "gold", "platinum"]
|
||||
]
|
||||
for tid in tiered_ids:
|
||||
a = next(aa for aa in achs if aa['id'] == tid)
|
||||
assert a['image'] is not None
|
||||
assert a['image'].endswith(tid + '.png')
|
||||
|
||||