Deck cost calculator: add a currency unit toggle (USD/EUR/GBP/sats)

A chip row at the top switches the whole sheet's unit: $ / € / £ / ₿ sats. Input
labels follow the unit and results reformat (sats are shown rounded, no
decimals). It relabels/reformats only — it does not convert values, since the
app only has a BTC/USD rate (no fiat↔fiat or reliable fiat↔sats conversion);
you enter amounts in the unit you think in. Choice persists with the rest.
This commit is contained in:
SkateSpots 2026-10-05 10:12:29 +07:00
parent b90fef1d2e
commit 9ecdd966c7
2 changed files with 50 additions and 17 deletions

View file

@ -819,7 +819,11 @@ 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. Next: write our own
and margin → selling price; the `deckCost` card links to it. Includes a
**currency unit toggle** (USD/EUR/GBP/sats) that relabels/reformats the whole
sheet — it **does not convert** values (only a BTC/USD rate is available, so no
fiat↔fiat or reliable fiat↔sats conversion; enter amounts in the unit you
think in). 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

@ -3,6 +3,7 @@ import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Plus, Trash2, RotateCcw } from 'lucide-react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { cn } from '@/lib/utils';
/**
* Deck cost calculator — a self-contained tool for working out what a deck
@ -19,7 +20,12 @@ interface MaterialRow { id: string; name: string; orderCost: number; shipping: n
interface ProcessRow { id: string; name: string; minutes: number; boards: number }
interface PackageRow { id: string; name: string; minutesPerBoard: number; materialsPerBoard: number }
type CurrencyCode = 'USD' | 'EUR' | 'GBP' | 'sats';
const CURRENCY_SYMBOL: Record<CurrencyCode, string> = { USD: '$', EUR: '€', GBP: '£', sats: '' };
interface CalcState {
currency: CurrencyCode;
hourlyRate: number;
overheadFactor: number;
boardsPerMonth: number;
@ -37,6 +43,7 @@ interface CalcState {
const uid = () => Math.random().toString(36).slice(2, 9);
const DEFAULT_CALC: CalcState = {
currency: 'USD',
hourlyRate: 20,
overheadFactor: 2.5,
boardsPerMonth: 160,
@ -83,11 +90,16 @@ const DEFAULT_CALC: CalcState = {
],
};
const money = (n: number) => `$${(Number.isFinite(n) ? n : 0).toFixed(2)}`;
export function DeckCostCalculator() {
const [s, setS] = useLocalStorage<CalcState>('skatespots:deck-cost', DEFAULT_CALC);
const cur = s.currency;
const unit = cur === 'sats' ? 'sats' : CURRENCY_SYMBOL[cur];
const fmt = (n: number) => {
const v = Number.isFinite(n) ? n : 0;
return cur === 'sats' ? `${Math.round(v).toLocaleString()} sats` : `${CURRENCY_SYMBOL[cur]}${v.toFixed(2)}`;
};
const r = useMemo(() => {
const laborPerMin = (s.hourlyRate * s.overheadFactor) / 60;
const equipment = s.equipment.reduce((a, e) => a + (e.boards > 0 ? e.cost / e.boards : 0), 0);
@ -130,12 +142,29 @@ export function DeckCostCalculator() {
return (
<div className="space-y-5">
{/* Currency — reformats the whole sheet (enter amounts in the unit you think in) */}
<div className="flex flex-wrap items-center gap-2">
<span className="text-[10px] font-black uppercase tracking-wider text-gray-500">Currency</span>
{(['USD', 'EUR', 'GBP', 'sats'] as CurrencyCode[]).map((c) => (
<button
key={c}
type="button"
onClick={() => patch({ currency: c })}
className={cn(
'min-h-0 rounded-full border px-3 py-1 text-xs font-bold transition-colors',
cur === c ? 'border-[#ccff00] bg-[#ccff00]/15 text-[#ccff00]' : 'border-white/10 bg-white/5 text-gray-400 hover:text-white',
)}
>
{c === 'sats' ? '₿ sats' : `${CURRENCY_SYMBOL[c]} ${c}`}
</button>
))}
</div>
{/* Labor */}
<section className="rounded-2xl border border-white/10 bg-white/[0.02] p-4">
<h3 className="mb-3 text-sm font-black uppercase tracking-wide text-[#ccff00]">Labor</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<label className="space-y-1">
<span className={label}>Hourly rate ($)</span>
<span className={label}>Hourly rate ({unit})</span>
{numInput(s.hourlyRate, (n) => patch({ hourlyRate: n }))}
</label>
<label className="space-y-1">
@ -145,7 +174,7 @@ export function DeckCostCalculator() {
<div className="space-y-1">
<span className={label}>Labor cost</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]">
{money(s.hourlyRate * s.overheadFactor)}/hr
{fmt(s.hourlyRate * s.overheadFactor)}/hr
</div>
</div>
</div>
@ -156,7 +185,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 ($)</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}># 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">
@ -164,7 +193,7 @@ export function DeckCostCalculator() {
<td className={td}>{numInput(e.cost, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, cost: n } : x) }))}</td>
<td className={td}>{numInput(e.setupHrs, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, setupHrs: n } : x) }))}</td>
<td className={td}>{numInput(e.boards, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, boards: n } : x) }))}</td>
<td className={`${td} whitespace-nowrap font-bold text-gray-300`}>{money(e.boards > 0 ? e.cost / e.boards : 0)}</td>
<td className={`${td} whitespace-nowrap font-bold text-gray-300`}>{fmt(e.boards > 0 ? e.cost / e.boards : 0)}</td>
<td className={td}><button onClick={() => patch({ equipment: s.equipment.filter((x) => x.id !== e.id) })} className="min-h-0 min-w-0 text-gray-600 hover:text-red-400" aria-label="Remove"><Trash2 className="h-3.5 w-3.5" /></button></td>
</tr>
))}
@ -185,7 +214,7 @@ export function DeckCostCalculator() {
<label className="space-y-1"><span className={label}>Utilities / month</span>{numInput(s.utilities, (n) => patch({ utilities: n }))}</label>
<label className="space-y-1"><span className={label}>Subscriptions / month</span>{numInput(s.subscriptions, (n) => patch({ subscriptions: n }))}</label>
</div>
<p className="mt-2 text-xs text-gray-500">Per board: <span className="font-bold text-gray-300">{money(r.operations)}</span></p>
<p className="mt-2 text-xs text-gray-500">Per board: <span className="font-bold text-gray-300">{fmt(r.operations)}</span></p>
</section>
{/* Materials */}
@ -193,7 +222,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 ($)</th><th className={th}>Ship ($)</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</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;
@ -206,7 +235,7 @@ export function DeckCostCalculator() {
<td className={td}>{numInput(m.boards, (n) => up({ boards: n }))}</td>
<td className={td}>{numInput(m.orderMin, (n) => up({ orderMin: n }))}</td>
<td className={td}>{numInput(m.unloadMin, (n) => up({ unloadMin: n }))}</td>
<td className={`${td} whitespace-nowrap font-bold text-gray-300`}>{money(per)}</td>
<td className={`${td} whitespace-nowrap font-bold text-gray-300`}>{fmt(per)}</td>
<td className={td}><button onClick={() => patch({ materials: s.materials.filter((x) => x.id !== m.id) })} className="min-h-0 min-w-0 text-gray-600 hover:text-red-400" aria-label="Remove"><Trash2 className="h-3.5 w-3.5" /></button></td>
</tr>
);
@ -234,7 +263,7 @@ export function DeckCostCalculator() {
<td className={td}><Input value={p.name} onChange={(e) => up({ name: e.target.value })} className="h-8 min-w-36" /></td>
<td className={td}>{numInput(p.minutes, (n) => up({ minutes: n }))}</td>
<td className={td}>{numInput(p.boards, (n) => up({ boards: n }))}</td>
<td className={`${td} whitespace-nowrap font-bold text-gray-300`}>{money(per)}</td>
<td className={`${td} whitespace-nowrap font-bold text-gray-300`}>{fmt(per)}</td>
<td className={td}><button onClick={() => patch({ processes: s.processes.filter((x) => x.id !== p.id) })} className="min-h-0 min-w-0 text-gray-600 hover:text-red-400" aria-label="Remove"><Trash2 className="h-3.5 w-3.5" /></button></td>
</tr>
);
@ -252,7 +281,7 @@ export function DeckCostCalculator() {
<h3 className="mb-2 text-sm font-black uppercase tracking-wide text-[#ccff00]">Packaging</h3>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead><tr><th className={th}>Packaging</th><th className={th}>Time/board (m)</th><th className={th}>Materials/board ($)</th><th className={th}>Per board</th><th /></tr></thead>
<thead><tr><th className={th}>Packaging</th><th className={th}>Time/board (m)</th><th className={th}>Materials/board ({unit})</th><th className={th}>Per board</th><th /></tr></thead>
<tbody>
{s.packaging.map((p) => {
const per = p.materialsPerBoard + p.minutesPerBoard * r.laborPerMin;
@ -262,7 +291,7 @@ export function DeckCostCalculator() {
<td className={td}><Input value={p.name} onChange={(e) => up({ name: e.target.value })} className="h-8 min-w-36" /></td>
<td className={td}>{numInput(p.minutesPerBoard, (n) => up({ minutesPerBoard: n }))}</td>
<td className={td}>{numInput(p.materialsPerBoard, (n) => up({ materialsPerBoard: n }))}</td>
<td className={`${td} whitespace-nowrap font-bold text-gray-300`}>{money(per)}</td>
<td className={`${td} whitespace-nowrap font-bold text-gray-300`}>{fmt(per)}</td>
<td className={td}><button onClick={() => patch({ packaging: s.packaging.filter((x) => x.id !== p.id) })} className="min-h-0 min-w-0 text-gray-600 hover:text-red-400" aria-label="Remove"><Trash2 className="h-3.5 w-3.5" /></button></td>
</tr>
);
@ -281,18 +310,18 @@ export function DeckCostCalculator() {
<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>
<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">{money(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">{money(r.costWithPack)}</div></div>
<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>
<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">
<p className="text-[10px] font-bold uppercase tracking-wider text-gray-500">Selling price · no packaging</p>
<p className="mt-1 text-2xl font-black text-[#ccff00]">{money(r.priceNoPack)}</p>
<p className="mt-1 text-2xl font-black text-[#ccff00]">{fmt(r.priceNoPack)}</p>
</div>
<div className="rounded-xl border border-[#ccff00]/40 bg-black/40 p-3">
<p className="text-[10px] font-bold uppercase tracking-wider text-gray-500">Selling price · with packaging</p>
<p className="mt-1 text-2xl font-black text-[#ccff00]">{money(r.priceWithPack)}</p>
<p className="mt-1 text-2xl font-black text-[#ccff00]">{fmt(r.priceWithPack)}</p>
</div>
</div>
<p className="mt-2 text-[11px] text-gray-500">Does not include shipping to the buyer.</p>