diff --git a/.env.example b/.env.example index 22361b8..bd3a815 100644 --- a/.env.example +++ b/.env.example @@ -22,7 +22,6 @@ NEXTAUTH_SECRET="" # auto-filled by `npm run setup` (or: o # --- Admin & Security ----------------------------------------- ADMIN_USER_IDS="" # comma-separated user UUIDs allowed to access /admin -CRON_SECRET="" # auto-filled by `npm run setup` — shared secret for cron/internal endpoints # WARNING: ALLOW_TEST_AUTH makes the app trust the X-User-Id header # as the authenticated identity. It exists ONLY for automated tests. @@ -64,6 +63,7 @@ AI_CACHE_TTL_SECONDS="3600" AI_DAILY_COST_CAP_USD="1.0" # max spend per user per day (default $1) AI_MONTHLY_COST_CAP_USD="10.0" # max spend per user per month (default $10) AI_DISABLED="false" # emergency kill switch — set "true" to block all AI calls +# MOCK_AI_MALFORMED="1" # test-only: makes the mock provider emit malformed output (never set outside tests) # --- Background Worker ---------------------------------------- JOB_WORKER_CONCURRENCY="2" diff --git a/README.md b/README.md index 85711d5..61c2611 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Then open http://localhost:3000. The defaults need no API keys or external accou To be blunt about the default: mock mode exists for tests. With `AI_PROVIDER` set to `mock`, sessions get template questions and canned feedback, which exercises the machinery but teaches you nothing. To actually study, set `AI_PROVIDER` to `openai` and add an `OPENAI_API_KEY` in `.env`. -What `npm run setup` does: copies `.env.example` to `.env`, generates `NEXTAUTH_SECRET`, `TOKEN_ENC_KEY`, and `CRON_SECRET`, starts PostgreSQL through Docker, and applies all migrations. It is idempotent and never overwrites a value you set yourself. +What `npm run setup` does: copies `.env.example` to `.env`, generates `NEXTAUTH_SECRET` and `TOKEN_ENC_KEY`, starts PostgreSQL through Docker, and applies all migrations. It is idempotent and never overwrites a value you set yourself. Notes: diff --git a/docker-compose.yml b/docker-compose.yml index b212af6..ecf1955 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,7 +26,6 @@ services: BASE_URL: ${BASE_URL:-http://localhost:3000} NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000} NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:?Set NEXTAUTH_SECRET in .env or environment} - CRON_SECRET: ${CRON_SECRET:-} ADMIN_USER_IDS: ${ADMIN_USER_IDS:-} GOOGLE_PROVIDER: ${GOOGLE_PROVIDER:-fake} AI_PROVIDER: ${AI_PROVIDER:-mock} diff --git a/next.config.js b/next.config.js index 7a9db54..e1da177 100644 --- a/next.config.js +++ b/next.config.js @@ -27,13 +27,6 @@ const nextConfig = { }, ], }, - { - source: "/sw.js", - headers: [ - { key: "Service-Worker-Allowed", value: "/" }, - { key: "Cache-Control", value: "no-cache" }, - ], - }, ], } diff --git a/package-lock.json b/package-lock.json index 4b4b4ed..93df98e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,6 @@ "@types/nodemailer": "^8.0.0", "@types/pg": "^8.20.0", "@types/react": "^19.2.14", - "@types/web-push": "^3.6.4", "@vitejs/plugin-react": "^6.0.1", "dotenv": "^17.4.2", "eslint": "^8.57.1", @@ -1903,16 +1902,6 @@ "csstype": "^3.2.2" } }, - "node_modules/@types/web-push": { - "version": "3.6.4", - "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", - "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.58.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", diff --git a/package.json b/package.json index eee670b..a07101b 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,6 @@ "@types/nodemailer": "^8.0.0", "@types/pg": "^8.20.0", "@types/react": "^19.2.14", - "@types/web-push": "^3.6.4", "@vitejs/plugin-react": "^6.0.1", "dotenv": "^17.4.2", "eslint": "^8.57.1", diff --git a/prisma/migrations/20260716120000_drop_notification_prefs_and_dead_columns/migration.sql b/prisma/migrations/20260716120000_drop_notification_prefs_and_dead_columns/migration.sql new file mode 100644 index 0000000..a404896 --- /dev/null +++ b/prisma/migrations/20260716120000_drop_notification_prefs_and_dead_columns/migration.sql @@ -0,0 +1,6 @@ +-- Drop the unused notification preferences table (feature was never shipped) +DROP TABLE "notification_preferences"; + +-- Drop dead columns from user_game_state +ALTER TABLE "user_game_state" DROP COLUMN "display_name"; +ALTER TABLE "user_game_state" DROP COLUMN "leaderboard_visible"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cb2a95e..fcd6cda 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -589,7 +589,6 @@ model Achievement { model UserGameState { id String @id @default(uuid()) userId String @unique @map("user_id") - displayName String? @map("display_name") dailyXpGoal Int @default(50) @map("daily_xp_goal") streakFreezes Int @default(0) @map("streak_freezes") streakFreezeUsedDate String? @map("streak_freeze_used_date") // YYYY-MM-DD @@ -597,7 +596,6 @@ model UserGameState { studyEndTime String @default("17:00") @map("study_end_time") dailyStudyCap Int @default(180) @map("daily_study_cap") onboardingComplete Boolean @default(false) @map("onboarding_complete") - leaderboardVisible Boolean @default(true) @map("leaderboard_visible") timezone String? // IANA timezone for streak day boundaries; null = UTC createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -617,9 +615,8 @@ model User { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - notificationPreference NotificationPreference? - chatMessages ChatMessage[] - verificationTokens VerificationToken[] + chatMessages ChatMessage[] + verificationTokens VerificationToken[] @@map("users") } @@ -641,25 +638,6 @@ model ChatMessage { @@map("chat_messages") } -// ── Notification Preferences ──────────────────────────────────── - -model NotificationPreference { - id String @id @default(uuid()) - userId String @unique @map("user_id") - emailReminders Boolean @default(true) @map("email_reminders") - pushReminders Boolean @default(true) @map("push_reminders") - reminderTime String @default("09:00") @map("reminder_time") // HH:MM - streakReminders Boolean @default(true) @map("streak_reminders") - weeklyDigest Boolean @default(true) @map("weekly_digest") - emailAddress String? @map("email_address") // override if different from User.email - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@map("notification_preferences") -} - // ── Verification Tokens (email verify, password reset) ────────── model VerificationToken { diff --git a/scripts/setup.mjs b/scripts/setup.mjs index 5bbd201..dab1dc5 100644 --- a/scripts/setup.mjs +++ b/scripts/setup.mjs @@ -6,7 +6,7 @@ * * What it does: * 1. Copies .env.example -> .env (never overwrites an existing .env). - * 2. Generates NEXTAUTH_SECRET, TOKEN_ENC_KEY and CRON_SECRET where they + * 2. Generates NEXTAUTH_SECRET and TOKEN_ENC_KEY where they * are empty (user-set values are never touched). * 3. Starts the Postgres container via Docker Compose (if Docker is * available) and waits for its healthcheck. @@ -26,7 +26,7 @@ const envPath = join(root, ".env"); const examplePath = join(root, ".env.example"); // --------------------------------------------------------------------------- -// 0. Node version check (Next.js 15 + this script need Node 20.19+) +// 0. Node version check (Next.js 14 + this script need Node 20.19+) // --------------------------------------------------------------------------- const [nodeMajor, nodeMinor] = process.versions.node.split(".").map(Number); if (nodeMajor < 20 || (nodeMajor === 20 && nodeMinor < 19)) { @@ -81,7 +81,6 @@ if (existsSync(envPath)) { const SECRET_GENERATORS = { NEXTAUTH_SECRET: () => randomBytes(32).toString("base64"), TOKEN_ENC_KEY: () => randomBytes(32).toString("hex"), - CRON_SECRET: () => randomBytes(16).toString("hex"), }; let envText = readFileSync(envPath, "utf8"); diff --git a/src/__tests__/content-prompts-validation.test.ts b/src/__tests__/content-prompts-validation.test.ts index 94d558a..9ae71ad 100644 --- a/src/__tests__/content-prompts-validation.test.ts +++ b/src/__tests__/content-prompts-validation.test.ts @@ -206,6 +206,32 @@ describe("generateContentAwarePrompts output validation", () => { expect(mcq.meta?.key_points).toEqual(["point one", "point two"]); }); + it("sanitizes difficulty: rounds floats, clamps to 1..5, and defaults non-finite/non-number values to 1", async () => { + mockRunTaskRaw({ + prompts: [ + { ...validPrompt(1), difficulty: 3.6 }, // float → rounded + { ...validPrompt(2), difficulty: NaN }, // NaN → 1 + { ...validPrompt(3), difficulty: Infinity }, // non-finite → 1 + { ...validPrompt(4), difficulty: "4" }, // string → 1 (not coerced) + { ...validPrompt(5), difficulty: 99 }, // above range → 5 + { ...validPrompt(6), difficulty: -2 }, // below range → 1 + { ...validPrompt(7), difficulty: undefined }, // missing → 1 + { ...validPrompt(8), difficulty: 0.4 }, // rounds to 0 → clamped to 1 + ], + }); + + const result = await generateContentAwarePrompts(DEFAULT_PARAMS); + + expect(result).not.toBeNull(); + expect(result!.map((p) => p.difficulty)).toEqual([4, 1, 1, 1, 5, 1, 1, 1]); + // Every persisted difficulty is a valid Int in range for the Prisma column + for (const p of result!) { + expect(Number.isInteger(p.difficulty)).toBe(true); + expect(p.difficulty).toBeGreaterThanOrEqual(1); + expect(p.difficulty).toBeLessThanOrEqual(5); + } + }); + it("returns null when runTask throws", async () => { runTaskMock.mockRejectedValue(new Error("provider down")); diff --git a/src/__tests__/feedback-persistence.test.ts b/src/__tests__/feedback-persistence.test.ts index 8747041..95a6597 100644 --- a/src/__tests__/feedback-persistence.test.ts +++ b/src/__tests__/feedback-persistence.test.ts @@ -227,14 +227,25 @@ describe("feedback persistence and eager generation", () => { expect(runTaskMock).not.toHaveBeenCalled(); }); - it("persists the empty-results response when search finds nothing", async () => { + it("persists an explicit no-sources terminal state when search finds nothing", async () => { searchChunksMock.mockResolvedValue([]); const result = await generateFeedback("user-1", "attempt-1"); - expect(result).toEqual({ status: "OK", excerpts: [] }); + // no_sources marks the empty result as terminal — clients stop polling + // and render an honest fallback instead of an eternal spinner. + expect(result).toEqual({ status: "OK", excerpts: [], no_sources: true }); expect(store.row?.feedbackStatus).toBe("READY"); expect(store.row?.feedbackJson).toEqual(result); + + // Refetch returns the persisted terminal state verbatim — no + // regeneration, no renewed searching. + searchChunksMock.mockClear(); + runTaskMock.mockClear(); + const second = await generateFeedback("user-1", "attempt-1"); + expect(second).toEqual(result); + expect(searchChunksMock).not.toHaveBeenCalled(); + expect(runTaskMock).not.toHaveBeenCalled(); }); it("does not persist or claim for unscored (EXAM-phase) attempts", async () => { diff --git a/src/__tests__/feedback-polling.test.ts b/src/__tests__/feedback-polling.test.ts new file mode 100644 index 0000000..6cc03f9 --- /dev/null +++ b/src/__tests__/feedback-polling.test.ts @@ -0,0 +1,106 @@ +/** + * Unit tests for the client-side feedback poll loop (pollFeedback). + * + * The live-audit bug: an empty/failed feedback generation left the review + * panel spinning forever. The contract under test: PENDING is the ONLY + * non-terminal status — any other result (OK with content, OK + no_sources, + * UNAVAILABLE) resolves the poll immediately, the poll budget yields null + * instead of spinning, and cancellation stops the loop without a result. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { pollFeedback } from "@/app/s/[sessionId]/feedback-poll"; + +const fetchMock = vi.fn(); +vi.stubGlobal("fetch", fetchMock); + +function jsonResponse(body: unknown, ok = true) { + return { ok, json: async () => body }; +} + +const notCancelled = () => false; + +describe("pollFeedback terminal semantics", () => { + beforeEach(() => { + fetchMock.mockReset(); + }); + + it("resolves on the first non-PENDING result (single fetch)", async () => { + const payload = { status: "OK", excerpts: [], explanation: "because osmosis" }; + fetchMock.mockResolvedValue(jsonResponse(payload)); + + const result = await pollFeedback("attempt-1", notCancelled, 5, 1); + + expect(result).toEqual(payload); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("treats the explicit no-sources state as terminal — no further polling", async () => { + const payload = { status: "OK", excerpts: [], no_sources: true }; + fetchMock.mockResolvedValue(jsonResponse(payload)); + + const result = await pollFeedback("attempt-1", notCancelled, 5, 1); + + expect(result).toEqual(payload); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("treats UNAVAILABLE (generation failure) as terminal", async () => { + const payload = { status: "UNAVAILABLE", excerpts: [] }; + fetchMock.mockResolvedValue(jsonResponse(payload)); + + const result = await pollFeedback("attempt-1", notCancelled, 5, 1); + + expect(result).toEqual(payload); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("keeps polling through PENDING, then returns the terminal result", async () => { + const terminal = { status: "OK", excerpts: [], no_sources: true }; + fetchMock + .mockResolvedValueOnce(jsonResponse({ status: "PENDING", excerpts: [] })) + .mockResolvedValueOnce(jsonResponse({ status: "PENDING", excerpts: [] })) + .mockResolvedValueOnce(jsonResponse(terminal)); + + const result = await pollFeedback("attempt-1", notCancelled, 5, 1); + + expect(result).toEqual(terminal); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("returns null (not an infinite spin) when the poll budget is exhausted", async () => { + fetchMock.mockResolvedValue(jsonResponse({ status: "PENDING", excerpts: [] })); + + const result = await pollFeedback("attempt-1", notCancelled, 3, 1); + + expect(result).toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("returns null without fetching when already cancelled", async () => { + const result = await pollFeedback("attempt-1", () => true, 5, 1); + + expect(result).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("stops mid-loop when cancellation flips during a PENDING stretch", async () => { + let cancelled = false; + fetchMock.mockImplementation(async () => { + cancelled = true; // cancel after the first response comes back + return jsonResponse({ status: "PENDING", excerpts: [] }); + }); + + const result = await pollFeedback("attempt-1", () => cancelled, 5, 1); + + expect(result).toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("propagates a fetch error so the caller can settle into a fallback", async () => { + fetchMock.mockResolvedValue(jsonResponse({ error: "Internal server error" }, false)); + + await expect(pollFeedback("attempt-1", notCancelled, 5, 1)).rejects.toThrow( + "Internal server error", + ); + }); +}); diff --git a/src/__tests__/followups.test.ts b/src/__tests__/followups.test.ts new file mode 100644 index 0000000..55f3c2f --- /dev/null +++ b/src/__tests__/followups.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect } from "vitest"; +import { computeFollowupSchedule } from "@/services/followups"; + +/** + * Unit tests for the pure follow-up scheduling core: deterministic mapping + * from a completed run's results onto concrete plan dates, timezone-aware, + * exam-capped. (DB insertion + idempotency are covered by the integration + * suite in src/__tests__/integration/followups.test.ts.) + */ + +const AVAILABILITY = Array.from({ length: 7 }, () => ({ + start: "09:00", + end: "17:00", +})); + +// 2026-07-16 15:00:00 UTC = 11:00 in America/New_York (EDT) +const ENDED_AT = new Date("2026-07-16T15:00:00Z"); + +function baseInput(overrides: Partial[0]> = {}) { + return { + accuracy: 0.75, + endedAt: ENDED_AT, + timezone: "America/New_York", + examDate: "2026-08-15", + planStartDate: "2026-07-16", + availability: AVAILABILITY, + ...overrides, + }; +} + +describe("computeFollowupSchedule", () => { + it("is a deterministic function of the run's results", () => { + const a = computeFollowupSchedule(baseInput()); + const b = computeFollowupSchedule(baseInput()); + expect(a).toEqual(b); + }); + + it("uses the spaced-repetition ladder offsets (accuracy 0.75 -> 2 and 4 days)", () => { + const { planned, skipped } = computeFollowupSchedule(baseInput()); + expect(skipped).toEqual([]); + expect(planned.map((p) => p.days_from_now)).toEqual([2, 4]); + expect(planned.map((p) => p.date)).toEqual(["2026-07-18", "2026-07-20"]); + }); + + it("uses shorter intervals for low accuracy and longer for high accuracy", () => { + const low = computeFollowupSchedule(baseInput({ accuracy: 0.5 })); + const high = computeFollowupSchedule(baseInput({ accuracy: 0.9 })); + expect(low.planned.map((p) => p.days_from_now)).toEqual([1, 2]); + expect(high.planned.map((p) => p.days_from_now)).toEqual([3, 6]); + }); + + it("computes calendar dates in the plan's timezone, not the server's", () => { + // 03:00 UTC on the 16th is still 23:00 on the 15th in New York, so + // "+2 days" lands on the 17th there. + const { planned } = computeFollowupSchedule( + baseInput({ endedAt: new Date("2026-07-16T03:00:00Z") }), + ); + expect(planned.map((p) => p.date)).toEqual(["2026-07-17", "2026-07-19"]); + }); + + it("schedules at the availability window start in the plan's timezone", () => { + const { planned } = computeFollowupSchedule(baseInput()); + // 09:00 America/New_York (EDT, UTC-4) = 13:00 UTC + expect(planned[0].start_time.toISOString()).toBe("2026-07-18T13:00:00.000Z"); + expect(planned[0].day_index).toBe(2); + }); + + it("gives every follow-up a ~30 minute duration", () => { + const { planned } = computeFollowupSchedule(baseInput()); + for (const p of planned) { + expect(p.end_time.getTime() - p.start_time.getTime()).toBe(30 * 60000); + } + }); + + it("skips dates past the exam", () => { + const { planned, skipped } = computeFollowupSchedule( + baseInput({ examDate: "2026-07-18" }), + ); + expect(planned.map((p) => p.days_from_now)).toEqual([2]); + expect(skipped).toEqual([ + { date: "2026-07-20", days_from_now: 4, reason: "past_exam" }, + ]); + }); + + it("skips everything when the exam is tomorrow and accuracy is high", () => { + const { planned, skipped } = computeFollowupSchedule( + baseInput({ accuracy: 0.95, examDate: "2026-07-17" }), + ); + expect(planned).toEqual([]); + expect(skipped).toHaveLength(2); + expect(skipped.every((s) => s.reason === "past_exam")).toBe(true); + }); + + it("allows a follow-up on the exam day itself", () => { + const { planned } = computeFollowupSchedule(baseInput({ examDate: "2026-07-18" })); + expect(planned.some((p) => p.date === "2026-07-18")).toBe(true); + }); + + it("wraps availability by weekday for days beyond the plan window", () => { + const availability = AVAILABILITY.map((a, i) => + i === 1 ? { start: "14:30", end: "18:00" } : a, + ); + // Plan started 7-16; run ends 7-20 (day 4); +4 days = day 8 -> wraps to + // availability[1] (14:30). + const { planned } = computeFollowupSchedule( + baseInput({ + endedAt: new Date("2026-07-20T15:00:00Z"), + availability, + }), + ); + const late = planned.find((p) => p.days_from_now === 4)!; + expect(late.day_index).toBe(8); + // 14:30 America/New_York (EDT) = 18:30 UTC + expect(late.start_time.toISOString()).toBe("2026-07-24T18:30:00.000Z"); + }); + + it("falls back to 09:00 when no availability windows exist", () => { + const { planned } = computeFollowupSchedule(baseInput({ availability: null })); + expect(planned[0].start_time.toISOString()).toBe("2026-07-18T13:00:00.000Z"); + }); + + it("handles DST transitions (spring forward) without drifting", () => { + // 2026-03-06 ends; +2 days = 2026-03-08, the US spring-forward day. + const { planned } = computeFollowupSchedule( + baseInput({ + endedAt: new Date("2026-03-06T15:00:00Z"), + planStartDate: "2026-03-06", + examDate: "2026-04-01", + }), + ); + // 09:00 America/New_York on 3/8 is EDT (UTC-4) = 13:00 UTC + expect(planned[0].date).toBe("2026-03-08"); + expect(planned[0].start_time.toISOString()).toBe("2026-03-08T13:00:00.000Z"); + }); + + it("falls back to server-local wall time for an invalid timezone", () => { + const { planned } = computeFollowupSchedule(baseInput({ timezone: "Not/AZone" })); + expect(planned).toHaveLength(2); + const d = planned[0].start_time; + expect(d.getHours()).toBe(9); + expect(d.getMinutes()).toBe(0); + }); +}); diff --git a/src/__tests__/integration/followups.test.ts b/src/__tests__/integration/followups.test.ts new file mode 100644 index 0000000..0634c08 --- /dev/null +++ b/src/__tests__/integration/followups.test.ts @@ -0,0 +1,265 @@ +/** + * Integration tests for the follow-up scheduler. + * + * Tests: scheduleFollowups inserts RETRIEVAL plan items on the active plan, + * is idempotent per run, and reports "no_plan" when the user has no active + * study plan. + * + * Requires a running PostgreSQL database. Set DATABASE_URL before running. + * Run: DATABASE_URL= npm run test:integration + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; + +const hasDb = !!process.env.DATABASE_URL; + +let prisma: any; +let createPlan: any; +let scheduleFollowups: any; + +const userId = "test_user_followups"; +const noPlanUserId = "test_user_followups_noplan"; + +async function cleanup(uid: string) { + await prisma.studyPlanItem.deleteMany({ where: { plan: { userId: uid } } }); + await prisma.studyPlan.deleteMany({ where: { userId: uid } }); + await prisma.sessionRun.deleteMany({ where: { userId: uid } }); + await prisma.session.deleteMany({ where: { userId: uid } }); +} + +/** Create a completed RETRIEVAL session + run for the given user/course. */ +async function createCompletedRun( + uid: string, + courseName: string, + accuracy: number, + suffix: string, +) { + const sessionId = `fu_session_${suffix}`; + const runId = `fu_run_${suffix}`; + await prisma.session.create({ + data: { + sessionId, + userId: uid, + courseId: "", + courseName, + examId: "", + examName: "Midterm", + mode: "RETRIEVAL", + topicScope: "Data structures, Algorithms", + objectives: [ + { id: "obj_1", title: "Data structures" }, + { id: "obj_2", title: "Algorithms" }, + ], + targetOutcome: { type: "accuracy", target_accuracy: 0.8 }, + breakProtocol: { type: "50_10", cycles: 1 }, + plannedMinutes: 30, + }, + }); + await prisma.sessionRun.create({ + data: { + runId, + sessionId, + userId: uid, + mode: "RETRIEVAL", + phase: "COMPLETE", + status: "COMPLETED", + startedAt: new Date(Date.now() - 30 * 60 * 1000), + endedAt: new Date(), + prompts: [], + metrics: { + attempts_count: 8, + correct_count: 6, + partial_count: 1, + incorrect_count: 1, + accuracy, + time_spent_seconds: 1200, + }, + }, + }); + return { sessionId, runId }; +} + +describe.skipIf(!hasDb)("Integration: follow-up scheduling", () => { + let planId: string; + let runId: string; + let baselineItemCount: number; + + const futureExam = new Date(); + futureExam.setDate(futureExam.getDate() + 30); + const examDateStr = futureExam.toISOString().split("T")[0]; + + beforeAll(async () => { + const dbModule = await import("@/lib/db"); + prisma = dbModule.prisma; + const planService = await import("@/services/plan"); + createPlan = planService.createPlan; + const followupService = await import("@/services/followups"); + scheduleFollowups = followupService.scheduleFollowups; + + await cleanup(userId); + await cleanup(noPlanUserId); + + const plan = await createPlan(userId, { + course_name: "FOLLOWUP 301", + exam_name: "Midterm", + exam_date: examDateStr, + objectives: ["Data structures", "Algorithms", "Graph theory"], + availability: Array.from({ length: 7 }, () => ({ + start: "09:00", + end: "17:00", + })), + daily_study_cap_minutes: 180, + break_protocol_default: "50_10", + }); + planId = plan.plan_id; + baselineItemCount = plan.items.length; + + const run = await createCompletedRun(userId, "FOLLOWUP 301", 0.75, "main"); + runId = run.runId; + }); + + afterAll(async () => { + if (!prisma) return; + await cleanup(userId); + await cleanup(noPlanUserId); + await prisma.$disconnect(); + }); + + it("schedules the recommended follow-ups on the active plan", async () => { + const result = await scheduleFollowups(userId, runId); + expect("data" in result).toBe(true); + expect(result.data.status).toBe("scheduled"); + expect(result.data.plan_id).toBe(planId); + // Accuracy 0.75 -> +2 and +4 days, both well before the exam + expect(result.data.scheduled).toHaveLength(2); + expect(result.data.scheduled.map((s: any) => s.days_from_now)).toEqual([2, 4]); + expect(result.data.skipped).toEqual([]); + for (const s of result.data.scheduled) { + expect(s.session_id).toBeDefined(); + expect(s.session_url).toContain(`/s/${s.session_id}`); + expect(s.date).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(s.date <= examDateStr).toBe(true); + } + }); + + it("created RETRIEVAL sessions carrying the run marker and the objectives", async () => { + const sessions = await prisma.session.findMany({ + where: { + userId, + resources: { path: ["followup_of_run_id"], equals: runId }, + }, + }); + expect(sessions).toHaveLength(2); + for (const s of sessions) { + expect(s.mode).toBe("RETRIEVAL"); + expect(s.plannedMinutes).toBe(30); + expect(s.courseName).toBe("FOLLOWUP 301"); + expect(s.topicScope).toContain("Data structures"); + expect(s.objectives).toEqual([ + { id: "obj_1", title: "Data structures" }, + { id: "obj_2", title: "Algorithms" }, + ]); + } + }); + + it("created plan items for the follow-up sessions", async () => { + const plan = await prisma.studyPlan.findUnique({ + where: { planId }, + include: { items: true }, + }); + expect(plan.items).toHaveLength(baselineItemCount + 2); + + const sessions = await prisma.session.findMany({ + where: { userId, resources: { path: ["followup_of_run_id"], equals: runId } }, + select: { sessionId: true }, + }); + for (const s of sessions) { + const item = plan.items.find((i: any) => i.sessionId === s.sessionId); + expect(item, `plan item for ${s.sessionId} missing`).toBeDefined(); + expect(item.status).toBe("SCHEDULED"); + expect(item.endTime.getTime() - item.startTime.getTime()).toBe(30 * 60000); + } + }); + + it("is idempotent: a second call does not double-insert", async () => { + const result = await scheduleFollowups(userId, runId); + expect("data" in result).toBe(true); + expect(result.data.status).toBe("already_scheduled"); + expect(result.data.scheduled).toHaveLength(2); + + const sessions = await prisma.session.findMany({ + where: { userId, resources: { path: ["followup_of_run_id"], equals: runId } }, + }); + expect(sessions).toHaveLength(2); + + const plan = await prisma.studyPlan.findUnique({ + where: { planId }, + include: { items: true }, + }); + expect(plan.items).toHaveLength(baselineItemCount + 2); + }); + + it("returns no_plan when the user has no active study plan", async () => { + const { runId: orphanRunId } = await createCompletedRun( + noPlanUserId, + "NOPLAN 101", + 0.75, + "noplan", + ); + const result = await scheduleFollowups(noPlanUserId, orphanRunId); + expect("data" in result).toBe(true); + expect(result.data.status).toBe("no_plan"); + + // Nothing was inserted + const sessions = await prisma.session.findMany({ + where: { + userId: noPlanUserId, + resources: { path: ["followup_of_run_id"], equals: orphanRunId }, + }, + }); + expect(sessions).toHaveLength(0); + }); + + it("rejects runs owned by another user", async () => { + const result = await scheduleFollowups("intruder", runId); + expect("error" in result).toBe(true); + expect(result.error).toBe("forbidden"); + }); + + it("rejects unknown runs", async () => { + const result = await scheduleFollowups(userId, "does_not_exist"); + expect("error" in result).toBe(true); + expect(result.error).toBe("not_found"); + }); + + it("rejects runs that are not completed", async () => { + const sessionId = "fu_session_active"; + const activeRunId = "fu_run_active"; + await prisma.session.create({ + data: { + sessionId, + userId, + courseId: "", + courseName: "FOLLOWUP 301", + examId: "", + examName: "Midterm", + mode: "RETRIEVAL", + topicScope: "Graph theory", + plannedMinutes: 30, + }, + }); + await prisma.sessionRun.create({ + data: { + runId: activeRunId, + sessionId, + userId, + mode: "RETRIEVAL", + status: "ACTIVE", + prompts: [], + metrics: { attempts_count: 0, accuracy: 0 }, + }, + }); + const result = await scheduleFollowups(userId, activeRunId); + expect("error" in result).toBe(true); + expect(result.error).toBe("run_not_completed"); + }); +}); diff --git a/src/__tests__/integration/mode-parity.test.ts b/src/__tests__/integration/mode-parity.test.ts index ed24db8..af19b62 100644 --- a/src/__tests__/integration/mode-parity.test.ts +++ b/src/__tests__/integration/mode-parity.test.ts @@ -450,8 +450,9 @@ describe.skipIf(!hasDb)("Integration: ERROR_REPAIR mode", () => { data: { lastCorrectAt: new Date(Date.now() - 24 * 60 * 60 * 1000) }, }); + // Same course as the original error — ERROR_REPAIR decks are course-scoped const result = await createSession(userId, { - course_name: "REPAIR TEST DAY2", + course_name: "REPAIR TEST", exam_name: "Quiz 1", mode: "ERROR_REPAIR", topic_scope: "Errors", @@ -480,9 +481,10 @@ describe.skipIf(!hasDb)("Integration: ERROR_REPAIR mode", () => { }); it("resolved errors are not included in new ERROR_REPAIR decks", async () => { - // Create another repair session + // Create another repair session in the SAME course (a different course + // would exclude the error via course scoping, not via resolution) const result = await createSession(userId, { - course_name: "REPAIR TEST 2", + course_name: "REPAIR TEST", exam_name: "Quiz 2", mode: "ERROR_REPAIR", topic_scope: "Errors", @@ -502,6 +504,219 @@ describe.skipIf(!hasDb)("Integration: ERROR_REPAIR mode", () => { }); }); +// ============================================================ +// ERROR_REPAIR course scoping +// ============================================================ + +describe.skipIf(!hasDb)("Integration: ERROR_REPAIR decks are course-scoped", () => { + const userId = "test_errorrepair_scope_user"; + let getDeckPreview: any; + let courseAErrorId: string; + let courseBErrorId: string; + + beforeAll(async () => { + const dbModule = await import("@/lib/db"); + prisma = dbModule.prisma; + const sessionService = await import("@/services/session"); + createSession = sessionService.createSession; + const runService = await import("@/services/run"); + startOrResumeRun = runService.startOrResumeRun; + getDeckPreview = runService.getDeckPreview; + + // Seed one unresolved error in each of two courses. Runs and error logs + // are inserted directly — the deck builder only cares about the rows, + // not how they got there. + const seedError = async (courseName: string, correctionRule: string) => { + const created = await createSession(userId, { + course_name: courseName, + exam_name: "Quiz 1", + mode: "RETRIEVAL", + topic_scope: "Errors", + planned_minutes: 15, + objectives: [{ id: "obj_1", title: "Topic" }], + target_outcome: { prompt_count: 2 }, + break_protocol: { type: "TEST_3_2", cycles: 1 }, + }); + const run = await prisma.sessionRun.create({ + data: { + runId: `run_scope_${courseName.replace(/\W/g, "_").toLowerCase()}`, + sessionId: created.session_id, + userId, + status: "COMPLETED", + prompts: [], + }, + }); + const log = await prisma.sessionErrorLog.create({ + data: { + runId: run.runId, + userId, + promptIndex: 0, + errorType: "MISCONCEPTION", + correctionRule: correctionRule, + variantQuestion: `Variant for ${courseName}`, + }, + }); + return log.id; + }; + + courseAErrorId = await seedError("SCOPE COURSE A", "Rule for course A"); + courseBErrorId = await seedError("SCOPE COURSE B", "Rule for course B"); + }); + + afterAll(async () => { + if (!prisma) return; + await prisma.sessionErrorLog.deleteMany({ where: { run: { userId } } }); + await prisma.sessionAttempt.deleteMany({ where: { run: { userId } } }); + await prisma.sessionRun.deleteMany({ where: { userId } }); + await prisma.session.deleteMany({ where: { userId } }); + }); + + it("an ERROR_REPAIR deck contains only the current course's unresolved errors", async () => { + const result = await createSession(userId, { + course_name: "SCOPE COURSE A", + exam_name: "Quiz 1", + mode: "ERROR_REPAIR", + topic_scope: "Errors", + planned_minutes: 15, + target_outcome: { prompt_count: 5 }, + break_protocol: { type: "TEST_3_2", cycles: 1 }, + }); + + const startResult = await startOrResumeRun(userId, result.session_id); + const d = startResult.data!; + expect(d.mode).toBe("ERROR_REPAIR"); + const prompts = d.prompts as any[]; + const sourceIds = prompts + .map((p) => p.meta?.source_error_log_id) + .filter(Boolean); + + // Course A's error is repaired; course B's error never leaks in + expect(sourceIds).toContain(courseAErrorId); + expect(sourceIds).not.toContain(courseBErrorId); + }); + + it("getDeckPreview counts only the current course's unresolved errors", async () => { + // Course B still has its own unresolved error (untouched above) + const previewB = await getDeckPreview(userId, { + courseName: "SCOPE COURSE B", + mode: "ERROR_REPAIR", + objectives: null, + promptCount: 5, + }); + expect(previewB.repair_count).toBe(1); + expect(previewB.total).toBe(1); + + // A course with no errors at all falls back to a small retrieval deck + const previewEmpty = await getDeckPreview(userId, { + courseName: "SCOPE COURSE EMPTY", + mode: "ERROR_REPAIR", + objectives: null, + promptCount: 5, + }); + expect(previewEmpty.repair_count).toBe(0); + expect(previewEmpty.new_count).toBe(3); + }); +}); + +// ============================================================ +// Completion race: exactly one caller runs post-completion effects +// ============================================================ + +describe.skipIf(!hasDb)("Integration: concurrent final attempt + completeRun", () => { + const userId = "test_completion_race_user"; + let submitAttemptFn: any; + let completeRunFn: any; + + beforeAll(async () => { + const dbModule = await import("@/lib/db"); + prisma = dbModule.prisma; + const sessionService = await import("@/services/session"); + createSession = sessionService.createSession; + const runService = await import("@/services/run"); + startOrResumeRun = runService.startOrResumeRun; + submitAttemptFn = runService.submitAttempt; + completeRunFn = runService.completeRun; + + // Suppress pre-test prepends so the deck is exactly the requested prompts + await prisma.objectiveMastery.createMany({ + data: [{ userId, courseName: "RACE 101", objectiveKey: "obj_race" }], + skipDuplicates: true, + }); + }); + + afterAll(async () => { + if (!prisma) return; + await prisma.xpEvent.deleteMany({ where: { userId } }); + await prisma.sessionErrorLog.deleteMany({ where: { run: { userId } } }); + await prisma.sessionAttempt.deleteMany({ where: { run: { userId } } }); + await prisma.sessionRun.deleteMany({ where: { userId } }); + await prisma.session.deleteMany({ where: { userId } }); + await prisma.objectiveMastery.deleteMany({ where: { userId } }); + }); + + it("racing the final attempt against completeRun awards SESSION_COMPLETED XP exactly once", async () => { + const created = await createSession(userId, { + course_name: "RACE 101", + exam_name: "Midterm", + mode: "RETRIEVAL", + topic_scope: "Racing", + planned_minutes: 15, + objectives: [{ id: "obj_race", title: "Race conditions" }], + target_outcome: { prompt_count: 2 }, + break_protocol: { type: "TEST_3_2", cycles: 1 }, + }); + const startResult = await startOrResumeRun(userId, created.session_id); + const runId = startResult.data!.run_id as string; + + // Answer everything except the last prompt + const promptCount = startResult.data!.prompt_count as number; + for (let i = 0; i < promptCount - 1; i++) { + const res = await submitAttemptFn(userId, runId, { + prompt_index: i, + user_answer: `Answer ${i}`, + self_score: "CORRECT", + time_to_answer_seconds: 5, + }); + expect("data" in res).toBe(true); + } + + // Race the final attempt against a manual completeRun. Exactly one path + // may claim the ACTIVE -> COMPLETED transition and run post-completion + // effects; the loser must observe the completed run (as data or a + // run_completed error), never resurrect it or re-run effects. + const [attemptRes, completeRes] = await Promise.all([ + submitAttemptFn(userId, runId, { + prompt_index: promptCount - 1, + user_answer: "Final answer", + self_score: "CORRECT", + time_to_answer_seconds: 5, + }), + completeRunFn(userId, runId), + ]); + + // completeRun is idempotent — it always reports COMPLETED + expect("data" in completeRes).toBe(true); + expect(completeRes.data!.status).toBe("COMPLETED"); + // The attempt either landed (winner or pre-completion) or was rejected + // because completeRun won the transition first + if ("error" in attemptRes) { + expect(attemptRes.error).toBe("run_completed"); + } else { + expect(attemptRes.data!.status).toBe("COMPLETED"); + } + + // The run is completed exactly once + const run = await prisma.sessionRun.findUnique({ where: { runId } }); + expect(run.status).toBe("COMPLETED"); + + // Post-completion effects ran exactly once: one SESSION_COMPLETED XP event + const xpEvents = await prisma.xpEvent.findMany({ + where: { userId, action: "SESSION_COMPLETED", sourceId: runId }, + }); + expect(xpEvents).toHaveLength(1); + }); +}); + // ============================================================ // Phase enforcement across modes // ============================================================ diff --git a/src/__tests__/plan-generator.test.ts b/src/__tests__/plan-generator.test.ts index 451486f..1bebbb9 100644 --- a/src/__tests__/plan-generator.test.ts +++ b/src/__tests__/plan-generator.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from "vitest"; -import { generatePlan, PlanBlock } from "@/lib/plan-generator"; +import { + generatePlan, + PlanBlock, + slugifyObjectiveTitle, + buildObjectiveIdMap, +} from "@/lib/plan-generator"; // --------------------------------------------------------------------------- // Helpers @@ -211,6 +216,59 @@ describe("plan-generator: determinism", () => { }); }); +// --------------------------------------------------------------------------- +// C2) Stable objective ids +// --------------------------------------------------------------------------- + +describe("plan-generator: stable objective ids", () => { + it("slugifies titles: lowercase, non-alphanumeric runs to single underscore, trimmed", () => { + expect(slugifyObjectiveTitle("Photosynthesis: Light Reactions!")).toBe( + "photosynthesis_light_reactions", + ); + expect(slugifyObjectiveTitle(" --Weird spacing-- ")).toBe("weird_spacing"); + expect(slugifyObjectiveTitle("Objective 1")).toBe("objective_1"); + }); + + it("caps slugs at 60 chars without a trailing underscore", () => { + const long = "A ".repeat(80); // slugs to a_a_a_... far beyond 60 + const slug = slugifyObjectiveTitle(long); + expect(slug.length).toBeLessThanOrEqual(60); + expect(slug.endsWith("_")).toBe(false); + }); + + it("falls back to a non-empty id for titles with no alphanumerics", () => { + expect(slugifyObjectiveTitle("!!!")).toBe("objective"); + }); + + it("disambiguates colliding slugs deterministically in first-seen order", () => { + const map = buildObjectiveIdMap(["Loops!", "Loops?", "Loops."]); + expect(map.get("Loops!")).toBe("loops"); + expect(map.get("Loops?")).toBe("loops_2"); + expect(map.get("Loops.")).toBe("loops_3"); + }); + + it("gives the same objective the same id in every block it appears in", () => { + // 10 objectives → packs split; interleaved and full-scope blocks would + // previously remint per-block obj_N ids that collided across blocks. + const blocks = generatePlan(defaultInput({ objectives: makeObjectives(10) })); + const idByTitle = new Map(); + const titleById = new Map(); + for (const b of blocks) { + for (const o of b.objectives) { + const priorId = idByTitle.get(o.title); + expect(priorId ?? o.id, `"${o.title}" changed id across blocks`).toBe(o.id); + idByTitle.set(o.title, o.id); + const priorTitle = titleById.get(o.id); + expect(priorTitle ?? o.title, `id "${o.id}" reused for a different title`).toBe(o.title); + titleById.set(o.id, o.title); + } + } + // Ids derive from titles, not positions + expect(idByTitle.get("Objective 1")).toBe("objective_1"); + expect(idByTitle.get("Objective 10")).toBe("objective_10"); + }); +}); + // --------------------------------------------------------------------------- // D) Edge cases // --------------------------------------------------------------------------- diff --git a/src/__tests__/research-plan-generator.test.ts b/src/__tests__/research-plan-generator.test.ts index 1b3e787..2bc7fc7 100644 --- a/src/__tests__/research-plan-generator.test.ts +++ b/src/__tests__/research-plan-generator.test.ts @@ -147,6 +147,22 @@ describe("generatePlanWithResearch", () => { expect(result.blocks[2].mode).toBe("INTERLEAVED_PRACTICE"); expect(result.blocks[3].mode).toBe("EXAM_SIM"); + + // Objective ids are stable slugs of the titles: the same objective + // carries the same id in every block it appears in. + expect(result.blocks[0].objectives).toEqual([ + { id: "algebra_basics", title: "Algebra basics" }, + { id: "linear_equations", title: "Linear equations" }, + ]); + const idsByTitle = new Map(); + for (const block of result.blocks) { + for (const o of block.objectives) { + const prior = idsByTitle.get(o.title); + expect(prior ?? o.id).toBe(o.id); + idsByTitle.set(o.title, o.id); + } + } + expect(idsByTitle.get("Word problems")).toBe("word_problems"); }); it("filters out blocks with invalid modes", async () => { diff --git a/src/__tests__/reveal-route.test.ts b/src/__tests__/reveal-route.test.ts new file mode 100644 index 0000000..10c29dc --- /dev/null +++ b/src/__tests__/reveal-route.test.ts @@ -0,0 +1,110 @@ +/** + * Unit tests for GET /api/runs/[runId]/reveal — the answer-standard reveal + * used at self-score time for free-recall prompts. + * + * Contract: authenticated owner only, index validated, service errors map to + * 404/403/409, and the model_answer/key_points payload passes through for + * the happy path (the MCQ pre-answer guard lives in the service and returns + * a null payload — never the answer key). + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { NextRequest } from "next/server"; + +vi.mock("@/lib/auth", () => ({ + getUserId: vi.fn(async (request: Request) => request.headers.get("x-user-id")), +})); + +vi.mock("@/services/run", () => ({ + getAnswerReveal: vi.fn(), +})); + +import { GET } from "@/app/api/runs/[runId]/reveal/route"; +import { getAnswerReveal } from "@/services/run"; + +const getAnswerRevealMock = vi.mocked(getAnswerReveal); + +function makeRequest(index: string | null = "0", userId: string | null = "user-1"): NextRequest { + const headers: Record = {}; + if (userId) headers["x-user-id"] = userId; + const qs = index === null ? "" : `?index=${index}`; + return new Request(`http://localhost/api/runs/run-1/reveal${qs}`, { + headers, + }) as unknown as NextRequest; +} + +const PARAMS = { params: { runId: "run-1" } }; + +describe("GET /api/runs/[runId]/reveal", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 without a user", async () => { + const res = await GET(makeRequest("0", null), PARAMS); + expect(res.status).toBe(401); + expect(getAnswerRevealMock).not.toHaveBeenCalled(); + }); + + it("returns 400 for a missing or negative index", async () => { + expect((await GET(makeRequest(null), PARAMS)).status).toBe(400); + expect((await GET(makeRequest("-1"), PARAMS)).status).toBe(400); + expect((await GET(makeRequest("abc"), PARAMS)).status).toBe(400); + expect(getAnswerRevealMock).not.toHaveBeenCalled(); + }); + + it("maps not_found to 404 and forbidden to 403", async () => { + getAnswerRevealMock.mockResolvedValue({ error: "not_found" }); + expect((await GET(makeRequest(), PARAMS)).status).toBe(404); + + getAnswerRevealMock.mockResolvedValue({ error: "forbidden" }); + expect((await GET(makeRequest(), PARAMS)).status).toBe(403); + }); + + it("maps wrong_phase (EXAM) and wrong_index to 409", async () => { + getAnswerRevealMock.mockResolvedValue({ + error: "wrong_phase", + message: "Answers are revealed after the exam phase", + }); + expect((await GET(makeRequest(), PARAMS)).status).toBe(409); + + getAnswerRevealMock.mockResolvedValue({ error: "wrong_index", expected: 2 }); + const res = await GET(makeRequest("5"), PARAMS); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ error: "Wrong prompt index", expected: 2 }); + }); + + it("returns model_answer and key_points for the authenticated owner", async () => { + getAnswerRevealMock.mockResolvedValue({ + data: { model_answer: "Water moves toward solute.", key_points: ["gradient", "membrane"] }, + }); + + const res = await GET(makeRequest(), PARAMS); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + model_answer: "Water moves toward solute.", + key_points: ["gradient", "membrane"], + }); + expect(getAnswerRevealMock).toHaveBeenCalledWith("user-1", "run-1", 0); + }); + + it("passes through a graceful-absence payload (prompt without key points)", async () => { + getAnswerRevealMock.mockResolvedValue({ + data: { model_answer: null, key_points: null }, + }); + + const res = await GET(makeRequest(), PARAMS); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ model_answer: null, key_points: null }); + }); + + it("returns 500 when the service throws", async () => { + getAnswerRevealMock.mockRejectedValue(new Error("boom")); + + const res = await GET(makeRequest(), PARAMS); + + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ error: "Internal server error" }); + }); +}); diff --git a/src/__tests__/validation-content.test.ts b/src/__tests__/validation-content.test.ts index 79ee161..1dbf17f 100644 --- a/src/__tests__/validation-content.test.ts +++ b/src/__tests__/validation-content.test.ts @@ -2,10 +2,6 @@ import { describe, it, expect } from "vitest"; import { uploadDocumentSchema, searchContentSchema, - createPracticeSetSchema, - importQuestionsSchema, - createEvidencePaperSchema, - createEvidenceCardSchema, } from "@/lib/validation-content"; describe("uploadDocumentSchema", () => { @@ -74,85 +70,3 @@ describe("searchContentSchema", () => { expect(result.success).toBe(false); }); }); - -describe("createPracticeSetSchema", () => { - it("accepts valid input", () => { - const result = createPracticeSetSchema.safeParse({ - course_name: "CS 101", - title: "Midterm Prep", - }); - expect(result.success).toBe(true); - }); - - it("rejects empty title", () => { - const result = createPracticeSetSchema.safeParse({ - course_name: "CS 101", - title: "", - }); - expect(result.success).toBe(false); - }); -}); - -describe("importQuestionsSchema", () => { - it("accepts valid question array", () => { - const result = importQuestionsSchema.safeParse({ - questions: [ - { kind: "SHORT_ANSWER", prompt_text: "What is X?" }, - { kind: "MCQ", prompt_text: "Choose A/B/C", answer_key: "A" }, - ], - }); - expect(result.success).toBe(true); - }); - - it("rejects empty questions array", () => { - const result = importQuestionsSchema.safeParse({ - questions: [], - }); - expect(result.success).toBe(false); - }); - - it("rejects invalid kind", () => { - const result = importQuestionsSchema.safeParse({ - questions: [{ kind: "ESSAY", prompt_text: "Write about X" }], - }); - expect(result.success).toBe(false); - }); -}); - -describe("createEvidencePaperSchema", () => { - it("accepts valid paper", () => { - const result = createEvidencePaperSchema.safeParse({ - title: "Testing Effect", - document_id: "doc_123", - tags: ["retrieval_practice"], - }); - expect(result.success).toBe(true); - }); - - it("rejects missing document_id", () => { - const result = createEvidencePaperSchema.safeParse({ - title: "Paper", - }); - expect(result.success).toBe(false); - }); -}); - -describe("createEvidenceCardSchema", () => { - it("accepts valid card", () => { - const result = createEvidenceCardSchema.safeParse({ - claim: "Testing enhances learning", - recommendation: "Use retrieval practice", - strength: "STRONG", - }); - expect(result.success).toBe(true); - }); - - it("rejects invalid strength", () => { - const result = createEvidenceCardSchema.safeParse({ - claim: "X", - recommendation: "Y", - strength: "VERY_STRONG", - }); - expect(result.success).toBe(false); - }); -}); diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx index c740c07..8ffea60 100644 --- a/src/app/achievements/page.tsx +++ b/src/app/achievements/page.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import Link from "next/link"; -import { getOrCreateUserId } from "@/lib/client-utils"; +import { apiGet } from "@/lib/client-api"; import { ALL_BADGES, TOTAL_BADGES, CATEGORY_LABELS } from "@/lib/badge-data"; interface GameState { @@ -21,9 +21,7 @@ export default function AchievementsPage() { const [loading, setLoading] = useState(true); useEffect(() => { - const userId = getOrCreateUserId(); - fetch("/api/stats/game", { headers: { "X-User-Id": userId } }) - .then((r) => r.json()) + apiGet("/api/stats/game") .then((state) => setGameState(state)) .catch(() => {}) .finally(() => setLoading(false)); diff --git a/src/app/api/account/delete/route.ts b/src/app/api/account/delete/route.ts index 0c1d159..89e6859 100644 --- a/src/app/api/account/delete/route.ts +++ b/src/app/api/account/delete/route.ts @@ -50,7 +50,6 @@ export async function DELETE(request: NextRequest) { tx.objectiveAnchor.deleteMany({ where: { userId } }), tx.sessionErrorLog.deleteMany({ where: { userId } }), tx.chatMessage.deleteMany({ where: { userId } }), - tx.notificationPreference.deleteMany({ where: { userId } }), tx.verificationToken.deleteMany({ where: { userId } }), ]); diff --git a/src/app/api/account/export/route.ts b/src/app/api/account/export/route.ts index decca62..422d57f 100644 --- a/src/app/api/account/export/route.ts +++ b/src/app/api/account/export/route.ts @@ -24,7 +24,6 @@ export async function GET(request: NextRequest) { sessions, flashcardDecks, chatMessages, - notificationPreference, userGameState, aiCallLogs, contentDocuments, @@ -66,10 +65,6 @@ export async function GET(request: NextRequest) { orderBy: { createdAt: "asc" }, }), - prisma.notificationPreference.findUnique({ - where: { userId }, - }), - prisma.userGameState.findUnique({ where: { userId }, }), @@ -161,7 +156,6 @@ export async function GET(request: NextRequest) { planCalendarPublications, planItemExternalEvents, googleIntegration, - notificationPreference, }; logger.info("account.export.success", { userId }); diff --git a/src/app/api/auth/signup/route.ts b/src/app/api/auth/signup/route.ts index 81cacab..257f9f6 100644 --- a/src/app/api/auth/signup/route.ts +++ b/src/app/api/auth/signup/route.ts @@ -48,14 +48,9 @@ export async function POST(request: Request) { }, }); - await tx.notificationPreference.create({ - data: { userId: u.id }, - }); - await tx.userGameState.create({ data: { userId: u.id, - displayName: name, onboardingComplete: false, }, }); diff --git a/src/app/api/config/route.ts b/src/app/api/config/route.ts new file mode 100644 index 0000000..177e3b5 --- /dev/null +++ b/src/app/api/config/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; + +// Env-derived flags must be read at request time, not baked in at build. +export const dynamic = "force-dynamic"; + +/** + * GET /api/config — public, non-secret runtime flags for the UI. + * + * - verification_required: whether email verification gates sign-in + * (opt-in via REQUIRE_EMAIL_VERIFICATION, see src/lib/auth-options.ts). + * - ai_mock: whether the AI provider factory resolves to the mock provider + * (AI_PROVIDER unset or "mock", see src/lib/ai/provider-factory.ts). + */ +export async function GET() { + return NextResponse.json({ + verification_required: process.env.REQUIRE_EMAIL_VERIFICATION === "true", + ai_mock: (process.env.AI_PROVIDER || "mock") === "mock", + }); +} diff --git a/src/app/api/notifications/preferences/route.ts b/src/app/api/notifications/preferences/route.ts deleted file mode 100644 index 0bfaa97..0000000 --- a/src/app/api/notifications/preferences/route.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { NextResponse } from "next/server"; -import { prisma } from "@/lib/db"; -import { getUserId } from "@/lib/auth"; -import { z } from "zod/v4"; - -const prefsSchema = z.object({ - emailReminders: z.boolean().optional(), - pushReminders: z.boolean().optional(), - reminderTime: z - .string() - .regex(/^\d{2}:\d{2}$/, "Must be HH:MM format") - .refine((v) => { - const [h, m] = v.split(":").map(Number); - return h >= 0 && h <= 23 && m >= 0 && m <= 59; - }, "Invalid time") - .optional(), - streakReminders: z.boolean().optional(), - weeklyDigest: z.boolean().optional(), - emailAddress: z.string().email().nullable().optional(), -}); - -export async function GET(request: Request) { - const userId = await getUserId(request); - if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - - const prefs = await prisma.notificationPreference.findUnique({ - where: { userId }, - }); - - if (!prefs) { - // Return defaults - return NextResponse.json({ - emailReminders: true, - pushReminders: true, - reminderTime: "09:00", - streakReminders: true, - weeklyDigest: true, - emailAddress: null, - }); - } - - return NextResponse.json({ - emailReminders: prefs.emailReminders, - pushReminders: prefs.pushReminders, - reminderTime: prefs.reminderTime, - streakReminders: prefs.streakReminders, - weeklyDigest: prefs.weeklyDigest, - emailAddress: prefs.emailAddress, - }); -} - -export async function PUT(request: Request) { - const userId = await getUserId(request); - if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - - let body: z.infer; - try { - body = prefsSchema.parse(await request.json()); - } catch (err) { - if (err instanceof z.ZodError) { - return NextResponse.json({ error: "Validation error", details: err.issues }, { status: 400 }); - } - return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); - } - - const prefs = await prisma.notificationPreference.upsert({ - where: { userId }, - create: { - userId, - emailReminders: body.emailReminders ?? true, - pushReminders: body.pushReminders ?? true, - reminderTime: body.reminderTime ?? "09:00", - streakReminders: body.streakReminders ?? true, - weeklyDigest: body.weeklyDigest ?? true, - emailAddress: body.emailAddress ?? null, - }, - update: { - ...(body.emailReminders !== undefined && { emailReminders: body.emailReminders }), - ...(body.pushReminders !== undefined && { pushReminders: body.pushReminders }), - ...(body.reminderTime !== undefined && { reminderTime: body.reminderTime }), - ...(body.streakReminders !== undefined && { streakReminders: body.streakReminders }), - ...(body.weeklyDigest !== undefined && { weeklyDigest: body.weeklyDigest }), - ...(body.emailAddress !== undefined && { emailAddress: body.emailAddress }), - }, - }); - - return NextResponse.json({ - emailReminders: prefs.emailReminders, - pushReminders: prefs.pushReminders, - reminderTime: prefs.reminderTime, - streakReminders: prefs.streakReminders, - weeklyDigest: prefs.weeklyDigest, - emailAddress: prefs.emailAddress, - }); -} diff --git a/src/app/api/plans/followups/route.ts b/src/app/api/plans/followups/route.ts new file mode 100644 index 0000000..738f4a9 --- /dev/null +++ b/src/app/api/plans/followups/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getUserId } from "@/lib/auth"; +import { scheduleFollowups } from "@/services/followups"; +import { logger } from "@/lib/logger"; +import { generalLimiter, tooManyRequests } from "@/lib/rate-limit"; +import { z } from "zod/v4"; + +const bodySchema = z.object({ run_id: z.string().min(1) }); + +export async function POST(request: NextRequest) { + const userId = await getUserId(request); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const rl = generalLimiter.check(userId); + if (!rl.allowed) return tooManyRequests(rl.retryAfterMs); + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const parsed = bodySchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Validation failed", issues: parsed.error.issues }, + { status: 400 }, + ); + } + + try { + const result = await scheduleFollowups(userId, parsed.data.run_id); + + if ("error" in result) { + if (result.error === "not_found") { + return NextResponse.json({ error: "Run not found" }, { status: 404 }); + } + if (result.error === "forbidden") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + return NextResponse.json({ error: result.error }, { status: 409 }); + } + + return NextResponse.json(result.data); + } catch (err) { + logger.error("followups_schedule_failed", { error: String(err) }); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx index 074adfa..180c84d 100644 --- a/src/app/auth/signup/page.tsx +++ b/src/app/auth/signup/page.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; +import { signIn } from "next-auth/react"; import Link from "next/link"; import { containerStyle, @@ -54,6 +55,37 @@ export default function SignUpPage() { return; } + // When email verification is not required (the default), sign the new + // user in with the credentials they just chose and land them on the + // dashboard, which shows onboarding for fresh accounts. Only send them + // to "Check Your Email" when this server actually requires verification. + let verificationRequired = true; + try { + const configRes = await fetch("/api/config"); + if (configRes.ok) { + const config = await configRes.json(); + verificationRequired = config.verification_required === true; + } + } catch { + // Config unavailable — fall through to the verify-email page, which + // explains that verification may be optional. + } + + if (!verificationRequired) { + const result = await signIn("credentials", { + email, + password, + redirect: false, + }); + if (!result?.error) { + window.location.href = "/"; + return; + } + // Auto sign-in failed unexpectedly; let the user sign in manually. + router.push("/auth/signin"); + return; + } + // Redirect to verify-email page with email for resend router.push(`/auth/verify-email?email=${encodeURIComponent(email)}`); } catch { diff --git a/src/app/auth/verify-email/page.tsx b/src/app/auth/verify-email/page.tsx index 04d5313..490ffb3 100644 --- a/src/app/auth/verify-email/page.tsx +++ b/src/app/auth/verify-email/page.tsx @@ -62,10 +62,10 @@ function VerifyEmailContent() { The link expires in 24 hours. If you don't see it, check your spam folder.

