mirror of
https://github.com/skoelle/PeopleCostCounter.git
synced 2026-09-17 20:00:25 +00:00
277 lines
18 KiB
HTML
277 lines
18 KiB
HTML
<!-- Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de) -->
|
||
<!-- Licensed under the MIT License. See LICENSE file in project root for details. -->
|
||
<!DOCTYPE html>
|
||
<html lang="de" data-theme="light">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Meeting Cost Tracker</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300..700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
|
||
<style>
|
||
:root, [data-theme="light"] {
|
||
--color-bg:#f7f6f2;--color-surface:#f9f8f5;--color-surface-2:#fbfbf9;
|
||
--color-surface-offset:#f3f0ec;--color-divider:#dcd9d5;--color-border:#d4d1ca;
|
||
--color-text:#28251d;--color-text-muted:#7a7974;--color-text-faint:#bab9b4;
|
||
--color-primary:#01696f;--color-primary-hover:#0c4e54;
|
||
--color-warning:#964219;--color-success:#437a22;
|
||
--shadow-sm:0 1px 2px oklch(0.2 0.01 80/0.06);
|
||
--shadow-md:0 4px 12px oklch(0.2 0.01 80/0.08);
|
||
--shadow-lg:0 12px 32px oklch(0.2 0.01 80/0.12);
|
||
--radius-sm:.375rem;--radius-md:.5rem;--radius-lg:.75rem;--radius-xl:1rem;--radius-full:9999px;
|
||
--text-xs:clamp(.75rem,.7rem + .25vw,.875rem);
|
||
--text-sm:clamp(.875rem,.8rem + .35vw,1rem);
|
||
--text-base:clamp(1rem,.95rem + .25vw,1.125rem);
|
||
--text-lg:clamp(1.125rem,1rem + .75vw,1.5rem);
|
||
--text-xl:clamp(1.5rem,1.2rem + 1.25vw,2.25rem);
|
||
--space-1:.25rem;--space-2:.5rem;--space-3:.75rem;--space-4:1rem;
|
||
--space-5:1.25rem;--space-6:1.5rem;--space-8:2rem;
|
||
--font-body:'Inter','Helvetica Neue',sans-serif;
|
||
--font-mono:'JetBrains Mono','Courier New',monospace;
|
||
--transition:180ms cubic-bezier(.16,1,.3,1);
|
||
}
|
||
[data-theme="dark"] {
|
||
--color-bg:#171614;--color-surface:#1c1b19;--color-surface-2:#201f1d;
|
||
--color-surface-offset:#22211f;--color-divider:#262523;--color-border:#393836;
|
||
--color-text:#cdccca;--color-text-muted:#797876;--color-text-faint:#5a5957;
|
||
--color-primary:#4f98a3;--color-primary-hover:#227f8b;
|
||
--color-warning:#bb653b;--color-success:#6daa45;
|
||
--shadow-sm:0 1px 2px oklch(0 0 0/.2);
|
||
--shadow-md:0 4px 12px oklch(0 0 0/.3);
|
||
--shadow-lg:0 12px 32px oklch(0 0 0/.4);
|
||
}
|
||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||
html{-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
|
||
body{min-height:100dvh;font-family:var(--font-body);font-size:var(--text-base);color:var(--color-text);background:var(--color-bg);display:flex;flex-direction:column;align-items:center;padding:var(--space-8) var(--space-4)}
|
||
input,button{font:inherit;color:inherit}
|
||
a,button,input{transition:color var(--transition),background var(--transition),border-color var(--transition),box-shadow var(--transition)}
|
||
button{cursor:pointer;background:none;border:none}
|
||
.app-header{width:100%;max-width:720px;display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-8)}
|
||
.logo{display:flex;align-items:center;gap:var(--space-3)}
|
||
.logo-icon{color:var(--color-primary)}
|
||
.logo-text{font-size:var(--text-lg);font-weight:600;color:var(--color-text);letter-spacing:-.02em}
|
||
.logo-sub{font-size:var(--text-xs);color:var(--color-text-muted);margin-top:1px}
|
||
.theme-toggle{width:36px;height:36px;border-radius:var(--radius-md);display:flex;align-items:center;justify-content:center;color:var(--color-text-muted);border:1px solid oklch(from var(--color-text) l c h/.1)}
|
||
.theme-toggle:hover{color:var(--color-text);background:var(--color-surface)}
|
||
.card{width:100%;max-width:720px;background:var(--color-surface);border:1px solid oklch(from var(--color-text) l c h/.08);border-radius:var(--radius-xl);padding:var(--space-8);box-shadow:var(--shadow-md)}
|
||
.form-row{display:grid;grid-template-columns:1fr 1fr auto;gap:var(--space-4);align-items:end;margin-bottom:var(--space-6)}
|
||
@media(max-width:600px){.form-row{grid-template-columns:1fr}}
|
||
.form-field{display:flex;flex-direction:column;gap:var(--space-2)}
|
||
.form-label{font-size:var(--text-xs);font-weight:600;color:var(--color-text-muted);text-transform:uppercase;letter-spacing:.06em}
|
||
.form-input{padding:var(--space-3) var(--space-4);background:var(--color-bg);border:1px solid var(--color-border);border-radius:var(--radius-md);font-size:var(--text-base);color:var(--color-text);outline:none;width:100%}
|
||
.form-input:focus{border-color:var(--color-primary);box-shadow:0 0 0 3px oklch(from var(--color-primary) l c h/.15)}
|
||
.form-input:disabled{opacity:.5;cursor:not-allowed}
|
||
.form-hint{font-size:var(--text-xs);color:var(--color-text-faint);margin-top:var(--space-1)}
|
||
.input-prefix-wrap{position:relative}
|
||
.input-prefix-wrap .form-input{padding-left:var(--space-8)}
|
||
.input-prefix{position:absolute;left:var(--space-3);top:50%;transform:translateY(-50%);color:var(--color-text-muted);font-size:var(--text-sm);pointer-events:none;line-height:1}
|
||
.btn-start{padding:var(--space-3) var(--space-6);background:var(--color-primary);color:#fff;border-radius:var(--radius-md);font-size:var(--text-sm);font-weight:600;white-space:nowrap;display:flex;align-items:center;gap:var(--space-2);box-shadow:var(--shadow-sm);height:44px}
|
||
.btn-start:hover{background:var(--color-primary-hover);box-shadow:var(--shadow-md)}
|
||
.btn-start:active{transform:scale(.98)}
|
||
.btn-reset{padding:var(--space-2) var(--space-4);border:1px solid var(--color-border);border-radius:var(--radius-md);font-size:var(--text-xs);color:var(--color-text-muted);font-weight:500;display:flex;align-items:center;gap:var(--space-2)}
|
||
.btn-reset:hover{color:var(--color-text);border-color:var(--color-text-muted)}
|
||
.divider{height:1px;background:var(--color-divider);margin:var(--space-6) 0}
|
||
.action-row{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:var(--space-3);margin-bottom:var(--space-6)}
|
||
.status-badge{display:inline-flex;align-items:center;gap:var(--space-2);padding:var(--space-1) var(--space-3);border-radius:var(--radius-full);font-size:var(--text-xs);font-weight:600;letter-spacing:.04em;text-transform:uppercase;background:var(--color-surface-offset);color:var(--color-text-muted);border:1px solid var(--color-border)}
|
||
.status-badge.running{background:color-mix(in oklch,var(--color-warning) 12%,var(--color-surface));color:var(--color-warning);border-color:color-mix(in oklch,var(--color-warning) 30%,var(--color-border))}
|
||
.status-badge.stopped{background:color-mix(in oklch,var(--color-success) 10%,var(--color-surface));color:var(--color-success);border-color:color-mix(in oklch,var(--color-success) 30%,var(--color-border))}
|
||
.running-indicator{width:10px;height:10px;border-radius:var(--radius-full);background:currentColor;display:inline-block}
|
||
@keyframes pulse{0%{opacity:1}50%{opacity:.3}100%{opacity:1}}
|
||
.running-indicator.active{animation:pulse 1.2s ease-in-out infinite}
|
||
.cost-display-wrap{position:relative;background:var(--color-surface-offset);border:1px solid oklch(from var(--color-text) l c h/.06);border-radius:var(--radius-lg);padding:var(--space-8) var(--space-8) var(--space-6);text-align:center;min-height:160px;display:flex;flex-direction:column;align-items:center;justify-content:center}
|
||
.cost-display-label{font-size:var(--text-xs);color:var(--color-text-muted);text-transform:uppercase;letter-spacing:.08em;font-weight:600;margin-bottom:var(--space-3)}
|
||
.cost-display-amount{font-family:var(--font-mono);font-size:clamp(2.5rem,6vw,4.5rem);font-weight:600;letter-spacing:-.03em;color:var(--color-text);line-height:1;transition:color .3s ease}
|
||
.cost-display-amount.running{color:var(--color-warning)}
|
||
.cost-display-currency{font-size:var(--text-xl);font-weight:400;color:var(--color-text-muted);vertical-align:super;margin-right:var(--space-2);font-family:var(--font-mono)}
|
||
.cost-display-elapsed{margin-top:var(--space-3);font-size:var(--text-xs);color:var(--color-text-faint);font-family:var(--font-mono)}
|
||
.info-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:var(--space-3);margin-top:var(--space-6)}
|
||
.info-tile{background:var(--color-surface-2,var(--color-surface));border:1px solid oklch(from var(--color-text) l c h/.06);border-radius:var(--radius-md);padding:var(--space-4)}
|
||
.info-tile-label{font-size:var(--text-xs);color:var(--color-text-muted);text-transform:uppercase;letter-spacing:.05em;font-weight:600;margin-bottom:var(--space-2)}
|
||
.info-tile-value{font-family:var(--font-mono);font-size:var(--text-sm);font-weight:600;color:var(--color-text)}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header class="app-header">
|
||
<div class="logo">
|
||
<svg class="logo-icon" width="32" height="32" viewBox="0 0 32 32" fill="none" aria-label="Meeting Cost Tracker">
|
||
<circle cx="16" cy="16" r="14" stroke="currentColor" stroke-width="2"/>
|
||
<path d="M16 8v8l5 3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||
<circle cx="16" cy="16" r="2" fill="currentColor"/>
|
||
</svg>
|
||
<div>
|
||
<div class="logo-text">Meeting Cost Tracker</div>
|
||
<div class="logo-sub">Echtzeit-Kostenübersicht fuer Meetings</div>
|
||
</div>
|
||
</div>
|
||
<button class="theme-toggle" data-theme-toggle aria-label="Theme umschalten">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||
</svg>
|
||
</button>
|
||
</header>
|
||
<main class="card">
|
||
<div class="form-row">
|
||
<div class="form-field">
|
||
<label class="form-label" for="devCount">Entwickler anwesend</label>
|
||
<input class="form-input" type="number" id="devCount" min="1" max="500" value="5">
|
||
<div class="form-hint">Anzahl der Teilnehmer</div>
|
||
</div>
|
||
<div class="form-field">
|
||
<label class="form-label" for="avgSalary">Avg. Monatsgehalt</label>
|
||
<div class="input-prefix-wrap">
|
||
<span class="input-prefix">EUR</span>
|
||
<input class="form-input" type="number" id="avgSalary" min="1000" max="50000" value="5000" style="padding-left:3.5rem">
|
||
</div>
|
||
<div class="form-hint">Brutto, durchschnittlich</div>
|
||
</div>
|
||
<button class="btn-start" id="mainBtn" onclick="handleBtn()">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" id="btnIcon"><polygon points="5,3 19,12 5,21"/></svg>
|
||
<span id="btnLabel">Start</span>
|
||
</button>
|
||
</div>
|
||
<div class="divider"></div>
|
||
<div class="action-row">
|
||
<span class="status-badge" id="statusBadge">
|
||
<span class="running-indicator" id="runIndicator"></span>
|
||
Bereit
|
||
</span>
|
||
<button class="btn-reset" id="resetBtn" onclick="resetTimer()" style="display:none">
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
|
||
Zuruecksetzen
|
||
</button>
|
||
</div>
|
||
<div class="cost-display-wrap">
|
||
<div class="cost-display-label">Verbrauchte Meeting-Kosten</div>
|
||
<div class="cost-display-amount" id="costDisplay">
|
||
<span class="cost-display-currency">EUR</span><span id="costValue">0,00</span>
|
||
</div>
|
||
<div class="cost-display-elapsed" id="elapsedDisplay">Noch nicht gestartet</div>
|
||
</div>
|
||
<div class="info-grid" id="infoGrid" style="display:none">
|
||
<div class="info-tile">
|
||
<div class="info-tile-label">Kosten / Sekunde</div>
|
||
<div class="info-tile-value" id="tilePerSec">-</div>
|
||
</div>
|
||
<div class="info-tile">
|
||
<div class="info-tile-label">Kosten / Minute</div>
|
||
<div class="info-tile-value" id="tilePerMin">-</div>
|
||
</div>
|
||
<div class="info-tile">
|
||
<div class="info-tile-label">Kosten / Stunde</div>
|
||
<div class="info-tile-value" id="tilePerHour">-</div>
|
||
</div>
|
||
<div class="info-tile">
|
||
<div class="info-tile-label">Personen x Gehalt</div>
|
||
<div class="info-tile-value" id="tileTotal">-</div>
|
||
</div>
|
||
<div class="info-tile">
|
||
<div class="info-tile-label">Jahresgehalt gesamt</div>
|
||
<div class="info-tile-value" id="tileAnnual">-</div>
|
||
</div>
|
||
<div class="info-tile">
|
||
<div class="info-tile-label">inkl. Arbeitgeberanteil (×1,2)</div>
|
||
<div class="info-tile-value" id="tileEmployer">-</div>
|
||
</div>
|
||
<div class="info-tile">
|
||
<div class="info-tile-label">Gesamtkosten / Jahr</div>
|
||
<div class="info-tile-value" id="tileAnnualTotal">-</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
<script>
|
||
(function(){
|
||
var root=document.documentElement;
|
||
var dark=false;
|
||
var btn=document.querySelector('[data-theme-toggle]');
|
||
var sunSvg='<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>';
|
||
var moonSvg='<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>';
|
||
if(btn){
|
||
// initialize button/icon to reflect light theme
|
||
root.setAttribute('data-theme', dark ? 'dark' : 'light');
|
||
btn.innerHTML = dark ? sunSvg : moonSvg;
|
||
btn.addEventListener('click', function(){
|
||
dark = !dark; root.setAttribute('data-theme', dark ? 'dark' : 'light'); btn.innerHTML = dark ? sunSvg : moonSvg;
|
||
});
|
||
}
|
||
})();
|
||
var timer=null,isRunning=false,startTime=null,pausedElapsed=0;
|
||
function fmt(n){return n.toLocaleString('de-DE',{minimumFractionDigits:2,maximumFractionDigits:2});}
|
||
function fmtElapsed(sec){
|
||
var h=Math.floor(sec/3600),m=Math.floor((sec%3600)/60),s=Math.floor(sec%60);
|
||
if(h>0)return h+'h '+String(m).padStart(2,'0')+'m '+String(s).padStart(2,'0')+'s';
|
||
if(m>0)return m+'m '+String(s).padStart(2,'0')+'s';
|
||
return s+'s gelaufen';
|
||
}
|
||
function getParams(){
|
||
var devCount=Math.max(1,parseInt(document.getElementById('devCount').value)||1);
|
||
var avgSalary=Math.max(0,parseFloat(document.getElementById('avgSalary').value)||5000);
|
||
var annualSalary=avgSalary*12;
|
||
var annualWithEmployer=annualSalary*1.2;
|
||
var totalCost=annualWithEmployer*devCount;
|
||
var costPerSecond=totalCost/220/8/60/60;
|
||
return{devCount:devCount,avgSalary:avgSalary,totalCost:totalCost,costPerSecond:costPerSecond};
|
||
}
|
||
function tick(){
|
||
var elapsed=pausedElapsed+(Date.now()-startTime)/1000;
|
||
var p=getParams();
|
||
var spent=p.costPerSecond*elapsed;
|
||
document.getElementById('costValue').textContent=fmt(spent);
|
||
document.getElementById('elapsedDisplay').textContent=fmtElapsed(elapsed);
|
||
document.getElementById('tilePerSec').textContent='EUR '+p.costPerSecond.toLocaleString('de-DE',{minimumFractionDigits:4,maximumFractionDigits:4});
|
||
document.getElementById('tilePerMin').textContent='EUR '+fmt(p.costPerSecond*60);
|
||
document.getElementById('tilePerHour').textContent='EUR '+fmt(p.costPerSecond*3600);
|
||
document.getElementById('tileTotal').textContent=p.devCount+' x EUR '+fmt(p.avgSalary);
|
||
// show annual / employer / total year values (as integers)
|
||
var annual = p.annualSalary || p.avgSalary*12;
|
||
var employer = p.annualWithEmployer || annual*1.2;
|
||
var annualTotal = p.totalCost || employer * p.devCount;
|
||
document.getElementById('tileAnnual').textContent = annual.toLocaleString('de-DE',{minimumFractionDigits:0,maximumFractionDigits:0}) + ' €';
|
||
document.getElementById('tileEmployer').textContent = employer.toLocaleString('de-DE',{minimumFractionDigits:0,maximumFractionDigits:0}) + ' €';
|
||
document.getElementById('tileAnnualTotal').textContent = annualTotal.toLocaleString('de-DE',{minimumFractionDigits:0,maximumFractionDigits:0}) + ' €';
|
||
}
|
||
function handleBtn(){isRunning?pauseTimer():startTimer();}
|
||
function startTimer(){
|
||
isRunning=true;startTime=Date.now();
|
||
timer=setInterval(tick,100);
|
||
document.getElementById('btnLabel').textContent='Pause';
|
||
document.getElementById('btnIcon').innerHTML='<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>';
|
||
document.getElementById('costDisplay').classList.add('running');
|
||
document.getElementById('statusBadge').className='status-badge running';
|
||
document.getElementById('statusBadge').innerHTML='<span class="running-indicator active"></span> Laeuft';
|
||
document.getElementById('resetBtn').style.display='flex';
|
||
document.getElementById('infoGrid').style.display='grid';
|
||
document.getElementById('devCount').disabled=true;
|
||
document.getElementById('avgSalary').disabled=true;
|
||
tick();
|
||
}
|
||
function pauseTimer(){
|
||
isRunning=false;pausedElapsed+=(Date.now()-startTime)/1000;
|
||
clearInterval(timer);timer=null;
|
||
document.getElementById('btnLabel').textContent='Weiter';
|
||
document.getElementById('btnIcon').innerHTML='<polygon points="5,3 19,12 5,21"/>';
|
||
document.getElementById('costDisplay').classList.remove('running');
|
||
document.getElementById('statusBadge').className='status-badge stopped';
|
||
document.getElementById('statusBadge').innerHTML='<span class="running-indicator"></span> Pausiert';
|
||
}
|
||
function resetTimer(){
|
||
if(timer)clearInterval(timer);
|
||
timer=null;isRunning=false;pausedElapsed=0;startTime=null;
|
||
document.getElementById('costValue').textContent='0,00';
|
||
document.getElementById('elapsedDisplay').textContent='Noch nicht gestartet';
|
||
document.getElementById('btnLabel').textContent='Start';
|
||
document.getElementById('btnIcon').innerHTML='<polygon points="5,3 19,12 5,21"/>';
|
||
document.getElementById('costDisplay').classList.remove('running');
|
||
document.getElementById('statusBadge').className='status-badge';
|
||
document.getElementById('statusBadge').innerHTML='<span class="running-indicator"></span> Bereit';
|
||
document.getElementById('resetBtn').style.display='none';
|
||
document.getElementById('infoGrid').style.display='none';
|
||
document.getElementById('devCount').disabled=false;
|
||
document.getElementById('avgSalary').disabled=false;
|
||
}
|
||
|
||
// No auto-start: user must press Start. (Previously auto-started here.)
|
||
</script>
|
||
</body>
|
||
</html>
|