Files
rank/public/app.js
T
2026-05-23 22:03:59 +02:00

217 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const profiles = {
mvp: {
name: 'MVP (2x2)',
gridLabel: 'Sorting grid (MVP - 2x2)',
zones: [
{ id:'must', label:'Required / Must Have', copy:'Critical for the solution. Necessary for MVP.', icon:'☆', color:'#16A34A', bg:'rgba(22,163,74,.06)', milestoneId:'now', status:'must', left:13 },
{ id:'should', label:'Should Have', copy:'Important but not critical. Adds significant value.', icon:'○', color:'#2563EB', bg:'rgba(37,99,235,.055)', milestoneId:'next', status:'should', left:47 },
{ id:'nice', label:'Nice to Have', copy:'Good to include if possible. Not essential.', icon:'♡', color:'#F59E0B', bg:'rgba(245,158,11,.07)', milestoneId:'later', status:'nice', left:68 },
{ id:'stretch', label:'Stretch Goal', copy:'Low priority ideas. Consider for the future.', icon:'ϟ', color:'#7C3AED', bg:'rgba(124,58,237,.065)', milestoneId:'later', status:'stretch', left:88 },
],
},
rice: {
name: 'RICE Score',
gridLabel: 'Sorting grid (RICE)',
zones: [
{ id:'must', label:'High RICE / Build Now', copy:'Reach, impact, confidence all justify immediate work.', icon:'R', color:'#16A34A', bg:'rgba(22,163,74,.06)', milestoneId:'now', status:'must', left:13 },
{ id:'should', label:'Promising / Validate', copy:'Strong impact but needs confidence or reach proof.', icon:'I', color:'#2563EB', bg:'rgba(37,99,235,.055)', milestoneId:'next', status:'should', left:47 },
{ id:'nice', label:'Low Reach / Batch', copy:'Useful, but only after higher-reach work is handled.', icon:'C', color:'#F59E0B', bg:'rgba(245,158,11,.07)', milestoneId:'later', status:'nice', left:68 },
{ id:'stretch', label:'Expensive Bet', copy:'Interesting, but effort or uncertainty is too high right now.', icon:'E', color:'#7C3AED', bg:'rgba(124,58,237,.065)', milestoneId:'later', status:'stretch', left:88 },
],
},
value: {
name: 'Value vs Effort',
gridLabel: 'Sorting grid (Value vs Effort)',
zones: [
{ id:'must', label:'High Value / Low Effort', copy:'Quick win. Pull into the near-term roadmap.', icon:'↗', color:'#16A34A', bg:'rgba(22,163,74,.06)', milestoneId:'now', status:'must', left:13 },
{ id:'should', label:'High Value / High Effort', copy:'Strategic work. Shape before committing.', icon:'◆', color:'#2563EB', bg:'rgba(37,99,235,.055)', milestoneId:'next', status:'should', left:47 },
{ id:'nice', label:'Low Value / Low Effort', copy:'Polish work. Batch when context is cheap.', icon:'◇', color:'#F59E0B', bg:'rgba(245,158,11,.07)', milestoneId:'later', status:'nice', left:68 },
{ id:'stretch', label:'Low Value / High Effort', copy:'Avoid unless strategy changes.', icon:'×', color:'#7C3AED', bg:'rgba(124,58,237,.065)', milestoneId:'later', status:'stretch', left:88 },
],
},
kano: {
name: 'Kano Model',
gridLabel: 'Sorting grid (Kano)',
zones: [
{ id:'must', label:'Basic Expectation', copy:'Users will notice if this is missing.', icon:'B', color:'#16A34A', bg:'rgba(22,163,74,.06)', milestoneId:'now', status:'must', left:13 },
{ id:'should', label:'Performance Driver', copy:'More of this directly improves satisfaction.', icon:'P', color:'#2563EB', bg:'rgba(37,99,235,.055)', milestoneId:'next', status:'should', left:47 },
{ id:'nice', label:'Delighter', copy:'Memorable, but not necessary for usefulness.', icon:'D', color:'#F59E0B', bg:'rgba(245,158,11,.07)', milestoneId:'later', status:'nice', left:68 },
{ id:'stretch', label:'Indifferent / Future', copy:'Weak user pull. Keep it outside the core release.', icon:'?', color:'#7C3AED', bg:'rgba(124,58,237,.065)', milestoneId:'later', status:'stretch', left:88 },
],
},
custom: {
name: 'Custom',
gridLabel: 'Sorting grid (Custom)',
zones: [
{ id:'must', label:'Commit', copy:'This belongs in the next real delivery slice.', icon:'✓', color:'#16A34A', bg:'rgba(22,163,74,.06)', milestoneId:'now', status:'must', left:13 },
{ id:'should', label:'Shape', copy:'Worth pursuing after requirements are sharpened.', icon:'✎', color:'#2563EB', bg:'rgba(37,99,235,.055)', milestoneId:'next', status:'should', left:47 },
{ id:'nice', label:'Queue', copy:'Keep visible, but do not distract the team now.', icon:'≡', color:'#F59E0B', bg:'rgba(245,158,11,.07)', milestoneId:'later', status:'nice', left:68 },
{ id:'stretch', label:'Someday', copy:'A possible future bet, not part of this plan.', icon:'∞', color:'#7C3AED', bg:'rgba(124,58,237,.065)', milestoneId:'later', status:'stretch', left:88 },
],
},
};
const state = { ideas: [], milestones: [], activity: [], activeId: null, selected: null, profileId: localStorage.getItem('rank-profile') || 'mvp', undo: null, recentPlacement: null };
const sampleBacklog = {
format: 'prioritix-feature-set-v1',
name: 'Messy SaaS launch backlog',
features: [
{ title: 'Mobile sorting flow feels clumsy', description: 'Users can capture ideas, but the phone flow makes prioritizing feel like work instead of relief.', labels: ['Mobile', 'Activation'], impact: 9, effort: 4, confidence: 8, urgency: 8 },
{ title: 'Team voting on every feature', description: 'Stakeholders want input, but voting may create politics before the product has a clear decision model.', labels: ['Collaboration'], impact: 7, effort: 7, confidence: 4, urgency: 5 },
{ title: 'Export roadmap for sales calls', description: 'Turn sorted decisions into something founders can share with clients or internal buyers.', labels: ['Sales', 'Export'], impact: 8, effort: 5, confidence: 7, urgency: 7 },
{ title: 'Dark mode polish', description: 'Nice for taste, but unlikely to decide whether anyone trusts the core prioritization.', labels: ['Polish'], impact: 4, effort: 3, confidence: 8, urgency: 2 },
{ title: 'AI explains why an idea moved', description: 'Every ranking should show a reason, risk, and what evidence would change the decision.', labels: ['Trust', 'AI'], impact: 9, effort: 6, confidence: 6, urgency: 8 }
]
};
function loadSampleBacklog(){
const input = $('#featureSetInput');
if(!input) return;
input.value = JSON.stringify(sampleBacklog, null, 2);
document.querySelector('#feature-sets')?.scrollIntoView({ behavior:'smooth', block:'start' });
toast('Sample backlog loaded. Import it or replace it with your own chaos.');
}
const $ = (sel, root=document) => root.querySelector(sel);
const $$ = (sel, root=document) => Array.from(root.querySelectorAll(sel));
const featureDeck = $('#featureDeck'); const sortingGrid = $('#sortingGrid'); const timeline = $('#timeline');
const detail = $('#detail'); const detailForm = $('#detailForm'); const toastEl = $('#toast');
function profile(){ return profiles[state.profileId] || profiles.mvp; }
function zones(){ return profile().zones; }
function escapeHtml(value){ return String(value ?? '').replace(/[&<>'"]/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[ch])); }
function short(text, len=130){ const v=String(text||'').trim(); return v.length>len ? `${v.slice(0,len-1)}` : v; }
function categoryOf(idea){ return (idea.labels && idea.labels[0]) || idea.sourceName || 'UI / UX'; }
function activeIdeas(){ return state.ideas.filter(i => !i.archived && (!i.status || i.status === 'inbox' || i.milestoneId === 'inbox')); }
function sortedIdeas(){ return state.ideas.filter(i => !i.archived && !activeIdeas().some(a => a.id === i.id)); }
function utilityIdeas(){ return state.ideas.filter(i => ['park','investigate'].includes(i.status)); }
function zoneFor(idea){ return zones().find(z=>z.status===idea.status) || profiles.mvp.zones.find(z=>z.status===idea.status) || zones()[0]; }
function isMobile(){ return window.matchMedia('(max-width: 680px)').matches; }
async function api(path, options={}){ const res = await fetch(path,{ headers:{'Content-Type':'application/json',...(options.headers||{})}, ...options, body: options.body && typeof options.body !== 'string' ? JSON.stringify(options.body) : options.body }); const text=await res.text(); const data=text?JSON.parse(text):null; if(!res.ok) throw new Error(data?.error||res.statusText); return data; }
function toast(message, action){ toastEl.innerHTML = `<span>${escapeHtml(message)}</span>${action ? '<button type="button">Undo</button>' : ''}`; toastEl.hidden = false; const btn = $('button', toastEl); if(btn) btn.addEventListener('click', action, { once:true }); clearTimeout(toastEl._timer); toastEl._timer = setTimeout(()=>{toastEl.hidden=true}, action ? 5200 : 2400); }
function currentActive(){ const queue = activeIdeas().sort((a,b)=>(a.rank-b.rank)||(new Date(a.createdAt)-new Date(b.createdAt))); if(!state.activeId || !queue.some(i=>i.id===state.activeId)) state.activeId = queue[0]?.id || null; return queue.find(i=>i.id===state.activeId) || null; }
function renderProgress(){ const total = state.ideas.filter(i=>!i.archived).length; const sorted = sortedIdeas().length; const active = activeIdeas().length; const pct = total ? Math.round((sorted/total)*100) : 0; $('#progressCard').innerHTML = `<strong>${sorted} / ${total}</strong><span>features sorted</span><div class="progressbar"><i style="width:${pct}%"></i></div>`; const home=$('#mobileHomeProgress'); if(home) home.innerHTML = `<strong>${sorted} / ${total} features sorted</strong><div class="progressbar"><i style="width:${pct}%"></i></div><span>${active} active · ${utilityIdeas().length} parked/investigate</span>`; }
function renderProfile(){ const p=profile(); $('.profile-card strong').textContent=p.name; $('#gridLabel').textContent=p.gridLabel; $$('#profileMenu [data-profile]').forEach(btn=>{ const active=btn.dataset.profile===state.profileId; btn.classList.toggle('selected', active); btn.textContent = profiles[btn.dataset.profile].name + (active ? ' ✓' : ''); }); }
function renderDeck(){ const idea = currentActive(); if(!idea){ featureDeck.innerHTML = `<div class="empty-deck"><strong>No active feature</strong><br>Add a feature above and it will become the next card in the decision deck.</div>`; return; } featureDeck.innerHTML = `<article class="feature-card" draggable="true" data-id="${escapeHtml(idea.id)}"><div class="feature-meta"><span class="category-dot"></span>${escapeHtml(categoryOf(idea))}</div><h2>${escapeHtml(idea.title)}</h2><p>${escapeHtml(short(idea.description || 'No brief yet. Open details to add one.'))}</p><button type="button" class="open-details" aria-label="Open details">↗</button></article>`; $('.feature-card').addEventListener('dragstart', e => e.dataTransfer.setData('text/plain', idea.id)); $('.open-details').addEventListener('click', () => openDetail(idea.id)); $('.feature-card').addEventListener('dblclick', () => openDetail(idea.id)); }
function renderGrid(){ sortingGrid.innerHTML = zones().map((z,idx) => { const placed = state.recentPlacement?.zoneId === z.id ? `<div class="placed-card"><small>${escapeHtml(state.recentPlacement.label)}</small><strong>${escapeHtml(short(state.recentPlacement.title,44))}</strong></div>` : ''; return `<button type="button" class="zone" data-zone="${z.id}" style="--zone-color:${z.color};--zone-bg:${z.bg}"><div><div class="zone-icon">${z.icon}</div><strong>${z.label}</strong><span>${z.copy}</span>${placed}<div class="drop-hint">${idx+1}</div></div></button>`; }).join(''); $$('.zone').forEach(el => { el.addEventListener('click', () => placeActive(el.dataset.zone)); el.addEventListener('dragover', e => { e.preventDefault(); el.classList.add('drag-over'); }); el.addEventListener('dragleave', () => el.classList.remove('drag-over')); el.addEventListener('drop', e => { e.preventDefault(); el.classList.remove('drag-over'); const id=e.dataTransfer.getData('text/plain') || state.activeId; placeFeature(id, el.dataset.zone); }); }); }
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 headCopy=$('.timeline-head p'); if(headCopy) headCopy.textContent = isMobile() ? 'Sorted features become vertical roadmap cards. Tap a card to inspect details.' : 'Sorted features become nodes. Drag nodes horizontally to reorder the roadmap; click a node to inspect details.'; 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'}]; if(isMobile()){ const grouped = zones().map(z => ({z, items: items.filter(i=>i.status===z.status)})); timeline.innerHTML = `<div class="roadmap-list">${grouped.map(group=>`<section class="roadmap-phase" style="--node-color:${group.z.color}"><h3>${escapeHtml(group.z.label)}</h3>${group.items.length ? group.items.map((idea,idx)=>`<button class="roadmap-item" data-id="${escapeHtml(idea.id)}"><span class="roadmap-dot">${idx+1}</span><span><strong>${escapeHtml(short(idea.title,52))}</strong><small>${escapeHtml(categoryOf(idea))} · ${escapeHtml(short(idea.description,64))}</small></span><b>⋮</b></button>`).join('') : '<div class="roadmap-empty">+ Add feature</div>'}</section>`).join('')}<div class="roadmap-utilities">Parked / Investigate / Removed · <a href="#backlog">View all</a></div></div>`; $$('.roadmap-item', timeline).forEach(n => n.addEventListener('click', () => openDetail(n.dataset.id))); return; } 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 renderReview(){ const el=$('#reviewPanel'); if(!el) return; const total=state.ideas.filter(i=>!i.archived).length; const sorted=sortedIdeas().length; const remaining=activeIdeas().length; const complete=total>0 && remaining===0; const groups=zones().map(z=>`<div class="review-row" style="--node-color:${z.color}"><span>${z.icon}</span><strong>${escapeHtml(z.label)}</strong><b>${state.ideas.filter(i=>!i.archived && i.status===z.status).length}</b></div>`).join(''); el.innerHTML = `<div class="review-hero"><div class="success-mark">${complete?'✓':'↻'}</div><h2>${complete?'Great job!':'Review roadmap'}</h2><p>${complete?'All active features are sorted.':'Keep sorting or export the current roadmap setup.'}</p></div><div class="review-stats"><div><strong>${sorted}</strong><span>Sorted</span></div><div><strong>${remaining}</strong><span>Active</span></div><div><strong>${profile().name}</strong><span>Profile</span></div></div><div class="review-groups">${groups}</div><div class="review-actions"><a href="#prioritize">Continue sorting</a><button type="button" id="reviewExport">Export roadmap</button></div>`; $('#reviewExport')?.addEventListener('click',()=>$('#exportFeatureSet')?.click()); }
function render(){ renderProfile(); renderProgress(); renderDeck(); renderGrid(); renderTimeline(); renderBacklog(); renderReview(); }
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}; state.recentPlacement = { id, zoneId, title: idea.title, label: categoryOf(idea) }; Object.assign(idea,{status:z.status,milestoneId:z.milestoneId,rank:Math.round(z.left*1000)}); render(); clearTimeout(state.recentPlacementTimer); state.recentPlacementTimer=setTimeout(()=>{ if(state.recentPlacement?.id===id){ state.recentPlacement=null; renderGrid(); } }, 1800); 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){ state.recentPlacement=null; Object.assign(idea, previous); render(); toast(error.message); } }
async function undoIdea(previous){ if(!previous?.id) return; try{ const updated=await api(`/api/ideas/${previous.id}`,{method:'PATCH',body:{status:previous.status,milestoneId:previous.milestoneId,rank:previous.rank,archived:previous.archived}}); replaceIdea(updated); state.activeId=updated.status === 'inbox' ? updated.id : state.activeId; render(); toast('Undone'); }catch(error){ toast(error.message); } }
async function setUtility(action){ const idea=currentActive(); if(!idea) return toast('No active feature'); if(action==='remove') return archiveIdea(idea.id); const previous={...idea}; Object.assign(idea,{status:action,milestoneId:'later',rank:Date.now()%100000}); render(); try{ const updated=await api(`/api/ideas/${idea.id}`,{method:'PATCH',body:{status:action,milestoneId:'later',rank:idea.rank}}); replaceIdea(updated); state.activeId=null; render(); toast(action === 'park' ? 'Parked' : 'Sent to investigate', () => undoIdea(previous)); } catch(error){ Object.assign(idea, previous); render(); toast(error.message); } }
async function reorderOnTimeline(id,e){ const line=e.currentTarget; const idea=state.ideas.find(i=>i.id===id); if(!idea) return; const rect=line.getBoundingClientRect(); const pct=Math.max(6,Math.min(94,((e.clientX-rect.left)/rect.width)*100)); const z=zones().reduce((best,item)=>Math.abs(item.left-pct)<Math.abs(best.left-pct)?item:best,zones()[0]); const previous={...idea}; Object.assign(idea,{status:z.status,milestoneId:z.milestoneId,rank:Math.round(pct*1000)}); render(); try{ const data=await api('/api/reorder',{method:'POST',body:{updates:[{id,rank:idea.rank,status:z.status,milestoneId:z.milestoneId}]}}); (data.changed||[]).forEach(replaceIdea); render(); toast(`Moved to ${z.label}`, () => undoIdea(previous)); }catch(error){ Object.assign(idea, previous); render(); toast(error.message); } }
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); } }
$('#sampleBacklog')?.addEventListener('click', loadSampleBacklog);
$('#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(); } });
$$('[data-utility]').forEach(b=>b.addEventListener('click',()=>setUtility(b.dataset.utility)));
$('#closeDetail').addEventListener('click',closeDetail); $('#archiveIdea').addEventListener('click',()=>archiveIdea()); $('#parkDetail').addEventListener('click',async()=>{ if(!state.selected) return; const id=state.selected; const previous=state.ideas.find(i=>i.id===id); try{ const updated=await api(`/api/ideas/${id}`,{method:'PATCH',body:{status:'park',milestoneId:'later'}}); replaceIdea(updated); closeDetail(); render(); toast('Parked', previous ? () => undoIdea(previous) : null); }catch(error){toast(error.message)} });
detailForm.addEventListener('submit', async e => { e.preventDefault(); if(!state.selected) return; const payload=Object.fromEntries(new FormData(detailForm).entries()); payload.labels=String(payload.labels||'').split(',').map(s=>s.trim()).filter(Boolean); try{ const idea=await api(`/api/ideas/${state.selected}`,{method:'PATCH',body:payload}); replaceIdea(idea); closeDetail(); render(); toast('Saved'); }catch(error){ toast(error.message); } });
$('#profileToggle').addEventListener('click',()=>{ const m=$('#profileMenu'); m.hidden=!m.hidden; });
$$('#profileMenu [data-profile]').forEach(btn=>btn.addEventListener('click',()=>{ state.profileId=btn.dataset.profile; localStorage.setItem('rank-profile',state.profileId); $('#profileMenu').hidden=true; render(); toast(`Profile: ${profile().name}`); }));
document.addEventListener('keydown', e => { const typing=['INPUT','TEXTAREA'].includes(document.activeElement.tagName); if(e.key==='/' && !typing){ e.preventDefault(); $('#title').focus(); return; } if(e.key==='Escape') closeDetail(); if(typing) return; if(['1','2','3','4'].includes(e.key)){ e.preventDefault(); placeActive(zones()[Number(e.key)-1]?.id); } if(e.key.toLowerCase()==='p') setUtility('park'); if(e.key.toLowerCase()==='i') setUtility('investigate'); if(e.key.toLowerCase()==='u' && toastEl.querySelector('button')) toastEl.querySelector('button').click(); });
async function load(){ try{ const data=await api('/api/bootstrap'); state.ideas=data.ideas||[]; state.milestones=data.milestones||[]; state.activity=data.activity||[]; render(); } catch(error){ featureDeck.innerHTML=`<div class="empty-deck"><strong>Backend is grumpy</strong><br>${escapeHtml(error.message)}</div>`; renderGrid(); } }
load();