Deck calculator: make labels match the maths (burden, loaded rate, markup)

The arithmetic was already consistent, but three labels described something
other than what the code computes:
- "Overhead factor" multiplied labour only while a separate section held the
  real overhead, inviting double-counting -> "Labour burden (x)" + a hint.
- "Labor cost" showed wage x burden, i.e. the loaded rate, not a per-board
  cost -> "Loaded rate".
- "Profit margin (x)" was a markup -> "Markup (x)", with the implied gross
  margin % shown so the name matches the number.

Also: distinct board-count columns (Lifetime boards / Boards per order /
Boards per run) instead of three identical "# boards"; "Scrap / waste (%)".
PDF and Markdown exports use the same names. CSV machine keys are unchanged so
existing exports still round-trip.
This commit is contained in:
SkateSpots 2026-10-05 11:21:38 +07:00
parent 5b9616a86d
commit 3b8fadad2c
2 changed files with 30 additions and 15 deletions

View file

@ -819,7 +819,15 @@ session with **no other memory of this project**. So:
calculator (2026-10-05):** the first in-app tool lives at `/diy/deck-cost`
(`components/DeckCostCalculator.tsx`) — a self-contained (localStorage) model of
labour, equipment amortisation, operations, materials, process time, packaging
and margin → selling price; the `deckCost` card links to it. **Currency
and margin → selling price; the `deckCost` card links to it. **Naming is
deliberate (2026-10-05):** the labour multiplier is **"Labour burden (×)"** (not
"overhead" — rent/bills are the separate **Operation expenses** section; the
hint warns against counting them twice), the derived `wage × burden` figure is
the **"Loaded rate"**, and the multiplier is **"Markup (×)"** with the implied
**gross margin %** shown (it is *not* a margin). The three `# boards` columns
are context-specific: **Lifetime boards** (equipment), **Boards / order**
(materials), **Boards / run** (processes). CSV keys stay machine-stable
(`overhead_factor`, `profit_margin`, …) for round-tripping. **Currency
(2026-10-05):** a **searchable currency selector** (Popover + search) covering
**sats + BTC** (both Bitcoin units) and every **fiat** Coinbase reports (crypto
is filtered out via `FIAT_CODES`), and switching **converts every money field**

View file

