Fix desktop header overflow; add plan attachment on roast detail
Test and deploy / test-and-deploy (push) Successful in 55s

- The app header now wraps: brand + actions on the first row (actions
  shrink and wrap instead of pushing Print off-screen), section pills
  always on their own full-width row; the planner's sticky offsets use
  a two-row --header-h via body:has(#plan-form)
- Roast detail gains a Plan selector (attach/detach any of your plans)
  alongside the Machine selector; PUT /api/roasts/:id accepts
  roastPlanId with ownership checks

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Shane Maynard
2026-08-09 08:47:13 -04:00
co-authored by Claude Fable 5
parent 4a3d192c2c
commit c6c392730f
3 changed files with 86 additions and 11 deletions
+20 -9
View File
@@ -76,13 +76,20 @@ output {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 30; z-index: 30;
height: var(--header-h); min-height: var(--header-h);
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 24px; gap: 8px 24px;
background: var(--surface-raised); background: var(--surface-raised);
border-bottom: 1px solid var(--line); 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 { .header-row-top {
display: contents; display: contents;
@@ -109,14 +116,15 @@ output {
color: var(--ink-3); color: var(--ink-3);
} }
/* No overflow scrolling: below 1180px this row already sits on its own full-width line, and /* Always its own full-width row under the brand/actions — never squeezed beside them, never
at ≤720px it becomes a fixed five-column grid — a hidden horizontal scroll region in a an overflow scroller (a hidden horizontal scroll region in a header is undiscoverable,
header is undiscoverable, especially on touch. */ especially on touch). At ≤720px it becomes a fixed four-column grid. */
.section-nav { .section-nav {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 2px; gap: 2px;
flex: 1 1 auto; order: 3;
flex-basis: 100%;
flex-wrap: wrap; flex-wrap: wrap;
} }
.section-nav a { .section-nav a {
@@ -145,8 +153,11 @@ output {
.header-actions { .header-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 6px 8px;
flex: 0 0 auto; flex: 1 1 auto;
flex-wrap: wrap;
justify-content: flex-end;
min-width: 0;
} }
.header-divider { .header-divider {
width: 1px; width: 1px;
+46 -1
View File
@@ -9,6 +9,7 @@ let openId = null;
let pollTimer = null; let pollTimer = null;
let machineProfile = null; let machineProfile = null;
let roasterList = []; let roasterList = [];
let planList = [];
const SVG_NS = "http://www.w3.org/2000/svg"; const SVG_NS = "http://www.w3.org/2000/svg";
@@ -316,11 +317,50 @@ async function openDetail(id, { keepScroll = false } = {}) {
meta.textContent = [ meta.textContent = [
detail.roast?.roastDate || fmtDate(detail.createdAt), detail.roast?.roastDate || fmtDate(detail.createdAt),
detail.roast?.roasterType, detail.roast?.roasterType,
detail.planTitle ? `Plan: ${detail.planTitle}` : "No plan attached",
detail.filename, detail.filename,
] ]
.filter(Boolean) .filter(Boolean)
.join(" · "); .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. // Machine assignment: which of the user's roasters this roast trains.
if (roasterList.length) { if (roasterList.length) {
const wrap = document.createElement("span"); const wrap = document.createElement("span");
@@ -501,6 +541,11 @@ async function init() {
} catch { } catch {
roasterList = []; roasterList = [];
} }
try {
planList = (await api("/api/plans")).plans;
} catch {
planList = [];
}
await loadRoasts(); await loadRoasts();
const requested = new URLSearchParams(location.search).get("roast"); const requested = new URLSearchParams(location.search).get("roast");
if (requested && roasts.some((roast) => roast.id === requested)) if (requested && roasts.some((roast) => roast.id === requested))
+20 -1
View File
@@ -1750,6 +1750,24 @@ export function createApp({
params.push(roasterId); params.push(roasterId);
sets.push(`roaster_id=$${params.length}`); 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) { if (req.body.after !== undefined) {
const after = sanitizeAfter(req.body.after); const after = sanitizeAfter(req.body.after);
if (!after) if (!after)
@@ -1761,7 +1779,7 @@ export function createApp({
return res.status(400).json({ ok: false, code: "bad_request" }); return res.status(400).json({ ok: false, code: "bad_request" });
params.push(req.params.id, req.user.id); params.push(req.params.id, req.user.id);
const r = await db.query( 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, params,
); );
if (!r.rowCount) if (!r.rowCount)
@@ -1769,6 +1787,7 @@ export function createApp({
res.json({ res.json({
ok: true, ok: true,
roasterId: r.rows[0].roaster_id, roasterId: r.rows[0].roaster_id,
roastPlanId: r.rows[0].roast_plan_id,
after: r.rows[0].after, after: r.rows[0].after,
}); });
} catch (e) { } catch (e) {