Deck calculator: fix the mobile layout (no horizontal scrolling)

The sheet tables carry 5-8 columns and were unusable on a phone: values were
truncated ("900" -> "90") and columns fell off the edge, and the cost-vs-quantity
table scrolled sideways.

- On max-width:639px the four data tables render as stacked cards (.ss-sheet-table
  in index.css): thead hidden, each td a label/value row (label via data-label +
  td::before), cells flex-wrap so a long label plus input never overflows.
- The cost-vs-quantity block is now a vertical list instead of a table, so it
  never scrolls horizontally.

Verified at 390px: values are full and each row is a readable card.
This commit is contained in:
SkateSpots 2026-10-05 15:30:12 +07:00
parent 277b6a910f
commit 5baec5fb80
3 changed files with 93 additions and 39 deletions

View file

@ -860,7 +860,16 @@ session with **no other memory of this project**. So:
(Copy) action beside delete; the sticky summary chip shows the **order total**
when `quantity > 1` (and uses the setup-inclusive cost); and the marketplace
hand-off passes a **buyer-inclusive** unit price (tax added on top when tax
isn't marked inclusive). **Currency
isn't marked inclusive).
**Mobile layout (2026-10-05, part 3):** the sheet tables (5–8 columns) were
unusable on a phone — values truncated (`900`→`90`) and columns fell off the
edge. On `max-width:639px` the 4 data tables become **stacked cards** via
`src/index.css` `.ss-sheet-table` (`thead` hidden; each `td` a label/value row;
`data-label` supplies the label through `td::before`; cells `flex-wrap` so a
long label + input never overflows). The **Cost-vs-quantity** block is now a
vertical list, not a table, so nothing scrolls sideways. Verify with a
`--window-size=390,…` headless screenshot; add `defaultOpen` to a `CalcSection`
temporarily to see a table expanded. **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

@ -1293,17 +1293,17 @@ export function DeckCostCalculator() {
{/* Equipment */}
<CalcSection title="Equipment" subtotal={`${fmt(r.equipment)}/board`}>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<table className="ss-sheet-table 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}>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">
<td className={td}><Input value={e.name} onChange={(ev) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, name: ev.target.value } : x) })} className="h-8 min-w-36" /></td>
<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`}>{fmt(e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0)}</td>
<td className={td}>
<td className={`${td} ss-cell-name`}><Input value={e.name} onChange={(ev) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, name: ev.target.value } : x) })} className="h-8 min-w-36" /></td>
<td className={td} data-label="Cost">{numInput(e.cost, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, cost: n } : x) }))}</td>
<td className={td} data-label="Setup (h)">{numInput(e.setupHrs, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, setupHrs: n } : x) }))}</td>
<td className={td} data-label="Lifetime boards">{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`} data-label="Per board">{fmt(e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0)}</td>
<td className={`${td} ss-cell-actions`}>
<div className="flex items-center gap-0.5">
<button type="button" onClick={() => patch({ equipment: [...s.equipment, { ...e, id: uid() }] })} className="min-h-0 min-w-0 text-gray-600 hover:text-[#ccff00]" aria-label="Duplicate"><Copy className="h-3.5 w-3.5" /></button>
<button type="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>
@ -1334,7 +1334,7 @@ export function DeckCostCalculator() {
{/* Materials */}
<CalcSection title="Materials" subtotal={`${fmt(r.materials)}/board`}>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<table className="ss-sheet-table 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 / 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) => {
@ -1342,14 +1342,14 @@ export function DeckCostCalculator() {
const up = (p: Partial<MaterialRow>) => patch({ materials: s.materials.map((x) => x.id === m.id ? { ...x, ...p } : x) });
return (
<tr key={m.id} className="border-t border-white/5">
<td className={td}><Input value={m.name} onChange={(e) => up({ name: e.target.value })} className="h-8 min-w-36" /></td>
<td className={td}>{numInput(m.orderCost, (n) => up({ orderCost: n }))}</td>
<td className={td}>{numInput(m.shipping, (n) => up({ shipping: n }))}</td>
<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`}>{fmt(per)}</td>
<td className={td}>
<td className={`${td} ss-cell-name`}><Input value={m.name} onChange={(e) => up({ name: e.target.value })} className="h-8 min-w-36" /></td>
<td className={td} data-label="Order">{numInput(m.orderCost, (n) => up({ orderCost: n }))}</td>
<td className={td} data-label="Shipping">{numInput(m.shipping, (n) => up({ shipping: n }))}</td>
<td className={td} data-label="Boards / order">{numInput(m.boards, (n) => up({ boards: n }))}</td>
<td className={td} data-label="Order (min)">{numInput(m.orderMin, (n) => up({ orderMin: n }))}</td>
<td className={td} data-label="Unload (min)">{numInput(m.unloadMin, (n) => up({ unloadMin: n }))}</td>
<td className={`${td} whitespace-nowrap font-bold text-gray-300`} data-label="Per board">{fmt(per)}</td>
<td className={`${td} ss-cell-actions`}>
<div className="flex items-center gap-0.5">
<button type="button" onClick={() => patch({ materials: [...s.materials, { ...m, id: uid() }] })} className="min-h-0 min-w-0 text-gray-600 hover:text-[#ccff00]" aria-label="Duplicate"><Copy className="h-3.5 w-3.5" /></button>
<button type="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>
@ -1370,7 +1370,7 @@ export function DeckCostCalculator() {
{/* Processes */}
<CalcSection title="Process costs" subtotal={`${fmt(r.processes)}/board`}>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<table className="ss-sheet-table w-full text-xs">
<thead><tr><th className={th}>Process</th><th className={th}>Time (m)</th><th className={th}>Boards / run</th>{advanced && <th className={th}>Rate/hr (0 = shop)</th>}{advanced && <th className={th}>Scrap %</th>}<th className={th}>Per board</th><th /></tr></thead>
<tbody>
{s.processes.map((p) => {
@ -1379,13 +1379,13 @@ export function DeckCostCalculator() {
const up = (pp: Partial<ProcessRow>) => patch({ processes: s.processes.map((x) => x.id === p.id ? { ...x, ...pp } : x) });
return (
<tr key={p.id} className="border-t border-white/5">
<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>
{advanced && <td className={td}>{numInput(p.rate, (n) => up({ rate: n }))}</td>}
{advanced && <td className={td}>{numInput(p.scrapPct, (n) => up({ scrapPct: Math.min(99, n) }))}</td>}
<td className={`${td} whitespace-nowrap font-bold text-gray-300`}>{fmt(per)}</td>
<td className={td}>
<td className={`${td} ss-cell-name`}><Input value={p.name} onChange={(e) => up({ name: e.target.value })} className="h-8 min-w-36" /></td>
<td className={td} data-label="Time (min)">{numInput(p.minutes, (n) => up({ minutes: n }))}</td>
<td className={td} data-label="Boards / run">{numInput(p.boards, (n) => up({ boards: n }))}</td>
{advanced && <td className={td} data-label="Rate/hr">{numInput(p.rate, (n) => up({ rate: n }))}</td>}
{advanced && <td className={td} data-label="Scrap %">{numInput(p.scrapPct, (n) => up({ scrapPct: Math.min(99, n) }))}</td>}
<td className={`${td} whitespace-nowrap font-bold text-gray-300`} data-label="Per board">{fmt(per)}</td>
<td className={`${td} ss-cell-actions`}>
<div className="flex items-center gap-0.5">
<button type="button" onClick={() => patch({ processes: [...s.processes, { ...p, id: uid() }] })} className="min-h-0 min-w-0 text-gray-600 hover:text-[#ccff00]" aria-label="Duplicate"><Copy className="h-3.5 w-3.5" /></button>
<button type="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>
@ -1406,7 +1406,7 @@ export function DeckCostCalculator() {
{/* Packaging */}
<CalcSection title="Packaging" subtotal={`${fmt(r.packaging)}/board`}>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<table className="ss-sheet-table w-full text-xs">
<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) => {
@ -1414,11 +1414,11 @@ export function DeckCostCalculator() {
const up = (pp: Partial<PackageRow>) => patch({ packaging: s.packaging.map((x) => x.id === p.id ? { ...x, ...pp } : x) });
return (
<tr key={p.id} className="border-t border-white/5">
<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`}>{fmt(per)}</td>
<td className={td}>
<td className={`${td} ss-cell-name`}><Input value={p.name} onChange={(e) => up({ name: e.target.value })} className="h-8 min-w-36" /></td>
<td className={td} data-label="Time/board (min)">{numInput(p.minutesPerBoard, (n) => up({ minutesPerBoard: n }))}</td>
<td className={td} data-label="Materials/board">{numInput(p.materialsPerBoard, (n) => up({ materialsPerBoard: n }))}</td>
<td className={`${td} whitespace-nowrap font-bold text-gray-300`} data-label="Per board">{fmt(per)}</td>
<td className={`${td} ss-cell-actions`}>
<div className="flex items-center gap-0.5">
<button type="button" onClick={() => patch({ packaging: [...s.packaging, { ...p, id: uid() }] })} className="min-h-0 min-w-0 text-gray-600 hover:text-[#ccff00]" aria-label="Duplicate"><Copy className="h-3.5 w-3.5" /></button>
<button type="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>
@ -1507,15 +1507,19 @@ export function DeckCostCalculator() {
</p>
)}
{advanced && (
<div className="mt-3 overflow-x-auto rounded-xl border border-white/10 bg-black/20 p-3">
<div className="mt-3 rounded-xl border border-white/10 bg-black/20 p-3">
<p className="mb-2 text-[10px] font-bold uppercase tracking-wider text-gray-500">Cost vs quantity (setup amortised)</p>
<table className="w-full text-xs">
<thead><tr><th className={th}>Decks</th>{r.curve.map((c) => <th key={c.qty} className={th}>{c.qty}</th>)}</tr></thead>
<tbody>
<tr className="border-t border-white/5"><td className={td}>Unit price</td>{r.curve.map((c) => <td key={c.qty} className={td}>{fmt(c.unit)}</td>)}</tr>
<tr className="border-t border-white/5"><td className={td}>Order total</td>{r.curve.map((c) => <td key={c.qty} className={td}>{fmt(c.total)}</td>)}</tr>
</tbody>
</table>
<div>
{r.curve.map((c) => (
<div key={c.qty} className="flex items-start justify-between gap-3 border-t border-white/5 py-1.5 text-xs">
<span className="shrink-0 pt-0.5 font-bold text-gray-400">{c.qty}{c.qty === 1 ? ' deck' : ' decks'}</span>
<span className="min-w-0 text-right">
<span className="block font-bold text-gray-100">{fmt(c.unit)}<span className="font-normal text-gray-500"> / deck</span></span>
<span className="block text-[10px] text-gray-500">order {fmt(c.total)}</span>
</span>
</div>
))}
</div>
</div>
)}
<p className="mt-2 text-[11px] text-gray-500">Does not include shipping to the buyer.</p>

View file

@ -873,3 +873,44 @@ select {
.skatespots-dark-tiles {
filter: invert(1) hue-rotate(180deg) brightness(0.9) contrast(0.9) saturate(0.7);
}
/* ── Calculator sheet tables ──────────────────────────────────────────────
The deck-cost tables carry 5–8 columns (name + numbers + per-board + row
actions). That cannot fit a 390px phone: values get clipped ("900" → "90")
and columns fall off the edge. On phones each row becomes a stacked card
(label left, control right); on sm+ it stays a real table. `td::before`
pulls the column title from `data-label`. */
@media (max-width: 639px) {
.ss-sheet-table, .ss-sheet-table tbody, .ss-sheet-table tr, .ss-sheet-table td { display: block; width: 100%; }
.ss-sheet-table thead { display: none; }
.ss-sheet-table tr {
margin-bottom: 0.5rem;
padding: 0.5rem 0.625rem;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 0.75rem;
background: rgba(255, 255, 255, 0.02);
}
.ss-sheet-table td {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.2rem 0;
border: 0;
}
.ss-sheet-table td[data-label]::before {
content: attr(data-label);
flex: 0 0 auto;
color: #6b7280;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.ss-sheet-table td > input,
.ss-sheet-table td > div { max-width: 62%; }
.ss-sheet-table td.ss-cell-name { margin-bottom: 0.35rem; }
.ss-sheet-table td.ss-cell-name > input { max-width: 100%; }
.ss-sheet-table td.ss-cell-actions { justify-content: flex-end; padding-top: 0.35rem; }
}