@ -479,6 +479,9 @@ export function DeckCostCalculator() {
};
}, [s]);
/** Markup (×) expressed as a gross margin, so the name matches the maths. */
const marginPct = s.profitMargin > 0 ? Math.round((1 - 1 / s.profitMargin) * 100) : 0;
const patch = (p: Partial<CalcState>) => setS((prev) => ({ ...prev, ...p }));
const savePreset = () => {
@ -554,8 +557,8 @@ export function DeckCostCalculator() {
section('Labor');
kv('Hourly rate', fmt(s.hourlyRate));
kv('Overhead factor', `${s.overheadFactor}\u00d7`);
kv('Labor cost', `${fmt(s.hourlyRate * s.overheadFactor)}/hr`);
kv('Labour burden', `${s.overheadFactor}\u00d7`);
kv('Loaded rate', `${fmt(s.hourlyRate * s.overheadFactor)}/hr`);
section('Equipment');
s.equipment.forEach((e) => {
@ -589,8 +592,8 @@ export function DeckCostCalculator() {
// ── Result (kept together) ──
ensure(180);
section('Result');
kv('Profit margin', `${s.profitMargin}\u00d7`);
kv('Scrap', `${s.scrapPct}%`);
kv('Markup', `${s.profitMargin}\u00d7 (\u2248 ${marginPct}% gross margin)`);
kv('Scrap / waste', `${s.scrapPct}%`);
kv('Cost / board (no packaging)', fmt(r.costNoPack));
kv('Cost / board (with packaging)', fmt(r.costWithPack));
y += 6;
@ -867,8 +870,8 @@ export function DeckCostCalculator() {
'',
'## Labor',
`- Hourly rate: ${fmt(s.hourlyRate)}`,
`- Overhead factor: ${s.overheadFactor}×`,
`- Labor cost: ${fmt(s.hourlyRate * s.overheadFactor)}/hr`,
`- Labour burden: ${s.overheadFactor}×`,
`- Loaded rate: ${fmt(s.hourlyRate * s.overheadFactor)}/hr`,
'',
'## Equipment',
...s.equipment.map((e) => `- ${e.name}: ${fmt(e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0)} (${fmt(e.cost)} · ${e.boards.toLocaleString()} boards)`),
@ -894,7 +897,7 @@ export function DeckCostCalculator() {
`- **Packaging per board: ${fmt(r.packaging)}**`,
'',
'## Result',
`- Profit margin: ${s.profitMargin}× · Scrap: ${s.scrapPct}%`,
`- Markup: ${s.profitMargin}× (≈ ${marginPct}% gross margin) · Scrap: ${s.scrapPct}%`,
`- Cost/board (no packaging): ${fmt(r.costNoPack)}`,
`- Cost/board (with packaging): ${fmt(r.costWithPack)}`,
`- **Selling price (no packaging): ${fmt(r.priceNoPack)}**`,
@ -1043,16 +1046,17 @@ export function DeckCostCalculator() {
{numInput(s.hourlyRate, (n) => patch({ hourlyRate: n }))}
</label>
<label className="space-y-1">
<span className={label}>Overhead factor (2–3)</span>
<span className={label}>Labour burden (×)</span>
{numInput(s.overheadFactor, (n) => patch({ overheadFactor: n }))}
</label>
<div className="space-y-1">
<span className={label}>Labor cost</span>
<span className={label}>Loaded rate</span>
<div className="flex h-8 items-center rounded-md border border-white/10 bg-black/40 px-2 text-sm font-bold text-[#ccff00]">
{fmt(s.hourlyRate * s.overheadFactor)}/hr
</div>
</div>
</div>
<p className="mt-2 text-[11px] text-gray-500">Loaded rate = wage × burden (employer taxes and insurance on labour). Rent and bills go under <span className="text-gray-300">Operation expenses</span> — don’t count them in both.</p>
</section>
{/* Equipment */}
@ -1060,7 +1064,7 @@ export function DeckCostCalculator() {
<h3 className="mb-2 text-sm font-black uppercase tracking-wide text-[#ccff00]">Equipment</h3>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead><tr><th className={th}>Equipment</th><th className={th}>Cost ({unit})</th><th className={th}>Setup (h)</th><th className={th}># boards</th><th className={th}>Per board</th><th /></tr></thead>
<thead><tr><th className={th}>Equipment</th><th className={th}>Cost ({unit})</th><th className={th}>Setup (h)</th><th className={th}>Lifetime boards</th><th className={th}>Per board</th><th /></tr></thead>
<tbody>
{s.equipment.map((e) => (
<tr key={e.id} className="border-t border-white/5">
@ -1098,7 +1102,7 @@ export function DeckCostCalculator() {
<h3 className="mb-2 text-sm font-black uppercase tracking-wide text-[#ccff00]">Materials</h3>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead><tr><th className={th}>Material</th><th className={th}>Order ({unit})</th><th className={th}>Ship ({unit})</th><th className={th}># boards</th><th className={th}>Order (m)</th><th className={th}>Unload (m)</th><th className={th}>Per board</th><th /></tr></thead>
<thead><tr><th className={th}>Material</th><th className={th}>Order ({unit})</th><th className={th}>Ship ({unit})</th><th className={th}>Boards / order</th><th className={th}>Order (m)</th><th className={th}>Unload (m)</th><th className={th}>Per board</th><th /></tr></thead>
<tbody>
{s.materials.map((m) => {
const per = m.boards > 0 ? (m.orderCost + m.shipping + (m.orderMin + m.unloadMin) * r.laborPerMin) / m.boards : 0;
@ -1130,7 +1134,7 @@ export function DeckCostCalculator() {
<h3 className="mb-2 text-sm font-black uppercase tracking-wide text-[#ccff00]">Process costs</h3>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead><tr><th className={th}>Process</th><th className={th}>Time (m)</th><th className={th}># boards</th><th className={th}>Per board</th><th /></tr></thead>
<thead><tr><th className={th}>Process</th><th className={th}>Time (m)</th><th className={th}>Boards / run</th><th className={th}>Per board</th><th /></tr></thead>
<tbody>
{s.processes.map((p) => {
const per = p.boards > 0 ? (p.minutes * r.laborPerMin) / p.boards : 0;
@ -1187,11 +1191,14 @@ export function DeckCostCalculator() {
<section className="rounded-2xl border-2 border-[#ccff00]/30 bg-[#ccff00]/[0.04] p-4">
<h3 className="mb-3 text-sm font-black uppercase tracking-wide text-[#ccff00]">Margins</h3>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<label className="space-y-1"><span className={label}>Profit margin (×)</span>{numInput(s.profitMargin, (n) => patch({ profitMargin: n }))}</label>
<label className="space-y-1"><span className={label}>Scrap (%)</span>{numInput(s.scrapPct, (n) => patch({ scrapPct: n }))}</label>
<label className="space-y-1"><span className={label}>Markup (×)</span>{numInput(s.profitMargin, (n) => patch({ profitMargin: n }))}</label>
<label className="space-y-1"><span className={label}>Scrap / waste (%)</span>{numInput(s.scrapPct, (n) => patch({ scrapPct: n }))}</label>
<div className="space-y-1"><span className={label}>Cost / board (no packaging)</span><div className="flex h-8 items-center text-sm font-bold text-gray-200">{fmt(r.costNoPack)}</div></div>
<div className="space-y-1"><span className={label}>Cost / board (with packaging)</span><div className="flex h-8 items-center text-sm font-bold text-gray-200">{fmt(r.costWithPack)}</div></div>
</div>
<p className="mt-2 text-[11px] text-gray-500">
Markup {s.profitMargin}× ≈ <span className="text-gray-300">{marginPct}% gross margin</span> · waste {s.scrapPct}% is spread over the good boards.
</p>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="rounded-xl border border-[#ccff00]/40 bg-black/40 p-3">