import { parseDuration } from "./time.js?v=__ASSET_VERSION__"; // Coffee identity follows the inventory lot when available; otherwise the entered coffee name. export function coffeeKey(plan, id = "unassigned") { if (!plan?.inventory?.lotId && !String(plan?.fields?.["0.1"] || "").trim()) return `plan:${id}`; return plan?.inventory?.lotId ? `lot:${plan.inventory.lotId}` : `coffee:${String(plan?.fields?.["0.1"] || "Untitled coffee") .trim() .toLowerCase()}`; } export function buildWorkspace(plans, roasts, lots, cups, now = Date.now()) { const groups = new Map(); const groupFor = (key, name) => { if (!groups.has(key)) groups.set(key, { key, name, plans: [], roasts: [], cuppings: [], lot: null, }); return groups.get(key); }; for (const lot of lots) groupFor(`lot:${lot.id}`, lot.name || lot.origin).lot = { id: lot.id, name: lot.name || lot.origin, remainingG: Number(lot.remaining_weight_g), archived: lot.archived, }; const planGroups = new Map(); for (const row of plans) { const p = row.plan, g = groupFor( coffeeKey(p, row.id), p.fields?.["0.1"] || row.name || "Untitled coffee", ); planGroups.set(row.id, g); g.plans.push({ id: row.id, name: row.name || p.fields?.["0.1"] || "Untitled plan", level: p.fields?.["4.1"] || "", batchG: p.fields?.["0.4"] || "", experiment: p.experiment || {}, updatedAt: row.updated_at, }); } const unattached = []; for (const row of roasts) { const r = { id: row.id, planId: row.roast_plan_id, title: row.parsed?.roast?.title || row.filename, roastDate: row.parsed?.roast?.roastDate || row.created_at, createdAt: row.created_at, derived: row.parsed?.derived || {}, after: row.after || {}, weightLossPct: row.parsed?.roast?.weightLossPct, reviewStatus: row.evaluation_status, experiment: row.roast_experiment || null, }; const g = planGroups.get(r.planId); if (g) g.roasts.push(r); else unattached.push(r); } for (const c of cups) { const g = planGroups.get(c.roast_plan_id); if (g) g.cuppings.push({ id: c.id, planId: c.roast_plan_id, score: Number(c.total_score), date: c.created_at, }); } const coffees = [...groups.values()]; const actions = []; for (const g of coffees) { if (g.lot && !g.lot.archived && g.lot.remainingG < 500) actions.push({ kind: "inventory", title: `${g.name}: ${g.lot.remainingG} g left`, detail: "Below the 500 g reminder threshold.", href: "/inventory", }); for (const r of g.roasts) { const age = (now - new Date(r.roastDate).getTime()) / 86400000; if (age >= 3 && !r.after.tasteBalance && !r.after.cupNotes) actions.push({ kind: "taste", title: `Taste ${r.title}`, detail: "At least 3 days since roasting; taste when appropriate for your coffee.", href: `/roasts?roast=${r.id}`, }); const p = g.plans.find((p) => p.id === r.planId); const experiment = r.experiment ?? p?.experiment; if (experiment?.expected && !r.after.experimentResult) actions.push({ kind: "experiment", title: `Did the experiment work? ${r.title}`, detail: experiment.expected, href: `/roasts?roast=${r.id}`, }); } } for (const r of unattached) actions.push({ kind: "attach", title: `Attach a plan to ${r.title}`, detail: "Connect its roast and tasting history to a coffee.", href: `/roasts?roast=${r.id}`, }); return { coffees, unattached, actions }; } export const SUGGESTION_FIELDS = { 4.1: "Roast level", 4.2: "Weight-loss target (%)", 4.4: "Development override", 1.4: "First-crack anchor", 1.5: "Phase profile", 1.6: "First-crack adjustment (seconds)", }; export function cleanSuggestions(raw) { if (!Array.isArray(raw)) return []; return raw.slice(0, 3).flatMap((s) => { if (!s || typeof s !== "object") return []; const fields = {}; for (const [key, value] of Object.entries(s.fields || {})) { if ( !Object.hasOwn(SUGGESTION_FIELDS, key) || typeof value !== "string" || value.length > 100 ) continue; if ( key === "4.1" && !["light", "light-medium", "medium", "dark", "very-dark"].includes( value, ) ) continue; if (["4.4", "1.4"].includes(key)) { const seconds = parseDuration(value); if (seconds === null || seconds <= 0 || seconds > 1800) continue; } if ( key === "1.5" && (value.split("|").length !== 3 || value.split("|").some((v) => { const t = parseDuration(v); return t === null || t <= 0 || t > 1800; })) ) continue; if ( key === "4.2" && (!Number.isFinite(Number(value)) || Number(value) < 5 || Number(value) > 30) ) continue; if ( key === "1.6" && (!Number.isFinite(Number(value)) || Math.abs(Number(value)) > 180) ) continue; fields[key] = value; } return Object.keys(fields).length ? [ { fields, reason: String(s.reason || "").slice(0, 2000), expected: String(s.expected || "").slice(0, 2000), evidenceRoastIds: Array.isArray(s.evidenceRoastIds) ? s.evidenceRoastIds .filter((v) => typeof v === "string") .slice(0, 10) : [], }, ] : []; }); }