Voeg thematische kleurplaten en creatieve niveaus toe (v0.4.65-beta) #3
11 changed files with 280 additions and 22 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.4.62-beta
|
||||
0.4.63-beta
|
||||
|
|
|
|||
|
|
@ -2055,7 +2055,7 @@ body.dark .world-fact{background:#1d3b2a;color:#a9e5b7}body.dark .world-fact .wo
|
|||
.widget-body{background-image:radial-gradient(circle at 96% 4%,color-mix(in srgb,var(--widget-accent) 8%,transparent) 0 54px,transparent 55px)}
|
||||
.widget-body .tbtn{transition:transform .12s,filter .12s,box-shadow .12s}.widget-body .tbtn:not(.ghost){background:var(--widget-accent);box-shadow:0 3px 0 color-mix(in srgb,var(--widget-accent) 72%,#18233a)}.widget-body .tbtn:active{transform:translateY(2px) scale(.985);box-shadow:none}.widget-body :is(button,input,select,textarea):focus-visible{outline:3px solid color-mix(in srgb,var(--widget-accent) 72%,#fff);outline-offset:2px}
|
||||
.play-mission{width:min(100%,560px);margin:0 auto;padding:8px 12px;border:1px solid color-mix(in srgb,var(--widget-accent) 25%,var(--line));border-radius:13px;background:var(--widget-soft);color:color-mix(in srgb,var(--widget-accent) 68%,var(--ink));font-size:13px;font-weight:850;line-height:1.35;text-align:center}
|
||||
.fl-reflect{width:100%;padding:8px 10px;border-radius:14px;background:var(--widget-soft);text-align:center}.fl-reflect p{margin:0 0 7px;font-weight:900}.fl-reflect>div{display:flex;justify-content:center;gap:7px;flex-wrap:wrap}.fl-reflect[hidden]{display:none}
|
||||
.fl-reflect,.hk-reflect{width:100%;padding:8px 10px;border-radius:14px;background:var(--widget-soft);text-align:center}.fl-reflect p,.hk-reflect p{margin:0 0 7px;font-weight:900}.fl-reflect>div,.hk-reflect>div{display:flex;justify-content:center;gap:7px;flex-wrap:wrap}.fl-reflect[hidden],.hk-reflect[hidden]{display:none}
|
||||
.dc-history{min-height:28px;display:flex;align-items:center;justify-content:center;gap:5px;flex-wrap:wrap}.dc-history span{margin-right:3px;color:var(--muted);font-size:11px;font-weight:850}.dc-history b{min-width:28px;height:28px;display:grid;place-items:center;border-radius:9px;background:var(--widget-soft);color:var(--widget-accent)}
|
||||
.notes-app{height:100%;display:flex;flex-direction:column;background:#fff8cf;transition:background .15s}.notes-app[data-theme="mint"]{background:#e5f7df}.notes-app[data-theme="sky"]{background:#e2f3ff}.notes-app[data-theme="lilac"]{background:#f0e8ff}.notes-tools{min-height:42px;display:flex;align-items:center;gap:7px;padding:6px 8px;border-bottom:1px solid rgba(29,52,97,.12);color:#52627b;font-size:11px;font-weight:900}.notes-swatches{display:flex;gap:4px}.notes-swatches button{width:24px;height:24px;padding:0;border:2px solid rgba(29,52,97,.16);border-radius:50%;cursor:pointer}.notes-swatches button[data-theme="sun"]{background:#fff0a8}.notes-swatches button[data-theme="mint"]{background:#cdeebe}.notes-swatches button[data-theme="sky"]{background:#c6e9ff}.notes-swatches button[data-theme="lilac"]{background:#dfd0ff}.notes-swatches button.on{outline:2px solid var(--ink);outline-offset:1px}.notes-size{height:28px;padding:0 7px;border:1px solid rgba(29,52,97,.18);border-radius:8px;background:rgba(255,255,255,.5);color:#33445f;font-weight:900;cursor:pointer}.notes-size.on{background:#fff}.notes-count{margin-left:auto;white-space:nowrap}
|
||||
.mt-card,.lg-card,.money-task-card,.clock-task-card,.world-task-card,.sb-sentence,.nlw-scroll{box-shadow:0 7px 20px rgba(29,52,97,.09)}
|
||||
|
|
|
|||
|
|
@ -982,6 +982,27 @@
|
|||
return row;
|
||||
}
|
||||
|
||||
/* de rechterkolom van een foutregel. Drie vormen, want de widgets leveren
|
||||
drie soorten momenten: een fout afgesloten opdracht (som opgegeven,
|
||||
ballonwoord verloren), een zelfcheck van de leerling (flitswoorden,
|
||||
hakken & plakken - daar bestaat geen ingetypt antwoord) en een opdracht die
|
||||
goed eindigde maar pogingen kostte (letterblokken kent geen faalmoment) */
|
||||
function progressMistakeLine(ev){
|
||||
const d = ev.detail || {}, misses = d.mistakes || [];
|
||||
if(ev.correct > 0 && misses.length){
|
||||
/* een letter die op een andere plek hoorde tonen we als "a → k"; bij het
|
||||
ballonnenwoord bestaat er geen letter-voor-letter verwachting, dan
|
||||
volstaat de rij fout geraden letters */
|
||||
const shown = misses.slice(0,3).map(m=>
|
||||
m.expected && m.expected!==d.question ? m.given+" → "+m.expected : m.given).join(", ");
|
||||
return T("amProgressRetried").replace("{count}", ev.attempts || misses.length+1)
|
||||
+ (shown ? " · " + shown : "");
|
||||
}
|
||||
if(d.selfCheck || d.answer == null) return T("amProgressSelfCheck");
|
||||
return T("amProgressGiven").replace("{answer}", d.answer) + " · " +
|
||||
T("amProgressExpected").replace("{answer}", d.expected);
|
||||
}
|
||||
|
||||
/* voortgang: leerkracht kiest een leerling en een vakgebied, ziet meteen
|
||||
het toegewezen niveau (klassenmanagement-koppeling), een grafiek
|
||||
(nauwkeurigheid + goed/fout per dag) en de recentste fouten mét welke
|
||||
|
|
@ -1025,16 +1046,15 @@
|
|||
summary.append(summaryText, progressDailyBars(events));
|
||||
body.appendChild(summary);
|
||||
|
||||
const mistakes = events.filter(ev=>ev.detail && ev.detail.correct===false).slice(0, 10);
|
||||
const mistakes = events.filter(ev=>ev.detail
|
||||
&& (ev.detail.correct===false || (ev.detail.mistakes||[]).length)).slice(0, 10);
|
||||
if(mistakes.length){
|
||||
body.appendChild(h("div","am-group", T("amProgressMistakes")));
|
||||
const list = h("div","am-list");
|
||||
mistakes.forEach(ev=>{
|
||||
const row = h("div","am-row");
|
||||
row.appendChild(h("span","am-name", ev.detail.question||""));
|
||||
row.appendChild(h("span","am-code",
|
||||
T("amProgressGiven").replace("{answer}", ev.detail.answer) + " · " +
|
||||
T("amProgressExpected").replace("{answer}", ev.detail.expected)));
|
||||
row.appendChild(h("span","am-code", progressMistakeLine(ev)));
|
||||
row.appendChild(h("span","am-code", new Date(ev.createdAt).toLocaleString(LANG==="nl" ? "nl-NL" : "en-GB")));
|
||||
list.appendChild(row);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"use strict";
|
||||
/* version — shown until /api/version resolves (or if the fetch fails, e.g. offline).
|
||||
Kept in sync by hand with the VERSION file at the repo root on every release. */
|
||||
const VERSION = "0.4.62-beta";
|
||||
const VERSION = "0.4.63-beta";
|
||||
/* Safari en oudere mobiele browsers behandelen 100vh/100% soms als de hoogte
|
||||
achter hun adres- en navigatiebalk. visualViewport geeft de werkelijk
|
||||
zichtbare hoogte; de CSS gebruikt deze pixelwaarde als betrouwbare bron en
|
||||
|
|
@ -119,6 +119,7 @@ const I18N = {
|
|||
namesDone:"Iedereen is geweest! 🎉", namesReset:"Opnieuw", namesProgress:"{used} van {total} geweest",
|
||||
wg_hak:"Hakken & plakken", wg_hak_d:"Woorden in klanken hakken en weer plakken, met voorlezen.",
|
||||
hakChop:"✂️ Hak", hakGlue:"🔗 Plak", hakSay:"🔊 Lees voor", hakHint:"Bekijk en hoor de klankgroepen in het woord.",
|
||||
hakCheck:"Kon je het woord zelf hakken en plakken?", hakCheckLetter:"Hoorde je de eerste letter zelf goed?",
|
||||
wg_ball:"Ballonnenwoord", wg_ball_d:"Raad het woord letter voor letter voordat de ballonnen knappen.",
|
||||
wg_anchor:"Ankerwoorden", wg_anchor_d:"Woordweb voor woordenschatonderwijs: leer woorden in samenhang rond een themawoord (het anker), met plaatjes. Elk woord verankert aan de woorden eromheen.",
|
||||
fabAnchor:"Ankerdiagram (woordweb)",
|
||||
|
|
@ -439,6 +440,7 @@ const I18N = {
|
|||
amProgressLast14Days:"Laatste 14 dagen", amProgressMistakes:"Recente fouten",
|
||||
amProgressTimeline:"Volledige tijdlijn",
|
||||
amProgressGiven:"Gegeven: {answer}", amProgressExpected:"Juist: {answer}",
|
||||
amProgressRetried:"Na {count} pogingen goed", amProgressSelfCheck:"Zelf aangegeven: nog niet gelukt",
|
||||
amExportClass:"Exporteren per klas", amExportCsv:"Exporteer als CSV", amPickClassHint:"Kies eerst een klas.",
|
||||
amExportColPupil:"Leerling", amExportColWidget:"Widget", amExportColAttempts:"Pogingen",
|
||||
amExportColCorrect:"Goed", amExportColStars:"Sterren", amExportColDate:"Datum",
|
||||
|
|
@ -541,6 +543,7 @@ const I18N = {
|
|||
namesDone:"Everyone has had a turn! 🎉", namesReset:"Start over", namesProgress:"{used} of {total} picked",
|
||||
wg_hak:"Chop & blend", wg_hak_d:"Chop words into sounds and blend them back, with read-aloud.",
|
||||
hakChop:"✂️ Chop", hakGlue:"🔗 Blend", hakSay:"🔊 Read aloud", hakHint:"See and hear the sound groups in the word.",
|
||||
hakCheck:"Could you chop and blend the word yourself?", hakCheckLetter:"Did you hear the first letter correctly yourself?",
|
||||
wg_ball:"Balloon word", wg_ball_d:"Guess the word letter by letter before the balloons pop.",
|
||||
wg_anchor:"Anchor words", wg_anchor_d:"Word web for vocabulary teaching: learn words in meaningful clusters around a theme word (the anchor), with pictures. Every word anchors to its neighbours.",
|
||||
fabAnchor:"Anchor diagram (word web)",
|
||||
|
|
@ -861,6 +864,7 @@ const I18N = {
|
|||
amProgressLast14Days:"Last 14 days", amProgressMistakes:"Recent mistakes",
|
||||
amProgressTimeline:"Full timeline",
|
||||
amProgressGiven:"Given: {answer}", amProgressExpected:"Correct: {answer}",
|
||||
amProgressRetried:"Correct after {count} tries", amProgressSelfCheck:"Self-reported: not yet",
|
||||
amExportClass:"Export by class", amExportCsv:"Export as CSV", amPickClassHint:"Pick a class first.",
|
||||
amExportColPupil:"Pupil", amExportColWidget:"Widget", amExportColAttempts:"Attempts",
|
||||
amExportColCorrect:"Correct", amExportColStars:"Stars", amExportColDate:"Date",
|
||||
|
|
|
|||
|
|
@ -105,6 +105,41 @@ function languageTaskRules(yearGroup,level){
|
|||
const year=learningYearGroup(yearGroup);if(!year)return null;const l=learningLevel(level);
|
||||
return{literacyMode:year===1?"letters":year===2?"sounds":"words",letterLevel:year<=2?Math.min(2,l):year===3?Math.max(2,l):3,flashSeconds:year<=2?3:year<=4?2:year<=6?1:.5,lives:year<=3?7:year<=5?6:5,wordtypeCount:year<=3?2:year===4?3:4}
|
||||
}
|
||||
/* Voortgang bij taal: één gedeelde vorm voor het "moment" dat een taalwidget
|
||||
aflevert. math.js logt sinds v0.4.35-beta per som mét een detail (welke som,
|
||||
gegeven en juist antwoord); de taalwidgets leverden alleen een kale telling,
|
||||
waardoor de leerkracht bij taal wel zag DAT er geoefend was maar nooit welk
|
||||
woord moeite kostte. attempts telt de pogingen (1 + aantal fouten) zodat de
|
||||
goed/fout-grafiek in de Voortgang-tab (wrong = attempts - correct) klopt.
|
||||
detail.correct betekent hier "foutloos afgerond": letterblokken kent geen
|
||||
faalmoment (je kunt blijven proberen), dus zonder dat criterium zou de
|
||||
foutenlijst daar altijd leeg blijven. */
|
||||
function languageProgressMistakes(mistakes){
|
||||
return (Array.isArray(mistakes)?mistakes:[])
|
||||
.filter(m=>m&&(m.given!=null||m.expected!=null))
|
||||
.slice(0,6)
|
||||
.map(m=>({expected:String(m.expected==null?"":m.expected).slice(0,40),given:String(m.given==null?"":m.given).slice(0,40)}))
|
||||
}
|
||||
function languageProgressPayload(options){
|
||||
const o=options||{},list=languageProgressMistakes(o.mistakes),first=list[0]||null;
|
||||
const task=String(o.word==null?"":o.word).slice(0,80),solved=o.solved!==false;
|
||||
const detail={
|
||||
question:task,
|
||||
expected:o.expected!==undefined?String(o.expected==null?"":o.expected).slice(0,80):(first?first.expected:task),
|
||||
/* bij een zelfcheck (flits/hak) typt de leerling niets in: er ís geen
|
||||
antwoord om aan de leerkracht te tonen, alleen "lukte het zelf of niet" */
|
||||
answer:o.answer!==undefined?(o.answer==null?null:String(o.answer).slice(0,80)):(o.selfCheck?null:(first?first.given:task)),
|
||||
correct:solved&&!list.length
|
||||
};
|
||||
if(list.length)detail.mistakes=list;
|
||||
if(o.selfCheck)detail.selfCheck=true;
|
||||
return{
|
||||
attempts:((solved?1:0)+list.length)||1,
|
||||
correct:solved?1:0,
|
||||
stars:solved?Math.max(0,Number(o.stars)||0):0,
|
||||
detail
|
||||
}
|
||||
}
|
||||
function mathTaskRules(yearGroup,level){
|
||||
const year=learningYearGroup(yearGroup);if(!year)return null;const l=learningLevel(level),factor=l===1?.25:l===2?.6:1;
|
||||
const addCap=[0,10,20,20,100,1000,10000,100000,1000000][year],numberMax=[0,20,50,100,100,1000,10000,100000,1000000][year];
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
==========================================================*/
|
||||
function mountBall(root, initState, opts){
|
||||
const onProgress = opts && opts.onProgress;
|
||||
const S = { level:1, yearGroup:null, managedLearningLevel:false, card:0, snd:true, lives:6, guessed:new Set(), over:false, order:[], idx:0, stars:0, assignmentWords:null, assignmentThemeName:"" };
|
||||
const S = { level:1, yearGroup:null, managedLearningLevel:false, card:0, snd:true, lives:6, guessed:new Set(), over:false, order:[], idx:0, stars:0, assignmentWords:null, assignmentThemeName:"", wrong:[] };
|
||||
if(initState){S.level=initState.level||1;S.yearGroup=initState.yearGroup||null;S.managedLearningLevel=!!initState.managedLearningLevel;S.card = initState.card ?? 0; S.snd = initState.snd !== false;S.assignmentWords=Array.isArray(initState.assignmentWords)?initState.assignmentWords:null;S.assignmentThemeName=typeof initState.assignmentThemeName==="string"?initState.assignmentThemeName:"";}
|
||||
const assignedRules=languageTaskRules(S.yearGroup,S.level),maxLives=assignedRules?.lives||6;
|
||||
const play = f=>{ if(S.snd) f(); };
|
||||
|
|
@ -56,7 +56,7 @@ function mountBall(root, initState, opts){
|
|||
S.order = shuffle(list().map((_,i)=>i));
|
||||
S.idx = 0;
|
||||
}
|
||||
S.lives = maxLives; S.guessed = new Set(); S.over = false;
|
||||
S.lives = maxLives; S.guessed = new Set(); S.over = false; S.wrong = [];
|
||||
msgEl.textContent = ""; msgEl.className = "bl-msg";
|
||||
nextBtn.classList.remove("show");
|
||||
kbdEl.querySelectorAll("button").forEach(b=>{ b.disabled = false; b.style.opacity = 1; });
|
||||
|
|
@ -78,10 +78,13 @@ function mountBall(root, initState, opts){
|
|||
play(sndWin); confetti(el(".mt-card"));
|
||||
nextBtn.classList.add("show");
|
||||
scoreEl.textContent = S.stars+" ★";
|
||||
if(onProgress) onProgress({ attempts: 1, correct: 1, stars: 1 });
|
||||
if(onProgress) onProgress(languageProgressPayload({
|
||||
word: word(), mistakes: S.wrong.map(x=>({given:x, expected:word()})),
|
||||
answer: S.wrong.join(", ") || word(), expected: word(), solved: true, stars: 1 }));
|
||||
}
|
||||
}else{
|
||||
S.lives--;
|
||||
S.wrong.push(l);
|
||||
play(sndBad);
|
||||
drawBalloons();
|
||||
if(S.lives <= 0){
|
||||
|
|
@ -90,6 +93,12 @@ function mountBall(root, initState, opts){
|
|||
msgEl.textContent = `${T("ballLose")} ${word()}`;
|
||||
msgEl.classList.add("lose");
|
||||
nextBtn.classList.add("show");
|
||||
/* een kapotgegokt woord leverde tot nu toe géén enkele voortgang op -
|
||||
precies de opdracht die de leerkracht wél zou willen zien */
|
||||
if(onProgress) onProgress(languageProgressPayload({
|
||||
word: word(), mistakes: S.wrong.map(x=>({given:x, expected:word()})),
|
||||
answer: word().split("").map(x=>S.guessed.has(x)?x:"_").join(""),
|
||||
expected: word(), solved: false }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
/* =========================================================
|
||||
Flash words widget
|
||||
==========================================================*/
|
||||
function mountFlits(root, initState){
|
||||
function mountFlits(root, initState, opts){
|
||||
const onProgress = opts && opts.onProgress;
|
||||
const S = { level:1, yearGroup:null, managedLearningLevel:false, card:0, dur:2, idx:0, order:[], score:0, tries:0, assignmentWords:null, assignmentThemeName:"" };
|
||||
if(initState){ S.level=initState.level||1;S.yearGroup=initState.yearGroup||null;S.managedLearningLevel=!!initState.managedLearningLevel;S.card = initState.card ?? 0; S.dur = initState.dur || 2; S.score=+initState.score||0; S.tries=+initState.tries||0;S.assignmentWords=Array.isArray(initState.assignmentWords)?initState.assignmentWords:null;S.assignmentThemeName=typeof initState.assignmentThemeName==="string"?initState.assignmentThemeName:""; }
|
||||
const assignedRules=languageTaskRules(S.yearGroup,S.level);if(S.managedLearningLevel&&assignedRules)S.dur=assignedRules.flashSeconds;
|
||||
|
|
@ -81,6 +82,11 @@ function mountFlits(root, initState){
|
|||
function assess(ok){
|
||||
if(assessed)return;assessed=true;
|
||||
S.tries++; if(ok){S.score++;sndGood();confetti(el('.mt-card'));}else sndBad();
|
||||
/* de zelfcheck van de leerling ("gelukt" / "nog oefenen") was tot nu toe
|
||||
alleen een sessieteller; het is het enige goed/fout-signaal dat deze
|
||||
widget heeft en dus precies wat de voortgang bij taal miste */
|
||||
if(onProgress) onProgress(languageProgressPayload({
|
||||
word: cur()[0], solved: ok, stars: ok?1:0, selfCheck: true }));
|
||||
score(); reflect.hidden=true;
|
||||
}
|
||||
el(".fl-flash").addEventListener("click", flash);
|
||||
|
|
|
|||
|
|
@ -18,8 +18,14 @@ function soundChunks(word, lang=LANG){
|
|||
/* =========================================================
|
||||
Chop & blend widget — words in sounds, with read-aloud
|
||||
==========================================================*/
|
||||
function mountHak(root, initState){
|
||||
function mountHak(root, initState, opts){
|
||||
const onProgress = opts && opts.onProgress;
|
||||
const S = { level:1, yearGroup:null, managedLearningLevel:false, card:0, idx:0, order:[], chopped:false, assignmentWords:null, assignmentThemeName:"" };
|
||||
/* deze widget had als enige taalwidget geen goed/fout-moment: hakken, plakken
|
||||
en voorlezen kun je niet fout doen. De zelfcheck van flits.js (lukte het
|
||||
zelf?) geeft de leerling er één zonder de oefening zelf te veranderen -
|
||||
hij verschijnt pas nadat het woord onthuld is, dus na een eerlijke poging */
|
||||
let assessed = false;
|
||||
if(initState){S.level=initState.level||1;S.yearGroup=initState.yearGroup||null;S.managedLearningLevel=!!initState.managedLearningLevel;S.card = initState.card ?? 0;S.assignmentWords=Array.isArray(initState.assignmentWords)?initState.assignmentWords:null;S.assignmentThemeName=typeof initState.assignmentThemeName==="string"?initState.assignmentThemeName:"";}
|
||||
const canSpeak = "speechSynthesis" in window;
|
||||
|
||||
|
|
@ -34,6 +40,10 @@ function mountHak(root, initState){
|
|||
<div class="fl-pic hk-pic"></div>
|
||||
<div class="hk-word"></div>
|
||||
</div>
|
||||
<div class="hk-reflect" hidden>
|
||||
<p class="hk-question"></p>
|
||||
<div><button class="tbtn hk-got"></button><button class="tbtn ghost hk-again"></button></div>
|
||||
</div>
|
||||
<div class="trow" style="display:flex;gap:8px;justify-content:center;flex-wrap:wrap;flex:none;">
|
||||
<button class="tbtn hk-say" ${canSpeak?"":'style="display:none"'}></button>
|
||||
<button class="tbtn ghost hk-chop"></button>
|
||||
|
|
@ -43,7 +53,7 @@ function mountHak(root, initState){
|
|||
</div>`;
|
||||
const el = s => root.querySelector(s);
|
||||
const srcSel = el(".hk-src"), wordEl = el(".hk-word"),
|
||||
picEl = el(".hk-pic"), cntEl = el(".hk-cnt");
|
||||
picEl = el(".hk-pic"), cntEl = el(".hk-cnt"), reflect = el(".hk-reflect");
|
||||
|
||||
function list(){
|
||||
if(S.assignmentWords?.length)return languageWidgetWords(S.assignmentWords,S.yearGroup,S.level,"hak");
|
||||
|
|
@ -96,27 +106,41 @@ function mountHak(root, initState){
|
|||
else if(S.chopped)speak(soundChunks(word).join(", "),0.7);
|
||||
else speak(word,0.85);
|
||||
});
|
||||
el(".hk-chop").addEventListener("click", ()=>{ S.chopped = true; display(); play(sndGood); });
|
||||
el(".hk-glue").addEventListener("click", ()=>{ S.chopped = false; display(); play(sndGood); });
|
||||
function assess(ok){
|
||||
if(assessed) return;
|
||||
assessed = true;
|
||||
if(ok){ sndGood(); confetti(el(".mt-card")); } else sndBad();
|
||||
if(onProgress) onProgress(languageProgressPayload({
|
||||
word: cur()[0], solved: ok, stars: ok?1:0, selfCheck: true }));
|
||||
reflect.hidden = true;
|
||||
}
|
||||
function askSelfCheck(){ reflect.hidden = assessed; }
|
||||
el(".hk-chop").addEventListener("click", ()=>{ S.chopped = true; display(); play(sndGood); askSelfCheck(); });
|
||||
el(".hk-glue").addEventListener("click", ()=>{ S.chopped = false; display(); play(sndGood); askSelfCheck(); });
|
||||
el(".hk-got").addEventListener("click", ()=>assess(true));
|
||||
el(".hk-again").addEventListener("click", ()=>assess(false));
|
||||
el(".hk-next").addEventListener("click", ()=>{
|
||||
S.idx++; if(S.idx >= S.order.length) newRound();
|
||||
S.chopped = false; display();
|
||||
S.chopped = false; assessed = false; reflect.hidden = true; display();
|
||||
});
|
||||
const play = f=>f();
|
||||
srcSel.addEventListener("change", ()=>{
|
||||
S.card = srcSel.value;
|
||||
newRound(); S.chopped = false; display();
|
||||
newRound(); S.chopped = false; assessed = false; reflect.hidden = true; display();
|
||||
});
|
||||
function labels(){
|
||||
const letters=languageTaskRules(S.yearGroup,S.level)?.literacyMode==="letters";
|
||||
el(".hk-question").textContent = T(letters?"hakCheckLetter":"hakCheck");
|
||||
el(".hk-got").textContent = T("flGotIt");
|
||||
el(".hk-again").textContent = T("flAgain");
|
||||
el(".hk-say").textContent = T("hakSay");
|
||||
el(".hk-chop").textContent = letters?(LANG==="nl"?"Toon letter":"Show letter"):T("hakChop");
|
||||
el(".hk-glue").textContent = letters?(LANG==="nl"?"Verberg letter":"Hide letter"):T("hakGlue");
|
||||
el(".hk-hint").textContent = letters?(LANG==="nl"?"Luister naar het woord. Welke letter hoor je vooraan?":"Listen to the word. Which letter do you hear first?"):T("hakHint");
|
||||
buildSrc();
|
||||
}
|
||||
document.addEventListener("langchange", ()=>{ if(!S.assignmentWords)S.card=0; labels(); newRound(); S.chopped=false; display(); });
|
||||
document.addEventListener("wordschange", ()=>{ buildSrc(); newRound(); S.chopped=false; display(); });
|
||||
document.addEventListener("langchange", ()=>{ if(!S.assignmentWords)S.card=0; labels(); newRound(); S.chopped=false; assessed=false; reflect.hidden=true; display(); });
|
||||
document.addEventListener("wordschange", ()=>{ buildSrc(); newRound(); S.chopped=false; assessed=false; reflect.hidden=true; display(); });
|
||||
labels(); newRound(); display();
|
||||
root.classList.toggle("managed-learning-level",S.managedLearningLevel);
|
||||
return { getState: ()=>({card:S.card}) };
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
function mountLetterGame(root, initState, opts){
|
||||
const readonly = !!(opts && opts.readonly);
|
||||
const onProgress = opts && opts.onProgress;
|
||||
const S = { level:1, yearGroup:null, managedLearningLevel:false, card:0, wordIdx:0, stars:0, lang:LANG, count:6, order:[], snd:true, amode:"blocks", assignmentWords:null, assignmentThemeName:"" };
|
||||
/* wordMistakes: de missers binnen het huidige woord (welke letter hoorde er,
|
||||
welke koos de leerling) - gaat mee als voortgangdetail zodra het woord af is */
|
||||
const S = { level:1, yearGroup:null, managedLearningLevel:false, card:0, wordIdx:0, stars:0, lang:LANG, count:6, order:[], snd:true, amode:"blocks", assignmentWords:null, assignmentThemeName:"", wordMistakes:[] };
|
||||
if(initState){
|
||||
S.level = initState.level||1;
|
||||
S.yearGroup = initState.yearGroup||null;
|
||||
|
|
@ -145,6 +147,7 @@ function mountLetterGame(root, initState, opts){
|
|||
play(sndGood);
|
||||
checkComplete();
|
||||
}else{
|
||||
S.wordMistakes.push({expected: slot.dataset.letter, given: l});
|
||||
slot.classList.add("shake");
|
||||
setTimeout(()=>slot.classList.remove("shake"), 400);
|
||||
play(sndBad);
|
||||
|
|
@ -256,6 +259,7 @@ function mountLetterGame(root, initState, opts){
|
|||
if(allOpt) allOpt.textContent = T("all");
|
||||
|
||||
const [word, pic] = currentWord();
|
||||
S.wordMistakes = []; /* nieuw woord op het scherm: missers tellen per woord */
|
||||
const literacyMode=languageTaskRules(S.yearGroup,S.level)?.literacyMode||"words";
|
||||
setPic(picEl, pic);
|
||||
countEl.textContent = `${T("word")} ${S.wordIdx+1}/${S.order.length}`;
|
||||
|
|
@ -309,7 +313,11 @@ function mountLetterGame(root, initState, opts){
|
|||
doneEl.querySelector(".dp").textContent = currentWord()[0];
|
||||
doneEl.classList.add("show");
|
||||
nextBtn.classList.add("show");
|
||||
if(onProgress) onProgress({ attempts: 1, correct: 1, stars: 1 });
|
||||
/* het woord is af (er is geen faalmoment), maar de missers onderweg
|
||||
vertellen de leerkracht welke letter lastig was */
|
||||
if(onProgress) onProgress(languageProgressPayload({
|
||||
word: currentWord()[0], mistakes: S.wordMistakes, solved: true, stars: 1 }));
|
||||
S.wordMistakes = [];
|
||||
}
|
||||
}
|
||||
function showCardDone(){
|
||||
|
|
@ -752,6 +760,7 @@ function mountLetterGame(root, initState, opts){
|
|||
play(sndGood);
|
||||
checkComplete();
|
||||
}else{
|
||||
S.wordMistakes.push({expected: target.dataset.letter, given: block.dataset.letter});
|
||||
target.classList.add("shake");
|
||||
setTimeout(()=>target.classList.remove("shake"), 400);
|
||||
play(sndBad);
|
||||
|
|
|
|||
|
|
@ -881,7 +881,11 @@ export default async function api(app) {
|
|||
]);
|
||||
const ASSIGNMENT_LIBRARY_BOARD = 'assignment-library';
|
||||
const THEMED_ASSIGNMENT_WIDGETS = new Set(['letters', 'flits', 'hak', 'ball']);
|
||||
const PROGRESS_ASSIGNMENT_WIDGETS = new Set(['letters', 'ball', 'madd', 'msub', 'mmul', 'mdiv', 'mpct']);
|
||||
// Widgets die een echt afgerond moment loggen; alleen dan kan een stap in een
|
||||
// reeks op bewijs wachten. flits/hak kwamen erbij toen ze een zelfcheck
|
||||
// ("lukte het zelf?") kregen - daarvoor completeerden die stappen meteen,
|
||||
// omdat er geen enkel voortgangsevent kón bestaan.
|
||||
const PROGRESS_ASSIGNMENT_WIDGETS = new Set(['letters', 'ball', 'flits', 'hak', 'madd', 'msub', 'mmul', 'mdiv', 'mpct']);
|
||||
const parseAssignmentTheme = (rawTheme, widgetType, index, reply) => {
|
||||
if (rawTheme == null) return { theme: null };
|
||||
if (!THEMED_ASSIGNMENT_WIDGETS.has(widgetType))
|
||||
|
|
|
|||
147
test/language-progress.test.js
Normal file
147
test/language-progress.test.js
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import Fastify from 'fastify';
|
||||
import cookie from '@fastify/cookie';
|
||||
import rateLimit from '@fastify/rate-limit';
|
||||
import api from '../src/api.js';
|
||||
|
||||
/* Voortgang bij taal. math.js logt sinds v0.4.35-beta per som mét detail; de
|
||||
taalwidgets leverden alleen een kale telling op, waardoor de leerkracht bij
|
||||
taal nooit zag WELK woord moeite kostte. De payloadvorm zit nu in één pure
|
||||
functie, die hier echt uitgevoerd wordt (niet alleen op tekst gecontroleerd),
|
||||
zelfde vm-techniek als de SLO-getalgrenzen in test/pupil-levels.test.js. */
|
||||
async function loadPayloadHelper() {
|
||||
const source = await readFile('public/js/learning-progressions.js', 'utf8');
|
||||
const cut = source.indexOf('let SELF_LEARNING_LEVELS'); // daaronder hangt alles aan de DOM/api
|
||||
const context = { LANG: 'nl', document: undefined };
|
||||
runInNewContext(`${source.slice(0, cut)};globalThis.__payload = languageProgressPayload`, context);
|
||||
// objecten uit een vm-context delen onze prototypes niet: platslaan zodat
|
||||
// deepEqual over de wáárden gaat en niet over de realm
|
||||
return (options) => JSON.parse(JSON.stringify(context.__payload(options)));
|
||||
}
|
||||
|
||||
test('taalvoortgang: een foutloos woord levert één schone poging op', async () => {
|
||||
const payload = await loadPayloadHelper();
|
||||
const result = payload({ word: 'boom', mistakes: [], solved: true, stars: 1 });
|
||||
assert.deepEqual(result, {
|
||||
attempts: 1, correct: 1, stars: 1,
|
||||
detail: { question: 'boom', expected: 'boom', answer: 'boom', correct: true },
|
||||
});
|
||||
assert.ok(!('mistakes' in result.detail), 'zonder missers geen mistakes-lijst');
|
||||
});
|
||||
|
||||
test('taalvoortgang: goed na fouten telt de pogingen en bewaart welke letter fout ging', async () => {
|
||||
const payload = await loadPayloadHelper();
|
||||
const result = payload({
|
||||
word: 'boom', stars: 1, solved: true,
|
||||
mistakes: [{ expected: 'o', given: 'a' }, { expected: 'm', given: 'n' }],
|
||||
});
|
||||
assert.equal(result.attempts, 3, '1 goede plaatsing + 2 missers');
|
||||
assert.equal(result.correct, 1);
|
||||
// de grafiek rekent wrong = attempts - correct: 2 fouten, 1 goed
|
||||
assert.equal(result.attempts - result.correct, 2);
|
||||
assert.equal(result.detail.correct, false, 'niet foutloos: hoort in de foutenlijst');
|
||||
assert.deepEqual(result.detail.mistakes, [
|
||||
{ expected: 'o', given: 'a' }, { expected: 'm', given: 'n' },
|
||||
]);
|
||||
assert.equal(result.detail.answer, 'a', 'eerste misser vult het gegeven antwoord');
|
||||
assert.equal(result.detail.expected, 'o');
|
||||
});
|
||||
|
||||
test('taalvoortgang: een verloren ballonwoord logt een echte fout', async () => {
|
||||
const payload = await loadPayloadHelper();
|
||||
const result = payload({
|
||||
word: 'bloem', solved: false, expected: 'bloem', answer: 'b_o__',
|
||||
mistakes: ['k', 's', 'r', 'a', 'd', 'p'].map((given) => ({ given, expected: 'bloem' })),
|
||||
});
|
||||
assert.equal(result.correct, 0);
|
||||
assert.equal(result.stars, 0, 'geen ster voor een verloren woord');
|
||||
assert.equal(result.attempts, 6);
|
||||
assert.equal(result.detail.correct, false);
|
||||
assert.equal(result.detail.answer, 'b_o__', 'wat de leerling nog open had staan');
|
||||
assert.equal(result.detail.expected, 'bloem');
|
||||
});
|
||||
|
||||
test('taalvoortgang: een zelfcheck zonder ingetypt antwoord blijft één poging', async () => {
|
||||
const payload = await loadPayloadHelper();
|
||||
const nietGelukt = payload({ word: 'schaap', solved: false, selfCheck: true });
|
||||
assert.equal(nietGelukt.attempts, 1, 'ook zonder missers telt dit als één poging');
|
||||
assert.equal(nietGelukt.correct, 0);
|
||||
assert.equal(nietGelukt.detail.answer, null, 'er is geen antwoord om te tonen');
|
||||
assert.equal(nietGelukt.detail.selfCheck, true);
|
||||
const gelukt = payload({ word: 'schaap', solved: true, stars: 1, selfCheck: true });
|
||||
assert.equal(gelukt.correct, 1);
|
||||
assert.equal(gelukt.detail.correct, true);
|
||||
});
|
||||
|
||||
test('taalvoortgang: het detail blijft binnen de servergrens van 1000 tekens', async () => {
|
||||
const payload = await loadPayloadHelper();
|
||||
const result = payload({
|
||||
word: 'w'.repeat(300), solved: true, stars: 1,
|
||||
mistakes: Array.from({ length: 20 }, (_, i) => ({ expected: 'x'.repeat(80), given: String(i) })),
|
||||
});
|
||||
assert.equal(result.detail.mistakes.length, 6, 'hooguit zes missers bewaren');
|
||||
assert.equal(result.detail.question.length, 80, 'woord afgekapt');
|
||||
assert.ok(JSON.stringify(result.detail).length < 1000,
|
||||
'anders gooit sanitizeProgressDetail het hele detail weg');
|
||||
});
|
||||
|
||||
/* De vier taalwidgets moeten de gedeelde helper ook echt gebruiken; de werking
|
||||
in de widget zelf (drag & drop, ballonnen, zelfcheck) is in de browser
|
||||
nagelopen met echte interactie. */
|
||||
test('taalwidgets gebruiken de gedeelde payload en ontvangen opts', async () => {
|
||||
const [letters, ball, flits, hak] = await Promise.all([
|
||||
readFile('public/js/widgets/letters.js', 'utf8'),
|
||||
readFile('public/js/widgets/ball.js', 'utf8'),
|
||||
readFile('public/js/widgets/flits.js', 'utf8'),
|
||||
readFile('public/js/widgets/hak.js', 'utf8'),
|
||||
]);
|
||||
for (const [name, source] of [['letters', letters], ['ball', ball], ['flits', flits], ['hak', hak]]) {
|
||||
assert.match(source, /onProgress\(languageProgressPayload\(/, `${name} logt via de gedeelde vorm`);
|
||||
assert.doesNotMatch(source, /onProgress\(\s*\{\s*attempts/, `${name} heeft geen losse payload meer`);
|
||||
}
|
||||
assert.match(flits, /function mountFlits\(root, initState, opts\)/);
|
||||
assert.match(hak, /function mountHak\(root, initState, opts\)/);
|
||||
// ballon logt op twee plekken: gewonnen én verloren woord
|
||||
assert.equal((ball.match(/languageProgressPayload\(/g) || []).length, 2);
|
||||
// letterblokken telt missers in beide invoerwijzen (toetsenbord en blokken)
|
||||
assert.equal((letters.match(/S\.wordMistakes\.push\(/g) || []).length, 2);
|
||||
});
|
||||
|
||||
test('reeks: een flitsstap wacht nu op een echt afgerond moment', async () => {
|
||||
const teacher = {
|
||||
id: 3, username: 'juf', role: 'teacher', school_id: 2, class_id: null,
|
||||
data: {}, data_rev: 0,
|
||||
};
|
||||
const calls = [];
|
||||
const query = async (sql, params = []) => {
|
||||
calls.push({ sql, params });
|
||||
if (sql.includes('FROM sessions s JOIN users u')) return { rows: [teacher] };
|
||||
if (sql.includes('FROM user_roles')) return { rows: [] };
|
||||
if (sql.startsWith('SELECT * FROM classes WHERE id')) return { rows: [{ id: 50, school_id: 2 }] };
|
||||
if (sql.includes('FROM class_teachers')) return { rows: [{ class_id: 50 }] };
|
||||
if (sql.includes('INSERT INTO assignments')) return { rows: [{ id: 90 }] };
|
||||
return { rows: [] };
|
||||
};
|
||||
const app = Fastify({ trustProxy: 2 });
|
||||
await app.register(cookie);
|
||||
await app.register(rateLimit, { global: false });
|
||||
app.decorate('pg', { query });
|
||||
await app.register(api, { prefix: '/api' });
|
||||
await app.ready();
|
||||
const response = await app.inject({
|
||||
method: 'PUT', url: '/api/assignments/class/50', cookies: { teach_session: 'x'.repeat(64) },
|
||||
payload: {
|
||||
title: 'Taalroute',
|
||||
sequence: [{ widgetType: 'flits', mode: 'werken' }, { widgetType: 'hak', mode: 'werken' }],
|
||||
},
|
||||
});
|
||||
assert.equal(response.statusCode, 200, response.body);
|
||||
const insert = calls.find((call) => call.sql.includes('INSERT INTO assignments'));
|
||||
const sequence = JSON.parse(insert.params[6]);
|
||||
assert.equal(sequence[0].requiresProgress, true, 'flits heeft nu een zelfcheck als bewijs');
|
||||
assert.equal(sequence[1].requiresProgress, true, 'hakken & plakken ook');
|
||||
await app.close();
|
||||
});
|
||||
Loading…
Reference in a new issue