Deck cost calculator: BTC denomination + a full materials checklist

- Currency picker now offers BOTH Bitcoin units — ₿ sats and ₿ BTC (plus fiat).
  BTC is 1 per BTC, sats 1e8; switching converts between them and fiats
  (BTC shows up to 8 decimals, trimmed).
- Default Materials is now a complete deck-build checklist, grouped: wood & core
  (veneer/edge tape/logs), adhesives & fillers (glue/epoxy/filler), press/mold
  consumables (release/vacuum film), shaping (sandpaper/saw+drill bits/masking
  tape), finish & graphics (stain/primer/paint/clearcoat/grip) and Other. New
  rows default to 0 so the worked example is unchanged.
This commit is contained in:
SkateSpots 2026-10-05 10:43:26 +07:00
parent 0387d005d0
commit 885e818e3b
2 changed files with 58 additions and 16 deletions

View file

@ -821,7 +821,8 @@ session with **no other memory of this project**. So:
labour, equipment amortisation, operations, materials, process time, packaging
and margin → selling price; the `deckCost` card links to it. **Currency
(2026-10-05):** a **searchable currency selector** (Popover + search) covering
~sats + every fiat Coinbase reports, and switching **converts every money field**
**sats + BTC** (both Bitcoin units) and every **fiat** Coinbase reports (crypto
is filtered out via `FIAT_CODES`), and switching **converts every money field**
using live BTC rates (`useBtcRates` → `getBtcRates`, one Coinbase
`exchange-rates?currency=BTC` call — all currencies, `sats` = 1e8; unitless
fields are untouched). Falls back to relabel-only when rates are unavailable.
@ -836,7 +837,10 @@ session with **no other memory of this project**. So:
(lazy-loaded `jspdf`) — a formatted summary of every section + the selling
price; the reason it's a real PDF and not print-to-PDF is that
`window.print()` doesn't work in the Capacitor WebView. A secondary **JSON**
export remains (for Import/backup); Import reads JSON. Next: write our own
export remains (for Import/backup); Import reads JSON. The default **Materials**
list is a full deck-build checklist (veneer/edge tape/logs, glue/epoxy/filler,
mold release/vacuum film, sandpaper/saw+drill bits/masking tape, stain/primer/
paint/clearcoat/grip). Next: write our own
step-by-step guides (NIP-23 `30023` with `t=diy`) to flip the coming-soon cards
live.
- **App Shop** (`/app-shop`, `src/pages/AppShopPage.tsx`): SkateSpots merch, to be

View file

