Dashboard decisions: stable form inputs + copy to clipboard

Polling fix:
- Blocking decisions now use a Mutable (blockingDecisions) + refreshDecisions()
  instead of deriving from summary.blocking_decisions
- Cards only re-render on initial page load or after a successful resolve,
  not on every 15 s summary poll — so typing in the form is never interrupted
- On successful resolve, refreshDecisions() re-fetches the list; the resolved
  decision no longer matches the open/escalated filter so it disappears cleanly

Copy to clipboard:
- Small "Copy" button in the card header (next to deadline/escalation badges)
- Formats full decision as markdown: title, description, context, status, date
- Shows "✓ Copied" for 1.5 s, reverts to "Copy"; shows "⚠ Failed" on error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 09:47:52 +01:00
parent 1d9d776a23
commit 312d64a14b

View File

@@ -35,6 +35,18 @@ const tasks = totals.tasks ?? {};
const decisions = totals.decisions ?? {}; const decisions = totals.decisions ?? {};
``` ```
```js
// Blocking decisions — fetched once on load, refreshed only after a resolve action.
// Kept separate from the summary poll so in-progress form inputs aren't wiped every 15 s.
const blockingDecisions = Mutable([]);
const refreshDecisions = async () => {
const r = await fetch(`${API}/decisions/?decision_type=pending`).catch(() => null);
const all = r?.ok ? await r.json() : [];
blockingDecisions.value = all.filter(d => ["open", "escalated"].includes(d.status));
};
refreshDecisions();
```
```js ```js
// Registered projects — milestone events tagged with registration // Registered projects — milestone events tagged with registration
const regsState = (async function*() { const regsState = (async function*() {
@@ -205,7 +217,9 @@ if (emptyRegistered.length > 0) {
## Blocking Decisions ## Blocking Decisions
```js ```js
const blocking = summary.blocking_decisions ?? []; // Uses blockingDecisions (Mutable) — only re-renders when refreshDecisions() is called,
// not on every summary poll, so in-progress form input is preserved between polls.
const blocking = blockingDecisions ?? [];
if (blocking.length === 0) { if (blocking.length === 0) {
display(html`<p style="color:green">✓ No blocking decisions.</p>`); display(html`<p style="color:green">✓ No blocking decisions.</p>`);
} else { } else {
@@ -216,6 +230,7 @@ if (blocking.length === 0) {
<span class="dec-meta"> <span class="dec-meta">
${d.escalation_note ? html`<span class="dec-warn-badge">⚠ escalated</span>` : ""} ${d.escalation_note ? html`<span class="dec-warn-badge">⚠ escalated</span>` : ""}
${d.deadline ? html`<span>Due ${new Date(d.deadline).toLocaleDateString()}</span>` : ""} ${d.deadline ? html`<span>Due ${new Date(d.deadline).toLocaleDateString()}</span>` : ""}
<button class="r-copy" title="Copy decision to clipboard">Copy</button>
</span> </span>
</div> </div>
${d.description ? html`<p class="dec-desc">${d.description}</p>` : ""} ${d.description ? html`<p class="dec-desc">${d.description}</p>` : ""}
@@ -236,13 +251,31 @@ if (blocking.length === 0) {
</details> </details>
</div>`; </div>`;
const btn = card.querySelector(".r-submit"); // Copy to clipboard
const msg = card.querySelector(".r-msg"); const copyBtn = card.querySelector(".r-copy");
const det = card.querySelector(".dec-resolve"); copyBtn.addEventListener("click", () => {
const parts = [
`# ${d.title}`,
"",
d.description ?? "",
d.rationale ? `\n**Context:** ${d.rationale}` : "",
d.escalation_note ? `\n**⚠ Escalated:** ${d.escalation_note}` : "",
`\n**Status:** ${d.status} | **Created:** ${new Date(d.created_at).toLocaleDateString()}`,
d.deadline ? `**Due:** ${new Date(d.deadline).toLocaleDateString()}` : "",
].filter(Boolean).join("\n");
navigator.clipboard.writeText(parts).then(() => {
copyBtn.textContent = "✓ Copied";
setTimeout(() => { copyBtn.textContent = "Copy"; }, 1500);
}).catch(() => { copyBtn.textContent = "⚠ Failed"; setTimeout(() => { copyBtn.textContent = "Copy"; }, 2000); });
});
// Resolve
const btn = card.querySelector(".r-submit");
const msg = card.querySelector(".r-msg");
btn.addEventListener("click", async () => { btn.addEventListener("click", async () => {
const rationale = card.querySelector(".r-text").value.trim(); const rationale = card.querySelector(".r-text").value.trim();
const decidedBy = card.querySelector(".r-by").value.trim() || "Bernd"; const decidedBy = card.querySelector(".r-by").value.trim() || "Bernd";
if (!rationale) { msg.textContent = "⚠ Please enter a rationale."; return; } if (!rationale) { msg.textContent = "⚠ Please enter a rationale."; return; }
btn.disabled = true; btn.textContent = "Saving…"; btn.disabled = true; btn.textContent = "Saving…";
try { try {
@@ -252,10 +285,7 @@ if (blocking.length === 0) {
body: JSON.stringify({rationale, decided_by: decidedBy}), body: JSON.stringify({rationale, decided_by: decidedBy}),
}); });
if (r.ok) { if (r.ok) {
det.open = false; await refreshDecisions(); // re-fetches list — resolved decision won't appear
det.querySelector("summary").textContent = "✓ Resolved — DECISIONS.md written, updates in next poll";
det.querySelector("summary").style.color = "green";
card.style.opacity = "0.55";
} else { } else {
const err = await r.json().catch(() => ({})); const err = await r.json().catch(() => ({}));
msg.textContent = `Error ${r.status}: ${err.detail ?? "unknown"}`; msg.textContent = `Error ${r.status}: ${err.detail ?? "unknown"}`;
@@ -326,4 +356,6 @@ display(Inputs.table((summary.recent_progress ?? []).map(e => ({
.dec-resolve-actions button { padding: 0.35rem 0.9rem; border-radius: 4px; border: none; background: steelblue; color: white; cursor: pointer; font-size: 0.875rem; } .dec-resolve-actions button { padding: 0.35rem 0.9rem; border-radius: 4px; border: none; background: steelblue; color: white; cursor: pointer; font-size: 0.875rem; }
.dec-resolve-actions button:disabled { opacity: 0.5; cursor: default; } .dec-resolve-actions button:disabled { opacity: 0.5; cursor: default; }
.r-msg { font-size: 0.8rem; color: #b45309; } .r-msg { font-size: 0.8rem; color: #b45309; }
.r-copy { padding: 0.15rem 0.55rem; border-radius: 3px; border: 1px solid var(--theme-foreground-faint); background: var(--theme-background); color: var(--theme-foreground-muted); cursor: pointer; font-size: 0.75rem; }
.r-copy:hover { background: var(--theme-background-alt); }
</style> </style>