Skip to content

Commit 3cbb076

Browse files
committed
feat(story): multi-story library with completion tracking, longer content, and translation tooling
1 parent fe3d3a7 commit 3cbb076

16 files changed

Lines changed: 1732 additions & 104 deletions

File tree

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ A focused language learning app built with React + Express. Adaptive CEFR progre
1313
- Post-session mistake review — optional mini-session drilled from session errors
1414
- Session autosave and resume — in-progress sessions survive page refresh or tab close
1515
- Per-language course switching — independent progress tracked per language
16-
- **Story Reader** (Read tab) — comprehensible-input reading mode with leveled short stories (A1–B2). Tap any word for a bottom lookup drawer (gloss, part of speech, optional grammar note, listen button) backed by the same three-tier word system as exercise tooltips. Glossary words get a solid accent underline, grammar-hint words a dotted underline. Includes sentence-level audio, a Show/Hide English toggle, an inline cultural note, and looked-up/saved counters. "Save to review" persists unknown words so they resurface in practice sessions via spaced repetition. Reading grants no XP (anti-score-inflation).
16+
- **Story Reader** (Read tab) — comprehensible-input reading mode with a leveled story library (A1–B2). Each level offers several stories; level tabs drive a per-level list that marks finished stories and opens to the first unread one. Tap any word for a bottom lookup drawer (gloss, part of speech, optional grammar note, listen button) backed by the same three-tier word system as exercise tooltips. Glossary words get a solid accent underline, grammar-hint words a dotted underline. Stories run from short A1 pieces to multi-paragraph B1 reads, with sentence-level audio, a Show/Hide English toggle, an inline cultural note, and looked-up/saved counters. "Finish story" records completion and suggests the next unread story; "Save to review" persists unknown words so they resurface in practice sessions via spaced repetition. Reading grants no XP (anti-score-inflation).
1717

1818
### Exercise Types
1919

@@ -56,7 +56,9 @@ npm run translate:language
5656

5757
The tool reads `server/.env`, lets you select a supported target language and category files, rate-limits API calls if needed, and writes only new files under `server/content/languages/<language>/`. It never overwrites existing category files.
5858

59-
The wizard first asks **what to generate** — course categories or the **practice word** pool. Choosing practice words translates the English word list in `server/content/practice_words/_template.json` into the target language, writing `server/content/practice_words/<language>.json`. Translations are batched (`TRANSLATE_BATCH_SIZE` words per request), so a ~1000-word pool costs roughly 20 API calls rather than one per word.
59+
The wizard first asks **what to generate** — course categories, the **practice word** pool, or **Story Reader stories**. Choosing practice words translates the English word list in `server/content/practice_words/_template.json` into the target language, writing `server/content/practice_words/<language>.json`. Translations are batched (`TRANSLATE_BATCH_SIZE` words per request), so a ~1000-word pool costs roughly 20 API calls rather than one per word.
60+
61+
Choosing **Stories** reads the hand-authored `server/content/stories/english.json`, translates each sentence and title English → target, then runs a reverse target → English pass to build a per-word glossary (part-of-speech left blank for machine glosses), writing `server/content/stories/<language>.json`. As with all jobs it never overwrites an existing file, so delete a language's short-seed story file first if you want to regenerate it from the longer English source.
6062

6163
The generator code is split for clarity under `scripts/libretranslate/`: `terminal-menu.ts` (generic interactive prompts), `content-generator.ts` (translation + JSON file IO), and `index.ts` (the wizard that wires them together).
6264

@@ -327,6 +329,7 @@ npm run verify # Lint + client tests
327329
| `DELETE` | `/api/bookmarks/:questionId` | Remove a bookmark |
328330
| `GET` | `/api/stories?language=<id>&level=<lvl>&category=<cat>` | List story summaries (filterable) |
329331
| `GET` | `/api/stories/:id` | Fetch a full story (sentences, glossary, cultural note) |
332+
| `POST` | `/api/stories/:id/complete` | Mark a story finished (idempotent, per user) |
330333
| `GET` | `/api/saved-words?language=<id>` | List words saved from the Story Reader |
331334
| `POST` | `/api/saved-words` | Save a word to review (idempotent; enters the SRS queue) |
332335
| `DELETE` | `/api/saved-words/:word?language=<id>` | Remove a saved word |