@ -52,15 +52,35 @@ const FIAT_CODES = new Set([
'ZAR', 'ZMW',
]);
/** How a currency is shown: `$1.00`, `CHF 1.00`, `1,000 sats`. */
/** How a currency is shown: `$1.00`, `CHF 1.00`, `1,000 sats`, `0.00012345 BTC`. */
function formatMoney(v: number, cur: string): string {
if (cur === 'sats') return `${Math.round(v).toLocaleString()} sats`;
if (cur === 'BTC') {
const str = v.toFixed(8).replace(/0+$/, '').replace(/\.$/, '');
return `${str || '0'} BTC`;
}
const s = CURRENCY_SYMBOLS[cur];
const n = v.toFixed(2);
if (!s) return `${cur} ${n}`;
return /[A-Za-z]/.test(s) ? `${s} ${n}` : `${s}${n}`;
}
/** Bitcoin is one asset shown two ways — `sats` and `BTC` are both offered. */
const BITCOIN_UNITS: Record<string, number> = { sats: 1e8, BTC: 1 };
/** Units of a currency per 1 BTC (sats/BTC = fixed; fiats come from rates). */
function ratePerBtc(cur: string, rates?: Record<string, number>): number | undefined {
return BITCOIN_UNITS[cur] ?? rates?.[cur];
}
/** Chip/trigger label, e.g. `₿ sats`, `₿ BTC`, `$ USD`. */
function currencyLabel(cur: string): string {
if (cur === 'sats') return '₿ sats';
if (cur === 'BTC') return '₿ BTC';
const s = CURRENCY_SYMBOLS[cur];
return s ? `${s} ${cur}` : cur;
}
interface CalcState {
currency: CurrencyCode;
hourlyRate: number;
@ -101,13 +121,28 @@ const DEFAULT_CALC: CalcState = {
{ id: uid(), name: 'Other Equipment', cost: 0, setupHrs: 0, boards: 100000 },
],
materials: [
{ id: uid(), name: 'Veneer', orderCost: 1500, shipping: 500, boards: 200, orderMin: 15, unloadMin: 120 },
{ id: uid(), name: 'Glue', orderCost: 35, shipping: 0, boards: 10, orderMin: 20, unloadMin: 0 },
// Wood & core
{ id: uid(), name: 'Veneer (maple plies)', orderCost: 1500, shipping: 500, boards: 200, orderMin: 15, unloadMin: 120 },
{ id: uid(), name: 'Veneer tape / edge tape', orderCost: 0, shipping: 0, boards: 100, orderMin: 5, unloadMin: 0 },
{ id: uid(), name: 'Logs (if milling your own veneer)', orderCost: 80, shipping: 0, boards: 500, orderMin: 0, unloadMin: 0 },
// Adhesives & fillers
{ id: uid(), name: 'Wood glue / epoxy resin', orderCost: 35, shipping: 0, boards: 10, orderMin: 20, unloadMin: 0 },
{ id: uid(), name: 'Wood filler / putty', orderCost: 0, shipping: 0, boards: 50, orderMin: 0, unloadMin: 0 },
// Press / mold consumables
{ id: uid(), name: 'Mold release / wax paper', orderCost: 0, shipping: 0, boards: 100, orderMin: 0, unloadMin: 0 },
{ id: uid(), name: 'Vacuum bag / breather film', orderCost: 0, shipping: 0, boards: 200, orderMin: 0, unloadMin: 0 },
// Shaping consumables
{ id: uid(), name: 'Sandpaper', orderCost: 30, shipping: 0, boards: 100, orderMin: 5, unloadMin: 15 },
{ id: uid(), name: 'Bits', orderCost: 50, shipping: 0, boards: 500, orderMin: 5, unloadMin: 0 },
{ id: uid(), name: 'Saw Blades', orderCost: 20, shipping: 0, boards: 500, orderMin: 5, unloadMin: 0 },
{ id: uid(), name: 'Logs', orderCost: 80, shipping: 0, boards: 500, orderMin: 0, unloadMin: 0 },
{ id: uid(), name: 'Other (inks, coatings, etc.)', orderCost: 0, shipping: 0, boards: 50, orderMin: 5, unloadMin: 0 },
{ id: uid(), name: 'Saw blades', orderCost: 20, shipping: 0, boards: 500, orderMin: 5, unloadMin: 0 },
{ id: uid(), name: 'Drill bits / router bits', orderCost: 50, shipping: 0, boards: 500, orderMin: 5, unloadMin: 0 },
{ id: uid(), name: 'Masking tape', orderCost: 0, shipping: 0, boards: 100, orderMin: 0, unloadMin: 0 },
// Finish & graphics
{ id: uid(), name: 'Stain / dye', orderCost: 0, shipping: 0, boards: 50, orderMin: 0, unloadMin: 0 },
{ id: uid(), name: 'Primer / base coat', orderCost: 0, shipping: 0, boards: 50, orderMin: 0, unloadMin: 0 },
{ id: uid(), name: 'Paint / screen-print ink', orderCost: 0, shipping: 0, boards: 50, orderMin: 0, unloadMin: 0 },
{ id: uid(), name: 'Clearcoat / lacquer', orderCost: 0, shipping: 0, boards: 50, orderMin: 0, unloadMin: 0 },
{ id: uid(), name: 'Grip tape', orderCost: 0, shipping: 0, boards: 50, orderMin: 0, unloadMin: 0 },
{ id: uid(), name: 'Other (hardware, coatings, etc.)', orderCost: 0, shipping: 0, boards: 50, orderMin: 5, unloadMin: 0 },
],
processes: [
{ id: uid(), name: 'Glue & Press', minutes: 20, boards: 3 },
@ -154,7 +189,7 @@ export function DeckCostCalculator() {
const fiat = rates
? Object.keys(rates).filter((c) => FIAT_CODES.has(c)).sort()
: ['USD', 'EUR', 'GBP'];
return ['sats', ...fiat];
return ['sats', 'BTC', ...fiat];
}, [rates]);
const filteredCurrencies = query
? currencies.filter((c) => c.toLowerCase().includes(query.toLowerCase()))
@ -167,11 +202,14 @@ export function DeckCostCalculator() {
setQuery('');
setS((prev) => {
if (to === prev.currency) return prev;
const rf = prev.currency === 'sats' ? 1e8 : rates?.[prev.currency];
const rt = to === 'sats' ? 1e8 : rates?.[to];
const rf = ratePerBtc(prev.currency, rates);
const rt = ratePerBtc(to, rates);
if (!rf || !rt) return { ...prev, currency: to }; // no rate → relabel only
const k = rt / rf;
const r2 = (v: number) => (to === 'sats' ? Math.round(v * k) : Math.round(v * k * 100) / 100);
const r2 = (v: number) =>
to === 'sats' ? Math.round(v * k)
: to === 'BTC' ? Math.round(v * k * 1e8) / 1e8
: Math.round(v * k * 100) / 100;
return {
...prev,
currency: to,
@ -276,7 +314,7 @@ export function DeckCostCalculator() {
setText(17, true, 15); doc.text('SkateSpots — Deck cost calculator', M, y + 8); y += 26;
setText(9, false, 120);
doc.text(
`${new Date().toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' })} · ${cur === 'sats' ? 'sats (Bitcoin)' : cur}`,
`${new Date().toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' })} · ${cur === 'sats' ? 'sats (Bitcoin)' : cur === 'BTC' ? 'BTC (Bitcoin)' : cur}`,
M, y,
);
y += 10;
@ -390,7 +428,7 @@ export function DeckCostCalculator() {
type="button"
className="flex min-h-0 items-center gap-2 rounded-full border border-[#ccff00]/40 bg-[#ccff00]/10 px-3 py-1.5 text-xs font-bold text-[#ccff00] hover:bg-[#ccff00]/15"
>
{cur === 'sats' ? '₿ sats' : `${CURRENCY_SYMBOLS[cur] ?? ''} ${cur}`.trim()}
{currencyLabel(cur)}
<ChevronDown className="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
@ -416,7 +454,7 @@ export function DeckCostCalculator() {
c === cur ? 'bg-[#ccff00]/15 text-[#ccff00]' : 'text-gray-300 hover:bg-white/5',
)}
>
<span>{c === 'sats' ? '₿ sats' : `${CURRENCY_SYMBOLS[c] ?? ''} ${c}`.trim()}</span>
<span>{currencyLabel(c)}</span>
{c === cur && <Check className="h-3.5 w-3.5" />}
</button>
))}