diff --git a/LearningPlatform/Dockerfile b/LearningPlatform/Dockerfile
index 0822153..dc06098 100644
--- a/LearningPlatform/Dockerfile
+++ b/LearningPlatform/Dockerfile
@@ -26,7 +26,27 @@ COPY prisma.config.ts ./
# This avoids running `prisma generate` before the full source (including schema)
# is copied into the image. We'll run `prisma generate` explicitly later when
# the schema file is present.
-RUN npm ci --no-audit --no-fund --ignore-scripts
+RUN set -eux; \
+ RETRIES=5; \
+ COUNT=0; \
+ until [ "$COUNT" -ge "$RETRIES" ]; do \
+ npm ci \
+ --no-audit \
+ --no-fund \
+ --ignore-scripts \
+ --fetch-retries=5 \
+ --fetch-retry-factor=2 \
+ --fetch-retry-mintimeout=10000 \
+ --fetch-retry-maxtimeout=120000 && break || true; \
+ COUNT=$((COUNT+1)); \
+ echo "npm ci failed, retrying ($COUNT/$RETRIES)"; \
+ npm cache verify || true; \
+ sleep 5; \
+ done; \
+ if [ "$COUNT" -ge "$RETRIES" ]; then \
+ echo "npm ci failed after $RETRIES attempts"; \
+ exit 1; \
+ fi
# Copy rest of source and run build
COPY . .
diff --git a/LearningPlatform/app/(student)/(shell)/courses/[slug]/lessons/[lessonId]/page.tsx b/LearningPlatform/app/(student)/(shell)/courses/[slug]/lessons/[lessonId]/page.tsx
index 775be82..a13b957 100644
--- a/LearningPlatform/app/(student)/(shell)/courses/[slug]/lessons/[lessonId]/page.tsx
+++ b/LearningPlatform/app/(student)/(shell)/courses/[slug]/lessons/[lessonId]/page.tsx
@@ -116,6 +116,15 @@ export default async function LessonPage({
prisma.lessonProgress.findUnique({
where: { userId_lessonId: { userId: session.user.id, lessonId } },
}),
+ // If the user starts learning this course again, restore it to active.
+ prisma.courseProgress.updateMany({
+ where: {
+ userId: session.user.id,
+ courseId: String(courseId),
+ archivedAt: { not: null },
+ },
+ data: { archivedAt: null },
+ }),
])
taskProgressRecords.forEach((tp) => taskProgressMap.set(tp.taskId, tp))
lessonProgress = lessonProgressRecord
diff --git a/LearningPlatform/app/(student)/(shell)/courses/[slug]/page.tsx b/LearningPlatform/app/(student)/(shell)/courses/[slug]/page.tsx
index d6f59aa..14d3e39 100644
--- a/LearningPlatform/app/(student)/(shell)/courses/[slug]/page.tsx
+++ b/LearningPlatform/app/(student)/(shell)/courses/[slug]/page.tsx
@@ -13,6 +13,7 @@ import { prisma } from '@/lib/prisma'
import { CourseHeroTitle } from '@/components/courses/course-hero-title'
import { studentGlassCard, studentGlassPill } from '@/lib/student-glass-styles'
import { cn } from '@/lib/utils'
+import { ArchiveCourseButton } from '@/components/profile/archive-actions'
/** Same visual as dashboard `StatPill` in `flashcard-section.tsx` (server-safe duplicate). */
function CourseFlashcardStatPill({ label, count }: { label: string; count: number }) {
@@ -147,10 +148,19 @@ export default async function CoursePage({ params }: { params: Promise<{ slug: s
const courseId = String(course.id)
const moduleIds = modulesWithLessons.map((m) => String(m.id))
- const mainDeck = await prisma.flashcardDeck.findFirst({
+ let mainDeck = await prisma.flashcardDeck.findFirst({
where: { courseId, parentDeckId: null },
select: { id: true, name: true, slug: true },
})
+ if (mainDeck && session?.user?.id) {
+ const archivedDeck = await prisma.userStandaloneFlashcardDeck.findUnique({
+ where: { userId_deckId: { userId: session.user.id, deckId: mainDeck.id } },
+ select: { archivedAt: true },
+ })
+ if (archivedDeck?.archivedAt) {
+ mainDeck = null
+ }
+ }
const subdecks = mainDeck
? await prisma.flashcardDeck.findMany({
@@ -468,6 +478,10 @@ export default async function CoursePage({ params }: { params: Promise<{ slug: s
)}
+
+
)
}
diff --git a/LearningPlatform/app/(student)/(shell)/dashboard/flashcards/page.tsx b/LearningPlatform/app/(student)/(shell)/dashboard/flashcards/page.tsx
index 146fd81..cf604db 100644
--- a/LearningPlatform/app/(student)/(shell)/dashboard/flashcards/page.tsx
+++ b/LearningPlatform/app/(student)/(shell)/dashboard/flashcards/page.tsx
@@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { studentGlassCard, studentGlassFooterNavButton, studentGlassPill } from '@/lib/student-glass-styles'
import type { FlashcardDashboardSummary } from '@/lib/flashcards-dashboard-summary'
+import { ArchiveDeckButton } from '@/components/profile/archive-actions'
type Stats = { total: number; newCards: number; due: number }
@@ -419,6 +420,9 @@ export default function StudentFlashcardDeckTreePage() {
)}
>
)}
+
)
diff --git a/LearningPlatform/app/(student)/(shell)/profile/page.tsx b/LearningPlatform/app/(student)/(shell)/profile/page.tsx
new file mode 100644
index 0000000..27bc073
--- /dev/null
+++ b/LearningPlatform/app/(student)/(shell)/profile/page.tsx
@@ -0,0 +1,213 @@
+import { auth } from '@/auth'
+import { getPayload } from 'payload'
+import config from '@payload-config'
+import { redirect } from 'next/navigation'
+import { prisma } from '@/lib/prisma'
+import { Card, CardContent } from '@/components/ui/card'
+import { studentGlassCard } from '@/lib/student-glass-styles'
+import { cn } from '@/lib/utils'
+import { CourseCarousel, type CourseProgressSnapshot } from '@/components/dashboard/course-carousel'
+import { FlashcardDeckCarousel } from '@/components/dashboard/flashcard-deck-carousel'
+import { UnarchiveCourseButton, UnarchiveDeckButton } from '@/components/profile/archive-actions'
+
+export const dynamic = 'force-dynamic'
+
+type DeckStats = { total: number; newCards: number; due: number }
+
+export default async function ProfilePage() {
+ const session = await auth()
+ const userId = session?.user?.id
+ if (!userId) redirect('/login')
+
+ const payload = await getPayload({ config })
+ const [archivedCourses, archivedDeckEnrollments] = await Promise.all([
+ prisma.courseProgress.findMany({
+ where: { userId, archivedAt: { not: null } },
+ orderBy: { archivedAt: 'desc' },
+ select: { courseId: true, completedLessons: true, totalLessons: true, progressPercentage: true },
+ }),
+ prisma.userStandaloneFlashcardDeck.findMany({
+ where: { userId, archivedAt: { not: null } },
+ orderBy: { archivedAt: 'desc' },
+ select: {
+ deck: {
+ select: {
+ id: true,
+ name: true,
+ slug: true,
+ description: true,
+ subjectId: true,
+ courseId: true,
+ parentDeckId: true,
+ tags: { select: { name: true } },
+ },
+ },
+ },
+ }),
+ ])
+
+ const archivedCourseIds = archivedCourses.map((c) => c.courseId)
+ const courseDocs = archivedCourseIds.length
+ ? (await payload.find({
+ collection: 'courses',
+ where: { id: { in: archivedCourseIds }, isPublished: { equals: true } },
+ limit: archivedCourseIds.length,
+ depth: 1,
+ })).docs
+ : []
+ const courseById = new Map(courseDocs.map((c) => [String(c.id), c]))
+
+ const archivedCourseItems = archivedCourseIds
+ .map((id) => {
+ const doc = courseById.get(id)
+ if (!doc) return null
+ return {
+ id,
+ title: doc.title,
+ slug: doc.slug,
+ description: doc.description,
+ coverImage: typeof doc.coverImage === 'object' ? (doc.coverImage as { filename: string; alt?: string }) : null,
+ level: doc.level,
+ subject: doc.subject as string | { name?: string } | null,
+ }
+ })
+ .filter((v): v is NonNullable => Boolean(v))
+
+ const archivedProgressByCourseId: Record = {}
+ for (const row of archivedCourses) {
+ archivedProgressByCourseId[row.courseId] = {
+ progressPercentage: row.progressPercentage,
+ completedLessons: row.completedLessons,
+ totalLessons: row.totalLessons,
+ hasStarted: row.totalLessons > 0 || row.completedLessons > 0,
+ }
+ }
+
+ const rootDecks = archivedDeckEnrollments
+ .map((row) => row.deck)
+ .filter((d) => d && !d.parentDeckId)
+ const rootDeckIds = rootDecks.map((d) => d.id)
+ const allDeckIds = new Set(rootDeckIds)
+ let layer = [...rootDeckIds]
+ for (let depth = 0; depth < 16 && layer.length > 0; depth++) {
+ const kids = await prisma.flashcardDeck.findMany({
+ where: { parentDeckId: { in: layer } },
+ select: { id: true },
+ })
+ layer = []
+ for (const k of kids) {
+ if (!allDeckIds.has(k.id)) {
+ allDeckIds.add(k.id)
+ layer.push(k.id)
+ }
+ }
+ }
+ const statsByRootId = new Map()
+ for (const id of rootDeckIds) statsByRootId.set(id, { total: 0, newCards: 0, due: 0 })
+ if (allDeckIds.size > 0) {
+ const deckRows = await prisma.flashcardDeck.findMany({
+ where: { id: { in: [...allDeckIds] } },
+ select: { id: true, parentDeckId: true },
+ })
+ const parentById = new Map(deckRows.map((d) => [d.id, d.parentDeckId]))
+ const rootByDeckId = new Map()
+ for (const d of deckRows) {
+ let cursor: string | null = d.id
+ while (cursor) {
+ const parent: string | null = parentById.get(cursor) ?? null
+ if (!parent) {
+ rootByDeckId.set(d.id, cursor)
+ break
+ }
+ cursor = parent
+ }
+ }
+ const flashcards = await prisma.flashcard.findMany({
+ where: { deckId: { in: [...allDeckIds] } },
+ select: {
+ deckId: true,
+ userProgress: { where: { userId }, select: { state: true, nextReviewAt: true } },
+ },
+ })
+ const now = new Date()
+ for (const row of flashcards) {
+ const rootId = rootByDeckId.get(row.deckId)
+ if (!rootId) continue
+ const stats = statsByRootId.get(rootId)
+ if (!stats) continue
+ stats.total += 1
+ const progress = row.userProgress[0]
+ const state = progress?.state ?? 'NEW'
+ if (state === 'NEW') stats.newCards += 1
+ else if (progress?.nextReviewAt && progress.nextReviewAt <= now) stats.due += 1
+ }
+ }
+
+ const archivedDeckRows = rootDecks.map((deck) => ({
+ id: deck.id,
+ name: deck.name,
+ subtitle:
+ deck.courseId != null
+ ? 'Course-linked deck'
+ : deck.tags?.length
+ ? deck.tags.map((t) => t.name).join(' · ')
+ : deck.description ?? '',
+ openHref:
+ deck.courseId != null
+ ? '/dashboard/flashcards'
+ : `/dashboard/flashcards?standaloneDeckSlug=${encodeURIComponent(deck.slug)}`,
+ stats: statsByRootId.get(deck.id) ?? { total: 0, newCards: 0, due: 0 },
+ }))
+
+ return (
+
+
+
+
Your profile
+
+ Manage archived courses and flashcard decks.
+
+
+
+
+
+ Archived courses
+
+ {archivedCourseItems.length > 0 ? (
+ [course.id, ]),
+ )}
+ />
+ ) : (
+
+ No archived courses yet.
+
+ )}
+
+
+
+
+ Archived flashcards
+
+ {archivedDeckRows.length > 0 ? (
+ [row.id, ]),
+ )}
+ />
+ ) : (
+
+ No archived flashcard decks yet.
+
+ )}
+
+
+
+ )
+}
diff --git a/LearningPlatform/app/actions/course-progress.ts b/LearningPlatform/app/actions/course-progress.ts
index 7dd2202..2e5ffd2 100644
--- a/LearningPlatform/app/actions/course-progress.ts
+++ b/LearningPlatform/app/actions/course-progress.ts
@@ -29,7 +29,7 @@ export async function getAllCourseProgress() {
}
return prisma.courseProgress.findMany({
- where: { userId: session.user.id },
+ where: { userId: session.user.id, archivedAt: null },
orderBy: { lastActivityAt: 'desc' },
})
}
diff --git a/LearningPlatform/app/api/flashcards/study/route.ts b/LearningPlatform/app/api/flashcards/study/route.ts
index 5b01631..3d69803 100644
--- a/LearningPlatform/app/api/flashcards/study/route.ts
+++ b/LearningPlatform/app/api/flashcards/study/route.ts
@@ -129,6 +129,7 @@ export async function GET(req: Request) {
whereFilter = whereFilter ? { AND: [whereFilter, mainDeckPart] } : mainDeckPart
}
+ let validatedMainDeckId: string | null = null
if (mainDeckSlugQ && !deckFilterSlug) {
// Ensure main deck exists and is not itself a subdeck, otherwise we might silently return unrelated data.
const mainDeck = await prisma.flashcardDeck.findUnique({
@@ -141,6 +142,7 @@ export async function GET(req: Request) {
{ status: 400 },
)
}
+ validatedMainDeckId = mainDeck.id
}
if (deckFilterSlug || mainDeckSlugQ) {
@@ -154,6 +156,55 @@ export async function GET(req: Request) {
{ status: 403 },
)
}
+
+ // Auto-unarchive when the user starts studying this deck scope again.
+ let rootDeck: { id: string; courseId: string | null } | null = null
+ if (validatedMainDeckId) {
+ rootDeck = await prisma.flashcardDeck.findUnique({
+ where: { id: validatedMainDeckId },
+ select: { id: true, courseId: true },
+ })
+ } else if (deckFilterSlug) {
+ const deck = await prisma.flashcardDeck.findUnique({
+ where: { slug: deckFilterSlug },
+ select: { id: true, parentDeckId: true, courseId: true },
+ })
+ if (deck) {
+ if (deck.parentDeckId) {
+ rootDeck = await prisma.flashcardDeck.findUnique({
+ where: { id: deck.parentDeckId },
+ select: { id: true, courseId: true },
+ })
+ } else {
+ rootDeck = { id: deck.id, courseId: deck.courseId }
+ }
+ }
+ }
+
+ if (rootDeck) {
+ if (typeof prisma.userStandaloneFlashcardDeck?.updateMany === 'function') {
+ await prisma.userStandaloneFlashcardDeck.updateMany({
+ where: {
+ userId: user.id,
+ deckId: rootDeck.id,
+ archivedAt: { not: null },
+ },
+ data: { archivedAt: null },
+ })
+ }
+ if (rootDeck.courseId) {
+ if (typeof prisma.courseProgress?.updateMany === 'function') {
+ await prisma.courseProgress.updateMany({
+ where: {
+ userId: user.id,
+ courseId: rootDeck.courseId,
+ archivedAt: { not: null },
+ },
+ data: { archivedAt: null },
+ })
+ }
+ }
+ }
}
const flashcards = await prisma.flashcard.findMany({
diff --git a/LearningPlatform/app/api/practice/session/route.ts b/LearningPlatform/app/api/practice/session/route.ts
index ec6e70d..0428237 100644
--- a/LearningPlatform/app/api/practice/session/route.ts
+++ b/LearningPlatform/app/api/practice/session/route.ts
@@ -6,6 +6,7 @@ import { getUserTagStats, getUserWeakTags } from '@/lib/analytics'
import { prisma } from '@/lib/prisma'
import { randomUUID } from 'crypto'
import { extractText } from '@/lib/lexical'
+import { courseIdFromPayloadTask } from '@/lib/payload-task-helpers'
/**
* GET /api/practice/session?limit=10
@@ -118,6 +119,15 @@ export async function GET(req: Request) {
limit: CANDIDATE_LIMIT,
depth: 1,
})
+ const archivedCourseRows = await prisma.courseProgress.findMany({
+ where: { userId: user.id, archivedAt: { not: null } },
+ select: { courseId: true },
+ })
+ const archivedCourseIds = new Set(archivedCourseRows.map((r) => r.courseId))
+ const candidateTasks = (allTasks as any[]).filter((task) => {
+ const taskCourseId = courseIdFromPayloadTask(task)
+ return !taskCourseId || !archivedCourseIds.has(taskCourseId)
+ })
// Normalised task shape
interface NTask { id: string; question: string; tags: string[] }
@@ -134,9 +144,9 @@ export async function GET(req: Request) {
return unsolved.length > 0 ? unsolved : pool
}
- const weakPool = preferUnsolved(shuffle((allTasks as any[]).map(toNTask).filter((t) => t.tags.some((tag) => weakTagSet.has(tag)))))
- const mediumPool = preferUnsolved(shuffle((allTasks as any[]).map(toNTask).filter((t) => t.tags.some((tag) => mediumTagSet.has(tag)))))
- const randomPool = preferUnsolved(shuffle((allTasks as any[]).map(toNTask)))
+ const weakPool = preferUnsolved(shuffle(candidateTasks.map(toNTask).filter((t) => t.tags.some((tag) => weakTagSet.has(tag)))))
+ const mediumPool = preferUnsolved(shuffle(candidateTasks.map(toNTask).filter((t) => t.tags.some((tag) => mediumTagSet.has(tag)))))
+ const randomPool = preferUnsolved(shuffle(candidateTasks.map(toNTask)))
// ── Step 4: compose session ───────────────────────────────────────────────
// Target distribution: 40% weak, 30% medium, 30% random
diff --git a/LearningPlatform/app/api/profile/archive/route.ts b/LearningPlatform/app/api/profile/archive/route.ts
new file mode 100644
index 0000000..5a2461d
--- /dev/null
+++ b/LearningPlatform/app/api/profile/archive/route.ts
@@ -0,0 +1,99 @@
+import { NextResponse } from 'next/server'
+import { z } from 'zod'
+import { prisma } from '@/lib/prisma'
+import { requireAuth } from '@/lib/auth-helpers'
+
+const archiveBodySchema = z.discriminatedUnion('type', [
+ z.object({
+ type: z.literal('course'),
+ courseSlug: z.string().min(1, 'courseSlug is required'),
+ archiveLinkedDeck: z.boolean().optional(),
+ }),
+ z.object({
+ type: z.literal('deck'),
+ deckId: z.string().min(1, 'deckId is required'),
+ archiveLinkedCourse: z.boolean().optional(),
+ }),
+])
+
+/** POST /api/profile/archive — archive a course or main deck for the current user. */
+export async function POST(req: Request) {
+ try {
+ const user = await requireAuth()
+ const payload = await req.json().catch(() => null)
+ const parsed = archiveBodySchema.safeParse(payload)
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: 'Validation failed', issues: parsed.error.flatten().fieldErrors },
+ { status: 400 },
+ )
+ }
+
+ const now = new Date()
+ if (parsed.data.type === 'course') {
+ const courseSlug = parsed.data.courseSlug.trim()
+ const courseRows = await prisma.$queryRaw>`
+ SELECT c.id::text AS id
+ FROM payload.courses c
+ WHERE c.slug = ${courseSlug}
+ AND c.is_published = TRUE
+ LIMIT 1
+ `
+ if (courseRows.length === 0) {
+ return NextResponse.json({ error: 'Course not found' }, { status: 404 })
+ }
+ const courseId = courseRows[0].id
+
+ await prisma.courseProgress.upsert({
+ where: { userId_courseId: { userId: user.id, courseId } },
+ create: { userId: user.id, courseId, archivedAt: now },
+ update: { archivedAt: now, lastActivityAt: now },
+ })
+
+ if (parsed.data.archiveLinkedDeck) {
+ const deck = await prisma.flashcardDeck.findFirst({
+ where: { courseId, parentDeckId: null },
+ select: { id: true },
+ })
+ if (deck) {
+ await prisma.userStandaloneFlashcardDeck.upsert({
+ where: { userId_deckId: { userId: user.id, deckId: deck.id } },
+ create: { userId: user.id, deckId: deck.id, archivedAt: now },
+ update: { archivedAt: now },
+ })
+ }
+ }
+ return NextResponse.json({ ok: true })
+ }
+
+ const deck = await prisma.flashcardDeck.findUnique({
+ where: { id: parsed.data.deckId },
+ select: { id: true, courseId: true, parentDeckId: true },
+ })
+ if (!deck || deck.parentDeckId) {
+ return NextResponse.json({ error: 'Main deck not found' }, { status: 404 })
+ }
+
+ await prisma.userStandaloneFlashcardDeck.upsert({
+ where: { userId_deckId: { userId: user.id, deckId: deck.id } },
+ create: { userId: user.id, deckId: deck.id, archivedAt: now },
+ update: { archivedAt: now },
+ })
+
+ if (parsed.data.archiveLinkedCourse && deck.courseId) {
+ await prisma.courseProgress.upsert({
+ where: { userId_courseId: { userId: user.id, courseId: deck.courseId } },
+ create: { userId: user.id, courseId: deck.courseId, archivedAt: now },
+ update: { archivedAt: now, lastActivityAt: now },
+ })
+ }
+
+ return NextResponse.json({ ok: true })
+ } catch (error) {
+ if (error instanceof Error && (error.message === 'Unauthorized' || error.message === 'Forbidden')) {
+ return NextResponse.json({ error: error.message }, { status: error.message === 'Unauthorized' ? 401 : 403 })
+ }
+ console.error('[POST /api/profile/archive]', error)
+ return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
+ }
+}
diff --git a/LearningPlatform/app/api/profile/unarchive/route.ts b/LearningPlatform/app/api/profile/unarchive/route.ts
new file mode 100644
index 0000000..bf27d8a
--- /dev/null
+++ b/LearningPlatform/app/api/profile/unarchive/route.ts
@@ -0,0 +1,50 @@
+import { NextResponse } from 'next/server'
+import { z } from 'zod'
+import { prisma } from '@/lib/prisma'
+import { requireAuth } from '@/lib/auth-helpers'
+
+const unarchiveBodySchema = z.discriminatedUnion('type', [
+ z.object({
+ type: z.literal('course'),
+ courseId: z.string().min(1, 'courseId is required'),
+ }),
+ z.object({
+ type: z.literal('deck'),
+ deckId: z.string().min(1, 'deckId is required'),
+ }),
+])
+
+/** POST /api/profile/unarchive — unarchive a course or deck for the current user. */
+export async function POST(req: Request) {
+ try {
+ const user = await requireAuth()
+ const payload = await req.json().catch(() => null)
+ const parsed = unarchiveBodySchema.safeParse(payload)
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: 'Validation failed', issues: parsed.error.flatten().fieldErrors },
+ { status: 400 },
+ )
+ }
+
+ if (parsed.data.type === 'course') {
+ await prisma.courseProgress.updateMany({
+ where: { userId: user.id, courseId: parsed.data.courseId },
+ data: { archivedAt: null },
+ })
+ return NextResponse.json({ ok: true })
+ }
+
+ await prisma.userStandaloneFlashcardDeck.updateMany({
+ where: { userId: user.id, deckId: parsed.data.deckId },
+ data: { archivedAt: null },
+ })
+ return NextResponse.json({ ok: true })
+ } catch (error) {
+ if (error instanceof Error && (error.message === 'Unauthorized' || error.message === 'Forbidden')) {
+ return NextResponse.json({ error: error.message }, { status: error.message === 'Unauthorized' ? 401 : 403 })
+ }
+ console.error('[POST /api/profile/unarchive]', error)
+ return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
+ }
+}
diff --git a/LearningPlatform/app/api/recommend/tasks/route.ts b/LearningPlatform/app/api/recommend/tasks/route.ts
index 54cafa1..db3e549 100644
--- a/LearningPlatform/app/api/recommend/tasks/route.ts
+++ b/LearningPlatform/app/api/recommend/tasks/route.ts
@@ -5,6 +5,7 @@ import { requireAuth } from '@/lib/auth-helpers'
import { getUserWeakTags } from '@/lib/analytics'
import { prisma } from '@/lib/prisma'
import { extractText } from '@/lib/lexical'
+import { courseIdFromPayloadTask } from '@/lib/payload-task-helpers'
/**
* GET /api/recommend/tasks?limit=5&mode=weak|review|mixed
@@ -260,15 +261,24 @@ export async function GET(req: Request) {
limit: CANDIDATE_LIMIT,
depth: 1,
})
+ const archivedCourseRows = await prisma.courseProgress.findMany({
+ where: { userId: user.id, archivedAt: { not: null } },
+ select: { courseId: true },
+ })
+ const archivedCourseIds = new Set(archivedCourseRows.map((r) => r.courseId))
+ const candidateTasks = (allTasks as any[]).filter((task) => {
+ const taskCourseId = courseIdFromPayloadTask(task)
+ return !taskCourseId || !archivedCourseIds.has(taskCourseId)
+ })
let result: { tasks: ScoredTask[]; explanation: string }
if (mode === 'review') {
- result = await reviewMode(user.id, allTasks as any[], limit)
+ result = await reviewMode(user.id, candidateTasks, limit)
} else if (mode === 'mixed') {
- result = await mixedMode(user.id, allTasks as any[], limit)
+ result = await mixedMode(user.id, candidateTasks, limit)
} else {
- result = await weakMode(user.id, allTasks as any[], limit)
+ result = await weakMode(user.id, candidateTasks, limit)
}
return NextResponse.json({ ...result, mode })
diff --git a/LearningPlatform/components/admin/assign-to-lesson-dialog.tsx b/LearningPlatform/components/admin/assign-to-lesson-dialog.tsx
index 5647f7f..16e044f 100644
--- a/LearningPlatform/components/admin/assign-to-lesson-dialog.tsx
+++ b/LearningPlatform/components/admin/assign-to-lesson-dialog.tsx
@@ -70,7 +70,11 @@ export function AssignToLessonDialog({
const now = Date.now()
// During tests, skip the module cache so tests that stub `fetch` observe
// the mocked responses instead of stale cached data from other tests.
- const cacheValid = _cache && now - _cache.ts < CACHE_TTL_MS && process.env.NODE_ENV !== 'test'
+ const cacheValid =
+ _cache &&
+ now - _cache.ts < CACHE_TTL_MS &&
+ process.env.NODE_ENV !== 'test' &&
+ process.env.VITEST !== 'true'
function applyExpansion(courseList: CourseItem[]) {
if (currentLessonIds.length > 0) {
diff --git a/LearningPlatform/components/dashboard/course-carousel.tsx b/LearningPlatform/components/dashboard/course-carousel.tsx
index 28d7c1f..ea7ba14 100644
--- a/LearningPlatform/components/dashboard/course-carousel.tsx
+++ b/LearningPlatform/components/dashboard/course-carousel.tsx
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
import { DashboardHorizontalScroll } from '@/components/dashboard/dashboard-horizontal-scroll'
import { studentGlassCard, studentGlassPill } from '@/lib/student-glass-styles'
import { cn } from '@/lib/utils'
+import type { ReactNode } from 'react'
export type CourseCarouselItem = {
id: string | number
@@ -35,9 +36,13 @@ const defaultProgress: CourseProgressSnapshot = {
function CourseCarouselCard({
course,
progress,
+ compact = false,
+ footerAction,
}: {
course: CourseCarouselItem
progress: CourseProgressSnapshot
+ compact?: boolean
+ footerAction?: ReactNode
}) {
const { progressPercentage, completedLessons, totalLessons, hasStarted } = progress
@@ -50,6 +55,7 @@ function CourseCarouselCard({
@@ -116,6 +122,7 @@ function CourseCarouselCard({
{hasStarted ? 'Continue learning' : 'Start course'}
+ {footerAction ? {footerAction}
: null}
)
@@ -125,11 +132,15 @@ export function CourseCarousel({
courses,
progressByCourseId,
scrollAriaLabel = 'Your courses',
+ compact = false,
+ footerActionByCourseId,
}: {
courses: CourseCarouselItem[]
progressByCourseId: Record
/** Passed to the horizontal scroller for accessibility. */
scrollAriaLabel?: string
+ compact?: boolean
+ footerActionByCourseId?: Record
}) {
if (courses.length === 0) return null
@@ -142,7 +153,12 @@ export function CourseCarousel({
return (
)
@@ -159,6 +175,8 @@ export function CourseCarousel({
key={course.id}
course={course}
progress={progressFor(course)}
+ compact={compact}
+ footerAction={footerActionByCourseId?.[String(course.id)]}
/>
))}
diff --git a/LearningPlatform/components/dashboard/flashcard-deck-carousel.tsx b/LearningPlatform/components/dashboard/flashcard-deck-carousel.tsx
new file mode 100644
index 0000000..d4ac6e3
--- /dev/null
+++ b/LearningPlatform/components/dashboard/flashcard-deck-carousel.tsx
@@ -0,0 +1,125 @@
+'use client'
+
+import Link from 'next/link'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent } from '@/components/ui/card'
+import { DashboardHorizontalScroll } from '@/components/dashboard/dashboard-horizontal-scroll'
+import { Brain } from 'lucide-react'
+import type { ReactNode } from 'react'
+import { studentGlassCard } from '@/lib/student-glass-styles'
+import { cn } from '@/lib/utils'
+
+export type FlashcardDeckCarouselRow = {
+ id: string
+ name: string
+ subtitle: string
+ openHref: string
+ stats: { total: number; newCards: number; due: number }
+}
+
+function StatPill({ label, count }: { label: string; count: number }) {
+ return (
+
+ {count}
+
+ {label}
+
+
+ )
+}
+
+function DeckCard({
+ row,
+ compact,
+ action,
+}: {
+ row: FlashcardDeckCarouselRow
+ compact?: boolean
+ action?: ReactNode
+}) {
+ const canOpen = row.stats.total > 0
+ return (
+
+
+
+
{row.name}
+ {row.subtitle ?
{row.subtitle}
: null}
+
+
+
+
+
+
+
+ {canOpen ? (
+
+
+
+ ) : (
+
+ )}
+ {action ?
{action}
: null}
+
+
+
+ )
+}
+
+export function FlashcardDeckCarousel({
+ rows,
+ compact = false,
+ actionByDeckId,
+}: {
+ rows: FlashcardDeckCarouselRow[]
+ compact?: boolean
+ actionByDeckId?: Record
+}) {
+ if (rows.length === 0) return null
+ if (rows.length === 1) {
+ const row = rows[0]
+ return (
+
+ )
+ }
+ return (
+
+ {rows.map((row) => (
+
+ ))}
+
+ )
+}
diff --git a/LearningPlatform/components/navbar.tsx b/LearningPlatform/components/navbar.tsx
index 561447b..76b3fca 100644
--- a/LearningPlatform/components/navbar.tsx
+++ b/LearningPlatform/components/navbar.tsx
@@ -22,7 +22,7 @@ export async function Navbar() {
{session ? (
<>
{/* Order: user → theme → settings → (admin) → sign out */}
-
+
{displayName ?? 'Account'}
-
+