diff --git a/README.md b/README.md index 600a308..3d28fde 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,9 @@ no database, no data leaving your machine except the requests you send to Ollama - Arrow-key / PageUp / PageDown / Home / End navigation, plus swipe on touch devices - Progress bar, activity counter, XP counter and answer streak in the chrome - Instant feedback with a short explanation for every activity, confetti when you finish +- Results screen: **Retry missed ones** (fresh, answerable items), **Study the whole deck again**, + and **Regenerate deck** — rebuilds the activities from the stored notes so you can keep drilling + until you're ready **Activity types** (each one is its own React component, rendered from JSON by a `type` field) @@ -98,7 +101,8 @@ no database, no data leaving your machine except the requests you send to Ollama - **Folders**: create named groups ("History", "Biology", …) from the sidebar or the create screen. New decks freeze straight into the selected folder; anything unfiled lives in **Unorganized**. Rename, delete (decks survive — they return to Unorganized) and move decks between folders -- Rename, delete (with confirm), regenerate, and **add more activities** to an existing deck +- Rename, delete (with confirm), regenerate (from the library or the results screen), and + **add more activities** to an existing deck - Export a deck as JSON and import it back — the folder name travels with the file and is recreated on import diff --git a/components/ResultsScreen.tsx b/components/ResultsScreen.tsx index 0042190..55f73a8 100644 --- a/components/ResultsScreen.tsx +++ b/components/ResultsScreen.tsx @@ -6,7 +6,7 @@ import { motion } from "framer-motion"; import type { Activity, Deck } from "@/lib/schemas"; import { downloadDeck } from "@/lib/storage"; import { cn } from "@/lib/utils"; -import { BoltIcon, DownloadIcon, FlameIcon, HomeIcon, RefreshIcon, SnowflakeIcon } from "@/components/icons"; +import { BoltIcon, DownloadIcon, FlameIcon, HomeIcon, RefreshIcon, SnowflakeIcon, SparklesIcon } from "@/components/icons"; import { Button, Chip } from "@/components/ui"; import type { AttemptResult } from "./StudyFeed"; @@ -60,6 +60,9 @@ export function ResultsScreen({ retryMode, onRetryMissed, onRestart, + onRegenerate, + regenerating, + regenerateError, }: { deck: Deck; xp: number; @@ -71,6 +74,9 @@ export function ResultsScreen({ retryMode: boolean; onRetryMissed: () => void; onRestart: () => void; + onRegenerate: () => void; + regenerating: boolean; + regenerateError: string | null; }) { const percent = total > 0 ? Math.round((correct / total) * 100) : 0; const perfect = missed.length === 0 && total > 0; @@ -151,6 +157,21 @@ export function ResultsScreen({ Study the whole deck again ) : null} + + {regenerateError ? ( +

+ {regenerateError} +

