Files
Apothecary/weed-tracker/project/screens-2.jsx
T

665 lines
38 KiB
React

// Add Product, Mark Consumed/Gone, Audit, Bins, Charts, Settings
const AddProductFlow = ({data, onClose, onSave}) => {
const [form, setForm] = React.useState({
name: "", brandId: data.brands[0].id, shopId: data.shops[0].id, type: "Flower",
weight: 3.5, countOriginal: 1, unitWeight: 0.7,
price: 45, thc: 22, cbd: 0.4, totalCannabinoids: 26,
purchaseDate: "2026-04-25", binId: data.bins[0].id,
sku: "", assetTag: ""
});
const update = (k, v) => setForm(f => ({...f, [k]: v}));
const cfg = data.types.find(t => t.id === form.type);
const isDiscrete = cfg?.kind === "discrete";
const cpg = !isDiscrete && form.weight > 0 ? form.price / form.weight : 0;
return (
<div style={{position: "fixed", inset: 0, background: "oklch(20% 0.02 60 / 0.4)", zIndex: 50, display: "flex", justifyContent: "center", alignItems: "flex-start", overflow: "auto"}} onClick={onClose}>
<div onClick={e => e.stopPropagation()} style={{
width: "min(840px, 96vw)", margin: "40px 20px", background: "var(--bg)",
border: "1px solid var(--line)", borderRadius: "var(--r-lg)", boxShadow: "var(--shadow-lg)"
}}>
<div style={{padding: "20px 32px", borderBottom: "1px solid var(--line)", display: "flex", justifyContent: "space-between", alignItems: "center"}}>
<div>
<div className="smallcaps" style={{color: "var(--ink-3)"}}>New entry</div>
<h2 className="serif" style={{fontSize: 28, margin: "4px 0 0", fontWeight: 500}}>Add a product</h2>
</div>
<Btn variant="ghost" icon="close" onClick={onClose} />
</div>
<div style={{padding: 32}}>
<div className="smallcaps" style={{color: "var(--ink-3)", marginBottom: 16}}>Identity</div>
<div style={{display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 16, marginBottom: 28}}>
<Field label="Product name" span={2}><Input value={form.name} placeholder="e.g. Garden Ghost" onChange={e => update("name", e.target.value)} /></Field>
<Field label="Brand"><Select value={form.brandId} onChange={e => update("brandId", e.target.value)}>{data.brands.map(b => <option key={b.id} value={b.id}>{b.name}</option>)}</Select></Field>
<Field label="Shop"><Select value={form.shopId} onChange={e => update("shopId", e.target.value)}>{data.shops.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}</Select></Field>
<Field label="Type"><Select value={form.type} onChange={e => update("type", e.target.value)}>{data.types.map(t => <option key={t.id} value={t.id}>{t.id} ({t.kind})</option>)}</Select></Field>
<Field label="Bin"><Select value={form.binId} onChange={e => update("binId", e.target.value)}>{data.bins.map(b => <option key={b.id} value={b.id}>{b.name} {b.location}</option>)}</Select></Field>
<Field label="SKU" hint="Leave blank — we'll generate one"><Input value={form.sku} placeholder="SKU-…" onChange={e => update("sku", e.target.value)} /></Field>
<Field label="Asset tag (optional)" hint="If you've physically tagged the item"><Input value={form.assetTag} placeholder="AT-0000" onChange={e => update("assetTag", e.target.value)} /></Field>
</div>
<div className="smallcaps" style={{color: "var(--ink-3)", marginBottom: 16}}>Acquisition</div>
<div style={{display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16, marginBottom: 8}}>
{isDiscrete ? (
<>
<Field label={`Quantity (${cfg.unit})`}><Input type="number" step="1" value={form.countOriginal} onChange={e => update("countOriginal", +e.target.value)} /></Field>
<Field label="Per-unit weight (g)" hint="For grams stats"><Input type="number" step="0.1" value={form.unitWeight} onChange={e => update("unitWeight", +e.target.value)} /></Field>
</>
) : (
<Field label={`Size (${cfg?.unit || "g"})`} span={2}><Input type="number" step="0.1" value={form.weight} onChange={e => update("weight", +e.target.value)} /></Field>
)}
<Field label="Price ($)"><Input type="number" step="0.01" value={form.price} onChange={e => update("price", +e.target.value)} /></Field>
<Field label="Purchase date"><Input type="date" value={form.purchaseDate} onChange={e => update("purchaseDate", e.target.value)} /></Field>
</div>
{!isDiscrete && cpg > 0 && (
<div style={{marginTop: 12, fontSize: 12, color: "var(--ink-3)"}}>
Cost per {cfg?.unit || "g"}: <span className="mono" style={{color: "var(--ink-2)"}}>{fmt.money(cpg)}</span>
</div>
)}
<div className="smallcaps" style={{color: "var(--ink-3)", margin: "28px 0 16px"}}>Cannabinoid profile</div>
<div style={{display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16}}>
<Field label="THC %"><Input type="number" step="0.1" value={form.thc} onChange={e => update("thc", +e.target.value)} /></Field>
<Field label="CBD %"><Input type="number" step="0.1" value={form.cbd} onChange={e => update("cbd", +e.target.value)} /></Field>
<Field label="Total cannabinoids %"><Input type="number" step="0.1" value={form.totalCannabinoids} onChange={e => update("totalCannabinoids", +e.target.value)} /></Field>
</div>
</div>
<div style={{padding: "16px 32px", borderTop: "1px solid var(--line)", display: "flex", justifyContent: "space-between", alignItems: "center", background: "var(--bg-2)", borderRadius: "0 0 var(--r-lg) var(--r-lg)"}}>
<div style={{fontSize: 12, color: "var(--ink-3)"}}>
{form.name ? `"${form.name}" → ${data.bins.find(b=>b.id===form.binId)?.name}.` : "Fill in the name to continue."}
</div>
<div style={{display: "flex", gap: 8}}>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn variant="primary" icon="check" disabled={!form.name} onClick={() => onSave(form)}>Save product</Btn>
</div>
</div>
</div>
</div>
);
};
// ─── CONSUME (mark finished) ──────────────────────────────────────
const ConsumeFlow = ({data, onClose, product: initialProduct}) => {
const active = data.products.filter(p => p.status === "active");
const [productId, setProductId] = React.useState(initialProduct?.id || active[0]?.id);
const [rating, setRating] = React.useState(4);
const [notes, setNotes] = React.useState("");
const [date, setDate] = React.useState("2026-04-25");
const product = data.products.find(p => p.id === productId);
if (!product) return null;
const bin = data.bins.find(b => b.id === product.binId);
const lifespan = Math.round((new Date(date) - new Date(product.purchaseDate))/86400000);
return (
<div style={{position: "fixed", inset: 0, background: "oklch(20% 0.02 60 / 0.4)", zIndex: 50, display: "flex", justifyContent: "center", alignItems: "flex-start", overflow: "auto"}} onClick={onClose}>
<div onClick={e => e.stopPropagation()} style={{
width: "min(720px, 96vw)", margin: "40px 20px", background: "var(--bg)",
border: "1px solid var(--line)", borderRadius: "var(--r-lg)", boxShadow: "var(--shadow-lg)"
}}>
<div style={{padding: "20px 32px", borderBottom: "1px solid var(--line)", display: "flex", justifyContent: "space-between", alignItems: "center"}}>
<div>
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Archive · used up</div>
<h2 className="serif" style={{fontSize: 28, margin: "4px 0 0", fontWeight: 500}}>Mark as finished</h2>
</div>
<Btn variant="ghost" icon="close" onClick={onClose} />
</div>
<div style={{padding: 32}}>
<Field label="Product">
<Select value={productId} onChange={e => setProductId(e.target.value)}>
{active.map(p => <option key={p.id} value={p.id}>{p.name} {H.brandName(data, p.brandId)} ({remainingShort(p)} left)</option>)}
</Select>
</Field>
<div style={{marginTop: 16, padding: 16, background: "var(--bg-2)", border: "1px solid var(--line)", borderRadius: "var(--r-md)", display: "flex", justifyContent: "space-between", alignItems: "center"}}>
<div>
<div className="serif" style={{fontSize: 22, fontWeight: 500}}>{product.name}</div>
<div style={{fontSize: 12, color: "var(--ink-3)"}}>{H.brandName(data, product.brandId)} · {bin?.name} · purchased {fmt.dateShort(product.purchaseDate)}</div>
</div>
<div style={{textAlign: "right"}}>
<div className="mono" style={{fontSize: 11, color: "var(--ink-3)"}}>LASTED</div>
<div className="serif" style={{fontSize: 24}}>{lifespan} days</div>
</div>
</div>
<div style={{display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginTop: 24}}>
<Field label="Date finished"><Input type="date" value={date} onChange={e => setDate(e.target.value)} /></Field>
<Field label="Rating">
<div style={{display: "flex", gap: 4, alignItems: "center", padding: "10px 12px", background: "var(--bg)", border: "1px solid var(--line)", borderRadius: "var(--r-md)"}}>
{[1,2,3,4,5].map(n => (
<button key={n} onClick={() => setRating(n)} style={{border: "none", background: "transparent", cursor: "pointer", padding: 2}}>
<Icon name="star" size={20} color={n <= rating ? "var(--amber)" : "var(--ink-4)"} />
</button>
))}
<span style={{marginLeft: "auto", fontSize: 12, color: "var(--ink-3)", fontFamily: "var(--mono)"}}>{rating}/5</span>
</div>
</Field>
</div>
<div style={{marginTop: 16}}>
<Field label="Final notes" hint="Flavor, effects, would you rebuy">
<Textarea value={notes} onChange={e => setNotes(e.target.value)} placeholder="What stood out?" />
</Field>
</div>
</div>
<div style={{padding: "16px 32px", borderTop: "1px solid var(--line)", display: "flex", justifyContent: "flex-end", gap: 8, background: "var(--bg-2)", borderRadius: "0 0 var(--r-lg) var(--r-lg)"}}>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn variant="primary" icon="check" onClick={onClose}>Mark finished</Btn>
</div>
</div>
</div>
);
};
// ─── MARK GONE (lost / damaged / expired) ─────────────────────────
const MarkGoneFlow = ({data, onClose, product: initialProduct}) => {
const active = data.products.filter(p => p.status === "active");
const [productId, setProductId] = React.useState(initialProduct?.id || active[0]?.id);
const [reason, setReason] = React.useState("lost");
const [notes, setNotes] = React.useState("");
const [date, setDate] = React.useState("2026-04-25");
const product = data.products.find(p => p.id === productId);
if (!product) return null;
const reasons = [
["lost", "Lost / misplaced"],
["damaged", "Damaged"],
["expired", "Expired"],
["gifted", "Gifted away"],
["other", "Other"]
];
return (
<div style={{position: "fixed", inset: 0, background: "oklch(20% 0.02 60 / 0.4)", zIndex: 50, display: "flex", justifyContent: "center", alignItems: "flex-start", overflow: "auto"}} onClick={onClose}>
<div onClick={e => e.stopPropagation()} style={{
width: "min(640px, 96vw)", margin: "40px 20px", background: "var(--bg)",
border: "1px solid var(--line)", borderRadius: "var(--r-lg)", boxShadow: "var(--shadow-lg)"
}}>
<div style={{padding: "20px 32px", borderBottom: "1px solid var(--line)", display: "flex", justifyContent: "space-between", alignItems: "center"}}>
<div>
<div className="smallcaps" style={{color: "var(--terracotta)"}}>Archive · not consumed</div>
<h2 className="serif" style={{fontSize: 28, margin: "4px 0 0", fontWeight: 500}}>Mark as gone</h2>
</div>
<Btn variant="ghost" icon="close" onClick={onClose} />
</div>
<div style={{padding: 32}}>
<div style={{fontSize: 13, color: "var(--ink-2)", marginBottom: 20, padding: 14, background: "var(--amber-soft)", borderRadius: "var(--r-md)"}}>
Use this when an item is lost, damaged, expired, or gifted away. Counts as <strong>spend</strong> but not as <strong>consumption</strong>, so daily averages stay accurate.
</div>
<Field label="Product">
<Select value={productId} onChange={e => setProductId(e.target.value)}>
{active.map(p => <option key={p.id} value={p.id}>{p.name} {H.brandName(data, p.brandId)} ({remainingShort(p)} left)</option>)}
</Select>
</Field>
<div style={{display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginTop: 16}}>
<Field label="Reason">
<Select value={reason} onChange={e => setReason(e.target.value)}>
{reasons.map(([k,l]) => <option key={k} value={k}>{l}</option>)}
</Select>
</Field>
<Field label="Date">
<Input type="date" value={date} onChange={e => setDate(e.target.value)} />
</Field>
</div>
<div style={{marginTop: 16}}>
<Field label="Notes (optional)" hint="What happened">
<Textarea value={notes} onChange={e => setNotes(e.target.value)} placeholder="e.g. Pack went through the wash" />
</Field>
</div>
</div>
<div style={{padding: "16px 32px", borderTop: "1px solid var(--line)", display: "flex", justifyContent: "flex-end", gap: 8, background: "var(--bg-2)", borderRadius: "0 0 var(--r-lg) var(--r-lg)"}}>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn variant="danger" icon="bin" onClick={onClose}>Mark gone</Btn>
</div>
</div>
</div>
);
};
// ─── AUDIT MODAL ──────────────────────────────────────────────────
const AuditFlow = ({data, onClose, product: initialProduct}) => {
const overdueFirst = [...data.products]
.filter(p => p.status === "active")
.sort((a, b) => H.daysSinceCheck(b) - H.daysSinceCheck(a));
const [productId, setProductId] = React.useState(initialProduct?.id || overdueFirst[0]?.id);
const [date, setDate] = React.useState("2026-04-25");
const product = data.products.find(p => p.id === productId);
const cfg = product ? data.types.find(t => t.id === product.type) : null;
// For bulk: weighed/estimated value. For discrete: count + confirmedBy.
const last = product ? H.lastAudit(product) : null;
const initialValue = product
? (product.kind === "discrete"
? (product.countLastAudit != null ? product.countLastAudit : product.countOriginal)
: H.estimatedRemaining(product, "2026-04-25").toFixed(2))
: 0;
const [value, setValue] = React.useState(initialValue);
const [confirmedBy, setConfirmedBy] = React.useState("SKU");
React.useEffect(() => {
if (product) {
setValue(product.kind === "discrete"
? (product.countLastAudit != null ? product.countLastAudit : product.countOriginal)
: H.estimatedRemaining(product, "2026-04-25").toFixed(2));
}
}, [productId]);
if (!product) return null;
const auditMode = cfg?.auditMode || "weigh";
const prevValue = product.kind === "discrete"
? (product.countLastAudit != null ? product.countLastAudit : product.countOriginal)
: (last ? last.value : product.weight);
const auditModeLabels = {
weigh: { title: "Reweigh on a scale", desc: "Place the jar (minus tare) and record the new weight." },
estimate: { title: "Visual estimate", desc: "Eyeball the remaining amount — quick and approximate." },
presence: { title: "Confirm presence", desc: "Verify the item is still where you left it. Count units if applicable." }
};
const ml = auditModeLabels[auditMode];
return (
<div style={{position: "fixed", inset: 0, background: "oklch(20% 0.02 60 / 0.4)", zIndex: 50, display: "flex", justifyContent: "center", alignItems: "flex-start", overflow: "auto"}} onClick={onClose}>
<div onClick={e => e.stopPropagation()} style={{
width: "min(720px, 96vw)", margin: "40px 20px", background: "var(--bg)",
border: "1px solid var(--line)", borderRadius: "var(--r-lg)", boxShadow: "var(--shadow-lg)"
}}>
<div style={{padding: "20px 32px", borderBottom: "1px solid var(--line)", display: "flex", justifyContent: "space-between", alignItems: "center"}}>
<div>
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Audit</div>
<h2 className="serif" style={{fontSize: 28, margin: "4px 0 0", fontWeight: 500}}>{ml.title}</h2>
</div>
<Btn variant="ghost" icon="close" onClick={onClose} />
</div>
<div style={{padding: 32}}>
<Field label="Product">
<Select value={productId} onChange={e => setProductId(e.target.value)}>
{overdueFirst.map(p => {
const od = H.auditOverdue(data, p);
const sc = H.daysSinceCheck(p);
return <option key={p.id} value={p.id}>{od ? "⚠ " : ""}{p.name} {H.brandName(data, p.brandId)} · {sc}d since check</option>;
})}
</Select>
</Field>
<div style={{marginTop: 16, padding: 16, background: "var(--bg-2)", border: "1px solid var(--line)", borderRadius: "var(--r-md)"}}>
<div style={{display: "flex", justifyContent: "space-between", alignItems: "center"}}>
<div>
<div className="serif" style={{fontSize: 20, fontWeight: 500}}>{product.name}</div>
<div style={{fontSize: 12, color: "var(--ink-3)"}}>
{product.type} · {product.kind} · cadence every {cfg?.cadenceDays}d
</div>
</div>
<div style={{textAlign: "right"}}>
<div className="mono" style={{fontSize: 11, color: "var(--ink-3)"}}>LAST CHECKED</div>
<div className="serif" style={{fontSize: 18}}>{last ? `${H.daysSinceCheck(product)}d ago` : "Never"}</div>
</div>
</div>
<div style={{fontSize: 12, color: "var(--ink-2)", marginTop: 10, fontStyle: "italic"}}>{ml.desc}</div>
</div>
<div style={{display: "grid", gridTemplateColumns: auditMode === "presence" ? "1fr 1fr 1fr" : "1fr 1fr", gap: 16, marginTop: 24}}>
<Field label={
product.kind === "discrete"
? `Count now (${cfg?.unit})`
: auditMode === "weigh" ? `Weight now (${cfg?.unit})` : `Estimate now (${cfg?.unit})`
}>
<Input
type="number"
step={product.kind === "discrete" ? "1" : "0.1"}
value={value}
onChange={e => setValue(e.target.value)}
/>
</Field>
<Field label="Date"><Input type="date" value={date} onChange={e => setDate(e.target.value)} /></Field>
{auditMode === "presence" && (
<Field label="Confirmed by">
<Select value={confirmedBy} onChange={e => setConfirmedBy(e.target.value)}>
<option value="SKU">SKU label</option>
<option value="asset">Asset tag</option>
<option value="visual">Visual ID</option>
</Select>
</Field>
)}
</div>
<div style={{marginTop: 20, padding: 14, background: "var(--surface)", border: "1px solid var(--line)", borderRadius: "var(--r-md)", display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16}}>
<div>
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Was</div>
<div className="serif" style={{fontSize: 22}}>{prevValue} {cfg?.unit}</div>
</div>
<div>
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Now</div>
<div className="serif" style={{fontSize: 22, color: "var(--sage)"}}>{value} {cfg?.unit}</div>
</div>
<div>
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Δ since last</div>
<div className="serif" style={{fontSize: 22, color: (+value - +prevValue) < 0 ? "var(--terracotta)" : "var(--ink)"}}>
{(+value - +prevValue).toFixed(product.kind === "discrete" ? 0 : 2)} {cfg?.unit}
</div>
</div>
</div>
</div>
<div style={{padding: "16px 32px", borderTop: "1px solid var(--line)", display: "flex", justifyContent: "space-between", alignItems: "center", background: "var(--bg-2)", borderRadius: "0 0 var(--r-lg) var(--r-lg)"}}>
<div style={{fontSize: 12, color: "var(--ink-3)"}}>Next audit due in {cfg?.cadenceDays}d</div>
<div style={{display: "flex", gap: 8}}>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn variant="primary" icon="check" onClick={onClose}>Save audit</Btn>
</div>
</div>
</div>
</div>
);
};
// ─── BINS ─────────────────────────────────────────────────────────
const BinsView = ({data, onSelectProduct}) => {
return (
<div style={{padding: "32px 40px 80px", maxWidth: 1400, margin: "0 auto"}}>
<div style={{display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 24}}>
<div>
<div className="smallcaps" style={{color: "var(--ink-3)"}}>{data.bins.length} bins</div>
<h1 className="serif" style={{fontSize: 44, margin: "6px 0 0", fontWeight: 500, letterSpacing: "-0.02em"}}>Bins & storage</h1>
</div>
<Btn variant="secondary" icon="plus">New bin</Btn>
</div>
<div style={{fontSize: 14, color: "var(--ink-2)", marginBottom: 24, maxWidth: 600}}>
Where each active product physically lives. Archived items aren't assigned to a bin.
</div>
<div style={{display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(380px, 1fr))", gap: 14}}>
{data.bins.map(bin => {
const items = data.products.filter(p => p.binId === bin.id && p.status === "active");
const fillPct = items.length / bin.capacity;
const totalValue = items.reduce((s, p) => s + p.price * H.pctRemaining(p, "2026-04-25"), 0);
return (
<Card key={bin.id} padded={false} style={{display: "flex", flexDirection: "column"}}>
<div style={{padding: "20px 22px 16px", borderBottom: "1px solid var(--line)"}}>
<div style={{display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 4}}>
<h3 className="serif" style={{fontSize: 24, margin: 0, fontWeight: 500}}>{bin.name}</h3>
<Pill tone="outline">{items.length} / {bin.capacity}</Pill>
</div>
<div style={{fontSize: 12, color: "var(--ink-3)", display: "flex", justifyContent: "space-between"}}>
<span>{bin.location}</span>
<span className="mono">{fmt.money(totalValue)}</span>
</div>
<div style={{marginTop: 12, height: 4, background: "var(--bg-3)", borderRadius: 2, overflow: "hidden"}}>
<div style={{width: `${Math.min(fillPct, 1)*100}%`, height: "100%", background: fillPct > 0.9 ? "var(--terracotta)" : fillPct > 0.7 ? "var(--amber)" : "var(--sage)"}} />
</div>
</div>
<div style={{padding: 8, flex: 1}}>
{items.length === 0 && <div style={{padding: 30, textAlign: "center", fontSize: 12, color: "var(--ink-3)", fontStyle: "italic"}}>Empty</div>}
{items.map(p => (
<div key={p.id} onClick={() => onSelectProduct(p)} style={{display: "flex", alignItems: "center", gap: 10, padding: "8px 14px", borderRadius: "var(--r-sm)", cursor: "pointer"}}>
<div style={{fontFamily: "var(--serif)", fontSize: 18, color: "var(--ink-3)", width: 18}}>{TYPE_GLYPHS[p.type]}</div>
<div style={{flex: 1, minWidth: 0}}>
<div style={{fontSize: 13, fontWeight: 500, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis"}}>{p.name}</div>
<div style={{fontSize: 11, color: "var(--ink-3)"}}>{H.brandName(data, p.brandId)}</div>
</div>
<div className="mono" style={{fontSize: 11, color: "var(--ink-2)"}}>{remainingShort(p)}</div>
</div>
))}
</div>
</Card>
);
})}
</div>
</div>
);
};
// ─── CHARTS ───────────────────────────────────────────────────────
const ChartsView = ({data, stats}) => {
const series = stats.series90.map(s => ({date: s.date, grams: s.grams, label: ""}));
const spendByMonth = {};
data.products.forEach(p => {
const k = p.purchaseDate.slice(0, 7);
spendByMonth[k] = (spendByMonth[k] || 0) + p.price;
});
const months = Object.entries(spendByMonth).sort();
const spendByShop = {};
data.products.forEach(p => {
const name = H.shopName(data, p.shopId);
spendByShop[name] = (spendByShop[name] || 0) + p.price;
});
const shopRanked = Object.entries(spendByShop).sort((a,b) => b[1]-a[1]);
return (
<div style={{padding: "32px 40px 80px", maxWidth: 1400, margin: "0 auto"}}>
<div style={{marginBottom: 24}}>
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Last 90 days</div>
<h1 className="serif" style={{fontSize: 44, margin: "6px 0 0", fontWeight: 500, letterSpacing: "-0.02em"}}>Patterns & spend</h1>
</div>
<Card style={{marginBottom: 14}}>
<div style={{display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 18}}>
<div className="serif" style={{fontSize: 22}}>Daily grams · 90 days</div>
<div style={{display: "flex", gap: 24, fontSize: 12, color: "var(--ink-3)"}}>
<div>Total <span className="serif" style={{fontSize: 18, color: "var(--ink)"}}>{series.reduce((s,e)=>s+e.grams,0).toFixed(1)} g</span></div>
<div>Avg <span className="serif" style={{fontSize: 18, color: "var(--ink)"}}>{(series.reduce((s,e)=>s+e.grams,0)/90).toFixed(2)} g/day</span></div>
<div>Items finished <span className="serif" style={{fontSize: 18, color: "var(--ink)"}}>{stats.consumedCount}</span></div>
{stats.goneCount > 0 && <div>Items gone <span className="serif" style={{fontSize: 18, color: "var(--ink)"}}>{stats.goneCount}</span></div>}
</div>
</div>
<BarChart data={series.map(s => ({value: s.grams, label: ""}))} height={180} color="var(--sage)" />
</Card>
<div style={{display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14, marginBottom: 14}}>
<Card>
<div className="serif" style={{fontSize: 22, marginBottom: 18}}>Spend by month</div>
<div style={{display: "flex", flexDirection: "column", gap: 14}}>
{months.map(([m, v]) => {
const max = Math.max(...months.map(x => x[1]));
const d = new Date(m + "-01");
return (
<div key={m} style={{display: "flex", alignItems: "center", gap: 12}}>
<div className="smallcaps" style={{color: "var(--ink-3)", width: 60}}>{d.toLocaleDateString("en-US", {month: "short", year: "2-digit"})}</div>
<div style={{flex: 1, height: 24, background: "var(--bg-2)", borderRadius: 4, position: "relative"}}>
<div style={{width: `${(v/max)*100}%`, height: "100%", background: "var(--terracotta)", borderRadius: 4, opacity: 0.85}} />
</div>
<div className="mono" style={{width: 70, textAlign: "right", fontSize: 13}}>{fmt.moneyShort(v)}</div>
</div>
);
})}
</div>
</Card>
<Card>
<div className="serif" style={{fontSize: 22, marginBottom: 18}}>Spend by shop</div>
<div style={{display: "flex", flexDirection: "column", gap: 14}}>
{shopRanked.map(([s, v]) => {
const max = shopRanked[0][1];
return (
<div key={s} style={{display: "flex", alignItems: "center", gap: 12}}>
<div style={{flex: 1.5, fontSize: 13, color: "var(--ink-2)"}}>{s}</div>
<div style={{flex: 2, height: 8, background: "var(--bg-2)", borderRadius: 4, position: "relative"}}>
<div style={{width: `${(v/max)*100}%`, height: "100%", background: "var(--sage)", borderRadius: 4}} />
</div>
<div className="mono" style={{width: 70, textAlign: "right", fontSize: 13}}>{fmt.moneyShort(v)}</div>
</div>
);
})}
</div>
</Card>
</div>
<Card>
<div className="serif" style={{fontSize: 22, marginBottom: 6}}>Inferred consumption heatmap</div>
<div style={{fontSize: 12, color: "var(--ink-3)", marginBottom: 18}}>13 weeks · darker = higher inferred daily use, prorated across each item's lifespan</div>
<Heatmap series={series} />
</Card>
</div>
);
};
const Heatmap = ({series}) => {
const first = new Date(series[0].date);
const offset = first.getDay();
const cells = [];
for (let i = 0; i < offset; i++) cells.push(null);
series.forEach(s => cells.push(s));
while (cells.length < 13 * 7) cells.push(null);
const max = Math.max(...series.map(s => s.grams), 0.001);
const colorFor = (g) => {
if (g === 0) return "var(--bg-3)";
const t = g / max;
return `oklch(${72 - t * 30}% ${0.04 + t * 0.06} 145)`;
};
const days = ["S","M","T","W","T","F","S"];
return (
<div style={{display: "flex", gap: 8, alignItems: "flex-start"}}>
<div style={{display: "flex", flexDirection: "column", gap: 3, paddingTop: 18}}>
{days.map((d, i) => <div key={i} style={{height: 14, fontSize: 9, color: "var(--ink-3)", fontFamily: "var(--mono)"}}>{d}</div>)}
</div>
<div style={{flex: 1}}>
<div style={{display: "grid", gridTemplateColumns: "repeat(13, 1fr)", gap: 3, marginBottom: 4}}>
{Array.from({length: 13}).map((_, w) => {
const firstDay = cells[w * 7];
return <div key={w} style={{fontSize: 9, color: "var(--ink-3)", fontFamily: "var(--mono)", textAlign: "center"}}>{firstDay && new Date(firstDay.date).getDate() <= 7 ? new Date(firstDay.date).toLocaleDateString("en-US", {month: "short"}) : ""}</div>;
})}
</div>
<div style={{display: "grid", gridTemplateRows: "repeat(7, 1fr)", gridAutoFlow: "column", gap: 3}}>
{cells.map((c, i) => (
<div key={i} title={c ? `${c.date}: ${c.grams.toFixed(2)}g` : ""} style={{
aspectRatio: "1",
minHeight: 14,
background: c ? colorFor(c.grams) : "transparent",
borderRadius: 2
}} />
))}
</div>
<div style={{display: "flex", justifyContent: "flex-end", alignItems: "center", gap: 6, marginTop: 14, fontSize: 10, color: "var(--ink-3)", fontFamily: "var(--mono)"}}>
<span>Less</span>
{[0, 0.25, 0.5, 0.75, 1].map(t => (
<div key={t} style={{width: 14, height: 14, background: t === 0 ? "var(--bg-3)" : `oklch(${72 - t * 30}% ${0.04 + t * 0.06} 145)`, borderRadius: 2}} />
))}
<span>More</span>
</div>
</div>
</div>
);
};
// ─── SETTINGS ─────────────────────────────────────────────────────
const SettingsView = ({data, tweaks, onTweakChange}) => {
return (
<div style={{padding: "32px 40px 80px", maxWidth: 800, margin: "0 auto"}}>
<div style={{marginBottom: 24}}>
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Settings</div>
<h1 className="serif" style={{fontSize: 44, margin: "6px 0 0", fontWeight: 500, letterSpacing: "-0.02em"}}>Preferences</h1>
</div>
<Card style={{marginBottom: 14}}>
<div className="serif" style={{fontSize: 22, marginBottom: 16}}>Appearance</div>
<div style={{display: "flex", flexDirection: "column", gap: 14}}>
<SettingRow label="Theme" hint="Light parchment or dim ink">
<div style={{display: "inline-flex", background: "var(--bg-2)", border: "1px solid var(--line)", borderRadius: "var(--r-md)", padding: 3}}>
{["light", "dark"].map(t => (
<button key={t} onClick={() => onTweakChange("theme", t)} style={{padding: "6px 14px", fontSize: 12, fontWeight: 500, borderRadius: 6, border: "none", background: tweaks.theme === t ? "var(--surface)" : "transparent", color: tweaks.theme === t ? "var(--ink)" : "var(--ink-3)", cursor: "pointer", textTransform: "capitalize"}}>{t}</button>
))}
</div>
</SettingRow>
<SettingRow label="Dashboard layout" hint="Editorial leans on type; data-dense packs more in">
<div style={{display: "inline-flex", background: "var(--bg-2)", border: "1px solid var(--line)", borderRadius: "var(--r-md)", padding: 3}}>
{[["editorial","Editorial"], ["dense","Data-dense"], ["minimal","Minimal"]].map(([k,l]) => (
<button key={k} onClick={() => onTweakChange("dashboard", k)} style={{padding: "6px 14px", fontSize: 12, fontWeight: 500, borderRadius: 6, border: "none", background: tweaks.dashboard === k ? "var(--surface)" : "transparent", color: tweaks.dashboard === k ? "var(--ink)" : "var(--ink-3)", cursor: "pointer"}}>{l}</button>
))}
</div>
</SettingRow>
<SettingRow label="Tone">
<Select value={tweaks.tone} onChange={e => onTweakChange("tone", e.target.value)} style={{...inputStyle, width: 200}}>
<option value="botanical">Botanical (default)</option>
<option value="neutral">Neutral inventory</option>
<option value="discreet">Discreet (code names)</option>
</Select>
</SettingRow>
</div>
</Card>
{/* Shops */}
<Card style={{marginBottom: 14}}>
<div style={{display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 14}}>
<div className="serif" style={{fontSize: 22}}>Shops</div>
<Btn variant="ghost" icon="plus">Add shop</Btn>
</div>
<div style={{border: "1px solid var(--line)", borderRadius: "var(--r-md)", overflow: "hidden"}}>
{data.shops.map((s, i) => {
const count = data.products.filter(p => p.shopId === s.id).length;
return (
<div key={s.id} style={{padding: "12px 16px", borderBottom: i < data.shops.length-1 ? "1px solid var(--line)" : "none", display: "flex", alignItems: "center", gap: 12}}>
<div style={{flex: 1}}>
<div style={{fontSize: 14, fontWeight: 500}}>{s.name}</div>
<div style={{fontSize: 11, color: "var(--ink-3)"}}>{s.location}</div>
</div>
<Pill tone="outline">{count} purchase{count===1?"":"s"}</Pill>
<Btn variant="ghost" icon="edit" />
</div>
);
})}
</div>
</Card>
{/* Brands */}
<Card style={{marginBottom: 14}}>
<div style={{display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 14}}>
<div className="serif" style={{fontSize: 22}}>Brands</div>
<Btn variant="ghost" icon="plus">Add brand</Btn>
</div>
<div style={{display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 8}}>
{data.brands.map(b => {
const count = data.products.filter(p => p.brandId === b.id).length;
return (
<div key={b.id} style={{padding: "10px 14px", border: "1px solid var(--line)", borderRadius: "var(--r-md)", display: "flex", alignItems: "center", gap: 12}}>
<div style={{flex: 1, fontSize: 13, fontWeight: 500}}>{b.name}</div>
<span className="mono" style={{fontSize: 11, color: "var(--ink-3)"}}>{count}</span>
</div>
);
})}
</div>
</Card>
<Card style={{marginBottom: 14}}>
<div className="serif" style={{fontSize: 22, marginBottom: 16}}>Library</div>
<div style={{display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 12, marginBottom: 16}}>
<Stat label="Active" value={data.products.filter(p=>p.status==="active").length} />
<Stat label="Consumed" value={data.products.filter(p=>p.status==="consumed").length} />
<Stat label="Gone" value={data.products.filter(p=>p.status==="gone").length} />
<Stat label="Bins" value={data.bins.length} />
</div>
<div style={{fontSize: 12, color: "var(--ink-3)"}}>All data is stored locally. Export anytime.</div>
<div style={{display: "flex", gap: 8, marginTop: 12}}>
<Btn variant="secondary">Export CSV</Btn>
<Btn variant="secondary">Export JSON</Btn>
<Btn variant="ghost" style={{color: "var(--terracotta)"}}>Reset all data</Btn>
</div>
</Card>
</div>
);
};
const SettingRow = ({label, hint, children}) => (
<div style={{display: "flex", alignItems: "center", justifyContent: "space-between", paddingBottom: 14, borderBottom: "1px solid var(--line)"}}>
<div style={{flex: 1}}>
<div style={{fontSize: 13, fontWeight: 500}}>{label}</div>
{hint && <div style={{fontSize: 12, color: "var(--ink-3)", marginTop: 2}}>{hint}</div>}
</div>
{children}
</div>
);
Object.assign(window, { AddProductFlow, ConsumeFlow, MarkGoneFlow, AuditFlow, BinsView, ChartsView, SettingsView });