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
+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(); } });