diff --git a/VERSION b/VERSION index a9ce227..acfd48e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.62-beta +0.4.63-beta diff --git a/public/css/teach.css b/public/css/teach.css index c901283..cb6adbf 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -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)} diff --git a/public/js/admin.js b/public/js/admin.js index 82571e6..6b9c330 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -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); }); diff --git a/public/js/core.js b/public/js/core.js index 9178383..2a82781 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -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", diff --git a/public/js/learning-progressions.js b/public/js/learning-progressions.js index 6afdf6f..ef2e1db 100644 --- a/public/js/learning-progressions.js +++ b/public/js/learning-progressions.js @@ -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]; diff --git a/public/js/widgets/ball.js b/public/js/widgets/ball.js index a46d0b6..1c9d4ae 100644 --- a/public/js/widgets/ball.js +++ b/public/js/widgets/ball.js @@ -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 })); } } } diff --git a/public/js/widgets/flits.js b/public/js/widgets/flits.js index fd0e8f7..3432108 100644 --- a/public/js/widgets/flits.js +++ b/public/js/widgets/flits.js @@ -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); diff --git a/public/js/widgets/hak.js b/public/js/widgets/hak.js index 2620166..2ac819c 100644 --- a/public/js/widgets/hak.js +++ b/public/js/widgets/hak.js @@ -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){
+