From 61abbd26b22769b25c71e6ec83fd995c80ef1edc Mon Sep 17 00:00:00 2001 From: Pierre Le Fevre Date: Sat, 14 Mar 2026 10:04:50 +0100 Subject: [PATCH] Save lesson progress at each task for mid-lesson resume Add lesson_state table and API endpoints (PUT/GET/DELETE /api/lesson-state) to persist in-progress lesson state (current exercise index, hearts, correct count). On lesson load, check for saved state and restore it. After each exercise advance, save state to the server. On lesson completion, delete the saved state. This prevents losing all progress on disconnect or page refresh. Co-Authored-By: Claude Opus 4.6 (1M context) --- api/migrations/003_create_lesson_state.sql | 11 +++ api/src/db.rs | 1 + api/src/main.rs | 12 ++- api/src/models.rs | 30 +++++++ api/src/routes/lesson_state.rs | 95 ++++++++++++++++++++++ api/src/routes/mod.rs | 1 + web/src/api/client.ts | 4 + web/src/hooks/useLesson.ts | 17 ++++ web/src/pages/LessonPage.tsx | 52 +++++++++++- web/src/pages/ReviewPage.tsx | 7 ++ 10 files changed, 225 insertions(+), 5 deletions(-) create mode 100644 api/migrations/003_create_lesson_state.sql create mode 100644 api/src/routes/lesson_state.rs diff --git a/api/migrations/003_create_lesson_state.sql b/api/migrations/003_create_lesson_state.sql new file mode 100644 index 0000000..1e23282 --- /dev/null +++ b/api/migrations/003_create_lesson_state.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS lesson_state ( + user_id TEXT NOT NULL, + topic_id TEXT NOT NULL, + lesson_id TEXT NOT NULL, + current_index INTEGER NOT NULL DEFAULT 0, + hearts INTEGER NOT NULL DEFAULT 5, + correct_count INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL, + PRIMARY KEY (user_id, topic_id, lesson_id), + FOREIGN KEY (user_id) REFERENCES users(id) +); diff --git a/api/src/db.rs b/api/src/db.rs index 0772015..bf0f7ae 100644 --- a/api/src/db.rs +++ b/api/src/db.rs @@ -18,6 +18,7 @@ pub async fn run_migrations(pool: &SqlitePool) { let migrations = [ include_str!("../migrations/001_create_users.sql"), include_str!("../migrations/002_create_progress.sql"), + include_str!("../migrations/003_create_lesson_state.sql"), ]; for sql in &migrations { diff --git a/api/src/main.rs b/api/src/main.rs index e4b6881..d6b2ea1 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -5,7 +5,7 @@ mod errors; mod models; mod routes; -use axum::routing::{get, post}; +use axum::routing::{get, post, put}; use axum::Router; use tower_http::cors::{Any, CorsLayer}; use tower_http::services::{ServeDir, ServeFile}; @@ -28,6 +28,7 @@ async fn main() { axum::http::Method::GET, axum::http::Method::POST, axum::http::Method::PUT, + axum::http::Method::DELETE, ]) .allow_headers([ axum::http::header::AUTHORIZATION, @@ -42,6 +43,15 @@ async fn main() { "/api/progress", get(routes::progress::get_progress).put(routes::progress::update_progress), ) + .route( + "/api/lesson-state", + put(routes::lesson_state::save_lesson_state), + ) + .route( + "/api/lesson-state/:topic_id/:lesson_id", + get(routes::lesson_state::get_lesson_state) + .delete(routes::lesson_state::delete_lesson_state), + ) .route("/api/tts", get(routes::tts::synthesize)) .layer(cors) .with_state(state); diff --git a/api/src/models.rs b/api/src/models.rs index 9cda117..b393212 100644 --- a/api/src/models.rs +++ b/api/src/models.rs @@ -36,6 +36,18 @@ pub struct UserStats { pub hearts: i32, } +#[derive(Debug, FromRow)] +#[allow(dead_code)] +pub struct LessonState { + pub user_id: String, + pub topic_id: String, + pub lesson_id: String, + pub current_index: i32, + pub hearts: i32, + pub correct_count: i32, + pub updated_at: String, +} + // --------------------------------------------------------------------------- // Request DTOs // --------------------------------------------------------------------------- @@ -61,6 +73,15 @@ pub struct ProgressUpdateRequest { pub xp_earned: i32, } +#[derive(Debug, Deserialize)] +pub struct LessonStateSaveRequest { + pub topic_id: String, + pub lesson_id: String, + pub current_index: i32, + pub hearts: i32, + pub correct_count: i32, +} + // --------------------------------------------------------------------------- // Response DTOs // --------------------------------------------------------------------------- @@ -105,3 +126,12 @@ pub struct LessonProgress { pub best_score: i32, pub completed_at: Option, } + +#[derive(Debug, Serialize)] +pub struct LessonStateResponse { + pub topic_id: String, + pub lesson_id: String, + pub current_index: i32, + pub hearts: i32, + pub correct_count: i32, +} diff --git a/api/src/routes/lesson_state.rs b/api/src/routes/lesson_state.rs new file mode 100644 index 0000000..147785e --- /dev/null +++ b/api/src/routes/lesson_state.rs @@ -0,0 +1,95 @@ +use axum::extract::{Path, State}; +use axum::Json; +use chrono::Utc; + +use crate::auth::AuthUser; +use crate::config::AppState; +use crate::errors::AppError; +use crate::models::{LessonState, LessonStateResponse, LessonStateSaveRequest}; + +// --------------------------------------------------------------------------- +// PUT /api/lesson-state +// --------------------------------------------------------------------------- + +pub async fn save_lesson_state( + State(state): State, + AuthUser(user_id): AuthUser, + Json(body): Json, +) -> Result, AppError> { + let now_iso = Utc::now().format("%Y-%m-%dT%H:%M:%S").to_string(); + + sqlx::query( + "INSERT INTO lesson_state (user_id, topic_id, lesson_id, current_index, hearts, correct_count, updated_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?) \ + ON CONFLICT (user_id, topic_id, lesson_id) \ + DO UPDATE SET current_index = ?, hearts = ?, correct_count = ?, updated_at = ?", + ) + .bind(&user_id) + .bind(&body.topic_id) + .bind(&body.lesson_id) + .bind(body.current_index) + .bind(body.hearts) + .bind(body.correct_count) + .bind(&now_iso) + .bind(body.current_index) + .bind(body.hearts) + .bind(body.correct_count) + .bind(&now_iso) + .execute(&state.db) + .await?; + + Ok(Json(LessonStateResponse { + topic_id: body.topic_id, + lesson_id: body.lesson_id, + current_index: body.current_index, + hearts: body.hearts, + correct_count: body.correct_count, + })) +} + +// --------------------------------------------------------------------------- +// GET /api/lesson-state/:topic_id/:lesson_id +// --------------------------------------------------------------------------- + +pub async fn get_lesson_state( + State(state): State, + AuthUser(user_id): AuthUser, + Path((topic_id, lesson_id)): Path<(String, String)>, +) -> Result>, AppError> { + let row = sqlx::query_as::<_, LessonState>( + "SELECT user_id, topic_id, lesson_id, current_index, hearts, correct_count, updated_at \ + FROM lesson_state WHERE user_id = ? AND topic_id = ? AND lesson_id = ?", + ) + .bind(&user_id) + .bind(&topic_id) + .bind(&lesson_id) + .fetch_optional(&state.db) + .await?; + + Ok(Json(row.map(|r| LessonStateResponse { + topic_id: r.topic_id, + lesson_id: r.lesson_id, + current_index: r.current_index, + hearts: r.hearts, + correct_count: r.correct_count, + }))) +} + +// --------------------------------------------------------------------------- +// DELETE /api/lesson-state/:topic_id/:lesson_id +// --------------------------------------------------------------------------- + +pub async fn delete_lesson_state( + State(state): State, + AuthUser(user_id): AuthUser, + Path((topic_id, lesson_id)): Path<(String, String)>, +) -> Result<(), AppError> { + sqlx::query("DELETE FROM lesson_state WHERE user_id = ? AND topic_id = ? AND lesson_id = ?") + .bind(&user_id) + .bind(&topic_id) + .bind(&lesson_id) + .execute(&state.db) + .await?; + + Ok(()) +} diff --git a/api/src/routes/mod.rs b/api/src/routes/mod.rs index adef8b3..3392339 100644 --- a/api/src/routes/mod.rs +++ b/api/src/routes/mod.rs @@ -1,3 +1,4 @@ pub mod auth; +pub mod lesson_state; pub mod progress; pub mod tts; diff --git a/web/src/api/client.ts b/web/src/api/client.ts index b82da14..884f6d3 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -58,4 +58,8 @@ export const apiClient = { put(path: string, body?: unknown): Promise { return request('PUT', path, body); }, + + delete(path: string): Promise { + return request('DELETE', path); + }, }; diff --git a/web/src/hooks/useLesson.ts b/web/src/hooks/useLesson.ts index 8fcf919..7f4c424 100644 --- a/web/src/hooks/useLesson.ts +++ b/web/src/hooks/useLesson.ts @@ -14,6 +14,7 @@ export interface LessonState { type LessonAction = | { type: 'SET_EXERCISES'; exercises: Exercise[] } + | { type: 'RESTORE_STATE'; exercises: Exercise[]; currentIndex: number; hearts: number; correctCount: number } | { type: 'SELECT_ANSWER'; answer: unknown } | { type: 'CHECK_ANSWER'; correct: boolean } | { type: 'NEXT_EXERCISE' } @@ -38,6 +39,15 @@ function lessonReducer(state: LessonState, action: LessonAction): LessonState { exercises: action.exercises, }; + case 'RESTORE_STATE': + return { + ...initialState, + exercises: action.exercises, + currentIndex: action.currentIndex, + hearts: action.hearts, + correctCount: action.correctCount, + }; + case 'SELECT_ANSWER': if (state.isChecked) return state; return { @@ -93,6 +103,12 @@ export function useLesson() { [], ); + const restoreState = useCallback( + (exercises: Exercise[], currentIndex: number, hearts: number, correctCount: number) => + dispatch({ type: 'RESTORE_STATE', exercises, currentIndex, hearts, correctCount }), + [], + ); + const selectAnswer = useCallback( (answer: unknown) => dispatch({ type: 'SELECT_ANSWER', answer }), [], @@ -113,6 +129,7 @@ export function useLesson() { return { state, setExercises, + restoreState, selectAnswer, checkAnswer, nextExercise, diff --git a/web/src/pages/LessonPage.tsx b/web/src/pages/LessonPage.tsx index 4afffb1..389e53e 100644 --- a/web/src/pages/LessonPage.tsx +++ b/web/src/pages/LessonPage.tsx @@ -8,6 +8,7 @@ import CircularProgress from '@mui/material/CircularProgress'; import CloseIcon from '@mui/icons-material/Close'; import { useLesson } from '../hooks/useLesson'; import { getLesson } from '../content/loader'; +import { apiClient } from '../api/client'; import { ProgressBar } from '../components/layout/ProgressBar'; import { HeartsDisplay } from '../components/common/HeartsDisplay'; import { MultipleChoice } from '../components/exercises/MultipleChoice'; @@ -74,6 +75,7 @@ export function LessonPage() { const { state, setExercises, + restoreState, selectAnswer, checkAnswer, nextExercise, @@ -84,11 +86,35 @@ export function LessonPage() { [topicId, lessonId], ); + // Load lesson exercises and restore saved state if available useEffect(() => { - if (lesson) { - setExercises(lesson.exercises); + if (!lesson || !topicId || !lessonId) return; + + interface SavedState { + current_index: number; + hearts: number; + correct_count: number; } - }, [lesson, setExercises]); + + apiClient + .get(`/api/lesson-state/${topicId}/${lessonId}`) + .then((saved) => { + if (saved && saved.current_index > 0) { + restoreState( + lesson.exercises, + saved.current_index, + saved.hearts, + saved.correct_count, + ); + } else { + setExercises(lesson.exercises); + } + }) + .catch(() => { + // If fetching saved state fails, just start fresh + setExercises(lesson.exercises); + }); + }, [lesson, topicId, lessonId, setExercises, restoreState]); const currentExercise: Exercise | undefined = state.exercises[state.currentIndex]; @@ -100,8 +126,26 @@ export function LessonPage() { }, [currentExercise, state.selectedAnswer, checkAnswer]); const handleContinue = useCallback(() => { + if (!topicId || !lessonId) return; nextExercise(); - }, [nextExercise]); + // Save progress after advancing to next exercise. + // Use current state values which reflect the CHECK_ANSWER result. + const nextIndex = state.currentIndex + 1; + const isLast = nextIndex >= state.exercises.length; + if (!isLast) { + apiClient + .put('/api/lesson-state', { + topic_id: topicId, + lesson_id: lessonId, + current_index: nextIndex, + hearts: state.hearts, + correct_count: state.correctCount, + }) + .catch(() => { + // Save failed silently - not critical + }); + } + }, [nextExercise, topicId, lessonId, state.currentIndex, state.exercises.length, state.hearts, state.correctCount]); // Navigate to review when finished useEffect(() => { diff --git a/web/src/pages/ReviewPage.tsx b/web/src/pages/ReviewPage.tsx index af6eb69..e199f31 100644 --- a/web/src/pages/ReviewPage.tsx +++ b/web/src/pages/ReviewPage.tsx @@ -41,6 +41,7 @@ export function ReviewPage() { (reviewState.correctCount / reviewState.total) * 100, ); + // Save final progress and clear in-progress lesson state apiClient .put('/api/progress', { topic_id: reviewState.topicId, @@ -51,6 +52,12 @@ export function ReviewPage() { .catch(() => { // Progress save failed silently - user can retry the lesson }); + + apiClient + .delete(`/api/lesson-state/${reviewState.topicId}/${reviewState.lessonId}`) + .catch(() => { + // Cleanup failed silently - stale state will be overwritten on next attempt + }); }, [reviewState, navigate]); if (!reviewState) return null; -- 2.51.2