stenodojo

stenodojo
git clone git@git.zachrice.app:repos/stenodojo.git
Log | Files | Refs

commit 6ecf86cc0eac3d358098db13d89a778aa4bcad74
Author: Zach Rice <bynxmusic@gmail.com>
Date:   Thu, 28 May 2026 17:42:18 -0400

Init commit

Diffstat:
A.env.example | 9+++++++++
A.gitignore | 32++++++++++++++++++++++++++++++++
Aapp.py | 1084+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adatabase.py | 696+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adictionary.py | 353+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alessons.py | 460+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aphrases.py | 400+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aplover_engine.py | 692+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Arequirements.txt | 3+++
Asentence_generator.py | 172+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asettings.py | 38++++++++++++++++++++++++++++++++++++++
Aspaced_repetition.py | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Astatic/css/style.css | 1900+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Astatic/js/app.js | 1319+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atemplates/base.html | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atemplates/dashboard.html | 174+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atemplates/login.html | 46++++++++++++++++++++++++++++++++++++++++++++++
Atemplates/partials/word_card.html | 2++
Atemplates/practice.html | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atemplates/settings.html | 295+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atemplates/stats.html | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
21 files changed, 8002 insertions(+), 0 deletions(-)

diff --git a/.env.example b/.env.example @@ -0,0 +1,9 @@ +# Flask secret key (generate with: python -c "import secrets; print(secrets.token_hex(32))") +SECRET_KEY=change-me-to-a-random-string + +# Google OAuth credentials (from https://console.cloud.google.com/apis/credentials) +# Create an OAuth 2.0 Client ID with redirect URI: http://localhost:5000/auth/google/callback +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +# Leave GOOGLE_CLIENT_ID empty to run in dev mode (no auth required) diff --git a/.gitignore b/.gitignore @@ -0,0 +1,32 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +*.egg + +# Virtual environments +venv/ +.venv/ +env/ + +# Environment variables +.env + +# Database +*.db + +# Uploads (user-specific data) +uploads/ + +# Test results +test-results/ + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db diff --git a/app.py b/app.py @@ -0,0 +1,1084 @@ +"""Flask application for multi-user stenography practice.""" + +import configparser +import json +import os +import random +import re +import time +from functools import wraps + +from authlib.integrations.flask_client import OAuth +from dotenv import load_dotenv +from flask import Flask, jsonify, redirect, render_template, request, session, url_for +from werkzeug.utils import secure_filename + +from database import Database +from lessons import ( + TOP_WORDS, + get_lesson_by_id, + get_lesson_list, + get_lesson_word_list, + get_lesson_words, + get_sentences, +) +from plover_engine import PloverEngine + +load_dotenv() + +app = Flask(__name__) +app.secret_key = os.environ.get("SECRET_KEY", "dev-secret-key-change-me") +app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 0 +app.config["MAX_CONTENT_LENGTH"] = 50 * 1024 * 1024 + +UPLOAD_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "uploads") +DEV_MODE = not os.environ.get("GOOGLE_CLIENT_ID") + +_start_time = int(time.time()) + +oauth = OAuth(app) +if not DEV_MODE: + google = oauth.register( + name="google", + client_id=os.environ["GOOGLE_CLIENT_ID"], + client_secret=os.environ["GOOGLE_CLIENT_SECRET"], + server_metadata_url="https://accounts.google.com/.well-known/openid-configuration", + client_kwargs={"scope": "openid email profile"}, + ) + +db = Database() + +if DEV_MODE: + dev_user = db.ensure_dev_user() + print(f"DEV MODE: No Google OAuth configured. Using dev user (id={dev_user['id']}).") + +# Per-user engine cache +_engine_cache = {} + + +def _get_user_dict_dir(user_id): + return os.path.join(UPLOAD_DIR, str(user_id)) + + +def get_engine(user_id): + if user_id in _engine_cache: + return _engine_cache[user_id] + engine = PloverEngine() + dict_dir = _get_user_dict_dir(user_id) + dicts = db.get_user_dictionaries(user_id) + if dicts: + engine.load_from_uploaded(dict_dir, [dict(d) for d in dicts]) + _engine_cache[user_id] = engine + return engine + + +def reload_engine(user_id): + _engine_cache.pop(user_id, None) + return get_engine(user_id) + + +@app.context_processor +def inject_globals(): + user = None + if "user_id" in session: + user = db.get_user(session["user_id"]) + return {"cache_bust": _start_time, "current_user": user, "dev_mode": DEV_MODE} + + +@app.after_request +def add_no_cache(response): + if "text/html" in response.content_type or "javascript" in response.content_type: + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + return response + + +# ── Auth ── + + +def login_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if DEV_MODE: + session.setdefault("user_id", dev_user["id"]) + return f(*args, **kwargs) + if "user_id" not in session: + return redirect(url_for("login")) + return f(*args, **kwargs) + return decorated + + +def current_user_id(): + return session.get("user_id") + + +@app.route("/login") +def login(): + if "user_id" in session: + return redirect("/") + if DEV_MODE: + session["user_id"] = dev_user["id"] + return redirect("/") + return render_template("login.html") + + +@app.route("/login/google") +def login_google(): + if DEV_MODE: + session["user_id"] = dev_user["id"] + return redirect("/") + redirect_uri = url_for("auth_google_callback", _external=True) + return google.authorize_redirect(redirect_uri) + + +@app.route("/auth/google/callback") +def auth_google_callback(): + if DEV_MODE: + session["user_id"] = dev_user["id"] + return redirect("/") + + token = google.authorize_access_token() + userinfo = token.get("userinfo") + if not userinfo: + return "Authentication failed", 400 + + if not userinfo.get("email_verified"): + return render_template("login.html", error="Your Google email is not verified. Please verify it and try again.") + + user = db.get_or_create_user( + email=userinfo["email"], + name=userinfo.get("name"), + avatar_url=userinfo.get("picture"), + ) + session["user_id"] = user["id"] + session.permanent = True + return redirect("/") + + +@app.route("/logout") +def logout(): + session.clear() + if DEV_MODE: + return redirect("/") + return redirect(url_for("login")) + + +# ── Page routes ── + + +MODE_LABELS = { + "common": "Common Words", + "random": "Random", + "new": "New Words", + "weak": "Weak Words", + "review": "Review", + "speed": "Speed Drill", + "sentences": "Sentences", + "phrases": "Phrases", + "lesson": "Lesson", +} + + +@app.route("/") +@login_required +def dashboard(): + uid = current_user_id() + daily = db.get_daily_stats(uid, days=1) + raw_today = daily[0] if daily else { + "words_practiced": 0, "words_correct": 0, "total_time_ms": 0, "sessions": 0 + } + wp = raw_today.get("words_practiced", 0) + wc = raw_today.get("words_correct", 0) + today = { + "words": wp, + "correct": wc, + "accuracy": (wc / wp * 100) if wp > 0 else 0, + "sessions": raw_today.get("sessions", 0), + } + + overview = db.get_overview_stats(uid) + streak = overview["current_streak"] + words_mastered = overview["words_mastered"] + total_practiced = overview["unique_words"] + review_due = len(db.get_words_due_for_review(uid, count=1000)) + + lesson_list = get_lesson_list() + for l in lesson_list: + lesson_def = get_lesson_by_id(l["id"]) + if lesson_def: + word_list = get_lesson_word_list(lesson_def) + if word_list: + prog = db.get_lesson_progress(uid, word_list) + l["progress"] = prog + + raw_sessions = db.get_recent_sessions(uid, limit=5) + recent_sessions = [] + for s in raw_sessions: + wa = s.get("words_attempted", 0) + wco = s.get("words_correct", 0) + total_ms = s.get("total_time_ms", 0) + total_chars = wa * 5 + wpm = (total_chars / 5) / (total_ms / 60000) if total_ms > 0 else 0 + recent_sessions.append({ + "mode": s.get("mode", ""), + "date_str": (s.get("started_at") or "")[:10], + "words_attempted": wa, + "words_correct": wco, + "accuracy": (wco / wa * 100) if wa > 0 else 0, + "wpm": round(wpm, 1), + }) + + engine = get_engine(uid) + has_dicts = bool(engine._practice_words) + + return render_template( + "dashboard.html", + today=today, + streak=streak, + words_mastered=words_mastered, + total_practiced=total_practiced, + review_due=review_due, + recent_sessions=recent_sessions, + lessons=lesson_list, + has_dicts=has_dicts, + ) + + +@app.route("/practice") +@login_required +def practice(): + mode = request.args.get("mode", "common") + lesson_id = request.args.get("lesson") + mode_label = MODE_LABELS.get(mode, "Practice") + if lesson_id: + lesson = get_lesson_by_id(lesson_id) + if lesson: + mode_label = lesson["title"] + return render_template("practice.html", mode=mode, mode_label=mode_label, lesson_id=lesson_id or "") + + +@app.route("/stats") +@login_required +def stats(): + return render_template("stats.html") + + +@app.route("/settings") +@login_required +def settings_page(): + uid = current_user_id() + user_settings = db.get_user_settings(uid) + user_dicts = db.get_user_dictionaries(uid) + return render_template("settings.html", settings=user_settings, dictionaries=user_dicts) + + +# ── Session API ── + +session_store = {} + + +@app.route("/api/session/start", methods=["POST"]) +@login_required +def api_session_start(): + uid = current_user_id() + engine = get_engine(uid) + data = request.get_json() + mode = data.get("mode", "common") + lesson_id = data.get("lesson_id") + user_settings = db.get_user_settings(uid) + count = data.get("count", user_settings.get("words_per_session", 50)) + + if lesson_id: + mode = "lesson" + lesson = get_lesson_by_id(lesson_id) + if not lesson: + return jsonify({"error": "Lesson not found"}), 404 + sentence_count = data.get("sentence_count", 10) + items = get_lesson_words(lesson, engine, count=sentence_count) + if lesson.get("mode") != "phrases": + return _start_sentence_session(uid, engine, mode, items) + else: + items = _select_words(uid, engine, mode, count) + + if not items: + return jsonify({"error": "No words available for this mode"}), 400 + + if mode == "sentences": + return _start_sentence_session(uid, engine, mode, items) + + session_id = db.create_session(uid, mode) + db.increment_daily_sessions(uid) + + words_data = [] + for i, word in enumerate(items): + display = engine.display_form(word) + hints = engine.get_all_hints(word) + words_data.append({ + "word": display, + "index": i, + "word_hints": hints["word_hints"], + "phrase_hints": hints["phrase_hints"], + "fingerspell": hints.get("fingerspell", False), + "punct_hints": hints.get("punct_hints", []), + "cap_hints": hints.get("cap_hints", []), + }) + + session_store[session_id] = { + "words": items, + "user_id": uid, + "index": 0, + "streak": 0, + "best_streak": 0, + } + + return jsonify({ + "session_id": session_id, + "words": words_data, + "total": len(items), + }) + + +_SENT_PUNCT = set('.,;:!?"\')}]>…') + + +def _tokenize_sentence(sentence): + """Split sentence into steno tokens: words and trailing punctuation separately. + Each token has a 'prefix' = the expected sentence text up through that token, + used for prefix-based input matching.""" + tokens = [] + for raw_word in sentence.split(): + idx = sentence.index(raw_word, tokens[-1]["end"] if tokens else 0) + + word = raw_word + while word and word[-1] in _SENT_PUNCT: + word = word[:-1] + + if word: + word_end = idx + len(word) + tokens.append({"text": word, "prefix": sentence[:word_end], + "end": word_end, "is_punct": False}) + + for j in range(len(word), len(raw_word)): + char_end = idx + j + 1 + tokens.append({"text": raw_word[j], "prefix": sentence[:char_end], + "end": char_end, "is_punct": True}) + return tokens + + +def _start_sentence_session(uid, engine, mode, sentences): + session_id = db.create_session(uid, mode) + db.increment_daily_sessions(uid) + + # Pass 1: build tokens with cumulative prefixes + raw_tokens = [] + cumulative_text = "" + for sentence in sentences: + sent_words = sentence.split() + tokens = _tokenize_sentence(sentence) + for token in tokens: + if token["is_punct"]: + cumulative_text += token["text"] + else: + if cumulative_text: + cumulative_text += " " + cumulative_text += token["text"] + raw_tokens.append({ + "text": token["text"], + "is_punct": token["is_punct"], + "prefix": cumulative_text, + "sentence": sentence, + "sent_words": sent_words, + }) + + # Pass 2: add hints with lookahead context + all_tokens = [] + for i, tok in enumerate(raw_tokens): + if tok["is_punct"]: + next_word = None + for j in range(i + 1, len(raw_tokens)): + if not raw_tokens[j]["is_punct"]: + next_word = raw_tokens[j]["text"] + break + space_after = next_word is not None + cap_next = bool(next_word and next_word[0].isupper()) + hints = { + "word_hints": engine.get_punct_hints( + tok["text"], space_after=space_after, cap_next=cap_next), + "phrase_hints": [], "fingerspell": False, + "punct_hints": [], "cap_hints": [], + } + else: + sent_words = tok["sent_words"] + word_idx = next((wi for wi, w in enumerate(sent_words) + if w.startswith(tok["text"])), 0) + hints = engine.get_all_hints( + tok["text"], context_words=sent_words, + context_index=word_idx) + + all_tokens.append({ + "word": tok["text"], + "prefix": tok["prefix"], + "is_punct": tok["is_punct"], + "index": len(all_tokens), + "word_hints": hints["word_hints"], + "phrase_hints": hints.get("phrase_hints", []), + "fingerspell": hints.get("fingerspell", False), + "punct_hints": hints.get("punct_hints", []), + "cap_hints": hints.get("cap_hints", []), + "sentence": tok["sentence"], + }) + + stat_words = [t["word"] for t in all_tokens] + + session_store[session_id] = { + "words": stat_words, + "user_id": uid, + "is_sentence": True, + "index": 0, + "streak": 0, + "best_streak": 0, + } + + return jsonify({ + "session_id": session_id, + "words": all_tokens, + "total": len(all_tokens), + "is_sentence": True, + }) + + +_TRAILING_PUNCT = re.compile(r'[.!?,;:"\'\)\]\}]+$') + + +def _strip_word_punctuation(word): + """Strip trailing punctuation for stats tracking, but keep contractions intact.""" + return _TRAILING_PUNCT.sub("", word) + + +@app.route("/api/word/submit", methods=["POST"]) +@login_required +def api_word_submit(): + uid = current_user_id() + engine = get_engine(uid) + data = request.get_json() + session_id = data.get("session_id") + word = data.get("word", "") + typed = data.get("typed", "") + time_ms = data.get("time_ms", 0) + word_index = data.get("word_index", None) + + if session_id not in session_store: + return jsonify({"error": "Session not found"}), 404 + + sess = session_store[session_id] + if sess.get("user_id") != uid: + return jsonify({"error": "Not your session"}), 403 + + correct = typed.strip() == word.strip() + stat_word = _strip_word_punctuation(word.strip()).lower() + strokes = engine.get_strokes(stat_word or word) + best_stroke = strokes[0] if strokes else "" + + db.record_attempt(uid, session_id, stat_word or word, best_stroke, typed.strip(), correct, time_ms) + + if correct: + sess["streak"] += 1 + sess["best_streak"] = max(sess["best_streak"], sess["streak"]) + else: + sess["streak"] = 0 + + if word_index is not None: + sess["index"] = word_index + 1 + + db_session = db.get_session(session_id) + words_attempted = db_session["words_attempted"] if db_session else 1 + words_correct = db_session["words_correct"] if db_session else (1 if correct else 0) + accuracy = words_correct / words_attempted if words_attempted > 0 else 0 + + word_chars = len(stat_word) if stat_word else len(typed.strip()) + wpm = (word_chars / 5) / (time_ms / 60000) if time_ms > 0 else 0 + + return jsonify({ + "correct": correct, + "expected": word, + "typed": typed.strip(), + "time_ms": time_ms, + "accuracy": accuracy, + "streak": sess["streak"], + "wpm": round(wpm, 1), + }) + + +@app.route("/api/session/end", methods=["POST"]) +@login_required +def api_session_end(): + uid = current_user_id() + data = request.get_json() + session_id = data.get("session_id") + + if session_id is None: + return jsonify({"error": "session_id required"}), 400 + + db.end_session(session_id) + stats = db.get_session_stats(session_id) + if not stats: + return jsonify({"error": "Session not found"}), 404 + + attempts = db.get_session_attempts(session_id) + + weakest = [] + for a in attempts: + if not a["correct"]: + weakest.append({ + "word": a["word"], + "typed": a.get("typed_text", ""), + "strokes_display": a["strokes"] or "", + "time_ms": a["time_ms"], + "correct": False, + }) + + if len(weakest) < 5: + correct_attempts = sorted( + [a for a in attempts if a["correct"]], + key=lambda x: x["time_ms"], + reverse=True, + ) + for a in correct_attempts: + if len(weakest) >= 5: + break + weakest.append({ + "word": a["word"], + "typed": a.get("typed_text", ""), + "strokes_display": a["strokes"] or "", + "time_ms": a["time_ms"], + "correct": True, + }) + + # Full per-word results for detailed accuracy review + all_results = [] + for a in attempts: + all_results.append({ + "word": a["word"], + "typed": a.get("typed_text", ""), + "correct": bool(a["correct"]), + "time_ms": a["time_ms"], + "strokes": a["strokes"] or "", + }) + + total_chars = sum(len(_strip_word_punctuation(a["word"] or "")) for a in attempts) + total_time = stats["total_time_ms"] + avg_wpm = (total_chars / 5) / (total_time / 60000) if total_time > 0 else 0 + + session_store.pop(session_id, None) + + return jsonify({ + "session_id": session_id, + "words_attempted": stats["words_attempted"], + "words_correct": stats["words_correct"], + "accuracy": stats["accuracy"], + "avg_wpm": round(avg_wpm, 1), + "total_time_ms": stats["total_time_ms"], + "best_streak": stats["best_streak"], + "weakest_words": weakest, + "all_results": all_results, + }) + + +# ── Settings API ── + + +@app.route("/api/settings", methods=["GET"]) +@login_required +def api_settings_get(): + return jsonify(db.get_user_settings(current_user_id())) + + +@app.route("/api/settings", methods=["POST"]) +@login_required +def api_settings_set(): + uid = current_user_id() + data = request.get_json() + if "words_per_session" in data: + val = data["words_per_session"] + if isinstance(val, int) and 10 <= val <= 200: + db.update_user_settings(uid, words_per_session=val) + return jsonify({"ok": True, "settings": db.get_user_settings(uid)}) + + +@app.route("/api/data/clear", methods=["POST"]) +@login_required +def api_data_clear(): + db.clear_user_data(current_user_id()) + return jsonify({"ok": True}) + + +# ── Dictionary Upload API ── + +ALLOWED_DICT_EXTENSIONS = {".json", ".py"} + + +@app.route("/api/dictionaries/upload-config", methods=["POST"]) +@login_required +def api_upload_config(): + uid = current_user_id() + + if "config" not in request.files: + return jsonify({"error": "No config file provided"}), 400 + + cfg_file = request.files["config"] + if not cfg_file.filename: + return jsonify({"error": "No file selected"}), 400 + + cfg_content = cfg_file.read().decode("utf-8", errors="replace") + entries = PloverEngine.parse_plover_config(cfg_content) + + if not entries: + return jsonify({"error": "No dictionaries found in config. Make sure it has a [System: English Stenotype] section."}), 400 + + user_dir = _get_user_dict_dir(uid) + os.makedirs(user_dir, exist_ok=True) + + cfg_path = os.path.join(user_dir, "plover.cfg") + with open(cfg_path, "w", encoding="utf-8") as f: + f.write(cfg_content) + + db.clear_user_dictionaries(uid) + + existing_files = set(os.listdir(user_dir)) if os.path.isdir(user_dir) else set() + + result_entries = [] + for entry in entries: + filename = entry["filename"] + safe = secure_filename(filename) + if not safe: + continue + + ext = os.path.splitext(safe)[1].lower() + if ext not in ALLOWED_DICT_EXTENSIONS: + continue + + uploaded = safe in existing_files + entry_count = 0 + if uploaded and ext == ".json": + try: + with open(os.path.join(user_dir, safe), "r", encoding="utf-8") as f: + entry_count = len(json.load(f)) + except Exception: + pass + + dict_id = db.add_user_dictionary( + uid, safe, filename, entry["order"], entry_count + ) + + result_entries.append({ + "id": dict_id, + "filename": safe, + "display_name": filename, + "order": entry["order"], + "enabled": entry["enabled"], + "uploaded": uploaded, + "entry_count": entry_count, + }) + + if any(e["uploaded"] for e in result_entries): + reload_engine(uid) + + return jsonify({"ok": True, "dictionaries": result_entries}) + + +@app.route("/api/dictionaries/upload-folder", methods=["POST"]) +@login_required +def api_upload_folder(): + """Receive plover.cfg + only the referenced dict files (pre-filtered client-side).""" + uid = current_user_id() + cfg_file = request.files.get("config") + if not cfg_file: + return jsonify({"error": "No plover.cfg provided"}), 400 + + cfg_content = cfg_file.read().decode("utf-8", errors="replace") + entries = PloverEngine.parse_plover_config(cfg_content) + if not entries: + return jsonify({"error": "No dictionaries found in plover.cfg."}), 400 + + user_dir = _get_user_dict_dir(uid) + os.makedirs(user_dir, exist_ok=True) + + with open(os.path.join(user_dir, "plover.cfg"), "w", encoding="utf-8") as out: + out.write(cfg_content) + + dict_files = request.files.getlist("dicts") + file_map = {} + for f in dict_files: + name = os.path.basename(f.filename or "") + if name: + file_map[name] = f + + db.clear_user_dictionaries(uid) + + result_entries = [] + saved = 0 + for entry in entries: + filename = entry["filename"] + safe = secure_filename(filename) + if not safe: + continue + ext = os.path.splitext(safe)[1].lower() + if ext not in ALLOWED_DICT_EXTENSIONS: + continue + + uploaded_file = file_map.get(filename) or file_map.get(safe) + found = False + entry_count = 0 + if uploaded_file: + dest = os.path.join(user_dir, safe) + uploaded_file.save(dest) + found = True + saved += 1 + if ext == ".json": + try: + with open(dest, "r", encoding="utf-8") as jf: + entry_count = len(json.load(jf)) + except Exception: + pass + + dict_id = db.add_user_dictionary( + uid, safe, filename, entry["order"], entry_count + ) + result_entries.append({ + "id": dict_id, + "filename": safe, + "display_name": filename, + "order": entry["order"], + "enabled": entry["enabled"], + "uploaded": found, + "entry_count": entry_count, + }) + + reload_engine(uid) + + missing = [e for e in result_entries if not e["uploaded"]] + return jsonify({ + "ok": True, + "dictionaries": result_entries, + "saved": saved, + "missing": len(missing), + }) + + +@app.route("/api/dictionaries/upload", methods=["POST"]) +@login_required +def api_upload_dicts(): + uid = current_user_id() + user_dir = _get_user_dict_dir(uid) + os.makedirs(user_dir, exist_ok=True) + + files = request.files.getlist("files") + if not files: + return jsonify({"error": "No files provided"}), 400 + + known = {d["filename"]: d for d in db.get_user_dictionaries(uid)} + + uploaded = [] + for f in files: + if not f.filename: + continue + safe = secure_filename(f.filename) + if not safe: + continue + ext = os.path.splitext(safe)[1].lower() + if ext not in ALLOWED_DICT_EXTENSIONS: + continue + + dest = os.path.join(user_dir, safe) + f.save(dest) + + entry_count = 0 + if ext == ".json": + try: + with open(dest, "r", encoding="utf-8") as jf: + entry_count = len(json.load(jf)) + except Exception: + pass + + if safe in known: + db.update_dictionary_entry_count(known[safe]["id"], uid, entry_count) + else: + order = max((d.get("dict_order", 0) for d in known.values()), default=-1) + 1 + db.add_user_dictionary(uid, safe, f.filename, order, entry_count) + + uploaded.append({"filename": safe, "entry_count": entry_count}) + + reload_engine(uid) + + all_dicts = db.get_user_dictionaries(uid) + existing_files = set(os.listdir(user_dir)) if os.path.isdir(user_dir) else set() + dict_list = [] + for d in all_dicts: + dict_list.append({ + "id": d["id"], + "filename": d["filename"], + "display_name": d["display_name"], + "order": d["dict_order"], + "enabled": bool(d["enabled"]), + "uploaded": d["filename"] in existing_files, + "entry_count": d["entry_count"], + }) + + return jsonify({"ok": True, "uploaded": uploaded, "dictionaries": dict_list}) + + +@app.route("/api/dictionaries", methods=["GET"]) +@login_required +def api_get_dicts(): + uid = current_user_id() + all_dicts = db.get_user_dictionaries(uid) + user_dir = _get_user_dict_dir(uid) + existing_files = set(os.listdir(user_dir)) if os.path.isdir(user_dir) else set() + + result = [] + for d in all_dicts: + result.append({ + "id": d["id"], + "filename": d["filename"], + "display_name": d["display_name"], + "order": d["dict_order"], + "enabled": bool(d["enabled"]), + "uploaded": d["filename"] in existing_files, + "entry_count": d["entry_count"], + }) + return jsonify(result) + + +@app.route("/api/dictionaries/<int:dict_id>/toggle", methods=["POST"]) +@login_required +def api_toggle_dict(dict_id): + uid = current_user_id() + data = request.get_json() + enabled = data.get("enabled", True) + db.toggle_dictionary(dict_id, uid, enabled) + reload_engine(uid) + return jsonify({"ok": True}) + + +@app.route("/api/dictionaries/<int:dict_id>", methods=["DELETE"]) +@login_required +def api_delete_dict(dict_id): + uid = current_user_id() + filename = db.delete_dictionary(dict_id, uid) + if filename: + fpath = os.path.join(_get_user_dict_dir(uid), filename) + if os.path.exists(fpath): + os.remove(fpath) + reload_engine(uid) + return jsonify({"ok": True}) + + +# ── Stats API ── + + +@app.route("/api/stats/overview") +@login_required +def api_stats_overview(): + uid = current_user_id() + raw = db.get_overview_stats(uid) + total_ms = raw.get("total_time_ms", 0) + return jsonify({ + "total_sessions": raw.get("total_sessions", 0), + "total_words": raw.get("total_words_practiced", 0), + "words_mastered": raw.get("words_mastered", 0), + "avg_accuracy": raw.get("overall_accuracy", 0) * 100, + "avg_wpm": 0, + "total_time_seconds": total_ms // 1000, + "best_streak": raw.get("current_streak", 0), + }) + + +@app.route("/api/stats/daily") +@login_required +def api_stats_daily(): + uid = current_user_id() + days = request.args.get("days", 30, type=int) + raw = db.get_daily_stats(uid, days=days) + result = [] + for d in raw: + wp = d.get("words_practiced", 0) + wc = d.get("words_correct", 0) + result.append({ + "date": d.get("date", ""), + "words_total": wp, + "words_correct": wc, + "accuracy": (wc / wp * 100) if wp > 0 else 0, + "avg_wpm": 0, + "sessions": d.get("sessions", 0), + }) + return jsonify(result) + + +@app.route("/api/stats/heatmap") +@login_required +def api_stats_heatmap(): + return jsonify(db.get_heatmap_data(current_user_id())) + + +@app.route("/api/stats/words") +@login_required +def api_stats_words(): + uid = current_user_id() + sort_by = request.args.get("sort", "accuracy") + order = request.args.get("order", "asc") + limit = request.args.get("limit", 50, type=int) + + all_stats = db.get_word_stats(uid) + result = [] + for ws in all_stats: + att = ws.get("attempts", 0) + cor = ws.get("correct", 0) + result.append({ + "word": ws.get("word", ""), + "strokes": ws.get("strokes", ""), + "attempts": att, + "correct": cor, + "accuracy": (cor / att * 100) if att > 0 else 0, + "avg_time": ws.get("avg_time_ms", 0), + }) + + reverse = order == "desc" + if sort_by in ("accuracy", "attempts", "correct", "avg_time"): + result.sort(key=lambda x: x.get(sort_by, 0), reverse=reverse) + elif sort_by == "word": + result.sort(key=lambda x: x.get("word", ""), reverse=reverse) + else: + result.sort(key=lambda x: x.get("accuracy", 0), reverse=reverse) + + return jsonify(result[:limit]) + + +@app.route("/api/stats/sessions") +@login_required +def api_stats_sessions(): + uid = current_user_id() + limit = request.args.get("limit", 20, type=int) + raw = db.get_all_sessions(uid, limit=limit) + result = [] + for s in raw: + wa = s.get("words_attempted", 0) + wc = s.get("words_correct", 0) + total_ms = s.get("total_time_ms", 0) + total_chars = wa * 5 + wpm = (total_chars / 5) / (total_ms / 60000) if total_ms > 0 else 0 + started = s.get("started_at") or "" + result.append({ + "date": started[:10] if started else "", + "date_str": started[:10] if started else "", + "mode": s.get("mode", ""), + "words_attempted": wa, + "words_correct": wc, + "accuracy": (wc / wa * 100) if wa > 0 else 0, + "wpm": round(wpm, 1), + "duration_seconds": total_ms // 1000, + }) + return jsonify(result) + + +@app.route("/api/lessons") +@login_required +def api_lessons(): + uid = current_user_id() + lessons = get_lesson_list() + for l in lessons: + lesson_def = get_lesson_by_id(l["id"]) + if lesson_def: + word_list = get_lesson_word_list(lesson_def) + if word_list: + prog = db.get_lesson_progress(uid, word_list) + l["progress"] = { + "total_words": prog["total_words"], + "practiced": prog["practiced"], + "mastered": prog["mastered"], + "avg_wpm": prog["avg_wpm"], + "avg_accuracy": prog["avg_accuracy"], + } + return jsonify(lessons) + + +@app.route("/api/lessons/<lesson_id>/progress") +@login_required +def api_lesson_progress(lesson_id): + uid = current_user_id() + lesson = get_lesson_by_id(lesson_id) + if not lesson: + return jsonify({"error": "Lesson not found"}), 404 + word_list = get_lesson_word_list(lesson) + if not word_list: + return jsonify({"error": "No word list for this lesson"}), 400 + progress = db.get_lesson_progress(uid, word_list) + return jsonify(progress) + + +# ── Helpers ── + + +def _select_words(uid, engine, mode, count): + has_words = bool(engine.practice_words) + + if mode == "common": + if has_words: + words = [w for w in TOP_WORDS[:500] if engine.word_exists(w)] + else: + words = list(TOP_WORDS[:500]) + return words[:count] + elif mode == "random": + if has_words: + words = engine.practice_words + return random.sample(words, min(count, len(words))) + return list(TOP_WORDS[:count]) + elif mode == "new": + practiced = db.get_practiced_words(uid) + if has_words: + available = [w for w in engine.practice_words if w not in practiced] + else: + available = [w for w in TOP_WORDS[:500] if w not in practiced] + return random.sample(available, min(count, len(available))) if available else [] + elif mode == "weak": + word_stats = db.get_word_stats(uid) + weak = [] + for ws in word_stats: + if ws["attempts"] >= 3: + accuracy = ws["correct"] / ws["attempts"] + weak.append((accuracy, ws["word"])) + weak.sort() + words = [w for _, w in weak[:count]] + if len(words) < count and has_words: + extra = random.sample(engine.practice_words, min(count - len(words), len(engine.practice_words))) + words.extend(extra) + return words[:count] + elif mode == "review": + words = db.get_words_due_for_review(uid, count) + if len(words) < count and has_words: + extra = random.sample(engine.practice_words, min(count - len(words), len(engine.practice_words))) + words.extend(extra) + return words[:count] + elif mode == "speed": + if has_words: + words = engine.single_stroke_words + return random.sample(words, min(count, len(words))) + return list(TOP_WORDS[:count]) + elif mode == "phrases": + phrases = engine.all_phrases + engine.generate_phrasing_phrases(200) + return random.sample(phrases, min(count, len(phrases))) if phrases else [] + elif mode == "sentences": + return get_sentences("intermediate", count=10) + else: + if has_words: + words = engine.practice_words + return random.sample(words, min(count, len(words))) + return list(TOP_WORDS[:count]) + + +print("Initializing StenoDojo...") +db_check = Database() +from sentence_generator import _load_corpus +_load_corpus() +print("Ready!\n") + +if __name__ == "__main__": + app.run(debug=True, port=5000) diff --git a/database.py b/database.py @@ -0,0 +1,696 @@ +"""Database module for multi-user stenography practice app.""" + +import sqlite3 +from contextlib import contextmanager +from datetime import datetime, timedelta + +from spaced_repetition import calculate_review + +DB_PATH = "/mnt/M2_1/dev/plover-practice/plover_practice.db" + + +class Database: + def __init__(self, db_path=None): + self.db_path = db_path or DB_PATH + self._init_db() + + @contextmanager + def _get_conn(self): + conn = sqlite3.connect(self.db_path, check_same_thread=False) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + def _init_db(self): + with self._get_conn() as conn: + tables = {r[0] for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall()} + + if "sessions" not in tables: + self._create_tables(conn) + return + + session_cols = {r[1] for r in conn.execute("PRAGMA table_info(sessions)").fetchall()} + if "user_id" not in session_cols: + self._migrate_to_v2(conn) + else: + self._create_tables(conn) + + def _create_tables(self, conn): + conn.executescript(""" + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE NOT NULL, + name TEXT, + avatar_url TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + mode TEXT NOT NULL, + started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + ended_at TIMESTAMP, + words_attempted INTEGER DEFAULT 0, + words_correct INTEGER DEFAULT 0, + total_time_ms INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS attempts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER REFERENCES sessions(id), + word TEXT NOT NULL, + strokes TEXT, + typed_text TEXT, + correct BOOLEAN NOT NULL, + time_ms INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS word_stats ( + user_id INTEGER NOT NULL REFERENCES users(id), + word TEXT NOT NULL, + strokes TEXT, + attempts INTEGER DEFAULT 0, + correct INTEGER DEFAULT 0, + avg_time_ms REAL DEFAULT 0, + last_seen TIMESTAMP, + next_review TIMESTAMP, + ease_factor REAL DEFAULT 2.5, + interval_days REAL DEFAULT 0, + repetitions INTEGER DEFAULT 0, + PRIMARY KEY (user_id, word) + ); + + CREATE TABLE IF NOT EXISTS daily_stats ( + user_id INTEGER NOT NULL REFERENCES users(id), + date TEXT NOT NULL, + words_practiced INTEGER DEFAULT 0, + words_correct INTEGER DEFAULT 0, + total_time_ms INTEGER DEFAULT 0, + sessions INTEGER DEFAULT 0, + PRIMARY KEY (user_id, date) + ); + + CREATE TABLE IF NOT EXISTS user_dictionaries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + filename TEXT NOT NULL, + display_name TEXT, + dict_order INTEGER NOT NULL DEFAULT 0, + enabled BOOLEAN DEFAULT 1, + entry_count INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS user_settings ( + user_id INTEGER PRIMARY KEY REFERENCES users(id), + words_per_session INTEGER DEFAULT 50 + ); + + CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); + CREATE INDEX IF NOT EXISTS idx_attempts_session ON attempts(session_id); + CREATE INDEX IF NOT EXISTS idx_attempts_word ON attempts(word); + CREATE INDEX IF NOT EXISTS idx_word_stats_review ON word_stats(user_id, next_review); + CREATE INDEX IF NOT EXISTS idx_daily_stats_user ON daily_stats(user_id, date); + CREATE INDEX IF NOT EXISTS idx_user_dicts ON user_dictionaries(user_id, dict_order); + """) + + def _migrate_to_v2(self, conn): + print("Migrating database to multi-user schema...") + + conn.execute(""" + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE NOT NULL, + name TEXT, + avatar_url TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + conn.execute("INSERT OR IGNORE INTO users (id, email, name) VALUES (1, 'local@stenodojo', 'Local User')") + + session_cols = {r[1] for r in conn.execute("PRAGMA table_info(sessions)").fetchall()} + if "user_id" not in session_cols: + conn.execute("ALTER TABLE sessions ADD COLUMN user_id INTEGER DEFAULT 1") + + ws_cols = {r[1] for r in conn.execute("PRAGMA table_info(word_stats)").fetchall()} + if "user_id" not in ws_cols: + conn.execute(""" + CREATE TABLE word_stats_v2 ( + user_id INTEGER NOT NULL DEFAULT 1 REFERENCES users(id), + word TEXT NOT NULL, + strokes TEXT, + attempts INTEGER DEFAULT 0, + correct INTEGER DEFAULT 0, + avg_time_ms REAL DEFAULT 0, + last_seen TIMESTAMP, + next_review TIMESTAMP, + ease_factor REAL DEFAULT 2.5, + interval_days REAL DEFAULT 0, + repetitions INTEGER DEFAULT 0, + PRIMARY KEY (user_id, word) + ) + """) + conn.execute(""" + INSERT INTO word_stats_v2 (user_id, word, strokes, attempts, correct, avg_time_ms, + last_seen, next_review, ease_factor, interval_days, repetitions) + SELECT 1, word, strokes, attempts, correct, avg_time_ms, + last_seen, next_review, ease_factor, interval_days, repetitions + FROM word_stats + """) + conn.execute("DROP TABLE word_stats") + conn.execute("ALTER TABLE word_stats_v2 RENAME TO word_stats") + + ds_cols = {r[1] for r in conn.execute("PRAGMA table_info(daily_stats)").fetchall()} + if "user_id" not in ds_cols: + conn.execute(""" + CREATE TABLE daily_stats_v2 ( + user_id INTEGER NOT NULL DEFAULT 1 REFERENCES users(id), + date TEXT NOT NULL, + words_practiced INTEGER DEFAULT 0, + words_correct INTEGER DEFAULT 0, + total_time_ms INTEGER DEFAULT 0, + sessions INTEGER DEFAULT 0, + PRIMARY KEY (user_id, date) + ) + """) + conn.execute(""" + INSERT INTO daily_stats_v2 (user_id, date, words_practiced, words_correct, total_time_ms, sessions) + SELECT 1, date, words_practiced, words_correct, total_time_ms, sessions + FROM daily_stats + """) + conn.execute("DROP TABLE daily_stats") + conn.execute("ALTER TABLE daily_stats_v2 RENAME TO daily_stats") + + conn.executescript(""" + CREATE TABLE IF NOT EXISTS user_dictionaries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + filename TEXT NOT NULL, + display_name TEXT, + dict_order INTEGER NOT NULL DEFAULT 0, + enabled BOOLEAN DEFAULT 1, + entry_count INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS user_settings ( + user_id INTEGER PRIMARY KEY REFERENCES users(id), + words_per_session INTEGER DEFAULT 50 + ); + CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); + CREATE INDEX IF NOT EXISTS idx_word_stats_review ON word_stats(user_id, next_review); + CREATE INDEX IF NOT EXISTS idx_daily_stats_user ON daily_stats(user_id, date); + CREATE INDEX IF NOT EXISTS idx_user_dicts ON user_dictionaries(user_id, dict_order); + """) + + print("Migration complete.") + + # ── User methods ── + + def get_or_create_user(self, email, name=None, avatar_url=None): + with self._get_conn() as conn: + row = conn.execute("SELECT * FROM users WHERE email = ?", (email,)).fetchone() + if row: + if name or avatar_url: + conn.execute( + "UPDATE users SET name = COALESCE(?, name), avatar_url = COALESCE(?, avatar_url) WHERE id = ?", + (name, avatar_url, row["id"]), + ) + return dict(conn.execute("SELECT * FROM users WHERE id = ?", (row["id"],)).fetchone()) + cursor = conn.execute( + "INSERT INTO users (email, name, avatar_url) VALUES (?, ?, ?)", + (email, name, avatar_url), + ) + return {"id": cursor.lastrowid, "email": email, "name": name, "avatar_url": avatar_url} + + def get_user(self, user_id): + with self._get_conn() as conn: + row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() + return dict(row) if row else None + + def ensure_dev_user(self): + return self.get_or_create_user("dev@stenodojo.local", name="Dev User") + + # ── User Settings ── + + def get_user_settings(self, user_id): + with self._get_conn() as conn: + row = conn.execute("SELECT * FROM user_settings WHERE user_id = ?", (user_id,)).fetchone() + if row: + return dict(row) + return {"user_id": user_id, "words_per_session": 50} + + def update_user_settings(self, user_id, **kwargs): + allowed = {"words_per_session"} + kwargs = {k: v for k, v in kwargs.items() if k in allowed} + if not kwargs: + return + with self._get_conn() as conn: + existing = conn.execute("SELECT 1 FROM user_settings WHERE user_id = ?", (user_id,)).fetchone() + if existing: + sets = ", ".join(f"{k} = ?" for k in kwargs) + conn.execute(f"UPDATE user_settings SET {sets} WHERE user_id = ?", (*kwargs.values(), user_id)) + else: + kwargs["user_id"] = user_id + cols = ", ".join(kwargs.keys()) + placeholders = ", ".join("?" * len(kwargs)) + conn.execute(f"INSERT INTO user_settings ({cols}) VALUES ({placeholders})", tuple(kwargs.values())) + + # ── Dictionary management ── + + def get_user_dictionaries(self, user_id): + with self._get_conn() as conn: + rows = conn.execute( + "SELECT * FROM user_dictionaries WHERE user_id = ? ORDER BY dict_order", + (user_id,), + ).fetchall() + return [dict(r) for r in rows] + + def add_user_dictionary(self, user_id, filename, display_name, dict_order, entry_count=0): + with self._get_conn() as conn: + cursor = conn.execute( + """INSERT INTO user_dictionaries (user_id, filename, display_name, dict_order, entry_count) + VALUES (?, ?, ?, ?, ?)""", + (user_id, filename, display_name, dict_order, entry_count), + ) + return cursor.lastrowid + + def clear_user_dictionaries(self, user_id): + with self._get_conn() as conn: + conn.execute("DELETE FROM user_dictionaries WHERE user_id = ?", (user_id,)) + + def toggle_dictionary(self, dict_id, user_id, enabled): + with self._get_conn() as conn: + conn.execute( + "UPDATE user_dictionaries SET enabled = ? WHERE id = ? AND user_id = ?", + (enabled, dict_id, user_id), + ) + + def delete_dictionary(self, dict_id, user_id): + with self._get_conn() as conn: + row = conn.execute( + "SELECT filename FROM user_dictionaries WHERE id = ? AND user_id = ?", + (dict_id, user_id), + ).fetchone() + if row: + conn.execute("DELETE FROM user_dictionaries WHERE id = ? AND user_id = ?", (dict_id, user_id)) + return row["filename"] + return None + + def update_dictionary_entry_count(self, dict_id, user_id, count): + with self._get_conn() as conn: + conn.execute( + "UPDATE user_dictionaries SET entry_count = ? WHERE id = ? AND user_id = ?", + (count, dict_id, user_id), + ) + + # ── Session methods ── + + def create_session(self, user_id, mode): + with self._get_conn() as conn: + cursor = conn.execute( + "INSERT INTO sessions (user_id, mode) VALUES (?, ?)", + (user_id, mode), + ) + return cursor.lastrowid + + def end_session(self, session_id): + with self._get_conn() as conn: + conn.execute( + "UPDATE sessions SET ended_at = CURRENT_TIMESTAMP WHERE id = ?", + (session_id,), + ) + + def record_attempt(self, user_id, session_id, word, strokes, typed_text, correct, time_ms): + with self._get_conn() as conn: + conn.execute( + """INSERT INTO attempts (session_id, word, strokes, typed_text, correct, time_ms) + VALUES (?, ?, ?, ?, ?, ?)""", + (session_id, word, strokes, typed_text, correct, time_ms), + ) + + conn.execute( + """UPDATE sessions SET + words_attempted = words_attempted + 1, + words_correct = words_correct + ?, + total_time_ms = total_time_ms + ? + WHERE id = ?""", + (1 if correct else 0, time_ms, session_id), + ) + + existing = conn.execute( + "SELECT * FROM word_stats WHERE user_id = ? AND word = ?", (user_id, word) + ).fetchone() + + now = datetime.utcnow().isoformat() + + if existing: + old_avg = existing["avg_time_ms"] + old_count = existing["attempts"] + new_avg = ((old_avg * old_count) + time_ms) / (old_count + 1) + + new_ef, new_interval, new_reps = calculate_review( + correct, time_ms, + existing["ease_factor"], + existing["interval_days"], + existing["repetitions"], + ) + next_review = (datetime.utcnow() + timedelta(days=new_interval)).isoformat() + + conn.execute( + """UPDATE word_stats SET + strokes = ?, attempts = attempts + 1, correct = correct + ?, + avg_time_ms = ?, last_seen = ?, next_review = ?, + ease_factor = ?, interval_days = ?, repetitions = ? + WHERE user_id = ? AND word = ?""", + (strokes, 1 if correct else 0, new_avg, now, + next_review, new_ef, new_interval, new_reps, user_id, word), + ) + else: + new_ef, new_interval, new_reps = calculate_review(correct, time_ms, 2.5, 0, 0) + next_review = (datetime.utcnow() + timedelta(days=new_interval)).isoformat() + + conn.execute( + """INSERT INTO word_stats + (user_id, word, strokes, attempts, correct, avg_time_ms, + last_seen, next_review, ease_factor, interval_days, repetitions) + VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)""", + (user_id, word, strokes, 1 if correct else 0, float(time_ms), + now, next_review, new_ef, new_interval, new_reps), + ) + + today = datetime.utcnow().strftime("%Y-%m-%d") + existing_daily = conn.execute( + "SELECT * FROM daily_stats WHERE user_id = ? AND date = ?", (user_id, today) + ).fetchone() + + if existing_daily: + conn.execute( + """UPDATE daily_stats SET + words_practiced = words_practiced + 1, + words_correct = words_correct + ?, + total_time_ms = total_time_ms + ? + WHERE user_id = ? AND date = ?""", + (1 if correct else 0, time_ms, user_id, today), + ) + else: + conn.execute( + """INSERT INTO daily_stats (user_id, date, words_practiced, words_correct, total_time_ms, sessions) + VALUES (?, ?, 1, ?, ?, 0)""", + (user_id, today, 1 if correct else 0, time_ms), + ) + + def get_session(self, session_id): + with self._get_conn() as conn: + row = conn.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone() + return dict(row) if row else None + + def get_session_attempts(self, session_id): + with self._get_conn() as conn: + rows = conn.execute( + "SELECT * FROM attempts WHERE session_id = ? ORDER BY created_at", + (session_id,), + ).fetchall() + return [dict(r) for r in rows] + + def get_session_stats(self, session_id): + with self._get_conn() as conn: + session = conn.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone() + if not session: + return None + + attempts = conn.execute( + "SELECT correct, time_ms FROM attempts WHERE session_id = ? ORDER BY created_at", + (session_id,), + ).fetchall() + + words_attempted = len(attempts) + words_correct = sum(1 for a in attempts if a["correct"]) + total_time = sum(a["time_ms"] for a in attempts) + accuracy = words_correct / words_attempted if words_attempted > 0 else 0 + + best_streak = 0 + current_streak = 0 + for a in attempts: + if a["correct"]: + current_streak += 1 + best_streak = max(best_streak, current_streak) + else: + current_streak = 0 + + return { + "words_attempted": words_attempted, + "words_correct": words_correct, + "accuracy": accuracy, + "avg_time_ms": total_time / words_attempted if words_attempted > 0 else 0, + "total_time_ms": total_time, + "best_streak": best_streak, + } + + def get_word_stats(self, user_id, word=None): + with self._get_conn() as conn: + if word: + row = conn.execute( + "SELECT * FROM word_stats WHERE user_id = ? AND word = ?", (user_id, word) + ).fetchone() + return dict(row) if row else None + rows = conn.execute( + "SELECT * FROM word_stats WHERE user_id = ? ORDER BY last_seen DESC", (user_id,) + ).fetchall() + return [dict(r) for r in rows] + + def get_words_due_for_review(self, user_id, count=25): + now = datetime.utcnow().isoformat() + with self._get_conn() as conn: + rows = conn.execute( + """SELECT word FROM word_stats + WHERE user_id = ? AND next_review <= ? + ORDER BY next_review ASC LIMIT ?""", + (user_id, now, count), + ).fetchall() + return [r["word"] for r in rows] + + def get_weakest_words(self, user_id, count=25): + with self._get_conn() as conn: + rows = conn.execute( + """SELECT word, attempts, correct, + CAST(correct AS REAL) / attempts AS accuracy + FROM word_stats + WHERE user_id = ? AND attempts >= 3 + ORDER BY accuracy ASC, attempts DESC LIMIT ?""", + (user_id, count), + ).fetchall() + return [dict(r) for r in rows] + + def get_practiced_words(self, user_id): + with self._get_conn() as conn: + rows = conn.execute("SELECT word FROM word_stats WHERE user_id = ?", (user_id,)).fetchall() + return {r["word"] for r in rows} + + def get_daily_stats(self, user_id, days=30): + start_date = (datetime.utcnow() - timedelta(days=days)).strftime("%Y-%m-%d") + with self._get_conn() as conn: + rows = conn.execute( + "SELECT * FROM daily_stats WHERE user_id = ? AND date >= ? ORDER BY date DESC", + (user_id, start_date), + ).fetchall() + return [dict(r) for r in rows] + + def get_heatmap_data(self, user_id): + start_date = (datetime.utcnow() - timedelta(days=365)).strftime("%Y-%m-%d") + with self._get_conn() as conn: + rows = conn.execute( + """SELECT date, words_practiced AS count + FROM daily_stats WHERE user_id = ? AND date >= ? ORDER BY date""", + (user_id, start_date), + ).fetchall() + return [dict(r) for r in rows] + + def get_overview_stats(self, user_id): + with self._get_conn() as conn: + totals = conn.execute( + """SELECT + COALESCE(SUM(words_practiced), 0) AS total_words, + COALESCE(SUM(words_correct), 0) AS total_correct, + COALESCE(SUM(total_time_ms), 0) AS total_time_ms, + COALESCE(SUM(sessions), 0) AS total_sessions, + COUNT(*) AS days_practiced + FROM daily_stats WHERE user_id = ?""", + (user_id,), + ).fetchone() + + streak = self._calculate_streak(conn, user_id) + mastered = self.get_words_mastered_count(user_id) + + unique_words = conn.execute( + "SELECT COUNT(*) AS count FROM word_stats WHERE user_id = ?", (user_id,) + ).fetchone()["count"] + + total_words = totals["total_words"] + total_correct = totals["total_correct"] + + return { + "total_words_practiced": total_words, + "total_correct": total_correct, + "overall_accuracy": total_correct / total_words if total_words > 0 else 0, + "total_time_ms": totals["total_time_ms"], + "total_sessions": totals["total_sessions"], + "days_practiced": totals["days_practiced"], + "current_streak": streak, + "words_mastered": mastered, + "unique_words": unique_words, + } + + def _calculate_streak(self, conn, user_id): + today = datetime.utcnow().date() + streak = 0 + current_date = today + while True: + date_str = current_date.strftime("%Y-%m-%d") + row = conn.execute( + "SELECT 1 FROM daily_stats WHERE user_id = ? AND date = ? AND words_practiced > 0", + (user_id, date_str), + ).fetchone() + if row: + streak += 1 + current_date -= timedelta(days=1) + else: + break + return streak + + def get_recent_sessions(self, user_id, limit=5): + with self._get_conn() as conn: + rows = conn.execute( + "SELECT * FROM sessions WHERE user_id = ? ORDER BY started_at DESC LIMIT ?", + (user_id, limit), + ).fetchall() + return [dict(r) for r in rows] + + def get_all_sessions(self, user_id, limit=50): + with self._get_conn() as conn: + rows = conn.execute( + "SELECT * FROM sessions WHERE user_id = ? ORDER BY started_at DESC LIMIT ?", + (user_id, limit), + ).fetchall() + return [dict(r) for r in rows] + + def get_words_mastered_count(self, user_id): + with self._get_conn() as conn: + row = conn.execute( + """SELECT COUNT(*) AS count FROM word_stats + WHERE user_id = ? AND attempts >= 5 + AND CAST(correct AS REAL) / attempts >= 0.9""", + (user_id,), + ).fetchone() + return row["count"] + + def increment_daily_sessions(self, user_id): + today = datetime.utcnow().strftime("%Y-%m-%d") + with self._get_conn() as conn: + existing = conn.execute( + "SELECT * FROM daily_stats WHERE user_id = ? AND date = ?", (user_id, today) + ).fetchone() + if existing: + conn.execute( + "UPDATE daily_stats SET sessions = sessions + 1 WHERE user_id = ? AND date = ?", + (user_id, today), + ) + else: + conn.execute( + """INSERT INTO daily_stats (user_id, date, words_practiced, words_correct, total_time_ms, sessions) + VALUES (?, ?, 0, 0, 0, 1)""", + (user_id, today), + ) + + def get_lesson_progress(self, user_id, word_list): + """Compute mastery progress for a set of words. + + Returns dict with: + total_words, practiced, mastered, avg_wpm, avg_accuracy, + per-word breakdown of attempts/accuracy/avg_time. + + Mastery target: 95% accuracy and avg_time < 1500ms (~200 WPM for + a 5-char word) with at least 5 attempts. + """ + if not word_list: + return {"total_words": 0, "practiced": 0, "mastered": 0, + "avg_wpm": 0, "avg_accuracy": 0, "words": []} + + lower_words = [w.lower() for w in word_list] + with self._get_conn() as conn: + placeholders = ",".join("?" * len(lower_words)) + rows = conn.execute( + f"""SELECT word, attempts, correct, avg_time_ms + FROM word_stats + WHERE user_id = ? AND LOWER(word) IN ({placeholders})""", + (user_id, *lower_words), + ).fetchall() + + stats_map = {r["word"].lower(): dict(r) for r in rows} + + practiced = 0 + mastered = 0 + total_time = 0 + total_correct = 0 + total_attempts = 0 + words = [] + + for w in lower_words: + s = stats_map.get(w) + if s and s["attempts"] > 0: + practiced += 1 + acc = s["correct"] / s["attempts"] + total_correct += s["correct"] + total_attempts += s["attempts"] + total_time += s["avg_time_ms"] * s["attempts"] + if s["attempts"] >= 5 and acc >= 0.95 and s["avg_time_ms"] < 1500: + mastered += 1 + words.append({ + "word": w, + "attempts": s["attempts"], + "accuracy": round(acc * 100, 1), + "avg_time_ms": round(s["avg_time_ms"]), + }) + else: + words.append({ + "word": w, + "attempts": 0, + "accuracy": 0, + "avg_time_ms": 0, + }) + + avg_accuracy = (total_correct / total_attempts * 100) if total_attempts > 0 else 0 + avg_time = (total_time / total_attempts) if total_attempts > 0 else 0 + avg_wpm = (5 / (avg_time / 60000)) if avg_time > 0 else 0 + + return { + "total_words": len(lower_words), + "practiced": practiced, + "mastered": mastered, + "avg_wpm": round(avg_wpm, 1), + "avg_accuracy": round(avg_accuracy, 1), + "words": words, + } + + def clear_user_data(self, user_id): + with self._get_conn() as conn: + session_ids = [r["id"] for r in conn.execute( + "SELECT id FROM sessions WHERE user_id = ?", (user_id,) + ).fetchall()] + if session_ids: + placeholders = ",".join("?" * len(session_ids)) + conn.execute(f"DELETE FROM attempts WHERE session_id IN ({placeholders})", session_ids) + conn.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,)) + conn.execute("DELETE FROM word_stats WHERE user_id = ?", (user_id,)) + conn.execute("DELETE FROM daily_stats WHERE user_id = ?", (user_id,)) diff --git a/dictionary.py b/dictionary.py @@ -0,0 +1,353 @@ +"""Plover dictionary loader and word selection for stenography practice.""" + +import configparser +import json +import os +import random +import re +from pathlib import Path + + +# Top 500 most common English words for frequency ranking +COMMON_WORDS = [ + "the", "be", "to", "of", "and", "a", "in", "that", "have", "i", + "it", "for", "not", "on", "with", "he", "as", "you", "do", "at", + "this", "but", "his", "by", "from", "they", "we", "her", "she", "or", + "an", "will", "my", "one", "all", "would", "there", "their", "what", "so", + "up", "out", "if", "about", "who", "get", "which", "go", "me", "when", + "make", "can", "like", "time", "no", "just", "him", "know", "take", "people", + "into", "year", "your", "good", "some", "could", "them", "see", "other", "than", + "then", "now", "look", "only", "come", "its", "over", "think", "also", "back", + "after", "use", "two", "how", "our", "work", "first", "well", "way", "even", + "new", "want", "because", "any", "these", "give", "day", "most", "us", "great", + "find", "here", "thing", "many", "still", "between", "long", "own", "say", "help", + "through", "much", "before", "line", "right", "too", "mean", "old", "same", "tell", + "boy", "did", "three", "call", "hand", "high", "keep", "last", "let", "begin", + "seem", "country", "place", "never", "run", "read", "why", "while", "world", "little", + "house", "city", "add", "story", "set", "change", "move", "play", "learn", "should", + "home", "big", "end", "put", "ask", "under", "turn", "show", "try", "life", + "off", "every", "near", "head", "food", "far", "thought", "few", "left", "school", + "open", "next", "hard", "along", "both", "might", "must", "start", "point", "kind", + "need", "children", "number", "each", "side", "part", "later", "word", "always", "those", + "feel", "talk", "book", "early", "went", "got", "been", "name", "live", "page", + "group", "carry", "took", "young", "often", "important", "until", "girl", "close", "night", + "real", "almost", "light", "above", "man", "woman", "small", "water", "write", "bring", + "once", "face", "family", "white", "air", "room", "mother", "area", "money", "state", + "stop", "leave", "power", "door", "stand", "form", "car", "another", "problem", "child", + "human", "service", "eye", "case", "public", "question", "look", "body", "become", "sure", + "already", "several", "minute", "second", "war", "better", "during", "number", "course", "against", + "different", "system", "father", "away", "moment", "company", "may", "level", "enough", "develop", + "business", "study", "game", "sit", "yes", "nothing", "age", "government", "half", "large", + "top", "social", "possible", "program", "short", "sense", "best", "full", "result", "idea", + "among", "yet", "week", "hear", "love", "plan", "death", "such", "less", "done", + "together", "hand", "high", "late", "hold", "free", "produce", "land", "heart", "reason", + "cut", "since", "today", "force", "report", "believe", "person", "town", "build", "control", + "student", "past", "clear", "true", "health", "cost", "able", "include", "accept", "allow", + "drive", "process", "front", "law", "grow", "type", "value", "meet", "four", "ever", + "main", "support", "view", "street", "fact", "lot", "pay", "art", "care", "simple", + "deal", "voice", "act", "issue", "miss", "present", "red", "low", "month", "class", + "offer", "rule", "step", "pass", "appear", "record", "table", "morning", "model", "interest", + "bit", "fine", "oil", "stay", "bed", "order", "reach", "rest", "whole", "continue", + "return", "effect", "happy", "dark", "increase", "quite", "single", "strong", "require", "rather", + "create", "expect", "market", "term", "paper", "local", "effort", "test", "wrong", "common", + "watch", "draw", "provide", "describe", "remember", "consider", "nation", "member", "break", "across", + "note", "fish", "picture", "animal", "source", "rock", "field", "perhaps", "window", "nature", + "horse", "material", "happen", "lost", "ball", "join", "five", "upon", "fall", "cover", + "explain", "hot", "rate", "road", "plant", "black", "example", "check", "fire", "cold", + "language", "sit", "star", "color", "decide", "space", "blue", "deep", "rock", "tree", + "ground", "song", "final", "range", "building", "action", "sound", "river", "complete", "cause", + "surface", "ocean", "object", "sleep", "feeling", "thus", "music", "million", "wind", "pain", + "figure", "middle", "position", "outside", "wide", "alone", "glass", "store", "piece", "notice", + "sign", "west", "science", "center", "test", "press", "travel", "green", "sort", "total", + "garden", "chance", "south", "safe", "edge", "forget", "dinner", "gold", "chair", +] + + +# Steno order for parsing strokes +STENO_ORDER = "#STKPWHRAO*EUFRPBLGTSDZ" + + +class PloverDictionary: + """Loads and manages Plover steno dictionaries.""" + + def __init__(self, config_path=None): + if config_path is None: + config_path = os.path.expanduser("~/.config/plover/plover.cfg") + + self.config_path = config_path + self.config_dir = os.path.dirname(config_path) + self.stroke_to_word = {} + self.word_to_strokes = {} + self._practice_words = [] + self._single_stroke_words = [] + self._common_word_set = set() + + self._load_dictionaries() + self._build_word_index() + + def _load_dictionaries(self): + """Load all enabled JSON dictionaries from plover.cfg.""" + config = configparser.ConfigParser() + config.read(self.config_path) + + section = "System: English Stenotype" + if not config.has_section(section): + print(f"Warning: Section [{section}] not found in config") + return + + dicts_json = config.get(section, "dictionaries", fallback="[]") + dict_entries = json.loads(dicts_json) + + # Dictionaries listed first have higher priority + # Process in order — first dict wins for stroke_to_word + loaded_count = 0 + for entry in dict_entries: + if not entry.get("enabled", False): + continue + + path = entry["path"] + + # Skip non-JSON dictionaries (Python plugins, etc.) + if not path.endswith(".json"): + continue + + full_path = os.path.join(self.config_dir, path) + if not os.path.exists(full_path): + print(f"Warning: Dictionary not found: {full_path}") + continue + + try: + with open(full_path, "r", encoding="utf-8") as f: + data = json.load(f) + + dict_name = os.path.basename(path) + for stroke, translation in data.items(): + # First dict wins for stroke_to_word + if stroke not in self.stroke_to_word: + self.stroke_to_word[stroke] = translation + + loaded_count += 1 + print(f" Loaded: {path} ({len(data)} entries)") + + except (json.JSONDecodeError, OSError) as e: + print(f"Warning: Failed to load {path}: {e}") + + print(f" Total dictionaries loaded: {loaded_count}") + print(f" Total stroke entries: {len(self.stroke_to_word)}") + + def _build_word_index(self): + """Build word_to_strokes mapping and practice word lists.""" + # Build reverse mapping: word -> list of strokes + raw_word_to_strokes = {} + + for stroke, translation in self.stroke_to_word.items(): + if not self.is_practice_word(translation): + continue + + word_key = translation.lower().strip() + if word_key not in raw_word_to_strokes: + raw_word_to_strokes[word_key] = [] + raw_word_to_strokes[word_key].append(stroke) + + # Sort strokes by preference: fewer strokes first, then shorter string + for word, strokes in raw_word_to_strokes.items(): + strokes.sort(key=lambda s: (s.count("/") + 1, len(s))) + self.word_to_strokes[word] = strokes + + # Build practice word list + self._practice_words = list(self.word_to_strokes.keys()) + + # Build single-stroke words list + self._single_stroke_words = [ + word for word, strokes in self.word_to_strokes.items() + if any("/" not in s for s in strokes) + ] + + # Build common words set (words in our frequency list that exist in dictionary) + common_lower = [w.lower() for w in COMMON_WORDS] + self._common_word_set = set( + w for w in common_lower if w in self.word_to_strokes + ) + + print(f" Practice words: {len(self._practice_words)}") + print(f" Single-stroke words: {len(self._single_stroke_words)}") + print(f" Common words (in dict): {len(self._common_word_set)}") + + @staticmethod + def is_practice_word(translation): + """ + Returns True only for plain English words/phrases suitable for practice. + + Filters out: + - Plover commands/formatting (contains { or }) + - Non-alphabetic content (except spaces, apostrophes, hyphens) + - Empty strings + - All-uppercase abbreviations (except single chars like "I", "a") + """ + if not translation or not translation.strip(): + return False + + # Filter out Plover commands and formatting + if "{" in translation or "}" in translation: + return False + + # Must contain only letters, spaces, apostrophes, hyphens + if not re.match(r"^[a-zA-Z][a-zA-Z '\-]*$", translation): + return False + + # Filter out all-uppercase abbreviations (but allow single characters) + stripped = translation.strip() + if len(stripped) > 1 and stripped.replace(" ", "").replace("-", "").replace("'", "").isupper(): + return False + + return True + + @staticmethod + def parse_stroke(stroke): + """ + Parse a single stroke into individual key identifiers. + + Returns list of key identifiers like ["S-", "T-", "A", "-R"]. + For multi-stroke sequences (containing /), parses each stroke separately + and returns a list of lists. + """ + if "/" in stroke: + return [PloverDictionary._parse_single_stroke(s) for s in stroke.split("/")] + return [PloverDictionary._parse_single_stroke(stroke)] + + @staticmethod + def _parse_single_stroke(stroke): + """Parse a single stroke (no /) into key identifiers.""" + keys = [] + + # Handle number bar + if stroke.startswith("#"): + keys.append("#") + stroke = stroke[1:] + + if not stroke: + return keys + + # Left-side keys (before vowels in steno order) + left_keys = "STKPWHR" + # Vowels/center + center_keys = "AO*EU" + # Right-side keys + right_keys = "FRPBLGTSDZ" + + if "-" in stroke: + # Explicit separator: left of - is left+center, right of - is right side + left_part, right_part = stroke.split("-", 1) + # Parse left part + for ch in left_part: + if ch in left_keys: + keys.append(f"{ch}-") + elif ch in center_keys: + keys.append(ch) + # Parse right part + for ch in right_part: + if ch in right_keys: + keys.append(f"-{ch}") + elif ch in center_keys: + keys.append(ch) + else: + # No explicit separator — use steno order to disambiguate + pos = 0 + for ch in stroke: + # Find this character at or after current position in steno order + found = False + for i in range(pos, len(STENO_ORDER)): + if STENO_ORDER[i] == ch: + pos = i + 1 + # Determine which section this position falls in + # Steno order: #STKPWHRAO*EUFRPBLGTSDZ + # Indices: 0123456789... + # # = index 0 + # S,T,K,P,W,H,R = indices 1-7 (left) + # A,O = indices 8-9 (center) + # * = index 10 (center) + # E,U = indices 11-12 (center) + # F,R,P,B,L,G,T,S,D,Z = indices 13-22 (right) + if i == 0: + keys.append("#") + elif i <= 7: + keys.append(f"{ch}-") + elif i <= 12: + keys.append(ch) + else: + keys.append(f"-{ch}") + found = True + break + + if not found: + # Character not found in remaining steno order — skip + pass + + return keys + + def get_common_words(self, count=25): + """Return common words that exist in the dictionary, in frequency order.""" + # Use the frequency list order, filtering to words in our dictionary + result = [] + seen = set() + for word in COMMON_WORDS: + w = word.lower() + if w in self.word_to_strokes and w not in seen: + result.append(w) + seen.add(w) + if len(result) >= count: + break + return result + + def get_single_stroke_words(self, count=25): + """Return random words achievable in a single stroke.""" + if len(self._single_stroke_words) <= count: + return list(self._single_stroke_words) + return random.sample(self._single_stroke_words, count) + + def get_weak_words(self, word_stats, count=25): + """ + Return words with low accuracy from word_stats. + + word_stats: list of dicts with 'word', 'attempts', 'correct' keys + """ + # Filter to words with at least 3 attempts, sort by accuracy ascending + weak = [] + for ws in word_stats: + if ws["attempts"] >= 3: + accuracy = ws["correct"] / ws["attempts"] if ws["attempts"] > 0 else 0 + weak.append((accuracy, ws["word"])) + + weak.sort(key=lambda x: x[0]) + words = [w for _, w in weak[:count]] + + # If not enough weak words, pad with random words + if len(words) < count: + remaining = count - len(words) + available = [w for w in self._practice_words if w not in set(words)] + if available: + words.extend(random.sample(available, min(remaining, len(available)))) + + return words + + def get_new_words(self, practiced_words, count=25): + """Return words never practiced before.""" + new_words = [w for w in self._practice_words if w not in practiced_words] + if len(new_words) <= count: + return new_words + return random.sample(new_words, count) + + def get_random_words(self, count=25): + """Return a random sample from all practice words.""" + if len(self._practice_words) <= count: + return list(self._practice_words) + return random.sample(self._practice_words, count) + + def get_strokes_for_word(self, word): + """Get all valid strokes for a word.""" + return self.word_to_strokes.get(word.lower().strip(), []) + + def get_best_stroke(self, word): + """Get the best (shortest) stroke for a word.""" + strokes = self.get_strokes_for_word(word) + return strokes[0] if strokes else None diff --git a/lessons.py b/lessons.py @@ -0,0 +1,460 @@ +"""Lesson structure and word frequency data for guided practice.""" + +import random + +from sentence_generator import generate_sentences +from wordfreq import top_n_list + +# Proper nouns / demonyms that wordfreq includes due to raw frequency. +# These don't belong in a vocabulary practice list — steno dicts map them +# to capitalized output, so practicing them lowercase is impossible. +_PROPER_NOUNS = { + # People names + "john", "paul", "peter", "james", "david", "michael", "george", "mary", + "thomas", "william", "charles", "henry", "edward", "robert", "richard", + "joseph", "jack", "sam", "tom", "joe", "harry", "jane", "elizabeth", + "kate", "anna", "sarah", "emma", "alice", "adam", "daniel", "steve", + "alex", "ben", "max", "nick", "matt", "chris", "mike", "bob", "jim", + "dan", "bill", "jeff", "ryan", "kevin", "brian", "scott", "eric", + "patrick", "martin", "frank", "sean", "carl", "ray", "fred", "lee", + "alan", "peter", "roger", "bruce", "ralph", "roy", "donald", "larry", + "jerry", "terry", "wayne", "philip", "howard", "johnny", "tommy", + "billy", "jimmy", "bobby", "charlie", "eddie", "willie", "annie", + "helen", "margaret", "nancy", "ruth", "betty", "dorothy", "karen", + "susan", "jessica", "lisa", "jennifer", "amy", "emily", "rachel", + "laura", "marie", "victoria", "catherine", "diana", "sophie", + # Countries / regions + "america", "europe", "africa", "asia", "australia", "canada", "england", + "britain", "france", "germany", "italy", "spain", "russia", "china", + "japan", "india", "mexico", "ireland", "scotland", "wales", "israel", + "iraq", "iran", "syria", "egypt", "brazil", "korea", "vietnam", + "texas", "california", "florida", "york", "london", "paris", + "washington", "hollywood", "broadway", "manhattan", "boston", "chicago", + # Demonyms / nationalities — steno maps these capitalized + "american", "americans", "european", "african", "asian", "australian", + "canadian", "english", "british", "french", "german", "italian", + "spanish", "russian", "chinese", "japanese", "indian", "mexican", + "irish", "scottish", "korean", "dutch", "polish", "turkish", "swedish", + "norwegian", "danish", "finnish", "czech", "hungarian", "portuguese", + "brazilian", "thai", "vietnamese", "indonesian", "filipino", "arab", + "arabic", "hebrew", "persian", "greek", "roman", "latin", + # Religion / holidays + "god", "jesus", "christ", "christian", "muslim", "jewish", "catholic", + "bible", "christmas", "easter", "halloween", + # Days / months — steno maps these capitalized + "sunday", "monday", "tuesday", "wednesday", "thursday", "friday", + "saturday", "january", "february", "march", "april", "june", "july", + "august", "september", "october", "november", "december", + # Brands / platforms + "facebook", "twitter", "google", "youtube", "instagram", "amazon", + "apple", "microsoft", "netflix", "uber", "disney", "walmart", + "samsung", "toyota", "nike", "tesla", "bitcoin", + # Political + "democrat", "republican", "nazi", "obama", "trump", + "olympic", "olympics", "grammy", "emmy", "oscar", + # Historical + "shakespeare", "darwin", "einstein", "newton", "napoleon", +} + +# Top 10,000 English words by real-world frequency (wordfreq: Google, Wikipedia, +# Reddit, Twitter, OpenSubtitles, etc.). Filtered to alphabetic common nouns/ +# verbs/adjectives — no proper nouns, no single-letter junk. +_raw = top_n_list("en", 10000) +TOP_WORDS = [ + w for w in _raw + if ((w.isalpha() and len(w) > 1) or w in ("a", "i")) + and w not in _PROPER_NOUNS +] + + +# Practice sentences organized by difficulty +SENTENCES = { + "beginner": [ + "The cat sat on a mat.", + "I have a big red hat.", + "He can run and jump.", + "She will go to the store.", + "We like to play in the park.", + "It is a good day to go out.", + "They came home from school.", + "I want to read a book.", + "The dog ran to the door.", + "We need to get some food.", + "Can you help me find my bag?", + "I think we should go now.", + "She put the cup on the table.", + "He will come back soon.", + "They like to play games.", + "I need to do my work.", + "The sun is up in the sky.", + "We have a lot to do today.", + "You can sit here with me.", + "She said it was time to go.", + ], + "intermediate": [ + "The quick brown fox jumps over the lazy dog.", + "I would like to know what you think about this.", + "She has been working on the project all morning.", + "They don't believe that it will work out in the end.", + "We should try to find a better way to solve this problem.", + "He doesn't seem to care about what other people think.", + "I have been trying to learn how to play the piano.", + "You will need to come back tomorrow for the results.", + "She would like to travel around the world some day.", + "They should have told us about the change in plans.", + "The children played in the garden until it got dark.", + "I remember when this street was just a small road.", + "He could not believe what he saw through the window.", + "We must find a way to make this work before the end.", + "You should always try your best no matter what happens.", + "The old house at the end of the street is for sale.", + "I think we need to talk about what happened last night.", + "She has never been to the ocean, but she wants to go.", + "They were late because the train did not come on time.", + "We have enough food for everyone if we share it well.", + ], + "advanced": [ + "The government announced several important changes to the education system.", + "Scientists believe that the discovery could lead to significant medical advances.", + "The temperature dropped suddenly, and the roads became dangerous to drive on.", + "Experience has taught me that nothing worthwhile comes without considerable effort.", + "The company decided to separate the research division from the main business.", + "Understanding different cultures requires patience and an open mind toward others.", + "The original design was modified to include additional safety features throughout.", + "Modern technology has completely transformed how we communicate with each other.", + "The solution to the problem required thinking about it from a different angle.", + "Environmental protection has become one of the most important issues of our time.", + "The committee reached a decision after several hours of careful consideration.", + "Effective communication depends on both speaking clearly and listening carefully.", + "The population of the city has grown considerably over the past few decades.", + "Financial planning requires discipline and a clear understanding of your goals.", + "The relationship between science and society continues to evolve in complex ways.", + ], + "literature": [ + # Jane Austen — Pride and Prejudice (1813) + "It is a truth universally acknowledged, that a single man in possession of a good fortune, must be in want of a wife.", + "She was a woman of mean understanding, little information, and uncertain temper.", + "Vanity and pride are different things, though the words are often used as one.", + "I could easily forgive his pride, if he had not mortified mine.", + "There is a stubbornness about me that never can bear to be frightened at the will of others.", + "A lady's imagination is very rapid; it jumps from admiration to love, from love to matrimony in a moment.", + # Jane Austen — Sense and Sensibility (1811) + "The family of Dashwood had long been settled in Sussex.", + "It is not time or opportunity that is to determine intimacy; it is disposition alone.", + # Charles Dickens — A Tale of Two Cities (1859) + "It was the best of times, it was the worst of times, it was the age of wisdom, it was the age of foolishness.", + "A wonderful fact to reflect upon, that every human creature is constituted to be that profound secret and mystery to every other.", + "It is a far, far better thing that I do, than I have ever done; it is a far, far better rest that I go to than I have ever known.", + # Charles Dickens — Great Expectations (1861) + "In a word, I was too cowardly to do what I knew to be right, as I had been too cowardly to avoid doing what I knew to be wrong.", + "Take nothing on its looks; take everything on evidence. There is no better rule.", + "We need never be ashamed of our tears, for they are rain upon the blinding dust of earth.", + "Suffering has been stronger than all other teaching, and has taught me to understand what your heart used to be.", + # Charles Dickens — Oliver Twist (1838) + "There are books of which the backs and covers are by far the best parts.", + # Charlotte Bronte — Jane Eyre (1847) + "I am no bird; and no net ensnares me; I am a free human being with an independent will.", + "I would always rather be happy than dignified.", + "There was no possibility of taking a walk that day.", + "Life appears to me too short to be spent in nursing animosity or registering wrongs.", + # Emily Bronte — Wuthering Heights (1847) + "He is more myself than I am. Whatever our souls are made of, his and mine are the same.", + "I have not broken your heart; you have broken it; and in breaking it, you have broken mine.", + "Terror made me cruel; and finding it useless to attempt shaking the creature off, I pulled its wrist on to the broken pane.", + # Herman Melville — Moby Dick (1851) + "Call me Ishmael. Some years ago, having little or no money in my purse, I thought I would sail about a little and see the watery part of the world.", + "It is not down on any map; true places never are.", + "Better to sleep with a sober cannibal than a drunken Christian.", + "There is a wisdom that is woe; but there is a woe that is madness.", + # Leo Tolstoy — Anna Karenina (1877, trans. Constance Garnett) + "All happy families are alike; each unhappy family is unhappy in its own way.", + "He stepped down, trying not to look long at her, as if she were the sun, yet he saw her, like the sun, even without looking.", + "If you look for perfection, you will never be content.", + # Leo Tolstoy — War and Peace (1869, trans. Constance Garnett) + "The strongest of all warriors are these two: time and patience.", + "We can know only that we know nothing. And that is the highest degree of human wisdom.", + "Nothing is so necessary for a young man as the company of intelligent women.", + # Mark Twain — The Adventures of Tom Sawyer (1876) + "The less there is to justify a traditional custom, the harder it is to get rid of it.", + "He had discovered a great law of human action, without knowing it, that in order to make a man covet a thing, it is only necessary to make the thing difficult to attain.", + # Mark Twain — Adventures of Huckleberry Finn (1884) + "All right, then, I will go to it. It was awful thoughts and awful words, but they were said.", + "It does not matter what you do; he can find fault with it.", + "That is just the way with some people. They get down on a thing when they do not know nothing about it.", + # Mary Shelley — Frankenstein (1818) + "Nothing is so painful to the human mind as a great and sudden change.", + "Even broken in spirit as he is, no one can feel more deeply than he does the beauties of nature.", + "Beware; for I am fearless, and therefore powerful.", + "Life, although it may only be an accumulation of anguish, is dear to me, and I will defend it.", + # Lewis Carroll — Alice's Adventures in Wonderland (1865) + "It was all very well to say drink me, but the wise little Alice was not going to do that in a hurry.", + "If everybody minded their own business, the world would go around a great deal faster than it does.", + "Begin at the beginning and go on till you come to the end; then stop.", + "Why, sometimes I have believed as many as six impossible things before breakfast.", + # Oscar Wilde — The Picture of Dorian Gray (1890) + "The only way to get rid of a temptation is to yield to it.", + "Every portrait that is painted with feeling is a portrait of the artist, not of the sitter.", + "I am too fond of reading books to care to write them.", + "Experience is merely the name men gave to their mistakes.", + "To define is to limit.", + # Arthur Conan Doyle — Sherlock Holmes stories (1887-1905) + "When you have eliminated the impossible, whatever remains, however improbable, must be the truth.", + "There is nothing more deceptive than an obvious fact.", + "The world is full of obvious things which nobody by any chance ever observes.", + "It is a capital mistake to theorize before one has data.", + "I am a brain, Watson. The rest of me is a mere appendage.", + # H.G. Wells — The Time Machine (1895) + "We should strive to welcome change and challenges, because they are the things that let us grow.", + "It sounds plausible enough tonight, but wait until tomorrow. Wait for the common sense of the morning.", + "There is no intelligence where there is no change and no need of change.", + # H.G. Wells — The War of the Worlds (1898) + "No one would have believed in the last years of the nineteenth century that this world was being watched keenly and closely by intelligences greater than ours.", + "Few people realized the ordeal that was at hand. I think that the greatest wonder of the whole thing was the swiftness of it all.", + # Jack London — The Call of the Wild (1903) + "He was mastered by the sheer surging of life, the tidal wave of being, the perfect joy of each separate muscle, joint, and sinew.", + "There is an ecstasy that marks the summit of life, and beyond which life cannot rise.", + # Edgar Allan Poe — various (1830s-1840s) + "All that we see or seem is but a dream within a dream.", + "I became insane, with long intervals of horrible sanity.", + "Deep into that darkness peering, long I stood there, wondering, fearing, doubting.", + "Words have no power to impress the mind without the exquisite horror of their reality.", + # Robert Louis Stevenson — Treasure Island (1883) + "Fifteen men on the dead man's chest. Drink and the devil had done for the rest.", + "I was far less afraid of the captain than anyone else who knew him.", + # Louisa May Alcott — Little Women (1868) + "I am not afraid of storms, for I am learning how to sail my ship.", + "I like good strong words that mean something.", + "Love is a great beautifier.", + # Nathaniel Hawthorne — The Scarlet Letter (1850) + "No man, for any considerable period, can wear one face to himself and another to the multitude.", + "She had not known the weight until she felt the freedom.", + # Bram Stoker — Dracula (1897) + "There are darknesses in life and there are lights, and you are one of the lights.", + "We learn from failure, not from success.", + # L. Frank Baum — The Wonderful Wizard of Oz (1900) + "A heart is not judged by how much you love; but by how much you are loved by others.", + "No matter how dreary and gray our homes are, we people of flesh and blood would rather live there than in any other country.", + # Daniel Defoe — Robinson Crusoe (1719) + "Thus fear of danger is ten thousand times more terrifying than danger itself.", + "All our discontents about what we want appeared to spring from the want of thankfulness for what we have.", + # Jonathan Swift — Gulliver's Travels (1726) + "I cannot but conclude the bulk of your natives to be the most pernicious race of little odious vermin that nature ever suffered to crawl upon the surface of the earth.", + "Whoever could make two ears of corn or two blades of grass to grow where only one grew before, would deserve better of mankind.", + # Homer — The Odyssey (trans. Samuel Butler, 1900) + "There is a time for many words, and there is also a time for sleep.", + "Of all creatures that breathe and move upon the earth, nothing is bred that is weaker than man.", + # Walt Whitman — Leaves of Grass (1855) + "Do I contradict myself? Very well then I contradict myself. I am large, I contain multitudes.", + "I exist as I am, that is enough.", + "Keep your face always toward the sunshine, and shadows will fall behind you.", + # Assorted public domain (pre-1928) + "The only thing we have to fear is fear itself.", + "In the middle of difficulty lies opportunity.", + "The mind is everything. What you think, you become.", + "To be yourself in a world that is constantly trying to make you something else is the greatest accomplishment.", + "It does not do to dwell on dreams and forget to live.", + "We are what we repeatedly do. Excellence, then, is not an act, but a habit.", + "The unexamined life is not worth living.", + "Knowing yourself is the beginning of all wisdom.", + "An investment in knowledge pays the best interest.", + "Well done is better than well said.", + "Lost time is never found again.", + "Either write something worth reading or do something worth writing.", + "Tell me and I forget. Teach me and I remember. Involve me and I learn.", + "The journey of a thousand miles begins with a single step.", + ], +} + + +LESSONS = [ + { + "id": "basics-1", + "title": "The Basics", + "subtitle": "Top 100 most common words", + "level": "beginner", + "word_range": (0, 100), + "sentences_key": "beginner", + "order": 1, + }, + { + "id": "building-1", + "title": "Building Up", + "subtitle": "Words 101-200", + "level": "intermediate", + "word_range": (100, 200), + "sentences_key": "intermediate", + "order": 2, + }, + { + "id": "building-2", + "title": "Growing Vocabulary", + "subtitle": "Words 201-350", + "level": "intermediate", + "word_range": (200, 350), + "sentences_key": "intermediate", + "order": 3, + }, + { + "id": "building-3", + "title": "Expanding Range", + "subtitle": "Words 351-500", + "level": "intermediate", + "word_range": (350, 500), + "sentences_key": "intermediate", + "order": 4, + }, + { + "id": "phrases-1", + "title": "Common Phrases", + "subtitle": "Two and three word phrases", + "level": "intermediate", + "word_range": None, + "mode": "phrases", + "sentences_key": "intermediate", + "order": 5, + }, + { + "id": "advanced-1", + "title": "Advanced Words", + "subtitle": "Words 501-700", + "level": "advanced", + "word_range": (500, 700), + "sentences_key": "advanced", + "order": 6, + }, + { + "id": "advanced-2", + "title": "Expert Words", + "subtitle": "Words 701-1000", + "level": "advanced", + "word_range": (700, 1000), + "sentences_key": "advanced", + "order": 7, + }, + { + "id": "advanced-3", + "title": "Uncommon Words", + "subtitle": "Words 1001-1500", + "level": "advanced", + "word_range": (1000, 1500), + "order": 8, + }, + { + "id": "expert-1", + "title": "Expert Vocabulary", + "subtitle": "Words 1501-2500", + "level": "expert", + "word_range": (1500, 2500), + "order": 9, + }, + { + "id": "expert-2", + "title": "Broad Vocabulary", + "subtitle": "Words 2501-4000", + "level": "expert", + "word_range": (2500, 4000), + "order": 10, + }, + { + "id": "expert-3", + "title": "Deep Vocabulary", + "subtitle": "Words 4001-6000", + "level": "expert", + "word_range": (4000, 6000), + "order": 11, + }, + { + "id": "master-1", + "title": "Master I", + "subtitle": "Words 6001-8000", + "level": "master", + "word_range": (6000, 8000), + "order": 12, + }, + { + "id": "master-2", + "title": "Master II", + "subtitle": "Words 8001-10000", + "level": "master", + "word_range": (8000, 10000), + "order": 13, + }, + { + "id": "sentences-1", + "title": "Real Sentences", + "subtitle": "Literature and real-world text", + "level": "advanced", + "word_range": None, + "mode": "sentences", + "sentences_key": "literature", + "order": 14, + }, +] + + +def get_lesson_words(lesson, engine, count=10): + """Get practice content for a lesson. + + Word-range lessons generate sentences from their cumulative word pool. + The literature lesson returns randomized public domain passages. + Phrase lessons return multi-word phrases. + """ + if lesson.get("mode") == "phrases": + phrases = engine.all_phrases + phrasing = engine.generate_phrasing_phrases(200) + phrases = phrases + phrasing + random.shuffle(phrases) + return phrases[:50] + + if lesson.get("mode") == "sentences": + key = lesson.get("sentences_key", "literature") + sents = list(SENTENCES.get(key, SENTENCES["literature"])) + random.shuffle(sents) + return sents[:count] + + word_range = lesson.get("word_range") + if word_range: + start, end = word_range + cumulative = TOP_WORDS[:end] + pool = [w for w in cumulative if engine.word_exists(w)] + if not pool: + pool = list(cumulative) + return generate_sentences(pool, count=count) + + return [] + + +def get_sentences(level="beginner", count=10): + sents = list(SENTENCES.get(level, SENTENCES["beginner"])) + random.shuffle(sents) + return sents[:count] + + +def get_lesson_list(): + return [ + { + "id": l["id"], + "title": l["title"], + "subtitle": l["subtitle"], + "level": l["level"], + "order": l["order"], + "mode": l.get("mode", "sentences"), + } + for l in LESSONS + ] + + +def get_lesson_word_list(lesson): + """Return the raw word list for a word-range lesson (for progress tracking).""" + word_range = lesson.get("word_range") + if word_range: + start, end = word_range + return TOP_WORDS[start:end] + return [] + + +def get_lesson_by_id(lesson_id): + for l in LESSONS: + if l["id"] == lesson_id: + return l + return None diff --git a/phrases.py b/phrases.py @@ -0,0 +1,400 @@ +"""Jeff's Phrasing dictionary integration for stenography practice. + +Loads the Jeff's Phrasing Plover dictionary plugin and provides functions +to generate phrasing-based practice content. +""" + +import os +import random +import sys + +# --------------------------------------------------------------------------- +# Import Jeff's Phrasing dictionary +# --------------------------------------------------------------------------- + +JEFF_PHRASING = None + +_jeff_phrasing_dir = os.path.expanduser( + "~/.config/plover/dicts/jeff-phrasing" +) + +try: + if _jeff_phrasing_dir not in sys.path: + sys.path.insert(0, _jeff_phrasing_dir) + import importlib + JEFF_PHRASING = importlib.import_module("jeff-phrasing") +except Exception: + JEFF_PHRASING = None + +# --------------------------------------------------------------------------- +# Starters and enders used to generate phrase combinations +# --------------------------------------------------------------------------- + +# Pronoun starters: stroke -> display word +STARTER_STROKES = { + "SWR": "I", + "KPWR": "you", + "KWHR": "he", + "SKWHR": "she", + "KPWH": "it", + "TWR": "we", + "TWH": "they", +} + +# Common ender strokes (present tense only for generation) +ENDER_STROKES = { + "RB": "ask", + "B": "be", + "RPBG": "become", + "BL": "believe", + "RBLG": "call", + "BGS": "can", + "RZ": "care", + "PBGZ": "change", + "BG": "come", + "RP": "do", + "PGS": "expect", + "LT": "feel", + "PBLG": "find", + "RG": "forget", + "GS": "get", + "GZ": "give", + "G": "go", + "GT": "go to", + "T": "have", + "TS": "have to", + "PZ": "happen", + "PG": "hear", + "RPS": "hope", + "PLG": "imagine", + "PBGS": "keep", + "PB": "know", + "RPBS": "learn", + "LS": "let", + "BLG": "like", + "LZ": "live", + "L": "look", + "LG": "love", + "RPBL": "make", + "PL": "may", + "PBL": "mean", + "PLZ": "move", + "PBLGS": "must", + "RPG": "need", + "RPGT": "need to", + "PS": "put", + "RPL": "remember", + "R": "run", + "BS": "say", + "S": "see", + "PLS": "seem", + "RBL": "shall", + "RBGS": "will", + "RBGSZ": "would", + "RBS": "wish", + "RBG": "work", + "RBT": "take", + "RLT": "tell", + "PBG": "think", + "RT": "try", + "RPB": "understand", + "Z": "use", + "P": "want", + "PT": "want to", +} + +# Middle/structure modifiers to combine with starters + enders +MIDDLE_STROKES = { + "": "simple present/past", + "*": "negative", + "A": "can/could", + "A*": "can't/couldn't", + "O": "shall/should", + "AO": "will/would", + "AO*": "won't/wouldn't", +} + +# --------------------------------------------------------------------------- +# Phrase generation via Jeff's Phrasing lookup +# --------------------------------------------------------------------------- + +def _try_lookup(stroke): + """Attempt to look up a single stroke via Jeff's Phrasing. Returns text or None.""" + if JEFF_PHRASING is None: + return None + try: + return JEFF_PHRASING.lookup((stroke,)).strip() + except (KeyError, TypeError, AttributeError): + return None + + +def _build_stroke(starter, middle, ender): + """Combine starter, middle, and ender stroke fragments into a single stroke string.""" + import re + hyphen_chars = re.compile(r"[AO*EU-]") + + combined = starter + middle + ender + + # Add hyphen between left and right bank when needed + if not hyphen_chars.search(starter + middle) and ender: + combined = starter + middle + "-" + ender + + return combined + + +def _generate_phrase_list(): + """Build a list of (phrase_text, stroke) tuples from starter + ender combinations.""" + if JEFF_PHRASING is None: + return [] + + phrases = [] + seen = set() + + # Simple starter + ender (present tense) + for s_stroke in STARTER_STROKES: + for e_stroke in ENDER_STROKES: + stroke = _build_stroke(s_stroke, "", e_stroke) + text = _try_lookup(stroke) + if text and text not in seen: + phrases.append((text, stroke)) + seen.add(text) + + # Starter + middle + ender + for s_stroke in STARTER_STROKES: + for m_stroke in MIDDLE_STROKES: + if not m_stroke: + continue + for e_stroke in ENDER_STROKES: + stroke = _build_stroke(s_stroke, m_stroke, e_stroke) + text = _try_lookup(stroke) + if text and text not in seen: + phrases.append((text, stroke)) + seen.add(text) + + # Past tense: starter + ender with D suffix + for s_stroke in STARTER_STROKES: + for e_stroke, verb in ENDER_STROKES.items(): + past_stroke = e_stroke + "D" if not e_stroke.endswith("D") else e_stroke + stroke = _build_stroke(s_stroke, "", past_stroke) + text = _try_lookup(stroke) + if text and text not in seen: + phrases.append((text, stroke)) + seen.add(text) + + # Structure variations: be/have forms + structure_mods = ["E", "F", "EF"] # be, have, have been + for s_stroke in STARTER_STROKES: + for struct in structure_mods: + for e_stroke in ENDER_STROKES: + stroke = _build_stroke(s_stroke, struct, e_stroke) + text = _try_lookup(stroke) + if text and text not in seen: + phrases.append((text, stroke)) + seen.add(text) + + return phrases + + +# --------------------------------------------------------------------------- +# Pre-computed phrase cache +# --------------------------------------------------------------------------- + +_PHRASE_CACHE = [] +_PHRASE_TEXT_SET = set() + + +def _init_cache(): + global _PHRASE_CACHE, _PHRASE_TEXT_SET + if not _PHRASE_CACHE: + _PHRASE_CACHE = _generate_phrase_list() + _PHRASE_TEXT_SET = {p[0] for p in _PHRASE_CACHE} + + +_init_cache() + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def get_phrase_strokes(phrase_text): + """Return a list of stroke tuples for the given phrase text using Jeff's reverse_lookup. + + Example return: [("SWRPB",), ("SWR-PB",)] + Returns an empty list if Jeff's Phrasing is not available or the phrase is not found. + """ + if JEFF_PHRASING is None: + return [] + try: + return JEFF_PHRASING.reverse_lookup(phrase_text) + except Exception: + return [] + + +def get_practice_phrases(count=20): + """Return a list of random phrase texts for practice. + + Each item is a dict with 'text' and 'strokes' keys. + """ + if not _PHRASE_CACHE: + return [{"text": s, "strokes": []} for s in random.sample( + CURATED_SENTENCES, min(count, len(CURATED_SENTENCES)) + )] + + phrases = random.sample(_PHRASE_CACHE, min(count, len(_PHRASE_CACHE))) + return [{"text": text, "strokes": get_phrase_strokes(text)} for text, stroke in phrases] + + +def get_all_cached_phrases(): + """Return the full list of pre-computed (phrase_text, stroke) tuples.""" + return list(_PHRASE_CACHE) + + +def get_sentence_words(count=10): + """Generate simple practice sentences that combine phrases with common words. + + Returns a list of sentence strings suitable for steno practice. + """ + sentences = list(CURATED_SENTENCES) + random.shuffle(sentences) + + if count <= len(sentences): + return sentences[:count] + + # If we need more than curated, also generate from phrase combinations + generated = [] + completions = [ + "now", "here", "today", "again", "soon", "there", + "at home", "at work", "in the morning", "every day", + "to the store", "for a while", "right now", "this time", + "a lot", "so much", "very well", "too much", + "about it", "with you", "for me", "to them", + ] + + if _PHRASE_CACHE: + for text, stroke in random.sample(_PHRASE_CACHE, min(50, len(_PHRASE_CACHE))): + completion = random.choice(completions) + generated.append(f"{text} {completion}") + + combined = sentences + generated + random.shuffle(combined) + return combined[:count] + + +# --------------------------------------------------------------------------- +# Hand-curated practice sentences +# --------------------------------------------------------------------------- + +CURATED_SENTENCES = [ + # Short phrases (2-4 words) + "I can do it", + "you have to go", + "I want to know", + "she will come", + "they have to work", + "we can see it", + "he will go", + "I need to go", + "you can do it", + "it will be", + "I have to say", + "they want to come", + "she can go", + "we need to talk", + "he has to go", + "I would like to", + "you should go", + "they will come", + "we have to go", + "I can tell", + "she will be", + "you have to come", + "it can be", + "he will come", + "I should go", + + # Medium phrases (4-6 words) + "they don't want to come", + "she has been working", + "I don't think so", + "you can't do that", + "we have been here", + "he doesn't want to go", + "I have been working", + "they will have to come", + "she can't believe it", + "you don't have to go", + "we should try to go", + "I don't want to go", + "he has been looking", + "they don't know how", + "you should have come", + "I can't believe it", + "she doesn't want to come", + "we don't have to go", + "he will have to come", + "I have to tell you", + "they can't come today", + "you don't need to go", + "she would like to come", + "we will have to go", + "it doesn't have to be", + "I used to live here", + "you have to believe me", + "they have been working", + "she will need to go", + "he can't seem to find", + + # Longer sentences (6-10 words) + "I think that you should go to the store", + "they have been working on it all day", + "she will come to the house in the morning", + "we don't want to go to work today", + "he has been trying to find a new home", + "I would like to tell you about it", + "you should try to come here every day", + "they don't believe that it will work", + "she has to go to the store today", + "we need to find a way to do it", + "I can't remember how to get there", + "you will have to come back again soon", + "they should have been here by now", + "he doesn't seem to care about it", + "I have been trying to call you all day", + "she will need to learn how to do it", + "we can't go to the store right now", + "you don't have to do it today", + "they used to live in a big house", + "I would like to know what you think", + "he can't seem to make it work", + "she has been wanting to come here", + "we should try to get there on time", + "you have to tell me what you know", + "they will need to come back tomorrow", + + # Common conversational phrases + "I don't know", + "you have to", + "I want to", + "she will be there", + "they have been", + "we can do it", + "he doesn't know", + "I will try", + "you should know", + "it has to be", + "I can see", + "they don't care", + "we will go", + "she has been", + "he will try", + "I have been here before", + "you can come with me", + "they should have told us", + "she would like to help", + "we need to go home now", + "he used to work here", + "I will have to think about it", + "you don't seem to understand", + "they can't find it", + "she has to come back", +] diff --git a/plover_engine.py b/plover_engine.py @@ -0,0 +1,692 @@ +"""Plover dictionary engine: loads JSON + Python dictionaries from plover.cfg.""" + +import configparser +import importlib +import importlib.util +import json +import os +import random +import re +import sys + +STENO_ORDER = "#STKPWHRAO*EUFRPBLGTSDZ" +_DISPLAY_OVERRIDES = {"i": "I"} + +_CAP_COMMANDS = { + # Old-style + "{-|}": "cap_next", + "{^}{-|}": "cap_next_attach", + "{*-|}": "cap_retro", + "{*<}": "upper_retro", + "{<}": "upper_next", + "{>}": "lower_next", + "{*>}": "lower_retro", + # New-style (standalone) + "{:case:cap_first_word}": "cap_next", + "{:case:upper_first_word}": "upper_next", + "{:case:lower_first_char}": "lower_next", + "{:retro_case:cap_first_word}": "cap_retro", + "{:retro_case:upper_first_word}": "upper_retro", + "{:retro_case:lower_first_char}": "lower_retro", + # Compound: {} + command (space-aware cap) + "{}{-|}": "cap_next", + "{}{:case:cap_first_word}": "cap_next", + # Compound: {^} + command (attach + cap) + "{^}{:case:cap_first_word}": "cap_next_attach", + "{^}{:case:upper_first_word}": "upper_next_attach", +} + + +class PloverEngine: + def __init__(self, config_path=None): + self.config_path = config_path + self.config_dir = os.path.dirname(self.config_path) if self.config_path else "" + self.stroke_to_word = {} + self.word_to_strokes = {} + self.phrase_to_strokes = {} + self._phrase_display = {} + self.punctuation_to_strokes = {} + self.cap_strokes = {} + self._practice_words = [] + self._single_stroke_words = [] + self._python_dicts = [] + self._stroke_priority = {} + self._stroke_labels = {} + self._stroke_context = {} + self._ready = False + self.load_error = None + + def _reset(self): + self.stroke_to_word.clear() + self.word_to_strokes.clear() + self.phrase_to_strokes.clear() + self._phrase_display.clear() + self.punctuation_to_strokes.clear() + self.cap_strokes.clear() + self._practice_words.clear() + self._single_stroke_words.clear() + self._python_dicts.clear() + self._stroke_priority.clear() + self._stroke_labels.clear() + self._stroke_context.clear() + self._ready = False + self.load_error = None + + def load(self): + self._reset() + + if not self.config_path or not os.path.exists(self.config_path): + self.load_error = f"Plover config not found: {self.config_path}" + print(f"Warning: {self.load_error}") + self._ready = True + return + + config = configparser.ConfigParser() + config.read(self.config_path) + + section = "System: English Stenotype" + if not config.has_section(section): + self.load_error = f"Section [{section}] not found in plover.cfg" + print(f"Warning: {self.load_error}") + self._ready = True + return + + dicts_json = config.get(section, "dictionaries", fallback="[]") + dict_entries = json.loads(dicts_json) + + json_count = 0 + py_count = 0 + + for priority, entry in enumerate(dict_entries): + if not entry.get("enabled", False): + continue + + path = entry["path"] + full_path = os.path.join(self.config_dir, path) + + if path.endswith(".json"): + if self._load_json_dict(full_path, path, priority): + json_count += 1 + elif path.endswith(".py"): + if self._load_python_dict(full_path, path, priority): + py_count += 1 + + print(f" JSON dictionaries: {json_count}") + print(f" Python dictionaries: {py_count}") + print(f" Total stroke entries: {len(self.stroke_to_word)}") + + self._build_indexes() + self._ready = True + + def load_from_uploaded(self, dict_dir, dict_entries): + """Load dictionaries from user-uploaded files. + + dict_entries: list of dicts with keys: filename, enabled, dict_order + """ + self._reset() + + if not os.path.isdir(dict_dir): + self.load_error = "No dictionaries uploaded yet" + self._ready = True + return + + sorted_entries = sorted(dict_entries, key=lambda e: e.get("dict_order", 0)) + json_count = 0 + py_count = 0 + + for priority, entry in enumerate(sorted_entries): + if not entry.get("enabled", True): + continue + filename = entry["filename"] + full_path = os.path.join(dict_dir, filename) + + if filename.endswith(".json"): + if self._load_json_dict(full_path, filename, priority): + json_count += 1 + elif filename.endswith(".py"): + if self._load_python_dict(full_path, filename, priority): + py_count += 1 + + print(f" Uploaded JSON dictionaries: {json_count}") + print(f" Uploaded Python dictionaries: {py_count}") + print(f" Total stroke entries: {len(self.stroke_to_word)}") + + self._build_indexes() + self._ready = True + + @staticmethod + def parse_plover_config(cfg_content): + """Parse plover.cfg content and return the ordered list of dictionary entries.""" + config = configparser.ConfigParser() + config.read_string(cfg_content) + + section = "System: English Stenotype" + if not config.has_section(section): + return [] + + dicts_json = config.get(section, "dictionaries", fallback="[]") + raw = json.loads(dicts_json) + + result = [] + for i, entry in enumerate(raw): + path = entry.get("path", "") + filename = os.path.basename(path) + if not filename: + continue + result.append({ + "filename": filename, + "original_path": path, + "enabled": entry.get("enabled", True), + "order": i, + }) + return result + + def _load_json_dict(self, full_path, display_path, priority=0): + if not os.path.exists(full_path): + print(f" Warning: not found: {display_path}") + return False + try: + with open(full_path, "r", encoding="utf-8") as f: + data = json.load(f) + count = 0 + for stroke, translation in data.items(): + if stroke not in self.stroke_to_word: + self.stroke_to_word[stroke] = translation + self._stroke_priority[stroke] = priority + count += 1 + print(f" Loaded: {display_path} ({count} entries)") + return True + except (json.JSONDecodeError, OSError) as e: + print(f" Warning: failed to load {display_path}: {e}") + return False + + def _load_python_dict(self, full_path, display_path, priority=0): + if not os.path.exists(full_path): + print(f" Warning: not found: {display_path}") + return False + try: + dict_dir = os.path.dirname(full_path) + module_name = os.path.splitext(os.path.basename(full_path))[0] + + if dict_dir not in sys.path: + sys.path.insert(0, dict_dir) + + safe_name = f"plover_pydict_{module_name.replace('-', '_')}" + spec = importlib.util.spec_from_file_location(safe_name, full_path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + has_lookup = hasattr(mod, "lookup") + has_reverse = hasattr(mod, "reverse_lookup") + longest_key = getattr(mod, "LONGEST_KEY", 0) + + if has_lookup: + self._python_dicts.append({ + "module": mod, + "name": module_name, + "path": display_path, + "has_reverse": has_reverse, + "longest_key": longest_key, + "priority": priority, + }) + label = "lookup+reverse" if has_reverse else "lookup only" + print(f" Loaded: {display_path} (python, {label})") + return True + else: + print(f" Skipped: {display_path} (no lookup function)") + return False + except Exception as e: + print(f" Warning: failed to load {display_path}: {e}") + return False + + def _build_indexes(self): + raw = {} + origcase = {} + punct_raw = {} + for stroke, translation in self.stroke_to_word.items(): + stripped = translation.strip() + if _is_punctuation(stripped): + if stripped not in punct_raw: + punct_raw[stripped] = [] + punct_raw[stripped].append(stroke) + continue + plover_punct = _PLOVER_PUNCT.get(stripped) + if plover_punct: + if plover_punct not in punct_raw: + punct_raw[plover_punct] = [] + punct_raw[plover_punct].append(stroke) + continue + cap_type = _CAP_COMMANDS.get(stripped) + if cap_type: + if cap_type not in self.cap_strokes: + self.cap_strokes[cap_type] = [] + self.cap_strokes[cap_type].append(stroke) + continue + if not _is_practice_word(stripped): + continue + key = stripped.lower() + if key not in raw: + raw[key] = [] + if key != stripped and key not in origcase: + origcase[key] = stripped + raw[key].append(stroke) + + prio = self._stroke_priority + for word, strokes in raw.items(): + strokes.sort(key=lambda s: (prio.get(s, 999), s.count("/") + 1, len(s))) + if " " in word: + self.phrase_to_strokes[word] = strokes + if word not in self._phrase_display: + self._phrase_display[word] = origcase.get(word, word) + else: + self.word_to_strokes[word] = strokes + + self._scan_python_dicts_for_punctuation(punct_raw) + + for punct, strokes in punct_raw.items(): + strokes.sort(key=lambda s: (prio.get(s, 999), s.count("/") + 1, len(s))) + self.punctuation_to_strokes[punct] = strokes + + for cap_type, strokes in self.cap_strokes.items(): + strokes.sort(key=lambda s: (prio.get(s, 999), s.count("/") + 1, len(s))) + + if self.cap_strokes: + print(f" Capitalization commands: {sum(len(v) for v in self.cap_strokes.values())}") + + self._practice_words = list(self.word_to_strokes.keys()) + self._single_stroke_words = [ + w for w, ss in self.word_to_strokes.items() + if any("/" not in s for s in ss) + ] + + print(f" Practice words: {len(self._practice_words)}") + print(f" Phrases (from JSON): {len(self.phrase_to_strokes)}") + print(f" Punctuation entries: {len(self.punctuation_to_strokes)}") + print(f" Single-stroke words: {len(self._single_stroke_words)}") + + def _scan_python_dicts_for_punctuation(self, punct_raw): + """Probe Python dicts to find strokes that produce punctuation.""" + target_chars = set(".,;:!?\"'()-[]{}…/") + found = 0 + for pd in self._python_dicts: + mod = pd["module"] + sym_table = getattr(mod, "symbols", None) + if isinstance(sym_table, dict): + attach_method = getattr(mod, "attachmentMethod", "space") + found += self._scan_symbol_table( + sym_table, target_chars, punct_raw, + pd["priority"], attach_method) + if found: + print(f" Punctuation from Python dicts: {found}") + + @staticmethod + def _emily_stroke_variants(starter, pattern, attachment_method): + """Generate attachment/capitalization variants for emily-style strokes. + Returns list of (stroke, label, context_tag) tuples.""" + def build(mid): + if mid: + return starter + mid + pattern + return starter + ("-" if pattern else "") + pattern + + if attachment_method == "space": + return [ + (build(""), None, "no_space"), + (build("O"), "spc after", "space_after"), + (build("O*"), "spc + cap", "space_cap"), + (build("AO"), "spc both", "space_both"), + ] + else: + return [ + (build(""), None, "space_both"), + (build("A"), "attach left", "space_after"), + (build("A*"), "attach + cap", "space_cap"), + (build("AO"), "no spc", "no_space"), + ] + + def _scan_symbol_table(self, sym_table, target_chars, punct_raw, + priority, attachment_method): + """Extract punctuation strokes from an emily-style symbols dict.""" + found = 0 + for starter, patterns in sym_table.items(): + if not isinstance(patterns, dict): + continue + for pattern, variants in patterns.items(): + if isinstance(variants, str): + variants = [variants] + if not isinstance(variants, list): + continue + char = variants[0] if variants else "" + if len(char) == 1 and char in target_chars: + for stroke, label, ctx in self._emily_stroke_variants( + starter, pattern, attachment_method): + if char not in punct_raw: + punct_raw[char] = [] + if stroke not in punct_raw[char]: + punct_raw[char].append(stroke) + self._stroke_priority[stroke] = priority + self._stroke_context[stroke] = ctx + if label: + self._stroke_labels[stroke] = label + found += 1 + return found + + def get_strokes(self, text): + key = text.lower().strip() + result = list(self.word_to_strokes.get(key, [])) + if not result: + result = list(self.phrase_to_strokes.get(key, [])) + if not result: + result = self._python_reverse_lookup(text.strip()) + if not result and key != text.strip(): + result = self._python_reverse_lookup(key) + return result + + def _python_reverse_lookup(self, text): + results = [] + for pd in self._python_dicts: + if not pd["has_reverse"]: + continue + try: + hits = pd["module"].reverse_lookup(text) + for stroke_tuple in hits: + if len(stroke_tuple) == 1: + results.append(stroke_tuple[0]) + except Exception: + pass + return results + + def get_phrase_strokes(self, text): + key = text.lower().strip() + result = list(self.phrase_to_strokes.get(key, [])) + py_strokes = self._python_reverse_lookup(text.strip()) + if not py_strokes and key != text.strip(): + py_strokes = self._python_reverse_lookup(key) + for s in py_strokes: + if s not in result: + result.append(s) + return result + + def detect_phrase_hints(self, words, current_index): + if current_index >= len(words): + return [] + hints = [] + max_len = min(8, len(words) - current_index + 1) + for length in range(2, max_len): + phrase = " ".join(words[current_index:current_index + length]) + strokes = self.get_phrase_strokes(phrase) + if strokes: + single = [s for s in strokes if "/" not in s] + best = single[:5] if single else strokes[:5] + hints.append({ + "phrase": phrase, + "words_covered": length, + "strokes": best, + "is_single_stroke": bool(single), + "keys": parse_stroke(best[0]) if best else [], + }) + hints.sort(key=lambda h: h["words_covered"], reverse=True) + return hints + + def get_all_hints(self, word, context_words=None, context_index=None, + suppress_cap=False): + bare, trailing_punct = _strip_punctuation(word) + lookup_word = bare if bare else word + + strokes = self.get_strokes(lookup_word) + all_hints = [] + for s in strokes: + sc = s.count("/") + 1 + kc = sum(len(_parse_single_stroke(part)) for part in s.split("/")) + all_hints.append({ + "stroke": s, + "stroke_count": sc, + "key_count": kc, + "keys": parse_stroke(s), + }) + all_hints.sort(key=lambda h: (h["stroke_count"], h["key_count"])) + + fingerspell = not all_hints and len(lookup_word) > 0 + punct_hints = [] + for p in trailing_punct: + ps = self.punctuation_to_strokes.get(p, []) + if ps: + punct_hints.append({"char": p, "stroke": ps[0]}) + + phrase_hints = [] + if context_words and context_index is not None: + phrase_hints = self.detect_phrase_hints(context_words, context_index) + + cap_hints = [] + if (not suppress_cap and lookup_word and lookup_word[0].isupper() + and lookup_word not in _DISPLAY_OVERRIDES.values()): + for cap_type in ("cap_next", "cap_next_attach", "cap_retro"): + ss = self.cap_strokes.get(cap_type, []) + if ss: + cap_hints.append({"type": cap_type, "stroke": ss[0]}) + if (not suppress_cap and lookup_word and lookup_word.isupper() + and len(lookup_word) > 1): + for cap_type in ("upper_next", "upper_next_attach", "upper_retro"): + ss = self.cap_strokes.get(cap_type, []) + if ss: + cap_hints.append({"type": cap_type, "stroke": ss[0]}) + + return { + "word_hints": all_hints[:10], + "phrase_hints": phrase_hints, + "fingerspell": fingerspell, + "punct_hints": punct_hints, + "cap_hints": cap_hints, + } + + def get_punct_hints(self, char, limit=8, + space_after=False, cap_next=False): + """Build hint items for a punctuation character, sorted by context.""" + strokes = self.punctuation_to_strokes.get(char, []) + if space_after and cap_next: + target = "space_cap" + elif space_after: + target = "space_after" + else: + target = "no_space" + + hints = [] + for s in strokes: + sc = s.count("/") + 1 + kc = sum(len(_parse_single_stroke(part)) for part in s.split("/")) + ctx = self._stroke_context.get(s) + h = { + "stroke": s, + "stroke_count": sc, + "key_count": kc, + "keys": parse_stroke(s), + } + label = self._stroke_labels.get(s) + if label: + h["label"] = label + match_rank = 0 if ctx == target else 1 + hints.append((match_rank, sc, kc, h)) + + hints.sort(key=lambda t: (t[0], t[1], t[2])) + return [h for _, _, _, h in hints[:limit]] + + def word_exists(self, word): + key = word.lower().strip() + if key in self.word_to_strokes or key in self.phrase_to_strokes: + return True + bare, _ = _strip_punctuation(key) + if bare and bare != key: + return bare in self.word_to_strokes or bare in self.phrase_to_strokes + return False + + def display_form(self, word): + """Return the canonical display form (e.g. 'i' -> 'I').""" + return _DISPLAY_OVERRIDES.get(word.lower().strip(), word) + + @property + def practice_words(self): + return list(self._practice_words) + + @property + def single_stroke_words(self): + return list(self._single_stroke_words) + + @property + def all_phrases(self): + result = [] + for key in self.phrase_to_strokes: + display = self._phrase_display.get(key, key) + if _phrase_has_proper_noun(display): + continue + result.append(display) + return result + + def generate_phrasing_phrases(self, count=200): + """Generate random phrases from Python phrasing dicts (e.g. jeff-phrasing).""" + _V1 = ["", "A", "O", "AO"] + _STAR = ["", "*"] + _V2 = ["", "E", "U", "EU"] + _F = ["", "F"] + + all_phrases = [] + for pd in self._python_dicts: + mod = pd["module"] + starters = getattr(mod, "STARTERS", None) + enders = getattr(mod, "ENDERS", None) + simple_starters = getattr(mod, "SIMPLE_STARTERS", None) + simple_pronouns = getattr(mod, "SIMPLE_PRONOUNS", None) + if not starters or not enders: + continue + + starter_keys = list(starters.keys()) + ender_keys = list(enders.keys()) + ss_keys = list(simple_starters.keys()) if simple_starters else [] + sp_keys = list(simple_pronouns.keys()) if simple_pronouns else [] + + seen = set() + attempts = 0 + while len(all_phrases) < count and attempts < count * 30: + attempts += 1 + use_simple = ss_keys and sp_keys and random.random() < 0.3 + if use_simple: + stroke = (random.choice(ss_keys) + + random.choice(sp_keys) + + random.choice(_F) + + random.choice(ender_keys)) + else: + stroke = (random.choice(starter_keys) + + random.choice(_V1) + + random.choice(_STAR) + + random.choice(_V2) + + random.choice(_F) + + random.choice(ender_keys)) + try: + out = mod.lookup((stroke,)) + except (KeyError, Exception): + continue + out = out.strip() + if not out or " " not in out: + continue + wc = len(out.split()) + if wc < 2 or wc > 5: + continue + if out in seen: + continue + seen.add(out) + all_phrases.append(out) + + return all_phrases + + +_PUNCTUATION_CHARS = set(".,;:!?\"'()-[]{}…/") + +_PLOVER_PUNCT = { + # Old-style + "{.}": ".", "{,}": ",", "{!}": "!", "{?}": "?", + "{;}": ";", "{:}": ":", + # New-style + "{:stop:.}": ".", "{:stop:?}": "?", "{:stop:!}": "!", + "{:comma:,}": ",", "{:comma:;}": ";", "{:comma::}": ":", +} + + +def _strip_punctuation(word): + """Split trailing punctuation from a word. Returns (bare_word, [punct_chars]).""" + trailing = [] + while word and word[-1] in _PUNCTUATION_CHARS: + trailing.append(word[-1]) + word = word[:-1] + trailing.reverse() + return word, trailing + + +def _is_punctuation(text): + return len(text) == 1 and text in _PUNCTUATION_CHARS + + +def _phrase_has_proper_noun(phrase): + """True if phrase contains capitalized words other than 'I' and its contractions.""" + return any( + w[0].isupper() and w != "I" and not w.startswith("I'") + for w in phrase.split() if w + ) + + +def _is_practice_word(translation): + if not translation or not translation.strip(): + return False + if "{" in translation or "}" in translation: + return False + if not re.match(r"^[a-zA-Z][a-zA-Z '\-]*$", translation): + return False + stripped = translation.strip() + if len(stripped) > 1 and stripped.replace(" ", "").replace("-", "").replace("'", "").isupper(): + return False + return True + + +def parse_stroke(stroke): + if "/" in stroke: + return [_parse_single_stroke(s) for s in stroke.split("/")] + return [_parse_single_stroke(stroke)] + + +def _parse_single_stroke(stroke): + keys = [] + if stroke.startswith("#"): + keys.append("#") + stroke = stroke[1:] + if not stroke: + return keys + + left_keys = "STKPWHR" + center_keys = "AO*EU" + right_keys = "FRPBLGTSDZ" + + if "-" in stroke: + left_part, right_part = stroke.split("-", 1) + for ch in left_part: + if ch in left_keys: + keys.append(f"{ch}-") + elif ch in center_keys: + keys.append(ch) + for ch in right_part: + if ch in right_keys: + keys.append(f"-{ch}") + elif ch in center_keys: + keys.append(ch) + else: + pos = 0 + for ch in stroke: + for i in range(pos, len(STENO_ORDER)): + if STENO_ORDER[i] == ch: + pos = i + 1 + if i == 0: + keys.append("#") + elif i <= 7: + keys.append(f"{ch}-") + elif i <= 12: + keys.append(ch) + else: + keys.append(f"-{ch}") + break + return keys diff --git a/requirements.txt b/requirements.txt @@ -0,0 +1,3 @@ +flask>=3.0 +authlib>=1.3 +python-dotenv>=1.0 diff --git a/sentence_generator.py b/sentence_generator.py @@ -0,0 +1,172 @@ +"""Generate practice sentences from a constrained word pool. + +Uses NLTK's Brown and Gutenberg corpora — filters real English sentences +to those where every word belongs to the target pool. Falls back to +simple templates when the pool is too small to find corpus matches. +""" + +import re +import random + +_corpus_cache = None + + +def _normalize(word): + """Lowercase, strip non-alpha except apostrophes (for contractions).""" + return re.sub(r"[^a-z']", "", word.lower()) + + +def _load_corpus(): + """Lazily load and pre-process NLTK sentences into (word_set, text) pairs.""" + global _corpus_cache + if _corpus_cache is not None: + return _corpus_cache + + from nltk.corpus import brown, gutenberg + + raw = list(brown.sents()) + list(gutenberg.sents()) + corpus = [] + for tokens in raw: + clean = _rebuild(tokens) + if not clean: + continue + words = {_normalize(w) for w in clean.split()} + words.discard("") + if len(words) < 3: + continue + corpus.append((words, clean)) + + _corpus_cache = corpus + return corpus + + +def _rebuild(tokens): + """Reconstruct a sentence from NLTK tokens into clean readable text.""" + text = " ".join(tokens) + # Fix NLTK tokenization artifacts + text = re.sub(r" ([.,;:!?)\]}])", r"\1", text) + text = re.sub(r"([\[({]) ", r"\1", text) + text = text.replace("`` ", '"').replace(" ''", '"').replace("''", '"') + text = text.replace('``', '"') + text = text.replace(" n't", "n't").replace(" 're", "'re") + text = text.replace(" 've", "'ve").replace(" 'll", "'ll") + text = text.replace(" 'd", "'d").replace(" 's", "'s") + text = text.replace(" 'm", "'m") + # Strip leading/trailing quotes and whitespace + text = text.strip().strip('"').strip() + # Drop sentences with numbers + if re.search(r"\d", text): + return "" + # Must start with a letter and end with sentence-ending punctuation + if not text or not text[0].isalpha(): + return "" + if not text.endswith((".", "!", "?")): + return "" + # Ensure first character is uppercase + text = text[0].upper() + text[1:] + # Skip very short or very long + word_count = len(text.split()) + if word_count < 5 or word_count > 18: + return "" + # Skip sentences with remaining quote marks (dialogue fragments) + if '"' in text or '``' in text or "''" in text: + return "" + return text + + +def _filter_corpus(word_pool, count): + """Return up to `count` corpus sentences using only words from pool.""" + corpus = _load_corpus() + pool = {w.lower() for w in word_pool} + # Also include common contractions if their base is in the pool + extras = set() + for w in list(pool): + for suffix in ("n't", "'s", "'re", "'ve", "'ll", "'d", "'m"): + extras.add(w + suffix) + pool |= extras + + matches = [] + for word_set, text in corpus: + if word_set.issubset(pool): + matches.append(text) + + random.shuffle(matches) + return matches[:count] + + +# ── Fallback template generator for very small pools ── + +_PRONOUNS = {"i", "he", "she", "we", "they", "you", "it"} +_VERBS = { + "be", "have", "do", "say", "go", "get", "make", "know", "take", "come", + "see", "think", "look", "want", "give", "use", "find", "tell", "ask", + "work", "seem", "feel", "try", "leave", "call", "keep", "let", "begin", + "run", "read", "show", "turn", "play", "learn", "set", "change", "move", + "put", "start", "need", "help", "talk", "open", "mean", "add", "live", +} +_NOUNS = { + "time", "people", "year", "day", "way", "man", "world", "life", "hand", + "part", "place", "thing", "child", "eye", "woman", "work", "case", + "point", "home", "water", "room", "mother", "area", "money", "story", + "fact", "month", "lot", "right", "study", "book", "job", "word", "side", + "head", "house", "name", "end", "door", "car", "food", "night", "state", +} +_DETS = {"the", "a", "an", "this", "that", "some", "any", "no"} +_PREPS = {"in", "on", "at", "to", "for", "with", "from", "by", "about", "into", "over"} +_AUXS = {"will", "would", "can", "could", "should", "must", "may", "might"} + +_TEMPLATES = [ + ("SUBJ", "VERB", "DET", "NOUN"), + ("SUBJ", "AUX", "VERB"), + ("SUBJ", "VERB", "PREP", "DET", "NOUN"), + ("DET", "NOUN", "VERB", "PREP", "DET", "NOUN"), + ("SUBJ", "AUX", "VERB", "DET", "NOUN"), +] + +_SLOT_MAP = { + "SUBJ": _PRONOUNS, "VERB": _VERBS, "NOUN": _NOUNS, + "DET": _DETS, "PREP": _PREPS, "AUX": _AUXS, +} + + +def _template_sentences(word_pool, count): + pool = {w.lower() for w in word_pool} + available = {} + for slot, candidates in _SLOT_MAP.items(): + words = list(pool & candidates) + if words: + available[slot] = words + + usable = [t for t in _TEMPLATES if all(s in available for s in t)] + if not usable: + return [] + + results = set() + for _ in range(count * 10): + pattern = random.choice(usable) + words = [random.choice(available[s]) for s in pattern] + words[0] = words[0].capitalize() + words = ["I" if w == "i" else w for w in words] + sent = " ".join(words) + "." + results.add(sent) + if len(results) >= count: + break + return list(results)[:count] + + +def generate_sentences(word_pool, count=10): + """Generate practice sentences constrained to word_pool. + + Tries corpus filtering first; falls back to templates for small pools. + """ + if not word_pool: + return [] + + sentences = _filter_corpus(word_pool, count) + if len(sentences) >= count: + return sentences + + # Supplement with template-generated sentences + needed = count - len(sentences) + sentences.extend(_template_sentences(word_pool, needed)) + return sentences[:count] diff --git a/settings.py b/settings.py @@ -0,0 +1,38 @@ +"""Settings management for the stenography practice app.""" + +import json +import os + +SETTINGS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "settings.json") + +DEFAULTS = { + "plover_config_path": os.path.expanduser("~/.config/plover/plover.cfg"), + "words_per_session": 50, +} + + +def load(): + settings = dict(DEFAULTS) + if os.path.exists(SETTINGS_PATH): + try: + with open(SETTINGS_PATH) as f: + stored = json.load(f) + settings.update(stored) + except (json.JSONDecodeError, OSError): + pass + return settings + + +def save(settings): + with open(SETTINGS_PATH, "w") as f: + json.dump(settings, f, indent=2) + + +def get(key, default=None): + return load().get(key, default) + + +def update(updates): + settings = load() + settings.update(updates) + save(settings) diff --git a/spaced_repetition.py b/spaced_repetition.py @@ -0,0 +1,59 @@ +"""SM-2 spaced repetition algorithm for stenography practice.""" + + +def sm2(quality, ease_factor, interval, repetitions): + """ + SM-2 spaced repetition algorithm. + + quality: 0-5 (0=blackout, 5=perfect recall) + ease_factor: current ease factor (starts at 2.5) + interval: current interval in days + repetitions: number of consecutive correct reviews + + Returns: (new_ease_factor, new_interval, new_repetitions) + """ + # Calculate new ease factor + new_ef = ease_factor + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02)) + new_ef = max(1.3, new_ef) + + if quality >= 3: + # Correct response + new_repetitions = repetitions + 1 + if repetitions == 0: + new_interval = 1.0 + elif repetitions == 1: + new_interval = 6.0 + else: + new_interval = interval * new_ef + else: + # Incorrect response — reset + new_repetitions = 0 + new_interval = 1.0 + new_ef = ease_factor # Don't change ease factor on failure + + return (new_ef, new_interval, new_repetitions) + + +def calculate_review(correct, time_ms, ease_factor, interval, repetitions): + """ + Map practice result to SM-2 quality score and calculate next review. + + correct: whether the answer was correct + time_ms: time taken in milliseconds + ease_factor: current ease factor + interval: current interval in days + repetitions: consecutive correct count + + Returns: (new_ease_factor, new_interval, new_repetitions) + """ + if correct: + if time_ms < 2000: + quality = 5 # Perfect, fast recall + elif time_ms < 5000: + quality = 4 # Correct with hesitation + else: + quality = 3 # Correct but slow + else: + quality = 1 # Incorrect + + return sm2(quality, ease_factor, interval, repetitions) diff --git a/static/css/style.css b/static/css/style.css @@ -0,0 +1,1900 @@ +/* ===== RESET & BASE ===== */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --font-sans: 'Space Grotesk', -apple-system, sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', monospace; + --radius-sm: 10px; + --radius-md: 16px; + --radius-lg: 22px; + --transition: 200ms ease; + + /* Tokyo Night Storm (dark) */ + --base: #24283b; + --base-light: #292e42; + --base-dark: #1f2335; + --inset: #1a1b26; + --border: #3b4261; + --border-light: #3b4261; + --shadow-dark: rgba(0, 0, 0, 0.55); + --shadow-light: rgba(255, 255, 255, 0.06); + --neu-raised: 8px 8px 18px var(--shadow-dark), -8px -8px 18px var(--shadow-light); + --neu-raised-sm: 5px 5px 12px var(--shadow-dark), -5px -5px 12px var(--shadow-light); + --neu-raised-lg: 12px 12px 28px var(--shadow-dark), -12px -12px 28px var(--shadow-light); + --neu-pressed: inset 4px 4px 10px var(--shadow-dark), inset -4px -4px 10px var(--shadow-light); + --neu-pressed-deep: inset 6px 6px 16px var(--shadow-dark), inset -6px -6px 16px var(--shadow-light); + --neu-flat: 0 0 0 transparent; + + --text-primary: #c0caf5; + --text-secondary: #a9b1d6; + --text-muted: #737ba3; + --accent: #7aa2f7; + --accent-dark: #3d59a1; + --accent-glow: rgba(122, 162, 247, 0.25); + --success: #9ece6a; + --success-glow: rgba(158, 206, 106, 0.2); + --warning: #e0af68; + --warning-glow: rgba(224, 175, 104, 0.2); + --error: #f7768e; + --error-glow: rgba(247, 118, 142, 0.2); + --streak: #ff9e64; + --streak-glow: rgba(255, 158, 100, 0.2); + --text-on-color: #1f2335; + + --chart-blue: #7aa2f7; + --chart-green: #9ece6a; + --chart-red: #f7768e; + --heatmap-1: #1a2740; + --heatmap-2: #1f3d5c; + --heatmap-3: #2a6490; + --heatmap-4: #7aa2f7; + --cyan: #7dcfff; + --magenta: #bb9af7; + --teal: #73daca; +} + +[data-theme="light"] { + /* Tokyo Night Day (light) */ + --base: #d5d6db; + --base-light: #e1e2e7; + --base-dark: #c8c9ce; + --inset: #cbccd1; + --border: #b4b5b9; + --border-light: #b4b5b9; + --shadow-dark: rgba(0, 0, 0, 0.15); + --shadow-light: rgba(255, 255, 255, 0.7); + + --text-primary: #343b58; + --text-secondary: #4c505e; + --text-muted: #6e717b; + --accent: #2367c5; + --accent-dark: #1e5dba; + --accent-glow: rgba(35, 103, 197, 0.2); + --success: #587539; + --success-glow: rgba(88, 117, 57, 0.15); + --warning: #8f5e15; + --warning-glow: rgba(143, 94, 21, 0.15); + --error: #c2204d; + --error-glow: rgba(194, 32, 77, 0.15); + --streak: #b15c00; + --streak-glow: rgba(177, 92, 0, 0.15); + --text-on-color: #ffffff; + + --chart-blue: #2367c5; + --chart-green: #587539; + --chart-red: #c2204d; + --heatmap-1: #c8daf0; + --heatmap-2: #9bbde0; + --heatmap-3: #5a9bd4; + --heatmap-4: #2367c5; + --cyan: #007197; + --magenta: #7847bd; + --teal: #387068; +} + +html { + font-size: 16px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font-family: var(--font-sans); + background: radial-gradient(ellipse at 50% 15%, var(--base-light) 0%, var(--base) 55%, var(--base-dark) 100%); + background-attachment: fixed; + color: var(--text-primary); + line-height: 1.6; + min-height: 100vh; + font-weight: 500; + transition: color 0.3s ease; +} + +a { + color: var(--accent); + text-decoration: none; + transition: color var(--transition); +} +a:hover { color: var(--text-primary); } + +/* ===== NAVBAR ===== */ +.navbar { + position: sticky; + top: 0; + z-index: 100; +} + +.nav-inner { + max-width: 960px; + margin: 0 auto; + padding: 0 1.5rem; + display: flex; + align-items: center; + justify-content: space-between; + height: 56px; +} + +.nav-brand { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-size: 1.25rem; + font-weight: 700; + color: var(--text-primary); + letter-spacing: -0.02em; + text-transform: uppercase; +} +.nav-brand:hover { color: var(--accent); } + +.nav-logo { + width: 28px; + height: 28px; + color: var(--accent); +} + +.nav-links { + list-style: none; + display: flex; + gap: 0.25rem; + align-items: center; +} + +.nav-link { + display: block; + padding: 0.5rem 1rem; + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: 0.875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + transition: all var(--transition); +} +.nav-link:hover { + color: var(--text-primary); +} + +/* ===== HAMBURGER ===== */ +.nav-hamburger { + display: none; + flex-direction: column; + justify-content: center; + gap: 5px; + width: 36px; + height: 36px; + padding: 6px; + background: none; + border: none; + cursor: pointer; + z-index: 301; +} +.nav-hamburger span { + display: block; + height: 2px; + background: var(--text-primary); + border-radius: 2px; + transition: all 0.3s ease; +} +.nav-hamburger.active span:nth-child(1) { + transform: translateY(7px) rotate(45deg); +} +.nav-hamburger.active span:nth-child(2) { + opacity: 0; +} +.nav-hamburger.active span:nth-child(3) { + transform: translateY(-7px) rotate(-45deg); +} + +/* ===== MAIN CONTENT ===== */ +.main-content { + max-width: 960px; + margin: 0 auto; + padding: 2rem 1.5rem; +} + +/* ===== CARDS ===== */ +.card { + background: var(--base); + border-radius: var(--radius-md); + padding: 1.5rem; + box-shadow: var(--neu-raised); +} + +.stat-card { + background: var(--base); + border-radius: var(--radius-md); + padding: 1.25rem; + text-align: center; + transition: all var(--transition); + box-shadow: var(--neu-raised); +} +.stat-card:hover { + box-shadow: var(--neu-raised-lg); + transform: translateY(-2px); +} + +.stat-card--streak { + position: relative; + overflow: hidden; +} + +.stat-card__icon { + width: 36px; + height: 36px; + margin: 0 auto 0.5rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.streak-fire { background: var(--streak); } +.stat-icon--words { background: var(--accent); } +.stat-icon--mastered { background: var(--success); } +.stat-icon--review { background: var(--warning); } + +.stat-card__value { + font-size: 2rem; + font-weight: 700; + line-height: 1.2; +} + +.stat-card__label { + font-size: 0.8rem; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 600; + margin-top: 0.25rem; +} + +.stat-card__sub { + display: block; + font-size: 0.75rem; + color: var(--text-muted); + text-transform: none; + letter-spacing: 0; + margin-top: 0.125rem; +} + +.stat-card__action { + display: inline-block; + margin-top: 0.5rem; + font-size: 0.8rem; + padding: 0.3rem 0.8rem; + background: var(--warning); + color: var(--text-on-color); + border-radius: var(--radius-sm); + font-weight: 600; + border: none; + box-shadow: var(--neu-raised-sm); + transition: all var(--transition); + cursor: pointer; +} +.stat-card__action:hover { + box-shadow: var(--neu-pressed); + color: var(--text-on-color); +} + +/* ===== BUTTONS ===== */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.625rem 1.25rem; + border: none; + border-radius: var(--radius-sm); + background: var(--base); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + transition: all var(--transition); + text-decoration: none; + line-height: 1.4; + text-transform: uppercase; + letter-spacing: 0.03em; + box-shadow: var(--neu-raised-sm); +} +.btn:hover { + box-shadow: var(--neu-raised); + color: var(--text-primary); + transform: translateY(-1px); +} +.btn:active { + box-shadow: var(--neu-pressed); + transform: translateY(0); +} + +.btn--primary { + background: var(--accent); + color: var(--text-on-color); +} +.btn--primary:hover { + color: var(--text-on-color); +} + +.btn--danger { + background: var(--error); + color: var(--text-on-color); +} +.btn--danger:hover { + color: var(--text-on-color); +} + +.btn--ghost { + background: transparent; + color: var(--text-secondary); + box-shadow: none; +} +.btn--ghost:hover { + color: var(--text-primary); + background: var(--base); + box-shadow: var(--neu-raised-sm); +} +.btn--ghost:active { + box-shadow: var(--neu-pressed); +} + +.btn--small { + padding: 0.375rem 0.75rem; + font-size: 0.8rem; +} + +/* ===== BADGES ===== */ +.badge { + display: inline-block; + padding: 0.2rem 0.6rem; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + border: none; + background: var(--base-light); +} +.badge--common { color: var(--accent); } +.badge--new { color: var(--success); } +.badge--weak { color: var(--error); } +.badge--speed { color: var(--warning); } +.badge--review { color: var(--streak); } +.badge--random { color: var(--text-secondary); } +.badge--sentences { color: var(--cyan); } +.badge--phrases { color: var(--streak); } +.badge--lesson { color: var(--magenta); } +.badge--beginner { color: var(--success); } +.badge--intermediate { color: var(--warning); } +.badge--advanced { color: var(--error); } +.badge--expert { color: var(--magenta); } +.badge--master { color: var(--teal); } + +/* ===== PROGRESS BAR ===== */ +.progress-bar { + height: 10px; + background: var(--base); + border-radius: 999px; + overflow: hidden; + box-shadow: var(--neu-pressed); +} + +.progress-bar__fill { + height: 100%; + background: var(--accent); + border-radius: 999px; + transition: width 0.3s ease; +} + +.progress-bar__label { + font-size: 0.8rem; + color: var(--text-secondary); + margin-top: 0.5rem; + text-align: center; + font-weight: 600; +} + +/* ===== TABLES ===== */ +.table-wrapper { + overflow-x: auto; + border-radius: var(--radius-md); + box-shadow: var(--neu-pressed); + background: var(--base); +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} + +.table th { + text-align: left; + padding: 0.75rem 1rem; + background: var(--base-dark); + color: var(--text-secondary); + font-weight: 700; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.table td { + padding: 0.625rem 1rem; + border-bottom: 1px solid rgba(128,128,160,0.06); +} + +.table tbody tr:last-child td { border-bottom: none; } +.table tbody tr:hover { background: var(--base-light); } + +.table-empty { + text-align: center; + color: var(--text-muted); + padding: 2rem 1rem; +} + +.sortable { cursor: pointer; user-select: none; } +.sortable:hover { color: var(--accent); } +.sortable::after { content: ' \2195'; font-size: 0.7em; opacity: 0.5; } + +.accuracy-cell { font-weight: 700; } +.accuracy--good { color: var(--success); } +.accuracy--mid { color: var(--warning); } +.accuracy--low { color: var(--error); } + +/* ===== DASHBOARD ===== */ +.dashboard { + display: flex; + flex-direction: column; + gap: 2.5rem; +} + +.hero-stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1.25rem; +} + +.section-title { + font-size: 1rem; + font-weight: 700; + margin-bottom: 1rem; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.page-title { + font-size: 1.5rem; + font-weight: 700; + margin-bottom: 1.5rem; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +/* ===== LESSON GRID ===== */ +.lesson-grid { + display: flex; + flex-direction: column; + gap: 0.75rem; + background: var(--base); + border-radius: var(--radius-lg); + padding: 1rem; + box-shadow: var(--neu-pressed-deep); +} + +.lesson-card { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.875rem 1.125rem; + background: var(--base); + border-radius: var(--radius-md); + text-decoration: none; + transition: all var(--transition); + box-shadow: var(--neu-raised-sm); +} +.lesson-card:hover { + box-shadow: var(--neu-raised); + transform: translateY(-1px); + color: var(--text-primary); +} +.lesson-card:active { + box-shadow: var(--neu-pressed); + transform: translateY(0); +} + +.lesson-card__order { + width: 34px; + height: 34px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + font-size: 0.85rem; + font-weight: 700; + flex-shrink: 0; + color: var(--text-on-color); +} + +.lesson-card--beginner .lesson-card__order { background: var(--success); color: var(--text-on-color); } +.lesson-card--intermediate .lesson-card__order { background: var(--warning); color: var(--text-on-color); } +.lesson-card--advanced .lesson-card__order { background: var(--error); color: var(--text-on-color); } +.lesson-card--expert .lesson-card__order { background: var(--magenta); color: var(--text-on-color); } +.lesson-card--master .lesson-card__order { background: var(--teal); color: var(--text-on-color); } + +.lesson-card__info { flex: 1; min-width: 0; } +.lesson-card__title { font-weight: 600; font-size: 0.95rem; color: var(--text-primary); } +.lesson-card__subtitle { font-size: 0.8rem; color: var(--text-secondary); } +.lesson-card__level { flex-shrink: 0; } + +.lesson-progress { margin-top: 0.35rem; } +.lesson-progress__bar { + position: relative; + height: 4px; + background: var(--surface-hover); + border-radius: 2px; + overflow: hidden; +} +.lesson-progress__fill { + position: absolute; + top: 0; left: 0; height: 100%; + border-radius: 2px; + transition: width 0.3s ease; +} +.lesson-progress__fill--practiced { + background: var(--text-muted); + opacity: 0.3; +} +.lesson-progress__fill--mastered { + background: var(--success); +} +.lesson-progress__text { + display: flex; + justify-content: space-between; + font-size: 0.7rem; + color: var(--text-muted); + margin-top: 0.2rem; +} + +/* ===== MODE GRID ===== */ +.mode-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1rem; + background: var(--base); + border-radius: var(--radius-lg); + padding: 1rem; + box-shadow: var(--neu-pressed-deep); +} + +.mode-card { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 1.25rem 0.75rem; + background: var(--base); + border-radius: var(--radius-md); + transition: all var(--transition); + text-decoration: none; + box-shadow: var(--neu-raised-sm); +} +.mode-card:hover { + box-shadow: var(--neu-raised); + transform: translateY(-2px); + color: var(--text-primary); +} +.mode-card:active { + box-shadow: var(--neu-pressed); + transform: translateY(0); +} + +.mode-card__icon { + width: 44px; + height: 44px; + border-radius: 50%; + margin-bottom: 0.75rem; +} + +.mode-icon--common { background: var(--accent); } +.mode-icon--new { background: var(--success); } +.mode-icon--weak { background: var(--error); } +.mode-icon--speed { background: var(--warning); } +.mode-icon--review { background: #7c3aed; } +.mode-icon--random { background: #64748b; } +.mode-icon--sentences { background: #0ea5e9; } +.mode-icon--phrases { background: var(--streak); } + +.mode-card__title { + font-weight: 700; + font-size: 0.9rem; + color: var(--text-primary); + margin-bottom: 0.25rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.mode-card__desc { + font-size: 0.8rem; + color: var(--text-secondary); +} + +/* ===== TODAY'S PROGRESS ===== */ +.progress-container { + background: var(--base); + border-radius: var(--radius-md); + padding: 1.25rem; + box-shadow: var(--neu-pressed); +} + +/* ===== EMPTY STATE ===== */ +.empty-state { + text-align: center; + padding: 3rem 1.5rem; + color: var(--text-secondary); + background: var(--base); + border-radius: var(--radius-md); + box-shadow: var(--neu-pressed); +} + +/* ===== PRACTICE PAGE ===== */ +#practice-container { + min-height: calc(100vh - 56px - 4rem); + display: flex; + flex-direction: column; + justify-content: center; +} + +.practice-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1.5rem; +} + +.practice-mode-label { + font-size: 1rem; + font-weight: 700; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.practice-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem; + color: var(--text-secondary); + gap: 1rem; +} + +.session-config { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} +.session-config__label { + font-size: 0.85rem; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; +} +.session-config__options { + display: flex; + gap: 0.5rem; +} +.session-config__btn { + padding: 0.5rem 1.2rem; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--surface); + color: var(--text-secondary); + cursor: pointer; + font-size: 0.85rem; + transition: all 0.15s ease; +} +.session-config__btn:hover { + border-color: var(--accent); + color: var(--text-primary); +} +.session-config__btn--active { + background: var(--accent); + color: var(--base); + border-color: var(--accent); + font-weight: 600; +} +.session-config__start { + margin-top: 0.5rem; + padding: 0.65rem 2rem; + font-size: 1rem; +} + +.spinner { + width: 32px; + height: 32px; + border: 3px solid var(--base-light); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { to { transform: rotate(360deg); } } + +.practice-area { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 1.5rem; + max-width: 1000px; + margin: 0 auto; +} + +.practice-main { + display: flex; + gap: 1.5rem; + align-items: flex-start; +} + +.practice-left { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +/* ===== MONKEYTYPE TYPING DISPLAY ===== */ +.typing-display { + width: 100%; + background: var(--base); + border-radius: var(--radius-lg); + padding: 2rem 2.5rem; + min-height: 180px; + max-height: 220px; + overflow-y: auto; + scrollbar-width: none; + cursor: text; + position: relative; + transition: box-shadow var(--transition); + box-shadow: var(--neu-pressed-deep); +} + +.typing-display::-webkit-scrollbar { + display: none; +} + +.typing-display.typing-display--focused { + box-shadow: var(--neu-pressed-deep); +} + +.typing-input { + position: fixed; + top: -100px; + left: -100px; + opacity: 0; + pointer-events: none; + width: 1px; + height: 1px; +} + +.typing-words { + font-family: var(--font-mono); + font-size: 1.5rem; + line-height: 2.4; + color: var(--text-muted); + user-select: none; +} + +.tw-word { transition: color 0.1s ease; } +.tw-word--active { color: var(--text-primary); } +.tw-word--correct { color: var(--text-muted); } +.tw-word--incorrect { + color: var(--error); + text-decoration: underline; + text-decoration-color: var(--error); + text-decoration-thickness: 3px; + text-underline-offset: 4px; +} +.tw-word--punct { + color: var(--accent); +} +.tw-word--phrase { + color: var(--streak); + background: rgba(251, 146, 60, 0.08); + border-radius: 3px; + padding: 0 2px; +} + +.tw-char { position: relative; } +.tw-char--correct { color: var(--success); } +.tw-char--incorrect { color: var(--error); } +.tw-char--extra { color: var(--error); opacity: 0.7; } +.tw-space-sep { + display: inline-block; + width: 1ch; + text-align: center; + color: var(--text-muted); + opacity: 0.25; + user-select: none; +} +.tw-space-sep--done { color: var(--success); opacity: 0.2; } +.tw-space-sep--correct { color: var(--success); opacity: 0.5; } +.tw-space-sep--incorrect { color: var(--error); opacity: 1; } +.tw-space-sep--missed { color: var(--error); opacity: 0.7; text-decoration: line-through; } +.tw-space-sep--pending { color: var(--accent); opacity: 0.5; } + +.tw-cursor { + display: inline-block; + width: 3px; + height: 1.4em; + background: var(--accent); + vertical-align: text-bottom; + animation: cursor-blink 1s step-end infinite; + margin-left: 1px; + border-radius: 2px; +} + +@keyframes cursor-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +/* ===== TYPED TEXT DISPLAY ===== */ +.typed-text-display { + font-family: var(--font-mono); + font-size: 0.9rem; + color: var(--text-secondary); + min-height: 1.6em; + padding: 0.5rem 0.75rem; + background: var(--base); + border-radius: var(--radius-sm); + white-space: pre-wrap; + word-break: break-all; + box-shadow: var(--neu-pressed); +} + +/* ===== STENO HINT ===== */ +.steno-hint { + width: 100%; + background: var(--base); + border-radius: var(--radius-md); + padding: 1rem; + box-shadow: var(--neu-pressed); +} + +.steno-hint--revealed { } + +.steno-keyboards { + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +.steno-keyboard-section { flex: 1; min-width: 0; } +.steno-keyboard-section--multi { flex-basis: 100%; } + +.steno-keyboards-row { + display: flex; + gap: 0.5rem; + align-items: flex-start; +} + +.steno-keyboard-cell { flex: 1; min-width: 0; text-align: center; } + +.steno-keyboard-cell__label { + font-family: var(--font-mono); + font-size: 0.75rem; + font-weight: 700; + color: var(--accent); + margin-bottom: 0.25rem; +} +.steno-keyboard-cell__label--phrase { color: var(--streak); } + +.steno-hint__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; +} + +.steno-hint__title { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + font-weight: 700; +} +.steno-hint__title--phrase { color: var(--streak); } + +.steno-stroke { + font-family: var(--font-mono); + font-size: 1.2rem; + font-weight: 700; + color: var(--accent); +} +.steno-stroke--phrase { color: var(--streak); } + +.steno-hint__keyboard { display: flex; justify-content: center; } + +.steno-keyboard { + width: 100%; + max-width: 520px; + height: auto; +} + +.steno-key { + fill: var(--base-light); + stroke: none; + transition: fill 0.15s ease, filter 0.15s ease; +} + +.steno-key.active { + fill: var(--accent); +} + +.steno-keyboard--phrase .steno-key.active { + fill: var(--streak); +} + +.steno-key-label { + fill: var(--text-secondary); + font-family: var(--font-sans); + font-size: 12px; + font-weight: 700; + text-anchor: middle; + dominant-baseline: central; + pointer-events: none; +} +.steno-key-label--small { font-size: 10px; } + +/* ===== SIDE HINTS PANEL ===== */ +.hints-panel { + width: 260px; + flex-shrink: 0; + background: var(--base); + border-radius: var(--radius-md); + padding: 1rem; + max-height: 600px; + overflow-y: auto; + position: sticky; + top: 72px; + box-shadow: var(--neu-pressed); +} + +.hints-panel__header { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-muted); + margin-bottom: 0.75rem; + font-weight: 700; +} + +.hints-list { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.hint-item { + display: flex; + flex-direction: column; + gap: 0.125rem; + padding: 0.5rem 0.625rem; + background: var(--base); + border-radius: var(--radius-sm); + cursor: pointer; + transition: all var(--transition); + box-shadow: var(--neu-raised-sm); +} +.hint-item:hover { + box-shadow: var(--neu-raised); + transform: translateY(-1px); +} +.hint-item:active { + box-shadow: var(--neu-pressed); + transform: translateY(0); +} + +.hint-item--best { + background: var(--accent); + color: var(--text-on-color); + box-shadow: var(--neu-raised-sm); +} +.hint-item--best .hint-item__stroke { color: var(--text-on-color); } +.hint-item--best .hint-item__meta { opacity: 0.75; color: var(--text-on-color); } + +.hint-item--empty { + color: var(--text-muted); + font-size: 0.8rem; + cursor: default; + text-align: center; + box-shadow: none; +} + +.hint-item--punct { opacity: 0.8; } +.hint-item--punct .hint-item__stroke { + font-size: 0.8rem; + color: var(--text-secondary); +} +.hint-item--cap { opacity: 0.8; } +.hint-item--cap .hint-item__stroke { + font-size: 0.8rem; + color: var(--accent); +} + +.hint-item__stroke { + font-family: var(--font-mono); + font-size: 0.9rem; + font-weight: 700; + color: var(--text-primary); +} + +.hint-item__meta { + font-size: 0.7rem; + color: var(--text-muted); +} + +.hint-item__label { + display: inline-block; + background: var(--accent); + color: var(--base-dark); + padding: 0 5px; + border-radius: 3px; + font-weight: 600; + font-size: 0.65rem; + margin-left: 4px; + vertical-align: middle; +} + +/* ===== PHRASE HINTS ===== */ +.phrase-hints { + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid rgba(128,128,160,0.08); +} + +.phrase-hints__header { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--streak); + margin-bottom: 0.5rem; + font-weight: 700; +} + +.phrase-hints-list { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.phrase-hint-item { + padding: 0.5rem 0.625rem; + background: var(--base); + border-radius: var(--radius-sm); + display: flex; + flex-direction: column; + gap: 0.2rem; + box-shadow: var(--neu-raised-sm); +} + +.phrase-hint-item--single { + background: var(--streak); + box-shadow: var(--neu-raised-sm); +} +.phrase-hint-item--single .phrase-hint-item__phrase { color: var(--text-on-color); } +.phrase-hint-item--single .phrase-hint-item__stroke { color: var(--text-on-color); opacity: 0.9; } +.phrase-hint-item--single .phrase-hint-item__words { color: var(--text-on-color); opacity: 0.6; } + +.phrase-hint-item__phrase { + font-size: 0.8rem; + font-weight: 700; + color: var(--text-primary); + font-style: italic; +} + +.phrase-hint-item__words { + font-size: 0.65rem; + color: var(--text-muted); +} + +.phrase-hint-item__stroke { + font-family: var(--font-mono); + font-size: 0.8rem; + font-weight: 700; + color: var(--streak); +} + +/* ===== SESSION STATS BAR ===== */ +.session-stats { + display: flex; + gap: 1.5rem; + padding: 0.75rem 1.5rem; + background: var(--base); + border-radius: var(--radius-md); + width: 100%; + justify-content: center; + box-shadow: var(--neu-pressed); +} + +.session-stat { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.85rem; +} + +.session-stat__icon { + width: 8px; + height: 8px; + border-radius: 50%; +} + +.session-stat__icon--time { background: var(--accent); } +.session-stat__icon--accuracy { background: var(--success); } +.session-stat__icon--streak { background: var(--streak); } +.session-stat__icon--wpm { background: var(--warning); } +.session-stat__icon--progress { background: #a78bfa; } + +.session-stat__value { + font-weight: 700; + color: var(--text-primary); +} + +/* ===== MODAL / SESSION SUMMARY ===== */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(8px); +} + +.modal { + background: var(--base); + border-radius: var(--radius-lg); + padding: 2.5rem; + max-width: 500px; + width: 90%; + box-shadow: var(--neu-raised-lg), 0 20px 60px rgba(0,0,0,0.3); +} + +.modal__title { + text-align: center; + font-size: 1.25rem; + font-weight: 700; + margin-bottom: 1.5rem; + color: var(--text-primary); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.summary-accuracy { + text-align: center; + font-size: 3.5rem; + font-weight: 800; + margin-bottom: 1.5rem; +} + +.summary-accuracy--good { color: var(--success); text-shadow: 0 0 20px var(--success-glow); } +.summary-accuracy--mid { color: var(--warning); text-shadow: 0 0 20px var(--warning-glow); } +.summary-accuracy--low { color: var(--error); text-shadow: 0 0 20px var(--error-glow); } + +.summary-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + margin-bottom: 1.5rem; + text-align: center; +} + +.summary-stat__label { + display: block; + font-size: 0.75rem; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 600; +} + +.summary-stat__value { + display: block; + font-size: 1.5rem; + font-weight: 700; + margin-top: 0.25rem; +} + +.summary-weak-words { margin-bottom: 1.5rem; } + +.summary-weak-words__title { + font-size: 0.85rem; + font-weight: 700; + color: var(--text-secondary); + margin-bottom: 0.5rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.summary-weak-words__list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.weak-word-chip { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.3rem 0.7rem; + background: var(--base); + border-radius: 999px; + font-size: 0.8rem; + box-shadow: var(--neu-raised-sm); +} + +.weak-word-chip__word { font-weight: 700; color: var(--error); } +.weak-word-chip__stroke { + font-family: var(--font-mono); + font-size: 0.7rem; + color: var(--text-muted); +} + +.summary-errors__list { + display: flex; + flex-direction: column; + gap: 0.4rem; + max-height: 200px; + overflow-y: auto; +} +.error-detail { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.35rem 0.7rem; + background: var(--base); + border-radius: var(--radius); + font-size: 0.8rem; +} +.error-detail__words { + display: flex; + align-items: center; + gap: 0.4rem; + min-width: 0; +} +.error-detail__expected { + font-weight: 700; + color: var(--success); +} +.error-detail__arrow { + color: var(--text-muted); + font-size: 0.7rem; +} +.error-detail__typed { + font-weight: 600; + color: var(--error); +} +.error-detail__stroke { + font-family: var(--font-mono); + font-size: 0.7rem; + color: var(--text-muted); + white-space: nowrap; +} + +.modal__actions { + display: flex; + gap: 0.75rem; + justify-content: center; +} + +/* ===== STATS PAGE ===== */ +.stats-page { + display: flex; + flex-direction: column; + gap: 2rem; +} + +.stats-overview { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 1rem; +} + +.stats-section { + background: var(--base); + border-radius: var(--radius-md); + padding: 1.5rem; + box-shadow: var(--neu-pressed); +} + +.stats-section .section-title { margin-bottom: 1.25rem; } + +.chart-container { + position: relative; + width: 100%; + height: 280px; +} +.chart-container canvas { width: 100% !important; height: 100% !important; } + +/* ===== HEATMAP ===== */ +.heatmap-container { overflow-x: auto; padding-bottom: 0.5rem; } + +.heatmap { + display: inline-grid; + grid-template-rows: repeat(7, 14px); + grid-auto-flow: column; + grid-auto-columns: 14px; + gap: 3px; +} + +.heatmap-cell { + width: 14px; + height: 14px; + border-radius: 3px; + background: var(--base-dark); + box-shadow: var(--neu-pressed); +} + +.heatmap-cell[data-level="1"] { background: var(--heatmap-1); box-shadow: none; } +.heatmap-cell[data-level="2"] { background: var(--heatmap-2); box-shadow: none; } +.heatmap-cell[data-level="3"] { background: var(--heatmap-3); box-shadow: none; } +.heatmap-cell[data-level="4"] { background: var(--heatmap-4); box-shadow: none; } + +.heatmap-months { + display: flex; + gap: 0; + margin-bottom: 0.5rem; + font-size: 0.65rem; + color: var(--text-muted); +} +.heatmap-month { text-align: left; } + +.heatmap-legend { + display: flex; + align-items: center; + gap: 0.25rem; + margin-top: 0.75rem; + font-size: 0.7rem; + color: var(--text-muted); +} +.heatmap-legend__cell { width: 12px; height: 12px; border-radius: 3px; } + +/* ===== SETTINGS PAGE ===== */ +.settings-page { max-width: 700px; } + +.settings-section { + background: var(--base); + border-radius: var(--radius-md); + padding: 1.5rem; + margin-bottom: 1.5rem; + box-shadow: var(--neu-pressed); +} + +.settings-section--danger { + box-shadow: var(--neu-pressed); +} + +.setting-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} +.setting-row:last-child { margin-bottom: 0; } + +.setting-label { font-weight: 700; font-size: 0.9rem; color: var(--text-primary); margin-bottom: 0.25rem; } +.setting-help { font-size: 0.8rem; color: var(--text-muted); margin-top: 0.25rem; } + +.setting-input-group { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.setting-input { + padding: 0.5rem 0.75rem; + background: var(--base); + border: none; + border-radius: var(--radius-sm); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 0.875rem; + min-width: 300px; + transition: box-shadow var(--transition); + box-shadow: var(--neu-pressed); +} +.setting-input--short { min-width: 80px; max-width: 100px; } +.setting-input:focus { + outline: none; + box-shadow: var(--neu-pressed-deep), 0 0 0 2px var(--accent); +} + +.settings-toast { + position: fixed; + bottom: 2rem; + right: 2rem; + padding: 0.75rem 1.5rem; + border-radius: var(--radius-sm); + font-size: 0.875rem; + font-weight: 600; + z-index: 1000; + border: none; +} + +.settings-toast--success { + background: var(--success); + color: var(--text-on-color); + box-shadow: var(--neu-raised-sm); +} + +.settings-toast--error { + background: var(--error); + color: var(--text-on-color); + box-shadow: var(--neu-raised-sm); +} + +/* ===== THEME TOGGLE ===== */ +.theme-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 0; + background: var(--base); + border: none; + border-radius: 50%; + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition); + font-size: 1.1rem; + line-height: 1; + box-shadow: var(--neu-raised-sm); +} +.theme-toggle:hover { + color: var(--accent); + box-shadow: var(--neu-raised); + transform: translateY(-1px); +} +.theme-toggle:active { + box-shadow: var(--neu-pressed); + transform: translateY(0); +} + +/* ===== RESPONSIVE ===== */ +@media (max-width: 768px) { + /* --- Mobile Nav Overlay --- */ + .nav-hamburger { display: flex; } + .nav-links { + position: fixed; + inset: 0; + background: var(--base); + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + z-index: 300; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s ease; + } + .nav-links.open { + opacity: 1; + pointer-events: all; + } + .nav-links .nav-link { + font-size: 1.5rem; + padding: 1rem 2rem; + } + .nav-links .theme-toggle { + margin-top: 1rem; + width: 48px; + height: 48px; + font-size: 1.4rem; + } + + /* --- Dashboard --- */ + .hero-stats { grid-template-columns: repeat(2, 1fr); } + .mode-grid { grid-template-columns: repeat(2, 1fr); } + .nav-inner { padding: 0 1rem; } + .main-content { padding: 1.5rem 1rem; } + .summary-stats { grid-template-columns: 1fr; gap: 0.75rem; } + .modal { padding: 1.5rem; } + .stats-overview { grid-template-columns: repeat(2, 1fr); } + .setting-row { flex-direction: column; } + .setting-input { min-width: unset; width: 100%; } + + /* --- Practice Mobile (portrait) --- */ + #practice-container { + min-height: calc(100dvh - 56px); + justify-content: flex-start; + padding: 0; + } + .practice-header { + padding: 0.5rem 0; + margin-bottom: 0.5rem; + } + .practice-header .btn { font-size: 0.75rem; padding: 0.4rem 0.75rem; } + .practice-mode-label { font-size: 0.85rem; } + .practice-area { gap: 0.5rem; } + .practice-main { + flex-direction: column; + gap: 0.5rem; + } + .practice-left { gap: 0.5rem; } + .typing-display { + min-height: unset; + max-height: 130px; + padding: 0.75rem 1rem; + border-radius: var(--radius-md); + } + .typing-words { + font-size: 1.1rem; + line-height: 2; + } + .typed-text-display { display: none; } + .steno-hint { padding: 0.5rem; } + .steno-keyboard { max-width: 360px; } + .hints-panel { + width: 100%; + max-height: 120px; + position: static; + padding: 0.75rem; + } + .hints-list { flex-direction: row; flex-wrap: wrap; gap: 0.25rem; } + .hint-item { padding: 0.35rem 0.5rem; } + .hint-item__stroke { font-size: 0.8rem; } + .hint-item__meta { display: none; } + .session-stats { + flex-wrap: wrap; + gap: 0.5rem 1rem; + padding: 0.5rem 1rem; + font-size: 0.8rem; + } +} + +@media (max-width: 480px) { + .hero-stats { grid-template-columns: repeat(2, 1fr); } + .mode-grid { grid-template-columns: repeat(2, 1fr); } + .practice-header { flex-wrap: wrap; gap: 0.5rem; } + + .typing-display { max-height: 110px; padding: 0.6rem 0.75rem; } + .typing-words { font-size: 1rem; line-height: 1.9; } + .hints-panel { max-height: 100px; } + .steno-keyboard { max-width: 300px; } +} + +/* --- Practice Landscape Mobile --- */ +@media (max-height: 500px) and (orientation: landscape) { + #practice-container { + min-height: calc(100dvh - 56px); + justify-content: flex-start; + padding: 0; + } + .main-content { padding: 0.5rem 1rem; } + .practice-header { margin-bottom: 0.25rem; padding: 0.25rem 0; } + .practice-header .btn { font-size: 0.7rem; padding: 0.3rem 0.6rem; } + .practice-mode-label { font-size: 0.8rem; } + .practice-area { gap: 0.35rem; } + .practice-main { + flex-direction: row; + gap: 0.5rem; + align-items: stretch; + } + .practice-left { gap: 0.35rem; flex: 1; } + .typing-display { + min-height: unset; + max-height: 90px; + padding: 0.5rem 0.75rem; + border-radius: var(--radius-sm); + } + .typing-words { font-size: 0.95rem; line-height: 1.8; } + .typed-text-display { display: none; } + .steno-hint { padding: 0.35rem; } + .steno-keyboard { max-width: 280px; } + .steno-keyboard-cell__label { font-size: 0.65rem; } + .hints-panel { + width: 180px; + max-height: unset; + position: static; + padding: 0.5rem; + flex-shrink: 0; + } + .hints-panel__header { font-size: 0.6rem; margin-bottom: 0.35rem; } + .hint-item { padding: 0.25rem 0.4rem; } + .hint-item__stroke { font-size: 0.75rem; } + .hint-item__meta { display: none; } + .session-stats { + padding: 0.35rem 0.75rem; + gap: 0.75rem; + font-size: 0.75rem; + } + .session-stat__value { font-size: 0.75rem; } +} + +/* ===== LOGIN PAGE ===== */ +.login-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; +} + +.login-card { + background: var(--base); + border-radius: var(--radius-lg); + padding: 3rem 2.5rem; + max-width: 400px; + width: 100%; + text-align: center; + box-shadow: var(--neu-raised-lg); +} + +.login-brand { + margin-bottom: 2rem; +} + +.login-logo { + width: 56px; + height: 56px; + color: var(--accent); + margin-bottom: 0.75rem; +} + +.login-title { + font-size: 1.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.25rem; +} + +.login-subtitle { + font-size: 0.9rem; + color: var(--text-secondary); +} + +.login-error { + background: var(--error); + color: var(--text-on-color); + padding: 0.75rem 1rem; + border-radius: var(--radius-sm); + margin-bottom: 1.5rem; + font-size: 0.875rem; + font-weight: 600; +} + +.btn--google { + width: 100%; + padding: 0.75rem 1.5rem; + background: var(--base); + color: var(--text-primary); + font-size: 0.95rem; + gap: 0.75rem; + box-shadow: var(--neu-raised); + margin-bottom: 1.5rem; +} +.btn--google:hover { + box-shadow: var(--neu-raised-lg); + transform: translateY(-2px); +} + +.google-icon { + flex-shrink: 0; +} + +.login-footer { + font-size: 0.8rem; + color: var(--text-muted); +} + +/* ===== NAV USER ===== */ +.nav-user { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.nav-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + box-shadow: var(--neu-raised-sm); +} + +.nav-link--logout { + color: var(--text-muted); + font-size: 0.8rem; +} + +/* ===== DICTIONARY BANNER ===== */ +.dict-banner { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem 1.25rem; + background: var(--base); + border-radius: var(--radius-md); + box-shadow: var(--neu-pressed); + border-left: 4px solid var(--warning); + margin-bottom: 0; +} + +.dict-banner__icon { + flex-shrink: 0; + color: var(--warning); +} + +.dict-banner__text { + font-size: 0.875rem; + color: var(--text-secondary); + line-height: 1.5; +} +.dict-banner__text strong { + color: var(--text-primary); +} + +/* ===== DICTIONARY MANAGEMENT ===== */ +.dict-upload-row { + margin-bottom: 1rem; +} + +.dict-upload-group { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.dict-upload-btn { + cursor: pointer; +} + +.dict-list { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.dict-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.625rem 0.875rem; + background: var(--base); + border-radius: var(--radius-sm); + box-shadow: var(--neu-raised-sm); + transition: all var(--transition); +} +.dict-item:hover { + box-shadow: var(--neu-raised); +} + +.dict-item--missing { + opacity: 0.6; +} +.dict-item--missing .dict-item__count { + color: var(--warning); +} + +.dict-item__order { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: var(--accent); + color: var(--text-on-color); + font-size: 0.7rem; + font-weight: 700; + flex-shrink: 0; +} + +.dict-item__name { + flex: 1; + min-width: 0; + font-size: 0.875rem; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dict-item__count { + font-size: 0.75rem; + color: var(--text-muted); + flex-shrink: 0; +} + +.dict-item__toggle { + flex-shrink: 0; + cursor: pointer; +} + +.dict-item__toggle input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--accent); + cursor: pointer; +} + +.dict-item__delete { + padding: 0.25rem 0.4rem; + font-size: 0.8rem; + color: var(--text-muted); +} +.dict-item__delete:hover { + color: var(--error); +} + +.dict-empty { + text-align: center; + padding: 1.5rem; + color: var(--text-muted); + font-size: 0.875rem; +} + +.upload-progress { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + margin-bottom: 0.75rem; + background: var(--base); + border-radius: var(--radius-sm); + box-shadow: var(--neu-pressed); + font-size: 0.85rem; + color: var(--text-secondary); + font-weight: 600; +} + +.dict-status { + margin-top: 0.75rem; + padding: 0.5rem 0.75rem; + border-radius: var(--radius-sm); + font-size: 0.8rem; + font-weight: 600; +} + +@media (max-width: 768px) { + .login-card { padding: 2rem 1.5rem; } + .nav-user { flex-direction: column; gap: 0.25rem; } + .nav-avatar { width: 40px; height: 40px; } + .dict-upload-group { flex-direction: column; } + .dict-item { flex-wrap: wrap; } + .dict-item__name { flex-basis: calc(100% - 100px); } +} diff --git a/static/js/app.js b/static/js/app.js @@ -0,0 +1,1319 @@ +(function(){var m=document.cookie.match(/(?:^| )theme=([^;]+)/);document.documentElement.setAttribute('data-theme',m?m[1]:'dark')})(); + +function getCookie(name) { + const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)')); + return match ? match[2] : null; +} + +function setCookie(name, value, days) { + const d = new Date(); + d.setTime(d.getTime() + (days * 86400000)); + document.cookie = name + '=' + value + ';expires=' + d.toUTCString() + ';path=/;SameSite=Lax'; +} + +function getCSSVar(name) { + return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); +} + +/* ===== UTILITY FUNCTIONS ===== */ + +function escapeHtml(str) { + if (typeof str !== 'string') return ''; + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +function formatTime(ms) { + if (ms < 1000) return ms + 'ms'; + if (ms < 60000) return (ms / 1000).toFixed(1) + 's'; + const minutes = Math.floor(ms / 60000); + const seconds = Math.floor((ms % 60000) / 1000); + return minutes + ':' + String(seconds).padStart(2, '0'); +} + +function formatWpm(wpm) { + if (typeof wpm !== 'number' || isNaN(wpm)) return '0.0'; + return wpm.toFixed(1); +} + +function formatAccuracy(pct) { + if (typeof pct !== 'number' || isNaN(pct)) return '0%'; + return Math.round(pct) + '%'; +} + +function formatDuration(seconds) { + if (!seconds || seconds < 60) return (seconds || 0) + 's'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return minutes + 'm'; + const hours = Math.floor(minutes / 60); + const remainMin = minutes % 60; + return hours + 'h ' + remainMin + 'm'; +} + +function accuracyClass(pct) { + if (pct >= 90) return 'good'; + if (pct >= 70) return 'mid'; + return 'low'; +} + + +/* ===== MONKEYTYPE-STYLE PRACTICE SESSION ===== */ + +class PracticeSession { + constructor(mode, lessonId) { + this.mode = mode; + this.lessonId = lessonId; + this.sentenceCount = 10; + this.sessionId = null; + this.words = []; + this.wordIndex = 0; + this.typedChars = ''; + this._expectsSpace = false; + this._spaceOk = null; + this._activeSpaceEl = null; + this._punctSpaceEl = null; + this._expectedBuf = ''; + this._expectedBufStack = []; + this._missedSpaces = new Set(); + this.consumedLength = 0; + this.consumedStack = []; + this.wordStartTime = null; + this.sessionStartTime = null; + this.timerInterval = null; + this.ended = false; + + this.stats = { + attempted: 0, + correct: 0, + streak: 0, + bestStreak: 0, + totalTimeMs: 0, + results: [], + }; + + this.els = { + loading: document.getElementById('loading'), + area: document.getElementById('practice-area'), + typingDisplay: document.getElementById('typing-display'), + typingWords: document.getElementById('typing-words'), + typingInput: document.getElementById('typing-input'), + stenoHint: document.getElementById('steno-hint'), + stenoStrokeText: document.getElementById('steno-stroke-text'), + hintsPanel: document.getElementById('hints-panel'), + hintsList: document.getElementById('hints-list'), + phraseHints: document.getElementById('phrase-hints'), + phraseHintsList: document.getElementById('phrase-hints-list'), + typedTextDisplay: document.getElementById('typed-text-display'), + stenoKeyboards: document.getElementById('steno-keyboards'), + statTime: document.getElementById('stat-time'), + statAccuracy: document.getElementById('stat-accuracy'), + statStreak: document.getElementById('stat-streak'), + statWpm: document.getElementById('stat-wpm'), + statProgress: document.getElementById('stat-progress'), + summary: document.getElementById('session-summary'), + summaryAccuracy: document.getElementById('summary-accuracy'), + summaryWords: document.getElementById('summary-words'), + summaryWpm: document.getElementById('summary-wpm'), + summaryStreak: document.getElementById('summary-streak'), + summaryWeakWords: document.getElementById('summary-weak-words'), + endBtn: document.getElementById('end-session-btn'), + }; + + this._bindEvents(); + this._setupConfig(); + } + + _bindEvents() { + // The hidden input captures all keystrokes + this.els.typingInput.addEventListener('input', () => this._onInput()); + + this.els.typingInput.addEventListener('keydown', (e) => { + if (e.key === 'Tab' || e.key === 'Escape') { + e.preventDefault(); + } + }); + + // Click on the typing display focuses the input + this.els.typingDisplay.addEventListener('click', () => { + this.els.typingInput.focus(); + }); + + this.els.typingInput.addEventListener('focus', () => { + this.els.typingDisplay.classList.add('typing-display--focused'); + }); + this.els.typingInput.addEventListener('blur', () => { + this.els.typingDisplay.classList.remove('typing-display--focused'); + }); + + this.els.endBtn.addEventListener('click', () => this.end()); + + // Keep input focused + document.addEventListener('keydown', (e) => { + if (this.ended) return; + if (e.target.tagName === 'INPUT' && e.target !== this.els.typingInput) return; + if (document.activeElement !== this.els.typingInput) { + this.els.typingInput.focus(); + } + }); + } + + _setupConfig() { + const configEl = document.getElementById('session-config'); + const startBtn = document.getElementById('start-session-btn'); + if (!configEl || !startBtn) { + this.start(); + return; + } + + const btns = configEl.querySelectorAll('.session-config__btn'); + btns.forEach(btn => { + btn.addEventListener('click', () => { + btns.forEach(b => b.classList.remove('session-config__btn--active')); + btn.classList.add('session-config__btn--active'); + this.sentenceCount = parseInt(btn.dataset.count) || 10; + }); + }); + + startBtn.addEventListener('click', () => { + configEl.innerHTML = '<div class="spinner"></div><p>Loading session...</p>'; + this.start(); + }); + } + + async start() { + try { + const body = { mode: this.mode }; + if (this.lessonId) { + body.lesson_id = this.lessonId; + body.sentence_count = this.sentenceCount; + } + + const resp = await fetch('/api/session/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await resp.json(); + + if (data.error) { + this.els.loading.innerHTML = '<p style="color: var(--error);">' + escapeHtml(data.error) + '</p>'; + return; + } + + this.sessionId = data.session_id; + this.words = data.words || []; + this.isSentence = !!data.is_sentence; + + this.els.loading.style.display = 'none'; + this.els.area.style.display = 'flex'; + + if (typeof gsap !== 'undefined') { + const tl = gsap.timeline({ defaults: { ease: 'power2.out', clearProps: 'all' } }); + tl.from('.typing-display', { y: 20, opacity: 0, duration: 0.4 }) + .from('.steno-hint', { y: 20, opacity: 0, duration: 0.4 }, '-=0.25') + .from('.hints-panel', { x: 20, opacity: 0, duration: 0.4 }, '-=0.2') + .from('.session-stats', { y: 15, opacity: 0, duration: 0.35 }, '-=0.15'); + } + + this._renderWords(); + this._updateHints(); + this._updateStats(); + + this.els.typingInput.focus(); + } catch (err) { + this.els.loading.innerHTML = '<p style="color: var(--error);">Failed to start session.</p>'; + } + } + + _renderWords() { + const container = this.els.typingWords; + container.innerHTML = ''; + this._activeSpaceEl = null; + this._punctSpaceEl = null; + + // Determine which upcoming words are covered by phrase hints + const phraseSet = new Set(); + const currentWord = this.words[this.wordIndex]; + if (currentWord && currentWord.phrase_hints) { + currentWord.phrase_hints.forEach(ph => { + for (let j = 0; j < ph.words_covered; j++) { + phraseSet.add(this.wordIndex + j); + } + }); + } + + this.words.forEach((w, i) => { + const wordEl = document.createElement('span'); + wordEl.className = 'tw-word'; + wordEl.dataset.index = i; + + if (i < this.wordIndex) { + const result = this.stats.results[i]; + if (result && result.correct) { + wordEl.classList.add('tw-word--correct'); + } else { + wordEl.classList.add('tw-word--incorrect'); + } + wordEl.textContent = w.word; + } else if (i === this.wordIndex) { + wordEl.classList.add('tw-word--active'); + this._renderActiveWord(wordEl, w.word); + } else { + wordEl.textContent = w.word; + if (phraseSet.has(i)) { + wordEl.classList.add('tw-word--phrase'); + } + } + + if (w.is_punct) wordEl.classList.add('tw-word--punct'); + container.appendChild(wordEl); + + if (i < this.words.length - 1) { + const next = this.words[i + 1]; + if (!next.is_punct) { + if (this.isSentence) { + const spaceEl = document.createElement('span'); + if (i + 1 < this.wordIndex && this._missedSpaces.has(i + 1)) { + spaceEl.className = 'tw-space-sep tw-space-sep--missed'; + } else if (i + 1 < this.wordIndex) { + spaceEl.className = 'tw-space-sep tw-space-sep--done'; + } else { + spaceEl.className = 'tw-space-sep'; + } + spaceEl.textContent = '␣'; + if (i + 1 === this.wordIndex) { + this._activeSpaceEl = spaceEl; + } + // Track space after active punct token + if (i === this.wordIndex && w.is_punct) { + this._punctSpaceEl = spaceEl; + } + container.appendChild(spaceEl); + } else { + container.appendChild(document.createTextNode(' ')); + } + } + } + }); + + this._scrollToActive(); + } + + _renderActiveWord(wordEl, expectedWord) { + wordEl.innerHTML = ''; + const typed = this.typedChars; + + for (let i = 0; i < expectedWord.length; i++) { + // Insert cursor before the next untyped character + if (i === typed.length) { + const cursor = document.createElement('span'); + cursor.className = 'tw-cursor'; + wordEl.appendChild(cursor); + } + + const charSpan = document.createElement('span'); + if (i < typed.length) { + if (typed[i] === expectedWord[i]) { + charSpan.className = 'tw-char tw-char--correct'; + } else { + charSpan.className = 'tw-char tw-char--incorrect'; + } + charSpan.textContent = expectedWord[i]; + } else { + charSpan.className = 'tw-char'; + charSpan.textContent = expectedWord[i]; + } + wordEl.appendChild(charSpan); + } + + // Extra typed characters beyond the word length + if (typed.length > expectedWord.length) { + for (let i = expectedWord.length; i < typed.length; i++) { + const charSpan = document.createElement('span'); + charSpan.className = 'tw-char tw-char--extra'; + charSpan.textContent = typed[i]; + wordEl.appendChild(charSpan); + } + const cursor = document.createElement('span'); + cursor.className = 'tw-cursor'; + wordEl.appendChild(cursor); + } + } + + _scrollToActive() { + const active = this.els.typingWords.querySelector('.tw-word--active'); + if (!active) return; + + const container = this.els.typingDisplay; + const padding = parseFloat(getComputedStyle(container).paddingTop) || 0; + container.scrollTop = Math.max(0, active.offsetTop - padding); + } + + _onInput() { + if (this.ended) return; + if (this.isSentence) return this._onInputSentence(); + + const raw = this.els.typingInput.value; + + // Undo: Plover's * sends backspaces — if input shrank past consumed boundary, revert words + let didUndo = false; + while (raw.length < this.consumedLength && this.wordIndex > 0) { + this.wordIndex--; + this.consumedLength = this.consumedStack.pop() || 0; + const last = this.stats.results.pop(); + if (last) { + this.stats.attempted--; + this.stats.totalTimeMs -= last.timeMs; + if (last.correct) this.stats.correct--; + } + this.stats.streak = 0; + for (let i = this.stats.results.length - 1; i >= 0; i--) { + if (this.stats.results[i].correct) this.stats.streak++; + else break; + } + didUndo = true; + } + if (didUndo) { + this.wordStartTime = performance.now(); + this._renderWords(); + this._updateHints(); + this._updateStats(); + } + + if (this.wordIndex >= this.words.length) return; + + // Current word's text = everything past consumed, with leading whitespace stripped + let current = raw.substring(this.consumedLength); + const trimmed = current.replace(/^\s+/, ''); + + // Ignore empty input before session starts (Plover focus noise) + if (!trimmed && !this.sessionStartTime) return; + + if (!this.sessionStartTime && trimmed) { + this.sessionStartTime = performance.now(); + this.wordStartTime = performance.now(); + this.timerInterval = setInterval(() => { + if (this.sessionStartTime && !this.ended) { + const elapsed = performance.now() - this.sessionStartTime; + this.els.statTime.textContent = formatTime(Math.round(elapsed)); + } + }, 200); + } + + this.typedChars = trimmed; + if (this.els.typedTextDisplay) { + this.els.typedTextDisplay.textContent = trimmed || ' '; + } + const expected = this.words[this.wordIndex].word; + + // Multi-word output: typed text contains expected word + space + more + if (trimmed.length > expected.length) { + const head = trimmed.substring(0, expected.length); + const tail = trimmed.substring(expected.length); + if (head === expected && /^\s/.test(tail)) { + this.typedChars = head; + this.consumedStack.push(this.consumedLength); + this.consumedLength = raw.length - tail.replace(/^\s+/, '').length; + this._submitCurrentWord(); + this._onInput(); + return; + } + } + + // Auto-match: exact match (case matters for steno capitalization) + if (trimmed === expected) { + this.consumedStack.push(this.consumedLength); + this.consumedLength = raw.length; + this._submitCurrentWord(); + return; + } + + // Update character feedback on active word + const activeEl = this.els.typingWords.querySelector('.tw-word--active'); + if (activeEl) { + this._renderActiveWord(activeEl, expected); + } + this._updateStats(); + } + + _onInputSentence() { + const raw = this.els.typingInput.value; + const buf = raw.trimStart(); + + if (!buf && !this.sessionStartTime) return; + + if (!this.sessionStartTime && buf) { + this.sessionStartTime = performance.now(); + this.wordStartTime = performance.now(); + this.timerInterval = setInterval(() => { + if (this.sessionStartTime && !this.ended) { + const elapsed = performance.now() - this.sessionStartTime; + this.els.statTime.textContent = formatTime(Math.round(elapsed)); + } + }, 200); + } + + if (this.wordIndex >= this.words.length) return; + + // Undo detection: buffer no longer matches our tracked expected prefix + while (this.wordIndex > 0 && this._expectedBufStack.length > 0) { + if (buf.length < this._expectedBuf.length || + buf.substring(0, this._expectedBuf.length) !== this._expectedBuf) { + this._expectedBuf = this._expectedBufStack.pop(); + this.wordIndex--; + this._missedSpaces.delete(this.wordIndex); + const last = this.stats.results.pop(); + if (last) { + this.stats.attempted--; + this.stats.totalTimeMs -= last.timeMs; + if (last.correct) this.stats.correct--; + } + this.stats.streak = 0; + for (let i = this.stats.results.length - 1; i >= 0; i--) { + if (this.stats.results[i].correct) this.stats.streak++; + else break; + } + this.wordStartTime = performance.now(); + this._renderWords(); + this._updateHints(); + this._updateStats(); + } else { + break; + } + } + + // Advance through tokens, tolerating missed spaces + let advanced = false; + while (this.wordIndex < this.words.length) { + const token = this.words[this.wordIndex]; + const needsSpace = !token.is_punct && this._expectedBuf.length > 0; + const withSpace = this._expectedBuf + (needsSpace ? ' ' : '') + token.word; + const noSpace = needsSpace ? (this._expectedBuf + token.word) : null; + + if (buf.length >= withSpace.length && + buf.substring(0, withSpace.length) === withSpace) { + this._expectedBufStack.push(this._expectedBuf); + this._expectedBuf = withSpace; + this.typedChars = token.word; + this._submitCurrentWord(); + advanced = true; + } else if (noSpace && buf.length >= noSpace.length && + buf.substring(0, noSpace.length) === noSpace) { + this._expectedBufStack.push(this._expectedBuf); + this._expectedBuf = noSpace; + this._missedSpaces.add(this.wordIndex); + this.typedChars = token.word; + this._submitCurrentWord(); + advanced = true; + } else { + break; + } + } + + if (advanced) return; + + // Show partial-typing feedback on the active token + const token = this.words[this.wordIndex]; + const afterPrev = buf.substring(this._expectedBuf.length); + + const expectsSpace = !token.is_punct && this._expectedBuf.length > 0; + this._expectsSpace = expectsSpace; + if (expectsSpace) { + this._spaceOk = afterPrev.length > 0 ? afterPrev[0] === ' ' : null; + this.typedChars = afterPrev.replace(/^\s?/, ''); + } else { + this._spaceOk = null; + this.typedChars = token.is_punct ? afterPrev : afterPrev.replace(/^\s+/, ''); + } + + if (this.els.typedTextDisplay) { + this.els.typedTextDisplay.textContent = this.typedChars || ' '; + } + + // Update space separator colors + if (this._activeSpaceEl) { + if (this._spaceOk === true) { + this._activeSpaceEl.className = 'tw-space-sep tw-space-sep--correct'; + } else if (this._spaceOk === false) { + this._activeSpaceEl.className = 'tw-space-sep tw-space-sep--incorrect'; + } else { + this._activeSpaceEl.className = 'tw-space-sep'; + } + } + // Highlight space after active punct to show the stroke includes it + if (this._punctSpaceEl && token.is_punct && this.typedChars.length > 0) { + this._punctSpaceEl.className = 'tw-space-sep tw-space-sep--pending'; + } + + const activeEl = this.els.typingWords.querySelector('.tw-word--active'); + if (activeEl) { + this._renderActiveWord(activeEl, token.word); + } + this._updateStats(); + } + + async _submitCurrentWord() { + if (this.wordIndex >= this.words.length) return; + + const word = this.words[this.wordIndex]; + const typed = this.typedChars; + const timeMs = Math.round(performance.now() - this.wordStartTime); + const correct = typed === word.word; + + this.stats.attempted++; + this.stats.totalTimeMs += timeMs; + + if (correct) { + this.stats.correct++; + this.stats.streak++; + if (this.stats.streak > this.stats.bestStreak) { + this.stats.bestStreak = this.stats.streak; + } + } else { + this.stats.streak = 0; + } + + this.stats.results.push({ + word: word.word, + typed: typed, + correct: correct, + timeMs: timeMs, + strokes: word.word_hints && word.word_hints[0] ? word.word_hints[0].stroke : '', + }); + + // Submit to server (fire and forget for speed) + fetch('/api/word/submit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + session_id: this.sessionId, + word: word.word, + typed: typed, + time_ms: timeMs, + word_index: this.wordIndex, + }), + }).catch(() => {}); + + // Advance (don't clear input — Plover manages the text buffer) + this.wordIndex++; + this.typedChars = ''; + if (this.els.typedTextDisplay) { + this.els.typedTextDisplay.textContent = ' '; + } + this.wordStartTime = performance.now(); + + this._updateStats(); + + if (this.wordIndex >= this.words.length) { + this.end(); + return; + } + + this._renderWords(); + this._updateHints(); + } + + _updateHints() { + if (this.wordIndex >= this.words.length) return; + + const word = this.words[this.wordIndex]; + const wordHints = word.word_hints || []; + const phraseHints = word.phrase_hints || []; + + // Phrase hints in side panel (already sorted longest-first by server) + if (phraseHints.length > 0 && this.els.phraseHintsList) { + this.els.phraseHints.style.display = 'block'; + let html = ''; + phraseHints.forEach(ph => { + const strokesStr = ph.strokes.join(' or '); + html += '<div class="phrase-hint-item' + (ph.is_single_stroke ? ' phrase-hint-item--single' : '') + '">'; + html += '<span class="phrase-hint-item__phrase">"' + escapeHtml(ph.phrase) + '"</span>'; + html += '<span class="phrase-hint-item__words">' + ph.words_covered + ' words</span>'; + if (strokesStr) { + html += '<span class="phrase-hint-item__stroke">' + escapeHtml(strokesStr) + '</span>'; + } + html += '</div>'; + }); + this.els.phraseHintsList.innerHTML = html; + } else if (this.els.phraseHints) { + this.els.phraseHints.style.display = 'none'; + } + + // Build keyboard visualizations + this._renderKeyboards(phraseHints, wordHints, word); + + // Word-level strokes in side panel + if (wordHints.length > 0) { + let html = ''; + wordHints.forEach((hint, i) => { + const cls = i === 0 ? 'hint-item hint-item--best' : 'hint-item'; + const strokeLabel = hint.stroke_count === 1 ? '1 stroke' : hint.stroke_count + ' strokes'; + html += '<div class="' + cls + '" data-hint-index="' + i + '">'; + html += '<span class="hint-item__stroke">' + escapeHtml(hint.stroke) + '</span>'; + const labelTag = hint.label ? ' <span class="hint-item__label">' + escapeHtml(hint.label) + '</span>' : ''; + html += '<span class="hint-item__meta">' + strokeLabel + ', ' + hint.key_count + ' keys' + labelTag + '</span>'; + html += '</div>'; + }); + this.els.hintsList.innerHTML = html; + + this.els.hintsList.querySelectorAll('.hint-item').forEach(el => { + el.addEventListener('click', () => { + const idx = parseInt(el.dataset.hintIndex); + if (wordHints[idx] && wordHints[idx].keys) { + this._renderKeyboards(phraseHints, wordHints, word, idx); + } + }); + }); + } else if (word.fingerspell) { + this.els.hintsList.innerHTML = '<div class="hint-item hint-item--empty">Fingerspell: spell out each letter</div>'; + } else { + this.els.hintsList.innerHTML = '<div class="hint-item hint-item--empty">No strokes found</div>'; + } + + // Punctuation hints below word strokes + const punctHints = word.punct_hints || []; + if (punctHints.length > 0) { + let punctHtml = ''; + punctHints.forEach(ph => { + punctHtml += '<div class="hint-item hint-item--punct">'; + punctHtml += '<span class="hint-item__stroke">' + escapeHtml(ph.char) + ' → ' + escapeHtml(ph.stroke) + '</span>'; + punctHtml += '</div>'; + }); + this.els.hintsList.innerHTML += punctHtml; + } + + // Capitalization hints + const capHints = word.cap_hints || []; + if (capHints.length > 0) { + const capLabels = { + 'cap_next': 'Capitalize', + 'cap_next_attach': 'Cap (no space)', + 'cap_retro': 'Retro cap', + 'upper_next': 'ALL CAPS', + 'upper_next_attach': 'ALL CAPS (no space)', + 'upper_retro': 'Retro ALL CAPS', + 'lower_next': 'Lowercase', + 'lower_retro': 'Retro lowercase', + }; + let capHtml = ''; + capHints.forEach(ch => { + const label = capLabels[ch.type] || ch.type; + capHtml += '<div class="hint-item hint-item--cap">'; + capHtml += '<span class="hint-item__stroke">' + escapeHtml(label) + ' → ' + escapeHtml(ch.stroke) + '</span>'; + capHtml += '</div>'; + }); + this.els.hintsList.innerHTML += capHtml; + } + } + + _renderKeyboards(phraseHints, wordHints, word, wordHintIdx) { + const container = this.els.stenoKeyboards; + container.innerHTML = ''; + if (wordHintIdx === undefined) wordHintIdx = 0; + + const bestPhrase = phraseHints.length > 0 ? phraseHints[0] : null; + const bestWord = wordHints.length > 0 ? wordHints[wordHintIdx] : null; + + if (bestPhrase && bestPhrase.keys && bestPhrase.keys.length > 0) { + const strokeParts = (bestPhrase.strokes[0] || '').split('/'); + this._appendKeyboardGroup(container, 'Phrase', bestPhrase.strokes[0], bestPhrase.keys, 'phrase', strokeParts); + } + + if (bestWord && bestWord.keys && bestWord.keys.length > 0) { + const strokeParts = bestWord.stroke.split('/'); + this._appendKeyboardGroup(container, 'Word', bestWord.stroke, bestWord.keys, 'word', strokeParts); + } else if (word.fingerspell) { + const section = document.createElement('div'); + section.className = 'steno-keyboard-section'; + section.innerHTML = '<div class="steno-hint__header">' + + '<span class="steno-hint__title">Word</span>' + + '<span class="steno-stroke">fingerspell</span>' + + '</div>'; + container.appendChild(section); + } + } + + _appendKeyboardGroup(container, label, strokeText, keyGroups, type, strokeParts) { + const isPhrase = type === 'phrase'; + const titleClass = isPhrase ? ' steno-hint__title--phrase' : ''; + const strokeClass = isPhrase ? ' steno-stroke--phrase' : ''; + + if (keyGroups.length === 1) { + const section = document.createElement('div'); + section.className = 'steno-keyboard-section'; + section.innerHTML = '<div class="steno-hint__header">' + + '<span class="steno-hint__title' + titleClass + '">' + escapeHtml(label) + '</span>' + + '<span class="steno-stroke' + strokeClass + '">' + escapeHtml(strokeText) + '</span>' + + '</div>'; + const kbWrap = document.createElement('div'); + kbWrap.className = 'steno-hint__keyboard'; + const svg = this._createKeyboardSVG(isPhrase); + this._highlightKeysOn(svg, keyGroups[0]); + kbWrap.appendChild(svg); + section.appendChild(kbWrap); + container.appendChild(section); + } else { + const section = document.createElement('div'); + section.className = 'steno-keyboard-section steno-keyboard-section--multi'; + section.innerHTML = '<div class="steno-hint__header">' + + '<span class="steno-hint__title' + titleClass + '">' + escapeHtml(label) + '</span>' + + '<span class="steno-stroke' + strokeClass + '">' + escapeHtml(strokeText) + '</span>' + + '</div>'; + const row = document.createElement('div'); + row.className = 'steno-keyboards-row'; + keyGroups.forEach((keys, i) => { + const cell = document.createElement('div'); + cell.className = 'steno-keyboard-cell'; + if (strokeParts && strokeParts[i]) { + const partLabel = document.createElement('div'); + partLabel.className = 'steno-keyboard-cell__label' + (isPhrase ? ' steno-keyboard-cell__label--phrase' : ''); + partLabel.textContent = strokeParts[i]; + cell.appendChild(partLabel); + } + const svg = this._createKeyboardSVG(isPhrase); + this._highlightKeysOn(svg, keys); + cell.appendChild(svg); + row.appendChild(cell); + }); + section.appendChild(row); + container.appendChild(section); + } + } + + _createKeyboardSVG(isPhrase) { + const ns = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(ns, 'svg'); + svg.setAttribute('class', 'steno-keyboard' + (isPhrase ? ' steno-keyboard--phrase' : '')); + svg.setAttribute('viewBox', '0 0 520 170'); + const keys = [ + { x: 10, y: 5, w: 500, h: 28, key: '#', lx: 260, ly: 24, small: true }, + { x: 10, y: 40, w: 44, h: 72, key: 'S-', lx: 32, ly: 80 }, + { x: 58, y: 40, w: 44, h: 34, key: 'T-', lx: 80, ly: 62 }, + { x: 106, y: 40, w: 44, h: 34, key: 'P-', lx: 128, ly: 62 }, + { x: 154, y: 40, w: 44, h: 34, key: 'H-', lx: 176, ly: 62 }, + { x: 210, y: 40, w: 44, h: 72, key: '*', lx: 232, ly: 80 }, + { x: 266, y: 40, w: 44, h: 34, key: '-F', lx: 288, ly: 62 }, + { x: 314, y: 40, w: 44, h: 34, key: '-P', lx: 336, ly: 62 }, + { x: 362, y: 40, w: 44, h: 34, key: '-L', lx: 384, ly: 62 }, + { x: 410, y: 40, w: 44, h: 34, key: '-T', lx: 432, ly: 62 }, + { x: 458, y: 40, w: 44, h: 34, key: '-D', lx: 480, ly: 62 }, + { x: 58, y: 78, w: 44, h: 34, key: 'K-', lx: 80, ly: 100 }, + { x: 106, y: 78, w: 44, h: 34, key: 'W-', lx: 128, ly: 100 }, + { x: 154, y: 78, w: 44, h: 34, key: 'R-', lx: 176, ly: 100 }, + { x: 266, y: 78, w: 44, h: 34, key: '-R', lx: 288, ly: 100 }, + { x: 314, y: 78, w: 44, h: 34, key: '-B', lx: 336, ly: 100 }, + { x: 362, y: 78, w: 44, h: 34, key: '-G', lx: 384, ly: 100 }, + { x: 410, y: 78, w: 44, h: 34, key: '-S', lx: 432, ly: 100 }, + { x: 458, y: 78, w: 44, h: 34, key: '-Z', lx: 480, ly: 100 }, + { x: 130, y: 120, w: 52, h: 34, key: 'A', lx: 156, ly: 142 }, + { x: 186, y: 120, w: 52, h: 34, key: 'O', lx: 212, ly: 142 }, + { x: 282, y: 120, w: 52, h: 34, key: 'E', lx: 308, ly: 142 }, + { x: 338, y: 120, w: 52, h: 34, key: 'U', lx: 364, ly: 142 }, + ]; + const labelMap = { + '#': '#', 'S-': 'S', 'T-': 'T', 'P-': 'P', 'H-': 'H', '*': '*', + '-F': 'F', '-P': 'P', '-L': 'L', '-T': 'T', '-D': 'D', + 'K-': 'K', 'W-': 'W', 'R-': 'R', + '-R': 'R', '-B': 'B', '-G': 'G', '-S': 'S', '-Z': 'Z', + 'A': 'A', 'O': 'O', 'E': 'E', 'U': 'U', + }; + keys.forEach(k => { + const rect = document.createElementNS(ns, 'rect'); + rect.setAttribute('x', k.x); + rect.setAttribute('y', k.y); + rect.setAttribute('width', k.w); + rect.setAttribute('height', k.h); + rect.setAttribute('rx', '4'); + rect.setAttribute('class', 'steno-key'); + rect.setAttribute('data-key', k.key); + svg.appendChild(rect); + const text = document.createElementNS(ns, 'text'); + text.setAttribute('x', k.lx); + text.setAttribute('y', k.ly); + text.setAttribute('class', 'steno-key-label' + (k.small ? ' steno-key-label--small' : '')); + text.textContent = labelMap[k.key] || k.key; + svg.appendChild(text); + }); + return svg; + } + + _highlightKeysOn(svgEl, keys) { + if (!keys || !svgEl) return; + keys.forEach(key => { + const el = svgEl.querySelector('.steno-key[data-key="' + CSS.escape(key) + '"]'); + if (el) { + el.classList.add('active'); + const nextText = el.nextElementSibling; + if (nextText && nextText.tagName === 'text') { + nextText.style.fill = getCSSVar('--text-on-color'); + } + } + }); + } + + _updateStats() { + const elapsed = performance.now() - (this.sessionStartTime || performance.now()); + this.els.statTime.textContent = formatTime(Math.round(elapsed)); + + const accuracy = this.stats.attempted > 0 + ? (this.stats.correct / this.stats.attempted) * 100 + : 0; + this.els.statAccuracy.textContent = formatAccuracy(accuracy); + this.els.statStreak.textContent = this.stats.streak; + + const avgWpm = this.stats.totalTimeMs > 0 && this.stats.attempted > 0 + ? (this.stats.attempted / (this.stats.totalTimeMs / 60000)) + : 0; + this.els.statWpm.textContent = formatWpm(avgWpm) + ' WPM'; + + this.els.statProgress.textContent = this.wordIndex + '/' + this.words.length; + } + + async end() { + if (this.ended) return; + this.ended = true; + if (this.timerInterval) { + clearInterval(this.timerInterval); + this.timerInterval = null; + } + + try { + const resp = await fetch('/api/session/end', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: this.sessionId }), + }); + await resp.json(); + } catch (err) {} + + this._showSummary(); + } + + _showSummary() { + const accuracy = this.stats.attempted > 0 + ? (this.stats.correct / this.stats.attempted) * 100 + : 0; + const avgWpm = this.stats.totalTimeMs > 0 && this.stats.attempted > 0 + ? (this.stats.attempted / (this.stats.totalTimeMs / 60000)) + : 0; + + const accClass = accuracyClass(accuracy); + this.els.summaryAccuracy.textContent = formatAccuracy(accuracy); + this.els.summaryAccuracy.className = 'summary-accuracy summary-accuracy--' + accClass; + + this.els.summaryWords.textContent = this.stats.correct + ' / ' + this.stats.attempted; + this.els.summaryWpm.textContent = formatWpm(avgWpm); + this.els.summaryStreak.textContent = this.stats.bestStreak; + + const errors = this.stats.results.filter(r => !r.correct); + const slowest = this.stats.results + .filter(r => r.correct) + .sort((a, b) => b.timeMs - a.timeMs) + .slice(0, 5); + + if (errors.length > 0) { + let html = '<div class="summary-weak-words__title">Errors (' + errors.length + ')</div>'; + html += '<div class="summary-errors__list">'; + errors.slice(0, 15).forEach(w => { + html += '<div class="error-detail">'; + html += '<div class="error-detail__words">'; + html += '<span class="error-detail__expected">' + escapeHtml(w.word) + '</span>'; + html += '<span class="error-detail__arrow">&rarr;</span>'; + html += '<span class="error-detail__typed">' + escapeHtml(w.typed || '(empty)') + '</span>'; + html += '</div>'; + if (w.strokes) { + html += '<span class="error-detail__stroke">' + escapeHtml(w.strokes) + '</span>'; + } + html += '</div>'; + }); + html += '</div>'; + if (slowest.length > 0) { + html += '<div class="summary-weak-words__title" style="margin-top:1rem;">Slowest Correct</div>'; + html += '<div class="summary-weak-words__list">'; + slowest.forEach(w => { + html += '<span class="weak-word-chip">'; + html += '<span class="weak-word-chip__word">' + escapeHtml(w.word) + '</span>'; + html += '<span class="weak-word-chip__stroke">' + formatTime(w.timeMs) + '</span>'; + html += '</span>'; + }); + html += '</div>'; + } + this.els.summaryWeakWords.innerHTML = html; + } else { + this.els.summaryWeakWords.innerHTML = '<p style="text-align:center;color:var(--success);font-weight:600;">Perfect session!</p>'; + } + + this.els.summary.style.display = 'flex'; + + if (typeof gsap !== 'undefined') { + const stl = gsap.timeline({ defaults: { clearProps: 'all' } }); + stl.from('.modal', { y: 30, scale: 0.95, opacity: 0, duration: 0.35, ease: 'back.out(1.2)' }) + .from('.summary-accuracy', { scale: 0.5, opacity: 0, duration: 0.5, ease: 'back.out(1.5)' }, '-=0.1') + .from('.summary-stats .summary-stat', { y: 15, opacity: 0, duration: 0.3, stagger: 0.08, ease: 'power2.out' }, '-=0.2'); + } + } +} + + +/* ===== STATS PAGE ===== */ + +async function loadStats() { + let overview, daily, heatmap, words, sessions; + + try { + const results = await Promise.allSettled([ + fetch('/api/stats/overview').then(r => r.json()), + fetch('/api/stats/daily?days=30').then(r => r.json()), + fetch('/api/stats/heatmap').then(r => r.json()), + fetch('/api/stats/words?sort=accuracy&order=asc&limit=50').then(r => r.json()), + fetch('/api/stats/sessions?limit=20').then(r => r.json()) + ]); + + overview = results[0].status === 'fulfilled' ? results[0].value : null; + daily = results[1].status === 'fulfilled' ? results[1].value : null; + heatmap = results[2].status === 'fulfilled' ? results[2].value : null; + words = results[3].status === 'fulfilled' ? results[3].value : null; + sessions = results[4].status === 'fulfilled' ? results[4].value : null; + } catch (err) { + console.error('Failed to load stats:', err); + return; + } + + if (overview) renderOverview(overview); + if (daily) { + renderWpmChart(daily); + renderDailyChart(daily); + } + if (heatmap) renderHeatmap(heatmap); + if (words) renderWordTable(words); + if (sessions) renderSessionTable(sessions); +} + +function renderOverview(data) { + const setEl = (id, val) => { + const el = document.getElementById(id); + if (el) el.textContent = val; + }; + + setEl('overview-sessions', data.total_sessions || 0); + setEl('overview-words', data.total_words || 0); + setEl('overview-mastered', data.words_mastered || 0); + setEl('overview-accuracy', formatAccuracy(data.avg_accuracy || 0)); + setEl('overview-wpm', formatWpm(data.avg_wpm || 0)); + setEl('overview-time', formatDuration(data.total_time_seconds || 0)); + setEl('overview-streak', data.best_streak || 0); +} + +function renderWpmChart(daily) { + const ctx = document.getElementById('wpm-chart'); + if (!ctx) return; + + const labels = daily.map(d => d.date); + const wpmData = daily.map(d => d.avg_wpm || 0); + const accData = daily.map(d => d.accuracy || 0); + + new Chart(ctx, { + type: 'line', + data: { + labels: labels, + datasets: [ + { + label: 'WPM', + data: wpmData, + borderColor: getCSSVar('--chart-blue'), + backgroundColor: getCSSVar('--accent-glow'), + borderWidth: 2, + fill: true, + tension: 0.3, + yAxisID: 'y', + pointRadius: 3, + pointBackgroundColor: getCSSVar('--chart-blue') + }, + { + label: 'Accuracy %', + data: accData, + borderColor: getCSSVar('--chart-green'), + backgroundColor: getCSSVar('--success-glow'), + borderWidth: 2, + fill: false, + tension: 0.3, + yAxisID: 'y1', + pointRadius: 3, + pointBackgroundColor: getCSSVar('--chart-green'), + borderDash: [5, 5] + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: 'index', intersect: false }, + plugins: { legend: { labels: { color: getCSSVar('--text-secondary'), font: { size: 12 } } } }, + scales: { + x: { ticks: { color: getCSSVar('--text-muted'), maxRotation: 45, font: { size: 10 } }, grid: { color: getCSSVar('--border-light') } }, + y: { type: 'linear', position: 'left', title: { display: true, text: 'WPM', color: getCSSVar('--chart-blue') }, ticks: { color: getCSSVar('--chart-blue') }, grid: { color: getCSSVar('--border-light') }, beginAtZero: true }, + y1: { type: 'linear', position: 'right', title: { display: true, text: 'Accuracy %', color: getCSSVar('--chart-green') }, ticks: { color: getCSSVar('--chart-green') }, grid: { drawOnChartArea: false }, min: 0, max: 100 } + } + } + }); +} + +function renderDailyChart(daily) { + const ctx = document.getElementById('daily-chart'); + if (!ctx) return; + + const labels = daily.map(d => d.date); + const correctData = daily.map(d => d.words_correct || 0); + const incorrectData = daily.map(d => (d.words_total || 0) - (d.words_correct || 0)); + + new Chart(ctx, { + type: 'bar', + data: { + labels: labels, + datasets: [ + { label: 'Correct', data: correctData, backgroundColor: getCSSVar('--chart-green'), borderRadius: 3 }, + { label: 'Incorrect', data: incorrectData, backgroundColor: getCSSVar('--chart-red'), borderRadius: 3 } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { labels: { color: getCSSVar('--text-secondary'), font: { size: 12 } } } }, + scales: { + x: { stacked: true, ticks: { color: getCSSVar('--text-muted'), maxRotation: 45, font: { size: 10 } }, grid: { color: getCSSVar('--border-light') } }, + y: { stacked: true, ticks: { color: getCSSVar('--text-secondary') }, grid: { color: getCSSVar('--border-light') }, beginAtZero: true, title: { display: true, text: 'Words', color: getCSSVar('--text-secondary') } } + } + } + }); +} + +function renderHeatmap(data) { + const container = document.getElementById('heatmap-container'); + if (!container) return; + + const days = Array.isArray(data) ? data : (data.days || []); + const countMap = {}; + let maxCount = 1; + days.forEach(d => { + countMap[d.date] = d.count || 0; + if (d.count > maxCount) maxCount = d.count; + }); + + const today = new Date(); + const oneDay = 86400000; + const startDate = new Date(today.getTime() - 364 * oneDay); + const startDow = startDate.getDay(); + const alignedStart = new Date(startDate.getTime() - startDow * oneDay); + const totalDays = Math.ceil((today.getTime() - alignedStart.getTime()) / oneDay) + 1; + const totalWeeks = Math.ceil(totalDays / 7); + + const months = []; + let lastMonth = -1; + for (let w = 0; w < totalWeeks; w++) { + const d = new Date(alignedStart.getTime() + w * 7 * oneDay); + const m = d.getMonth(); + if (m !== lastMonth) { + lastMonth = m; + months.push({ week: w, label: d.toLocaleDateString('en-US', { month: 'short' }) }); + } + } + + let html = '<div class="heatmap-months" style="padding-left:0;">'; + months.forEach((m, i) => { + const nextWeek = (i + 1 < months.length) ? months[i + 1].week : totalWeeks; + const colSpan = nextWeek - m.week; + html += '<span class="heatmap-month" style="width:' + (colSpan * 17) + 'px;">' + escapeHtml(m.label) + '</span>'; + }); + html += '</div><div class="heatmap">'; + + for (let w = 0; w < totalWeeks; w++) { + for (let d = 0; d < 7; d++) { + const date = new Date(alignedStart.getTime() + (w * 7 + d) * oneDay); + const dateStr = date.toISOString().split('T')[0]; + const count = countMap[dateStr] || 0; + let level = 0; + if (count > 0) { + const ratio = count / maxCount; + if (ratio <= 0.25) level = 1; + else if (ratio <= 0.5) level = 2; + else if (ratio <= 0.75) level = 3; + else level = 4; + } + const isFuture = date > today; + html += '<div class="heatmap-cell' + (isFuture ? ' heatmap-cell--future' : '') + '" data-level="' + (isFuture ? 0 : level) + '" title="' + escapeHtml(dateStr + ': ' + count + ' words') + '"></div>'; + } + } + html += '</div>'; + html += '<div class="heatmap-legend"><span>Less</span>'; + for (let i = 0; i <= 4; i++) { + html += '<div class="heatmap-legend__cell heatmap-cell" data-level="' + i + '" style="display:inline-block;"></div>'; + } + html += '<span>More</span></div>'; + container.innerHTML = html; +} + +function renderWordTable(data) { + const tbody = document.getElementById('word-table-body'); + if (!tbody) return; + const words = Array.isArray(data) ? data : (data.words || []); + if (words.length === 0) { + tbody.innerHTML = '<tr><td colspan="5" class="table-empty">No word data yet. Start practicing!</td></tr>'; + return; + } + let html = ''; + words.forEach(w => { + const accCls = accuracyClass(w.accuracy || 0); + html += '<tr><td><strong>' + escapeHtml(w.word) + '</strong></td>'; + html += '<td class="steno-stroke">' + escapeHtml(w.strokes || '--') + '</td>'; + html += '<td>' + (w.attempts || 0) + '</td>'; + html += '<td class="accuracy-cell accuracy--' + accCls + '">' + formatAccuracy(w.accuracy || 0) + '</td>'; + html += '<td>' + formatTime(w.avg_time || 0) + '</td></tr>'; + }); + tbody.innerHTML = html; + _setupTableSort('word-table', words); +} + +function _setupTableSort(tableId, data) { + const table = document.getElementById(tableId); + if (!table) return; + const headers = table.querySelectorAll('th[data-sort]'); + let currentSort = null; + let currentOrder = 'asc'; + + headers.forEach(th => { + th.addEventListener('click', () => { + const field = th.dataset.sort; + if (currentSort === field) { + currentOrder = currentOrder === 'asc' ? 'desc' : 'asc'; + } else { + currentSort = field; + currentOrder = 'asc'; + } + const sorted = [...data].sort((a, b) => { + let va = a[field], vb = b[field]; + if (typeof va === 'string') va = va.toLowerCase(); + if (typeof vb === 'string') vb = vb.toLowerCase(); + if (va < vb) return currentOrder === 'asc' ? -1 : 1; + if (va > vb) return currentOrder === 'asc' ? 1 : -1; + return 0; + }); + const tbody = table.querySelector('tbody'); + let html = ''; + sorted.forEach(w => { + const accCls = accuracyClass(w.accuracy || 0); + html += '<tr><td><strong>' + escapeHtml(w.word) + '</strong></td>'; + html += '<td class="steno-stroke">' + escapeHtml(w.strokes || '--') + '</td>'; + html += '<td>' + (w.attempts || 0) + '</td>'; + html += '<td class="accuracy-cell accuracy--' + accCls + '">' + formatAccuracy(w.accuracy || 0) + '</td>'; + html += '<td>' + formatTime(w.avg_time || 0) + '</td></tr>'; + }); + tbody.innerHTML = html; + }); + }); +} + +function renderSessionTable(data) { + const tbody = document.getElementById('session-table-body'); + if (!tbody) return; + const sessions = Array.isArray(data) ? data : (data.sessions || []); + if (sessions.length === 0) { + tbody.innerHTML = '<tr><td colspan="6" class="table-empty">No sessions yet.</td></tr>'; + return; + } + let html = ''; + sessions.forEach(s => { + const accCls = accuracyClass(s.accuracy || 0); + html += '<tr>'; + html += '<td>' + escapeHtml(s.date || s.date_str || '--') + '</td>'; + html += '<td><span class="badge badge--' + escapeHtml(s.mode || '') + '">' + escapeHtml(s.mode || '--') + '</span></td>'; + html += '<td>' + (s.words_correct || 0) + ' / ' + (s.words_attempted || 0) + '</td>'; + html += '<td class="accuracy-cell accuracy--' + accCls + '">' + formatAccuracy(s.accuracy || 0) + '</td>'; + html += '<td>' + formatWpm(s.wpm || 0) + '</td>'; + html += '<td>' + formatDuration(s.duration_seconds || 0) + '</td>'; + html += '</tr>'; + }); + tbody.innerHTML = html; +} + + +/* ===== PAGE INITIALIZATION ===== */ + +document.addEventListener('DOMContentLoaded', () => { + const practiceEl = document.getElementById('practice-container'); + if (practiceEl) { + const mode = practiceEl.dataset.mode; + const lessonId = practiceEl.dataset.lesson || null; + new PracticeSession(mode, lessonId); + } + + const statsEl = document.getElementById('stats-container'); + if (statsEl) { + loadStats(); + } + + // Mobile nav hamburger + const hamburger = document.getElementById('nav-hamburger'); + const navLinks = document.getElementById('nav-links'); + if (hamburger && navLinks) { + hamburger.addEventListener('click', () => { + navLinks.classList.toggle('open'); + hamburger.classList.toggle('active'); + }); + navLinks.querySelectorAll('.nav-link').forEach(link => { + link.addEventListener('click', () => { + navLinks.classList.remove('open'); + hamburger.classList.remove('active'); + }); + }); + } + + // Theme toggle + const themeToggle = document.getElementById('theme-toggle'); + const themeIcon = document.getElementById('theme-icon'); + function updateThemeIcon() { + if (!themeIcon) return; + const t = document.documentElement.getAttribute('data-theme') || 'dark'; + themeIcon.textContent = t === 'dark' ? '☀' : '☽'; + } + if (themeToggle) { + updateThemeIcon(); + themeToggle.addEventListener('click', () => { + const cur = document.documentElement.getAttribute('data-theme') || 'dark'; + const next = cur === 'dark' ? 'light' : 'dark'; + document.documentElement.setAttribute('data-theme', next); + setCookie('theme', next, 365); + updateThemeIcon(); + }); + } + + // GSAP page entrance animations + if (typeof gsap !== 'undefined') { + gsap.from('.nav-brand', { x: -10, opacity: 0, duration: 0.3, ease: 'power2.out', clearProps: 'all' }); + gsap.from('.nav-links li', { y: -8, opacity: 0, duration: 0.25, stagger: 0.05, delay: 0.1, ease: 'power2.out', clearProps: 'all' }); + + if (document.querySelector('.dashboard')) { + const tl = gsap.timeline({ defaults: { ease: 'power2.out', clearProps: 'all' } }); + tl.from('.hero-stats .stat-card', { y: 10, opacity: 0, duration: 0.35, stagger: 0.03 }, 0) + .from('.dashboard .section-title', { opacity: 0, duration: 0.35 }, 0) + .from('.lesson-grid', { opacity: 0, duration: 0.35 }, 0) + .from('.lesson-card', { y: 6, opacity: 0, duration: 0.35, stagger: 0.02 }, 0) + .from('.mode-grid', { opacity: 0, duration: 0.35 }, 0) + .from('.mode-card', { y: 6, opacity: 0, duration: 0.35, stagger: 0.02 }, 0) + .from('.today-progress, .recent-sessions', { y: 6, opacity: 0, duration: 0.35 }, 0); + } + + if (document.querySelector('.stats-page')) { + const tl = gsap.timeline({ defaults: { ease: 'power2.out', clearProps: 'all' } }); + tl.from('.stats-page .page-title', { y: -10, opacity: 0, duration: 0.35 }) + .from('.stats-overview .stat-card', { y: 20, opacity: 0, duration: 0.35, stagger: 0.06 }, '-=0.15') + .from('.stats-section', { y: 25, opacity: 0, duration: 0.4, stagger: 0.1 }, '-=0.1'); + } + + if (document.querySelector('.settings-page')) { + const tl = gsap.timeline({ defaults: { ease: 'power2.out', clearProps: 'all' } }); + tl.from('.settings-page .page-title', { y: -10, opacity: 0, duration: 0.35 }) + .from('.settings-section', { y: 20, opacity: 0, duration: 0.35, stagger: 0.1 }, '-=0.15'); + } + } +}); diff --git a/templates/base.html b/templates/base.html @@ -0,0 +1,61 @@ +<!DOCTYPE html> +<html lang="en" data-theme="dark"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{% block title %}StenoDojo{% endblock %}</title> + <link rel="preconnect" href="https://fonts.googleapis.com"> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> + <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet"> + <script src="https://unpkg.com/htmx.org@2.0.4"></script> + <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script> + <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script> + <link rel="stylesheet" href="/static/css/style.css?v={{ cache_bust }}"> + <script> + (function(){var m=document.cookie.match(/(?:^| )theme=([^;]+)/);document.documentElement.setAttribute('data-theme',m?m[1]:'dark')})(); + </script> + <script src="/static/js/app.js?v={{ cache_bust }}" defer></script> +</head> +<body> + <nav class="navbar"> + <div class="nav-inner"> + <a href="/" class="nav-brand"> + <svg class="nav-logo" viewBox="0 0 24 24" fill="none"> + <rect x="1" y="3" width="9" height="11" rx="2.5" fill="currentColor" opacity="0.6"/> + <rect x="14" y="3" width="9" height="11" rx="2.5" fill="currentColor"/> + <rect x="5" y="16" width="14" height="6" rx="2.5" fill="currentColor" opacity="0.8"/> + </svg> + StenoDojo + </a> + <button class="nav-hamburger" id="nav-hamburger" aria-label="Toggle menu"> + <span></span><span></span><span></span> + </button> + <ul class="nav-links" id="nav-links"> + <li><a href="/" class="nav-link">Dashboard</a></li> + <li><a href="/practice?mode=common" class="nav-link">Practice</a></li> + <li><a href="/stats" class="nav-link">Stats</a></li> + <li><a href="/settings" class="nav-link">Settings</a></li> + <li> + <button id="theme-toggle" class="theme-toggle" aria-label="Toggle theme"> + <span id="theme-icon"></span> + </button> + </li> + {% if current_user %} + <li class="nav-user"> + {% if current_user.avatar_url %} + <img src="{{ current_user.avatar_url }}" alt="" class="nav-avatar"> + {% endif %} + <a href="/logout" class="nav-link nav-link--logout">Logout</a> + </li> + {% endif %} + </ul> + </div> + </nav> + + <main class="main-content"> + {% block content %}{% endblock %} + </main> + + {% block scripts %}{% endblock %} +</body> +</html> diff --git a/templates/dashboard.html b/templates/dashboard.html @@ -0,0 +1,174 @@ +{% extends "base.html" %} + +{% block title %}Dashboard - StenoDojo{% endblock %} + +{% block content %} +<div class="dashboard"> + {% if not has_dicts %} + <div class="dict-banner"> + <div class="dict-banner__icon"> + <svg viewBox="0 0 24 24" fill="none" width="28" height="28"> + <rect x="1" y="3" width="9" height="11" rx="2.5" fill="currentColor" opacity="0.6"/> + <rect x="14" y="3" width="9" height="11" rx="2.5" fill="currentColor"/> + <rect x="5" y="16" width="14" height="6" rx="2.5" fill="currentColor" opacity="0.8"/> + </svg> + </div> + <div class="dict-banner__text"> + <strong>No dictionaries loaded.</strong> Upload your Plover config in <a href="/settings">Settings</a> to get stroke hints and unlock all practice modes. + </div> + </div> + {% endif %} + <section class="hero-stats"> + <div class="stat-card stat-card--streak"> + <div class="stat-card__icon streak-fire"></div> + <div class="stat-card__value">{{ streak }}</div> + <div class="stat-card__label">Day Streak</div> + </div> + <div class="stat-card"> + <div class="stat-card__icon stat-icon--words"></div> + <div class="stat-card__value">{{ today.words }}</div> + <div class="stat-card__label">Words Today + {% if today.words > 0 %} + <span class="stat-card__sub">{{ today.accuracy | round(0) | int }}% accuracy</span> + {% endif %} + </div> + </div> + <div class="stat-card"> + <div class="stat-card__icon stat-icon--mastered"></div> + <div class="stat-card__value">{{ words_mastered }}</div> + <div class="stat-card__label">Words Mastered</div> + </div> + <div class="stat-card"> + <div class="stat-card__icon stat-icon--review"></div> + <div class="stat-card__value">{{ review_due }}</div> + <div class="stat-card__label">Review Due + {% if review_due > 0 %} + <a href="/practice?mode=review" class="stat-card__action">Start Review</a> + {% endif %} + </div> + </div> + </section> + + <section> + <h2 class="section-title">Guided Lessons</h2> + <div class="lesson-grid"> + {% for lesson in lessons %} + <a href="/practice?mode=lesson&lesson={{ lesson.id }}" class="lesson-card lesson-card--{{ lesson.level }}"> + <div class="lesson-card__order">{{ lesson.order }}</div> + <div class="lesson-card__info"> + <div class="lesson-card__title">{{ lesson.title }}</div> + <div class="lesson-card__subtitle">{{ lesson.subtitle }}</div> + {% if lesson.progress %} + {% set prog = lesson.progress %} + {% set pct = (prog.mastered / prog.total_words * 100) | round(0) | int if prog.total_words > 0 else 0 %} + <div class="lesson-progress"> + <div class="lesson-progress__bar"> + <div class="lesson-progress__fill lesson-progress__fill--practiced" style="width: {{ (prog.practiced / prog.total_words * 100) | round(0) | int if prog.total_words > 0 else 0 }}%"></div> + <div class="lesson-progress__fill lesson-progress__fill--mastered" style="width: {{ pct }}%"></div> + </div> + <div class="lesson-progress__text"> + <span>{{ prog.mastered }}/{{ prog.total_words }} mastered</span> + {% if prog.avg_wpm > 0 %} + <span>{{ prog.avg_wpm }} WPM</span> + {% endif %} + </div> + </div> + {% endif %} + </div> + <div class="lesson-card__level badge badge--{{ lesson.level }}">{{ lesson.level }}</div> + </a> + {% endfor %} + </div> + </section> + + <section class="quick-start"> + <h2 class="section-title">Free Practice</h2> + <div class="mode-grid"> + <a href="/practice?mode=common" class="mode-card"> + <div class="mode-card__icon mode-icon--common"></div> + <div class="mode-card__title">Common Words</div> + <div class="mode-card__desc">Most frequent words</div> + </a> + <a href="/practice?mode=speed" class="mode-card"> + <div class="mode-card__icon mode-icon--speed"></div> + <div class="mode-card__title">Speed Drill</div> + <div class="mode-card__desc">Single-stroke words</div> + </a> + <a href="/practice?mode=phrases" class="mode-card"> + <div class="mode-card__icon mode-icon--phrases"></div> + <div class="mode-card__title">Phrases</div> + <div class="mode-card__desc">Multi-word phrases</div> + </a> + <a href="/practice?mode=sentences" class="mode-card"> + <div class="mode-card__icon mode-icon--sentences"></div> + <div class="mode-card__title">Sentences</div> + <div class="mode-card__desc">Full sentences</div> + </a> + <a href="/practice?mode=new" class="mode-card"> + <div class="mode-card__icon mode-icon--new"></div> + <div class="mode-card__title">New Words</div> + <div class="mode-card__desc">Words you haven't seen</div> + </a> + <a href="/practice?mode=weak" class="mode-card"> + <div class="mode-card__icon mode-icon--weak"></div> + <div class="mode-card__title">Weak Words</div> + <div class="mode-card__desc">Your trouble spots</div> + </a> + <a href="/practice?mode=review" class="mode-card"> + <div class="mode-card__icon mode-icon--review"></div> + <div class="mode-card__title">Review</div> + <div class="mode-card__desc">Spaced repetition</div> + </a> + <a href="/practice?mode=random" class="mode-card"> + <div class="mode-card__icon mode-icon--random"></div> + <div class="mode-card__title">Random</div> + <div class="mode-card__desc">Mix of all words</div> + </a> + </div> + </section> + + <section class="today-progress"> + <h2 class="section-title">Today's Progress</h2> + <div class="progress-container"> + <div class="progress-bar"> + <div class="progress-bar__fill" style="width: {{ [today.words / 100 * 100, 100] | min }}%"></div> + </div> + <div class="progress-bar__label">{{ today.words }} / 100 words</div> + </div> + </section> + + <section class="recent-sessions"> + <h2 class="section-title">Recent Sessions</h2> + {% if recent_sessions %} + <div class="table-wrapper"> + <table class="table"> + <thead> + <tr> + <th>Mode</th> + <th>Date</th> + <th>Words</th> + <th>Accuracy</th> + <th>WPM</th> + </tr> + </thead> + <tbody> + {% for session in recent_sessions[:5] %} + <tr> + <td><span class="badge badge--{{ session.mode }}">{{ session.mode }}</span></td> + <td>{{ session.date_str }}</td> + <td>{{ session.words_correct }} / {{ session.words_attempted }}</td> + <td class="accuracy-cell accuracy--{{ 'good' if session.accuracy >= 90 else ('mid' if session.accuracy >= 70 else 'low') }}">{{ session.accuracy | round(0) | int }}%</td> + <td>{{ session.wpm | round(1) }}</td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + {% else %} + <div class="empty-state"> + <p>No sessions yet. Start practicing to see your progress here.</p> + </div> + {% endif %} + </section> +</div> +{% endblock %} diff --git a/templates/login.html b/templates/login.html @@ -0,0 +1,46 @@ +<!DOCTYPE html> +<html lang="en" data-theme="dark"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Sign In - StenoDojo</title> + <link rel="preconnect" href="https://fonts.googleapis.com"> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> + <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet"> + <link rel="stylesheet" href="/static/css/style.css?v={{ cache_bust }}"> + <script> + (function(){var m=document.cookie.match(/(?:^| )theme=([^;]+)/);document.documentElement.setAttribute('data-theme',m?m[1]:'dark')})(); + </script> +</head> +<body> + <div class="login-page"> + <div class="login-card"> + <div class="login-brand"> + <svg class="login-logo" viewBox="0 0 24 24" fill="none"> + <rect x="1" y="3" width="9" height="11" rx="2.5" fill="currentColor" opacity="0.6"/> + <rect x="14" y="3" width="9" height="11" rx="2.5" fill="currentColor"/> + <rect x="5" y="16" width="14" height="6" rx="2.5" fill="currentColor" opacity="0.8"/> + </svg> + <h1 class="login-title">StenoDojo</h1> + <p class="login-subtitle">Stenography Practice</p> + </div> + + {% if error %} + <div class="login-error">{{ error }}</div> + {% endif %} + + <a href="/login/google" class="btn btn--google"> + <svg class="google-icon" viewBox="0 0 24 24" width="20" height="20"> + <path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/> + <path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/> + <path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/> + <path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/> + </svg> + Sign in with Google + </a> + + <p class="login-footer">Your email must be verified by Google to sign in.</p> + </div> + </div> +</body> +</html> diff --git a/templates/partials/word_card.html b/templates/partials/word_card.html @@ -0,0 +1,2 @@ +{# This partial is not used - practice is handled entirely via JS/fetch #} +{# Kept as placeholder for potential future HTMX-based features #} diff --git a/templates/practice.html b/templates/practice.html @@ -0,0 +1,104 @@ +{% extends "base.html" %} + +{% block title %}{{ mode_label }} - StenoDojo{% endblock %} + +{% block content %} +<div id="practice-container" data-mode="{{ mode }}" data-lesson="{{ lesson_id }}"> + <div class="practice-header"> + <a href="/" class="btn btn--ghost practice-back">&larr; Dashboard</a> + <h1 class="practice-mode-label">{{ mode_label }}</h1> + <button id="end-session-btn" class="btn btn--danger practice-end">End</button> + </div> + + <div id="loading" class="practice-loading"> + {% if lesson_id %} + <div id="session-config" class="session-config"> + <label class="session-config__label">Session Length</label> + <div class="session-config__options"> + <button class="session-config__btn" data-count="5">Short (5)</button> + <button class="session-config__btn session-config__btn--active" data-count="10">Medium (10)</button> + <button class="session-config__btn" data-count="20">Long (20)</button> + </div> + <button id="start-session-btn" class="btn btn--primary session-config__start">Start Practice</button> + </div> + {% else %} + <div class="spinner"></div> + <p>Loading session...</p> + {% endif %} + </div> + + <div id="practice-area" class="practice-area" style="display: none;"> + <div class="practice-main"> + <div class="practice-left"> + <div id="typing-display" class="typing-display"> + <div id="typing-words" class="typing-words"></div> + </div> + <input type="text" id="typing-input" class="typing-input" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" autofocus> + <div id="typed-text-display" class="typed-text-display"></div> + + <div id="steno-hint" class="steno-hint"> + <div id="steno-keyboards" class="steno-keyboards"></div> + </div> + </div> + + <div id="hints-panel" class="hints-panel"> + <div id="phrase-hints" class="phrase-hints" style="display: none;"> + <div class="phrase-hints__header">Phrase Shortcuts</div> + <div id="phrase-hints-list" class="phrase-hints-list"></div> + </div> + <div class="hints-panel__header">Strokes</div> + <div id="hints-list" class="hints-list"></div> + </div> + </div> + + <div id="session-stats" class="session-stats"> + <div class="session-stat"> + <span class="session-stat__icon session-stat__icon--time"></span> + <span id="stat-time" class="session-stat__value">0.0s</span> + </div> + <div class="session-stat"> + <span class="session-stat__icon session-stat__icon--accuracy"></span> + <span id="stat-accuracy" class="session-stat__value">0%</span> + </div> + <div class="session-stat"> + <span class="session-stat__icon session-stat__icon--streak"></span> + <span id="stat-streak" class="session-stat__value">0</span> + </div> + <div class="session-stat"> + <span class="session-stat__icon session-stat__icon--wpm"></span> + <span id="stat-wpm" class="session-stat__value">0 WPM</span> + </div> + <div class="session-stat"> + <span class="session-stat__icon session-stat__icon--progress"></span> + <span id="stat-progress" class="session-stat__value">0/0</span> + </div> + </div> + </div> + + <div id="session-summary" class="modal-overlay" style="display: none;"> + <div class="modal"> + <h2 class="modal__title">Session Complete</h2> + <div id="summary-accuracy" class="summary-accuracy"></div> + <div class="summary-stats"> + <div class="summary-stat"> + <span class="summary-stat__label">Words</span> + <span id="summary-words" class="summary-stat__value"></span> + </div> + <div class="summary-stat"> + <span class="summary-stat__label">Avg WPM</span> + <span id="summary-wpm" class="summary-stat__value"></span> + </div> + <div class="summary-stat"> + <span class="summary-stat__label">Best Streak</span> + <span id="summary-streak" class="summary-stat__value"></span> + </div> + </div> + <div id="summary-weak-words" class="summary-weak-words"></div> + <div class="modal__actions"> + <a href="/practice?mode={{ mode }}{% if lesson_id %}&lesson={{ lesson_id }}{% endif %}" class="btn btn--primary">Practice Again</a> + <a href="/" class="btn btn--ghost">Back to Dashboard</a> + </div> + </div> + </div> +</div> +{% endblock %} diff --git a/templates/settings.html b/templates/settings.html @@ -0,0 +1,295 @@ +{% extends "base.html" %} + +{% block title %}Settings - StenoDojo{% endblock %} + +{% block content %} +<div id="settings-container" class="settings-page"> + <h1 class="page-title">Settings</h1> + + <div class="settings-section"> + <h2 class="section-title">Dictionaries</h2> + <p class="setting-help" style="margin-bottom: 1rem;">Select your Plover config directory — only plover.cfg and referenced dictionaries will be uploaded.</p> + + <div class="dict-upload-row"> + <div class="dict-upload-group"> + <label class="btn btn--primary dict-upload-btn"> + Select Plover Config Directory + <input type="file" id="folder-upload" webkitdirectory hidden> + </label> + </div> + </div> + + <div id="upload-progress" class="upload-progress" style="display: none;"> + <div class="spinner" style="width: 20px; height: 20px; border-width: 2px;"></div> + <span id="upload-progress-text">Uploading...</span> + </div> + + <div id="dict-list" class="dict-list"> + {% if dictionaries %} + {% for d in dictionaries %} + <div class="dict-item" data-id="{{ d.id }}"> + <span class="dict-item__order">{{ d.dict_order + 1 }}</span> + <span class="dict-item__name">{{ d.display_name or d.filename }}</span> + <span class="dict-item__count">{{ d.entry_count }} entries</span> + <label class="dict-item__toggle"> + <input type="checkbox" class="dict-toggle-cb" data-id="{{ d.id }}" {{ 'checked' if d.enabled else '' }}> + </label> + <button class="dict-item__delete btn btn--ghost btn--small" data-id="{{ d.id }}">&#10005;</button> + </div> + {% endfor %} + {% else %} + <div class="dict-empty">No dictionaries configured. Select your Plover config directory to get started.</div> + {% endif %} + </div> + </div> + + <div class="settings-section"> + <h2 class="section-title">Practice</h2> + <div class="setting-row"> + <label class="setting-label" for="words-per-session">Words Per Session</label> + <div class="setting-input-group"> + <input type="number" id="words-per-session" class="setting-input setting-input--short" value="{{ settings.words_per_session }}" min="10" max="200"> + <button id="save-words-count" class="btn btn--primary btn--small">Save</button> + </div> + </div> + </div> + + <div class="settings-section settings-section--danger"> + <h2 class="section-title">Data Management</h2> + <div class="setting-row"> + <div> + <div class="setting-label">Clear All Practice Data</div> + <p class="setting-help">This will permanently delete all session history, word stats, and progress. This cannot be undone.</p> + </div> + <button id="clear-data-btn" class="btn btn--danger">Clear All Data</button> + </div> + </div> + + {% if current_user and not dev_mode %} + <div class="settings-section"> + <h2 class="section-title">Account</h2> + <div class="setting-row"> + <div> + <div class="setting-label">{{ current_user.email }}</div> + <p class="setting-help">Signed in as {{ current_user.name or current_user.email }}</p> + </div> + <a href="/logout" class="btn btn--ghost">Sign Out</a> + </div> + </div> + {% endif %} + + <div id="settings-toast" class="settings-toast" style="display: none;"></div> +</div> + +<script> +document.addEventListener('DOMContentLoaded', () => { + const toast = document.getElementById('settings-toast'); + const progressEl = document.getElementById('upload-progress'); + const progressText = document.getElementById('upload-progress-text'); + + function showToast(msg, type) { + toast.textContent = msg; + toast.className = 'settings-toast settings-toast--' + type; + toast.style.display = 'block'; + if (typeof gsap !== 'undefined') { + gsap.fromTo(toast, { y: 20, opacity: 0 }, { y: 0, opacity: 1, duration: 0.3, ease: 'power2.out' }); + setTimeout(() => { + gsap.to(toast, { y: 20, opacity: 0, duration: 0.25, onComplete: () => { toast.style.display = 'none'; } }); + }, 3000); + } else { + setTimeout(() => { toast.style.display = 'none'; }, 3000); + } + } + + function showProgress(msg) { + progressText.textContent = msg; + progressEl.style.display = 'flex'; + } + function hideProgress() { + progressEl.style.display = 'none'; + } + + function renderDictList(dicts) { + const container = document.getElementById('dict-list'); + if (!dicts || dicts.length === 0) { + container.innerHTML = '<div class="dict-empty">No dictionaries configured. Select your Plover config directory to get started.</div>'; + return; + } + let html = ''; + dicts.forEach(d => { + const statusClass = d.uploaded ? '' : ' dict-item--missing'; + const statusLabel = d.uploaded ? d.entry_count + ' entries' : 'not uploaded'; + html += '<div class="dict-item' + statusClass + '" data-id="' + d.id + '">'; + html += '<span class="dict-item__order">' + (d.order + 1) + '</span>'; + html += '<span class="dict-item__name">' + (d.display_name || d.filename) + '</span>'; + html += '<span class="dict-item__count">' + statusLabel + '</span>'; + html += '<label class="dict-item__toggle"><input type="checkbox" class="dict-toggle-cb" data-id="' + d.id + '"' + (d.enabled ? ' checked' : '') + '></label>'; + html += '<button class="dict-item__delete btn btn--ghost btn--small" data-id="' + d.id + '">&#10005;</button>'; + html += '</div>'; + }); + container.innerHTML = html; + bindDictEvents(); + } + + function bindDictEvents() { + document.querySelectorAll('.dict-toggle-cb').forEach(cb => { + cb.addEventListener('change', async () => { + const id = cb.dataset.id; + try { + await fetch('/api/dictionaries/' + id + '/toggle', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({enabled: cb.checked}) + }); + showToast('Dictionary ' + (cb.checked ? 'enabled' : 'disabled') + '.', 'success'); + } catch(e) { + showToast('Failed to toggle dictionary.', 'error'); + } + }); + }); + + document.querySelectorAll('.dict-item__delete').forEach(btn => { + btn.addEventListener('click', async () => { + if (!confirm('Delete this dictionary?')) return; + const id = btn.dataset.id; + try { + await fetch('/api/dictionaries/' + id, { method: 'DELETE' }); + btn.closest('.dict-item').remove(); + showToast('Dictionary deleted.', 'success'); + } catch(e) { + showToast('Failed to delete.', 'error'); + } + }); + }); + } + bindDictEvents(); + + function parsePloverCfg(text) { + const match = text.match(/^\[System:\s*English Stenotype\]\s*$/m); + if (!match) return []; + const afterSection = text.slice(match.index + match[0].length); + const dictLine = afterSection.match(/^dictionaries\s*=\s*(.+)$/m); + if (!dictLine) return []; + try { + return JSON.parse(dictLine[1]); + } catch(e) { + return []; + } + } + + // ── Folder upload with client-side filtering ── + document.getElementById('folder-upload').addEventListener('change', async (e) => { + const allFiles = e.target.files; + if (!allFiles || !allFiles.length) return; + + let cfgFile = null; + let cfgPrefix = ''; + const filesByPath = {}; + + for (let i = 0; i < allFiles.length; i++) { + const f = allFiles[i]; + const path = f.webkitRelativePath || f.name; + filesByPath[path] = f; + if (f.name === 'plover.cfg') { + cfgFile = f; + cfgPrefix = path.slice(0, -'plover.cfg'.length); + } + } + + if (!cfgFile) { + showToast('No plover.cfg found in that directory.', 'error'); + e.target.value = ''; + return; + } + + showProgress('Reading config...'); + + const cfgText = await cfgFile.text(); + const entries = parsePloverCfg(cfgText); + + if (!entries.length) { + hideProgress(); + showToast('No dictionaries found in plover.cfg.', 'error'); + e.target.value = ''; + return; + } + + const form = new FormData(); + form.append('config', cfgFile, 'plover.cfg'); + + let matched = 0; + for (const entry of entries) { + const dictPath = entry.path || ''; + if (!dictPath) continue; + + let file = filesByPath[cfgPrefix + dictPath]; + if (!file) { + const basename = dictPath.split('/').pop().split('\\').pop(); + for (const [fpath, fobj] of Object.entries(filesByPath)) { + if (fobj.name === basename) { file = fobj; break; } + } + } + if (file) { + form.append('dicts', file, file.webkitRelativePath || file.name); + matched++; + } + } + + showProgress('Uploading plover.cfg + ' + matched + ' dictionaries...'); + + try { + const r = await fetch('/api/dictionaries/upload-folder', { method: 'POST', body: form }); + const d = await r.json(); + hideProgress(); + if (d.ok) { + renderDictList(d.dictionaries); + if (d.missing > 0) { + showToast(d.saved + ' dictionaries loaded, ' + d.missing + ' referenced files not found.', 'success'); + } else { + showToast('All ' + d.saved + ' dictionaries loaded!', 'success'); + } + } else { + showToast(d.error || 'Upload failed.', 'error'); + } + } catch(err) { + hideProgress(); + showToast('Failed to upload.', 'error'); + } + e.target.value = ''; + }); + + document.getElementById('save-words-count').addEventListener('click', async () => { + const count = parseInt(document.getElementById('words-per-session').value); + if (isNaN(count) || count < 10 || count > 200) { + showToast('Must be between 10 and 200.', 'error'); + return; + } + try { + const r = await fetch('/api/settings', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({words_per_session: count}) + }); + const d = await r.json(); + if (d.ok) showToast('Words per session updated.', 'success'); + } catch(e) { + showToast('Failed to save.', 'error'); + } + }); + + document.getElementById('clear-data-btn').addEventListener('click', async () => { + if (!confirm('Are you sure you want to delete ALL practice data? This cannot be undone.')) return; + try { + const r = await fetch('/api/data/clear', { + method: 'POST', + headers: {'Content-Type': 'application/json'} + }); + const d = await r.json(); + if (d.ok) showToast('All data cleared.', 'success'); + } catch(e) { + showToast('Failed to clear data.', 'error'); + } + }); +}); +</script> +{% endblock %} diff --git a/templates/stats.html b/templates/stats.html @@ -0,0 +1,103 @@ +{% extends "base.html" %} + +{% block title %}Statistics - StenoDojo{% endblock %} + +{% block content %} +<div id="stats-container" class="stats-page"> + <h1 class="page-title">Statistics</h1> + + <section id="stats-overview" class="stats-overview"> + <div class="stat-card"> + <div class="stat-card__value" id="overview-sessions">--</div> + <div class="stat-card__label">Total Sessions</div> + </div> + <div class="stat-card"> + <div class="stat-card__value" id="overview-words">--</div> + <div class="stat-card__label">Words Practiced</div> + </div> + <div class="stat-card"> + <div class="stat-card__value" id="overview-mastered">--</div> + <div class="stat-card__label">Words Mastered</div> + </div> + <div class="stat-card"> + <div class="stat-card__value" id="overview-accuracy">--</div> + <div class="stat-card__label">Avg Accuracy</div> + </div> + <div class="stat-card"> + <div class="stat-card__value" id="overview-wpm">--</div> + <div class="stat-card__label">Avg WPM</div> + </div> + <div class="stat-card"> + <div class="stat-card__value" id="overview-time">--</div> + <div class="stat-card__label">Total Time</div> + </div> + <div class="stat-card stat-card--streak"> + <div class="stat-card__icon streak-fire"></div> + <div class="stat-card__value" id="overview-streak">--</div> + <div class="stat-card__label">Best Streak</div> + </div> + </section> + + <section class="stats-section"> + <h2 class="section-title">WPM &amp; Accuracy (Last 30 Days)</h2> + <div class="chart-container"> + <canvas id="wpm-chart"></canvas> + </div> + </section> + + <section class="stats-section"> + <h2 class="section-title">Daily Activity (Last 30 Days)</h2> + <div class="chart-container"> + <canvas id="daily-chart"></canvas> + </div> + </section> + + <section class="stats-section"> + <h2 class="section-title">Practice Heatmap</h2> + <div id="heatmap-container" class="heatmap-container"> + <div class="heatmap-loading">Loading heatmap...</div> + </div> + </section> + + <section class="stats-section"> + <h2 class="section-title">Word Performance</h2> + <div class="table-wrapper"> + <table id="word-table" class="table"> + <thead> + <tr> + <th data-sort="word" class="sortable">Word</th> + <th data-sort="strokes" class="sortable">Strokes</th> + <th data-sort="attempts" class="sortable">Attempts</th> + <th data-sort="accuracy" class="sortable">Accuracy</th> + <th data-sort="avg_time" class="sortable">Avg Time</th> + </tr> + </thead> + <tbody id="word-table-body"> + <tr><td colspan="5" class="table-empty">Loading...</td></tr> + </tbody> + </table> + </div> + </section> + + <section class="stats-section"> + <h2 class="section-title">Session History</h2> + <div class="table-wrapper"> + <table id="session-table" class="table"> + <thead> + <tr> + <th>Date</th> + <th>Mode</th> + <th>Words</th> + <th>Accuracy</th> + <th>WPM</th> + <th>Duration</th> + </tr> + </thead> + <tbody id="session-table-body"> + <tr><td colspan="6" class="table-empty">Loading...</td></tr> + </tbody> + </table> + </div> + </section> +</div> +{% endblock %}