freezmo/components/StudyFeed.tsx
Martin Dvorak a8b3dfd0e3 Phone Aero glass + a timed run you can see
The phone (touch) chat chrome carried a 55% flat white veil while every other
Aero surface uses the thin 32% Ask-pill tint, so the header, welcome card and
composer read as plain white on a bright sky. They now use the same flat glass
as the cards, the answer/user bubbles keep 42% for long reading, and the
composer's field is a lighter 42% tile. The shared Aero wash block moved above
the touch media query so its background-image can no longer resurrect the bake
on phones.

The timed run worked but was invisible: the only cue was a small chip among the
streak/XP counters. The chrome pill now draws the countdown along its lower
edge, the chip is bordered (a control, not a stat), turns rose with the bar in
the last ten seconds, and fires one soft haptic tick at ten seconds.

Contrast scan clean on the phone chat in all three themes; activities suite
pins the countdown bar (25 checks). Version 0.6.2, SW v84.
2026-10-15 08:11:52 +00:00

503 lines
21 KiB
TypeScript

"use client";
import { AnimatePresence, motion } from "framer-motion";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActivityRenderer, NotesTextProvider } from "@/components/activities";
import { AiDisclaimer } from "@/components/AiDisclaimer";
import { celebrate } from "@/lib/confetti";
import { ApiError, generateDeck } from "@/lib/client-api";
import { materialiseActivities } from "@/lib/deck-ops";
import { haptic } from "@/lib/haptics";
import { recordAnswer } from "@/lib/history";
import { useI18n } from "@/lib/i18n";
import { withoutReported } from "@/lib/question-reports";
import type { Activity, Deck } from "@/lib/schemas";
import { recordAttempt, upsertDeck } from "@/lib/storage";
import { useWakeLock } from "@/lib/use-wake-lock";
import { cn } from "@/lib/utils";
import { showToast } from "@/lib/toast";
import { BoltIcon, ClockIcon, FlameIcon, XIcon } from "@/components/icons";
import { ReportQuestionButton } from "@/components/ReportQuestionButton";
import { Button, ProgressBar } from "@/components/ui";
import { ResultsScreen } from "./ResultsScreen";
export type AttemptResult = { correct: boolean; userAnswer?: string };
/** Seconds a card is given in a timed run (the chrome shows the countdown). */
export const TIMED_CARD_SECONDS = 30;
export function StudyFeed({ deck }: { deck: Deck }) {
const { t } = useI18n();
/* Keep the screen awake for the whole study session (feature-detected). */
useWakeLock(true);
const [currentDeck, setCurrentDeck] = useState(deck);
// Reported questions never enter a session; a fully reported deck stays
// studiable (withoutReported falls back to the full list).
const [queue, setQueue] = useState<Activity[]>(() => withoutReported(deck.id, deck.activities));
/** Ids reported during this run: skipped, so they are not "missed". */
const [skipped, setSkipped] = useState<string[]>([]);
const [index, setIndex] = useState(0);
const [results, setResults] = useState<Record<string, AttemptResult>>({});
const [xp, setXp] = useState(0);
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);
/** Timed run: each card gets TIMED_CARD_SECONDS, a card that runs out counts
* as a miss. Off by default — the toggle sits in the chrome. */
const [timed, setTimed] = useState(false);
const [remaining, setRemaining] = useState<number | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const recordedRef = useRef(false);
const total = queue.length;
const slideCount = total + 1; // + results slide
const goTo = useCallback((target: number) => {
const container = scrollRef.current;
if (!container) return;
const clamped = Math.max(0, Math.min(target, slideCount - 1));
container.scrollTo({ top: clamped * container.clientHeight, behavior: "smooth" });
}, [slideCount]);
const next = useCallback(() => goTo(index + 1), [goTo, index]);
// Track which slide is visible (works for wheel, touch and programmatic
// scroll). Re-attached whenever the scroller remounts on a new run (runId).
useEffect(() => {
const container = scrollRef.current;
if (!container) return;
let frame = 0;
const onScroll = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
const height = container.clientHeight || 1;
const current = Math.round(container.scrollTop / height);
setIndex((previous) => (previous === current ? previous : current));
});
};
container.addEventListener("scroll", onScroll, { passive: true });
return () => {
container.removeEventListener("scroll", onScroll);
cancelAnimationFrame(frame);
};
}, [runId]);
// Keyboard navigation.
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
if (target && (/^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName) || target.isContentEditable)) return;
if (event.metaKey || event.ctrlKey || event.altKey) return;
if (event.key === "ArrowDown" || event.key === "PageDown" || event.key === "j") {
event.preventDefault();
next();
} else if (event.key === "ArrowUp" || event.key === "PageUp" || event.key === "k") {
event.preventDefault();
goTo(index - 1);
} else if (event.key === "Home") {
event.preventDefault();
goTo(0);
} else if (event.key === "End") {
event.preventDefault();
goTo(slideCount - 1);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [goTo, index, next, slideCount]);
const finished = index >= total;
useEffect(() => {
if (!finished) return;
if (!retryMode && !recordedRef.current) {
recordedRef.current = true;
const answered = Object.values(results);
const correct = queue.filter((activity) => !skipped.includes(activity.id) && results[activity.id]?.correct).length;
const attempted = Math.max(0, total - skipped.length);
const score = attempted > 0 ? (correct / attempted) * 100 : 0;
// 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: attempted });
setCurrentDeck((prev) => ({
...prev,
attempts: prev.attempts + 1,
bestScore: Math.max(prev.bestScore ?? 0, Math.round(score)),
updatedAt: Date.now(),
}));
haptic("celebrate");
void celebrate("big");
}
}
}, [finished, queue, results, total, skipped, currentDeck.id, retryMode]);
const handleAnswered = useCallback(
(activity: Activity, correct: boolean, meta?: { userAnswer?: string }) => {
if (results[activity.id]) return; // first answer counts, recorded once
setResults((current) => {
if (current[activity.id]) return current;
return { ...current, [activity.id]: { correct, userAnswer: meta?.userAnswer } };
});
// Every answer feeds the history, the SRS schedule and the mistakes set.
recordAnswer({
deckId: currentDeck.id,
deckTitle: currentDeck.title,
activityId: activity.id,
type: activity.type,
topic: activity.topic,
correct,
});
if (correct) {
haptic("success");
const nextStreak = streak + 1;
setStreak(nextStreak);
setBestStreak((best) => Math.max(best, nextStreak));
setXp((value) => value + activity.xp);
if (nextStreak > 0 && nextStreak % 5 === 0) void celebrate("normal");
} else {
haptic("error");
setStreak(0);
}
},
[currentDeck.id, currentDeck.title, results, streak],
);
/* Timed runs ---------------------------------------------------------- */
const timedActivity = index < total ? queue[index] : null;
const timedAnswered = timedActivity ? Boolean(results[timedActivity.id]) : true;
/** Seconds left on the card in view, or null when no countdown runs. */
const timedRemaining = timed && remaining !== null && timedActivity && !timedAnswered ? remaining : null;
const timedDanger = timedRemaining !== null && timedRemaining <= 10;
useEffect(() => {
if (!timed || !timedActivity || timedAnswered) {
setRemaining(null);
return;
}
setRemaining(TIMED_CARD_SECONDS);
const timer = window.setInterval(() => {
setRemaining((value) => (value === null ? null : Math.max(0, value - 1)));
}, 1000);
return () => window.clearInterval(timer);
}, [timed, timedActivity, timedAnswered, runId]);
/* Last ten seconds: one soft tick per card, like a phone timer. */
const warnedRef = useRef<string | null>(null);
useEffect(() => {
if (!timed || timedRemaining === null || timedRemaining > 10 || !timedActivity) return;
if (warnedRef.current === timedActivity.id) return;
warnedRef.current = timedActivity.id;
haptic("toggle");
}, [timed, timedRemaining, timedActivity]);
/* Ran out: the card counts as a miss (the same path as a wrong answer, so
history and the SRS schedule see it), the learner is told, and the feed
moves on. */
useEffect(() => {
if (!timed || remaining !== 0 || !timedActivity || timedAnswered) return;
handleAnswered(timedActivity, false, { userAnswer: t("feed.timedOut") });
showToast({ message: t("feed.timedOut") });
// Straight away, not on a timer: recording the miss re-renders (and
// re-runs this effect), and a deferred scroll would be cleaned up with it.
goTo(index + 1);
}, [timed, remaining, timedActivity, timedAnswered, handleAnswered, goTo, index, t]);
const missed = useMemo(
() => queue.filter((activity) => !skipped.includes(activity.id) && !results[activity.id]?.correct),
[queue, results, skipped],
);
/** What the results screen counts: reported cards were never attempted. */
const attemptedTotal = Math.max(0, total - skipped.length);
const correctCount = useMemo(
() => queue.filter((activity) => !skipped.includes(activity.id) && results[activity.id]?.correct).length,
[queue, results, skipped],
);
const restart = useCallback(
(activities: Activity[], retry: boolean) => {
setQueue(activities);
setResults({});
setSkipped([]);
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
// *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);
},
[],
);
// Whenever a run starts (including the first render), snap back to slide 1.
useEffect(() => {
const container = scrollRef.current;
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(t("deck.noSourceRegenerate"));
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 : t("deck.couldNotRegenerate"));
} finally {
setRegenerating(false);
}
}, [currentDeck, regenerating, restart, t]);
const progressValue = Math.min(index + 1, slideCount);
const progressMax = slideCount;
return (
// The deck's own notes travel with the feed: after an answer the feedback
// panel points back into them (NotesSource).
<NotesTextProvider value={currentDeck.sourceText}>
<div className="frost-bg overlay-open fixed inset-0 z-[45] flex flex-col fade-in-mobile">
{/* Chrome ---------------------------------------------------- */}
{/* One pill: round close button, title + position above the bar, and
the streak/xp chips at the other end. Everything sits on the pill's
centre line with even padding, so nothing crowds the rounded ends. */}
<header className="pt-safe absolute inset-x-0 top-0 z-30 px-3">
<div className="study-chrome relative mx-auto flex max-w-xl items-center gap-2.5 rounded-full px-2.5 py-2">
{/* The countdown, drawn along the pill's lower edge: the chip alone
was easy to miss mid-card. */}
{timedRemaining !== null ? (
<span
aria-hidden
className="pointer-events-none absolute inset-x-4 bottom-[0.3rem] h-[3px] overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--text)_14%,transparent)]"
>
<span
data-timed-bar
className={cn(
"block h-full rounded-full transition-[width] duration-1000 ease-linear motion-reduce:transition-none",
timedDanger ? "bg-rose-500" : "grad-ice",
)}
style={{ width: `${Math.round((timedRemaining / TIMED_CARD_SECONDS) * 100)}%` }}
/>
</span>
) : null}
<Link
href="/"
aria-label={t("feed.back")}
title={t("feed.back")}
className="grid size-10 shrink-0 place-items-center rounded-full bg-[var(--surface-sunken)] text-[var(--text)] transition hover:bg-[var(--hover)]"
>
<XIcon className="size-4" />
</Link>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-3">
<span className="min-w-0 truncate text-[13px] font-semibold leading-tight text-[var(--text)]">
{currentDeck.title}
</span>
<span className="shrink-0 text-[11.5px] font-bold tabular-nums text-[var(--text-muted)]">
{retryMode ? t("feed.retry") : null}
{Math.min(index + 1, total)}/{total}
</span>
</div>
<ProgressBar
className="mt-1.5"
value={progressValue}
max={progressMax}
label={t("feed.activityOf", { n: progressValue, total: progressMax })}
/>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<button
type="button"
data-timed-toggle
data-timed-seconds={TIMED_CARD_SECONDS}
aria-pressed={timed}
onClick={() => setTimed((value) => !value)}
aria-label={timed ? t("feed.timedOn") : t("feed.timed")}
title={timed ? t("feed.timedOn") : t("feed.timed")}
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-extrabold tabular-nums transition",
timed
? cn("border-transparent tint-peach", timedDanger && "tint-rose")
: "border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] hover:text-[var(--text)]",
)}
>
<ClockIcon className="size-3.5" />
{timedRemaining !== null ? `${timedRemaining}s` : `${TIMED_CARD_SECONDS}s`}
</button>
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-extrabold tabular-nums transition",
streak >= 3
? "tint-peach"
: "bg-[var(--surface-sunken)] text-[var(--text-muted)]",
)}
title={t("feed.streak", { n: streak })}
aria-label={t("feed.streak", { n: streak })}
>
<FlameIcon className="size-3.5" />
{streak}
</span>
<span
className="inline-flex items-center gap-1 rounded-full bg-[var(--surface-sunken)] px-2.5 py-1 text-xs font-extrabold tabular-nums text-[var(--text-muted)]"
title={t("feed.xp", { n: xp })}
aria-label={t("feed.xp", { n: xp })}
>
<BoltIcon className="size-3.5" />
{xp}
</span>
</div>
</div>
</header>
{/* Feed ------------------------------------------------------ */}
{/* The scroller remounts on every run (key={runId}) so it always
starts on the first slide with fresh scroll/snap state: a shrinking
queue or an interrupted smooth scroll can never leave the feed on a
stale, half-scrolled or blank position. */}
<div
key={runId}
ref={scrollRef}
data-testid="study-feed"
className="no-scrollbar h-full snap-y snap-mandatory overflow-y-scroll overscroll-y-contain [overflow-anchor:none]"
>
{queue.map((activity, activityIndex) => (
<section
key={`${activity.id}-${runId}`}
aria-label={t("feed.activityOf", { n: activityIndex + 1, total })}
className="flex h-full snap-start snap-always items-stretch justify-center overflow-y-auto overscroll-y-contain"
>
<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] [&>*]:shrink-0">
<ActivityRenderer
activity={activity}
active={index === activityIndex}
language={currentDeck.language}
isLast={activityIndex === total - 1}
onAnswered={(correct, meta) => handleAnswered(activity, correct, meta)}
onNext={() => goTo(activityIndex + 1)}
/>
{/* Under the card: disclaimer centred, report control in the
bottom-right corner (it used to sit in the top chrome). */}
{!finished && index === activityIndex ? (
<div className="grid grid-cols-[2.75rem_1fr_2.75rem] items-center gap-3">
<span aria-hidden />
<AiDisclaimer />
<div className="flex justify-end">
<ReportQuestionButton
deckId={currentDeck.id}
deckTitle={currentDeck.title}
activity={activity}
onSkip={() => {
setSkipped((current) => [...current, activity.id]);
goTo(activityIndex + 1);
}}
/>
</div>
</div>
) : (
<AiDisclaimer />
)}
</div>
</section>
))}
<section
aria-label={t("feed.results")}
className="h-full snap-start snap-always overflow-y-auto overscroll-y-contain"
>
<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] [&>*]:shrink-0">
<ResultsScreen
deck={currentDeck}
xp={xp}
bestStreak={bestStreak}
total={attemptedTotal}
correct={correctCount}
missed={missed}
results={results}
retryMode={retryMode}
onRetryMissed={() => restart(missed, true)}
onRestart={() => restart(withoutReported(currentDeck.id, currentDeck.activities), false)}
onRegenerate={regenerate}
regenerating={regenerating}
regenerateError={regenerateError}
/>
<AiDisclaimer />
</div>
</section>
</div>
{/* Swipe hint on the very first screen ----------------------- */}
<AnimatePresence>
{index === 0 && Object.keys(results).length === 0 ? (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="pointer-events-none absolute inset-x-0 bottom-6 z-20 flex justify-center sm:hidden"
>
<span className="rounded-full bg-frost-950/70 px-4 py-2 text-xs font-semibold text-white backdrop-blur">
{t("feed.swipeUp")}
</span>
</motion.div>
) : null}
</AnimatePresence>
{/* Desktop next/prev buttons --------------------------------- */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 hidden justify-center pb-4 sm:flex">
<div className="study-chrome pointer-events-auto flex items-center gap-2 rounded-full px-2 py-1.5">
<Button variant="ghost" size="sm" onClick={() => goTo(index - 1)} disabled={index === 0} aria-label={t("feed.prev")}>
↑
</Button>
<span className="px-2 text-xs font-semibold text-[var(--text)]">
{index + 1} / {slideCount}
</span>
<Button variant="ghost" size="sm" onClick={() => goTo(index + 1)} disabled={index >= slideCount - 1} aria-label={t("feed.next")}>
↓
</Button>
</div>
</div>
</div>
</NotesTextProvider>
);
}