Initial addition of the Arcade study feature. Add connections as the first working game.
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m4s

This commit is contained in:
Elijah 2026-07-13 17:35:18 -07:00
parent 7b90409f2e
commit 611a585757
43 changed files with 5990 additions and 68 deletions

View file

@ -0,0 +1,40 @@
-- CreateTable
CREATE TABLE "ArcadePack" (
"id" TEXT NOT NULL PRIMARY KEY,
"classId" TEXT NOT NULL,
"gameType" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"schemaVersion" INTEGER NOT NULL,
"sourceJson" TEXT NOT NULL,
"normalizedJson" TEXT NOT NULL,
"validationReportJson" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "ArcadePack_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "ArcadeAttempt" (
"id" TEXT NOT NULL PRIMARY KEY,
"arcadePackId" TEXT NOT NULL,
"mode" TEXT NOT NULL,
"score" INTEGER NOT NULL,
"maxScore" INTEGER NOT NULL,
"accuracy" REAL NOT NULL,
"durationSeconds" INTEGER NOT NULL,
"mistakes" INTEGER NOT NULL,
"hintsUsed" INTEGER NOT NULL,
"settingsJson" TEXT NOT NULL,
"resultsJson" TEXT NOT NULL,
"seed" TEXT NOT NULL,
"completedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ArcadeAttempt_arcadePackId_fkey" FOREIGN KEY ("arcadePackId") REFERENCES "ArcadePack" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "ArcadePack_classId_gameType_sortOrder_idx" ON "ArcadePack"("classId", "gameType", "sortOrder");
-- CreateIndex
CREATE INDEX "ArcadeAttempt_arcadePackId_completedAt_idx" ON "ArcadeAttempt"("arcadePackId", "completedAt");

View file

@ -18,6 +18,7 @@ model Class {
quizSets QuizSet[]
materialGroups MaterialGroup[]
spacedRepetitionSets SpacedRepetitionSet[]
arcadePacks ArcadePack[]
}
model Deck {
@ -146,12 +147,52 @@ model Setting {
model StudyActivity {
id String @id @default(uuid())
type String // "FLASHCARD" | "QUIZ_QUESTION"
type String // "FLASHCARD" | "QUIZ_QUESTION" | "ARCADE_GROUP"
occurredAt DateTime @default(now())
@@index([occurredAt])
}
model ArcadePack {
id String @id @default(uuid())
classId String
gameType String
name String
description String?
schemaVersion Int
sourceJson String
normalizedJson String
validationReportJson String
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
attempts ArcadeAttempt[]
@@index([classId, gameType, sortOrder])
}
model ArcadeAttempt {
id String @id @default(uuid())
arcadePackId String
mode String
score Int
maxScore Int
accuracy Float
durationSeconds Int
mistakes Int
hintsUsed Int
settingsJson String
resultsJson String
seed String
completedAt DateTime @default(now())
arcadePack ArcadePack @relation(fields: [arcadePackId], references: [id], onDelete: Cascade)
@@index([arcadePackId, completedAt])
}
model MaterialGroup {
id String @id @default(uuid())
classId String

View file

@ -0,0 +1,18 @@
import { notFound } from "next/navigation";
import { randomUUID } from "node:crypto";
import { ARCADE_RENDERERS } from "@/components/arcade/rendererRegistry";
import { getArcadePack } from "@/services/arcadeService";
export default async function ConnectionsPlayPage(
props: PageProps<"/[classSlug]/arcade/connections/[packId]/play">
) {
const [{ classSlug, packId }, query] = await Promise.all([props.params, props.searchParams]);
const pack = await getArcadePack(packId);
if (!pack || pack.class.slug !== classSlug || pack.gameType !== "connections") notFound();
const mistakesValue = Number(query.mistakes);
const allowedMistakes = Number.isInteger(mistakesValue) && mistakesValue >= 1 && mistakesValue <= 8
? mistakesValue
: pack.normalized.settings.allowedMistakes;
const Renderer = ARCADE_RENDERERS.connections;
return <Renderer seed={randomUUID()} packId={pack.id} packName={pack.name} classSlug={classSlug} pack={pack.normalized} settings={{ allowedMistakes, oneAwayFeedback: query.oneAway !== "0" }} />;
}

View file

@ -0,0 +1,12 @@
import { notFound } from "next/navigation";
import { ConnectionsHub } from "@/components/arcade/ConnectionsHub";
import { getClassBySlug } from "@/services/classService";
import { listArcadePacks } from "@/services/arcadeService";
export default async function ConnectionsHubPage(props: PageProps<"/[classSlug]/arcade/connections">) {
const { classSlug } = await props.params;
const classItem = await getClassBySlug(classSlug);
if (!classItem) notFound();
const packs = await listArcadePacks(classItem.id, "connections");
return <ConnectionsHub classId={classItem.id} classSlug={classSlug} initialPacks={packs} />;
}

View file

@ -0,0 +1,3 @@
export default function ArcadeLayout({ children }: { children: React.ReactNode }) {
return <div className="arcade-route">{children}</div>;
}

View file

@ -0,0 +1,46 @@
import Link from "next/link";
import { ARCADE_GAMES } from "@/config/arcadeGames";
function ConnectionsVisual() {
return <div className="arcade-visual connections-visual" aria-hidden><div className="connections-visual-grid">{Array.from({ length: 16 }, (_, index) => <i key={index} />)}</div><span className="connections-visual-label">4 hidden groups</span></div>;
}
function CrosswordVisual() {
const cells = ["C", "", "", "", "R", "", "O", "", "S", "T", "U", "D", "Y", "", "S", "", "", "", "W", "", "", "", "O", "", "", "", "R", "", "", "", "D", ""];
return <div className="arcade-visual crossword-visual" aria-hidden><div className="crossword-grid">{cells.map((letter, index) => <i key={index} className={letter ? "has-letter" : ""}>{letter}</i>)}</div><span className="crossword-pencil"></span></div>;
}
function FallingBlocksVisual() {
return <div className="arcade-visual blocks-visual" aria-hidden><span className="blocks-arrow"></span><div className="blocks-piece blocks-piece-a"><i /><i /><i /><i /></div><div className="blocks-piece blocks-piece-b"><i /><i /><i /></div><div className="blocks-floor">{Array.from({ length: 9 }, (_, index) => <i key={index} />)}</div></div>;
}
function AsteroidVisual() {
return <div className="arcade-visual asteroid-visual" aria-hidden><i className="arcade-star star-a" /><i className="arcade-star star-b" /><i className="arcade-star star-c" /><span className="asteroid asteroid-a" /><span className="asteroid asteroid-b" /><span className="asteroid asteroid-c" /><span className="space-station"><i /></span><span className="laser-beam" /><span className="shield-ring" /></div>;
}
function GameVisual({ gameKey }: { gameKey: (typeof ARCADE_GAMES)[number]["key"] }) {
if (gameKey === "connections") return <ConnectionsVisual />;
if (gameKey === "crossword") return <CrosswordVisual />;
if (gameKey === "falling-blocks") return <FallingBlocksVisual />;
return <AsteroidVisual />;
}
export default async function ArcadePage(props: PageProps<"/[classSlug]/arcade">) {
const { classSlug } = await props.params;
return (
<div className="arcade-lobby pb-20">
<div className="arcade-lobby-heading mb-8">
<p className="text-xs font-bold uppercase tracking-[0.22em] text-primary">Insert curiosity</p>
<h2 className="mt-2 text-4xl font-black tracking-[-0.05em] text-text-heading sm:text-5xl">Choose your cabinet</h2>
<p className="mt-3 max-w-2xl text-sm leading-6 text-text-secondary">Step away from the desk and turn your study material into a quick challenge.</p>
</div>
<div className="arcade-cabinet-grid grid gap-5 sm:grid-cols-2 xl:grid-cols-4">
{ARCADE_GAMES.map((game) => {
const card = <article className={`arcade-cabinet arcade-cabinet-${game.key} ${game.available ? "is-playable" : "is-coming"}`}><GameVisual gameKey={game.key} /><div className="arcade-cabinet-copy"><p className="arcade-cabinet-status">{game.estimatedMinutes}</p><h3>{game.name}</h3><p>{game.description}</p><span className="arcade-cabinet-action">{game.available ? "Play Connections →" : "Coming soon"}</span></div></article>;
return game.available ? <Link key={game.key} href={`/${classSlug}/arcade/${game.path}`} className="rounded-[1.75rem] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-primary">{card}</Link> : <div key={game.key}>{card}</div>;
})}
</div>
</div>
);
}

View file

@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
import { ArcadeImportError } from "@/lib/arcade/connectionsImport";
import { arcadePreviewRequestSchema } from "@/lib/validation/arcadeSchemas";
import * as arcadeService from "@/services/arcadeService";
export async function POST(request: NextRequest) {
const parsed = arcadePreviewRequestSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "A game type and raw JSON are required." }, { status: 400 });
}
try {
return NextResponse.json(arcadeService.previewArcadeImport(parsed.data.rawJson));
} catch (error) {
if (error instanceof ArcadeImportError) {
return NextResponse.json({ error: error.message, details: error.details }, { status: 400 });
}
throw error;
}
}

View file

@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from "next/server";
import { arcadeAttemptCreateSchema } from "@/lib/validation/arcadeSchemas";
import * as arcadeService from "@/services/arcadeService";
export async function GET(
_request: NextRequest,
context: RouteContext<"/api/arcade/packs/[id]/attempts">
) {
const { id } = await context.params;
const pack = await arcadeService.getArcadePack(id);
if (!pack) return NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
return NextResponse.json(await arcadeService.listArcadeAttempts(id));
}
export async function POST(
request: NextRequest,
context: RouteContext<"/api/arcade/packs/[id]/attempts">
) {
const { id } = await context.params;
const parsed = arcadeAttemptCreateSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "Invalid completed attempt", details: parsed.error.issues }, { status: 400 });
}
try {
const attempt = await arcadeService.createArcadeAttempt(id, parsed.data);
return attempt
? NextResponse.json(attempt, { status: 201 })
: NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Attempt could not be saved" },
{ status: 400 }
);
}
}

View file

@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { arcadePackUpdateSchema } from "@/lib/validation/arcadeSchemas";
import * as arcadeService from "@/services/arcadeService";
export async function GET(
_request: NextRequest,
context: RouteContext<"/api/arcade/packs/[id]">
) {
const { id } = await context.params;
const pack = await arcadeService.getArcadePack(id);
return pack
? NextResponse.json(pack)
: NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
}
export async function PATCH(
request: NextRequest,
context: RouteContext<"/api/arcade/packs/[id]">
) {
const { id } = await context.params;
const parsed = arcadePackUpdateSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "A valid name is required" }, { status: 400 });
const pack = await arcadeService.updateArcadePack(id, parsed.data.name);
return pack
? NextResponse.json(pack)
: NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
}
export async function DELETE(
_request: NextRequest,
context: RouteContext<"/api/arcade/packs/[id]">
) {
const { id } = await context.params;
const deleted = await arcadeService.deleteArcadePack(id);
return deleted
? NextResponse.json({ success: true })
: NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
}

View file

@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { ArcadeImportError } from "@/lib/arcade/connectionsImport";
import { arcadePackCreateSchema } from "@/lib/validation/arcadeSchemas";
import * as arcadeService from "@/services/arcadeService";
export async function GET(request: NextRequest) {
const params = new URL(request.url).searchParams;
const classId = params.get("classId");
const gameType = params.get("gameType");
if (!classId || gameType !== "connections") {
return NextResponse.json({ error: "Valid classId and gameType values are required." }, { status: 400 });
}
return NextResponse.json(await arcadeService.listArcadePacks(classId, gameType));
}
export async function POST(request: NextRequest) {
const parsed = arcadePackCreateSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "Invalid Arcade pack request", details: parsed.error.issues }, { status: 400 });
}
try {
const packs = await arcadeService.createArcadePacks(parsed.data);
return NextResponse.json({ packs, count: packs.length }, { status: 201 });
} catch (error) {
if (error instanceof ArcadeImportError) {
return NextResponse.json({ error: error.message, details: error.details }, { status: 400 });
}
if (error instanceof Error) {
const status = error.message === "Class not found" ? 404 : 400;
return NextResponse.json({ error: error.message }, { status });
}
throw error;
}
}

View file