TODO.md

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -376,11 +376,47 @@ pipeline feeding the spaced-repetition system.
376376
as the learner re-encounters the word.
377377
- [ ] **Native/Forvo audio** — Story Reader uses browser `SpeechSynthesis` only; real recorded audio
378378
remains out of scope (shared with the existing audio TODO).
379-
- [ ] **More stories per language** — each language has the three seed stories (A1/A2/B1). Add more
380-
per level and a B2 tier, keeping the concrete high-frequency vocabulary + factual cultural-note bar.
379+
- [ ] **More stories per language** — English and Russian now ship six longer stories each (2 per
380+
A1/A2/B1, see Phase 18.1). Generate the remaining five languages from the English source via the
381+
LibreTranslate Stories job, then add a B2 tier.
381382
- [ ] Optionally surface saved words directly in the mistake-review drill, and add a saved-words
382383
management view (reuse the Bookmarks page pattern).
383384

385+
#### Phase 18.1: Longer content, progressive library & translation pipeline — shipped
386+
387+
Expands the MVP from one short story per level to a multi-story library that tracks progress, with
388+
a tooling path to scale authored content across languages.
389+
390+
**Done**
391+
- [x] **Progressive library + completion tracking.** New `story_completions` table
392+
(`UNIQUE(user_id, story_id)`) with `markStoryComplete` / `getCompletedStoryIds` in `db.ts`.
393+
`POST /api/stories/:id/complete` records a finish; `GET /api/stories` is now user-aware and returns
394+
a `completed` flag per summary. "Finish story" calls the endpoint and the modal offers **Read next**
395+
(the next unread story, lowest level first).
396+
- [x] **Client `StoryPage.tsx`** rebuilt around a two-tier selector: level tabs (A1/A2/B1) drive a
397+
per-level story list (`sr-library`) showing each story's title + a completed check. On load it
398+
defaults to the first unread story; completed stories persist across reloads.
399+
- [x] **Longer stories with paragraph breaks.** `storyLoader.ts` accepts an optional `break` flag per
400+
sentence (rendered as a paragraph gap) and treats glossary `pos` as optional. English + Russian
401+
authored as the reference set: six stories each (Daily life, Family, Market, Weekend, Travel letter,
402+
First day at work) at ~10–20 sentences with full grammar-aware glossaries.
403+
- [x] **English as dual-purpose source.** `server/content/stories/english.json` ships as the
404+
English-course story set and is the canonical translation source.
405+
- [x] **LibreTranslate Stories job.** `scripts/libretranslate` gained a "Stories" content type:
406+
forward (English → target) for sentences/titles plus a reverse (target → English) glossary pass
407+
(`translateStories` in `content-generator.ts`). Never overwrites existing files; `pos` left blank
408+
for machine glosses.
409+
- [x] Tests: completion endpoint (idempotent, per-user, 404), loader `break`/optional-`pos`, updated
410+
content-stats counts; client Finish→complete + Read next flow.
411+
412+
**Remaining**
413+
- [ ] **Generate es/it/sv/fr/de stories** from the English source. Run
414+
`node --experimental-strip-types scripts/libretranslate/index.ts`*Stories*, after deleting each
415+
language's existing short-seed `server/content/stories/<lang>.json`. Requires `LIBRETRANSLATE_URL` /
416+
`LIBRETRANSLATE_API_KEY` in `server/.env`. Spot-check and hand-fix any unresolved glosses.
417+
- [ ] Optional: vocab-mastery–gated story ordering (the `story_completions` schema is compatible with
418+
layering this on later).
419+
384420
## Completed archive
385421

386422
### Phase 1: Reliability and anti-trivial-cheat

client/src/__tests__/story_page.test.tsx

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
44