+ ) : null}
- {deck.title} + {currentDeck.title} {retryMode ? "Retry · " : ""} {Math.min(index + 1, total)}/{total} @@ -210,7 +263,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
{queue.map((activity, activityIndex) => (
handleAnswered(activity, correct, meta)} onNext={() => goTo(activityIndex + 1)} @@ -237,7 +290,7 @@ export function StudyFeed({ deck }: { deck: Deck }) { >
restart(missed, true)} - onRestart={() => restart(deck.activities, false)} + onRestart={() => restart(currentDeck.activities, false)} + onRegenerate={regenerate} + regenerating={regenerating} + regenerateError={regenerateError} />
diff --git a/components/activities/TrueFalse.tsx b/components/activities/TrueFalse.tsx index 8947f6b..7a27ce1 100644 --- a/components/activities/TrueFalse.tsx +++ b/components/activities/TrueFalse.tsx @@ -19,8 +19,13 @@ type TrueFalseActivity = Extract; export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComponentProps) { const [result, setResult] = useState(null); const x = useMotionValue(0); - const trueOpacity = useTransform(x, [30, 110], [0, 1]); - const falseOpacity = useTransform(x, [-30, -110], [1, 0]); + // The drag is elastic, so the card moves less than the pointer — these + // ranges make each label reach full strength on a normal swipe. + const trueOpacity = useTransform(x, [20, 70], [0, 1]); + // Left drags go negative: at rest (0) this must be invisible, only fading + // in as the card is dragged left. The old range was inverted, so the + // "false ✕" label sat on the card permanently. + const falseOpacity = useTransform(x, [-70, -20], [1, 0]); const answer = (value: boolean) => { if (result !== null) return; diff --git a/lib/ai.ts b/lib/ai.ts index 136c6e6..db049c5 100644 --- a/lib/ai.ts +++ b/lib/ai.ts @@ -250,6 +250,13 @@ const VALID_TYPES = [ "short_answer", ] as const; +/** + * Giveaways that make an ordering card solvable without knowing the material: + * years, clock times, "first/next/finally", "step 2", "1.". + */ +const ORDERING_GIVEAWAY = + /\b(1[0-9]{3}|20[0-9]{2})\b|\b\d{1,2}[:.]\d{2}\b|\b(first|second|third|fourth|fifth|sixth|seventh|eighth|1st|2nd|3rd|4th|5th|6th|7th|8th)\b|\b(step|stage|phase)\s*\d+\b|^\s*\d+[.)]\s/i; + /** * Be forgiving: validate each activity on its own and keep the good ones. * Only if too few survive does the step get retried. @@ -280,7 +287,16 @@ function validateDeck(parsed: unknown, minimum: number, requested: number) { rawActivities.slice(0, 24).forEach((item, index) => { const result = generatedActivitySchema.safeParse(item); if (result.success) { - activities.push(sanitiseActivity(result.data)); + const activity = sanitiseActivity(result.data); + // An ordering card with dates/ordinals on it gives the answer away — + // drop it rather than ship a card the learner can solve without knowing. + if (activity.type === "ordering" && activity.items.some((entry) => ORDERING_GIVEAWAY.test(entry))) { + rejected.push( + `#${index + 1} (ordering): items must not contain dates, years, times or ordinals — the order has to come from the content.`, + ); + return; + } + activities.push(activity); } else { const type = (item as { type?: unknown } | null)?.type; rejected.push(`#${index + 1} (${typeof type === "string" ? type : "no type"}): ${summariseZodError(result.error)}`); diff --git a/lib/prompts.ts b/lib/prompts.ts index 000c214..ca5a444 100644 --- a/lib/prompts.ts +++ b/lib/prompts.ts @@ -85,7 +85,9 @@ export const ACTIVITY_JSON_SPEC = `Each activity is one JSON object with a "type { "type": "matching", "topic": "short label", "instruction": "Match each term to its definition.", "pairs": [{ "term": "Osmosis", "definition": "diffusion of water across a membrane" }], "explanation": "..." } 6. ordering - 3 to 7 items given in the CORRECT order (the app shuffles them) - { "type": "ordering", "topic": "short label", "instruction": "Put the stages of mitosis in order.", "items": ["Prophase", "Metaphase", "Anaphase", "Telophase"], "explanation": "..." } + Items must NEVER contain dates, years, clock times, step numbers or ordinals ("first", "step 2") — + the learner has to work out the order from what each item says, not read it off. + { "type": "ordering", "topic": "short label", "instruction": "Put the events in the order they happened.", "items": ["The Allies land in Normandy", "Paris is liberated", "Germany surrenders"], "explanation": "..." } 7. short_answer - typed answer, graded by AI { "type": "short_answer", "topic": "short label", "question": "...", "referenceAnswer": "a model answer in one or two sentences", "keyPoints": ["idea that must appear", "second idea"], "explanation": "..." } @@ -102,6 +104,8 @@ Hard rules: - Vary the activity types. Use a healthy mix of all seven types below, and never put more than two activities of the same type in a row. - Cover the whole notes, not just the first paragraph. Spread activities across the different topics, definitions, formulas and facts. - Make the wrong options plausible (common mix-ups from the same notes), never silly filler. +- Ordering items must never reveal their position: no dates, years, times, step numbers or ordinals + ("first", "next", "finally", "step 2"). Describe the event or stage itself. - Keep questions short enough to read on a phone screen. No walls of text. - Never reference "the notes", "the text above" or "the student". Speak directly to the learner. - Match the requested difficulty: easy = recall and simple recognition; medium = understanding and application; hard = analysis, edge cases and multi-step reasoning.