mirror of
https://github.com/skoelle/PeopleCostCounter.git
synced 2026-09-18 04:10:25 +00:00
Compare commits
10
Commits
ace92a048c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36847cc63a | ||
|
|
0f23a04592 | ||
|
|
3c90346982 | ||
|
|
055e81b8e7 | ||
|
|
cfd1ffdb29 | ||
|
|
258fb956a0 | ||
|
|
87ed08c458 | ||
|
|
9fc8d3663f | ||
|
|
e5608a35e3 | ||
|
|
16cb63981a |
@@ -10,11 +10,11 @@ jobs:
|
||||
html-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: '24'
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
- name: Run html-validate
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -0,0 +1,6 @@
|
||||
title: "PeopleCostCounter"
|
||||
emoji: "💰"
|
||||
category: code
|
||||
subcategory: "Dev Tools"
|
||||
status: active
|
||||
stack: [HTML, CSS, JavaScript]
|
||||
@@ -1,88 +1,133 @@
|
||||
# Meeting Cost Tracker
|
||||
# 💰 Meeting Cost Tracker
|
||||
|
||||
A small single-page tool that shows, in real time, how much a meeting is costing while it runs.
|
||||
> A small single-page tool that shows, **in real time**, how much a meeting is costing while it runs. 💸
|
||||
|
||||
It is intentionally simple: enter how many developers are present, enter the average monthly gross salary, press **Start**, and the page begins counting the cost every second.
|
||||
It is intentionally simple: enter how many developers are present, enter the average monthly gross salary, press **Start**, and the page begins counting the cost every second. ⏱️
|
||||
|
||||

