diff --git a/README.md b/README.md index 59039ae..72b3ea6 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,9 @@ starting points you edit. ## How it works - `js/model.js` — the pure total-cost-of-ownership maths (no DOM); the formulas live here. +- `js/onboarding.js` — the guided **Start here** front door: a few plain-language questions that + build a sensible comparison, so you need no car knowledge to begin (skippable; power users go + straight to the dashboard). - `js/templates.js` — anonymised starting scenarios + editable UK default rates. - `js/charts.js` — the cumulative-cost (crossover) line chart and the cost-breakdown bar. - `js/app.js` — wires inputs ↔ model ↔ charts, renders the pence-per-mile gauges, syncs the URL. diff --git a/css/styles.css b/css/styles.css index 7c699dc..24ccf56 100644 --- a/css/styles.css +++ b/css/styles.css @@ -260,5 +260,49 @@ a { color: var(--accent); } * { transition: none !important; scroll-behavior: auto !important; } } +/* ---------------- guided start (onboarding overlay) ---------------- */ +.onboard { + position: fixed; inset: 0; z-index: 50; + display: grid; place-items: center; padding: 20px; + background: rgba(27, 42, 46, .55); +} +.onboard[hidden] { display: none; } +.onboard-card { + position: relative; width: 100%; max-width: 560px; padding: 26px 24px 22px; + background: var(--panel); border-radius: var(--radius); box-shadow: var(--shadow); +} +.onboard-card h2 { font-size: 24px; margin: 0 0 4px; } +.onboard-lede { color: var(--muted); margin: 0 0 18px; } +.onboard-skip { + position: absolute; top: 14px; right: 14px; + background: transparent; border: 1px solid var(--line); color: var(--muted); + border-radius: 8px; padding: 6px 12px; font: inherit; cursor: pointer; +} +.onboard-skip:hover { color: var(--ink); border-color: var(--grid); } +.onboard-q { font-family: "Space Grotesk", "Inter", sans-serif; font-size: 17px; margin: 0 0 12px; } +.onboard-choices { display: flex; flex-direction: column; gap: 10px; } +.onboard-choice { + text-align: left; font: inherit; cursor: pointer; + background: #FBFCFB; border: 1px solid var(--line); border-left: 4px solid var(--accent); + border-radius: 10px; padding: 12px 14px; transition: border-color .15s, background .15s; +} +.onboard-choice:hover { border-left-color: var(--ev); background: var(--good-bg); } +.onboard-choice strong { display: block; font-family: "Space Grotesk", "Inter", sans-serif; } +.onboard-choice span { display: block; color: var(--muted); font-size: 13px; margin-top: 2px; } +.onboard-select { + width: 100%; font: inherit; padding: 10px 12px; + border: 1px solid var(--line); border-radius: 10px; background: #FBFCFB; color: var(--ink); +} +.onboard-actions { margin-top: 14px; } +.onboard-next { + font: inherit; cursor: pointer; color: #fff; background: var(--ev); + border: none; border-radius: 10px; padding: 10px 18px; +} +.onboard-next:hover { filter: brightness(1.05); } +.onboard-back { + margin-top: 14px; padding: 6px 0; font: inherit; cursor: pointer; + background: none; border: none; color: var(--accent); +} + /* visible keyboard focus everywhere */ :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } diff --git a/index.html b/index.html index b8ae070..41fa622 100644 --- a/index.html +++ b/index.html @@ -22,6 +22,7 @@

EV or not?

+
@@ -29,10 +30,23 @@

EV or not?

Years 7 +
+ + + +
diff --git a/js/app.js b/js/app.js index d6b42a1..c98e67b 100644 --- a/js/app.js +++ b/js/app.js @@ -8,6 +8,7 @@ import { createLineChart, createBarChart, updateLineChart, updateBarChart, setBreakEvenMarker, } from "./charts.js"; import { CARS, CAR_GROUPS, applyCarToScenario } from "./cars.js"; +import { initOnboarding } from "./onboarding.js"; const SERIES_COLORS = ["#D9772B", "#16A571", "#2E6BE6"]; // baseline, switch, third const MAX_SCENARIOS = 3; @@ -86,10 +87,12 @@ const COMMON_FIELDS = [ ]; const CHARGE_FIELDS = ["homePct", "publicPct", "solarPct"]; +// Keyed off which value the scenario actually holds (a kept car has currentValue; a bought car — +// including a bought baseline on the lost-car path — has purchasePrice), to match model.assetValue0. function valueField(s) { - return s.role === "baseline" - ? { f: "currentValue", label: "Current value £", step: 250, min: 0 } - : { f: "purchasePrice", label: "Purchase price £", step: 250, min: 0 }; + return s.purchasePrice != null + ? { f: "purchasePrice", label: "Purchase price £", step: 250, min: 0 } + : { f: "currentValue", label: "Current value £", step: 250, min: 0 }; } // ---------- render: scenario cards ---------- @@ -264,27 +267,47 @@ function recompute() { function renderVerdict(baseline, sw, cmp) { const box = $("#verdict"); if (!baseline || !sw || !cmp) { box.innerHTML = ""; return; } - const { breakEvenYear, lifetimeSaving, upfrontCash } = cmp; - const better = lifetimeSaving >= 0; + const { headline, sub, better } = verdictCopy(baseline, sw, cmp, state.rates.years); + box.className = "verdict " + (better ? "good" : "bad"); + box.innerHTML = `

