BrainBit / apps /mathematik-app.html
GitHub Actions
Deploy BrainBit desde GitHub dfe1bad1c7cf060cbc34bf6aa22ae12fae8518b1
bf9f71f
Raw
History Blame Contribute Delete
37.2 kB
<!DOCTYPE html>
<html lang="es">
<head>
<script>
/* BrainBit storage shim: sustituye window.storage (entorno artifacts de Claude.ai)
por localStorage con la misma interfaz async {value}. Notifica el progreso al parent. */
window.storage = {
async get(key){ const v = localStorage.getItem(key); return v === null ? null : { key, value: v }; },
async set(key, value){
localStorage.setItem(key, String(value));
try { if (window.parent !== window) window.parent.postMessage({ source: "brainbit-app", type: "progress", key: key, value: String(value) }, "*"); } catch(e){}
}
};
window.addEventListener("load", () => {
try {
if (window.parent === window) return;
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.endsWith("-progress")) {
window.parent.postMessage({ source: "brainbit-app", type: "progress", key: key, value: localStorage.getItem(key) }, "*");
}
}
} catch(e){}
});
</script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mathematik — ICT-Eignungstest</title>
<style>
:root{
--bg:#F7F6F3; --panel:#FFFFFF; --ink:#1C1C1C; --muted:#6B6864; --line:#D8D5CC;
--red:#D8232A; --pos:#1F7A4C; --pos-bg:#E6F1EA; --neg:#A63A22; --neg-bg:#F6E9E3;
--amber:#B8860B; --purple:#7A2A63; --purple-bg:#F0E3EE; --blue:#2E6DA4;
}
*{box-sizing:border-box;}
html,body{margin:0;padding:0;background:var(--bg);color:var(--ink);
font-family:"Helvetica Neue",Arial,sans-serif;-webkit-font-smoothing:antialiased;}
.mono{font-family:"SF Mono",ui-monospace,Menlo,Consolas,monospace;}
header{background:var(--ink);color:#fff;padding:28px 20px 22px;position:relative;overflow:hidden;}
header .rail{position:absolute;left:0;right:0;bottom:0;height:4px;
background:repeating-linear-gradient(90deg,var(--red) 0 18px, transparent 18px 30px);opacity:.9;}
.eyebrow{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:#C9C6BD;font-weight:700;margin-bottom:8px;}
h1{font-size:26px;margin:0 0 4px;font-weight:800;letter-spacing:-0.01em;}
.sub{font-size:14px;color:#C9C6BD;margin:0;max-width:560px;line-height:1.4;}
nav.tabs{display:flex;background:var(--panel);border-bottom:1px solid var(--line);position:sticky;top:0;z-index:20;}
nav.tabs button{flex:1;padding:14px 4px;font-size:12px;font-weight:700;background:none;border:none;
color:var(--muted);cursor:pointer;border-bottom:3px solid transparent;}
nav.tabs button.active{color:var(--ink);border-bottom-color:var(--red);}
main{max-width:760px;margin:0 auto;padding:20px 16px 60px;}
section{display:none;}
section.active{display:block;}
h2.section-title{font-size:19px;font-weight:800;margin:26px 0 10px;}
h2.section-title:first-child{margin-top:4px;}
h3.sub-title{font-size:15px;font-weight:800;margin:18px 0 8px;color:var(--muted);text-transform:uppercase;letter-spacing:.03em;}
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:18px;margin-bottom:14px;}
.card p{line-height:1.55;font-size:14.5px;margin:0 0 10px;}
.card p:last-child{margin-bottom:0;}
.rule-box{background:#FBF3E4;border:1px solid #E9D9B4;border-radius:8px;padding:14px 16px;margin:10px 0;}
.rule-box b{color:var(--amber);}
.fix-box{background:var(--purple-bg);border:1px solid #DCC0D6;border-radius:8px;padding:14px 16px;margin:10px 0;}
.fix-box b{color:var(--purple);}
table.ref{width:100%;border-collapse:collapse;font-size:13.5px;margin:8px 0;}
table.ref th{text-align:left;padding:7px 8px;background:#F2F0EA;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);}
table.ref td{padding:7px 8px;border-bottom:1px solid var(--line);font-family:"SF Mono",monospace;font-size:13px;}
table.ref tr:last-child td{border-bottom:none;}
/* ===== Sprint ===== */
.sprint-setup{text-align:center;padding:10px 0 4px;}
.sprint-setup p{color:var(--muted);font-size:14px;line-height:1.5;}
.cat-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:14px 0;}
.cat-btn{padding:13px 8px;border:1.5px solid var(--line);border-radius:8px;background:#fff;
font-size:13px;font-weight:700;cursor:pointer;color:var(--ink);}
.cat-btn.sel{border-color:var(--red);background:#FDF1F1;}
.start-btn{width:100%;padding:15px;background:var(--red);color:#fff;border:none;border-radius:8px;
font-size:15px;font-weight:800;cursor:pointer;margin-top:6px;}
.sprint-hud{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px;
font-family:"SF Mono",monospace;font-weight:800;font-size:14px;}
.hud-timer.urgent{color:var(--red);}
.hud-score{color:var(--pos);}
.q-text{font-size:16.5px;font-weight:700;line-height:1.5;margin:6px 0 16px;}
.q-context{font-size:13.5px;color:var(--muted);line-height:1.5;margin-bottom:8px;}
.opt-list{display:flex;flex-direction:column;gap:9px;}
.opt-btn{padding:14px;border:1.5px solid var(--line);border-radius:8px;background:#fff;text-align:left;
font-size:15px;font-weight:700;cursor:pointer;font-family:"SF Mono",monospace;}
.opt-btn:disabled{cursor:default;}
.opt-btn.right{border-color:var(--pos);background:var(--pos-bg);color:var(--pos);}
.opt-btn.wrong{border-color:var(--neg);background:var(--neg-bg);color:var(--neg);}
.q-feedback{margin-top:12px;font-size:13.5px;line-height:1.55;padding:12px;border-radius:8px;display:none;}
.q-feedback.show{display:block;}
.q-feedback.ok{background:var(--pos-bg);color:var(--pos);}
.q-feedback.ko{background:var(--neg-bg);color:var(--neg);}
.next-btn{margin-top:12px;width:100%;padding:13px;background:var(--ink);color:#fff;border:none;
border-radius:8px;font-size:14px;font-weight:800;cursor:pointer;display:none;}
.next-btn.show{display:block;}
.result-hero{text-align:center;padding:24px 10px;}
.result-num{font-size:52px;font-weight:800;font-family:"SF Mono",monospace;}
.result-label{font-size:14px;color:var(--muted);}
.result-best{margin-top:8px;font-size:13px;color:var(--amber);font-weight:700;}
/* ===== Entrenamiento curado ===== */
.ex-card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:16px;margin-bottom:14px;}
.ex-head{display:flex;justify-content:space-between;align-items:flex-start;gap:10px;margin-bottom:8px;}
.ex-num{font-size:11px;font-weight:800;letter-spacing:.06em;color:var(--muted);text-transform:uppercase;}
.level-tag{font-size:10px;font-weight:800;padding:3px 8px;border-radius:20px;text-transform:uppercase;}
.level-medio{background:#FBF3E4;color:var(--amber);}
.level-avanzado{background:var(--neg-bg);color:var(--neg);}
.level-extremo{background:var(--purple-bg);color:var(--purple);}
.ex-desc{font-size:14px;line-height:1.5;margin:6px 0 12px;}
.ex-question{font-size:14px;font-weight:700;margin:10px 0 12px;padding:9px 12px;
background:#F2F0EA;border-left:3px solid var(--red);border-radius:4px;}
.ex-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px;align-items:center;}
button.btn{border:none;border-radius:6px;padding:9px 14px;font-size:13px;font-weight:700;cursor:pointer;}
.btn-primary{background:var(--ink);color:#fff;}
.btn-timer{background:#fff;color:var(--ink);border:1px solid var(--line);}
.btn-timer.running{background:var(--red);color:#fff;border-color:var(--red);}
.btn-master{background:#fff;color:var(--muted);border:1px solid var(--line);}
.btn-master.done{background:var(--pos-bg);color:var(--pos);border-color:var(--pos);}
.timer-display{font-family:"SF Mono",monospace;font-weight:800;font-size:14px;color:var(--muted);}
.timer-display.urgent{color:var(--red);}
.solution{margin-top:12px;padding:14px;background:#F4F8F6;border:1px solid #CFE4D8;
border-radius:8px;font-size:14px;line-height:1.6;display:none;}
.solution.open{display:block;}
.solution b{color:var(--pos);}
.solution .step{margin:0 0 6px;padding-left:12px;border-left:2px solid var(--pos-bg);}
/* ===== Progreso ===== */
.progress-hero{text-align:center;padding:20px 10px 8px;}
.progress-num{font-size:46px;font-weight:800;font-family:"SF Mono",monospace;}
.progress-label{font-size:13px;color:var(--muted);margin-top:2px;}
.bar-track{height:8px;background:var(--line);border-radius:6px;overflow:hidden;margin:16px 0 22px;}
.bar-fill{height:100%;background:var(--red);transition:width .3s ease;}
.stat-row{display:flex;justify-content:space-between;align-items:center;padding:11px 4px;border-bottom:1px solid var(--line);font-size:14px;}
.stat-row:last-child{border-bottom:none;}
.stat-acc{font-family:"SF Mono",monospace;font-weight:800;}
.acc-good{color:var(--pos);} .acc-mid{color:var(--amber);} .acc-bad{color:var(--neg);} .acc-none{color:var(--muted);}
.reset-link{display:block;text-align:center;margin-top:20px;font-size:13px;color:var(--muted);
text-decoration:underline;cursor:pointer;background:none;border:none;}
</style>
</head>
<body>
<header>
<div class="eyebrow">ICT-Eignungstest · Schulwissen</div>
<h1>Mathematik</h1>
<p class="sub">Motor de ejercicios infinitos: cada pregunta se genera al azar y su respuesta se calcula por código — nunca se repiten, nunca tienen errores.</p>
<div class="rail"></div>
</header>
<nav class="tabs">
<button class="tab-btn active" data-tab="teoria">Teoría</button>
<button class="tab-btn" data-tab="sprint">⚡ Sprint</button>
<button class="tab-btn" data-tab="entreno">Entrenamiento</button>
<button class="tab-btn" data-tab="progreso">Progreso</button>
</nav>
<main>
<!-- ================= TEORÍA ================= -->
<section id="teoria" class="active">
<h2 class="section-title">¿Qué evalúa exactamente?</h2>
<div class="card">
<p>Tres bloques: <b>Kopfrechnen</b> (cálculo mental estricto, sin papel ni calculadora), <b>Proportionalität</b> (reglas de tres directas e inversas), y <b>Prozentrechnen</b> (descuentos encadenados, IVA suizo 8.1%, variaciones porcentuales). No mide matemática avanzada: mide <b>velocidad y precisión bajo presión</b>.</p>
</div>
<h2 class="section-title">Tabla de referencia: fracción ↔ decimal ↔ %</h2>
<div class="card">
<p>Memorízala hasta que sea automática — es la herramienta nº1 para comparar fracciones en segundos:</p>
<table class="ref">
<tr><th>Fracción</th><th>Decimal</th><th>%</th><th>Fracción</th><th>Decimal</th><th>%</th></tr>
<tr><td>1/2</td><td>0.5</td><td>50%</td><td>1/8</td><td>0.125</td><td>12.5%</td></tr>
<tr><td>1/3</td><td>0.333</td><td>33.3%</td><td>3/8</td><td>0.375</td><td>37.5%</td></tr>
<tr><td>2/3</td><td>0.667</td><td>66.7%</td><td>5/8</td><td>0.625</td><td>62.5%</td></tr>
<tr><td>1/4</td><td>0.25</td><td>25%</td><td>7/8</td><td>0.875</td><td>87.5%</td></tr>
<tr><td>3/4</td><td>0.75</td><td>75%</td><td>1/16</td><td>0.0625</td><td>6.25%</td></tr>
<tr><td>1/5</td><td>0.2</td><td>20%</td><td>11/16</td><td>0.6875</td><td>68.75%</td></tr>
<tr><td>1/6</td><td>0.167</td><td>16.7%</td><td>1/12</td><td>0.083</td><td>8.3%</td></tr>
</table>
</div>
<h2 class="section-title">Las 5 técnicas de examen</h2>
<div class="card">
<div class="rule-box"><p><b>1 · Descomposición de porcentajes (10%+5%):</b> 15% de 240 → 10%=24, 5%=12 → 36. Otros ladrillos: 25%=un cuarto, 50%=mitad, 1%=÷100. Construye cualquier porcentaje sumando ladrillos: 35% = 25% + 10%.</p></div>
<div class="rule-box"><p><b>2 · Estimación por opción más cercana:</b> cuando hay opciones en pantalla, la respuesta correcta es <b>la opción más cercana al valor exacto</b>, no la que produce tu redondeo. Redondea solo UNO de los factores para minimizar el error (49×19.95 → 49×20 = 980, casi exacto) y luego compara distancias contra las opciones. Redondear los dos factores a la vez acumula error en la misma dirección.</p></div>
<div class="rule-box"><p><b>3 · Simplificación cruzada:</b> en 4/7 × 14/8, cancela antes de multiplicar: 4 con 8 (→1/2), 14 con 7 (→2/1). Resultado 1, sin números grandes.</p></div>
<div class="rule-box"><p><b>4 · Proporcionalidad — decide el tipo ANTES de calcular:</b> pregúntate "si sube X, ¿el resultado sube o baja?". Más tiempo → más solicitudes procesadas = DIRECTA (multiplica). Menos días de plazo → más gente necesaria = INVERSA (usa días-hombre: personas × días = carga constante).</p></div>
<div class="rule-box"><p><b>5 · Redondeo suizo:</b> los precios finales en CHF se redondean a 0.05 (5 céntimos). 350.244 → 350.25. En código: round(x/0.05)×0.05.</p></div>
</div>
<h2 class="section-title">Reglas de divisibilidad (para simplificar fracciones al instante)</h2>
<div class="card">
<table class="ref">
<tr><th>÷</th><th>Regla</th><th>Ejemplo</th></tr>
<tr><td>2</td><td>termina en par</td><td>438 ✓</td></tr>
<tr><td>3</td><td>suma de dígitos ÷3</td><td>438 → 4+3+8=15 ✓</td></tr>
<tr><td>4</td><td>últimos 2 dígitos ÷4</td><td>316 → 16 ✓</td></tr>
<tr><td>5</td><td>termina en 0 o 5</td><td>485 ✓</td></tr>
<tr><td>6</td><td>cumple ÷2 y ÷3</td><td>438 ✓</td></tr>
<tr><td>9</td><td>suma de dígitos ÷9</td><td>738 → 18 ✓</td></tr>
</table>
</div>
<h2 class="section-title">Unidades de datos (la trampa más cara del examen)</h2>
<div class="card">
<p><b>Bits vs Bytes:</b> la velocidad de red se mide en <b>Mbps (megaBITS/s)</b>, el tamaño de archivos en <b>MB (megaBYTES)</b>. 1 Byte = 8 bits, así que <b>1 MB = 8 Mb</b>. Todo cálculo de descarga/subida requiere primero convertir MB→Mb multiplicando por 8. Convención del examen: 1 GB = 1000 MB.</p>
<p><b>Secuencia fija de 3 pasos:</b> ① tamaño → MB, ② MB × 8 = Mb, ③ Mb ÷ Mbps = segundos → convierte a min:seg.</p>
</div>
</section>
<!-- ================= SPRINT ================= -->
<section id="sprint">
<div id="sprint-area"></div>
</section>
<!-- ================= ENTRENAMIENTO ================= -->
<section id="entreno">
<div class="card" style="background:#F2F0EA;border-style:dashed;">
<p style="font-size:13px;color:var(--muted);">Ejercicios curados de referencia con solución explicada paso a paso. Para volumen y velocidad, usa el <b>Sprint</b> — genera preguntas infinitas.</p>
</div>
<h3 class="sub-title">A · Kopfrechnen</h3>
<div id="mental-exercises"></div>
<h3 class="sub-title">B · Proportionalität</h3>
<div id="proportion-exercises"></div>
<h3 class="sub-title">C · Prozentrechnen</h3>
<div id="percent-exercises"></div>
<h3 class="sub-title">D · Datenmengen</h3>
<div id="data-exercises"></div>
</section>
<!-- ================= PROGRESO ================= -->
<section id="progreso">
<div class="progress-hero">
<div class="progress-num" id="prog-count"></div>
<div class="progress-label">mejor puntuación en Sprint (de 10)</div>
</div>
<h2 class="section-title">Precisión por tipo de pregunta (Sprint)</h2>
<div class="card" id="stats-list"></div>
<h2 class="section-title">Entrenamiento curado</h2>
<div class="card" id="prog-list"></div>
<button class="reset-link" id="reset-btn">Reiniciar todo mi progreso</button>
<div style="font-size:12px;color:var(--muted);text-align:center;padding:10px;" id="storage-note"></div>
</section>
</main>
<script>
/* ============================================================
GENERADORES — cada pregunta se construye al azar y su
respuesta correcta se CALCULA. Cero respuestas escritas a mano.
============================================================ */
const ri = (a,b) => a + Math.floor(Math.random()*(b-a+1));
const pick = arr => arr[ri(0,arr.length-1)];
const shuffle = arr => { const a=[...arr]; for(let i=a.length-1;i>0;i--){const j=ri(0,i);[a[i],a[j]]=[a[j],a[i]];} return a; };
const chf = v => (Math.round(v/0.05)*0.05).toFixed(2);
const fmtMinSec = s => `${Math.floor(s/60)} min ${s%60} s`;
/* --- 1. Porcentaje mental --- */
function genPercent(){
const p = pick([5,10,15,20,25,30,35,40,45,50,60,75]);
const N = ri(3,48)*20;
const ans = N*p/100;
let d1 = N*(p+5)/100, d2 = p>5 ? N*(p-5)/100 : N*(p+10)/100;
return {
type:"percent", typeLabel:"Porcentajes",
text:`¿Cuánto es el ${p}% de ${N}?`,
context:"Sin calculadora — usa la descomposición 10% / 5% / 25%.",
options:[ans,d1,d2].map(String), correct:String(ans),
explain:`10% de ${N} = ${N/10}. Construye: ${p}% = ${ans}.`
};
}
/* --- 2. Comparación de fracciones --- */
const FRACS = [[1,2],[3,5],[5,8],[2,3],[7,10],[3,4],[4,5],[5,6],[7,8],[11,16],[9,16],[13,20],[17,20],[5,12],[7,12],[11,12],[2,5],[3,8]];
function genFraction(){
let sel;
do { sel = shuffle(FRACS).slice(0,3); }
while (new Set(sel.map(f=>f[0]/f[1])).size < 3);
const wantMax = Math.random() < 0.5;
const vals = sel.map(f=>f[0]/f[1]);
const target = wantMax ? Math.max(...vals) : Math.min(...vals);
const correct = sel[vals.indexOf(target)];
return {
type:"fraction", typeLabel:"Fracciones",
text:`¿Cuál de estas fracciones es la ${wantMax ? "MAYOR" : "MENOR"}?`,
context:"Convierte a decimal con la tabla de referencia de la Teoría.",
options: sel.map(f=>`${f[0]}/${f[1]}`), correct:`${correct[0]}/${correct[1]}`,
explain:`En decimal: ${sel.map(f=>`${f[0]}/${f[1]} = ${(f[0]/f[1]).toFixed(4).replace(/0+$/,"")}`).join(" · ")}.`
};
}
/* --- 3. Estimación por opción más cercana --- */
function genEstimate(){
for(let tries=0; tries<50; tries++){
const a = ri(17,59);
const b = pick([3.1,4.95,5.9,7.9,9.9,12.1,19.95,24.9,29.9]);
const exact = a*b;
const base = Math.round(exact/10)*10;
const opts = [base-20, base, base+20].filter(o=>o>0);
if(opts.length<3) continue;
const dists = opts.map(o=>Math.abs(o-exact));
const minD = Math.min(...dists);
if(dists.filter(d=>Math.abs(d-minD)<0.6).length > 1) continue; // evitar empates
const correct = opts[dists.indexOf(minD)];
return {
type:"estimate", typeLabel:"Estimación",
text:`Estima: ${a} × ${b} ≈ ? Elige la opción MÁS CERCANA.`,
context:"Redondea solo UNO de los factores para minimizar el error.",
options: opts.map(String), correct:String(correct),
explain:`Valor exacto: ${a} × ${b} = ${exact.toFixed(2)}. La opción más cercana es ${correct} (diferencia ${Math.abs(correct-exact).toFixed(2)}).`
};
}
return genPercent();
}
/* --- 4. Proporcionalidad inversa (días-hombre) --- */
function genInverse(){
for(let tries=0; tries<80; tries++){
const w = ri(2,6), d = pick([6,8,9,10,12]);
const total = w*d;
const divs = [];
for(let x=2;x<d;x++) if(total%x===0) divs.push(x);
if(!divs.length) continue;
const d2 = pick(divs);
const need = total/d2, add = need-w;
if(add<1) continue;
const opts = new Set([add, need, add+1]);
if(add>1) opts.add(add-1);
return {
type:"inverse", typeLabel:"Prop. inversa",
text:`¿Cuántas personas ADICIONALES hay que incorporar?`,
context:`${w} desarrolladores tardan ${d} días en completar un módulo a ritmo constante. El nuevo plazo es de ${d2} días.`,
options: shuffle([...opts]).slice(0,4).map(String), correct:String(add),
explain:`Carga: ${w}×${d} = ${total} días-persona. Necesarios: ${total}÷${d2} = ${need}. Adicionales: ${need}${w} = ${add}. (La trampa clásica es responder ${need}.)`
};
}
return genPercent();
}
/* --- 5. Proporcionalidad directa (tasas) --- */
function genDirect(){
const t = pick([4,5,6,8]), k = ri(4,12)*10;
const N = t*k;
let t2; do { t2 = pick([10,12,15,20]); } while(t2===t);
const ans = k*t2;
const opts = new Set([ans, k*t, ans+k, Math.max(k, ans-k)]);
return {
type:"direct", typeLabel:"Prop. directa",
text:`¿Cuántas solicitudes procesa en ${t2} segundos?`,
context:`Un servidor procesa ${N} solicitudes cada ${t} segundos, a ritmo constante.`,
options: shuffle([...opts]).slice(0,4).map(String), correct:String(ans),
explain:`Tasa: ${N}÷${t} = ${k}/s. En ${t2}s: ${k}×${t2} = ${ans}.`
};
}
/* --- 6. Descuentos encadenados + IVA suizo --- */
function genChained(){
for(let tries=0; tries<50; tries++){
const base = pick([200,250,300,350,400,450,500,600]);
const d1 = pick([10,15,20,25]), d2 = pick([5,10,15]);
const correct = chf(base*(1-d1/100)*(1-d2/100)*1.081);
const trapSum = chf(base*(1-(d1+d2)/100)*1.081); // sumar descuentos (error clásico)
const noVat = chf(base*(1-d1/100)*(1-d2/100)); // olvidar IVA
if(correct===trapSum || correct===noVat || trapSum===noVat) continue;
return {
type:"chained", typeLabel:"Descuentos+IVA",
text:`¿Precio final con IVA incluido? (CHF)`,
context:`Precio base CHF ${base}. Descuento del ${d1}%, luego ${d2}% adicional sobre el precio ya rebajado. Al final se suma el 8.1% de IVA (MWST). Redondeo suizo a 0.05.`,
options: shuffle([correct,trapSum,noVat]), correct,
explain:`${base}×${(1-d1/100).toFixed(2)} = ${(base*(1-d1/100)).toFixed(2)} → ×${(1-d2/100).toFixed(2)} = ${(base*(1-d1/100)*(1-d2/100)).toFixed(2)} → ×1.081 = ${correct}. Trampa: sumar ${d1}+${d2}=${d1+d2}% de golpe da ${trapSum} (incorrecto).`
};
}
return genPercent();
}
/* --- 7. Transferencia de datos --- */
function genData(){
for(let tries=0; tries<80; tries++){
const mbps = pick([10,20,25,40,50,80]);
const s = ri(9,60)*10; // 90..600 s
const mbits = mbps*s;
if(mbits % 8 !== 0) continue;
const MB = mbits/8;
if(MB < 100 || MB > 6000) continue;
const wrongNoX8 = Math.round(MB/mbps); // olvidó ×8
if(wrongNoX8===s) continue;
const opts = new Set([fmtMinSec(s), fmtMinSec(wrongNoX8), fmtMinSec(s+30), fmtMinSec(Math.max(30,s-30))]);
const sizeTxt = MB%1000===0 ? `${MB/1000} GB (=${MB} MB)` : `${MB} MB`;
return {
type:"data", typeLabel:"Datos/Red",
text:`¿Cuánto tarda la descarga?`,
context:`Archivo de ${sizeTxt}. Velocidad: ${mbps} Mbps. (1 MB = 8 Mb)`,
options: shuffle([...opts]).slice(0,4), correct:fmtMinSec(s),
explain:`${MB} MB × 8 = ${mbits} Mb → ${mbits} ÷ ${mbps} = ${s} s = ${fmtMinSec(s)}. Trampa: sin ×8 sale ${fmtMinSec(wrongNoX8)} (incorrecto).`
};
}
return genPercent();
}
const GENERATORS = {
percent: {fn:genPercent, label:"Porcentajes"},
fraction: {fn:genFraction, label:"Fracciones"},
estimate: {fn:genEstimate, label:"Estimación"},
inverse: {fn:genInverse, label:"Prop. inversa"},
direct: {fn:genDirect, label:"Prop. directa"},
chained: {fn:genChained, label:"Descuentos+IVA"},
data: {fn:genData, label:"Datos/Red"}
};
/* ============================================================
SPRINT — 10 preguntas, 4 minutos, puntuación y estadísticas
============================================================ */
const SPRINT_N = 10, SPRINT_SECONDS = 240;
let sprint = null, sprintTimer = null, selectedCats = new Set(Object.keys(GENERATORS));
function renderSprintSetup(){
clearInterval(sprintTimer);
const area = document.getElementById("sprint-area");
area.innerHTML = `
<div class="card sprint-setup">
<h2 class="section-title" style="margin-top:0;">Sprint: ${SPRINT_N} preguntas · ${SPRINT_SECONDS/60} minutos</h2>
<p>Cada pregunta se genera al azar con respuesta calculada por código. Los distractores son los errores clásicos reales (sumar descuentos, olvidar ×8, confundir directa/inversa). Elige qué tipos entrenar:</p>
<div class="cat-grid">
${Object.entries(GENERATORS).map(([k,g]) => `
<button class="cat-btn ${selectedCats.has(k)?'sel':''}" data-cat="${k}">${g.label}</button>
`).join("")}
</div>
<button class="start-btn" id="start-sprint">▶ Empezar Sprint</button>
</div>`;
area.querySelectorAll(".cat-btn").forEach(b => b.addEventListener("click", () => {
const k = b.dataset.cat;
if(selectedCats.has(k)){ if(selectedCats.size>1) selectedCats.delete(k); }
else selectedCats.add(k);
renderSprintSetup();
}));
area.querySelector("#start-sprint").addEventListener("click", startSprint);
}
function startSprint(){
const cats = [...selectedCats];
const qs = [];
for(let i=0;i<SPRINT_N;i++) qs.push(GENERATORS[cats[i%cats.length]].fn());
sprint = { qs: shuffle(qs), idx:0, score:0, remaining:SPRINT_SECONDS };
sprintTimer = setInterval(() => {
sprint.remaining--;
const el = document.getElementById("hud-timer");
if(el){
el.textContent = `${Math.floor(sprint.remaining/60)}:${(sprint.remaining%60).toString().padStart(2,"0")}`;
el.classList.toggle("urgent", sprint.remaining<=30);
}
if(sprint.remaining<=0) endSprint(true);
}, 1000);
renderQuestion();
}
function renderQuestion(){
const q = sprint.qs[sprint.idx];
const area = document.getElementById("sprint-area");
area.innerHTML = `
<div class="card">
<div class="sprint-hud">
<span>${sprint.idx+1} / ${SPRINT_N}</span>
<span class="hud-timer" id="hud-timer">${Math.floor(sprint.remaining/60)}:${(sprint.remaining%60).toString().padStart(2,"0")}</span>
<span class="hud-score">✓ ${sprint.score}</span>
</div>
${q.context ? `<p class="q-context">${q.context}</p>` : ""}
<p class="q-text">${q.text}</p>
<div class="opt-list">
${shuffle(q.options).map(o => `<button class="opt-btn" data-val="${o}">${o}</button>`).join("")}
</div>
<div class="q-feedback" id="q-feedback"></div>
<button class="next-btn" id="next-btn">Siguiente →</button>
</div>`;
area.querySelectorAll(".opt-btn").forEach(btn => btn.addEventListener("click", () => answer(btn, q)));
}
function answer(btn, q){
const area = document.getElementById("sprint-area");
const isRight = btn.dataset.val === q.correct;
area.querySelectorAll(".opt-btn").forEach(b => {
b.disabled = true;
if(b.dataset.val === q.correct) b.classList.add("right");
else if(b===btn && !isRight) b.classList.add("wrong");
});
if(isRight) sprint.score++;
if(!stats[q.type]) stats[q.type] = {ok:0,total:0};
stats[q.type].total++; if(isRight) stats[q.type].ok++;
saveState();
const fb = document.getElementById("q-feedback");
fb.className = `q-feedback show ${isRight?'ok':'ko'}`;
fb.innerHTML = `${isRight ? "✓ Correcto. " : "✗ Incorrecto. "}${q.explain}`;
document.querySelector(".hud-score").textContent = `✓ ${sprint.score}`;
const nb = document.getElementById("next-btn");
nb.classList.add("show");
nb.textContent = sprint.idx+1 < SPRINT_N ? "Siguiente →" : "Ver resultado";
nb.addEventListener("click", () => {
sprint.idx++;
if(sprint.idx < SPRINT_N) renderQuestion(); else endSprint(false);
});
}
function endSprint(timedOut){
clearInterval(sprintTimer);
const prev = best;
if(sprint.score > best){ best = sprint.score; saveState(); }
const area = document.getElementById("sprint-area");
area.innerHTML = `
<div class="card result-hero">
<div class="result-num">${sprint.score} / ${SPRINT_N}</div>
<div class="result-label">${timedOut ? "⏱ Se acabó el tiempo" : "Sprint completado"}</div>
${sprint.score > prev ? `<div class="result-best">★ ¡Nuevo récord personal!</div>` : `<div class="result-best" style="color:var(--muted)">Mejor marca: ${best}/${SPRINT_N}</div>`}
<button class="start-btn" id="again">▶ Otro Sprint</button>
<button class="reset-link" id="back-setup">Cambiar tipos de pregunta</button>
</div>`;
area.querySelector("#again").addEventListener("click", startSprint);
area.querySelector("#back-setup").addEventListener("click", renderSprintSetup);
renderProgress();
}
/* ============================================================
ENTRENAMIENTO CURADO (soluciones verificadas paso a paso)
============================================================ */
const mentalExercises = [
{ id:"m1", level:"medio", seconds:20,
question:"Ordena de menor a mayor: Servidor A = 5/8, Servidor B = 11/16, Servidor C = 3/4",
solution:"<div class='step'>A=5/8=0.625 · B=11/16=0.6875 · C=3/4=0.75</div><b>A (5/8) &lt; B (11/16) &lt; C (3/4)</b>" },
{ id:"m2", level:"medio", seconds:10,
question:"Estima sin calculadora: 49 licencias a CHF 19.95/mes. Si las opciones en pantalla son 970, 990, 1000 — ¿cuál es la MÁS CERCANA?",
solution:"<div class='step'>Valor exacto: 49 × 19.95 = 977.55 CHF.</div><div class='step'>970 → dif. 7.55 · 990 → dif. 12.45 · 1000 → dif. 22.45.</div><b>Respuesta: 970</b>. Técnica: redondea solo UNO de los números (49×20 = 980, casi exacto)." },
{ id:"m3", level:"medio", seconds:8,
question:"Estima sin calculadora: 498 × 3.1 ≈ ?",
solution:"<div class='step'>Redondea: 498→500, 3.1→3 → 1500.</div><b>≈1500</b> (exacto: 1543.8)." },
{ id:"m4", level:"avanzado", seconds:15,
question:"Truco 10%+5%: ¿cuánto es el 15% de 340?",
solution:"<div class='step'>10% = 34 · 5% = 17.</div><b>34 + 17 = 51</b>" },
{ id:"m5", level:"avanzado", seconds:15,
question:"Truco 25%+10%: ¿cuánto es el 35% de 480?",
solution:"<div class='step'>25% = 120 · 10% = 48.</div><b>120 + 48 = 168</b>" }
];
const proportionExercises = [
{ id:"p1", level:"avanzado",
desc:"3 ingenieros tardan 12 días en desarrollar un módulo. El cliente exige que esté listo en 4 días.",
question:"¿Cuántos ingenieros adicionales hay que incorporar?",
solution:"<div class='step'>Carga: 3 × 12 = 36 días-persona.</div><div class='step'>Necesarios: 36 ÷ 4 = 9.</div><b>Adicionales: 9 − 3 = 6</b>",
note:"INVERSA: menos días → más gente. Trampa clásica: responder 9 en vez de los adicionales." },
{ id:"p2", level:"avanzado",
desc:"5 técnicos tardan 8 días en migrar una base de datos. Nuevo plazo: 2 días.",
question:"¿Cuántos técnicos adicionales?",
solution:"<div class='step'>Carga: 5 × 8 = 40. Necesarios: 40 ÷ 2 = 20.</div><b>Adicionales: 20 − 5 = 15</b>" },
{ id:"p3", level:"medio",
desc:"Un servidor procesa 480 solicitudes cada 6 segundos.",
question:"¿Cuántas procesa en 15 segundos?",
solution:"<div class='step'>Tasa: 480 ÷ 6 = 80/s.</div><b>80 × 15 = 1200</b>",
note:"DIRECTA: más tiempo → más solicitudes. Decide el tipo antes de calcular." }
];
const percentExercises = [
{ id:"c1", level:"extremo",
desc:"Servidor cloud: CHF 450/mes. Descuento 20%, luego 10% adicional sobre el rebajado, más 8.1% IVA.",
question:"¿Coste final con IVA?",
solution:"<div class='step'>450 × 0.80 = 360 → × 0.90 = 324 → × 1.081 = 350.244.</div><b>CHF 350.25</b> (redondeo suizo a 0.05)" },
{ id:"c2", level:"extremo",
desc:"Licencia: CHF 300. Descuento 25%, luego 15% adicional, más 8.1% IVA.",
question:"¿Coste final con IVA?",
solution:"<div class='step'>300 × 0.75 = 225 → × 0.85 = 191.25 → × 1.081 ≈ 206.74.</div><b>CHF 206.75</b>" }
];
const dataExercises = [
{ id:"d1", level:"extremo",
desc:"Backup de 1.5 GB. Velocidad: 40 Mbps. (1 GB = 1000 MB, 1 MB = 8 Mb)",
question:"¿Tiempo exacto en min y seg?",
solution:"<div class='step'>1.5 GB = 1500 MB × 8 = 12000 Mb ÷ 40 = 300 s.</div><b>5 min 0 s</b>" },
{ id:"d2", level:"extremo",
desc:"Subida de 800 MB a 20 Mbps.",
question:"¿Tiempo exacto en min y seg?",
solution:"<div class='step'>800 × 8 = 6400 Mb ÷ 20 = 320 s.</div><b>5 min 20 s</b>" }
];
let mastered = {}, stats = {}, best = 0, timers = {};
const allIds = [...mentalExercises, ...proportionExercises, ...percentExercises, ...dataExercises].map(e=>e.id);
function renderTimedGroup(containerId, list, hasTimer){
const container = document.getElementById(containerId);
container.innerHTML = list.map((ex,i) => `
<div class="ex-card">
<div class="ex-head">
<span class="ex-num">${i+1} / ${list.length}</span>
<span class="level-tag level-${ex.level}">${ex.level}</span>
</div>
${ex.desc ? `<p class="ex-desc">${ex.desc}</p>` : ""}
<p class="ex-question">${ex.question}</p>
<div class="ex-actions">
<button class="btn btn-primary reveal-btn" data-id="${ex.id}">Revelar solución</button>
${hasTimer ? `<button class="btn btn-timer timer-btn" data-id="${ex.id}" data-seconds="${ex.seconds}">⏱ ${ex.seconds}s</button><span class="timer-display" id="timer-${ex.id}"></span>` : ""}
<button class="btn btn-master ${mastered[ex.id]?'done':''}" data-id="${ex.id}">${mastered[ex.id]?'✓ Dominado':'Marcar dominado'}</button>
</div>
<div class="solution" id="sol-${ex.id}">${ex.solution}${ex.note ? `<p style="margin-top:8px;color:var(--muted);font-size:13px">${ex.note}</p>` : ""}</div>
</div>`).join("");
container.querySelectorAll(".reveal-btn").forEach(btn => btn.addEventListener("click", () => {
const sol = document.getElementById(`sol-${btn.dataset.id}`);
sol.classList.toggle("open");
btn.textContent = sol.classList.contains("open") ? "Ocultar solución" : "Revelar solución";
}));
container.querySelectorAll(".timer-btn").forEach(btn =>
btn.addEventListener("click", () => startCuratedTimer(btn.dataset.id, parseInt(btn.dataset.seconds))));
container.querySelectorAll(".btn-master").forEach(btn =>
btn.addEventListener("click", async () => { mastered[btn.dataset.id]=!mastered[btn.dataset.id]; await saveState(); renderCurated(); renderProgress(); }));
}
function startCuratedTimer(id, seconds){
if(timers[id]){ clearInterval(timers[id]); timers[id]=null; }
let remaining = seconds;
const display = document.getElementById(`timer-${id}`);
const btn = document.querySelector(`.timer-btn[data-id="${id}"]`);
btn.classList.add("running"); btn.textContent = "Detener";
display.textContent = `0:${seconds.toString().padStart(2,"0")}`;
timers[id] = setInterval(() => {
remaining--;
display.textContent = `0:${Math.max(remaining,0).toString().padStart(2,"0")}`;
display.classList.toggle("urgent", remaining<=3);
if(remaining<=0){ clearInterval(timers[id]); timers[id]=null; display.textContent="¡Tiempo!"; btn.classList.remove("running"); btn.textContent=`⏱ ${seconds}s`; }
},1000);
}
function renderCurated(){
renderTimedGroup("mental-exercises", mentalExercises, true);
renderTimedGroup("proportion-exercises", proportionExercises, false);
renderTimedGroup("percent-exercises", percentExercises, false);
renderTimedGroup("data-exercises", dataExercises, false);
}
/* ============================================================
PROGRESO + STORAGE
============================================================ */
function accClass(pct){ return pct>=80?"acc-good":pct>=50?"acc-mid":"acc-bad"; }
function renderProgress(){
document.getElementById("prog-count").textContent = best>0 ? `${best} / ${SPRINT_N}` : "—";
document.getElementById("stats-list").innerHTML = Object.entries(GENERATORS).map(([k,g]) => {
const s = stats[k];
const inner = s && s.total>0
? `<span class="stat-acc ${accClass(Math.round(s.ok/s.total*100))}">${Math.round(s.ok/s.total*100)}% (${s.ok}/${s.total})</span>`
: `<span class="stat-acc acc-none">sin datos</span>`;
return `<div class="stat-row"><span>${g.label}</span>${inner}</div>`;
}).join("");
const all = [...mentalExercises, ...proportionExercises, ...percentExercises, ...dataExercises];
document.getElementById("prog-list").innerHTML = all.map(ex => `
<div class="stat-row"><span>${ex.question.slice(0,48)}${ex.question.length>48?"…":""}</span>
<span class="stat-acc ${mastered[ex.id]?'acc-good':'acc-none'}">${mastered[ex.id]?"✓":"—"}</span></div>`).join("");
}
async function loadState(){
try{
const res = await window.storage.get("mathematik-progress");
if(res && res.value){
const d = JSON.parse(res.value);
mastered = d.mastered||{}; stats = d.stats||{}; best = d.best||0;
}
}catch(e){}
}
async function saveState(){
try{ await window.storage.set("mathematik-progress", JSON.stringify({mastered, stats, best})); }
catch(e){ const n=document.getElementById("storage-note"); if(n) n.textContent="No se pudo guardar el progreso."; }
}
document.getElementById("reset-btn").addEventListener("click", async () => {
if(!confirm("¿Reiniciar todo tu progreso (récord, estadísticas y dominados)?")) return;
mastered={}; stats={}; best=0;
await saveState(); renderCurated(); renderProgress();
});
document.querySelectorAll(".tab-btn").forEach(btn => btn.addEventListener("click", () => {
document.querySelectorAll(".tab-btn").forEach(b=>b.classList.remove("active"));
document.querySelectorAll("main > section").forEach(s=>s.classList.remove("active"));
btn.classList.add("active");
document.getElementById(btn.dataset.tab).classList.add("active");
}));
(async function init(){
await loadState();
renderSprintSetup();
renderCurated();
renderProgress();
})();
</script>
</body>
</html>