${headline}
${sub}
`; +} + +// Plain-language verdict for both situations. A kept baseline (no purchasePrice) is a "keep vs +// switch" decision; a bought baseline (purchasePrice set) is a "buy vs buy" decision — e.g. +// replacing a lost car — where nothing is kept, so "pays for itself" is reframed and the cash line +// shows the GAP between the two purchases. All interpolated labels are HTML-escaped. +function verdictCopy(baseline, sw, cmp, years) { + const { breakEvenYear, lifetimeSaving, upfrontCash, baselineUpfront = 0 } = cmp; + const better = lifetimeSaving >= 0; // the switch/EV is cheaper to own over the horizon + const isReplace = baseline.purchasePrice != null; + const swL = `${esc(sw.label)}`; + const baseL = `${esc(baseline.label)}`; + let headline; - if (breakEvenYear === null) { - headline = better - ? `Cheaper overall, but doesn't break even within ${state.rates.years} years` - : `Costs more — keeping wins over ${state.rates.years} years`; - } else if (breakEvenYear === 0) { - headline = `Cheaper from year one`; + if (isReplace) { + if (breakEvenYear === 0) headline = `${swL} is cheaper — from year one`; + else if (breakEvenYear !== null) headline = `${swL}'s higher price is repaid by year ${breakEvenYear.toFixed(1)}`; + else if (better) headline = `${swL} works out cheaper, but only beyond ${years} years`; + else headline = `${baseL} is the cheaper choice over ${years} years`; } else { - headline = `Pays for itself in year ${breakEvenYear.toFixed(1)}`; + if (breakEvenYear === 0) headline = `${swL} is cheaper than keeping — from year one`; + else if (breakEvenYear !== null) headline = `${swL} pays for itself in year ${breakEvenYear.toFixed(1)}`; + else if (better) headline = `Cheaper overall, but doesn't pay back within ${years} years`; + else headline = `Keeping ${baseL} is cheapest over the next ${years} years`; } - box.className = "verdict " + (better ? "good" : "bad"); - box.innerHTML = ` -${headline}
-- Over ${state.rates.years} years, ${esc(sw.label)} is - ${gbp(Math.abs(lifetimeSaving))} ${better ? "cheaper" : "dearer"} - than ${esc(baseline.label)}. - Upfront cash needed now: ${gbp(upfrontCash)}. -
`; + + const own = `Over ${years} years, ${swL} is + ${gbp(Math.abs(lifetimeSaving))} ${better ? "cheaper" : "dearer"} to own than ${baseL}.`; + let cash; + if (isReplace) { + const gap = upfrontCash - baselineUpfront; + cash = Math.abs(gap) < 1 + ? `Both need about the same cash up front.` + : `${swL} needs ${gbp(Math.abs(gap))} ${gap > 0 ? "more" : "less"} cash up front.`; + } else { + cash = `Upfront cash needed now: ${gbp(upfrontCash)}.`; + } + return { headline, sub: `${own} ${cash}`, better }; } // "Why the lines diverge" — ranked cost factors that separate the two main scenarios. @@ -428,6 +451,8 @@ function bindEvents() { catch { flash($("#share"), "Copy from address bar"); } }); + $("#restart-onboard").addEventListener("click", openOnboarding); + $("#to-report").addEventListener("click", () => $("#report").scrollIntoView({ behavior: "smooth" })); } @@ -443,6 +468,17 @@ function applyTemplate(key) { renderAll(); } +// Open the guided "Start here" front door. onComplete swaps in the built state and renders the +// dashboard; onSkip just closes the overlay onto whatever is already there. +function openOnboarding() { + const overlay = $("#onboard"); + overlay.hidden = false; + initOnboarding({ + onComplete: (s) => { state = s; renderAll(); overlay.hidden = true; }, + onSkip: () => { overlay.hidden = true; }, + }); +} + function renderAll() { renderGlobals(); renderScenarioCards(); @@ -455,9 +491,13 @@ function init() { const barCtx = $("#bar-chart").getContext("2d"); lineChart = createLineChart(lineCtx); barChart = createBarChart(barCtx); - state = decodeState() ?? loadTemplate(DEFAULT_TEMPLATE); + // decodeState() is non-null only when the URL carries a shared #c= comparison; in that case skip + // the guided start and show exactly what was shared. A clean first visit opens the front door. + const shared = decodeState(); + state = shared ?? loadTemplate(DEFAULT_TEMPLATE); bindEvents(); renderAll(); + if (!shared) openOnboarding(); } document.addEventListener("DOMContentLoaded", init); diff --git a/js/cars.js b/js/cars.js index 20ac94a..60f1573 100644 --- a/js/cars.js +++ b/js/cars.js @@ -41,14 +41,21 @@ export const CAR_GROUPS = [ { label: "Hybrid", powertrain: "hybrid" }, ]; -/** Fields a chosen car contributes to a scenario (mileage & charging mix are left untouched). */ -export function applyCarToScenario(scenario, car) { +/** Fields a chosen car contributes to a scenario (mileage & charging mix are left untouched). + * By default a baseline card represents a car you already OWN, so its current value is seeded from + * the price and purchasePrice is cleared. Pass { asPurchase: true } for a baseline that is a car + * you'd BUY (e.g. replacing a lost car) so it keeps purchasePrice and stays a "buy" shape. */ +export function applyCarToScenario(scenario, car, opts = {}) { const fields = ["powertrain", "mpg", "milesPerKwh", "purchasePrice", "depreciationPctPerYear", "insurancePerYear", "servicingPerYear", "vedPerYear"]; const next = { ...scenario, carId: car.id, label: car.name }; for (const f of fields) if (car[f] != null) next[f] = car[f]; - // Baseline cars represent a car you already own: seed its current value from the price. - if (scenario.role === "baseline") next.currentValue = car.purchasePrice; + if (scenario.role === "baseline" && !opts.asPurchase) { + next.currentValue = car.purchasePrice; // a car you already own + delete next.purchasePrice; // keep it a "keep" shape for assetValue0/upfrontCash + } else if (opts.asPurchase) { + delete next.currentValue; // a car you'd buy — keep it a "buy" shape + } // Clear the efficiency field that doesn't apply to the new powertrain. if (car.powertrain === "ev") delete next.mpg; else delete next.milesPerKwh; return next; diff --git a/js/model.js b/js/model.js index e8717aa..aa4af2d 100644 --- a/js/model.js +++ b/js/model.js @@ -70,9 +70,12 @@ export function annualBreakdown(scenario, rates) { }; } -/** The capital tied up in this car today: current resale value if kept, purchase price if bought. */ +/** The capital tied up in this car today: purchase price if bought, current resale value if kept. + * Keyed off which field is present, not the scenario's role, so a "buy vs buy" comparison (e.g. + * replacing a lost car — every option is a purchase, none is kept) depreciates each option from its + * purchase price. A kept car carries currentValue and no purchasePrice, so it is unaffected. */ export function assetValue0(scenario) { - return scenario.role === "baseline" ? (scenario.currentValue ?? 0) : (scenario.purchasePrice ?? 0); + return scenario.purchasePrice != null ? scenario.purchasePrice : (scenario.currentValue ?? 0); } /** Depreciation lost by end of year t (reducing-balance). */ @@ -98,10 +101,12 @@ export function cumulativeSeries(scenario, rates) { return out; } -/** Upfront cash needed to take this option today (0 for keeping the current car). */ +/** Upfront cash needed to take this option today. £0 for keeping the current car (no purchasePrice); + * purchase price minus any trade-in for a bought car. Keyed off purchasePrice presence so a bought + * baseline (lost-car replacement) reports its own cash instead of £0. */ export function upfrontCash(scenario) { - if (scenario.role === "baseline") return 0; - return Math.max(0, (scenario.purchasePrice ?? 0) - (scenario.tradeInValue ?? 0)); + if (scenario.purchasePrice == null) return 0; + return Math.max(0, scenario.purchasePrice - (scenario.tradeInValue ?? 0)); } /** All-in pence per mile over the full horizon. */ @@ -141,7 +146,9 @@ export function compare(baseline, switchTo, rates) { } const lifetimeSaving = cumulativeCostAt(baseline, rates, rates.years) - cumulativeCostAt(switchTo, rates, rates.years); - return { breakEvenYear, lifetimeSaving, upfrontCash: upfrontCash(switchTo) }; + // upfrontCash is the switch's cash; baselineUpfront is the baseline's (0 when the baseline is a + // kept car, non-zero on a buy-vs-buy comparison) so the verdict can show the cash GAP between them. + return { breakEvenYear, lifetimeSaving, upfrontCash: upfrontCash(switchTo), baselineUpfront: upfrontCash(baseline) }; } /** diff --git a/js/onboarding.js b/js/onboarding.js new file mode 100644 index 0000000..021d483 --- /dev/null +++ b/js/onboarding.js @@ -0,0 +1,197 @@ +// onboarding.js — the guided "Start here" front door. A small step machine that asks a few +// plain-language questions and builds a sensible comparison for someone with no car knowledge. +// +// There are NO free-text inputs here: every answer is a fixed button or a