Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .project/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ translation and linguistic context as you read.
<!-- 001-plan-version-2 — achieved 2026-06-12 (omitted per convention) -->

### [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
Expand Down
84 changes: 84 additions & 0 deletions .project/tasks/task-012_quiz-card-example.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions src/app/api/quiz/result/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
67 changes: 14 additions & 53 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@

import { useState, useEffect, useCallback } from 'react';
import {
TranslatableWord,
TranslatableText,
ContentSelector,
SelectionTranslationPopup,
BACKGROUND_COLORS,
TRANSLATION_MODES,
TranslationMode,
saveInputText,
Expand Down Expand Up @@ -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<number | null>(null);
const [translationMode, setTranslationMode] = useState<TranslationMode>(TRANSLATION_MODES.BOTH);
const [showContentSelector, setShowContentSelector] = useState(false);
const [lastTranslatedRange, setLastTranslatedRange] = useState<LastTranslatedRange | null>(null);
Expand Down Expand Up @@ -158,7 +155,6 @@ export default function Home() {
const handleClear = () => {
setText('');
setShowInput(true);
setActiveWordIndex(null);
setLastTranslatedRange(null);
setSavedScrollY(null);
setHasRestoredScroll(false);
Expand Down Expand Up @@ -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 (
<main className="min-h-screen bg-gradient-to-b from-blue-50 to-white">
<div className="w-full min-h-screen mx-auto md:max-w-4xl p-4 sm:p-6 md:p-8">
Expand Down Expand Up @@ -355,37 +342,19 @@ export default function Home() {
{/* Reading Content */}
<div
id="reading-content"
className="p-4 sm:p-8 bg-gray-50 rounded-xl shadow-sm
border-2 border-gray-100 leading-relaxed
className="p-4 sm:p-8 bg-gray-50 rounded-xl shadow-sm
border-2 border-gray-100 leading-relaxed
text-base sm:text-lg min-h-[150px] sm:min-h-[200px] whitespace-pre-wrap"
>
{words.map(({ content, isWhitespace, key }) => {
const isLastTranslated = !!lastTranslatedRange && key >= lastTranslatedRange.start && key <= lastTranslatedRange.end;

return isWhitespace ? (
<span
key={key}
data-token-index={key}
className={isLastTranslated ? BACKGROUND_COLORS.LAST_TRANSLATED : undefined}
>
{content}
</span>
) : (
<TranslatableWord
key={key}
word={content}
tokenIndex={key}
sourceLang={sourceLang}
targetLang={targetLang}
onHover={() => setActiveWordIndex(key)}
onTranslated={(tokenIndex) => handleTranslatedRange({ start: tokenIndex, end: tokenIndex })}
onWordTranslated={handleWordTranslated}
isActive={activeWordIndex === key}
isLastTranslated={isLastTranslated}
translationMode={translationMode}
/>
);
})}
<TranslatableText
text={text}
sourceLang={sourceLang}
targetLang={targetLang}
translationMode={translationMode}
lastTranslatedRange={lastTranslatedRange}
onTranslatedRange={handleTranslatedRange}
onWordTranslated={handleWordTranslated}
/>
</div>
<div className="flex flex-col sm:flex-row justify-center gap-3 sm:gap-4">
<button
Expand Down Expand Up @@ -415,16 +384,8 @@ export default function Home() {
)}
</div>
</div>

{/* Subtitle-Style Translation Popup — only active in reading view */}
<SelectionTranslationPopup
sourceLang={sourceLang}
targetLang={targetLang}
translationMode={translationMode}
isInputMode={showInput || learningView === 'overview'}
onTranslated={handleTranslatedRange}
onSelectionTranslated={handleWordTranslated}
/>
{/* The subtitle-style selection popup now lives inside <TranslatableText>, mounted only in
reading view — so it is naturally absent in input/overview mode. */}
</main>
);
}
7 changes: 5 additions & 2 deletions src/modules/reader/components/SelectionTranslationPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -20,6 +22,7 @@ export default function SelectionTranslationPopup({
targetLang,
translationMode,
isInputMode,
scopeSelector = '[data-translatable-text]',
onTranslated,
onSelectionTranslated,
}: SelectionTranslationPopupProps) {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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;

Expand Down
98 changes: 98 additions & 0 deletions src/modules/reader/components/TranslatableText.tsx
Original file line number Diff line number Diff line change
@@ -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<number | null>(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 (
<>
<span data-translatable-text className={className}>
{tokens.map(({ content, isWhitespace, key }) => {
const isLastTranslated =
!!lastTranslatedRange && key >= lastTranslatedRange.start && key <= lastTranslatedRange.end;

return isWhitespace ? (
<span
key={key}
data-token-index={key}
className={isLastTranslated ? BACKGROUND_COLORS.LAST_TRANSLATED : undefined}
>
{content}
</span>
) : (
<TranslatableWord
key={key}
word={content}
tokenIndex={key}
sourceLang={sourceLang}
targetLang={targetLang}
onHover={() => setActiveWordIndex(key)}
onTranslated={(tokenIndex) => onTranslatedRange?.({ start: tokenIndex, end: tokenIndex })}
onWordTranslated={onWordTranslated}
isActive={activeWordIndex === key}
isLastTranslated={isLastTranslated}
translationMode={translationMode}
/>
);
})}
</span>

{/* Subtitle-style popup for phrase/sentence selection within this text. */}
<SelectionTranslationPopup
sourceLang={sourceLang}
targetLang={targetLang}
translationMode={translationMode}
isInputMode={false}
scopeSelector="[data-translatable-text]"
onTranslated={(range) => onTranslatedRange?.(range)}
onSelectionTranslated={onWordTranslated}
Comment on lines +89 to +94
/>
</>
);
}
1 change: 1 addition & 0 deletions src/modules/reader/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Comment on lines 43 to 45
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()),
};
Expand Down
Loading