diff --git a/backend/auth.py b/backend/auth.py index f20ef61..003f5b1 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -32,6 +32,7 @@ import logging import secrets +import uuid from datetime import datetime, timedelta from typing import Optional @@ -138,6 +139,15 @@ class UserOut(BaseModel): COOKIE_NAME = "sll_session" SESSION_TTL_DAYS = 14 +# Course-progression #5 (2026-04-27): anonymous learner identity. Without +# this every guest writes to the shared user_id="default" bucket on +# UserProgress, which (a) leaks progress between unrelated guests, and +# (b) makes it impossible to migrate their work to a real user_id when +# they sign up. The middleware below issues an `sll_anon` cookie on +# first request and the progress endpoints use it as the fallback user_id. +ANON_COOKIE_NAME = "sll_anon" +ANON_TTL_DAYS = 365 + def _set_session_cookie(response: Response, token: str) -> None: # secure=False for local dev (localhost:8001 HTTP). In prod nginx does TLS @@ -157,22 +167,44 @@ def _clear_session_cookie(response: Response) -> None: response.delete_cookie(key=COOKIE_NAME, path="/") -# ── Middleware: load session into request.state ─────────────────────────── +def _set_anon_cookie(response: Response, anon_id: str) -> None: + response.set_cookie( + key=ANON_COOKIE_NAME, + value=anon_id, + max_age=ANON_TTL_DAYS * 24 * 3600, + httponly=True, + samesite="lax", + secure=False, + path="/", + ) -async def session_middleware(request: Request, call_next): - """Attach request.state.user + request.state.session. - Two auth paths supported: - 1. `sll_session` cookie (browser flow) — existing - 2. `Authorization: Bearer ` header (CLI flow) — added 2026-04-25 +def _clear_anon_cookie(response: Response) -> None: + response.delete_cookie(key=ANON_COOKIE_NAME, path="/") - Both resolve through the same `auth_sessions` table; bearer tokens - just live in the same store. CLI tokens are issued via - POST /api/auth/cli_token and are long-lived (90 days) so a - `skillslab login` lasts across many work sessions. + +# ── Middleware: load session into request.state ─────────────────────────── + +async def session_middleware(request: Request, call_next): + """Attach request.state.user + request.state.session + request.state.anon_id. + + Three identity layers supported: + 1. `sll_session` cookie (browser flow) — authenticated user + 2. `Authorization: Bearer ` header (CLI flow) — authenticated user + 3. `sll_anon` cookie (browser flow) — anonymous learner identity + (course-progression #5). Issued on first request; persists 1 year. + Lets us track guest progress separately per browser/device and + migrate it to a real user_id at sign-up time. + + Both authenticated paths resolve through the same `auth_sessions` table. + The anon path is independent — anon_id is set even when a session is + present, so the progress endpoints can still see the anon cookie if a + pre-signup migration is pending. """ request.state.user = None request.state.session = None + request.state.anon_id = None + request.state._issue_anon_cookie = False # internal: tells the post-call_next stage to set the cookie # 1) Cookie flow (browser) token = request.cookies.get(COOKIE_NAME) @@ -198,7 +230,21 @@ async def session_middleware(request: Request, call_next): request.state.session = sess except Exception: logger.exception("session load failed") - return await call_next(request) + + # 3) Anonymous identity. Set even for authenticated users — register/ + # login handlers consume it to migrate pre-signup progress. + anon_id = request.cookies.get(ANON_COOKIE_NAME) + if not anon_id: + anon_id = uuid.uuid4().hex + request.state._issue_anon_cookie = True + request.state.anon_id = anon_id + + response = await call_next(request) + if getattr(request.state, "_issue_anon_cookie", False): + # Mint the cookie on the way out so it's bound to the response + # the browser is about to receive. + _set_anon_cookie(response, anon_id) + return response # ── Dependencies ────────────────────────────────────────────────────────── @@ -234,8 +280,80 @@ def _user_to_out(u: User) -> UserOut: ) +async def _migrate_anon_progress(db: AsyncSession, anon_id: str, user_id: int) -> int: + """Transfer guest progress from anon_id to authenticated user_id. + + Course-progression #5 (2026-04-27): runs at register + login so a + learner who solved a few steps as a guest doesn't lose them on signup. + + Conflict resolution when both anon and user already have a row for the + same step: take MAX(score), SUM(attempts), OR(completed). Prefer the + user's completed_at when set, else take anon's. Anon rows are deleted + after merge so re-running is idempotent. + + Returns count of step-rows migrated (transferred + merged). + """ + if not anon_id or anon_id == "default": + return 0 + + from backend.database import UserProgress as _UP, Certificate as _Cert + + new_user_id = str(user_id) + anon_rows = (await db.execute( + select(_UP).where(_UP.user_id == anon_id) + )).scalars().all() + if not anon_rows: + return 0 + + # Index user's existing rows by step_id so the conflict path is one + # dict lookup, not a per-row select. + user_existing = (await db.execute( + select(_UP).where(_UP.user_id == new_user_id) + )).scalars().all() + by_step = {row.step_id: row for row in user_existing} + + migrated = 0 + for arow in anon_rows: + urow = by_step.get(arow.step_id) + if urow is None: + arow.user_id = new_user_id + else: + # Merge. + urow.attempts = (urow.attempts or 0) + (arow.attempts or 0) + if (arow.score is not None) and (urow.score is None or arow.score > urow.score): + urow.score = arow.score + if arow.completed and not urow.completed: + urow.completed = True + urow.completed_at = arow.completed_at or urow.completed_at + elif arow.completed and urow.completed and arow.completed_at and ( + urow.completed_at is None or arow.completed_at < urow.completed_at + ): + # Preserve the earlier completion time when both completed. + urow.completed_at = arow.completed_at + if arow.response_data and not urow.response_data: + urow.response_data = arow.response_data + await db.delete(arow) + migrated += 1 + + # Move any anon Certificates the user doesn't already have. + anon_certs = (await db.execute( + select(_Cert).where(_Cert.user_id == anon_id) + )).scalars().all() + for cert in anon_certs: + existing = (await db.execute( + select(_Cert).where(_Cert.user_id == new_user_id, _Cert.course_id == cert.course_id) + )).scalar_one_or_none() + if existing is None: + cert.user_id = new_user_id + else: + await db.delete(cert) + + await db.flush() + return migrated + + @router.post("/register", response_model=UserOut) -async def register(req: RegisterRequest, response: Response, db: AsyncSession = Depends(get_db)): +async def register(req: RegisterRequest, request: Request, response: Response, db: AsyncSession = Depends(get_db)): email = req.email.lower() existing = (await db.execute(select(User).where(User.email == email))).scalar_one_or_none() if existing is not None: @@ -248,17 +366,28 @@ async def register(req: RegisterRequest, response: Response, db: AsyncSession = ) db.add(user) await db.flush() + # Migrate any pre-signup guest progress before issuing the session + # cookie — failure is logged but not fatal (registration still succeeds). + anon_id = getattr(request.state, "anon_id", None) + if anon_id: + try: + n = await _migrate_anon_progress(db, anon_id, user.id) + if n: + logger.info("migrated %d anon progress rows from %s to user %s", n, anon_id, user.id) + except Exception: + logger.exception("anon-progress migration failed at register; continuing") # Fresh session auto-logged-in. sess = AuthSession.new_for_user(user.id, ttl_hours=SESSION_TTL_DAYS * 24) db.add(sess) await db.flush() _set_session_cookie(response, sess.id) + _clear_anon_cookie(response) # subsequent requests use the session cookie await db.commit() return _user_to_out(user) @router.post("/login", response_model=UserOut) -async def login(req: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)): +async def login(req: LoginRequest, request: Request, response: Response, db: AsyncSession = Depends(get_db)): email = req.email.lower() user = (await db.execute(select(User).where(User.email == email))).scalar_one_or_none() # Intentionally vague error to avoid email enumeration. @@ -268,7 +397,18 @@ async def login(req: LoginRequest, response: Response, db: AsyncSession = Depend db.add(sess) await db.flush() user.last_login_at = datetime.utcnow() + # Same migration as register — covers the case where a returning user + # browsed as a guest before logging back in. + anon_id = getattr(request.state, "anon_id", None) + if anon_id: + try: + n = await _migrate_anon_progress(db, anon_id, user.id) + if n: + logger.info("migrated %d anon progress rows from %s to user %s on login", n, anon_id, user.id) + except Exception: + logger.exception("anon-progress migration failed at login; continuing") _set_session_cookie(response, sess.id) + _clear_anon_cookie(response) await db.commit() return _user_to_out(user) @@ -547,3 +687,167 @@ async def enrolled_learners(course_id: str, request: Request, db: AsyncSession = "completed_at": e.completed_at.isoformat() if e.completed_at else None, }) return {"course_id": course_id, "course_title": course.title, "total_steps": total_steps, "learners": out} + + +@router.get("/creator/courses/{course_id}/aggregate-stats") +async def creator_course_aggregate_stats(course_id: str, request: Request, db: AsyncSession = Depends(get_db)): + """Course-wide engagement signals for the creator dashboard. + + Course-progression #6 (2026-04-27): per-step pass rate (so the + creator can see which exercises are choking learners), per-module + funnel (% of enrolled who reached / completed each module), and a + last-active distribution (engagement freshness). Depends on the + persisted `UserProgress.attempts` (#4) and the normalized 0-1 + `UserProgress.score` scale (#3). + + Auth: creator-or-admin scoped to the requested course. + """ + user = await require_role("creator", "admin")(request) + course = (await db.execute(select(Course).where(Course.id == course_id))).scalar_one_or_none() + if course is None: + raise HTTPException(404, "Course not found") + if user.role != "admin" and course.creator_user_id != user.id: + raise HTTPException(403, "You are not the creator of this course") + + from sqlalchemy import case, func as _func, distinct + from backend.database import Module, Step, UserProgress as _UP + + modules = (await db.execute( + select(Module).where(Module.course_id == course_id).order_by(Module.position) + )).scalars().all() + module_step_ids: dict[int, list[int]] = {} + step_meta: dict[int, dict] = {} + for m in modules: + steps = (await db.execute( + select(Step).where(Step.module_id == m.id).order_by(Step.position) + )).scalars().all() + module_step_ids[m.id] = [s.id for s in steps] + for pos, s in enumerate(steps): + step_meta[s.id] = { + "step_id": s.id, + "module_id": m.id, + "module_position": m.position, + "module_title": m.title, + "position": pos, + "title": s.title, + "exercise_type": s.exercise_type or "concept", + } + all_step_ids = [sid for ids in module_step_ids.values() for sid in ids] + + # Per-step rollup: attempts, completed_count, avg_score, distinct learner count. + step_stats: dict[int, dict] = {sid: {"attempts": 0, "completed": 0, "learners": 0, "avg_score": None} + for sid in all_step_ids} + if all_step_ids: + rows = (await db.execute( + select( + _UP.step_id, + _func.coalesce(_func.sum(_UP.attempts), 0).label("attempts"), + _func.sum(case((_UP.completed.is_(True), 1), else_=0)).label("completed"), + _func.count(distinct(_UP.user_id)).label("learners"), + _func.avg(_UP.score).label("avg_score"), + ) + .where(_UP.step_id.in_(all_step_ids)) + .group_by(_UP.step_id) + )).all() + for r in rows: + sid = r.step_id + if sid in step_stats: + step_stats[sid] = { + "attempts": int(r.attempts or 0), + "completed": int(r.completed or 0), + "learners": int(r.learners or 0), + "avg_score": float(r.avg_score) if r.avg_score is not None else None, + } + + # Per-module funnel: distinct learners who have ≥1 progress row on any + # step of the module (regardless of completion). + per_module = [] + for m in modules: + sids = module_step_ids.get(m.id, []) + # Reach: distinct user_ids with any progress in this module + reach_q = select(_func.count(distinct(_UP.user_id))).where(_UP.step_id.in_(sids)) if sids else None + reached = int((await db.execute(reach_q)).scalar() or 0) if reach_q is not None else 0 + # Module completion: distinct user_ids who completed ALL steps in the module + if sids: + sub_q = ( + select(_UP.user_id) + .where(_UP.step_id.in_(sids), _UP.completed.is_(True)) + .group_by(_UP.user_id) + .having(_func.count(_UP.step_id) >= len(sids)) + ) + completed_users = (await db.execute(sub_q)).scalars().all() + mod_completed = len(completed_users) + else: + mod_completed = 0 + # Aggregate per-step stats for this module + m_attempts = sum(step_stats[sid]["attempts"] for sid in sids) + m_completed = sum(step_stats[sid]["completed"] for sid in sids) + per_module.append({ + "module_id": m.id, + "position": m.position, + "title": m.title, + "step_count": len(sids), + "reached_learners": reached, + "completed_learners": mod_completed, + "total_attempts": m_attempts, + "total_step_completions": m_completed, + "steps": [ + { + **step_meta[sid], + **step_stats[sid], + # Pass rate = completed / attempts. Useful when attempts > 0. + "pass_rate": ( + step_stats[sid]["completed"] / step_stats[sid]["attempts"] + if step_stats[sid]["attempts"] else None + ), + } + for sid in sids + ], + }) + + # Last-active distribution + course-wide enrollment summary. + enrollments = (await db.execute( + select(Enrollment).where(Enrollment.course_id == course_id) + )).scalars().all() + now = datetime.utcnow() + buckets = {"day": 0, "week": 0, "month": 0, "older": 0, "never": 0} + completed_courses = 0 + progress_percents: list[int] = [] + for e in enrollments: + if e.completed_at is not None: + completed_courses += 1 + progress_percents.append(int(e.progress_percent or 0)) + last = e.last_active_at + if last is None: + buckets["never"] += 1 + continue + delta = (now - last).total_seconds() + if delta <= 86_400: + buckets["day"] += 1 + elif delta <= 7 * 86_400: + buckets["week"] += 1 + elif delta <= 30 * 86_400: + buckets["month"] += 1 + else: + buckets["older"] += 1 + + avg_pct = (sum(progress_percents) / len(progress_percents)) if progress_percents else 0.0 + median_pct = ( + sorted(progress_percents)[len(progress_percents) // 2] + if progress_percents else 0 + ) + + return { + "course_id": course_id, + "course_title": course.title, + "total_steps": len(all_step_ids), + "module_count": len(modules), + "summary": { + "enrolled": len(enrollments), + "completed_course": completed_courses, + "avg_progress_percent": round(avg_pct, 1), + "median_progress_percent": median_pct, + "last_active_distribution": buckets, + }, + "modules": per_module, + } diff --git a/backend/database.py b/backend/database.py index 70cb6ef..cb043a8 100644 --- a/backend/database.py +++ b/backend/database.py @@ -17,6 +17,7 @@ Enum, Float, ForeignKey, + Index, Integer, String, Text, @@ -211,9 +212,26 @@ class UserProgress(Base): score: Mapped[float | None] = mapped_column(Float, nullable=True) response_data: Mapped[dict | None] = mapped_column(JSON, nullable=True) completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + # Course-progression #4 (2026-04-27): server-side attempt counter, + # incremented on every /api/exercises/validate call. Drives per-step + # pass-rate + dropout signals on the creator dashboard. Nullable for + # rows created before the column existed; treat None as 0. + attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") step: Mapped["Step"] = relationship(back_populates="progress_records") + # Indices (2026-04-27): UserProgress had zero indices, every query was a + # seq scan. Postgres dev/staging feels this on real data — every + # /progress/complete + /exercises/validate upsert (now per-attempt + # since #4) hits WHERE user_id = ? AND step_id = ?. The composite + # covers that hot path and any user_id-scoped reads (get_progress, + # creator-learners). The step_id-only index covers the reverse — + # aggregate-stats per-step rollup that filters WHERE step_id IN (...). + __table_args__ = ( + Index("ix_user_progress_user_step", "user_id", "step_id"), + Index("ix_user_progress_step_id", "step_id"), + ) + class Certificate(Base): __tablename__ = "certificates" @@ -386,6 +404,27 @@ async def create_tables() -> None: column="learner_surface", ddl="ALTER TABLE steps ADD COLUMN learner_surface VARCHAR", ) + # Course-progression #4 (2026-04-27) — per-step attempt counter on + # UserProgress. Incremented by /api/exercises/validate on every + # submission. Existing rows backfill to 0 via the DEFAULT clause. + await _ensure_column( + table="user_progress", + column="attempts", + ddl="ALTER TABLE user_progress ADD COLUMN attempts INTEGER NOT NULL DEFAULT 0", + ) + # Course-progression (2026-04-27) — UserProgress indices for Postgres + # dev/staging perf. CREATE INDEX IF NOT EXISTS works on both SQLite + # 3.8.0+ and Postgres 9.5+. Idempotent: noop on second startup. + # New tables created by Base.metadata.create_all already include + # these via __table_args__; this DDL backfills existing tables. + await _run_ddl( + "CREATE INDEX IF NOT EXISTS ix_user_progress_user_step " + "ON user_progress (user_id, step_id)" + ) + await _run_ddl( + "CREATE INDEX IF NOT EXISTS ix_user_progress_step_id " + "ON user_progress (step_id)" + ) async def _ensure_column(*, table: str, column: str, ddl: str) -> None: @@ -407,6 +446,15 @@ def _existing_cols(sync_conn): await conn.execute(text(ddl)) +async def _run_ddl(ddl: str) -> None: + """Execute a DDL statement. Used for idempotent CREATE INDEX IF NOT + EXISTS migrations where the IF NOT EXISTS clause itself makes the + statement re-runnable across SQLite and Postgres.""" + from sqlalchemy import text + async with engine.begin() as conn: + await conn.execute(text(ddl)) + + async def get_db() -> AsyncGenerator[AsyncSession, None]: """FastAPI dependency that yields an async session.""" async with async_session_factory() as session: diff --git a/backend/main.py b/backend/main.py index 0b4e514..c5efd79 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1435,7 +1435,7 @@ async def execute_code(req: CodeExecuteRequest): # ── Exercise validation ────────────────────────────────────────────────── @app.post("/api/exercises/validate", response_model=ExerciseSubmitResponse) -async def validate_exercise(req: ExerciseSubmitRequest, db: AsyncSession = Depends(get_db)): +async def validate_exercise(req: ExerciseSubmitRequest, request: Request, db: AsyncSession = Depends(get_db)): # Load the step to get validation data result = await db.execute(select(Step).where(Step.id == req.step_id)) step = result.scalars().first() @@ -1470,6 +1470,22 @@ async def validate_exercise(req: ExerciseSubmitRequest, db: AsyncSession = Depen vresult = _validate_exercise(exercise_type, merged_validation, response, step) + # Course-progression #4 (2026-04-27): record this submission as an + # attempt on UserProgress. Server-side counter — clients have been + # tracking attempt_number themselves for reveal-gating, but it never + # reached the DB. Without this, the creator dashboard can't compute + # per-step pass rate or dropout. Best-effort: a flush failure here + # shouldn't fail the validate response, so it lives in a try/except. + try: + auth_user_v = getattr(request.state, "user", None) + if auth_user_v: + attempt_user_id = str(auth_user_v.id) + else: + attempt_user_id = getattr(request.state, "anon_id", None) or "default" + await _record_attempt(db, attempt_user_id, req.step_id) + except Exception as _e: + logging.warning("attempt counter record failed for step %s: %s", req.step_id, _e) + # Build per-item teaching feedback from the full DB record (server has access to answers). # This is returned to the frontend AFTER submission so the UI can render rich feedback # without needing the answer fields at render time. @@ -1965,9 +1981,14 @@ def _collect_explanations(exercise_type: str, validation: dict) -> list[str] | N @app.get("/api/progress/{course_id}", response_model=ProgressOut) async def get_progress(course_id: str, request: Request, db: AsyncSession = Depends(get_db)): - # 2026-04-25 — same soft-auth pattern as /api/progress/complete + # 2026-04-25 — same soft-auth pattern as /api/progress/complete. + # Course-progression #5 (2026-04-27): anonymous learners read against + # their own anon_id bucket, not the shared "default" pool. auth_user = getattr(request.state, "user", None) - user_id = str(auth_user.id) if auth_user else "default" + if auth_user: + user_id = str(auth_user.id) + else: + user_id = getattr(request.state, "anon_id", None) or "default" # Get all modules + steps for this course mod_result = await db.execute( @@ -2044,13 +2065,20 @@ async def mark_step_complete( if not step_id: raise HTTPException(400, "step_id is required") - score = body.get("score") + score = _normalize_score(body.get("score")) response_data = body.get("response_data") # 2026-04-25 — attribute progress to the logged-in user when possible. - # Soft auth: anonymous learners (no session cookie) still get tracked - # under "default" — back-compat with review agents + headless smoke tests. + # Course-progression #5 (2026-04-27): anonymous learners are now keyed + # by their `sll_anon` cookie (request.state.anon_id) instead of the + # shared "default" bucket — so guests don't share progress with each + # other, and pre-signup work survives via _migrate_anon_progress in + # the register/login handlers. "default" is still the fallback for + # cookieless callers (smoke tests, review agents, curl). auth_user = getattr(request.state, "user", None) - user_id = str(auth_user.id) if auth_user else "default" + if auth_user: + user_id = str(auth_user.id) + else: + user_id = getattr(request.state, "anon_id", None) or "default" # Check step exists step_result = await db.execute(select(Step).where(Step.id == step_id)) @@ -10184,6 +10212,65 @@ def _fetch_one(url: str) -> dict: } +async def _record_attempt(db: AsyncSession, user_id: str, step_id: int) -> None: + """Increment UserProgress.attempts for (user_id, step_id) — insert if missing. + + Course-progression #4 (2026-04-27): called from /api/exercises/validate + so every grader invocation increments a durable attempt counter. The + creator dashboard (#6) reads this for per-step pass-rate. + + Idempotent on a single submission: increments by 1 each call. Doesn't + flip `completed`; that stays in /api/progress/complete's hands. + """ + existing = await db.execute( + select(UserProgress).where( + UserProgress.user_id == user_id, + UserProgress.step_id == step_id, + ) + ) + row = existing.scalars().first() + if row: + row.attempts = (row.attempts or 0) + 1 + else: + db.add(UserProgress( + user_id=user_id, + step_id=step_id, + attempts=1, + completed=False, + )) + await db.flush() + + +def _normalize_score(raw): + """Normalize a step score to the canonical 0.0-1.0 scale. + + Course-progression #3 (2026-04-27): clients drift on score scale — + Web sends raw 0-1 (or null), CLI/VSCode send int 0-100. Without + normalization the same column ends up with mixed scales and any + aggregate (avg, certificate score, creator dashboard) is wrong. + + Heuristic: + - None / "" / unparseable → None (no score recorded) + - 0.0 ≤ x ≤ 1.0 → as-is (already 0-1) + - 1.0 < x ≤ 100.0 → x / 100 (interpret as percentage) + - x < 0 → 0.0 (clamp) + - x > 100 → 1.0 (clamp; should never happen but be defensive) + """ + if raw is None or raw == "": + return None + try: + v = float(raw) + except (TypeError, ValueError): + return None + if v < 0: + return 0.0 + if v <= 1.0: + return v + if v <= 100.0: + return v / 100.0 + return 1.0 + + def _normalize_course_level(raw) -> str: """Normalize any of beginner/intermediate/advanced / explorer/builder/deployer / freeform text into one of: 'Beginner', 'Intermediate', 'Advanced'. Defaults to 'Intermediate'.""" diff --git a/frontend/index.html b/frontend/index.html index 8629951..56a643e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3042,7 +3042,16 @@

