Fix ordering giveaways, the stuck false label, add Regenerate deck
Ordering cards - Generation prompt now forbids dates, years, times, step numbers and ordinals in ordering items (spec + hard rules), and the server-side validation drops any ordering card whose items still contain them instead of shipping a giveaway. True/False - The 'false ✕' swipe label was permanently visible on the card: its opacity curve was inverted, so the clamp at rest was 1. Flipped the range (and tightened both ranges for the elastic drag) — each label now only fades in while swiping in that direction. Regenerate deck - Results screen gains a Regenerate deck button: rebuilds the activities from the deck's stored source notes, replaces the deck in storage (attempts/best score reset) and starts the fresh run from slide 1. - StudyFeed tracks the current deck so the header, export and messages use the regenerated title/activities. - Fixed a spurious 0% attempt recorded when a regenerated deck swapped in: scroll anchoring held the old results position, so the feed briefly reported 5/5 with zero answers. The scroll now resets before the new slides render, overflow-anchor is disabled on the feed, and a run with no answers is never recorded. Tests: new deck-fixes suite 15/15 (TF overlay at rest/drag, regenerate with loading state + storage reset, ordering guard drop/keep); retry 14/14, deck chrome 15/15, pwa 16/16, final-ux 12/12, security 8/8, contrast 20/20.
This commit is contained in:
parent
1bacf43ed2
commit
33967f9454
6 changed files with 122 additions and 16 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<RefreshIcon className="size-5" /> Study the whole deck again
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
onClick={onRegenerate}
|
||||
loading={regenerating}
|
||||
disabled={regenerating}
|
||||
>
|
||||
<SparklesIcon className="size-5" />
|
||||
{regenerating ? "Regenerating…" : "Regenerate deck"}
|
||||
</Button>
|
||||
{regenerateError ? (
|
||||
<p className="text-center text-xs text-rose-600 dark:text-rose-300" role="alert">
|
||||
{regenerateError}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button variant="ghost" onClick={() => downloadDeck(deck)}>
|
||||
<DownloadIcon className="size-4" /> Export JSON
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||
|
||||
import { ActivityRenderer } from "@/components/activities";
|
||||
import { celebrate } from "@/lib/confetti";
|
||||
import { ApiError, generateDeck } from "@/lib/client-api";
|
||||
import { materialiseActivities } from "@/lib/deck-ops";
|
||||
import type { Activity, Deck } from "@/lib/schemas";
|
||||
import { recordAttempt } from "@/lib/storage";
|
||||
import { recordAttempt, upsertDeck } from "@/lib/storage";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BoltIcon, FlameIcon, XIcon } from "@/components/icons";
|
||||
import { Button, ProgressBar } from "@/components/ui";
|
||||
|
|
@ -17,6 +19,7 @@ import { ResultsScreen } from "./ResultsScreen";
|
|||
export type AttemptResult = { correct: boolean; userAnswer?: string };
|
||||
|
||||
export function StudyFeed({ deck }: { deck: Deck }) {
|
||||
const [currentDeck, setCurrentDeck] = useState(deck);
|
||||
const [queue, setQueue] = useState<Activity[]>(deck.activities);
|
||||
const [index, setIndex] = useState(0);
|
||||
const [results, setResults] = useState<Record<string, AttemptResult>>({});
|
||||
|
|
@ -24,6 +27,8 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
|||
const [streak, setStreak] = useState(0);
|
||||
const [bestStreak, setBestStreak] = useState(0);
|
||||
const [retryMode, setRetryMode] = useState(false);
|
||||
const [regenerating, setRegenerating] = useState(false);
|
||||
const [regenerateError, setRegenerateError] = useState<string | null>(null);
|
||||
/** Bumped on every restart so the activity components remount fresh. */
|
||||
const [runId, setRunId] = useState(0);
|
||||
|
||||
|
|
@ -95,10 +100,15 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
|||
const answered = Object.values(results);
|
||||
const correct = answered.filter((entry) => entry.correct).length;
|
||||
const score = total > 0 ? (correct / total) * 100 : 0;
|
||||
recordAttempt(deck.id, { scorePercent: score, correct, total });
|
||||
void celebrate("big");
|
||||
// Only a run with at least one answer counts. Without this, the scroll
|
||||
// anchoring shift when a regenerated deck swaps in could fire a bogus
|
||||
// 0% attempt before the feed had snapped back to the first slide.
|
||||
if (answered.length > 0) {
|
||||
recordAttempt(currentDeck.id, { scorePercent: score, correct, total });
|
||||
void celebrate("big");
|
||||
}
|
||||
}
|
||||
}, [finished, results, total, deck.id, retryMode]);
|
||||
}, [finished, results, total, currentDeck.id, retryMode]);
|
||||
|
||||
const handleAnswered = useCallback(
|
||||
(activity: Activity, correct: boolean, meta?: { userAnswer?: string }) => {
|
||||
|
|
@ -136,7 +146,11 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
|||
setStreak(0);
|
||||
setRetryMode(retry);
|
||||
recordedRef.current = retry; // don't overwrite the best score with a retry run
|
||||
// A new run: remount every activity (fresh state) and jump to the top.
|
||||
// A new run: remount every activity (fresh state) and jump to the top
|
||||
// *before* the new slides render, so scroll anchoring can't hold the
|
||||
// old results position.
|
||||
const container = scrollRef.current;
|
||||
if (container) container.scrollTop = 0;
|
||||
setRunId((value) => value + 1);
|
||||
setIndex(0);
|
||||
},
|
||||
|
|
@ -149,6 +163,45 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
|||
if (container) container.scrollTop = 0;
|
||||
}, [runId]);
|
||||
|
||||
/** Rebuild the deck from its stored source notes and start it over. */
|
||||
const regenerate = useCallback(async () => {
|
||||
if (regenerating) return;
|
||||
const source = currentDeck.sourceText?.trim();
|
||||
if (!source) {
|
||||
setRegenerateError("This deck has no stored source notes, so it can't be regenerated.");
|
||||
return;
|
||||
}
|
||||
setRegenerating(true);
|
||||
setRegenerateError(null);
|
||||
try {
|
||||
const { deck: generated } = await generateDeck({
|
||||
text: source,
|
||||
difficulty: currentDeck.difficulty,
|
||||
language: currentDeck.language,
|
||||
count: Math.max(8, currentDeck.activities.length),
|
||||
});
|
||||
const activities = materialiseActivities(generated.activities, currentDeck.difficulty);
|
||||
const updated: Deck = {
|
||||
...currentDeck,
|
||||
title: generated.title || currentDeck.title,
|
||||
subject: generated.subject ?? currentDeck.subject,
|
||||
summary: generated.summary ?? currentDeck.summary,
|
||||
language: generated.language ?? currentDeck.language,
|
||||
activities,
|
||||
attempts: 0,
|
||||
bestScore: undefined,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
upsertDeck(updated);
|
||||
setCurrentDeck(updated);
|
||||
restart(activities, false);
|
||||
} catch (error) {
|
||||
setRegenerateError(error instanceof ApiError ? error.message : "Could not regenerate the deck.");
|
||||
} finally {
|
||||
setRegenerating(false);
|
||||
}
|
||||
}, [currentDeck, regenerating, restart]);
|
||||
|
||||
const progressValue = Math.min(index + 1, slideCount);
|
||||
const progressMax = slideCount;
|
||||
|
||||
|
|
@ -197,7 +250,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 text-[11px] font-semibold text-[var(--text-muted)]">
|
||||
<span className="min-w-0 truncate">{deck.title}</span>
|
||||
<span className="min-w-0 truncate">{currentDeck.title}</span>
|
||||
<span className="shrink-0">
|
||||
{retryMode ? "Retry · " : ""}
|
||||
{Math.min(index + 1, total)}/{total}
|
||||
|
|
@ -210,7 +263,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
|||
<div
|
||||
ref={scrollRef}
|
||||
data-testid="study-feed"
|
||||
className="no-scrollbar h-full snap-y snap-mandatory overflow-y-scroll overscroll-y-contain"
|
||||
className="no-scrollbar h-full snap-y snap-mandatory overflow-y-scroll overscroll-y-contain [overflow-anchor:none]"
|
||||
>
|
||||
{queue.map((activity, activityIndex) => (
|
||||
<section
|
||||
|
|
@ -222,7 +275,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
|||
<ActivityRenderer
|
||||
activity={activity}
|
||||
active={index === activityIndex}
|
||||
language={deck.language}
|
||||
language={currentDeck.language}
|
||||
isLast={activityIndex === total - 1}
|
||||
onAnswered={(correct, meta) => handleAnswered(activity, correct, meta)}
|
||||
onNext={() => goTo(activityIndex + 1)}
|
||||
|
|
@ -237,7 +290,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
|||
>
|
||||
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center]">
|
||||
<ResultsScreen
|
||||
deck={deck}
|
||||
deck={currentDeck}
|
||||
xp={xp}
|
||||
bestStreak={bestStreak}
|
||||
total={total}
|
||||
|
|
@ -246,7 +299,10 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
|||
results={results}
|
||||
retryMode={retryMode}
|
||||
onRetryMissed={() => restart(missed, true)}
|
||||
onRestart={() => restart(deck.activities, false)}
|
||||
onRestart={() => restart(currentDeck.activities, false)}
|
||||
onRegenerate={regenerate}
|
||||
regenerating={regenerating}
|
||||
regenerateError={regenerateError}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -19,8 +19,13 @@ type TrueFalseActivity = Extract<Activity, { type: "true_false" }>;
|
|||
export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<TrueFalseActivity>) {
|
||||
const [result, setResult] = useState<boolean | null>(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;
|
||||
|
|
|
|||
18
lib/ai.ts
18
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)}`);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue