const state = { ideas: [], milestones: [], activity: [], filter: 'all', search: '', selected: null, }; const $ = (sel, root = document) => root.querySelector(sel); const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); const board = $('#board'); const form = $('#ideaForm'); const detail = $('#detail'); const detailForm = $('#detailForm'); const milestoneSelect = $('#milestoneSelect'); 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 scoreOf(idea) { return Number(idea.score || 0).toFixed(1); } function escapeHtml(value) { return String(value ?? '').replace(/[&<>'"]/g, ch => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[ch])); } function short(text, len = 126) { const value = String(text || '').trim(); return value.length > len ? `${value.slice(0, len - 1)}…` : value; } function milestoneFor(id) { return state.milestones.find(m => m.id === id) || state.milestones[0]; } function sourceKind(idea) { return /agent|rook|iris|eve|claude|gpt|bot/i.test(`${idea.source} ${idea.sourceName}`) ? 'agent' : 'human'; } function toast(message) { const el = document.createElement('div'); el.className = 'toast'; el.textContent = message; document.body.append(el); setTimeout(() => el.remove(), 2200); } function filteredIdeas() { const q = state.search.toLowerCase(); return state.ideas.filter(idea => { if (state.filter === 'human' && sourceKind(idea) !== 'human') return false; if (state.filter === 'agent' && sourceKind(idea) !== 'agent') return false; if (state.filter === 'high' && Number(idea.score) < 4) return false; if (!q) return true; return [idea.title, idea.description, idea.sourceName, ...(idea.labels || [])].join(' ').toLowerCase().includes(q); }); } function renderStats() { const ideas = state.ideas; const now = ideas.filter(i => i.milestoneId === 'now').length; const agent = ideas.filter(i => sourceKind(i) === 'agent').length; const top = ideas.slice().sort((a, b) => b.score - a.score)[0]; $('#stats').innerHTML = [ ['Ideas', ideas.length], ['Now', now], ['Agent drops', agent], ['Top score', top ? scoreOf(top) : '—'], ].map(([label, value]) => `
${value}${label}
`).join(''); } function renderMilestoneOptions() { milestoneSelect.innerHTML = state.milestones.map(m => ``).join(''); } function renderActivity() { $('#activity').innerHTML = state.activity.slice(0, 8).map(item => `${escapeHtml(short(item.message, 52))}`).join(''); } function renderBoard() { renderStats(); renderMilestoneOptions(); renderActivity(); const ideas = filteredIdeas(); board.innerHTML = state.milestones.map(milestone => { const laneIdeas = ideas .filter(idea => (idea.milestoneId || 'inbox') === milestone.id) .sort((a, b) => (a.rank - b.rank) || (b.score - a.score)); const cards = laneIdeas.map(cardHtml).join('') || '
Drop something here
'; return `

${escapeHtml(milestone.name)}

${laneIdeas.length}

${escapeHtml(milestone.description || milestone.horizon || '')}

${cards}
`; }).join(''); bindDrag(); bindCards(); } function cardHtml(idea) { const tags = [`${sourceKind(idea)}`, idea.sourceName, ...(idea.labels || [])].filter(Boolean).slice(0, 5); return `

${escapeHtml(idea.title)}

${scoreOf(idea)}
${idea.description ? `

${escapeHtml(short(idea.description))}

` : ''}
${tags.map(t => `${escapeHtml(t)}`).join('')}
I ${idea.impact}E ${idea.effort}C ${idea.confidence}U ${idea.urgency}
`; } function bindCards() { $$('.card').forEach(card => card.addEventListener('click', () => openDetail(card.dataset.id))); } function bindDrag() { $$('.card').forEach(card => { card.addEventListener('dragstart', event => { card.classList.add('dragging'); event.dataTransfer.setData('text/plain', card.dataset.id); }); card.addEventListener('dragend', () => card.classList.remove('dragging')); }); $$('.lane').forEach(lane => { lane.addEventListener('dragover', event => { event.preventDefault(); lane.classList.add('drag-over'); }); lane.addEventListener('dragleave', () => lane.classList.remove('drag-over')); lane.addEventListener('drop', async event => { event.preventDefault(); lane.classList.remove('drag-over'); const id = event.dataTransfer.getData('text/plain'); const idea = state.ideas.find(i => i.id === id); const milestoneId = lane.dataset.milestone; if (!idea || idea.milestoneId === milestoneId) return; idea.milestoneId = milestoneId; idea.rank = Date.now() % 100000; renderBoard(); try { const updated = await api(`/api/ideas/${id}`, { method: 'PATCH', body: { milestoneId, rank: idea.rank } }); replaceIdea(updated); toast(`Moved to ${milestoneFor(milestoneId).name}`); } catch (error) { toast(error.message); await load(); } }); }); } 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); state.ideas.sort((a, b) => b.score - a.score); } function openDetail(id) { const idea = state.ideas.find(i => i.id === id); if (!idea) return; state.selected = idea.id; detailForm.title.value = idea.title; detailForm.description.value = idea.description || ''; detailForm.impact.value = idea.impact; detailForm.effort.value = idea.effort; detailForm.confidence.value = idea.confidence; detailForm.urgency.value = idea.urgency; detailForm.notes.value = idea.notes || ''; detail.classList.add('open'); detail.setAttribute('aria-hidden', 'false'); } function closeDetail() { detail.classList.remove('open'); detail.setAttribute('aria-hidden', 'true'); state.selected = null; } form.addEventListener('submit', async event => { event.preventDefault(); const fd = new FormData(form); const payload = Object.fromEntries(fd.entries()); payload.labels = String(payload.labels || '').split(',').map(s => s.trim()).filter(Boolean); payload.source = payload.sourceName ? sourceKind({ sourceName: payload.sourceName }) : 'human'; payload.status = payload.milestoneId === 'inbox' ? 'inbox' : 'planned'; try { const idea = await api('/api/ideas', { method: 'POST', body: payload }); replaceIdea(idea); form.reset(); form.impact.value = 7; form.effort.value = 4; form.confidence.value = 6; form.urgency.value = 5; renderBoard(); toast('Captured'); } catch (error) { toast(error.message); } }); detailForm.addEventListener('submit', async event => { event.preventDefault(); if (!state.selected) return; const payload = Object.fromEntries(new FormData(detailForm).entries()); try { const idea = await api(`/api/ideas/${state.selected}`, { method: 'PATCH', body: payload }); replaceIdea(idea); closeDetail(); renderBoard(); toast('Saved'); } catch (error) { toast(error.message); } }); $('#archiveIdea').addEventListener('click', async () => { if (!state.selected) return; try { await api(`/api/ideas/${state.selected}`, { method: 'PATCH', body: { archived: true } }); state.ideas = state.ideas.filter(i => i.id !== state.selected); closeDetail(); renderBoard(); toast('Archived'); } catch (error) { toast(error.message); } }); $('#closeDetail').addEventListener('click', closeDetail); $('#refresh').addEventListener('click', load); $('#search').addEventListener('input', event => { state.search = event.target.value; renderBoard(); }); $('#filters').addEventListener('click', event => { const button = event.target.closest('button[data-filter]'); if (!button) return; state.filter = button.dataset.filter; $$('#filters button').forEach(b => b.classList.toggle('active', b === button)); renderBoard(); }); $('#addMilestone').addEventListener('click', async () => { const name = prompt('Milestone name'); if (!name) return; const horizon = prompt('Horizon / timing', 'Custom') || ''; const colors = ['#8cf7ff', '#f8ff73', '#a78bfa', '#6ee7b7', '#ff5e7a']; try { const milestone = await api('/api/milestones', { method: 'POST', body: { name, horizon, color: colors[state.milestones.length % colors.length], position: state.milestones.length * 10 } }); state.milestones.push(milestone); renderBoard(); toast('Milestone added'); } catch (error) { toast(error.message); } }); document.addEventListener('keydown', event => { if (event.key === '/' && !['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)) { event.preventDefault(); $('#title').focus(); } if (event.key === 'Escape') closeDetail(); }); async function load() { try { const data = await api('/api/bootstrap'); state.ideas = data.ideas || []; state.milestones = data.milestones || []; state.activity = data.activity || []; renderBoard(); } catch (error) { board.innerHTML = `
Backend is grumpy: ${escapeHtml(error.message)}
`; } } load();