Initial commit: Apothecary v0.4.0

This commit is contained in:
2026-05-03 20:19:26 -04:00
commit 027cf032be
55 changed files with 14678 additions and 0 deletions
@@ -0,0 +1,405 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Apothecary — Personal Inventory</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="tokens.css" />
<style>
/* App chrome */
.app-shell {
min-height: 100vh;
display: grid;
grid-template-columns: 220px 1fr;
}
.sidebar {
border-right: 1px solid var(--line);
background: var(--bg-2);
padding: 28px 18px;
display: flex;
flex-direction: column;
gap: 4px;
position: sticky;
top: 0;
height: 100vh;
overflow: auto;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
padding: 0 8px 28px;
}
.brand-mark {
width: 32px; height: 32px;
border-radius: 50%;
border: 1px solid var(--ink);
display: flex; align-items: center; justify-content: center;
font-family: var(--serif);
font-size: 18px;
font-style: italic;
color: var(--ink);
}
.nav-link {
display: flex; align-items: center; gap: 10px;
padding: 9px 12px;
border-radius: var(--r-md);
color: var(--ink-2);
cursor: pointer;
font-size: 13px;
font-weight: 500;
border: none;
background: transparent;
text-align: left;
transition: background 100ms;
}
.nav-link:hover { background: var(--bg-3); color: var(--ink); }
.nav-link.active { background: var(--surface); color: var(--ink); box-shadow: var(--shadow-sm); border: 1px solid var(--line); }
.nav-section {
font-size: 10px; text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--ink-3);
padding: 16px 12px 6px;
font-weight: 500;
}
.inv-row:hover { background: var(--bg-2); }
@media (max-width: 880px) {
.app-shell { grid-template-columns: 1fr; }
.sidebar {
position: fixed; bottom: 0; left: 0; right: 0; top: auto;
height: auto;
flex-direction: row;
padding: 8px 12px;
border-top: 1px solid var(--line);
border-right: none;
z-index: 30;
overflow-x: auto;
}
.brand, .nav-section { display: none; }
.nav-link { white-space: nowrap; padding: 8px 12px; }
.main { padding-bottom: 60px; }
}
</style>
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script src="data.js?v=4"></script>
<script type="text/babel" src="primitives.jsx?v=4"></script>
<script type="text/babel" src="screens-1.jsx?v=4"></script>
<script type="text/babel" src="screens-2.jsx?v=4"></script>
<script type="text/babel" src="tweaks-panel.jsx?v=4"></script>
<script type="text/babel">
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"theme": "light",
"dashboard": "editorial",
"tone": "botanical",
"accent": "sage"
}/*EDITMODE-END*/;
const NAV = [
{ key: "dashboard", label: "Dashboard", icon: "home" },
{ key: "inventory", label: "Inventory", icon: "box" },
{ key: "bins", label: "Bins", icon: "bin" },
{ key: "charts", label: "Patterns", icon: "chart" },
{ key: "settings", label: "Settings", icon: "settings" }
];
const TONE_LABELS = {
botanical: { brand: "Apothecary", tagline: "A personal log" },
neutral: { brand: "Inventory", tagline: "Personal stockkeeping" },
discreet: { brand: "The Cabinet", tagline: "Private register" }
};
function App() {
const [tweaks, setTweaks] = useTweaks(TWEAK_DEFAULTS);
const [view, setView] = React.useState("dashboard");
const [selected, setSelected] = React.useState(null);
const [modal, setModal] = React.useState(null); // 'add' | 'consume' | 'gone' | 'audit'
const [modalProduct, setModalProduct] = React.useState(null);
const [tweaksOpen, setTweaksOpen] = React.useState(false);
const data = window.SAMPLE_DATA;
const stats = React.useMemo(() => computeStats(data), [data]);
// Apply theme
React.useEffect(() => {
document.documentElement.setAttribute("data-theme", tweaks.theme);
}, [tweaks.theme]);
// Tweaks toggle wiring
React.useEffect(() => {
const handler = (e) => {
if (e.data?.type === "__activate_edit_mode") setTweaksOpen(true);
if (e.data?.type === "__deactivate_edit_mode") setTweaksOpen(false);
};
window.addEventListener("message", handler);
window.parent.postMessage({type: "__edit_mode_available"}, "*");
return () => window.removeEventListener("message", handler);
}, []);
const labels = TONE_LABELS[tweaks.tone] || TONE_LABELS.botanical;
const onNav = (target) => {
if (target === "add") { setModalProduct(null); setModal("add"); }
else if (target === "consume") { setModalProduct(null); setModal("consume"); }
else if (target === "gone") { setModalProduct(null); setModal("gone"); }
else if (target === "audit") { setModalProduct(null); setModal("audit"); }
else setView(target);
};
const openAudit = (p) => { setModalProduct(p); setModal("audit"); };
const openGone = (p) => { setModalProduct(p); setSelected(null); setModal("gone"); };
const openConsume = (p) => { setModalProduct(p); setSelected(null); setModal("consume"); };
const handleSave = () => {
setModal(null);
};
return (
<div className="app-shell" data-screen-label="App">
{/* Sidebar */}
<aside className="sidebar">
<div className="brand">
<div className="brand-mark">A</div>
<div>
<div className="serif" style={{fontSize: 18, fontWeight: 500, lineHeight: 1}}>{labels.brand}</div>
<div style={{fontSize: 10, color: "var(--ink-3)", textTransform: "uppercase", letterSpacing: "0.1em"}}>{labels.tagline}</div>
</div>
</div>
<div className="nav-section">Workspace</div>
{NAV.map(n => (
<button key={n.key} className={"nav-link " + (view === n.key ? "active" : "")} onClick={() => setView(n.key)}>
<Icon name={n.icon} size={16} />
{n.label}
</button>
))}
<div className="nav-section">Quick</div>
<button className="nav-link" onClick={() => setModal("add")}>
<Icon name="plus" size={16} /> Add product
</button>
<button className="nav-link" onClick={() => setModal("consume")}>
<Icon name="check" size={16} /> Mark finished
</button>
<div style={{flex: 1}} />
<div style={{padding: 12, fontSize: 11, color: "var(--ink-3)", borderTop: "1px solid var(--line)", marginTop: 12}}>
<div className="mono">v0.4 · {data.products.length} items</div>
<div style={{marginTop: 4}}>Local-only · Apr 2026</div>
</div>
</aside>
{/* Main */}
<main className="main parchment" style={{minWidth: 0}}>
{view === "dashboard" && (
<DashboardSwitch
variant={tweaks.dashboard}
data={data}
stats={stats}
onNav={onNav}
onSelectProduct={setSelected}
onAudit={openAudit}
/>
)}
{view === "inventory" && <Inventory data={data} onSelectProduct={setSelected} onNav={onNav} />}
{view === "bins" && <BinsView data={data} onSelectProduct={setSelected} />}
{view === "charts" && <ChartsView data={data} stats={stats} />}
{view === "settings" && <SettingsView data={data} tweaks={tweaks} onTweakChange={(k,v) => setTweaks({...tweaks, [k]: v})} />}
</main>
{selected && (
<ProductDetail
product={selected}
data={data}
onClose={() => setSelected(null)}
onConsume={openConsume}
onMarkGone={openGone}
onAudit={openAudit}
onEdit={() => {}}
/>
)}
{modal === "add" && <AddProductFlow data={data} onClose={() => setModal(null)} onSave={handleSave} />}
{modal === "consume" && <ConsumeFlow data={data} onClose={() => setModal(null)} product={modalProduct} />}
{modal === "gone" && <MarkGoneFlow data={data} onClose={() => setModal(null)} product={modalProduct} />}
{modal === "audit" && <AuditFlow data={data} onClose={() => setModal(null)} product={modalProduct} />}
{tweaksOpen && (
<TweaksPanel onClose={() => setTweaksOpen(false)} title="Tweaks">
<TweakSection title="Appearance">
<TweakRadio label="Theme" value={tweaks.theme} options={[["light","Light"],["dark","Dark"]]} onChange={v => setTweaks({...tweaks, theme: v})} />
<TweakRadio label="Dashboard layout" value={tweaks.dashboard} options={[["editorial","Editorial"],["dense","Data-dense"],["minimal","Minimal"]]} onChange={v => setTweaks({...tweaks, dashboard: v})} />
</TweakSection>
<TweakSection title="Copy">
<TweakRadio label="Tone" value={tweaks.tone} options={[["botanical","Botanical"],["neutral","Neutral"],["discreet","Discreet"]]} onChange={v => setTweaks({...tweaks, tone: v})} />
</TweakSection>
</TweaksPanel>
)}
</div>
);
}
// Switches between 3 dashboard variants
const DashboardSwitch = ({variant, ...props}) => {
if (variant === "dense") return <DashboardDense {...props} />;
if (variant === "minimal") return <DashboardMinimal {...props} />;
return <Dashboard {...props} />;
};
// Variation 2: data-dense
const DashboardDense = ({data, stats, onNav, onSelectProduct}) => {
const series = stats.series90.map(s => ({date: s.date, value: s.grams}));
return (
<div style={{padding: "20px 28px 60px"}}>
<div style={{display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 18, paddingBottom: 14, borderBottom: "1px solid var(--line)"}}>
<div style={{display: "flex", alignItems: "baseline", gap: 16}}>
<h1 className="mono" style={{fontSize: 13, margin: 0, color: "var(--ink-2)", textTransform: "uppercase", letterSpacing: "0.08em"}}>Dashboard / 2026-04-25</h1>
<span style={{fontSize: 11, color: "var(--ink-3)"}}>· {stats.activeCount} active · {stats.consumedCount} archived</span>
</div>
<div style={{display: "flex", gap: 6}}>
<Btn variant="secondary" icon="plus" onClick={() => onNav("add")}>Product</Btn>
<Btn variant="primary" icon="check" onClick={() => onNav("consume")}>Finish</Btn>
</div>
</div>
<div style={{display: "grid", gridTemplateColumns: "repeat(8, 1fr)", gap: 1, background: "var(--line)", border: "1px solid var(--line)", borderRadius: "var(--r-md)", overflow: "hidden", marginBottom: 14}}>
{[
["DAILY g", stats.dailyAvg.toFixed(2)],
["WEEKLY g", stats.weeklyAvg.toFixed(1)],
["MONTHLY g", stats.monthlyAvg.toFixed(1)],
["AVG $/g", fmt.money(stats.avgPerGram)],
["30D SPEND", fmt.moneyShort(stats.spend30)],
["INV VALUE", fmt.moneyShort(stats.inventoryValue)],
["7D THC mg", stats.thcLast7],
["DAYS SUPPLY", Math.round(stats.daysOfSupply)]
].map(([l, v]) => (
<div key={l} style={{padding: "12px 14px", background: "var(--surface)"}}>
<div className="mono" style={{fontSize: 10, color: "var(--ink-3)", letterSpacing: "0.04em"}}>{l}</div>
<div className="mono" style={{fontSize: 20, marginTop: 4, fontWeight: 500}}>{v}</div>
</div>
))}
</div>
<div style={{display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14, marginBottom: 14}}>
<Card padded={false}>
<div style={{padding: "12px 16px", borderBottom: "1px solid var(--line)", display: "flex", justifyContent: "space-between"}}>
<span className="mono" style={{fontSize: 11, color: "var(--ink-3)", textTransform: "uppercase", letterSpacing: "0.08em"}}>g / day · 90d</span>
<span className="mono" style={{fontSize: 11, color: "var(--ink-2)"}}>{series.reduce((s,e)=>s+e.value,0).toFixed(1)} g total</span>
</div>
<div style={{padding: 14}}>
<BarChart data={series} height={120} color="var(--sage)" />
</div>
</Card>
<Card padded={false}>
<div style={{padding: "12px 16px", borderBottom: "1px solid var(--line)"}}>
<span className="mono" style={{fontSize: 11, color: "var(--ink-3)", textTransform: "uppercase", letterSpacing: "0.08em"}}>recent purchases</span>
</div>
<div>
{[...data.products].sort((a,b)=>new Date(b.purchaseDate)-new Date(a.purchaseDate)).slice(0, 5).map(p => (
<div key={p.id} onClick={() => onSelectProduct(p)} style={{padding: "10px 16px", borderBottom: "1px solid var(--line)", display: "flex", justifyContent: "space-between", alignItems: "center", cursor: "pointer", fontSize: 12}}>
<div>
<div style={{fontWeight: 500, fontSize: 13}}>{p.name}</div>
<div style={{fontSize: 11, color: "var(--ink-3)"}}>{window.DATA_HELPERS.brandName(data, p.brandId)} · {window.DATA_HELPERS.shopName(data, p.shopId)}</div>
</div>
<div className="mono" style={{textAlign: "right"}}>
<div>{fmt.money(p.price)}</div>
<div style={{fontSize: 10, color: "var(--ink-3)"}}>{fmt.dateShort(p.purchaseDate)}</div>
</div>
</div>
))}
</div>
</Card>
</div>
<Card padded={false}>
<div style={{padding: "12px 16px", borderBottom: "1px solid var(--line)"}}>
<span className="mono" style={{fontSize: 11, color: "var(--ink-3)", textTransform: "uppercase", letterSpacing: "0.08em"}}>active inventory · {stats.activeCount} rows</span>
</div>
<div style={{display: "grid", gridTemplateColumns: "2fr 1fr 0.6fr 0.6fr 0.6fr 0.8fr", padding: "8px 16px", borderBottom: "1px solid var(--line)", fontSize: 10, color: "var(--ink-3)", textTransform: "uppercase", letterSpacing: "0.08em", fontFamily: "var(--mono)"}}>
<div>NAME / SKU</div><div>BRAND</div><div style={{textAlign: "right"}}>THC%</div><div style={{textAlign: "right"}}>$</div><div style={{textAlign: "right"}}>REM g</div><div>BIN</div>
</div>
{data.products.filter(p=>p.status==="active").slice(0, 8).map(p => {
const bin = data.bins.find(b => b.id === p.binId);
return (
<div key={p.id} onClick={() => onSelectProduct(p)} className="inv-row" style={{display: "grid", gridTemplateColumns: "2fr 1fr 0.6fr 0.6fr 0.6fr 0.8fr", padding: "8px 16px", borderBottom: "1px solid var(--line)", fontSize: 12, cursor: "pointer", alignItems: "center"}}>
<div>
<div style={{fontWeight: 500}}>{p.name}</div>
<div className="mono" style={{fontSize: 10, color: "var(--ink-3)"}}>{p.sku}</div>
</div>
<div style={{color: "var(--ink-2)"}}>{window.DATA_HELPERS.brandName(data, p.brandId)}</div>
<div className="mono" style={{textAlign: "right"}}>{p.thc.toFixed(1)}</div>
<div className="mono" style={{textAlign: "right"}}>{fmt.money(p.price)}</div>
<div className="mono" style={{textAlign: "right"}}>{window.remainingShort(p)}</div>
<div style={{fontSize: 11, color: "var(--ink-3)"}}>{bin?.name || "—"}</div>
</div>
);
})}
</Card>
</div>
);
};
// Variation 3: minimal / editorial-quiet
const DashboardMinimal = ({data, stats, onNav, onSelectProduct}) => {
return (
<div style={{padding: "80px 60px", maxWidth: 900, margin: "0 auto"}}>
<div style={{textAlign: "center", marginBottom: 80}}>
<div className="smallcaps" style={{color: "var(--ink-3)", marginBottom: 20}}>Saturday · April 25, 2026</div>
<div className="serif" style={{fontSize: 96, fontWeight: 400, letterSpacing: "-0.03em", lineHeight: 1, fontStyle: "italic"}}>
{stats.dailyAvg.toFixed(2)}<span style={{fontSize: 36, fontStyle: "normal", color: "var(--ink-3)", marginLeft: 8}}>g/day</span>
</div>
<div style={{marginTop: 20, fontSize: 14, color: "var(--ink-2)", maxWidth: 520, margin: "20px auto 0"}}>
{stats.activeCount} items in your cabinet, valued around {fmt.moneyShort(stats.inventoryValue)}, with roughly {Math.round(stats.daysOfSupply)} days of flower at this pace.
</div>
<div style={{marginTop: 8, fontSize: 13, color: "var(--ink-3)", maxWidth: 520, margin: "8px auto 0"}}>
{stats.consumedCount} items archived · avg lifespan {Math.round(stats.avgLifespan)} days.
</div>
</div>
<div style={{display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 0, marginBottom: 80}}>
{[
["Avg $ per gram", fmt.money(stats.avgPerGram)],
["30-day spend", fmt.moneyShort(stats.spend30)],
["7-day THC", `${stats.thcLast7} mg`]
].map(([l,v], i) => (
<div key={l} style={{textAlign: "center", padding: "0 20px", borderRight: i < 2 ? "1px solid var(--line)" : "none"}}>
<div className="smallcaps" style={{color: "var(--ink-3)", marginBottom: 10}}>{l}</div>
<div className="serif" style={{fontSize: 36, fontWeight: 500}}>{v}</div>
</div>
))}
</div>
<div style={{borderTop: "1px solid var(--line)", paddingTop: 40}}>
<div style={{display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 18}}>
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Recent additions</div>
<button onClick={() => onNav("inventory")} style={{background: "none", border: "none", fontSize: 12, color: "var(--ink-2)", cursor: "pointer", textDecoration: "underline"}}>See all </button>
</div>
{[...data.products].filter(p=>p.status==="active").sort((a,b)=>new Date(b.purchaseDate)-new Date(a.purchaseDate)).slice(0, 5).map(p => (
<div key={p.id} onClick={() => onSelectProduct(p)} style={{display: "flex", justifyContent: "space-between", alignItems: "baseline", padding: "20px 0", borderBottom: "1px solid var(--line)", cursor: "pointer"}}>
<div>
<div className="serif" style={{fontSize: 24, fontWeight: 500}}>{p.name}</div>
<div style={{fontSize: 12, color: "var(--ink-3)", marginTop: 2}}>{window.DATA_HELPERS.brandName(data, p.brandId)} · {window.DATA_HELPERS.shopName(data, p.shopId)} · {fmt.dateShort(p.purchaseDate)}</div>
</div>
<div className="mono" style={{fontSize: 13, color: "var(--ink-2)"}}>{window.remainingShort(p)} · {fmt.money(p.price)}</div>
</div>
))}
</div>
<div style={{marginTop: 60, display: "flex", gap: 12, justifyContent: "center"}}>
<Btn variant="secondary" icon="plus" onClick={() => onNav("add")}>Add product</Btn>
<Btn variant="primary" icon="check" onClick={() => onNav("consume")}>Mark finished</Btn>
</div>
</div>
);
};
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
</script>
</body>
</html>