- Running locally with the default console email provider? The verification link is - printed in the terminal where npm run dev is running. And unless - REQUIRE_EMAIL_VERIFICATION is set, verification is optional and you can just sign - in directly. + Seeing this page means this server has REQUIRE_EMAIL_VERIFICATION enabled + (verification is off by default). Running locally with the default console email + provider? The verification link is printed in the terminal where{" "} + npm run dev is running.

{error &&
{error}
} diff --git a/src/app/globals.css b/src/app/globals.css index f061b5f..c979716 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -380,12 +380,6 @@ input[type="number"] { font-size: 1rem; } - /* -- Leaderboard entries: more compact -- */ - .leaderboard-entry { - padding: 0.4rem 0.6rem; - font-size: 0.8rem; - } - /* -- Rating buttons in flashcards -- */ div[style*="display: \"flex\""][style*="gap: \"0.4rem\""] { flex-wrap: wrap; diff --git a/src/app/icon.svg b/src/app/icon.svg new file mode 100644 index 0000000..8bad40b --- /dev/null +++ b/src/app/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/learn/page.tsx b/src/app/learn/page.tsx index 11db04f..c12b03a 100644 --- a/src/app/learn/page.tsx +++ b/src/app/learn/page.tsx @@ -2,9 +2,17 @@ import { useState, useEffect } from "react"; import Link from "next/link"; -import { getOrCreateUserId, getActiveCourse, setActiveCourse } from "@/lib/client-utils"; +import { getActiveCourse, setActiveCourse } from "@/lib/client-utils"; import { apiGet } from "@/lib/client-api"; +/** Render a mastery key / objective slug (e.g. "photosynthesis_light_reactions") as a clean label. */ +function prettifyObjectiveKey(key: string): string { + return key + .replace(/_/g, " ") + .trim() + .replace(/\b\w/g, (c) => c.toUpperCase()); +} + interface CourseData { courseName: string; examNames: string[]; @@ -66,7 +74,7 @@ export default function LearnPage() { topic_scope: recs.next_session.topic_scope || activeCourse.courseName, planned_minutes: 30, objectives: recs.next_session.objectives.length > 0 - ? recs.next_session.objectives.map((key) => ({ id: key, title: key.replace(/_/g, " ") })) + ? recs.next_session.objectives.map((key) => ({ id: key, title: prettifyObjectiveKey(key) })) : undefined, target_outcome: { prompt_count: 10 }, break_protocol: { type: "25_5", cycles: 1 }, @@ -83,9 +91,7 @@ export default function LearnPage() { const setSelectedCourse = (v: string | null) => { setSelectedCourseRaw(v); if (v) setActiveCourse(v); }; useEffect(() => { - const userId = getOrCreateUserId(); - fetch("/api/learn", { headers: { "X-User-Id": userId } }) - .then((r) => r.json()) + apiGet("/api/learn") .then((d) => { setData(d); if (d.courses?.length > 0) { @@ -263,8 +269,9 @@ export default function LearnPage() { )} - {/* Due cards alert */} - {course.dueCardCount > 0 && ( + {/* Due cards alert — only meaningful when the course actually has decks + to review; without decks there is no due queue to send anyone to. */} + {course.deckCount > 0 && course.dueCardCount > 0 && (
! @@ -282,7 +289,7 @@ export default function LearnPage() {

Quick Actions

- {course.dueCardCount > 0 && ( + {course.deckCount > 0 && course.dueCardCount > 0 && (
Review Due Cards
diff --git a/src/app/page.tsx b/src/app/page.tsx index def8b29..312464f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -440,8 +440,7 @@ function OnboardingFlow({ onComplete: () => void; onSkip: () => void; }) { - // Focus the dialog once on mount. An inline ref callback would re-fire on - // every render and steal focus from the display-name input mid-typing. + // Focus the dialog once on mount. const dialogRef = useRef(null); useEffect(() => { dialogRef.current?.focus(); diff --git a/src/app/plan/page.tsx b/src/app/plan/page.tsx index c98976d..3e708c5 100644 --- a/src/app/plan/page.tsx +++ b/src/app/plan/page.tsx @@ -1,30 +1,109 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { MODE_LABELS } from "@/lib/client-utils"; -const DAY_LABELS = ["Day 0 (Today)", "Day 1", "Day 2", "Day 3", "Day 4", "Day 5", "Day 6"]; - interface PlanItem { id: string; day_index: number; start_time: string; end_time: string; + status?: string; session_id: string; session_url: string; mode: string; topic_scope: string; planned_minutes: number; + objectives?: { id: string; title: string }[]; } -interface PlanResult { +interface PlanDetail { plan_id: string; ics_download_url: string; feed_url: string; webcal_url: string; + course_name: string; + exam_name: string; + exam_date: string; // YYYY-MM-DD + timezone: string; + start_date: string; + end_date: string; items: PlanItem[]; } +interface PlanSummary { + plan_id: string; + course_name: string; + exam_name: string; + exam_date: string; + created_at: string; +} + +// ---- Date helpers (all computed in the plan's timezone) ---- + +function ymdInTz(date: Date, tz?: string): string { + const opts: Intl.DateTimeFormatOptions = { year: "numeric", month: "2-digit", day: "2-digit" }; + try { + return new Intl.DateTimeFormat("en-CA", { ...opts, timeZone: tz }).format(date); + } catch { + return new Intl.DateTimeFormat("en-CA", opts).format(date); + } +} + +function ymdToUtcMs(ymd: string): number { + const [y, m, d] = ymd.split("-").map(Number); + return Date.UTC(y, m - 1, d); +} + +/** Calendar days from today (in the plan tz) until a YYYY-MM-DD date. */ +function daysUntil(ymd: string, tz?: string): number { + return Math.round((ymdToUtcMs(ymd) - ymdToUtcMs(ymdInTz(new Date(), tz))) / 86400000); +} + +/** "Wednesday, Jul 16" for an ISO instant, rendered in the plan tz. */ +function formatDayLabel(iso: string, tz?: string): string { + const opts: Intl.DateTimeFormatOptions = { weekday: "long", month: "short", day: "numeric" }; + try { + return new Intl.DateTimeFormat("en-US", { ...opts, timeZone: tz }).format(new Date(iso)); + } catch { + return new Intl.DateTimeFormat("en-US", opts).format(new Date(iso)); + } +} + +/** "Wednesday, Jul 23" for a YYYY-MM-DD calendar date (no tz math needed). */ +function formatCalendarDate(ymd: string): string { + return new Intl.DateTimeFormat("en-US", { + weekday: "long", + month: "short", + day: "numeric", + timeZone: "UTC", + }).format(new Date(ymdToUtcMs(ymd))); +} + +function formatTime(iso: string, tz?: string): string { + try { + return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", timeZone: tz }); + } catch { + return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + } +} + +/** Short per-session focus line: the item's own objectives, first 2 + "+N more". */ +function focusLine(item: PlanItem): string { + const titles = (item.objectives ?? []).map((o) => o.title).filter(Boolean); + if (titles.length === 0) return item.topic_scope; + const shown = titles.slice(0, 2).join(", "); + return titles.length > 2 ? `${shown} +${titles.length - 2} more` : shown; +} + +function countdownText(plan: PlanDetail): string { + const n = daysUntil(plan.exam_date, plan.timezone); + if (n > 1) return `${n} days until ${plan.exam_name}`; + if (n === 1) return `1 day until ${plan.exam_name}`; + if (n === 0) return `${plan.exam_name} is today`; + return `${plan.exam_name} was ${-n} ${-n === 1 ? "day" : "days"} ago`; +} + export default function PlanPage() { const [courseName, setCourseName] = useState(""); const [examName, setExamName] = useState(""); @@ -35,7 +114,17 @@ export default function PlanPage() { const [objectivesText, setObjectivesText] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [result, setResult] = useState(null); + + // Availability prefilled from saved settings (falls back to localStorage, then defaults) + const [studyStart, setStudyStart] = useState("09:00"); + const [studyEnd, setStudyEnd] = useState("17:00"); + const [dailyCap, setDailyCap] = useState(180); + + // Persistent plan state — loaded from the API on every visit + const [plans, setPlans] = useState([]); + const [plan, setPlan] = useState(null); + const [initialLoading, setInitialLoading] = useState(true); + const [showForm, setShowForm] = useState(false); const [googleConnected, setGoogleConnected] = useState(false); const [publishing, setPublishing] = useState(false); @@ -58,6 +147,75 @@ export default function PlanPage() { checkGoogle(); }, []); + // Prefill availability from saved study hours (backend first, localStorage fallback) + useEffect(() => { + async function loadPrefs() { + try { + const res = await fetch("/api/settings"); + if (!res.ok) throw new Error("fallback"); + const settings = await res.json(); + if (settings.studyStart) setStudyStart(settings.studyStart); + if (settings.studyEnd) setStudyEnd(settings.studyEnd); + if (settings.dailyCap) setDailyCap(settings.dailyCap); + } catch { + try { + const raw = localStorage.getItem("study_bot_prefs"); + if (raw) { + const prefs = JSON.parse(raw); + if (prefs.studyStart) setStudyStart(prefs.studyStart); + if (prefs.studyEnd) setStudyEnd(prefs.studyEnd); + if (prefs.dailyCap) setDailyCap(prefs.dailyCap); + } + } catch { /* defaults */ } + } + } + loadPrefs(); + }, []); + + const fetchPlanDetail = useCallback(async (planId: string): Promise => { + const res = await fetch(`/api/plans/${planId}`, { cache: "no-store" }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Failed to load plan"); + return data as PlanDetail; + }, []); + + // Load the user's plans and show the most recent one (or the preferred one) + const refreshPlans = useCallback(async (preferPlanId?: string) => { + const res = await fetch("/api/plans", { cache: "no-store" }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Failed to load plans"); + const list: PlanSummary[] = data.plans ?? []; + setPlans(list); + const target = (preferPlanId && list.find((p) => p.plan_id === preferPlanId)) || list[0]; + if (target) { + const detail = await fetchPlanDetail(target.plan_id); + setPlan(detail); + setShowForm(false); + setReflowPreview(null); + } else { + setPlan(null); + setShowForm(true); + } + }, [fetchPlanDetail]); + + useEffect(() => { + refreshPlans() + .catch(() => setError("Failed to load your plans")) + .finally(() => setInitialLoading(false)); + }, [refreshPlans]); + + const switchPlan = async (planId: string) => { + setError(null); + try { + const detail = await fetchPlanDetail(planId); + setPlan(detail); + setPublishDone(false); + setReflowPreview(null); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Failed to load plan"); + } + }; + const handleFileUpload = async (e: React.ChangeEvent) => { const files = e.target.files; if (!files || files.length === 0) return; @@ -117,11 +275,11 @@ export default function PlanPage() { }; const handlePublish = async () => { - if (!result) return; + if (!plan) return; setPublishing(true); setError(null); try { - const res = await fetch(`/api/plans/${result.plan_id}/publish/google`, { + const res = await fetch(`/api/plans/${plan.plan_id}/publish/google`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), @@ -155,33 +313,35 @@ export default function PlanPage() { return; } - // Read saved preferences — try backend first, fall back to localStorage - let studyStart = "09:00"; - let studyEnd = "17:00"; - let dailyCap = 180; - try { - const settingsRes = await fetch("/api/settings", { - }); - if (settingsRes.ok) { - const settings = await settingsRes.json(); - if (settings.studyStart) studyStart = settings.studyStart; - if (settings.studyEnd) studyEnd = settings.studyEnd; - if (settings.dailyCap) dailyCap = settings.dailyCap; - } else { - throw new Error("fallback"); + // Replace (never stack) any existing plan for the same course + const sameCourse = plans.filter( + (p) => p.course_name.trim().toLowerCase() === courseName.trim().toLowerCase(), + ); + if (sameCourse.length > 0) { + const ok = window.confirm( + `You already have a plan for ${sameCourse[0].course_name}. Creating a new one will replace it. Continue?`, + ); + if (!ok) { + setLoading(false); + return; } - } catch { - // Fall back to localStorage - try { - const raw = localStorage.getItem("study_bot_prefs"); - if (raw) { - const prefs = JSON.parse(raw); - if (prefs.studyStart) studyStart = prefs.studyStart; - if (prefs.studyEnd) studyEnd = prefs.studyEnd; - if (prefs.dailyCap) dailyCap = prefs.dailyCap; + for (const old of sameCourse) { + try { + const delRes = await fetch(`/api/plans/${old.plan_id}`, { method: "DELETE" }); + if (!delRes.ok && delRes.status !== 404) { + const data = await delRes.json().catch(() => ({})); + setError(data.error || "Failed to replace the existing plan"); + setLoading(false); + return; + } + } catch { + setError("Network error"); + setLoading(false); + return; } - } catch { /* defaults */ } + } } + const availability = Array.from({ length: 7 }, () => ({ start: studyStart, end: studyEnd })); try { @@ -205,8 +365,15 @@ export default function PlanPage() { setError(data.error || "Failed to create plan"); return; } - setResult(data); setPublishDone(false); + // Reset the form for next time and switch to the persistent plan view + setCourseName(""); + setExamName(""); + setExamDate(""); + setUploadedFiles([]); + setObjectivesText(""); + setUseManualObjectives(false); + await refreshPlans(data.plan_id); } catch { setError("Network error"); } finally { @@ -215,12 +382,12 @@ export default function PlanPage() { }; const handleDeletePlan = async () => { - if (!result || deletingPlan) return; + if (!plan || deletingPlan) return; if (!window.confirm("Delete this study plan? This cannot be undone.")) return; setDeletingPlan(true); setError(null); try { - const res = await fetch(`/api/plans/${result.plan_id}`, { + const res = await fetch(`/api/plans/${plan.plan_id}`, { method: "DELETE", }); if (!res.ok) { @@ -228,8 +395,8 @@ export default function PlanPage() { setError(data.error || "Failed to delete plan"); return; } - setResult(null); setPublishDone(false); + await refreshPlans(); } catch { setError("Network error"); } finally { @@ -240,11 +407,11 @@ export default function PlanPage() { // ---- Reflow: reschedule missed sessions (the recovery path a slipping // exam week actually needs; ~900 lines of tested logic behind one button) const previewReflow = async () => { - if (!result || reflowLoading) return; + if (!plan || reflowLoading) return; setReflowLoading(true); setError(null); try { - const res = await fetch(`/api/plans/${result.plan_id}/reflow/preview`, { + const res = await fetch(`/api/plans/${plan.plan_id}/reflow/preview`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ reason: "missed_sessions" }), @@ -260,11 +427,11 @@ export default function PlanPage() { }; const applyReflow = async () => { - if (!result || reflowLoading) return; + if (!plan || reflowLoading) return; setReflowLoading(true); setError(null); try { - const res = await fetch(`/api/plans/${result.plan_id}/reflow/apply`, { + const res = await fetch(`/api/plans/${plan.plan_id}/reflow/apply`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ reason: "missed_sessions" }), @@ -272,11 +439,8 @@ export default function PlanPage() { const data = await res.json(); if (!res.ok) throw new Error(data.error || "Failed to apply reschedule"); // Refresh the plan so the new times render - const fresh = await fetch(`/api/plans/${result.plan_id}`); - const freshData = await fresh.json(); - if (fresh.ok && freshData.items) { - setResult((prev) => (prev ? { ...prev, items: freshData.items } : prev)); - } + const fresh = await fetchPlanDetail(plan.plan_id); + setPlan(fresh); setReflowPreview(null); } catch (e: unknown) { setError(e instanceof Error ? e.message : "Failed to apply reschedule"); @@ -285,18 +449,28 @@ export default function PlanPage() { } }; - const grouped = result - ? result.items.reduce>((acc, item) => { - (acc[item.day_index] = acc[item.day_index] || []).push(item); - return acc; - }, {}) - : {}; + // ---- Loading view ---- + if (initialLoading) { + return ( +
+

Your Plan

+
Loading your plan...
+
+ ); + } // ---- Form view ---- - if (!result) { + if (showForm || !plan) { return (
-

New Study Plan

+
+

New Study Plan

+ {plan && ( + + )} +
-
+
+
+ + +
{/* Content upload */}
@@ -409,11 +603,25 @@ export default function PlanPage() { ); } - // ---- Result view ---- + // ---- Plan view (persistent) ---- + const tz = plan.timezone; + const todayYmd = ymdInTz(new Date(), tz); + const grouped = plan.items.reduce>((acc, item) => { + (acc[item.day_index] = acc[item.day_index] || []).push(item); + return acc; + }, {}); + const dayIndexes = Object.keys(grouped).map(Number).sort((a, b) => a - b); + const otherPlans = plans.filter((p) => p.plan_id !== plan.plan_id); + return (
-
-

Your Plan

+
+
+

Your Plan

+
+ {plan.course_name} · {countdownText(plan)} +
+
{googleConnected && !publishDone && ( )} -
); })} + +
+

{formatCalendarDate(plan.exam_date)}

+
+ {plan.exam_name} — exam day +
+
+ + {otherPlans.length > 0 && ( +
+

Other plans

+ {otherPlans.map((p) => ( + + ))} +
+ )}
); } @@ -518,6 +752,16 @@ const headingStyle: React.CSSProperties = { fontFamily: "var(--font-display)", }; +const dayHeadingStyle: React.CSSProperties = { + color: "var(--color-text-muted)", + fontSize: "0.85rem", + fontWeight: 600, + textTransform: "uppercase", + letterSpacing: "0.08em", + margin: "0 0 0.5rem", + fontFamily: "var(--font-display)", +}; + const labelStyle: React.CSSProperties = { display: "flex", flexDirection: "column", @@ -624,3 +868,31 @@ const sessionCardStyle: React.CSSProperties = { textDecoration: "none", borderRadius: "var(--radius-sm)", }; + +const examRowStyle: React.CSSProperties = { + background: "var(--color-bg-card)", + padding: "0.75rem 1rem", + border: "1px solid var(--color-border-subtle)", + borderLeft: "3px solid var(--color-warning)", + borderRadius: "var(--radius-sm)", + fontWeight: 600, + color: "var(--color-text)", +}; + +const otherPlanRowStyle: React.CSSProperties = { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + gap: "0.5rem", + width: "100%", + textAlign: "left", + background: "var(--color-bg-card)", + color: "var(--color-text-secondary)", + padding: "0.6rem 1rem", + marginBottom: "0.4rem", + border: "1px solid var(--color-border-subtle)", + borderRadius: "var(--radius-sm)", + fontFamily: "inherit", + fontSize: "0.95rem", + cursor: "pointer", +}; diff --git a/src/app/s/[sessionId]/feedback-poll.ts b/src/app/s/[sessionId]/feedback-poll.ts new file mode 100644 index 0000000..9526c48 --- /dev/null +++ b/src/app/s/[sessionId]/feedback-poll.ts @@ -0,0 +1,67 @@ +/** + * Deferred-feedback polling client, shared by the runner screens. + * + * Plain .ts module (no JSX) so the poll contract is unit-testable: PENDING + * is the only non-terminal status — any other result (content, explicit + * no_sources, UNAVAILABLE) resolves immediately, and the poll budget yields + * null instead of spinning forever. + */ + +export interface FeedbackExcerpt { + chunk_id: string; + doc_title: string; + page_number: number | null; + snippet: string; + rank: number; +} + +export interface FeedbackResult { + status: string; + excerpts: FeedbackExcerpt[]; + /** Server-persisted terminal marker: nothing matching in the materials. */ + no_sources?: boolean; + // AI explanation (wrong/partial) + explanation?: string; + key_takeaway?: string; + // Concept connections (all scores) + concept_connection?: string; + // Mnemonic (wrong/partial) + mnemonic?: string; + // Mistake pattern advice + pattern_advice?: string; + // Reinforcement (correct) + reinforcement?: string; + deeper_insight?: string; + // Socratic follow-up (all scores) + socratic_followup?: string; + socratic_purpose?: string; +} + +/** Fetch deferred feedback for an attempt */ +async function fetchFeedback(attemptId: string): Promise { + const res = await fetch(`/api/attempts/${attemptId}/feedback`); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Request failed"); + return data; +} + +/** + * Poll feedback until generation completes. The server generates feedback + * eagerly at submit time; PENDING means another worker owns generation, so + * we poll instead of duplicating the AI calls. Returns null when cancelled + * or when the poll budget runs out while still PENDING. + */ +export async function pollFeedback( + attemptId: string, + isCancelled: () => boolean, + maxAttempts = 25, + intervalMs = 1000 +): Promise { + for (let i = 0; i < maxAttempts; i++) { + if (isCancelled()) return null; + const result = await fetchFeedback(attemptId); + if (result.status !== "PENDING") return result; + await new Promise((r) => setTimeout(r, intervalMs)); + } + return null; +} diff --git a/src/app/s/[sessionId]/screens/end-screen.tsx b/src/app/s/[sessionId]/screens/end-screen.tsx index 868044f..74da44c 100644 --- a/src/app/s/[sessionId]/screens/end-screen.tsx +++ b/src/app/s/[sessionId]/screens/end-screen.tsx @@ -1,5 +1,7 @@ "use client"; +import { useMemo, useState } from "react"; +import { computeFollowups } from "@/lib/spacing"; import type { RunData, RunMetrics, SessionData } from "../session-runner"; interface Props { @@ -8,6 +10,11 @@ interface Props { onNewRun: () => void; } +type ScheduleResult = + | { status: "scheduled" | "already_scheduled"; dates: string[] } + | { status: "no_plan" } + | { status: "error"; message: string }; + interface CalibrationPoint { prompt_index: number; confidence: number; @@ -51,6 +58,65 @@ export function EndScreen({ run, session, onNewRun }: Props) { (session.last_completed_run?.metrics as RunMetrics | undefined) ?? null; + // Follow-ups are ONE deterministic function of the run's results: the + // spaced-repetition ladder from accuracy, anchored at the run's end time. + // (The stored metrics carry two divergent server-computed lists — the fixed + // ladder at completion, later overwritten by mastery due dates — so a page + // refresh used to show different numbers. Recomputing here keeps the + // display stable across refreshes.) + const followupAnchor = useMemo(() => { + const last = session.last_completed_run; + if (last?.ended_at && (!run || run.run_id === last.run_id)) { + return new Date(last.ended_at); + } + // Fresh completion (no reload yet): the run ended moments ago. + return new Date(); + }, [run, session.last_completed_run]); + + const followups = useMemo( + () => + metrics && metrics.attempts_count > 0 + ? computeFollowups(metrics.accuracy, followupAnchor) + : [], + [metrics, followupAnchor], + ); + + const scheduleRunId = run?.run_id ?? session.last_completed_run?.run_id ?? null; + const [scheduling, setScheduling] = useState(false); + const [scheduleResult, setScheduleResult] = useState(null); + const scheduleDone = scheduleResult != null && scheduleResult.status !== "error"; + + async function handleScheduleFollowups() { + if (!scheduleRunId || scheduling || scheduleDone) return; + setScheduling(true); + try { + const res = await fetch("/api/plans/followups", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ run_id: scheduleRunId }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Request failed"); + if (data.status === "no_plan") { + setScheduleResult({ status: "no_plan" }); + } else { + setScheduleResult({ + status: data.status, + dates: (data.scheduled ?? []).map((s: { date: string }) => s.date), + }); + } + } catch (e: unknown) { + setScheduleResult({ + status: "error", + message: e instanceof Error ? e.message : "Failed to schedule follow-ups", + }); + } finally { + setScheduling(false); + } + } + if (!metrics || metrics.attempts_count === 0) { return (
@@ -59,7 +125,7 @@ export function EndScreen({ run, session, onNewRun }: Props) { Start a session to see your results here.

); @@ -183,7 +249,7 @@ export function EndScreen({ run, session, onNewRun }: Props) { {/* Recommendations */} - {metrics.recommended_followups && metrics.recommended_followups.length > 0 && ( + {followups.length > 0 && (

RECOMMENDED FOLLOW-UPS

    - {metrics.recommended_followups.map((f, i) => ( + {followups.map((f, i) => (
  • - {f.label} — {followupDisplayDate(f)} + {f.label} — {f.date}
  • ))}

Based on spaced repetition research: lower accuracy → shorter intervals.

+ {scheduleRunId && ( + + )} + {scheduleResult && ( +

+ {scheduleResult.status === "no_plan" + ? "No active plan — create a study plan to schedule follow-ups." + : scheduleResult.status === "error" + ? scheduleResult.message + : scheduleResult.dates.length > 0 + ? `Added to your plan: ${scheduleResult.dates.join(", ")}` + : "Nothing to schedule — the recommended dates fall after your exam."} +

+ )}
)}
); @@ -259,16 +361,6 @@ function StatCard({ label, value, color }: { label: string; value: string; color ); } -function followupDisplayDate(f: { days_from_now?: number; date: string }): string { - // Compute the date in the user's own timezone from the day offset so it - // matches the "in N days" label; fall back to the server-provided date. - if (f.days_from_now == null) return f.date; - const d = new Date(Date.now() + f.days_from_now * 86400000); - const month = String(d.getMonth() + 1).padStart(2, "0"); - const day = String(d.getDate()).padStart(2, "0"); - return `${d.getFullYear()}-${month}-${day}`; -} - function accuracyColor(accuracy: number): string { if (accuracy >= 0.85) return "var(--color-success)"; if (accuracy >= 0.7) return "var(--color-warning)"; @@ -283,6 +375,19 @@ const sectionTitle: React.CSSProperties = { fontFamily: "var(--font-display)", }; +const secondaryBtn: React.CSSProperties = { + width: "100%", + padding: "0.6rem 1.25rem", + fontSize: "0.95rem", + fontFamily: "var(--font-body)", + fontWeight: 600, + background: "transparent", + color: "var(--color-info)", + border: "1px solid var(--color-info)", + borderRadius: "var(--radius)", + cursor: "pointer", +}; + const primaryBtn: React.CSSProperties = { width: "100%", padding: "0.75rem 1.5rem", diff --git a/src/app/s/[sessionId]/screens/runner.tsx b/src/app/s/[sessionId]/screens/runner.tsx index 78bac03..750af2c 100644 --- a/src/app/s/[sessionId]/screens/runner.tsx +++ b/src/app/s/[sessionId]/screens/runner.tsx @@ -38,11 +38,18 @@ export function RunnerScreen({ run, session, onSubmit, onComplete }: Props) { const [submitting, setSubmitting] = useState(false); const [feedbackExcerpts, setFeedbackExcerpts] = useState([]); const [feedbackLoading, setFeedbackLoading] = useState(false); + // Terminal feedback outcome reached (READY content, empty, or failed) — + // stops the poll loop and swaps the spinner for an honest fallback. + const [feedbackSettled, setFeedbackSettled] = useState(false); const [lastScore, setLastScore] = useState(null); // Consolidated AI feedback content — avoids 8 separate setState calls const emptyFeedback: FeedbackResult = { status: "", excerpts: [] }; const [fb, setFb] = useState(emptyFeedback); const [confidence, setConfidence] = useState(3); + // Calibration integrity: confidence is only submitted when the student + // actually touched the widget — the untouched default (3) must never be + // recorded as a rating (mirrors the MCQ path's null-until-rated rule). + const [confidenceTouched, setConfidenceTouched] = useState(false); const [selfExplanation, setSelfExplanation] = useState(""); const [generatedExample, setGeneratedExample] = useState(""); const [socraticAnswer, setSocraticAnswer] = useState(""); @@ -100,33 +107,40 @@ export function RunnerScreen({ run, session, onSubmit, onComplete }: Props) { // Deferred feedback — generation starts server-side the moment the attempt // lands; poll until it's ready (PENDING means another worker owns it). + // Every outcome — content, explicitly-empty (no_sources), UNAVAILABLE, + // poll timeout, or fetch error — marks the feedback settled so the loop + // runs at most once per attempt and the spinner always resolves. + // (feedbackLoading must NOT be a dependency here: this effect sets it, + // which would cancel its own in-flight poll and strand the spinner.) useEffect(() => { - const shouldFetch = - run.last_attempt_id && - !feedbackLoading && - feedbackExcerpts.length === 0 && - !fb.explanation && - !fb.reinforcement && - uiPhase === "review"; + if (uiPhase !== "review" || !run.last_attempt_id || feedbackSettled) return; - if (!shouldFetch) return; - - let mounted = true; + let cancelled = false; setFeedbackLoading(true); - pollFeedback(run.last_attempt_id!, () => !mounted) + pollFeedback(run.last_attempt_id, () => cancelled) .then((result: FeedbackResult | null) => { - if (!mounted || !result) return; - if (result.status === "OK") { - if (result.excerpts.length > 0) setFeedbackExcerpts(result.excerpts); + if (cancelled) return; + setFeedbackSettled(true); + if (result) { + if (result.status === "OK" && result.excerpts.length > 0) { + setFeedbackExcerpts(result.excerpts); + } setFb(result); + } else { + // Poll budget exhausted while the server still reported PENDING. + setFb({ status: "TIMEOUT", excerpts: [] }); } }) .catch(() => { - // Feedback failure is non-fatal + // Feedback failure is non-fatal — settle into the honest fallback. + if (!cancelled) { + setFeedbackSettled(true); + setFb({ status: "UNAVAILABLE", excerpts: [] }); + } }) - .finally(() => { if (mounted) setFeedbackLoading(false); }); - return () => { mounted = false; }; - }, [run.last_attempt_id, uiPhase, feedbackLoading, feedbackExcerpts.length, fb.explanation, fb.reinforcement]); + .finally(() => { if (!cancelled) setFeedbackLoading(false); }); + return () => { cancelled = true; }; + }, [run.last_attempt_id, uiPhase, feedbackSettled]); // Answer standard reveal: once the student commits an answer (scoring // phase), fetch the model answer + key points so self-scoring happens @@ -156,10 +170,12 @@ export function RunnerScreen({ run, session, onSubmit, onComplete }: Props) { setErrorType("MISCONCEPTION"); setFeedbackExcerpts([]); setFeedbackLoading(false); + setFeedbackSettled(false); setLastScore(null); setMcqResult(null); setFb(emptyFeedback); setConfidence(3); + setConfidenceTouched(false); setSelfExplanation(""); setGeneratedExample(""); setSocraticAnswer(""); @@ -196,28 +212,43 @@ export function RunnerScreen({ run, session, onSubmit, onComplete }: Props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [promptKey, uiPhase]); - // Draft preservation: a break can interrupt at submit time (the server - // rejects the attempt and this screen unmounts) — keep the free-recall - // draft in sessionStorage so it survives the break and reloads. + // Draft preservation: a mid-session refresh (or a break interrupting at + // submit time) must not eat typed work. The in-flight draft — the answer + // plus any error-log elaboration typed before the score lands — lives in + // localStorage keyed by run + prompt, is restored on mount, and is cleared + // once the server accepts the scored attempt. The review panel is excluded + // both ways: by then the parent has already advanced promptKey to the NEXT + // prompt, and reading/writing there would leak this answer forward. const draftKey = `draft:${run.run_id}:${promptKey}`; useEffect(() => { - if (isReviewPhase) return; - try { - const saved = sessionStorage.getItem(draftKey); - if (saved) setAnswer((prev) => prev || saved); - } catch { /* storage unavailable */ } + if (isReviewPhase || uiPhase === "review") return; + const draft = readDraft(draftKey); + if (!draft) return; + if (draft.answer) setAnswer((prev) => prev || draft.answer!); + if (draft.correction_rule) setCorrectionRule((prev) => prev || draft.correction_rule!); + if (draft.variant_question) setVariantQuestion((prev) => prev || draft.variant_question!); + if (draft.error_type) setErrorType((prev) => (prev === "MISCONCEPTION" ? draft.error_type! : prev)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [draftKey, isReviewPhase]); useEffect(() => { - if (isReviewPhase) return; + if (isReviewPhase || uiPhase === "review") return; try { - if (answer) sessionStorage.setItem(draftKey, answer); - else sessionStorage.removeItem(draftKey); + if (answer || correctionRule || variantQuestion) { + const draft: PromptDraft = { + answer: answer || undefined, + correction_rule: correctionRule || undefined, + variant_question: variantQuestion || undefined, + error_type: errorType !== "MISCONCEPTION" ? errorType : undefined, + }; + localStorage.setItem(draftKey, JSON.stringify(draft)); + } else { + localStorage.removeItem(draftKey); + } } catch { /* storage unavailable */ } - }, [answer, draftKey, isReviewPhase]); + }, [answer, correctionRule, variantQuestion, errorType, draftKey, isReviewPhase, uiPhase]); const clearDraft = () => { - try { sessionStorage.removeItem(draftKey); } catch { /* noop */ } + try { localStorage.removeItem(draftKey); } catch { /* noop */ } }; // Keyboard-first loop (deliberate practice = maximum feedback cycles per @@ -268,7 +299,9 @@ export function RunnerScreen({ run, session, onSubmit, onComplete }: Props) { kind: "ANSWER", user_answer: answer, time_to_answer_seconds: elapsed, - confidence_rating: confidence, + // Only sent when the student actually rated it — the untouched + // default would corrupt end-screen calibration (MCQ path parity). + ...(confidenceTouched ? { confidence_rating: confidence } : {}), }); clearDraft(); } catch { @@ -357,7 +390,9 @@ export function RunnerScreen({ run, session, onSubmit, onComplete }: Props) { user_answer: answer, self_score: s, time_to_answer_seconds: elapsed, - confidence_rating: confidence, + // Calibration integrity: omitted entirely unless the student rated it + // (server stores null, and calibration/mastery skip null ratings). + ...(confidenceTouched ? { confidence_rating: confidence } : {}), }; if (isPretest) attempt.is_pretest = true; if (isRepair) attempt.is_repair = true; @@ -736,34 +771,43 @@ export function RunnerScreen({ run, session, onSubmit, onComplete }: Props) { }} >
- How confident are you? - - {confidenceLabels[confidence - 1]} + How confident are you? (optional) + + {confidenceTouched ? confidenceLabels[confidence - 1] : "Not rated"}
- {[1, 2, 3, 4, 5].map((level) => ( - - ))} + {[1, 2, 3, 4, 5].map((level) => { + const selected = confidenceTouched && confidence === level; + return ( + + ); + })}
)} @@ -1212,9 +1256,18 @@ export function RunnerScreen({ run, session, onSubmit, onComplete }: Props) { ); })} - {!feedbackLoading && feedbackExcerpts.length === 0 && !fb.explanation && ( -

- No relevant excerpts found in your materials. + {/* Honest terminal fallbacks — never a spinner once the poll + settles. no_sources / empty OK: the materials genuinely have + nothing matching. Anything else (UNAVAILABLE, timeout, + fetch error): generation didn't produce a result. */} + {feedbackSettled && !feedbackLoading && feedbackExcerpts.length === 0 && !fb.explanation && ( +

+ {fb.no_sources || fb.status === "OK" + ? "Nothing matching in your materials for this one." + : "Feedback couldn't be generated this time — your answer is saved."}

)} @@ -1378,6 +1431,26 @@ export function RunnerScreen({ run, session, onSubmit, onComplete }: Props) { // --- Helpers --- +/** In-flight prompt work persisted locally so a refresh can't eat it. */ +interface PromptDraft { + answer?: string; + correction_rule?: string; + variant_question?: string; + error_type?: string; +} + +/** Read a persisted prompt draft; malformed or missing entries yield null. */ +function readDraft(key: string): PromptDraft | null { + try { + const raw = localStorage.getItem(key); + if (!raw) return null; + const parsed: unknown = JSON.parse(raw); + return parsed && typeof parsed === "object" ? (parsed as PromptDraft) : null; + } catch { + return null; + } +} + /** Escape HTML entities to prevent XSS when using dangerouslySetInnerHTML */ function escapeHtml(str: string): string { return str diff --git a/src/app/s/[sessionId]/session-runner.tsx b/src/app/s/[sessionId]/session-runner.tsx index 5118644..50c7d74 100644 --- a/src/app/s/[sessionId]/session-runner.tsx +++ b/src/app/s/[sessionId]/session-runner.tsx @@ -96,33 +96,11 @@ export interface RunPolicies { allowEndBreakEarly: boolean; } -export interface FeedbackExcerpt { - chunk_id: string; - doc_title: string; - page_number: number | null; - snippet: string; - rank: number; -} - -export interface FeedbackResult { - status: string; - excerpts: FeedbackExcerpt[]; - // AI explanation (wrong/partial) - explanation?: string; - key_takeaway?: string; - // Concept connections (all scores) - concept_connection?: string; - // Mnemonic (wrong/partial) - mnemonic?: string; - // Mistake pattern advice - pattern_advice?: string; - // Reinforcement (correct) - reinforcement?: string; - deeper_insight?: string; - // Socratic follow-up (all scores) - socratic_followup?: string; - socratic_purpose?: string; -} +// Deferred-feedback polling (and its types) live in a plain .ts sibling so +// the terminal-state contract is unit-testable; re-exported here so screen +// components keep a single import surface. +export type { FeedbackExcerpt, FeedbackResult } from "./feedback-poll"; +export { pollFeedback } from "./feedback-poll"; export interface RunData { run_id: string; @@ -203,31 +181,6 @@ async function fetchPrompt(runId: string, index: number): Promise { return apiGet(`/api/runs/${runId}/prompt?index=${index}`); } -/** Fetch deferred feedback for an attempt */ -async function fetchFeedback(attemptId: string): Promise { - return apiGet(`/api/attempts/${attemptId}/feedback`); -} - -/** - * Poll feedback until generation completes. The server generates feedback - * eagerly at submit time; PENDING means another worker owns generation, so - * we poll instead of duplicating the AI calls. - */ -export async function pollFeedback( - attemptId: string, - isCancelled: () => boolean, - maxAttempts = 25, - intervalMs = 1000 -): Promise { - for (let i = 0; i < maxAttempts; i++) { - if (isCancelled()) return null; - const result = await fetchFeedback(attemptId); - if (result.status !== "PENDING") return result; - await new Promise((r) => setTimeout(r, intervalMs)); - } - return null; -} - /** Reveal the model answer / key points for the current prompt (post-commit). */ export async function fetchReveal(runId: string, index: number): Promise { return apiGet(`/api/runs/${runId}/reveal?index=${index}`); diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 086a82d..7e8975e 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -48,6 +48,34 @@ export default function SettingsPage() { const [googleStatus, setGoogleStatus] = useState<"loading" | "connected" | "disconnected">("loading"); const [googleConfigured, setGoogleConfigured] = useState(true); + // Ticks every second so the timezone preview shows a live clock. + const [now, setNow] = useState(() => new Date()); + useEffect(() => { + const id = setInterval(() => setNow(new Date()), 1000); + return () => clearInterval(id); + }, []); + + // Instant validation feedback: render the current time in the selected + // zone, or flag the value as unrecognized. Empty means UTC (the default). + const effectiveZone = timezone.trim() || "UTC"; + let zonePreview: string | null = null; + try { + zonePreview = new Intl.DateTimeFormat(undefined, { + timeZone: effectiveZone, + weekday: "short", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }).format(now); + } catch { + zonePreview = null; // Not a valid IANA timezone + } + + const handleDetectTimezone = () => { + const detected = Intl.DateTimeFormat().resolvedOptions().timeZone; + if (detected) setTimezone(detected); + }; + // Load settings from backend (with localStorage fallback) useEffect(() => { async function loadSettings() { @@ -175,20 +203,42 @@ export default function SettingsPage() {

Timezone

Used for streak day boundaries. Leave empty for UTC.

- setTimezone(e.target.value)} - placeholder="e.g. America/New_York" - list="timezone-options" - aria-label="Timezone" - style={textInputStyle} - /> +
+ setTimezone(e.target.value)} + placeholder="e.g. America/New_York" + list="timezone-options" + aria-label="Timezone" + style={{ ...textInputStyle, flex: 1 }} + /> + +
{TIMEZONE_OPTIONS.map((tz) => ( +

+ {zonePreview + ? `Current time in ${effectiveZone}: ${zonePreview}` + : `"${timezone.trim()}" is not a recognized timezone (expected an IANA name like America/New_York).`} +

@@ -337,6 +387,19 @@ const saveBtnStyle: React.CSSProperties = { borderRadius: "var(--radius-sm)", }; +const detectBtnStyle: React.CSSProperties = { + background: "var(--color-bg-input)", + color: "var(--color-text)", + border: "1px solid var(--color-border)", + padding: "0.5rem 0.9rem", + fontFamily: "inherit", + fontSize: "0.9rem", + fontWeight: 600, + cursor: "pointer", + borderRadius: "var(--radius-sm)", + whiteSpace: "nowrap", +}; + const connectBtnStyle: React.CSSProperties = { background: "var(--color-info)", color: "var(--color-bg-darkest)", diff --git a/src/lib/client-utils.ts b/src/lib/client-utils.ts index d50b7d2..ab85b1c 100644 --- a/src/lib/client-utils.ts +++ b/src/lib/client-utils.ts @@ -7,21 +7,6 @@ export { MODE_LABELS } from "./calendar"; const COURSE_KEY = "study_bot_active_course"; -/** - * @deprecated Use useSession() from next-auth/react instead. - * Kept for backward compatibility during migration. - */ -export function getOrCreateUserId(): string { - if (typeof window === "undefined") return "anonymous"; - const USER_ID_KEY = "study_bot_user_id"; - let uid = localStorage.getItem(USER_ID_KEY); - if (!uid) { - uid = "user_" + Math.random().toString(36).slice(2, 10); - localStorage.setItem(USER_ID_KEY, uid); - } - return uid; -} - /** Persist the active course across pages (e.g. "Biology 101" or "Bio||Midterm"). */ export function getActiveCourse(): string { if (typeof window === "undefined") return ""; diff --git a/src/lib/content-prompts.ts b/src/lib/content-prompts.ts index 4576f36..76c8a44 100644 --- a/src/lib/content-prompts.ts +++ b/src/lib/content-prompts.ts @@ -32,6 +32,16 @@ interface ContentPromptParams { gatewayCtx: GatewayContext | null; } +/** + * Difficulty is persisted into a Prisma Int column; the model may emit + * floats, NaN, strings, or nothing. Accept only finite numbers, round, + * and clamp to 1..5 — anything else defaults to 1. + */ +function sanitizeDifficulty(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value)) return 1; + return Math.max(1, Math.min(5, Math.round(value))); +} + interface GeneratedPrompt { objective_id: string; text: string; @@ -255,7 +265,7 @@ export async function generateContentAwarePrompts( id: `p_${i}`, objective_id: g.objective_id, text: g.text, - difficulty: Math.max(1, Math.min(5, g.difficulty || 1)), + difficulty: sanitizeDifficulty(g.difficulty), format: isMcq ? "MCQ" : "FREE_RECALL", ...(isMcq ? { choices: g.choices as string[], diff --git a/src/lib/email/templates.ts b/src/lib/email/templates.ts index 793872b..6d572e2 100644 --- a/src/lib/email/templates.ts +++ b/src/lib/email/templates.ts @@ -74,177 +74,7 @@ function signOff(text: string): string { return `

${text}

`; } -// ── 1. Study Reminder ─────────────────────────────────────────────────────── - -const STUDY_SUBJECTS = [ - "Your brain called. It wants a workout.", - "Quick reminder: knowledge doesn't download itself", - "Your flashcards are starting to feel neglected", - "This email will self-destruct if you don't study today", - "Your future self is begging present you to open this", -]; - -function pickRandom(arr: T[]): T { - return arr[Math.floor(Math.random() * arr.length)]; -} - -export function studyReminder( - name: string, - items: string[], - minutes: number, -): { subject: string; html: string } { - const subject = pickRandom(STUDY_SUBJECTS); - - const itemList = items - .map( - (item) => - `
  • ${esc(item)}
  • `, - ) - .join("\n"); - - const html = layout(` -${heading("Time to study!")} -${paragraph(`Hi ${esc(name)},`)} -${paragraph( - "Your study plan says you should be reviewing today. Your study plan is very wise. You should listen to it.", -)} -${paragraph("Here's what's on the agenda:")} -
      -${itemList} -
    -${paragraph( - `Estimated time: ${minutes} minutes. That's less time than you spent deciding what to watch last night.`, -)} -${ctaButton("Open Study Bot", APP_URL)} -${signOff("— Study Bot (The only bot that actually wants you to succeed)")} - `); - - return { subject, html }; -} - -// ── 2. Streak Warning ─────────────────────────────────────────────────────── - -const STREAK_SUBJECTS = [ - "STREAK EMERGENCY", - (streak: number) => `Your ${streak}-day streak is on life support`, -]; - -export function streakWarning( - name: string, - streak: number, -): { subject: string; html: string } { - const subjectPick = pickRandom(STREAK_SUBJECTS); - const subject = - typeof subjectPick === "function" ? subjectPick(streak) : subjectPick; - - const html = layout(` -${heading("STREAK ALERT")} -${paragraph(`Hi ${esc(name)},`)} -${paragraph( - `Your ${esc(String(streak))}-day study streak is in critical condition. The doctors say it needs just 5 minutes of your time to survive.`, -)} -${paragraph("Don't let it flatline. You've come so far.")} -${paragraph( - "Open the app. Do one flashcard review. Save a streak's life.", -)} - - -
    -

    🚨

    -

    STREAK STATUS: CRITICAL

    -
    -${ctaButton("Save My Streak", APP_URL)} -${signOff( - "— Study Bot (We're not being dramatic. OK maybe a little.)", -)} - `); - - return { subject, html }; -} - -// ── 3. Weekly Digest ──────────────────────────────────────────────────────── - -interface WeeklyStats { - sessions: number; - xp: number; - accuracy: number; - streak: number; -} - -function commentary(accuracy: number): string { - if (accuracy >= 85) - return "Honestly? We're running out of compliments. You're making the other students look bad."; - if (accuracy >= 70) - return "Solid week! Your neurons are throwing a party up there."; - if (accuracy >= 50) - return "Not your strongest week, but hey, showing up counts. Your brain appreciates the effort."; - return "OK so... this week happened. But here's the thing: next week is a blank canvas. Let's paint it with knowledge (and maybe a few tears, we don't judge)."; -} - -export function weeklyDigest( - name: string, - stats: WeeklyStats, -): { subject: string; html: string } { - const subject = - "Your week in numbers (spoiler: you're either crushing it or we need to talk)"; - - const statRow = (label: string, value: string) => - ` -${esc(label)} -${esc(value)} -`; - - const html = layout(` -${heading("Your Weekly Digest")} -${paragraph(`Hi ${esc(name)},`)} -${paragraph("Here's how your week went — by the numbers:")} - -${statRow("Study Sessions", String(stats.sessions))} -${statRow("XP Earned", `${stats.xp} XP`)} -${statRow("Accuracy", `${stats.accuracy}%`)} -${statRow("Current Streak", `${stats.streak} days`)} -
    -${paragraph(`Our take: ${commentary(stats.accuracy)}`)} -${ctaButton("Keep It Going", APP_URL)} -${signOff( - "— Study Bot (Your weekly accountability partner, delivered to your inbox)", -)} - `); - - return { subject, html }; -} - -// ── 4. Missed Session ─────────────────────────────────────────────────────── - -export function missedSession( - name: string, - sessionTitle: string, -): { subject: string; html: string } { - const subject = - "We noticed you missed a study session. Not judging. (OK maybe a little.)"; - - const html = layout(` -${heading("Missed Session")} -${paragraph(`Hi ${esc(name)},`)} -${paragraph( - `So, ${esc(sessionTitle)} was on your schedule today... and it didn't happen. We're not mad, just disappointed. (Kidding. Mostly.)`, -)} -${paragraph( - "Life happens. Dogs need walking, snacks need eating, existential crises need... existing. We get it.", -)} -${paragraph( - "But here's the good news: you can reschedule this session right now and pretend today went exactly as planned. We won't tell anyone.", -)} -${ctaButton("Reschedule Session", `${APP_URL}/plans`)} -${signOff( - "— Study Bot (Not judging. OK fine, judging a tiny bit. But lovingly.)", -)} - `); - - return { subject, html }; -} - -// ── 5. Email Verification ────────────────────────────────────────────────── +// ── 1. Email Verification ────────────────────────────────────────────────── export function emailVerification( name: string, @@ -271,7 +101,7 @@ ${signOff("— Study Bot (Yes, we verify emails. We're responsible like that.)") return { subject, html }; } -// ── 6. Password Reset ────────────────────────────────────────────────────── +// ── 2. Password Reset ────────────────────────────────────────────────────── export function passwordReset( name: string, diff --git a/src/lib/plan-generator.ts b/src/lib/plan-generator.ts index b35f493..4068a8e 100644 --- a/src/lib/plan-generator.ts +++ b/src/lib/plan-generator.ts @@ -56,12 +56,57 @@ function clampDuration(desired: number, cap: number, windowMinutes: number): num return Math.max(15, Math.min(desired, max)); } -function toObjectiveEntries(strs: string[]): { id: string; title: string }[] { - return strs.map((s, i) => ({ id: `obj_${i}`, title: s })); +/** + * Derive a stable objective id from its title. The same title always yields + * the same id, so an objective keeps one identity across every block it + * appears in (SM-2 mastery rows and feedback anchors are keyed by this id). + */ +export function slugifyObjectiveTitle(title: string): string { + const slug = title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") + .slice(0, 60) + .replace(/_+$/g, ""); + return slug || "objective"; +} + +/** + * Build a title -> id map for a whole plan's objectives. Ids are slugs of the + * titles; when two distinct titles collide on the same slug, later ones get + * a deterministic _2/_3/... suffix in first-seen order. + */ +export function buildObjectiveIdMap(titles: string[]): Map { + const idByTitle = new Map(); + const used = new Set(); + for (const title of titles) { + if (idByTitle.has(title)) continue; + const base = slugifyObjectiveTitle(title); + let id = base; + let n = 2; + while (used.has(id)) { + id = `${base}_${n}`; + n += 1; + } + idByTitle.set(title, id); + used.add(id); + } + return idByTitle; +} + +function toObjectiveEntries( + strs: string[], + idByTitle: Map, +): { id: string; title: string }[] { + return strs.map((s) => ({ + id: idByTitle.get(s) ?? slugifyObjectiveTitle(s), + title: s, + })); } export function generatePlan(input: PlanGeneratorInput): PlanBlock[] { const { objectives, dailyCap, availability } = input; + const objectiveIds = buildObjectiveIdMap(objectives); const packs = splitIntoPacks(objectives); const blocks: PlanBlock[] = []; @@ -203,7 +248,7 @@ export function generatePlan(input: PlanGeneratorInput): PlanBlock[] { dayIndex: item.dayIndex, mode: item.mode, topicScope: item.scope, - objectives: toObjectiveEntries(item.objs), + objectives: toObjectiveEntries(item.objs, objectiveIds), plannedMinutes: minutes, targetOutcome: { type: item.outcomeType, diff --git a/src/lib/research-plan-generator.ts b/src/lib/research-plan-generator.ts index c267290..1967019 100644 --- a/src/lib/research-plan-generator.ts +++ b/src/lib/research-plan-generator.ts @@ -7,7 +7,7 @@ * template when AI is unavailable or returns null blocks. */ import { SessionMode } from "./validation"; -import { generatePlan, PlanBlock } from "./plan-generator"; +import { generatePlan, PlanBlock, buildObjectiveIdMap, slugifyObjectiveTitle } from "./plan-generator"; import { buildResearchContext } from "@/services/research"; import { runTask, GatewayError } from "./ai/gateway"; import type { GatewayContext } from "./ai/gateway"; @@ -60,8 +60,14 @@ function availableMinutes(avail: { start: string; end: string }): number { return (eh * 60 + em) - (sh * 60 + sm); } -function toObjectiveEntries(strs: string[]): { id: string; title: string }[] { - return strs.map((s, i) => ({ id: `obj_${i}`, title: s })); +function toObjectiveEntries( + strs: string[], + idByTitle: Map, +): { id: string; title: string }[] { + return strs.map((s) => ({ + id: idByTitle.get(s) ?? slugifyObjectiveTitle(s), + title: s, + })); } /** @@ -75,6 +81,10 @@ function convertAiBlocks( ): PlanBlock[] { const dayRemaining: Record = {}; + // Stable ids derived from titles, disambiguated across the whole plan so an + // objective keeps the same id in every block it appears in. + const objectiveIds = buildObjectiveIdMap(allObjectives); + const results: PlanBlock[] = []; for (const b of aiBlocks) { @@ -103,7 +113,7 @@ function convertAiBlocks( dayIndex: b.dayIndex, mode: b.mode as SessionMode, topicScope: objs.join(", "), - objectives: toObjectiveEntries(objs), + objectives: toObjectiveEntries(objs, objectiveIds), plannedMinutes: minutes, targetOutcome: { type: b.outcomeType ?? undefined, diff --git a/src/lib/validation-content.ts b/src/lib/validation-content.ts index 4511edf..9673bce 100644 --- a/src/lib/validation-content.ts +++ b/src/lib/validation-content.ts @@ -32,46 +32,3 @@ export const listDocumentsSchema = z.object({ course_name: z.string().optional(), exam_name: z.string().optional(), }); - -// ---- Practice Bank ---- - -const QUESTION_KINDS = ["SHORT_ANSWER", "MCQ", "CODING"] as const; - -export const createPracticeSetSchema = z.object({ - course_name: z.string().min(1, "course_name is required"), - exam_name: z.string().optional(), - title: z.string().min(1, "title is required"), -}); - -export const practiceQuestionSchema = z.object({ - kind: z.enum(QUESTION_KINDS), - prompt_text: z.string().min(1, "prompt_text is required"), - answer_key: z.string().optional(), - solution_steps: z.string().optional(), - tags: z.record(z.string(), z.unknown()).optional(), -}); - -export const importQuestionsSchema = z.object({ - questions: z.array(practiceQuestionSchema).min(1, "At least one question required"), -}); - -// ---- Evidence (SSKB) ---- - -const EVIDENCE_STRENGTHS = ["WEAK", "MODERATE", "STRONG"] as const; - -export const createEvidencePaperSchema = z.object({ - title: z.string().min(1, "title is required"), - authors: z.string().optional(), - year: z.number().int().optional(), - venue: z.string().optional(), - document_id: z.string().min(1, "document_id is required"), - tags: z.array(z.string()).optional(), -}); - -export const createEvidenceCardSchema = z.object({ - claim: z.string().min(1, "claim is required"), - recommendation: z.string().min(1, "recommendation is required"), - boundary_conditions: z.string().optional(), - strength: z.enum(EVIDENCE_STRENGTHS), - tags: z.array(z.string()).optional(), -}); diff --git a/src/services/feedback.ts b/src/services/feedback.ts index c137ec9..95872d8 100644 --- a/src/services/feedback.ts +++ b/src/services/feedback.ts @@ -13,6 +13,13 @@ import type { Prisma } from "../../generated/prisma/client"; export interface FeedbackResponse { status: "OK" | "UNAVAILABLE" | "NOT_FOUND" | "PENDING"; excerpts: FeedbackExcerpt[]; + /** + * Explicit terminal no-sources marker: search found nothing in the user's + * materials for this attempt. Persisted READY (like any generated result) + * so polling clients resolve to an honest "nothing in your materials" + * state instead of treating the empty payload as still-loading. + */ + no_sources?: boolean; // AI explanation (PARTIAL/INCORRECT) explanation?: string; key_takeaway?: string; @@ -357,7 +364,10 @@ async function generateContent( if (results.length === 0) { logger.info("feedback.empty", { attempt_id: attemptId, fts_ms: ftsMs }); - return { status: "OK", excerpts: [] }; + // Terminal state: generateAndPersist stores this as READY, so refetches + // return it verbatim and the client stops polling. Without the explicit + // marker this payload is indistinguishable from "not generated yet". + return { status: "OK", excerpts: [], no_sources: true }; } // Build excerpts and store citations in a single batch transaction diff --git a/src/services/followups.ts b/src/services/followups.ts new file mode 100644 index 0000000..94abcfa --- /dev/null +++ b/src/services/followups.ts @@ -0,0 +1,307 @@ +import { prisma } from "@/lib/db"; +import { generateSessionId } from "@/lib/session-id"; +import { computeFollowups } from "@/lib/spacing"; +import { dayKey } from "@/lib/timezone"; +import { getBaseUrl } from "@/lib/config"; +import { logger } from "@/lib/logger"; + +/** + * Follow-up scheduling: turn a completed run's spaced-repetition + * recommendations (computeFollowups — the same deterministic ladder the end + * screen displays) into real StudyPlanItems on the user's active plan. + * + * Conventions mirror src/services/plan.ts: one Session row per plan item + * (unique planId+sessionId), RETRIEVAL mode, objectives copied from the + * source session, times anchored to the plan's availability windows. + * Idempotency: each created Session carries a `followup_of_run_id` marker in + * its resources JSON — a second call for the same run returns the existing + * items instead of double-inserting. + */ + +const FOLLOWUP_DURATION_MINUTES = 30; +const DEFAULT_START_TIME = "09:00"; +const DAY_MS = 24 * 60 * 60 * 1000; + +export interface ScheduledFollowup { + session_id: string; + session_url: string; + /** YYYY-MM-DD in the plan's timezone. */ + date: string; + days_from_now: number | null; + start_time: string; // ISO instant +} + +export interface SkippedFollowup { + /** YYYY-MM-DD in the plan's timezone. */ + date: string; + days_from_now: number; + reason: "past_exam"; +} + +export interface PlannedFollowupItem { + days_from_now: number; + /** YYYY-MM-DD in the plan's timezone. */ + date: string; + day_index: number; + start_time: Date; + end_time: Date; +} + +export interface FollowupScheduleInput { + /** Graded accuracy of the completed run (0-1). */ + accuracy: number; + /** When the run ended — the anchor every follow-up offset counts from. */ + endedAt: Date; + /** IANA timezone of the study plan. */ + timezone: string; + /** Exam date as YYYY-MM-DD; follow-ups after this day are skipped. */ + examDate: string; + /** Plan start date as YYYY-MM-DD (dayIndex anchor). */ + planStartDate: string; + /** Per-day availability windows from the plan config (indexed by dayIndex). */ + availability?: { start: string; end: string }[] | null; +} + +/** Difference in calendar days between two YYYY-MM-DD strings. */ +function daysBetween(from: string, to: string): number { + return Math.round((Date.parse(to) - Date.parse(from)) / DAY_MS); +} + +/** + * Convert a wall-clock time in an IANA timezone to a UTC instant. + * Two-pass correction handles DST transitions; an invalid timezone falls + * back to the server's local clock (same behavior as dayKey). + */ +function wallTimeToUtc(dateStr: string, timeStr: string, timeZone: string): Date { + const [y, mo, d] = dateStr.split("-").map(Number); + const [h, mi] = timeStr.split(":").map(Number); + const target = Date.UTC(y, mo - 1, d, h, mi, 0); + try { + const dtf = new Intl.DateTimeFormat("en-US", { + timeZone, + hour12: false, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); + let ts = target; + for (let i = 0; i < 2; i++) { + const parts: Record = {}; + for (const p of dtf.formatToParts(new Date(ts))) parts[p.type] = p.value; + const wall = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + parts.hour === "24" ? 0 : Number(parts.hour), + Number(parts.minute), + ); + const diff = wall - target; + if (diff === 0) break; + ts -= diff; + } + return new Date(ts); + } catch { + return new Date(y, mo - 1, d, h, mi, 0, 0); + } +} + +/** + * Pure scheduling core: map a run's accuracy + end time onto concrete plan + * dates. Deterministic — the same run always yields the same schedule. + */ +export function computeFollowupSchedule(input: FollowupScheduleInput): { + planned: PlannedFollowupItem[]; + skipped: SkippedFollowup[]; +} { + const recommendations = computeFollowups(input.accuracy, input.endedAt); + const planned: PlannedFollowupItem[] = []; + const skipped: SkippedFollowup[] = []; + + for (const rec of recommendations) { + // The recommended calendar day, expressed in the plan's timezone. + const date = dayKey( + new Date(input.endedAt.getTime() + rec.days_from_now * DAY_MS), + input.timezone, + ); + // YYYY-MM-DD strings compare lexicographically. + if (date > input.examDate) { + skipped.push({ date, days_from_now: rec.days_from_now, reason: "past_exam" }); + continue; + } + + const dayIndex = daysBetween(input.planStartDate, date); + // Days beyond the plan window reuse the same weekday's window. + const avail = + input.availability?.[dayIndex] ?? + input.availability?.[((dayIndex % 7) + 7) % 7]; + const startTime = wallTimeToUtc(date, avail?.start ?? DEFAULT_START_TIME, input.timezone); + + planned.push({ + days_from_now: rec.days_from_now, + date, + day_index: dayIndex, + start_time: startTime, + end_time: new Date(startTime.getTime() + FOLLOWUP_DURATION_MINUTES * 60000), + }); + } + + return { planned, skipped }; +} + +/** + * Insert the recommended follow-ups for a completed run into the user's + * active study plan. Idempotent per run: repeated calls return the already + * scheduled items ("already_scheduled") instead of inserting duplicates. + */ +export async function scheduleFollowups(userId: string, runId: string) { + const run = await prisma.sessionRun.findUnique({ + where: { runId }, + select: { + runId: true, + userId: true, + sessionId: true, + status: true, + endedAt: true, + metrics: true, + }, + }); + if (!run) return { error: "not_found" as const }; + if (run.userId !== userId) return { error: "forbidden" as const }; + if (run.status !== "COMPLETED") return { error: "run_not_completed" as const }; + + const session = await prisma.session.findUnique({ + where: { sessionId: run.sessionId }, + }); + if (!session) return { error: "not_found" as const }; + + // Active plan: the course's plan with the nearest upcoming exam (same + // convention as the exam-aware mastery lookup in run.ts). + const plan = await prisma.studyPlan.findFirst({ + where: { userId, courseName: session.courseName, examDate: { gte: new Date() } }, + orderBy: { examDate: "asc" }, + }); + if (!plan) return { data: { status: "no_plan" as const } }; + + const metrics = run.metrics as { accuracy?: number } | null; + const { planned, skipped } = computeFollowupSchedule({ + accuracy: metrics?.accuracy ?? 0, + endedAt: run.endedAt ?? new Date(), + timezone: plan.timezone, + examDate: plan.examDate.toISOString().split("T")[0], + planStartDate: plan.startDate.toISOString().split("T")[0], + availability: (plan.config as { availability?: { start: string; end: string }[] } | null) + ?.availability, + }); + + const base = getBaseUrl(); + const breakProtocol = (plan.config as { break_protocol_default?: string } | null) + ?.break_protocol_default ?? "50_10"; + + const result = await prisma.$transaction(async (tx) => { + // Idempotency guard: sessions created by a previous call carry the run + // marker in their resources JSON. + const existing = await tx.session.findMany({ + where: { + userId, + resources: { path: ["followup_of_run_id"], equals: run.runId }, + }, + select: { sessionId: true, resources: true }, + }); + + if (existing.length > 0) { + const daysBySession = new Map( + existing.map((s) => [ + s.sessionId, + (s.resources as { followup_days_from_now?: number } | null) + ?.followup_days_from_now ?? null, + ]), + ); + const items = await tx.studyPlanItem.findMany({ + where: { sessionId: { in: existing.map((s) => s.sessionId) } }, + orderBy: { startTime: "asc" }, + }); + return { + status: "already_scheduled" as const, + scheduled: items.map((item) => ({ + session_id: item.sessionId, + session_url: `${base}/s/${item.sessionId}`, + date: dayKey(item.startTime, plan.timezone), + days_from_now: daysBySession.get(item.sessionId) ?? null, + start_time: item.startTime.toISOString(), + })), + }; + } + + const toCreate = planned.map((p) => ({ ...p, sessionId: generateSessionId() })); + + if (toCreate.length > 0) { + await tx.session.createMany({ + data: toCreate.map((p) => ({ + sessionId: p.sessionId, + userId, + courseId: session.courseId, + courseName: session.courseName, + examId: session.examId, + examName: session.examName, + mode: "RETRIEVAL", + topicScope: `Follow-up: ${session.topicScope}`, + objectives: session.objectives ?? undefined, + targetOutcome: { + type: "accuracy", + prompt_count: 8, + target_accuracy: 0.8, + closed_book_required: true, + }, + breakProtocol: { type: breakProtocol, cycles: 1 }, + resources: { + followup_of_run_id: run.runId, + followup_days_from_now: p.days_from_now, + }, + plannedMinutes: FOLLOWUP_DURATION_MINUTES, + })), + }); + + await tx.studyPlanItem.createMany({ + data: toCreate.map((p) => ({ + planId: plan.planId, + sessionId: p.sessionId, + dayIndex: p.day_index, + startTime: p.start_time, + endTime: p.end_time, + })), + }); + } + + return { + status: "scheduled" as const, + scheduled: toCreate.map((p) => ({ + session_id: p.sessionId, + session_url: `${base}/s/${p.sessionId}`, + date: p.date, + days_from_now: p.days_from_now as number | null, + start_time: p.start_time.toISOString(), + })), + }; + }); + + logger.info("followups.scheduled", { + user_id: userId, + run_id: runId, + plan_id: plan.planId, + status: result.status, + scheduled_count: result.scheduled.length, + skipped_count: skipped.length, + }); + + return { + data: { + status: result.status, + plan_id: plan.planId, + timezone: plan.timezone, + scheduled: result.scheduled as ScheduledFollowup[], + skipped, + }, + }; +} diff --git a/src/services/plan.ts b/src/services/plan.ts index 4d4967c..11f297e 100644 --- a/src/services/plan.ts +++ b/src/services/plan.ts @@ -545,6 +545,9 @@ export async function getPlan(userId: string, planId: string) { mode: session?.mode ?? "", topic_scope: session?.topicScope ?? "", planned_minutes: session?.plannedMinutes ?? 0, + // Per-session block objectives (additive) so the UI can show a short + // focus line instead of the whole plan's topic scope + objectives: (session?.objectives ?? []) as { id: string; title: string }[], calendar: cal ?? null, gcal_link: cal ? buildGoogleCalendarLink({ diff --git a/src/services/run.ts b/src/services/run.ts index 9fdcbbf..77acca1 100644 --- a/src/services/run.ts +++ b/src/services/run.ts @@ -449,11 +449,17 @@ export async function startOrResumeRun(userId: string, sessionId: string) { const count = promptCount; // Fetch unresolved error logs — high-confidence errors first // (hypercorrection: Butterfield & Metcalfe 2001 — confident misses, - // once corrected, are remembered best), then newest. + // once corrected, are remembered best), then newest. Scoped to this + // session's course (matching the cross-session repair injection and + // the recommendation trigger) so a Chemistry repair session never + // surfaces Biology errors. const errorLogs = await prisma.sessionErrorLog.findMany({ where: { userId, resolvedAt: null, + run: { + session: { courseName: session.courseName }, + }, }, orderBy: [ { confidenceRating: { sort: "desc", nulls: "last" } }, @@ -1255,8 +1261,14 @@ async function handleImmediateScoring( ? { ...newMetrics, recommended_followups: computeFollowups(newMetrics.accuracy) } : newMetrics; - await tx.sessionRun.update({ - where: { id: run.id }, + // Guarded update: under READ COMMITTED a concurrent completeRun may + // have committed the ACTIVE -> COMPLETED transition after our fresh + // read above. The status filter makes this update claim the transition + // atomically — if another caller already completed the run, count is 0 + // and we bail (RunCompletedError) instead of resurrecting the run or + // double-running post-completion effects (double SM-2 advance). + const updated = await tx.sessionRun.updateMany({ + where: { id: run.id, status: { not: "COMPLETED" } }, data: { currentIndex: newIndex, promptCount: finalPromptCount, @@ -1267,6 +1279,7 @@ async function handleImmediateScoring( endedAt: isLastPromptFinal ? new Date() : undefined, }, }); + if (updated.count === 0) throw new RunCompletedError(); return { attemptId: attempt.id, newMetrics, newIndex, promptCount: finalPromptCount, isLastPrompt: isLastPromptFinal, updatedBreakState: updatedBreakStateFinal, variantInjected }; }); @@ -1983,8 +1996,16 @@ export async function getDeckPreview( const { courseName, mode, objectives, promptCount } = params; if (mode === "ERROR_REPAIR") { + // Course-scoped to mirror the real deck builder (and the cross-session + // repair injection): only this course's unresolved errors count. const unresolved = await prisma.sessionErrorLog.count({ - where: { userId, resolvedAt: null }, + where: { + userId, + resolvedAt: null, + run: { + session: { courseName }, + }, + }, }); const repairs = Math.min(unresolved, promptCount); return repairs > 0 diff --git a/src/ui/components/NavBar.tsx b/src/ui/components/NavBar.tsx index a107da5..a979daf 100644 --- a/src/ui/components/NavBar.tsx +++ b/src/ui/components/NavBar.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useRef, useCallback } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; +import { useSession } from "next-auth/react"; const NAV_LINKS = [ { label: "Dashboard", href: "/" }, @@ -18,10 +19,31 @@ const NAV_HEIGHT = 52; export function NavBar() { const pathname = usePathname(); + const { status } = useSession(); const [menuOpen, setMenuOpen] = useState(false); + // null = config not loaded yet — badge stays hidden until we know for sure. + const [aiMock, setAiMock] = useState(null); const navRef = useRef(null); const hamburgerRef = useRef(null); + // Fetch non-secret runtime flags once authenticated (badge is for + // signed-in pages only). + useEffect(() => { + if (status !== "authenticated" || aiMock !== null) return; + let cancelled = false; + fetch("/api/config") + .then((res) => (res.ok ? res.json() : null)) + .then((config) => { + if (!cancelled && config) setAiMock(config.ai_mock === true); + }) + .catch(() => { + // Non-critical — leave badge hidden. + }); + return () => { + cancelled = true; + }; + }, [status, aiMock]); + function isActive(href: string): boolean { if (href === "/") return pathname === "/"; return pathname === href || pathname.startsWith(href + "/"); @@ -99,6 +121,20 @@ export function NavBar() { flexShrink: 0, }; + const mockBadgeStyle: React.CSSProperties = { + fontSize: "0.65rem", + fontWeight: 600, + letterSpacing: "0.05em", + textTransform: "uppercase", + color: "var(--color-text-muted)", + background: "var(--color-bg-card)", + border: "1px solid var(--color-border-subtle)", + borderRadius: "999px", + padding: "0.15rem 0.5rem", + whiteSpace: "nowrap", + cursor: "help", + }; + const navListStyle: React.CSSProperties = { listStyle: "none", margin: 0, @@ -115,9 +151,19 @@ export function NavBar() { role="navigation" aria-label="Main navigation" > - - Study Bot - +
    + + Study Bot + + {status === "authenticated" && aiMock === true && ( + + Mock AI + + )} +