ui: portfolio actions row + AI analysis regenerate

Two small UX changes to the portfolio panel:

1. "Forget this pie" is destructive enough to belong in edit-mode
   only. The button now hides by default and only surfaces when the
   #portfolio-panel.pf-editing class is on the panel (same surface
   that already shows per-row × and the add-position form). The
   element stays in the DOM so the existing click handler keeps
   working without re-mount.

2. "Generate AI analysis" disappears once an analysis exists. In its
   place a small "Regenerate" button is rendered inside the
   collapsible analysis box — in the summary header, right-aligned
   next to the timestamp. The button stops the summary's default
   toggle action so a click regenerates without collapsing the
   panel. runAnalysis() now tolerates either pf-analyze or pf-regen
   as the trigger, and showAnalysis() takes an optional
   onRegenerate callback so callers can wire the button to the
   current pie/enriched closure context. Re-hydration after the
   60s portfolio refresh passes the same callback so the button
   survives a refresh cycle.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-05-29 15:04:08 +02:00
parent 652995feea
commit 736d161990
2 changed files with 80 additions and 12 deletions

View file

@ -86,6 +86,13 @@
.pf-actions button:disabled { opacity: 0.5; cursor: not-allowed; }
.pf-secondary { color: var(--muted); }
.pf-secondary:hover { color: var(--negative); border-color: var(--negative); }
/* "Forget this pie" is destructive only show it while the user is
in edit mode (the same mode that surfaces the per-row delete × and
the add-position form). Outside of edit mode the row stays in the
DOM so existing handlers and any future surface that wants to
toggle it can do so without re-rendering. */
#pf-forget { display: none; }
#portfolio-panel.pf-editing #pf-forget { display: inline-block; }
.pf-analysis {
margin-top: 14px;
@ -107,6 +114,24 @@
list-style: none; /* hide native marker in Firefox */
}
.pf-analysis__head::-webkit-details-marker { display: none; }
.pf-analysis__head-right {
display: inline-flex;
align-items: center;
gap: 12px;
}
.pf-regen {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
padding: 3px 9px;
font: inherit;
font-size: 10.5px;
letter-spacing: inherit;
cursor: pointer;
text-transform: inherit;
}
.pf-regen:hover { color: var(--accent); border-color: var(--accent); }
.pf-regen:disabled { opacity: 0.5; cursor: not-allowed; }
.pf-analysis__head-left::before {
content: "▸ ";
display: inline-block;

View file

@ -372,13 +372,24 @@
'</tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>' +
// The "Generate" button only renders when there's no cached
// analysis yet. Once one exists, regeneration moves inside the
// collapsible analysis box (see showAnalysis below). The "Forget
// this pie" button is destructive enough that it lives in
// edit-mode only — CSS in portfolio.css hides it when the
// portfolio panel isn't carrying the .pf-editing class.
'<div class="pf-actions">' +
'<button id="pf-analyze" type="button">Generate AI analysis</button>' +
(pie.analysis && pie.analysis.content
? ''
: '<button id="pf-analyze" type="button">Generate AI analysis</button>') +
'<button id="pf-forget" type="button" class="pf-secondary">Forget this pie</button>' +
'</div>' +
'<div id="pf-analysis" class="pf-analysis" hidden></div>';
document.getElementById('pf-analyze').addEventListener('click', () => runAnalysis(pie, enriched));
const analyzeBtn = document.getElementById('pf-analyze');
if (analyzeBtn) {
analyzeBtn.addEventListener('click', () => runAnalysis(pie, enriched));
}
document.getElementById('pf-forget').addEventListener('click', () => {
if (confirm('Remove the saved pie from this browser? The server holds nothing — this is local.')) {
clearPie();
@ -390,13 +401,16 @@
// wipe it. Rendered expanded so the user keeps seeing the body they
// just generated — collapsing it under their cursor every minute
// reads as "the analysis disappeared". They can still click the
// header to collapse manually within a single refresh window.
// header to collapse manually within a single refresh window. The
// regenerate callback closes over the current pie/enriched so a
// click rebuilds the analysis with the same context that drove
// the initial render.
if (pie.analysis && pie.analysis.content) {
showAnalysis(pie.analysis, { open: true });
showAnalysis(pie.analysis, { open: true }, () => runAnalysis(pie, enriched));
}
}
function showAnalysis(analysis, opts) {
function showAnalysis(analysis, opts, onRegenerate) {
const out = document.getElementById('pf-analysis');
if (!out) return;
const openAttr = (opts && opts.open) ? ' open' : '';
@ -407,20 +421,43 @@
'<span class="pf-analysis__head-left">' +
'AI analysis' +
'</span>' +
'<span class="pf-analysis__head-right">' +
'<span class="pf-as-of">' +
esc((analysis.generated_at || '').slice(0, 19).replace('T', ' ')) +
' UTC</span>' +
(onRegenerate
? '<button id="pf-regen" type="button" class="pf-regen"' +
' title="Run the analysis again on the current portfolio">' +
'Regenerate</button>'
: '') +
'</span>' +
'</summary>' +
'<pre class="pf-analysis__body">' + esc(analysis.content) + '</pre>' +
'</details>';
if (onRegenerate) {
const regen = document.getElementById('pf-regen');
if (regen) {
regen.addEventListener('click', (e) => {
// The button lives inside <summary>; clicking it would
// normally toggle the <details> open/closed. Suppress the
// default toggle and the bubble so only our regen runs.
e.preventDefault();
e.stopPropagation();
onRegenerate();
});
}
}
}
async function runAnalysis(pie, enriched) {
const out = document.getElementById('pf-analysis');
const btn = document.getElementById('pf-analyze');
// First-run click is on pf-analyze; the regenerate path is pf-regen
// inside the details summary. Either may be the live trigger.
const btn = document.getElementById('pf-analyze') ||
document.getElementById('pf-regen');
out.hidden = false;
out.innerHTML = '<div class="empty">generating…</div>';
btn.disabled = true;
if (btn) btn.disabled = true;
// Build the prices payload from the universe cache so the server
// doesn't have to re-fetch.
@ -458,11 +495,17 @@
}
// Persist before rendering so auto-refresh can re-hydrate.
saveAnalysis(data);
showAnalysis(data, { open: true });
// Pass the regenerate callback so the in-details "Regenerate"
// button shows up on the freshly-rendered analysis too.
showAnalysis(data, { open: true }, () => runAnalysis(pie, enriched));
} catch (e) {
out.innerHTML = '<div class="pf-warn">' + esc(e.message) + '</div>';
} finally {
btn.disabled = false;
// The original button may have been replaced by showAnalysis →
// re-fetch its handle (or null if neither id is on the page now).
const liveBtn = document.getElementById('pf-analyze') ||
document.getElementById('pf-regen');
if (liveBtn) liveBtn.disabled = false;
}
}