Study/src/components/flashcards/StreakFlame.tsx
Elijah 32bce673ac
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m17s
Add streak counter to flashcards
2026-07-27 14:05:35 -07:00

114 lines
3.7 KiB
TypeScript

"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>
);
}