Add feature set import export
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user