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
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue