feat: grist progression + mark as read

This commit is contained in:
electron-rare
2026-06-10 23:09:43 +02:00
parent 5da0ee2140
commit 81c34cc3fc
5 changed files with 206 additions and 1 deletions
+51
View File
@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest';
import { mapRecords, isBookComplete, type ReadMark } from './grist';
describe('mapRecords', () => {
it('maps a realistic Grist records payload', () => {
const raw = {
records: [
{ id: 1, fields: { user_sub: 'kc-1', email: 'a@b.c', course_slug: 'kicad-makers', module_n: 1, chapitre_n: 2, lu_le: 1760000000, moodle_synced: false } },
{ id: 2, fields: { user_sub: 'kc-1', email: 'a@b.c', course_slug: 'kicad-makers', module_n: 2, chapitre_n: 3, lu_le: 1760000100, moodle_synced: true } },
],
};
expect(mapRecords(raw)).toEqual([
{ id: 1, course_slug: 'kicad-makers', module_n: 1, chapitre_n: 2, moodle_synced: false },
{ id: 2, course_slug: 'kicad-makers', module_n: 2, chapitre_n: 3, moodle_synced: true },
]);
});
it('coerces missing or odd-typed fields safely', () => {
const raw = { records: [{ id: 7, fields: { module_n: '3', chapitre_n: null, moodle_synced: 0 } }] };
expect(mapRecords(raw)).toEqual([
{ id: 7, course_slug: '', module_n: 3, chapitre_n: 0, moodle_synced: false },
]);
});
it('returns empty array for empty records', () => {
expect(mapRecords({ records: [] })).toEqual([]);
});
});
describe('isBookComplete', () => {
const mark = (m: number, c: number): ReadMark =>
({ id: 0, course_slug: 's', module_n: m, chapitre_n: c, moodle_synced: false });
it('true when every chapter of the module is read', () => {
expect(isBookComplete([mark(1, 1), mark(1, 2), mark(1, 3)], 1, 3)).toBe(true);
});
it('false when there is a hole', () => {
expect(isBookComplete([mark(1, 1), mark(1, 3)], 1, 3)).toBe(false);
});
it('false on empty reads', () => {
expect(isBookComplete([], 1, 3)).toBe(false);
});
it('only counts chapters of the requested module', () => {
const reads = [mark(2, 1), mark(2, 2)];
expect(isBookComplete(reads, 1, 2)).toBe(false);
expect(isBookComplete(reads, 2, 2)).toBe(true);
});
});
+68
View File
@@ -0,0 +1,68 @@
const BASE = () => process.env.GRIST_BASE_URL || 'https://grist.saillant.cc';
const KEY = () => process.env.GRIST_API_KEY || '';
const DOC = () => process.env.GRIST_DOC_ID || '';
export interface ReadMark {
course_slug: string;
module_n: number;
chapitre_n: number;
moodle_synced: boolean;
id: number;
}
async function grist(path: string, init?: RequestInit): Promise<any> {
const res = await fetch(`${BASE()}/api/docs/${DOC()}${path}`, {
...init,
headers: { Authorization: `Bearer ${KEY()}`, 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
signal: AbortSignal.timeout(6000),
});
if (!res.ok) throw new Error(`grist ${path}: HTTP ${res.status}`);
return res.json();
}
export function mapRecords(raw: { records: { id: number; fields: Record<string, unknown> }[] }): ReadMark[] {
return raw.records.map((r) => ({
id: r.id,
course_slug: String(r.fields.course_slug ?? ''),
module_n: Number(r.fields.module_n ?? 0),
chapitre_n: Number(r.fields.chapitre_n ?? 0),
moodle_synced: Boolean(r.fields.moodle_synced),
}));
}
export async function getReads(sub: string, courseSlug: string): Promise<ReadMark[]> {
const filter = encodeURIComponent(JSON.stringify({ user_sub: [sub], course_slug: [courseSlug] }));
return mapRecords(await grist(`/tables/Progression/records?filter=${filter}`));
}
export async function addRead(sub: string, email: string, courseSlug: string, moduleN: number, chapitreN: number): Promise<void> {
const existing = await getReads(sub, courseSlug);
if (existing.some((r) => r.module_n === moduleN && r.chapitre_n === chapitreN)) return; // idempotent
await grist('/tables/Progression/records', {
method: 'POST',
body: JSON.stringify({
records: [{
fields: {
user_sub: sub, email, course_slug: courseSlug,
module_n: moduleN, chapitre_n: chapitreN,
lu_le: Math.floor(Date.now() / 1000), // Grist DateTime accepts epoch seconds
moodle_synced: false,
},
}],
}),
});
}
export async function markSynced(ids: number[]): Promise<void> {
if (!ids.length) return;
await grist('/tables/Progression/records', {
method: 'PATCH',
body: JSON.stringify({ records: ids.map((id) => ({ id, fields: { moodle_synced: true } })) }),
});
}
export function isBookComplete(reads: ReadMark[], moduleN: number, chapterCount: number): boolean {
const seen = new Set(reads.filter((r) => r.module_n === moduleN).map((r) => r.chapitre_n));
for (let i = 1; i <= chapterCount; i++) if (!seen.has(i)) return false;
return true;
}
+5
View File
@@ -0,0 +1,5 @@
/** Best-effort Moodle completion sync — STUB, implemented in SP2 Task 4. */
export async function syncToMoodle(
_u: { sub: string; email: string; name: string },
_slug: string,
): Promise<void> {}
+49
View File
@@ -0,0 +1,49 @@
import type { APIRoute } from 'astro';
import { verifySession, SESSION_COOKIE } from '../../lib/session';
import { bySlug } from '../../config/courses';
import { addRead } from '../../lib/grist';
import { syncToMoodle } from '../../lib/moodle-sync';
const json = (status: number, body: unknown) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
export const POST: APIRoute = async ({ request, cookies, redirect }) => {
const user = verifySession(cookies.get(SESSION_COOKIE)?.value);
if (!user) return json(401, { error: 'auth' });
const ct = request.headers.get('content-type') ?? '';
const isForm = ct.includes('application/x-www-form-urlencoded') || ct.includes('multipart/form-data');
let slug = '', moduleRaw = '', chapitreRaw = '';
try {
if (isForm) {
const form = await request.formData();
slug = String(form.get('slug') ?? '');
moduleRaw = String(form.get('module') ?? '');
chapitreRaw = String(form.get('chapitre') ?? '');
} else {
const body = (await request.json()) as Record<string, unknown>;
slug = String(body.slug ?? '');
moduleRaw = String(body.module ?? '');
chapitreRaw = String(body.chapitre ?? '');
}
} catch {
return json(400, { error: 'body' });
}
const course = bySlug(slug);
const m = Number(moduleRaw);
const c = Number(chapitreRaw);
if (!course || !course.published || !Number.isInteger(m) || m < 1 || !Number.isInteger(c) || c < 1)
return json(400, { error: 'params' });
const back = `/cours/${slug}/module-${m}/chapitre-${c}`;
try {
await addRead(user.sub, user.email, slug, m, c);
} catch (err) {
console.error('[lu] grist addRead failed:', (err as Error).message);
return isForm ? redirect(`${back}?erreur=progression`, 303) : json(502, { error: 'grist' });
}
syncToMoodle({ sub: user.sub, email: user.email, name: user.name }, slug).catch(() => {});
return isForm ? redirect(back, 303) : json(200, { ok: true });
};
@@ -5,6 +5,7 @@ import { bySlug } from '../../../../config/courses';
import { getCourseBooks, getChapterHtml } from '../../../../lib/moodle';
import { sanitizeHtml } from '../../../../lib/sanitize';
import { verifySession, SESSION_COOKIE } from '../../../../lib/session';
import { getReads } from '../../../../lib/grist';
const user = verifySession(Astro.cookies.get(SESSION_COOKIE)?.value);
const course = bySlug(Astro.params.slug ?? '');
@@ -26,6 +27,16 @@ let html;
try { html = sanitizeHtml(await getChapterHtml(chapter.fileUrl)); }
catch { return new Response('Chapitre momentanément indisponible.', { status: 503, headers: { 'Content-Type': 'text/plain; charset=utf-8' } }); }
let dejaLu = false;
let gristDown = false;
if (user) {
try {
const reads = await getReads(user.sub, course.slug);
dejaLu = reads.some((r) => r.module_n === mi + 1 && r.chapitre_n === ci + 1);
} catch { gristDown = true; }
}
const progressionErreur = Astro.url.searchParams.get('erreur') === 'progression' || gristDown;
const prev = ci > 0 ? `/cours/${course.slug}/module-${mi + 1}/chapitre-${ci}`
: mi > 0 ? `/cours/${course.slug}/module-${mi}/chapitre-${modules[mi - 1].chapters.length}` : null;
const next = ci < mod.chapters.length - 1 ? `/cours/${course.slug}/module-${mi + 1}/chapitre-${ci + 2}`
@@ -42,7 +53,28 @@ const next = ci < mod.chapters.length - 1 ? `/cours/${course.slug}/module-${mi +
</nav>
<h1 class="text-3xl md:text-4xl font-semibold tracking-tight text-ink mb-8">{chapter.title}</h1>
<article class="chapitre" set:html={html} />
<div class="mt-16 pt-8 border-t border-slate-200 flex justify-between gap-4">
{progressionErreur && (
<div class="mt-12 rounded-xl border border-amber-300 bg-amber-50 px-5 py-3 text-sm text-amber-800">
Progression momentanément indisponible — réessayez dans quelques minutes.
</div>
)}
<div class="mt-12">
{!user ? (
<p class="rounded-xl bg-slate-100 px-5 py-4 text-sm text-slate-500">
<a href={`/connexion?next=${encodeURIComponent(Astro.url.pathname)}`} class="text-copper underline hover:text-copper/80">Se connecter</a> pour suivre votre progression.
</p>
) : dejaLu ? (
<span class="inline-block px-5 py-3 rounded-full bg-copper/10 text-copper text-sm font-medium">Chapitre lu ✓</span>
) : (
<form method="POST" action="/api/lu">
<input type="hidden" name="slug" value={course.slug} />
<input type="hidden" name="module" value={String(mi + 1)} />
<input type="hidden" name="chapitre" value={String(ci + 1)} />
<button type="submit" class="px-5 py-3 rounded-full bg-copper text-white text-sm font-medium hover:bg-copper/90 cursor-pointer">Marquer comme lu ✓</button>
</form>
)}
</div>
<div class="mt-10 pt-8 border-t border-slate-200 flex justify-between gap-4">
{prev ? <a href={prev} class="px-5 py-3 rounded-full bg-slate-100 text-ink text-sm font-medium hover:bg-slate-200">← Précédent</a> : <span />}
{next ? <a href={next} class="px-5 py-3 rounded-full bg-copper text-white text-sm font-medium hover:bg-copper/90">Suivant →</a>
: <a href={`/cours/${course.slug}`} class="px-5 py-3 rounded-full bg-night text-white text-sm font-medium">Terminer le module →</a>}