diff --git a/.project/overview.md b/.project/overview.md index 365560a..2208718 100644 --- a/.project/overview.md +++ b/.project/overview.md @@ -26,7 +26,7 @@ translation and linguistic context as you read. ### [002-build-v2-mvp](goals/002-build-v2-mvp.md) — active -- No open tasks — all complete; pending re-evaluation against the goal's success criteria. +- No pending tasks at the moment ## Output locations diff --git a/.project/tasks/task-012_quiz-card-example.md b/.project/tasks/task-012_quiz-card-example.md new file mode 100644 index 0000000..65c099d --- /dev/null +++ b/.project/tasks/task-012_quiz-card-example.md @@ -0,0 +1,84 @@ +--- +status: done # to-do | in-progress | in-review | done +owner: both +goal: "[[002-build-v2-mvp]]" +--- + +## Description +Show an **example sentence** on the quiz card when the answer is revealed — so the user sees +the word used in context. The example comes from a user-maintained **`Example` column** in the +Google Sheet (read-only — the app never writes it, per [[003-vocab-sheet-design]]). Shown after +reveal on **both** directions (FI→EN and EN→FI), only when the word has an example. + +The example is in the **learning language** (source language), so its text carries the **same +hover / touch / selection translation** the Reader provides — kept language-agnostic (label it +"Example", not "Finnish example") so future languages work unchanged. + +**Key design point — reuse, not duplicate.** The Reader's interactive translation lives in +[TranslatableWord](../../src/modules/reader/components/TranslatableWord.tsx) + +[SelectionTranslationPopup](../../src/modules/reader/components/SelectionTranslationPopup.tsx), +but the page ([page.tsx](../../src/app/page.tsx)) does the tokenization + active-token / state +wiring inline. To give the quiz card the same behaviour without copy-paste, extract a reusable +**`TranslatableText`** component (tokenize a string → `TranslatableWord`s + its own active-index +state + a `SelectionTranslationPopup`). Reader page and quiz card both consume it. This keeps +the modules independently operable (a given constraint). + +### Build — Phase A (data/plumbing, no visual design) — done +- [x] `vocab-test/types.ts`: add `example: string | null` to `KnowledgeItem`. +- [x] Adapter `getAll()`: read the `Example` column by header name (read-only; absent → null); + add to each item. Never written in `recordResult`. +- [x] Confirmed `example` flows through the session route → `client.ts` (rides on + `QuizCard.item`; selector keeps the full item; route returns `{ cards }` — no contract change). +- [x] typecheck + lint + build clean. + +### Build — Phase B (UI / reuse) — done +- [x] Extracted reusable `TranslatableText` + ([TranslatableText.tsx](../../src/modules/reader/components/TranslatableText.tsx)) from the + Reader page wiring (tokenizer + active-index + its own `SelectionTranslationPopup`). +- [x] Refactored [page.tsx](../../src/app/page.tsx) to consume it (Reader behaviour unchanged; + dropped its inline tokenizer, `activeWordIndex` state, and page-level popup). +- [x] Made the selection popup's scope configurable (`scopeSelector`, default + `[data-translatable-text]`) so it works on the quiz card without leaking the Reader's + `#reading-content` id. +- [x] Render the example under the answer in + [QuizSession](../../src/modules/vocab-test/components/QuizSession.tsx) on reveal, **both** + directions, source = learning language (`fi`); hidden when no example. Hover/touch + + selection both work, and only after reveal (the example only mounts then). +- [x] typecheck + lint + build clean (`/` and `/test` routes present). + +## Done when +On reveal, a card whose sheet row has an `Example` shows that sentence under the answer (both +directions), and hovering / touching / selecting words in it triggers translation exactly like +the Reader. Cards without an example show nothing extra. The app never writes the `Example` +column. + +## Outputs +- [types.ts](../../src/modules/vocab-test/types.ts) — `KnowledgeItem.example`. +- [GoogleSheetsKnowledgeRepository.ts](../../src/modules/vocab-test/adapters/GoogleSheetsKnowledgeRepository.ts) + — reads the user-owned `Example` column (read-only). +- [TranslatableText.tsx](../../src/modules/reader/components/TranslatableText.tsx) — new reusable + interactive-text component (exported from [reader/index.ts](../../src/modules/reader/index.ts)). +- [SelectionTranslationPopup.tsx](../../src/modules/reader/components/SelectionTranslationPopup.tsx) + — configurable `scopeSelector`. +- [page.tsx](../../src/app/page.tsx) — Reader refactored onto `TranslatableText`. +- [QuizSession.tsx](../../src/modules/vocab-test/components/QuizSession.tsx) — example shown on reveal. + +## Log +- 2026-06-20: Drafted [human + ai]. Decided: example source = user-owned `Example` sheet column + (read-only, per [[003-vocab-sheet-design]]); shown after reveal on both directions; learning- + language-agnostic labelling; example text reuses the Reader's hover/touch/selection translation + via a new reusable `TranslatableText` component (extract from the Reader page, don't duplicate) + [human choices]. Phasing data→UI per [[plan-before-ui-changes]]. Starting Phase A; bringing the + Phase B plan for approval before building UI. +- 2026-06-20: **Phase A built** [ai]. `KnowledgeItem.example` added; adapter reads the user-owned + `Example` column read-only (absent → null, never written); verified it rides through the selector + → session route → client with no API-contract change. typecheck + lint + build clean. +- 2026-06-20: Phase B approved [human]: extract + refactor the Reader; on the quiz card enable + **both** hover/touch and the bottom selection popup, but only after reveal. +- 2026-06-20: **Phase B built** [ai]. Extracted `TranslatableText` (tokenizer + active-word state + + its own selection popup); refactored the Reader page onto it (behaviour unchanged). Made the + popup's selection scope configurable (`scopeSelector`, default `[data-translatable-text]`) so the + quiz card gets selection translation without borrowing the Reader's `#reading-content` id. Quiz + card now shows the `Example` under the answer on reveal, both directions, with full hover/touch + + selection translation; hidden when no example. typecheck + lint + build clean. Static checks only + — awaiting live [human] review. diff --git a/src/app/api/quiz/result/route.ts b/src/app/api/quiz/result/route.ts index a8a8629..d9cf5ef 100644 --- a/src/app/api/quiz/result/route.ts +++ b/src/app/api/quiz/result/route.ts @@ -36,6 +36,7 @@ export async function POST(request: Request) { rowNumber: body.rowNumber, finnish: '', translation: '', + example: null, lastTested: body.lastTested ?? null, intervalSeconds: body.intervalSeconds ?? null, }; diff --git a/src/app/page.tsx b/src/app/page.tsx index 204a5bc..3282e56 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,10 +2,8 @@ import { useState, useEffect, useCallback } from 'react'; import { - TranslatableWord, + TranslatableText, ContentSelector, - SelectionTranslationPopup, - BACKGROUND_COLORS, TRANSLATION_MODES, TranslationMode, saveInputText, @@ -37,7 +35,6 @@ export default function Home() { const [sourceLang, _setSourceLang] = useState<'en' | 'fi'>('fi'); const [targetLang, _setTargetLang] = useState<'en' | 'fi'>('en'); const [showInput, setShowInput] = useState(true); - const [activeWordIndex, setActiveWordIndex] = useState(null); const [translationMode, setTranslationMode] = useState(TRANSLATION_MODES.BOTH); const [showContentSelector, setShowContentSelector] = useState(false); const [lastTranslatedRange, setLastTranslatedRange] = useState(null); @@ -158,7 +155,6 @@ export default function Home() { const handleClear = () => { setText(''); setShowInput(true); - setActiveWordIndex(null); setLastTranslatedRange(null); setSavedScrollY(null); setHasRestoredScroll(false); @@ -192,15 +188,6 @@ export default function Home() { clearLastTranslatedRange(); }; - // Split text preserving newlines and multiple spaces - const words = text - .split(/(\s+)/g) // Split on whitespace but keep the separators - .map((part, index) => ({ - content: part, - isWhitespace: /^\s+$/.test(part), - key: index - })); - return (
@@ -355,37 +342,19 @@ export default function Home() { {/* Reading Content */}
- {words.map(({ content, isWhitespace, key }) => { - const isLastTranslated = !!lastTranslatedRange && key >= lastTranslatedRange.start && key <= lastTranslatedRange.end; - - return isWhitespace ? ( - - {content} - - ) : ( - setActiveWordIndex(key)} - onTranslated={(tokenIndex) => handleTranslatedRange({ start: tokenIndex, end: tokenIndex })} - onWordTranslated={handleWordTranslated} - isActive={activeWordIndex === key} - isLastTranslated={isLastTranslated} - translationMode={translationMode} - /> - ); - })} +
- - {/* Subtitle-Style Translation Popup — only active in reading view */} - + {/* The subtitle-style selection popup now lives inside , mounted only in + reading view — so it is naturally absent in input/overview mode. */}
); } diff --git a/src/modules/reader/components/SelectionTranslationPopup.tsx b/src/modules/reader/components/SelectionTranslationPopup.tsx index d599e14..feb7914 100644 --- a/src/modules/reader/components/SelectionTranslationPopup.tsx +++ b/src/modules/reader/components/SelectionTranslationPopup.tsx @@ -11,6 +11,8 @@ interface SelectionTranslationPopupProps { targetLang: 'en' | 'fi'; translationMode: TranslationMode; isInputMode: boolean; + /** CSS selector for the element that scopes which selections are translated. */ + scopeSelector?: string; onTranslated: (range: { start: number; end: number }) => void; onSelectionTranslated?: (word: string, translation: string, type: 'selection') => void; } @@ -20,6 +22,7 @@ export default function SelectionTranslationPopup({ targetLang, translationMode, isInputMode, + scopeSelector = '[data-translatable-text]', onTranslated, onSelectionTranslated, }: SelectionTranslationPopupProps) { @@ -61,7 +64,7 @@ export default function SelectionTranslationPopup({ const isWithinReadingContent = (node: Node | null | undefined) => { const element = getElementFromNode(node); - return !!element?.closest?.('#reading-content'); + return !!element?.closest?.(scopeSelector); }; const handleSelectionChange = async () => { @@ -167,7 +170,7 @@ export default function SelectionTranslationPopup({ clearTimeout(debounceTimer); } }; - }, [sourceLang, targetLang, translationMode, isInputMode, onTranslated, onSelectionTranslated]); + }, [sourceLang, targetLang, translationMode, isInputMode, scopeSelector, onTranslated, onSelectionTranslated]); if (!showSubtitlePopup) return null; diff --git a/src/modules/reader/components/TranslatableText.tsx b/src/modules/reader/components/TranslatableText.tsx new file mode 100644 index 0000000..d590f23 --- /dev/null +++ b/src/modules/reader/components/TranslatableText.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { useState } from 'react'; +import TranslatableWord from './TranslatableWord'; +import SelectionTranslationPopup from './SelectionTranslationPopup'; +import { BACKGROUND_COLORS, TranslationMode } from '../config/readerConfig'; +import type { LastTranslatedRange } from '../storage'; + +interface TranslatableTextProps { + /** The text to make interactive (source/learning language). */ + text: string; + sourceLang: 'en' | 'fi'; + targetLang: 'en' | 'fi'; + translationMode: TranslationMode; + /** Reader highlight of the last-translated token range; omit for no highlight. */ + lastTranslatedRange?: LastTranslatedRange | null; + /** Notified with the token range whenever a word/selection is translated (e.g. to persist + highlight). */ + onTranslatedRange?: (range: LastTranslatedRange) => void; + /** Notified of each translated word/selection (e.g. session history). */ + onWordTranslated?: (word: string, translation: string, type: 'hover' | 'selection') => void; + /** Extra classes on the wrapping element. */ + className?: string; +} + +/** + * Renders a string as interactive, translatable text: hover/touch per-word tooltips plus + * subtitle-style selection translation — the Reader's reading surface, extracted so the quiz + * card (and anything else) can reuse the exact same behaviour. Owns only local interaction + * state (the active word); persistence / session-history concerns stay with the caller via the + * optional callbacks. + */ +export default function TranslatableText({ + text, + sourceLang, + targetLang, + translationMode, + lastTranslatedRange = null, + onTranslatedRange, + onWordTranslated, + className, +}: TranslatableTextProps) { + const [activeWordIndex, setActiveWordIndex] = useState(null); + + // Split text preserving newlines and multiple spaces (separators kept as their own tokens). + const tokens = text + .split(/(\s+)/g) + .map((part, index) => ({ + content: part, + isWhitespace: /^\s+$/.test(part), + key: index, + })); + + return ( + <> + + {tokens.map(({ content, isWhitespace, key }) => { + const isLastTranslated = + !!lastTranslatedRange && key >= lastTranslatedRange.start && key <= lastTranslatedRange.end; + + return isWhitespace ? ( + + {content} + + ) : ( + setActiveWordIndex(key)} + onTranslated={(tokenIndex) => onTranslatedRange?.({ start: tokenIndex, end: tokenIndex })} + onWordTranslated={onWordTranslated} + isActive={activeWordIndex === key} + isLastTranslated={isLastTranslated} + translationMode={translationMode} + /> + ); + })} + + + {/* Subtitle-style popup for phrase/sentence selection within this text. */} + onTranslatedRange?.(range)} + onSelectionTranslated={onWordTranslated} + /> + + ); +} diff --git a/src/modules/reader/index.ts b/src/modules/reader/index.ts index fb5fcf6..8f14ef8 100644 --- a/src/modules/reader/index.ts +++ b/src/modules/reader/index.ts @@ -5,6 +5,7 @@ */ export { default as TranslatableWord } from './components/TranslatableWord'; +export { default as TranslatableText } from './components/TranslatableText'; export { default as ContentSelector } from './components/ContentSelector'; export { default as SelectionTranslationPopup } from './components/SelectionTranslationPopup'; diff --git a/src/modules/vocab-test/adapters/GoogleSheetsKnowledgeRepository.ts b/src/modules/vocab-test/adapters/GoogleSheetsKnowledgeRepository.ts index 81d292c..1f10913 100644 --- a/src/modules/vocab-test/adapters/GoogleSheetsKnowledgeRepository.ts +++ b/src/modules/vocab-test/adapters/GoogleSheetsKnowledgeRepository.ts @@ -36,14 +36,17 @@ export function createGoogleSheetsKnowledgeRepository(spreadsheetId: string): Kn const tr = headers['Translation']; const lt = headers['Last Tested']; const iv = headers['Review Interval']; + const ex = headers['Example']; // user-owned, optional — read-only, never written return rows .map((row, i): KnowledgeItem => { const lastRaw = String(row[lt] ?? '').trim(); + const exampleRaw = ex === undefined ? '' : String(row[ex] ?? '').trim(); return { rowNumber: i + 2, // data starts at sheet row 2 finnish: String(row[fi] ?? '').trim(), translation: String(row[tr] ?? '').trim(), + example: exampleRaw || null, lastTested: lastRaw || null, intervalSeconds: parseInterval(String(row[iv] ?? '').trim()), }; diff --git a/src/modules/vocab-test/components/QuizSession.tsx b/src/modules/vocab-test/components/QuizSession.tsx index a5de230..5360acd 100644 --- a/src/modules/vocab-test/components/QuizSession.tsx +++ b/src/modules/vocab-test/components/QuizSession.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import Link from 'next/link'; +import { TranslatableText, TRANSLATION_MODES } from '@/modules/reader'; import { fetchQuizSession, submitQuizResult } from '../client'; import type { Grade, QuizCard } from '../types'; @@ -191,7 +192,26 @@ export default function QuizSession() {

{prompt}

{revealed ? ( -

{answer}

+ <> +

{answer}

+ {card.item.example && ( +
+

+ Example +

+ {/* Learning-language example with the Reader's hover/touch + selection translation, + available only here (after reveal). */} +
+ +
+
+ )} + ) : (