Add feature set import export

This commit is contained in:
OpenClaw Bot
2026-05-22 23:28:15 +02:00
parent da9d3673c1
commit 7cc43a5e92
5 changed files with 170 additions and 25 deletions
+2 -3
View File
@@ -1,7 +1,6 @@
node_modules
.git
npm-debug.log
.DS_Store
.env
*.log
.DS_Store
coverage
playwright-report
+98
View File
@@ -74,6 +74,89 @@ function renderGrid(){ sortingGrid.innerHTML = zones().map((z,idx) => `<button t
function renderTimeline(){ const items = sortedIdeas().filter(i => !['park','investigate','remove'].includes(i.status)).sort((a,b)=>(a.rank-b.rank)||(new Date(a.updatedAt)-new Date(b.updatedAt))); const milestones = [{label:'MVP',left:8,color:'#16A34A'},{label:'Beta',left:42,color:'#2563EB'},{label:'1.0',left:66,color:'#DC2626'},{label:'Stretch Goal',left:88,color:'#7C3AED'}]; const positions = items.map((idea, idx) => { const z = zoneFor(idea); const base = typeof idea.rank === 'number' && idea.rank > 0 ? Math.max(7, Math.min(94, idea.rank / 1000)) : z.left; return {idea,z,left: Math.min(96, base + (idx%3)*1.5)}; }); timeline.innerHTML = `<div class="timeline-line" id="timelineLine">${milestones.map(m=>`<span class="milestone-label" style="left:${m.left}%;--milestone-color:${m.color}">${m.label}</span><span class="milestone-tick" style="left:${m.left}%;--milestone-color:${m.color}"></span>`).join('')}${positions.map((p,idx)=>`<button class="node" draggable="true" data-id="${escapeHtml(p.idea.id)}" title="Drag to reorder · ${escapeHtml(p.idea.title)}" style="left:${p.left}%;--node-color:${p.z.color}">${idx+1}</button>`).join('')}${positions.slice(0,1).map(p=>`<div class="node-card" style="left:${p.left}%"><strong>${escapeHtml(short(p.idea.title,32))}</strong><span>${escapeHtml(categoryOf(p.idea))} · ${escapeHtml(p.z.label)}</span></div>`).join('')}</div>`; const line=$('#timelineLine'); line.addEventListener('dragover', e => { e.preventDefault(); line.classList.add('drag-over'); }); line.addEventListener('dragleave', () => line.classList.remove('drag-over')); line.addEventListener('drop', e => { e.preventDefault(); line.classList.remove('drag-over'); const id=e.dataTransfer.getData('text/plain'); if(id) reorderOnTimeline(id,e); }); $$('.node', timeline).forEach(n => { n.addEventListener('click', () => openDetail(n.dataset.id)); n.addEventListener('dragstart', e => e.dataTransfer.setData('text/plain', n.dataset.id)); }); }
function renderBacklog(){ const items = utilityIdeas(); $('#backlogList').innerHTML = items.length ? items.map(i=>`<button type="button" class="mini-card" data-id="${escapeHtml(i.id)}"><strong>${escapeHtml(i.title)}</strong><span>${escapeHtml(i.status)} · ${escapeHtml(short(i.description,70))}</span></button>`).join('') : '<div class="mini-card"><strong>Clean utility lanes</strong><span>Parked and investigate items will appear here.</span></div>'; $$('.mini-card[data-id]').forEach(c => c.addEventListener('click',()=>openDetail(c.dataset.id))); }
function render(){ renderProfile(); renderProgress(); renderDeck(); renderGrid(); renderTimeline(); renderBacklog(); }
function sortedSetupFeatures(){
const ordered = state.ideas
.filter(i => !i.archived)
.slice()
.sort((a,b) => {
const aActive = activeIdeas().some(i => i.id === a.id) ? 0 : 1;
const bActive = activeIdeas().some(i => i.id === b.id) ? 0 : 1;
return (aActive - bActive) || ((a.rank || 0) - (b.rank || 0)) || ((b.score || 0) - (a.score || 0)) || String(a.title).localeCompare(String(b.title));
});
return ordered.map((idea, index) => {
const z = zoneFor(idea);
return {
title: idea.title,
description: idea.description || '',
labels: idea.labels || [],
impact: idea.impact ?? 5,
effort: idea.effort ?? 5,
confidence: idea.confidence ?? 5,
urgency: idea.urgency ?? 5,
score: idea.score ?? 0,
status: idea.status || 'inbox',
milestoneId: idea.milestoneId || 'inbox',
rank: idea.rank ?? 0,
notes: idea.notes || '',
sortedSetup: {
order: index + 1,
profile: state.profileId,
lane: activeIdeas().some(i => i.id === idea.id) ? 'Inbox / unsorted' : z.label,
milestoneId: idea.milestoneId || 'inbox',
status: idea.status || 'inbox'
}
};
});
}
function featureSetPayload(){
return {
format: 'prioritix-feature-set-v1',
exportedAt: new Date().toISOString(),
sortingProfile: state.profileId,
scoring: '((impact×2.4)+(confidence×1.2)+(urgency×1.4))/effort',
features: sortedSetupFeatures()
};
}
function parseFeatureSetText(text){
let parsed;
try { parsed = JSON.parse(text); } catch { throw new Error('Paste valid Prioritix Feature Set v1 JSON. Required shape: { "format":"prioritix-feature-set-v1", "features":[{"title":"..."}] }.'); }
const features = Array.isArray(parsed) ? parsed : parsed.features;
if(!Array.isArray(features) || !features.length) throw new Error('Feature set must contain a non-empty features array.');
const cleaned = features.map((feature, index) => {
const title = String(feature?.title || feature?.name || '').trim();
if(!title) throw new Error(`Feature ${index + 1} is missing title.`);
return {
title,
description: feature.description || feature.brief || '',
labels: Array.isArray(feature.labels) ? feature.labels : String(feature.labels || feature.category || '').split(',').map(s=>s.trim()).filter(Boolean),
impact: feature.impact ?? 5,
effort: feature.effort ?? 5,
confidence: feature.confidence ?? 6,
urgency: feature.urgency ?? 5,
status: feature.status || 'inbox',
milestoneId: feature.milestoneId || feature.milestone || 'inbox',
rank: feature.rank ?? 0,
notes: feature.notes || ''
};
});
return { name: parsed.name || 'Pasted feature set', format: parsed.format || 'prioritix-feature-set-v1', features: cleaned };
}
function downloadJson(filename, payload){
const blob = new Blob([JSON.stringify(payload, null, 2) + '\n'], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove();
URL.revokeObjectURL(url);
}
async function importFeatureSet(){
const input = $('#featureSetInput');
const payload = parseFeatureSetText(input.value.trim());
const data = await api('/api/feature-set/import', { method:'POST', body: payload });
(data.created || []).forEach(replaceIdea);
state.activeId = data.created?.[0]?.id || state.activeId;
render();
toast(data.errors?.length ? `Imported ${data.imported}; ${data.errors.length} skipped` : `Imported ${data.imported} feature${data.imported === 1 ? '' : 's'}`);
}
function replaceIdea(idea){ const idx=state.ideas.findIndex(i=>i.id===idea.id); if(idx>=0) state.ideas[idx]=idea; else state.ideas.unshift(idea); }
async function placeActive(zoneId){ const idea = currentActive(); if(!idea) return toast('Capture a feature first'); return placeFeature(idea.id, zoneId); }
async function placeFeature(id, zoneId){ const z = zones().find(z=>z.id===zoneId); const idea = state.ideas.find(i=>i.id===id); if(!z || !idea) return; const previous = {...idea}; Object.assign(idea,{status:z.status,milestoneId:z.milestoneId,rank:Math.round(z.left*1000)}); render(); try{ const updated = await api(`/api/ideas/${id}`, {method:'PATCH', body:{status:z.status,milestoneId:z.milestoneId,rank:idea.rank}}); replaceIdea(updated); state.activeId=null; render(); toast(`Placed in ${z.label}`, () => undoIdea(previous)); } catch(error){ Object.assign(idea, previous); render(); toast(error.message); } }
@@ -83,6 +166,21 @@ async function reorderOnTimeline(id,e){ const line=e.currentTarget; const idea=s
function openDetail(id){ const idea=state.ideas.find(i=>i.id===id); if(!idea) return; state.selected=id; detailForm.title.value=idea.title||''; detailForm.description.value=idea.description||''; detailForm.labels.value=(idea.labels||[]).join(', '); detailForm.impact.value=idea.impact??5; detailForm.effort.value=idea.effort??5; detailForm.confidence.value=idea.confidence??5; detailForm.urgency.value=idea.urgency??5; detailForm.notes.value=idea.notes||''; $('#detailCategory').textContent=categoryOf(idea); $('.detail-head .chip').textContent=zoneFor(idea).label; detail.classList.add('open'); detail.setAttribute('aria-hidden','false'); }
function closeDetail(){ detail.classList.remove('open'); detail.setAttribute('aria-hidden','true'); state.selected=null; }
async function archiveIdea(id=state.selected){ if(!id) return; const previous=state.ideas.find(i=>i.id===id); try{ await api(`/api/ideas/${id}`,{method:'PATCH',body:{archived:true,status:'remove'}}); state.ideas = state.ideas.filter(i=>i.id!==id); closeDetail(); render(); toast('Removed', previous ? () => undoIdea({...previous, archived:false}) : null); } catch(error){ toast(error.message); } }
$('#featureSetFile')?.addEventListener('change', async e => {
const file = e.currentTarget.files?.[0];
if(!file) return;
try { $('#featureSetInput').value = await file.text(); toast(`Loaded ${file.name}`); }
catch(error){ toast(error.message || 'Could not read file'); }
});
$('#importFeatureSet')?.addEventListener('click', async () => {
try { await importFeatureSet(); } catch(error){ toast(error.message); }
});
$('#exportFeatureSet')?.addEventListener('click', () => {
const payload = featureSetPayload();
$('#featureSetInput').value = JSON.stringify(payload, null, 2);
downloadJson(`prioritix-feature-set-${new Date().toISOString().slice(0,10)}.json`, payload);
toast(`Exported ${payload.features.length} feature${payload.features.length === 1 ? '' : 's'}`);
});
$('#ideaForm').addEventListener('submit', async e => { e.preventDefault(); const form=e.currentTarget; const fd=new FormData(form); const payload=Object.fromEntries(fd.entries()); payload.labels=String(payload.labels||'UI / UX').split(',').map(s=>s.trim()).filter(Boolean); payload.source='human'; payload.status='inbox'; try{ const idea=await api('/api/ideas',{method:'POST',body:payload}); replaceIdea(idea); state.activeId=idea.id; form.reset(); $('#title').value=''; $('#description').value=''; $('#labels').value=''; render(); toast('Captured'); }catch(error){ toast(error.message); } });
$('#skipFeature').addEventListener('click',()=>{ const q=activeIdeas(); if(q.length<2) return toast('No next feature'); const idx=q.findIndex(i=>i.id===state.activeId); state.activeId=q[(idx+1)%q.length].id; renderDeck(); });
$('#postponeFeature').addEventListener('click',async()=>{ const idea=currentActive(); if(!idea) return; idea.rank=Date.now()%100000; state.activeId=null; render(); try{ const updated=await api(`/api/ideas/${idea.id}`,{method:'PATCH',body:{rank:idea.rank}}); replaceIdea(updated); render(); toast('Postponed'); }catch(error){ toast(error.message); await load(); } });
+24 -2
View File
@@ -6,7 +6,7 @@
<meta name="theme-color" content="#061B33" />
<title>Prioritix — Feature Prioritization</title>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="/styles.css?v=prioritix-20260522-1" />
<link rel="stylesheet" href="/styles.css?v=prioritix-20260522-2" />
</head>
<body>
<div class="app-shell">
@@ -63,6 +63,28 @@
</form>
</section>
<section class="feature-set-panel" id="feature-sets" aria-label="Feature set import and export">
<div class="feature-set-copy">
<div class="section-label">Feature set import / export</div>
<h2>Upload or paste a whole feature set.</h2>
<p>Use <strong>Prioritix Feature Set v1</strong>: JSON with a top-level <code>features</code> array. Required: <code>title</code>. Optional: <code>description</code>, <code>labels</code>, <code>impact</code>, <code>effort</code>, <code>confidence</code>, <code>urgency</code>, <code>status</code>, <code>milestoneId</code>, <code>rank</code>, <code>notes</code>. Export uses the same format, including the sorted setup.</p>
</div>
<div class="feature-set-actions">
<textarea id="featureSetInput" spellcheck="false" placeholder='{
"format": "prioritix-feature-set-v1",
"name": "Launch plan",
"features": [
{ "title": "Smart requirement sorting", "description": "Cluster pasted requirements into delivery slices.", "labels": ["AI", "Planning"], "impact": 8, "effort": 5, "confidence": 7, "urgency": 6 }
]
}'></textarea>
<div class="feature-set-toolbar">
<label class="file-pick">Upload .json<input id="featureSetFile" type="file" accept="application/json,.json" /></label>
<button type="button" id="importFeatureSet">Import features</button>
<button type="button" id="exportFeatureSet">Export sorted setup</button>
</div>
</div>
</section>
<section class="sorting-layout" id="prioritize">
<section class="active-column" aria-label="Active feature">
<div class="section-label">Active feature</div>
@@ -130,6 +152,6 @@
</aside>
<div class="toast" id="toast" hidden></div>
<script src="/app.js?v=prioritix-20260522-3" type="module"></script>
<script src="/app.js?v=prioritix-20260522-4" type="module"></script>
</body>
</html>
+2
View File
@@ -18,3 +18,5 @@ button,input,textarea{font:inherit} button{cursor:pointer} a{color:inherit;text-
@media(max-width:1050px){.app-shell{grid-template-columns:1fr}.sidebar{display:none}.workspace{padding:14px}.topbar{grid-template-columns:auto 1fr}.profile-card,.progress-card{grid-column:1/-1}.sorting-layout{grid-template-columns:1fr}.capture-strip form{grid-template-columns:1fr 1fr}.capture-strip button{grid-column:1/-1}.utility-row{grid-template-columns:1fr}.utility-row button{border-right:0;border-bottom:1px solid var(--border)}.utility-row button:last-child{border-bottom:0}}
.shortcut-hint{margin:10px 2px 0;color:var(--slate-600);font-size:12px}.shortcut-hint kbd{display:inline-grid;place-items:center;min-width:22px;height:20px;padding:0 5px;border:1px solid var(--border);border-bottom-width:2px;border-radius:4px;background:#fff;color:var(--navy-950);font-weight:800}.timeline-line.drag-over{outline:2px dashed var(--blue-600);outline-offset:8px;background:linear-gradient(90deg,rgba(22,163,74,.06),rgba(37,99,235,.06),rgba(245,158,11,.06),rgba(124,58,237,.06))}.node[draggable="true"]{cursor:grab}.node[draggable="true"]:active{cursor:grabbing}.toast{display:flex;align-items:center;gap:12px}.toast button{border:1px solid rgba(255,255,255,.35);background:rgba(255,255,255,.12);color:#fff;border-radius:4px;padding:6px 9px;font-weight:800}.detail label input[name="labels"]{width:100%;border:1px solid var(--border);border-radius:6px;padding:11px 12px;background:#F8FAFC;color:var(--slate-900);margin-top:6px}.profile-menu button.selected{background:#F0E9FF;color:var(--purple-600);font-weight:900}
@media(max-width:680px){.workspace{padding:10px}.topbar{padding:12px;gap:12px}.project-title h1{font-size:21px}.sorting-grid{grid-template-columns:1fr 1fr;gap:9px}.zone{min-height:142px;padding:12px}.zone-icon{font-size:30px}.zone strong{font-size:14px}.zone span{font-size:12px}.capture-strip form,.deck-actions,.detail-grid,.detail-actions{grid-template-columns:1fr}.timeline{overflow-x:auto}.timeline-line{min-width:760px}.feature-card{min-height:160px}.feature-card::before,.feature-card::after{display:none}}
.feature-set-panel{display:grid;grid-template-columns:minmax(280px,.9fr) minmax(420px,1.4fr);gap:16px;background:linear-gradient(135deg,rgba(6,27,51,.96),rgba(9,38,75,.92));color:white;border:1px solid rgba(255,255,255,.12);border-radius:var(--radius);box-shadow:var(--shadow);padding:16px;margin-bottom:18px;overflow:hidden;position:relative}.feature-set-panel::before{content:"";position:absolute;inset:auto -80px -120px auto;width:260px;height:260px;background:radial-gradient(circle,rgba(69,184,255,.25),transparent 65%);pointer-events:none}.feature-set-copy{position:relative;z-index:1}.feature-set-copy .section-label{color:#A7D8FF}.feature-set-copy h2{margin:7px 0 8px;font-size:22px;letter-spacing:-.03em}.feature-set-copy p{margin:0;color:#D9E8FF;line-height:1.5;font-size:13px}.feature-set-copy code{background:rgba(255,255,255,.10);border:1px solid rgba(255,255,255,.10);border-radius:4px;padding:1px 5px;color:#fff}.feature-set-actions{position:relative;z-index:1;display:grid;gap:10px}.feature-set-actions textarea{width:100%;min-height:178px;resize:vertical;border:1px solid rgba(255,255,255,.16);border-radius:6px;background:rgba(2,8,23,.64);color:#EAF6FF;padding:12px;font-family:"JetBrains Mono","SFMono-Regular",ui-monospace,monospace;font-size:12px;line-height:1.45;outline:none}.feature-set-actions textarea:focus{border-color:#45B8FF;box-shadow:0 0 0 3px rgba(69,184,255,.16)}.feature-set-toolbar{display:flex;gap:10px;flex-wrap:wrap;align-items:center}.feature-set-toolbar button,.file-pick{border:1px solid rgba(255,255,255,.20);border-radius:6px;background:rgba(255,255,255,.10);color:white;padding:10px 12px;font-weight:850;box-shadow:0 10px 24px rgba(0,0,0,.12)}.feature-set-toolbar button:hover,.file-pick:hover{background:rgba(255,255,255,.16)}.file-pick input{position:absolute;inline-size:1px;block-size:1px;opacity:0;pointer-events:none}@media(max-width:1050px){.feature-set-panel{grid-template-columns:1fr}.feature-set-actions textarea{min-height:220px}}
+44 -20
View File
@@ -56,6 +56,29 @@ function scoreIdea({ impact, effort, confidence, urgency }) {
return Number((((i * 2.4) + (c * 1.2) + (u * 1.4)) / Math.max(1, e)).toFixed(2));
}
function ideaDataFromInput(input = {}, defaults = {}) {
const title = cleanText(input.title || input.name, 180);
if (!title) throw Object.assign(new Error('Every feature needs a title.'), { code: 400 });
const data = {
title,
description: cleanMultiline(input.description || input.brief || '', 5000),
source: cleanText(input.source || defaults.source || 'import', 40),
sourceName: cleanText(input.sourceName || input.owner || defaults.sourceName || 'Prioritix feature set', 80),
status: cleanText(input.status || defaults.status || 'inbox', 40),
milestoneId: cleanText(input.milestoneId || input.milestone || defaults.milestoneId || 'inbox', 64),
impact: clampInt(input.impact, defaults.impact ?? 5),
effort: clampInt(input.effort, defaults.effort ?? 5, 1, 10),
confidence: clampInt(input.confidence, defaults.confidence ?? 6),
urgency: clampInt(input.urgency, defaults.urgency ?? 5),
rank: clampInt(input.rank, defaults.rank ?? 0, -100000, 100000),
labels: encodeList(input.labels || input.category || input.categories || []),
notes: cleanMultiline(input.notes || '', 4000),
archived: Boolean(input.archived ?? false),
};
data.score = scoreIdea(data);
return data;
}
function publicIdea(row) {
return {
id: row.$id,
@@ -174,30 +197,31 @@ app.get('/api/bootstrap', async (_req, res) => {
});
app.post('/api/ideas', requireAgent, async (req, res) => {
const title = cleanText(req.body.title, 180);
if (!title) return res.status(400).json({ error: 'title is required' });
const data = {
title,
description: cleanMultiline(req.body.description, 5000),
source: cleanText(req.body.source || 'human', 40),
sourceName: cleanText(req.body.sourceName || req.body.agent || '', 80),
status: cleanText(req.body.status || 'inbox', 40),
milestoneId: cleanText(req.body.milestoneId || 'inbox', 64),
impact: clampInt(req.body.impact, 5),
effort: clampInt(req.body.effort, 5, 1, 10),
confidence: clampInt(req.body.confidence, 6),
urgency: clampInt(req.body.urgency, 5),
rank: clampInt(req.body.rank, 0, -100000, 100000),
labels: encodeList(req.body.labels),
notes: cleanMultiline(req.body.notes, 4000),
archived: false,
};
data.score = scoreIdea(data);
const data = ideaDataFromInput(req.body, { source: 'human', status: 'inbox', milestoneId: 'inbox' });
const row = assertRow(await tables.createRow({ databaseId, tableId: ideasTableId, rowId: ID.unique(), data }));
await logActivity('idea.created', `Captured “${title}`, row.$id, data.source);
await logActivity('idea.created', `Captured “${data.title}`, row.$id, data.source);
res.status(201).json(publicIdea(row));
});
app.post('/api/feature-set/import', requireAgent, async (req, res) => {
const features = Array.isArray(req.body.features) ? req.body.features.slice(0, 100) : [];
if (!features.length) return res.status(400).json({ error: 'Feature set must include a non-empty features array.' });
const defaults = req.body.defaults && typeof req.body.defaults === 'object' ? req.body.defaults : {};
const created = [];
const errors = [];
for (const [index, feature] of features.entries()) {
try {
const data = ideaDataFromInput(feature, { source: 'import', sourceName: req.body.name || 'Prioritix feature set', ...defaults });
const row = assertRow(await tables.createRow({ databaseId, tableId: ideasTableId, rowId: ID.unique(), data }));
created.push(publicIdea(row));
} catch (error) {
errors.push({ index, title: cleanText(feature?.title || feature?.name || '', 180), error: error.message });
}
}
if (created.length) await logActivity('feature_set.imported', `Imported ${created.length} feature${created.length === 1 ? '' : 's'}`, '', req.body.name || 'feature-set-v1');
res.status(created.length ? 201 : 400).json({ ok: errors.length === 0, imported: created.length, created, errors });
});
app.patch('/api/ideas/:id', requireAgent, async (req, res) => {
const allowed = ['title', 'description', 'source', 'sourceName', 'status', 'milestoneId', 'impact', 'effort', 'confidence', 'urgency', 'rank', 'labels', 'notes', 'archived'];
const data = {};