The book reader's word lookup used DictionaryService, a verb-conjugation
index plus ~200 hand-typed words: ordinary nouns like "taza" returned
nothing, and homographs always lost (tapping "como" in "como siempre"
gave the verb "comer" because the verb index is checked first).
Add a glossary phase to the books pipeline (build_glossary.py): every
distinct Spanish word is translated once, in its sentence context, by
the same Claude-Code-subagent LLM step the pipeline already uses for
chapter translation. English front matter is excluded by an ES==EN
paragraph-ratio heuristic. The glossary is bundled into book_<slug>.json
and is now part of the pipeline for every book.
In the app, Book carries the decoded glossary and BookReaderView resolves
each tap automatically through cache -> glossary -> DictionaryService ->
on-device LLM, citing which source answered so a curated glossary hit
reads differently from a best-effort AI guess.
book_olly-vol2.json regenerated with a 3,658-word glossary.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The widget extension opened the shared local SwiftData store with a
7-entity schema while the app's store has 10. SwiftData treats the
smaller schema as a migration and destructively drops the unlisted
tables — so every widget refresh deleted the bundled Book/BookChapter
rows (and DownloadedVideo), which is why books vanished after reinstalls.
Introduce SharedStore.localSchemaModels as the single source of truth
for the local schema and build the app and both widget containers from
it, so app and widget can no longer drift apart. The same class of bug
hit TextbookChapter previously; a shared list prevents a third recurrence.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New "Books" row in the Practice tab opens a library of bundled bilingual
books. Each chapter renders Spanish paragraph-by-paragraph; tap any
word for a definition sheet (DictionaryService with on-device AI
fallback), or toggle the toolbar button to swap to the pre-computed
English translation inline.
Local-only Book + BookChapter SwiftData models added to the local
container schema (reset version bumped to 5). DataLoader.seedBooks
walks the bundle for `book_*.json` resources, so future books drop in
without touching app code — just bundle a new JSON and bump
bookDataVersion.
First book: Olly Richards' "Spanish Short Stories For Beginners
Vol 2" — 13 chapters, 2,646 paragraphs, bilingual.
Scripts/books/ is the repeatable pipeline for future EPUBs:
extract_epub.py → translate_chapters.py (per-chapter resumable jobs) →
bundle_book.py. Translation is done by parallel Claude Code subagents
reading per-job input files and writing output files — no API key
required, matching the pattern used for the textbook vocab vision
pass. See Scripts/books/README.md for the full how-to.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-week "Extra Study (N)" row appears at the end of each LanGo week
section when at least one card is marked. Cards are marked from inside
VocabFlashcardView via a star next to the speaker on reveal. Marks are
keyed by the same SHA256 hash CourseReviewCard uses, so a mark and its
SRS state describe the same logical card.
ExtraStudyMark is CloudKit-synced (private DB), with uniqueness enforced
by fetch-or-create on id since CloudKit forbids @Attribute(.unique).
Skipped for textbook courses: DeckStudyView nils out the mark context
when the deck's courseName matches a TextbookChapter, and CourseView
hides the row when the active course is a textbook — so there are no
orphan marks the user can't reach.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The eligibility filter required every form's regularity tag to equal
"regular", but the data uses four labels:
- regular (179 forms — curated paradigm verbs)
- ordinary (50,992 forms — pattern-following verbs like hablar, comer)
- irregular (8,653)
- orto (176 — orthographic spelling changes like busqué)
Result was a 27-combo eligible pool, ~26 of which were -ir verbs in
present tense — every Full Table prompt landed on the same handful of
verbs.
Pulled the rule into a SharedModels function (FullTableEligibility) so
it's testable in isolation. Accepts "regular" + "ordinary" (both mean
"follows the pattern"); rejects "irregular" and "orto". 9 unit tests
cover the matrix including edge cases (incomplete forms, mixed labels,
unknown values).
PracticeSessionService.makePromptIfFullyRegular now delegates to
FullTableEligibility, sorting forms by personIndex so the regularity
array lines up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each of the 56 tense guides and grammar notes gets a curated YouTube video
attached (54 with picks, 2 silent nulls on rare / hard-to-find topics).
Users can stream in YouTube/Safari, download via YouTubeKit for offline
viewing, or play the local MP4 full-screen via AVPlayer.
YouTubeVideoStore loads the bundled youtube_videos.json at launch and serves
lookups by tense id or grammar note id. VideoDownloadService resolves the
best progressive MP4 stream off the main actor (YouTubeKit isn't Sendable),
writes to documents/videos/<videoId>.mp4, and records a DownloadedVideo row
in the local SwiftData container so the app knows what's on disk across
launches.
VideoActionsButtonRow is the unified UI for both detail views: three large
buttons — Stream (red, always enabled), Download (blue, disabled while in
flight and after completion, shows progress), Play (green, enabled only
when downloaded). Full-screen cover on tap. Settings gains a Downloaded
Videos list with swipe-delete, total-size summary, and a 500 MB warning.
Local store reset version bumped to 4 for the new DownloadedVideo schema.
Known fragility: YouTubeKit scrapes YouTube's private stream API and will
break when YouTube changes their internal format. Streaming keeps working
regardless.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundles the 100 most common reflexive verbs from spanishwithdaniel.com as a
canonical list and wires it through the UI. Compound list entries (recibirse
/ graduarse, equivocarse / confundirse) are split. Trailing prepositions and
set-phrase completions are captured as usageHint (e.g. acordarse "de",
ponerse "de acuerdo").
ReflexiveVerbStore loads the JSON at launch and exposes lookups by base
infinitive, both via @Environment for SwiftUI and a static shared instance
for services. Verbs whose bare infinitive isn't in the list skip the UI
treatment silently.
VerbDetailView shows a new Reflexive section with the reflexive infinitive,
usage hint, and English meaning when there is a match. VerbListView gains a
"Reflexive verbs only" filter alongside the existing Level and Irregularity
filters. Settings adds the same flag so it also constrains the practice
pool; PracticeSessionService applies the reflexive filter in all six pick
paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tapping a verb now shows six example sentences beneath the conjugation table,
one per core tense (Present, Preterite, Imperfect, Future, Present Subjunctive,
Imperative). Each example renders the tense label, Spanish sentence, and
English translation in the DeckStudyView style.
Generation uses Foundation Models with a @Generable schema that pins each
response to the requested tenseId and forces tú/nosotros subjects for
imperatives. Results are cached as JSON in the Caches directory keyed by
verb id (DictionaryService pattern); cache misses regenerate on demand.
Devices without Apple Intelligence see an inline notice instead of the
loading state. No network dependency.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UserProgress gains selectedLevelsBlob and enabledIrregularCategoriesBlob
(mirrors the existing tense-blob pattern). The multi-level setter keeps the
legacy selectedLevel String in sync with the highest-ranked selection, so
widget sync, AI scenarios, and achievement checks keep working unchanged.
Legacy single-level users are migrated on first read.
Settings replaces the level Picker with per-level toggles and adds an
Irregular Types section with three toggles. Practice pool is the literal
intersection: empty levels means zero results, empty irregular categories
means no irregularity constraint.
Pure filter logic lives in SharedModels (PracticeFilter, VerbLevel.highest)
and is covered by 20 Swift Testing cases. ReferenceStore delegates so the
intersection behavior is unit-tested without a ModelContainer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New features:
- Offline Dictionary: reverse index of 175K verb forms + 200 common
words, cached to disk, powers instant word lookups in Stories
- Vocab SRS Review: spaced repetition for course vocabulary cards
with due count badge and Again/Hard/Good/Easy rating
- Cloze Practice: fill-in-the-blank using SentenceQuizEngine with
distractor generation from vocabulary pool
- Grammar Exercises: interactive quizzes for 5 grammar topics
(ser/estar, por/para, preterite/imperfect, subjunctive, personal a)
with "Practice This" button on grammar note detail
- Listening Practice: listen-and-type + pronunciation check modes
using Speech framework with word-by-word match scoring
- Conversational Practice: AI chat partner via Foundation Models
with 10 scenario types, saved to cloud container
Other changes:
- Add Conversation model to SharedModels and cloud container
- Add Info.plist keys for speech recognition and microphone
- Skip speech auth on simulator to prevent crash
- Fix preparing data screen to only show during seed/migration
- Extract courseDataVersion to static property on DataLoader
- Add "How Features Work" reference page in Settings
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Generate one-paragraph Spanish stories on-device using Foundation Models,
matched to user's level and enabled tenses. Every word is tappable —
pre-annotated words show instantly, others get a quick on-device AI
lookup with caching. English translation hidden by default behind a
toggle. Comprehension quiz with 3 multiple-choice questions. Stories
saved to cloud container for sync and persistence across resets.
Closes#9
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New EnglishConjugator in SharedModels constructs English translations
by combining the verb's infinitive with person pronouns and tense
auxiliaries (e.g., abatir conditional yo → "I would knock down").
Covers all 20 tense IDs, handles 60+ irregular English verbs,
multi-word verbs, 3rd person rules, gerund and participle formation.
VerbDetailView shows the English below each conjugated form, plus a
legend explaining red = irregular conjugation. 42 tests.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New feature in the Practice tab that lets users search for Spanish songs
by artist + title, fetch lyrics from LRCLIB (free, no API key), pull
album art from iTunes Search API, auto-translate to English via Apple's
on-device Translation framework, and save for offline reading.
Components:
- SavedSong SwiftData model (local container, no CloudKit sync)
- LyricsSearchService actor (LRCLIB + iTunes Search, concurrent)
- LyricsSearchView (artist/song fields, result list with album art)
- LyricsConfirmationView (lyrics preview, auto-translation, save)
- LyricsLibraryView (saved songs list, swipe to delete)
- LyricsReaderView (Spanish lines with English subtitles)
- Practice tab integration (Lyrics button with NavigationLink)
- localStoreResetVersion bumped to 3 for schema migration
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Examples shorter than 4 words (like pronunciation guides
"discutir(dees-koo-teer)") are now rejected by both
isBlankResolvable and buildQuestion. The engine only picks real
multi-word sentences for the quiz prompt.
Every card already has at least one real sentence alongside its
phonetic entries, so no data regeneration is needed — the filter
alone fixes the issue.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a new Complete the Sentence quiz type that renders a Spanish example
sentence from the card with the target word blanked out and asks the
student to pick the missing word from 4 choices (other cards' fronts
from the same week's pool).
Core logic lives in SharedModels/SentenceQuizEngine as pure functions
over VocabCard, covered by 18 Swift Testing tests. CourseQuizView calls
the engine, pre-filters the card pool to cards that can produce a
resolvable blank, and reuses the existing MC rendering via a new
correctAnswer(for:) helper.
VocabCard gains examplesBlanks (parallel array to examplesES) so content
can explicitly tag the blanked substring; DataLoader reads an optional
"blank" key on each example. Additive schema change, CloudKit-safe
default.
Also adds ContentCoverageTests that parse the repo's course_data.json
and assert every card has >=3 examples and yields a resolvable question.
These tests currently fail: 1,117 cards still need sentences. They are
the oracle for the gap-fill pass that follows.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previously the widget was picking from course VocabCards, which could
land on any course week and was showing unrelated phrases instead of the
verbs the user is actually studying.
Now the widget uses a new VerbStore.fetchVerbOfDay helper that:
- Expands the user's selectedLevel via VerbLevelGroup.dataLevels
- Runs a FetchDescriptor<Verb> filtered by those levels, sorted by rank
- Uses fetchCount + fetchOffset for a deterministic daily pick
The main app mirrors UserProgress.selectedLevel into the shared app
group UserDefaults (key "selectedVerbLevel") on every WidgetDataService
update, so the widget process can read it without touching the cloud
store.
WordOfDay.weekNumber was replaced with a more flexible subtitle: String
so widgets can display "Level: Basic" instead of course week numbers.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Root cause: the widget was opening the shared local.store with a 2-entity
schema (VocabCard, CourseDeck), causing SwiftData to destructively migrate
the file and drop the 4 entities the widget didn't know about (Verb,
VerbForm, IrregularSpan, TenseGuide). The main app would then re-seed on
next launch, and the cycle repeated forever.
Fix: move Verb, VerbForm, IrregularSpan, TenseGuide from the app target
into SharedModels so both the main app and the widget use the exact same
types from the same module. Both now declare all 6 local entities in their
ModelContainer, producing identical schema hashes and eliminating the
destructive migration.
Other changes bundled in this commit (accumulated during debugging):
- Split ModelContainer into localContainer + cloudContainer (no more
CloudKit + non-CloudKit configs in one container)
- Add SharedStore.localStoreURL() helper and a global reference for
bypass-environment fetches
- One-time store reset mechanism to wipe stale schema metadata from
previous broken iterations
- Bootstrap/maintenance split so only seeding gates the UI; dedup and
cloud repair run in the background
- Sync status toast that shows "Syncing" while background maintenance
runs (network-aware, auto-dismisses)
- Background app refresh task to keep the widget word-of-day fresh
- Speaker icon on VerbDetailView for TTS
- Grammar notes navigation fix (nested NavigationStack was breaking
detail pane on iPhone)
- Word-of-day widget swaps front/back when the deck is reversed so the
Spanish word always shows in bold
- StoreInspector diagnostic helper for raw SQLite table inspection
- Add Conjuga scheme explicitly to project.yml so xcodegen doesn't drop it
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>