Study/study-app-implementation-plan.md
Elijah b7ce314f01
Some checks failed
Automated Container Build / build-and-push (push) Failing after 7s
Initial working commit
2026-06-27 19:19:38 -07:00

40 KiB

Self-Hosted Study App: Implementation Plan

1. Viability Verdict

This is a solid, buildable project. Nothing in the requirements needs exotic infrastructure: it's a single-user CRUD app with auth, a scoring algorithm, and some client-side state persistence. The three parts that need real design thought (and are covered in detail below) are:

  1. NCLEX-style partial credit scoring for SATA questions
  2. Resuming a study session at the right position, in the right order (sequential vs. shuffled), across visits
  3. Brute-force protection on a single-password login

Everything else (class list, deck list, import modal, flashcard viewer, quiz results page) is routine UI work.

Stack: Next.js (App Router, TypeScript) + Prisma + SQLite, single Docker container.

Why a unified full-stack framework instead of a split frontend/backend (the pattern used for Paperjet):

  • Paperjet's split made sense because it had to talk to an external service (OnlyOffice) and benefited from a dedicated Python backend. This app has no comparable external dependency, it's pure CRUD plus business logic.
  • One process means one Dockerfile and one port. Since your reverse proxy already exists on Unraid, the container just needs to expose a port; no nginx/SSL container needed inside this stack.
  • SQLite as a single file means the entire app's state lives in one file you can back up with whatever you already use for Unraid appdata backups.
  • Shared TypeScript types and a shared Zod validation schema can be used on both the import-preview (client) and import-commit (server) steps, so validation logic isn't duplicated.
  • Next.js patterns are extremely well-documented and well-represented in AI coding tools' training data, which matters given your workflow of directing agents rather than hand-coding.

Supporting libraries:

  • Prisma as the ORM (SQLite-friendly migrations, type-safe queries)
  • Zod for import validation (shared between client preview and server-side enforcement)
  • Argon2id (via the argon2 package) for password hashing
  • jsonrepair for auto-correcting minor JSON syntax errors in pasted imports (Section 6)
  • react-markdown (with the remark-gfm plugin) for rendering card and question text (Section 6)
  • react-tinder-card for the swipeable flashcard grading gesture (Section 7)
  • Tailwind CSS for styling
  • Custom session handling (signed httpOnly cookie). NextAuth is built for multi-provider auth and is overkill for a single hardcoded password; a ~100-line custom session module is easier for an agent to implement correctly and easier for you to audit.

3. Data Model

// prisma/schema.prisma
datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

model Class {
  id        String   @id @default(uuid())
  slug      String   @unique
  name      String
  sortOrder Int      @default(0)
  createdAt DateTime @default(now())

  decks     Deck[]
  quizSets  QuizSet[]
}

model Deck {
  id          String   @id @default(uuid())
  classId     String
  name        String
  description String?
  sortOrder   Int      @default(0)
  createdAt   DateTime @default(now())

  class       Class          @relation(fields: [classId], references: [id])
  cards       Flashcard[]
  shareLink   ShareLink?
  progress    StudyProgress?
}

model Flashcard {
  id        String @id @default(uuid())
  deckId    String
  front     String
  back      String
  sortOrder Int    @default(0)

  deck      Deck   @relation(fields: [deckId], references: [id])
}

model QuizSet {
  id          String   @id @default(uuid())
  classId     String
  name        String
  description String?
  sortOrder   Int      @default(0)
  createdAt   DateTime @default(now())

  class       Class          @relation(fields: [classId], references: [id])
  questions   Question[]
  attempts    QuizAttempt[]
  shareLink   ShareLink?
  progress    StudyProgress?
}

model Question {
  id        String   @id @default(uuid())
  quizSetId String
  type      String   // "MULTIPLE_CHOICE" | "SATA"
  prompt    String
  rationale String
  category  String   // LLM-assigned topic tag, e.g. "thyroid". Required.
  sortOrder Int      @default(0)

  quizSet   QuizSet        @relation(fields: [quizSetId], references: [id])
  options   AnswerOption[]
}

model AnswerOption {
  id         String  @id @default(uuid())
  questionId String
  text       String
  isCorrect  Boolean @default(false)
  sortOrder  Int     @default(0)

  question   Question @relation(fields: [questionId], references: [id])
}