|
||||
|
||||
## Quick start
|
||||
---
|
||||
|
||||
## 🚀 Quick start
|
||||
|
||||
Open the tracker in your browser. The project is published via GitHub Pages — live demo URLs:
|
||||
|
||||
- English UI: https://skoelle.github.io/PeopleCostCounter/meeting-tracker-en.html
|
||||
- German UI: https://skoelle.github.io/PeopleCostCounter/meeting-tracker-de.html
|
||||
- 🇬🇧 English UI: https://skoelle.github.io/PeopleCostCounter/meeting-tracker-en.html
|
||||
- 🇩🇪 German UI: https://skoelle.github.io/PeopleCostCounter/meeting-tracker-de.html
|
||||
|
||||
You can also open the local HTML files directly in a browser (no build step required). Recommended browsers: Chrome, Edge, Firefox.
|
||||
|
||||
If you prefer a live-reload development workflow, use an editor extension such as Live Server (VS Code) or any simple static file server.
|
||||
|
||||
## Controls
|
||||
---
|
||||
|
||||
- **Start** begins the live counter at zero.
|
||||
- **Reset** stops the counter and sets the accumulated cost back to zero.
|
||||
- The cost per second recalculates when inputs change; the accumulated cost updates once per second while running.
|
||||
## 🎮 Controls
|
||||
|
||||
## Files of interest
|
||||
| Button | Action |
|
||||
|-----------|--------|
|
||||
| ▶️ **Start** | Begins the live counter at zero. |
|
||||
| 🔄 **Reset** | Stops the counter and sets the accumulated cost back to zero. |
|
||||
|
||||
- `meeting-tracker-de.html` / `meeting-tracker-en.html` — canonical builds (German / English)
|
||||
|
||||
Experimental variants (moved to `variants/`):
|
||||
|
||||
- `variants/meeting-tracker-variant1.html`
|
||||
- `variants/meeting-tracker-variant2.html`
|
||||
- `variants/meeting-tracker-variant3.html`
|
||||
|
||||
- `meeting-cost-prompt.md` — internal prompt and notes used while developing the tracker
|
||||
|
||||
## Browser & dependencies
|
||||
|
||||
- No build tool or runtime dependencies. The pages use only vanilla HTML/CSS/JS.
|
||||
- Google Fonts are used for typography (Inter / JetBrains Mono); pages work fine if fonts are blocked.
|
||||
|
||||
## Example (quick test)
|
||||
|
||||
- Developers: `8`
|
||||
- Avg. monthly: `5000`
|
||||
- Expected: annual per-dev = `60.000 €`, incl. employer ≈ `72.000 €`, total for 8 ≈ `576.000 €`, cost/sec ≈ `0.0909 €`, after 60s ≈ `5,45 €`.
|
||||
The cost per second recalculates when inputs change; the accumulated cost updates once per second while running.
|
||||
|
||||
---
|
||||
|
||||
## What it calculates
|
||||
## 📁 Files of interest
|
||||
|
||||
The tracker estimates meeting cost from salary, employer overhead, team size, and elapsed time. The calculation is based on the idea that one developer’s salary can be converted into an annual employer cost, then into a working-hour and working-second cost. Similar meeting-cost tools commonly start from annual salary, convert it to an hourly rate, and multiply by meeting duration and headcount. [meetingtoll](https://www.meetingtoll.com/blog/meeting-cost-formula-per-employee)
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `meeting-tracker-de.html` | 🇩🇪 Canonical build — German UI |
|
||||
| `meeting-tracker-en.html` | 🇬🇧 Canonical build — English UI |
|
||||
|
||||
### Formula
|
||||
### 🧪 Experimental variants (in `variants/`)
|
||||
|
||||
The app uses this formula:
|
||||
- `meeting-tracker-variant1.html`
|
||||
- `meeting-tracker-variant2.html`
|
||||
- `meeting-tracker-variant3.html`
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Browser & dependencies
|
||||
|
||||
- ✅ No build tool or runtime dependencies — only vanilla HTML/CSS/JS.
|
||||
- 🔤 Google Fonts are used for typography (Inter / JetBrains Mono); pages work fine if fonts are blocked.
|
||||
|
||||
---
|
||||
|
||||
## 🧮 Example (quick test)
|
||||
|
||||
| Input | Value |
|
||||
|-------|-------|
|
||||
| 👥 Developers | `8` |
|
||||
| 💶 Avg. monthly salary | `5000` |
|
||||
|
||||
**Expected output:**
|
||||
|
||||
- 📊 Annual per-dev = `60.000 €`
|
||||
- 🏢 incl. employer ≈ `72.000 €`
|
||||
- 🧾 Total for 8 ≈ `576.000 €`
|
||||
- ⏱️ Cost/sec ≈ `0.0909 €`
|
||||
- 💣 After 60s ≈ `5,45 €`
|
||||
|
||||
---
|
||||
|
||||
## 🔍 What it calculates
|
||||
|
||||
The tracker estimates meeting cost from salary, employer overhead, team size, and elapsed time. The calculation is based on the idea that one developer's salary can be converted into an annual employer cost, then into a working-hour and working-second cost.
|
||||
|
||||
Similar meeting-cost tools commonly start from annual salary, convert it to an hourly rate, and multiply by meeting duration and headcount. 📐
|
||||
|
||||
> 🔗 [meetingtoll.com — Meeting cost formula per employee](https://www.meetingtoll.com/blog/meeting-cost-formula-per-employee)
|
||||
|
||||
### 📝 Formula
|
||||
|
||||
```
|
||||
AnnualSalary = AvgMonthlySalary × 12
|
||||
AnnualSalaryWithEmployerContribution = AnnualSalary × 1.2
|
||||
TotalAnnualCost = AnnualSalaryWithEmployerContribution × DevelopersPresent
|
||||
CostPerSecond = TotalAnnualCost ÷ 220 ÷ 8 ÷ 60 ÷ 60
|
||||
AnnualSalary = AvgMonthlySalary × 12
|
||||
AnnualSalaryWithEmployer = AnnualSalary × 1.2
|
||||
TotalAnnualCost = AnnualSalaryWithEmployer × DevelopersPresent
|
||||
CostPerSecond = TotalAnnualCost ÷ 220 ÷ 8 ÷ 60 ÷ 60
|
||||
```
|
||||
|
||||
The 1.2 multiplier is a simplified overhead factor for employer contributions, which is in the same rough range as public Germany payroll references that place employer burden around 20–23% above gross salary. [boundlesshq](https://boundlesshq.com/blog/payroll-in-germany/)
|
||||
The **1.2 multiplier** is a simplified overhead factor for employer contributions (~20% above gross salary). 📈
|
||||
|
||||
### Why 220 days
|
||||
> 🔗 [boundlesshq.com — Payroll in Germany](https://boundlesshq.com/blog/payroll-in-germany/)
|
||||
|
||||
The app divides by 220 working days instead of 365 calendar days. That makes the result closer to actual working time, because meetings happen during paid work, not across the full calendar year. [capme](https://www.capme.app/meeting-cost-calculator)
|
||||
### 🗓️ Why 220 days?
|
||||
|
||||
Using 220 days, 8 hours per day, and 60 minutes per hour is a practical simplification that turns annual cost into a live per-second burn rate. It is not a payroll-grade accounting model, but it is a useful way to visualize meeting cost in real time. [meetingking](https://meetingking.com/meeting-cost-calculator/)
|
||||
The app divides by **220 working days** instead of 365 calendar days — making the result closer to actual working time, because meetings happen during paid work, not across the full calendar year. 🏖️
|
||||
|
||||
## Notes
|
||||
> 🔗 [capme.app — Meeting Cost Calculator](https://www.capme.app/meeting-cost-calculator)
|
||||
|
||||
This tool is designed to make meeting costs visible, not to produce exact payroll accounting. In real companies, the true employer cost can vary by insurance rates, caps, bonuses, and other overhead, so the 1.2 factor should be understood as a simple approximation. [payrollgermany](https://payrollgermany.de/blog/employer-contributions-to-social-security-in-germany-a-comprehensive-guide/)
|
||||
Using 220 days, 8 hours per day, and 60 minutes per hour is a practical simplification that turns annual cost into a live per-second burn rate. It is not a payroll-grade accounting model, but it is a useful way to visualize meeting cost in real time.
|
||||
|
||||
The result is best used as a conversation starter: it helps teams notice how quickly meeting time turns into money.
|
||||
> 🔗 [meetingking.com — Meeting Cost Calculator](https://meetingking.com/meeting-cost-calculator/)
|
||||
|
||||
## Contributing
|
||||
---
|
||||
|
||||
- Prefer small, focused pull requests. Create a branch named `feature/...` or `fix/...` for changes.
|
||||
- If you want me to push changes, tell me whether to create a PR or commit directly to `main`.
|
||||
## 📌 Notes
|
||||
|
||||
## License
|
||||
This tool is designed to make meeting costs **visible**, not to produce exact payroll accounting. In real companies, the true employer cost can vary by insurance rates, caps, bonuses, and other overhead — so the 1.2 factor should be understood as a simple approximation. 🤏
|
||||
|
||||
Licensed under the [MIT License](LICENSE) - Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||
> 🔗 [payrollgermany.de — Employer contributions in Germany](https://payrollgermany.de/blog/employer-contributions-to-social-security-in-germany-a-comprehensive-guide/)
|
||||
|
||||
The result is best used as a **conversation starter**: it helps teams notice how quickly meeting time turns into money. 💡
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
- 🌿 Prefer small, focused pull requests.
|
||||
- 🏷️ Create a branch named `feature/...` or `fix/...` for changes.
|
||||
- 📩 If you want me to push changes, tell me whether to create a PR or commit directly to `main`.
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
Licensed under the [MIT License](LICENSE) — Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
Made with ❤️ for better meetings
|
||||
</p>
|
||||
|
||||
@@ -210,7 +210,7 @@ function getParams(){
|
||||
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};
|
||||
return{devCount:devCount,avgSalary:avgSalary,annualSalary:annualSalary,annualWithEmployer:annualWithEmployer,totalCost:totalCost,costPerSecond:costPerSecond};
|
||||
}
|
||||
function tick(){
|
||||
var elapsed=pausedElapsed+(Date.now()-startTime)/1000;
|
||||
@@ -222,10 +222,9 @@ function tick(){
|
||||
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;
|
||||
var annual = p.annualSalary;
|
||||
var employer = p.annualWithEmployer;
|
||||
var annualTotal = p.totalCost;
|
||||
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}) + ' €';
|
||||
@@ -270,6 +269,10 @@ function resetTimer(){
|
||||
document.getElementById('avgSalary').disabled=false;
|
||||
}
|
||||
|
||||
// input listeners: update per-second display when editing
|
||||
document.getElementById('devCount').addEventListener('input', function(){ if(!isRunning){ var p=getParams(); if(p.costPerSecond>0) document.getElementById('tilePerSec').textContent='EUR '+p.costPerSecond.toLocaleString('de-DE',{minimumFractionDigits:4,maximumFractionDigits:4}); }});
|
||||
document.getElementById('avgSalary').addEventListener('input', function(){ if(!isRunning){ var p=getParams(); if(p.costPerSecond>0) document.getElementById('tilePerSec').textContent='EUR '+p.costPerSecond.toLocaleString('de-DE',{minimumFractionDigits:4,maximumFractionDigits:4}); }});
|
||||
|
||||
// No auto-start: user must press Start. (Previously auto-started here.)
|
||||
</script>
|
||||
</body>
|
||||
|
||||
+260
-154
@@ -3,170 +3,276 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Meeting Cost Tracker — English</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>
|
||||
/* same styles as variant2 (kept compact) */
|
||||
: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 rgba(0,0,0,0.06);--shadow-md:0 4px 12px rgba(0,0,0,0.08);--radius-md:.5rem;--radius-lg:.75rem;--radius-xl:1rem;--text-xs:.75rem;--text-sm:0.9rem;--text-base:1rem;--space-3:.75rem;--space-4:1rem;--space-6:1.5rem;--space-8:2rem;--font-body:'Inter',system-ui,sans-serif;--font-mono:'JetBrains Mono',monospace}
|
||||
[data-theme="dark"]{--color-bg:#171614;--color-surface:#1c1b19;--color-text:#cdccca}
|
||||
*{box-sizing:border-box;margin:0;padding:0} 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)}
|
||||
.app-header{width:100%;max-width:720px;display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-8)}
|
||||
.logo-text{font-size:1.125rem;font-weight:600}
|
||||
.logo-sub{font-size:0.85rem;color:var(--color-text-muted)}
|
||||
.theme-toggle{width:36px;height:36px;border-radius:.5rem;display:flex;align-items:center;justify-content:center;border:1px solid rgba(0,0,0,0.06);background:transparent}
|
||||
.card{width:100%;max-width:720px;background:var(--color-surface);border:1px solid var(--color-border);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:1rem;align-items:end;margin-bottom:1.5rem}
|
||||
.form-field{display:flex;flex-direction:column;gap:.5rem}
|
||||
.form-label{font-size:.75rem;color:var(--color-text-muted);text-transform:uppercase}
|
||||
.form-input{padding:.75rem 1rem;background:var(--color-bg);border:1px solid var(--color-border);border-radius:.5rem}
|
||||
.input-prefix{position:absolute;left:1rem;top:50%;transform:translateY(-50%);color:var(--color-text-muted)}
|
||||
.btn-start{padding:.6rem 1.2rem;background:var(--color-primary);color:#fff;border-radius:.5rem;display:flex;align-items:center;gap:.5rem}
|
||||
.btn-reset{padding:.4rem .75rem;border:1px solid var(--color-border);border-radius:.5rem}
|
||||
.divider{height:1px;background:var(--color-divider);margin:1.25rem 0}
|
||||
.status-badge{display:inline-flex;align-items:center;gap:.5rem;padding:.25rem .75rem;border-radius:9999px;background:var(--color-surface-offset);color:var(--color-text-muted);border:1px solid var(--color-border)}
|
||||
.running-indicator{width:10px;height:10px;border-radius:9999px;background:currentColor}
|
||||
.running-indicator.active{animation:pulse 1.2s infinite}@keyframes pulse{0%{opacity:1}50%{opacity:.3}100%{opacity:1}}
|
||||
.cost-display-wrap{position:relative;background:var(--color-surface-offset);border:1px solid var(--color-border);border-radius:1rem;padding:2rem;text-align:center;min-height:160px;display:flex;flex-direction:column;align-items:center;justify-content:center}
|
||||
.cost-display-amount{font-family:var(--font-mono);font-size:clamp(2.5rem,6vw,4.5rem);font-weight:700}
|
||||
.info-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:1rem;margin-top:1.5rem}
|
||||
.info-tile{background:var(--color-surface-2);border:1px solid var(--color-border);border-radius:.5rem;padding:1rem}
|
||||
.info-tile-label{font-size:.75rem;color:var(--color-text-muted);text-transform:uppercase;margin-bottom:.5rem}
|
||||
.info-tile-value{font-family:var(--font-mono);font-weight:600}
|
||||
</style>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Meeting Cost Tracker — English</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 style="display:flex;align-items:center;gap:1rem">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" aria-hidden="true"><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">Live meeting cost overview</div>
|
||||
</div>
|
||||
<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">Live meeting cost overview</div>
|
||||
</div>
|
||||
<button class="theme-toggle" data-theme-toggle aria-label="Toggle theme">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/></svg>
|
||||
</div>
|
||||
<button class="theme-toggle" data-theme-toggle aria-label="Toggle theme">
|
||||
<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">Developers present</label>
|
||||
<input class="form-input" type="number" id="devCount" min="1" max="500" value="5">
|
||||
<div class="form-hint">Number of participants</div>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="avgSalary">Avg. monthly gross salary</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, average salary</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>
|
||||
</header>
|
||||
|
||||
<main class="card">
|
||||
<div class="form-row">
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="devCount">Developers present</label>
|
||||
<input class="form-input" type="number" id="devCount" min="1" max="500" value="5" aria-label="Developers present">
|
||||
<div style="font-size:.85rem;color:var(--color-text-muted)">number of participants</div>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="avgSalary">Avg. monthly gross salary</label>
|
||||
<div style="position:relative">
|
||||
<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" aria-label="Average monthly salary">
|
||||
</div>
|
||||
<div style="font-size:.85rem;color:var(--color-text-muted)">brutto, avarage salary</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>
|
||||
Ready
|
||||
</span>
|
||||
<button class="btn-reset" id="resetBtn" onclick="resetTimer()" style="display:none">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<div class="cost-display-wrap">
|
||||
<div class="cost-display-label">Total cost so far</div>
|
||||
<div class="cost-display-amount" id="costDisplay">
|
||||
<span class="cost-display-currency">EUR</span><span id="costValue">0.00</span>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem">
|
||||
<span class="status-badge" id="statusBadge"><span class="running-indicator" id="runIndicator"></span> Ready</span>
|
||||
<button class="btn-reset" id="resetBtn" onclick="resetTimer()" style="display:none">Reset</button>
|
||||
<div class="cost-display-elapsed" id="elapsedDisplay">Not started</div>
|
||||
</div>
|
||||
<div class="info-grid" id="infoGrid" style="display:none">
|
||||
<div class="info-tile">
|
||||
<div class="info-tile-label">Cost / second</div>
|
||||
<div class="info-tile-value" id="tilePerSec">-</div>
|
||||
</div>
|
||||
|
||||
<div class="cost-display-wrap">
|
||||
<div style="font-size:.75rem;color:var(--color-text-muted);text-transform:uppercase;margin-bottom:.5rem">Total cost so far</div>
|
||||
<div class="cost-display-amount" id="costDisplay"><span style="font-family:var(--font-mono);font-size:1rem;margin-right:.4rem">EUR</span><span id="costValue">0.00</span></div>
|
||||
<div style="margin-top:.5rem;font-size:.85rem;color:var(--color-text-muted)" id="elapsedDisplay">Not started</div>
|
||||
<div class="info-tile">
|
||||
<div class="info-tile-label">Cost / minute</div>
|
||||
<div class="info-tile-value" id="tilePerMin">-</div>
|
||||
</div>
|
||||
|
||||
<div class="info-grid" id="infoGrid" style="display:none">
|
||||
<div class="info-tile"><div class="info-tile-label">Cost / second</div><div class="info-tile-value" id="tilePerSec">-</div></div>
|
||||
<div class="info-tile"><div class="info-tile-label">Cost / minute</div><div class="info-tile-value" id="tilePerMin">-</div></div>
|
||||
<div class="info-tile"><div class="info-tile-label">Cost / hour</div><div class="info-tile-value" id="tilePerHour">-</div></div>
|
||||
<div class="info-tile"><div class="info-tile-label">People × salary</div><div class="info-tile-value" id="tileTotal">-</div></div>
|
||||
<div class="info-tile"><div class="info-tile-label">Annual salary (per person)</div><div class="info-tile-value" id="tileAnnual">-</div></div>
|
||||
<div class="info-tile"><div class="info-tile-label">incl. employer contributions (×1.2)</div><div class="info-tile-value" id="tileEmployer">-</div></div>
|
||||
<div class="info-tile"><div class="info-tile-label">Total annual cost</div><div class="info-tile-value" id="tileAnnualTotal">-</div></div>
|
||||
<div class="info-tile">
|
||||
<div class="info-tile-label">Cost / hour</div>
|
||||
<div class="info-tile-value" id="tilePerHour">-</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"/></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){ 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;
|
||||
// English formatting (GBP/GB style but currency EUR)
|
||||
function fmt(n){ return new Intl.NumberFormat('en-GB',{ style:'currency', currency:'EUR', minimumFractionDigits:2, maximumFractionDigits:2 }).format(n); }
|
||||
function fmtPlain(n,d){ return n.toLocaleString('en-GB',{minimumFractionDigits:d,maximumFractionDigits:d}); }
|
||||
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 Math.floor(sec) + 's elapsed'; }
|
||||
|
||||
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, annualSalary:annualSalary, annualWithEmployer:annualWithEmployer, totalCost:totalCost, costPerSecond:costPerSecond };
|
||||
<div class="info-tile">
|
||||
<div class="info-tile-label">People x salary</div>
|
||||
<div class="info-tile-value" id="tileTotal">-</div>
|
||||
</div>
|
||||
<div class="info-tile">
|
||||
<div class="info-tile-label">Annual salary (per person)</div>
|
||||
<div class="info-tile-value" id="tileAnnual">-</div>
|
||||
</div>
|
||||
<div class="info-tile">
|
||||
<div class="info-tile-label">incl. employer contributions (x1.2)</div>
|
||||
<div class="info-tile-value" id="tileEmployer">-</div>
|
||||
</div>
|
||||
<div class="info-tile">
|
||||
<div class="info-tile-label">Total annual cost</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){
|
||||
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 new Intl.NumberFormat('en-GB',{style:'currency',currency:'EUR',minimumFractionDigits:2,maximumFractionDigits:2}).format(n);}
|
||||
function fmtPlain(n,d){return n.toLocaleString('en-GB',{minimumFractionDigits:d,maximumFractionDigits:d});}
|
||||
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 Math.floor(sec)+'s elapsed';
|
||||
}
|
||||
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,annualSalary:annualSalary,annualWithEmployer:annualWithEmployer,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=fmtPlain(spent,2);
|
||||
document.getElementById('elapsedDisplay').textContent=fmtElapsed(elapsed);
|
||||
document.getElementById('tilePerSec').textContent='EUR '+p.costPerSecond.toLocaleString('en-GB',{minimumFractionDigits:4,maximumFractionDigits:4});
|
||||
document.getElementById('tilePerMin').textContent='EUR '+fmtPlain(p.costPerSecond*60,2);
|
||||
document.getElementById('tilePerHour').textContent='EUR '+fmtPlain(p.costPerSecond*3600,2);
|
||||
document.getElementById('tileTotal').textContent=p.devCount+' x EUR '+fmtPlain(p.avgSalary,2);
|
||||
var annual = p.annualSalary;
|
||||
var employer = p.annualWithEmployer;
|
||||
var annualTotal = p.totalCost;
|
||||
document.getElementById('tileAnnual').textContent=fmtPlain(annual,0)+' EUR';
|
||||
document.getElementById('tileEmployer').textContent=fmtPlain(employer,0)+' EUR';
|
||||
document.getElementById('tileAnnualTotal').textContent=fmtPlain(annualTotal,0)+' EUR';
|
||||
}
|
||||
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> Running';
|
||||
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='Resume';
|
||||
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> Paused';
|
||||
}
|
||||
function resetTimer(){
|
||||
if(timer)clearInterval(timer);
|
||||
timer=null;isRunning=false;pausedElapsed=0;startTime=null;
|
||||
document.getElementById('costValue').textContent='0.00';
|
||||
document.getElementById('elapsedDisplay').textContent='Not started';
|
||||
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> Ready';
|
||||
document.getElementById('resetBtn').style.display='none';
|
||||
document.getElementById('infoGrid').style.display='none';
|
||||
document.getElementById('devCount').disabled=false;
|
||||
document.getElementById('avgSalary').disabled=false;
|
||||
}
|
||||
|
||||
function tick(){
|
||||
var elapsed = pausedElapsed + (Date.now()-startTime)/1000;
|
||||
var p = getParams();
|
||||
var spent = p.costPerSecond * elapsed;
|
||||
document.getElementById('costValue').textContent = fmtPlain(spent,2);
|
||||
document.getElementById('elapsedDisplay').textContent = fmtElapsed(elapsed);
|
||||
document.getElementById('tilePerSec').textContent = 'EUR ' + p.costPerSecond.toLocaleString('en-GB',{minimumFractionDigits:4,maximumFractionDigits:4});
|
||||
document.getElementById('tilePerMin').textContent = 'EUR ' + fmtPlain(p.costPerSecond*60,2);
|
||||
document.getElementById('tilePerHour').textContent = 'EUR ' + fmtPlain(p.costPerSecond*3600,2);
|
||||
document.getElementById('tileTotal').textContent = p.devCount + ' x EUR ' + fmtPlain(p.avgSalary,2);
|
||||
// annual / employer / total
|
||||
document.getElementById('tileAnnual').textContent = fmtPlain(p.annualSalary,0) + ' €';
|
||||
document.getElementById('tileEmployer').textContent = fmtPlain(p.annualWithEmployer,0) + ' €';
|
||||
document.getElementById('tileAnnualTotal').textContent = fmtPlain(p.totalCost,0) + ' €';
|
||||
}
|
||||
// input listeners: update per-second display when editing
|
||||
document.getElementById('devCount').addEventListener('input', function(){ if(!isRunning){ var p=getParams(); if(p.costPerSecond>0) document.getElementById('tilePerSec').textContent='EUR '+p.costPerSecond.toLocaleString('en-GB',{minimumFractionDigits:4,maximumFractionDigits:4}); }});
|
||||
document.getElementById('avgSalary').addEventListener('input', function(){ if(!isRunning){ var p=getParams(); if(p.costPerSecond>0) document.getElementById('tilePerSec').textContent='EUR '+p.costPerSecond.toLocaleString('en-GB',{minimumFractionDigits:4,maximumFractionDigits:4}); }});
|
||||
|
||||
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> Running';
|
||||
document.getElementById('resetBtn').style.display='inline-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='Resume';
|
||||
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> Paused';
|
||||
}
|
||||
|
||||
function resetTimer(){ if(timer) clearInterval(timer); timer=null; isRunning=false; pausedElapsed=0; startTime=null; document.getElementById('costValue').textContent='0.00'; document.getElementById('elapsedDisplay').textContent='Not started'; 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> Ready'; document.getElementById('resetBtn').style.display='none'; document.getElementById('infoGrid').style.display='none'; document.getElementById('devCount').disabled=false; document.getElementById('avgSalary').disabled=false; }
|
||||
|
||||
// input listeners: update per-second display when editing
|
||||
document.getElementById('devCount').addEventListener('input', function(){ if(!isRunning){ var p=getParams(); if(p.costPerSecond>0) document.getElementById('tilePerSec').textContent='EUR '+p.costPerSecond.toLocaleString('en-GB',{minimumFractionDigits:4,maximumFractionDigits:4}); }});
|
||||
document.getElementById('avgSalary').addEventListener('input', function(){ if(!isRunning){ var p=getParams(); if(p.costPerSecond>0) document.getElementById('tilePerSec').textContent='EUR '+p.costPerSecond.toLocaleString('en-GB',{minimumFractionDigits:4,maximumFractionDigits:4}); }});
|
||||
|
||||
// No auto-start: user must press Start.
|
||||
</script>
|
||||
// No auto-start: user must press Start.
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+201
@@ -0,0 +1,201 @@
|
||||
{
|
||||
"name": "people-cost-counter",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "people-cost-counter",
|
||||
"version": "0.0.0",
|
||||
"devDependencies": {
|
||||
"html-validate": "^11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@html-validate/stylish": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@html-validate/stylish/-/stylish-6.0.0.tgz",
|
||||
"integrity": "sha512-d1/lI3qIXhGVJF3+MmKEBn1dRX6U4Z+BDaOdDI3E6SXcO+OQ7hC/3mSo6BIjwQlcuV6NdDOKm6QCrzIQL1EezQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^22.16.0 || >= 24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sidvind/better-ajv-errors": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@sidvind/better-ajv-errors/-/better-ajv-errors-7.0.0.tgz",
|
||||
"integrity": "sha512-imVsp5D3KxJ8+uUmu2dsLKnFg3qdX952v/L1gZoCa0NO2ivhQWHMpYM2KmpFrRXHWFjlqkNZ/935nxiTqXEi1A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^22.12 || >= 24.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
|
||||
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/html-validate": {
|
||||
"version": "11.6.2",
|
||||
"resolved": "https://registry.npmjs.org/html-validate/-/html-validate-11.6.2.tgz",
|
||||
"integrity": "sha512-dlAAEeWOOdv9ya/n3pdSFTzzzGlrw+ZI43J/39zBgCSqzSg24lyqrouuBP9MEkSFAtXVZm64F35mlL7m43jCNA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/html-validate"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@html-validate/stylish": "^6.0.0",
|
||||
"@sidvind/better-ajv-errors": "7.0.0",
|
||||
"ajv": "^8.0.0",
|
||||
"kleur": "^4.1.0",
|
||||
"prompts": "^2.0.0",
|
||||
"semver": "^7.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"html-validate": "bin/html-validate.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.22.0 || >= 24.8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@jest/globals": "^29.0.3 || ^30.0.0",
|
||||
"@vitest/expect": "^3.2.0 || ^4.0.1",
|
||||
"jest": "^29.0.3 || ^30.0.0",
|
||||
"jest-snapshot": "^29.0.3 || ^30.0.0",
|
||||
"vitest": "^3.2.0 || ^4.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@jest/globals": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/expect": {
|
||||
"optional": true
|
||||
},
|
||||
"jest": {
|
||||
"optional": true
|
||||
},
|
||||
"jest-snapshot": {
|
||||
"optional": true
|
||||
},
|
||||
"vitest": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
||||
"integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/prompts": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
|
||||
"integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"kleur": "^3.0.3",
|
||||
"sisteransi": "^1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/prompts/node_modules/kleur": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
|
||||
"integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/sisteransi": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
|
||||
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"html-validate": "^8.8.0"
|
||||
"html-validate": "^11.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"lint:html": "html-validate \"**/*.html\""
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended"],
|
||||
"schedule": ["before 6am on Monday"],
|
||||
"packageRules": [
|
||||
{
|
||||
"matchManagers": ["github-actions"],
|
||||
"groupName": "GitHub Actions",
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"matchManagers": ["dockerfile"],
|
||||
"groupName": "Docker",
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"matchManagers": ["npm"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"groupName": "npm minor/patch",
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"matchUpdateTypes": ["major"],
|
||||
"labels": ["major-update"],
|
||||
"automerge": false
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user