export type ProductStatus = "active" | "consumed" | "gone" | "checked-out"; export type ProductKind = "bulk" | "discrete"; export type AuditMode = "weigh" | "estimate" | "presence"; export interface Audit { date: string; mode: AuditMode; value: number; prev: number | null; confirmedBy: string | null; } // Product = catalog entry. Identified by SKU. One row per "kind of thing // you can scan" — strain + brand + form factor (Flower/bulk, Pre-roll/discrete). // Display name comes from the linked strain (strainId is required). export interface Product { id: string; sku: string; strainId: string; brandId: string | null; type: string; kind: ProductKind; createdAt: string; } // InventoryItem = a physical jar/pack you bought. Has a 6-digit asset id // (printed on a roll of labels the user owns). Carries per-batch values: // shop, bin, price, cannabinoids, weight/count, lifecycle, audits. Brand // lives on the product, not here. export interface InventoryItem { id: string; assetId: string; productId: string; shopId: string | null; binId: string | null; price: number; thc: number; cbd: number; totalCannabinoids: number; weight: number; containerWeight: number | null; lastAuditWeight: number | null; countOriginal: number; countLastAudit: number | null; unitWeight: number; purchaseDate: string; status: ProductStatus; consumedDate: string | null; goneDate: string | null; checkoutDate: string | null; prevBinId: string | null; rating: number | null; notes: string | null; audits: Audit[]; } // Item = InventoryItem with its product's catalog fields denormalized in. // Built once from bootstrap (`enrichItems`) so views can access `name`, // `sku`, `type`, `kind`, `brandId` without a per-row lookup. The display // `name` is the strain's name. This is the shape the UI and helpers operate on. export interface Item extends InventoryItem { name: string; sku: string; type: string; kind: ProductKind; brandId: string | null; strainId: string; } export interface Strain { id: string; name: string; defaultThc: number | null; defaultCbd: number | null; defaultTotalCannabinoids: number | null; notes: string | null; } export interface Brand { id: string; name: string; } export interface Shop { id: string; name: string; location: string | null; } export interface Bin { id: string; name: string; capacity: number; cadenceDays: number; lastChecked: string | null; } export interface TypeConfig { id: string; kind: ProductKind; auditMode: AuditMode; cadenceDays: number; unit: string; weighable: boolean; weightUnit: string; showCannabinoidPct: boolean; } export interface Bootstrap { products: Product[]; inventoryItems: InventoryItem[]; brands: Brand[]; shops: Shop[]; bins: Bin[]; strains: Strain[]; today: string; } // Type config lives client-side — static, not user data. export const TYPES: TypeConfig[] = [ { id: "Flower", kind: "bulk", auditMode: "weigh", cadenceDays: 14, unit: "g", weighable: true, weightUnit: "g", showCannabinoidPct: true }, { id: "Concentrate", kind: "bulk", auditMode: "estimate", cadenceDays: 21, unit: "g", weighable: true, weightUnit: "g", showCannabinoidPct: true }, { id: "Tincture", kind: "bulk", auditMode: "estimate", cadenceDays: 30, unit: "ml", weighable: false, weightUnit: "ml", showCannabinoidPct: true }, { id: "Pre-roll", kind: "discrete", auditMode: "presence", cadenceDays: 30, unit: "ct", weighable: false, weightUnit: "g", showCannabinoidPct: true }, { id: "Edible", kind: "discrete", auditMode: "presence", cadenceDays: 60, unit: "ct", weighable: false, weightUnit: "mg", showCannabinoidPct: false }, { id: "Vaporizer", kind: "discrete", auditMode: "presence", cadenceDays: 30, unit: "ct", weighable: false, weightUnit: "g", showCannabinoidPct: true }, ]; // User-supplied 6-digit asset ids are printed on a roll of physical tags. export const ASSET_ID_RE = /^\d{6}$/; import { getToday, getBrowserTimezone } from "./tz.js"; export { getToday } from "./tz.js"; export const TODAY_STR = getToday(getBrowserTimezone()); // Build the joined Item[] view from bootstrap. Inventory items are dropped // silently if they reference a missing product — that shouldn't happen in // practice (server enforces the FK) and skipping is safer than crashing. export function enrichItems(data: Bootstrap): Item[] { const productMap = new Map(data.products.map((p) => [p.id, p])); const strainMap = new Map(data.strains.map((s) => [s.id, s])); const out: Item[] = []; for (const inv of data.inventoryItems) { const product = productMap.get(inv.productId); if (!product) continue; const strain = strainMap.get(product.strainId); out.push({ ...inv, name: strain?.name ?? "(unknown strain)", sku: product.sku, type: product.type, kind: product.kind, brandId: product.brandId, strainId: product.strainId, }); } return out; } // Find the most recent inventory item for a product — used to autofill brand, // shop, price, and cannabinoids when scanning a SKU we've bought before. // Sorted by purchaseDate desc, then id desc as tiebreaker. export function getLastInstance( items: InventoryItem[], productId: string, ): InventoryItem | null { const matches = items .filter((i) => i.productId === productId) .sort((a, b) => { const d = +new Date(b.purchaseDate) - +new Date(a.purchaseDate); if (d !== 0) return d; return b.id.localeCompare(a.id); }); return matches[0] ?? null; } // Helpers — match data.js DATA_HELPERS API. Operate on Item (the joined // inventory + product view) so views and stats can call them as `helpers.x(p)`. export const helpers = { shopName(data: { shops: Shop[] }, id: string | null): string { return data.shops.find((s) => s.id === id)?.name ?? "—"; }, brandName(data: { brands: Brand[] }, id: string | null): string { return data.brands.find((b) => b.id === id)?.name ?? "—"; }, typeConfig(id: string): TypeConfig { return TYPES.find((t) => t.id === id) ?? TYPES[0]!; }, tare(item: { containerWeight: number | null; weight: number }): number | null { if (item.containerWeight == null) return null; return item.containerWeight - item.weight; }, daysSince(iso: string | null, today = TODAY_STR): number { if (!iso) return Infinity; return Math.floor((+new Date(today) - +new Date(iso)) / 86_400_000); }, lastAudit(p: Item): Audit | null { return p.audits.length > 0 ? p.audits[p.audits.length - 1]! : null; }, daysSinceCheck(p: Item, today = TODAY_STR): number { const last = p.audits.length > 0 ? p.audits[p.audits.length - 1]!.date : p.purchaseDate; return Math.floor((+new Date(today) - +new Date(last)) / 86_400_000); }, auditOverdue(p: Item, today = TODAY_STR): boolean { if (p.status !== "active" && p.status !== "checked-out") return false; const cfg = TYPES.find((t) => t.id === p.type); if (!cfg || cfg.kind === "discrete") return false; return this.daysSinceCheck(p, today) >= cfg.cadenceDays; }, binCheckOverdue(bin: Bin, today = TODAY_STR): boolean { return this.daysSinceBinCheck(bin, today) >= bin.cadenceDays; }, daysSinceBinCheck(bin: Bin, today = TODAY_STR): number { return this.daysSince(bin.lastChecked, today); }, remaining(p: Item): number { if (p.status !== "active" && p.status !== "checked-out") return 0; if (p.kind === "discrete") { return p.countLastAudit ?? p.countOriginal; } const last = this.lastAudit(p); return last ? last.value : p.weight; }, pctRemaining(p: Item): number { if (p.kind === "discrete") { const cur = p.countLastAudit ?? p.countOriginal; return p.countOriginal > 0 ? cur / p.countOriginal : 0; } const rem = this.remaining(p); return p.weight > 0 ? rem / p.weight : 0; }, };