Build coffee history and experiment workflows with Ask Bean Bro
Test and deploy / test-and-deploy (push) Successful in 1m21s

This commit is contained in:
snowspeeder committed 2026-09-06 18:43:33 -04:00
1 parent 1fd53091ac
commit 8c21750356
28 files changed
+3848 -384

No files matched your search

+187
View File
@@ -0,0 +1,187 @@
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)
: [],
},
]
: [];
});
}