Initial commit: forskjeller.naiv.no
Interactive salary gap calculator (public/lonn/) showing how percentage-based raises amplify absolute salary differences over time. Includes landing page, README, and CLAUDE.md. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
44c8fe26bf
6 changed files with 946 additions and 0 deletions
159
public/lonn/app.js
Normal file
159
public/lonn/app.js
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
function fmtKr(n) {
|
||||
return Math.round(n).toLocaleString('nb-NO') + '\u202fkr';
|
||||
}
|
||||
|
||||
function fmtShort(n) {
|
||||
if (n >= 1e6) return (n/1e6).toFixed(1).replace('.',',') + '\u202fM';
|
||||
if (n >= 1e3) return Math.round(n/1e3) + '\u202fk';
|
||||
return Math.round(n);
|
||||
}
|
||||
|
||||
function getData() {
|
||||
const A0 = +document.getElementById('salA').value;
|
||||
const B0 = +document.getElementById('salB').value;
|
||||
const pct = +document.getElementById('pct').value / 100;
|
||||
const Y = +document.getElementById('yrs').value;
|
||||
const labels=[], sA=[], sB=[], gaps=[], monthly=[], cumA=[], cumB=[], cumGaps=[];
|
||||
let rA=0, rB=0;
|
||||
for (let y=0; y<=Y; y++) {
|
||||
labels.push(y===0 ? 'I dag' : (y % 5 === 0 || Y <= 10) ? 'År '+y : (y===Y ? 'År '+y : ''));
|
||||
const a = A0 * Math.pow(1+pct, y);
|
||||
const b = B0 * Math.pow(1+pct, y);
|
||||
sA.push(a); sB.push(b);
|
||||
gaps.push(b-a);
|
||||
monthly.push((b-a)/12);
|
||||
rA += a; rB += b;
|
||||
cumA.push(rA); cumB.push(rB); cumGaps.push(rB-rA);
|
||||
}
|
||||
return {A0,B0,pct,Y,labels,sA,sB,gaps,monthly,cumA,cumB,cumGaps};
|
||||
}
|
||||
|
||||
const GRID_COLOR = 'rgba(0,0,0,0.06)';
|
||||
const TICK_COLOR = '#8a857e';
|
||||
|
||||
function baseOpts() {
|
||||
return {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: { label: c => ' ' + fmtKr(c.parsed.y) },
|
||||
backgroundColor: '#1a1714',
|
||||
titleColor: '#f5f2eb',
|
||||
bodyColor: 'rgba(245,242,235,0.7)',
|
||||
padding: 10,
|
||||
cornerRadius: 4,
|
||||
titleFont: { family: 'DM Sans', size: 12 },
|
||||
bodyFont: { family: 'DM Sans', size: 12 }
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: { color: TICK_COLOR, font: { size: 11, family: 'DM Sans' }, autoSkip: false, maxRotation: 0 },
|
||||
grid: { display: false },
|
||||
border: { color: 'rgba(0,0,0,0.1)' }
|
||||
},
|
||||
y: {
|
||||
ticks: { color: TICK_COLOR, font: { size: 11, family: 'DM Sans' }, callback: v => fmtShort(v) },
|
||||
grid: { color: GRID_COLOR },
|
||||
border: { dash: [3,3], color: 'transparent' }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const chart1 = new Chart(document.getElementById('chart1'), {
|
||||
type: 'line',
|
||||
data: { labels:[], datasets:[
|
||||
{ data:[], borderColor:'#1a4a8a', backgroundColor:'rgba(26,74,138,0.07)', fill:true, tension:0.35, pointRadius:0, borderWidth:2 },
|
||||
{ data:[], borderColor:'#c0392b', backgroundColor:'rgba(192,57,43,0.07)', fill:true, tension:0.35, pointRadius:0, borderWidth:2 }
|
||||
]},
|
||||
options: baseOpts()
|
||||
});
|
||||
|
||||
const chart2 = new Chart(document.getElementById('chart2'), {
|
||||
type: 'line',
|
||||
data: { labels:[], datasets:[
|
||||
{ data:[], borderColor:'#4a3a8a', backgroundColor:'rgba(74,58,138,0.1)', fill:true, tension:0.35, pointRadius:0, borderWidth:2 }
|
||||
]},
|
||||
options: baseOpts()
|
||||
});
|
||||
|
||||
const chart3 = new Chart(document.getElementById('chart3'), {
|
||||
type: 'line',
|
||||
data: { labels:[], datasets:[
|
||||
{ data:[], borderColor:'#2c6e49', backgroundColor:'rgba(44,110,73,0.1)', fill:true, tension:0.35, pointRadius:0, borderWidth:2 }
|
||||
]},
|
||||
options: baseOpts()
|
||||
});
|
||||
|
||||
const chart4 = new Chart(document.getElementById('chart4'), {
|
||||
type: 'line',
|
||||
data: { labels:[], datasets:[
|
||||
{ data:[], borderColor:'#1a4a8a', backgroundColor:'rgba(26,74,138,0.07)', fill:true, tension:0.35, pointRadius:0, borderWidth:2 },
|
||||
{ data:[], borderColor:'#c0392b', backgroundColor:'rgba(192,57,43,0.07)', fill:true, tension:0.35, pointRadius:0, borderWidth:2 },
|
||||
{ data:[], borderColor:'#b5620a', backgroundColor:'rgba(181,98,10,0.1)', fill:true, tension:0.35, pointRadius:0, borderWidth:2, borderDash:[6,4] }
|
||||
]},
|
||||
options: baseOpts()
|
||||
});
|
||||
|
||||
// Alle verdier som settes via innerHTML kommer fra numeriske beregninger (slider-verdier),
|
||||
// ikke fra brukerinput-tekst — ingen XSS-risiko.
|
||||
function update() {
|
||||
const {A0,B0,pct,Y,labels,sA,sB,gaps,monthly,cumA,cumB,cumGaps} = getData();
|
||||
|
||||
// Oppdater visning
|
||||
document.querySelectorAll('.yr-lbl').forEach(el => el.textContent = Y);
|
||||
document.getElementById('salA-out').textContent = Math.round(A0).toLocaleString('nb-NO') + '\u202fkr';
|
||||
document.getElementById('salB-out').textContent = Math.round(B0).toLocaleString('nb-NO') + '\u202fkr';
|
||||
document.getElementById('pct-out').textContent = (+document.getElementById('pct').value).toLocaleString('nb-NO', {minimumFractionDigits:1}) + '\u202f%';
|
||||
document.getElementById('yrs-out').textContent = Y + '\u202får';
|
||||
|
||||
// Statistikk
|
||||
document.getElementById('st-finalA').textContent = fmtKr(sA[Y]);
|
||||
document.getElementById('st-monthA').textContent = fmtKr(sA[Y]/12) + '/mnd';
|
||||
document.getElementById('st-subA').textContent = '+' + fmtKr(sA[Y]-A0) + ' fra start';
|
||||
document.getElementById('st-finalB').textContent = fmtKr(sB[Y]);
|
||||
document.getElementById('st-monthB').textContent = fmtKr(sB[Y]/12) + '/mnd';
|
||||
document.getElementById('st-subB').textContent = '+' + fmtKr(sB[Y]-B0) + ' fra start';
|
||||
document.getElementById('st-diffyr').textContent = fmtKr(gaps[Y]) + '/år';
|
||||
document.getElementById('st-diffmnd').textContent = fmtKr(monthly[Y]) + '/mnd';
|
||||
document.getElementById('st-diffstart').textContent = 'Var ' + fmtKr(B0-A0) + '/år ved start';
|
||||
document.getElementById('st-cumgap').textContent = fmtKr(cumGaps[Y]);
|
||||
|
||||
// Banner
|
||||
document.getElementById('banner-gap').textContent = fmtKr(cumGaps[Y]);
|
||||
|
||||
// Innsikt — innerHTML er trygt her: verdiene er kun formaterte tall fra slidere
|
||||
const monthlyNow = Math.round((B0-A0)/12);
|
||||
const monthlyEnd = Math.round(monthly[Y]);
|
||||
document.getElementById('insight-monthly').innerHTML =
|
||||
`Månedlig gap i dag: <strong>${fmtKr(monthlyNow)}</strong> ekstra til B. `+
|
||||
`Etter ${Y} år er det <strong>${fmtKr(monthlyEnd)}</strong> per måned — `+
|
||||
`en økning på <strong>${fmtKr(monthlyEnd - monthlyNow)}</strong> kun pga. prosentvekst.`;
|
||||
|
||||
// Diagrammer
|
||||
chart1.data.labels = labels;
|
||||
chart1.data.datasets[0].data = sA;
|
||||
chart1.data.datasets[1].data = sB;
|
||||
chart1.update('none');
|
||||
|
||||
chart2.data.labels = labels;
|
||||
chart2.data.datasets[0].data = gaps;
|
||||
chart2.update('none');
|
||||
|
||||
chart3.data.labels = labels;
|
||||
chart3.data.datasets[0].data = monthly;
|
||||
chart3.update('none');
|
||||
|
||||
chart4.data.labels = labels;
|
||||
chart4.data.datasets[0].data = cumA;
|
||||
chart4.data.datasets[1].data = cumB;
|
||||
chart4.data.datasets[2].data = cumGaps;
|
||||
chart4.update('none');
|
||||
}
|
||||
|
||||
['salA','salB','pct','yrs'].forEach(id =>
|
||||
document.getElementById(id).addEventListener('input', update)
|
||||
);
|
||||
update();
|
||||
Loading…
Add table
Add a link
Reference in a new issue