diff --git a/backend/app/collection/config.py b/backend/app/collection/config.py index 1f2ce28b..0028b169 100644 --- a/backend/app/collection/config.py +++ b/backend/app/collection/config.py @@ -67,12 +67,6 @@ class PlaylistConfig: ] PLAYLISTS: list[PlaylistConfig] = [ - PlaylistConfig( - name="Radio | Luyện nghe tiếng Trung", - playlist_id="PLniHrP5FDBnjRxYSs0j8_siiNPVs58pZ_", - default_difficulty="HSK 1-2", - default_topic="Daily Conversation", - ), PlaylistConfig( name="Mr.Chinese Channel", playlist_id="PLN7MEvFrgspUfsYuGJord_LVV9gp-L1bZ", diff --git a/backend/app/collection/router.py b/backend/app/collection/router.py index 8047fe32..7c7881a1 100644 --- a/backend/app/collection/router.py +++ b/backend/app/collection/router.py @@ -3,7 +3,12 @@ from fastapi import APIRouter, HTTPException -from app.collection.service import get_collection, get_playlist_videos, get_video_metadata +from app.collection.service import ( + get_collection, + get_playlist_videos, + get_video_metadata, + resolve_curated_video, +) router = APIRouter(prefix="/api") @@ -30,3 +35,14 @@ async def get_video_endpoint(video_id: str) -> dict: if result is None: raise HTTPException(status_code=404, detail="Video not found") return result + + +@router.get("/collection/resolve/{video_id}") +async def resolve_video_endpoint(video_id: str) -> dict: + """Resolve a recommended YouTube video to its internal tip route (playlist or standalone).""" + result = await asyncio.to_thread(resolve_curated_video, video_id) + if result["status"] in ("video", "playlist"): + return result + if result["status"] == "not_curated": + raise HTTPException(status_code=404, detail="Not a curated video") + raise HTTPException(status_code=503, detail="Resolution unavailable") diff --git a/backend/app/collection/service.py b/backend/app/collection/service.py index c5e3c310..5ffd2706 100644 --- a/backend/app/collection/service.py +++ b/backend/app/collection/service.py @@ -175,6 +175,40 @@ def get_cached_playlist(playlist_id: str) -> list[dict]: return entries +def resolve_curated_video(video_id: str) -> dict: + """Resolve a YouTube video_id to its curated tip home. + + The YouTube Data API has no reverse video→playlist lookup, so we enumerate the + curated tip playlists and check membership (reusing the cached playlistItems fetch). + + Returns one of: + - {"status": "video", "video_id": ...} standalone tip video + - {"status": "playlist", "playlist_id": ..., "video_id": ...} tip-playlist member + - {"status": "not_curated"} every tip playlist fetched, no match (definitive) + - {"status": "unresolved"} a tip playlist fetch was empty/failed, no match (transient) + """ + for video in STANDALONE_VIDEOS: + if video.video_id == video_id and video.content_type == "tip": + return {"status": "video", "video_id": video_id} + + had_empty_fetch = False + for playlist in PLAYLISTS: + if playlist.default_content_type != "tip": + continue + entries = get_cached_playlist(playlist.playlist_id) + if not entries: + had_empty_fetch = True + continue + if any(entry["id"] == video_id for entry in entries): + return { + "status": "playlist", + "playlist_id": playlist.playlist_id, + "video_id": video_id, + } + + return {"status": "unresolved"} if had_empty_fetch else {"status": "not_curated"} + + # Separate cache for playlist-level metadata (thumbnail, video_count) _meta_cache: dict[str, tuple[float, dict]] = {} diff --git a/backend/app/lessons/router.py b/backend/app/lessons/router.py index a882041e..dc7145a8 100644 --- a/backend/app/lessons/router.py +++ b/backend/app/lessons/router.py @@ -25,12 +25,13 @@ pick_manual_subtitle, ) from app.lessons.services.romanization_provider import get_romanization_provider +from app.lessons.services.segmentation_provider import get_segmentation_provider from app.lessons.services.chinese_normalizer import normalize_chinese from app.transcription.services.transcription_provider import STTProvider, TranscriptionKeys from app.translation.services.translation import translate_segments from app.lessons.services.validation import ValidationError, validate_upload_file, validate_youtube_url from app.shared.language_config import get_language_config -from app.lessons.services.vocabulary import extract_vocabulary +from app.lessons.services.vocabulary import enrich_vocabulary, extract_vocabulary from app.lessons.services.blog_scraper import scrape_article from app.tts.services.tts_provider import TTSProvider, TTSKeys @@ -70,6 +71,21 @@ async def _shared_pipeline( enriched_segments.append({**seg, "romanization": romanizer.romanize_text(seg["text"])}) logger.info("[pipeline] romanization: done in %.1fs (source_language=%s)", time.monotonic() - t0, source_language) + meaning_language = get_language_config(translation_languages[0])["language_name"] + segmenter = get_segmentation_provider(source_language) + if segmenter is not None: + for seg in enriched_segments: + seg["tokens"] = segmenter.segment(seg["text"]) + vocab_coro = enrich_vocabulary( + enriched_segments, romanizer, api_key, + source_language=source_language, meaning_language=meaning_language, + ) + else: + vocab_coro = extract_vocabulary( + enriched_segments, api_key, + source_language=source_language, meaning_language=meaning_language, + ) + jobs[job_id].step = "translation" t0 = time.monotonic() translated_segments, vocab_map = await asyncio.gather( @@ -77,11 +93,7 @@ async def _shared_pipeline( enriched_segments, translation_languages, api_key, source_language=source_language, ), - extract_vocabulary( - enriched_segments, api_key, - source_language=source_language, - meaning_language=get_language_config(translation_languages[0])["language_name"], - ), + vocab_coro, ) logger.info( "[pipeline] translation+vocabulary: done in %.1fs, %d segments, %d vocab entries", diff --git a/backend/app/lessons/services/segmentation_provider.py b/backend/app/lessons/services/segmentation_provider.py new file mode 100644 index 00000000..e06a8724 --- /dev/null +++ b/backend/app/lessons/services/segmentation_provider.py @@ -0,0 +1,29 @@ +"""Pluggable word-segmentation providers — deterministic, one per language family.""" + +import re +from typing import Protocol + +# A token is meaningful if it contains at least one word character. \w under +# Python's default Unicode matching covers CJK; fullwidth/halfwidth punctuation +# and whitespace do not — so this drops pure-punctuation tokens jieba emits. +_HAS_WORD_CHAR = re.compile(r"\w", re.UNICODE) + + +class SegmentationProvider(Protocol): + def segment(self, text: str) -> list[str]: ... + + +class ChineseSegmentationProvider: + def segment(self, text: str) -> list[str]: + import jieba # type: ignore[import] + return [ + tok for tok in jieba.lcut(text) + if _HAS_WORD_CHAR.search(tok) + ] + + +def get_segmentation_provider(source_language: str) -> SegmentationProvider | None: + """Return a deterministic segmenter for source_language, or None to fall back to LLM.""" + if source_language.startswith("zh"): + return ChineseSegmentationProvider() + return None diff --git a/backend/app/lessons/services/vocabulary.py b/backend/app/lessons/services/vocabulary.py index 25926a9b..b203d479 100644 --- a/backend/app/lessons/services/vocabulary.py +++ b/backend/app/lessons/services/vocabulary.py @@ -1,12 +1,21 @@ -"""Vocabulary extraction service using OpenRouter API.""" +"""Vocabulary extraction service using OpenRouter API. + +Two paths: +- ``enrich_vocabulary``: words are pre-segmented (e.g. jieba) and pre-romanized + deterministically; the LLM only fills meaning + usage. Cannot drop or invent words. +- ``extract_vocabulary``: legacy fallback where the LLM segments + romanizes + defines, + used for languages without a deterministic segmenter. +""" import asyncio +import json import logging -from typing import List +from typing import Awaitable, Callable, List import httpx from pydantic import BaseModel, ConfigDict +from app.lessons.services.romanization_provider import RomanizationProvider from app.settings import settings from app.shared._retry import RetryableError, http_retry from app.shared.language_config import get_language_config @@ -37,7 +46,25 @@ class VocabularyResponse(BaseModel): segments: List[SegmentVocabulary] -# Fully inlined JSON schema for OpenAI strict structured outputs. +class EnrichWordEntry(BaseModel): + model_config = ConfigDict(extra="ignore") + word: str + meaning: str + usage: str + + +class EnrichSegment(BaseModel): + model_config = ConfigDict(extra="ignore") + id: int + words: List[EnrichWordEntry] + + +class EnrichResponse(BaseModel): + model_config = ConfigDict(extra="ignore") + segments: List[EnrichSegment] + + +# Fully inlined JSON schemas for OpenAI strict structured outputs. # Cannot use model_json_schema() — OpenAI strict mode forbids $ref/$defs. _VOCABULARY_JSON_SCHEMA = { "type": "object", @@ -72,9 +99,41 @@ class VocabularyResponse(BaseModel): "additionalProperties": False, } +_VOCAB_ENRICH_JSON_SCHEMA = { + "type": "object", + "properties": { + "segments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "words": { + "type": "array", + "items": { + "type": "object", + "properties": { + "word": {"type": "string"}, + "meaning": {"type": "string"}, + "usage": {"type": "string"}, + }, + "required": ["word", "meaning", "usage"], + "additionalProperties": False, + }, + }, + }, + "required": ["id", "words"], + "additionalProperties": False, + }, + }, + }, + "required": ["segments"], + "additionalProperties": False, +} + def _build_vocab_prompt(segments: list[dict], source_language: str = "zh-CN", meaning_language: str = "English") -> str: - """Build a prompt to extract key vocabulary from segments.""" + """Build a prompt to extract key vocabulary from segments (legacy LLM segmentation).""" lang_cfg = get_language_config(source_language) no_romanization = lang_cfg["romanization_description"].startswith("leave empty") romanization_line = ( @@ -102,25 +161,47 @@ def _build_vocab_prompt(segments: list[dict], source_language: str = "zh-CN", me ) +def _build_enrich_prompt(segments: list[dict], source_language: str = "zh-CN", meaning_language: str = "English") -> str: + """Build a prompt that defines pre-segmented words. The LLM only adds meaning + usage.""" + lang_cfg = get_language_config(source_language) + segments_text = "\n".join( + f'{{"id": {seg["id"]}, "words": {json.dumps(seg.get("tokens", []), ensure_ascii=False)}}}' + for seg in segments + ) + return ( + f"You are a {lang_cfg['language_name']} language teacher. Each segment below has an id and an ordered " + "list of words that have ALREADY been segmented.\n" + "For EVERY word in each list, provide its meaning and an example usage. Do NOT add, drop, merge, " + "reorder, or alter the words.\n\n" + "For each word provide:\n" + '- "word": copy the word EXACTLY as given\n' + f'- "meaning": concise {meaning_language} meaning\n' + '- "usage": a short example sentence (different from the source)\n\n' + f"Segments:\n{segments_text}\n\n" + "Return the SAME words in the SAME order for each segment, as a JSON object with this exact structure:\n" + '{"segments": [{"id": , "words": [{"word": "", "meaning": "", "usage": ""}]}]}' + ) + + _VOCAB_BATCH_SIZE = 5 -async def _extract_batch_with_retry( - segments: list[dict], +def _make_batch_runner( + seg_ids: list[int], + prompt: str, + json_schema_name: str, + json_schema: dict, + parse: Callable[[str], dict[int, list[dict]]], api_key: str, semaphore: asyncio.Semaphore, - source_language: str = "zh-CN", - meaning_language: str = "English", -) -> dict[int, list[dict]]: - """Extract vocabulary for a batch of segments with semaphore gating and retry on transient errors.""" - seg_ids = [s["id"] for s in segments] - prompt = _build_vocab_prompt(segments, source_language=source_language, meaning_language=meaning_language) +) -> Callable[[], Awaitable[dict[int, list[dict]]]]: + """Build a retrying coroutine that POSTs one batch and parses the structured response.""" response_format = { "type": "json_schema", "json_schema": { - "name": "vocabulary_response", + "name": json_schema_name, "strict": True, - "schema": _VOCABULARY_JSON_SCHEMA, + "schema": json_schema, }, } @@ -143,37 +224,39 @@ async def _call() -> dict[int, list[dict]]: "reasoning": {"effort": "none"}, }, ) - response.raise_for_status() - body = response.json() - if "error" in body: - raise RetryableError( - f"Vocab batch {seg_ids}: OpenRouter error — {body['error']}" - ) - choice = body["choices"][0] - finish_reason = choice.get("finish_reason", "") - if finish_reason == "length": - logger.warning( - "Vocab batch %s: response truncated (finish_reason=length), " - "output hit max_tokens limit", - seg_ids, - ) - raise RetryableError(f"Vocab batch {seg_ids}: response truncated by token limit") - content = choice["message"]["content"] - try: - parsed = VocabularyResponse.model_validate_json(content) - total_words = sum(len(seg.words) for seg in parsed.segments) - logger.info( - "Vocab batch %s: OK — %d segments, %d words extracted", - seg_ids, len(parsed.segments), total_words, - ) - return {seg.id: [w.model_dump() for w in seg.words] for seg in parsed.segments} - except Exception as e: - raise RetryableError( - f"Vocab batch {seg_ids}: failed to parse response — {e}" - ) from e + response.raise_for_status() + body = response.json() + if "error" in body: + raise RetryableError( + f"Vocab batch {seg_ids}: OpenRouter error — {body['error']}" + ) + choice = body["choices"][0] + finish_reason = choice.get("finish_reason", "") + if finish_reason == "length": + logger.warning( + "Vocab batch %s: response truncated (finish_reason=length), " + "output hit max_tokens limit", + seg_ids, + ) + raise RetryableError(f"Vocab batch {seg_ids}: response truncated by token limit") + content = choice["message"]["content"] + try: + return parse(content) + except Exception as e: + raise RetryableError( + f"Vocab batch {seg_ids}: failed to parse response — {e}" + ) from e + + return _call + +async def _run_batch( + runner: Callable[[], Awaitable[dict[int, list[dict]]]], + seg_ids: list[int], +) -> dict[int, list[dict]]: + """Execute a batch runner, wrapping unexpected failures as VocabularyExtractionError.""" try: - return await _call() + return await runner() except VocabularyExtractionError: raise except asyncio.CancelledError: @@ -184,17 +267,109 @@ async def _call() -> dict[int, list[dict]]: ) from e +async def _gather_batches(tasks: list[asyncio.Task]) -> dict[int, list[dict]]: + """Await all batch tasks, cancel siblings on first failure, merge results.""" + try: + results: list[dict[int, list[dict]]] = await asyncio.gather(*tasks) + except VocabularyExtractionError: + for t in tasks: + t.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + raise + except Exception as e: + for t in tasks: + t.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + raise VocabularyExtractionError(f"Vocabulary extraction failed: {e}") from e + + merged: dict[int, list[dict]] = {} + for batch_result in results: + merged.update(batch_result) + return merged + + +def _parse_extraction(content: str, seg_ids: list[int]) -> dict[int, list[dict]]: + parsed = VocabularyResponse.model_validate_json(content) + total_words = sum(len(seg.words) for seg in parsed.segments) + logger.info( + "Vocab batch %s: OK — %d segments, %d words extracted", + seg_ids, len(parsed.segments), total_words, + ) + return {seg.id: [w.model_dump() for w in seg.words] for seg in parsed.segments} + + +def _parse_enrichment( + content: str, + tokens_by_id: dict[int, list[str]], + romanizer: RomanizationProvider, + seg_ids: list[int], +) -> dict[int, list[dict]]: + """Attach deterministic pinyin to our jieba tokens; splice in LLM meaning/usage. + + The token list is authoritative — coverage is guaranteed regardless of what the LLM + returns. Meaning/usage are matched by exact word string, with a positional fallback + only when the LLM returned the same number of words. + """ + parsed = EnrichResponse.model_validate_json(content) + llm_by_id = {seg.id: seg.words for seg in parsed.segments} + + result: dict[int, list[dict]] = {} + for seg_id, tokens in tokens_by_id.items(): + llm_words = llm_by_id.get(seg_id, []) + by_word: dict[str, EnrichWordEntry] = {} + for w in llm_words: + by_word.setdefault(w.word, w) + same_length = len(llm_words) == len(tokens) + + words_out: list[dict] = [] + for i, token in enumerate(tokens): + entry = by_word.get(token) + if entry is None and same_length: + entry = llm_words[i] + words_out.append({ + "word": token, + "romanization": romanizer.romanize_word(token), + "meaning": entry.meaning if entry else "", + "usage": entry.usage if entry else "", + }) + result[seg_id] = words_out + + total_words = sum(len(v) for v in result.values()) + logger.info( + "Vocab batch %s: enriched — %d segments, %d words", + seg_ids, len(result), total_words, + ) + return result + + +async def _extract_batch_with_retry( + segments: list[dict], + api_key: str, + semaphore: asyncio.Semaphore, + source_language: str = "zh-CN", + meaning_language: str = "English", +) -> dict[int, list[dict]]: + """Extract vocabulary for one batch (LLM segments + romanizes + defines).""" + seg_ids = [s["id"] for s in segments] + prompt = _build_vocab_prompt(segments, source_language=source_language, meaning_language=meaning_language) + runner = _make_batch_runner( + seg_ids, prompt, "vocabulary_response", _VOCABULARY_JSON_SCHEMA, + lambda content: _parse_extraction(content, seg_ids), + api_key, semaphore, + ) + return await _run_batch(runner, seg_ids) + + async def extract_vocabulary( segments: list[dict], api_key: str, source_language: str = "zh-CN", meaning_language: str = "English", ) -> dict[int, list[dict]]: - """Extract vocabulary for all segments in parallel batches. + """LLM extracts + segments + romanizes vocabulary for all segments (legacy fallback). Fires all batch tasks concurrently (max 20 in-flight via semaphore). - Raises VocabularyExtractionError if any batch fails after retries — - guaranteeing all-or-nothing consistency. + Raises VocabularyExtractionError if any batch fails after retries. """ if not segments: return {} @@ -210,22 +385,44 @@ async def extract_vocabulary( ] logger.info("Vocabulary: dispatching %d parallel batches for %d segments", len(tasks), len(segments)) - try: - results: list[dict[int, list[dict]]] = await asyncio.gather(*tasks) - except VocabularyExtractionError: - for t in tasks: - t.cancel() - await asyncio.gather(*tasks, return_exceptions=True) - raise - except Exception as e: - for t in tasks: - t.cancel() - await asyncio.gather(*tasks, return_exceptions=True) - raise VocabularyExtractionError(f"Vocabulary extraction failed: {e}") from e + merged = await _gather_batches(tasks) + logger.info("Vocabulary: complete — %d segments with words", len(merged)) + return merged - merged: dict[int, list[dict]] = {} - for batch_result in results: - merged.update(batch_result) - logger.info("Vocabulary: complete — %d segments with words", len(merged)) +async def enrich_vocabulary( + segments: list[dict], + romanizer: RomanizationProvider, + api_key: str, + source_language: str = "zh-CN", + meaning_language: str = "English", +) -> dict[int, list[dict]]: + """Enrich pre-segmented words with meaning + usage; pinyin filled deterministically. + + Each segment must carry a ``tokens: list[str]`` list (e.g. from jieba). The LLM only + supplies meaning + usage — it cannot drop or invent words, so coverage is guaranteed. + """ + if not segments: + return {} + + semaphore = asyncio.Semaphore(20) + batches = [ + segments[i : i + _VOCAB_BATCH_SIZE] + for i in range(0, len(segments), _VOCAB_BATCH_SIZE) + ] + tasks = [] + for batch in batches: + seg_ids = [s["id"] for s in batch] + tokens_by_id = {s["id"]: list(s.get("tokens", [])) for s in batch} + prompt = _build_enrich_prompt(batch, source_language=source_language, meaning_language=meaning_language) + runner = _make_batch_runner( + seg_ids, prompt, "vocabulary_enrich_response", _VOCAB_ENRICH_JSON_SCHEMA, + lambda content, tbi=tokens_by_id, ids=seg_ids: _parse_enrichment(content, tbi, romanizer, ids), + api_key, semaphore, + ) + tasks.append(asyncio.create_task(_run_batch(runner, seg_ids))) + logger.info("Vocabulary: dispatching %d parallel enrich batches for %d segments", len(tasks), len(segments)) + + merged = await _gather_batches(tasks) + logger.info("Vocabulary: enrich complete — %d segments with words", len(merged)) return merged diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 95fdda25..77f38b3e 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "bgutil-ytdlp-pot-provider>=1.3.0", "ffmpeg-python>=0.2.0", "pypinyin>=0.53.0", + "jieba>=0.42.1", "pykakasi>=2.2.1", "eng-to-ipa>=0.0.2", "httpx>=0.28.0", diff --git a/backend/tests/test_collection_router.py b/backend/tests/test_collection_router.py index bd97549c..148f9eaa 100644 --- a/backend/tests/test_collection_router.py +++ b/backend/tests/test_collection_router.py @@ -97,3 +97,51 @@ async def test_get_playlist_returns_404_for_unknown(monkeypatch): response = await client.get("/api/playlist/UNKNOWN") assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_resolve_video_returns_playlist_route(monkeypatch): + """GET /api/collection/resolve/:id returns 200 + playlist route for a tip member.""" + from app.main import app + from app.collection import router as collection_router + + monkeypatch.setattr(collection_router, "resolve_curated_video", lambda vid: { + "status": "playlist", "playlist_id": "PL_TIP", "video_id": vid, + }) + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/collection/resolve/vid1") + + assert response.status_code == 200 + assert response.json() == {"status": "playlist", "playlist_id": "PL_TIP", "video_id": "vid1"} + + +@pytest.mark.asyncio +async def test_resolve_video_404_when_not_curated(monkeypatch): + """GET /api/collection/resolve/:id returns 404 when definitively not curated.""" + from app.main import app + from app.collection import router as collection_router + + monkeypatch.setattr(collection_router, "resolve_curated_video", lambda vid: {"status": "not_curated"}) + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/collection/resolve/vid1") + + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_resolve_video_503_when_unresolved(monkeypatch): + """GET /api/collection/resolve/:id returns 503 on transient resolution failure.""" + from app.main import app + from app.collection import router as collection_router + + monkeypatch.setattr(collection_router, "resolve_curated_video", lambda vid: {"status": "unresolved"}) + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/collection/resolve/vid1") + + assert response.status_code == 503 diff --git a/backend/tests/test_collection_service.py b/backend/tests/test_collection_service.py index dd31600a..257e1183 100644 --- a/backend/tests/test_collection_service.py +++ b/backend/tests/test_collection_service.py @@ -878,6 +878,84 @@ def test_get_playlist_videos_returns_data_for_non_curated_playlist(monkeypatch): assert v["content_type"] is None +# ── resolve_curated_video ───────────────────────────────────────────────────── + +def test_resolve_curated_video_standalone(monkeypatch): + """A standalone tip video resolves to the single-video route.""" + import app.collection.service as svc + from app.collection.config import VideoConfig + + monkeypatch.setattr(svc, "STANDALONE_VIDEOS", [VideoConfig(video_id="solo1", content_type="tip", skill="Grammar")]) + monkeypatch.setattr(svc, "PLAYLISTS", []) + + assert svc.resolve_curated_video("solo1") == {"status": "video", "video_id": "solo1"} + + +def test_resolve_curated_video_playlist_member(monkeypatch): + """A video in a curated tip playlist resolves to that playlist.""" + import app.collection.service as svc + from app.collection.config import PlaylistConfig + + monkeypatch.setattr(svc, "STANDALONE_VIDEOS", []) + monkeypatch.setattr(svc, "PLAYLISTS", [ + PlaylistConfig(name="Grammar", playlist_id="PL_TIP", default_content_type="tip", default_skill="Grammar"), + ]) + monkeypatch.setattr(svc, "get_cached_playlist", lambda pid: [{"id": "vidX"}, {"id": "vidY"}]) + + assert svc.resolve_curated_video("vidY") == { + "status": "playlist", "playlist_id": "PL_TIP", "video_id": "vidY", + } + + +def test_resolve_curated_video_skips_non_tip_playlists(monkeypatch): + """Material playlists are not scanned; a video only there is not curated (as a tip).""" + import app.collection.service as svc + from app.collection.config import PlaylistConfig + + monkeypatch.setattr(svc, "STANDALONE_VIDEOS", []) + monkeypatch.setattr(svc, "PLAYLISTS", [ + PlaylistConfig(name="Material", playlist_id="PL_MAT", default_content_type="material"), + ]) + called = {"n": 0} + + def fake_cached(pid): + called["n"] += 1 + return [{"id": "vidZ"}] + + monkeypatch.setattr(svc, "get_cached_playlist", fake_cached) + + assert svc.resolve_curated_video("vidZ") == {"status": "not_curated"} + assert called["n"] == 0 # material playlist never fetched + + +def test_resolve_curated_video_not_curated(monkeypatch): + """No match across fully-fetched tip playlists -> definitive not_curated.""" + import app.collection.service as svc + from app.collection.config import PlaylistConfig + + monkeypatch.setattr(svc, "STANDALONE_VIDEOS", []) + monkeypatch.setattr(svc, "PLAYLISTS", [ + PlaylistConfig(name="Grammar", playlist_id="PL_TIP", default_content_type="tip", default_skill="Grammar"), + ]) + monkeypatch.setattr(svc, "get_cached_playlist", lambda pid: [{"id": "other"}]) + + assert svc.resolve_curated_video("missing") == {"status": "not_curated"} + + +def test_resolve_curated_video_unresolved_on_empty_fetch(monkeypatch): + """A tip playlist returning [] (fetch failed) with no match -> unresolved (transient).""" + import app.collection.service as svc + from app.collection.config import PlaylistConfig + + monkeypatch.setattr(svc, "STANDALONE_VIDEOS", []) + monkeypatch.setattr(svc, "PLAYLISTS", [ + PlaylistConfig(name="Grammar", playlist_id="PL_TIP", default_content_type="tip", default_skill="Grammar"), + ]) + monkeypatch.setattr(svc, "get_cached_playlist", lambda pid: []) + + assert svc.resolve_curated_video("anything") == {"status": "unresolved"} + + def test_fetch_standalone_video_entries_returns_full_metadata(monkeypatch): """fetch_standalone_video_entries returns title, channel, duration, view_count.""" from unittest.mock import MagicMock, patch diff --git a/backend/tests/test_segmentation_provider.py b/backend/tests/test_segmentation_provider.py new file mode 100644 index 00000000..d0e67b27 --- /dev/null +++ b/backend/tests/test_segmentation_provider.py @@ -0,0 +1,34 @@ +from app.lessons.services.segmentation_provider import ( + ChineseSegmentationProvider, + get_segmentation_provider, +) + + +def test_chinese_segmentation_splits_words(): + tokens = ChineseSegmentationProvider().segment("我喜欢学习中文") + assert tokens == ["我", "喜欢", "学习", "中文"] + + +def test_chinese_segmentation_drops_punctuation(): + tokens = ChineseSegmentationProvider().segment("我喜欢学习中文。") + assert "。" not in tokens + assert tokens[-1] == "中文" + + +def test_chinese_segmentation_tokens_are_substrings(): + text = "今天天气很好,我们去公园吧!" + tokens = ChineseSegmentationProvider().segment(text) + assert tokens # non-empty + for tok in tokens: + assert tok in text # exact substring — frontend buildWordSpans contract + + +def test_get_segmentation_provider_chinese(): + assert isinstance(get_segmentation_provider("zh-CN"), ChineseSegmentationProvider) + assert isinstance(get_segmentation_provider("zh-TW"), ChineseSegmentationProvider) + + +def test_get_segmentation_provider_other_languages_none(): + assert get_segmentation_provider("ja") is None + assert get_segmentation_provider("en") is None + assert get_segmentation_provider("ko") is None diff --git a/backend/tests/test_vocabulary.py b/backend/tests/test_vocabulary.py index 6eeaf80c..22a9bacb 100644 --- a/backend/tests/test_vocabulary.py +++ b/backend/tests/test_vocabulary.py @@ -294,3 +294,97 @@ def test_build_vocab_prompt_english(): assert "English" in prompt assert "IPA" in prompt assert "Chinese" not in prompt + + +# ── enrich_vocabulary (deterministic segmentation + LLM meaning/usage) ────────── + + +class _FakeRomanizer: + def romanize_text(self, text: str) -> str: + return f"T:{text}" + + def romanize_word(self, word: str) -> str: + return f"R:{word}" + + +def _enrich_content(seg_words: dict) -> str: + return json.dumps({ + "segments": [ + {"id": i, "words": [{"word": w, "meaning": f"m{w}", "usage": f"u{w}"} for w in words]} + for i, words in seg_words.items() + ] + }) + + +def test_build_enrich_prompt_has_no_romanization_and_lists_tokens(): + from app.lessons.services.vocabulary import _build_enrich_prompt + segments = [{"id": 0, "text": "我喜欢", "tokens": ["我", "喜欢"]}] + prompt = _build_enrich_prompt(segments, source_language="zh-CN") + assert "romanization" not in prompt + assert '"words":' in prompt + assert "我" in prompt and "喜欢" in prompt + assert "ALREADY been segmented" in prompt + + +@pytest.mark.asyncio +async def test_enrich_vocabulary_fills_pinyin_in_python_not_llm(): + """Romanization comes from the romanizer, not the LLM response.""" + from app.lessons.services.vocabulary import enrich_vocabulary + + segments = [{"id": 0, "text": "我喜欢学习", "tokens": ["我", "喜欢", "学习"]}] + content = _enrich_content({0: ["我", "喜欢", "学习"]}) + + with patch("app.lessons.services.vocabulary.httpx.AsyncClient") as mock_cls: + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client.post = AsyncMock(return_value=_make_mock_response(200, content)) + mock_cls.return_value = mock_client + + result = await enrich_vocabulary(segments, _FakeRomanizer(), "test_key") + + words = result[0] + assert [w["word"] for w in words] == ["我", "喜欢", "学习"] + assert [w["romanization"] for w in words] == ["R:我", "R:喜欢", "R:学习"] + assert words[1]["meaning"] == "m喜欢" + assert words[1]["usage"] == "u喜欢" + # enrich schema must omit romanization + sent = mock_client.post.call_args.kwargs["json"] + schema = sent["response_format"]["json_schema"]["schema"] + word_props = schema["properties"]["segments"]["items"]["properties"]["words"]["items"]["properties"] + assert "romanization" not in word_props + + +@pytest.mark.asyncio +async def test_enrich_vocabulary_guarantees_coverage_when_llm_omits(): + """Every jieba token appears with pinyin even if the LLM drops some words.""" + from app.lessons.services.vocabulary import enrich_vocabulary + + segments = [{"id": 0, "text": "我喜欢学习", "tokens": ["我", "喜欢", "学习"]}] + # LLM omits "喜欢" + content = _enrich_content({0: ["我", "学习"]}) + + with patch("app.lessons.services.vocabulary.httpx.AsyncClient") as mock_cls: + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client.post = AsyncMock(return_value=_make_mock_response(200, content)) + mock_cls.return_value = mock_client + + result = await enrich_vocabulary(segments, _FakeRomanizer(), "test_key") + + words = result[0] + assert [w["word"] for w in words] == ["我", "喜欢", "学习"] # all tokens present, in order + assert all(w["romanization"] == f"R:{w['word']}" for w in words) + omitted = words[1] + assert omitted["word"] == "喜欢" + assert omitted["meaning"] == "" # LLM omitted → blank, not vanished + assert words[0]["meaning"] == "m我" + assert words[2]["meaning"] == "m学习" + + +@pytest.mark.asyncio +async def test_enrich_vocabulary_empty_segments(): + from app.lessons.services.vocabulary import enrich_vocabulary + result = await enrich_vocabulary([], _FakeRomanizer(), "test_key") + assert result == {} diff --git a/backend/uv.lock b/backend/uv.lock index d34d6b33..8c821bb0 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1070,6 +1070,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/da/9657d637bcacdbaf6a914ce504000da5639f9d945f8d3552a940f021d6c0/jaconv-0.5.0-py3-none-any.whl", hash = "sha256:2914114fe761ca49fc7089e25e6ad4a400c26f262ffce84e13b176916b71610a", size = 16831, upload-time = "2026-02-08T11:15:55.322Z" }, ] +[[package]] +name = "jieba" +version = "0.42.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/cb/18eeb235f833b726522d7ebed54f2278ce28ba9438e3135ab0278d9792a2/jieba-0.42.1.tar.gz", hash = "sha256:055ca12f62674fafed09427f176506079bc135638a14e23e25be909131928db2", size = 19214172, upload-time = "2020-01-20T14:27:23.5Z" } + [[package]] name = "jiter" version = "0.13.0" @@ -2589,6 +2595,7 @@ dependencies = [ { name = "fastapi" }, { name = "ffmpeg-python" }, { name = "httpx" }, + { name = "jieba" }, { name = "livekit-api" }, { name = "openai" }, { name = "opencc-python-reimplemented" }, @@ -2620,6 +2627,7 @@ requires-dist = [ { name = "ffmpeg-python", specifier = ">=0.2.0" }, { name = "httpx", specifier = ">=0.28.0" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28.0" }, + { name = "jieba", specifier = ">=0.42.1" }, { name = "livekit-api", specifier = ">=1.1.0" }, { name = "openai", specifier = "==2.6.0" }, { name = "opencc-python-reimplemented", specifier = ">=0.1.7" }, diff --git a/docs/superpowers/plans/2026-05-29-continue-where-left-off.md b/docs/superpowers/plans/2026-05-29-continue-where-left-off.md new file mode 100644 index 00000000..795cff77 --- /dev/null +++ b/docs/superpowers/plans/2026-05-29-continue-where-left-off.md @@ -0,0 +1,610 @@ +# Continue Where Left Off — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a "Continue where left off" item to the Smart Study queue that resurfaces the most recently abandoned (<80% watched) grammar tip from Collection/Discover and links straight back to the exact page + timestamp. + +**Architecture:** Persist the video `title` and exact page `resumeRoute` onto each `TipProgress` record as the user watches. A new derivation in `useStudyQueue` scans all tip-progress records, picks the most recently abandoned one, and exposes it as `continueItem`. `DailyQueuePopup` renders one row (same shape as the existing Shadowing row) that navigates to the stored route; the tip page already seeks the player to `watchedSec`. + +**Tech Stack:** React 19, TypeScript, IndexedDB (`idb`), vitest + Testing Library + fake-indexeddb. + +**Spec:** `docs/superpowers/specs/2026-05-29-continue-where-left-off-design.md` + +--- + +## File Structure + +- `frontend/src/features/learning-materials/domain/tips.ts` — add optional `title` / `resumeRoute` to `TipProgress`. +- `frontend/src/db/index.ts` — add read-only `getAllTipProgress` accessor. +- `frontend/src/features/learning-materials/application/useTipProgress.ts` — persist `title`/`resumeRoute` in `recordPosition`; preserve them in `markComplete`/`markIncomplete`. +- `frontend/src/features/learning-materials/ui/TipCoursePage.tsx` — pass `{ title, route }` at the `recordPosition` call site. +- `frontend/src/features/study/application/useStudyQueue.ts` — `ContinueItem` type, derivation, `continueItem`/`continueDone` state, count integration. +- `frontend/src/features/study/ui/queue/DailyQueuePopup.tsx` — render the Continue row. +- `frontend/src/shared/lib/i18n.ts` — `queue.continue` key (en + vi). +- Tests (co-located `*.test.ts`): `useStudyQueue.test.ts` (extend), `useTipProgress.test.ts` (new). + +--- + +## Task 1: Extend `TipProgress` type + add `getAllTipProgress` accessor + +**Files:** +- Modify: `frontend/src/features/learning-materials/domain/tips.ts:32-44` +- Modify: `frontend/src/db/index.ts` (after `listTipProgressForCourse`, ~line 954) +- Test: `frontend/src/features/study/application/useStudyQueue.test.ts` (round-trip assertion added in Task 3; this task’s accessor is exercised there) + +- [ ] **Step 1: Add the two optional fields to `TipProgress`** + +In `frontend/src/features/learning-materials/domain/tips.ts`, change the interface: + +```ts +export interface TipProgress { + // Composite key: `${courseId}:${videoId}` to scope progress per course. + // A standalone video referenced by multiple discovery paths still uses + // its own course namespace. + key: string + courseId: string + videoId: string + watchedSec: number + totalSec: number + completed: boolean + completedAt: string | null + lastSeenAt: string + // Optional for backward compatibility: records written before the + // "continue where left off" feature lack these. New writes always set them. + title?: string // resolved video title, for the queue row label + resumeRoute?: string // exact page link, e.g. /tips/video/abc?lesson=abc +} +``` + +- [ ] **Step 2: Add the `getAllTipProgress` accessor** + +In `frontend/src/db/index.ts`, immediately after the existing `listTipProgressForCourse` function (~line 956): + +```ts +export async function getAllTipProgress(db: ShadowLearnDB): Promise { + return db.getAll('tip-progress') +} +``` + +`TipProgress` is already imported at the top of `db/index.ts` (line 4). The `tip-progress` store already exists — this is a pure read accessor, so **no `DB_VERSION` bump**. + +- [ ] **Step 3: Typecheck** + +Run: `cd frontend && npx tsc --noEmit` +Expected: PASS (no errors). + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/features/learning-materials/domain/tips.ts frontend/src/db/index.ts +git commit -m "feat: add optional title/resumeRoute to TipProgress and getAllTipProgress accessor" +``` + +--- + +## Task 2: Persist `title`/`resumeRoute` in `useTipProgress` + +**Files:** +- Modify: `frontend/src/features/learning-materials/application/useTipProgress.ts` +- Modify: `frontend/src/features/learning-materials/ui/TipCoursePage.tsx:191` +- Test: `frontend/src/features/learning-materials/application/useTipProgress.test.ts` (create) + +- [ ] **Step 1: Write the failing test** + +Create `frontend/src/features/learning-materials/application/useTipProgress.test.ts`: + +```ts +import type { ShadowLearnDB } from '@/db' +import { act, renderHook, waitFor } from '@testing-library/react' +import { IDBFactory } from 'fake-indexeddb' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { getTipProgress, initDB } from '@/db' +import 'fake-indexeddb/auto' + +let testDb: ShadowLearnDB + +// useTipProgress reads `db` from AuthContext; mock it to our test DB. +vi.mock('@/app/providers/AuthContext', () => ({ + useAuth: () => ({ db: testDb, keys: null }), +})) + +// Imported after the mock so the hook picks up the mocked AuthContext. +const { useTipProgress } = await import('@/features/learning-materials/application/useTipProgress') + +beforeEach(async () => { + testDb = await initDB() +}) + +afterEach(() => { + testDb.close() + globalThis.indexedDB = new IDBFactory() +}) + +describe('useTipProgress persistence', () => { + it('recordPosition persists title and resumeRoute', async () => { + const { result } = renderHook(() => useTipProgress('vid1', 'vid1')) + await waitFor(() => expect(result.current.loaded).toBe(true)) + await act(() => result.current.recordPosition(10, 100, { + title: 'Grammar 101', + route: '/tips/video/vid1?lesson=vid1', + })) + const saved = await getTipProgress(testDb, 'vid1:vid1') + expect(saved?.title).toBe('Grammar 101') + expect(saved?.resumeRoute).toBe('/tips/video/vid1?lesson=vid1') + }) + + it('markComplete preserves existing title and resumeRoute', async () => { + const { result } = renderHook(() => useTipProgress('vid1', 'vid1')) + await waitFor(() => expect(result.current.loaded).toBe(true)) + await act(() => result.current.recordPosition(10, 100, { + title: 'Grammar 101', + route: '/tips/video/vid1?lesson=vid1', + })) + await act(() => result.current.markComplete()) + const saved = await getTipProgress(testDb, 'vid1:vid1') + expect(saved?.completed).toBe(true) + expect(saved?.title).toBe('Grammar 101') + expect(saved?.resumeRoute).toBe('/tips/video/vid1?lesson=vid1') + }) +}) +``` + +Add the missing `vi` import at the top: change the vitest import line to +`import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd frontend && npx vitest run src/features/learning-materials/application/useTipProgress.test.ts` +Expected: FAIL — `recordPosition` does not yet accept a `meta` argument, so `saved.title` is `undefined`. + +- [ ] **Step 3: Extend the `recordPosition` signature and persist the fields** + +In `frontend/src/features/learning-materials/application/useTipProgress.ts`: + +Update the result type (lines 8-16): + +```ts +export interface UseTipProgressResult { + loaded: boolean + watchedSec: number + totalSec: number + completed: boolean + recordPosition: (watchedSec: number, totalSec: number, meta?: { title?: string, route?: string }) => Promise + markComplete: () => Promise + markIncomplete: () => Promise +} +``` + +Replace `recordPosition` (lines 45-59) with: + +```ts + const recordPosition = useCallback(async (watchedSec: number, totalSec: number, meta?: { title?: string, route?: string }) => { + const wasComplete = state.p?.completed ?? false + const shouldComplete = wasComplete || (totalSec > 0 && watchedSec / totalSec >= WATCHED_THRESHOLD) + const next: TipProgress = { + key, + courseId, + videoId, + watchedSec, + totalSec, + completed: shouldComplete, + completedAt: shouldComplete ? (state.p?.completedAt ?? new Date().toISOString()) : null, + lastSeenAt: new Date().toISOString(), + title: meta?.title ?? state.p?.title, + resumeRoute: meta?.route ?? state.p?.resumeRoute, + } + await writeState(next) + }, [state.p, key, courseId, videoId, writeState]) +``` + +In `markComplete` (lines 61-73), add the two preserved fields to the `next` object, right after `lastSeenAt`: + +```ts + lastSeenAt: new Date().toISOString(), + title: state.p?.title, + resumeRoute: state.p?.resumeRoute, +``` + +In `markIncomplete` (lines 75-87), add the same two lines after `lastSeenAt`: + +```ts + lastSeenAt: new Date().toISOString(), + title: state.p?.title, + resumeRoute: state.p?.resumeRoute, +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd frontend && npx vitest run src/features/learning-materials/application/useTipProgress.test.ts` +Expected: PASS (2 tests). + +- [ ] **Step 5: Pass title + route at the call site** + +In `frontend/src/features/learning-materials/ui/TipCoursePage.tsx`, replace the `onTimeUpdate` prop on `LessonPlayer` (line 191): + +```tsx + onTimeUpdate={(cur, dur) => { + void progress.recordPosition(cur, dur, { + title: activeLesson?.title, + route: `/tips/${safeSource}/${safeId}?lesson=${activeVideoId}`, + }) + }} +``` + +`safeSource`, `safeId`, `activeVideoId`, and `activeLesson` are already in scope (lines 27-28, 59-66). + +- [ ] **Step 6: Typecheck** + +Run: `cd frontend && npx tsc --noEmit` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add frontend/src/features/learning-materials/application/useTipProgress.ts frontend/src/features/learning-materials/application/useTipProgress.test.ts frontend/src/features/learning-materials/ui/TipCoursePage.tsx +git commit -m "feat: persist video title and resume route onto TipProgress" +``` + +--- + +## Task 3: Derive `continueItem` in `useStudyQueue` + +**Files:** +- Modify: `frontend/src/features/study/application/useStudyQueue.ts` +- Test: `frontend/src/features/study/application/useStudyQueue.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Add to `frontend/src/features/study/application/useStudyQueue.test.ts`. + +First, extend the imports (line 5) to include `putTipProgress`: + +```ts +import { initDB, putTipProgress, saveSpacedRepetitionItem, saveVocabEntry } from '@/db' +``` + +Add a helper near `makeSRItem` (after line 40): + +```ts +function makeTipProgress(over: Partial = {}) { + const courseId = over.courseId ?? 'vidA' + const videoId = over.videoId ?? 'vidA' + return { + key: `${courseId}:${videoId}`, + courseId, + videoId, + watchedSec: 30, + totalSec: 100, + completed: false, + completedAt: null, + lastSeenAt: '2026-05-13T09:00:00.000Z', + ...over, + } +} +``` + +Add this `describe` block at the end of the file (before the final closing of the outer `describe`, i.e. inside it as a sibling of the existing `it`s): + +```ts + it('continueItem null when no tip progress exists', async () => { + const { result } = renderHook(() => useStudyQueue(db, null)) + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.continueItem).toBeNull() + }) + + it('surfaces an abandoned tip (incomplete, watched > 0)', async () => { + await putTipProgress(db, makeTipProgress({ + title: 'Grammar 101', + resumeRoute: '/tips/video/vidA?lesson=vidA', + })) + const { result } = renderHook(() => useStudyQueue(db, null)) + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.continueItem).toEqual({ + title: 'Grammar 101', + route: '/tips/video/vidA?lesson=vidA', + }) + }) + + it('excludes completed tips', async () => { + await putTipProgress(db, makeTipProgress({ completed: true, completedAt: '2026-05-12T00:00:00.000Z' })) + const { result } = renderHook(() => useStudyQueue(db, null)) + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.continueItem).toBeNull() + }) + + it('excludes untouched tips (watchedSec === 0)', async () => { + await putTipProgress(db, makeTipProgress({ watchedSec: 0 })) + const { result } = renderHook(() => useStudyQueue(db, null)) + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.continueItem).toBeNull() + }) + + it('picks the most recent abandoned tip by lastSeenAt', async () => { + await putTipProgress(db, makeTipProgress({ + courseId: 'old', videoId: 'old', lastSeenAt: '2026-05-10T00:00:00.000Z', + title: 'Old', resumeRoute: '/tips/video/old?lesson=old', + })) + await putTipProgress(db, makeTipProgress({ + courseId: 'new', videoId: 'new', lastSeenAt: '2026-05-13T08:00:00.000Z', + title: 'New', resumeRoute: '/tips/video/new?lesson=new', + })) + const { result } = renderHook(() => useStudyQueue(db, null)) + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.continueItem?.title).toBe('New') + }) + + it('falls back to heuristic route and empty title for legacy records', async () => { + // Standalone video: courseId === videoId → /tips/video/ + await putTipProgress(db, makeTipProgress({ courseId: 'soloVid', videoId: 'soloVid' })) + const { result } = renderHook(() => useStudyQueue(db, null)) + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.continueItem).toEqual({ + title: '', + route: '/tips/video/soloVid', + }) + }) + + it('falls back to playlist heuristic route when courseId !== videoId', async () => { + await putTipProgress(db, makeTipProgress({ courseId: 'PL123', videoId: 'vidX' })) + const { result } = renderHook(() => useStudyQueue(db, null)) + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.continueItem?.route).toBe('/tips/playlist/PL123?lesson=vidX') + }) + + it('continueDone true when tip last seen today; folds out of incompleteCount', async () => { + await putTipProgress(db, makeTipProgress({ lastSeenAt: '2026-05-13T08:00:00.000Z' })) + const { result } = renderHook(() => useStudyQueue(db, null)) + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.continueDone).toBe(true) + expect(result.current.incompleteCount).toBe(0) + }) + + it('continueDone false when tip last seen before today; counts as incomplete', async () => { + await putTipProgress(db, makeTipProgress({ lastSeenAt: '2026-05-12T08:00:00.000Z' })) + const { result } = renderHook(() => useStudyQueue(db, null)) + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.continueDone).toBe(false) + expect(result.current.incompleteCount).toBe(1) + }) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd frontend && npx vitest run src/features/study/application/useStudyQueue.test.ts` +Expected: FAIL — `continueItem` / `continueDone` are not on the returned object (TS error or `undefined`). + +- [ ] **Step 3: Implement the derivation** + +In `frontend/src/features/study/application/useStudyQueue.ts`: + +Add imports. Change the `@/db` import block (lines 4-11) to include `getAllTipProgress`: + +```ts +import { + deleteDailyTask, + getAllSessionLogs, + getAllTipProgress, + getDailyTasks, + getDueItems, + getVocabEntryById, + saveDailyTask, +} from '@/db' +``` + +Add a type import for `TipProgress` near the top type imports (after line 2): + +```ts +import type { TipProgress } from '@/features/learning-materials/domain/tips' +``` + +Add the `ContinueItem` interface and extend `StudyQueueState` (add the two fields after `shadowingDone` on line 38): + +```ts +export interface ContinueItem { + title: string + route: string +} +``` + +```ts + shadowingDone: boolean + continueItem: ContinueItem | null + continueDone: boolean +``` + +Add a module-level helper (after `MAX_WORDS` on line 21): + +```ts +function tipFallbackRoute(t: TipProgress): string { + return t.courseId === t.videoId + ? `/tips/video/${t.courseId}` + : `/tips/playlist/${t.courseId}?lesson=${t.videoId}` +} +``` + +Add state (after the `shadowingDone` state on line 56): + +```ts + const [continueItem, setContinueItem] = useState(null) + const [continueDone, setContinueDone] = useState(false) +``` + +In `load`, after the Custom tasks block (`setCustomTasks(await getDailyTasks(db))`, line 120), insert: + +```ts + // ── Continue where left off (most recent abandoned grammar tip) ───────── + const tips = await getAllTipProgress(db) + const abandoned = tips + .filter(t => !t.completed && t.watchedSec > 0) + .sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt))[0] + if (abandoned) { + setContinueItem({ + title: abandoned.title ?? '', + route: abandoned.resumeRoute ?? tipFallbackRoute(abandoned), + }) + setContinueDone(abandoned.lastSeenAt.slice(0, 10) === today) + } + else { + setContinueItem(null) + setContinueDone(false) + } +``` + +Extend `incompleteCount` (lines 190-193) to add the continue term: + +```ts + const incompleteCount + = (hasDailyReview && !dailyReviewDone ? 1 : 0) + + (hasLesson && !shadowingDone ? 1 : 0) + + (continueItem && !continueDone ? 1 : 0) + + customTasks.filter(t => t.completedDate !== today).length +``` + +Add both fields to the returned object (after `shadowingDone,` on line 212): + +```ts + shadowingDone, + continueItem, + continueDone, +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd frontend && npx vitest run src/features/study/application/useStudyQueue.test.ts` +Expected: PASS (all existing + 9 new tests). + +- [ ] **Step 5: Typecheck** + +Run: `cd frontend && npx tsc --noEmit` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add frontend/src/features/study/application/useStudyQueue.ts frontend/src/features/study/application/useStudyQueue.test.ts +git commit -m "feat: derive continueItem (abandoned tip) in useStudyQueue" +``` + +--- + +## Task 4: Render the Continue row + i18n + +**Files:** +- Modify: `frontend/src/shared/lib/i18n.ts:727` (en) and `:1652` (vi) +- Modify: `frontend/src/features/study/ui/queue/DailyQueuePopup.tsx` + +- [ ] **Step 1: Add the i18n key (en)** + +In `frontend/src/shared/lib/i18n.ts`, after the en `'queue.shadowing'` line (727): + +```ts + 'queue.continue': 'Continue where you left off', +``` + +- [ ] **Step 2: Add the i18n key (vi)** + +After the vi `'queue.shadowing'` line (1652): + +```ts + 'queue.continue': 'Tiếp tục bài đang học dở', +``` + +- [ ] **Step 3: Add the Continue row to the popup** + +In `frontend/src/features/study/ui/queue/DailyQueuePopup.tsx`: + +Extend `hasAnyContent` (line 39) to include the continue item: + +```ts + const hasAnyContent = queue.hasDailyReview || !!mostRecentLesson || queue.customTasks.length > 0 || !!queue.continueItem +``` + +Add a navigation handler next to `handleStartShadowing` (after line 50): + +```ts + function handleContinue() { + if (!queue.continueItem) + return + onClose() + navigate(queue.continueItem.route) + } +``` + +Insert the row immediately after the Shadowing block (after its closing `)}` on line 207, before the Custom tasks `{queue.customTasks.map(...)}` on line 210): + +```tsx + {/* Continue where left off */} + {queue.continueItem && ( +
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleContinue() } }} + > + + + {queue.continueItem.title || t('queue.continue')} + + {queue.continueDone + ? ( + + ) + : } +
+ )} +``` + +`CircleIndicator`, `StartButton`, `Check`, `Button`, `cn`, and `t` are already imported/defined in this file. + +- [ ] **Step 4: Typecheck + lint** + +Run: `cd frontend && npx tsc --noEmit && npx eslint src/features/study/ui/queue/DailyQueuePopup.tsx src/shared/lib/i18n.ts` +Expected: PASS (no errors). + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/shared/lib/i18n.ts frontend/src/features/study/ui/queue/DailyQueuePopup.tsx +git commit -m "feat: render Continue where left off row in study queue" +``` + +--- + +## Task 5: Full verification + +- [ ] **Step 1: Run the full frontend test suite** + +Run: `cd frontend && npx vitest run` +Expected: PASS (all green, including the new useTipProgress and useStudyQueue tests). + +- [ ] **Step 2: Typecheck the whole project** + +Run: `cd frontend && npx tsc --noEmit` +Expected: PASS. + +- [ ] **Step 3: Lint changed files** + +Run: `cd frontend && npx eslint src/features/learning-materials/application/useTipProgress.ts src/features/learning-materials/ui/TipCoursePage.tsx src/features/study/application/useStudyQueue.ts src/features/study/ui/queue/DailyQueuePopup.tsx src/db/index.ts src/features/learning-materials/domain/tips.ts` +Expected: PASS. + +- [ ] **Step 4: Manual smoke (optional, requires running app)** + +1. Open a Collection tip (either tab), watch a few seconds (<80%), navigate away. +2. Open the Smart Study queue popup — a "Continue where you left off" row appears showing the tip title. +3. Click it — lands on the tip page; the player resumes at the watched second. +4. Watch past 80% — on next queue refresh the row disappears. + +--- + +## Self-Review notes + +- **Spec coverage:** title/resumeRoute persistence (Tasks 1-2), both-tabs coverage via shared TipProgress (Task 2 call site is the single watch path), 80% abandoned filter (Task 3 `!completed && watchedSec>0`), most-recent-by-lastSeenAt (Task 3), heuristic + generic-label fallbacks (Task 3), row + done semantics (Task 4), count integration (Task 3). All covered. +- **No DB_VERSION bump:** confirmed — optional value fields + read accessor only. +- **Type consistency:** `recordPosition(…, meta?: { title?, route? })`, `TipProgress.title?`/`resumeRoute?`, `ContinueItem { title, route }`, `continueItem`/`continueDone` used identically across hook, tests, and popup. diff --git a/docs/superpowers/specs/2026-05-29-continue-where-left-off-design.md b/docs/superpowers/specs/2026-05-29-continue-where-left-off-design.md new file mode 100644 index 00000000..56f43343 --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-continue-where-left-off-design.md @@ -0,0 +1,198 @@ +# Continue Where Left Off — Smart Study Item + +**Date:** 2026-05-29 +**Status:** Approved design, pending implementation plan + +## Goal + +Add a "Continue where left off" item to the Smart Study queue that resurfaces the +most recently **abandoned grammar tip** from Collection/Discover, nudging the user to +finish it. One row, the single most relevant tip. + +## Scope decisions (resolved during brainstorming) + +- **Tips only — not own-lessons.** The user's own YouTube lessons (LessonView + AI + Companion) are already surfaced by the existing **Shadowing** row. Adding a second + row for the same lesson would be redundant and confusing. This feature targets the + grammar **tips**, which currently have no presence in the Smart Study queue. +- **Both Collection tabs covered.** Tips appear in two tabs — **Mẹo học tập** (Tips) + and **Tài liệu của tôi** (My materials). Both open through `TipCoursePage` + (`/tips/:source/:id`) and write to the same `TipProgress` store, so a single scan of + that store covers both with no per-tab logic. + +## Definition of "abandoned" (incomplete) + +A tip is abandoned when started but `<80%` watched: `!completed && watchedSec > 0`. +`TipProgress.completed` already auto-flips at 80% (`WATCHED_THRESHOLD = 0.8` in +`useTipProgress.ts`). No new threshold logic needed. + +## What already exists (no work) + +- `tip-progress` IDB store (`db/index.ts:245`). +- `useTipProgress.recordPosition` runs on every video time tick, writing + `watchedSec`/`totalSec`/`lastSeenAt` and auto-completing at 80% + (`useTipProgress.ts:45-59`). +- Resume route `/tips/{source}/{id}?lesson={videoId}` already seeks the tip player to + the exact timestamp: `LessonPlayer` sets YouTube `playerVars.start = resumeSec` + (`LessonPlayer.tsx:59`), fed `resumeSec={progress.watchedSec}` (`TipCoursePage.tsx:190`). + So `watchedSec` (already persisted) is the source of truth for the resume point — no + timestamp URL param is needed. +- The video title and the page route are both known on the frontend at watch time + (`TipCoursePage`: `activeLesson.title`, `safeSource`, `safeId`, `activeVideoId`). + +## Persisting title + resume link onto TipProgress + +The title and the page link exist on the frontend at watch time but are never written +to IDB, so the queue scan can't see them. Fix: persist them when recording progress. + +- Extend the `TipProgress` value with two optional fields: + + ```ts + interface TipProgress { + // ...existing fields... + title?: string // resolved video title, for the queue row label + resumeRoute?: string // exact page link, e.g. /tips/video/abc?lesson=abc + } + ``` + + These are optional fields on an existing object store. IndexedDB stores values + schemalessly (only keyPath/indexes require migration), so **no DB_VERSION bump**. + They are optional purely for **backward compatibility**: every record written after + this change always populates both — only pre-existing records lack them, handled by + the fallbacks below. No migration; fallbacks are ~2 lines. + +- Extend `recordPosition` to accept and persist them: + + ```ts + recordPosition: (watchedSec: number, totalSec: number, + meta?: { title?: string, route?: string }) => Promise + ``` + + At the call site (`TipCoursePage.tsx:191`), pass + `{ title: activeLesson?.title, route: \`/tips/${safeSource}/${safeId}?lesson=${activeVideoId}\` }`. + `markComplete`/`markIncomplete` preserve any existing `title`/`resumeRoute` on the + record. + + Clicking the queue item navigates to `resumeRoute`; the tip page then seeks the + player to `watchedSec` automatically. Exact page + exact timestamp. + + **Tradeoff:** storing the link denormalizes the route. If the `/tips` route scheme + changes later, legacy records' links go stale — recovered by the heuristic fallback + below and overwritten on the next watch. Low risk, accepted. + +## Architecture + +Selection logic lives in `useStudyQueue` (already async). It returns one new field; +the popup renders a row. + +### New state field + +```ts +interface ContinueItem { + title: string // stored TipProgress.title, or generic i18n fallback + route: string // ready-to-navigate + lastSeenAt: string // ISO; for done-check +} + +// added to StudyQueueState +continueItem: ContinueItem | null +``` + +### Selection logic (in `useStudyQueue.load`) + +1. Scan all tips via new `getAllTipProgress(db)`. +2. Filter `watchedSec > 0 && (!completed || completedAt is today)` — keep unfinished + tips AND tips completed today (so a just-finished tip stays visible as done for the + rest of the day instead of vanishing the moment it crosses 80%). +3. Pick the most recent by `lastSeenAt`. +4. Build the row: + - **label** = persisted `TipProgress.title` if present, else the registered + material name (`getUserMaterialByExternalId(courseId)?.name` — recovers titles + for tips watched before title-persistence shipped, for user-registered "My + materials"), else generic i18n (`queue.continue`). + - **route** = `resumeRoute` if present, else fallback heuristic on + `courseId`/`videoId`: `courseId === videoId` → `/tips/video/{courseId}`, else + `/tips/playlist/{courseId}?lesson={videoId}`. (Holds because a standalone video's + `courseId` equals its `videoId`, while a playlist's `courseId` is the playlist id.) +5. `continueItem = null` if no tip qualifies. + +### New DB accessor (read-only, no DB_VERSION bump) + +```ts +export async function getAllTipProgress(db: ShadowLearnDB): Promise { + return db.getAll('tip-progress') +} +``` + +The `tip-progress` store already exists; a pure read accessor needs no migration. + +## Rendering — DailyQueuePopup + +New row after the Shadowing row, identical in shape (`DailyQueuePopup.tsx:183-207`): + +- `CircleIndicator` + label + `StartButton`. +- Click → `navigate(continueItem.route)` then `onClose()`. +- Hidden when `continueItem == null`. + +**Done semantics** — `continueDone = candidate.completed`. The row strikes through +once the tip is completed (≥80%). A tip completed today stays shown (clickable, marked +done) for the rest of the day; tomorrow it drops out and the next unfinished tip +surfaces. A partial (<80%) watch keeps it shown as still-to-continue. The queue +re-reads IDB on popup open (`App.tsx` `toggleQueue`), so completing a tip on its page +is reflected when the popup is next opened — no reload needed. + +## Count integration + +Fold into existing derivations (`useStudyQueue.ts:190-198`), matching the +`hasLesson && !shadowingDone` pattern: + +```ts +const incompleteCount + = (hasDailyReview && !dailyReviewDone ? 1 : 0) + + (hasLesson && !shadowingDone ? 1 : 0) + + (continueItem && !continueDone ? 1 : 0) + + customTasks.filter(t => t.completedDate !== today).length +``` + +## Edge cases + +- Legacy `TipProgress` lacking `title`: show generic i18n label. +- Legacy `TipProgress` lacking `resumeRoute`: route via the `courseId === videoId` + heuristic. +- No abandoned tip: `continueItem = null`, row hidden. +- `totalSec === 0` guards already prevent a record from being mis-flagged (recordPosition + only completes when `totalSec > 0`); the `watchedSec > 0` filter excludes untouched tips. + +## Testing (`tests/`, vitest + fake-indexeddb) + +`useStudyQueue` carries logic, so it needs coverage: + +- Tip `!completed && watchedSec > 0` → surfaces; completed tip → excluded; untouched + (`watchedSec === 0`) → excluded. +- Multiple abandoned tips → most recent `lastSeenAt` wins. +- Stored `resumeRoute`/`title` used for route/label; legacy record (no route/title) → + heuristic route + generic label. +- `continueDone` (tip completed) folds into `incompleteCount`; a tip completed today + stays shown as done, a tip completed before today is excluded. + +## Out of scope + +- No own-lesson candidate (Shadowing row covers it). +- No new progress tracking (recordPosition already runs). +- No new routes (resume routes exist). +- No changes to the Shadowing row. + +## Files touched + +- `frontend/src/features/learning-materials/domain/tips.ts` — add optional + `title`/`resumeRoute` to `TipProgress`. +- `frontend/src/features/learning-materials/application/useTipProgress.ts` — persist + `title`/`resumeRoute` in `recordPosition`; preserve in `markComplete`/`markIncomplete`. +- `frontend/src/features/learning-materials/ui/TipCoursePage.tsx` — pass + `{ title, route }` to `recordPosition`. +- `frontend/src/db/index.ts` — add `getAllTipProgress` accessor (read-only). +- `frontend/src/features/study/application/useStudyQueue.ts` — derivation, new field, + count. +- `frontend/src/features/study/ui/queue/DailyQueuePopup.tsx` — new row. +- i18n locale files — `queue.continue` key. +- `frontend/tests/` — `useStudyQueue` continue-item tests. diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 2a8602b1..d11ca42f 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -117,12 +117,16 @@ function FloatingDock() { } function toggleQueue() { - setOpen((o) => { - const next = !o - if (next) - closePanel() - return next - }) + const next = !open + setOpen(next) + if (next) { + closePanel() + // Re-read IDB on open so progress made on lesson/tip pages + // (watch position, completion) is reflected without a reload. + // Side effects must live outside the setState updater (which runs + // during render) to avoid setState-in-render on StudyQueueProvider. + void queue.refresh() + } } if (location.pathname.startsWith('/lesson/') || location.pathname.startsWith('/tips/')) diff --git a/frontend/src/app/Layout.tsx b/frontend/src/app/Layout.tsx index 98d30fa3..80af6a97 100644 --- a/frontend/src/app/Layout.tsx +++ b/frontend/src/app/Layout.tsx @@ -107,6 +107,7 @@ export function Layout({ children }: LayoutProps) {
- ) + if (onSeek) { + const decoded = typeof href === 'string' ? decodeURIComponent(href) : '' + if (decoded.startsWith(TIMESTAMP_HASH_PREFIX)) { + const sec = Number.parseInt(decoded.slice(TIMESTAMP_HASH_PREFIX.length), 10) + if (Number.isFinite(sec)) { + return ( + + ) + } } } + const videoId = typeof href === 'string' ? extractYouTubeVideoId(href) : null + if (href && videoId) + return {children} return {children} }, } @@ -53,22 +62,12 @@ interface MessageMarkdownProps { } export const MessageMarkdown = memo(({ text, onTimestampClick }: MessageMarkdownProps) => { - const components = useMemo( - () => (onTimestampClick ? makeTimestampComponents(onTimestampClick) : undefined), - [onTimestampClick], - ) - if (onTimestampClick) { - return ( -
- - {linkifyTimestamps(text)} - -
- ) - } + const components = useMemo(() => makeLinkComponents(onTimestampClick), [onTimestampClick]) return (
- {text} + + {onTimestampClick ? linkifyTimestamps(text) : text} +
) }) diff --git a/frontend/src/features/agent/ui/chat/RecommendedVideoLink.tsx b/frontend/src/features/agent/ui/chat/RecommendedVideoLink.tsx new file mode 100644 index 00000000..66b45f63 --- /dev/null +++ b/frontend/src/features/agent/ui/chat/RecommendedVideoLink.tsx @@ -0,0 +1,65 @@ +import type { PropsWithChildren } from 'react' +import { useEffect, useState } from 'react' +import { API_BASE } from '@/shared/lib/config' + +interface ResolveResponse { + status: 'video' | 'playlist' + video_id: string + playlist_id?: string +} + +// videoId -> internal tip path, or null when the backend says it isn't curated (404). +// Successful + 404 results are cached for the session; transient failures (503 / network) +// delete their entry so a later render retries instead of sticking on the external link. +const cache = new Map>() + +function resolveTipPath(videoId: string): Promise { + const cached = cache.get(videoId) + if (cached) + return cached + + const promise = fetch(`${API_BASE}/api/collection/resolve/${videoId}`) + .then((res) => { + if (res.status === 404) + return null // definitively not curated -> keep external + if (!res.ok) + throw new Error(`resolve failed: ${res.status}`) // 503/etc -> transient, retry later + return res.json() as Promise + }) + .then((data) => { + if (!data) + return null + return data.status === 'playlist' + ? `/tips/playlist/${data.playlist_id}?lesson=${videoId}` + : `/tips/video/${videoId}` + }) + .catch((err) => { + cache.delete(videoId) + throw err + }) + + cache.set(videoId, promise) + return promise +} + +/** + * A recommended YouTube link in chat. Resolves the video to its internal tip route + * (curated playlist or standalone) and opens it in a new tab; falls back to the + * original external YouTube URL while pending or when the video isn't curated. + */ +export function RecommendedVideoLink({ href, videoId, children }: PropsWithChildren<{ href: string, videoId: string }>) { + const [resolvedHref, setResolvedHref] = useState(href) + + useEffect(() => { + let active = true + resolveTipPath(videoId) + .then((tipPath) => { + if (active && tipPath) + setResolvedHref(tipPath) + }) + .catch(() => { /* keep external href */ }) + return () => { active = false } + }, [videoId]) + + return {children} +} diff --git a/frontend/src/features/learning-materials/application/useTipProgress.test.ts b/frontend/src/features/learning-materials/application/useTipProgress.test.ts new file mode 100644 index 00000000..7bd49c7c --- /dev/null +++ b/frontend/src/features/learning-materials/application/useTipProgress.test.ts @@ -0,0 +1,67 @@ +import type { ShadowLearnDB } from '@/db' +import { act, renderHook, waitFor } from '@testing-library/react' +import { IDBFactory } from 'fake-indexeddb' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getTipProgress, initDB } from '@/db' +import 'fake-indexeddb/auto' + +let testDb: ShadowLearnDB + +// useTipProgress reads `db` from AuthContext; mock it to our test DB. +vi.mock('@/app/providers/AuthContext', () => ({ + useAuth: () => ({ db: testDb, keys: null }), +})) + +// Imported after the mock so the hook picks up the mocked AuthContext. +const { useTipProgress } = await import('@/features/learning-materials/application/useTipProgress') + +beforeEach(async () => { + testDb = await initDB() +}) + +afterEach(() => { + testDb.close() + globalThis.indexedDB = new IDBFactory() +}) + +describe('useTipProgress persistence', () => { + it('recordPosition persists title and resumeRoute', async () => { + const { result } = renderHook(() => useTipProgress('vid1', 'vid1')) + await waitFor(() => expect(result.current.loaded).toBe(true)) + await act(() => result.current.recordPosition(10, 100, { + title: 'Grammar 101', + route: '/tips/video/vid1?lesson=vid1', + })) + const saved = await getTipProgress(testDb, 'vid1:vid1') + expect(saved?.title).toBe('Grammar 101') + expect(saved?.resumeRoute).toBe('/tips/video/vid1?lesson=vid1') + }) + + it('markComplete preserves existing title and resumeRoute', async () => { + const { result } = renderHook(() => useTipProgress('vid1', 'vid1')) + await waitFor(() => expect(result.current.loaded).toBe(true)) + await act(() => result.current.recordPosition(10, 100, { + title: 'Grammar 101', + route: '/tips/video/vid1?lesson=vid1', + })) + await act(() => result.current.markComplete()) + const saved = await getTipProgress(testDb, 'vid1:vid1') + expect(saved?.completed).toBe(true) + expect(saved?.title).toBe('Grammar 101') + expect(saved?.resumeRoute).toBe('/tips/video/vid1?lesson=vid1') + }) + + it('markIncomplete preserves existing title and resumeRoute', async () => { + const { result } = renderHook(() => useTipProgress('vid1', 'vid1')) + await waitFor(() => expect(result.current.loaded).toBe(true)) + await act(() => result.current.recordPosition(10, 100, { + title: 'Grammar 101', + route: '/tips/video/vid1?lesson=vid1', + })) + await act(() => result.current.markIncomplete()) + const saved = await getTipProgress(testDb, 'vid1:vid1') + expect(saved?.completed).toBe(false) + expect(saved?.title).toBe('Grammar 101') + expect(saved?.resumeRoute).toBe('/tips/video/vid1?lesson=vid1') + }) +}) diff --git a/frontend/src/features/learning-materials/application/useTipProgress.ts b/frontend/src/features/learning-materials/application/useTipProgress.ts index 60438710..611d0c06 100644 --- a/frontend/src/features/learning-materials/application/useTipProgress.ts +++ b/frontend/src/features/learning-materials/application/useTipProgress.ts @@ -10,7 +10,7 @@ export interface UseTipProgressResult { watchedSec: number totalSec: number completed: boolean - recordPosition: (watchedSec: number, totalSec: number) => Promise + recordPosition: (watchedSec: number, totalSec: number, meta?: { title?: string, route?: string }) => Promise markComplete: () => Promise markIncomplete: () => Promise } @@ -42,7 +42,7 @@ export function useTipProgress(courseId: string, videoId: string): UseTipProgres await putTipProgress(db, next) }, [db]) - const recordPosition = useCallback(async (watchedSec: number, totalSec: number) => { + const recordPosition = useCallback(async (watchedSec: number, totalSec: number, meta?: { title?: string, route?: string }) => { const wasComplete = state.p?.completed ?? false const shouldComplete = wasComplete || (totalSec > 0 && watchedSec / totalSec >= WATCHED_THRESHOLD) const next: TipProgress = { @@ -54,6 +54,8 @@ export function useTipProgress(courseId: string, videoId: string): UseTipProgres completed: shouldComplete, completedAt: shouldComplete ? (state.p?.completedAt ?? new Date().toISOString()) : null, lastSeenAt: new Date().toISOString(), + title: meta?.title ?? state.p?.title, + resumeRoute: meta?.route ?? state.p?.resumeRoute, } await writeState(next) }, [state.p, key, courseId, videoId, writeState]) @@ -68,6 +70,8 @@ export function useTipProgress(courseId: string, videoId: string): UseTipProgres completed: true, completedAt: state.p?.completedAt ?? new Date().toISOString(), lastSeenAt: new Date().toISOString(), + title: state.p?.title, + resumeRoute: state.p?.resumeRoute, } await writeState(next) }, [state.p, key, courseId, videoId, writeState]) @@ -82,6 +86,8 @@ export function useTipProgress(courseId: string, videoId: string): UseTipProgres completed: false, completedAt: null, lastSeenAt: new Date().toISOString(), + title: state.p?.title, + resumeRoute: state.p?.resumeRoute, } await writeState(next) }, [state.p, key, courseId, videoId, writeState]) diff --git a/frontend/src/features/learning-materials/domain/tips.ts b/frontend/src/features/learning-materials/domain/tips.ts index 2ba8ce85..1336af0b 100644 --- a/frontend/src/features/learning-materials/domain/tips.ts +++ b/frontend/src/features/learning-materials/domain/tips.ts @@ -41,6 +41,10 @@ export interface TipProgress { completed: boolean completedAt: string | null lastSeenAt: string + // Optional for backward compatibility: records written before the + // "continue where left off" feature lack these. New writes always set them. + title?: string // resolved video title, for the queue row label + resumeRoute?: string // exact page link, e.g. /tips/video/abc?lesson=abc } export type TipTranscriptStatus = 'pending' | 'ready' | 'unavailable' | 'error' | 'too_long' diff --git a/frontend/src/features/learning-materials/ui/TipCoursePage.tsx b/frontend/src/features/learning-materials/ui/TipCoursePage.tsx index b2f8b7a0..c006a43f 100644 --- a/frontend/src/features/learning-materials/ui/TipCoursePage.tsx +++ b/frontend/src/features/learning-materials/ui/TipCoursePage.tsx @@ -188,7 +188,12 @@ export function TipCoursePage() { key={activeVideoId} videoId={activeVideoId} resumeSec={progress.watchedSec || undefined} - onTimeUpdate={(cur, dur) => { void progress.recordPosition(cur, dur) }} + onTimeUpdate={(cur, dur) => { + void progress.recordPosition(cur, dur, { + title: activeLesson?.title, + route: `/tips/${safeSource}/${safeId}?lesson=${activeVideoId}`, + }) + }} onEnded={() => { void progress.markComplete() }} />
diff --git a/frontend/src/features/learning-materials/ui/tips/CourseSidebar.tsx b/frontend/src/features/learning-materials/ui/tips/CourseSidebar.tsx index b0169cfa..eb0a16b1 100644 --- a/frontend/src/features/learning-materials/ui/tips/CourseSidebar.tsx +++ b/frontend/src/features/learning-materials/ui/tips/CourseSidebar.tsx @@ -1,5 +1,6 @@ import type { TipLesson } from '@/features/learning-materials/domain/tips' import { ChevronLeft } from 'lucide-react' +import { useEffect, useRef } from 'react' import { useNavigate } from 'react-router-dom' import { useI18n } from '@/app/providers/I18nContext' import { LessonRow } from './LessonRow' @@ -34,6 +35,13 @@ export function CourseSidebar({ courseName, lessons, activeVideoId, completedVid navigate('/collection') } + // Bring the active lesson into view when arriving via ?lesson= (it may be far + // down a long playlist). `block: 'nearest'` is a no-op when already visible. + const listRef = useRef(null) + useEffect(() => { + listRef.current?.querySelector('[aria-current="true"]')?.scrollIntoView({ block: 'nearest' }) + }, [activeVideoId, lessons.length]) + return (