Add streak counter to flashcards
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m17s

This commit is contained in:
Elijah 2026-07-27 14:05:35 -07:00
parent a6af3a49ad
commit 32bce673ac
6 changed files with 452 additions and 6 deletions

View file

@ -170,6 +170,65 @@
.animate-slide-up { animation: slideUp .32s var(--ease-spring); } .animate-slide-up { animation: slideUp .32s var(--ease-spring); }
.animate-subtle-pulse { animation: subtlePulse 1.7s ease-in-out infinite; } .animate-subtle-pulse { animation: subtlePulse 1.7s ease-in-out infinite; }
/* Flashcard streaks. Flame colours are deliberately theme-independent: the same
yellow-to-orange ramp reads correctly on both the light and dark surfaces. */
:root {
--flame-hot: #fbbf24;
--flame-mid: #fb923c;
--flame-core: #ea580c;
--flame-ink: #451a03;
}
@keyframes streakBarFlow { from { background-position: 0% 50%; } to { background-position: -200% 50%; } }
@keyframes streakChipIn { from { opacity: 0; transform: translateY(-4px) scale(.9); } to { opacity: 1; transform: translateY(0) scale(1); } }
@keyframes streakFlicker {
0%, 100% { transform: scale(1) rotate(0); }
25% { transform: scale(1.07) rotate(-2.5deg); }
60% { transform: scale(.96) rotate(2deg); }
}
@keyframes streakPop {
0% { opacity: 0; transform: translateY(26px) scale(.55); }
28% { opacity: 1; transform: translateY(-2px) scale(1.1); }
44% { transform: translateY(0) scale(1); }
74% { opacity: 1; transform: translateY(-5px) scale(1); }
100% { opacity: 0; transform: translateY(-38px) scale(.9); }
}
.streak-bar-flame {
background-image: linear-gradient(100deg, var(--flame-hot), var(--flame-mid) 35%, var(--flame-core) 55%, var(--flame-mid) 75%, var(--flame-hot));
background-size: 200% 100%;
animation: streakBarFlow 2.6s linear infinite;
box-shadow: 0 0 12px color-mix(in srgb, var(--flame-core) 55%, transparent);
}
.streak-chip { animation: streakChipIn .28s var(--ease-spring); }
.streak-chip-flame {
color: var(--flame-ink);
background-image: linear-gradient(100deg, var(--flame-hot), var(--flame-core));
box-shadow: 0 2px 12px color-mix(in srgb, var(--flame-core) 40%, transparent);
}
/* Softens the card behind a burst so the milestone stays readable. */
@keyframes streakScrim { 0% { opacity: 0; } 14% { opacity: 1; } 76% { opacity: 1; } 100% { opacity: 0; } }
.streak-scrim {
background: radial-gradient(
circle at 50% 46%,
color-mix(in srgb, var(--color-bg-surface) 94%, transparent) 0%,
color-mix(in srgb, var(--color-bg-surface) 78%, transparent) 42%,
transparent 76%
);
animation: streakScrim var(--streak-pop-duration, 1.8s) ease-out forwards;
}
.streak-pop { animation: streakPop var(--streak-pop-duration, 1.8s) var(--ease-spring) forwards; }
.streak-pop-flame { animation: streakFlicker .9s ease-in-out infinite; transform-origin: 50% 85%; }
.streak-pop-heading {
background-image: linear-gradient(180deg, var(--flame-hot), var(--flame-core));
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}
/* Arcade routes take over the complete application shell, including navigation. */ /* Arcade routes take over the complete application shell, including navigation. */
body:has(.arcade-route) { body:has(.arcade-route) {
--theme-bg-base: #070b18; --theme-bg-base: #070b18;

View file

@ -1,9 +1,11 @@
import { getShareLink } from "@/services/shareService"; import { getShareLink, getShareLinkMeta } from "@/services/shareService";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { ThemeToggle } from "@/components/ui/ThemeToggle"; import { ThemeToggle } from "@/components/ui/ThemeToggle";
import Link from "next/link"; import Link from "next/link";
import { SharedViewer } from "./SharedViewer"; import { SharedViewer } from "./SharedViewer";
import { SharedGroupViewer } from "./SharedGroupViewer"; import { SharedGroupViewer } from "./SharedGroupViewer";
import { buildShareMetadata } from "@/lib/shareMetadata";
import type { Metadata } from "next";
import type { SharedGroupData, SharedStudyItem } from "@/types/study"; import type { SharedGroupData, SharedStudyItem } from "@/types/study";
type SharedContentType = "flashcards" | "quizzes"; type SharedContentType = "flashcards" | "quizzes";
@ -16,6 +18,20 @@ interface SharedPageProps {
}>; }>;
} }
export async function generateMetadata(
props: SharedPageProps & { searchParams: Promise<{ itemId?: string }> },
): Promise<Metadata> {
const { classSlug, type, token } = await props.params;
const { itemId } = await props.searchParams;
if (type !== "flashcards" && type !== "quizzes" && type !== "groups") {
return buildShareMetadata(null);
}
const link = await getShareLinkMeta(token);
return buildShareMetadata(link, { classSlug, itemId });
}
export default async function SharedPage(props: SharedPageProps & { searchParams: Promise<{ itemId?: string }> }) { export default async function SharedPage(props: SharedPageProps & { searchParams: Promise<{ itemId?: string }> }) {
const { classSlug, type, token } = await props.params; const { classSlug, type, token } = await props.params;
const { itemId } = await props.searchParams; const { itemId } = await props.searchParams;

View file

@ -1,9 +1,18 @@
"use client"; "use client";
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { recordStudyActivity } from "@/lib/activityClient"; import { recordStudyActivity } from "@/lib/activityClient";
import {
STREAK_FLAME_THRESHOLD,
StreakBurst,
StreakCounter,
isStreakMilestone,
streakBurstDuration,
streakTier,
type StreakTier,
} from "./StreakFlame";
interface Card { interface Card {
id: string; id: string;
@ -66,6 +75,48 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
const missedCount = Object.values(results).filter((r) => r === "missed").length; const missedCount = Object.values(results).filter((r) => r === "missed").length;
const totalGraded = correctCount + missedCount; const totalGraded = correctCount + missedCount;
// Current run of consecutive correct cards, derived by walking back from the
// current position. Deriving it (rather than storing it) keeps resumed
// sessions and the "previous card" button correct without extra persistence.
const streak = useMemo(() => {
let run = 0;
for (let i = currentIndex - 1; i >= 0; i--) {
if (results[order[i]] !== "correct") break;
run++;
}
return run;
}, [order, currentIndex, results]);
const isOnFire = streak >= STREAK_FLAME_THRESHOLD;
// Milestone celebration. The first settled streak is only a baseline, so
// resuming a session mid-streak never fires a burst on arrival.
const [burst, setBurst] = useState<{ streak: number; tier: StreakTier; id: number } | null>(null);
const prevStreakRef = useRef(0);
const streakBaselineRef = useRef(false);
useEffect(() => {
if (!isLoaded) return;
const previous = prevStreakRef.current;
prevStreakRef.current = streak;
if (!streakBaselineRef.current) {
streakBaselineRef.current = true;
return;
}
if (streak > previous && isStreakMilestone(streak)) {
setBurst({ streak, tier: streakTier(streak), id: Date.now() });
}
}, [streak, isLoaded]);
useEffect(() => {
if (!burst) return;
const timer = setTimeout(() => setBurst(null), streakBurstDuration(burst.tier));
return () => clearTimeout(timer);
}, [burst]);
// Auto-hide toast // Auto-hide toast
useEffect(() => { useEffect(() => {
if (toastMessage) { if (toastMessage) {
@ -344,19 +395,20 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
{/* Running tally */} {/* Running tally */}
<div className="mb-5 w-full max-w-4xl rounded-xl border border-border-light bg-bg-surface/70 px-4 py-3"> <div className="mb-5 w-full max-w-4xl rounded-xl border border-border-light bg-bg-surface/70 px-4 py-3">
<div className="flex items-center justify-between text-sm"> <div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1.5 text-sm">
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
<span className="text-success font-medium">{correctCount} </span> <span className="text-success font-medium">{correctCount} </span>
<span className="text-error font-medium">{missedCount} </span> <span className="text-error font-medium">{missedCount} </span>
{streak >= 2 && <StreakCounter streak={streak} />}
</div> </div>
<span className="text-text-muted"> <span className="text-text-muted">
{currentIndex + 1} of {order.length} {currentIndex + 1} of {order.length}
</span> </span>
</div> </div>
{/* Progress bar */} {/* Progress bar — turns to flame once the streak is hot */}
<div className="w-full bg-bg-surface rounded-full h-2 mt-2 border border-border-light/50 overflow-hidden"> <div className="w-full bg-bg-surface rounded-full h-2 mt-2 border border-border-light/50 overflow-hidden">
<div <div
className="h-full bg-primary transition-all duration-300" className={`h-full transition-all duration-300 ${isOnFire ? "streak-bar-flame" : "bg-primary"}`}
style={{ width: `${((currentIndex + 1) / order.length) * 100}%` }} style={{ width: `${((currentIndex + 1) / order.length) * 100}%` }}
/> />
</div> </div>
@ -433,6 +485,9 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
</div> </div>
)} )}
{/* Streak milestone burst */}
{burst && <StreakBurst key={burst.id} streak={burst.streak} tier={burst.tier} />}
{/* Toast Notification */} {/* Toast Notification */}
{toastMessage && ( {toastMessage && (
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-30 animate-fade-in pointer-events-none"> <div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-30 animate-fade-in pointer-events-none">

View file

@ -0,0 +1,114 @@
"use client";
import { useId, type CSSProperties } from "react";
/**
* Streak visuals for the flashcard viewer. A streak is a run of consecutive
* "correct" grades; milestones fire a flame burst that clears itself.
*/
export type StreakTier = 1 | 2 | 3;
/** Milestones: 5, then every 10 (10, 20, 30, ...). */
export function isStreakMilestone(streak: number) {
return streak === 5 || (streak >= 10 && streak % 10 === 0);
}
export function streakTier(streak: number): StreakTier {
if (streak >= 20) return 3;
if (streak >= 10) return 2;
return 1;
}
/** A streak this long turns the progress bar (and counter) to flame. */
export const STREAK_FLAME_THRESHOLD = 5;
const TIER_COPY: Record<StreakTier, string> = {
1: "On fire",
2: "Blazing",
3: "Unstoppable",
};
const TIER_FLAME_SIZE: Record<StreakTier, string> = {
1: "h-16 w-16",
2: "h-24 w-24",
3: "h-36 w-36 md:h-44 md:w-44",
};
const TIER_HEADING_SIZE: Record<StreakTier, string> = {
1: "text-lg",
2: "text-2xl",
3: "text-4xl md:text-5xl",
};
const TIER_DURATION: Record<StreakTier, number> = {
1: 1600,
2: 1900,
3: 2400,
};
export function streakBurstDuration(tier: StreakTier) {
return TIER_DURATION[tier];
}
export function FlameIcon({ className }: { className?: string }) {
// useId() output contains punctuation that is unsafe inside a url(#...)
// reference, so strip it down to an id the gradient fill can resolve.
const id = `flame-gradient-${useId().replace(/[^a-zA-Z0-9]/g, "")}`;
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<defs>
<linearGradient id={id} x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stopColor="#ea580c" />
<stop offset="55%" stopColor="#fb923c" />
<stop offset="100%" stopColor="#fbbf24" />
</linearGradient>
</defs>
<path
fill={`url(#${id})`}
fillRule="evenodd"
clipRule="evenodd"
d="M12.963 2.286a.75.75 0 0 0-1.071-.136 9.742 9.742 0 0 0-3.539 6.177A7.547 7.547 0 0 1 6.648 6.61a.75.75 0 0 0-1.152-.082A9 9 0 1 0 15.68 4.534a7.46 7.46 0 0 1-2.717-2.248ZM15.75 14.25a3.75 3.75 0 1 1-7.313-1.172c.628.465 1.35.81 2.133 1a5.99 5.99 0 0 1 1.925-3.547 3.75 3.75 0 0 1 3.255 3.719Z"
/>
</svg>
);
}
/**
* The celebratory flame overlay. Rendered inside the card's relative wrapper and
* never interactive, so taps still reach the card underneath.
*/
export function StreakBurst({ streak, tier }: { streak: number; tier: StreakTier }) {
return (
<div
className="streak-scrim pointer-events-none absolute inset-0 z-40 flex items-center justify-center rounded-[2rem]"
style={{ "--streak-pop-duration": `${TIER_DURATION[tier]}ms` } as CSSProperties}
role="status"
aria-live="polite"
>
<div className="streak-pop flex flex-col items-center">
<FlameIcon className={`streak-pop-flame drop-shadow-[0_6px_24px_rgba(234,88,12,0.55)] ${TIER_FLAME_SIZE[tier]}`} />
<span className={`streak-pop-heading mt-1 font-black uppercase tracking-wide ${TIER_HEADING_SIZE[tier]}`}>
{TIER_COPY[tier]}
</span>
<span className="mt-1 text-sm font-semibold text-text-heading">{streak} in a row</span>
</div>
</div>
);
}
/** The persistent "x in a row" counter shown next to the running tally. */
export function StreakCounter({ streak }: { streak: number }) {
const onFire = streak >= STREAK_FLAME_THRESHOLD;
return (
<span
className={`streak-chip inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-bold whitespace-nowrap ${
onFire ? "streak-chip-flame" : "bg-bg-surface-alt text-text-secondary"
}`}
>
{onFire && <FlameIcon className="h-3.5 w-3.5 shrink-0" />}
{streak} in a row
</span>
);
}

159
src/lib/shareMetadata.ts Normal file
View file

@ -0,0 +1,159 @@
import type { Metadata } from "next";
/**
* Builds the link-preview metadata (Discord, Slack, iMessage, ...) for a public
* share URL, so every shared deck, quiz and group describes itself rather than
* falling back to the generic site description.
*/
const SITE_NAME = "Study Desk";
const SITE_DESCRIPTION = "A focused workspace for flashcards and practice quizzes";
interface MetaClass {
slug: string;
name: string;
}
interface MetaItem {
id: string;
name: string;
description: string | null;
count: number;
}
export interface ShareMetaLink {
deck: {
name: string;
description: string | null;
class: MetaClass;
_count: { cards: number };
} | null;
quizSet: {
name: string;
description: string | null;
class: MetaClass;
_count: { questions: number };
} | null;
group: {
name: string;
type: string;
class: MetaClass;
decks: { id: string; name: string; description: string | null; _count: { cards: number } }[];
quizSets: { id: string; name: string; description: string | null; _count: { questions: number } }[];
} | null;
}
function count(n: number, singular: string, plural: string) {
return `${n} ${n === 1 ? singular : plural}`;
}
/** Collapses Markdown-ish whitespace and keeps previews to a sane length. */
function tidy(text: string, maxLength = 200) {
const flat = text.replace(/\s+/g, " ").trim();
if (flat.length <= maxLength) return flat;
return `${flat.slice(0, maxLength - 1).trimEnd()}`;
}
function toMetadata(title: string, description: string): Metadata {
return {
title,
description,
openGraph: {
title,
description,
siteName: SITE_NAME,
type: "website",
},
twitter: {
card: "summary",
title,
description,
},
};
}
function itemMetadata(
item: MetaItem,
className: string,
kind: "flashcards" | "quiz",
groupName?: string,
) {
const title = `${item.name} · ${className}`;
const context = groupName ? `${className} ${groupName}` : className;
const fallback =
kind === "flashcards"
? `${context} flashcard deck · ${count(item.count, "card", "cards")}`
: `${context} practice quiz · ${count(item.count, "question", "questions")}`;
const described = item.description?.trim();
return toMetadata(title, described ? tidy(described) : fallback);
}
/**
* `itemId` mirrors the `?itemId=` deep link into a shared group, which shows a
* single deck or quiz instead of the group index.
*/
export function buildShareMetadata(
link: ShareMetaLink | null,
options: { classSlug?: string; itemId?: string } = {},
): Metadata {
const { classSlug, itemId } = options;
if (!link) return toMetadata(SITE_NAME, SITE_DESCRIPTION);
// Never describe content whose URL does not agree with the stored class, so
// previews stay silent for the same requests the page itself rejects.
const target = link.deck ?? link.quizSet ?? link.group;
if (!target || (classSlug && target.class.slug !== classSlug)) {
return toMetadata(SITE_NAME, SITE_DESCRIPTION);
}
if (link.deck) {
return itemMetadata(
{ id: "", name: link.deck.name, description: link.deck.description, count: link.deck._count.cards },
link.deck.class.name,
"flashcards",
);
}
if (link.quizSet) {
return itemMetadata(
{ id: "", name: link.quizSet.name, description: link.quizSet.description, count: link.quizSet._count.questions },
link.quizSet.class.name,
"quiz",
);
}
const group = link.group!;
const className = group.class.name;
const isDeckGroup = group.type === "DECK";
if (itemId) {
const deck = isDeckGroup ? group.decks.find((d) => d.id === itemId) : undefined;
const quizSet = isDeckGroup ? undefined : group.quizSets.find((q) => q.id === itemId);
if (deck) {
return itemMetadata(
{ id: deck.id, name: deck.name, description: deck.description, count: deck._count.cards },
className,
"flashcards",
group.name,
);
}
if (quizSet) {
return itemMetadata(
{ id: quizSet.id, name: quizSet.name, description: quizSet.description, count: quizSet._count.questions },
className,
"quiz",
group.name,
);
}
}
const members = isDeckGroup
? count(group.decks.length, "flashcard deck", "flashcard decks")
: count(group.quizSets.length, "quiz", "quizzes");
return toMetadata(`${group.name} · ${className}`, `${className} ${group.name}${members}`);
}

View file

@ -49,6 +49,49 @@ export async function getShareLink(token: string) {
}); });
} }
/**
* Names, descriptions and counts only enough to build social/link-preview
* metadata without loading every card, question and option.
*/
export async function getShareLinkMeta(token: string) {
return prisma.shareLink.findUnique({
where: { id: token },
select: {
deck: {
select: {
name: true,
description: true,
class: { select: { slug: true, name: true } },
_count: { select: { cards: true } },
},
},
quizSet: {
select: {
name: true,
description: true,
class: { select: { slug: true, name: true } },
_count: { select: { questions: true } },
},
},
group: {
select: {
name: true,
type: true,
class: { select: { slug: true, name: true } },
decks: {
select: { id: true, name: true, description: true, _count: { select: { cards: true } } },
orderBy: { sortOrder: "asc" },
},
quizSets: {
select: { id: true, name: true, description: true, _count: { select: { questions: true } } },
orderBy: { sortOrder: "asc" },
},
},
},
},
});
}
export async function getShareLinkForContent(targetType: "DECK" | "QUIZ" | "GROUP", contentId: string) { export async function getShareLinkForContent(targetType: "DECK" | "QUIZ" | "GROUP", contentId: string) {
if (targetType === "DECK") { if (targetType === "DECK") {
return prisma.shareLink.findUnique({ where: { deckId: contentId } }); return prisma.shareLink.findUnique({ where: { deckId: contentId } });