diff --git a/public/app.js b/public/app.js
index fd258dc..6d5cfe7 100644
--- a/public/app.js
+++ b/public/app.js
@@ -1,255 +1,129 @@
-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 sample = {
+ idea: 'I’m building a lightweight product feedback tool for people with too many ideas and too many possible features. It should feel useful before becoming a workspace.',
+ optionsText: `- Ranked feedback map for pasted feature lists
+- Expert reflections on the top options
+- Accounts and saved workspaces
+- Team voting on feature priority
+- Exportable decision brief for Slack or Notion
+- Custom criteria builder
+- Paid deeper product strategy pass`,
+ context: 'MVP, solo builder, needs to feel valuable in under two minutes, avoid dashboard swamp.',
+ mode: 'progress',
};
-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 }
- ]
-};
-const aiReadyPrompt = `You are preparing features for Rank / Prioritix, a prioritization workbench that imports Prioritix Feature Set v1 JSON. Convert my messy backlog, notes, stakeholder requests, or product ideas into valid JSON only — no markdown, no commentary.
-Return this exact shape:
-{
- "format": "prioritix-feature-set-v1",
- "name": "Short project or backlog name",
- "features": [
- {
- "title": "Clear feature title",
- "description": "Plain-language reason this feature matters and what outcome it supports.",
- "labels": ["Category", "Optional second category"],
- "impact": 1-10,
- "effort": 1-10,
- "confidence": 1-10,
- "urgency": 1-10,
- "notes": "Risks, assumptions, dependencies, or evidence that would change the ranking."
- }
- ]
+const form = document.querySelector('#rankForm');
+const results = document.querySelector('#results');
+const toastEl = document.querySelector('#toast');
+
+function escapeHtml(value) {
+ return String(value ?? '').replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]));
}
-Scoring guidance: impact means user/business value, effort means delivery difficulty, confidence means how sure the evidence is, urgency means time sensitivity. Use the full 1-10 range honestly. Split vague bundles into separate features. Merge duplicates. Label polish as polish, bets as validation/research, and must-have product work as core. If evidence is weak, lower confidence rather than pretending. Keep titles short enough to scan on a card.`;
-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.');
+function metricPct(value) {
+ return Math.max(0, Math.min(100, Math.round(Number(value || 0) * 10)));
}
-async function copyAiPrompt(){
- const status = $('#aiPromptStatus');
+
+function toast(message) {
+ toastEl.textContent = message;
+ toastEl.hidden = false;
+ clearTimeout(toastEl._timer);
+ toastEl._timer = setTimeout(() => { toastEl.hidden = true; }, 2600);
+}
+
+function fillSample() {
+ form.idea.value = sample.idea;
+ form.optionsText.value = sample.optionsText;
+ form.context.value = sample.context;
+ form.mode.value = sample.mode;
+ document.querySelector('#try')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ toast('Sample loaded. Run it or replace it with your own mess.');
+}
+
+function laneClass(lane) {
+ return `lane-${lane?.id || 'defer'}`;
+}
+
+function renderMetrics(metrics) {
+ const items = [
+ ['Value', metrics.value],
+ ['Feasible', metrics.feasibility],
+ ['Confidence', metrics.confidence],
+ ['Revenue', metrics.revenue],
+ ['Risk', metrics.risk],
+ ];
+ return `
${items.map(([label, value]) => `
+
${label}
+ `).join('')}
`;
+}
+
+function renderResults(data) {
+ const ranked = data.ranked || [];
+ const brief = data.brief || {};
+ results.hidden = false;
+ results.innerHTML = `
+
+
${escapeHtml(data.mode?.label || 'Ranked feedback')}
+
${escapeHtml(brief.headline || 'Ranked feedback map')}
+
${escapeHtml(brief.summary || '')}
+
+
+
+ ${ranked.map((item) => `
+
+
+ #${item.rank}
+ ${escapeHtml(item.lane?.label || 'Ranked')}
+ ${item.metrics.score}
+
+ ${escapeHtml(item.title)}
+ ${item.description && item.description !== item.title ? `${escapeHtml(item.description)}
` : ''}
+ Why: ${escapeHtml(item.reason)}.
+ Concern: ${escapeHtml(item.concern)}
+ ${renderMetrics(item.metrics)}
+
+ `).join('')}
+
+
+
+
+ Next 48 hours
+ ${(brief.next48Hours || []).map((step) => `${escapeHtml(step)} `).join('')}
+
+ ${(brief.expertReflections || []).map((reflection) => `
+
+ ${escapeHtml(reflection.lens)}
+ ${escapeHtml(reflection.text)}
+
+ `).join('')}
+
+ ${escapeHtml(brief.caution || 'First-pass judgement, not an oracle.')}
+ `;
+ results.scrollIntoView({ behavior: 'smooth', block: 'start' });
+}
+
+async function createFeedbackMap(event) {
+ event.preventDefault();
+ const submit = form.querySelector('button[type="submit"]');
+ const payload = Object.fromEntries(new FormData(form).entries());
+ submit.disabled = true;
+ submit.textContent = 'Ranking…';
try {
- await navigator.clipboard.writeText(aiReadyPrompt);
- if(status) status.textContent = 'Copied. Paste it into any AI, then paste the returned JSON back here.';
- toast('AI-ready prompt copied');
- } catch {
- const input = $('#featureSetInput');
- if(input){
- input.value = aiReadyPrompt;
- input.focus();
- input.select();
- }
- if(status) status.textContent = 'Clipboard blocked — the prompt is selected in the text area for manual copy.';
- toast('Clipboard blocked. Prompt selected for manual copy.');
+ const response = await fetch('/api/rank-feedback', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ const data = await response.json();
+ if (!response.ok) throw new Error(data.error || 'Could not rank this list.');
+ renderResults(data);
+ } catch (error) {
+ toast(error.message);
+ } finally {
+ submit.disabled = false;
+ submit.textContent = 'Create ranked feedback map';
}
}
-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 => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[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 = `${escapeHtml(message)} ${action ? 'Undo ' : ''}`; 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 = `${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) => { const placed = state.recentPlacement?.zoneId === z.id ? `${escapeHtml(state.recentPlacement.label)} ${escapeHtml(short(state.recentPlacement.title,44))}
` : ''; return `${z.icon}
${z.label} ${z.copy} ${placed}
${idx+1}
`; }).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)=>`${idx+1} ${escapeHtml(short(idea.title,52))} ${escapeHtml(categoryOf(idea))} · ${escapeHtml(short(idea.description,64))} ⋮ `).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)=>`
${idx+1} `).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=>`${escapeHtml(i.title)} ${escapeHtml(i.status)} · ${escapeHtml(short(i.description,70))} `).join('') : 'Clean utility lanes Parked and investigate items will appear here.
'; $$('.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=>`${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)
- .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) 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);
-$('#copyAiPrompt')?.addEventListener('click', copyAiPrompt);
-$('#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=`Backend is grumpy ${escapeHtml(error.message)}
`; renderGrid(); } }
-load();
+form.addEventListener('submit', createFeedbackMap);
+document.querySelector('#loadSample')?.addEventListener('click', fillSample);
+document.querySelector('#loadSampleTop')?.addEventListener('click', fillSample);
diff --git a/public/index.html b/public/index.html
index 9fba48c..9ae20c8 100644
--- a/public/index.html
+++ b/public/index.html
@@ -3,224 +3,126 @@
-
- Rank — Work your ideas before they work you
-
-
+
+
+ Ranker — ranked feedback maps for messy decisions
+
-
-
-
-
+
-
For scattered founders, product teams, and backlog-heavy brains
-
Your backlog is not a roadmap.
-
Rank is a nice-looking tool that understands your chaos. Paste messy ideas and work them into a clear build order before they start working you.
+
Decision feedback for scatterminds, builders, and structured people
+
Dump your options. See what deserves attention first.
+
Ranker turns a messy list of ideas, features, offers, roadmap items, or next moves into a ranked feedback map: what to test, build, defer, or drop — and why.
-
-
Build now
+
+ Do first
Validate next
Park safely
- Explain why
+ Expert reflections
-
-
-
Before
-
Messy idea pile
-
- AI onboarding coach
- Team voting
- Dark mode
- Stripe export
- Better mobile sorting
-
+
+
+
+ Ranked Feedback Map
+ Fastest useful progress
- →
-
-
After
-
Defendable build order
-
- Build now Mobile sorting removes the biggest usage friction.
- Validate next Team voting needs proof before it becomes process theatre.
- Park Dark mode is nice, not the reason anyone buys.
-
+
+ Manual offer critique High signal, easy to test, clear buyer pain.
+ Pricing calculator Useful, but generic unless paired with judgement.
+ Client dashboard Park it. Too much machinery before proof.
+
+
+
Pattern noticed
+
Your strongest wedge is judgement, not automation. Test whether people want their offer criticized before building a platform around it.
-
-
-
-
- No dashboard first. Proof before controls. You see the transformation before learning the system.
- No fake AI mysticism. Every decision needs a plain reason, risk, and next evidence.
- No surprise gate. Work the ideas first; export, save, and deeper workflows come after value is obvious.
-
-
-
-
-
- 9:41 Rank
-
-
Project overview
-
Work your ideas
-
Paste the chaos. Sort what to build now, validate next, and park without guilt.
-
-
-
-
-
-
-
-
-
-
Paste the chaos
-
Drop in the rough list. Rank turns it into a build order.
-
Start with a messy backlog, stakeholder requests, or five half-formed ideas. Rank helps you decide what to build now, what needs evidence, what can wait, and what is just noise wearing a nice hat. JSON import still works if you need it.
-
-
-
-
- Upload .json
- Copy AI-ready prompt
- Import features
- Export sorted setup
-
-
Copies a prompt that makes another AI return import-ready Rank JSON.
-
-
-
-
-
- Active feature
-
-
- ▷ Skip
- ↓ Postpone
-
-
-
-
- Sorting grid (MVP - 2x2)
-
- Shortcuts: 1 -4 place active card · P park · I investigate · U undo · / capture
-
-
-
-
- ⚑ Park Valid idea, not relevant now. Revisit later.
- ⌕ Investigate Needs more research or validation.
- ⌫ Remove Not a good fit. Remove from project.
-
-
-
-
-
-
Timeline view
-
Sorted features become nodes. Drag nodes horizontally to reorder the roadmap; click a node to inspect details.
-
-
− + ⛶
-
-
-
-
-
- Backlog / utility states
-
-
-
-
-
-
-
-