Files
edu/app.js
Sebastian Molenda 526e42a2af
All checks were successful
Deploy to FTP / deploy (push) Successful in 4s
srbdbrfdbfha
2026-05-05 20:45:36 +02:00

505 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Minimal app logic for math training (addition & subtraction)
const state = {
ops: new Set(),
mode: null, // 'timed' or 'training'
score: 0,
currentProblem: null,
answerBuffer: '',
timerId: null,
timeLeft: 60,
settings: {
timedSeconds: 60,
maxResult: 40,
maxOperand: 20,
sessionProblems: 20,
allowNegative: false,
allowFraction: false,
}
}
// elements
const menuScreen = document.getElementById('menu-screen')
const playScreen = document.getElementById('play-screen')
const opsContainer = document.getElementById('ops')
const modeButtons = document.querySelectorAll('.mode-btn')
const opButtons = document.querySelectorAll('.op-btn')
const backBtn = document.getElementById('back-btn')
const problemEl = document.getElementById('problem')
const answerEl = document.getElementById('answer')
const feedbackEl = document.getElementById('feedback')
const timerEl = document.getElementById('timer')
const scoreEl = document.getElementById('score')
const keypad = document.querySelectorAll('.key')
const submitBtn = document.getElementById('submit')
const clearBtn = document.getElementById('clear')
const backspaceBtn = document.getElementById('backspace')
const historyBtn = document.getElementById('history-btn')
const historyScreen = document.getElementById('history-screen')
const historyBack = document.getElementById('history-back')
const historyList = document.getElementById('history-list')
const clearHistoryBtn = document.getElementById('clear-history')
const historyPanel = document.getElementById('history-panel')
const historyToggle = document.getElementById('history-toggle')
const settingTimed = document.getElementById('setting-timed')
const settingMaxResult = document.getElementById('setting-max-result')
const settingMaxOperand = document.getElementById('setting-max-operand')
const settingAllowNegative = document.getElementById('setting-allow-negative')
const settingAllowFraction = document.getElementById('setting-allow-fraction')
const saveSettingsBtn = document.getElementById('save-settings')
const resetSettingsBtn = document.getElementById('reset-settings')
const settingsToggle = document.getElementById('settings-toggle')
const settingsPanel = document.getElementById('settings-panel')
const progressInner = document.getElementById('progress-inner')
const dotBtn = document.getElementById('dot')
const negateBtn = document.getElementById('negate')
const summaryPanel = document.getElementById('summary-panel')
const summaryText = document.getElementById('summary-text')
const summaryBack = document.getElementById('summary-back')
// load settings from localStorage
function loadSettings(){
try{
const raw = localStorage.getItem('matma:settings')
if (raw) {
const s = JSON.parse(raw)
state.settings = Object.assign(state.settings, s)
}
}catch(e){ console.warn('settings load failed', e) }
// reflect to inputs
settingTimed.value = state.settings.timedSeconds
settingMaxResult.value = state.settings.maxResult
settingMaxOperand.value = state.settings.maxOperand
const sess = state.settings.sessionProblems || 20
const settingSessionProblems = document.getElementById('setting-session-problems')
if (settingSessionProblems) settingSessionProblems.value = sess
if (settingAllowNegative) settingAllowNegative.checked = !!state.settings.allowNegative
if (settingAllowFraction) settingAllowFraction.checked = !!state.settings.allowFraction
}
function saveSettings(){
const timed = parseInt(settingTimed.value,10) || state.settings.timedSeconds
const maxResult = parseInt(settingMaxResult.value,10) || state.settings.maxResult
const maxOperand = parseInt(settingMaxOperand.value,10) || state.settings.maxOperand
state.settings.timedSeconds = Math.max(5, Math.min(600, timed))
state.settings.maxResult = Math.max(1, Math.min(999, maxResult))
state.settings.maxOperand = Math.max(1, Math.min(999, maxOperand))
const settingSessionProblems = document.getElementById('setting-session-problems')
if (settingSessionProblems) state.settings.sessionProblems = Math.max(1, Math.min(500, parseInt(settingSessionProblems.value,10) || state.settings.sessionProblems))
if (settingAllowNegative) state.settings.allowNegative = !!settingAllowNegative.checked
if (settingAllowFraction) state.settings.allowFraction = !!settingAllowFraction.checked
try{ localStorage.setItem('matma:settings', JSON.stringify(state.settings)) }catch(e){console.warn('save failed',e)}
// visual feedback
feedbackEl.textContent = 'Ustawienia zapisane'
setTimeout(()=>feedbackEl.textContent = '',1500)
}
function resetSettings(){
state.settings = {timedSeconds:60, maxResult:40, maxOperand:20, sessionProblems:20}
saveSettings()
loadSettings()
}
saveSettingsBtn.addEventListener('click', saveSettings)
resetSettingsBtn.addEventListener('click', resetSettings)
// settings toggle
settingsToggle.addEventListener('click', ()=>{
const collapsed = settingsPanel.classList.toggle('collapsed')
settingsToggle.textContent = collapsed ? 'Pokaż' : 'Ukryj'
})
loadSettings()
// initial wiring
opButtons.forEach(b => {
if (b.classList.contains('disabled')) return
b.addEventListener('click', () => {
const op = b.dataset.op
if (state.ops.has(op)) { state.ops.delete(op); b.classList.remove('active') }
else { state.ops.add(op); b.classList.add('active') }
})
})
modeButtons.forEach(b => b.addEventListener('click', () => {
modeButtons.forEach(x=>x.classList.remove('active'))
b.classList.add('active')
state.mode = b.dataset.mode
// require at least one op selected to start
if (state.ops.size === 0) {
feedbackEl.textContent = 'Wybierz co najmniej jedno działanie.'
return
}
startPlay()
}))
backBtn.addEventListener('click', ()=>{
stopTimer()
playScreen.classList.add('hidden')
menuScreen.classList.remove('hidden')
feedbackEl.textContent = ''
})
function startPlay(){
menuScreen.classList.add('hidden')
playScreen.classList.remove('hidden')
state.score = 0
scoreEl.textContent = state.score
state.answerBuffer = ''
answerEl.textContent = ''
feedbackEl.textContent = ''
state.currentProblem = generateProblem()
renderProblem()
// make problem larger for mobile clarity
problemEl.classList.add('big')
// init progress
if (state.mode === 'timed'){
progressInner.style.width = '0%'
startTimer(state.settings.timedSeconds)
} else {
progressInner.style.width = '0%'
state.sessionSolved = 0
state.sessionTarget = Math.max(1, state.settings.sessionProblems || 20)
updateProgress()
hideTimer()
}
}
function generateProblem(){
// choose an operation from selected ops
const ops = Array.from(state.ops)
const op = ops[Math.floor(Math.random()*ops.length)]
// generate operands within maxOperand and ensure result within maxResult
const maxOp = Math.max(1, state.settings.maxOperand)
const maxRes = Math.max(1, state.settings.maxResult)
let a = 0, b = 0
// try a few times to satisfy constraints
for (let i=0;i<200;i++){
if (op === 'div'){
// divisor b should be non-zero
b = randInt(1, maxOp)
if (!state.settings.allowFraction){
// produce integer result q such that a = q * b and a <= maxOp and q <= maxRes
const maxQByOp = Math.floor(maxOp / b)
const maxQ = Math.min(maxRes, maxQByOp)
if (maxQ < 0) continue
const q = randInt(0, maxQ)
a = q * b
// optional negative results
if (state.settings.allowNegative && Math.random() < 0.2) a = -a
return {a,b,op}
} else {
// allow fractional results: generate tenths (one decimal place)
const maxResultNByOp = Math.floor((10 * maxOp) / b)
const maxResultN = Math.min(maxRes * 10, maxResultNByOp)
if (maxResultN < 0) continue
const result_n = randInt(0, maxResultN)
// ensure dividend is integer: (result_n * b) % 10 === 0
if ((result_n * b) % 10 !== 0) continue
a = (result_n * b) / 10
if (state.settings.allowNegative && Math.random() < 0.2) a = -a
return {a,b,op}
}
} else if (op === 'mul'){
a = randInt(0, maxOp)
b = randInt(0, maxOp)
if (a * b <= maxRes) return {a,b,op}
} else if (op === 'add'){
a = randInt(0, maxOp)
b = randInt(0, maxOp)
if (a + b <= maxRes) return {a,b,op}
} else if (op === 'sub'){
a = randInt(0, maxOp)
b = randInt(0, maxOp)
if (!state.settings.allowNegative){
if (a < b) [a,b] = [b,a]
if (a - b <= maxRes) return {a,b,op}
} else {
// negative allowed: result absolute should be <= maxRes
if (Math.abs(a - b) <= maxRes) return {a,b,op}
}
}
}
// fallback: return simple small numbers
a = Math.min(maxOp, Math.floor(maxRes/2))
b = Math.min(maxOp, 1)
return {a,b,op}
}
function randInt(min,max){
return Math.floor(Math.random()*(max-min+1))+min
}
function renderProblem(){
const p = state.currentProblem
const map = {add: '+', sub: '', mul: '×', div: '÷'}
const symbol = map[p.op] || '?'
problemEl.textContent = `${p.a} ${symbol} ${p.b} = ?`
}
function submitAnswer(){
const buf = state.answerBuffer.trim()
if (buf.length === 0) return
const given = parseFloat(buf)
const p = state.currentProblem
let correct = null
if (p.op === 'add') correct = p.a + p.b
else if (p.op === 'sub') correct = p.a - p.b
else if (p.op === 'mul') correct = p.a * p.b
else if (p.op === 'div') correct = (p.b === 0) ? null : (p.a / p.b)
const eps = state.settings.allowFraction ? 1e-6 : 1e-9
const isCorrect = (correct !== null) && (Math.abs(given - correct) < eps)
if (isCorrect) {
state.score += 1
feedbackEl.textContent = '✔ dobrze'
} else {
feedbackEl.textContent = `✖ poprawne: ${Number.isFinite(correct) ? correct : '—'}`
}
scoreEl.textContent = state.score
// next problem
state.answerBuffer = ''
answerEl.textContent = ''
// increment solved count and handle training limit
if (state.mode === 'training'){
state.sessionSolved = (state.sessionSolved || 0) + 1
updateProgress()
if (state.sessionSolved >= state.sessionTarget){
// reached limit -> show summary instead of next problem
saveSessionToHistory()
showSummary()
return
}
}
// otherwise generate next problem
state.currentProblem = generateProblem()
renderProblem()
}
submitBtn.addEventListener('click', submitAnswer)
keypad.forEach(k => {
k.addEventListener('click', ()=>{
const v = k.textContent.trim()
if (!/^[0-9]$/.test(v)) return
if (state.answerBuffer.length >= 12) return
state.answerBuffer += v
answerEl.textContent = state.answerBuffer
})
})
if (dotBtn){
dotBtn.addEventListener('click', ()=>{
if (!state.settings.allowFraction) return
if (state.answerBuffer.includes('.')) return
if (state.answerBuffer.length === 0) state.answerBuffer = '0'
state.answerBuffer += '.'
answerEl.textContent = state.answerBuffer
})
}
if (negateBtn){
negateBtn.addEventListener('click', ()=>{
if (!state.settings.allowNegative) return
if (state.answerBuffer.startsWith('-')) state.answerBuffer = state.answerBuffer.slice(1)
else state.answerBuffer = '-' + state.answerBuffer
answerEl.textContent = state.answerBuffer
})
}
clearBtn.addEventListener('click', ()=>{
state.answerBuffer = ''
answerEl.textContent = ''
})
backspaceBtn.addEventListener('click', ()=>{
state.answerBuffer = state.answerBuffer.slice(0,-1)
answerEl.textContent = state.answerBuffer
})
function startTimer(seconds){
state.timeLeft = seconds
timerEl.textContent = state.timeLeft
timerEl.classList.remove('hidden')
state.timerId = setInterval(()=>{
state.timeLeft -= 1
timerEl.textContent = state.timeLeft
// update progress bar for timed mode
const pct = Math.max(0, ((seconds - state.timeLeft) / seconds) * 100)
progressInner.style.width = pct + '%'
if (state.timeLeft <= 0) {
stopTimer()
endSession()
}
},1000)
}
function stopTimer(){
if (state.timerId) clearInterval(state.timerId)
state.timerId = null
}
function hideTimer(){
timerEl.classList.add('hidden')
}
function endSession(){
feedbackEl.textContent = `Koniec! Wynik: ${state.score}`
saveSessionToHistory()
// fill progress
progressInner.style.width = '100%'
// return to menu after short pause (timed mode)
setTimeout(()=>{
playScreen.classList.add('hidden')
menuScreen.classList.remove('hidden')
},2500)
}
function showSummary(){
// hide problem area and keypad, show summary panel
const summaryTextEl = summaryText
if (summaryTextEl) summaryTextEl.textContent = `Poprawne odpowiedzi: ${state.score}`
// hide play UI sections
const problemArea = document.querySelector('.problem-area')
const answerArea = document.querySelector('.answer-area')
if (problemArea) problemArea.classList.add('hidden')
if (answerArea) answerArea.classList.add('hidden')
if (summaryPanel) summaryPanel.classList.remove('hidden')
}
function hideSummary(){
const problemArea = document.querySelector('.problem-area')
const answerArea = document.querySelector('.answer-area')
if (problemArea) problemArea.classList.remove('hidden')
if (answerArea) answerArea.classList.remove('hidden')
if (summaryPanel) summaryPanel.classList.add('hidden')
}
if (summaryBack) summaryBack.addEventListener('click', ()=>{
// close summary and go back to menu
hideSummary()
playScreen.classList.add('hidden')
menuScreen.classList.remove('hidden')
})
function updateProgress(){
if (state.mode === 'training'){
const solved = state.sessionSolved || 0
const target = state.sessionTarget || 1
const pct = Math.min(100, Math.round((solved/target)*100))
progressInner.style.width = pct + '%'
// show count in status
const status = document.getElementById('status')
if (status) status.textContent = `${solved}/${target}`
}
}
// history persistence
function loadHistory(){
try{
const raw = localStorage.getItem('matma:history')
return raw ? JSON.parse(raw) : []
}catch(e){ return [] }
}
function saveSessionToHistory(){
const h = loadHistory()
h.unshift({ts: Date.now(), mode: state.mode, score: state.score, settings: {...state.settings}})
// keep only last 100
while(h.length>100) h.pop()
try{ localStorage.setItem('matma:history', JSON.stringify(h)) }catch(e){ }
}
function renderHistory(){
const h = loadHistory()
historyList.innerHTML = ''
if (h.length === 0) {
historyList.innerHTML = '<div class="history-item">Brak zapisanych sesji</div>'
if (historyList) historyList.innerHTML = historyList.innerHTML
return
}
h.forEach(item => {
const d = new Date(item.ts).toLocaleString()
const el = document.createElement('div')
el.className = 'history-item'
el.innerHTML = `<div><strong>${item.mode === 'timed' ? 'Na czas' : 'Trening'}</strong> — Wynik: ${item.score}</div><div class="history-meta">${d} • czas:${item.settings.timedSeconds}s maxRes:${item.settings.maxResult} maxOp:${item.settings.maxOperand}</div>`
historyList.appendChild(el)
})
if (historyList) historyList.innerHTML = historyList.innerHTML
}
// Toggle in-menu history panel (behaves like settings)
function toggleHistory(){
if (!historyPanel) return
// toggle collapsed class (collapsed => hidden)
const nowCollapsed = historyPanel.classList.toggle('collapsed')
// nowCollapsed is true when panel is hidden
if (!nowCollapsed) renderHistory()
// keep main menu button label constant; header toggle shows state
historyBtn.textContent = 'Historia'
if (historyToggle) historyToggle.textContent = nowCollapsed ? 'Pokaż' : 'Ukryj'
}
// history toggle
historyToggle.addEventListener('click', ()=>{
const collapsed = historyPanel.classList.toggle('collapsed')
historyToggle.textContent = collapsed ? 'Pokaż' : 'Ukryj'
if (!collapsed) renderHistory()
else historyList.innerHTML = ''
})
// historyBtn.addEventListener('click', toggleHistory)
// if (historyToggle) historyToggle.addEventListener('click', toggleHistory)
// historyBack.addEventListener('click', ()=>{
// historyScreen.classList.add('hidden')
// menuScreen.classList.remove('hidden')
// })
// clearHistoryBtn.addEventListener('click', ()=>{
// try{ localStorage.removeItem('matma:history') }catch(e){}
// renderHistory()
// })
// keyboard support: allow Enter/Backspace/0-9
window.addEventListener('keydown', (e)=>{
if (playScreen.classList.contains('hidden')) return
if (/^[0-9]$/.test(e.key)){
if (state.answerBuffer.length < 6) {
state.answerBuffer += e.key
answerEl.textContent = state.answerBuffer
}
} else if (e.key === 'Backspace'){
state.answerBuffer = state.answerBuffer.slice(0,-1)
answerEl.textContent = state.answerBuffer
} else if (e.key === 'Enter'){
submitAnswer()
}
})
// small helper to set initial focus - mobile browsers will not open keyboard for divs
document.addEventListener('touchstart', ()=>{}, {passive:true})
// Load commit SHA into footer (simple: prefer window.COMMIT_SHA/meta, else fetch /version.sha which contains only the hash)
document.addEventListener('DOMContentLoaded', async () => {
const el = document.getElementById('commit-sha')
if (!el) return
let sha = (window.COMMIT_SHA || '').toString().trim()
if (!sha) {
const meta = document.querySelector('meta[name="commit-sha"]')
if (meta && meta.content) sha = meta.content.trim()
}
if (!sha) {
try {
const res = await fetch('/version.sha', { cache: 'no-cache' })
if (res.ok) {
const txt = await res.text()
const first = txt.split(/\r?\n/).find(l => l.trim().length > 0)
if (first) sha = first.trim()
}
} catch (e) {
// ignore
}
}
if (sha) el.textContent = sha.slice(0,8)
})