Files
Adminator 1e51ef7def Nachtrag: alle bisher untracked Ordner + hängende Änderungen mit-committen
- Konzept/, didaktik_geografie/, didaktik_simulation/, v2-modules/, v2-platform/
- 12 code-workspace-Files
- STATUS-*.md
- viele M/D/R-Änderungen an bereits getrackten Files
- .gitignore verstärkt: **/.humaninput/, **/secret_keys.txt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-08 02:27:02 +02:00

248 lines
12 KiB
HTML
Raw Permalink 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.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Kosten-Hochrechnung pro Schüler:in/Jahr · GeoGraSim</title>
<style>
:root { --moss:#1f4b37; --sage:#4a7c4e; --sand:#e8c547; --beige:#e8e4d8; --ink:#2a2a2a; --muted:#6a6a6a; --line:rgba(0,0,0,.08); }
*{box-sizing:border-box}
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:var(--beige);color:var(--ink);margin:0;padding:1.5rem;line-height:1.5}
.wrap{max-width:980px;margin:0 auto}
a.back{color:var(--sage);text-decoration:none;font-size:.78rem}
a.back:hover{text-decoration:underline}
h1{font-size:1.6rem;margin:.4rem 0 .2rem;color:var(--moss);letter-spacing:-.01em}
.sub{color:var(--muted);font-size:.85rem;margin-bottom:1.5rem}
.panel{background:#fff;border-radius:10px;padding:1.1rem 1.2rem;border:1px solid var(--line);box-shadow:0 1px 2px rgba(0,0,0,.03);margin-bottom:1rem}
h2{font-size:.95rem;color:var(--moss);margin:0 0 .8rem;font-weight:800;text-transform:uppercase;letter-spacing:.05em}
.params{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:.7rem .9rem}
.param{display:flex;flex-direction:column;gap:.2rem}
.param label{font-size:.7rem;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;font-weight:600}
.param .row{display:flex;align-items:center;gap:.4rem}
.param input[type=number]{flex:1;padding:.45rem .55rem;border:1px solid var(--line);border-radius:6px;font-size:.9rem;font-family:inherit;font-variant-numeric:tabular-nums}
.param input[type=range]{width:100%}
.param .unit{font-size:.75rem;color:var(--muted);min-width:3em}
.param .hint{font-size:.66rem;color:#a0a0a0}
table{width:100%;border-collapse:collapse;font-size:.85rem}
th,td{padding:.55rem .6rem;text-align:left;border-bottom:1px solid var(--line)}
th{font-size:.7rem;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);font-weight:700;background:#fafaf6}
td.num{text-align:right;font-variant-numeric:tabular-nums;font-weight:600}
td.eur{text-align:right;font-variant-numeric:tabular-nums;font-weight:800;color:var(--moss)}
tr.highlight{background:#f4f9f0}
.legend{font-size:.7rem;color:var(--muted);margin-top:.6rem}
.big{font-size:1.8rem;font-weight:800;color:var(--moss);font-variant-numeric:tabular-nums}
.big-label{font-size:.7rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted)}
.summary{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:.8rem;margin-top:.4rem}
.stat{text-align:center;padding:.6rem;background:#f7faf3;border-radius:8px;border:1px solid rgba(74,124,78,.15)}
.note{font-size:.72rem;color:var(--muted);margin-top:.7rem;line-height:1.5}
.row-meta{font-size:.7rem;color:var(--muted);margin-top:.2rem}
details{margin-top:.5rem}
summary{font-size:.78rem;color:var(--sage);cursor:pointer;font-weight:600}
details ul{font-size:.78rem;color:var(--ink);padding-left:1.2rem;margin:.4rem 0}
details li{margin-bottom:.2rem}
</style>
</head>
<body>
<div class="wrap">
<a class="back" href="../">← Local Developer Tools</a>
<h1>💰 Kosten pro Schüler:in / Jahr</h1>
<p class="sub">Hochrechnung der DigitalOcean-Hosting-Kosten basierend auf gemessenen DB-Schreibmengen von Heli, Klima &amp; restlichen Sims. Werte sind live einstellbar.</p>
<div class="panel">
<h2>Rahmenbedingungen</h2>
<div class="params">
<div class="param">
<label for="users">Userbasis</label>
<div class="row"><input type="number" id="users" min="1000" max="500000" step="1000" value="100000"><span class="unit">User</span></div>
<input type="range" min="1000" max="500000" step="1000" value="100000" oninput="document.getElementById('users').value=this.value;recalc()">
</div>
<div class="param">
<label for="hoursPerWeek">Spielzeit pro Woche</label>
<div class="row"><input type="number" id="hoursPerWeek" min="0.25" max="10" step="0.25" value="1"><span class="unit">h/Woche</span></div>
<div class="hint">Schulische Nutzung im Mittel</div>
</div>
<div class="param">
<label for="weeksPerYear">Schulwochen pro Jahr</label>
<div class="row"><input type="number" id="weeksPerYear" min="1" max="52" step="1" value="36"><span class="unit">Wochen</span></div>
<div class="hint">36 = AT-Schuljahr, 40 = aktiver Einsatz</div>
</div>
<div class="param">
<label for="kbPerHour">DB-Schreiblast/Stunde</label>
<div class="row"><input type="number" id="kbPerHour" min="1" max="500" step="1" value="50"><span class="unit">KB/h</span></div>
<div class="hint">Status quo 50 / mit Aggregation ~10</div>
</div>
<div class="param">
<label for="storagePrice">Storage-Preis</label>
<div class="row"><input type="number" id="storagePrice" min="0.05" max="1.0" step="0.05" value="0.20"><span class="unit">$/GB/Monat</span></div>
<div class="hint">DigitalOcean Managed MySQL Tier</div>
</div>
<div class="param">
<label for="basecost">Fix-Infrastruktur</label>
<div class="row"><input type="number" id="basecost" min="0" max="2000" step="10" value="160"><span class="unit">$/Monat</span></div>
<div class="hint">App-Server + DB-Cluster-Base + Backups</div>
</div>
</div>
</div>
<div class="panel">
<h2>Resultat</h2>
<div class="summary">
<div class="stat">
<div class="big-label">DB-Speicher pro Jahr</div>
<div class="big" id="r-storage"> GB</div>
<div class="row-meta" id="r-storage-detail"></div>
</div>
<div class="stat">
<div class="big-label">Gesamtkosten pro Jahr</div>
<div class="big" id="r-total"> $</div>
<div class="row-meta" id="r-total-detail">Storage + Fix-Kosten</div>
</div>
<div class="stat" style="background:#fff8e0;border-color:rgba(232,197,71,.4)">
<div class="big-label">pro Schüler:in / Jahr</div>
<div class="big" id="r-per-user"> ¢</div>
<div class="row-meta" id="r-per-user-detail">Vollkosten geteilt durch Userbasis</div>
</div>
</div>
</div>
<div class="panel">
<h2>Vergleichsszenarien (bei aktueller Eingabe)</h2>
<table>
<thead>
<tr>
<th>Skala</th>
<th class="num">Schüler:innen</th>
<th class="num">DB-Volumen/Jahr</th>
<th class="num">Vollkosten/Jahr</th>
<th class="num">pro Person</th>
</tr>
</thead>
<tbody id="scenarios"></tbody>
</table>
<div class="legend">Annahme: Fix-Infrastruktur skaliert grob mit log<sub>10</sub>(Userbasis). Storage linear.</div>
</div>
<div class="panel">
<h2>Status quo vs. Aggregation</h2>
<table>
<thead>
<tr>
<th>Variante</th>
<th class="num">DB-Volumen/Jahr</th>
<th class="num">Storage-Kosten</th>
<th class="num">Vollkosten</th>
<th class="num">pro Person/Jahr</th>
</tr>
</thead>
<tbody id="aggr"></tbody>
</table>
<details>
<summary>Was bedeutet „Aggregation"?</summary>
<ul>
<li><strong>Live (im Spiel):</strong> 1 Row pro aktiver Schüler:in × Sim, UPSERT → kein Wachstum.</li>
<li><strong>Tagespuffer (24 h):</strong> Heartbeat-Rows landen in `live_pings`, werden nach 24 h gelöscht.</li>
<li><strong>Tages-Archiv (30 Tage):</strong> 1 Row pro Schüler:in × Sim × Tag mit `total_minutes`, `best_stars`.</li>
<li><strong>Langzeit (Schuljahr):</strong> 1 Monats-Aggregat — sehr kompakt.</li>
</ul>
</details>
</div>
<div class="note">
<strong>Messgrundlage:</strong> 1 445 Heli-Assessment-Rows aus Prod-DB (Stand 06.06.2026) — durchschnittlich 340 Byte/Row, 97,8 % davon sind 30-s-Heartbeats. Klima schreibt selten, aber dafür 10 KB pro Final-Submit. Gewichteter Mittelwert: ~50 KB pro Spielstunde.
</div>
</div>
<script>
function fmt(n, digits) {
digits = digits||0;
return n.toLocaleString('de-AT', {minimumFractionDigits:digits, maximumFractionDigits:digits});
}
function fmtBytes(gb) {
if (gb < 1) return fmt(gb*1024, 1) + ' MB';
if (gb < 1024) return fmt(gb, 1) + ' GB';
return fmt(gb/1024, 2) + ' TB';
}
function calcCosts(users, hoursPerWeek, weeks, kbPerHour, storagePrice, basecostMonthly) {
var hoursPerUserPerYear = hoursPerWeek * weeks;
var bytesPerUserPerYear = hoursPerUserPerYear * kbPerHour * 1024;
var totalBytesPerYear = bytesPerUserPerYear * users;
var totalGB = totalBytesPerYear / (1024*1024*1024);
var storageCostYear = totalGB * storagePrice * 12;
var fixCostYear = basecostMonthly * 12;
var totalYear = storageCostYear + fixCostYear;
var perUserCent = (totalYear / users) * 100; // $ → ¢
return { totalGB, storageCostYear, fixCostYear, totalYear, perUserCent, hoursPerUserPerYear, bytesPerUserPerYear };
}
function recalc() {
var users = +document.getElementById('users').value;
var hpw = +document.getElementById('hoursPerWeek').value;
var weeks = +document.getElementById('weeksPerYear').value;
var kbh = +document.getElementById('kbPerHour').value;
var sp = +document.getElementById('storagePrice').value;
var base = +document.getElementById('basecost').value;
var r = calcCosts(users, hpw, weeks, kbh, sp, base);
document.getElementById('r-storage').textContent = fmtBytes(r.totalGB);
document.getElementById('r-storage-detail').textContent =
fmt(r.bytesPerUserPerYear/1024, 0) + ' KB × ' + fmt(users, 0) + ' User';
document.getElementById('r-total').textContent = '$ ' + fmt(r.totalYear, 0);
document.getElementById('r-total-detail').textContent =
'Storage $' + fmt(r.storageCostYear, 0) + ' + Fix $' + fmt(r.fixCostYear, 0);
document.getElementById('r-per-user').textContent = fmt(r.perUserCent, 2) + ' ¢';
document.getElementById('r-per-user-detail').textContent =
'$ ' + fmt(r.perUserCent/100, 4) + ' / Jahr';
// Szenarien (verschiedene Userbasen, alles andere konstant)
var scen = [
{label:'Pilot-Schule', n:500, base: Math.max(15, base*0.10)},
{label:'1 Bezirk', n:5000, base: Math.max(40, base*0.30)},
{label:'1 Bundesland', n:25000, base: Math.max(100, base*0.65)},
{label:'AT 25 %', n:100000, base: base},
{label:'AT 100 %', n:400000, base: base*1.8},
];
var tbody = document.getElementById('scenarios');
tbody.innerHTML = '';
scen.forEach(function(s){
var rs = calcCosts(s.n, hpw, weeks, kbh, sp, s.base);
var tr = document.createElement('tr');
if (s.n === users) tr.className = 'highlight';
tr.innerHTML =
'<td><strong>'+s.label+'</strong></td>' +
'<td class="num">'+fmt(s.n,0)+'</td>' +
'<td class="num">'+fmtBytes(rs.totalGB)+'</td>' +
'<td class="num">$ '+fmt(rs.totalYear,0)+'</td>' +
'<td class="eur">'+fmt(rs.perUserCent,2)+' ¢</td>';
tbody.appendChild(tr);
});
// Status quo vs Aggregation
var statusQuo = calcCosts(users, hpw, weeks, 50, sp, base);
var aggressive = calcCosts(users, hpw, weeks, 10, sp, base);
var rows = [
{label:'Status quo (Heli-Heartbeats 30 s in DB)', r: statusQuo},
{label:'Mit Aggregation (Heartbeats nach 24 h aggregiert)', r: aggressive},
];
var tbA = document.getElementById('aggr');
tbA.innerHTML = '';
rows.forEach(function(r,i){
var tr = document.createElement('tr');
if ((kbh > 25 && i===0) || (kbh <= 25 && i===1)) tr.className = 'highlight';
tr.innerHTML =
'<td><strong>'+r.label+'</strong></td>' +
'<td class="num">'+fmtBytes(r.r.totalGB)+'</td>' +
'<td class="num">$ '+fmt(r.r.storageCostYear,0)+'</td>' +
'<td class="num">$ '+fmt(r.r.totalYear,0)+'</td>' +
'<td class="eur">'+fmt(r.r.perUserCent,2)+' ¢</td>';
tbA.appendChild(tr);
});
}
// Inputs live an Re-Calc binden
['users','hoursPerWeek','weeksPerYear','kbPerHour','storagePrice','basecost']
.forEach(function(id){
document.getElementById(id).addEventListener('input', recalc);
});
recalc();
</script>
</body>
</html>