From ddaeea0223dd9cc9a0d4201e84b1a0cfa13a784a Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 6 Jun 2026 10:37:37 -0400 Subject: [PATCH] Add mobile card layouts to SKUs, Brands, Shops, Custody, and remaining views All list views now render card-based layouts on mobile instead of multi-column grid tables that overflow on small screens. Also adjust container padding, heading sizes, and grid breakpoints for Bins, Patterns, and Settings views. Co-Authored-By: Claude Opus 4.6 --- web/src/views/BinsView.tsx | 15 +- web/src/views/BrandsView.tsx | 143 +++++++++++++++++- web/src/views/ChartsView.tsx | 23 ++- web/src/views/CustodyView.tsx | 82 ++++++++++- web/src/views/SettingsView.tsx | 15 +- web/src/views/ShopsView.tsx | 146 ++++++++++++++++++- web/src/views/SkusView.tsx | 255 ++++++++++++++++++++++++++------- 7 files changed, 606 insertions(+), 73 deletions(-) diff --git a/web/src/views/BinsView.tsx b/web/src/views/BinsView.tsx index 4fdac13..b288165 100644 --- a/web/src/views/BinsView.tsx +++ b/web/src/views/BinsView.tsx @@ -8,6 +8,7 @@ import { api } from "../api.js"; import { Btn, Card, Pill, Icon } from "../components/primitives/index.js"; import { ConfirmDialog } from "../components/modals/ConfirmDialog.js"; import { useToast } from "../components/Toast.js"; +import { useIsMobile } from "../hooks/useIsMobile.js"; // Bins follow a "letter + number" naming convention (A1, A2, B1, …). // Group by the letter prefix so each letter starts a new visual row, @@ -50,6 +51,7 @@ export function BinsView({ onAddBin: () => void; onEditBin: (bin: Bin) => void; }) { + const isMobile = useIsMobile(); const qc = useQueryClient(); const { toast } = useToast(); const items = useMemo(() => enrichItems(data), [data]); @@ -70,17 +72,24 @@ export function BinsView({ return (
-
+
{data.bins.length} bins

Bins & storage

