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:
@@ -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()
|
||||
Reference in New Issue
Block a user