660e7e2747
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>
91 lines
3.1 KiB
Python
91 lines
3.1 KiB
Python
#!/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()
|