${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 ${groups} +
${backHtml()}`; + } + + function render() { + if (cursor < 0) renderSituation(); + else renderStep(flow[cursor]); + } + + function advance() { + cursor += 1; + if (cursor >= flow.length) finish(); + else render(); + } + + body.onclick = (e) => { + const t = e.target.closest("[data-pick],[data-back],[data-next]"); + if (!t) return; + if (t.dataset.back != null) { cursor -= 1; render(); return; } + if (t.dataset.next != null) { + const sel = document.getElementById("onboard-car"); + answers[flow[cursor]] = sel ? sel.value : CARS[0].id; // flow[cursor] is "carA" or "carB" + advance(); + return; + } + const idx = +t.dataset.pick; + if (cursor < 0) { + answers.situation = SITUATIONS[idx].key; + flow = FLOWS[answers.situation] ?? []; + cursor = 0; + render(); + } else { + answers[flow[cursor]] = choices[idx]; + advance(); + } + }; + + if (skipBtn) skipBtn.onclick = doSkip; + document.addEventListener("keydown", onKey); + renderSituation(); +} diff --git a/js/templates.js b/js/templates.js index cffe843..0e05b50 100644 --- a/js/templates.js +++ b/js/templates.js @@ -54,6 +54,38 @@ export const TEMPLATES = { }, ], }, + + // Lost / written-off car: there's nothing to keep and nothing to trade in, so every option is a + // fresh purchase. Each scenario carries purchasePrice and no currentValue/tradeInValue — a pure + // buy-vs-buy comparison on equal mileage. + "replace-lost-car": { + label: "Lost your car? Used EV vs petrol vs diesel", + blurb: + "Your car is gone and you need a replacement. This compares three used buys on the same " + + "mileage — which is cheapest to own over the years, and the cash each one needs up front.", + rates: { ...DEFAULT_RATES, years: 7 }, + scenarios: [ + { + id: "buy-petrol", role: "baseline", label: "Buy a used petrol", powertrain: "petrol", + annualMiles: 9000, mpg: 50, + purchasePrice: 9000, depreciationPctPerYear: 0.10, + insurancePerYear: 380, servicingPerYear: 280, repairsPerYear: 150, vedPerYear: 180, + }, + { + id: "buy-ev", role: "switch", label: "Buy a used EV", powertrain: "ev", + annualMiles: 9000, milesPerKwh: 4.0, + homePct: 80, publicPct: 20, solarPct: 0, + purchasePrice: 15000, depreciationPctPerYear: 0.11, + insurancePerYear: 520, servicingPerYear: 150, vedPerYear: 0, + }, + { + id: "buy-diesel", role: "compare", label: "Buy a used diesel", powertrain: "diesel", + annualMiles: 9000, mpg: 58, + purchasePrice: 12000, depreciationPctPerYear: 0.11, + insurancePerYear: 460, servicingPerYear: 360, repairsPerYear: 150, vedPerYear: 180, + }, + ], + }, }; export const DEFAULT_TEMPLATE = "keep-vs-used-ev-solar"; diff --git a/test/model.test.mjs b/test/model.test.mjs index daa54be..f32e9b6 100644 --- a/test/model.test.mjs +++ b/test/model.test.mjs @@ -10,6 +10,7 @@ import { cumulativeCostAt, cumulativeSeries, pencePerMile, + assetValue0, upfrontCash, compare, divergenceReasons, @@ -144,6 +145,41 @@ test("compare() reports break-even 0 when the switch is cheaper from year one", assert.ok(r.lifetimeSaving > 0); }); +test("assetValue0 keys off the field present, not the role (buy-vs-buy support)", () => { + // A bought baseline (lost-car replacement) has purchasePrice → depreciate from the purchase price. + assert.equal(assetValue0({ role: "baseline", purchasePrice: 9000 }), 9000); + // A kept car carries currentValue and no purchasePrice → unchanged behaviour. + assert.equal(assetValue0({ role: "baseline", currentValue: 4000 }), 4000); + // purchasePrice wins if somehow both are present. + assert.equal(assetValue0({ role: "baseline", purchasePrice: 9000, currentValue: 4000 }), 9000); +}); + +test("upfront cash is reported for a bought baseline, still zero for a kept car", () => { + assert.equal(upfrontCash({ role: "baseline", purchasePrice: 9000 }), 9000); + assert.equal(upfrontCash({ role: "baseline", purchasePrice: 12000, tradeInValue: 2000 }), 10000); + assert.equal(upfrontCash({ role: "baseline", currentValue: 4000 }), 0); +}); + +test("kept baseline still depreciates from currentValue, not a purchase price", () => { + // currentValue 4000, 10%/yr, no running costs → year-1 cumulative ≈ 400 of depreciation. + const keep = { role: "baseline", powertrain: "petrol", annualMiles: 0, mpg: 50, currentValue: 4000, depreciationPctPerYear: 0.10 }; + approx(cumulativeCostAt(keep, rates, 1), 400, 0.5); +}); + +test("compare() on buy-vs-buy reports each option's own upfront cash", () => { + const buyPetrol = { + role: "baseline", powertrain: "petrol", annualMiles: 9000, mpg: 50, + purchasePrice: 9000, depreciationPctPerYear: 0.10, insurancePerYear: 380, servicingPerYear: 280, vedPerYear: 180, + }; + const buyEv = { + role: "switch", powertrain: "ev", annualMiles: 9000, milesPerKwh: 4.0, homePct: 80, publicPct: 20, + purchasePrice: 15000, depreciationPctPerYear: 0.11, insurancePerYear: 520, servicingPerYear: 150, vedPerYear: 0, + }; + const r = compare(buyPetrol, buyEv, { ...rates, years: 7 }); + assert.equal(r.upfrontCash, 15000); // the EV's cash + assert.equal(r.baselineUpfront, 9000); // the petrol's cash — no longer forced to 0 +}); + test("compare() reports no break-even when the switch never pays back", () => { const cheapKeep = { role: "baseline", powertrain: "petrol", annualMiles: 3000, mpg: 50,