feat(gamification): Phase 1 — full kill-switch + textbook XP wrapping

Until now the 'gamification' feature flag did nothing: it had no row in
app_settings, the admin couldn't toggle it, awardXP/awardCoins ignored
it, and the CSS only hid three dashboard widgets — XP bars in textbooks
stayed visible regardless.

Phase 1 closes every hole.

Backend (source of truth):
  • migration 029 seeds feature_gamification_enabled=1
  • new isGamificationEnabled() helper in gamification/_shared.js with a
    30s cache + invalidateGamificationCache() for instant admin toggles
  • awardXP / awardCoins / updateStreak / unlockAchievement /
    checkAchievements all bail out when the flag is off
  • /api/gamification/* and /api/shop/* (user routes) return 404 when
    disabled; admin routes remain open so the switch itself is reachable
  • adminController.updateFeatures gains 'gamification' in the allow-list
    and invalidates the cache on flip

Frontend:
  • LS.isGamificationEnabled() (synchronous, populated by loadFeatures)
    so xp.js + applyCosmetics can bail without a round-trip
  • xp.js load/add/flush become no-ops when the flag is off
  • applyCosmetics skips the round-trip when off
  • CSS .no-gamification rule expanded to cover .hero-xp-badge, .po-xp,
    .xp-card, .xp-bar, #frames-section, and a universal [data-gamified]
    hook for future blocks

Textbooks (Variant 2 of the plan):
  • backend/scripts/wrap_textbook_xp.py — idempotent script that adds
    data-gamified to 167 XP tags across 63 textbook files (chapters +
    hubs, all subjects/grades). Single CSS rule now hides everything.

Verified end-to-end: with the flag off, awardXP/awardCoins write nothing;
flipping back restores normal behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-29 19:43:24 +03:00
parent 3e7e6e5b9b
commit 660e7e2747
73 changed files with 349 additions and 122 deletions
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""
wrap_textbook_xp.py — Add `data-gamified` attribute to XP UI in textbooks.
Part of Phase 1 of the gamification kill-switch (Variant 2 of plan §E.3).
The CSS rule `body.no-gamification [data-gamified] { display:none }`
catches every wrapped block in one selector, so future authors don't have
to invent new class names just to be hidden by the master switch.
What we wrap:
• <div id="hero-xp-badge" ...> — chapter & hub hero badges
• <div class="po-xp" ...> — hub overall-progress XP pill
• <div class="xp-card" ...> — chapter inline progress card
• inline JS that generates `<div class="xp-card">` HTML strings
• `.xp-bar` containers when they're standalone
Idempotent: if `data-gamified` is already present on an opening tag the
script skips it. Reports each file touched.
Run from repo root:
python backend/scripts/wrap_textbook_xp.py
"""
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2] / "frontend" / "textbooks"
if not ROOT.is_dir():
print(f"[wrap-xp] textbook dir not found: {ROOT}", file=sys.stderr)
sys.exit(1)
# Selectors that mark an opening tag as an XP block. The first group
# captures `<tag` + attributes UP TO (but not including) the existing
# `class="..."` / `id="..."` we matched, so we can splice in
# `data-gamified` before the closing `>`.
PATTERNS = [
# <... id="hero-xp-badge" ...>
re.compile(r'(<[a-zA-Z][^>]*?\bid="hero-xp-badge"[^>]*?)(>)'),
# <... class="po-xp" ...> or class="...po-xp..."
re.compile(r'(<[a-zA-Z][^>]*?\bclass="[^"]*\bpo-xp\b[^"]*"[^>]*?)(>)'),
# <... class="...xp-card..."> — covers HTML and inline JS `'<div class="xp-card">'`
re.compile(r'(<[a-zA-Z][^>]*?\bclass="[^"]*\bxp-card\b[^"]*"[^>]*?)(>)'),
# Standalone class="hero-xp-badge"
re.compile(r'(<[a-zA-Z][^>]*?\bclass="[^"]*\bhero-xp-badge\b[^"]*"[^>]*?)(>)'),
]
# Already-wrapped check
RX_HAS_ATTR = re.compile(r'\bdata-gamified\b')
def patch_file(path: Path) -> int:
"""Return number of opening tags newly given data-gamified."""
text = path.read_text(encoding="utf-8")
new = text
changes = 0
for rx in PATTERNS:
def sub(m, _rx=rx):
nonlocal changes
head, close = m.group(1), m.group(2)
if RX_HAS_ATTR.search(head):
return m.group(0)
changes += 1
sep = "" if head.endswith(" ") else " "
return f'{head}{sep}data-gamified{close}'
new = rx.sub(sub, new)
if new != text:
path.write_text(new, encoding="utf-8")
return changes
def main():
files = sorted(ROOT.glob("*.html"))
total_files = 0
total_tags = 0
for f in files:
try:
n = patch_file(f)
except Exception as e:
print(f"[wrap-xp] {f.name}: ERROR {e}", file=sys.stderr)
continue
if n:
print(f"[wrap-xp] {f.name}: +{n} tag(s)")
total_files += 1
total_tags += n
print(f"[wrap-xp] done — patched {total_tags} tags across {total_files} files "
f"(scanned {len(files)})")
if __name__ == "__main__":
main()