Norwegian Nursing Home Demand-Supply Model
Interactive Dashboard — Municipal Home Care & Institutional Care Projections 2025–2036
About this model
This dashboard presents a Markov cohort model of Norwegian elderly home care and nursing home demand, projected from 2025 to 2036. The model is built from publicly available Norwegian data sources and calibrated to reproduce the observed 2025 care system.
Key question: If early-stage home care stabilisation interventions slow the progression of elderly patients through care need levels, how much does this delay or reduce the nursing home capacity crisis projected under current demographic trends?
Data sources: KOSTRA 2025 (municipal care expenditure and capacity), SSB table 11645 (care service users), SSB table 11875 (institutional beds), SSB population projections (strong and weak ageing scenarios).
Model parameters
Code
// Display calibrated transition probabilities
{
const probs = params.calibrated_probs
const rows = [
["Light → Moderate", (probs.light_to_moderate * 100).toFixed(2) + "%", "Primary care pathway progression"],
["Moderate → Intensive", (probs.moderate_to_intensive * 100).toFixed(2) + "%","Progression to high-need home care"],
["Intensive → NH", (probs.intensive_to_nh * 100).toFixed(2) + "%", "Admission from intensive home care"],
["Light → NH (direct)", (probs.light_to_nh * 100).toFixed(2) + "%", "Direct admission bypassing progression"],
["Moderate → NH (direct)",(probs.moderate_to_nh * 100).toFixed(2) + "%", "Direct admission from moderate care"],
["NH mortality", (probs.die_nh * 100).toFixed(2) + "%", "Annual mortality in nursing home"]
]
return html`
<div style="margin: 1rem 0;">
<h4 style="color: #1F3864; margin-bottom: 0.5rem;">Calibrated 2025 Transition Probabilities</h4>
<p style="font-size: 0.85rem; color: #666; margin-bottom: 0.75rem;">
These probabilities were calibrated to reproduce the observed 2025 Norwegian care system:
${params.calibration_targets.observed_light.toLocaleString()} light-need,
${params.calibration_targets.observed_moderate.toLocaleString()} moderate-need,
${params.calibration_targets.observed_intensive.toLocaleString()} intensive-need home care users,
and ${params.calibration_targets.observed_nh.toLocaleString()} long-term nursing home residents.
</p>
<table style="width:100%; border-collapse:collapse; font-size:0.9rem;">
<thead>
<tr style="background:#1F3864; color:white;">
<th style="padding:8px 12px; text-align:left;">Transition</th>
<th style="padding:8px 12px; text-align:center;">Annual probability</th>
<th style="padding:8px 12px; text-align:left;">Interpretation</th>
</tr>
</thead>
<tbody>
${rows.map((r, i) => html`
<tr style="background:${i % 2 === 0 ? '#f8f9fa' : 'white'};">
<td style="padding:7px 12px;">${r[0]}</td>
<td style="padding:7px 12px; text-align:center; font-weight:bold;">${r[1]}</td>
<td style="padding:7px 12px; color:#555;">${r[2]}</td>
</tr>
`)}
</tbody>
</table>
<p style="font-size:0.8rem; color:#888; margin-top:0.5rem;">
Supply ceiling: ${params.national_supply_flat.toLocaleString()} beds (2025 baseline).
Home care entry rate: ${(params.hc_entry_rate * 100).toFixed(2)}% of elderly population per year.
</p>
</div>
`
}Section 1 — NH demand vs supply trajectory
Use the controls below to adjust the intervention strength and see how the nursing home demand trajectory changes.
Code
viewof intervention_pct = Inputs.range([0, 40], {
step: 10,
value: 0,
label: "Intervention strength (% reduction in progression rates)"
})
viewof supply_scenario = Inputs.select(
["flat", "historical", "expansion"],
{
value: "flat",
label: "Supply scenario",
format: x => x === "flat" ? "Flat (no new beds)" :
x === "historical" ? "Historical growth (+0.4%/yr)" :
"Expansion (+500 beds/yr)"
}
)Code
selected_label = intervention_pct + "% reduction"
filtered_traj = trajectories.filter(d =>
d.intervention === selected_label
)
// Compute supply line
supply_line = filtered_traj.map(d => ({
year: d.year,
supply: supply_scenario === "flat" ? params.national_supply_flat :
supply_scenario === "historical" ? Math.round(params.national_supply_flat *
Math.pow(1 + params.supply_growth_rate, d.year - 2025)) :
params.national_supply_flat +
params.expansion_annual * (d.year - 2025)
}))
// Crossover detection
crossover_year_val = (() => {
for (let i = 0; i < filtered_traj.length; i++) {
const supply = supply_line[i].supply
if (filtered_traj[i].nh_residents > supply) return filtered_traj[i].year
}
return "No crossover by 2036"
})()Code
// ── Crossover year badge ──────────────────────────────────────────────────
html`
<div style="display:flex; gap:1rem; margin: 1rem 0; flex-wrap:wrap;">
<div style="background:#1F3864; color:white; padding:1rem 1.5rem;
border-radius:8px; min-width:200px;">
<div style="font-size:0.8rem; opacity:0.8;">Crossover year</div>
<div style="font-size:1.8rem; font-weight:bold;">${crossover_year_val}</div>
<div style="font-size:0.75rem; opacity:0.7;">demand exceeds supply</div>
</div>
<div style="background:#E2EFDA; color:#1F5C1F; padding:1rem 1.5rem;
border-radius:8px; min-width:200px;">
<div style="font-size:0.8rem;">Intervention</div>
<div style="font-size:1.8rem; font-weight:bold;">${intervention_pct}%</div>
<div style="font-size:0.75rem;">reduction in progression rates</div>
</div>
<div style="background:#F2F2F2; color:#333; padding:1rem 1.5rem;
border-radius:8px; min-width:200px;">
<div style="font-size:0.8rem;">Supply ceiling (2025)</div>
<div style="font-size:1.8rem; font-weight:bold;">${params.national_supply_flat.toLocaleString()}</div>
<div style="font-size:0.75rem;">available institutional beds</div>
</div>
</div>
`Code
// ── Trajectory plot ───────────────────────────────────────────────────────
Plot.plot({
width: 800,
height: 380,
marginLeft: 70,
marginRight: 20,
x: { label: "Year", tickFormat: d => d.toString() },
y: { label: "NH residents / beds", tickFormat: d => d.toLocaleString() },
marks: [
// Shaded over-capacity area
Plot.areaY(
filtered_traj.map((d, i) => ({
year: d.year,
y1: Math.min(d.nh_residents, supply_line[i].supply),
y2: Math.max(d.nh_residents, supply_line[i].supply),
over: d.nh_residents > supply_line[i].supply
})).filter(d => d.over),
{ x: "year", y1: "y1", y2: "y2", fill: "#C00000", fillOpacity: 0.15 }
),
// Supply line
Plot.line(
supply_line,
{ x: "year", y: "supply", stroke: "#4472C4",
strokeWidth: 2, strokeDasharray: "6,3" }
),
// Demand line
Plot.line(
filtered_traj,
{ x: "year", y: "nh_residents", stroke: "#C00000", strokeWidth: 2.5 }
),
Plot.dot(
filtered_traj,
{ x: "year", y: "nh_residents", fill: "#C00000", r: 4 }
),
// Crossover annotation
...(crossover_year_val !== "No crossover by 2036" ? [
Plot.text(
[{ year: crossover_year_val, label: "⚠ Crossover" }],
{ x: "year", y: supply_line.find(d => d.year === crossover_year_val)?.supply || 0,
dy: -12, text: "label", fill: "#C00000", fontWeight: "bold", fontSize: 11 }
)
] : [])
],
style: { fontSize: 12 }
})Red line = projected NH residents. Dashed blue = supply ceiling. Red shading = demand exceeds supply.
Section 2 — Gap closure by intervention strength
This chart shows how the gap between projected nursing home demand and supply in 2034 closes as intervention strength increases.
Code
Plot.plot({
width: 750,
height: 350,
marginLeft: 80,
marginRight: 20,
x: {
label: "Intervention strength (% reduction in progression rates)",
tickFormat: d => (d * 100).toFixed(0) + "%"
},
y: {
label: "Remaining gap (residents above supply ceiling)",
tickFormat: d => d.toLocaleString()
},
marks: [
Plot.ruleY([0], { stroke: "#C00000", strokeDasharray: "6,3", strokeWidth: 1.5 }),
Plot.areaY(gap_closure, {
x: "strength", y: "gap",
fill: "#4472C4", fillOpacity: 0.15,
curve: "monotone-x"
}),
Plot.line(gap_closure, {
x: "strength", y: "gap",
stroke: "#4472C4", strokeWidth: 2.5,
curve: "monotone-x"
}),
Plot.dot(
gap_closure.filter(d => [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.7, 1.0].includes(d.strength)),
{ x: "strength", y: "gap", fill: "#4472C4", r: 5 }
),
Plot.text(
gap_closure.filter(d => [0, 0.2, 0.4, 0.7, 1.0].includes(d.strength)),
{ x: "strength", y: "gap",
text: d => d.gap_closed_pct + "% closed",
dy: -12, fontSize: 10, fill: "#333" }
),
Plot.text(
[{ strength: 0.05, gap: 200 }],
{ x: "strength", y: "gap",
text: ["Supply ceiling"],
fill: "#C00000", fontSize: 11 }
)
],
style: { fontSize: 12 }
})Section 3 — Annual cost savings
Code
Code
filtered_costs = cost_data.filter(d => d.intervention === cost_intervention)
// Cumulative saving
cumulative_saving = filtered_costs.reduce((sum, d) => sum + d.annual_saving_NOK, 0)
html`
<div style="display:flex; gap:1rem; margin:1rem 0; flex-wrap:wrap;">
<div style="background:#C6EFCE; color:#1F5C1F; padding:1rem 1.5rem;
border-radius:8px; min-width:220px;">
<div style="font-size:0.8rem;">Cumulative saving 2025–2034</div>
<div style="font-size:1.6rem; font-weight:bold;">
NOK ${(cumulative_saving / 1e9).toFixed(1)}B
</div>
<div style="font-size:0.75rem;">${cost_intervention}</div>
</div>
<div style="background:#E2EFDA; color:#1F5C1F; padding:1rem 1.5rem;
border-radius:8px; min-width:220px;">
<div style="font-size:0.8rem;">Annual saving by 2034</div>
<div style="font-size:1.6rem; font-weight:bold;">
NOK ${((filtered_costs.at(-1)?.annual_saving_NOK || 0) / 1e6).toFixed(0)}M
</div>
<div style="font-size:0.75rem;">vs baseline</div>
</div>
</div>
`Code
Plot.plot({
width: 750,
height: 320,
marginLeft: 80,
marginRight: 20,
x: { label: "Year", tickFormat: d => d.toString() },
y: {
label: "Annual saving vs baseline (NOK million)",
tickFormat: d => d.toLocaleString()
},
marks: [
Plot.ruleY([0]),
Plot.barY(filtered_costs, {
x: "year",
y: d => d.annual_saving_NOK / 1e6,
fill: "#70AD47",
fillOpacity: 0.8
}),
Plot.text(filtered_costs, {
x: "year",
y: d => d.annual_saving_NOK / 1e6,
text: d => "NOK " + Math.round(d.annual_saving_NOK / 1e6) + "M",
dy: -8, fontSize: 10, fill: "#333"
})
],
style: { fontSize: 12 }
})Section 4 — Sensitivity analysis
How does the crossover year change if transition probabilities are 20% higher or lower than calibrated?
Code
sens_summary = ["Low (-20%)", "Base (0%)", "High (+20%)"].map(scen => {
const scen_data = sensitivity.filter(d => d.sensitivity === scen)
const crossover = scen_data.find(d => d.over_capacity)
const max_residents = Math.max(...scen_data.map(d => d.nh_residents))
return {
scenario: scen,
crossover_year: crossover ? crossover.year : "No crossover",
max_residents: max_residents,
scen_data: scen_data
}
})
html`
<div style="display:flex; gap:1rem; margin:1rem 0; flex-wrap:wrap;">
${sens_summary.map(s => html`
<div style="background:${s.scenario.includes('Low') ? '#E2EFDA' :
s.scenario.includes('High') ? '#FCE4D6' : '#D9E1F2'};
padding:1rem 1.5rem; border-radius:8px; min-width:180px;">
<div style="font-size:0.8rem; font-weight:bold;">${s.scenario}</div>
<div style="font-size:1.4rem; font-weight:bold;">
${s.crossover_year}
</div>
<div style="font-size:0.75rem; color:#555;">
Max: ${s.max_residents.toLocaleString()} residents
</div>
</div>
`)}
</div>
`Code
Plot.plot({
width: 750,
height: 340,
marginLeft: 70,
marginRight: 20,
x: { label: "Year", tickFormat: d => d.toString() },
y: {
label: "NH residents",
tickFormat: d => d.toLocaleString()
},
color: {
domain: ["Low (-20%)", "Base (0%)", "High (+20%)"],
range: ["#70AD47", "#4472C4", "#C00000"],
legend: true
},
marks: [
Plot.ruleY([params.national_supply_flat], {
stroke: "black", strokeDasharray: "6,3", strokeWidth: 1.5
}),
Plot.text(
[{ year: 2026, y: params.national_supply_flat }],
{ x: "year", y: "y", text: ["Supply ceiling"], dy: -10,
fontSize: 10, fill: "#333" }
),
...["Low (-20%)", "Base (0%)", "High (+20%)"].map(scen =>
Plot.line(
sensitivity.filter(d => d.sensitivity === scen),
{ x: "year", y: "nh_residents",
stroke: scen, strokeWidth: 2 }
)
)
],
style: { fontSize: 12 }
})Section 5 — Municipality crossover table
Municipalities where projected long-term institutional demand is expected to exceed supply, sorted by urgency.
Code
Code
Inputs.table(muni_search, {
columns: ["muni_code", "muni_name", "year", "total_elderly",
"projected_demand", "supply", "admissions_to_prevent",
"pct_reduction_needed"],
header: {
muni_code: "Code",
muni_name: "Municipality",
year: "Action from",
total_elderly: "Elderly pop",
projected_demand: "Projected demand",
supply: "Supply (beds)",
admissions_to_prevent: "Admissions to prevent",
pct_reduction_needed: "% reduction needed"
},
format: {
total_elderly: d => Math.round(d).toLocaleString(),
projected_demand: d => Math.round(d).toLocaleString(),
supply: d => Math.round(d).toLocaleString(),
admissions_to_prevent: d => Math.round(d).toLocaleString(),
pct_reduction_needed: d => d.toFixed(1) + "%"
},
sort: "year",
width: { muni_name: 180, pct_reduction_needed: 160 }
})Section 6 — Population projections
Code
Code
Plot.plot({
width: 750,
height: 340,
marginLeft: 70,
marginRight: 20,
x: { label: "Year", tickFormat: d => d.toString() },
y: {
label: "Population (thousands)",
tickFormat: d => (d / 1000).toFixed(0) + "k"
},
color: {
domain: ["67-79", "80-89", "90+"],
range: ["#4472C4", "#ED7D31", "#C00000"],
legend: true
},
marks: [
...["67-79", "80-89", "90+"].map(band =>
Plot.line(
pop_proj.filter(d => d.scenario === pop_scenario && d.age_band === band),
{ x: "year", y: "population", stroke: band, strokeWidth: 2 }
)
),
...["67-79", "80-89", "90+"].map(band =>
Plot.dot(
pop_proj.filter(d => d.scenario === pop_scenario && d.age_band === band),
{ x: "year", y: "population", fill: band, r: 3 }
)
)
],
style: { fontSize: 12 }
})Assumptions and limitations
Code
html`
<div style="background:#FFF2CC; border-left:4px solid #FFC000;
padding:1rem 1.5rem; border-radius:4px; margin:1rem 0;">
<h4 style="margin-top:0; color:#7F6000;">Key assumptions</h4>
<ol style="color:#555; font-size:0.9rem; line-height:1.7;">
<li><strong>Transition probabilities</strong> are calibrated to reproduce the observed 2025
Norwegian care system cross-section, not estimated from longitudinal follow-up data.
Longitudinal registry data (NorCog, IPLOS) would provide more accurate estimates.</li>
<li><strong>Mortality rates</strong> (light 3%, moderate 5%, intensive 8%, NH 25%) are
informed assumptions consistent with Norwegian life tables for ages 75–85,
but not formally derived from SSB age-sex-specific tables.</li>
<li><strong>New entrants</strong> are assumed to enter home care at the light need level.
In practice some people enter at moderate or intensive need following hospital discharge,
which would accelerate the pipeline to nursing home admission.</li>
<li><strong>Average home care duration</strong> of 4 years is a literature-informed
assumption. The model results are moderately sensitive to this parameter.</li>
<li><strong>Average NH stay</strong> of 2.5 years is used to estimate annual admissions
from the observed stock. Norwegian NH data suggests 2–3 years is a plausible range.</li>
<li><strong>Need-level shares</strong> (36.7% light, 29.8% moderate, 24.7% intensive)
are from SSB national data for all home care users, not dementia patients specifically.</li>
<li><strong>Supply</strong> is modelled as flat, growing at historical rate (+0.4%/yr),
or expanding (+500 beds/yr). No municipality-specific bed expansion plans are incorporated.</li>
</ol>
</div>
<div style="background:#D9E1F2; border-left:4px solid #4472C4;
padding:1rem 1.5rem; border-radius:4px; margin:1rem 0;">
<h4 style="margin-top:0; color:#1F3864;">Data sources</h4>
<ul style="color:#555; font-size:0.9rem; line-height:1.7;">
<li>KOSTRA 2025 — municipal care expenditure, bed counts, user statistics</li>
<li>SSB table 11645 — users of care services by type (long-term stay, home nursing etc.)</li>
<li>SSB table 11875 — institutional beds by type and ownership</li>
<li>SSB population projections — strong and weak ageing scenarios, single-year age, 2025–2036</li>
<li>Norwegian care cost data — unit costs per care state estimated from KOSTRA expenditure
and SSB salary statistics</li>
</ul>
</div>
`Model built in R using a discrete-time Markov cohort approach. Dashboard rendered with Quarto and Observable JS. All data from publicly available Norwegian sources.