// Tracks resume position. One row per (content, mode) so switching
// between sequential and shuffled keeps separate progress for each.
model StudyProgress {
  id              String   @id @default(uuid())
  contentType     String   // "DECK" | "QUIZ"
  deckId          String?
  quizSetId       String?
  mode            String   // "SEQUENTIAL" | "SHUFFLED"
  currentIndex    Int      @default(0)
  orderJson       String   // JSON array of card/question ids, the active order
  answersJson     String?  // in-progress quiz answers, quiz only
  cardResultsJson String?  // per-card "correct"/"missed" self-grades this pass, deck only
  updatedAt       DateTime @updatedAt

  deck            Deck?    @relation(fields: [deckId], references: [id])
  quizSet         QuizSet? @relation(fields: [quizSetId], references: [id])

  @@unique([deckId, mode])
  @@unique([quizSetId, mode])
}

model QuizAttempt {
  id               String   @id @default(uuid())
  quizSetId        String
  score            Float
  maxScore         Int
  answersJson      String   // snapshot for the results/review screen
  isPartialRetake  Boolean  @default(false) // true if scoped to previously-missed questions only
  completedAt      DateTime @default(now())

  quizSet          QuizSet  @relation(fields: [quizSetId], references: [id])
}

// The id here is the public share token used in the URL.
model ShareLink {
  id         String   @id @default(uuid())
  targetType String   // "DECK" | "QUIZ"
  deckId     String?  @unique
  quizSetId  String?  @unique
  createdAt  DateTime @default(now())

  deck       Deck?    @relation(fields: [deckId], references: [id])
  quizSet    QuizSet? @relation(fields: [quizSetId], references: [id])
}

model AuthSecurity {
  id             Int       @id @default(1)
  failedAttempts Int       @default(0)
  lockedUntil    DateTime?
  lastAttemptAt  DateTime?
}

// Generic key-value store. First use is the editable LLM instruction
// template (key "llmInstructions"), see Section 6.
model Setting {
  key   String @id
  value String
}

Design notes:

  • Deck/Flashcard and QuizSet/Question/AnswerOption are kept as distinct, explicit tables rather than one generic "study item" polymorphic table. This is a deliberate choice against premature abstraction. It is easier to query, easier to type, and easier for an agent to implement correctly. When you add a new study mode later, it gets its own analogous tables rather than forcing everything through a shared shape that doesn't quite fit.
  • There's no User table. Single-user auth doesn't need one; the password hash lives in an environment variable, and AuthSecurity is a singleton row tracking lockout state.
  • Setting is a generic key-value table rather than a one-off column somewhere, so any future app-wide setting (not just the LLM instructions) has an obvious place to live without a schema change.

