From daeb3fbf3ec71e52a7390bcf92ab7d08427f868c Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Mon, 5 Oct 2026 10:47:23 +0700 Subject: [PATCH] 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). --- AGENTS.md | 6 +- src/components/DeckCostCalculator.tsx | 224 ++++++++++++++++++++++++-- 2 files changed, 220 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e44b3d2..05fff36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -840,7 +840,11 @@ session with **no other memory of this project**. So: 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 + 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 live. - **App Shop** (`/app-shop`, `src/pages/AppShopPage.tsx`): SkateSpots merch, to be diff --git a/src/components/DeckCostCalculator.tsx b/src/components/DeckCostCalculator.tsx index 4518a9e..b5d7faf 100644 --- a/src/components/DeckCostCalculator.tsx +++ b/src/components/DeckCostCalculator.tsx @@ -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(); + const ma = new Map(); + const pr = new Map(); + const pk = new Map(); + 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() { const [s, setS] = useLocalStorage('skatespots:deck-cost', DEFAULT_CALC); const { data: rates } = useBtcRates(); @@ -387,12 +516,76 @@ export function DeckCostCalculator() { doc.save(`deck-cost-${new Date().toISOString().slice(0, 10)}.pdf`); }; - const importJson = (e: React.ChangeEvent) => { + 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) => { const file = e.target.files?.[0]; if (!file) return; + const isCsv = file.name.toLowerCase().endsWith('.csv'); const reader = new FileReader(); 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); e.target.value = ''; @@ -681,15 +874,28 @@ export function DeckCostCalculator() { ))} - - + + + + + + {[ + { label: 'PDF (print / quote)', run: () => void exportPdf() }, + { label: 'CSV (spreadsheet)', run: exportCsv }, + { label: 'Markdown', run: exportMarkdown }, + { label: 'JSON (backup)', run: exportJson }, + ].map((o) => ( + + ))} + +