Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
44 changes: 44 additions & 0 deletions css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
14 changes: 14 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,31 @@ <h1>EV&nbsp;or&nbsp;not?</h1>
<div class="templates" role="group" aria-label="Start from a template">
<button class="chip" data-template="keep-vs-used-ev-solar">Keep vs used EV + solar</button>
<button class="chip" data-template="mileage-jumped">Mileage jumped — now or wait?</button>
<button class="chip" data-template="replace-lost-car">Lost your car?</button>
</div>

<div class="globals">
<label class="years">
<span>Years <strong id="years-val">7</strong></span>
<input id="years" type="range" min="1" max="15" step="1" value="7">
</label>
<button id="restart-onboard" class="btn-ghost" title="Answer a few plain questions to set up a comparison">Start here</button>
<button id="share" class="btn-ghost" title="Copy a shareable link to this comparison">Share</button>
</div>
</header>

<!-- ===================== GUIDED START (novice front door) ===================== -->
<!-- Shown only on a clean first visit (no #c= hash). Plain-language questions build a sensible
comparison; the dashboard below stays for fine-tuning. Body is rendered by js/onboarding.js. -->
<div id="onboard" class="onboard" hidden role="dialog" aria-modal="true" aria-labelledby="onboard-title">
<div class="onboard-card">
<button class="onboard-skip" data-onboard="skip">Skip — I know my numbers</button>
<h2 id="onboard-title">Start here</h2>
<p class="onboard-lede">A few plain questions — no car knowledge needed.</p>
<div id="onboard-body"></div>
</div>
</div>

<main class="dashboard">
<!-- left: editable scenarios -->
<section class="panel inputs" aria-label="Your cars and numbers">
Expand Down
84 changes: 62 additions & 22 deletions js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 ----------
Expand Down Expand Up @@ -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 = `<p class="verdict-head">${headline}</p><p class="verdict-sub">${sub}</p>`;
}

// 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 = `<em>${esc(sw.label)}</em>`;
const baseL = `<em>${esc(baseline.label)}</em>`;

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 <strong>year ${breakEvenYear.toFixed(1)}</strong>`;
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 <strong>year ${breakEvenYear.toFixed(1)}</strong>`;
if (breakEvenYear === 0) headline = `${swL} is cheaper than keeping — from year one`;
else if (breakEvenYear !== null) headline = `${swL} pays for itself in <strong>year ${breakEvenYear.toFixed(1)}</strong>`;
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 = `
<p class="verdict-head">${headline}</p>
<p class="verdict-sub">
Over ${state.rates.years} years, <em>${esc(sw.label)}</em> is
<strong>${gbp(Math.abs(lifetimeSaving))} ${better ? "cheaper" : "dearer"}</strong>
than <em>${esc(baseline.label)}</em>.
Upfront cash needed now: <strong>${gbp(upfrontCash)}</strong>.
</p>`;

const own = `Over ${years} years, ${swL} is
<strong>${gbp(Math.abs(lifetimeSaving))} ${better ? "cheaper" : "dearer"}</strong> 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 <strong>${gbp(Math.abs(gap))} ${gap > 0 ? "more" : "less"}</strong> cash up front.`;
} else {
cash = `Upfront cash needed now: <strong>${gbp(upfrontCash)}</strong>.`;
}
return { headline, sub: `${own} ${cash}`, better };
}

// "Why the lines diverge" — ranked cost factors that separate the two main scenarios.
Expand Down Expand Up @@ -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" }));
}
Expand All @@ -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();
Expand All @@ -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);
15 changes: 11 additions & 4 deletions js/cars.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
19 changes: 13 additions & 6 deletions js/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand All @@ -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. */
Expand Down Expand Up @@ -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) };
}

/**
Expand Down
Loading
Loading