diff --git a/web/src/views/BrandsView.tsx b/web/src/views/BrandsView.tsx index 6aaadbf..3256ff0 100644 --- a/web/src/views/BrandsView.tsx +++ b/web/src/views/BrandsView.tsx @@ -3,6 +3,7 @@ import type { Bootstrap, Brand } from "../types.js"; import { fmt } from "../format.js"; import { getStoredTimezone } from "../tz.js"; import { Btn, Card, Icon, Select, inputStyle } from "../components/primitives/index.js"; +import { useIsMobile } from "../hooks/useIsMobile.js"; interface BrandRow { brand: Brand; @@ -67,6 +68,7 @@ export function BrandsView({ onSelectBrand: (brand: Brand) => void; onAddBrand: () => void; }) { + const isMobile = useIsMobile(); const [search, setSearch] = useState(""); const [sortBy, setSortBy] = useState("name"); @@ -98,7 +100,9 @@ export function BrandsView({ return (
@@ -117,7 +121,12 @@ export function BrandsView({

Brands

@@ -137,6 +146,72 @@ export function BrandsView({ Add your first brand + ) : isMobile ? ( + <> +
+ + setSearch(e.target.value)} + style={{ + border: "none", + outline: "none", + background: "transparent", + padding: "10px 0", + fontSize: 14, + flex: 1, + color: "var(--ink)", + }} + /> + {search && ( + + )} +
+ + {sorted.length === 0 && ( +
+ No brands match these filters. +
+ )} + {sorted.map((r) => ( + onSelectBrand(r.brand)} /> + ))} + ) : ( <> @@ -185,7 +260,6 @@ export function BrandsView({ )}
-
- {sorted.length === 0 && ( @@ -276,6 +349,66 @@ function BrandHeaderRow({ ); } +function MobileBrandCard({ row, onClick }: { row: BrandRow; onClick: () => void }) { + return ( +
+
+
+
+ {row.brand.name} +
+
+ +
+
+ {row.skuCount} SKU{row.skuCount !== 1 ? "s" : ""} + {row.itemCount} item{row.itemCount !== 1 ? "s" : ""} + {row.itemCount > 0 && {fmt.money(row.totalSpend)}} + {row.avgRating != null && ( + + + {row.avgRating.toFixed(1)} + + )} + {row.lastPurchase && ( + + {fmt.dateShort(row.lastPurchase, getStoredTimezone())} + + )} +
+
+ ); +} + function BrandItemRow({ row, onClick }: { row: BrandRow; onClick: () => void }) { return (
({ date: s.date, grams: s.grams })); @@ -128,19 +130,26 @@ export function ChartsView({ data, stats }: { data: Bootstrap; stats: Stats }) { return (
{/* ── 1. Header ──────────────────────────────────────────────── */} -
+
Last 90 days

Patterns

@@ -168,7 +177,9 @@ export function ChartsView({ data, stats }: { data: Bootstrap; stats: Stats }) {
void; onMarkGone: (i: Item) => void; }) { + const isMobile = useIsMobile(); const items = useMemo(() => enrichItems(data), [data]); const checkedOut = useMemo( () => @@ -37,18 +39,25 @@ export function CustodyView({ return (
-
+
{checkedOut.length} item{checkedOut.length === 1 ? "" : "s"} checked out

My Custody

@@ -73,6 +82,20 @@ export function CustodyView({ Nothing checked out right now.
+ ) : isMobile ? ( +
+ {checkedOut.map((item) => ( + onSelectItem(item)} + onCheckin={() => onCheckin(item)} + onConsume={() => onConsume(item)} + onMarkGone={() => onMarkGone(item)} + /> + ))} +
) : (
void; + onCheckin: () => void; + onConsume: () => void; + onMarkGone: () => void; +}) { + const glyph = TYPE_GLYPHS[item.type] ?? "·"; + const pct = helpers.pctRemaining(item); + + return ( +
+
+
{glyph}
+
+
{item.name}
+
+ {helpers.brandName(data, item.brandId)} · {remainingShort(item)} · {Math.round(pct * 100)}% +
+
+
+
+ Checked out {fmt.daysAgo(item.checkoutDate, getStoredTimezone())} +
+
e.stopPropagation()} + > + Check in + Consume + +
+
+ ); +} + function CustodyRow({ item, data, diff --git a/web/src/views/SettingsView.tsx b/web/src/views/SettingsView.tsx index 751e171..708391a 100644 --- a/web/src/views/SettingsView.tsx +++ b/web/src/views/SettingsView.tsx @@ -1,6 +1,7 @@ import type { Bootstrap } from "../types.js"; import { Btn, Card, Select, Stat } from "../components/primitives/index.js"; import { getBrowserTimezone } from "../tz.js"; +import { useIsMobile } from "../hooks/useIsMobile.js"; function getTimezoneOptions(): string[] { try { @@ -98,19 +99,27 @@ export function SettingsView({ timezone: string; onTimezoneChange: (tz: string) => void; }) { + const isMobile = useIsMobile(); return (
-
+
Settings

Preferences

diff --git a/web/src/views/ShopsView.tsx b/web/src/views/ShopsView.tsx index ee4c17c..d66df7f 100644 --- a/web/src/views/ShopsView.tsx +++ b/web/src/views/ShopsView.tsx @@ -3,6 +3,7 @@ import type { Bootstrap, Shop } from "../types.js"; import { fmt } from "../format.js"; import { getStoredTimezone } from "../tz.js"; import { Btn, Card, Icon, Select, inputStyle } from "../components/primitives/index.js"; +import { useIsMobile } from "../hooks/useIsMobile.js"; interface ShopRow { shop: Shop; @@ -57,6 +58,7 @@ export function ShopsView({ onSelectShop: (shop: Shop) => void; onAddShop: () => void; }) { + const isMobile = useIsMobile(); const [search, setSearch] = useState(""); const [sortBy, setSortBy] = useState("name"); @@ -91,7 +93,9 @@ export function ShopsView({ return (
@@ -110,7 +114,12 @@ export function ShopsView({

Shops

@@ -130,6 +139,71 @@ export function ShopsView({ Add your first shop + ) : isMobile ? ( + <> +
+ + setSearch(e.target.value)} + style={{ + border: "none", + outline: "none", + background: "transparent", + padding: "10px 0", + fontSize: 14, + flex: 1, + color: "var(--ink)", + }} + /> + {search && ( + + )} +
+ + {sorted.length === 0 && ( +
+ No shops match these filters. +
+ )} + {sorted.map((r) => ( + onSelectShop(r.shop)} /> + ))} + ) : ( <> @@ -178,7 +252,6 @@ export function ShopsView({ )}
-
- {sorted.length === 0 && ( @@ -268,6 +340,70 @@ function ShopHeaderRow({ ); } +function MobileShopCard({ row, onClick }: { row: ShopRow; onClick: () => void }) { + return ( +
+
+
+
+ {row.shop.name} +
+ {row.shop.location && ( +
+ {row.shop.location} +
+ )} +
+ +
+
+ {row.itemCount} item{row.itemCount !== 1 ? "s" : ""} + {row.itemCount > 0 && {fmt.money(row.totalSpend)}} + {row.avgRating != null && ( + + + {row.avgRating.toFixed(1)} + + )} + {row.lastPurchase && ( + + {fmt.dateShort(row.lastPurchase, getStoredTimezone())} + + )} +
+
+ ); +} + function ShopItemRow({ row, onClick }: { row: ShopRow; onClick: () => void }) { return (
void; onAddSku: () => void; }) { + const isMobile = useIsMobile(); const [typeFilter, setTypeFilter] = useState("all"); const [search, setSearch] = useState(""); const [sortBy, setSortBy] = useState("name"); @@ -113,7 +115,9 @@ export function SkusView({ return (
@@ -132,7 +136,12 @@ export function SkusView({

SKUs

@@ -142,19 +151,18 @@ export function SkusView({
- -
+ {isMobile ? ( + <>
@@ -166,8 +174,8 @@ export function SkusView({ border: "none", outline: "none", background: "transparent", - padding: "8px 0", - fontSize: 13, + padding: "10px 0", + fontSize: 14, flex: 1, color: "var(--ink)", }} @@ -188,45 +196,122 @@ export function SkusView({ )}
- - - - -
-
- - - - {sorted.length === 0 && ( -
- No SKUs match these filters. +
+ +
- )} - {sorted.map((r) => ( - onSelectSku(r.product)} /> - ))} - + {sorted.length === 0 && ( +
+ No SKUs match these filters. +
+ )} + {sorted.map((r) => ( + onSelectSku(r.product)} /> + ))} + + ) : ( + <> + +
+
+ + setSearch(e.target.value)} + style={{ + border: "none", + outline: "none", + background: "transparent", + padding: "8px 0", + fontSize: 13, + flex: 1, + color: "var(--ink)", + }} + /> + {search && ( + + )} +
+ + +
+
+ + + {sorted.length === 0 && ( +
+ No SKUs match these filters. +
+ )} + {sorted.map((r) => ( + onSelectSku(r.product)} /> + ))} +
+ + )}
); } @@ -289,6 +374,78 @@ function SkuHeaderRow({ ); } +function MobileSkuCard({ row, onClick }: { row: SkuRow; onClick: () => void }) { + return ( +
+
+
+ {TYPE_GLYPHS[row.product.type]} +
+
+
+ {row.name} +
+
+ {row.brand} · {row.product.type} +
+
+ +
+
+ + {row.itemCount} item{row.itemCount !== 1 ? "s" : ""} + {row.activeCount > 0 && row.activeCount < row.itemCount && ( + ({row.activeCount} active) + )} + + {row.itemCount > 0 && ( + {fmt.money(row.totalSpend)} + )} + {row.avgRating != null && ( + + + {row.avgRating.toFixed(1)} + + )} + {row.lastPurchase && ( + + {fmt.dateShort(row.lastPurchase, getStoredTimezone())} + + )} +
+
+ ); +} + function SkuItemRow({ row, onClick }: { row: SkuRow; onClick: () => void }) { return (