Sign in

return !!getProgress()[stepKey(courseId, moduleId, stepIndex)]; } -function markStepComplete(courseId, moduleId, stepIndex, score) { +// Persistence: localStorage is the optimistic source of truth (offline + +// instant UI), but the backend is durable. We: +// 1. Mark complete locally + render IMMEDIATELY (no await) so the UI +// doesn't stall on network latency. +// 2. POST to /api/progress/complete with await so errors are visible +// and the certificate-issuance signal lands deterministically. +// 3. On any failure, push the write into a localStorage retry queue +// so a tab close mid-POST doesn't lose progress — the queue drains +// on the next page load (see _drainPendingProgressWrites at init). +async function markStepComplete(courseId, moduleId, stepIndex, score) { const p = getProgress(); const key = stepKey(courseId, moduleId, stepIndex); if (p[key]) return; // Already complete @@ -3051,29 +3060,125 @@

Sign in

renderStepDots(); renderModuleList(); - // Persist to backend (fire and forget) const steps = state.modules?.[moduleId]?.steps; const stepObj = steps?.[stepIndex]; + const stepType = stepObj?.exercise_type || stepObj?.step_type || 'concept'; + + // Check local completion for course (only for exercise steps, not concept auto-completes) + if (stepType !== 'concept') { + checkCourseCompletion(courseId); + } + if (stepObj && stepObj.id) { - fetch('/api/progress/complete', { + await _persistProgressComplete(courseId, stepObj.id, score); + } +} + +const PROGRESS_PENDING_KEY = 'learnSkillsPendingWrites_v1'; + +function _loadPendingProgressWrites() { + try { return JSON.parse(localStorage.getItem(PROGRESS_PENDING_KEY) || '[]'); } + catch { return []; } +} +function _savePendingProgressWrites(arr) { + try { localStorage.setItem(PROGRESS_PENDING_KEY, JSON.stringify(arr)); } catch (_) {} +} + +async function _persistProgressComplete(courseId, stepId, score) { + try { + const res = await fetch('/api/progress/complete', { method: 'POST', headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({step_id: stepObj.id, score: score || null}), - }) - .then(r => r.json()) - .then(data => { - if (data.certificate_issued) { - showCertificateCelebration(courseId, data.certificate); - } - }) - .catch(() => {}); // Silently fail + credentials: 'same-origin', + body: JSON.stringify({step_id: stepId, score: score == null ? null : score}), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + if (data && data.certificate_issued) { + showCertificateCelebration(courseId, data.certificate); + } + } catch (e) { + // Queue for retry on next page load. The localStorage update already + // happened; this just means the backend is temporarily unsynced. + console.warn('progress sync failed, queued for retry:', e && e.message); + const q = _loadPendingProgressWrites(); + q.push({step_id: stepId, score: score == null ? null : score, course_id: courseId, queued_at: Date.now()}); + // Cap the queue at 100 entries so a long offline streak doesn't blow up localStorage. + if (q.length > 100) q.splice(0, q.length - 100); + _savePendingProgressWrites(q); } +} - // Check local completion for course (only for exercise steps, not concept auto-completes) - const stepType = stepObj?.exercise_type || stepObj?.step_type || 'concept'; - if (stepType !== 'concept') { - checkCourseCompletion(courseId); +// Pulls /api/progress/{course_id} from the backend and writes the +// completed step_ids into localStorage so the UI is consistent with the +// authenticated user's durable progress, not just the per-device local +// cache. Called from enterCourse before render. +// +// We resolve module + step indices for each backend-reported completion +// by looking up state.modules — the course detail just landed, so the +// step.id → (module.id, step_index) mapping is in memory. +async function _mergeBackendProgressIntoLocal(courseId) { + let payload; + try { + const res = await fetch(`${API}/progress/${encodeURIComponent(courseId)}`, { + credentials: 'same-origin', + }); + if (!res.ok) return; + payload = await res.json(); + } catch (_) { return; } + + if (!payload || !Array.isArray(payload.steps)) return; + // Build a step_id → (moduleId, stepIndex) map from the in-memory course. + const stepMap = new Map(); + for (const m of (state.modules || [])) { + const arr = m.steps || []; + for (let i = 0; i < arr.length; i++) { + if (arr[i] && arr[i].id != null) stepMap.set(arr[i].id, { moduleId: m.id, stepIndex: i }); + } + } + if (!stepMap.size) return; + + const p = getProgress(); + let mutated = false; + for (const sc of payload.steps) { + if (!sc || !sc.completed) continue; + const ref = stepMap.get(sc.step_id); + if (!ref) continue; + const key = stepKey(courseId, ref.moduleId, ref.stepIndex); + if (!p[key]) { + p[key] = true; + mutated = true; + } } + if (mutated) saveProgress(p); +} + +// Drains the retry queue on init. Best-effort — any entry that still fails +// stays in the queue for the next attempt. Avoids storms by stopping after +// the first failure (likely the same network problem applies to all). +async function _drainPendingProgressWrites() { + const q = _loadPendingProgressWrites(); + if (!q.length) return; + const remaining = []; + for (const entry of q) { + try { + const res = await fetch('/api/progress/complete', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + credentials: 'same-origin', + body: JSON.stringify({step_id: entry.step_id, score: entry.score}), + }); + if (!res.ok) { remaining.push(entry); break; } + const data = await res.json(); + if (data && data.certificate_issued && entry.course_id) { + showCertificateCelebration(entry.course_id, data.certificate); + } + } catch (_) { remaining.push(entry); break; } + } + // Anything we didn't process (after a break) stays queued. + const idx = q.indexOf(remaining[0]); + const tail = idx > 0 ? q.slice(idx) : remaining; + _savePendingProgressWrites(tail); } function checkCourseCompletion(courseId) { @@ -3306,6 +3411,10 @@

Course Complete!

// renders in its final form immediately (no flash from signed-out → // signed-in). try { await hydrateAuth(); } catch (_) {} + // Course-progression #1 (2026-04-27): drain any progress writes that + // were queued while offline / mid-tab-close. Best-effort, runs in the + // background — doesn't block catalog load. + _drainPendingProgressWrites().catch(() => {}); const r = parseHash(); if (r) { await loadCatalog(); @@ -3470,16 +3579,138 @@

📊 Learners ac if (!detail) return; detail.innerHTML = `
Loading learners…
`; try { - const r = await fetch(`${API}/auth/creator/courses/${encodeURIComponent(courseId)}/enrolled-learners`, - {credentials: 'same-origin'}); - if (!r.ok) throw new Error(`learners ${r.status}`); - const data = await r.json(); - renderCreatorLearnersTable(data, detail); + // Course-progression #6 (2026-04-27): fetch the aggregate stats alongside + // the per-learner roster so the overview + step heatmap render in the + // same view. /aggregate-stats failure is non-fatal — we still show the + // learner table with a small notice that aggregates are missing. + const [learnersRes, aggRes] = await Promise.all([ + fetch(`${API}/auth/creator/courses/${encodeURIComponent(courseId)}/enrolled-learners`, + {credentials: 'same-origin'}), + fetch(`${API}/auth/creator/courses/${encodeURIComponent(courseId)}/aggregate-stats`, + {credentials: 'same-origin'}), + ]); + if (!learnersRes.ok) throw new Error(`learners ${learnersRes.status}`); + const data = await learnersRes.json(); + const agg = aggRes.ok ? await aggRes.json() : null; + let html = ''; + if (agg) html += renderCreatorAggregateOverview(agg); + detail.innerHTML = html + `
`; + renderCreatorLearnersTable(data, document.getElementById('creatorLearnersTable')); } catch (e) { detail.innerHTML = `
Failed to load learners: ${esc(e.message)}
`; } } +// Course-progression #6 (2026-04-27): renders the aggregate-stats overview +// the creator sees above the per-learner table. Three blocks: +// - Top metrics row (enrolled / completed / avg pct / median pct) +// - Last-active distribution (engagement freshness — day / week / month / older / never) +// - Per-step pass-rate heatmap grouped by module (which exercises bottle up learners) +function renderCreatorAggregateOverview(agg) { + const s = agg.summary || {}; + const buckets = s.last_active_distribution || {}; + const enrolled = s.enrolled || 0; + const completed = s.completed_course || 0; + const avg = s.avg_progress_percent != null ? s.avg_progress_percent : 0; + const median = s.median_progress_percent != null ? s.median_progress_percent : 0; + + // Top metrics — stacked stat cards. + const stat = (label, value, hint) => ` +
+
${esc(label)}
+
${esc(value)}
+ ${hint ? `
${esc(hint)}
` : ''} +
`; + const metricsRow = ` +
+ ${stat('Enrolled', enrolled)} + ${stat('Completed course', completed, enrolled ? `${Math.round(100*completed/enrolled)}% of enrolled` : '')} + ${stat('Avg progress', `${avg}%`)} + ${stat('Median progress', `${median}%`)} +
`; + + // Last-active distribution: small horizontal bar. + const totalActiveBuckets = (buckets.day || 0) + (buckets.week || 0) + (buckets.month || 0) + (buckets.older || 0) + (buckets.never || 0); + const seg = (key, label, color) => { + const v = buckets[key] || 0; + const w = totalActiveBuckets ? (100 * v / totalActiveBuckets) : 0; + return v ? `
${v}
` : ''; + }; + const activityBar = totalActiveBuckets ? ` +
+
Last activity
+
+ ${seg('day', 'Within 24h', '#2dd4bf')} + ${seg('week', 'This week', '#4a7cff')} + ${seg('month', 'This month', '#a855f7')} + ${seg('older', 'Older', '#fbbf24')} + ${seg('never', 'Never active', '#3a4258')} +
+
+ 24h + 7d + 30d + Older + Never +
+
` : ''; + + // Per-step pass-rate heatmap. + const modulesHtml = (agg.modules || []).map(m => { + const reach = m.reached_learners || 0; + const compl = m.completed_learners || 0; + const reachPct = enrolled ? Math.round(100 * reach / enrolled) : 0; + const stepCells = (m.steps || []).map(st => { + const attempts = st.attempts || 0; + const passRate = st.pass_rate; // 0-1 or null + let bg = '#1a1f2e'; // no attempts yet → grey + let label = '—'; + if (attempts > 0 && passRate != null) { + const pct = Math.round(passRate * 100); + // Red < 40%, amber 40-70%, green > 70%. + bg = pct < 40 ? '#7f1d1d' : (pct < 70 ? '#78350f' : '#064e3b'); + label = `${pct}%`; + } + const tip = `${st.title} · ${attempts} attempt${attempts === 1 ? '' : 's'}` + + (passRate != null ? ` · pass ${Math.round(passRate * 100)}%` : '') + + ` · ${st.exercise_type || 'concept'}`; + return `
${esc(label)}
`; + }).join(''); + return ` +
+
+
M${m.position}: ${esc(m.title)}
+
+ Reached ${reach}/${enrolled} (${reachPct}%) · Completed ${compl} +
+
+
${stepCells || 'No steps.'}
+
`; + }).join(''); + + const heatmap = (agg.modules || []).length ? ` +
+
+ Per-step pass rate + red <40% · amber <70% · green ≥70% +
+ ${modulesHtml} +
` : ''; + + return ` +
+
+
+
Overview
+
${esc(agg.course_title || agg.course_id)} · ${agg.module_count || 0} modules · ${agg.total_steps || 0} steps
+
+
+ ${metricsRow} + ${activityBar} + ${heatmap} +
`; +} + function renderCreatorLearnersTable(data, container) { const learners = data.learners || []; const totalSteps = data.total_steps || 0; @@ -4119,6 +4350,14 @@

📊 Learners ac return; } + // Course-progression #2 (2026-04-27): merge backend progress into the + // local cache before render. Without this the catalog/course view shows + // localStorage state only — which is empty on a fresh device or browser, + // even when the backend has completion records for the current user. + // Best-effort: if the fetch fails (offline, 404, anonymous), the local + // cache remains the source of truth. + await _mergeBackendProgressIntoLocal(course.id).catch(() => {}); + // Show sidebar elements document.getElementById('sidebarCourseInfo').style.display = 'block'; document.getElementById('sidebarCourseTitle').textContent = course.title;