# 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. ## 2. Recommended Architecture **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 // 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 | ```ts // 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 ) { 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:** ```json { "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:** ```json { "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 ```ts // 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`](https://github.com/josdejong/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`](https://github.com/3DJakob/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: ```ts // 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. ```dockerfile # ---- 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"] ``` ```bash #!/bin/sh # docker-entrypoint.sh set -e npx prisma migrate deploy exec node server.js ``` ```yaml # 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: ```yaml # 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.