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
+155
View File
@@ -0,0 +1,155 @@
export type ProductStatus = "active" | "consumed" | "gone";
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;
}
export interface Product {
id: string;
sku: string;
assetTag: string | null;
name: string;
brandId: string | null;
shopId: string | null;
binId: string | null;
type: string;
kind: ProductKind;
weight: number;
lastAuditWeight: number | null;
countOriginal: number;
countLastAudit: number | null;
unitWeight: number;
price: number;
thc: number;
cbd: number;
totalCannabinoids: number;
purchaseDate: string;
status: ProductStatus;
consumedDate: string | null;
goneDate: string | null;
rating: number | null;
notes: string | null;
strainId: string | null;
audits: Audit[];
}
export interface Strain {
id: string;
name: string;
brandId: string | null;
type: 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;
location: string | null;
capacity: number;
}
export interface TypeConfig {
id: string;
kind: ProductKind;
auditMode: AuditMode;
cadenceDays: number;
unit: string;
weighable: boolean;
}
export interface Bootstrap {
products: Product[];
brands: Brand[];
shops: Shop[];
bins: Bin[];
strains: Strain[];
today: string;
}
// Type config lives client-side — static, not user data.
// Mirrors data.js TYPES array.
export const TYPES: TypeConfig[] = [
{ 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 },
];
export const TODAY_STR = "2026-04-25";
// Helpers — match data.js DATA_HELPERS API
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]!;
},
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: Product): Audit | null {
return p.audits.length > 0 ? p.audits[p.audits.length - 1]! : null;
},
daysSinceCheck(p: Product, 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: Product, today = TODAY_STR): boolean {
if (p.status !== "active") return false;
const cfg = TYPES.find((t) => t.id === p.type);
if (!cfg) return false;
return this.daysSinceCheck(p, today) >= cfg.cadenceDays;
},
estimatedRemaining(p: Product, today = TODAY_STR): number {
if (p.status !== "active") return 0;
if (p.kind === "discrete") {
return p.countLastAudit ?? p.countOriginal;
}
const last = this.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)) / 86_400_000),
);
const expectedLifespan =
p.type === "Flower" ? 35 : p.type === "Concentrate" ? 40 : 90;
const dailyBurn = p.weight / expectedLifespan;
return Math.max(0, baseValue - dailyBurn * daysSinceBase);
},
pctRemaining(p: Product, today = TODAY_STR): number {
if (p.kind === "discrete") {
const cur = p.countLastAudit ?? p.countOriginal;
return p.countOriginal > 0 ? cur / p.countOriginal : 0;
}
const est = this.estimatedRemaining(p, today);
return p.weight > 0 ? est / p.weight : 0;
},
};