Deck cost calculator: CSV + Markdown export, CSV import

- Export is now a menu: PDF (quote/print), CSV (spreadsheet-friendly
  section,item,field,value rows), Markdown, JSON (backup).
- Import auto-detects .json / .csv: JSON loads a backup, CSV round-trips the
  whole sheet (hand-rolled CSV quote/parse so commas in material names are safe).
This commit is contained in:
SkateSpots 2026-10-05 10:47:23 +07:00
parent 885e818e3b
commit daeb3fbf3e
2 changed files with 220 additions and 10 deletions

View file

@ -840,7 +840,11 @@ session with **no other memory of this project**. So:
export remains (for Import/backup); Import reads JSON. The default **Materials** 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, 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/ mold release/vacuum film, sandpaper/saw+drill bits/masking tape, stain/primer/
paint/clearcoat/grip). Next: write our own paint/clearcoat/grip). **Export formats (2026-10-05):** an **Export ▾** menu —
**PDF** (quote/print), **CSV** (spreadsheet, `section,item,field,value`),
**Markdown**, **JSON** (backup) — and **Import** auto-detects `.json`/`.csv`
(CSV round-trips the whole sheet; hand-rolled CSV parse/escape so commas in
names are safe). Next: write our own
step-by-step guides (NIP-23 `30023` with `t=diy`) to flip the coming-soon cards step-by-step guides (NIP-23 `30023` with `t=diy`) to flip the coming-soon cards
live. live.
- **App Shop** (`/app-shop`, `src/pages/AppShopPage.tsx`): SkateSpots merch, to be - **App Shop** (`/app-shop`, `src/pages/AppShopPage.tsx`): SkateSpots merch, to be

View file

