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;