diff --git a/public/app.js b/public/app.js
index e28771b..81f2ce4 100644
--- a/public/app.js
+++ b/public/app.js
@@ -50,7 +50,7 @@ const profiles = {
],
},
};
-const state = { ideas: [], milestones: [], activity: [], activeId: null, selected: null, profileId: localStorage.getItem('rank-profile') || 'mvp', undo: null };
+const state = { ideas: [], milestones: [], activity: [], activeId: null, selected: null, profileId: localStorage.getItem('rank-profile') || 'mvp', undo: null, recentPlacement: null };
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');
@@ -64,16 +64,18 @@ function activeIdeas(){ return state.ideas.filter(i => !i.archived && (!i.status
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 = `${escapeHtml(message)}${action ? '' : ''}`; 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 pct = total ? Math.round((sorted/total)*100) : 0; $('#progressCard').innerHTML = `${sorted} / ${total}features sorted
`; }
+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 = `${sorted} / ${total}features sorted
`; const home=$('#mobileHomeProgress'); if(home) home.innerHTML = `${sorted} / ${total} features sorted
${active} active · ${utilityIdeas().length} parked/investigate`; }
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 = `No active feature
Add a feature above and it will become the next card in the decision deck.
`; return; } featureDeck.innerHTML = `${escapeHtml(categoryOf(idea))}
${escapeHtml(idea.title)}
${escapeHtml(short(idea.description || 'No brief yet. Open details to add one.'))}
`; $('.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) => ``).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 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 = `${milestones.map(m=>`
${m.label}`).join('')}${positions.map((p,idx)=>`
`).join('')}${positions.slice(0,1).map(p=>`
${escapeHtml(short(p.idea.title,32))}${escapeHtml(categoryOf(p.idea))} · ${escapeHtml(p.z.label)}
`).join('')}
`; 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 renderGrid(){ sortingGrid.innerHTML = zones().map((z,idx) => { const placed = state.recentPlacement?.zoneId === z.id ? `${escapeHtml(state.recentPlacement.label)}${escapeHtml(short(state.recentPlacement.title,44))}
` : ''; return ``; }).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 = `${grouped.map(group=>`
${escapeHtml(group.z.label)}
${group.items.length ? group.items.map((idea,idx)=>``).join('') : '+ Add feature
'}`).join('')}
Parked / Investigate / Removed ·
View all `; $$('.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 = `${milestones.map(m=>`
${m.label}`).join('')}${positions.map((p,idx)=>`
`).join('')}${positions.slice(0,1).map(p=>`
${escapeHtml(short(p.idea.title,32))}${escapeHtml(categoryOf(p.idea))} · ${escapeHtml(p.z.label)}
`).join('')}
`; 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=>``).join('') : 'Clean utility lanesParked and investigate items will appear here.
'; $$('.mini-card[data-id]').forEach(c => c.addEventListener('click',()=>openDetail(c.dataset.id))); }
-function render(){ renderProfile(); renderProgress(); renderDeck(); renderGrid(); renderTimeline(); renderBacklog(); }
+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=>`${z.icon}${escapeHtml(z.label)}${state.ideas.filter(i=>!i.archived && i.status===z.status).length}
`).join(''); el.innerHTML = `${complete?'✓':'↻'}
${complete?'Great job!':'Review roadmap'}
${complete?'All active features are sorted.':'Keep sorting or export the current roadmap setup.'}
${sorted}Sorted
${remaining}Active
${profile().name}Profile
${groups}
`; $('#reviewExport')?.addEventListener('click',()=>$('#exportFeatureSet')?.click()); }
+function render(){ renderProfile(); renderProgress(); renderDeck(); renderGrid(); renderTimeline(); renderBacklog(); renderReview(); }
function sortedSetupFeatures(){
const ordered = state.ideas
.filter(i => !i.archived)
@@ -159,7 +161,7 @@ async function importFeatureSet(){
}
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); } }
+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) undoIdea(previous)); }catch(error){ Object.assign(idea, previous); render(); toast(error.message); } }
diff --git a/public/index.html b/public/index.html
index c20de4a..56fc72d 100644
--- a/public/index.html
+++ b/public/index.html
@@ -6,23 +6,23 @@
Prioritix — Feature Prioritization
-
+
-
+
Rank Prioritization Studio
@@ -43,6 +43,22 @@
+
+ 9:41Rank
+
+
Project overview
+
Rank Roadmap
+
Create a clear feature timeline from rough ideas, imports, and agent suggestions.
+
+
+
+
+
+
+
@@ -152,6 +172,6 @@
-
+