188 lines
5.1 KiB
JavaScript
188 lines
5.1 KiB
JavaScript
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)
|
|
: [],
|
|
},
|
|
]
|
|
: [];
|
|
});
|
|
}
|