Spaces:
Running
Running
| <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) < B (11/16) < 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> | |