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();