diff --git a/public/app.css b/public/app.css index abb551d..9d830bc 100644 --- a/public/app.css +++ b/public/app.css @@ -76,13 +76,20 @@ output { position: sticky; top: 0; z-index: 30; - height: var(--header-h); + min-height: var(--header-h); display: flex; + flex-wrap: wrap; align-items: center; - gap: 24px; + gap: 8px 24px; background: var(--surface-raised); border-bottom: 1px solid var(--line); - padding: 0 20px; + padding: 8px 20px; +} +/* The planner header is two rows tall (brand + tools, then the section pills) — its sticky + offsets (dock, anchor scroll margins) must account for that. :has() scopes it to the one + page that has the plan form. */ +body:has(#plan-form) { + --header-h: 112px; } .header-row-top { display: contents; @@ -109,14 +116,15 @@ output { color: var(--ink-3); } -/* No overflow scrolling: below 1180px this row already sits on its own full-width line, and - at ≤720px it becomes a fixed five-column grid — a hidden horizontal scroll region in a - header is undiscoverable, especially on touch. */ +/* Always its own full-width row under the brand/actions — never squeezed beside them, never + an overflow scroller (a hidden horizontal scroll region in a header is undiscoverable, + especially on touch). At ≤720px it becomes a fixed four-column grid. */ .section-nav { display: flex; align-items: center; gap: 2px; - flex: 1 1 auto; + order: 3; + flex-basis: 100%; flex-wrap: wrap; } .section-nav a { @@ -145,8 +153,11 @@ output { .header-actions { display: flex; align-items: center; - gap: 8px; - flex: 0 0 auto; + gap: 6px 8px; + flex: 1 1 auto; + flex-wrap: wrap; + justify-content: flex-end; + min-width: 0; } .header-divider { width: 1px; diff --git a/public/js/roasts.js b/public/js/roasts.js index 90ad52b..72a7f29 100644 --- a/public/js/roasts.js +++ b/public/js/roasts.js @@ -9,6 +9,7 @@ let openId = null; let pollTimer = null; let machineProfile = null; let roasterList = []; +let planList = []; const SVG_NS = "http://www.w3.org/2000/svg"; @@ -316,11 +317,50 @@ async function openDetail(id, { keepScroll = false } = {}) { meta.textContent = [ detail.roast?.roastDate || fmtDate(detail.createdAt), detail.roast?.roasterType, - detail.planTitle ? `Plan: ${detail.planTitle}` : "No plan attached", detail.filename, ] .filter(Boolean) .join(" · "); + // Plan attachment: link this roast to one of your plans (enables plan-vs-actual in the + // review, the refine handoff, and the plan overlay on the graph). + if (planList.length) { + const planWrap = document.createElement("span"); + planWrap.append(" · Plan: "); + const planSelect = document.createElement("select"); + planSelect.className = "field-input sm"; + planSelect.style.display = "inline-block"; + planSelect.style.width = "auto"; + planSelect.append( + Object.assign(document.createElement("option"), { + value: "", + textContent: "— none —", + }), + ...planList.map((plan) => + Object.assign(document.createElement("option"), { + value: plan.id, + textContent: plan.plan?.fields?.["0.1"] || "Untitled plan", + }), + ), + ); + planSelect.value = detail.roastPlanId ?? ""; + planSelect.addEventListener("change", async () => { + try { + await api(`/api/roasts/${detail.id}`, { + method: "PUT", + body: JSON.stringify({ roastPlanId: planSelect.value || null }), + }); + showToast("Plan attached — re-evaluate to review against its targets."); + await loadRoasts(); + await openDetail(detail.id, { keepScroll: true }); + } catch (error) { + showToast(error.message, "fail"); + } + }); + planWrap.append(planSelect); + meta.append(planWrap); + } else if (detail.planTitle) { + meta.append(` · Plan: ${detail.planTitle}`); + } // Machine assignment: which of the user's roasters this roast trains. if (roasterList.length) { const wrap = document.createElement("span"); @@ -501,6 +541,11 @@ async function init() { } catch { roasterList = []; } + try { + planList = (await api("/api/plans")).plans; + } catch { + planList = []; + } await loadRoasts(); const requested = new URLSearchParams(location.search).get("roast"); if (requested && roasts.some((roast) => roast.id === requested)) diff --git a/server/app.js b/server/app.js index 179a1f1..25df5a0 100644 --- a/server/app.js +++ b/server/app.js @@ -1750,6 +1750,24 @@ export function createApp({ params.push(roasterId); sets.push(`roaster_id=$${params.length}`); } + if (req.body.roastPlanId !== undefined) { + let roastPlanId = null; + if (req.body.roastPlanId !== null && req.body.roastPlanId !== "") { + if (!UUID_RE.test(req.body.roastPlanId)) + return res.status(404).json({ ok: false, code: "not_found" }); + const owns = ( + await db.query( + "SELECT 1 FROM roast_plans WHERE id=$1 AND user_id=$2", + [req.body.roastPlanId, req.user.id], + ) + ).rowCount; + if (!owns) + return res.status(404).json({ ok: false, code: "not_found" }); + roastPlanId = req.body.roastPlanId; + } + params.push(roastPlanId); + sets.push(`roast_plan_id=$${params.length}`); + } if (req.body.after !== undefined) { const after = sanitizeAfter(req.body.after); if (!after) @@ -1761,7 +1779,7 @@ export function createApp({ return res.status(400).json({ ok: false, code: "bad_request" }); params.push(req.params.id, req.user.id); const r = await db.query( - `UPDATE actual_roasts SET ${sets.join(", ")}, updated_at=now() WHERE id=$${params.length - 1} AND user_id=$${params.length} RETURNING roaster_id, after`, + `UPDATE actual_roasts SET ${sets.join(", ")}, updated_at=now() WHERE id=$${params.length - 1} AND user_id=$${params.length} RETURNING roaster_id, roast_plan_id, after`, params, ); if (!r.rowCount) @@ -1769,6 +1787,7 @@ export function createApp({ res.json({ ok: true, roasterId: r.rows[0].roaster_id, + roastPlanId: r.rows[0].roast_plan_id, after: r.rows[0].after, }); } catch (e) {