@@ -53,6 +67,7 @@ function mountSentence(root, initState){
[...kind.options].forEach(o=>o.textContent=T('sentence_'+o.value)); word.placeholder=T('sentenceAdd');
q('.sb-surprise').textContent=T('sentenceSurprise');q('.sb-clear').textContent=T('sentenceClear'); q('.sb-speak').textContent=T('sentenceSpeak'); renderSentence();renderBanks();
}
+ root.classList.toggle("managed-learning-level",!!initState?.managedLearningLevel);
document.addEventListener('langchange',labels);labels();
return {getState:()=>({banks:S.banks,sentence:S.sentence})};
}
diff --git a/public/js/wiki.js b/public/js/wiki.js
index 0f3a3be..bc588e0 100644
--- a/public/js/wiki.js
+++ b/public/js/wiki.js
@@ -126,6 +126,25 @@
tule.appendChild(sourceLinks);
root.appendChild(tule);
+ const profiles=node("details","wiki-tule wiki-year-profiles");
+ profiles.appendChild(node("summary",null,T("wikiYearProfiles")));
+ profiles.appendChild(node("p",null,T("wikiYearProfilesText")));
+ const profileGrid=node("div","wiki-profile-grid");
+ [["taal","📖"],["rekenen","🔢"]].forEach(([subject,icon])=>{
+ const column=node("section","wiki-profile-column");
+ column.appendChild(node("h3",null,icon+" "+(subject==="taal"?T("parentLevelTaal"):T("parentLevelRekenen"))));
+ LEARNING_YEAR_PROFILES[subject].slice(1).forEach((profile,index)=>{
+ const row=node("article","wiki-profile-row");
+ row.appendChild(node("strong",null,T("wikiYear").replace("{n}",index+1)));
+ row.appendChild(node("span","wiki-profile-title",profile.title));
+ row.appendChild(node("small",null,T("wikiProfileGoals")+": "+profile.goals.join(" · ")));
+ column.appendChild(row);
+ });
+ profileGrid.appendChild(column);
+ });
+ profiles.appendChild(profileGrid);
+ root.appendChild(profiles);
+
if(LANG==="en")root.appendChild(node("p","wiki-language-note",T("wikiLanguageNote")));
const searchWrap=node("div","wiki-search");
@@ -187,7 +206,7 @@
visible:()=>!!currentUser&&can("curriculum.view"),
activate(payload){showSettingsDom("curriculum");renderCurriculumWiki(payload);},
keywords:()=>[
- T("wikiTitle"),T("wikiSub"),"SLO","kerndoelen","curriculum","leerlijnen","TULE",
+ T("wikiTitle"),T("wikiSub"),T("wikiYearProfiles"),"SLO","kerndoelen","curriculum","leerlijnen","TULE",
...CURRICULUM_PO_2026.areas.flatMap(area=>[area.title,area.description])
],
searchItems:needle=>{
diff --git a/src/api.js b/src/api.js
index 5456ff0..3d8d67a 100644
--- a/src/api.js
+++ b/src/api.js
@@ -932,7 +932,7 @@ export default async function api(app) {
const yg = Number(yearGroup);
if (!Number.isInteger(yg) || yg < 1 || yg > 8) return fail(reply, 400, 'ongeldige groep');
const lvl = Number(level);
- if (!Number.isInteger(lvl) || lvl < 1) return fail(reply, 400, 'ongeldig niveau');
+ if (!Number.isInteger(lvl) || lvl < 1 || lvl > 3) return fail(reply, 400, 'ongeldig niveau');
return { subject, yearGroup: yg, level: lvl };
};
@@ -1034,6 +1034,24 @@ export default async function api(app) {
return row ? describePupilLevel(row) : { yearGroup: null, level: 1 };
});
+ // Alleen een leerling zonder school beheert het eigen niveau. Zodra een
+ // school het account adopteert, neemt de klas/leerlinginstelling van de
+ // leerkracht het automatisch over en wordt zelf wijzigen geweigerd.
+ app.put('/my/level', async (req, reply) => {
+ need(req, reply);
+ if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen');
+ if (req.user.school_id != null) return fail(reply, 403, 'het leerniveau wordt door de school beheerd');
+ const body = parseLevelBody(req, reply);
+ if (body.error) return body;
+ if (!['taal', 'rekenen'].includes(body.subject)) return fail(reply, 400, 'ongeldig vakgebied');
+ await pool.query(
+ `INSERT INTO pupil_levels (pupil_id, subject, year_group, level) VALUES ($1,$2,$3,$4)
+ ON CONFLICT (pupil_id, subject) WHERE pupil_id IS NOT NULL
+ DO UPDATE SET year_group = $3, level = $4, updated_at = now()`,
+ [req.user.id, body.subject, body.yearGroup, body.level]);
+ return { ok: true, level: { yearGroup: body.yearGroup, level: body.level } };
+ });
+
// ---- voortgang (leerling-omgeving) ---------------------------------------------
// Leerlingen loggen elk afgerond "moment" in een widget (ronde sommen, voltooid
// woord, geknapte ballon). teacher_id/board_id komen server-side uit de effectieve
@@ -1185,6 +1203,38 @@ export default async function api(app) {
})) };
});
+ app.get('/parent/children/:id/levels', async (req, reply) => {
+ needParent(req, reply);
+ if (!(await parentLinked(req.user.id, req.params.id))) return fail(reply, 403, 'geen rechten');
+ const child = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0];
+ if (!child || child.role !== 'pupil') return fail(reply, 404, 'kind onbekend');
+ if (child.school_id != null) return { schoolManaged: true, levels: {} };
+ const rows = (await pool.query(
+ "SELECT * FROM pupil_levels WHERE pupil_id = $1 AND subject IN ('taal','rekenen')",
+ [child.id])).rows;
+ return {
+ schoolManaged: false,
+ levels: Object.fromEntries(rows.map((row) => [row.subject, describePupilLevel(row)])),
+ };
+ });
+
+ app.put('/parent/children/:id/level', async (req, reply) => {
+ needParent(req, reply);
+ if (!(await parentLinked(req.user.id, req.params.id))) return fail(reply, 403, 'geen rechten');
+ const child = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0];
+ if (!child || child.role !== 'pupil') return fail(reply, 404, 'kind onbekend');
+ if (child.school_id != null) return fail(reply, 403, 'het leerniveau wordt door de school beheerd');
+ const body = parseLevelBody(req, reply);
+ if (body.error) return body;
+ if (!['taal', 'rekenen'].includes(body.subject)) return fail(reply, 400, 'ongeldig vakgebied');
+ await pool.query(
+ `INSERT INTO pupil_levels (pupil_id, subject, year_group, level) VALUES ($1,$2,$3,$4)
+ ON CONFLICT (pupil_id, subject) WHERE pupil_id IS NOT NULL
+ DO UPDATE SET year_group = $3, level = $4, updated_at = now()`,
+ [child.id, body.subject, body.yearGroup, body.level]);
+ return { ok: true };
+ });
+
// Kind aanmaken: pupil zonder school (thuiskind), meteen gekoppeld.
// Het gegenereerde wachtwoord wordt eenmalig teruggegeven.
app.post('/parent/children', async (req, reply) => {
@@ -1329,6 +1379,9 @@ export default async function api(app) {
if (e.code === '23505') return fail(reply, 409, 'gebruikersnaam bestaat al binnen deze school');
throw e;
}
+ // De thuiskeuze mag na adoptie niet als leerling-override boven de
+ // klasinstelling blijven hangen; vanaf nu bepaalt de leerkracht dit.
+ await pool.query('DELETE FROM pupil_levels WHERE pupil_id = $1', [r.pupil_id]);
// het kind hoort nu bij deze school: ook aanmeldingen bij andere
// scholen zijn daarmee vervallen
await pool.query('DELETE FROM school_link_requests WHERE pupil_id = $1', [r.pupil_id]);
diff --git a/test/learning-progressions.test.js b/test/learning-progressions.test.js
new file mode 100644
index 0000000..9c95046
--- /dev/null
+++ b/test/learning-progressions.test.js
@@ -0,0 +1,74 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {readFile} from 'node:fs/promises';
+import {runInNewContext} from 'node:vm';
+
+async function progressionContext(){
+ const source=await readFile('public/js/learning-progressions.js','utf8');
+ const pure=source.slice(0,source.indexOf('(function registerOwnLearningLevelSettings'));
+ const context={LANG:'nl'};
+ runInNewContext(pure+`
+ ;globalThis.__profiles=LEARNING_YEAR_PROFILES;
+ globalThis.__languageWordsForYear=languageWordsForYear;
+ globalThis.__languageTaskRules=languageTaskRules;
+ globalThis.__mathTaskRules=mathTaskRules;
+ `,context);
+ return context;
+}
+
+test('taal en rekenen hebben een compleet inhoudelijk profiel voor groep 1 tot en met 8',async()=>{
+ const {__profiles:profiles}=await progressionContext();
+ for(const subject of ['taal','rekenen']){
+ assert.equal(profiles[subject].length,9);
+ for(let year=1;year<=8;year++){
+ const profile=profiles[subject][year];
+ assert.ok(profile.phase&&profile.title,subject+' groep '+year);
+ assert.equal(profile.goals.length,3,subject+' groep '+year+' heeft drie concrete accenten');
+ }
+ }
+});
+
+test('taalprofiel selecteert eenvoudiger woorden in de onderbouw en bouwt taaksteun af',async()=>{
+ const c=await progressionContext();
+ const words=[['kat','x'],['vis','x'],['pen','x'],['maan','x'],['boek','x'],['fiets','x'],
+ ['brandweerauto','x'],['onderzoeker','x'],['verantwoordelijkheid','x']];
+ const early=JSON.parse(JSON.stringify(c.__languageWordsForYear(words,2,2)));
+ const late=JSON.parse(JSON.stringify(c.__languageWordsForYear(words,8,2)));
+ assert.ok(early.length
!['brandweerauto','verantwoordelijkheid'].includes(word)));
+ assert.equal(c.__languageTaskRules(2,2).flashSeconds,3);
+ assert.equal(c.__languageTaskRules(7,2).flashSeconds,.5);
+ assert.ok(c.__languageTaskRules(2,2).lives>c.__languageTaskRules(7,2).lives);
+});
+
+test('rekenprofiel vergroot getalgebied, tafels en breuken per leerjaar',async()=>{
+ const c=await progressionContext();
+ assert.equal(c.__mathTaskRules(2,3).addSub,20);
+ assert.equal(c.__mathTaskRules(3,1).timesTable,2);
+ assert.equal(c.__mathTaskRules(3,3).timesTable,10);
+ assert.equal(c.__mathTaskRules(4,3).addSub,100);
+ assert.equal(c.__mathTaskRules(6,3).addSub,10000);
+ assert.equal(c.__mathTaskRules(1,2).fractionDen,2);
+ assert.equal(c.__mathTaskRules(8,3).fractionDen,12);
+ assert.deepEqual(JSON.parse(JSON.stringify(c.__mathTaskRules(3,2).clockMinutes)),[0,15,30,45]);
+});
+
+test('alle taal- en rekenwidgets ontvangen de centrale leerjaarstate en tonen het profiel',async()=>{
+ const [html,board,pupil,progressions,letters,flits,hak,ball,math,numberline,learning]=await Promise.all([
+ readFile('public/index.html','utf8'),readFile('public/js/board.js','utf8'),
+ readFile('public/js/pupil.js','utf8'),readFile('public/js/learning-progressions.js','utf8'),
+ ...['letters','flits','hak','ball','math','numberline','learning'].map(name=>readFile('public/js/widgets/'+name+'.js','utf8')),
+ ]);
+ assert.ok(html.indexOf('js/data.js') {
await app.close();
});
+test('ouder leest en wijzigt het leerniveau van een gekoppeld thuiskind', async () => {
+ const respond = (sql) => {
+ if (sql.includes('FROM parent_children WHERE parent_id')) return { rows: [{ 1: 1 }] };
+ if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [homeChild] };
+ if (sql.includes("subject IN ('taal','rekenen')")) return { rows: [
+ { subject:'taal',year_group:3,level:2 },{ subject:'rekenen',year_group:4,level:1 },
+ ] };
+ return undefined;
+ };
+ const {app,calls}=await makeApp({user:parent,respond});
+ const get=await app.inject({method:'GET',url:'/api/parent/children/30/levels',cookies});
+ assert.equal(get.statusCode,200,get.body);
+ assert.deepEqual(get.json().levels,{taal:{yearGroup:3,level:2},rekenen:{yearGroup:4,level:1}});
+ const put=await app.inject({method:'PUT',url:'/api/parent/children/30/level',cookies,
+ payload:{subject:'rekenen',yearGroup:5,level:3}});
+ assert.equal(put.statusCode,200,put.body);
+ const insert=calls.find(call=>call.sql.startsWith('INSERT INTO pupil_levels'));
+ assert.deepEqual(insert.params,[30,'rekenen',5,3]);
+ await app.close();
+});
+
+test('ouder kan schoolniveau of niveau van een vreemd kind niet overschrijven', async () => {
+ const {app}=await makeApp({user:parent,respond:(sql)=>{
+ if(sql.includes('FROM parent_children WHERE parent_id'))return{rows:[{1:1}]};
+ if(sql.startsWith('SELECT * FROM users WHERE id'))return{rows:[{...homeChild,school_id:2}]};
+ return undefined;
+ }});
+ const school=await app.inject({method:'PUT',url:'/api/parent/children/30/level',cookies,
+ payload:{subject:'taal',yearGroup:4,level:2}});
+ assert.equal(school.statusCode,403,school.body);
+ const {app:other}=await makeApp({user:parent});
+ const strange=await other.inject({method:'PUT',url:'/api/parent/children/99/level',cookies,
+ payload:{subject:'taal',yearGroup:4,level:2}});
+ assert.equal(strange.statusCode,403,strange.body);
+ await app.close();await other.close();
+});
+
test('parent-routes zijn niet voor andere rollen', async () => {
const { app } = await makeApp({ user: admin });
const res = await app.inject({ method: 'GET', url: '/api/parent/children', cookies });
@@ -213,6 +250,8 @@ test('adopteren zet school en klas op het thuisaccount en ruimt verzoeken op', a
assert.equal(res.statusCode, 200, res.body);
const upd = calls.find((c) => c.sql.includes('UPDATE users SET school_id'));
assert.deepEqual(upd.params, [2, 5, null, 30]);
+ assert.ok(calls.some((c) => c.sql.startsWith('DELETE FROM pupil_levels')),
+ 'de oude thuiskeuze vervalt wanneer de leerkracht de regie krijgt');
assert.ok(calls.some((c) => c.sql.startsWith('DELETE FROM school_link_requests WHERE pupil_id')));
await app.close();
});
diff --git a/test/pupil-levels.test.js b/test/pupil-levels.test.js
index 16aee04..47bd1e7 100644
--- a/test/pupil-levels.test.js
+++ b/test/pupil-levels.test.js
@@ -43,6 +43,7 @@ const cookies = { teach_session: 'x'.repeat(64) };
const teacher = { id: 3, username: 'juf', role: 'teacher', school_id: 2, class_id: null, data: {}, data_rev: 0 };
const admin = { id: 4, username: 'beheer', role: 'admin', school_id: 2, class_id: null, data: {}, data_rev: 0 };
const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 100, data: {}, data_rev: 0 };
+const homePupil = { id: 10, username: 'thuis', role: 'pupil', school_id: null, class_id: null, data: {}, data_rev: 0 };
test('klassenmanagement: klas-standaard opslaan doet de juiste upsert-query', async () => {
const { app, calls } = await makeApp(admin);
@@ -74,6 +75,15 @@ test('klassenmanagement: groep buiten 1-8 geeft 400', async () => {
assert.equal(res.statusCode, 400, res.body);
});
+test('klassenmanagement: alleen de drie beschreven uitwerkingen zijn geldig', async () => {
+ const { app } = await makeApp(teacher);
+ const res = await app.inject({
+ method: 'PUT', url: '/api/levels/class/100', cookies,
+ payload: { subject: 'rekenen', yearGroup: 4, level: 4 },
+ });
+ assert.equal(res.statusCode, 400, res.body);
+});
+
test('klassenmanagement: GET class levert klas-standaard én leerling-overrides in één keer', async () => {
const { app } = await makeApp(admin, {
classDefault: { year_group: 4, level: 2 },
@@ -113,6 +123,21 @@ test('klassenmanagement: leerkracht-rol wordt geweigerd op /my/level (alleen lee
assert.equal(res.statusCode, 403, res.body);
});
+test('thuiskind kan eigen taalniveau opslaan, schoolleerling niet', async () => {
+ const { app, calls } = await makeApp(homePupil);
+ const res = await app.inject({ method: 'PUT', url: '/api/my/level', cookies,
+ payload: { subject: 'taal', yearGroup: 5, level: 2 } });
+ assert.equal(res.statusCode, 200, res.body);
+ const insert = calls.find(call=>call.sql.startsWith('INSERT INTO pupil_levels'));
+ assert.deepEqual(insert.params,[10,'taal',5,2]);
+
+ const { app: schoolApp } = await makeApp(pupil);
+ const denied = await schoolApp.inject({ method: 'PUT', url: '/api/my/level', cookies,
+ payload: { subject: 'taal', yearGroup: 5, level: 2 } });
+ assert.equal(denied.statusCode,403,denied.body);
+ await app.close();await schoolApp.close();
+});
+
/* Echte (niet regex-)test van de SLO-getalgrenzen: data.js's SLO_MATH_RANGES/
mathRangeFor zijn pure functies zonder DOM-afhankelijkheid, dus rechtstreeks
uitvoerbaar in een geïsoleerde VM-context - zelfde techniek als elders in
@@ -137,10 +162,11 @@ test('SLO-rekengrenzen: groep bepaalt de daadwerkelijke getalgrens, niet meer de
assert.equal(mathRangeFor(undefined, 2), null);
});
-test('math.js: gen() gebruikt mathRangeFor als er een groep is, anders de oude vaste klem', async () => {
+test('math.js: gen() gebruikt leerjaarregels en behoudt zonder groep de oude vaste klem', async () => {
const source = await readFile('public/js/widgets/math.js', 'utf8');
assert.match(source, /const slo = mathRangeFor\(S\.yearGroup, L\)/);
- assert.match(source, /const max = slo \? slo\.addSub : \(L===1 \? 10 : L===2 \? 20 : 100\)/);
- assert.match(source, /const tmax = slo \? slo\.timesTable : \(L===1 \? 5 : L===2 \? 10 : 12\)/);
+ assert.match(source, /const rules = mathTaskRules\(S\.yearGroup, L\)/);
+ assert.match(source, /const max = rules \? rules\.addSub/);
+ assert.match(source, /const tmax = rules \? rules\.timesTable/);
assert.ok(source.includes('S.yearGroup = initState.yearGroup || null'));
});