From 8b0a135e0a66e3474db9610d6c1bb58f3586e0f7 Mon Sep 17 00:00:00 2001 From: electron-rare <108685187+electron-rare@users.noreply.github.com> Date: Thu, 11 Jun 2026 01:43:03 +0200 Subject: [PATCH] feat: certificate celebration page --- src/lib/completion.test.ts | 80 ++++++++++++ src/lib/completion.ts | 51 ++++++++ src/pages/cours/[slug]/certificat.astro | 160 ++++++++++++++++++++++++ src/pages/cours/[slug]/index.astro | 13 ++ 4 files changed, 304 insertions(+) create mode 100644 src/lib/completion.test.ts create mode 100644 src/lib/completion.ts create mode 100644 src/pages/cours/[slug]/certificat.astro diff --git a/src/lib/completion.test.ts b/src/lib/completion.test.ts new file mode 100644 index 0000000..d3a4646 --- /dev/null +++ b/src/lib/completion.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest'; +import { courseComplete } from './completion'; +import type { ReadMark } from './grist'; +import type { Quiz, QuizAttempt } from './quiz'; + +// --- helpers ------------------------------------------------------------- + +const mod = (chapterCount: number) => + ({ chapters: Array.from({ length: chapterCount }, (_, i) => ({ title: `Ch ${i + 1}`, fileUrl: '' })) }); + +const read = (moduleN: number, chapitreN: number, id = 0): ReadMark => + ({ id, course_slug: 'kicad-makers', module_n: moduleN, chapitre_n: chapitreN, moodle_synced: true }); + +const allReads = (modules: { chapters: unknown[] }[]): ReadMark[] => + modules.flatMap((m, mi) => m.chapters.map((_, ci) => read(mi + 1, ci + 1, mi * 100 + ci))); + +const quiz = (quizN: number, gradepass = 60): Quiz => + ({ quizN, cmid: 50 + quizN, name: `Quiz ${quizN}`, gradepass, questions: [] }); + +const attempt = (quizN: number, score: number, total = 5, id = 0): QuizAttempt => + ({ id, quiz_n: quizN, score, total, reponses: '{}', date: 1700000000 + id, moodle_synced: true }); + +// --- courseComplete ------------------------------------------------------ + +describe('courseComplete', () => { + const modules = [mod(3), mod(2)]; // 5 chapters total + const quizzes = [quiz(1), quiz(2), quiz(3), quiz(4)]; + + it('is complete when every chapter is read and every quiz is passed', () => { + const attempts = [attempt(1, 5), attempt(2, 4), attempt(3, 3, 5, 3), attempt(4, 5)]; + const r = courseComplete(modules, allReads(modules), quizzes, attempts); + expect(r).toEqual({ complete: true, chaptersRead: 5, chaptersTotal: 5, quizzesPassed: 4, quizzesTotal: 4 }); + }); + + it('is incomplete when a chapter is missing, even with all quizzes passed', () => { + const reads = allReads(modules).filter((m) => !(m.module_n === 2 && m.chapitre_n === 2)); + const attempts = [attempt(1, 5), attempt(2, 5), attempt(3, 5), attempt(4, 5)]; + const r = courseComplete(modules, reads, quizzes, attempts); + expect(r.complete).toBe(false); + expect(r.chaptersRead).toBe(4); + expect(r.chaptersTotal).toBe(5); + expect(r.quizzesPassed).toBe(4); + }); + + it('is incomplete when a quiz is failed or never attempted', () => { + // Quiz 2 best attempt below gradepass (60 % of 5 → needs ≥3), quiz 4 never attempted. + const attempts = [attempt(1, 5), attempt(2, 2), attempt(2, 1, 5, 9), attempt(3, 4)]; + const r = courseComplete(modules, allReads(modules), quizzes, attempts); + expect(r.complete).toBe(false); + expect(r.quizzesPassed).toBe(2); + expect(r.quizzesTotal).toBe(4); + expect(r.chaptersRead).toBe(5); + }); + + it('treats an empty quiz list as trivially complete (0/0) when all chapters are read', () => { + const r = courseComplete(modules, allReads(modules), [], []); + expect(r).toEqual({ complete: true, chaptersRead: 5, chaptersTotal: 5, quizzesPassed: 0, quizzesTotal: 0 }); + }); + + it('never grants completion on an empty course (0 chapters)', () => { + const r = courseComplete([], [], [], []); + expect(r.complete).toBe(false); + expect(r.chaptersTotal).toBe(0); + }); + + it('counts only distinct read pairs that exist in the modules', () => { + // Duplicate read of 1-1 + a stale read pointing outside the course (module 9). + const reads = [read(1, 1, 1), read(1, 1, 2), read(9, 1, 3), read(1, 7, 4)]; + const r = courseComplete(modules, reads, quizzes, []); + expect(r.chaptersRead).toBe(1); + expect(r.complete).toBe(false); + }); + + it('uses the best attempt per quiz (a later fail does not undo a pass)', () => { + const attempts = [attempt(1, 5, 5, 1), attempt(1, 0, 5, 2)]; + const r = courseComplete(modules, allReads(modules), [quiz(1)], attempts); + expect(r.quizzesPassed).toBe(1); + expect(r.complete).toBe(true); + }); +}); diff --git a/src/lib/completion.ts b/src/lib/completion.ts new file mode 100644 index 0000000..90aba39 --- /dev/null +++ b/src/lib/completion.ts @@ -0,0 +1,51 @@ +import { isBookComplete, type ReadMark } from './grist'; +import { bestByQuiz, isPassed, type Quiz, type QuizAttempt } from './quiz'; + +export interface CourseCompletion { + complete: boolean; + chaptersRead: number; + chaptersTotal: number; + quizzesPassed: number; + quizzesTotal: number; +} + +/** Pure completion predicate of a course: every module fully read AND every + * quiz passed (best attempt). An empty quiz list is trivially complete + * (0/0); an empty course (0 chapters) never is. */ +export function courseComplete( + modules: { chapters: unknown[] }[], + reads: ReadMark[], + quizzes: Quiz[], + attempts: QuizAttempt[], +): CourseCompletion { + const chaptersTotal = modules.reduce((s, m) => s + m.chapters.length, 0); + + // Distinct (module, chapitre) pairs that actually exist in the modules — + // duplicates and stale marks (renumbered course) must not inflate the count. + const seen = new Set(); + let chaptersRead = 0; + for (const r of reads) { + const key = `${r.module_n}-${r.chapitre_n}`; + if (seen.has(key)) continue; + seen.add(key); + const m = modules[r.module_n - 1]; + if (m && r.chapitre_n >= 1 && r.chapitre_n <= m.chapters.length) chaptersRead++; + } + const chaptersComplete = + chaptersTotal > 0 && modules.every((m, i) => isBookComplete(reads, i + 1, m.chapters.length)); + + const best = bestByQuiz(attempts); + let quizzesPassed = 0; + for (const q of quizzes) { + const a = best.get(q.quizN); + if (a && isPassed(q, a.score, a.total)) quizzesPassed++; + } + + return { + complete: chaptersComplete && quizzesPassed === quizzes.length, + chaptersRead, + chaptersTotal, + quizzesPassed, + quizzesTotal: quizzes.length, + }; +} diff --git a/src/pages/cours/[slug]/certificat.astro b/src/pages/cours/[slug]/certificat.astro new file mode 100644 index 0000000..91433bb --- /dev/null +++ b/src/pages/cours/[slug]/certificat.astro @@ -0,0 +1,160 @@ +--- +import Base from '../../../layouts/Base.astro'; +import Nav from '../../../components/Nav.astro'; +import { bySlug } from '../../../config/courses'; +import { getCourseBooks, type BookModule } from '../../../lib/moodle'; +import { verifySession, SESSION_COOKIE } from '../../../lib/session'; +import { getReads, type ReadMark } from '../../../lib/grist'; +import { getQuizzes, getAttempts, bestByQuiz, isPassed, type Quiz, type QuizAttempt } from '../../../lib/quiz'; +import { courseComplete } from '../../../lib/completion'; + +const course = bySlug(Astro.params.slug ?? ''); +if (!course || !course.published) return Astro.rewrite('/404'); + +const user = verifySession(Astro.cookies.get(SESSION_COOKIE)?.value); +if (!user) return Astro.redirect('/connexion?next=' + encodeURIComponent(Astro.url.pathname)); + +let modules: BookModule[]; +let reads: ReadMark[]; +let quizzes: Quiz[]; +let attempts: QuizAttempt[]; +try { + [modules, reads, quizzes, attempts] = await Promise.all([ + getCourseBooks(course.id), + getReads(user.sub, course.slug), + getQuizzes(course.slug), + getAttempts(user.sub, course.slug), + ]); +} catch { + return new Response('Certificat momentanément indisponible — réessayez dans quelques minutes.', { status: 503, headers: { 'Content-Type': 'text/plain; charset=utf-8' } }); +} + +const completion = courseComplete(modules, reads, quizzes, attempts); + +// First unread chapter (incomplete view). +const readSet = new Set(reads.map((r) => `${r.module_n}-${r.chapitre_n}`)); +let firstMissing: { url: string; label: string } | null = null; +for (let mi = 0; mi < modules.length && !firstMissing; mi++) { + for (let ci = 0; ci < modules[mi].chapters.length; ci++) { + if (!readSet.has(`${mi + 1}-${ci + 1}`)) { + firstMissing = { + url: `/cours/${course.slug}/module-${mi + 1}/chapitre-${ci + 1}`, + label: modules[mi].chapters[ci].title, + }; + break; + } + } +} + +// First unpassed quiz (incomplete view). +const best = bestByQuiz(attempts); +const firstUnpassed = quizzes.find((q) => { + const a = best.get(q.quizN); + return !(a && isPassed(q, a.score, a.total)); +}) ?? null; + +const chPct = completion.chaptersTotal ? Math.round((completion.chaptersRead / completion.chaptersTotal) * 100) : 0; +const qzPct = completion.quizzesTotal ? Math.round((completion.quizzesPassed / completion.quizzesTotal) * 100) : 0; +--- + +