@ -1,16 +1,21 @@
import { NextRequest, NextResponse } from "next/server";
import * as settingsService from "@/services/settingsService";
function getInstructionType(request: NextRequest): settingsService.LlmInstructionType | null {
const type = new URL(request.url).searchParams.get("type") ?? "flashcards";
return type === "flashcards" || type === "quizzes" || type === "connections" ? type : null;
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const type = searchParams.get("type") as "flashcards" | "quizzes" || "flashcards";
const type = getInstructionType(request);
if (!type) return NextResponse.json({ error: "Invalid instruction type" }, { status: 400 });
const instructions = await settingsService.getLlmInstructions(type);
return NextResponse.json({ value: instructions });
}
export async function PATCH(request: NextRequest) {
const { searchParams } = new URL(request.url);
const type = searchParams.get("type") as "flashcards" | "quizzes" || "flashcards";
const type = getInstructionType(request);
if (!type) return NextResponse.json({ error: "Invalid instruction type" }, { status: 400 });
const body = await request.json().catch(() => null);
if (!body || typeof body.value !== "string") {

View file

@ -170,6 +170,334 @@
.animate-slide-up { animation: slideUp .32s var(--ease-spring); }
.animate-subtle-pulse { animation: subtlePulse 1.7s ease-in-out infinite; }
/* Arcade routes take over the complete application shell, including navigation. */
body:has(.arcade-route) {
--theme-bg-base: #070b18;
--theme-bg-surface: #11182a;
--theme-bg-surface-alt: #19233a;
--theme-bg-callout: #282357;
--theme-primary: #9a8cff;
--theme-primary-hover: #b5aaff;
--theme-text-heading: #f9f7ff;
--theme-text-body: #dbe2f4;
--theme-text-secondary: #aab6d1;
--theme-text-muted: #71809e;
--theme-border: #34415c;
--theme-border-light: #242f46;
min-height: 100vh;
background:
radial-gradient(circle at 76% 4%, rgba(116, 91, 255, .2), transparent 28rem),
radial-gradient(circle at 30% 92%, rgba(5, 198, 181, .11), transparent 34rem),
linear-gradient(145deg, #080c19, #0c1221 58%, #0b1020);
}
body:has(.arcade-route)::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
opacity: .11;
background-image: radial-gradient(rgba(201, 211, 255, .7) .7px, transparent .7px);
background-size: 22px 22px;
}
body:has(.arcade-route) main { min-height: 100vh; }
body:has(.arcade-route) .app-page { max-width: none; min-height: 100vh; }
body:has(.arcade-route) .arcade-route { width: min(100%, 1360px); margin-inline: auto; }
body:has(.arcade-route) .app-sidebar,
body:has(.arcade-route) .app-mobile-header {
border-color: rgba(138, 157, 201, .15);
background: rgba(8, 13, 27, .9);
box-shadow: 18px 0 55px rgba(0, 0, 0, .16);
}
body:has(.connections-world) {
background:
radial-gradient(circle at 28% 0%, rgba(88, 101, 242, .34), transparent 36rem),
radial-gradient(circle at 96% 28%, rgba(239, 71, 111, .16), transparent 30rem),
linear-gradient(145deg, #0b1329, #0e1932 54%, #142440);
}
.arcade-lobby-heading { position: relative; }
.arcade-lobby-heading::after {
content: "SELECT GAME";
position: absolute;
right: 0;
top: .5rem;
color: rgba(154, 140, 255, .08);
font-size: clamp(3rem, 8vw, 7rem);
font-weight: 950;
letter-spacing: -.06em;
line-height: .8;
pointer-events: none;
}
.arcade-cabinet {
position: relative;
display: flex;
min-height: 30rem;
flex-direction: column;
overflow: hidden;
border: 1px solid rgba(148, 163, 202, .2);
border-radius: 1.75rem;
background: #11182a;
box-shadow: inset 0 1px rgba(255,255,255,.06), 0 22px 55px rgba(0,0,0,.28);
transition: transform .24s var(--ease-spring), box-shadow .24s ease, border-color .24s ease;
}
.arcade-cabinet.is-playable:hover { transform: translateY(-7px) rotate(-.3deg); border-color: rgba(181,170,255,.65); box-shadow: 0 30px 70px rgba(0,0,0,.38), 0 0 45px rgba(126,103,255,.13); }
.arcade-cabinet.is-coming { filter: saturate(.82); }
.arcade-visual { position: relative; height: 13.5rem; overflow: hidden; border-bottom: 1px solid rgba(255,255,255,.1); }
.arcade-cabinet-copy { display: flex; flex: 1; flex-direction: column; padding: 1.25rem; }
.arcade-cabinet-status { font-size: .65rem; font-weight: 900; letter-spacing: .18em; text-transform: uppercase; }
.arcade-cabinet-copy h3 { margin-top: .35rem; color: white; font-size: 1.55rem; font-weight: 900; letter-spacing: -.04em; }
.arcade-cabinet-copy > p:not(.arcade-cabinet-status) { margin-top: .55rem; flex: 1; color: #aab6d1; font-size: .86rem; line-height: 1.65; }
.arcade-cabinet-action { display: grid; min-height: 2.9rem; margin-top: 1.1rem; place-items: center; border: 1px solid rgba(255,255,255,.13); border-radius: .9rem; color: #8d9ab5; background: rgba(5,9,20,.3); font-size: .8rem; font-weight: 850; }
.is-playable .arcade-cabinet-action { color: #14152a; border-color: transparent; background: linear-gradient(135deg, #a89dff, #8170f5); box-shadow: 0 10px 28px rgba(126,103,255,.28); }
.connections-visual { display: grid; place-items: center; background: radial-gradient(circle at 50% 20%, rgba(180,162,255,.35), transparent 55%), linear-gradient(145deg,#342c64,#1d2042); }
.connections-visual-grid { display: grid; width: 9.5rem; grid-template-columns: repeat(4, 1fr); gap: .38rem; transform: perspective(400px) rotateX(8deg) rotateZ(-2deg); }
.connections-visual-grid i { aspect-ratio: 1; border-radius: .5rem; box-shadow: inset 0 1px rgba(255,255,255,.45), 0 5px 10px rgba(0,0,0,.22); }
.connections-visual-grid i:nth-child(-n+4) { background: #ffd166; }
.connections-visual-grid i:nth-child(n+5):nth-child(-n+8) { background: #5ee0a0; }
.connections-visual-grid i:nth-child(n+9):nth-child(-n+12) { background: #5ac8fa; }
.connections-visual-grid i:nth-child(n+13) { background: #b69cff; }
.connections-visual-label { position: absolute; right: 1rem; bottom: .75rem; color: rgba(255,255,255,.6); font-size: .62rem; font-weight: 900; letter-spacing: .16em; text-transform: uppercase; }
.crossword-visual { display: grid; place-items: center; background: linear-gradient(150deg,#f4dca1,#c98c45); }
.crossword-visual::before { content: ""; position: absolute; inset: 0; opacity: .12; background-image: linear-gradient(#412911 1px,transparent 1px),linear-gradient(90deg,#412911 1px,transparent 1px); background-size: 18px 18px; }
.crossword-grid { display: grid; width: 10rem; grid-template-columns: repeat(8,1fr); gap: 2px; transform: rotate(-3deg); filter: drop-shadow(0 12px 12px rgba(71,38,10,.28)); }
.crossword-grid i { display: grid; aspect-ratio: 1; place-items: center; background: #312419; color: transparent; font-size: .62rem; font-style: normal; font-weight: 950; }
.crossword-grid i.has-letter { color: #322112; background: #fff9e9; }
.crossword-pencil { position: absolute; right: 1.2rem; bottom: .55rem; color: #6d3e17; font-size: 3rem; transform: rotate(-24deg); text-shadow: 0 5px 8px rgba(64,35,10,.2); }
.arcade-cabinet-crossword .arcade-cabinet-status { color: #f3bb62; }
.blocks-visual { background: linear-gradient(#071a20,#0a3436); }
.blocks-visual::before { content: ""; position: absolute; inset: 0; opacity: .18; background-image: linear-gradient(rgba(74,222,128,.45) 1px,transparent 1px),linear-gradient(90deg,rgba(74,222,128,.45) 1px,transparent 1px); background-size: 24px 24px; }
.blocks-piece { position: absolute; display: grid; gap: 3px; }
.blocks-piece i,.blocks-floor i { border: 1px solid rgba(255,255,255,.35); border-radius: 4px; box-shadow: inset 0 0 8px rgba(255,255,255,.25),0 0 13px currentColor; }
.blocks-piece-a { left: 32%; top: 18%; grid-template-columns: repeat(2,1.8rem); color: #38e6c5; animation: arcadeBlockHover 2s ease-in-out infinite; }
.blocks-piece-a i { width: 1.8rem; height: 1.8rem; background: #0fcbaa; }
.blocks-piece-b { right: 18%; top: 42%; grid-template-columns: repeat(3,1.55rem); color: #ff4f91; transform: rotate(90deg); }
.blocks-piece-b i { width: 1.55rem; height: 1.55rem; background: #ed397e; }
.blocks-floor { position: absolute; right: 12%; bottom: 1rem; left: 12%; display: grid; grid-template-columns: repeat(6,1fr); gap: 3px; }
.blocks-floor i { aspect-ratio: 1; color: #ffc857; background: #eaaa2e; }
.blocks-floor i:nth-child(3n) { color: #6c7cff; background: #5868ee; transform: translateY(-1.5rem); }
.blocks-arrow { position: absolute; left: 19%; top: 20%; color: rgba(103,232,211,.5); font-size: 2.2rem; animation: arcadeArrowDown 1.3s ease-in-out infinite; }
.arcade-cabinet-falling-blocks .arcade-cabinet-status { color: #49ddb6; }
.asteroid-visual { background: radial-gradient(circle at 72% 28%,#253c74,#101735 45%,#070b19); }
.arcade-star { position: absolute; width: 3px; height: 3px; border-radius: 50%; background: white; box-shadow: 0 0 8px white; }
.star-a { left: 16%; top: 18%; }.star-b { right: 18%; top: 12%; }.star-c { left: 48%; top: 39%; }
.asteroid { position: absolute; border: 2px solid #8c91a6; border-radius: 47% 53% 42% 58%; background: radial-gradient(circle at 32% 28%,#9299ac,#4c5268 62%,#292e41); box-shadow: inset -7px -8px 12px rgba(0,0,0,.35),0 8px 18px rgba(0,0,0,.35); }
.asteroid::after { content: ""; position: absolute; width: 28%; height: 24%; left: 18%; top: 22%; border-radius: 50%; background: rgba(32,37,55,.45); }
.asteroid-a { width: 3.2rem; height: 3rem; right: 14%; top: 18%; transform: rotate(18deg); }.asteroid-b { width: 2rem; height: 2rem; left: 16%; top: 27%; }.asteroid-c { width: 1.4rem; height: 1.35rem; right: 32%; bottom: 18%; }
.space-station { position: absolute; left: 20%; bottom: 18%; width: 4rem; height: 1.4rem; border-radius: 65% 20% 35% 65%; background: linear-gradient(90deg,#b9c8ef,#6f83bd); transform: rotate(-12deg); box-shadow: 0 0 20px rgba(105,151,255,.35); }
.space-station::before { content: ""; position: absolute; left: 1.2rem; bottom: 1rem; border-right: 1rem solid transparent; border-bottom: 1.5rem solid #53699f; border-left: .25rem solid transparent; }
.space-station i { position: absolute; right: -.75rem; top: .3rem; width: 1rem; height: .8rem; border-radius: 50%; background: #50e6ff; box-shadow: 0 0 16px #4fdff8; }
.laser-beam { position: absolute; width: 38%; height: 2px; left: 42%; top: 52%; background: linear-gradient(90deg,#70f4ff,transparent); transform: rotate(-20deg); transform-origin: left; box-shadow: 0 0 8px #52e7ff; }
.shield-ring { position: absolute; left: 8%; bottom: 4%; width: 7.5rem; height: 6rem; border: 2px solid rgba(90,221,255,.25); border-radius: 50%; transform: rotate(-15deg); }
.arcade-cabinet-asteroid-defense .arcade-cabinet-status { color: #65c9ff; }
@keyframes arcadeBlockHover { 0%,100% { transform: translateY(0); } 50% { transform: translateY(8px); } }
@keyframes arcadeArrowDown { 0%,100% { opacity: .3; transform: translateY(-5px); } 50% { opacity: .8; transform: translateY(6px); } }
/* Connections has its own playful game-show visual system, independent of the desk UI. */
.connections-world {
--theme-bg-surface: #182443;
--theme-bg-surface-alt: #223154;
--theme-bg-callout: #2c3d68;
--theme-primary: #ffd166;
--theme-primary-hover: #ffe29a;
--theme-text-heading: #f8fbff;
--theme-text-body: #dbe7ff;
--theme-text-secondary: #afc0df;
--theme-text-muted: #8497bd;
--theme-border: #52668d;
--theme-border-light: #33486f;
position: relative;
isolation: isolate;
overflow: hidden;
color: var(--theme-text-body);
background:
radial-gradient(circle at 10% 0%, rgba(88, 101, 242, .45), transparent 28rem),
radial-gradient(circle at 95% 25%, rgba(239, 71, 111, .2), transparent 25rem),
linear-gradient(145deg, #101a35 0%, #111d39 48%, #172846 100%);
box-shadow: 0 28px 80px rgba(8, 15, 34, .28);
}
.connections-hub.connections-world,
.connections-stage.connections-world {
overflow: visible;
background: transparent;
box-shadow: none;
}
.connections-hub.connections-world::before,
.connections-stage.connections-world::before { display: none; }
.connections-world::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
opacity: .13;
background-image:
linear-gradient(rgba(255,255,255,.18) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.18) 1px, transparent 1px);
background-size: 42px 42px;
mask-image: linear-gradient(to bottom, black, transparent 78%);
}
.connections-world .editorial-title {
font-family: var(--font-manrope), ui-sans-serif, system-ui, sans-serif;
font-weight: 850;
letter-spacing: -.045em;
}
.connections-hub-hero,
.connections-topbar,
.connections-status,
.connections-setup,
.connections-import {
backdrop-filter: blur(18px);
box-shadow: inset 0 1px rgba(255,255,255,.08), 0 18px 45px rgba(2, 8, 23, .22);
}
.connections-hub-hero {
background: linear-gradient(120deg, rgba(34,49,84,.94), rgba(24,36,67,.72));
border: 1px solid rgba(151, 174, 221, .18);
}
.connections-pack-card {
border-color: rgba(117, 141, 187, .28);
background: rgba(24, 36, 67, .74);
box-shadow: inset 0 1px rgba(255,255,255,.05), 0 12px 30px rgba(3, 9, 24, .16);
}
.connections-pack-card:hover {
transform: translateY(-2px);
border-color: rgba(255, 209, 102, .48);
}
.connections-pack-card.is-active {
border-color: #ffd166;
background: linear-gradient(120deg, rgba(65, 72, 132, .9), rgba(38, 55, 96, .9));
box-shadow: 0 0 0 1px rgba(255,209,102,.25), 0 18px 45px rgba(3,9,24,.25);
}
.connections-primary-button {
color: #172033;
background: linear-gradient(135deg, #ffd166, #ffb84d);
box-shadow: 0 10px 26px rgba(255, 184, 77, .24), inset 0 1px rgba(255,255,255,.5);
transition: transform .18s var(--ease-spring), filter .18s ease, box-shadow .18s ease;
}
.connections-primary-button:not(:disabled):hover {
transform: translateY(-2px) scale(1.01);
filter: brightness(1.06);
box-shadow: 0 14px 34px rgba(255, 184, 77, .32), inset 0 1px rgba(255,255,255,.6);
}
.connections-secondary-button {
color: #dbe7ff;
border: 1px solid #52668d;
background: rgba(24, 36, 67, .82);
transition: transform .18s var(--ease-spring), background .18s ease;
}
.connections-secondary-button:not(:disabled):hover { transform: translateY(-2px); background: #2c3d68; }
.connections-tile {
color: #15213a;
border: 1px solid rgba(255,255,255,.75);
background: linear-gradient(145deg, #fffaf0, #e9effa);
box-shadow: 0 7px 0 #aebbd2, 0 12px 24px rgba(2,8,23,.24), inset 0 1px #fff;
animation: connectionsTileIn .36s var(--ease-spring) both;
transition: transform .16s var(--ease-spring), box-shadow .16s ease, color .16s ease, background .16s ease;
}
.connections-tile:hover { transform: translateY(-3px); box-shadow: 0 9px 0 #aebbd2, 0 16px 30px rgba(2,8,23,.28), inset 0 1px #fff; }
.connections-tile.is-selected {
color: #fff;
border-color: #8c7cf7;
background: linear-gradient(145deg, #735df2, #5540ca);
box-shadow: 0 5px 0 #30228e, 0 12px 28px rgba(84,64,202,.4), inset 0 1px rgba(255,255,255,.28);
transform: translateY(2px) scale(.975);
animation: connectionsTileSelect .25s var(--ease-spring);
}
.connections-group,
.connections-review-card {
color: #172033;
box-shadow: inset 0 1px rgba(255,255,255,.55), 0 10px 26px rgba(2,8,23,.2);
}
.connections-group .text-text-heading,
.connections-group .text-text-secondary,
.connections-review-card .text-text-heading,
.connections-review-card .text-text-secondary { color: #172033; }
.connections-group { animation: connectionsGroupReveal .56s var(--ease-spring) both; }
.connections-group-0 { background: linear-gradient(135deg, #ffd166, #f7b944); }
.connections-group-1 { background: linear-gradient(135deg, #70e1a1, #36bd7c); }
.connections-group-2 { background: linear-gradient(135deg, #72d5f7, #4aa8e8); }
.connections-group-3 { background: linear-gradient(135deg, #b9a2ff, #8d73ed); }
.connections-results-hero {
background: linear-gradient(130deg, #5b46d8, #283d77 58%, #164e63);
box-shadow: inset 0 1px rgba(255,255,255,.18), 0 22px 50px rgba(2,8,23,.3);
}
.connections-completion {
position: relative;
display: grid;
min-height: 32rem;
place-content: center;
overflow: hidden;
border-radius: 1.75rem;
text-align: center;
background: radial-gradient(circle, rgba(94,234,212,.2), transparent 42%), rgba(10,18,39,.72);
animation: connectionsCompletionIn .55s var(--ease-spring) both;
}
.connections-completion-mark {
display: grid;
width: 6rem;
height: 6rem;
margin: 0 auto 1.5rem;
place-items: center;
border-radius: 999px;
color: #172033;
background: #ffd166;
box-shadow: 0 0 0 12px rgba(255,209,102,.12), 0 0 65px rgba(255,209,102,.46);
font-size: 3rem;
font-weight: 900;
animation: connectionsWinMark .8s .15s var(--ease-spring) both;
}
.connections-completion p { color: #8ee7d1; font-size: .75rem; font-weight: 850; letter-spacing: .2em; text-transform: uppercase; }
.connections-completion h2 { margin-top: .35rem; color: white; font-size: clamp(2.5rem, 8vw, 5.5rem); font-weight: 900; letter-spacing: -.065em; line-height: .95; }
.connections-completion span { margin-top: 1rem; color: #b9c8e5; font-weight: 700; }
.connections-confetti { position: absolute; inset: 0; pointer-events: none; }
.connections-confetti i {
position: absolute;
top: -8%;
left: var(--confetti-x);
width: 9px;
height: 17px;
border-radius: 2px;
background: var(--confetti-color);
animation: connectionsConfettiFall 1.45s var(--confetti-delay) cubic-bezier(.2,.7,.3,1) both;
}
@keyframes connectionsTileIn { from { opacity: 0; transform: translateY(16px) scale(.94); } to { opacity: 1; transform: translateY(0) scale(1); } }
@keyframes connectionsTileSelect { 0% { transform: scale(1); } 55% { transform: translateY(3px) scale(.94); } 100% { transform: translateY(2px) scale(.975); } }
@keyframes connectionsShake { 0%,100% { transform: translateX(0); } 20% { transform: translateX(-9px); } 40% { transform: translateX(8px); } 60% { transform: translateX(-5px); } 80% { transform: translateX(3px); } }
@keyframes connectionsGroupReveal { from { opacity: 0; transform: scale(.9) translateY(18px); filter: brightness(1.6); } 65% { transform: scale(1.025) translateY(-2px); } to { opacity: 1; transform: scale(1) translateY(0); filter: brightness(1); } }
@keyframes connectionsCompletionIn { from { opacity: 0; transform: scale(.94); } to { opacity: 1; transform: scale(1); } }
@keyframes connectionsWinMark { from { opacity: 0; transform: scale(.2) rotate(-25deg); } 70% { transform: scale(1.12) rotate(4deg); } to { opacity: 1; transform: scale(1) rotate(0); } }
@keyframes connectionsConfettiFall { 0% { opacity: 0; transform: translateY(-1rem) rotate(0); } 10% { opacity: 1; } 100% { opacity: 1; transform: translateY(38rem) rotate(var(--confetti-spin)); } }
@keyframes connectionsResultsIn { from { opacity: 0; transform: translateY(22px) scale(.98); } to { opacity: 1; transform: translateY(0) scale(1); } }
.animate-connections-shake { animation: connectionsShake .42s ease-in-out; }
.animate-connections-results-in { animation: connectionsResultsIn .52s var(--ease-spring) both; }
.markdown-content h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: .5rem; color: var(--color-text-heading); }
.markdown-content h2 { font-size: 1.25rem; font-weight: 650; margin-bottom: .5rem; color: var(--color-text-heading); }
.markdown-content h3 { font-size: 1.125rem; font-weight: 650; margin-bottom: .5rem; color: var(--color-text-heading); }

View file

@ -1,6 +1,5 @@
import type { Metadata } from "next";
import { Manrope, Newsreader } from "next/font/google";
import Script from "next/script";
import "./globals.css";
const manrope = Manrope({
@ -26,10 +25,12 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en" className={`${manrope.variable} ${newsreader.variable} h-full`} suppressHydrationWarning>
<body className="min-h-full flex flex-col font-sans antialiased">
<Script id="theme-init" strategy="beforeInteractive">
{`
<html lang="en" className={`${manrope.variable} ${newsreader.variable} h-full`} data-scroll-behavior="smooth" suppressHydrationWarning>
<head>
<script
id="theme-init"
dangerouslySetInnerHTML={{
__html: `
try {
const savedTheme = localStorage.theme
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
@ -39,8 +40,11 @@ export default function RootLayout({
document.documentElement.classList.remove('dark')
}
} catch (_) {}
`}
</Script>
`,
}}
/>
</head>
<body className="min-h-full flex flex-col font-sans antialiased" suppressHydrationWarning>
{children}
</body>
</html>

View file

@ -6,6 +6,7 @@ interface DailyActivity {
date: string;
flashcards: number;
questions: number;
arcade: number;
total: number;
level: 0 | 1 | 2 | 3 | 4;
}
@ -152,7 +153,7 @@ function DayTooltip({ day }: { day: DailyActivity }) {
return (
<div role="tooltip" className="rounded-lg border border-white/15 bg-[#0d1422] px-3 py-2 text-right text-xs shadow-xl">
<div className="font-bold text-white">{formatted} · {day.total} total</div>
<div className="mt-0.5 text-white/60">{day.flashcards} flashcards · {day.questions} questions</div>
<div className="mt-0.5 text-white/60">{day.flashcards} flashcards · {day.questions} questions · {day.arcade} arcade groups</div>
</div>
);
}
@ -183,5 +184,5 @@ function getMonthLabel(week: DailyActivity[], previousWeek?: DailyActivity[]) {
}
function getDayAriaLabel(day: DailyActivity) {
return `${day.date}: ${day.flashcards} flashcards and ${day.questions} questions, ${day.total} total activities`;
return `${day.date}: ${day.flashcards} flashcards, ${day.questions} questions, and ${day.arcade} arcade groups, ${day.total} total activities`;
}

View file

@ -0,0 +1,36 @@
"use client";
import type { ReactNode } from "react";
import { useRouter } from "next/navigation";
export function ArcadeGameShell({
title,
exitHref,
elapsedSeconds,
complete,
children,
}: {
title: string;
exitHref: string;
elapsedSeconds: number;
complete: boolean;
children: ReactNode;
}) {
const router = useRouter();
function exitGame() {
if (!complete && !window.confirm("Leave this round? Your progress will not be saved.")) return;
router.push(exitHref);
}
const minutes = Math.floor(elapsedSeconds / 60);
const seconds = String(elapsedSeconds % 60).padStart(2, "0");
return (
<div className="connections-world connections-stage mx-auto w-full max-w-6xl p-1 pb-16 sm:p-3">
<header className="connections-topbar mb-5 flex items-center justify-between gap-4 rounded-2xl border border-border-light bg-bg-surface/85 px-4 py-3 shadow-sm">
<button onClick={exitGame} className="min-h-10 rounded-xl px-3 text-sm font-bold text-text-muted hover:bg-bg-surface-alt hover:text-text-heading"> Exit</button>
<h1 className="truncate text-center text-lg font-extrabold text-text-heading">{title}</h1>
<div className="min-w-16 text-right font-mono text-sm font-bold text-text-muted" aria-label={`${elapsedSeconds} seconds elapsed`}>{minutes}:{seconds}</div>
</header>
{children}
</div>
);
}

View file

@ -0,0 +1,120 @@
"use client";
import { useState } from "react";
import { GenerateTab } from "@/components/import/GenerateTab";
import type { ArcadeImportBatchPreview } from "@/types/arcade";
export function ArcadeImportModal({
classId,
onClose,
onImported,
}: {
classId: string;
onClose: () => void;
onImported: () => void;
}) {
const [tab, setTab] = useState<"generate" | "import">("import");
const [rawJson, setRawJson] = useState("");
const [names, setNames] = useState<string[]>([]);
const [preview, setPreview] = useState<ArcadeImportBatchPreview | null>(null);
const [error, setError] = useState("");
const [details, setDetails] = useState<string[]>([]);
const [acknowledged, setAcknowledged] = useState(false);
const [busy, setBusy] = useState(false);
async function previewImport() {
setBusy(true);
setError("");
setDetails([]);
setPreview(null);
try {
const response = await fetch("/api/arcade/import/preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ gameType: "connections", rawJson }),
});
const data = await response.json();
if (!response.ok) {
setError(data.error ?? "Import preview failed");
setDetails(data.details ?? []);
return;
}
setPreview(data);
setNames(data.packs.map((pack: { name: string }) => pack.name));
} finally {
setBusy(false);
}
}
async function saveImport() {
if (!preview || names.some((name) => !name.trim())) return;
setBusy(true);
setError("");
try {
const response = await fetch("/api/arcade/packs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
classId,
gameType: "connections",
rawJson,
names: names.map((name) => name.trim()),
warningsAcknowledged: acknowledged,
}),
});
const data = await response.json();
if (!response.ok) {
setError(data.error ?? "Import failed");
setDetails(data.details ?? []);
return;
}
onImported();
} finally {
setBusy(false);
}
}
return (
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center sm:p-4">
<button className="absolute inset-0 bg-black/45 backdrop-blur-sm" onClick={onClose} aria-label="Close import dialog" />
<section role="dialog" aria-modal="true" aria-labelledby="arcade-import-title" className="connections-world connections-import relative flex max-h-[92vh] w-full max-w-2xl flex-col rounded-t-3xl border border-border-light bg-bg-surface shadow-[var(--shadow-modal)] sm:rounded-3xl">
<div className="flex items-center justify-between px-6 pt-5">
<h2 id="arcade-import-title" className="editorial-title text-2xl text-text-heading">Import Connections pack</h2>
<button onClick={onClose} className="grid h-10 w-10 place-items-center rounded-xl text-xl text-text-muted hover:bg-bg-surface-alt" aria-label="Close">×</button>
</div>
<div className="flex gap-1 border-b border-border-light px-6 pt-4">
{(["generate", "import"] as const).map((item) => (
<button key={item} onClick={() => setTab(item)} className={`border-b-2 px-4 py-3 text-sm font-bold capitalize ${tab === item ? "border-primary text-primary" : "border-transparent text-text-muted"}`}>{item}</button>
))}
</div>
<div className="overflow-y-auto p-6">
{tab === "generate" ? <GenerateTab importType="connections" /> : (
<div className="space-y-4">
<p className="text-sm text-text-secondary">Paste one pack object or an array of up to ten packs. Every board is validated before anything is saved.</p>
<textarea value={rawJson} onChange={(event) => { setRawJson(event.target.value); setPreview(null); setError(""); }} rows={11} placeholder="Paste Connections JSON object or array here…" className="w-full resize-y rounded-xl border border-border bg-bg-surface-alt/60 px-4 py-3 font-mono text-sm text-text-heading focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20" />
{!preview && <button onClick={previewImport} disabled={!rawJson.trim() || busy} className="w-full min-h-11 rounded-xl bg-primary px-5 text-sm font-bold text-white disabled:opacity-45">{busy ? "Checking…" : "Preview pack(s)"}</button>}
{error && <div role="alert" className="rounded-xl border border-error/20 bg-error-bg p-4 text-sm text-error"><p className="font-bold">{error}</p>{details.length > 0 && <ul className="mt-2 list-disc space-y-1 pl-5">{details.map((detail) => <li key={detail}>{detail}</li>)}</ul>}</div>}
{preview && (
<div className="space-y-4 rounded-2xl border border-primary/15 bg-bg-callout p-4">
<div className="flex items-center justify-between"><p className="font-extrabold text-text-heading">{preview.count} {preview.count === 1 ? "pack" : "packs"} ready</p>{preview.wasRepaired && <span className="text-xs font-bold text-primary">JSON syntax repaired</span>}</div>
<div className="space-y-3">
{preview.packs.map((pack, index) => (
<article key={`${pack.name}-${index}`} className="rounded-xl border border-border-light bg-bg-surface p-3">
<label className="mb-1 block text-xs font-bold uppercase tracking-wide text-text-muted">Pack {index + 1} name</label>
<input value={names[index] ?? ""} onChange={(event) => setNames((current) => current.map((name, nameIndex) => nameIndex === index ? event.target.value : name))} className="w-full rounded-lg border border-border bg-bg-surface-alt/50 px-3 py-2 font-bold text-text-heading" />
<div className="mt-2 flex flex-wrap gap-1.5">{pack.categories.map((category) => <span key={category} className="rounded-full bg-bg-surface-alt px-2 py-1 text-[11px] font-bold text-text-secondary">{category}</span>)}</div>
<p className="mt-2 text-xs text-text-muted">16 tiles · 4 groups</p>
</article>
))}
</div>
{preview.packs.some((pack) => pack.warnings.length > 0) && <div className="rounded-xl border border-amber-400/30 bg-amber-400/10 p-3 text-sm text-text-secondary"><p className="font-bold text-text-heading">Review warnings</p><ul className="mt-1 list-disc space-y-1 pl-5">{preview.packs.flatMap((pack, index) => pack.warnings.map((warning) => `Pack ${index + 1}: ${warning}`)).map((warning) => <li key={warning}>{warning}</li>)}</ul><label className="mt-3 flex items-start gap-2"><input type="checkbox" checked={acknowledged} onChange={(event) => setAcknowledged(event.target.checked)} className="mt-1" /><span>I reviewed these warnings and want to import the packs.</span></label></div>}
<button onClick={saveImport} disabled={busy || names.some((name) => !name.trim()) || (preview.packs.some((pack) => pack.warnings.length > 0) && !acknowledged)} className="w-full min-h-11 rounded-xl bg-primary px-5 text-sm font-bold text-white disabled:opacity-45">{busy ? "Importing…" : `Import ${preview.count} ${preview.count === 1 ? "pack" : "packs"}`}</button>
</div>
)}
</div>
)}
</div>
</section>
</div>
);
}

View file

@ -0,0 +1,237 @@
"use client";
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { ArcadeGameShell } from "@/components/arcade/ArcadeGameShell";
import { deterministicShuffle, replayConnectionsAttempt } from "@/lib/arcade/connectionsEngine";
import type {
ArcadeRoundResult,
ArcadeSessionSettings,
ConnectionsSubmission,
NormalizedConnectionsPack,
} from "@/types/arcade";
export function ConnectionsGame({
seed,
packId,
packName,
classSlug,
pack,
settings,
}: {
seed: string;
packId: string;
packName: string;
classSlug: string;
pack: NormalizedConnectionsPack;
settings: ArcadeSessionSettings;
}) {
const allItems = useMemo(() => pack.content.flatMap((group) => group.items), [pack]);
const itemById = useMemo(() => new Map(allItems.map((item) => [item.id, item])), [allItems]);
const [tileOrder, setTileOrder] = useState(() => deterministicShuffle(allItems.map((item) => item.id), seed));
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [submissions, setSubmissions] = useState<ConnectionsSubmission[]>([]);
const [feedback, setFeedback] = useState("Select four related tiles.");
const [result, setResult] = useState<ArcadeRoundResult | null>(null);
const [finishing, setFinishing] = useState<ArcadeRoundResult | null>(null);
const [feedbackKind, setFeedbackKind] = useState<"idle" | "correct" | "wrong">("idle");
const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle");
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const shuffleCount = useRef(0);
const startedAt = useRef(0);
const replay = useMemo(
() => replayConnectionsAttempt(pack, submissions, settings, elapsedSeconds),
[elapsedSeconds, pack, settings, submissions]
);
const solvedGroupIds = useMemo(
() => new Set(replay.result.groups.filter((group) => group.solved).map((group) => group.groupId)),
[replay.result.groups]
);
const solvedItemIds = useMemo(
() => new Set(pack.content.filter((group) => solvedGroupIds.has(group.id)).flatMap((group) => group.items.map((item) => item.id))),
[pack, solvedGroupIds]
);
const unresolvedOrder = tileOrder.filter((id) => !solvedItemIds.has(id));
useEffect(() => {
if (startedAt.current === 0) startedAt.current = Date.now();
if (result) return;
const interval = window.setInterval(() => setElapsedSeconds(Math.floor((Date.now() - startedAt.current) / 1000)), 1000);
return () => window.clearInterval(interval);
}, [result]);
function toggleTile(id: string) {
if (result || solvedItemIds.has(id)) return;
setSelectedIds((current) => current.includes(id) ? current.filter((itemId) => itemId !== id) : current.length < 4 ? [...current, id] : current);
}
async function persistAttempt(nextSubmissions: ConnectionsSubmission[], roundResult: ArcadeRoundResult) {
setSaveState("saving");
const response = await fetch(`/api/arcade/packs/${packId}/attempts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mode: "CLASSIC",
durationSeconds: roundResult.durationSeconds,
seed,
settings,
submissions: nextSubmissions,
}),
});
if (!response.ok) {
setSaveState("error");
return;
}
await response.json();
setSaveState("saved");
}
function submitSelection() {
if (selectedIds.length !== 4 || result) return;
const previousSolved = solvedGroupIds.size;
const nextSubmissions = [...submissions, { itemIds: selectedIds, elapsedMs: Date.now() - startedAt.current }];
const duration = Math.floor((Date.now() - startedAt.current) / 1000);
const nextReplay = replayConnectionsAttempt(pack, nextSubmissions, settings, duration);
setSubmissions(nextSubmissions);
setSelectedIds([]);
if (nextReplay.result.groups.filter((group) => group.solved).length > previousSolved) {
setFeedback("Correct group found.");
setFeedbackKind("correct");
} else if (nextReplay.result.hintsUsed > replay.result.hintsUsed) {
setFeedback("One away — three of those tiles belong together.");
setFeedbackKind("wrong");
} else {
setFeedback("Not a group. Try another combination.");
setFeedbackKind("wrong");
}
if (nextReplay.complete) {
setElapsedSeconds(duration);
setFinishing(nextReplay.result);
window.setTimeout(() => {
setResult(nextReplay.result);
setFinishing(null);
}, nextReplay.result.outcome === "WON" ? 1700 : 850);
void persistAttempt(nextSubmissions, nextReplay.result);
}
}
function shuffleTiles() {
shuffleCount.current += 1;
const shuffled = deterministicShuffle(unresolvedOrder, `${seed}-shuffle-${shuffleCount.current}`);
setTileOrder([...tileOrder.filter((id) => solvedItemIds.has(id)), ...shuffled]);
setSelectedIds([]);
setFeedback("Unsolved tiles shuffled.");
setFeedbackKind("idle");
}
function handleTileKey(event: React.KeyboardEvent<HTMLButtonElement>, id: string) {
const moves: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -4, ArrowDown: 4 };
const move = moves[event.key];
if (!move) return;
event.preventDefault();
const index = unresolvedOrder.indexOf(id);
const next = unresolvedOrder[(index + move + unresolvedOrder.length) % unresolvedOrder.length];
document.querySelector<HTMLButtonElement>(`[data-arcade-tile="${next}"]`)?.focus();
}
if (result) {
return (
<ArcadeGameShell title={packName} exitHref={`/${classSlug}/arcade/connections`} elapsedSeconds={elapsedSeconds} complete>
<ConnectionsResults result={result} itemById={itemById} saveState={saveState} onReplay={() => window.location.reload()} exitHref={`/${classSlug}/arcade/connections`} />
</ArcadeGameShell>
);
}
if (finishing) {
return (
<ArcadeGameShell title={packName} exitHref={`/${classSlug}/arcade/connections`} elapsedSeconds={elapsedSeconds} complete>
<CompletionTransition result={finishing} />
</ArcadeGameShell>
);
}
return (
<ArcadeGameShell title={packName} exitHref={`/${classSlug}/arcade/connections`} elapsedSeconds={elapsedSeconds} complete={false}>
<div className="connections-status mb-4 flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-border-light bg-bg-surface p-4">
<div><p className="text-xs font-bold uppercase tracking-wide text-text-muted">Mistakes remaining</p><div className="mt-1 flex gap-1.5" aria-label={`${settings.allowedMistakes - replay.result.mistakes} mistakes remaining`}>{Array.from({ length: settings.allowedMistakes }, (_, index) => <span key={index} className={`h-3 w-3 rounded-full border ${index < settings.allowedMistakes - replay.result.mistakes ? "border-primary bg-primary" : "border-border bg-bg-surface-alt"}`} />)}</div></div>
<p className="text-sm font-bold text-text-secondary" aria-live="polite">{feedback}</p>
</div>
{pack.content.filter((group) => solvedGroupIds.has(group.id)).map((group, index) => (
<div key={group.id} className={`connections-group connections-group-${index % 4} mb-2 rounded-2xl p-4 text-center`}>
<h2 className="font-extrabold uppercase tracking-wide text-text-heading">{group.category}</h2>
<p className="mt-1 text-sm font-semibold text-text-secondary">{group.items.map((item) => item.text).join(" · ")}</p>
</div>
))}
<div key={`board-${submissions.length}`} className={`connections-board grid grid-cols-4 gap-2 sm:gap-3 ${feedbackKind === "wrong" ? "animate-connections-shake" : ""}`} role="group" aria-label="Connections tiles">
{unresolvedOrder.map((id) => {
const item = itemById.get(id);
if (!item) return null;
const selected = selectedIds.includes(id);
return <button key={id} data-arcade-tile={id} aria-pressed={selected} onKeyDown={(event) => handleTileKey(event, id)} onClick={() => toggleTile(id)} className={`connections-tile min-h-20 break-words rounded-xl px-1.5 py-2 text-[11px] font-extrabold leading-tight sm:min-h-24 sm:px-3 sm:text-sm ${selected ? "is-selected" : ""}`}>{item.text}</button>;
})}
</div>
<div className="mt-5 grid grid-cols-2 gap-3 sm:flex sm:justify-center">
<button onClick={shuffleTiles} className="connections-secondary-button min-h-11 rounded-xl px-5 text-sm font-bold">Shuffle</button>
<button onClick={() => setSelectedIds([])} disabled={selectedIds.length === 0} className="connections-secondary-button min-h-11 rounded-xl px-5 text-sm font-bold disabled:opacity-40">Clear</button>
<button onClick={submitSelection} disabled={selectedIds.length !== 4} className="connections-primary-button col-span-2 min-h-11 rounded-xl px-7 text-sm font-extrabold disabled:opacity-40 sm:col-span-1">Submit group ({selectedIds.length}/4)</button>
</div>
</ArcadeGameShell>
);
}
function ConnectionsResults({
result,
itemById,
saveState,
onReplay,
exitHref,
}: {
result: ArcadeRoundResult;
itemById: Map<string, { id: string; text: string }>;
saveState: "idle" | "saving" | "saved" | "error";
onReplay: () => void;
exitHref: string;
}) {
const orderedGroups = [...result.groups].sort((a, b) => Number(a.solved) - Number(b.solved));
return (
<div className="animate-connections-results-in">
<section className="connections-results-hero rounded-3xl p-6 text-white sm:p-8">
<p className="text-xs font-bold uppercase tracking-[0.18em] text-white/55">Round complete</p>
<h2 className="editorial-title mt-1 text-4xl text-white">{result.outcome === "WON" ? "Board cleared" : "Groups revealed"}</h2>
<div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-4">{[["Score", `${result.score}/${result.maxScore}`], ["Accuracy", `${Math.round(result.accuracy * 100)}%`], ["Mistakes", result.mistakes], ["Time", `${result.durationSeconds}s`]].map(([label, value]) => <div key={label} className="rounded-2xl bg-white/8 p-3"><p className="text-xs font-bold uppercase text-white/45">{label}</p><p className="mt-1 text-xl font-extrabold">{value}</p></div>)}</div>
<p className="mt-4 text-xs text-white/55">{saveState === "saving" ? "Saving attempt…" : saveState === "error" ? "Attempt could not be saved." : "Attempt saved."}</p>
</section>
<div className="mt-5 space-y-3">{orderedGroups.map((group, index) => <article key={group.groupId} className={`connections-review-card connections-group-${index % 4} rounded-2xl p-4`}><p className="text-xs font-extrabold uppercase tracking-wide opacity-60">{group.solved ? "Solved" : "Revealed"}</p><h3 className="mt-1 text-lg font-extrabold">{group.category}</h3><p className="mt-1 text-sm font-semibold opacity-75">{group.items.join(" · ")}</p><p className="mt-3 text-sm leading-6 opacity-80">{group.explanation}</p></article>)}</div>
{result.incorrectSelections.length > 0 && <section className="mt-5 rounded-2xl border border-border-light bg-bg-surface p-4"><h3 className="font-extrabold text-text-heading">Incorrect selections</h3><ul className="mt-2 space-y-1 text-sm text-text-secondary">{result.incorrectSelections.map((selection, index) => <li key={`${selection.join("-")}-${index}`}>{selection.map((id) => itemById.get(id)?.text ?? id).join(" · ")}</li>)}</ul></section>}
<div className="mt-6 grid grid-cols-2 gap-3"><button onClick={onReplay} className="min-h-12 rounded-xl bg-primary px-5 text-sm font-extrabold text-white">Play again</button><a href={exitHref} className="flex min-h-12 items-center justify-center rounded-xl border border-border bg-bg-surface px-5 text-sm font-extrabold text-text-heading">Back to packs</a></div>
</div>
);
}
function CompletionTransition({ result }: { result: ArcadeRoundResult }) {
const won = result.outcome === "WON";
return (
<div className={`connections-completion ${won ? "is-win" : "is-loss"}`}>
{won && <ConfettiBurst />}
<div className="connections-completion-mark" aria-hidden>{won ? "✓" : "!"}</div>
<p>{won ? "Perfect connection" : "Round complete"}</p>
<h2>{won ? "Board cleared!" : "Groups revealed"}</h2>
<span>{won ? `${result.score} points` : "Lets review the board"}</span>
</div>
);
}
function ConfettiBurst() {
const colors = ["#ffd166", "#ef476f", "#06d6a0", "#4cc9f0", "#a78bfa"];
return <div className="connections-confetti" aria-hidden>{Array.from({ length: 52 }, (_, index) => {
const style = {
"--confetti-x": `${(index * 37) % 100}%`,
"--confetti-delay": `${(index % 13) * 0.035}s`,
"--confetti-spin": `${180 + (index % 7) * 80}deg`,
"--confetti-color": colors[index % colors.length],
} as CSSProperties;
return <i key={index} style={style} />;
})}</div>;
}

View file

@ -0,0 +1,123 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { ArcadeImportModal } from "@/components/arcade/ArcadeImportModal";
import type { ArcadeAttemptSummary, ArcadePackSummary } from "@/types/arcade";
export function ConnectionsHub({
classId,
classSlug,
initialPacks,
}: {
classId: string;
classSlug: string;
initialPacks: ArcadePackSummary[];
}) {
const router = useRouter();
const [selectedId, setSelectedId] = useState(initialPacks[0]?.id ?? "");
const [showImport, setShowImport] = useState(false);
const [allowedMistakes, setAllowedMistakes] = useState(initialPacks[0]?.defaultAllowedMistakes ?? 4);
const [oneAwayFeedback, setOneAwayFeedback] = useState(true);
const [attempts, setAttempts] = useState<ArcadeAttemptSummary[]>([]);
const [attemptsLoading, setAttemptsLoading] = useState(initialPacks.length > 0);
const effectiveSelectedId = initialPacks.some((pack) => pack.id === selectedId)
? selectedId
: initialPacks[0]?.id ?? "";
const selected = useMemo(() => initialPacks.find((pack) => pack.id === effectiveSelectedId) ?? null, [effectiveSelectedId, initialPacks]);
useEffect(() => {
if (!selected) return;
fetch(`/api/arcade/packs/${selected.id}/attempts`)
.then((response) => response.ok ? response.json() : [])
.then(setAttempts)
.finally(() => setAttemptsLoading(false));
}, [selected]);
function selectPack(pack: ArcadePackSummary) {
setSelectedId(pack.id);
setAllowedMistakes(pack.defaultAllowedMistakes);
setAttempts([]);
setAttemptsLoading(true);
}
async function renamePack(pack: ArcadePackSummary) {
const name = window.prompt("Rename Connections pack", pack.name)?.trim();
if (!name || name === pack.name) return;
await fetch(`/api/arcade/packs/${pack.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
router.refresh();
}
async function deletePack(pack: ArcadePackSummary) {
if (!window.confirm(`Delete “${pack.name}” and its attempt history?`)) return;
await fetch(`/api/arcade/packs/${pack.id}`, { method: "DELETE" });
if (selectedId === pack.id) setSelectedId("");
router.refresh();
}
const playHref = selected
? `/${classSlug}/arcade/connections/${selected.id}/play?mistakes=${allowedMistakes}&oneAway=${oneAwayFeedback ? "1" : "0"}`
: "#";
return (
<div className="connections-world connections-hub p-1 pb-20 sm:p-3">
<div className="connections-hub-hero mb-7 flex flex-col gap-4 rounded-3xl p-5 sm:flex-row sm:items-end sm:justify-between sm:p-7">
<div>
<Link href={`/${classSlug}/arcade`} className="mb-3 inline-flex min-h-10 items-center text-sm font-bold text-text-muted hover:text-primary"> Back to Arcade</Link>
<p className="text-xs font-bold uppercase tracking-[0.18em] text-primary">The pattern room</p>
<h2 className="editorial-title mt-1 text-4xl text-text-heading">Connections</h2>
<p className="mt-2 max-w-xl text-sm leading-6 text-text-secondary">Choose a pack, tune the mistake limit, and find the four hidden groups.</p>
</div>
<button onClick={() => setShowImport(true)} className="connections-primary-button min-h-12 rounded-xl px-5 text-sm font-bold">Import pack</button>
</div>
{initialPacks.length === 0 ? (
<div className="rounded-3xl border border-dashed border-border bg-bg-surface px-6 py-16 text-center">
<div className="mx-auto mb-4 grid w-fit grid-cols-2 gap-1.5" aria-hidden>{["bg-amber-400", "bg-emerald-500", "bg-sky-500", "bg-violet-500"].map((color) => <span key={color} className={`h-8 w-8 rounded-lg ${color}`} />)}</div>
<h3 className="editorial-title text-2xl text-text-heading">Import your first board</h3>
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-text-secondary">Connections uses purpose-built packs with four groups of four terms.</p>
<button onClick={() => setShowImport(true)} className="mt-6 min-h-11 rounded-xl bg-primary px-5 text-sm font-bold text-white">Import Connections JSON</button>
</div>
) : (
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.15fr)_minmax(20rem,.85fr)]">
<section>
<h3 className="mb-3 text-sm font-extrabold uppercase tracking-[0.14em] text-text-muted">Study packs</h3>
<div className="space-y-3">
{initialPacks.map((pack) => {
const active = pack.id === effectiveSelectedId;
return (
<article key={pack.id} className={`connections-pack-card rounded-2xl border p-4 transition-all ${active ? "is-active" : ""}`}>
<button onClick={() => selectPack(pack)} className="w-full text-left">
<div className="flex items-start justify-between gap-3"><div><h4 className="text-lg font-extrabold text-text-heading">{pack.name}</h4>{pack.description && <p className="mt-1 text-sm leading-5 text-text-secondary">{pack.description}</p>}</div><span className={`mt-1 h-4 w-4 shrink-0 rounded-full border-4 ${active ? "border-primary bg-white" : "border-border bg-bg-surface"}`} aria-hidden /></div>
<div className="mt-3 flex flex-wrap gap-2 text-xs font-bold text-text-muted"><span className="rounded-full bg-bg-surface-alt px-2.5 py-1">16 tiles</span><span className="rounded-full bg-bg-surface-alt px-2.5 py-1">Best {pack.bestScore ?? "—"}/400</span><span className="rounded-full bg-bg-surface-alt px-2.5 py-1">Latest {pack.latestAttempt?.score ?? "—"}</span></div>
</button>
<div className="mt-3 flex justify-end gap-2 border-t border-border-light pt-3"><button onClick={() => renamePack(pack)} className="min-h-9 rounded-lg px-3 text-xs font-bold text-text-muted hover:bg-bg-surface-alt hover:text-text-heading">Rename</button><button onClick={() => deletePack(pack)} className="min-h-9 rounded-lg px-3 text-xs font-bold text-text-muted hover:bg-error-bg hover:text-error">Delete</button></div>
</article>
);
})}
</div>
</section>
<aside className="connections-setup h-fit rounded-3xl border border-border-light bg-bg-surface p-5 lg:sticky lg:top-6">
<h3 className="editorial-title text-2xl text-text-heading">Round setup</h3>
{selected ? <>
<p className="mt-1 text-sm font-bold text-primary">{selected.name}</p>
<label className="mt-5 block text-sm font-bold text-text-heading">Allowed mistakes <span className="text-primary">{allowedMistakes}</span></label>
<input type="range" min={1} max={8} value={allowedMistakes} onChange={(event) => setAllowedMistakes(Number(event.target.value))} className="mt-2 w-full accent-primary" />
<label className="mt-5 flex items-start gap-3 rounded-xl bg-bg-surface-alt p-3 text-sm text-text-secondary"><input type="checkbox" checked={oneAwayFeedback} onChange={(event) => setOneAwayFeedback(event.target.checked)} className="mt-1" /><span><strong className="block text-text-heading">One-away feedback</strong>Tell me when three selected tiles belong together.</span></label>
<div className="mt-5 rounded-xl border border-border-light p-3 text-sm text-text-secondary"><div className="flex justify-between"><span>Board</span><strong className="text-text-heading">4 × 4</strong></div><div className="mt-2 flex justify-between"><span>Possible score</span><strong className="text-text-heading">400</strong></div></div>
<Link href={playHref} className="connections-primary-button mt-5 flex min-h-12 items-center justify-center rounded-xl px-5 text-sm font-extrabold">Enter the board</Link>
<div className="mt-7 border-t border-border-light pt-5"><h4 className="text-sm font-extrabold uppercase tracking-wide text-text-muted">Recent attempts</h4>{attemptsLoading ? <p className="mt-3 text-sm text-text-muted">Loading history</p> : attempts.length === 0 ? <p className="mt-3 text-sm text-text-muted">No attempts yet.</p> : <div className="mt-3 space-y-2">{attempts.slice(0, 5).map((attempt) => <div key={attempt.id} className="flex items-center justify-between rounded-xl bg-bg-surface-alt px-3 py-2 text-sm"><span className="font-bold text-text-heading">{attempt.score}/400</span><span className="text-text-muted">{Math.round(attempt.accuracy * 100)}% · {attempt.durationSeconds}s</span></div>)}</div>}</div>
</> : <p className="mt-3 text-sm text-text-muted">Select a pack to configure the round.</p>}
</aside>
</div>
)}
{showImport && <ArcadeImportModal classId={classId} onClose={() => setShowImport(false)} onImported={() => { setShowImport(false); router.refresh(); }} />}
</div>
);
}

View file

@ -0,0 +1,5 @@
import { ConnectionsGame } from "@/components/arcade/ConnectionsGame";
export const ARCADE_RENDERERS = {
connections: ConnectionsGame,
} as const;

View file

@ -2,12 +2,18 @@
import { useState, useEffect } from "react";
export function GenerateTab({ importType }: { importType: "flashcards" | "quizzes" }) {
export function GenerateTab({ importType }: { importType: "flashcards" | "quizzes" | "connections" }) {
const [instructions, setInstructions] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saveResult, setSaveResult] = useState<"success" | "error" | null>(null);
const [copied, setCopied] = useState(false);
const [packCount, setPackCount] = useState(1);
const batchOverride = importType === "connections" && packCount > 1
? `TEMPORARY BATCH OVERRIDE:\nGenerate exactly ${packCount} distinct Connections packs. Return one raw JSON array containing exactly ${packCount} objects that each follow the schema below. Make the packs meaningfully different from one another. This override takes precedence over any later instruction to return one object.\n\n`
: "";
const displayedInstructions = `${batchOverride}${instructions}`;
useEffect(() => {
fetch(`/api/settings/llm-instructions?type=${importType}`)
@ -52,7 +58,7 @@ export function GenerateTab({ importType }: { importType: "flashcards" | "quizze
}
async function handleCopy() {
await navigator.clipboard.writeText(instructions);
await navigator.clipboard.writeText(displayedInstructions);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
@ -67,9 +73,23 @@ export function GenerateTab({ importType }: { importType: "flashcards" | "quizze
Copy these instructions and paste them into an LLM chat along with your study material. The LLM will generate JSON you can paste into the Import tab.
</p>
{importType === "connections" && (
<div className="rounded-xl border border-border bg-bg-surface-alt/60 p-4">
<div className="flex items-center justify-between gap-4">
<div>
<label htmlFor="connections-pack-count" className="text-sm font-bold text-text-heading">Number of game packs</label>
<p className="text-xs text-text-muted">This temporarily updates the copied prompt. Your saved instructions stay unchanged.</p>
</div>
<output htmlFor="connections-pack-count" className="grid h-11 min-w-12 place-items-center rounded-lg border border-border bg-bg-surface px-3 text-lg font-extrabold text-text-heading">{packCount}</output>
</div>
<input id="connections-pack-count" type="range" min={1} max={10} step={1} value={packCount} onChange={(event) => setPackCount(Number(event.target.value))} className="mt-4 w-full accent-primary" />
<div className="mt-1 flex justify-between text-[10px] font-bold text-text-muted" aria-hidden><span>1</span><span>5</span><span>10</span></div>
</div>
)}
<textarea
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
value={displayedInstructions}
onChange={(event) => setInstructions(batchOverride && event.target.value.startsWith(batchOverride) ? event.target.value.slice(batchOverride.length) : event.target.value)}
rows={14}
className="w-full px-4 py-3 rounded-lg border border-border bg-bg-surface-alt/50 text-sm text-text-body font-mono leading-relaxed focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all duration-200 resize-y"
/>

View file

@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react";
import { useRouter, usePathname } from "next/navigation";
import Link from "next/link";
import { ThemeToggle } from "./ThemeToggle";
import { STUDY_MODES } from "@/config/studyModes";
function Mark() {
return (
@ -78,36 +79,28 @@ export function Navbar() {
{classSlug && (
<div className="pt-6">
<p className="mb-2 px-3 text-[10px] font-bold uppercase tracking-[0.18em] text-text-muted">Current class</p>
<Link
href={`/${classSlug}/flashcards`}
onClick={() => setOpen(false)}
className={`flex min-h-11 items-center gap-3 rounded-xl px-3 text-sm font-semibold transition-colors ${pathname.includes("/flashcards") ? "bg-bg-callout text-primary" : "text-text-secondary hover:bg-bg-surface-alt hover:text-text-heading"}`}
>
<span aria-hidden></span> Flashcards
</Link>
<Link
href={`/${classSlug}/quizzes`}
onClick={() => setOpen(false)}
className={`mt-1 flex min-h-11 items-center gap-3 rounded-xl px-3 text-sm font-semibold transition-colors ${pathname.includes("/quizzes") ? "bg-bg-callout text-primary" : "text-text-secondary hover:bg-bg-surface-alt hover:text-text-heading"}`}
>
<span aria-hidden></span> Quizzes
</Link>
<Link
href={`/${classSlug}/spaced-repetition`}
onClick={() => setOpen(false)}
className={`mt-1 flex min-h-11 items-center gap-3 rounded-xl px-3 text-sm font-semibold transition-colors ${pathname.includes("/spaced-repetition") ? "bg-bg-callout text-primary" : "text-text-secondary hover:bg-bg-surface-alt hover:text-text-heading"}`}
>
<span aria-hidden></span> Spaced Repetition
{classSlug && hasReadyCards && (
<span
className="ml-auto grid h-5 w-5 place-items-center rounded-full bg-error-bg text-xs font-extrabold text-error"
title="Spaced repetition cards are ready to study"
{STUDY_MODES.map((mode, index) => {
const isActive = pathname.includes(`/${mode.path}`);
return (
<Link
key={mode.key}
href={`/${classSlug}/${mode.path}`}
onClick={() => setOpen(false)}
className={`${index > 0 ? "mt-1 " : ""}flex min-h-11 items-center gap-3 rounded-xl px-3 text-sm font-semibold transition-colors ${isActive ? "bg-bg-callout text-primary" : "text-text-secondary hover:bg-bg-surface-alt hover:text-text-heading"}`}
>
<span aria-hidden>!</span>
<span className="sr-only">Cards ready to study</span>
</span>
)}
</Link>
<span aria-hidden>{mode.icon}</span> {mode.label}
{mode.key === "spaced-repetition" && hasReadyCards && (
<span
className="ml-auto grid h-5 w-5 place-items-center rounded-full bg-error-bg text-xs font-extrabold text-error"
title="Spaced repetition cards are ready to study"
>
<span aria-hidden>!</span>
<span className="sr-only">Cards ready to study</span>
</span>
)}
</Link>
);
})}
</div>
)}
</nav>
@ -126,11 +119,11 @@ export function Navbar() {
return (
<>
<aside className="fixed inset-y-0 left-0 z-30 hidden w-60 flex-col border-r border-border-light bg-bg-surface/88 px-4 py-5 backdrop-blur-xl md:flex">
<aside className="app-sidebar fixed inset-y-0 left-0 z-30 hidden w-60 flex-col border-r border-border-light bg-bg-surface/88 px-4 py-5 backdrop-blur-xl md:flex">
{nav}
</aside>
<header className="sticky top-0 z-40 flex h-16 items-center justify-between border-b border-border-light bg-bg-surface/88 px-4 backdrop-blur-xl md:hidden">
<header className="app-mobile-header sticky top-0 z-40 flex h-16 items-center justify-between border-b border-border-light bg-bg-surface/88 px-4 backdrop-blur-xl md:hidden">
<Link href="/" className="flex items-center gap-2.5">
<Mark />
<span className="editorial-title text-lg text-text-heading">Study Desk</span>

34
src/config/arcadeGames.ts Normal file
View file

@ -0,0 +1,34 @@
export const ARCADE_GAMES = [
{
key: "connections",
name: "Connections",
path: "connections",
available: true,
estimatedMinutes: "510 min",
description: "Find four hidden relationships among sixteen terms.",
},
{
key: "crossword",
name: "Crossword",
path: "crossword",
available: false,
estimatedMinutes: "Coming soon",
description: "Turn course terminology into an interlocking word puzzle.",
},
{
key: "falling-blocks",
name: "Falling Blocks",
path: "falling-blocks",
available: false,
estimatedMinutes: "Coming soon",
description: "Match prompts to answers before the board fills up.",
},
{
key: "asteroid-defense",
name: "Asteroid Defense",
path: "asteroid-defense",
available: false,
estimatedMinutes: "Coming soon",
description: "Defend the station by targeting the correct answers.",
},
] as const;

View file

@ -1,7 +1,8 @@
export const STUDY_MODES = [
{ key: "flashcards", label: "Flashcards", path: "flashcards" },
{ key: "quizzes", label: "Quizzes", path: "quizzes" },
{ key: "spaced-repetition", label: "Spaced Repetition", path: "spaced-repetition" },
{ key: "flashcards", label: "Flashcards", path: "flashcards", icon: "▤" },
{ key: "quizzes", label: "Quizzes", path: "quizzes", icon: "✓" },
{ key: "spaced-repetition", label: "Spaced Repetition", path: "spaced-repetition", icon: "↻" },
{ key: "arcade", label: "Arcade", path: "arcade", icon: "◆" },
// Future modes get added here — the layout, tab bar, and auth middleware need no changes.
] as const;

View file

@ -77,6 +77,16 @@ export type Setting = Prisma.SettingModel
*
*/
export type StudyActivity = Prisma.StudyActivityModel
/**
* Model ArcadePack
*
*/
export type ArcadePack = Prisma.ArcadePackModel
/**
* Model ArcadeAttempt
*
*/
export type ArcadeAttempt = Prisma.ArcadeAttemptModel
/**
* Model MaterialGroup
*

View file

@ -101,6 +101,16 @@ export type Setting = Prisma.SettingModel
*
*/
export type StudyActivity = Prisma.StudyActivityModel
/**
* Model ArcadePack
*
*/
export type ArcadePack = Prisma.ArcadePackModel
/**
* Model ArcadeAttempt
*
*/
export type ArcadeAttempt = Prisma.ArcadeAttemptModel
/**
* Model MaterialGroup
*

File diff suppressed because one or more lines are too long

View file

@ -396,6 +396,8 @@ export const ModelName = {
AuthSecurity: 'AuthSecurity',
Setting: 'Setting',
StudyActivity: 'StudyActivity',
ArcadePack: 'ArcadePack',
ArcadeAttempt: 'ArcadeAttempt',
MaterialGroup: 'MaterialGroup',
SpacedRepetitionSet: 'SpacedRepetitionSet',
SpacedRepetitionSetDeck: 'SpacedRepetitionSetDeck',
@ -415,7 +417,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions
}
meta: {
modelProps: "class" | "deck" | "flashcard" | "quizSet" | "question" | "answerOption" | "studyProgress" | "quizAttempt" | "shareLink" | "authSecurity" | "setting" | "studyActivity" | "materialGroup" | "spacedRepetitionSet" | "spacedRepetitionSetDeck" | "spacedRepetitionCardState"
modelProps: "class" | "deck" | "flashcard" | "quizSet" | "question" | "answerOption" | "studyProgress" | "quizAttempt" | "shareLink" | "authSecurity" | "setting" | "studyActivity" | "arcadePack" | "arcadeAttempt" | "materialGroup" | "spacedRepetitionSet" | "spacedRepetitionSetDeck" | "spacedRepetitionCardState"
txIsolationLevel: TransactionIsolationLevel
}
model: {
@ -1307,6 +1309,154 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
}
}
}
ArcadePack: {
payload: Prisma.$ArcadePackPayload<ExtArgs>
fields: Prisma.ArcadePackFieldRefs
operations: {
findUnique: {
args: Prisma.ArcadePackFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload> | null
}
findUniqueOrThrow: {
args: Prisma.ArcadePackFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>
}
findFirst: {
args: Prisma.ArcadePackFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload> | null
}
findFirstOrThrow: {
args: Prisma.ArcadePackFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>
}
findMany: {
args: Prisma.ArcadePackFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>[]
}
create: {
args: Prisma.ArcadePackCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>
}
createMany: {
args: Prisma.ArcadePackCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.ArcadePackCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>[]
}
delete: {
args: Prisma.ArcadePackDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>
}
update: {
args: Prisma.ArcadePackUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>
}
deleteMany: {
args: Prisma.ArcadePackDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.ArcadePackUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.ArcadePackUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>[]
}
upsert: {
args: Prisma.ArcadePackUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadePackPayload>
}
aggregate: {
args: Prisma.ArcadePackAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateArcadePack>
}
groupBy: {
args: Prisma.ArcadePackGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ArcadePackGroupByOutputType>[]
}
count: {
args: Prisma.ArcadePackCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ArcadePackCountAggregateOutputType> | number
}
}
}
ArcadeAttempt: {
payload: Prisma.$ArcadeAttemptPayload<ExtArgs>
fields: Prisma.ArcadeAttemptFieldRefs
operations: {
findUnique: {
args: Prisma.ArcadeAttemptFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload> | null
}
findUniqueOrThrow: {
args: Prisma.ArcadeAttemptFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>
}
findFirst: {
args: Prisma.ArcadeAttemptFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload> | null
}
findFirstOrThrow: {
args: Prisma.ArcadeAttemptFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>
}
findMany: {
args: Prisma.ArcadeAttemptFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>[]
}
create: {
args: Prisma.ArcadeAttemptCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>
}
createMany: {
args: Prisma.ArcadeAttemptCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.ArcadeAttemptCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>[]
}
delete: {
args: Prisma.ArcadeAttemptDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>
}
update: {
args: Prisma.ArcadeAttemptUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>
}
deleteMany: {
args: Prisma.ArcadeAttemptDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.ArcadeAttemptUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.ArcadeAttemptUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>[]
}
upsert: {
args: Prisma.ArcadeAttemptUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ArcadeAttemptPayload>
}
aggregate: {
args: Prisma.ArcadeAttemptAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateArcadeAttempt>
}
groupBy: {
args: Prisma.ArcadeAttemptGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ArcadeAttemptGroupByOutputType>[]
}
count: {
args: Prisma.ArcadeAttemptCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ArcadeAttemptCountAggregateOutputType> | number
}
}
}
MaterialGroup: {
payload: Prisma.$MaterialGroupPayload<ExtArgs>
fields: Prisma.MaterialGroupFieldRefs
@ -1779,6 +1929,43 @@ export const StudyActivityScalarFieldEnum = {
export type StudyActivityScalarFieldEnum = (typeof StudyActivityScalarFieldEnum)[keyof typeof StudyActivityScalarFieldEnum]
export const ArcadePackScalarFieldEnum = {
id: 'id',
classId: 'classId',
gameType: 'gameType',
name: 'name',
description: 'description',
schemaVersion: 'schemaVersion',
sourceJson: 'sourceJson',
normalizedJson: 'normalizedJson',
validationReportJson: 'validationReportJson',
sortOrder: 'sortOrder',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ArcadePackScalarFieldEnum = (typeof ArcadePackScalarFieldEnum)[keyof typeof ArcadePackScalarFieldEnum]
export const ArcadeAttemptScalarFieldEnum = {
id: 'id',
arcadePackId: 'arcadePackId',
mode: 'mode',
score: 'score',
maxScore: 'maxScore',
accuracy: 'accuracy',
durationSeconds: 'durationSeconds',
mistakes: 'mistakes',
hintsUsed: 'hintsUsed',
settingsJson: 'settingsJson',
resultsJson: 'resultsJson',
seed: 'seed',
completedAt: 'completedAt'
} as const
export type ArcadeAttemptScalarFieldEnum = (typeof ArcadeAttemptScalarFieldEnum)[keyof typeof ArcadeAttemptScalarFieldEnum]
export const MaterialGroupScalarFieldEnum = {
id: 'id',
classId: 'classId',
@ -2015,6 +2202,8 @@ export type GlobalOmitConfig = {
authSecurity?: Prisma.AuthSecurityOmit
setting?: Prisma.SettingOmit
studyActivity?: Prisma.StudyActivityOmit
arcadePack?: Prisma.ArcadePackOmit
arcadeAttempt?: Prisma.ArcadeAttemptOmit
materialGroup?: Prisma.MaterialGroupOmit
spacedRepetitionSet?: Prisma.SpacedRepetitionSetOmit
spacedRepetitionSetDeck?: Prisma.SpacedRepetitionSetDeckOmit

View file

@ -63,6 +63,8 @@ export const ModelName = {
AuthSecurity: 'AuthSecurity',
Setting: 'Setting',
StudyActivity: 'StudyActivity',
ArcadePack: 'ArcadePack',
ArcadeAttempt: 'ArcadeAttempt',
MaterialGroup: 'MaterialGroup',
SpacedRepetitionSet: 'SpacedRepetitionSet',
SpacedRepetitionSetDeck: 'SpacedRepetitionSetDeck',
@ -222,6 +224,43 @@ export const StudyActivityScalarFieldEnum = {
export type StudyActivityScalarFieldEnum = (typeof StudyActivityScalarFieldEnum)[keyof typeof StudyActivityScalarFieldEnum]
export const ArcadePackScalarFieldEnum = {
id: 'id',
classId: 'classId',
gameType: 'gameType',
name: 'name',
description: 'description',
schemaVersion: 'schemaVersion',
sourceJson: 'sourceJson',
normalizedJson: 'normalizedJson',
validationReportJson: 'validationReportJson',
sortOrder: 'sortOrder',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ArcadePackScalarFieldEnum = (typeof ArcadePackScalarFieldEnum)[keyof typeof ArcadePackScalarFieldEnum]
export const ArcadeAttemptScalarFieldEnum = {
id: 'id',
arcadePackId: 'arcadePackId',
mode: 'mode',
score: 'score',
maxScore: 'maxScore',
accuracy: 'accuracy',
durationSeconds: 'durationSeconds',
mistakes: 'mistakes',
hintsUsed: 'hintsUsed',
settingsJson: 'settingsJson',
resultsJson: 'resultsJson',
seed: 'seed',
completedAt: 'completedAt'
} as const
export type ArcadeAttemptScalarFieldEnum = (typeof ArcadeAttemptScalarFieldEnum)[keyof typeof ArcadeAttemptScalarFieldEnum]
export const MaterialGroupScalarFieldEnum = {
id: 'id',
classId: 'classId',

View file

@ -20,6 +20,8 @@ export type * from './models/ShareLink'
export type * from './models/AuthSecurity'
export type * from './models/Setting'
export type * from './models/StudyActivity'
export type * from './models/ArcadePack'
export type * from './models/ArcadeAttempt'
export type * from './models/MaterialGroup'
export type * from './models/SpacedRepetitionSet'
export type * from './models/SpacedRepetitionSetDeck'

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -220,6 +220,7 @@ export type ClassWhereInput = {
quizSets?: Prisma.QuizSetListRelationFilter
materialGroups?: Prisma.MaterialGroupListRelationFilter
spacedRepetitionSets?: Prisma.SpacedRepetitionSetListRelationFilter
arcadePacks?: Prisma.ArcadePackListRelationFilter
}
export type ClassOrderByWithRelationInput = {
@ -232,6 +233,7 @@ export type ClassOrderByWithRelationInput = {
quizSets?: Prisma.QuizSetOrderByRelationAggregateInput
materialGroups?: Prisma.MaterialGroupOrderByRelationAggregateInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetOrderByRelationAggregateInput
arcadePacks?: Prisma.ArcadePackOrderByRelationAggregateInput
}
export type ClassWhereUniqueInput = Prisma.AtLeast<{
@ -247,6 +249,7 @@ export type ClassWhereUniqueInput = Prisma.AtLeast<{
quizSets?: Prisma.QuizSetListRelationFilter
materialGroups?: Prisma.MaterialGroupListRelationFilter
spacedRepetitionSets?: Prisma.SpacedRepetitionSetListRelationFilter
arcadePacks?: Prisma.ArcadePackListRelationFilter
}, "id" | "slug">
export type ClassOrderByWithAggregationInput = {
@ -283,6 +286,7 @@ export type ClassCreateInput = {
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackCreateNestedManyWithoutClassInput
}
export type ClassUncheckedCreateInput = {
@ -295,6 +299,7 @@ export type ClassUncheckedCreateInput = {
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackUncheckedCreateNestedManyWithoutClassInput
}
export type ClassUpdateInput = {
@ -307,6 +312,7 @@ export type ClassUpdateInput = {
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUpdateManyWithoutClassNestedInput
}
export type ClassUncheckedUpdateInput = {
@ -319,6 +325,7 @@ export type ClassUncheckedUpdateInput = {
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUncheckedUpdateManyWithoutClassNestedInput
}
export type ClassCreateManyInput = {
@ -426,6 +433,20 @@ export type ClassUpdateOneRequiredWithoutQuizSetsNestedInput = {
update?: Prisma.XOR<Prisma.XOR<Prisma.ClassUpdateToOneWithWhereWithoutQuizSetsInput, Prisma.ClassUpdateWithoutQuizSetsInput>, Prisma.ClassUncheckedUpdateWithoutQuizSetsInput>
}
export type ClassCreateNestedOneWithoutArcadePacksInput = {
create?: Prisma.XOR<Prisma.ClassCreateWithoutArcadePacksInput, Prisma.ClassUncheckedCreateWithoutArcadePacksInput>
connectOrCreate?: Prisma.ClassCreateOrConnectWithoutArcadePacksInput
connect?: Prisma.ClassWhereUniqueInput
}
export type ClassUpdateOneRequiredWithoutArcadePacksNestedInput = {
create?: Prisma.XOR<Prisma.ClassCreateWithoutArcadePacksInput, Prisma.ClassUncheckedCreateWithoutArcadePacksInput>
connectOrCreate?: Prisma.ClassCreateOrConnectWithoutArcadePacksInput
upsert?: Prisma.ClassUpsertWithoutArcadePacksInput
connect?: Prisma.ClassWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.ClassUpdateToOneWithWhereWithoutArcadePacksInput, Prisma.ClassUpdateWithoutArcadePacksInput>, Prisma.ClassUncheckedUpdateWithoutArcadePacksInput>
}
export type ClassCreateNestedOneWithoutMaterialGroupsInput = {
create?: Prisma.XOR<Prisma.ClassCreateWithoutMaterialGroupsInput, Prisma.ClassUncheckedCreateWithoutMaterialGroupsInput>
connectOrCreate?: Prisma.ClassCreateOrConnectWithoutMaterialGroupsInput
@ -463,6 +484,7 @@ export type ClassCreateWithoutDecksInput = {
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackCreateNestedManyWithoutClassInput
}
export type ClassUncheckedCreateWithoutDecksInput = {
@ -474,6 +496,7 @@ export type ClassUncheckedCreateWithoutDecksInput = {
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackUncheckedCreateNestedManyWithoutClassInput
}
export type ClassCreateOrConnectWithoutDecksInput = {
@ -501,6 +524,7 @@ export type ClassUpdateWithoutDecksInput = {
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUpdateManyWithoutClassNestedInput
}
export type ClassUncheckedUpdateWithoutDecksInput = {
@ -512,6 +536,7 @@ export type ClassUncheckedUpdateWithoutDecksInput = {
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUncheckedUpdateManyWithoutClassNestedInput
}
export type ClassCreateWithoutQuizSetsInput = {
@ -523,6 +548,7 @@ export type ClassCreateWithoutQuizSetsInput = {
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackCreateNestedManyWithoutClassInput
}
export type ClassUncheckedCreateWithoutQuizSetsInput = {
@ -534,6 +560,7 @@ export type ClassUncheckedCreateWithoutQuizSetsInput = {
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackUncheckedCreateNestedManyWithoutClassInput
}
export type ClassCreateOrConnectWithoutQuizSetsInput = {
@ -561,6 +588,7 @@ export type ClassUpdateWithoutQuizSetsInput = {
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUpdateManyWithoutClassNestedInput
}
export type ClassUncheckedUpdateWithoutQuizSetsInput = {
@ -572,6 +600,71 @@ export type ClassUncheckedUpdateWithoutQuizSetsInput = {
decks?: Prisma.DeckUncheckedUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUncheckedUpdateManyWithoutClassNestedInput
}
export type ClassCreateWithoutArcadePacksInput = {
id?: string
slug: string
name: string
sortOrder?: number
createdAt?: Date | string
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
}
export type ClassUncheckedCreateWithoutArcadePacksInput = {
id?: string
slug: string
name: string
sortOrder?: number
createdAt?: Date | string
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
}
export type ClassCreateOrConnectWithoutArcadePacksInput = {
where: Prisma.ClassWhereUniqueInput
create: Prisma.XOR<Prisma.ClassCreateWithoutArcadePacksInput, Prisma.ClassUncheckedCreateWithoutArcadePacksInput>
}
export type ClassUpsertWithoutArcadePacksInput = {
update: Prisma.XOR<Prisma.ClassUpdateWithoutArcadePacksInput, Prisma.ClassUncheckedUpdateWithoutArcadePacksInput>
create: Prisma.XOR<Prisma.ClassCreateWithoutArcadePacksInput, Prisma.ClassUncheckedCreateWithoutArcadePacksInput>
where?: Prisma.ClassWhereInput
}
export type ClassUpdateToOneWithWhereWithoutArcadePacksInput = {
where?: Prisma.ClassWhereInput
data: Prisma.XOR<Prisma.ClassUpdateWithoutArcadePacksInput, Prisma.ClassUncheckedUpdateWithoutArcadePacksInput>
}
export type ClassUpdateWithoutArcadePacksInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
slug?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
}
export type ClassUncheckedUpdateWithoutArcadePacksInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
slug?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
decks?: Prisma.DeckUncheckedUpdateManyWithoutClassNestedInput
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedUpdateManyWithoutClassNestedInput
}
export type ClassCreateWithoutMaterialGroupsInput = {
@ -583,6 +676,7 @@ export type ClassCreateWithoutMaterialGroupsInput = {
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackCreateNestedManyWithoutClassInput
}
export type ClassUncheckedCreateWithoutMaterialGroupsInput = {
@ -594,6 +688,7 @@ export type ClassUncheckedCreateWithoutMaterialGroupsInput = {
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackUncheckedCreateNestedManyWithoutClassInput
}
export type ClassCreateOrConnectWithoutMaterialGroupsInput = {
@ -621,6 +716,7 @@ export type ClassUpdateWithoutMaterialGroupsInput = {
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUpdateManyWithoutClassNestedInput
}
export type ClassUncheckedUpdateWithoutMaterialGroupsInput = {
@ -632,6 +728,7 @@ export type ClassUncheckedUpdateWithoutMaterialGroupsInput = {
decks?: Prisma.DeckUncheckedUpdateManyWithoutClassNestedInput
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUncheckedUpdateManyWithoutClassNestedInput
}
export type ClassCreateWithoutSpacedRepetitionSetsInput = {
@ -643,6 +740,7 @@ export type ClassCreateWithoutSpacedRepetitionSetsInput = {
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackCreateNestedManyWithoutClassInput
}
export type ClassUncheckedCreateWithoutSpacedRepetitionSetsInput = {
@ -654,6 +752,7 @@ export type ClassUncheckedCreateWithoutSpacedRepetitionSetsInput = {
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
arcadePacks?: Prisma.ArcadePackUncheckedCreateNestedManyWithoutClassInput
}
export type ClassCreateOrConnectWithoutSpacedRepetitionSetsInput = {
@ -681,6 +780,7 @@ export type ClassUpdateWithoutSpacedRepetitionSetsInput = {
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUpdateManyWithoutClassNestedInput
}
export type ClassUncheckedUpdateWithoutSpacedRepetitionSetsInput = {
@ -692,6 +792,7 @@ export type ClassUncheckedUpdateWithoutSpacedRepetitionSetsInput = {
decks?: Prisma.DeckUncheckedUpdateManyWithoutClassNestedInput
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
arcadePacks?: Prisma.ArcadePackUncheckedUpdateManyWithoutClassNestedInput
}
@ -704,6 +805,7 @@ export type ClassCountOutputType = {
quizSets: number
materialGroups: number
spacedRepetitionSets: number
arcadePacks: number
}
export type ClassCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
@ -711,6 +813,7 @@ export type ClassCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.
quizSets?: boolean | ClassCountOutputTypeCountQuizSetsArgs
materialGroups?: boolean | ClassCountOutputTypeCountMaterialGroupsArgs
spacedRepetitionSets?: boolean | ClassCountOutputTypeCountSpacedRepetitionSetsArgs
arcadePacks?: boolean | ClassCountOutputTypeCountArcadePacksArgs
}
/**
@ -751,6 +854,13 @@ export type ClassCountOutputTypeCountSpacedRepetitionSetsArgs<ExtArgs extends ru
where?: Prisma.SpacedRepetitionSetWhereInput
}
/**
* ClassCountOutputType without action
*/
export type ClassCountOutputTypeCountArcadePacksArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.ArcadePackWhereInput
}
export type ClassSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
@ -762,6 +872,7 @@ export type ClassSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs =
quizSets?: boolean | Prisma.Class$quizSetsArgs<ExtArgs>
materialGroups?: boolean | Prisma.Class$materialGroupsArgs<ExtArgs>
spacedRepetitionSets?: boolean | Prisma.Class$spacedRepetitionSetsArgs<ExtArgs>
arcadePacks?: boolean | Prisma.Class$arcadePacksArgs<ExtArgs>
_count?: boolean | Prisma.ClassCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["class"]>
@ -795,6 +906,7 @@ export type ClassInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs =
quizSets?: boolean | Prisma.Class$quizSetsArgs<ExtArgs>
materialGroups?: boolean | Prisma.Class$materialGroupsArgs<ExtArgs>
spacedRepetitionSets?: boolean | Prisma.Class$spacedRepetitionSetsArgs<ExtArgs>
arcadePacks?: boolean | Prisma.Class$arcadePacksArgs<ExtArgs>
_count?: boolean | Prisma.ClassCountOutputTypeDefaultArgs<ExtArgs>
}
export type ClassIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {}
@ -807,6 +919,7 @@ export type $ClassPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs
quizSets: Prisma.$QuizSetPayload<ExtArgs>[]
materialGroups: Prisma.$MaterialGroupPayload<ExtArgs>[]
spacedRepetitionSets: Prisma.$SpacedRepetitionSetPayload<ExtArgs>[]
arcadePacks: Prisma.$ArcadePackPayload<ExtArgs>[]
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string
@ -1212,6 +1325,7 @@ export interface Prisma__ClassClient<T, Null = never, ExtArgs extends runtime.Ty
quizSets<T extends Prisma.Class$quizSetsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Class$quizSetsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$QuizSetPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
materialGroups<T extends Prisma.Class$materialGroupsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Class$materialGroupsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$MaterialGroupPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
spacedRepetitionSets<T extends Prisma.Class$spacedRepetitionSetsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Class$spacedRepetitionSetsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$SpacedRepetitionSetPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
arcadePacks<T extends Prisma.Class$arcadePacksArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Class$arcadePacksArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$ArcadePackPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved.
@ -1732,6 +1846,30 @@ export type Class$spacedRepetitionSetsArgs<ExtArgs extends runtime.Types.Extensi
distinct?: Prisma.SpacedRepetitionSetScalarFieldEnum | Prisma.SpacedRepetitionSetScalarFieldEnum[]
}
/**
* Class.arcadePacks
*/
export type Class$arcadePacksArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the ArcadePack
*/
select?: Prisma.ArcadePackSelect<ExtArgs> | null
/**
* Omit specific fields from the ArcadePack
*/
omit?: Prisma.ArcadePackOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.ArcadePackInclude<ExtArgs> | null
where?: Prisma.ArcadePackWhereInput
orderBy?: Prisma.ArcadePackOrderByWithRelationInput | Prisma.ArcadePackOrderByWithRelationInput[]
cursor?: Prisma.ArcadePackWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.ArcadePackScalarFieldEnum | Prisma.ArcadePackScalarFieldEnum[]
}
/**
* Class without action
*/

View file

@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { deterministicShuffle, replayConnectionsAttempt } from "@/lib/arcade/connectionsEngine";
import type { NormalizedConnectionsPack } from "@/types/arcade";
const pack: NormalizedConnectionsPack = {
schemaVersion: 1,
type: "connections",
name: "Test",
settings: { groupCount: 4, itemsPerGroup: 4, allowedMistakes: 4 },
content: Array.from({ length: 4 }, (_, groupIndex) => ({
id: `group-${groupIndex + 1}`,
category: `Category ${groupIndex + 1}`,
explanation: `Explanation ${groupIndex + 1}`,
items: Array.from({ length: 4 }, (_, itemIndex) => ({
id: `g${groupIndex + 1}-i${itemIndex + 1}`,
text: `Item ${groupIndex + 1}-${itemIndex + 1}`,
})),
})),
};
const settings = { allowedMistakes: 4, oneAwayFeedback: true };
describe("Connections engine", () => {
it("shuffles deterministically for a seed", () => {
const items = Array.from({ length: 16 }, (_, index) => index);
expect(deterministicShuffle(items, "round-1")).toEqual(deterministicShuffle(items, "round-1"));
expect(deterministicShuffle(items, "round-1")).not.toEqual(deterministicShuffle(items, "round-2"));
});
it("scores a perfect completed round", () => {
const submissions = pack.content.map((group, index) => ({
itemIds: group.items.map((item) => item.id),
elapsedMs: (index + 1) * 1000,
}));
const replay = replayConnectionsAttempt(pack, submissions, settings, 12);
expect(replay.complete).toBe(true);
expect(replay.result).toMatchObject({ outcome: "WON", score: 400, mistakes: 0, accuracy: 1 });
});
it("deducts mistakes and detects one-away selections", () => {
const wrong = [
...pack.content[0].items.slice(0, 3).map((item) => item.id),
pack.content[1].items[0].id,
];
const submissions = [
{ itemIds: wrong, elapsedMs: 1000 },
...pack.content.map((group, index) => ({ itemIds: group.items.map((item) => item.id), elapsedMs: (index + 2) * 1000 })),
];
const replay = replayConnectionsAttempt(pack, submissions, settings, 20);
expect(replay.result).toMatchObject({ outcome: "WON", score: 390, mistakes: 1, hintsUsed: 1, accuracy: 0.8 });
});
it("ends after the configured number of mistakes", () => {
const wrong = [pack.content[0].items[0].id, pack.content[1].items[0].id, pack.content[2].items[0].id, pack.content[3].items[0].id];
const replay = replayConnectionsAttempt(pack, Array.from({ length: 4 }, (_, index) => ({ itemIds: wrong, elapsedMs: index * 1000 })), settings, 10);
expect(replay.complete).toBe(true);
expect(replay.result).toMatchObject({ outcome: "LOST", score: 0, mistakes: 4 });
});
it("rejects selections containing unknown item ids", () => {
expect(() => replayConnectionsAttempt(pack, [{ itemIds: ["missing", "a", "b", "c"], elapsedMs: 0 }], settings, 0)).toThrow("invalid tile selection");
});
});

View file

@ -0,0 +1,110 @@
import type {
ArcadeRoundResult,
ArcadeSessionSettings,
ConnectionsSubmission,
NormalizedConnectionsGroup,
NormalizedConnectionsPack,
} from "@/types/arcade";
function hashSeed(seed: string) {
let value = 2166136261;
for (let index = 0; index < seed.length; index += 1) {
value ^= seed.charCodeAt(index);
value = Math.imul(value, 16777619);
}
return value >>> 0;
}
function seededRandom(seed: string) {
let state = hashSeed(seed);
return () => {
state += 0x6d2b79f5;
let value = state;
value = Math.imul(value ^ (value >>> 15), value | 1);
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
};
}
export function deterministicShuffle<T>(items: readonly T[], seed: string): T[] {
const result = [...items];
const random = seededRandom(seed);
for (let index = result.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(random() * (index + 1));
[result[index], result[swapIndex]] = [result[swapIndex], result[index]];
}
return result;
}
function groupForSelection(groups: NormalizedConnectionsGroup[], itemIds: string[]) {
const selected = new Set(itemIds);
return groups.find(
(group) => group.items.length === selected.size && group.items.every((item) => selected.has(item.id))
);
}
export function isOneAway(
groups: NormalizedConnectionsGroup[],
selectedItemIds: string[],
solvedGroupIds: Set<string>
) {
const selected = new Set(selectedItemIds);
return groups.some(
(group) =>
!solvedGroupIds.has(group.id) &&
group.items.filter((item) => selected.has(item.id)).length === 3
);
}
export function replayConnectionsAttempt(
pack: NormalizedConnectionsPack,
submissions: ConnectionsSubmission[],
settings: ArcadeSessionSettings,
durationSeconds: number
): { complete: boolean; result: ArcadeRoundResult } {
const validItemIds = new Set(pack.content.flatMap((group) => group.items.map((item) => item.id)));
const solvedGroupIds = new Set<string>();
const incorrectSelections: string[][] = [];
let hintsUsed = 0;
let mistakes = 0;
let processed = 0;
for (const submission of submissions) {
if (solvedGroupIds.size === pack.content.length || mistakes >= settings.allowedMistakes) break;
if (new Set(submission.itemIds).size !== 4 || submission.itemIds.some((id) => !validItemIds.has(id))) {
throw new Error("An attempt contains an invalid tile selection.");
}
processed += 1;
const group = groupForSelection(pack.content, submission.itemIds);
if (group && !solvedGroupIds.has(group.id)) {
solvedGroupIds.add(group.id);
} else {
if (settings.oneAwayFeedback && isOneAway(pack.content, submission.itemIds, solvedGroupIds)) {
hintsUsed += 1;
}
mistakes += 1;
incorrectSelections.push([...submission.itemIds]);
}
}
const complete = solvedGroupIds.size === pack.content.length || mistakes >= settings.allowedMistakes;
const score = Math.max(0, solvedGroupIds.size * 100 - mistakes * 10);
const result: ArcadeRoundResult = {
outcome: solvedGroupIds.size === pack.content.length ? "WON" : "LOST",
score,
maxScore: 400,
accuracy: processed === 0 ? 0 : solvedGroupIds.size / processed,
durationSeconds,
mistakes,
hintsUsed,
groups: pack.content.map((group) => ({
groupId: group.id,
category: group.category,
items: group.items.map((item) => item.text),
explanation: group.explanation,
solved: solvedGroupIds.has(group.id),
})),
incorrectSelections,
};
return { complete, result };
}

View file

@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { ArcadeImportError, parseConnectionsImport, parseConnectionsImportBatch } from "@/lib/arcade/connectionsImport";
function validPack() {
return {
schemaVersion: 1,
type: "connections",
name: "Medication Connections",
description: "Four medication groups",
settings: { groupCount: 4, itemsPerGroup: 4, allowedMistakes: 4 },
content: Array.from({ length: 4 }, (_, groupIndex) => ({
category: `Category ${groupIndex + 1}`,
items: Array.from({ length: 4 }, (_, itemIndex) => `Item ${groupIndex + 1}-${itemIndex + 1}`),
explanation: `Explanation ${groupIndex + 1}`,
})),
};
}
describe("Connections imports", () => {
it("normalizes a valid board and creates stable ids", () => {
const result = parseConnectionsImport(JSON.stringify(validPack()));
expect(result.preview.itemCount).toBe(16);
expect(result.preview.itemCount).toBe(16);
expect(result.preview.normalized.content[0].id).toBe("group-1");
expect(result.preview.normalized.content[0].items[0].id).toBe("group-1-item-1");
});
it("rejects fences and surrounding prose", () => {
expect(() => parseConnectionsImport(`\`\`\`json\n${JSON.stringify(validPack())}\n\`\`\``)).toThrow(ArcadeImportError);
expect(() => parseConnectionsImport(`Here is the pack: ${JSON.stringify(validPack())}`)).toThrow(ArcadeImportError);
});
it("rejects duplicate tiles case-insensitively", () => {
const pack = validPack();
pack.content[1].items[0] = " item 1-1 ";
expect(() => parseConnectionsImport(JSON.stringify(pack))).toThrow("Every tile must be unique");
});
it("rejects unknown keys", () => {
const pack = { ...validPack(), extra: true };
expect(() => parseConnectionsImport(JSON.stringify(pack))).toThrow("does not match schema version 1");
});
it("reports long-tile warnings without changing the content", () => {
const pack = validPack();
pack.content[0].items[0] = "This intentionally long tile contains too many words";
const result = parseConnectionsImport(JSON.stringify(pack));
expect(result.report.warnings.length).toBeGreaterThan(0);
expect(result.preview.normalized.content[0].items[0].text).toBe(pack.content[0].items[0]);
});
it("does not treat a shared generic word as a similar category", () => {
const pack = validPack();
pack.content[0].category = "ACE Inhibitors";
pack.content[1].category = "Proton Pump Inhibitors";
const result = parseConnectionsImport(JSON.stringify(pack));
expect(result.report.warnings).not.toContain(
"Categories “ACE Inhibitors” and “Proton Pump Inhibitors” may be too similar."
);
});
it("previews multiple packs from one JSON array", () => {
const first = validPack();
const second = { ...validPack(), name: "Second board" };
const result = parseConnectionsImportBatch(JSON.stringify([first, second]));
expect(result.count).toBe(2);
expect(result.packs.map((pack) => pack.name)).toEqual(["Medication Connections", "Second board"]);
});
it("limits batch imports to ten packs", () => {
const batch = Array.from({ length: 11 }, () => validPack());
expect(() => parseConnectionsImportBatch(JSON.stringify(batch))).toThrow("between 1 and 10");
});
});

View file

@ -0,0 +1,170 @@
import { parseAndRepairJson } from "@/lib/jsonRepair";
import { connectionsImportSchema } from "@/lib/validation/arcadeSchemas";
import type {
ArcadeImportPreview,
ArcadeImportBatchPreview,
ArcadeValidationReport,
NormalizedConnectionsPack,
} from "@/types/arcade";
export class ArcadeImportError extends Error {
constructor(message: string, public readonly details: string[] = []) {
super(message);
}
}
function clean(value: string) {
return value.trim().replace(/\s+/g, " ");
}
function comparisonKey(value: string) {
return clean(value).toLocaleLowerCase();
}
function safeId(value: string, fallback: string) {
const id = value
.trim()
.toLocaleLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
return id || fallback;
}
function findWarnings(pack: NormalizedConnectionsPack) {
const warnings: string[] = [];
const categoryKeys = pack.content.map((group) => comparisonKey(group.category));
for (const group of pack.content) {
for (const item of group.items) {
if (item.text.length > 40) {
warnings.push(`${item.text}” is longer than 40 characters and may be difficult to scan.`);
} else if (item.text.split(/\s+/).length > 4) {
warnings.push(`${item.text}” contains more than four words.`);
}
}
}
for (let left = 0; left < categoryKeys.length; left += 1) {
for (let right = left + 1; right < categoryKeys.length; right += 1) {
const a = categoryKeys[left];
const b = categoryKeys[right];
if (a.includes(b) || b.includes(a)) {
warnings.push(
`Categories “${pack.content[left].category}” and “${pack.content[right].category}” may be too similar.`
);
}
}
}
return [...new Set(warnings)];
}
export function parseConnectionsImportBatch(rawJson: string): ArcadeImportBatchPreview {
const trimmed = rawJson.trim();
const isObject = trimmed.startsWith("{") && trimmed.endsWith("}");
const isArray = trimmed.startsWith("[") && trimmed.endsWith("]");
if (trimmed.includes("```") || (!isObject && !isArray)) {
throw new ArcadeImportError("Paste one raw JSON object or an array of pack objects without Markdown fences or surrounding prose.");
}
const repaired = parseAndRepairJson(trimmed);
if (!repaired.success) {
throw new ArcadeImportError(repaired.error ?? "The JSON could not be parsed.");
}
const values = Array.isArray(repaired.data) ? repaired.data : [repaired.data];
if (values.length < 1 || values.length > 10) {
throw new ArcadeImportError("Import between 1 and 10 Connections packs at a time.");
}
const packs = values.map((value, index) => {
try {
const parsed = parseConnectionsImport(JSON.stringify(value));
return {
...parsed.preview,
wasRepaired: parsed.preview.wasRepaired || repaired.wasRepaired,
};
} catch (error) {
if (error instanceof ArcadeImportError) {
throw new ArcadeImportError(`Pack ${index + 1}: ${error.message}`, error.details);
}
throw error;
}
});
return { packs, count: packs.length, wasRepaired: repaired.wasRepaired };
}
export function parseConnectionsImport(rawJson: string): {
preview: ArcadeImportPreview;
report: ArcadeValidationReport;
} {
const trimmed = rawJson.trim();
if (trimmed.includes("```") || !trimmed.startsWith("{") || !trimmed.endsWith("}")) {
throw new ArcadeImportError("Paste one raw JSON object without Markdown fences or surrounding prose.");
}
const repaired = parseAndRepairJson(trimmed);
if (!repaired.success) {
throw new ArcadeImportError(repaired.error ?? "The JSON could not be parsed.");
}
const parsed = connectionsImportSchema.safeParse(repaired.data);
if (!parsed.success) {
throw new ArcadeImportError(
"The Connections pack does not match schema version 1.",
parsed.error.issues.map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`)
);
}
const categoryKeys = parsed.data.content.map((group) => comparisonKey(group.category));
if (new Set(categoryKeys).size !== categoryKeys.length) {
throw new ArcadeImportError("Every group must have a unique category name.");
}
const itemKeys = parsed.data.content.flatMap((group) => group.items.map(comparisonKey));
if (new Set(itemKeys).size !== itemKeys.length) {
throw new ArcadeImportError("Every tile must be unique across the entire board.");
}
const usedGroupIds = new Set<string>();
const normalized: NormalizedConnectionsPack = {
schemaVersion: 1,
type: "connections",
name: clean(parsed.data.name),
description: parsed.data.description ? clean(parsed.data.description) : undefined,
settings: { ...parsed.data.settings },
content: parsed.data.content.map((group, groupIndex) => {
const fallbackId = `group-${groupIndex + 1}`;
const groupId = safeId(group.id ?? fallbackId, fallbackId);
if (usedGroupIds.has(groupId)) {
throw new ArcadeImportError(`Group id “${groupId}” is duplicated.`);
}
usedGroupIds.add(groupId);
return {
id: groupId,
category: clean(group.category),
explanation: group.explanation.trim(),
items: group.items.map((text, itemIndex) => ({
id: `${groupId}-item-${itemIndex + 1}`,
text: clean(text),
})),
};
}),
};
const warnings = findWarnings(normalized);
const report = { wasRepaired: repaired.wasRepaired, warnings };
return {
report,
preview: {
gameType: "connections",
name: normalized.name,
description: normalized.description,
categories: normalized.content.map((group) => group.category),
itemCount: 16,
wasRepaired: repaired.wasRepaired,
warnings,
normalized,
},
};
}

View file

@ -0,0 +1,35 @@
import { parseConnectionsImportBatch } from "@/lib/arcade/connectionsImport";
import type { ArcadeGameKey } from "@/types/arcade";
const CONNECTIONS_PROMPT = `You are generating a Connections study game as strict JSON.
Return exactly one JSON object with no Markdown fence or commentary.
Use this schema:
{"schemaVersion":1,"type":"connections","name":"...","description":"...","settings":{"groupCount":4,"itemsPerGroup":4,"allowedMistakes":4},"content":[{"id":"group-1","category":"...","items":["...","...","...","..."],"explanation":"..."}]}
Rules:
- Generate exactly four groups of exactly four unique items.
- Every item must fit only its intended group within this board.
- Use specific, distinct category names and concise tiles of one to four words.
- Give every group a concise explanation.
- Do not create arbitrary leftover groups or use broad categories shared by most tiles.
- Base all content strictly on the study material below.
Material:
[paste your notes or lecture content here]`;
export const ARCADE_SERVER_REGISTRY = {
connections: {
schemaVersion: 1,
preview: parseConnectionsImportBatch,
defaultInstructions: CONNECTIONS_PROMPT,
},
} satisfies Record<ArcadeGameKey, {
schemaVersion: number;
preview: typeof parseConnectionsImportBatch;
defaultInstructions: string;
}>;
export function getArcadeGameDefinition(gameType: ArcadeGameKey) {
return ARCADE_SERVER_REGISTRY[gameType];
}

View file

@ -1,7 +1,7 @@
import { z } from "zod";
export const studyActivitySchema = z.object({
type: z.enum(["FLASHCARD", "QUIZ_QUESTION"]),
type: z.enum(["FLASHCARD", "QUIZ_QUESTION", "ARCADE_GROUP"]),
});
export type StudyActivityType = z.infer<typeof studyActivitySchema>["type"];

View file

@ -0,0 +1,61 @@
import { z } from "zod";
const connectionGroupSchema = z.object({
id: z.string().trim().min(1).optional(),
category: z.string().trim().min(1).max(100),
items: z.tuple([
z.string().trim().min(1),
z.string().trim().min(1),
z.string().trim().min(1),
z.string().trim().min(1),
]),
explanation: z.string().trim().min(1).max(1000),
// Accepted for compatibility with early packs, but no longer used or generated.
difficulty: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]).optional(),
}).strict();
export const connectionsImportSchema = z.object({
schemaVersion: z.literal(1),
type: z.literal("connections"),
name: z.string().trim().min(1).max(150),
description: z.string().trim().max(500).optional(),
settings: z.object({
groupCount: z.literal(4),
itemsPerGroup: z.literal(4),
allowedMistakes: z.number().int().min(1).max(8),
}).strict(),
content: z.array(connectionGroupSchema).length(4),
}).strict();
export const arcadePreviewRequestSchema = z.object({
gameType: z.literal("connections"),
rawJson: z.string().min(1),
});
export const arcadePackCreateSchema = arcadePreviewRequestSchema.extend({
classId: z.string().min(1),
name: z.string().trim().min(1).max(150).optional(),
names: z.array(z.string().trim().min(1).max(150)).min(1).max(10).optional(),
warningsAcknowledged: z.boolean().default(false),
});
export const arcadePackUpdateSchema = z.object({
name: z.string().trim().min(1).max(150),
});
export const arcadeAttemptCreateSchema = z.object({
mode: z.literal("CLASSIC").default("CLASSIC"),
durationSeconds: z.number().int().min(0).max(86400),
seed: z.string().min(1).max(100),
settings: z.object({
allowedMistakes: z.number().int().min(1).max(8),
oneAwayFeedback: z.boolean(),
}).strict(),
submissions: z.array(z.object({
itemIds: z.array(z.string().min(1)).length(4),
elapsedMs: z.number().int().min(0).max(86400000),
}).strict()).max(50),
});
export type ConnectionsImportInput = z.infer<typeof connectionsImportSchema>;
export type ArcadeAttemptCreateInput = z.infer<typeof arcadeAttemptCreateSchema>;

View file

@ -10,6 +10,7 @@ export interface DailyActivity {
date: string;
flashcards: number;
questions: number;
arcade: number;
total: number;
level: 0 | 1 | 2 | 3 | 4;
}
@ -20,6 +21,7 @@ export interface ActivitySummary {
activeDays: number;
flashcards: number;
questions: number;
arcade: number;
currentStreak: number;
}
@ -46,7 +48,7 @@ function getLevel(total: number): 0 | 1 | 2 | 3 | 4 {
}
function emptyDay(date: string): DailyActivity {
return { date, flashcards: 0, questions: 0, total: 0, level: 0 };
return { date, flashcards: 0, questions: 0, arcade: 0, total: 0, level: 0 };
}
export async function recordActivity(type: StudyActivityType) {
@ -71,6 +73,7 @@ export async function getActivitySummary(now = new Date()): Promise<ActivitySumm
const day = totals.get(date) ?? emptyDay(date);
if (activity.type === "FLASHCARD") day.flashcards += 1;
if (activity.type === "QUIZ_QUESTION") day.questions += 1;
if (activity.type === "ARCADE_GROUP") day.arcade += 1;
day.total += 1;
day.level = getLevel(day.total);
totals.set(date, day);
@ -83,6 +86,7 @@ export async function getActivitySummary(now = new Date()): Promise<ActivitySumm
const visibleDays = days.filter((day) => day.date <= today && day.date <= endDate);
const flashcards = visibleDays.reduce((sum, day) => sum + day.flashcards, 0);
const questions = visibleDays.reduce((sum, day) => sum + day.questions, 0);
const arcade = visibleDays.reduce((sum, day) => sum + day.arcade, 0);
let streakDate = (totals.get(today)?.total ?? 0) >= STREAK_THRESHOLD
? today
@ -99,6 +103,7 @@ export async function getActivitySummary(now = new Date()): Promise<ActivitySumm
activeDays: visibleDays.filter((day) => day.total > 0).length,
flashcards,
questions,
arcade,
currentStreak,
};
}

View file

@ -0,0 +1,176 @@
import { prisma } from "@/lib/db";
import { parseConnectionsImportBatch } from "@/lib/arcade/connectionsImport";
import { replayConnectionsAttempt } from "@/lib/arcade/connectionsEngine";
import type { ArcadeAttemptCreateInput } from "@/lib/validation/arcadeSchemas";
import type {
ArcadeAttemptSummary,
ArcadeGameKey,
ArcadePackSummary,
NormalizedConnectionsPack,
} from "@/types/arcade";
function attemptSummary(attempt: {
id: string;
score: number;
maxScore: number;
accuracy: number;
durationSeconds: number;
mistakes: number;
completedAt: Date;
resultsJson?: string;
}): ArcadeAttemptSummary {
return {
id: attempt.id,
score: attempt.score,
maxScore: attempt.maxScore,
accuracy: attempt.accuracy,
durationSeconds: attempt.durationSeconds,
mistakes: attempt.mistakes,
completedAt: attempt.completedAt.toISOString(),
...(attempt.resultsJson ? { results: JSON.parse(attempt.resultsJson) } : {}),
};
}
export function previewArcadeImport(rawJson: string) {
return parseConnectionsImportBatch(rawJson);
}
export async function createArcadePacks(input: {
classId: string;
gameType: ArcadeGameKey;
rawJson: string;
name?: string;
names?: string[];
warningsAcknowledged: boolean;
}) {
const classItem = await prisma.class.findUnique({ where: { id: input.classId }, select: { id: true } });
if (!classItem) throw new Error("Class not found");
const batch = parseConnectionsImportBatch(input.rawJson);
if (input.names && input.names.length !== batch.count) {
throw new Error("A name is required for every imported pack.");
}
if (batch.packs.some((preview) => preview.warnings.length > 0) && !input.warningsAcknowledged) {
throw new Error("Import warnings must be acknowledged before saving.");
}
const maxOrder = await prisma.arcadePack.aggregate({
where: { classId: input.classId, gameType: input.gameType },
_max: { sortOrder: true },
});
return prisma.$transaction(async (transaction) => {
const created = [];
for (const [index, preview] of batch.packs.entries()) {
created.push(await transaction.arcadePack.create({
data: {
classId: input.classId,
gameType: input.gameType,
name: input.names?.[index]?.trim() || (batch.count === 1 ? input.name?.trim() : undefined) || preview.name,
description: preview.description,
schemaVersion: preview.normalized.schemaVersion,
sourceJson: JSON.stringify(preview.normalized),
normalizedJson: JSON.stringify(preview.normalized),
validationReportJson: JSON.stringify({ wasRepaired: preview.wasRepaired, warnings: preview.warnings }),
sortOrder: (maxOrder._max.sortOrder ?? -1) + index + 1,
},
}));
}
return created;
});
}
export async function listArcadePacks(classId: string, gameType: ArcadeGameKey): Promise<ArcadePackSummary[]> {
const packs = await prisma.arcadePack.findMany({
where: { classId, gameType },
orderBy: { sortOrder: "asc" },
include: { attempts: { orderBy: { completedAt: "desc" } } },
});
return packs.map((pack) => {
const normalized = JSON.parse(pack.normalizedJson) as NormalizedConnectionsPack;
return {
id: pack.id,
classId: pack.classId,
gameType: pack.gameType as ArcadeGameKey,
name: pack.name,
description: pack.description,
schemaVersion: pack.schemaVersion,
itemCount: normalized.content.length * 4,
defaultAllowedMistakes: normalized.settings.allowedMistakes,
bestScore: pack.attempts.length ? Math.max(...pack.attempts.map((attempt) => attempt.score)) : null,
latestAttempt: pack.attempts[0] ? attemptSummary(pack.attempts[0]) : null,
};
});
}
export async function getArcadePack(id: string) {
const pack = await prisma.arcadePack.findUnique({
where: { id },
include: { class: { select: { slug: true, name: true } } },
});
if (!pack) return null;
return {
...pack,
gameType: pack.gameType as ArcadeGameKey,
normalized: JSON.parse(pack.normalizedJson) as NormalizedConnectionsPack,
validationReport: JSON.parse(pack.validationReportJson),
};
}
export async function updateArcadePack(id: string, name: string) {
const existing = await prisma.arcadePack.findUnique({ where: { id }, select: { id: true } });
if (!existing) return null;
return prisma.arcadePack.update({ where: { id }, data: { name } });
}
export async function deleteArcadePack(id: string) {
const existing = await prisma.arcadePack.findUnique({ where: { id }, select: { id: true } });
if (!existing) return false;
await prisma.arcadePack.delete({ where: { id } });
return true;
}
export async function listArcadeAttempts(arcadePackId: string) {
const attempts = await prisma.arcadeAttempt.findMany({
where: { arcadePackId },
orderBy: { completedAt: "desc" },
take: 20,
});
return attempts.map(attemptSummary);
}
export async function createArcadeAttempt(arcadePackId: string, input: ArcadeAttemptCreateInput) {
const pack = await prisma.arcadePack.findUnique({ where: { id: arcadePackId } });
if (!pack) return null;
const normalized = JSON.parse(pack.normalizedJson) as NormalizedConnectionsPack;
const replay = replayConnectionsAttempt(
normalized,
input.submissions,
input.settings,
input.durationSeconds
);
if (!replay.complete) throw new Error("Only completed Connections rounds can be saved.");
const solvedGroups = replay.result.groups.filter((group) => group.solved).length;
return prisma.$transaction(async (transaction) => {
const attempt = await transaction.arcadeAttempt.create({
data: {
arcadePackId,
mode: input.mode,
score: replay.result.score,
maxScore: replay.result.maxScore,
accuracy: replay.result.accuracy,
durationSeconds: replay.result.durationSeconds,
mistakes: replay.result.mistakes,
hintsUsed: replay.result.hintsUsed,
settingsJson: JSON.stringify(input.settings),
resultsJson: JSON.stringify(replay.result),
seed: input.seed,
},
});
if (solvedGroups > 0) {
await transaction.studyActivity.createMany({
data: Array.from({ length: solvedGroups }, () => ({ type: "ARCADE_GROUP" })),
});
}
return attemptSummary({ ...attempt, resultsJson: attempt.resultsJson });
});
}

View file

@ -1,4 +1,7 @@
import { prisma } from "@/lib/db";
import { ARCADE_SERVER_REGISTRY } from "@/lib/arcade/registry";
export type LlmInstructionType = "flashcards" | "quizzes" | "connections";
const DEFAULT_LLM_INSTRUCTIONS_FLASHCARDS = `You are generating study materials in a strict JSON format for import into a
personal study app. The overall output must be raw JSON with no wrapping
@ -48,9 +51,14 @@ Rules:
Material:
[paste your notes or lecture content here]`;
export async function getLlmInstructions(type: "flashcards" | "quizzes"): Promise<string> {
const key = type === "flashcards" ? "llmInstructionsFlashcards" : "llmInstructionsQuizzes";
const def = type === "flashcards" ? DEFAULT_LLM_INSTRUCTIONS_FLASHCARDS : DEFAULT_LLM_INSTRUCTIONS_QUIZZES;
function instructionConfig(type: LlmInstructionType) {
if (type === "flashcards") return { key: "llmInstructionsFlashcards", value: DEFAULT_LLM_INSTRUCTIONS_FLASHCARDS };
if (type === "quizzes") return { key: "llmInstructionsQuizzes", value: DEFAULT_LLM_INSTRUCTIONS_QUIZZES };
return { key: "llmInstructionsConnections", value: ARCADE_SERVER_REGISTRY.connections.defaultInstructions };
}
export async function getLlmInstructions(type: LlmInstructionType): Promise<string> {
const { key, value: def } = instructionConfig(type);
const setting = await prisma.setting.findUnique({
where: { key },
@ -58,8 +66,8 @@ export async function getLlmInstructions(type: "flashcards" | "quizzes"): Promis
return setting?.value ?? def;
}
export async function updateLlmInstructions(type: "flashcards" | "quizzes", value: string) {
const key = type === "flashcards" ? "llmInstructionsFlashcards" : "llmInstructionsQuizzes";
export async function updateLlmInstructions(type: LlmInstructionType, value: string) {
const { key } = instructionConfig(type);
return prisma.setting.upsert({
where: { key },
@ -68,9 +76,8 @@ export async function updateLlmInstructions(type: "flashcards" | "quizzes", valu
});
}
export async function resetLlmInstructions(type: "flashcards" | "quizzes") {
const key = type === "flashcards" ? "llmInstructionsFlashcards" : "llmInstructionsQuizzes";
const def = type === "flashcards" ? DEFAULT_LLM_INSTRUCTIONS_FLASHCARDS : DEFAULT_LLM_INSTRUCTIONS_QUIZZES;
export async function resetLlmInstructions(type: LlmInstructionType) {
const { key, value: def } = instructionConfig(type);
return prisma.setting.upsert({
where: { key },
@ -78,4 +85,3 @@ export async function resetLlmInstructions(type: "flashcards" | "quizzes") {
create: { key, value: def },
});
}

123
src/types/arcade.ts Normal file
View file

@ -0,0 +1,123 @@
export type ArcadeGameKey = "connections";
export interface ConnectionsImportGroup {
id?: string;
category: string;
items: [string, string, string, string];
explanation: string;
difficulty?: 1 | 2 | 3 | 4;
}
export interface ConnectionsImportData {
schemaVersion: 1;
type: "connections";
name: string;
description?: string;
settings: {
groupCount: 4;
itemsPerGroup: 4;
allowedMistakes: number;
};
content: ConnectionsImportGroup[];
}
export interface NormalizedConnectionsItem {
id: string;
text: string;
}
export interface NormalizedConnectionsGroup {
id: string;
category: string;
items: NormalizedConnectionsItem[];
explanation: string;
}
export interface NormalizedConnectionsPack {
schemaVersion: 1;
type: "connections";
name: string;
description?: string;
settings: { groupCount: 4; itemsPerGroup: 4; allowedMistakes: number };
content: NormalizedConnectionsGroup[];
}
export interface ArcadeValidationReport {
wasRepaired: boolean;
warnings: string[];
}
export interface ArcadeImportPreview {
gameType: ArcadeGameKey;
name: string;
description?: string;
categories: string[];
itemCount: number;
wasRepaired: boolean;
warnings: string[];
normalized: NormalizedConnectionsPack;
}
export interface ArcadeImportBatchPreview {
packs: ArcadeImportPreview[];
count: number;
wasRepaired: boolean;
}
export interface ArcadeSessionSettings {
allowedMistakes: number;
oneAwayFeedback: boolean;
}
export interface ConnectionsSubmission {
itemIds: string[];
elapsedMs: number;
}
export interface ArcadeActionLog {
submissions: ConnectionsSubmission[];
}
export interface ArcadeGroupResult {
groupId: string;
category: string;
items: string[];
explanation: string;
solved: boolean;
}
export interface ArcadeRoundResult {
outcome: "WON" | "LOST";
score: number;
maxScore: number;
accuracy: number;
durationSeconds: number;
mistakes: number;
hintsUsed: number;
groups: ArcadeGroupResult[];
incorrectSelections: string[][];
}
export interface ArcadeAttemptSummary {
id: string;
score: number;
maxScore: number;
accuracy: number;
durationSeconds: number;
mistakes: number;
completedAt: string;
results?: ArcadeRoundResult;
}
export interface ArcadePackSummary {
id: string;
classId: string;
gameType: ArcadeGameKey;
name: string;
description: string | null;
schemaVersion: number;
itemCount: number;
defaultAllowedMistakes: number;
bestScore: number | null;
latestAttempt: ArcadeAttemptSummary | null;
}