55
const getStories = vi.fn();
66
const getStory = vi.fn();
7+
const completeStory = vi.fn();
78
const getSavedWords = vi.fn();
89
const fetchWordTranslations = vi.fn();
910
const saveWord = vi.fn();
@@ -13,6 +14,7 @@ vi.mock("../api", () => ({
1314
api: {
1415
getStories: (...args: unknown[]) => getStories(...args),
1516
getStory: (...args: unknown[]) => getStory(...args),
17+
completeStory: (...args: unknown[]) => completeStory(...args),
1618
getSavedWords: (...args: unknown[]) => getSavedWords(...args),
1719
fetchWordTranslations: (...args: unknown[]) => fetchWordTranslations(...args),
1820
saveWord: (...args: unknown[]) => saveWord(...args),
@@ -59,6 +61,7 @@ const SUMMARIES = [
5961
beforeEach(() => {
6062
vi.clearAllMocks();
6163
getStories.mockResolvedValue(SUMMARIES);
64+
completeStory.mockResolvedValue({ ok: true });
6265
getSavedWords.mockResolvedValue([]);
6366
fetchWordTranslations.mockResolvedValue({});
6467
saveWord.mockResolvedValue({ ok: true, saved: true });
@@ -69,8 +72,9 @@ beforeEach(() => {
6972
describe("StoryPage", () => {
7073
it("renders the first story for the active language", async () => {
7174
render(<StoryPage language="russian" languageLabel="Russian" />);
72-
expect(await screen.findByText("Утро Анны")).toBeInTheDocument();
73-
expect(screen.getByText("Anna's morning")).toBeInTheDocument();
75+
// The title shows in the story heading (the library list repeats it as a span).
76+
expect(await screen.findByRole("heading", { name: "Утро Анны" })).toBeInTheDocument();
77+
expect(screen.getAllByText("Anna's morning").length).toBeGreaterThan(0);
7478
// English translations are hidden until toggled on.
7579
expect(screen.queryByText("Anna lives in Moscow.")).not.toBeInTheDocument();
7680
});
@@ -113,9 +117,22 @@ describe("StoryPage", () => {
113117

114118
it("switches stories when a different level is selected", async () => {
115119
render(<StoryPage language="russian" languageLabel="Russian" />);
116-
await screen.findByText("Утро Анны");
120+
await screen.findByRole("heading", { name: "Утро Анны" });
117121
await userEvent.click(screen.getByRole("button", { name: "A2" }));
118-
expect(await screen.findByText("На рынке")).toBeInTheDocument();
122+
expect(await screen.findByRole("heading", { name: "На рынке" })).toBeInTheDocument();
123+
expect(getStory).toHaveBeenCalledWith("ru-a2");
124+
});
125+
126+
it("marks the story complete on finish and jumps to the next unread story", async () => {
127+
render(<StoryPage language="russian" languageLabel="Russian" />);
128+
await screen.findByRole("heading", { name: "Утро Анны" });
129+
130+
await userEvent.click(screen.getByRole("button", { name: /Finish story/ }));
131+
await waitFor(() => expect(completeStory).toHaveBeenCalledWith("ru-a1"));
132+
133+
// The completion modal offers the next unread story (the A2 one).
134+
await userEvent.click(await screen.findByRole("button", { name: /Read next/ }));
135+
expect(await screen.findByRole("heading", { name: "На рынке" })).toBeInTheDocument();
119136
expect(getStory).toHaveBeenCalledWith("ru-a2");
120137
});
121138
});

client/src/api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,8 @@ export const api = {
506506
return request<StorySummary[]>(`/stories${query ? `?${query}` : ""}`);
507507
},
508508
getStory: (id: string): Promise<Story> => request<Story>(`/stories/${encodeURIComponent(id)}`),
509+
completeStory: (id: string): Promise<{ ok: boolean }> =>
510+
request<{ ok: boolean }>(`/stories/${encodeURIComponent(id)}/complete`, { method: "POST" }),
509511
getSavedWords: (language?: string): Promise<SavedWord[]> =>
510512
request<SavedWord[]>(`/saved-words${language ? `?language=${encodeURIComponent(language)}` : ""}`),
511513
saveWord: (payload: { language: string; word: string; translation: string; storyId: string; category: string }) =>

client/src/components/StoryPage.tsx

Lines changed: 99 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,24 @@ type StoryPageProps = {
88
languageLabel: string;
99
};
1010

11+
const LEVEL_SEQUENCE = ["a1", "a2", "b1", "b2"];
12+
13+
// First unread story in a list, falling back to the first story so a level tab
14+
// always resolves to something selectable.
15+
function firstUnreadId(list: StorySummary[]): string {
16+
const unread = list.find((summary) => !summary.completed);
17+
return (unread ?? list[0])?.id ?? "";
18+
}
19+
20+
// Next unread story across the whole library (lowest level first), used to power
21+
// the "Read next" affordance after finishing a story.
22+
function nextUnreadStory(list: StorySummary[], currentId: string): StorySummary | null {
23+
const ordered = [...list].sort(
24+
(a, b) => LEVEL_SEQUENCE.indexOf(a.level) - LEVEL_SEQUENCE.indexOf(b.level)
25+
);
26+
return ordered.find((summary) => !summary.completed && summary.id !== currentId) ?? null;
27+
}
28+
1129
type DrawerEntry = {
1230
word: string;
1331
gloss: string;
@@ -66,6 +84,7 @@ function Icon({ name }: { name: string }) {
6684
export function StoryPage({ language, languageLabel }: StoryPageProps) {
6785
const [stories, setStories] = useState<StorySummary[]>([]);
6886
const [storiesError, setStoriesError] = useState("");
87+
const [selectedLevel, setSelectedLevel] = useState("");
6988
const [selectedId, setSelectedId] = useState("");
7089
const [story, setStory] = useState<Story | null>(null);
7190
const [storyLoading, setStoryLoading] = useState(false);
@@ -90,7 +109,14 @@ export function StoryPage({ language, languageLabel }: StoryPageProps) {
90109
.then((list) => {
91110
if (cancelled) return;
92111
setStories(list);
93-
setSelectedId(list[0]?.id ?? "");
112+
// Default to the lowest level that still has an unread story (progressive).
113+
const present = LEVEL_SEQUENCE.filter((lvl) => list.some((s) => s.level === lvl));
114+
const level =
115+
present.find((lvl) => list.some((s) => s.level === lvl && !s.completed)) ??
116+
present[0] ??
117+
"";
118+
setSelectedLevel(level);
119+
setSelectedId(firstUnreadId(list.filter((s) => s.level === level)));
94120
})
95121
.catch(() => {
96122
if (cancelled) return;
@@ -187,6 +213,7 @@ export function StoryPage({ language, languageLabel }: StoryPageProps) {
187213
return story.sentences.map((sentence) => ({
188214
en: sentence.en,
189215
target: sentence.target,
216+
brk: Boolean(sentence.break),
190217
tokens: tokenizeSentence(sentence.target).map((token) => {
191218
const interactive = tokenHasLetter(token.core);
192219
const key = interactive ? token.core.toLowerCase() : "";
@@ -250,14 +277,42 @@ export function StoryPage({ language, languageLabel }: StoryPageProps) {
250277
}
251278
}
252279

253-
const levels = useMemo(() => {
254-
const seen = new Map<string, string>();
255-
for (const summary of stories) {
256-
if (!seen.has(summary.level)) seen.set(summary.level, summary.id);
257-
}
258-
return [...seen.entries()].map(([level, id]) => ({ level, id }));
280+
const availableLevels = useMemo(() => {
281+
const present = new Set(stories.map((summary) => summary.level));
282+
return LEVEL_SEQUENCE.filter((level) => present.has(level as StorySummary["level"]));
259283
}, [stories]);
260284

285+
const storiesInLevel = useMemo(
286+
() => stories.filter((summary) => summary.level === selectedLevel),
287+
[stories, selectedLevel]
288+
);
289+
290+
const readNext = useMemo(() => nextUnreadStory(stories, selectedId), [stories, selectedId]);
291+
292+
function selectLevel(level: string) {
293+
setSelectedLevel(level);
294+
setSelectedId(firstUnreadId(stories.filter((summary) => summary.level === level)));
295+
}
296+
297+
function selectStory(summary: StorySummary) {
298+
setSelectedLevel(summary.level);
299+
setSelectedId(summary.id);
300+
setFinished(false);
301+
}
302+
303+
async function handleFinish() {
304+
setFinished(true);
305+
if (!story) return;
306+
const id = story.id;
307+
// Optimistically mark the story complete; completion is best-effort.
308+
setStories((prev) => prev.map((s) => (s.id === id ? { ...s, completed: true } : s)));
309+
try {
310+
await api.completeStory(id);
311+
} catch {
312+
/* the modal still shows; completion will retry on the next finish */
313+
}
314+
}
315+
261316
const savedList = [...saved];
262317

263318
return (
@@ -268,15 +323,15 @@ export function StoryPage({ language, languageLabel }: StoryPageProps) {
268323
<p className="eyebrow">Read · {languageLabel}</p>
269324
<h2>Story Reader</h2>
270325
</div>
271-
{levels.length > 0 && (
326+
{availableLevels.length > 0 && (
272327
<div className="sr-levels" role="group" aria-label="Reading level">
273-
{levels.map(({ level, id }) => (
328+
{availableLevels.map((level) => (
274329
<button
275330
key={level}
276331
type="button"
277332
className="sr-level"
278-
aria-pressed={stories.find((s) => s.id === selectedId)?.level === level}
279-
onClick={() => setSelectedId(id)}
333+
aria-pressed={selectedLevel === level}
334+
onClick={() => selectLevel(level)}
280335
>
281336
{level.toUpperCase()}
282337
</button>
@@ -285,6 +340,31 @@ export function StoryPage({ language, languageLabel }: StoryPageProps) {
285340
)}
286341
</div>
287342

343+
{storiesInLevel.length > 0 && (
344+
<ul className="sr-library" aria-label="Stories in this level">
345+
{storiesInLevel.map((summary) => (
346+
<li key={summary.id}>
347+
<button
348+
type="button"
349+
className={`sr-lib-item ${summary.id === selectedId ? "active" : ""} ${
350+
summary.completed ? "done" : ""
351+
}`}
352+
aria-pressed={summary.id === selectedId}
353+
onClick={() => selectStory(summary)}
354+
>
355+
<span className="sr-lib-title">{summary.title}</span>
356+
<span className="sr-lib-sub">{summary.titleEn}</span>
357+
{summary.completed ? (
358+
<span className="sr-lib-check" aria-label="Completed">
359+
<Icon name="check" />
360+
</span>
361+
) : null}
362+
</button>
363+
</li>
364+
))}
365+
</ul>
366+
)}
367+
288368
{storiesError ? <div className="status">{storiesError}</div> : null}
289369
{!storiesError && !stories.length ? (
290370
<p className="sr-empty">No stories yet for {languageLabel}. Check back soon.</p>
@@ -303,7 +383,7 @@ export function StoryPage({ language, languageLabel }: StoryPageProps) {
303383

304384
<div className="sr-story">
305385
{tokenized.map((sentence, sentenceIndex) => (
306-
<p className="sr-sent" key={sentenceIndex}>
386+
<p className={`sr-sent${sentence.brk ? " sr-break" : ""}`} key={sentenceIndex}>
307387
{sentence.tokens.map((token, tokenIndex) => {
308388
const trailingSpace = tokenIndex < sentence.tokens.length - 1 ? " " : "";
309389
if (!token.entry || !token.key) {
@@ -433,7 +513,7 @@ export function StoryPage({ language, languageLabel }: StoryPageProps) {
433513
<b>{saved.size}</b> saved
434514
</span>
435515
</div>
436-
<button type="button" className="sr-finish" onClick={() => setFinished(true)}>
516+
<button type="button" className="sr-finish" onClick={handleFinish}>
437517
Finish story <Icon name="arrow" />
438518
</button>
439519
</div>
@@ -464,9 +544,14 @@ export function StoryPage({ language, languageLabel }: StoryPageProps) {
464544
)}
465545
</div>
466546
<div className="sr-modal-actions">
467-
<button type="button" className="sr-action primary" onClick={() => setFinished(false)}>
547+
<button type="button" className="sr-action" onClick={() => setFinished(false)}>
468548
<Icon name="check" /> Done
469549
</button>
550+
{readNext ? (
551+
<button type="button" className="sr-action primary" onClick={() => selectStory(readNext)}>
552+
Read next <Icon name="arrow" />
553+
</button>
554+
) : null}
470555
</div>
471556
</div>
472557
</div>

0 commit comments

Comments
 (0)