`s.
+ */
+export function buildDecorations(
+ kind: DecorationName,
+ fg: string,
+ index: number,
+ danger: string,
+): DecoStyle[] {
+ const out: DecoStyle[] = [];
+ const slideIndex = Math.trunc(index);
+ const seed = (slideIndex * 1297 + 71) >>> 0;
+
+ switch (kind) {
+ case "sparks": {
+ const r = createRng(seed + 7);
+ for (let index_ = 0; index_ < 16; index_++) {
+ const sz = 9 + r() * 18;
+ out.push({
+ position: "absolute",
+ left: toPct(r() * 92),
+ top: toPct(r() * 92),
+ width: toPx(sz),
+ height: toPx(sz),
+ background: fg,
+ clipPath: STAR,
+ opacity: Number((0.14 + r() * 0.26).toFixed(2)),
+ transform: toDeg(Math.trunc(r() * 90)),
+ });
+ }
+ break;
+ }
+ case "confetti": {
+ const r = createRng(seed + 13);
+ for (let index_ = 0; index_ < 24; index_++) {
+ out.push({
+ position: "absolute",
+ left: toPct(r() * 94),
+ top: toPct(r() * 94),
+ width: "7px",
+ height: "14px",
+ background: CONFETTI_COLORS[Math.trunc(r() * CONFETTI_COLORS.length)],
+ opacity: 0.6,
+ borderRadius: "1px",
+ transform: toDeg(Math.trunc(r() * 180)),
+ });
+ }
+ break;
+ }
+ case "blobs": {
+ const r = createRng(seed + 21);
+ for (let index_ = 0; index_ < 6; index_++) {
+ const sz = 150 + r() * 200;
+ out.push({
+ position: "absolute",
+ left: toPct(r() * 80 - 12),
+ top: toPct(r() * 80 - 12),
+ width: toPx(sz),
+ height: toPx(sz),
+ background: fg,
+ opacity: 0.08,
+ borderRadius: "42% 58% 63% 37% / 41% 44% 56% 59%",
+ filter: "blur(10px)",
+ });
+ }
+ break;
+ }
+ case "summer": {
+ // Real summer illustrations (OpenMoji, CC BY-SA 4.0), shuffled per slide.
+ const base =
+ "https://cdn.jsdelivr.net/gh/hfg-gmuend/openmoji@15.0.0/color/svg/";
+ const r = createRng(seed + 33);
+ const pool = shuffle(
+ [
+ "2600",
+ "1F334",
+ "1F349",
+ "1F379",
+ "1F576",
+ "1F366",
+ "1F33A",
+ "1F34D",
+ "1F3D6",
+ "1F41A",
+ ],
+ r,
+ );
+ // Sit at the frame edges — visible, but the content vignette keeps them
+ // from competing with the central text.
+ const slots: { c: CSSProperties; big?: boolean; spin?: boolean }[] = [
+ { c: { top: "6%", right: "2%" }, big: true, spin: true },
+ {
+ c: { bottom: "1%", right: "2%", transformOrigin: "bottom center" },
+ big: true,
+ },
+ { c: { bottom: "3%", left: "2%" } },
+ { c: { top: "8%", left: "1%" } },
+ { c: { bottom: "18%", right: "3%" } },
+ { c: { bottom: "14%", left: "3%" } },
+ ];
+ for (const [index_, slot] of slots.entries()) {
+ const code = pool[index_ % pool.length];
+ const sz = (slot.big === true ? 100 : 52) + Math.trunc(r() * 14);
+ const anim =
+ slot.spin === true
+ ? `wr-spin ${String(70 + Math.trunc(r() * 22))}s linear infinite`
+ : `${r() < 0.5 ? "wr-float " : "wr-sway "}${(4.6 + r() * 2).toFixed(1)}s ease-in-out ${(r() * 0.9).toFixed(1)}s infinite`;
+ out.push({
+ position: "absolute",
+ backgroundImage: `url('${base}${code}.svg')`,
+ backgroundRepeat: "no-repeat",
+ backgroundPosition: "center",
+ backgroundSize: "contain",
+ width: toPx(sz),
+ height: toPx(sz),
+ "--r": `${String(Math.trunc(r() * 16 - 8))}deg`,
+ filter: "drop-shadow(0 6px 14px rgba(0,0,0,.18))",
+ pointerEvents: "none",
+ animation: anim,
+ ...slot.c,
+ });
+ }
+ for (let index_ = 0; index_ < 3; index_++) {
+ const sz = 16 + r() * 10;
+ out.push({
+ position: "absolute",
+ left: `${(14 + r() * 64).toFixed(1)}%`,
+ top: `${(15 + r() * 30).toFixed(1)}%`,
+ width: toPx(sz),
+ height: toPx(sz),
+ backgroundImage: `url('${base}2728.svg')`,
+ backgroundRepeat: "no-repeat",
+ backgroundPosition: "center",
+ backgroundSize: "contain",
+ opacity: 0.8,
+ animation: `wr-twinkle ${(2.4 + r()).toFixed(1)}s ease-in-out ${r().toFixed(1)}s infinite`,
+ });
+ }
+ break;
+ }
+ case "wrapped": {
+ // Liquid gradient blobs + rainbow ribbon + pixel squiggle.
+ const r = createRng(seed + 51);
+ const cols = [
+ ["#7b2dff", "#19d3da"],
+ ["#ff5147", "#ffb020"],
+ ["#c6ff3a", "#19d3da"],
+ ["#ff3d8b", "#7b2dff"],
+ ["#ffb020", "#ff3d8b"],
+ ];
+ const blobPos: CSSProperties[] = [
+ { top: "-8%", left: "-8%" },
+ { bottom: "-10%", right: "-8%" },
+ { top: "34%", right: "-12%" },
+ ];
+ for (const [index_, p] of blobPos.entries()) {
+ const c = cols[(slideIndex + index_) % cols.length];
+ const sz = 220 + Math.trunc(r() * 130);
+ out.push({
+ position: "absolute",
+ width: toPx(sz),
+ height: toPx(sz),
+ borderRadius: "50%",
+ background: `radial-gradient(circle at 35% 35%, ${c[0]}, ${c[1]} 68%, transparent 72%)`,
+ filter: "blur(16px)",
+ opacity: 0.5,
+ animation: `wr-float ${(9 + r() * 6).toFixed(1)}s ease-in-out ${r().toFixed(1)}s infinite`,
+ ...p,
+ });
+ }
+ const ribbon =
+ "
";
+ out.push({
+ position: "absolute",
+ top: `${(-2 + r() * 30).toFixed(0)}%`,
+ right: `${(-8 + r() * 14).toFixed(0)}%`,
+ width: "72%",
+ height: "72%",
+ backgroundImage: svgUri(ribbon),
+ backgroundRepeat: "no-repeat",
+ backgroundPosition: "center",
+ backgroundSize: "contain",
+ opacity: 0.92,
+ transform: toDeg((slideIndex * 47) % 360),
+ });
+ const pixels =
+ "
";
+ out.push({
+ position: "absolute",
+ left: `${(1 + r() * 8).toFixed(0)}%`,
+ bottom: `${(2 + r() * 9).toFixed(0)}%`,
+ width: "62%",
+ height: "72px",
+ backgroundImage: svgUri(pixels),
+ backgroundRepeat: "no-repeat",
+ backgroundPosition: "left center",
+ backgroundSize: "contain",
+ opacity: 0.85,
+ });
+ break;
+ }
+ case "lines": {
+ // Warped B&W concentric rings + scribble + red/black dot cluster.
+ const r = createRng(seed + 67);
+ const ink = fg;
+ const redc = danger || "#ff2d2d";
+ const corners: CSSProperties[] = [
+ { top: "-10%", left: "-9%" },
+ { top: "-10%", right: "-9%" },
+ { bottom: "-10%", left: "-9%" },
+ { bottom: "-10%", right: "-9%" },
+ ];
+ out.push({
+ position: "absolute",
+ width: "64%",
+ height: "42%",
+ background: `repeating-radial-gradient(circle at center, ${withAlpha(ink, 0.5)} 0 11px, transparent 11px 22px)`,
+ transform: `scaleX(1.35) rotate(${String(((slideIndex * 23) % 44) - 22)}deg)`,
+ opacity: 0.6,
+ ...corners[slideIndex % corners.length],
+ });
+ const scrib = `
`;
+ out.push({
+ position: "absolute",
+ top: `${(8 + r() * 16).toFixed(0)}%`,
+ left: `${(8 + r() * 22).toFixed(0)}%`,
+ width: "60%",
+ height: "36%",
+ backgroundImage: svgUri(scrib),
+ backgroundRepeat: "no-repeat",
+ backgroundPosition: "center",
+ backgroundSize: "contain",
+ opacity: 0.7,
+ transform: toDeg(((slideIndex * 37) % 34) - 17),
+ });
+ for (let index_ = 0; index_ < 11; index_++) {
+ const sz = 9 + r() * 17;
+ const isRed = r() < 0.45;
+ out.push({
+ position: "absolute",
+ left: `${(50 + r() * 48).toFixed(1)}%`,
+ bottom: `${(2 + r() * 32).toFixed(1)}%`,
+ width: toPx(sz),
+ height: toPx(sz),
+ borderRadius: "50%",
+ background: isRed ? redc : withAlpha(ink, 0.8),
+ });
+ }
+ break;
+ }
+ case "none":
+ default: {
+ break;
+ }
+ }
+
+ return out;
+}
diff --git a/src/app/wrapped/derived.ts b/src/app/wrapped/derived.ts
new file mode 100644
index 00000000..92687abb
--- /dev/null
+++ b/src/app/wrapped/derived.ts
@@ -0,0 +1,62 @@
+import type { WrappedPersona } from "@/types/wrapped";
+
+export function formatStudyDuration(seconds: number): string {
+ const minutes = Math.round(seconds / 60);
+ return `${String(Math.floor(minutes / 60))} godz ${String(minutes % 60).padStart(2, "0")} min`;
+}
+
+export function peakHourLabel(hour: number): string {
+ return `${String(hour).padStart(2, "0")}:00`;
+}
+
+export function personaForHour(
+ hour: number,
+ { isGlobal = false }: { isGlobal?: boolean } = {},
+): WrappedPersona {
+ if (hour >= 0 && hour <= 4) {
+ return {
+ name: "Nocny Marek",
+ description: isGlobal
+ ? "Najwięcej odpowiedzi padało głęboko w nocy."
+ : "Najwięcej odpowiedzi udzielasz głęboko w nocy.",
+ };
+ }
+ if (hour >= 5 && hour <= 8) {
+ return {
+ name: "Ranny Ptaszek",
+ description: isGlobal
+ ? "Najwięcej odpowiedzi padało tuż po przebudzeniu."
+ : "Najwięcej odpowiedzi udzielasz tuż po przebudzeniu.",
+ };
+ }
+ if (hour >= 9 && hour <= 11) {
+ return {
+ name: "Poranny Umysł",
+ description: isGlobal
+ ? "Testownik najlepiej rozkręcał się przed południem."
+ : "Najlepiej rozkręcasz się przed południem.",
+ };
+ }
+ if (hour >= 12 && hour <= 16) {
+ return {
+ name: "Popołudniowy Maratończyk",
+ description: isGlobal
+ ? "Najwięcej odpowiedzi padało po południu."
+ : "Najwięcej odpowiedzi udzielasz po południu.",
+ };
+ }
+ if (hour >= 17 && hour <= 20) {
+ return {
+ name: "Wieczorny Strateg",
+ description: isGlobal
+ ? "Najwięcej odpowiedzi padało wieczorem."
+ : "Najwięcej odpowiedzi udzielasz wieczorem.",
+ };
+ }
+ return {
+ name: "Nocny Marek",
+ description: isGlobal
+ ? "Najwięcej odpowiedzi padało tuż przed północą."
+ : "Najwięcej odpowiedzi udzielasz tuż przed północą.",
+ };
+}
diff --git a/src/app/wrapped/fonts.ts b/src/app/wrapped/fonts.ts
new file mode 100644
index 00000000..f071b338
--- /dev/null
+++ b/src/app/wrapped/fonts.ts
@@ -0,0 +1,20 @@
+import { Anton, Space_Grotesk } from "next/font/google";
+
+/** Big, bold display face for the headline numbers (the "Wrapped" look). */
+export const wrappedDisplay = Anton({
+ weight: "400",
+ subsets: ["latin", "latin-ext"],
+ display: "swap",
+ variable: "--font-wrapped-display",
+});
+
+/** Body / label face. */
+export const wrappedSans = Space_Grotesk({
+ weight: ["400", "500", "600", "700"],
+ subsets: ["latin", "latin-ext"],
+ display: "swap",
+ variable: "--font-wrapped-sans",
+});
+
+/** Class string to apply on the Wrapped root so the CSS variables resolve. */
+export const wrappedFontVariables = `${wrappedDisplay.variable} ${wrappedSans.variable}`;
diff --git a/src/app/wrapped/format.ts b/src/app/wrapped/format.ts
new file mode 100644
index 00000000..cbc142e8
--- /dev/null
+++ b/src/app/wrapped/format.ts
@@ -0,0 +1,16 @@
+/** Number formats used by the count-up animation and static labels. */
+export type WrappedFormat = "int" | "pct" | "time";
+
+/** Format a value for display. `time` interprets the value as **minutes**. */
+export function formatValue(value: number, format: WrappedFormat): string {
+ if (format === "time") {
+ const minutes = Math.round(value);
+ const hours = Math.floor(minutes / 60);
+ return `${String(hours)} godz ${String(minutes % 60)} min`;
+ }
+ if (format === "pct") {
+ return `${String(Math.round(value))}%`;
+ }
+ // pl-PL groups with a narrow/no-break space; normalise to a plain space.
+ return Math.round(value).toLocaleString("pl-PL").replaceAll(/\s/g, " ");
+}
diff --git a/src/app/wrapped/global/page.tsx b/src/app/wrapped/global/page.tsx
new file mode 100644
index 00000000..a86cdbdf
--- /dev/null
+++ b/src/app/wrapped/global/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from "next";
+
+import { WrappedExperience } from "../components/wrapped-experience";
+
+export const metadata: Metadata = {
+ title: "Wrapped — cały Testownik",
+ description: "Podsumowanie semestru całego Testownika w liczbach.",
+};
+
+export default function GlobalWrappedPage() {
+ return
;
+}
diff --git a/src/app/wrapped/page.tsx b/src/app/wrapped/page.tsx
new file mode 100644
index 00000000..d3e955b3
--- /dev/null
+++ b/src/app/wrapped/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from "next";
+
+import { WrappedPageClient } from "./client";
+
+export const metadata: Metadata = {
+ title: "Wrapped",
+ description: "Twój semestr w Testowniku — podsumowanie w liczbach.",
+};
+
+export default function WrappedPage() {
+ return
;
+}
diff --git a/src/app/wrapped/share-image.ts b/src/app/wrapped/share-image.ts
new file mode 100644
index 00000000..7997e0f9
--- /dev/null
+++ b/src/app/wrapped/share-image.ts
@@ -0,0 +1,500 @@
+import { toast } from "sonner";
+
+import { env } from "@/env";
+import type { WrappedStoryData } from "@/types/wrapped";
+
+import { personaForHour } from "./derived";
+import { wrappedDisplay, wrappedSans } from "./fonts";
+import { formatValue } from "./format";
+import { createRng, isDark, withAlpha } from "./theme";
+import type { DecorationName } from "./wrapped.config";
+
+const WIDTH = 1080;
+const HEIGHT = 1920;
+const PAD = 96;
+
+/** Colours the share card is drawn with (matches the current theme). */
+export interface ShareTheme {
+ bg: string;
+ fg: string;
+ accent: string;
+ danger: string;
+ decoration: DecorationName;
+ slideIndex: number;
+}
+
+interface Tile {
+ value: string;
+ label: string;
+ highlight: boolean;
+}
+
+function summaryTiles(data: WrappedStoryData): Tile[] {
+ const hours = Math.floor(data.study_time.total_minutes / 60);
+ const persona = personaForHour(data.rhythm.peak_hour, {
+ isGlobal: data.is_global === true,
+ });
+ if (data.is_global === true) {
+ return [
+ {
+ value: `${String(hours)} godz`,
+ label: "czasu nauki",
+ highlight: false,
+ },
+ {
+ value: formatValue(data.volume.total_answers, "int"),
+ label: "pytań",
+ highlight: true,
+ },
+ {
+ value: `${String(data.accuracy.percent)}%`,
+ label: "skuteczność",
+ highlight: false,
+ },
+ {
+ value: formatValue(data.volume.sessions, "int"),
+ label: "sesji",
+ highlight: true,
+ },
+ {
+ value: persona.name,
+ label: "rytm semestru",
+ highlight: false,
+ },
+ {
+ value: data.top_quizzes[0]?.name ?? "brak danych",
+ label: "najdłuższy quiz",
+ highlight: true,
+ },
+ ];
+ }
+
+ return [
+ { value: `${String(hours)} godz`, label: "czasu nauki", highlight: false },
+ {
+ value: formatValue(data.volume.total_answers, "int"),
+ label: "pytań",
+ highlight: true,
+ },
+ {
+ value: `${String(data.accuracy.percent)}%`,
+ label: "skuteczność",
+ highlight: false,
+ },
+ {
+ value: formatValue(data.volume.sessions, "int"),
+ label: "sesji",
+ highlight: true,
+ },
+ {
+ value: `top ${String(data.rank.top_percent)}%`,
+ label: "najwytrwalszych",
+ highlight: false,
+ },
+ { value: persona.name, label: "Twój typ", highlight: true },
+ ];
+}
+
+function wrappedPath(data: WrappedStoryData): "/wrapped" | "/wrapped/global" {
+ return data.is_global === true ? "/wrapped/global" : "/wrapped";
+}
+
+function fitText(
+ context: CanvasRenderingContext2D,
+ text: string,
+ x: number,
+ y: number,
+ maxWidth: number,
+ baseSize: number,
+ family: string,
+): void {
+ let size = baseSize;
+ context.font = `${String(size)}px ${family}`;
+ while (context.measureText(text).width > maxWidth && size > 18) {
+ size -= 2;
+ context.font = `${String(size)}px ${family}`;
+ }
+ context.fillText(text, x, y);
+}
+
+function roundRect(
+ context: CanvasRenderingContext2D,
+ x: number,
+ y: number,
+ w: number,
+ h: number,
+ r: number,
+): void {
+ context.beginPath();
+ context.moveTo(x + r, y);
+ context.arcTo(x + w, y, x + w, y + h, r);
+ context.arcTo(x + w, y + h, x, y + h, r);
+ context.arcTo(x, y + h, x, y, r);
+ context.arcTo(x, y, x + w, y, r);
+ context.closePath();
+}
+
+function drawStar(
+ context: CanvasRenderingContext2D,
+ x: number,
+ y: number,
+ size: number,
+ color: string,
+ alpha: number,
+): void {
+ context.save();
+ context.translate(x, y);
+ context.globalAlpha = alpha;
+ context.fillStyle = color;
+ context.beginPath();
+ for (let index = 0; index < 8; index++) {
+ const radius = index % 2 === 0 ? size : size * 0.34;
+ const angle = -Math.PI / 2 + (index * Math.PI) / 4;
+ const px = Math.cos(angle) * radius;
+ const py = Math.sin(angle) * radius;
+ if (index === 0) {
+ context.moveTo(px, py);
+ } else {
+ context.lineTo(px, py);
+ }
+ }
+ context.closePath();
+ context.fill();
+ context.restore();
+}
+
+function drawShareDecorations(
+ context: CanvasRenderingContext2D,
+ theme: ShareTheme,
+): void {
+ if (theme.decoration === "none") {
+ return;
+ }
+
+ const rng = createRng(theme.slideIndex * 1297 + 191);
+ const colors = [
+ theme.accent,
+ theme.fg,
+ theme.danger,
+ "#19d3da",
+ "#ffb020",
+ "#7b2dff",
+ ];
+
+ context.save();
+
+ switch (theme.decoration) {
+ case "sparks": {
+ for (let index = 0; index < 24; index++) {
+ drawStar(
+ context,
+ rng() * WIDTH,
+ rng() * HEIGHT,
+ 10 + rng() * 22,
+ theme.accent,
+ 0.08 + rng() * 0.16,
+ );
+ }
+ break;
+ }
+ case "confetti": {
+ for (let index = 0; index < 42; index++) {
+ context.save();
+ context.translate(rng() * WIDTH, rng() * HEIGHT);
+ context.rotate(rng() * Math.PI);
+ context.globalAlpha = 0.28 + rng() * 0.22;
+ context.fillStyle =
+ colors[Math.floor(rng() * colors.length)] ?? theme.accent;
+ roundRect(context, -7, -16, 14, 32, 3);
+ context.fill();
+ context.restore();
+ }
+ break;
+ }
+ case "lines": {
+ context.strokeStyle = withAlpha(theme.fg, 0.18);
+ context.lineWidth = 12;
+ for (let index = 0; index < 9; index++) {
+ context.beginPath();
+ context.ellipse(
+ rng() < 0.5 ? 80 : WIDTH - 80,
+ rng() < 0.5 ? 120 : HEIGHT - 180,
+ 120 + index * 38,
+ 72 + index * 24,
+ rng() * Math.PI,
+ 0,
+ Math.PI * 2,
+ );
+ context.stroke();
+ }
+ for (let index = 0; index < 16; index++) {
+ context.fillStyle =
+ rng() < 0.42 ? theme.danger : withAlpha(theme.fg, 0.34);
+ context.beginPath();
+ context.arc(
+ WIDTH * (0.62 + rng() * 0.34),
+ HEIGHT * (0.72 + rng() * 0.18),
+ 8 + rng() * 16,
+ 0,
+ Math.PI * 2,
+ );
+ context.fill();
+ }
+ break;
+ }
+ case "summer": {
+ const sun = context.createRadialGradient(
+ WIDTH - 150,
+ HEIGHT - 210,
+ 20,
+ WIDTH - 150,
+ HEIGHT - 210,
+ 160,
+ );
+ sun.addColorStop(0, withAlpha(theme.accent, 0.5));
+ sun.addColorStop(1, withAlpha(theme.accent, 0));
+ context.fillStyle = sun;
+ context.beginPath();
+ context.arc(WIDTH - 150, HEIGHT - 210, 160, 0, Math.PI * 2);
+ context.fill();
+
+ context.strokeStyle = withAlpha(theme.fg, 0.22);
+ context.lineWidth = 10;
+ for (let index = 0; index < 3; index++) {
+ context.beginPath();
+ context.moveTo(80, HEIGHT - 260 + index * 34);
+ context.bezierCurveTo(
+ 230,
+ HEIGHT - 310 + index * 34,
+ 330,
+ HEIGHT - 210 + index * 34,
+ 490,
+ HEIGHT - 260 + index * 34,
+ );
+ context.stroke();
+ }
+ break;
+ }
+ case "wrapped":
+ case "blobs": {
+ const blobColors =
+ theme.decoration === "wrapped"
+ ? [theme.danger, theme.accent, "#19d3da", "#7b2dff"]
+ : [theme.accent, theme.fg, theme.danger];
+ for (let index = 0; index < 5; index++) {
+ const x = rng() * WIDTH;
+ const y = rng() * HEIGHT;
+ const radius = 180 + rng() * 220;
+ const color = blobColors[index % blobColors.length] ?? theme.accent;
+ const gradient = context.createRadialGradient(x, y, 20, x, y, radius);
+ gradient.addColorStop(0, withAlpha(color, 0.18));
+ gradient.addColorStop(1, withAlpha(color, 0));
+ context.fillStyle = gradient;
+ context.beginPath();
+ context.arc(x, y, radius, 0, Math.PI * 2);
+ context.fill();
+ }
+ break;
+ }
+ }
+
+ context.restore();
+}
+
+/** Render the shareable summary card to a PNG blob, in the given theme. */
+export async function generateShareImage(
+ data: WrappedStoryData,
+ theme: ShareTheme,
+): Promise
{
+ if (typeof document === "undefined") {
+ return null;
+ }
+ const canvas = document.createElement("canvas");
+ canvas.width = WIDTH;
+ canvas.height = HEIGHT;
+ const context = canvas.getContext("2d");
+ if (context === null) {
+ return null;
+ }
+
+ const display = wrappedDisplay.style.fontFamily;
+ const sans = wrappedSans.style.fontFamily;
+ try {
+ await document.fonts.ready;
+ await Promise.all([
+ document.fonts.load(`96px ${display}`),
+ document.fonts.load(`600 36px ${sans}`),
+ ]);
+ } catch {
+ // proceed with whatever is available
+ }
+
+ const { bg, fg, accent } = theme;
+ const muted = withAlpha(fg, 0.55);
+ const onAccent = isDark(accent) ? "#ffffff" : "#15171c";
+
+ // Background.
+ context.fillStyle = bg;
+ context.fillRect(0, 0, WIDTH, HEIGHT);
+ drawShareDecorations(context, theme);
+
+ // Header.
+ context.textBaseline = "alphabetic";
+ context.fillStyle = accent;
+ context.font = `700 34px ${sans}`;
+ context.fillText(`TESTOWNIK · WRAPPED ${data.season.year_label}`, PAD, 180);
+
+ // Title.
+ context.fillStyle = fg;
+ context.font = `150px ${display}`;
+ context.fillText("TAKI BYŁ", PAD, 360);
+ context.fillText(
+ data.is_global === true ? "TESTOWNIK" : "MÓJ SEMESTR",
+ PAD,
+ 500,
+ );
+
+ // Stats grid (2 cols × 3 rows).
+ const gridTop = 660;
+ const gap = 26;
+ const tileW = (WIDTH - PAD * 2 - gap) / 2;
+ const tileH = 300;
+ const tiles = summaryTiles(data);
+ for (const [index, tile] of tiles.entries()) {
+ const col = index % 2;
+ const row = Math.floor(index / 2);
+ const x = PAD + col * (tileW + gap);
+ const y = gridTop + row * (tileH + gap);
+
+ context.strokeStyle = withAlpha(fg, 0.18);
+ context.lineWidth = 2;
+ roundRect(context, x, y, tileW, tileH, 28);
+ context.stroke();
+
+ context.fillStyle = tile.highlight ? accent : fg;
+ fitText(context, tile.value, x + 36, y + 150, tileW - 72, 96, display);
+
+ context.fillStyle = muted;
+ context.font = `500 32px ${sans}`;
+ context.fillText(tile.label.toUpperCase(), x + 38, y + 210);
+ }
+
+ // Footer link pill.
+ const host = env.NEXT_PUBLIC_SITE_URL.replace(/^https?:\/\//, "").replace(
+ /\/$/,
+ "",
+ );
+ const link = `${host}${wrappedPath(data)}`;
+ context.font = `700 38px ${sans}`;
+ const pillW = context.measureText(link).width + 96;
+ const pillH = 92;
+ const pillX = (WIDTH - pillW) / 2;
+ const pillY = HEIGHT - PAD - pillH;
+ context.fillStyle = accent;
+ roundRect(context, pillX, pillY, pillW, pillH, pillH / 2);
+ context.fill();
+ context.fillStyle = onAccent;
+ context.textAlign = "center";
+ context.fillText(link, WIDTH / 2, pillY + 60);
+ context.textAlign = "left";
+
+ return new Promise((resolve) => {
+ canvas.toBlob((blob) => {
+ resolve(blob);
+ }, "image/png");
+ });
+}
+
+function shareText(data: WrappedStoryData): string {
+ const hours = Math.floor(data.study_time.total_minutes / 60);
+ if (data.is_global === true) {
+ return (
+ `Testownik Wrapped: ${String(hours)} godz nauki, ` +
+ `${formatValue(data.volume.total_answers, "int")} pytań, ` +
+ `${String(data.accuracy.percent)}% skuteczności w całym semestrze.`
+ );
+ }
+
+ return (
+ `Mój Testownik Wrapped: ${String(hours)} godz nauki, ` +
+ `${formatValue(data.volume.total_answers, "int")} pytań, ` +
+ `${String(data.accuracy.percent)}% skuteczności. Top ${String(
+ data.rank.top_percent,
+ )}%!`
+ );
+}
+
+/**
+ * Share the Wrapped summary. The link is included in the text (not as a
+ * separate `url` field) so targets receive the same caption/link payload in
+ * both native and fallback flows.
+ */
+export async function shareWrapped(
+ data: WrappedStoryData,
+ theme: ShareTheme,
+): Promise {
+ const url = `${env.NEXT_PUBLIC_SITE_URL.replace(/\/$/, "")}${wrappedPath(data)}`;
+ const text = shareText(data);
+ const textWithLink = `${text} ${url}`;
+ const blob = await generateShareImage(data, theme);
+
+ const nav = navigator as unknown as {
+ share?: (data?: ShareData) => Promise;
+ canShare?: (data?: ShareData) => boolean;
+ };
+
+ if (blob !== null) {
+ const file = new File([blob], "testownik-wrapped.png", {
+ type: "image/png",
+ });
+ const shareData: ShareData = {
+ files: [file],
+ text: textWithLink,
+ title: "Testownik Wrapped",
+ };
+ if (nav.canShare?.(shareData) === true && typeof nav.share === "function") {
+ try {
+ await nav.share(shareData);
+ return;
+ } catch {
+ // Native share may reject after the target handled/cancelled the file.
+ // Do not fall through, or we risk creating a duplicate image/link.
+ return;
+ }
+ }
+ downloadBlob(blob, "testownik-wrapped.png");
+ void copyLink(textWithLink);
+ toast.success("Zapisano obrazek — link do Wrapped skopiowany!");
+ return;
+ }
+
+ if (typeof nav.share === "function") {
+ try {
+ await nav.share({ text: textWithLink, title: "Testownik Wrapped" });
+ return;
+ } catch {
+ // fall through
+ }
+ }
+ void copyLink(textWithLink);
+ toast.success("Link do Wrapped skopiowany!");
+}
+
+function downloadBlob(blob: Blob, filename: string): void {
+ const href = URL.createObjectURL(blob);
+ const anchor = document.createElement("a");
+ anchor.href = href;
+ anchor.download = filename;
+ document.body.append(anchor);
+ anchor.click();
+ anchor.remove();
+ URL.revokeObjectURL(href);
+}
+
+async function copyLink(value: string): Promise {
+ try {
+ await navigator.clipboard.writeText(value);
+ } catch {
+ // clipboard unavailable — best effort
+ }
+}
diff --git a/src/app/wrapped/slides/index.tsx b/src/app/wrapped/slides/index.tsx
new file mode 100644
index 00000000..b6e7360a
--- /dev/null
+++ b/src/app/wrapped/slides/index.tsx
@@ -0,0 +1,1507 @@
+"use client";
+
+/* This module is a slide registry: it intentionally co-locates the slide
+ components with the `SLIDE_REGISTRY` lookup that the config references.
+ Fast-refresh's "only export components" rule doesn't apply to this pattern. */
+/* eslint-disable react-refresh/only-export-components */
+import formbricks from "@formbricks/js";
+import type { CSSProperties, ComponentType } from "react";
+import { SiGithub } from "react-icons/si";
+
+import { MarkdownRenderer } from "@/components/markdown-renderer";
+import type { WrappedStoryData } from "@/types/wrapped";
+
+import { CountUp, TimeCountUp } from "../components/count-up";
+import { formatStudyDuration, peakHourLabel, personaForHour } from "../derived";
+import { formatValue } from "../format";
+import { FORMBRICKS_SURVEY_ACTION, GITHUB_URL } from "../wrapped.config";
+import type { SlideId } from "../wrapped.config";
+
+export interface SlideProps {
+ data: WrappedStoryData;
+ onShare: () => void;
+ onRestart: () => void;
+}
+
+const FD = "var(--fd)";
+const FM = "var(--fm)";
+const HL = "var(--hl)";
+const muted = (pct: number) =>
+ `color-mix(in srgb, currentColor ${String(pct)}%, transparent)`;
+
+const HOUR_BUCKETS = Array.from({ length: 24 }, (_, hour) => hour);
+
+const int = (n: number) => formatValue(n, "int");
+const isGlobalStory = (data: WrappedStoryData) => data.is_global === true;
+const volumeHeroFontSize = (value: number) => {
+ const length = int(value).length;
+
+ if (length >= 11) {
+ return "clamp(42px, 13vw, 66px)";
+ }
+ if (length >= 10) {
+ return "clamp(46px, 14.5vw, 72px)";
+ }
+ if (length >= 9) {
+ return "clamp(54px, 17vw, 86px)";
+ }
+ if (length >= 8) {
+ return "clamp(62px, 21vw, 104px)";
+ }
+ return "clamp(72px,26vw,124px)";
+};
+
+interface SummaryItem {
+ val: string;
+ label: string;
+ highlight: boolean;
+ compact?: boolean;
+}
+
+function summaryValueStyle(item: SummaryItem): CSSProperties {
+ const length = item.val.length;
+ const compact = item.compact === true || length > 16;
+ const fontSize =
+ length >= 34
+ ? "13px"
+ : length >= 26
+ ? "14px"
+ : length >= 20
+ ? "15px"
+ : compact
+ ? "17px"
+ : "26px";
+
+ return {
+ fontFamily: FD,
+ fontSize,
+ lineHeight: compact ? 1.08 : 1,
+ color: item.highlight ? HL : "currentColor",
+ overflow: "hidden",
+ display: "-webkit-box",
+ WebkitLineClamp: length >= 34 ? 4 : length >= 24 ? 3 : compact ? 2 : 1,
+ WebkitBoxOrient: "vertical",
+ overflowWrap: "anywhere",
+ wordBreak: "break-word",
+ };
+}
+
+/** Bordered stat chip used on a few slides. */
+function StatBox({ value, label }: { value: string; label: string }) {
+ return (
+
+
+ {value}
+
+
+ {label}
+
+
+ );
+}
+
+/* ------------------------------------------------------------------ intro */
+function IntroSlide({ data }: SlideProps) {
+ const global = isGlobalStory(data);
+
+ return (
+
+
+ {data.season.label}
+
+
+ {global ? "Cały" : "Twój"}
+
+ {global ? "Testownik" : "semestr"}
+
+
+ {global ? "w semestrze" : "w liczbach"}
+
+
+
+ {data.season.date_range}
+
+
+ ▍ {" "}
+ dotknij, aby zacząć →
+
+
+ );
+}
+
+/* ------------------------------------------------------------------- time */
+function TimeSlide({ data }: SlideProps) {
+ const global = isGlobalStory(data);
+ return (
+
+
+ {global ? "Czas całej platformy" : "Czas w Testowniku"}
+
+
+ {global
+ ? "W tym semestrze spędzono nad nauką"
+ : "W tym semestrze spędziłeś nad nauką"}
+
+
+
+
+
+
+ ≈ {data.study_time.total_minutes / 20}
+
+
+ rundek w
+
+ lige
+
+
+
+ );
+}
+
+/* ----------------------------------------------------------------- volume */
+function VolumeSlide({ data }: SlideProps) {
+ const global = isGlobalStory(data);
+
+ return (
+
+
+ {global ? "Odpowiedzi na Testowniku" : "Liczba odpowiedzi"}
+
+
+ {global
+ ? "W całym Testowniku kliknięto odpowiedzi"
+ : "Kliknięto odpowiedzi"}
+
+
+
+
+
+ razy.
+
+
+
+
+
+
+ );
+}
+
+/* --------------------------------------------------------------- accuracy */
+function AccuracySlide({ data }: SlideProps) {
+ const global = isGlobalStory(data);
+ const correct = Math.round(data.accuracy.percent);
+ return (
+
+
+ {global ? "Jak odpowiadaliście?" : "Twoja odpowiedzi"}
+
+
+
+
+
+ {Array.from({ length: 100 }, (_, index) => (
+
+ ))}
+
+
+
+
+ {int(data.accuracy.correct)} dobrze
+
+
+
+ {int(data.accuracy.wrong)} źle
+
+
+
+ Za pierwszym podejściem {" "}
+ {global ? "trafiano w " : "trafiałeś w "}
+
+ {data.accuracy.first_attempt_percent}%
+ {" "}
+ pytań.
+
+
+ );
+}
+
+/* ---------------------------------------------------------------- rhythm */
+function RhythmSlide({ data }: SlideProps) {
+ const global = isGlobalStory(data);
+ const persona = personaForHour(data.rhythm.peak_hour, {
+ isGlobal: global,
+ });
+
+ return (
+
+
+ Godziny aktywności
+
+
+ Najwięcej
+
+ {global ? "odpowiadano o " : "odpowiadasz o "}
+
+ {peakHourLabel(data.rhythm.peak_hour)}
+
+
+
+ {HOUR_BUCKETS.map((hour) => {
+ const total = data.rhythm.hours[hour] ?? 0;
+ const correct = data.rhythm.correct_hours[hour] ?? 0;
+ return (
+
+ {/* All answers (behind). */}
+
+ {/* Correct answers (front). */}
+
+
+ );
+ })}
+
+
+ 00
+ 06
+ 12
+ 18
+ 23
+
+
+
+
+ wszystkie
+
+
+
+ poprawne
+
+
+
+
+ {global ? "Typ semestru" : "Twój typ"}
+
+
+ {persona.name}
+
+
+ {persona.description}
+
+
+
+ );
+}
+
+/* ------------------------------------------------------------------- top */
+function TopSlide({ data }: SlideProps) {
+ const global = isGlobalStory(data);
+ const max = data.top_quizzes[0]?.value || 1;
+ return (
+
+
+ {global ? "Najdłużej rozwiązywane" : "Najwięcej czasu"}
+
+
+ {global ? "Top quizy Testownika" : "Twoje top quizy"}
+
+
+ {data.top_quizzes.map((q, index) => (
+
+
+
+ {q.rank}
+
+
+ {q.name}
+
+
+ {formatStudyDuration(q.value)}
+
+
+
+
+ ))}
+
+
+ );
+}
+
+/* --------------------------------------------------------------- hardest */
+function HardestSlide({ data }: SlideProps) {
+ const global = isGlobalStory(data);
+ const q = data.hardest_question;
+ if (q == null) {
+ return null;
+ }
+ const hasImage = q.image != null && q.image !== "";
+ return (
+
+ {/* Faint floating "trauma" labels behind the content. */}
+
+
+ PTSD
+
+
+
+
+ flashbacki z wietnamu
+
+
+
+
+
+ {global ? "Koszmar semestru" : "Twój koszmar"}
+
+
+ {global
+ ? "Pytanie, które najczęściej dawało popalić"
+ : "Pytanie, które dało Ci popalić"}
+
+
+
+ Pytanie #{q.question_number} · quiz {q.quiz_name}
+
+ {/* Markdown so code / formatting renders; clamped so a long
+ question (with code blocks) can't blow out the card. */}
+
+ {hasImage ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : null}
+
+
+
+ {q.wrong_count}×
+
+
+ źle
+
+
+
+
+ {q.correct_count}×
+
+
+ dobrze
+
+
+
+
+
+
+ );
+}
+
+/* --------------------------------------------------------------- creator */
+function CreatorSlide({ data }: SlideProps) {
+ const c = data.creator_impact;
+ if (c == null) {
+ return null;
+ }
+ return (
+
+
+ Twoje quizy u innych
+
+
+ Twoje quizy uczą innych
+
+
+
+
+
+ osoby
+
+
+
+
+
+
+ );
+}
+
+/* ------------------------------------------------------------------ rank */
+function RankSlide({ data }: SlideProps) {
+ return (
+
+
+ Na tle innych
+
+
+ Jesteś w
+
+
+
+ top
+
+
+
+
+
+
+ najwytrwalszych studentów na Testowniku w tym semestrze.
+
+
+
+ );
+}
+
+/* ----------------------------------------------------------------- outro */
+function OutroSlide({ data, onShare, onRestart }: SlideProps) {
+ const global = isGlobalStory(data);
+ const persona = personaForHour(data.rhythm.peak_hour, {
+ isGlobal: global,
+ });
+ const topQuizName = data.top_quizzes[0]?.name ?? "brak danych";
+ const summary: SummaryItem[] = global
+ ? [
+ {
+ val: `${String(Math.floor(data.study_time.total_minutes / 60))} godz`,
+ label: "czasu nauki",
+ highlight: false,
+ },
+ {
+ val: int(data.volume.total_answers),
+ label: "pytań",
+ highlight: true,
+ },
+ {
+ val: `${String(data.accuracy.percent)}%`,
+ label: "skuteczność",
+ highlight: false,
+ },
+ { val: int(data.volume.sessions), label: "sesji", highlight: true },
+ {
+ val: persona.name,
+ label: "rytm semestru",
+ highlight: false,
+ compact: true,
+ },
+ {
+ val: topQuizName,
+ label: "najdłuższy quiz",
+ highlight: true,
+ compact: true,
+ },
+ ]
+ : [
+ {
+ val: `${String(Math.floor(data.study_time.total_minutes / 60))} godz`,
+ label: "czasu nauki",
+ highlight: false,
+ },
+ {
+ val: int(data.volume.total_answers),
+ label: "pytań",
+ highlight: true,
+ },
+ {
+ val: `${String(data.accuracy.percent)}%`,
+ label: "skuteczność",
+ highlight: false,
+ },
+ { val: int(data.volume.sessions), label: "sesji", highlight: true },
+ {
+ val: `top ${String(data.rank.top_percent)}%`,
+ label: "najwytrwalszych",
+ highlight: false,
+ },
+ {
+ val: persona.name,
+ label: "Twój typ",
+ highlight: true,
+ compact: true,
+ },
+ ];
+
+ return (
+
+
+ Testownik
+ · Wrapped {data.season.year_label}
+
+
+ {global ? "Taki był Testownik" : "Taki był Twój semestr"}
+
+
+ {summary.map((s) => (
+
+
{s.val}
+
+ {s.label}
+
+
+ ))}
+
+
+
+ Udostępnij
+
+
+ Od nowa
+
+
+
+
+ Wbij gwiazdkę
+
+
{
+ void formbricks.track(FORMBRICKS_SURVEY_ACTION);
+ }}
+ style={{
+ flex: 1,
+ height: "42px",
+ borderRadius: "12px",
+ background: "transparent",
+ border: `1.5px solid ${muted(35)}`,
+ color: "currentColor",
+ fontFamily: FM,
+ fontSize: "13px",
+ fontWeight: 600,
+ cursor: "pointer",
+ }}
+ >
+ Oceń nas
+
+
+
+ );
+}
+
+/** Slide id → component. Add an entry here when you add a slide to the config. */
+export const SLIDE_REGISTRY: Record> = {
+ intro: IntroSlide,
+ time: TimeSlide,
+ volume: VolumeSlide,
+ accuracy: AccuracySlide,
+ rhythm: RhythmSlide,
+ top: TopSlide,
+ hardest: HardestSlide,
+ creator: CreatorSlide,
+ rank: RankSlide,
+ outro: OutroSlide,
+};
diff --git a/src/app/wrapped/theme.ts b/src/app/wrapped/theme.ts
new file mode 100644
index 00000000..1dc4706a
--- /dev/null
+++ b/src/app/wrapped/theme.ts
@@ -0,0 +1,71 @@
+import type { Palette } from "./wrapped.config";
+
+/** Per-slide resolved colours. */
+export interface SlideColors {
+ bg: string;
+ fg: string;
+ accent: string;
+ danger: string;
+}
+
+function parseHex(hex: string): number | null {
+ if (!hex.startsWith("#")) {
+ return null;
+ }
+ const h = hex.replace("#", "");
+ // Expand shorthand (#abc → #aabbcc) by doubling each hex digit.
+ const full = h.length === 3 ? h.replaceAll(/(.)/g, "$1$1") : h;
+ return Number.parseInt(full, 16);
+}
+
+/** Translate a hex colour to an `rgba()` string at the given alpha. */
+export function withAlpha(hex: string, alpha: number): string {
+ const n = parseHex(hex);
+ if (n === null) {
+ return hex;
+ }
+ const r = (n >> 16) & 255;
+ const g = (n >> 8) & 255;
+ const b = n & 255;
+ return `rgba(${String(r)},${String(g)},${String(b)},${String(alpha)})`;
+}
+
+/** Perceptual-luminance check used to pick a readable accent. */
+export function isDark(hex: string): boolean {
+ const n = parseHex(hex);
+ if (n === null) {
+ return false;
+ }
+ const r = (n >> 16) & 255;
+ const g = (n >> 8) & 255;
+ const b = n & 255;
+ return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255 < 0.45;
+}
+
+/** Deterministic LCG — same seed always yields the same decoration layout. */
+export function createRng(seed: number): () => number {
+ let s = seed >>> 0;
+ return () => {
+ s = (s * 1_664_525 + 1_013_904_223) >>> 0;
+ return s / 4_294_967_296;
+ };
+}
+
+/** Resolve background / foreground / accent / danger for a slide index. */
+export function slideColors(palette: Palette, index: number): SlideColors {
+ const n = palette.pop.length;
+ const bg = palette.pop[index % n];
+ const fg = palette.fg[index % n];
+ const accent = isDark(bg) ? palette.hlDark : fg;
+ return { bg, fg, accent, danger: palette.danger };
+}
+
+/** Seeded array shuffle (Fisher–Yates). */
+export function shuffle(array: readonly T[], rng: () => number): T[] {
+ const a = [...array];
+ for (let index = a.length - 1; index > 0; index--) {
+ const target = Math.trunc(rng() * (index + 1));
+ [a[index], a[target]] = [a[target], a[index]];
+ }
+ return a;
+}
diff --git a/src/app/wrapped/use-prefers-reduced-motion.ts b/src/app/wrapped/use-prefers-reduced-motion.ts
new file mode 100644
index 00000000..cb2d4a35
--- /dev/null
+++ b/src/app/wrapped/use-prefers-reduced-motion.ts
@@ -0,0 +1,26 @@
+"use client";
+
+import { useSyncExternalStore } from "react";
+
+const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
+
+function subscribe(onStoreChange: () => void): () => void {
+ const mediaQuery = window.matchMedia(REDUCED_MOTION_QUERY);
+ mediaQuery.addEventListener("change", onStoreChange);
+ return () => {
+ mediaQuery.removeEventListener("change", onStoreChange);
+ };
+}
+
+function getSnapshot(): boolean {
+ return window.matchMedia(REDUCED_MOTION_QUERY).matches;
+}
+
+function getServerSnapshot(): boolean {
+ return false;
+}
+
+/** Reactively tracks the user's `prefers-reduced-motion` setting. */
+export function usePrefersReducedMotion(): boolean {
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
+}
diff --git a/src/app/wrapped/use-wrapped-player.ts b/src/app/wrapped/use-wrapped-player.ts
new file mode 100644
index 00000000..9a79633e
--- /dev/null
+++ b/src/app/wrapped/use-wrapped-player.ts
@@ -0,0 +1,215 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+
+/** Pointer held shorter than this counts as a tap (navigate), not a pause. */
+const TAP_MS = 240;
+
+function nowMs(): number {
+ return typeof performance === "undefined" ? Date.now() : performance.now();
+}
+
+function clampIndex(index: number, count: number): number {
+ const max = Math.max(0, count - 1);
+ return Math.max(0, Math.min(max, index));
+}
+
+interface UseWrappedPlayerOptions {
+ count: number;
+ getDuration: (index: number) => number;
+}
+
+interface HoldHandlers {
+ onPointerDown: () => void;
+ onPointerUp: () => void;
+ onPointerLeave: () => void;
+ onPointerCancel: () => void;
+}
+
+export interface WrappedPlayer {
+ idx: number;
+ count: number;
+ /** Whether the user has interacted yet (autoplay waits for the first tap). */
+ started: boolean;
+ /** Attach to the stage element — receives the live `--wr-progress` var. */
+ stageRef: React.RefObject;
+ next: () => void;
+ prev: () => void;
+ goto: (index: number) => void;
+ /** Tap/hold handlers for the left (back) zone. */
+ leftHold: HoldHandlers;
+ /** Tap/hold handlers for the right (forward) zone. */
+ rightHold: HoldHandlers;
+}
+
+/**
+ * Story-player state machine: drives auto-advance, the progress fill, hold-to-pause,
+ * tap-to-navigate and keyboard control. Autoplay only kicks in after the first user
+ * interaction, so the intro slide waits for a tap. Progress is written imperatively
+ * to a CSS variable so per-frame updates never re-render the slides.
+ *
+ * (React Compiler memoizes the callbacks below — no manual `useCallback` needed.)
+ */
+export function useWrappedPlayer({
+ count,
+ getDuration,
+}: UseWrappedPlayerOptions): WrappedPlayer {
+ const [index, setIndex] = useState(0);
+ const [started, setStarted] = useState(false);
+ const stageRef = useRef(null);
+ const visibleIndex = clampIndex(index, count);
+
+ // Mutable timing state, kept out of React to avoid per-frame renders.
+ const timing = useRef({
+ slideStart: 0,
+ paused: false,
+ pauseAt: 0,
+ advancing: false,
+ holdStart: 0,
+ started: false,
+ });
+
+ // Latest values for the rAF loop to read without re-subscribing.
+ const live = useRef({ idx: visibleIndex, count, getDuration });
+ live.current = { idx: visibleIndex, count, getDuration };
+
+ const markStarted = () => {
+ if (!timing.current.started) {
+ timing.current.started = true;
+ setStarted(true);
+ }
+ };
+
+ const goto = (target: number) => {
+ markStarted();
+ setIndex((current) => {
+ const clamped = clampIndex(target, live.current.count);
+ return clamped === current ? current : clamped;
+ });
+ };
+
+ const next = () => {
+ goto(live.current.idx + 1);
+ };
+
+ const previous = () => {
+ goto(live.current.idx - 1);
+ };
+
+ // Latest nav handlers, so the lifetime effects below can run once (empty deps)
+ // without going stale.
+ const handlers = useRef({ next, previous });
+ handlers.current = { next, previous };
+
+ // Reset timing whenever the slide changes.
+ useEffect(() => {
+ const t = timing.current;
+ t.slideStart = nowMs();
+ t.pauseAt = 0;
+ t.paused = false;
+ t.advancing = false;
+ const stage = stageRef.current;
+ if (stage !== null) {
+ stage.style.setProperty("--wr-progress", "0");
+ stage.style.setProperty("--wr-index", String(visibleIndex));
+ }
+ }, [visibleIndex]);
+
+ // Single rAF loop for the lifetime of the player.
+ useEffect(() => {
+ let raf = 0;
+ const setProgress = (pf: number) => {
+ stageRef.current?.style.setProperty("--wr-progress", String(pf));
+ };
+ const tick = () => {
+ const t = timing.current;
+ const { idx: current, count: n, getDuration: dur } = live.current;
+ if (!t.paused) {
+ const isLast = current >= n - 1;
+ const canAdvance = t.started && !isLast;
+ if (canAdvance && !t.advancing) {
+ const pf = Math.min(1, (nowMs() - t.slideStart) / dur(current));
+ setProgress(pf);
+ if (pf >= 1) {
+ t.advancing = true;
+ handlers.current.next();
+ }
+ } else {
+ // Not started yet → empty; last/started → full.
+ setProgress(t.started ? 1 : 0);
+ }
+ }
+ raf = requestAnimationFrame(tick);
+ };
+ raf = requestAnimationFrame(tick);
+ return () => {
+ cancelAnimationFrame(raf);
+ };
+ }, []);
+
+ // Keyboard control.
+ useEffect(() => {
+ const onKey = (event: KeyboardEvent) => {
+ if (
+ event.key === "ArrowRight" ||
+ event.key === " " ||
+ event.key === "Spacebar"
+ ) {
+ event.preventDefault();
+ handlers.current.next();
+ } else if (event.key === "ArrowLeft") {
+ event.preventDefault();
+ handlers.current.previous();
+ }
+ };
+ window.addEventListener("keydown", onKey);
+ return () => {
+ window.removeEventListener("keydown", onKey);
+ };
+ }, []);
+
+ const pause = () => {
+ const t = timing.current;
+ if (!t.paused) {
+ t.paused = true;
+ t.pauseAt = nowMs();
+ }
+ };
+
+ const resume = () => {
+ const t = timing.current;
+ if (t.paused) {
+ const pausedAt = t.pauseAt === 0 ? nowMs() : t.pauseAt;
+ t.slideStart += nowMs() - pausedAt;
+ t.paused = false;
+ }
+ };
+
+ const makeHold = (onTap: () => void): HoldHandlers => ({
+ onPointerDown: () => {
+ timing.current.holdStart = nowMs();
+ pause();
+ },
+ onPointerUp: () => {
+ const held = nowMs() - timing.current.holdStart;
+ resume();
+ if (held < TAP_MS) {
+ onTap();
+ }
+ },
+ onPointerLeave: resume,
+ onPointerCancel: resume,
+ });
+
+ return {
+ idx: visibleIndex,
+ count,
+ started,
+ stageRef,
+ next,
+ prev: previous,
+ goto,
+ leftHold: makeHold(previous),
+ rightHold: makeHold(next),
+ };
+}
diff --git a/src/app/wrapped/wrapped.config.ts b/src/app/wrapped/wrapped.config.ts
new file mode 100644
index 00000000..780567f8
--- /dev/null
+++ b/src/app/wrapped/wrapped.config.ts
@@ -0,0 +1,295 @@
+/**
+ * Single source of truth for the Testownik Wrapped story.
+ *
+ * Everything you'd want to retune is hardcoded here:
+ * - which slides appear and in what order → `SLIDES`
+ * - per-slide auto-advance timing → `SLIDES[].duration` / `DEFAULT_DURATION`
+ * - colour palettes (themes) → `PALETTES`
+ * - the default look → `WRAPPED_DEFAULTS`
+ * - the logo easter-egg → `LOGO_EASTER_EGG_CLICKS`
+ *
+ * To add a slide: write a component, register it in `slides/index.tsx`, then add
+ * its id to `SLIDES`. To reorder: move the entry. To hide one: remove it (or give
+ * it an `enabled` predicate). No player/orchestration changes required.
+ *
+ * Whether the story is shown at all (vs. the empty state) and whether the
+ * "creator impact" slide appears are derived from the API response, not config.
+ */
+import type { WrappedData } from "@/types/wrapped";
+
+export type SlideId =
+ | "intro"
+ | "time"
+ | "volume"
+ | "accuracy"
+ | "rhythm"
+ | "top"
+ | "hardest"
+ | "creator"
+ | "rank"
+ | "outro";
+
+export const PALETTE_NAMES = [
+ "wrapped",
+ "summer",
+ "neon",
+ "indigo",
+ "pastel",
+] as const;
+export type PaletteName = (typeof PALETTE_NAMES)[number];
+
+export const DECORATION_NAMES = [
+ "none",
+ "summer",
+ "wrapped",
+ "lines",
+ "confetti",
+ "sparks",
+ "blobs",
+] as const;
+export type DecorationName = (typeof DECORATION_NAMES)[number];
+
+export const PROGRESS_NAMES = ["dots", "bars", "numbers", "none"] as const;
+export type ProgressStyle = (typeof PROGRESS_NAMES)[number];
+
+export interface Palette {
+ accent: string;
+ /** Accent used on dark backgrounds (where `fg` would clash). */
+ hlDark: string;
+ danger: string;
+ /** Per-slide background, cycled by slide index. */
+ pop: string[];
+ /** Per-slide foreground, cycled by slide index (parallel to `pop`). */
+ fg: string[];
+}
+
+export const PALETTES: Record = {
+ wrapped: {
+ accent: "#ff3d8b",
+ hlDark: "#c6ff3a",
+ danger: "#ff5147",
+ pop: [
+ "#15171c",
+ "#ff5147",
+ "#c6ff3a",
+ "#19d3da",
+ "#ffb020",
+ "#ff3d8b",
+ "#c6ff3a",
+ "#ff5147",
+ "#19d3da",
+ "#15171c",
+ ],
+ fg: [
+ "#ffffff",
+ "#ffffff",
+ "#15171c",
+ "#15171c",
+ "#15171c",
+ "#ffffff",
+ "#15171c",
+ "#ffffff",
+ "#15171c",
+ "#ffffff",
+ ],
+ },
+ summer: {
+ accent: "#ff4d6d",
+ hlDark: "#ffd23f",
+ danger: "#ff5d3c",
+ pop: [
+ "#0a3d4d",
+ "#ff7a4d",
+ "#ffd23f",
+ "#16c0b0",
+ "#ff5d8f",
+ "#ffd23f",
+ "#16c0b0",
+ "#ff7a4d",
+ "#3aa0ff",
+ "#0a3d4d",
+ ],
+ fg: [
+ "#ffe9c7",
+ "#15171c",
+ "#15171c",
+ "#06231f",
+ "#15171c",
+ "#15171c",
+ "#06231f",
+ "#15171c",
+ "#07263f",
+ "#ffe9c7",
+ ],
+ },
+ neon: {
+ accent: "#ff2d8b",
+ hlDark: "#c6ff3a",
+ danger: "#ff2d8b",
+ pop: [
+ "#0b0014",
+ "#c6ff3a",
+ "#ff2d8b",
+ "#7b2dff",
+ "#19d3da",
+ "#c6ff3a",
+ "#ff2d8b",
+ "#7b2dff",
+ "#19d3da",
+ "#0b0014",
+ ],
+ fg: [
+ "#c6ff3a",
+ "#0b0014",
+ "#ffffff",
+ "#ffffff",
+ "#0b0014",
+ "#0b0014",
+ "#ffffff",
+ "#ffffff",
+ "#0b0014",
+ "#c6ff3a",
+ ],
+ },
+ indigo: {
+ accent: "#2f6fd6",
+ hlDark: "#9db8e8",
+ danger: "#e23a4a",
+ pop: [
+ "#192941",
+ "#5278B9",
+ "#37548C",
+ "#e8eef7",
+ "#5278B9",
+ "#37548C",
+ "#192941",
+ "#5278B9",
+ "#37548C",
+ "#192941",
+ ],
+ fg: [
+ "#e8eef7",
+ "#ffffff",
+ "#ffffff",
+ "#192941",
+ "#ffffff",
+ "#ffffff",
+ "#e8eef7",
+ "#ffffff",
+ "#ffffff",
+ "#e8eef7",
+ ],
+ },
+ pastel: {
+ accent: "#ff7aa0",
+ hlDark: "#ffb3c1",
+ danger: "#ff7aa0",
+ pop: [
+ "#2b2440",
+ "#ffb3c1",
+ "#a0e8d8",
+ "#ffe0a3",
+ "#b8c6ff",
+ "#ffb3c1",
+ "#a0e8d8",
+ "#ffe0a3",
+ "#b8c6ff",
+ "#2b2440",
+ ],
+ fg: [
+ "#ffe0a3",
+ "#2b2440",
+ "#1f3b34",
+ "#3d2f10",
+ "#1d2742",
+ "#2b2440",
+ "#1f3b34",
+ "#3d2f10",
+ "#1d2742",
+ "#ffe0a3",
+ ],
+ },
+};
+
+export interface SlideConfig {
+ id: SlideId;
+ /** Auto-advance duration in ms. Falls back to `DEFAULT_DURATION`. */
+ duration?: number;
+ /** Optional gate (derived from API data). When false the slide is skipped. */
+ enabled?: (data: WrappedData) => boolean;
+}
+
+/** Time (ms) a slide stays on screen before auto-advancing. */
+export const DEFAULT_DURATION = 8200;
+
+/**
+ * Ordered story. Reorder / add / remove freely.
+ * The `creator` slide only appears when the API returns creator-impact data.
+ */
+export const SLIDES: SlideConfig[] = [
+ { id: "intro", duration: 5200 },
+ { id: "time" },
+ { id: "volume" },
+ { id: "accuracy" },
+ { id: "rhythm" },
+ { id: "top", duration: 10_500 },
+ {
+ id: "hardest",
+ duration: 9800,
+ enabled: (data) => data.hardest_question != null,
+ },
+ {
+ id: "creator",
+ enabled: (data) => data.is_global !== true && data.creator_impact != null,
+ },
+ { id: "rank", enabled: (data) => data.is_global !== true },
+ { id: "outro" },
+];
+
+/** Duration (ms) of the entrance / count-up animation on each slide. */
+export const ENTER_MS = 1100;
+
+/** Outro call-to-action targets. */
+export const GITHUB_URL = "https://github.com/Solvro/web-testownik";
+/** Formbricks action fired when the user opts into the survey. */
+export const FORMBRICKS_SURVEY_ACTION = "wrapped_survey";
+
+export interface WrappedSettings {
+ palette: PaletteName;
+ decoration: DecorationName;
+ progress: ProgressStyle;
+}
+
+export const WRAPPED_DEFAULTS: WrappedSettings = {
+ palette: "wrapped",
+ decoration: "summer",
+ progress: "bars",
+};
+
+// --- Logo easter egg -------------------------------------------------------
+
+/** Clicks on the logo (in a row) that trigger a random theme. */
+export const LOGO_EASTER_EGG_CLICKS = 5;
+/** Max gap (ms) between clicks for them to count as "in a row". */
+export const LOGO_EASTER_EGG_WINDOW_MS = 600;
+
+function randomFrom(items: readonly T[]): T {
+ return items[Math.floor(Math.random() * items.length)];
+}
+
+/**
+ * Pick a random, visible theme combination for the easter egg. It lives in
+ * React state only — deliberately not persisted, so it resets on reload.
+ */
+export function pickRandomSettings(): WrappedSettings {
+ return {
+ palette: randomFrom(PALETTE_NAMES),
+ decoration: randomFrom(DECORATION_NAMES.filter((d) => d !== "none")),
+ progress: randomFrom(PROGRESS_NAMES.filter((p) => p !== "none")),
+ };
+}
+
+/** The visible, ordered slides for the given API data. */
+export function resolveSlides(data: WrappedData): SlideConfig[] {
+ return SLIDES.filter((slide) => slide.enabled?.(data) ?? true);
+}
diff --git a/src/app/wrapped/wrapped.css b/src/app/wrapped/wrapped.css
new file mode 100644
index 00000000..4824fcb4
--- /dev/null
+++ b/src/app/wrapped/wrapped.css
@@ -0,0 +1,196 @@
+/* Testownik Wrapped — animation keyframes + scoped base styles.
+ Keyframe names are prefixed `wr-` to avoid colliding with Tailwind utilities. */
+
+@keyframes wr-rise {
+ from {
+ opacity: 0;
+ transform: translateY(18px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+@keyframes wr-pop {
+ 0% {
+ opacity: 0;
+ transform: scale(0.78);
+ }
+ 60% {
+ transform: scale(1.05);
+ }
+ 100% {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+@keyframes wr-grow {
+ from {
+ transform: scaleY(0);
+ }
+ to {
+ transform: scaleY(1);
+ }
+}
+@keyframes wr-wipe {
+ from {
+ clip-path: inset(0 100% 0 0);
+ }
+ to {
+ clip-path: inset(0 0 0 0);
+ }
+}
+@keyframes wr-bub {
+ from {
+ transform: scale(0);
+ }
+ to {
+ transform: scale(1);
+ }
+}
+@keyframes wr-blink {
+ 0%,
+ 48% {
+ opacity: 1;
+ }
+ 49%,
+ 100% {
+ opacity: 0;
+ }
+}
+@keyframes wr-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+@keyframes wr-twinkle {
+ 0%,
+ 100% {
+ opacity: 0.25;
+ transform: scale(0.6);
+ }
+ 50% {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+@keyframes wr-float {
+ 0%,
+ 100% {
+ transform: translateY(0) rotate(var(--r, 0deg));
+ }
+ 50% {
+ transform: translateY(-12px) rotate(var(--r, 0deg));
+ }
+}
+@keyframes wr-sway {
+ 0%,
+ 100% {
+ transform: rotate(-4deg);
+ }
+ 50% {
+ transform: rotate(4deg);
+ }
+}
+@keyframes wr-drift {
+ from {
+ transform: translateY(-30px) rotate(0deg);
+ }
+ to {
+ transform: translateY(40px) rotate(180deg);
+ }
+}
+
+/* Base styles, scoped to the Wrapped root. */
+.wrapped-root {
+ --font-display: var(--font-wrapped-display), "Anton", system-ui, sans-serif;
+ --font-sans: var(--font-wrapped-sans), "Space Grotesk", system-ui, sans-serif;
+ font-family: var(--font-sans);
+ /* Story taps shouldn't zoom, select text, or pop the callout menu. */
+ touch-action: manipulation;
+ user-select: none;
+ -webkit-user-select: none;
+ -webkit-touch-callout: none;
+}
+
+.wrapped-root * {
+ box-sizing: border-box;
+}
+
+/* Centred phone-sized shell (desktop). */
+.wrapped-shell-wrap {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 100%;
+}
+.wrapped-shell {
+ position: relative;
+ width: min(468px, 100%);
+ height: min(900px, calc(100dvh - 8rem));
+ display: flex;
+ flex-direction: column;
+}
+
+/* Smooth palette transitions between slides. */
+.wrapped-stage {
+ transition:
+ background-color 0.45s ease,
+ color 0.45s ease;
+}
+
+.wrapped-empty-stage {
+ width: 100%;
+}
+
+/* Mobile: middle story slides use the immersive fixed overlay. Intro/outro use
+ `.is-edge` to pull into a card, and the no-activity state uses its own card
+ class so it does not trap the rest of the app behind it. */
+@media (max-width: 640px) {
+ .wrapped-shell {
+ width: 100%;
+ height: auto;
+ }
+ .wrapped-stage {
+ position: fixed !important;
+ top: 0 !important;
+ left: 0 !important;
+ right: 0 !important;
+ bottom: 0 !important;
+ z-index: 50 !important;
+ margin: 0 !important;
+ border-radius: 0 !important;
+ box-shadow: none !important;
+ transition:
+ top 0.42s cubic-bezier(0.4, 0, 0.2, 1),
+ left 0.42s cubic-bezier(0.4, 0, 0.2, 1),
+ right 0.42s cubic-bezier(0.4, 0, 0.2, 1),
+ bottom 0.42s cubic-bezier(0.4, 0, 0.2, 1),
+ border-radius 0.42s cubic-bezier(0.4, 0, 0.2, 1),
+ background-color 0.45s ease,
+ color 0.45s ease;
+ }
+ .wrapped-stage.is-edge {
+ top: 4.75rem !important;
+ left: 0.75rem !important;
+ z-index: 10 !important;
+ right: 0.75rem !important;
+ bottom: 0.75rem !important;
+ border-radius: 22px !important;
+ box-shadow: 0 20px 60px -20px rgba(0, 0, 0, 0.5) !important;
+ }
+ .wrapped-empty-stage {
+ width: calc(100% - 1.5rem);
+ min-height: min(620px, calc(100dvh - 7rem)) !important;
+ margin: 0.75rem auto 1rem;
+ border-radius: 22px !important;
+ }
+}
+
+/* Respect reduced-motion: kill entrance + decorative animation, show final state. */
+@media (prefers-reduced-motion: reduce) {
+ .wrapped-root [style*="animation"],
+ .wrapped-root [data-wr-anim] {
+ animation: none !important;
+ }
+}
diff --git a/src/assets/logo-full-dark.svg b/src/assets/logo-full-dark.svg
new file mode 100644
index 00000000..e7e510f5
--- /dev/null
+++ b/src/assets/logo-full-dark.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/logo-full.svg b/src/assets/logo-full.svg
new file mode 100644
index 00000000..5aca0e38
--- /dev/null
+++ b/src/assets/logo-full.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/navbar/navbar-client.tsx b/src/components/navbar/navbar-client.tsx
index 71a889e2..a844e46d 100644
--- a/src/components/navbar/navbar-client.tsx
+++ b/src/components/navbar/navbar-client.tsx
@@ -7,6 +7,7 @@ import { AppContext } from "@/app-context";
import { AppLogo } from "@/components/app-logo";
import { AuthButtons } from "@/components/navbar/auth-buttons";
import { LogoutButton } from "@/components/navbar/logout-button";
+import { cn } from "@/lib/utils";
import { MobileMenu } from "./mobile-menu";
import { MobileMenuButton } from "./mobile-menu-button";
@@ -21,7 +22,12 @@ export function NavbarClient() {
};
return (
-
+
diff --git a/src/services/index.ts b/src/services/index.ts
index e7014163..7a12e774 100644
--- a/src/services/index.ts
+++ b/src/services/index.ts
@@ -3,6 +3,7 @@ import { API_URL } from "@/lib/api";
import { ImageService } from "./image.service";
import { QuizService } from "./quiz.service";
import { UserService } from "./user.service";
+import { WrappedService } from "./wrapped.service";
/**
* Service registry for managing all API services
@@ -11,6 +12,7 @@ export class ServiceRegistry {
private quizService: QuizService;
private userService: UserService;
private imageService: ImageService;
+ private wrappedService: WrappedService;
constructor(
baseURL: string,
@@ -20,6 +22,11 @@ export class ServiceRegistry {
this.quizService = new QuizService(baseURL, defaultHeaders, accessToken);
this.userService = new UserService(baseURL, defaultHeaders, accessToken);
this.imageService = new ImageService(baseURL, defaultHeaders, accessToken);
+ this.wrappedService = new WrappedService(
+ baseURL,
+ defaultHeaders,
+ accessToken,
+ );
}
/**
@@ -42,6 +49,13 @@ export class ServiceRegistry {
get image(): ImageService {
return this.imageService;
}
+
+ /**
+ * Get the wrapped service
+ */
+ get wrapped(): WrappedService {
+ return this.wrappedService;
+ }
}
// Singleton instance
@@ -88,3 +102,10 @@ export function getUserService(): UserService {
export function getImageService(): ImageService {
return getServices().image;
}
+
+/**
+ * Get wrapped service directly
+ */
+export function getWrappedService(): WrappedService {
+ return getServices().wrapped;
+}
diff --git a/src/services/types.ts b/src/services/types.ts
index 54dfd805..b005b548 100644
--- a/src/services/types.ts
+++ b/src/services/types.ts
@@ -26,6 +26,20 @@ export type {
HardestQuestion,
HourlyEntry,
} from "@/types/quiz-stats";
+export type {
+ WrappedData,
+ WrappedStoryData,
+ WrappedSeason,
+ WrappedStudyTime,
+ WrappedVolume,
+ WrappedAccuracy,
+ WrappedRhythm,
+ WrappedPersona,
+ WrappedTopQuiz,
+ WrappedHardestQuestion,
+ WrappedCreatorImpact,
+ WrappedRank,
+} from "@/types/wrapped";
export interface ApiResponse {
data: T;
status: number;
diff --git a/src/services/wrapped.service.ts b/src/services/wrapped.service.ts
new file mode 100644
index 00000000..58caa8c4
--- /dev/null
+++ b/src/services/wrapped.service.ts
@@ -0,0 +1,20 @@
+import type { WrappedData } from "@/types/wrapped";
+
+import { BaseApiService } from "./base-api.service";
+
+/**
+ * Service for the Testownik Wrapped end-of-semester summary.
+ */
+export class WrappedService extends BaseApiService {
+ /** The current user's latest Wrapped report. */
+ async getWrapped(): Promise {
+ const response = await this.get("wrapped/");
+ return response.data;
+ }
+
+ /** The platform-wide (global) Wrapped for the latest term. */
+ async getGlobalWrapped(): Promise {
+ const response = await this.get("wrapped/global/");
+ return response.data;
+ }
+}
diff --git a/src/types/wrapped.ts b/src/types/wrapped.ts
new file mode 100644
index 00000000..efab0ec9
--- /dev/null
+++ b/src/types/wrapped.ts
@@ -0,0 +1,118 @@
+/**
+ * Shape of the data returned by `GET /api/wrapped/`.
+ */
+
+export interface WrappedSeason {
+ /** e.g. "Semestr zimowy 2025/2026" */
+ label: string;
+ /** e.g. "1 paź — 28 lut · 88 dni nauki" */
+ date_range: string;
+ /** Short label for the header pill / outro, e.g. "25/26" */
+ year_label: string;
+}
+
+export interface WrappedStudyTime {
+ /** Total minutes studied; formatted client-side as "X godz Y min". */
+ total_minutes: number;
+}
+
+export interface WrappedVolume {
+ total_answers: number;
+ sessions: number;
+ answers_per_session: number;
+}
+
+export interface WrappedAccuracy {
+ percent: number;
+ correct: number;
+ wrong: number;
+ first_attempt_percent: number;
+}
+
+export interface WrappedPersona {
+ name: string;
+ description: string;
+}
+
+export interface WrappedRhythm {
+ /** 24 weights (0–100), one per hour: all answers given that hour. */
+ hours: number[];
+ /** 24 weights (0–100), one per hour: correct answers given that hour (≤ hours). */
+ correct_hours: number[];
+ peak_hour: number;
+}
+
+export interface WrappedTopQuiz {
+ rank: number;
+ name: string;
+ /** Relative weight used to size the bar. */
+ value: number;
+}
+
+export interface WrappedHardestQuestion {
+ question_number: number;
+ quiz_name: string;
+ text: string;
+ wrong_count: number;
+ /** How many times the user eventually answered it correctly. */
+ correct_count: number;
+ /** Optional image attached to the question (rare). */
+ image?: string | null;
+}
+
+export interface WrappedCreatorImpact {
+ people: number;
+ answers: number;
+ hours: number;
+}
+
+export interface WrappedRank {
+ top_percent: number;
+ /** Where the marker sits on the percentile bar (0–100). */
+ percentile_fill: number;
+}
+
+export interface WrappedData {
+ is_empty: boolean;
+ /** True for the platform-wide (global) Wrapped. */
+ is_global?: boolean;
+ season: WrappedSeason | null;
+ /** Present only when `is_empty` is false. */
+ study_time?: WrappedStudyTime;
+ volume?: WrappedVolume;
+ accuracy?: WrappedAccuracy;
+ rhythm?: WrappedRhythm;
+ top_quizzes?: WrappedTopQuiz[];
+ hardest_question?: WrappedHardestQuestion | null;
+ creator_impact?: WrappedCreatorImpact | null;
+ rank?: WrappedRank;
+ /** Optional first-name personalisation. */
+ name?: string;
+}
+
+/** Narrowed variant where the story fields are guaranteed present. */
+export interface WrappedStoryData extends WrappedData {
+ is_empty: false;
+ season: WrappedSeason;
+ study_time: WrappedStudyTime;
+ volume: WrappedVolume;
+ accuracy: WrappedAccuracy;
+ rhythm: WrappedRhythm;
+ top_quizzes: WrappedTopQuiz[];
+ rank: WrappedRank;
+}
+
+export function isWrappedStoryData(
+ data: WrappedData,
+): data is WrappedStoryData {
+ return (
+ !data.is_empty &&
+ data.season !== null &&
+ data.study_time !== undefined &&
+ data.volume !== undefined &&
+ data.accuracy !== undefined &&
+ data.rhythm !== undefined &&
+ data.top_quizzes !== undefined &&
+ data.rank !== undefined
+ );
+}