Reframe Ranker as feedback map MVP
This commit is contained in:
+120
-246
@@ -1,255 +1,129 @@
|
|||||||
const profiles = {
|
const sample = {
|
||||||
mvp: {
|
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.',
|
||||||
name: 'MVP (2x2)',
|
optionsText: `- Ranked feedback map for pasted feature lists
|
||||||
gridLabel: 'Sorting grid (MVP - 2x2)',
|
- Expert reflections on the top options
|
||||||
zones: [
|
- Accounts and saved workspaces
|
||||||
{ 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 },
|
- Team voting on feature priority
|
||||||
{ 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 },
|
- Exportable decision brief for Slack or Notion
|
||||||
{ 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 },
|
- Custom criteria builder
|
||||||
{ 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 },
|
- Paid deeper product strategy pass`,
|
||||||
],
|
context: 'MVP, solo builder, needs to feel valuable in under two minutes, avoid dashboard swamp.',
|
||||||
},
|
mode: 'progress',
|
||||||
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 }
|
|
||||||
]
|
|
||||||
};
|
|
||||||
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:
|
const form = document.querySelector('#rankForm');
|
||||||
{
|
const results = document.querySelector('#results');
|
||||||
"format": "prioritix-feature-set-v1",
|
const toastEl = document.querySelector('#toast');
|
||||||
"name": "Short project or backlog name",
|
|
||||||
"features": [
|
function escapeHtml(value) {
|
||||||
{
|
return String(value ?? '').replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]));
|
||||||
"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."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 metricPct(value) {
|
||||||
function loadSampleBacklog(){
|
return Math.max(0, Math.min(100, Math.round(Number(value || 0) * 10)));
|
||||||
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.');
|
|
||||||
}
|
}
|
||||||
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 `<div class="metrics">${items.map(([label, value]) => `
|
||||||
|
<div class="metric"><span>${label}</span><i><b style="width:${metricPct(value)}%"></b></i></div>
|
||||||
|
`).join('')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResults(data) {
|
||||||
|
const ranked = data.ranked || [];
|
||||||
|
const brief = data.brief || {};
|
||||||
|
results.hidden = false;
|
||||||
|
results.innerHTML = `
|
||||||
|
<div class="results-head">
|
||||||
|
<p class="eyebrow">${escapeHtml(data.mode?.label || 'Ranked feedback')}</p>
|
||||||
|
<h2>${escapeHtml(brief.headline || 'Ranked feedback map')}</h2>
|
||||||
|
<p>${escapeHtml(brief.summary || '')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ranked-list">
|
||||||
|
${ranked.map((item) => `
|
||||||
|
<article class="rank-card ${laneClass(item.lane)}">
|
||||||
|
<div class="rank-topline">
|
||||||
|
<span class="rank-number">#${item.rank}</span>
|
||||||
|
<span class="lane-pill">${escapeHtml(item.lane?.label || 'Ranked')}</span>
|
||||||
|
<strong>${item.metrics.score}</strong>
|
||||||
|
</div>
|
||||||
|
<h3>${escapeHtml(item.title)}</h3>
|
||||||
|
${item.description && item.description !== item.title ? `<p class="item-description">${escapeHtml(item.description)}</p>` : ''}
|
||||||
|
<p><b>Why:</b> ${escapeHtml(item.reason)}.</p>
|
||||||
|
<p><b>Concern:</b> ${escapeHtml(item.concern)}</p>
|
||||||
|
${renderMetrics(item.metrics)}
|
||||||
|
</article>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="brief-grid">
|
||||||
|
<article class="brief-card next-card">
|
||||||
|
<span>Next 48 hours</span>
|
||||||
|
<ol>${(brief.next48Hours || []).map((step) => `<li>${escapeHtml(step)}</li>`).join('')}</ol>
|
||||||
|
</article>
|
||||||
|
${(brief.expertReflections || []).map((reflection) => `
|
||||||
|
<article class="brief-card">
|
||||||
|
<span>${escapeHtml(reflection.lens)}</span>
|
||||||
|
<p>${escapeHtml(reflection.text)}</p>
|
||||||
|
</article>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
<p class="caution">${escapeHtml(brief.caution || 'First-pass judgement, not an oracle.')}</p>
|
||||||
|
`;
|
||||||
|
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 {
|
try {
|
||||||
await navigator.clipboard.writeText(aiReadyPrompt);
|
const response = await fetch('/api/rank-feedback', {
|
||||||
if(status) status.textContent = 'Copied. Paste it into any AI, then paste the returned JSON back here.';
|
method: 'POST',
|
||||||
toast('AI-ready prompt copied');
|
headers: { 'Content-Type': 'application/json' },
|
||||||
} catch {
|
body: JSON.stringify(payload),
|
||||||
const input = $('#featureSetInput');
|
});
|
||||||
if(input){
|
const data = await response.json();
|
||||||
input.value = aiReadyPrompt;
|
if (!response.ok) throw new Error(data.error || 'Could not rank this list.');
|
||||||
input.focus();
|
renderResults(data);
|
||||||
input.select();
|
} catch (error) {
|
||||||
}
|
toast(error.message);
|
||||||
if(status) status.textContent = 'Clipboard blocked — the prompt is selected in the text area for manual copy.';
|
} finally {
|
||||||
toast('Clipboard blocked. Prompt selected for manual copy.');
|
submit.disabled = false;
|
||||||
|
submit.textContent = 'Create ranked feedback map';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const $ = (sel, root=document) => root.querySelector(sel);
|
form.addEventListener('submit', createFeedbackMap);
|
||||||
const $$ = (sel, root=document) => Array.from(root.querySelectorAll(sel));
|
document.querySelector('#loadSample')?.addEventListener('click', fillSample);
|
||||||
const featureDeck = $('#featureDeck'); const sortingGrid = $('#sortingGrid'); const timeline = $('#timeline');
|
document.querySelector('#loadSampleTop')?.addEventListener('click', fillSample);
|
||||||
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 = `<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);
|
|
||||||
$('#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=`<div class="empty-deck"><strong>Backend is grumpy</strong><br>${escapeHtml(error.message)}</div>`; renderGrid(); } }
|
|
||||||
load();
|
|
||||||
|
|||||||
+103
-201
@@ -3,224 +3,126 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="#061B33" />
|
<meta name="theme-color" content="#101626" />
|
||||||
<title>Rank — Work your ideas before they work you</title>
|
<meta name="rank-version" content="2.0.0-feedback-map-mvp" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<title>Ranker — ranked feedback maps for messy decisions</title>
|
||||||
<link rel="stylesheet" href="/styles.css?v=rank-sales-20260523-2" />
|
<link rel="stylesheet" href="/styles.css?v=2.0.0-feedback-map-mvp" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-shell">
|
<main class="page-shell">
|
||||||
<aside class="sidebar" aria-label="Main navigation">
|
<section class="hero" aria-labelledby="hero-title">
|
||||||
<div class="brand-mark" aria-label="Prioritix">P</div>
|
<nav class="topline" aria-label="Ranker navigation">
|
||||||
<nav>
|
<a class="brand" href="#top" aria-label="Ranker home"><span>R</span>Ranker</a>
|
||||||
<a class="active" href="#home"><span>⌂</span><b>Home</b></a>
|
<div class="nav-links">
|
||||||
<a href="#prioritize"><span>☆</span><b>Sort</b></a>
|
<a href="#try">Try it</a>
|
||||||
<a href="#roadmap"><span>◇</span><b>Roadmap</b></a>
|
<a href="#proof">Output</a>
|
||||||
<a href="#review"><span>✓</span><b>Review</b></a>
|
<a href="#why">Why</a>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<button class="collapse" type="button" title="Collapse sidebar">←</button>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main class="workspace">
|
<div class="hero-grid" id="top">
|
||||||
<section class="sales-hero" aria-label="Rank product promise">
|
|
||||||
<div class="hero-copy">
|
<div class="hero-copy">
|
||||||
<span class="eyebrow">For scattered founders, product teams, and backlog-heavy brains</span>
|
<p class="eyebrow">Decision feedback for scatterminds, builders, and structured people</p>
|
||||||
<h1>Your backlog is not a roadmap.</h1>
|
<h1 id="hero-title">Dump your options. See what deserves attention first.</h1>
|
||||||
<p class="hero-lede">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.</p>
|
<p class="lede">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.</p>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<a class="primary-cta" href="#feature-sets">Paste my messy backlog</a>
|
<a class="button primary" href="#try">Rank a messy list</a>
|
||||||
<button class="secondary-cta" type="button" id="sampleBacklog">Try sample backlog</button>
|
<button class="button ghost" type="button" id="loadSampleTop">Load sample</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="trust-row" aria-label="What Rank gives you">
|
<div class="promise-row" aria-label="What Ranker returns">
|
||||||
<span>Build now</span>
|
<span>Do first</span>
|
||||||
<span>Validate next</span>
|
<span>Validate next</span>
|
||||||
<span>Park safely</span>
|
<span>Park safely</span>
|
||||||
<span>Explain why</span>
|
<span>Expert reflections</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="proof-card" aria-label="Before and after example">
|
|
||||||
<div class="proof-column messy">
|
<article class="artifact-card" aria-label="Sample ranked feedback map">
|
||||||
<span>Before</span>
|
<div class="artifact-head">
|
||||||
<h2>Messy idea pile</h2>
|
<span>Ranked Feedback Map</span>
|
||||||
<ul>
|
<b>Fastest useful progress</b>
|
||||||
<li>AI onboarding coach</li>
|
|
||||||
<li>Team voting</li>
|
|
||||||
<li>Dark mode</li>
|
|
||||||
<li>Stripe export</li>
|
|
||||||
<li>Better mobile sorting</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="proof-arrow" aria-hidden="true">→</div>
|
<ol class="mini-ranking">
|
||||||
<div class="proof-column clear">
|
<li><strong>Manual offer critique</strong><small>High signal, easy to test, clear buyer pain.</small></li>
|
||||||
<span>After</span>
|
<li><strong>Pricing calculator</strong><small>Useful, but generic unless paired with judgement.</small></li>
|
||||||
<h2>Defendable build order</h2>
|
<li><strong>Client dashboard</strong><small>Park it. Too much machinery before proof.</small></li>
|
||||||
<ol>
|
</ol>
|
||||||
<li><b>Build now</b><small>Mobile sorting removes the biggest usage friction.</small></li>
|
<div class="mini-brief">
|
||||||
<li><b>Validate next</b><small>Team voting needs proof before it becomes process theatre.</small></li>
|
<strong>Pattern noticed</strong>
|
||||||
<li><b>Park</b><small>Dark mode is nice, not the reason anyone buys.</small></li>
|
<p>Your strongest wedge is judgement, not automation. Test whether people want their offer criticized before building a platform around it.</p>
|
||||||
</ol>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="approach-strip" aria-label="How Rank approaches you">
|
|
||||||
<article><strong>No dashboard first.</strong><span>Proof before controls. You see the transformation before learning the system.</span></article>
|
|
||||||
<article><strong>No fake AI mysticism.</strong><span>Every decision needs a plain reason, risk, and next evidence.</span></article>
|
|
||||||
<article><strong>No surprise gate.</strong><span>Work the ideas first; export, save, and deeper workflows come after value is obvious.</span></article>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<header class="topbar" id="home">
|
|
||||||
<button class="menu-button" type="button" aria-label="Menu">☰</button>
|
|
||||||
<div class="project-title">
|
|
||||||
<h1>Rank idea workbench</h1>
|
|
||||||
<p><strong>Project goal:</strong> turn rough feature ideas into a clear, defendable build order.</p>
|
|
||||||
</div>
|
|
||||||
<div class="profile-card">
|
|
||||||
<span>Sorting profile</span>
|
|
||||||
<strong>MVP (2x2)</strong>
|
|
||||||
<button type="button" id="profileToggle" aria-label="Change sorting profile">⌄</button>
|
|
||||||
<div class="profile-menu" id="profileMenu" hidden>
|
|
||||||
<button type="button" class="selected" data-profile="mvp">MVP (2x2) ✓</button>
|
|
||||||
<button type="button" data-profile="rice">RICE Score</button>
|
|
||||||
<button type="button" data-profile="value">Value vs Effort</button>
|
|
||||||
<button type="button" data-profile="kano">Kano Model</button>
|
|
||||||
<button type="button" data-profile="custom">Custom</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="progress-card" id="progressCard"></div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="mobile-home" id="mobileHome" aria-label="Project overview">
|
|
||||||
<div class="mobile-statusbar"><span>9:41</span><span>Rank</span></div>
|
|
||||||
<div class="mobile-project-card">
|
|
||||||
<span class="eyebrow">Project overview</span>
|
|
||||||
<h2>Work your ideas</h2>
|
|
||||||
<p>Paste the chaos. Sort what to build now, validate next, and park without guilt.</p>
|
|
||||||
<div id="mobileHomeProgress" class="mobile-home-progress"></div>
|
|
||||||
</div>
|
|
||||||
<div class="mobile-flow-cards">
|
|
||||||
<a href="#prioritize"><span>☆</span><strong>Active Feature Sorting</strong><small>Prioritize using the selected profile.</small></a>
|
|
||||||
<a href="#roadmap"><span>▱</span><strong>Timeline Roadmap</strong><small>Review and reorder the product timeline.</small></a>
|
|
||||||
<a href="#feature-sets"><span>⇪</span><strong>Paste messy backlog</strong><small>Drop in rough ideas and turn them into order.</small></a>
|
|
||||||
<a href="#review"><span>✓</span><strong>Completion Review</strong><small>Check sorted progress and export.</small></a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="capture-strip" aria-label="Quick capture">
|
|
||||||
<form id="ideaForm" autocomplete="off">
|
|
||||||
<label class="capture-title">One messy idea
|
|
||||||
<input id="title" name="title" maxlength="180" placeholder="Mobile sorting feels clumsy, team voting, Stripe export…" required />
|
|
||||||
</label>
|
|
||||||
<label>Brief
|
|
||||||
<input id="description" name="description" maxlength="500" placeholder="Why does this matter, and what would it change?" />
|
|
||||||
</label>
|
|
||||||
<label>Category
|
|
||||||
<input id="labels" name="labels" placeholder="UI / UX" />
|
|
||||||
</label>
|
|
||||||
<input type="hidden" name="milestoneId" value="inbox" />
|
|
||||||
<input type="hidden" name="impact" value="7" />
|
|
||||||
<input type="hidden" name="effort" value="4" />
|
|
||||||
<input type="hidden" name="confidence" value="6" />
|
|
||||||
<input type="hidden" name="urgency" value="5" />
|
|
||||||
<button type="submit">Capture ↵</button>
|
|
||||||
</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">Paste the chaos</div>
|
|
||||||
<h2>Drop in the rough list. Rank turns it into a build order.</h2>
|
|
||||||
<p>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.</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="copyAiPrompt" class="ai-prompt-button">Copy AI-ready prompt</button>
|
|
||||||
<button type="button" id="importFeatureSet">Import features</button>
|
|
||||||
<button type="button" id="exportFeatureSet">Export sorted setup</button>
|
|
||||||
</div>
|
|
||||||
<p class="ai-prompt-status" id="aiPromptStatus" aria-live="polite">Copies a prompt that makes another AI return import-ready Rank JSON.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="sorting-layout" id="prioritize">
|
|
||||||
<section class="active-column" aria-label="Active feature">
|
|
||||||
<div class="section-label">Active feature</div>
|
|
||||||
<div class="feature-deck" id="featureDeck"></div>
|
|
||||||
<div class="deck-actions">
|
|
||||||
<button type="button" id="skipFeature">▷ Skip</button>
|
|
||||||
<button type="button" id="postponeFeature">↓ Postpone</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="grid-column" aria-label="Sorting grid">
|
|
||||||
<div class="section-label" id="gridLabel">Sorting grid (MVP - 2x2)</div>
|
|
||||||
<div class="sorting-grid" id="sortingGrid"></div>
|
|
||||||
<p class="shortcut-hint">Shortcuts: <kbd>1</kbd>-<kbd>4</kbd> place active card · <kbd>P</kbd> park · <kbd>I</kbd> investigate · <kbd>U</kbd> undo · <kbd>/</kbd> capture</p>
|
|
||||||
</section>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="utility-row" aria-label="Feature card actions">
|
|
||||||
<button type="button" data-utility="park"><span>⚑</span><strong>Park</strong><small>Valid idea, not relevant now. Revisit later.</small></button>
|
|
||||||
<button type="button" data-utility="investigate"><span>⌕</span><strong>Investigate</strong><small>Needs more research or validation.</small></button>
|
|
||||||
<button type="button" data-utility="remove"><span>⌫</span><strong>Remove</strong><small>Not a good fit. Remove from project.</small></button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="timeline-panel" id="roadmap" aria-label="Timeline view">
|
|
||||||
<div class="timeline-head">
|
|
||||||
<div>
|
|
||||||
<h2>Timeline view</h2>
|
|
||||||
<p>Sorted features become nodes. Drag nodes horizontally to reorder the roadmap; click a node to inspect details.</p>
|
|
||||||
</div>
|
|
||||||
<div class="zoom-controls"><button type="button">−</button><button type="button">+</button><button type="button">⛶</button></div>
|
|
||||||
</div>
|
|
||||||
<div class="timeline" id="timeline"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="backlog-panel" id="backlog">
|
|
||||||
<div class="section-label">Backlog / utility states</div>
|
|
||||||
<div id="backlogList" class="backlog-list"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="review-panel" id="review" aria-label="Sorting completion review">
|
|
||||||
<div id="reviewPanel"></div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside class="detail" id="detail" aria-hidden="true">
|
|
||||||
<form id="detailForm">
|
|
||||||
<div class="detail-head">
|
|
||||||
<div><span class="category-dot"></span><span id="detailCategory">UI / UX</span></div>
|
|
||||||
<span class="chip">MVP</span>
|
|
||||||
<button type="button" id="closeDetail" aria-label="Close">×</button>
|
|
||||||
</div>
|
</div>
|
||||||
<input name="title" class="detail-title" />
|
</section>
|
||||||
<label>Brief<textarea name="description" rows="4" placeholder="What this feature changes for the product"></textarea></label>
|
|
||||||
<label>Categories / chips<input name="labels" placeholder="UI / UX, Dependencies, Growth" /></label>
|
<section class="decision-tool" id="try" aria-labelledby="try-title">
|
||||||
<div class="detail-grid">
|
<div class="tool-intro">
|
||||||
<label>User value<input type="number" min="0" max="10" name="impact" /></label>
|
<p class="eyebrow">MVP · no account · no dashboard swamp</p>
|
||||||
<label>Effort<input type="number" min="1" max="10" name="effort" /></label>
|
<h2 id="try-title">Rank the pile</h2>
|
||||||
<label>Confidence<input type="number" min="0" max="10" name="confidence" /></label>
|
<p>Paste one idea plus the possible features, directions, offers, or next steps. Pick what the ranking should care about. Ranker gives a first-pass decision brief.</p>
|
||||||
<label>Urgency<input type="number" min="0" max="10" name="urgency" /></label>
|
|
||||||
</div>
|
</div>
|
||||||
<label>Links, dependencies, notes<textarea name="notes" rows="5" placeholder="Decision notes, source links, dependency risks, acceptance details…"></textarea></label>
|
|
||||||
<div class="detail-actions">
|
<form class="rank-form" id="rankForm">
|
||||||
<button type="button" id="parkDetail">Park</button>
|
<label>
|
||||||
<button type="submit">Save</button>
|
<span>Main idea or context</span>
|
||||||
<button type="button" id="archiveIdea" class="danger">Delete</button>
|
<textarea name="idea" rows="4" placeholder="Example: I’m building a tool that helps freelancers package their services and decide what to sell first."></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>Options to rank <b>required</b></span>
|
||||||
|
<textarea name="optionsText" rows="9" required placeholder="One per line. Bullets, rambling, half-thoughts are fine.
|
||||||
|
- Offer critique
|
||||||
|
- Pricing calculator
|
||||||
|
- Proposal generator
|
||||||
|
- Client persona mapper
|
||||||
|
- Landing page copywriter
|
||||||
|
- Client dashboard"></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<fieldset class="mode-picker">
|
||||||
|
<legend>What should the ranking care about?</legend>
|
||||||
|
<label><input type="radio" name="mode" value="progress" checked /> <span>Fastest useful progress</span></label>
|
||||||
|
<label><input type="radio" name="mode" value="mvp" /> <span>Best MVP order</span></label>
|
||||||
|
<label><input type="radio" name="mode" value="revenue" /> <span>Revenue potential</span></label>
|
||||||
|
<label><input type="radio" name="mode" value="risk" /> <span>Risk reduction</span></label>
|
||||||
|
<label><input type="radio" name="mode" value="originality" /> <span>Most original</span></label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>Extra constraints <em>optional</em></span>
|
||||||
|
<input name="context" placeholder="Example: one week, solo builder, no paid ads, must be useful on mobile" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="button primary" type="submit">Create ranked feedback map</button>
|
||||||
|
<button class="button ghost" type="button" id="loadSample">Use sample</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="results" id="results" aria-live="polite" hidden></section>
|
||||||
|
|
||||||
|
<section class="proof-section" id="proof" aria-labelledby="proof-title">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">The artifact</p>
|
||||||
|
<h2 id="proof-title">A decision brief, not a vibes table.</h2>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
<div class="proof-grid">
|
||||||
</aside>
|
<article><strong>Ranked order</strong><p>Clear first-pass order with do / test / defer / park labels.</p></article>
|
||||||
|
<article><strong>Reason per item</strong><p>Every option gets a plain-language reason and concern.</p></article>
|
||||||
|
<article><strong>Expert reflections</strong><p>Product expert, scattermind simplifier, and structured operator lenses.</p></article>
|
||||||
|
<article><strong>Next 48 hours</strong><p>The output ends with a concrete move instead of more thinking fog.</p></article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="why-section" id="why" aria-labelledby="why-title">
|
||||||
|
<p class="eyebrow">Positioning</p>
|
||||||
|
<h2 id="why-title">Scattermind clarifies one idea. Ranker judges the possible moves.</h2>
|
||||||
|
<p>That makes Ranker broader: scattered people get relief, structured people get a second opinion, and builders get a defensible build order before they waste time on the wrong piece.</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
<div class="toast" id="toast" hidden></div>
|
<div class="toast" id="toast" hidden></div>
|
||||||
<script src="/app.js?v=rank-sales-20260523-2" type="module"></script>
|
<script src="/app.js?v=2.0.0-feedback-map-mvp" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+20
-56
File diff suppressed because one or more lines are too long
@@ -302,6 +302,167 @@ app.post('/api/reorder', requireAgent, async (req, res) => {
|
|||||||
res.json({ changed });
|
res.json({ changed });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const judgementModes = {
|
||||||
|
progress: {
|
||||||
|
label: 'Fastest useful progress',
|
||||||
|
weights: { value: 1.2, feasibility: 1.55, confidence: 1.25, urgency: 1.15, revenue: 0.55, novelty: 0.35, risk: -1.05 },
|
||||||
|
next: 'Test the highest-ranked low-effort option manually before adding product machinery.',
|
||||||
|
},
|
||||||
|
mvp: {
|
||||||
|
label: 'Best MVP order',
|
||||||
|
weights: { value: 1.55, feasibility: 1.25, confidence: 1.15, urgency: 0.85, revenue: 0.75, novelty: 0.45, risk: -1.2 },
|
||||||
|
next: 'Build the smallest slice that proves the core user promise, then defer everything that needs the proof first.',
|
||||||
|
},
|
||||||
|
revenue: {
|
||||||
|
label: 'Revenue potential',
|
||||||
|
weights: { value: 1.15, feasibility: 0.75, confidence: 0.95, urgency: 1.05, revenue: 1.85, novelty: 0.35, risk: -1.0 },
|
||||||
|
next: 'Validate willingness to pay before polishing delivery. A paid manual version beats a beautiful unpaid feature.',
|
||||||
|
},
|
||||||
|
risk: {
|
||||||
|
label: 'Risk reduction',
|
||||||
|
weights: { value: 0.95, feasibility: 1.0, confidence: 1.45, urgency: 0.8, revenue: 0.45, novelty: 0.25, risk: -1.85 },
|
||||||
|
next: 'Start where uncertainty is highest and evidence is cheapest. Do not build around an untested assumption.',
|
||||||
|
},
|
||||||
|
originality: {
|
||||||
|
label: 'Most original / differentiated',
|
||||||
|
weights: { value: 1.0, feasibility: 0.65, confidence: 0.75, urgency: 0.55, revenue: 0.65, novelty: 1.85, risk: -0.9 },
|
||||||
|
next: 'Keep the unusual angle, but demand one proof step so originality does not become expensive weirdness.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const wordSets = {
|
||||||
|
revenue: ['pay', 'paid', 'price', 'pricing', 'stripe', 'checkout', 'invoice', 'sales', 'sell', 'buyer', 'subscription', 'revenue', 'client', 'customer', 'conversion', 'upsell'],
|
||||||
|
value: ['pain', 'problem', 'save', 'faster', 'clear', 'clarity', 'decision', 'feedback', 'user', 'customer', 'relief', 'manual', 'core', 'must', 'need', 'trust'],
|
||||||
|
effort: ['dashboard', 'workspace', 'workspaces', 'collaboration', 'realtime', 'integration', 'integrations', 'automation', 'accounts', 'auth', 'billing', 'ai', 'model', 'mobile', 'sync', 'admin', 'export', 'exportable', 'slack', 'notion', 'team', 'voting'],
|
||||||
|
risk: ['unclear', 'maybe', 'complex', 'hard', 'risky', 'unknown', 'depends', 'enterprise', 'platform', 'everything', 'all users', 'team voting', 'marketplace', 'saved workspaces', 'team voting'],
|
||||||
|
novelty: ['new', 'novel', 'different', 'unique', 'original', 'weird', 'unexpected', 'expert', 'judgement', 'reflection', 'rank', 'compare'],
|
||||||
|
urgency: ['now', 'launch', 'blocker', 'first', 'mvp', 'today', 'week', 'urgent', 'stuck', 'before', 'next'],
|
||||||
|
};
|
||||||
|
|
||||||
|
function hits(text, words) {
|
||||||
|
const lower = ` ${String(text || '').toLowerCase()} `;
|
||||||
|
return words.reduce((count, word) => count + (lower.includes(word) ? 1 : 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionsFromText(value) {
|
||||||
|
const text = cleanMultiline(value, 12000);
|
||||||
|
const lines = text.split('\n').map(line => line.replace(/^\s*[-*•\d.)]+\s*/, '').trim()).filter(Boolean);
|
||||||
|
const optionLines = lines.length >= 2 ? lines : text.split(/[;|]/).map(part => part.trim()).filter(Boolean);
|
||||||
|
return optionLines.slice(0, 24).map((line, index) => {
|
||||||
|
const [rawTitle, ...rest] = line.split(/\s[-–—:]\s/);
|
||||||
|
return {
|
||||||
|
id: `option-${index + 1}`,
|
||||||
|
title: cleanText(rawTitle || line, 140),
|
||||||
|
description: cleanText(rest.join(' — ') || line, 420),
|
||||||
|
};
|
||||||
|
}).filter(item => item.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreOption(option, mode, context = '') {
|
||||||
|
const text = `${option.title} ${option.description} ${context}`;
|
||||||
|
const effortHits = hits(text, wordSets.effort);
|
||||||
|
const riskHits = hits(text, wordSets.risk);
|
||||||
|
const coreLoopHits = hits(text, ['ranked feedback map', 'feedback map', 'pasted feature', 'feature lists', 'first-pass', 'decision brief', 'expert reflections']);
|
||||||
|
const metrics = {
|
||||||
|
value: Math.min(10, 4.8 + hits(text, wordSets.value) * 0.9 + coreLoopHits * 0.8 + hits(context, wordSets.value) * 0.15),
|
||||||
|
feasibility: Math.max(1, Math.min(10, 8.2 - effortHits * 0.8 - riskHits * 0.35 + Math.min(1.1, coreLoopHits * 0.28))),
|
||||||
|
confidence: Math.max(1, Math.min(10, 5.8 + coreLoopHits * 0.35 + hits(text, ['manual', 'existing', 'already', 'simple', 'clear', 'known']) * 0.7 - hits(text, ['maybe', 'unknown', 'new market', 'all users']) * 0.9)),
|
||||||
|
urgency: Math.min(10, 4.9 + hits(text, wordSets.urgency) * 0.9),
|
||||||
|
revenue: Math.min(10, 3.8 + hits(text, wordSets.revenue) * 1.05),
|
||||||
|
novelty: Math.min(10, 4.1 + hits(text, wordSets.novelty) * 0.95),
|
||||||
|
risk: Math.min(10, 2.5 + riskHits * 1.1 + Math.max(0, effortHits - 2) * 0.45),
|
||||||
|
};
|
||||||
|
const weights = mode.weights;
|
||||||
|
const weighted = Object.entries(weights).reduce((sum, [key, weight]) => sum + metrics[key] * weight, 0);
|
||||||
|
const possible = Object.entries(weights).reduce((sum, [_key, weight]) => sum + Math.abs(weight) * 10, 0);
|
||||||
|
const score = Math.max(0, Math.min(100, Math.round((weighted + Math.abs(Math.min(0, weights.risk || 0)) * 10) / possible * 100)));
|
||||||
|
return { ...metrics, score };
|
||||||
|
}
|
||||||
|
|
||||||
|
function laneFor(option, rankIndex, total) {
|
||||||
|
if (rankIndex === 0) return { id: 'do', label: 'Do first', action: 'Build/test now' };
|
||||||
|
if (rankIndex <= Math.max(1, Math.ceil(total * 0.32))) return { id: 'test', label: 'Validate next', action: 'Find evidence' };
|
||||||
|
if (rankIndex >= Math.max(2, Math.floor(total * 0.72))) return { id: 'park', label: 'Park / cut', action: 'Keep out of the active plan' };
|
||||||
|
return { id: 'defer', label: 'Defer', action: 'Sequence after proof' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function reasonFor(option) {
|
||||||
|
const m = option.metrics;
|
||||||
|
if (m.feasibility >= 7.2 && m.value >= 6.2) return 'high enough value with low enough delivery drag to create fast signal';
|
||||||
|
if (m.revenue >= 6.4) return 'clearer buyer or money signal than the rest of the list';
|
||||||
|
if (m.risk >= 6.5) return 'interesting, but it carries assumption risk that should be tested before build';
|
||||||
|
if (m.novelty >= 6.7) return 'more differentiated than the safe options, but still needs proof';
|
||||||
|
return 'balanced tradeoff across value, effort, confidence, and timing';
|
||||||
|
}
|
||||||
|
|
||||||
|
function concernFor(option) {
|
||||||
|
const m = option.metrics;
|
||||||
|
if (m.risk >= 6.5) return 'The hidden risk is pretending this is ready to build before the core assumption is proven.';
|
||||||
|
if (m.feasibility <= 4.5) return 'The likely trap is scope creep: this may need too much machinery for an MVP.';
|
||||||
|
if (m.confidence <= 4.5) return 'Evidence looks thin. Treat this as a question, not a roadmap item.';
|
||||||
|
if (m.revenue <= 4 && m.value >= 6) return 'Useful does not automatically mean sellable. Check willingness to pay or repeat use.';
|
||||||
|
return 'The main risk is sequencing: do it only if it supports the first useful proof.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDecisionBrief({ idea, context, mode, ranked }) {
|
||||||
|
const top = ranked[0];
|
||||||
|
const second = ranked[1];
|
||||||
|
const risky = ranked.slice().sort((a, b) => b.metrics.risk - a.metrics.risk)[0];
|
||||||
|
const deferred = ranked.filter(item => ['defer', 'park'].includes(item.lane.id)).slice(0, 3);
|
||||||
|
const theme = top ? `The strongest signal is “${top.title}” because it has ${reasonFor(top)}.` : 'The list needs at least two options before ranking becomes useful.';
|
||||||
|
return {
|
||||||
|
headline: top ? `Start with ${top.title}` : 'Add options to get a ranked feedback map',
|
||||||
|
summary: `${theme}${second ? ` “${second.title}” is the nearest follow-up, not a parallel first step.` : ''}`,
|
||||||
|
expertReflections: [
|
||||||
|
{
|
||||||
|
lens: 'Product expert',
|
||||||
|
text: top ? `The ranking says the first job is not to build more surface area; it is to prove the highest-signal option. ${mode.next}` : 'A product expert would ask for concrete alternatives before giving serious advice.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lens: 'Scattermind simplifier',
|
||||||
|
text: deferred.length ? `Park ${deferred.map(item => `“${item.title}”`).join(', ')} for now. Not bad ideas — just mental tabs you do not need open while testing the first move.` : 'The list is already narrow. Keep it that way; do not add a fake backlog around a simple decision.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lens: 'Structured operator',
|
||||||
|
text: risky ? `The decision quality depends on one assumption: ${risky.title}. What evidence would make this move up or down the list? Write that before committing resources.` : 'Expose the criteria, the confidence, and the thing that would change the decision. That is what makes this defensible.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
next48Hours: top ? [
|
||||||
|
`Write a one-paragraph test for “${top.title}”.`,
|
||||||
|
'Put it in front of 3–5 real people or run it manually once.',
|
||||||
|
`Do not touch ${deferred[0] ? `“${deferred[0].title}”` : 'the parked ideas'} until the first signal is real.`,
|
||||||
|
] : ['Paste 3–10 options.', 'Choose what the ranking should care about.', 'Run the first-pass judgement.'],
|
||||||
|
caution: 'This is first-pass judgement, not an oracle. Change the criteria if the context changes.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post('/api/rank-feedback', (req, res) => {
|
||||||
|
const idea = cleanMultiline(req.body?.idea || '', 3000);
|
||||||
|
const context = cleanMultiline(req.body?.context || '', 3000);
|
||||||
|
const modeId = cleanText(req.body?.mode || 'progress', 40);
|
||||||
|
const mode = judgementModes[modeId] || judgementModes.progress;
|
||||||
|
let options = Array.isArray(req.body?.options)
|
||||||
|
? req.body.options.slice(0, 24).map((item, index) => ({ id: `option-${index + 1}`, title: cleanText(item?.title || item?.name || '', 140), description: cleanText(item?.description || item?.brief || '', 420) })).filter(item => item.title)
|
||||||
|
: parseOptionsFromText(req.body?.optionsText || '');
|
||||||
|
if (options.length < 2) return res.status(400).json({ error: 'Paste at least two options, features, ideas, or next moves to rank.' });
|
||||||
|
options = options.map(option => ({ ...option, metrics: scoreOption(option, mode, `${idea}\n${context}`) }))
|
||||||
|
.sort((a, b) => b.metrics.score - a.metrics.score || b.metrics.value - a.metrics.value || a.metrics.risk - b.metrics.risk)
|
||||||
|
.map((option, index, arr) => ({
|
||||||
|
...option,
|
||||||
|
rank: index + 1,
|
||||||
|
lane: laneFor(option, index, arr.length),
|
||||||
|
reason: reasonFor(option),
|
||||||
|
concern: concernFor(option),
|
||||||
|
}));
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
mode: { id: modeId in judgementModes ? modeId : 'progress', label: mode.label },
|
||||||
|
input: { idea, context, optionCount: options.length },
|
||||||
|
ranked: options,
|
||||||
|
brief: createDecisionBrief({ idea, context, mode, ranked: options }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
app.get(/.*/, (_req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));
|
app.get(/.*/, (_req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));
|
||||||
|
|
||||||
app.use((error, _req, res, _next) => {
|
app.use((error, _req, res, _next) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user