From ff5dff5e55806a0f9566821f2e0cf0897e177660 Mon Sep 17 00:00:00 2001 From: Pierre Le Fevre Date: Wed, 8 Apr 2026 21:24:21 +0200 Subject: [PATCH] Fix lesson completion bug, add TTS to exercise buttons, shuffle matching pairs, hide speak answer in text fallback - Fix completedLessonIds using only lesson_id without topic_id, causing all topics to show as complete - Add TTS pronunciation when clicking answer buttons in MultipleChoice, MatchingPairs, FillInTheBlank - Shuffle left and right columns independently in MatchingPairs - Hide phrase text in SpeakExercise when using text fallback so users can't just copy the answer - Add CLAUDE.md Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 78 +++++++++++++++++++ .../components/exercises/FillInTheBlank.tsx | 3 + .../components/exercises/MatchingPairs.tsx | 18 ++++- .../components/exercises/MultipleChoice.tsx | 10 ++- .../components/exercises/SpeakExercise.tsx | 16 ++-- web/src/pages/HomePage.tsx | 8 +- 6 files changed, 117 insertions(+), 16 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..40ac360 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,78 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Ayos is a Duolingo-style Tagalog learning PWA. Rust/Axum backend with SQLite, React/TypeScript/Vite frontend with Material-UI. Hosted on Tangled (`pierrelf.com/ayos`), deployed via Docker to Docker Hub (`pierrelf/ayos:latest`). + +## Commands + +### Backend (api/) +```bash +cd api && cargo run # Run dev server on port 3001 +cd api && cargo build # Build +cd api && cargo fmt # Format +cd api && cargo clippy -- -D warnings # Lint (treat warnings as errors) +``` + +### Frontend (web/) +```bash +cd web && npm install # Install deps (uses legacy-peer-deps via .npmrc) +cd web && npm run dev # Vite dev server on :5173 (proxies /api to :3001) +cd web && npm run build # TypeScript check + Vite build +cd web && npx tsc --noEmit # Type-check only +cd web && npm run lint # ESLint +``` + +### Docker +```bash +make build # Build ARM64 image +make push # Build + push to Docker Hub +docker compose up -d # Run on port 8882 +``` + +### Pre-push checklist +```bash +cd api && cargo fmt && cargo clippy -- -D warnings && cd .. +cd web && npx tsc --noEmit && cd .. +``` + +## Architecture + +### Backend (`api/src/`) +- **Framework**: Axum 0.8 on Tokio +- **Database**: SQLite via sqlx with embedded migrations (run automatically on startup) +- **Auth**: Argon2 password hashing, JWT tokens (30-day expiry) stored client-side in `localStorage` as `ayos_token`. Protected routes use `AuthUser` extractor. +- **Routes** in `routes/`: auth (register/login/me), progress (XP/streak/hearts), lesson_state (mid-lesson resume), tts (Edge TTS proxy), push (Web Push notifications with VAPID) +- **Config**: reads `.env` file — needs `DATABASE_URL=sqlite:ayos.db` and `JWT_SECRET` + +### Frontend (`web/src/`) +- **React 19** + TypeScript + Vite 8 + MUI 7 (dark theme) +- **Routing**: React Router 7 — pages in `pages/`, wrapped by `AppShell` layout +- **State**: AuthContext for user/token, `useLesson` reducer for exercise state, API calls for progress persistence +- **API client** (`api/client.ts`): adds Bearer token header, base fetch wrapper +- **PWA**: vite-plugin-pwa with Workbox, push notification service worker in `public/push-sw.js` + +### Lesson Content (`content/`) +- `topics.json`: defines all topics with sections, prerequisites, and lesson references +- `content//lesson-XX.json`: exercise arrays per lesson +- Exercise types: `multiple-choice`, `translation`, `matching-pairs`, `fill-in-the-blank`, `speak` +- Loaded at build time via Vite's `import.meta.glob()` in `web/src/content/loader.ts` + +### Data Flow +- Frontend bundles lesson content statically; exercises are evaluated client-side +- On lesson completion, frontend PUTs progress to `/api/progress` (updates XP, streak, hearts, best_score) +- Mid-lesson state can be saved/restored via `/api/lesson-state/{topicId}/{lessonId}` + +### Dev Proxy +Vite proxies `/api/*` to `http://localhost:3001` in development. In production, the backend serves the built frontend as static files with SPA fallback. + +## Tangled Git Hosting + +This repo uses Tangled (not GitHub). Use the `tangled` CLI for issues and PRs: +```bash +tangled issue list --repo pierrelf.com/ayos +tangled pr list --repo pierrelf.com/ayos +tangled pr create --repo pierrelf.com/ayos --head --base main --title "..." --body "..." +``` diff --git a/web/src/components/exercises/FillInTheBlank.tsx b/web/src/components/exercises/FillInTheBlank.tsx index b43fc90..d833578 100644 --- a/web/src/components/exercises/FillInTheBlank.tsx +++ b/web/src/components/exercises/FillInTheBlank.tsx @@ -4,6 +4,7 @@ import Typography from '@mui/material/Typography'; import Chip from '@mui/material/Chip'; import TextField from '@mui/material/TextField'; import { SpeakButton } from '../common/SpeakButton'; +import { useSpeech } from '../../hooks/useSpeech'; import type { FillInTheBlankExercise } from '../../types/lesson'; interface FillInTheBlankProps { @@ -19,6 +20,7 @@ export function FillInTheBlank({ isChecked, onSelect, }: FillInTheBlankProps) { + const { speak } = useSpeech(); const [inputValue, setInputValue] = useState(''); useEffect(() => { @@ -30,6 +32,7 @@ export function FillInTheBlank({ function handleWordBankClick(word: string) { if (isChecked) return; + speak(word); setInputValue(word); onSelect(word); } diff --git a/web/src/components/exercises/MatchingPairs.tsx b/web/src/components/exercises/MatchingPairs.tsx index b7e873f..5161477 100644 --- a/web/src/components/exercises/MatchingPairs.tsx +++ b/web/src/components/exercises/MatchingPairs.tsx @@ -1,8 +1,9 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import Box from '@mui/material/Box'; import Chip from '@mui/material/Chip'; import Typography from '@mui/material/Typography'; import { keyframes } from '@emotion/react'; +import { useSpeech } from '../../hooks/useSpeech'; import type { MatchingPairsExercise } from '../../types/lesson'; const fadeOut = keyframes` @@ -23,6 +24,7 @@ export function MatchingPairs({ isChecked, onSelect, }: MatchingPairsProps) { + const { speak } = useSpeech(); const [selectedLeft, setSelectedLeft] = useState(null); const [selectedRight, setSelectedRight] = useState(null); const [matchedPairs, setMatchedPairs] = useState>([]); @@ -35,15 +37,22 @@ export function MatchingPairs({ } }, [selectedAnswer]); - // Shuffled lists (stable within exercise) - const leftItems = exercise.pairs.map((p) => p.left); - const rightItems = exercise.pairs.map((p) => p.right); + // Shuffle independently so positions don't match + const leftItems = useMemo( + () => [...exercise.pairs.map((p) => p.left)].sort(() => Math.random() - 0.5), + [exercise], + ); + const rightItems = useMemo( + () => [...exercise.pairs.map((p) => p.right)].sort(() => Math.random() - 0.5), + [exercise], + ); const matchedLefts = new Set(matchedPairs.map(([l]) => l)); const matchedRights = new Set(matchedPairs.map(([, r]) => r)); function handleLeftClick(item: string) { if (isChecked || matchedLefts.has(item)) return; + speak(item); setSelectedLeft(item); if (selectedRight) { @@ -53,6 +62,7 @@ export function MatchingPairs({ function handleRightClick(item: string) { if (isChecked || matchedRights.has(item)) return; + speak(item); setSelectedRight(item); if (selectedLeft) { diff --git a/web/src/components/exercises/MultipleChoice.tsx b/web/src/components/exercises/MultipleChoice.tsx index 1f17753..bea3759 100644 --- a/web/src/components/exercises/MultipleChoice.tsx +++ b/web/src/components/exercises/MultipleChoice.tsx @@ -3,6 +3,7 @@ import Button from '@mui/material/Button'; import Typography from '@mui/material/Typography'; import Box from '@mui/material/Box'; import { SpeakButton } from '../common/SpeakButton'; +import { useSpeech } from '../../hooks/useSpeech'; import type { MultipleChoiceExercise } from '../../types/lesson'; interface MultipleChoiceProps { @@ -20,6 +21,13 @@ export function MultipleChoice({ isCorrect, onSelect, }: MultipleChoiceProps) { + const { speak } = useSpeech(); + + function handleSelect(index: number) { + speak(exercise.choices[index]); + onSelect(index); + } + function getButtonVariant(index: number) { if (!isChecked) { return index === selectedAnswer ? 'contained' : 'outlined'; @@ -62,7 +70,7 @@ export function MultipleChoice({ fullWidth variant={getButtonVariant(index)} color={getButtonColor(index) as 'primary' | 'secondary' | 'error' | 'inherit'} - onClick={() => onSelect(index)} + onClick={() => handleSelect(index)} disabled={isChecked} sx={{ py: 2, diff --git a/web/src/components/exercises/SpeakExercise.tsx b/web/src/components/exercises/SpeakExercise.tsx index fc51a41..cd42de3 100644 --- a/web/src/components/exercises/SpeakExercise.tsx +++ b/web/src/components/exercises/SpeakExercise.tsx @@ -64,13 +64,15 @@ export function SpeakExercise({ - - {exercise.phrase} - + {!showTextFallback && ( + + {exercise.phrase} + + )} {showTextFallback ? ( /* Text fallback when STT is not available */ diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx index b484b1f..2646db9 100644 --- a/web/src/pages/HomePage.tsx +++ b/web/src/pages/HomePage.tsx @@ -27,7 +27,7 @@ export function HomePage() { const completedLessonIds = useMemo(() => { if (!progress) return new Set(); return new Set( - progress.lessons.filter((l) => l.completed).map((l) => l.lesson_id), + progress.lessons.filter((l) => l.completed).map((l) => `${l.topic_id}/${l.lesson_id}`), ); }, [progress]); @@ -37,7 +37,7 @@ export function HomePage() { const lessons = getLessonsForTopic(topic.id); if ( lessons.length > 0 && - lessons.every((l) => completedLessonIds.has(l.id)) + lessons.every((l) => completedLessonIds.has(`${topic.id}/${l.id}`)) ) { completed.add(topic.id); } @@ -62,12 +62,12 @@ export function HomePage() { function getCompletedLessonsForTopic(topicId: string): number { const lessons = getLessonsForTopic(topicId); - return lessons.filter((l) => completedLessonIds.has(l.id)).length; + return lessons.filter((l) => completedLessonIds.has(`${topicId}/${l.id}`)).length; } function handleTopicClick(topicId: string) { const lessons = getLessonsForTopic(topicId); - const nextLesson = lessons.find((l) => !completedLessonIds.has(l.id)); + const nextLesson = lessons.find((l) => !completedLessonIds.has(`${topicId}/${l.id}`)); const lesson = nextLesson ?? lessons[0]; if (lesson) { navigate(`/lesson/${topicId}/${lesson.id}`); -- 2.51.2