Initial commit: Apothecary v0.4.0
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
// Sample inventory data
|
||||
window.SAMPLE_DATA = (function() {
|
||||
const TODAY = "2026-04-25";
|
||||
const daysAgo = (n) => {
|
||||
const d = new Date(TODAY); d.setDate(d.getDate() - n);
|
||||
return d.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
// Shops are objects now
|
||||
const SHOPS = [
|
||||
{ id: "shp-01", name: "Greenleaf Co-op", location: "Capitol Hill" },
|
||||
{ id: "shp-02", name: "Verdant Apothecary", location: "Ballard" },
|
||||
{ id: "shp-03", name: "The Field House", location: "Fremont" },
|
||||
{ id: "shp-04", name: "Northstar Dispensary",location: "Roosevelt" },
|
||||
{ id: "shp-05", name: "Wildwood Provisions", location: "West Seattle" }
|
||||
];
|
||||
|
||||
// Brands are objects too
|
||||
const BRANDS = [
|
||||
{ id: "brd-01", name: "Foxglove Farms" },
|
||||
{ id: "brd-02", name: "Slow Burn" },
|
||||
{ id: "brd-03", name: "Heirloom Botanicals" },
|
||||
{ id: "brd-04", name: "Terra Vera" },
|
||||
{ id: "brd-05", name: "North Field" },
|
||||
{ id: "brd-06", name: "Cinder & Sage" },
|
||||
{ id: "brd-07", name: "Old Forest" },
|
||||
{ id: "brd-08", name: "Marigold Ext." }
|
||||
];
|
||||
|
||||
// Type config — kind + audit cadence
|
||||
// bulk: track weight (g) — audit re-weighs (or estimates for concentrate)
|
||||
// discrete: track count (units) — audit confirms presence by SKU/asset
|
||||
const TYPES = [
|
||||
{ id: "Flower", kind: "bulk", auditMode: "weigh", cadenceDays: 14, unit: "g", weighable: true },
|
||||
{ id: "Concentrate", kind: "bulk", auditMode: "estimate", cadenceDays: 21, unit: "g", weighable: true },
|
||||
{ id: "Tincture", kind: "bulk", auditMode: "estimate", cadenceDays: 30, unit: "ml", weighable: false },
|
||||
{ id: "Pre-roll", kind: "discrete", auditMode: "presence", cadenceDays: 30, unit: "ct", weighable: false },
|
||||
{ id: "Edible", kind: "discrete", auditMode: "presence", cadenceDays: 60, unit: "ct", weighable: false },
|
||||
{ id: "Vaporizer", kind: "discrete", auditMode: "presence", cadenceDays: 30, unit: "ct", weighable: false }
|
||||
];
|
||||
|
||||
const BINS = [
|
||||
{ id: "bin-01", name: "Top Drawer", location: "Bedroom", capacity: 14 },
|
||||
{ id: "bin-02", name: "Apothecary Box", location: "Office shelf", capacity: 10 },
|
||||
{ id: "bin-03", name: "The Safe", location: "Closet", capacity: 8 },
|
||||
{ id: "bin-04", name: "Travel Tin", location: "Backpack", capacity: 4 },
|
||||
{ id: "bin-05", name: "Cold Storage", location: "Fridge — back", capacity: 6 }
|
||||
];
|
||||
|
||||
let n = 1;
|
||||
const mk = (o) => {
|
||||
const id = "prd-" + String(n).padStart(4, "0");
|
||||
n++;
|
||||
const sku = o.sku || ("SKU-" + Math.random().toString(36).slice(2, 8).toUpperCase());
|
||||
return {
|
||||
id,
|
||||
sku,
|
||||
assetTag: null,
|
||||
name: "",
|
||||
brandId: null,
|
||||
shopId: null,
|
||||
type: "Flower",
|
||||
kind: "bulk", // "bulk" or "discrete"
|
||||
// Bulk fields
|
||||
weight: 0, // total at purchase (g/ml)
|
||||
lastAuditWeight: null, // last measured weight
|
||||
// Discrete fields
|
||||
countOriginal: 0, // units at purchase
|
||||
countLastAudit: null, // units confirmed at last audit
|
||||
unitWeight: 0, // bulk-equivalent grams per unit (for grams stats)
|
||||
// Pricing
|
||||
price: 0,
|
||||
thc: 0,
|
||||
cbd: 0,
|
||||
totalCannabinoids: 0,
|
||||
purchaseDate: TODAY,
|
||||
binId: "bin-01",
|
||||
// Lifecycle
|
||||
status: "active", // "active" | "consumed" | "gone"
|
||||
consumedDate: null,
|
||||
goneDate: null,
|
||||
rating: null,
|
||||
notes: null,
|
||||
// Audits — newest last
|
||||
audits: [],
|
||||
...o
|
||||
};
|
||||
};
|
||||
|
||||
const products = [];
|
||||
|
||||
// ─── BULK: FLOWER ────────────────────────────────────────────────
|
||||
products.push(mk({
|
||||
name: "Garden Ghost", brandId: "brd-01", shopId: "shp-01",
|
||||
type: "Flower", kind: "bulk",
|
||||
weight: 3.5, lastAuditWeight: 2.4,
|
||||
price: 48, thc: 24.6, cbd: 0.3, totalCannabinoids: 28.4,
|
||||
purchaseDate: daysAgo(17), binId: "bin-01",
|
||||
audits: [
|
||||
{ date: daysAgo(10), mode: "weigh", value: 3.0, prev: 3.5 },
|
||||
{ date: daysAgo(3), mode: "weigh", value: 2.4, prev: 3.0 }
|
||||
]
|
||||
}));
|
||||
products.push(mk({
|
||||
name: "Honeydew Pine", brandId: "brd-02", shopId: "shp-02",
|
||||
type: "Flower", kind: "bulk",
|
||||
weight: 7, lastAuditWeight: 5.6,
|
||||
price: 85, thc: 21.0, cbd: 0.5, totalCannabinoids: 25.1,
|
||||
purchaseDate: daysAgo(11), binId: "bin-01",
|
||||
audits: [
|
||||
{ date: daysAgo(2), mode: "weigh", value: 5.6, prev: 7.0 }
|
||||
]
|
||||
}));
|
||||
products.push(mk({
|
||||
name: "Late Pear", brandId: "brd-01", shopId: "shp-01",
|
||||
type: "Flower", kind: "bulk",
|
||||
weight: 3.5, lastAuditWeight: 3.5,
|
||||
price: 50, thc: 25.2, cbd: 0.2, totalCannabinoids: 28.9,
|
||||
purchaseDate: daysAgo(6), binId: "bin-05"
|
||||
// recently bought, no audit yet
|
||||
}));
|
||||
products.push(mk({
|
||||
name: "Copper Fennel", brandId: "brd-02", shopId: "shp-02",
|
||||
type: "Flower", kind: "bulk",
|
||||
weight: 3.5, lastAuditWeight: 0.5,
|
||||
price: 42, thc: 20.4, cbd: 0.5, totalCannabinoids: 24.0,
|
||||
purchaseDate: daysAgo(26), binId: "bin-01",
|
||||
audits: [
|
||||
{ date: daysAgo(12), mode: "weigh", value: 1.6, prev: 3.5 },
|
||||
{ date: daysAgo(2), mode: "weigh", value: 0.5, prev: 1.6 }
|
||||
]
|
||||
}));
|
||||
// Overdue audit — Flower past 14d cadence
|
||||
products.push(mk({
|
||||
name: "Slate Cherry", brandId: "brd-06", shopId: "shp-04",
|
||||
type: "Flower", kind: "bulk",
|
||||
weight: 3.5, lastAuditWeight: 3.5,
|
||||
price: 46, thc: 22.8, cbd: 0.4, totalCannabinoids: 26.5,
|
||||
purchaseDate: daysAgo(22), binId: "bin-01"
|
||||
// No audit since purchase, 22d > 14d cadence → overdue
|
||||
}));
|
||||
|
||||
// ─── BULK: CONCENTRATE ──────────────────────────────────────────
|
||||
products.push(mk({
|
||||
name: "Indigo Cellar Live Rosin", brandId: "brd-03", shopId: "shp-03",
|
||||
type: "Concentrate", kind: "bulk",
|
||||
weight: 1, lastAuditWeight: 0.6,
|
||||
price: 65, thc: 78.4, cbd: 0.2, totalCannabinoids: 84.0,
|
||||
purchaseDate: daysAgo(28), binId: "bin-03",
|
||||
assetTag: "AT-0042",
|
||||
audits: [
|
||||
{ date: daysAgo(15), mode: "estimate", value: 0.8, prev: 1.0 },
|
||||
{ date: daysAgo(2), mode: "estimate", value: 0.6, prev: 0.8 }
|
||||
]
|
||||
}));
|
||||
products.push(mk({
|
||||
name: "Slate Apricot Hash", brandId: "brd-04", shopId: "shp-04",
|
||||
type: "Concentrate", kind: "bulk",
|
||||
weight: 2, lastAuditWeight: 1.4,
|
||||
price: 80, thc: 62.0, cbd: 1.1, totalCannabinoids: 70.5,
|
||||
purchaseDate: daysAgo(23), binId: "bin-03",
|
||||
audits: [
|
||||
{ date: daysAgo(8), mode: "estimate", value: 1.4, prev: 2.0 }
|
||||
]
|
||||
}));
|
||||
products.push(mk({
|
||||
name: "Birchwater Live Resin", brandId: "brd-03", shopId: "shp-04",
|
||||
type: "Concentrate", kind: "bulk",
|
||||
weight: 1, lastAuditWeight: 1.0,
|
||||
price: 70, thc: 76.0, cbd: 0.3, totalCannabinoids: 82.1,
|
||||
purchaseDate: daysAgo(4), binId: "bin-03"
|
||||
}));
|
||||
|
||||
// ─── BULK: TINCTURE ─────────────────────────────────────────────
|
||||
products.push(mk({
|
||||
name: "Nightjar Tincture 30ml", brandId: "brd-08", shopId: "shp-02",
|
||||
type: "Tincture", kind: "bulk",
|
||||
weight: 30, lastAuditWeight: 22,
|
||||
price: 60, thc: 0.5, cbd: 18.0, totalCannabinoids: 19.2,
|
||||
purchaseDate: daysAgo(62), binId: "bin-02",
|
||||
audits: [
|
||||
{ date: daysAgo(31), mode: "estimate", value: 26, prev: 30 },
|
||||
{ date: daysAgo(5), mode: "estimate", value: 22, prev: 26 }
|
||||
]
|
||||
}));
|
||||
|
||||
// ─── DISCRETE: PRE-ROLLS ───────────────────────────────────────
|
||||
products.push(mk({
|
||||
name: "Mossback Pre-rolls", brandId: "brd-07", shopId: "shp-03",
|
||||
type: "Pre-roll", kind: "discrete",
|
||||
countOriginal: 5, countLastAudit: 3, unitWeight: 0.7,
|
||||
price: 38, thc: 19.8, cbd: 0.4, totalCannabinoids: 23.0,
|
||||
purchaseDate: daysAgo(9), binId: "bin-04",
|
||||
audits: [
|
||||
{ date: daysAgo(2), mode: "presence", value: 3, prev: 5, confirmedBy: "SKU" }
|
||||
]
|
||||
}));
|
||||
products.push(mk({
|
||||
name: "Mossback Pre-rolls", brandId: "brd-07", shopId: "shp-03",
|
||||
type: "Pre-roll", kind: "discrete",
|
||||
countOriginal: 5, countLastAudit: 5, unitWeight: 0.7,
|
||||
price: 38, thc: 19.8, cbd: 0.4, totalCannabinoids: 23.0,
|
||||
purchaseDate: daysAgo(3), binId: "bin-04"
|
||||
}));
|
||||
products.push(mk({
|
||||
name: "Quiet Meadow Singles", brandId: "brd-05", shopId: "shp-05",
|
||||
type: "Pre-roll", kind: "discrete",
|
||||
countOriginal: 3, countLastAudit: 1, unitWeight: 1.0,
|
||||
price: 24, thc: 22.0, cbd: 0.3, totalCannabinoids: 25.0,
|
||||
purchaseDate: daysAgo(34), binId: "bin-04",
|
||||
audits: [
|
||||
{ date: daysAgo(20), mode: "presence", value: 2, prev: 3, confirmedBy: "SKU" },
|
||||
{ date: daysAgo(2), mode: "presence", value: 1, prev: 2, confirmedBy: "SKU" }
|
||||
]
|
||||
}));
|
||||
|
||||
// ─── DISCRETE: EDIBLES ─────────────────────────────────────────
|
||||
products.push(mk({
|
||||
name: "Ember Lily Gummies (10 ct)", brandId: "brd-06", shopId: "shp-01",
|
||||
type: "Edible", kind: "discrete",
|
||||
countOriginal: 10, countLastAudit: 6, unitWeight: 0,
|
||||
price: 22, thc: 5.0, cbd: 1.0, totalCannabinoids: 6.4,
|
||||
purchaseDate: daysAgo(20), binId: "bin-02",
|
||||
audits: [
|
||||
{ date: daysAgo(5), mode: "presence", value: 6, prev: 10, confirmedBy: "SKU" }
|
||||
]
|
||||
}));
|
||||
products.push(mk({
|
||||
name: "Marigold Mints (20 ct)", brandId: "brd-08", shopId: "shp-02",
|
||||
type: "Edible", kind: "discrete",
|
||||
countOriginal: 20, countLastAudit: 14, unitWeight: 0,
|
||||
price: 28, thc: 2.5, cbd: 0, totalCannabinoids: 3.0,
|
||||
purchaseDate: daysAgo(48), binId: "bin-02",
|
||||
audits: [
|
||||
{ date: daysAgo(7), mode: "presence", value: 14, prev: 20, confirmedBy: "SKU" }
|
||||
]
|
||||
}));
|
||||
|
||||
// ─── DISCRETE: VAPORIZER ───────────────────────────────────────
|
||||
products.push(mk({
|
||||
name: "Quiet Meadow Disposable", brandId: "brd-05", shopId: "shp-05",
|
||||
type: "Vaporizer", kind: "discrete",
|
||||
countOriginal: 1, countLastAudit: 1, unitWeight: 1.0,
|
||||
price: 55, thc: 84.0, cbd: 0.1, totalCannabinoids: 88.2,
|
||||
purchaseDate: daysAgo(14), binId: "bin-04",
|
||||
audits: [
|
||||
{ date: daysAgo(1), mode: "presence", value: 1, prev: 1, confirmedBy: "SKU" }
|
||||
]
|
||||
}));
|
||||
|
||||
// ─── CONSUMED (used up — counts as consumption) ─────────────────
|
||||
products.push(mk({
|
||||
name: "Oolong 19", brandId: "brd-04", shopId: "shp-01",
|
||||
type: "Flower", kind: "bulk",
|
||||
weight: 3.5, lastAuditWeight: 0,
|
||||
price: 46, thc: 23.0, cbd: 0.4, totalCannabinoids: 27.0,
|
||||
purchaseDate: daysAgo(66), binId: null,
|
||||
status: "consumed", consumedDate: daysAgo(31),
|
||||
rating: 4, notes: "Smooth, citrusy. Daytime favorite."
|
||||
}));
|
||||
products.push(mk({
|
||||
name: "Stonefruit OG", brandId: "brd-07", shopId: "shp-03",
|
||||
type: "Flower", kind: "bulk",
|
||||
weight: 7, lastAuditWeight: 0,
|
||||
price: 78, thc: 22.5, cbd: 0.6, totalCannabinoids: 26.4,
|
||||
purchaseDate: daysAgo(103), binId: null,
|
||||
status: "consumed", consumedDate: daysAgo(56),
|
||||
rating: 5, notes: "Best of the season. Rebuy."
|
||||
}));
|
||||
products.push(mk({
|
||||
name: "Lavender Coast", brandId: "brd-01", shopId: "shp-05",
|
||||
type: "Flower", kind: "bulk",
|
||||
weight: 3.5, lastAuditWeight: 0,
|
||||
price: 44, thc: 19.0, cbd: 0.8, totalCannabinoids: 22.2,
|
||||
purchaseDate: daysAgo(80), binId: null,
|
||||
status: "consumed", consumedDate: daysAgo(47),
|
||||
rating: 3, notes: "Mellow but underwhelming."
|
||||
}));
|
||||
products.push(mk({
|
||||
name: "Violet Tea", brandId: "brd-06", shopId: "shp-04",
|
||||
type: "Flower", kind: "bulk",
|
||||
weight: 3.5, lastAuditWeight: 0,
|
||||
price: 50, thc: 24.1, cbd: 0.3, totalCannabinoids: 28.0,
|
||||
purchaseDate: daysAgo(55), binId: null,
|
||||
status: "consumed", consumedDate: daysAgo(21),
|
||||
rating: 4, notes: "Floral nose, nice evenings."
|
||||
}));
|
||||
|
||||
// ─── GONE (lost / damaged — counts as $ spent, NOT consumption) ─
|
||||
products.push(mk({
|
||||
name: "Quiet Meadow Singles", brandId: "brd-05", shopId: "shp-05",
|
||||
type: "Pre-roll", kind: "discrete",
|
||||
countOriginal: 3, countLastAudit: 0, unitWeight: 1.0,
|
||||
price: 24, thc: 22.0, cbd: 0.3, totalCannabinoids: 25.0,
|
||||
purchaseDate: daysAgo(72), binId: null,
|
||||
status: "gone", goneDate: daysAgo(40),
|
||||
notes: "Pack went through the wash. Lesson learned.",
|
||||
audits: [
|
||||
{ date: daysAgo(40), mode: "presence", value: 0, prev: 3, confirmedBy: "lost" }
|
||||
]
|
||||
}));
|
||||
products.push(mk({
|
||||
name: "Ember Lily Gummies (10 ct)", brandId: "brd-06", shopId: "shp-01",
|
||||
type: "Edible", kind: "discrete",
|
||||
countOriginal: 10, countLastAudit: 4, unitWeight: 0,
|
||||
price: 22, thc: 5.0, cbd: 1.0, totalCannabinoids: 6.4,
|
||||
purchaseDate: daysAgo(95), binId: null,
|
||||
status: "gone", goneDate: daysAgo(15),
|
||||
notes: "Expired. Tossed the rest."
|
||||
}));
|
||||
|
||||
return {
|
||||
products,
|
||||
bins: BINS,
|
||||
shops: SHOPS,
|
||||
brands: BRANDS,
|
||||
types: TYPES,
|
||||
today: TODAY
|
||||
};
|
||||
})();
|
||||
|
||||
// Helpers — exported so screens can use consistent logic
|
||||
window.DATA_HELPERS = {
|
||||
shopName: (data, id) => data.shops.find(s => s.id === id)?.name || "—",
|
||||
brandName: (data, id) => data.brands.find(b => b.id === id)?.name || "—",
|
||||
typeConfig: (data, id) => data.types.find(t => t.id === id) || data.types[0],
|
||||
|
||||
// Days since a given ISO date string
|
||||
daysSince: (iso, today = "2026-04-25") => {
|
||||
if (!iso) return Infinity;
|
||||
return Math.floor((new Date(today) - new Date(iso)) / 86400000);
|
||||
},
|
||||
|
||||
// Last audit record (newest)
|
||||
lastAudit: (p) => p.audits && p.audits.length ? p.audits[p.audits.length - 1] : null,
|
||||
|
||||
// Days since last audit (or since purchase if none)
|
||||
daysSinceCheck: (p, today = "2026-04-25") => {
|
||||
const last = p.audits && p.audits.length ? p.audits[p.audits.length - 1].date : p.purchaseDate;
|
||||
return Math.floor((new Date(today) - new Date(last)) / 86400000);
|
||||
},
|
||||
|
||||
// Is audit overdue based on per-type cadence
|
||||
auditOverdue: (data, p, today = "2026-04-25") => {
|
||||
if (p.status !== "active") return false;
|
||||
const cfg = data.types.find(t => t.id === p.type);
|
||||
if (!cfg) return false;
|
||||
return window.DATA_HELPERS.daysSinceCheck(p, today) >= cfg.cadenceDays;
|
||||
},
|
||||
|
||||
// Estimated remaining (bulk: decay from last audit; discrete: countLastAudit)
|
||||
estimatedRemaining: (p, today = "2026-04-25") => {
|
||||
if (p.status !== "active") return 0;
|
||||
if (p.kind === "discrete") {
|
||||
return p.countLastAudit != null ? p.countLastAudit : p.countOriginal;
|
||||
}
|
||||
// Bulk: linear decay between last audit and average lifespan estimate
|
||||
const last = window.DATA_HELPERS.lastAudit(p);
|
||||
const baseDate = last ? last.date : p.purchaseDate;
|
||||
const baseValue = last ? last.value : p.weight;
|
||||
const daysSinceBase = Math.max(0, Math.floor((new Date(today) - new Date(baseDate)) / 86400000));
|
||||
// Decay rate: assume original weight is consumed over (purchase → expected lifespan ~ 35d for flower, 40 concentrate, 90 tincture)
|
||||
const expectedLifespan = p.type === "Flower" ? 35 : p.type === "Concentrate" ? 40 : 90;
|
||||
const dailyBurn = p.weight / expectedLifespan;
|
||||
const est = Math.max(0, baseValue - dailyBurn * daysSinceBase);
|
||||
return est;
|
||||
},
|
||||
|
||||
// Original-percent remaining (for low-stock on bulk)
|
||||
pctRemaining: (p, today = "2026-04-25") => {
|
||||
if (p.kind === "discrete") {
|
||||
const cur = p.countLastAudit != null ? p.countLastAudit : p.countOriginal;
|
||||
return p.countOriginal > 0 ? cur / p.countOriginal : 0;
|
||||
}
|
||||
const est = window.DATA_HELPERS.estimatedRemaining(p, today);
|
||||
return p.weight > 0 ? est / p.weight : 0;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user