Ground Bean Bro in displayed temperatures, stream replies, and add activity calendar
Test and deploy / test-and-deploy (push) Successful in 1m19s
Test and deploy / test-and-deploy (push) Successful in 1m19s
This commit is contained in:
1 parent
a72aec9f86
commit
d96ed22de1
16 files changed
+551
-484
No files matched your search
@@ -4428,3 +4428,38 @@ select.f {
|
||||
#delete-plan-description { line-height: 1.6; }
|
||||
#delete-plan-error { color: #a32920; }
|
||||
.delete-plan-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px; }
|
||||
|
||||
.calendar-shortcuts,.calendar-toolbar { display:flex; align-items:center; flex-wrap:wrap; gap:12px; margin-bottom:22px; }
|
||||
.calendar-toolbar h2 { margin:0; min-width:190px; text-align:center; font-size:23px; }
|
||||
.calendar-toolbar select { margin-left:auto; width:auto; }
|
||||
.activity-calendar { display:grid; grid-template-columns:repeat(7,minmax(0,1fr)); border:1px solid var(--line); border-radius:12px; overflow:hidden; background:#fff; }
|
||||
.calendar-weekday { padding:12px; font-size:12px; color:var(--muted); background:#f5f1eb; }
|
||||
.calendar-day { min-height:125px; padding:9px; border-top:1px solid var(--line); border-right:1px solid var(--line); min-width:0; }
|
||||
.calendar-day h3 { margin:0 0 9px; font-size:13px; }
|
||||
.calendar-padding { background:#faf8f5; }
|
||||
.calendar-today { box-shadow:inset 0 0 0 2px var(--accent); }
|
||||
.calendar-event { display:block; padding:5px 6px; margin-bottom:5px; border-radius:5px; font-size:12px; text-decoration:none; overflow-wrap:anywhere; line-height:1.4; }
|
||||
.event-roast { background:#f8e6d7; color:#793b15; }
|
||||
.event-cupping { background:#e9e5f6; color:#504078; }
|
||||
.event-brew { background:#deeee7; color:#245940; }
|
||||
.event-plan { background:#e6ebf0; color:#384e64; }
|
||||
.calendar-agenda { margin-top:28px; }
|
||||
.calendar-agenda h3 { font-size:14px; margin-top:24px; }
|
||||
.calendar-log-row { display:flex; align-items:center; gap:12px; padding:13px 0; border-bottom:1px solid var(--line); }
|
||||
.calendar-log-row a { overflow-wrap:anywhere; }
|
||||
.calendar-kind { flex-shrink:0; padding:4px 8px; border-radius:5px; font-size:12px; }
|
||||
@media(max-width:700px) {
|
||||
.activity-calendar { display:none; }
|
||||
.calendar-toolbar { gap:8px; }
|
||||
.calendar-toolbar h2 { min-width:0; flex:1; font-size:18px; }
|
||||
.calendar-toolbar select { max-width:55%; }
|
||||
.calendar-log-row { flex-wrap:wrap; }
|
||||
.calendar-log-row > a:first-of-type { flex:1; min-width:160px; }
|
||||
}
|
||||
|
||||
@media(max-width:700px) {
|
||||
.calendar-toolbar { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); }
|
||||
.calendar-toolbar h2 { grid-column:1 / -1; grid-row:1; padding:6px 0; }
|
||||
.calendar-toolbar button { padding-left:8px; padding-right:8px; }
|
||||
.calendar-toolbar select { grid-column:1 / -1; width:100%; max-width:none; }
|
||||
}
|
||||
@@ -33,3 +33,51 @@ export async function api(url, options = {}) {
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
// Fetch-based SSE keeps the existing cookie authentication and CSRF protection.
|
||||
export async function streamPlanChat(body, onText) {
|
||||
const response = await protectedFetch("/api/plan-chat", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", accept: "text/event-stream" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
const error = new Error(data.error || data.code || "Request failed");
|
||||
error.code = data.code;
|
||||
if (response.status === 401 && data.code === "unauthorized") location.assign("/login");
|
||||
throw error;
|
||||
}
|
||||
// Supports an older server during rolling deployment.
|
||||
if (!response.headers.get("content-type")?.includes("text/event-stream"))
|
||||
return response.json();
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
buffer += decoder.decode(value, { stream: !done });
|
||||
let end;
|
||||
while ((end = buffer.indexOf("\n\n")) !== -1) {
|
||||
const frame = buffer.slice(0, end);
|
||||
buffer = buffer.slice(end + 2);
|
||||
const event = frame.match(/^event: (.+)$/m)?.[1];
|
||||
const raw = frame.match(/^data: (.+)$/m)?.[1];
|
||||
if (!raw) continue;
|
||||
const data = JSON.parse(raw);
|
||||
if (event === "reply") onText(data.text);
|
||||
if (event === "done") return data;
|
||||
if (event === "error") {
|
||||
const error = new Error(data.error || "Reply interrupted");
|
||||
error.code = data.code;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (done) throw new Error("Connection interrupted before Bean Bro finished. Please retry.");
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {});
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
@@ -429,6 +429,8 @@ async function init() {
|
||||
updateRatingPill();
|
||||
await Promise.all([loadBeans(), loadBrews()]);
|
||||
const params = new URLSearchParams(location.search);
|
||||
const requestedBrew = brews.find(b => b.id === params.get("brew"));
|
||||
if (requestedBrew) startEdit(requestedBrew);
|
||||
const requestedBean = params.get("bean");
|
||||
if (requestedBean && beans.some((b) => b.id === requestedBean)) {
|
||||
if (params.get("new")) {
|
||||
|
||||
+1
-2
@@ -8,8 +8,7 @@ const NAV_GROUPS = [
|
||||
{
|
||||
title: "Roasting",
|
||||
items: [
|
||||
{ href: "/home", icon: "⌂", label: "Today" },
|
||||
{ href: "/coffees", icon: "◉", label: "Coffee history" },
|
||||
{ href: "/home", icon: "⌂", label: "Calendar" },
|
||||
{ href: "/plans", icon: "◐", label: "Plans" },
|
||||
{ href: "/roasts", icon: "∿", label: "Roasts" },
|
||||
{ href: "/roasters", icon: "♨", label: "Machines" },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { api } from "./api.js?v=__ASSET_VERSION__";
|
||||
import { api, streamPlanChat } from "./api.js?v=__ASSET_VERSION__";
|
||||
import {
|
||||
SUGGESTION_FIELDS,
|
||||
cleanSuggestions,
|
||||
@@ -232,11 +232,22 @@ export function initPlanChat({
|
||||
);
|
||||
thread.append(pending);
|
||||
const sentFields = structuredClone(getPlan().fields);
|
||||
memory().suggestions = [];
|
||||
showSuggestions([]);
|
||||
recompute();
|
||||
thread.setAttribute("aria-busy", "true");
|
||||
let partial = "";
|
||||
try {
|
||||
const result = await api("/api/plan-chat", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ plan: getPlan(), messages: memory().messages }),
|
||||
});
|
||||
const result = await streamPlanChat(
|
||||
{ plan: getPlan(), messages: memory().messages },
|
||||
(text) => {
|
||||
const nearBottom = thread.scrollHeight - thread.scrollTop - thread.clientHeight < 80;
|
||||
partial = text;
|
||||
pending.textContent = text;
|
||||
pending.classList.remove("pending");
|
||||
if (nearBottom) thread.scrollTop = thread.scrollHeight;
|
||||
},
|
||||
);
|
||||
memory().messages.push({ role: "assistant", content: result.reply });
|
||||
memory().messages = memory().messages.slice(-30);
|
||||
memory().suggestionBase = sentFields;
|
||||
@@ -246,14 +257,16 @@ export function initPlanChat({
|
||||
showSuggestions(memory().suggestions);
|
||||
recompute();
|
||||
} catch (error) {
|
||||
pending.textContent =
|
||||
error.code === "no_model"
|
||||
pending.classList.remove("pending");
|
||||
pending.textContent = (partial ? partial + "\n\nResponse interrupted. " : "") +
|
||||
(error.code === "no_model"
|
||||
? "Bean Bro is not connected. An admin can configure the provider in Admin → Bean Bro."
|
||||
: `Could not get a reply. ${error.message}`;
|
||||
: `Could not get a reply. ${error.message}`);
|
||||
memory().messages.pop();
|
||||
input.value = text;
|
||||
recompute();
|
||||
} finally {
|
||||
thread.setAttribute("aria-busy", "false");
|
||||
busy = false;
|
||||
send.disabled = false;
|
||||
}
|
||||
|
||||
+84
-413
@@ -1,425 +1,96 @@
|
||||
import { api, protectedFetch } from "./api.js?v=__ASSET_VERSION__";
|
||||
import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__";
|
||||
const root = document.getElementById("workspace-content");
|
||||
const n = (tag, text, cls = "") => {
|
||||
const e = document.createElement(tag);
|
||||
e.textContent = text;
|
||||
e.className = cls;
|
||||
return e;
|
||||
};
|
||||
const link = (text, href, cls = "") => {
|
||||
const e = n("a", text, cls);
|
||||
e.href = href;
|
||||
return e;
|
||||
};
|
||||
const time = (s) =>
|
||||
s == null
|
||||
? "—"
|
||||
: `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, "0")}`;
|
||||
const feedback = (r) =>
|
||||
[
|
||||
r.after.favorite === "yes" ? "★ Your favorite" : "",
|
||||
r.after.tasteBalance,
|
||||
r.after.sweetness ? `Sweetness ${r.after.sweetness}/5` : "",
|
||||
r.after.cupNotes,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || "Not tasted yet";
|
||||
let data;
|
||||
function coffeeCards(coffees) {
|
||||
const grid = n("div", "", "coffee-grid");
|
||||
for (const c of coffees) {
|
||||
const card = n("article", "", "coffee-card");
|
||||
card.append(
|
||||
n("span", "COFFEE HISTORY", "eyebrow"),
|
||||
link(
|
||||
c.name,
|
||||
"/coffees?coffee=" + encodeURIComponent(c.key),
|
||||
"coffee-name",
|
||||
),
|
||||
n(
|
||||
"p",
|
||||
`${c.plans.length} plans · ${c.roasts.length} roasts${c.lot ? ` · ${c.lot.remainingG} g remaining` : ""}`,
|
||||
"muted",
|
||||
),
|
||||
);
|
||||
const best = c.roasts.find((r) => r.after.favorite === "yes");
|
||||
card.append(
|
||||
n(
|
||||
"p",
|
||||
best
|
||||
? `Your pick: ${best.title}`
|
||||
: "Taste a batch and mark your favorite.",
|
||||
),
|
||||
);
|
||||
grid.append(card);
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
const n = (tag, text = "", cls = "") => Object.assign(document.createElement(tag), {textContent:text,className:cls});
|
||||
const link = (text, href, cls = "") => Object.assign(n("a",text,cls), {href});
|
||||
const now = new Date();
|
||||
const initial = new URLSearchParams(location.search).get("month");
|
||||
let month = /^20\d{2}-(0[1-9]|1[0-2])$/.test(initial || "") ? initial : `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,"0")}`;
|
||||
let events = [], generation = 0;
|
||||
const labels = {roast:"Roasted",cupping:"Cupped",brew:"Brewed",plan:"Plan created"};
|
||||
const toolbar = n("div","","calendar-toolbar");
|
||||
const previous = n("button","← Previous","ghost-btn"), next = n("button","Next →","ghost-btn"), current = n("button","This month","ghost-btn");
|
||||
const heading = n("h2");
|
||||
const filter = n("select","","field-input");
|
||||
filter.setAttribute("aria-label","Activity type");
|
||||
for (const [value,label] of [["","All activity"],...Object.entries(labels)]) filter.append(Object.assign(n("option",label),{value}));
|
||||
toolbar.append(previous,heading,next,current,filter);
|
||||
const status = n("p","","muted"); status.setAttribute("role","status");
|
||||
const content = n("div");
|
||||
const shortcuts = n("div","","calendar-shortcuts");
|
||||
shortcuts.append(link("Plan a roast","/plans","primary-btn"),link("Log a roast","/roasts","ghost-btn"),link("Cup coffee","/cupping","ghost-btn"),link("Log a brew","/brews","ghost-btn"));
|
||||
root.replaceChildren(shortcuts,toolbar,status,content);
|
||||
function render() {
|
||||
root.replaceChildren();
|
||||
const selected = new URLSearchParams(location.search).get("coffee");
|
||||
const coffee = data.coffees.find((c) => c.key === selected);
|
||||
if (coffee) {
|
||||
renderCoffee(coffee);
|
||||
return;
|
||||
}
|
||||
const home = location.pathname === "/home";
|
||||
document.getElementById("workspace-title").textContent = home
|
||||
? "Your next good roast"
|
||||
: "Your coffees";
|
||||
document.getElementById("workspace-subtitle").textContent = home
|
||||
? "Plan, roast, taste, and learn from the next batch."
|
||||
: "The plans, experiments and cups behind each coffee.";
|
||||
if (home) {
|
||||
const panel = n("section", "", "panel-card next-actions");
|
||||
panel.append(n("h2", "Next actions"));
|
||||
if (!data.actions.length)
|
||||
panel.append(
|
||||
n("p", "All caught up. Choose a coffee or start a new plan."),
|
||||
);
|
||||
for (const a of data.actions.slice(0, 12)) {
|
||||
const row = n("div", "", "next-action");
|
||||
row.append(link(a.title, a.href), n("p", a.detail, "muted"));
|
||||
panel.append(row);
|
||||
const selected = events.filter(e=>!filter.value || e.kind===filter.value);
|
||||
status.textContent = `${selected.length} ${selected.length===1?"activity":"activities"} this month`;
|
||||
content.replaceChildren();
|
||||
const [year,m] = month.split("-").map(Number);
|
||||
heading.textContent = new Date(year,m-1,1).toLocaleDateString(undefined,{month:"long",year:"numeric"});
|
||||
const grid = n("div","","activity-calendar");
|
||||
for (const day of ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"]) grid.append(n("div",day,"calendar-weekday"));
|
||||
const offset = new Date(year,m-1,1).getDay(), count = new Date(year,m,0).getDate();
|
||||
for(let i=0;i<offset;i++) grid.append(n("div","","calendar-day calendar-padding"));
|
||||
const today = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,"0")}-${String(now.getDate()).padStart(2,"0")}`;
|
||||
for(let day=1;day<=count;day++) {
|
||||
const key = month+"-"+String(day).padStart(2,"0");
|
||||
const cell = n("section","","calendar-day"+(key===today?" calendar-today":""));
|
||||
const title = n("h3",String(day));
|
||||
title.setAttribute("aria-label",new Date(year,m-1,day).toLocaleDateString(undefined,{dateStyle:"full"}));
|
||||
cell.append(title);
|
||||
for(const event of selected.filter(e=>e.date===key)) {
|
||||
const a = link(labels[event.kind]+" · "+event.title,event.href,"calendar-event event-"+event.kind);
|
||||
a.title = a.textContent;
|
||||
cell.append(a);
|
||||
}
|
||||
root.append(panel);
|
||||
grid.append(cell);
|
||||
}
|
||||
const search = n("input");
|
||||
search.type = "search";
|
||||
search.placeholder = "Find a coffee";
|
||||
search.setAttribute("aria-label", "Find a coffee");
|
||||
search.className = "field-input";
|
||||
const results = n("div");
|
||||
const update = () => {
|
||||
const q = search.value.toLowerCase();
|
||||
results.replaceChildren(
|
||||
coffeeCards(data.coffees.filter((c) => c.name.toLowerCase().includes(q))),
|
||||
);
|
||||
};
|
||||
search.addEventListener("input", update);
|
||||
root.append(search, results);
|
||||
update();
|
||||
if (!data.coffees.length)
|
||||
results.append(
|
||||
n("p", "Your coffee history begins with a plan or an inventory lot."),
|
||||
link("Create your first plan", "/plans", "primary-btn"),
|
||||
);
|
||||
for(let i=(offset+count)%7;i>0 && i<7;i++) grid.append(n("div","","calendar-day calendar-padding"));
|
||||
content.append(grid);
|
||||
const agenda = n("section","","calendar-agenda");
|
||||
agenda.append(n("h2","Activity log"));
|
||||
if(!selected.length) agenda.append(n("p","No activity this month. Log a roast, cupping or brew to see it here."));
|
||||
let lastDate;
|
||||
for(const event of [...selected].reverse()) {
|
||||
if(event.date!==lastDate) {
|
||||
agenda.append(n("h3",new Date(event.date+"T12:00:00").toLocaleDateString(undefined,{weekday:"long",month:"short",day:"numeric"})));
|
||||
lastDate=event.date;
|
||||
}
|
||||
const row = n("div","","calendar-log-row");
|
||||
row.append(n("span",labels[event.kind],"calendar-kind event-"+event.kind),link(event.title,event.href));
|
||||
if(event.planHref) row.append(link("View plan",event.planHref,"muted"));
|
||||
if(event.dateSource==="logged") row.append(n("small","Date logged","muted"));
|
||||
agenda.append(row);
|
||||
}
|
||||
content.append(agenda);
|
||||
}
|
||||
function renderCoffee(c) {
|
||||
document.getElementById("workspace-title").textContent = c.name;
|
||||
document.getElementById("workspace-subtitle").textContent =
|
||||
"A history of what you planned, roasted, and tasted.";
|
||||
root.append(link("← All coffees", "/coffees"));
|
||||
const best = c.roasts.find((r) => r.after.favorite === "yes");
|
||||
const intro = n("section", "", "panel-card coffee-intro");
|
||||
intro.append(
|
||||
n("h2", best ? "Your favorite batch" : "Find your favorite batch"),
|
||||
n(
|
||||
"p",
|
||||
best
|
||||
? `${best.title} — ${feedback(best)}`
|
||||
: "Record a quick tasting on a finished roast to build your reference.",
|
||||
),
|
||||
link(
|
||||
best ? "Open this roast" : "Record a roast",
|
||||
best ? `/roasts?roast=${best.id}` : "/roasts",
|
||||
"primary-btn",
|
||||
),
|
||||
);
|
||||
if (c.lot)
|
||||
intro.append(
|
||||
n("p", `${c.lot.remainingG} g of green coffee remaining`),
|
||||
link("Inventory", "/inventory"),
|
||||
);
|
||||
if(c.plans[0])intro.append(link("Plan the next experiment",`/app?plan=${c.plans[0].id}&intent=next-experiment`,"ghost-btn"));
|
||||
root.append(intro);
|
||||
const plans = n("section", "", "panel-card");
|
||||
plans.append(n("h2", "Plans & experiments"));
|
||||
const planFilter = n("input");
|
||||
planFilter.type = "search";
|
||||
planFilter.placeholder = "Find a plan or experiment";
|
||||
planFilter.setAttribute("aria-label", "Find a plan or experiment");
|
||||
planFilter.className = "field-input";
|
||||
const planRows = n("div"),
|
||||
planPager = n("div", "", "collection-pagination"),
|
||||
planPrev = n("button", "Previous plans", "ghost-btn"),
|
||||
planNext = n("button", "Next plans", "ghost-btn"),
|
||||
planCount = n("span");
|
||||
let planPage = 1;
|
||||
const renderPlans = () => {
|
||||
planRows.replaceChildren();
|
||||
const matches = c.plans.filter((p) =>
|
||||
[p.name, p.level, p.experiment?.question]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(planFilter.value.toLowerCase()),
|
||||
);
|
||||
planPage = Math.min(planPage, Math.max(1, Math.ceil(matches.length / 10)));
|
||||
planCount.textContent = `${matches.length} plans · page ${planPage}`;
|
||||
planPrev.disabled = planPage === 1;
|
||||
planNext.disabled = planPage * 10 >= matches.length;
|
||||
for (const p of matches.slice((planPage - 1) * 10, planPage * 10)) {
|
||||
const row = n("div", "", "history-row");
|
||||
row.append(
|
||||
link(p.name, `/app?plan=${p.id}`),
|
||||
n("p", `${p.level || "Level not set"} · ${p.batchG || "—"} g`, "muted"),
|
||||
);
|
||||
if (p.experiment?.question) row.append(n("p", p.experiment.question));
|
||||
if (p.experiment?.expected)
|
||||
row.append(n("p", "Expected: " + p.experiment.expected));
|
||||
const clone = n("button", "Create variation", "ghost-btn small");
|
||||
clone.onclick = async () => {
|
||||
clone.disabled = true;
|
||||
try {
|
||||
const r = await api(`/api/plans/${p.id}/clone`, { method: "POST" });
|
||||
location.assign("/app?plan=" + r.plan.id);
|
||||
} catch (e) {
|
||||
clone.disabled = false;
|
||||
alert(e.message);
|
||||
}
|
||||
};
|
||||
row.append(clone);
|
||||
planRows.append(row);
|
||||
}
|
||||
};
|
||||
planFilter.oninput = () => {
|
||||
planPage = 1;
|
||||
renderPlans();
|
||||
};
|
||||
planPrev.onclick = () => {
|
||||
planPage--;
|
||||
renderPlans();
|
||||
};
|
||||
planNext.onclick = () => {
|
||||
planPage++;
|
||||
renderPlans();
|
||||
};
|
||||
planPager.append(planPrev, planCount, planNext);
|
||||
plans.append(planFilter, planRows, planPager);
|
||||
renderPlans();
|
||||
root.append(plans);
|
||||
const section = n("section", "", "panel-card");
|
||||
section.append(
|
||||
n("h2", "Roast & taste history"),
|
||||
n(
|
||||
"p",
|
||||
"Select two batches to compare measured curves and tasting feedback.",
|
||||
"muted",
|
||||
),
|
||||
);
|
||||
const wrap = n("div", "", "table-wrap");
|
||||
const table = n("table", "", "data-table");
|
||||
const head = n("tr");
|
||||
for (const t of [
|
||||
"Compare",
|
||||
"Batch",
|
||||
"First crack",
|
||||
"Development",
|
||||
"Drop",
|
||||
"Tasting / experiment",
|
||||
])
|
||||
head.append(n("th", t));
|
||||
const thead = n("thead");
|
||||
thead.append(head);
|
||||
table.append(thead);
|
||||
const body = n("tbody");
|
||||
const selectedIds = new Set(),
|
||||
roastFilter = n("input"),
|
||||
roastPager = n("div", "", "collection-pagination"),
|
||||
roastPrev = n("button", "Previous roasts", "ghost-btn"),
|
||||
roastNext = n("button", "Next roasts", "ghost-btn"),
|
||||
roastCount = n("span");
|
||||
let roastPage = 1;
|
||||
roastFilter.type = "search";
|
||||
roastFilter.placeholder = "Find a batch or tasting note";
|
||||
roastFilter.setAttribute("aria-label", "Find a batch or tasting note");
|
||||
roastFilter.className = "field-input";
|
||||
section.append(roastFilter);
|
||||
const renderRoasts = () => {
|
||||
body.replaceChildren();
|
||||
const matches = c.roasts.filter((r) =>
|
||||
(r.title + " " + feedback(r))
|
||||
.toLowerCase()
|
||||
.includes(roastFilter.value.toLowerCase()),
|
||||
);
|
||||
roastPage = Math.min(
|
||||
roastPage,
|
||||
Math.max(1, Math.ceil(matches.length / 25)),
|
||||
);
|
||||
roastCount.textContent = `${matches.length} roasts · page ${roastPage}`;
|
||||
roastPrev.disabled = roastPage === 1;
|
||||
roastNext.disabled = roastPage * 25 >= matches.length;
|
||||
for (const r of matches.slice((roastPage - 1) * 25, roastPage * 25)) {
|
||||
const tr = n("tr"),
|
||||
cell = n("td"),
|
||||
check = n("input");
|
||||
check.type = "checkbox";
|
||||
check.value = r.id;
|
||||
check.checked = selectedIds.has(r.id);
|
||||
check.onchange = () => {
|
||||
if (check.checked) selectedIds.add(r.id);
|
||||
else selectedIds.delete(r.id);
|
||||
};
|
||||
check.setAttribute("aria-label", "Compare " + r.title);
|
||||
cell.append(check);
|
||||
const title = n("td");
|
||||
title.append(link(r.title, `/roasts?roast=${r.id}`));
|
||||
tr.append(
|
||||
cell,
|
||||
title,
|
||||
n("td", time(r.derived.firstCrackS)),
|
||||
n("td", time(r.derived.developmentS)),
|
||||
n("td", time(r.derived.dropS)),
|
||||
n(
|
||||
"td",
|
||||
feedback(r) +
|
||||
(r.after.experimentResult
|
||||
? ` · Experiment: ${r.after.experimentResult}`
|
||||
: ""),
|
||||
),
|
||||
);
|
||||
body.append(tr);
|
||||
}
|
||||
};
|
||||
roastFilter.oninput = () => {
|
||||
roastPage = 1;
|
||||
renderRoasts();
|
||||
};
|
||||
roastPrev.onclick = () => {
|
||||
roastPage--;
|
||||
renderRoasts();
|
||||
};
|
||||
roastNext.onclick = () => {
|
||||
roastPage++;
|
||||
renderRoasts();
|
||||
};
|
||||
roastPager.append(roastPrev, roastCount, roastNext);
|
||||
renderRoasts();
|
||||
table.append(body);
|
||||
wrap.append(table);
|
||||
section.append(wrap, roastPager);
|
||||
const compare = n("button", "Compare selected batches", "primary-btn");
|
||||
const result = n("div", "", "comparison-chart");
|
||||
compare.onclick = async () => {
|
||||
const ids = [...selectedIds];
|
||||
if (ids.length !== 2) {
|
||||
result.textContent = "Select exactly two batches.";
|
||||
return;
|
||||
}
|
||||
compare.disabled = true;
|
||||
try {
|
||||
const details = await Promise.all(
|
||||
ids.map((id) => api("/api/roasts/" + id)),
|
||||
);
|
||||
drawComparison(
|
||||
result,
|
||||
details.map((d) => d.roast || d),
|
||||
);
|
||||
} catch (e) {
|
||||
result.textContent = e.message;
|
||||
} finally {
|
||||
compare.disabled = false;
|
||||
}
|
||||
};
|
||||
section.append(compare, result);
|
||||
root.append(section);
|
||||
if (c.cuppings.length) {
|
||||
const cups = n("section", "", "panel-card");
|
||||
cups.append(n("h2", "Detailed cupping"));
|
||||
for (const cup of c.cuppings)
|
||||
cups.append(
|
||||
link(
|
||||
`${new Date(cup.date).toLocaleDateString()} · ${cup.score.toFixed(2)} points`,
|
||||
`/cupping?session=${cup.id}`,
|
||||
),
|
||||
n("br"),
|
||||
);
|
||||
root.append(cups);
|
||||
async function load() {
|
||||
const token = ++generation;
|
||||
filter.disabled = true;
|
||||
events = [];
|
||||
status.textContent="Loading activity…";
|
||||
content.replaceChildren();
|
||||
const [year,m] = month.split("-").map(Number);
|
||||
heading.textContent=new Date(year,m-1,1).toLocaleDateString(undefined,{month:"long",year:"numeric"});
|
||||
previous.disabled=month==="2000-01"; next.disabled=month==="2099-12";
|
||||
history.replaceState(null,"","/home?month="+month);
|
||||
try {
|
||||
const data = await api("/api/activity?"+new URLSearchParams({month,timeZone:Intl.DateTimeFormat().resolvedOptions().timeZone}));
|
||||
if(token!==generation)return;
|
||||
events=data.events; filter.disabled = false; render();
|
||||
} catch(error) {
|
||||
if(token!==generation)return;
|
||||
status.textContent="Could not load activity. "+error.message;
|
||||
const retry=n("button","Retry","ghost-btn");retry.onclick=load;content.append(retry);
|
||||
}
|
||||
}
|
||||
function drawComparison(root, details) {
|
||||
root.replaceChildren();
|
||||
const ns = "http://www.w3.org/2000/svg";
|
||||
const svg = document.createElementNS(ns, "svg");
|
||||
svg.setAttribute("viewBox", "0 0 800 340");
|
||||
svg.setAttribute("role", "img");
|
||||
svg.setAttribute(
|
||||
"aria-label",
|
||||
"Measured roast curves, Fahrenheit versus time",
|
||||
);
|
||||
const make = (tag, attrs, text) => {
|
||||
const el = document.createElementNS(ns, tag);
|
||||
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
|
||||
if (text) el.textContent = text;
|
||||
svg.append(el);
|
||||
};
|
||||
const curves = details.map((d) =>
|
||||
(d.parsed?.curve || [])
|
||||
.map((p) => ({ t: p.t ?? p.timeS, bt: p.bt ?? p.tempC }))
|
||||
.filter((p) => Number.isFinite(p.t) && Number.isFinite(p.bt)),
|
||||
);
|
||||
const maxT = Math.max(60, ...curves.flat().map((p) => p.t)),
|
||||
temps = curves.flat().map((p) => p.bt * 1.8 + 32);
|
||||
const lo = Math.floor(Math.min(100, ...temps) / 50) * 50,
|
||||
hi = Math.ceil(Math.max(400, ...temps) / 50) * 50;
|
||||
const x = (t) => 55 + (t / maxT) * 710,
|
||||
y = (f) => 285 - ((f - lo) / (hi - lo)) * 240;
|
||||
for (let f = lo; f <= hi; f += 50) {
|
||||
make("line", { x1: 55, x2: 765, y1: y(f), y2: y(f), stroke: "#ded8d0" });
|
||||
make(
|
||||
"text",
|
||||
{ x: 48, y: y(f) + 4, "text-anchor": "end", "font-size": 12 },
|
||||
`${f}°F`,
|
||||
);
|
||||
}
|
||||
for (let i = 0; i <= 5; i++)
|
||||
make(
|
||||
"text",
|
||||
{
|
||||
x: x((maxT * i) / 5),
|
||||
y: 310,
|
||||
"text-anchor": "middle",
|
||||
"font-size": 12,
|
||||
},
|
||||
time((maxT * i) / 5),
|
||||
);
|
||||
curves.forEach((curve, i) => {
|
||||
make("polyline", {
|
||||
points: curve.map((p) => `${x(p.t)},${y(p.bt * 1.8 + 32)}`).join(" "),
|
||||
fill: "none",
|
||||
stroke: i ? "#a8481a" : "#286350",
|
||||
"stroke-width": 2,
|
||||
});
|
||||
make(
|
||||
"text",
|
||||
{
|
||||
x: 60 + i * 355,
|
||||
y: 22,
|
||||
fill: i ? "#a8481a" : "#286350",
|
||||
"font-size": 12,
|
||||
},
|
||||
details[i].parsed?.roast?.title || details[i].filename,
|
||||
);
|
||||
});
|
||||
root.append(
|
||||
svg,
|
||||
n(
|
||||
"p",
|
||||
"Original imported measurements. Taste notes and observation corrections are shown separately.",
|
||||
"muted",
|
||||
),
|
||||
);
|
||||
function move(delta) {
|
||||
const [year,m]=month.split("-").map(Number), date=new Date(year,m-1+delta,1);
|
||||
month=`${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,"0")}`; load();
|
||||
}
|
||||
previous.onclick=()=>move(-1);next.onclick=()=>move(1);
|
||||
current.onclick=()=>{month=`${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,"0")}`;load();};
|
||||
filter.onchange=()=>{if(!status.textContent.startsWith("Loading"))render();};
|
||||
initSideNav();
|
||||
await loadNavUser();
|
||||
document.getElementById("btn-logout").onclick = async () => {
|
||||
await protectedFetch("/api/auth/logout", { method: "POST" });
|
||||
location.assign("/");
|
||||
};
|
||||
try {
|
||||
data = await api("/api/workspace");
|
||||
render();
|
||||
} catch (e) {
|
||||
root.textContent = "Could not load your workspace. " + e.message;
|
||||
}
|
||||
document.getElementById("btn-logout").onclick=async()=>{await protectedFetch("/api/auth/logout",{method:"POST"});location.assign("/");};
|
||||
await load();
|
||||
@@ -64,9 +64,9 @@
|
||||
</button>
|
||||
<div class="brand">
|
||||
<div class="brand-text">
|
||||
<h1 id="workspace-title">Your coffees</h1>
|
||||
<h1 id="workspace-title">Activity calendar</h1>
|
||||
<p class="brand-sub" id="workspace-subtitle">
|
||||
Find, filter and open your saved roast plans.
|
||||
Your roasts, cuppings, brews and plans, in one place.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -74,7 +74,7 @@
|
||||
</header>
|
||||
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
|
||||
|
||||
<main class="page-content wide" id="workspace-content"><p>Loading your coffee history…</p></main>
|
||||
<main class="page-content wide" id="workspace-content"><p>Loading your activity…</p></main>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/js/workspace.js?v=__ASSET_VERSION__"></script>
|
||||
|
||||
Reference in new issue
Block a user