4. Auth and Brute-Force Protection

  • Password hash generated once (a small one-off script using argon2.hash()) and stored as ADMIN_PASSWORD_HASH in the .env file, never as plaintext.
  • Login route (/api/auth/login) compares the submitted password with argon2.verify().
  • On success: create a signed httpOnly, secure, sameSite=lax session cookie (HMAC-signed with a SESSION_SECRET env var, or use iron-session), reset AuthSecurity.failedAttempts to 0.
  • On failure: increment AuthSecurity.failedAttempts. Apply progressive lockout, for example:
    • 5 failed attempts: locked 1 minute
    • 10: locked 5 minutes
    • 15: locked 30 minutes
    • 20+: locked 24 hours
  • This state lives in the database (not memory), so a container restart doesn't reset an attacker's lockout.
  • Additionally, rate-limit the login route itself per IP (a simple in-memory sliding window, e.g. max 10 requests/minute) to slow down rapid automated attempts before they even reach the lockout counter. In-memory is fine here since this is a single-instance, single-user container; there's no need for a shared store like Redis.
  • Next.js middleware.ts gates everything except /login, /api/auth/*, and /shared/* behind a valid session cookie check, redirecting to /login otherwise.
  • Since your reverse proxy already terminates the connection, make sure the app trusts the proxy's forwarded headers (X-Forwarded-For, X-Forwarded-Proto) so IP-based rate limiting and secure-cookie detection work correctly behind it.

5. Scoring Logic (NCLEX-style SATA partial credit)

Every question is worth 1 point, regardless of type.

  • Multiple choice: full credit if the single selected option is correct, otherwise zero.
  • SATA: partial credit based on correct selections minus incorrect selections, floored at zero, scaled to the number of correct options.
score = max(0, correctSelected - incorrectSelected) / totalCorrectOptions

This rewards picking correct options, penalizes picking wrong ones, and a single bad question can never drag the question below 0 (though it can certainly drag it to 0).

Worked example (3 correct options out of 5 total, totalCorrectOptions = 3):

Selection correctSelected incorrectSelected Score
All 3 correct, no wrong ones 3 0 3/3 = 1.0
2 correct, 1 wrong 2 1 1/3 ≈ 0.33
1 correct, 2 wrong 1 2 max(0, -1)/3 = 0
None selected 0 0 0
// lib/scoring.ts
type OptionLite = { id: string; isCorrect: boolean };
type QuestionLite = { id: string; type: "MULTIPLE_CHOICE" | "SATA"; options: OptionLite[] };

export function scoreQuestion(question: QuestionLite, selectedIds: string[]): number {
  const correctIds = question.options.filter(o => o.isCorrect).map(o => o.id);

  if (question.type === "MULTIPLE_CHOICE") {
    return correctIds.includes(selectedIds[0]) ? 1 : 0;
  }

  const correctSelected = selectedIds.filter(id => correctIds.includes(id)).length;
  const incorrectSelected = selectedIds.filter(id => !correctIds.includes(id)).length;
  return Math.max(0, correctSelected - incorrectSelected) / correctIds.length;
}

export function scoreQuiz(
  questions: QuestionLite[],
  answers: Record<string, string[]>
) {
  let total = 0;
  const perQuestion = questions.map(q => {
    const points = scoreQuestion(q, answers[q.id] ?? []);
    total += points;
    return { questionId: q.id, points };
  });
  return { total, maxScore: questions.length, perQuestion };
}

This lives in one small, pure, easily-tested module. Keep it that way; don't let it grow into a general "grading" file that also handles flashcards or progress tracking.

6. Content Import and Management

JSON formats

Flashcards:

{
  "type": "flashcards",
  "deckName": "Beta Blockers",
  "description": "Cardiac pharm, week 4",
  "cards": [
    { "front": "MOA of metoprolol", "back": "Selectively blocks beta-1 receptors, reducing heart rate and contractility" }
  ]
}

Quiz:

{
  "type": "quiz",
  "quizName": "Endocrine Pharm Quiz 1",
  "description": "optional",
  "questions": [
    {
      "type": "multiple_choice",
      "prompt": "Which lab value should be monitored first when starting levothyroxine?",
      "category": "thyroid",
      "options": [
        { "text": "TSH", "correct": true },
        { "text": "Potassium", "correct": false },
        { "text": "Calcium", "correct": false },
        { "text": "Hemoglobin A1c", "correct": false }
      ],
      "rationale": "TSH is the primary marker used to titrate levothyroxine dosing."
    },
    {
      "type": "sata",
      "prompt": "Select all signs consistent with hyperkalemia.",
      "category": "endocrine",
      "options": [
        { "text": "Peaked T waves", "correct": true },
        { "text": "Muscle weakness", "correct": true },
        { "text": "Polyuria", "correct": false },
        { "text": "Widened QRS", "correct": true },
        { "text": "Hyperreflexia", "correct": false }
      ],
      "rationale": "Peaked T waves and widened QRS reflect cardiac membrane effects; weakness reflects neuromuscular effects of elevated potassium."
    }
  ]
}

Shared validation schema

// lib/validation/importSchemas.ts
import { z } from "zod";

const optionSchema = z.object({
  text: z.string().min(1),
  correct: z.boolean(),
});

const questionSchema = z
  .object({
    type: z.enum(["multiple_choice", "sata"]),
    prompt: z.string().min(1),
    rationale: z.string().min(1),
    category: z.string().min(1),
    options: z.array(optionSchema).min(2),
  })
  .refine(q => q.options.filter(o => o.correct).length >= 1, {
    message: "Each question needs at least one correct option",
  })
  .refine(
    q => q.type !== "multiple_choice" || q.options.filter(o => o.correct).length === 1,
    { message: "multiple_choice questions must have exactly one correct option" }
  );

export const quizImportSchema = z.object({
  type: z.literal("quiz"),
  quizName: z.string().min(1),
  description: z.string().optional(),
  questions: z.array(questionSchema).min(1),
});

export const flashcardImportSchema = z.object({
  type: z.literal("flashcards"),
  deckName: z.string().min(1),
  description: z.string().optional(),
  cards: z.array(z.object({
    front: z.string().min(1),
    back: z.string().min(1),
  })).min(1),
});

This file is imported on both sides: the import modal runs it client-side to show a preview and inline errors before you commit, and the /api/decks and /api/quizzes POST routes run it again server-side. Never trust the client-side pass alone.

Markdown rendering for card and question text

front, back, prompt, and rationale are all rendered as Markdown rather than plain text, using react-markdown with the remark-gfm plugin (for tables and strikethrough, both genuinely useful for lecture-derived content with comparisons or lab value tables). Raw HTML passthrough stays disabled, react-markdown doesn't render embedded HTML unless explicitly enabled, so this is safe by default even though the only realistic content source here is your own LLM output. AnswerOption.text stays plain text. Options are short, single-line choices where formatting adds little and would complicate the click target.

The LLM prompt template (below) tells the model to use Markdown (lists, bold, tables) in back and rationale where it improves clarity, since nursing content frequently needs a list of signs/symptoms or a side-by-side comparison rather than one run-on sentence.

Naming, and renaming after the fact

The "Name" field shown in the import preview is pre-filled from the JSON's deckName/quizName, but it's an editable text input, not a locked-in value. Whatever's in that field when you hit "Confirm Import" is what gets saved, regardless of what the LLM happened to call it.

After import, renaming (and editing the description) lives behind a small "..." menu on each deck/quiz card in its list view, the same menu that holds the share toggle from Section 8 and delete.

  • PATCH /api/decks/[id] and PATCH /api/quizzes/[id] accept a partial { name?, description? } body and update in place. Nothing else about the deck (cards, progress, share link) is touched by a rename.

Editing and deleting individual flashcards

Each deck page has two views, toggled at the top: Study (the card-flipping viewer from Section 7) and Manage. Manage is a flat list of every card in the deck, each with inline edit (click into front or back, save) and delete (with a confirm step, since it's destructive), plus an "Add card" button at the bottom for dropping in a single new card without a full re-import.

  • PATCH /api/cards/[id] - body { front?, back? }
  • DELETE /api/cards/[id]
  • POST /api/decks/[id]/cards - body { front, back }, appends a single card

One edge case worth handling deliberately: if a card is deleted while it's part of an in-progress shuffled or sequential order, the orderJson stored in StudyProgress (Section 7) can end up referencing an id that no longer exists. When a session is resumed, filter orderJson down to ids that still exist in the deck before using it, and clamp currentIndex if it now runs past the end of the filtered list. That keeps "delete anytime" and "remember my position" from ever fighting each other.

The import modal: Generate and Import tabs

The actual workflow has two steps that often happen minutes or hours apart, in different browser tabs: getting instructions in front of an LLM chat, and bringing the result back. The import modal mirrors that with two tabs rather than one combined panel.

Generate tab: an editable textarea holding the current instruction template (seeded with the one below), a Copy to Clipboard button, a Save Changes button, and a Reset to Default button. The text is stored as a Setting row (key llmInstructions, see Section 3) rather than hardcoded, so edits persist across sessions and survive a container restart, and a future redeploy never silently reverts your tweaks.

  • GET /api/settings/llm-instructions
  • PATCH /api/settings/llm-instructions - body { value: string }

Import tab: the Name field described above, a paste-in textarea for the JSON, and the auto-repair pipeline below running on every paste. "Confirm Import" stays disabled until the pasted content parses and validates cleanly, after repair if repair was needed.

LLM prompt template for generating compliant imports

This is the default seed value for the Setting row above, and what the "Copy to Clipboard" button on the Generate tab copies until you edit it.

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
markdown code fence and no commentary before or after. Markdown formatting
INSIDE string values (lists, **bold**, tables) is fine, see the formatting
rule below.

For flashcards, use this shape:
{ "type": "flashcards", "deckName": "...", "description": "...", "cards": [{"front": "...", "back": "..."}] }

For quizzes, use this shape:
{ "type": "quiz", "quizName": "...", "description": "...", "questions": [
  {"type": "multiple_choice" | "sata", "prompt": "...", "category": "...",
   "options": [{"text": "...", "correct": true|false}], "rationale": "..."}
]}

Rules:
- "multiple_choice" questions must have exactly one option with correct: true.
- "sata" questions must have two or more options with correct: true.
- Every question needs a rationale explaining why the correct option(s) are
  correct, and briefly why the others are not.
- Every question must include a "category" field: a short topic tag you
  choose based on the content (e.g. "thyroid", "limbic system"). Reuse the
  exact same label across questions on the same topic rather than inventing
  a new variant each time, and keep the total number of distinct categories
  small relative to the number of questions.
- Use Markdown formatting (bullet lists, **bold**, tables) inside "back" and
  "rationale" text wherever it improves clarity, especially for symptom
  lists, step sequences, or comparisons.
- Base everything strictly on the material below. Do not add outside facts.

Material:
[paste your notes or lecture content here]

Automatic JSON repair

LLMs occasionally produce almost-valid JSON: a missing comma, a trailing comma, a stray markdown fence despite being told not to include one. The import pipeline treats that kind of mechanical noise very differently from an actual content problem.

  1. Strip a wrapping ```json fence if one slipped through anyway.
  2. Try JSON.parse() on the result directly.
  3. If that throws, run the text through jsonrepair, a package built specifically for fixing the kind of malformed JSON LLMs commonly produce (missing or trailing commas, missing quotes, comments, single quotes, and similar syntax slips), then try JSON.parse() again.
  4. If the repaired version parses, mark it as auto-corrected, show the repaired JSON in the preview pane so you can see exactly what changed before confirming anything, then run it through the Zod schema above.
  5. If repair still fails to produce parseable JSON, or it parses but fails Zod validation (no option marked correct, a missing rationale, an unrecognized type value), stop there. Show the specific validation message and, where the parser provides one, the approximate position of the problem. Nothing gets guessed at this level.

The dividing line is deliberate. Syntax is mechanical and safe to fix without asking, there's only ever one sensible way to interpret "you forgot a comma here." Content is a judgment call: guessing which option should be marked correct, or inventing a missing rationale, would mean wrong information ending up in your study material with no indication anything was assumed. That's the one thing this pipeline should never do quietly.

Repair and validation both run client-side first, for an instant preview. The server-side import route re-validates with the same Zod schema regardless, consistent with the rule above: never trust the client-side pass alone.

7. Resume Position, Shuffle Persistence, and Self-Grading

StudyProgress is keyed by (content, mode), so sequential and shuffled progress for the same deck/quiz are tracked independently. That means switching modes never destroys your spot in the other one.

Flow on opening a deck or quiz:

  1. Look up StudyProgress for this content + selected mode.
  2. If none exists: generate the order ([0..n-1] for sequential, a shuffled permutation for shuffled), create the row with currentIndex = 0.
  3. If one exists: reuse the stored orderJson exactly as-is. Do not reshuffle on reload, that would defeat the purpose of resuming.
  4. Persist currentIndex on every navigation (debounced, fire-and-forget PATCH so it doesn't block the UI).

For quizzes, answersJson on the same row stores in-progress selections, so leaving a half-finished quiz and coming back restores both your position and your answers so far. On final submission, the answers are scored, written to a new QuizAttempt row, and the in-progress StudyProgress row is cleared so "Restart" gives a clean slate.

See "Editing and deleting individual flashcards" in Section 6 for how this interacts with deleting a card mid-session.

Deck/quiz list UI: each item shows its current state (e.g. "12/40, shuffled" or "Completed, 87%") with a primary "Continue" button that resumes as-is, and a secondary "Restart" action that lets you pick sequential/shuffled again and wipes existing progress for that mode.

Flashcard self-grading and redo-missed

Flashcards don't have a built-in right answer the way multiple-choice does, so "correct" here is self-reported, on the Quizlet pattern. The interaction has two steps: tap/click the card (or press Space) to flip it and reveal the back, then grade it one of three equivalent ways:

  • Swipe the card left (incorrect) or right (correct), using react-tinder-card. It exposes an imperative swipe(dir) method on a ref, so the keyboard and button paths below trigger the exact same fly-off animation rather than reimplementing it.
  • Arrow keys: Right Arrow = correct, Left Arrow = incorrect.
  • On-screen buttons: a ✓ and a ✗, for mouse use or when swipe gestures aren't practical.

Grading is only active once the card is flipped, an accidental swipe on the front does nothing, since you shouldn't be able to grade yourself before seeing the answer. Set preventSwipe={['up', 'down']} so only left/right register. All three input paths call one grading function, which is what actually records the result and advances to the next card, the gesture/key/button is just a different trigger for the same action.

This lives entirely in the Study view; Manage (Section 6) stays focused on editing content and never shows grading controls.

Each grade is recorded in cardResultsJson on StudyProgress (a JSON map of card id to "correct" or "missed"), kept separate from the quiz-only answersJson field since the two content types don't share a column with different meanings.

Running total, live, not just at the end. A small persistent header sits above the card for the whole session, e.g. "7 ✓ · 2 ✗ · 9 of 16", and updates the instant each card is graded, the same live-tally pattern as the quiz running score in Section 12, just driven by cardResultsJson instead of answersJson.

When the last card in the active order has been graded, that same tally becomes the end-of-set summary, with two actions added:

  • Restart full set: clears cardResultsJson, regenerates the order if shuffled, starts over from card 1.
  • Redo missed only: builds a new order containing just the ids graded "missed," writes it into the same row's orderJson, resets currentIndex to 0, and clears cardResultsJson for the new pass.

Because "redo missed" overwrites orderJson rather than creating a separate row, leaving partway through a redo-missed pass and coming back resumes that pass exactly like any other session. If you want a card's miss history to persist across passes and sessions rather than resetting each time, see the starring suggestion in Section 13.

8. Public Sharing

  • ShareLink.id is the public token used directly in the URL, decoupled from the deck/quiz's internal id so you can revoke and regenerate a link without touching the underlying data.
  • Route shape: /shared/{classSlug}/{flashcards|quizzes}/{token}, matching the structure you described.
  • This route is excluded from the auth middleware entirely.
  • Shared quizzes are taken and scored client-side only, no answers or scores are written to the server from an unauthenticated visitor. This keeps the public route fully read-only from the database's perspective, no write surface for someone with a stale or leaked link to abuse.
  • Enabling sharing creates a ShareLink row from a toggle on the deck/quiz's settings; disabling deletes the row (token becomes invalid immediately). Regenerating deletes and recreates it, the old URL stops working, a new one is issued.

9. Project Structure

study-app/
├── prisma/
│   └── schema.prisma
├── src/
│   ├── app/
│   │   ├── login/page.tsx
│   │   ├── (protected)/
│   │   │   ├── layout.tsx                 # session check wrapper
│   │   │   ├── page.tsx                   # landing page, class list
│   │   │   └── [classSlug]/
│   │   │       ├── page.tsx                # class shell, tab nav
│   │   │       ├── flashcards/
│   │   │       │   ├── page.tsx            # deck list + import button
│   │   │       │   └── [deckId]/page.tsx   # study session
│   │   │       └── quizzes/
│   │   │           ├── page.tsx
│   │   │           └── [quizId]/
│   │   │               ├── page.tsx        # quiz-taking
│   │   │               └── results/page.tsx
│   │   ├── shared/
│   │   │   └── [classSlug]/[type]/[token]/page.tsx
│   │   └── api/
│   │       ├── auth/{login,logout}/route.ts
│   │       ├── classes/route.ts
│   │       ├── decks/route.ts
│   │       ├── decks/[id]/route.ts
│   │       ├── decks/[id]/cards/route.ts
│   │       ├── cards/[id]/route.ts
│   │       ├── quizzes/route.ts
│   │       ├── quizzes/[id]/route.ts
│   │       ├── quizzes/[id]/attempt/route.ts
│   │       ├── quizzes/[id]/attempts/route.ts
│   │       ├── progress/route.ts
│   │       ├── settings/llm-instructions/route.ts
│   │       └── share/route.ts
│   ├── components/
│   │   ├── ui/                # Button, Modal, Tabs, etc.
│   │   ├── flashcards/{FlashcardViewer,DeckList,CardManager,SetSummary}.tsx
│   │   ├── quizzes/{QuestionCard,QuizResults,CategoryBreakdown,QuizList,AttemptHistory}.tsx
│   │   └── import/{ImportModal,GenerateTab,ImportTab}.tsx
│   ├── lib/
│   │   ├── db.ts               # Prisma client singleton
│   │   ├── auth.ts             # session create/verify
│   │   ├── rateLimiter.ts      # IP-based login throttle
│   │   ├── scoring.ts          # scoreQuestion / scoreQuiz
│   │   ├── shuffle.ts          # order generation + persistence helpers
│   │   ├── jsonRepair.ts       # parse -> repair -> validate pipeline
│   │   └── validation/importSchemas.ts
│   ├── services/                # business logic, called by route handlers
│   │   ├── classService.ts
│   │   ├── deckService.ts
│   │   ├── cardService.ts
│   │   ├── quizService.ts
│   │   ├── progressService.ts
│   │   ├── settingsService.ts
│   │   └── shareService.ts
│   ├── middleware.ts            # auth gate, excludes /login, /shared, /api/auth
│   └── config/studyModes.ts     # drives tab navigation, see Section 10
├── Dockerfile
├── docker-compose.yml
├── docker-compose.override.yml  # local dev only
├── docker-entrypoint.sh
└── .env.example

The separation that matters most for maintainability: route handlers in app/api/** stay thin (parse request, call a service, return response). The actual logic lives in services/, and low-level utilities (db, auth, scoring, validation) live in lib/. Nothing should end up needing to be a 1000+ line file because each concern already has a clear, separate home. This is also why the import modal is three components (ImportModal as a thin shell, GenerateTab, ImportTab) rather than one file holding the instruction editor, the paste box, the repair pipeline wiring, and the preview rendering all at once, and why card-level operations get their own cardService instead of being bolted onto deckService.

10. Extensibility for Future Study Modes

Tab navigation on the class page is driven by a config array, not hardcoded conditionals:

// config/studyModes.ts
export const STUDY_MODES = [
  { key: "flashcards", label: "Flashcards", path: "flashcards" },
  { key: "quizzes", label: "Quizzes", path: "quizzes" },
  // a future mode just gets added here
];

Adding a genuinely new mode later (e.g. matching exercises, case studies) means: new Prisma model(s) following the same pattern as Deck/QuizSet, a new service file, a new API route folder, a new page route folder, and one new entry in STUDY_MODES. The shared layout, tab bar, and auth middleware need no changes.

11. Docker Setup

Use node:20-slim (Debian-based) rather than an Alpine image. Alpine's musl libc is a known source of friction with native modules (argon2) and Prisma's query engine binaries; Debian-slim avoids that class of problem entirely, and keeping build and runtime stages on the same base image avoids any binary-target mismatch.

# ---- deps ----
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

# ---- builder ----
FROM node:20-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate
RUN npm run build

# ---- runner ----
FROM node:20-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN useradd --system --create-home appuser
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
COPY --from=builder /app/prisma ./prisma
COPY docker-entrypoint.sh ./
RUN chmod +x docker-entrypoint.sh && chown -R appuser:appuser /app
USER appuser
EXPOSE 3000
ENTRYPOINT ["./docker-entrypoint.sh"]
#!/bin/sh
# docker-entrypoint.sh
set -e
npx prisma migrate deploy
exec node server.js
# docker-compose.yml
services:
  study-app:
    build: .
    container_name: study-app
    restart: unless-stopped
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=file:/app/data/study.db
      - SESSION_SECRET=${SESSION_SECRET}
      - ADMIN_PASSWORD_HASH=${ADMIN_PASSWORD_HASH}
      - NODE_ENV=production
    volumes:
      - ./data:/app/data

On Unraid: since your reverse proxy already routes to containers, point it at this container's IP/hostname and port 3000 (or whichever host port you map). No SSL handling or nginx needed inside this stack. Change the volume line to your appdata path, e.g. /mnt/user/appdata/study-app:/app/data, so the SQLite file lives wherever your existing backup routine already covers.

Locally with Docker Desktop: the same docker-compose.yml works unmodified, ./data:/app/data is a relative bind mount in your project folder, and you access it directly at http://localhost:3000 with no proxy involved.

For active development (hot reload instead of rebuilding the image every change), an override file:

# docker-compose.override.yml
services:
  study-app:
    build:
      target: deps
    command: npm run dev
    volumes:
      - .:/app
      - /app/node_modules
    environment:
      - NODE_ENV=development
    ports:
      - "3000:3000"

Docker Compose automatically merges docker-compose.override.yml on top of the base file when present, so docker compose up locally gives you live reload, while a clean docker compose -f docker-compose.yml up (or removing the override file) gives you the production build for testing the real deployment path before pushing to Unraid.

12. Quiz-Taking Flow, Running Score, and Category Breakdown

Each question has its own Submit Answer button. Selecting or changing an option beforehand does not touch the score, nothing is graded until you submit. The moment you do, scoreQuestion() from Section 5 runs against that question and the result is folded into a running total shown in a persistent header, e.g. "Score so far: 6.3/8." That total is a derived value, not a new column, it's recomputed from StudyProgress.answersJson plus the question bank, so there's nothing extra to keep in sync. Once submitted, a question's answer locks; a separate "Next" control moves you forward.

On the final question, "Finish Quiz" locks the attempt: a QuizAttempt row is written with the final score, maxScore, and a snapshot of every answer, and the in-progress StudyProgress row is cleared.

Results screen:

  • Headline: total score as a percentage (score / maxScore).
  • A review list of every question that didn't earn full credit, not just the ones scored zero, since SATA partial credit means a question can be wrong without being entirely wrong. Each entry shows your selections, the correct selections, and the rationale.
  • A category breakdown table (below).

Category tagging. Question.category (Section 3) is required, and the LLM chooses the label itself rather than picking from a list you provide, the prompt template in Section 6 instructs it to reuse the same label across questions on the same topic and to keep the total number of distinct categories small.

Because the model is choosing labels on its own rather than filling in ones you specified, consistency depends entirely on it following that instruction, there's no fixed list to constrain it to. Grouping for the breakdown normalizes by trimming and lowercasing before comparing, which catches case differences ("Thyroid" vs. "thyroid") but not genuinely different wording ("thyroid" vs. "thyroid disorders"). The import preview showing every distinct category detected, and how many questions carry each one, is the main safety net here, it's where an inconsistent label actually gets caught, since once imported, fixing one means re-importing the whole quiz unless you've also added the quiz question Manage view from Section 13.

Breakdown calculation: group the attempt's per-question scores by category, sum points and max points per group, and show each as points/maxPoints and a percentage, a pure aggregation over data already being computed for the overall score.

The same category field would work identically on Flashcard if you ever want topic-filtered flashcard study later, that's a direct copy of this pattern, not a new design.

Retake missed questions

Mirrors the flashcard "redo missed only" flow in Section 7. From the results screen, Retake Missed Only is available alongside Retake Full Quiz whenever the just-finished attempt has at least one question that didn't earn full credit.

  • Build a new order containing just the ids that scored below full credit on the most recent attempt.
  • Write that order into the relevant StudyProgress row, reset currentIndex to 0, clear answersJson.
  • On completion, write a new QuizAttempt with isPartialRetake: true and maxScore equal to the size of that subset, not the original quiz length, so the percentage reflects what was actually retaken.

Quiz attempt history

QuizAttempt already stores every completed attempt. The quiz's landing page (before starting a new attempt) lists past ones, most recent first, each showing its date, score, and whether it was a full attempt or a partial retake, e.g. "Today, 85% (full)" / "Jun 24, 67% (retake of 3 missed)". Tapping an old entry reopens the same Results screen component from above, fed that attempt's stored answersJson instead of a freshly-finished one, so reviewing history needs no separate UI, just the existing Results screen pointed at older data.

  • GET /api/quizzes/[id]/attempts - list, ordered by completedAt descending.

13. Suggested Additional Features

A few things still worth deciding on now, before content piles up, even though none of these were explicitly requested. Quiz attempt history, Markdown rendering, and retaking missed quiz questions were on this list too, they're now folded into Sections 6, 7, and 12 above.

Edit and delete individual quiz questions. Flashcards get a Manage view (Section 6); quiz questions currently don't. Now that every question carries a required category tag, a typo or miscategorized question can only be fixed today by re-importing the whole quiz. The same pattern from Section 6, a Manage view per quiz with inline edit and delete per question, closes that gap, and is the most natural next addition given how Section 12 already leans on it as the fix for an inconsistent category.

Export a deck or quiz back to JSON. The reverse of import: a button that dumps a deck or quiz set in the exact shape described in Section 6, ready to paste into an LLM chat for bulk edits ("clean up the wording on these 12 cards") or just to keep an external backup outside the SQLite file.

Starred or flagged cards, independent of any one session. "Redo missed" only knows about the pass you just finished. A persistent starred boolean on Flashcard lets you build a "hard cards" pile that survives across many sessions, with "Study starred only" as a third option alongside sequential and shuffled. This is how Quizlet's star feature works.

Mark a question for review mid-quiz. A lightweight flag you can set on any question before finishing, with a "jump to flagged" control, mirrors how real exam software lets you move on and come back rather than getting stuck.

None of these need to land before you start building. If one's worth doing at the same time as the core features above rather than later, it's the quiz question Manage view, for the reason noted above.

14. Suggested Build Order

  1. Project scaffold, Prisma schema + migrations, auth + lockout, a bare class list (seeded manually is fine for now).
  2. Flashcard import (including the Generate/Import modal tabs and JSON auto-repair from Section 6), Markdown rendering, deck list, study viewer, sequential-only progress persistence, rename, and the card Manage view (edit/delete/add a card).
  3. Shuffle mode and the resume logic described in Section 7, swipe/keyboard/button self-grading with the live running tally, and redo-missed.
  4. Quiz import (including the required category field), multiple-choice question viewer, submit-gated running score, scoring, quiz rename.
  5. SATA scoring, the results screen (non-full-credit review list with rationale), the category breakdown, retake-missed-only, and quiz attempt history.
  6. Public sharing (Section 8).
  7. Docker packaging, reverse proxy hookup on Unraid, mobile responsiveness pass (worth prioritizing since you'll likely use this between clinicals on your phone).

Each phase is independently testable and produces something usable, so you're never stuck with a half-built app you can't actually study from.