// keel-page.jsx — Keel Check: how many months of cash a business has under four storms. // Free: everything on screen. Paid (Deal Pack / Complete, same codes as the calculator): one-page PDF (print) // + Excel that recalculates month by month (keel-workbook.js). // 🔴 Nothing the user types leaves the browser: state lives in localStorage only; all arithmetic is keel.js. // 🔴 Operating moves only. Never suggest borrowing, never name a lender, never use lending vocabulary. const KEEL_KEY = 'vc_keel_v1'; const KEEL_HORIZON = 18; // Text inputs as typed. Percentages are typed as percentages and converted for the engine. const KEEL_EMPTY = () => ({ revenue: '', margin: '', fixed: '', drawings: '', cash: '', overdraft: '', dso: '', dpo: '', dio: '', repay: '', floating: '', top: '', drop: '', rate: '', late: '', business: '', preparer: '', // only printed on the exports }); // Teaching case (fictional wholesaler). The only copy of these numbers on the site — roadmap §3.6. const KEEL_TEACHING = () => ({ revenue: '200000', margin: '30', fixed: '40000', drawings: '10000', cash: '120000', overdraft: '50000', dso: '45', dpo: '30', dio: '30', repay: '8000', floating: '600000', top: '25', drop: '', rate: '', late: '', business: 'Teaching case (fictional wholesaler)', preparer: '', }); // Blank optional fields fall back to these (shown as placeholders, so nothing is assumed silently). const KEEL_DEFAULTS = { drawings: 0, overdraft: 0, dso: 30, dpo: 30, dio: 0, repay: 0, floating: 0 }; const KEEL_REQUIRED = ['revenue', 'margin', 'fixed', 'cash', 'top']; const KEEL_LABELS = { revenue: 'Monthly revenue', margin: 'Gross margin', fixed: 'Fixed costs', drawings: 'Owner drawings', cash: 'Cash in the bank', overdraft: 'Undrawn overdraft limit', dso: 'Debtor days', dpo: 'Creditor days', dio: 'Stock days', repay: 'Loan repayments', floating: 'Floating-rate debt', top: 'Biggest customer', drop: 'Revenue fall', rate: 'Rate rise', late: 'Customers pay later', }; // Engine field → form field, so an engine error can name the box the user typed in. const KEEL_ENGINE_FIELD = { revenue: 'revenue', grossMargin: 'margin', fixedCosts: 'fixed', ownerDrawings: 'drawings', cash: 'cash', undrawnOverdraft: 'overdraft', dso: 'dso', dpo: 'dpo', dio: 'dio', debtService: 'repay', floatingDebt: 'floating', topCustomerShare: 'top', revenueDrop: 'drop', rateRise: 'rate', slowPayDays: 'late', }; const keelNum = (s) => { const t = String(s == null ? '' : s).replace(/[,\s$%]/g, ''); if (t === '') return null; const n = Number(t); return isFinite(n) ? n : NaN; }; // U+2060 (word joiner) keeps the sign on the same line as the amount when a narrow screen wraps. const keelMoney = (n) => { const v = Math.round(n); return (v < 0 ? "\u2212\u2060" : "") + "$" + Math.abs(v).toLocaleString("en-NZ"); }; // round first: −0.000…01 prints as $0, not −$0 const keelPerMonth = (n) => { const v = Math.round(n); return (v < 0 ? "\u2212\u2060" : "+\u2060") + "$" + Math.abs(v).toLocaleString("en-NZ") + " a month"; }; function keelInput(d) { const v = (k) => { const n = keelNum(d[k]); return n === null ? KEEL_DEFAULTS[k] : n; }; const pct = (k) => { const n = keelNum(d[k]); return n === null ? null : n / 100; }; const input = { revenue: v('revenue'), grossMargin: pct('margin'), fixedCosts: v('fixed'), ownerDrawings: v('drawings'), cash: v('cash'), undrawnOverdraft: v('overdraft'), dso: v('dso'), dpo: v('dpo'), dio: v('dio'), debtService: v('repay'), floatingDebt: v('floating'), topCustomerShare: pct('top'), }; const a = {}; const drop = keelNum(d.drop), rate = keelNum(d.rate), late = keelNum(d.late); if (drop !== null) a.revenueDrop = drop / 100; if (rate !== null) a.rateRise = rate / 100; if (late !== null) a.slowPayDays = late; return { input, assumptions: a }; } // "Standing" months out of the horizon, for the bar length. const keelStanding = (s) => (s.breaksInMonth === null ? KEEL_HORIZON : s.breaksInMonth - 1); // Once a storm has broken, the months after it are not a business any more: quote the gap in the month it // breaks (cash below the overdraft floor), never the lowest point further down the path. const keelShortfall = (s, overdraft) => -overdraft - s.path[s.breaksInMonth - 1]; // Why a storm is not run. A strength set to 0 is the user switching it off, not the business being immune // (Codex review 10-26, finding 5: "No floating-rate debt" was shown next to $600,000 of floating debt). const keelNaReason = (s, input, a) => s.id === 'rates' ? (a.rateRise === 0 ? 'Storm switched off.' : 'No floating-rate debt.') : s.id === 'slowpay' ? (a.slowPayDays === 0 ? 'Storm switched off.' : 'Customers pay on the spot, so they cannot pay later.') : s.id === 'customer' ? 'No customer concentration entered.' : 'Storm switched off.'; const keelBetter = (a, b) => { const ka = a.breaksInMonth === null ? Infinity : a.breaksInMonth, kb = b.breaksInMonth === null ? Infinity : b.breaksInMonth; return ka !== kb ? ka > kb : a.lowCash > b.lowCash + 0.5; }; function KeelField({ label, hint, children }) { const v4 = window.v4; return ( ); } function KeelLeverLine({ lever, before }) { const w = lever.weakest; if (!keelBetter(w, before)) return <>No change to the weakest storm.; if (before.breaksInMonth !== null && w.breaksInMonth === null) return <>Every storm is still standing at month {KEEL_HORIZON}.; if (before.breaksInMonth !== null && w.breaksInMonth === before.breaksInMonth) return <>Not enough on its own: still runs out in month {w.breaksInMonth}.; if (before.breaksInMonth !== null) return <>First break moves from month {before.breaksInMonth} to month {w.breaksInMonth}.; return <>Lowest cash in the weakest storm rises from {keelMoney(before.lowCash)} to {keelMoney(w.lowCash)}.; } // Print-only one-pager: storms, the cash path at a few months, the moves, and the inputs behind them. function KeelPrint({ d, r, input, assumptions }) { const v4 = window.v4; const a = r.assumptions; const w = r.weakest; const cell = { padding: '3px 4px', borderBottom: '1px solid #e2dccd', verticalAlign: 'top' }; const head = { ...cell, borderBottom: '1.5px solid #0d0d0d', textAlign: 'left', fontWeight: 700 }; const num = { ...cell, textAlign: 'right', fontFamily: v4.mono }; const at = [1, 3, 6, 12, 18]; const rows = [r.baseline, ...r.storms]; const inputs = [ ['Monthly revenue', keelMoney(input.revenue)], ['Gross margin', (input.grossMargin * 100).toFixed(1) + '%'], ['Fixed costs / month', keelMoney(input.fixedCosts)], ['Owner drawings / month', keelMoney(input.ownerDrawings)], ['Cash in the bank', keelMoney(input.cash)], ['Undrawn overdraft limit (floor)', keelMoney(input.undrawnOverdraft)], ['Debtor / creditor / stock days', `${input.dso} / ${input.dpo} / ${input.dio}`], ['Loan repayments / month', keelMoney(input.debtService)], ['Floating-rate debt', keelMoney(input.floatingDebt)], ['Biggest customer', (input.topCustomerShare * 100).toFixed(1) + '% of sales'], ['Storm strengths', `revenue −${+(a.revenueDrop * 100).toFixed(1)}% · rates +${+(a.rateRise * 100).toFixed(2)} pts · pay ${a.slowPayDays} days later`], ]; return (
Keel check · working paper
{d.business || 'Unnamed business'}
{d.preparer ? <>Prepared by {d.preparer}
: null} {new Date().toLocaleDateString('en-NZ', { day: 'numeric', month: 'long', year: 'numeric' })}
{!w ? No storm applies with these settings. : w.breaksInMonth !== null ? <>First to break: {w.label}. Cash runs out in month {w.breaksInMonth} of {KEEL_HORIZON}, short by {keelMoney(keelShortfall(w, input.undrawnOverdraft))} that month. : <>Weakest point: {w.label}. Still standing after {KEEL_HORIZON} months; lowest cash {keelMoney(w.lowCash)} in month {w.lowMonth}.} {r.burning ? <> Before any storm the business spends more than it makes ({keelPerMonth(r.baseline.monthlyNet)}). : null}
{at.map(m => )} {rows.map(s => ( {/* A storm that does not apply prints no numbers at all (Codex review 10-26, finding 2). */} {at.map(m => )} ))}
StormResultA month once it settlesCash · m{m}
{s.label} {!s.applicable ? keelNaReason(s, input, r.assumptions) : s.breaksInMonth !== null ? `Runs out in month ${s.breaksInMonth} (short ${keelMoney(keelShortfall(s, input.undrawnOverdraft))})` : `${KEEL_HORIZON}+ months · low ${keelMoney(s.lowCash)}`}{!s.applicable ? '—' : keelPerMonth(s.monthlyNet).replace(' a month', '')}= s.breaksInMonth ? '#c0392b' : undefined }}>{!s.applicable ? '—' : keelMoney(s.path[m - 1])}
Red = at or after the month cash fell below the floor of −{keelMoney(input.undrawnOverdraft).replace('−', '')} (the undrawn overdraft).
{w ? (
Operating moves, each tried on its own
{r.levers.map(l =>
{l.label}:
)}
) : null} {inputs.map(([k, v]) => )}
InputValue
{k}{v}
Educational working paper prepared with Keelvalue (keelvalue.com). Cash arithmetic only: not financial advice and not an assessment of the business by anyone. All figures were entered by the preparer and have not been verified. Each storm runs on its own for {KEEL_HORIZON} months; balances are monthly flow × days ÷ 30; stock falls a third of the way to its new level each month and never faster than it sells. Left out: seasonality, GST timing and bad debts.
); } function V4Keel() { const v4 = window.v4; const accent = v4.yellow; const accent2 = v4.red; const access = window.useVCAccess(); const [busy, setBusy] = React.useState(''); const [err, setErr] = React.useState(''); const [d, setD] = React.useState(() => { try { const s = localStorage.getItem(KEEL_KEY); if (s) return { ...KEEL_EMPTY(), ...JSON.parse(s) }; } catch (e) {} return KEEL_EMPTY(); }); React.useEffect(() => { try { localStorage.setItem(KEEL_KEY, JSON.stringify(d)); } catch (e) {} }, [d]); const set = (k, v) => setD(x => ({ ...x, [k]: v })); const missing = KEEL_REQUIRED.filter(k => keelNum(d[k]) === null); const { input, assumptions } = keelInput(d); const r = missing.length ? null : window.Keel.run(input, assumptions); const ok = r && r.ok; const errors = r && !r.ok ? r.errors.map(e => { const f = e.split(' ')[0]; return (KEEL_LABELS[KEEL_ENGINE_FIELD[f]] || (f === 'input' ? 'Your numbers' : f)) + ': ' + ( /supported range/.test(e) ? 'larger than this check supports' + (/days/.test(e) ? ' (1,000 days at most)' : /1,000,000,000/.test(e) ? ' (1,000,000,000 at most)' : '') : /fraction/.test(e) ? 'enter a percentage between 0 and 100' : /negative/.test(e) ? 'cannot be negative' : 'enter a number'); }) : []; const card = { border: `1.5px solid ${v4.ink}`, background: v4.paper, padding: '22px 20px', marginBottom: 18 }; const btn = (bg, color, extra) => ({ background: bg, color, border: `1.5px solid ${v4.ink}`, padding: '12px 16px', fontFamily: v4.mono, fontSize: 12, fontWeight: 700, letterSpacing: 1.5, textTransform: 'uppercase', cursor: 'pointer', ...extra }); const inputStyle = (extra) => ({ width: '100%', boxSizing: 'border-box', padding: '11px 12px', fontSize: 16, fontFamily: v4.sans, border: `1.5px solid ${v4.ink}`, background: '#fff', color: v4.ink, outline: 'none', borderRadius: 0, ...extra }); const field = (k, hint, opts = {}) => ( set(k, e.target.value)} style={inputStyle(opts.big ? { fontSize: 22, fontWeight: 700 } : { fontWeight: 600 })} placeholder={opts.placeholder != null ? opts.placeholder : (KEEL_DEFAULTS[k] != null ? `${KEEL_DEFAULTS[k]} if blank` : '')} /> ); const sectionTitle = (t) =>
{t}
; const w = ok ? r.weakest : null; const best = ok && w && r.levers.length && keelBetter(r.levers[0].weakest, w) ? r.levers[0] : null; const exportExcel = async () => { setErr(''); setBusy('excel'); try { await window.KeelWorkbook.download({ input, assumptions, business: d.business, preparer: d.preparer, fileName: 'Keel-Check' + (d.business ? '-' + d.business.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) : '') + '.xlsx', }); } catch (e) { setErr(e.message || 'Export failed.'); } setBusy(''); }; return (
§ Keel check · free

How deep is
your keel?

A dozen numbers from your accounts, four storms: revenue falls, rates rise, your biggest customer leaves, customers pay a month late. See how many months your cash lasts in each, which one breaks first, and the one operating move that helps most. Nothing you type is sent anywhere — it is saved in this browser only.

{/* ── inputs ── */}
{sectionTitle('1 · Sales and costs, per month')} {field('revenue', 'Average of the last 12 months: annual sales ÷ 12.', { unit: 'NZD', big: true, placeholder: '200,000' })}
{field('margin', '(Sales − cost of sales) ÷ sales.', { unit: '%', placeholder: '30' })} {field('top', 'Their share of last year’s sales.', { unit: '%', placeholder: '25' })}
{field('fixed', 'Wages, rent and everything else that does not move with sales. Leave out interest and loan repayments.', { unit: 'NZD', placeholder: '40,000' })} {field('drawings', 'What the owners take out each month, as salary or drawings.', { unit: 'NZD' })}
{sectionTitle('2 · Cash and debt')}
{field('cash', null, { unit: 'NZD', placeholder: '120,000' })} {field('overdraft', null, { unit: 'NZD' })}
The unused overdraft is only the floor the check measures against. It is not a suggestion to draw on it.
{field('repay', 'Principal and interest, per month.', { unit: 'NZD' })} {field('floating', 'Balance on a floating rate. Used only for the rate storm.', { unit: 'NZD' })}
{sectionTitle('3 · Working capital, in days')}
{field('dso', null)} {field('dpo', null)} {field('dio', null)}
Debtor days = trade debtors ÷ annual sales × 365 (0 if customers pay on the spot). Creditor days = trade creditors ÷ annual cost of sales × 365. Stock days = stock ÷ annual cost of sales × 365 (0 if you hold none).
4 · How hard the storms hit (optional)
{field('drop', null, { unit: '%', placeholder: '20' })} {field('rate', null, { unit: 'points', placeholder: '2' })} {field('late', null, { unit: 'days', placeholder: '30' })}
Each storm is run on its own over {KEEL_HORIZON} months. Stock is cut a third of the way to its new level each month, never faster than it sells.
{/* ── results ── */}
{!r ? (
Your storms appear here
Still needed: {missing.map(k => KEEL_LABELS[k]).join(', ')}. Or load the teaching case to see how it reads.
) : !ok ? (
Check these
{errors.map((e, i) =>
{e}
)}
) : ( <> {r.burning ? (
Before any storm: you spend more than you make ({keelPerMonth(r.baseline.monthlyNet)}).{' '} {r.baseline.breaksInMonth !== null ? <>At today’s numbers cash runs out in month {r.baseline.breaksInMonth}. : <>Cash in the bank covers it for now.}
) : null}
{!w ? 'No storm applies' : w.breaksInMonth !== null ? 'First to break' : 'Weakest point'}
{w ? ( <>
{w.label}
{w.breaksInMonth !== null ? <>Cash runs out in month {w.breaksInMonth} of {KEEL_HORIZON}, short by {keelMoney(keelShortfall(w, input.undrawnOverdraft))} that month. : <>Still standing after {KEEL_HORIZON} months. Lowest cash {keelMoney(w.lowCash)} in month {w.lowMonth}{w.monthlyNet < 0 ? <>, and still {keelPerMonth(w.monthlyNet)} : null}.}
) :
Every storm is switched off or does not fit this business.
}
{[r.baseline, ...r.storms].map(s => { const off = !s.applicable; const breaks = s.breaksInMonth !== null; const color = breaks ? accent2 : s.monthlyNet < 0 ? accent : '#7ad9c5'; return (
{s.label} {off ? null : } {off ? 'n/a' : breaks ? `month ${s.breaksInMonth}` : `${KEEL_HORIZON}+ mo`}
{off ? keelNaReason(s, input, r.assumptions) : breaks ? <>{keelPerMonth(s.monthlyNet)} once it settles · short by {keelMoney(keelShortfall(s, input.undrawnOverdraft))} in month {s.breaksInMonth} : <>{keelPerMonth(s.monthlyNet)} once it settles · low {keelMoney(s.lowCash)} (month {s.lowMonth})}
); })}
{/* ── the one operating move ── */} {w ? (
The move that helps most
{best ? ( <>
{best.label}.
) :
No single operating move below changes the weakest storm on its own.
}
{r.levers.filter(l => l !== best).map(l => (
{l.label}:
))}
Operating moves only, each tried on its own as if already done. This check never suggests borrowing.
) : null} {/* ── exports (paid) ── */}
{access.export ? `● Unlocked · ${access.name}` : 'Take it with you · Deal Pack or Complete'}
A one-page PDF with every storm, the moves and the inputs behind them, and an Excel file that recalculates month by month.
{access.export ? ( <>
set('business', e.target.value)} style={inputStyle({ fontSize: 14, padding: '9px 10px' })} placeholder="Optional" /> set('preparer', e.target.value)} style={inputStyle({ fontSize: 14, padding: '9px 10px' })} placeholder="Optional" />
PDF: choose “Save as PDF” in the print dialog.
) : ( )} {err ?
{err}
: null}
Why this matters for value
Two businesses with the same profit are not worth the same. A buyer prices a shallow keel — one big customer, slow debtors, thin cash — into the discount rate as specific-company risk.{' '} Lesson 5.03 shows where →
)}
Cash arithmetic for education, not financial advice, and not an assessment of your business by anyone. Figures are yours and are not checked. v1 leaves out seasonality, GST timing and bad debts.
{ok && w ? ( {w.breaksInMonth !== null ? 'First to break' : 'Weakest'} {w.breaksInMonth !== null ? `Month ${w.breaksInMonth}` : `${KEEL_HORIZON}+ months`} Result ↓ ) : null}
{/* ── print-only one-pager (paid: Deal Pack / Complete) ── */} {!access.export ? (
The one-page export comes with the Keelvalue Deal Pack or Complete. See keelvalue.com/pricing.html
) : ok ? :
Fill in the numbers first: the export prints the storms for this business.
}
); } window.V4Keel = V4Keel;