@ -173,6 +173,135 @@ function freshDefault(): CalcState {
}; };
} }
// ─── CSV helpers (round-trippable sheet; also handy for supplier price lists) ──
function csvEscape(v: string): string {
return /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
}
function toCsv(rows: string[][]): string {
return rows.map((r) => r.map(csvEscape).join(',')).join('\n');
}
function parseCsv(text: string): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let field = '';
let inQuotes = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (inQuotes) {
if (c === '"') {
if (text[i + 1] === '"') { field += '"'; i++; } else { inQuotes = false; }
} else field += c;
} else if (c === '"') inQuotes = true;
else if (c === ',') { row.push(field); field = ''; }
else if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; }
else if (c !== '\r') field += c;
}
if (field.length > 0 || row.length > 0) { row.push(field); rows.push(row); }
return rows.filter((r) => r.some((c) => c !== ''));
}
/** Rows for the CSV export: `section,item,field,value`. */
function calcCsvRows(s: CalcState): string[][] {
const rows: string[][] = [['section', 'item', 'field', 'value']];
const num = (n: number) => String(n);
rows.push(['settings', 'currency', 'value', s.currency]);
rows.push(['labor', 'hourly_rate', 'value', num(s.hourlyRate)]);
rows.push(['labor', 'overhead_factor', 'value', num(s.overheadFactor)]);
rows.push(['operations', 'boards_per_month', 'value', num(s.boardsPerMonth)]);
rows.push(['operations', 'rent', 'value', num(s.rent)]);
rows.push(['operations', 'utilities', 'value', num(s.utilities)]);
rows.push(['operations', 'subscriptions', 'value', num(s.subscriptions)]);
rows.push(['margins', 'profit_margin', 'value', num(s.profitMargin)]);
rows.push(['margins', 'scrap_pct', 'value', num(s.scrapPct)]);
for (const e of s.equipment) {
rows.push(['equipment', e.name, 'cost', num(e.cost)]);
rows.push(['equipment', e.name, 'setup_hrs', num(e.setupHrs)]);
rows.push(['equipment', e.name, 'boards', num(e.boards)]);
}
for (const m of s.materials) {
rows.push(['materials', m.name, 'order_cost', num(m.orderCost)]);
rows.push(['materials', m.name, 'shipping', num(m.shipping)]);
rows.push(['materials', m.name, 'boards', num(m.boards)]);
rows.push(['materials', m.name, 'order_min', num(m.orderMin)]);
rows.push(['materials', m.name, 'unload_min', num(m.unloadMin)]);
}
for (const p of s.processes) {
rows.push(['processes', p.name, 'minutes', num(p.minutes)]);
rows.push(['processes', p.name, 'boards', num(p.boards)]);
}
for (const p of s.packaging) {
rows.push(['packaging', p.name, 'minutes_per_board', num(p.minutesPerBoard)]);
rows.push(['packaging', p.name, 'materials_per_board', num(p.materialsPerBoard)]);
}
return rows;
}
/** Parse an exported CSV back into a CalcState (unknown sections ignored). */
function calcStateFromCsv(text: string): CalcState | null {
const rows = parseCsv(text);
if (rows.length < 2) return null;
const out = freshDefault();
const eq = new Map<string, EquipmentRow>();
const ma = new Map<string, MaterialRow>();
const pr = new Map<string, ProcessRow>();
const pk = new Map<string, PackageRow>();
const n = (v: string) => Number(v) || 0;
for (const r of rows.slice(1)) {
const [section, item, field, value] = r;
if (!section || !field) continue;
if (section === 'settings' && field === 'value') { out.currency = item || 'USD'; continue; }
if (section === 'labor' && field === 'value') {
if (item === 'hourly_rate') out.hourlyRate = n(value);
else if (item === 'overhead_factor') out.overheadFactor = n(value);
continue;
}
if (section === 'operations' && field === 'value') {
if (item === 'boards_per_month') out.boardsPerMonth = n(value);
else if (item === 'rent') out.rent = n(value);
else if (item === 'utilities') out.utilities = n(value);
else if (item === 'subscriptions') out.subscriptions = n(value);
continue;
}
if (section === 'margins' && field === 'value') {
if (item === 'profit_margin') out.profitMargin = n(value);
else if (item === 'scrap_pct') out.scrapPct = n(value);
continue;
}
if (section === 'equipment') {
const row = eq.get(item) ?? { id: uid(), name: item, cost: 0, setupHrs: 0, boards: 1 };
if (field === 'cost') row.cost = n(value);
else if (field === 'setup_hrs') row.setupHrs = n(value);
else if (field === 'boards') row.boards = n(value);
eq.set(item, row);
} else if (section === 'materials') {
const row = ma.get(item) ?? { id: uid(), name: item, orderCost: 0, shipping: 0, boards: 1, orderMin: 0, unloadMin: 0 };
if (field === 'order_cost') row.orderCost = n(value);
else if (field === 'shipping') row.shipping = n(value);
else if (field === 'boards') row.boards = n(value);
else if (field === 'order_min') row.orderMin = n(value);
else if (field === 'unload_min') row.unloadMin = n(value);
ma.set(item, row);
} else if (section === 'processes') {
const row = pr.get(item) ?? { id: uid(), name: item, minutes: 0, boards: 1 };
if (field === 'minutes') row.minutes = n(value);
else if (field === 'boards') row.boards = n(value);
pr.set(item, row);
} else if (section === 'packaging') {
const row = pk.get(item) ?? { id: uid(), name: item, minutesPerBoard: 0, materialsPerBoard: 0 };
if (field === 'minutes_per_board') row.minutesPerBoard = n(value);
else if (field === 'materials_per_board') row.materialsPerBoard = n(value);
pk.set(item, row);
}
}
out.equipment = [...eq.values()];
out.materials = [...ma.values()];
out.processes = [...pr.values()];
out.packaging = [...pk.values()];
return out;
}
export function DeckCostCalculator() { export function DeckCostCalculator() {
const [s, setS] = useLocalStorage<CalcState>('skatespots:deck-cost', DEFAULT_CALC); const [s, setS] = useLocalStorage<CalcState>('skatespots:deck-cost', DEFAULT_CALC);
const { data: rates } = useBtcRates(); const { data: rates } = useBtcRates();
@ -387,12 +516,76 @@ export function DeckCostCalculator() {
doc.save(`deck-cost-${new Date().toISOString().slice(0, 10)}.pdf`); doc.save(`deck-cost-${new Date().toISOString().slice(0, 10)}.pdf`);
}; };
const importJson = (e: React.ChangeEvent<HTMLInputElement>) => { const download = (filename: string, content: string, mime: string) => {
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};
const exportCsv = () => download('deck-cost.csv', toCsv(calcCsvRows(s)), 'text/csv');
const exportMarkdown = () => {
const md = [
'# Deck cost calculator',
'',
`${new Date().toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' })} · ${currencyLabel(cur)}`,
'',
'## Labor',
`- Hourly rate: ${fmt(s.hourlyRate)}`,
`- Overhead factor: ${s.overheadFactor}×`,
`- Labor cost: ${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)`),
`- **Equipment per board: ${fmt(r.equipment)}**`,
'',
'## Operation expenses',
`- Boards/month: ${s.boardsPerMonth}`,
`- Rent: ${fmt(s.rent)}`,
`- Utilities: ${fmt(s.utilities)}`,
`- Subscriptions: ${fmt(s.subscriptions)}`,
`- **Per board: ${fmt(r.operations)}**`,
'',
'## Materials',
...s.materials.map((m) => `- ${m.name}: ${fmt(m.boards > 0 ? (m.orderCost + m.shipping + (m.orderMin + m.unloadMin) * r.laborPerMin) / m.boards : 0)} (${fmt(m.orderCost)} · ${m.boards.toLocaleString()} boards)`),
`- **Materials per board: ${fmt(r.materials)}**`,
'',
'## Process costs',
...s.processes.map((p) => `- ${p.name}: ${fmt(p.boards > 0 ? (p.minutes * r.laborPerMin) / p.boards : 0)} (${p.minutes} min)`),
`- **Processes per board: ${fmt(r.processes)}**`,
'',
'## Packaging',
...s.packaging.map((p) => `- ${p.name}: ${fmt(p.materialsPerBoard + p.minutesPerBoard * r.laborPerMin)}`),
`- **Packaging per board: ${fmt(r.packaging)}**`,
'',
'## Result',
`- Profit margin: ${s.profitMargin}× · Scrap: ${s.scrapPct}%`,
`- Cost/board (no packaging): ${fmt(r.costNoPack)}`,
`- Cost/board (with packaging): ${fmt(r.costWithPack)}`,
`- **Selling price (no packaging): ${fmt(r.priceNoPack)}**`,
`- **Selling price (with packaging): ${fmt(r.priceWithPack)}**`,
'',
'_Does not include shipping to the buyer. Generated by SkateSpots._',
].join('\n');
download('deck-cost.md', md, 'text/markdown');
};
const importFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
const isCsv = file.name.toLowerCase().endsWith('.csv');
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
try { setS({ ...freshDefault(), ...(JSON.parse(String(reader.result)) as CalcState) }); } catch { /* ignore malformed */ } try {
const text = String(reader.result);
if (isCsv) {
const parsed = calcStateFromCsv(text);
if (parsed) setS(parsed);
} else {
setS({ ...freshDefault(), ...(JSON.parse(text) as CalcState) });
}
} catch { /* ignore malformed */ }
}; };
reader.readAsText(file); reader.readAsText(file);
e.target.value = ''; e.target.value = '';
@ -681,15 +874,28 @@ export function DeckCostCalculator() {
))} ))}
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 border-white/15 text-xs" onClick={exportPdf}> <Popover>
<Download className="h-3.5 w-3.5" /> Export PDF <PopoverTrigger asChild>
</Button> <Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 border-white/15 text-xs">
<Button type="button" variant="ghost" size="sm" className="h-8 gap-1.5 text-xs text-gray-400" onClick={exportJson}> <Download className="h-3.5 w-3.5" /> Export <ChevronDown className="h-3.5 w-3.5" />
JSON
</Button> </Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-52 p-1">
{[
{ label: 'PDF (print / quote)', run: () => void exportPdf() },
{ label: 'CSV (spreadsheet)', run: exportCsv },
{ label: 'Markdown', run: exportMarkdown },
{ label: 'JSON (backup)', run: exportJson },
].map((o) => (
<button key={o.label} type="button" onClick={o.run} className="flex w-full min-h-0 rounded-lg px-2.5 py-1.5 text-left text-xs font-semibold text-gray-300 hover:bg-white/5">
{o.label}
</button>
))}
</PopoverContent>
</Popover>
<label className="flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-white/15 px-3 text-xs font-semibold hover:bg-white/5"> <label className="flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-white/15 px-3 text-xs font-semibold hover:bg-white/5">
<Upload className="h-3.5 w-3.5" /> Import <Upload className="h-3.5 w-3.5" /> Import
<input type="file" accept="application/json" className="hidden" onChange={importJson} /> <input type="file" accept=".json,.csv,application/json,text/csv" className="hidden" onChange={importFile} />
</label> </label>
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 border-white/15 text-xs" onClick={reset}> <Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 border-white/15 text-xs" onClick={reset}>
<RotateCcw className="h-3.5 w-3.5" /> Reset <RotateCcw className="h-3.5 w-3.5" /> Reset