Fix desktop header overflow; add plan attachment on roast detail
Test and deploy / test-and-deploy (push) Successful in 55s
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:
co-authored by
Claude Fable 5
parent
4a3d192c2c
commit
c6c392730f
+20
-9
@@ -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
@@ -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
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user