netops/netops-todo-node/public/app.js
Jon Vanvik be166034de Legg til dagsplan + kommentar ved statusbytte
Dagsplan: tredje visning ved siden av tavle/tabell. To-kolonne planlegger
der man plukker oppgaver fra backlog og bygger en ordnet plan for dagen
(drag-sortering, kapasitet i timer, datovelger). Per bruker, per dato,
lagret i plans.json; synkes mellom egne faner via plan-WS-melding.

Kommentar ved statusbytte: når en oppgave markeres ferdig/blokkert (knapp,
kanban-drag eller modal) tilbys en valgfri kommentar som festes til oppgaven
(kind:status) og legges i aktivitetsloggen — atomisk i ett update-kall.

20 integrasjonstester (8 nye), alle grønne. README + CLAUDE.md oppdatert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 13:18:33 +02:00

1373 lines
53 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
/**
* NetOps To-Do — frontend.
* Vanlig JS uten rammeverk (bevisst, lett å patche). All data via REST-API;
* sanntidsoppdateringer, presence og "redigerer nå" via WebSocket.
*/
// ---------- Konstanter ----------
const CATEGORIES = [
{ id: 'L1', name: 'L1 Fysisk', desc: 'Kabling, fiber, patch, SFP, rack' },
{ id: 'L2', name: 'L2 Switching', desc: 'VLAN, STP, MAC, trunking' },
{ id: 'L3', name: 'L3 Ruting', desc: 'OSPF, ISIS, statisk, VRF' },
{ id: 'L4', name: 'L4 Lastbalansering', desc: 'HAProxy, NGINX, F5, BIG-IP' },
{ id: 'BGP', name: 'BGP', desc: 'iBGP / eBGP, policies, RR' },
{ id: 'PEER', name: 'Peering / IX', desc: 'IXP, private peering, PeeringDB' },
{ id: 'SEC', name: 'Firewall / Security',desc: 'ACL, firewall, IPS, audit' },
{ id: 'NMS', name: 'Overvåking / NMS', desc: 'LibreNMS, Grafana, alerts' },
{ id: 'AUTO', name: 'Automatisering', desc: 'Ansible, scripts, CI/CD' },
{ id: 'DOC', name: 'Dokumentasjon', desc: 'Diagrammer, IPAM, runbooks' },
{ id: 'INC', name: 'Incident / On-call', desc: 'Feilsøking, postmortem' },
{ id: 'PROJ', name: 'Prosjekt / Plan', desc: 'Design, anskaffelse, oppgraderinger' },
];
const CAT_COLORS = {
L1: '#ff9f43', L2: '#4fc3f7', L3: '#7fb8ff', L4: '#bb86fc',
BGP: '#4ade80', PEER: '#26de81', SEC: '#ff5370', NMS: '#ffd93d',
AUTO: '#00d2d3', DOC: '#a0a0a0', INC: '#ff6b6b', PROJ: '#8172B3'
};
const STATUS_LABELS = { todo: 'Å gjøre', progress: 'Pågår', blocked: 'Blokkert', done: 'Ferdig' };
const PRIO_ORDER = { P1: 1, P2: 2, P3: 3, P4: 4 };
const BOARD_COLS = ['todo', 'progress', 'blocked', 'done'];
const ACT_META = {
create: { icon: '', verb: 'opprettet' },
update: { icon: '✎', verb: 'endret' },
done: { icon: '✓', verb: 'fullførte' },
reopen: { icon: '↺', verb: 'gjenåpnet' },
delete: { icon: '✕', verb: 'slettet' },
comment: { icon: '💬', verb: 'kommenterte' },
import: { icon: '⬆', verb: 'importerte' },
seed: { icon: '◇', verb: 'lastet eksempeldata' },
};
// ---------- State ----------
const state = {
user: 'anon',
canSetName: false,
tasks: [],
activity: [],
knownUsers: [],
online: [],
editingMap: {}, // taskId -> [brukere med modal åpen]
filters: { status: 'open', category: 'all', priority: 'all', owner: 'all', search: '' },
sort: { field: 'priority', dir: 'asc' },
view: 'board', // 'board' | 'table' | 'plan'
editingId: null, // id i åpen modal (null = ny oppgave)
editingBaseV: null, // versjonen modalen redigerer fra
editingStatus: null, // statusen oppgaven hadde da modalen ble åpnet
plan: { date: '', items: [] }, // dagsplan for valgt dato (ordnet liste av task-ids)
wsOk: false,
};
const VIEWS = ['board', 'table', 'plan'];
let charts = {};
let ws = null;
let wsRetry = 0;
let fallbackPoll = null;
const flashIds = new Set(); // ids som skal blinke ved neste render (endret av andre)
const $ = id => document.getElementById(id);
// ---------- Persistens av UI-valg ----------
function loadPrefs() {
try {
const p = JSON.parse(localStorage.getItem('netops.prefs') || '{}');
if (p.filters) Object.assign(state.filters, p.filters);
if (p.sort) state.sort = p.sort;
if (VIEWS.includes(p.view)) state.view = p.view;
} catch { /* ignorer korrupt localStorage */ }
}
function savePrefs() {
localStorage.setItem('netops.prefs', JSON.stringify({
filters: state.filters, sort: state.sort, view: state.view,
}));
}
// ---------- API ----------
async function api(method, path, body = null) {
const opts = { method, headers: {} };
if (body !== null) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
const res = await fetch(path, opts);
const ct = res.headers.get('content-type') || '';
if (!ct.includes('application/json')) {
throw new Error(`Uventet svar fra server (HTTP ${res.status})`);
}
const data = await res.json();
if (!res.ok) {
const err = new Error(data.error || `HTTP ${res.status}`);
err.status = res.status;
err.data = data;
throw err;
}
return data;
}
// ---------- Toast ----------
function toast(msg, opts = {}) {
const stack = $('toast-stack');
const el = document.createElement('div');
el.className = 'toast' + (opts.kind ? ' ' + opts.kind : '');
const span = document.createElement('span');
span.textContent = msg;
el.appendChild(span);
if (opts.action) {
const btn = document.createElement('button');
btn.textContent = opts.action.label;
btn.addEventListener('click', () => { el.remove(); opts.action.fn(); });
el.appendChild(btn);
}
stack.appendChild(el);
setTimeout(() => el.remove(), opts.ttl || 3500);
}
// ---------- Utils ----------
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, m => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[m]);
}
function todayISO(offsetDays = 0) {
const d = new Date(); d.setDate(d.getDate() + offsetDays);
return d.toISOString().slice(0, 10);
}
function formatDeadline(dateStr) {
if (!dateStr) return { text: '—', cls: '' };
const d = new Date(dateStr);
const today = new Date(); today.setHours(0, 0, 0, 0);
const diff = Math.round((d - today) / 86400000);
if (diff < 0) return { text: `${Math.abs(diff)}d forfalt`, cls: 'overdue' };
if (diff === 0) return { text: 'I dag', cls: 'soon' };
if (diff === 1) return { text: 'I morgen', cls: 'soon' };
if (diff <= 3) return { text: `${diff}d igjen`, cls: 'soon' };
return { text: dateStr, cls: '' };
}
function relTime(iso) {
const diff = (Date.now() - Date.parse(iso)) / 1000;
if (diff < 60) return 'nå nettopp';
if (diff < 3600) return `${Math.floor(diff / 60)} min siden`;
if (diff < 86400) return `${Math.floor(diff / 3600)} t siden`;
const d = new Date(iso);
return d.toLocaleString('nb-NO', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
}
function findTask(id) { return state.tasks.find(t => t.id === id); }
function upsertTask(task) {
const idx = state.tasks.findIndex(t => t.id === task.id);
if (idx >= 0) state.tasks[idx] = task; else state.tasks.push(task);
}
function pushActivity(entry) {
if (!entry) return;
state.activity.unshift(entry);
if (state.activity.length > 400) state.activity.length = 400;
}
// ---------- WebSocket ----------
function connectWS() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.onopen = () => {
wsRetry = 0;
state.wsOk = true;
setConnStatus(true);
if (fallbackPoll) { clearInterval(fallbackPoll); fallbackPoll = null; }
};
ws.onmessage = ev => {
let msg;
try { msg = JSON.parse(ev.data); } catch { return; }
handleWSMessage(msg);
};
ws.onclose = () => {
state.wsOk = false;
setConnStatus(false);
// Fallback: poll mens WS er nede, og prøv å koble til igjen med backoff.
if (!fallbackPoll) fallbackPoll = setInterval(refreshState, 30000);
const delay = Math.min(15000, 1000 * Math.pow(2, wsRetry++));
setTimeout(connectWS, delay);
};
ws.onerror = () => ws.close();
}
function sendWS(obj) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
}
function setConnStatus(ok) {
const dot = $('conn-dot');
const txt = $('conn-text');
if (ok) {
dot.className = 'live';
txt.textContent = 'live · sanntidssynk aktiv';
} else {
dot.className = 'off';
txt.textContent = 'frakoblet · prøver igjen …';
}
}
function handleWSMessage(msg) {
switch (msg.type) {
case 'hello':
state.user = msg.user;
state.online = msg.online || [];
state.editingMap = msg.editing || {};
renderHeader();
break;
case 'sync':
state.tasks = msg.tasks || [];
state.activity = msg.activity || [];
if (msg.by && msg.by !== state.user) toast(`${msg.by} erstattet oppgavelisten (import)`);
renderAll();
break;
case 'presence':
state.online = msg.online || [];
renderHeader();
break;
case 'editing':
state.editingMap = msg.editing || {};
renderView();
updateModalWarning();
break;
case 'plan':
// Dagsplanen synkes mellom samme brukers faner. Bruk bare hvis den
// gjelder datoen vi ser på nå.
if (msg.date === state.plan.date) {
state.plan.items = msg.items || [];
if (state.view === 'plan') renderPlan();
}
break;
case 'task': {
const mine = msg.by === state.user;
pushActivity(msg.activity);
if (msg.op === 'delete') {
state.tasks = state.tasks.filter(t => t.id !== msg.id);
state.plan.items = state.plan.items.filter(id => id !== msg.id);
if (!mine && state.editingId === msg.id) {
closeModal();
toast(`Oppgaven du redigerte ble slettet av ${msg.by}`, { kind: 'warn', ttl: 6000 });
}
} else if (msg.task) {
upsertTask(msg.task);
if (!mine) {
flashIds.add(msg.task.id);
// Noen andre lagret oppgaven jeg har åpen i modal → varsle, ikke overskriv skjemaet.
if (state.editingId === msg.task.id) updateModalWarning(msg.by);
if (state.editingId === msg.task.id) renderComments(msg.task);
}
}
renderAll();
break;
}
}
}
// ---------- Hent state via REST (init + fallback) ----------
async function refreshState() {
try {
const data = await api('GET', '/api/state');
state.user = data.user;
state.canSetName = !!data.canSetName;
state.tasks = data.tasks || [];
state.activity = data.activity || [];
state.knownUsers = data.known_users || [];
state.online = data.online || [];
if (data.plan) state.plan = data.plan;
if (!state.plan.date) state.plan = { date: todayISO(), items: [] };
renderAll();
} catch (e) {
toast('Kunne ikke laste: ' + e.message, { kind: 'err' });
}
}
// ---------- Quick add ----------
function parseQuickAdd(text) {
const out = { title: '', tags: [] };
const words = [];
for (const tok of text.trim().split(/\s+/)) {
if (!tok) continue;
let m;
if (/^p[1-4]$/i.test(tok)) { out.priority = tok.toUpperCase(); continue; }
if ((m = tok.match(/^#(.+)$/))) {
const cat = CATEGORIES.find(c => c.id.toLowerCase() === m[1].toLowerCase());
if (cat && !out.category) out.category = cat.id;
else out.tags.push(m[1]);
continue;
}
if ((m = tok.match(/^@(.+)$/))) {
out.owner = m[1].toLowerCase() === 'me' ? state.user : m[1];
continue;
}
if ((m = tok.match(/^!(.+)$/))) {
const v = m[1].toLowerCase();
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) out.deadline = v;
else if ((m = v.match(/^\+(\d{1,3})d?$/))) out.deadline = todayISO(Number(m[1]));
else if (v === 'idag' || v === 'today') out.deadline = todayISO(0);
else if (v === 'imorgen' || v === 'tomorrow') out.deadline = todayISO(1);
continue;
}
if ((m = tok.match(/^~(\d+(?:[.,]\d+)?)t?h?$/i))) { out.estHours = parseFloat(m[1].replace(',', '.')); continue; }
if ((m = tok.match(/^loc:(.+)$/i))) { out.location = m[1]; continue; }
words.push(tok);
}
out.title = words.join(' ');
return out;
}
function renderQuickAddPreview() {
const text = $('quickadd-input').value;
const el = $('quickadd-preview');
if (!text.trim()) { el.innerHTML = ''; return; }
const p = parseQuickAdd(text);
const chips = [];
const chip = (label, val) => `<span class="qa-chip">${label} <b>${escapeHtml(val)}</b></span>`;
chips.push(chip('tittel', p.title || '(mangler)'));
if (p.category) chips.push(chip('kategori', (CATEGORIES.find(c => c.id === p.category) || {}).name || p.category));
if (p.priority) chips.push(chip('prio', p.priority));
if (p.owner) chips.push(chip('eier', p.owner));
if (p.deadline) chips.push(chip('frist', p.deadline));
if (p.estHours != null) chips.push(chip('est', p.estHours + 't'));
if (p.location) chips.push(chip('lokasjon', p.location));
if (p.tags.length) chips.push(chip('tags', p.tags.join(', ')));
el.innerHTML = chips.join('');
}
async function submitQuickAdd() {
const input = $('quickadd-input');
const p = parseQuickAdd(input.value);
if (!p.title) { toast('Skriv en tittel først', { kind: 'warn' }); return; }
try {
const { task } = await api('POST', '/api/tasks', p);
upsertTask(task);
input.value = '';
renderQuickAddPreview();
renderAll();
toast('Opprettet: ' + p.title.slice(0, 40));
} catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); }
}
// ---------- Filtrering / sortering ----------
function applyFilters(tasks, { ignoreStatus = false } = {}) {
const f = state.filters;
return tasks.filter(t => {
if (!ignoreStatus) {
if (f.status === 'open') { if (t.status === 'done') return false; }
else if (f.status !== 'all' && t.status !== f.status) return false;
}
if (f.category !== 'all' && t.category !== f.category) return false;
if (f.priority !== 'all' && t.priority !== f.priority) return false;
if (f.owner === '__me__') { if ((t.owner || '') !== state.user) return false; }
else if (f.owner === '__unassigned__') { if (t.owner) return false; }
else if (f.owner !== 'all') { if ((t.owner || '') !== f.owner) return false; }
if (f.search) {
const q = f.search.toLowerCase();
const hay = [t.title, t.desc, t.location, t.owner, (t.tags || []).join(' ')].join(' ').toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
}
function sortTasks(tasks) {
const { field, dir } = state.sort;
const sign = dir === 'asc' ? 1 : -1;
return [...tasks].sort((a, b) => {
let av = a[field], bv = b[field];
if (field === 'priority') { av = PRIO_ORDER[av]; bv = PRIO_ORDER[bv]; }
if (field === 'deadline') { av = av || '9999'; bv = bv || '9999'; }
if (field === 'estHours') { av = av || 0; bv = bv || 0; }
if (av == null) av = '';
if (bv == null) bv = '';
if (av < bv) return -1 * sign;
if (av > bv) return 1 * sign;
return 0;
});
}
// ---------- Render ----------
function renderAll() {
renderHeader();
renderKPIs();
renderCharts();
renderView();
renderActivity();
updateOwnerSelects();
}
function renderHeader() {
$('user-chip').textContent = state.user;
$('user-chip').className = 'user-chip' + (state.canSetName ? ' clickable' : '');
$('anon-banner').className = 'anon-banner' + (state.user === 'anon' && state.canSetName ? ' show' : '');
const others = state.online.filter(u => u !== state.user);
$('online-row').innerHTML = others.length
? others.slice(0, 6).map(u => `<span class="online-chip">${escapeHtml(u)}</span>`).join('')
+ (others.length > 6 ? `<span class="online-chip">+${others.length - 6}</span>` : '')
: '';
}
function renderKPIs() {
const open = state.tasks.filter(t => t.status !== 'done');
const mine = open.filter(t => (t.owner || '') === state.user);
const p1 = open.filter(t => t.priority === 'P1').length;
const hours = open.reduce((s, t) => s + (t.estHours || 0), 0);
const today = todayISO();
const overdue = open.filter(t => t.deadline && t.deadline < today).length;
const weekAgo = todayISO(-7);
const done7 = state.tasks.filter(t => t.status === 'done' && t.completed && t.completed >= weekAgo).length;
$('kpi-open').textContent = open.length;
$('kpi-open-sub').textContent = `av ${state.tasks.length} totalt`;
$('kpi-mine').textContent = mine.length;
const mineHours = mine.reduce((s, t) => s + (t.estHours || 0), 0);
$('kpi-mine-sub').textContent = mineHours > 0 ? `${fmtHours(mineHours)} arbeid` : 'tildelt meg';
$('kpi-p1').textContent = p1;
$('kpi-hours').textContent = fmtHours(hours);
$('kpi-hours-sub').textContent = hours > 8 ? `${(hours / 8).toFixed(1)} arbeidsdager` : 'arbeidstimer';
$('kpi-overdue').textContent = overdue;
$('kpi-done').textContent = done7;
}
function fmtHours(h) { return h.toFixed(1).replace(/\.0$/, '') + 't'; }
function renderCharts() {
if (typeof Chart === 'undefined') { $('chart-row').style.display = 'none'; return; }
const open = state.tasks.filter(t => t.status !== 'done');
const byCat = {};
open.forEach(t => { byCat[t.category] = (byCat[t.category] || 0) + 1; });
const catEntries = Object.entries(byCat).sort((a, b) => b[1] - a[1]);
$('cat-total').textContent = `${open.length} åpne`;
const byPrio = { P1: 0, P2: 0, P3: 0, P4: 0 };
open.forEach(t => { byPrio[t.priority]++; });
const byOwner = {};
open.forEach(t => {
const o = t.owner || '(utildelt)';
byOwner[o] = (byOwner[o] || 0) + 1;
});
const ownerEntries = Object.entries(byOwner).sort((a, b) => b[1] - a[1]).slice(0, 10);
Object.values(charts).forEach(c => c && c.destroy());
const commonFont = { family: "-apple-system, 'Segoe UI', sans-serif", size: 11 };
const barOpts = {
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: ctx => `${ctx.parsed.x} oppgave${ctx.parsed.x === 1 ? '' : 'r'}` } }
},
scales: {
x: { beginAtZero: true, ticks: { color: '#8b98a9', font: commonFont, stepSize: 1 }, grid: { color: '#2a3340' } },
y: { ticks: { color: '#e6edf3', font: commonFont }, grid: { display: false } }
}
};
charts.cat = new Chart($('catChart'), {
type: 'bar',
data: {
labels: catEntries.map(([k]) => (CATEGORIES.find(c => c.id === k) || { name: k }).name),
datasets: [{
data: catEntries.map(([, v]) => v),
backgroundColor: catEntries.map(([k]) => (CAT_COLORS[k] || '#666') + 'CC'),
borderColor: catEntries.map(([k]) => CAT_COLORS[k] || '#666'),
borderWidth: 1, borderRadius: 4,
}]
},
options: barOpts,
});
charts.prio = new Chart($('prioChart'), {
type: 'doughnut',
data: {
labels: ['P1 Kritisk', 'P2 Høy', 'P3 Middels', 'P4 Lav'],
datasets: [{
data: [byPrio.P1, byPrio.P2, byPrio.P3, byPrio.P4],
backgroundColor: ['#ff5370CC', '#ff9f43CC', '#ffd93dCC', '#7fb8ffCC'],
borderColor: '#1a212b', borderWidth: 2,
}]
},
options: {
responsive: true, maintainAspectRatio: false, cutout: '60%',
plugins: {
legend: { position: 'right', labels: { color: '#e6edf3', font: commonFont, usePointStyle: true, padding: 12 } },
tooltip: { callbacks: { label: ctx => {
const tot = ctx.dataset.data.reduce((a, b) => a + b, 0);
const pct = tot ? ((ctx.parsed / tot) * 100).toFixed(0) : 0;
return `${ctx.label}: ${ctx.parsed} (${pct}%)`;
} } }
}
}
});
charts.owner = new Chart($('ownerChart'), {
type: 'bar',
data: {
labels: ownerEntries.map(([k]) => k),
datasets: [{
data: ownerEntries.map(([, v]) => v),
backgroundColor: ownerEntries.map(([k]) => k === state.user ? '#4ade80CC' : '#bb86fcCC'),
borderColor: ownerEntries.map(([k]) => k === state.user ? '#4ade80' : '#bb86fc'),
borderWidth: 1, borderRadius: 4,
}]
},
options: barOpts,
});
}
function renderView() {
$('view-board').className = state.view === 'board' ? 'active-view' : '';
$('view-table').className = state.view === 'table' ? 'active-view' : '';
$('view-plan').className = state.view === 'plan' ? 'active-view' : '';
// Statusfilteret gir bare mening i tabellvisningen (tavle/plan grupperer selv).
$('fg-status').style.display = state.view === 'table' ? '' : 'none';
$('board').style.display = state.view === 'board' ? '' : 'none';
$('table-panel').style.display = state.view === 'table' ? '' : 'none';
$('planner').style.display = state.view === 'plan' ? '' : 'none';
if (state.view === 'board') renderBoard();
else if (state.view === 'table') renderTable();
else renderPlan();
flashIds.clear();
}
// Felles småbiter for kort og rader
function ownerBadge(t) {
const cls = !t.owner ? 'unassigned' : (t.owner === state.user ? 'me' : '');
return `<span class="owner-badge ${cls}">${t.owner ? escapeHtml(t.owner) : 'utildelt'}</span>`;
}
function catBadge(t) {
const cat = CATEGORIES.find(c => c.id === t.category);
const col = CAT_COLORS[t.category] || '#4fc3f7';
return `<span class="cat-badge" style="color:${col};border-color:${col}40;background:${col}15" title="${cat ? escapeHtml(cat.desc) : ''}">${escapeHtml(t.category)}</span>`;
}
function editingTag(t) {
const editors = (state.editingMap[t.id] || []).filter(u => u !== state.user);
return editors.length ? `<span class="card-editing" title="Redigeres nå av ${escapeHtml(editors.join(', '))}">✎ ${escapeHtml(editors[0])}</span>` : '';
}
function renderBoard() {
const filtered = applyFilters(state.tasks, { ignoreStatus: true });
const board = $('board');
const showEmpty = state.tasks.length === 0;
$('empty').style.display = showEmpty ? 'block' : 'none';
$('empty-seed').style.display = showEmpty ? 'block' : 'none';
board.innerHTML = BOARD_COLS.map(status => {
let items = filtered.filter(t => t.status === status);
let moreNote = '';
if (status === 'done') {
items.sort((a, b) => (b.completed || '').localeCompare(a.completed || ''));
if (items.length > 15) {
moreNote = `<div class="board-more">+ ${items.length - 15} eldre ferdige (se tabell-visning)</div>`;
items = items.slice(0, 15);
}
} else {
items.sort((a, b) =>
(PRIO_ORDER[a.priority] - PRIO_ORDER[b.priority])
|| (a.deadline || '9999').localeCompare(b.deadline || '9999'));
}
const est = items.reduce((s, t) => s + (t.estHours || 0), 0);
return `
<div class="board-col" data-status="${status}">
<div class="board-col-head">
<h3>${STATUS_LABELS[status]}</h3>
<span class="col-meta">${items.length}${est > 0 && status !== 'done' ? ` · ${fmtHours(est)}` : ''}</span>
</div>
<div class="board-col-body" data-status="${status}">
${items.map(renderCard).join('')}
${moreNote}
</div>
</div>`;
}).join('');
bindBoardDnD();
}
function renderCard(t) {
const dl = formatDeadline(t.deadline);
const flash = flashIds.has(t.id) ? ' flash' : '';
const doneCls = t.status === 'done' ? ' done-card' : '';
const tags = (t.tags || []).slice(0, 4).map(tag => `<span class="tag-chip">#${escapeHtml(tag)}</span>`).join(' ');
return `
<div class="card${flash}${doneCls}" draggable="true" data-id="${t.id}">
${editingTag(t)}
<div class="card-top">
<span class="badge prio-${t.priority}">${t.priority}</span>
${catBadge(t)}
${t.location ? `<span class="tag-chip" style="margin-left:auto">${escapeHtml(t.location)}</span>` : ''}
</div>
<div class="card-title" onclick="openEdit('${t.id}')">${escapeHtml(t.title)}</div>
<div class="card-meta">
${ownerBadge(t)}
${t.deadline ? `<span class="time-cell ${dl.cls}">${dl.text}</span>` : ''}
${t.estHours ? `<span>~${t.estHours}t</span>` : ''}
${t.comments && t.comments.length ? `<span class="comment-count">💬 ${t.comments.length}</span>` : ''}
${tags}
</div>
</div>`;
}
function bindBoardDnD() {
document.querySelectorAll('.card[draggable]').forEach(card => {
card.addEventListener('dragstart', e => {
e.dataTransfer.setData('text/plain', card.dataset.id);
e.dataTransfer.effectAllowed = 'move';
card.classList.add('dragging');
});
card.addEventListener('dragend', () => card.classList.remove('dragging'));
});
document.querySelectorAll('.board-col').forEach(col => {
col.addEventListener('dragover', e => { e.preventDefault(); col.classList.add('drag-over'); });
col.addEventListener('dragleave', () => col.classList.remove('drag-over'));
col.addEventListener('drop', e => {
e.preventDefault();
col.classList.remove('drag-over');
const id = e.dataTransfer.getData('text/plain');
moveTask(id, col.dataset.status);
});
});
}
async function moveTask(id, status) {
const t = findTask(id);
if (!t || t.status === status) return;
// Drag til Ferdig/Blokkert → tilby kommentar (ingen optimistisk flytting,
// siden brukeren kan avbryte i dialogen).
if (status === 'done' || status === 'blocked') {
changeStatusWithComment(id, status);
return;
}
const prev = t.status;
t.status = status; // optimistisk — broadcast/svar korrigerer
renderAll();
try {
const { task } = await api('PUT', `/api/tasks/${id}`, { ...t, status, v: t.v });
upsertTask(task);
renderAll();
} catch (e) {
t.status = prev;
renderAll();
handleSaveError(e, id);
}
}
function renderTable() {
const filtered = sortTasks(applyFilters(state.tasks));
const tbody = $('tbl-body');
$('tbl-count').textContent = filtered.length;
const showEmpty = filtered.length === 0;
$('empty').style.display = showEmpty ? 'block' : 'none';
$('empty-seed').style.display = state.tasks.length === 0 ? 'block' : 'none';
if (showEmpty) { tbody.innerHTML = ''; return; }
tbody.innerHTML = filtered.map(t => {
const dl = formatDeadline(t.deadline);
const flash = flashIds.has(t.id) ? ' flash' : '';
const tags = (t.tags || []).map(tag => `<span class="tag-chip">#${escapeHtml(tag)}</span>`).join(' ');
const editors = (state.editingMap[t.id] || []).filter(u => u !== state.user);
return `
<tr class="status-${t.status}${flash}" data-id="${t.id}">
<td><span class="badge prio-${t.priority}">${t.priority}</span></td>
<td>
<div class="task-title" onclick="openEdit('${t.id}')">${escapeHtml(t.title)}
${t.comments && t.comments.length ? `<span class="comment-count">💬${t.comments.length}</span>` : ''}
${editors.length ? `<span class="editing-tag">✎ ${escapeHtml(editors.join(', '))} redigerer</span>` : ''}
</div>
${t.desc ? `<div class="task-desc">${escapeHtml(t.desc.slice(0, 160))}${t.desc.length > 160 ? '…' : ''}</div>` : ''}
${tags ? `<div style="margin-top:4px">${tags}</div>` : ''}
</td>
<td>${catBadge(t)}</td>
<td><span class="status-badge status-${t.status}">${STATUS_LABELS[t.status] || t.status}</span></td>
<td>${ownerBadge(t)}</td>
<td style="font-family:var(--mono);font-size:12px;color:var(--text-dim)">${escapeHtml(t.location || '—')}</td>
<td style="text-align:right;font-family:var(--mono)">${t.estHours || '—'}</td>
<td class="time-cell ${dl.cls}">${dl.text}</td>
<td>
<div class="row-actions">
${t.status !== 'done'
? `<button class="icon-btn do" onclick="markDone('${t.id}')" title="Marker ferdig">✓</button>`
: `<button class="icon-btn" onclick="reopenTask('${t.id}')" title="Gjenåpne">↺</button>`}
<button class="icon-btn" onclick="openEdit('${t.id}')" title="Rediger">✎</button>
${!t.owner ? `<button class="icon-btn do" onclick="claimTask('${t.id}')" title="Ta eierskap">👤</button>` : ''}
<button class="icon-btn del" onclick="deleteTask('${t.id}')" title="Slett">🗑</button>
</div>
</td>
</tr>`;
}).join('');
}
// ---------- Dagsplan ----------
// Oppgavene i planen, i lagret rekkefølge, med slettede filtrert bort.
function planTasks() {
return state.plan.items.map(findTask).filter(Boolean);
}
function renderPlan() {
$('empty').style.display = 'none';
const dateInput = $('plan-date');
if (document.activeElement !== dateInput) dateInput.value = state.plan.date;
const planned = planTasks();
const planIds = new Set(planned.map(t => t.id));
// Venstre: åpne oppgaver som ikke alt er i planen (respekterer kat/prio/eier/søk).
const avail = sortTasks(
applyFilters(state.tasks, { ignoreStatus: true })
.filter(t => t.status !== 'done' && !planIds.has(t.id))
);
$('plan-avail-count').textContent = avail.length ? `(${avail.length})` : '';
$('plan-available-body').innerHTML = avail.length
? avail.map(renderAvailRow).join('')
: '<div class="plan-empty">Ingen åpne oppgaver igjen — alt er planlagt eller filtrert bort.</div>';
// Høyre: kapasitet + ordnet liste.
const remaining = planned.filter(t => t.status !== 'done');
const hours = remaining.reduce((s, t) => s + (t.estHours || 0), 0);
const doneCount = planned.length - remaining.length;
const cap = $('plan-capacity');
cap.innerHTML = planned.length
? `${planned.length} oppg. · <b>${fmtHours(hours)}</b> planlagt${doneCount ? ` · ${doneCount} ferdig` : ''}`
: '';
cap.className = 'col-meta' + (hours > 8 ? ' over-capacity' : '');
$('plan-today-body').innerHTML = planned.length
? planned.map(renderPlanItem).join('')
: '<div class="plan-empty">Tomt. Legg til oppgaver fra venstre med +, eller dra dem hit. Dra for å sortere.</div>';
bindPlanDnD();
}
function renderAvailRow(t) {
const dl = formatDeadline(t.deadline);
return `
<div class="avail-row" draggable="true" data-id="${t.id}">
<div class="avail-main">
<div class="avail-title" onclick="openEdit('${t.id}')">${escapeHtml(t.title)}</div>
<div class="avail-meta">
<span class="badge prio-${t.priority}">${t.priority}</span>
${catBadge(t)}
${t.estHours ? `<span>~${t.estHours}t</span>` : ''}
${t.deadline ? `<span class="time-cell ${dl.cls}">${dl.text}</span>` : ''}
${t.owner ? ownerBadge(t) : ''}
</div>
</div>
<button class="avail-add" onclick="addToPlan('${t.id}')" title="Legg til i dagsplan">+</button>
</div>`;
}
function renderPlanItem(t, i) {
const dl = formatDeadline(t.deadline);
const doneCls = t.status === 'done' ? ' done-item' : '';
return `
<div class="plan-item${doneCls}" draggable="true" data-id="${t.id}">
<span class="plan-handle" title="Dra for å sortere">⠿</span>
<span class="plan-num">${i + 1}</span>
<div class="plan-item-main">
<div class="plan-item-title" onclick="openEdit('${t.id}')">${escapeHtml(t.title)}</div>
<div class="plan-item-meta">
<span class="badge prio-${t.priority}">${t.priority}</span>
${catBadge(t)}
<span class="status-badge status-${t.status}">${STATUS_LABELS[t.status] || t.status}</span>
${t.estHours ? `<span>~${t.estHours}t</span>` : ''}
${t.deadline ? `<span class="time-cell ${dl.cls}">${dl.text}</span>` : ''}
</div>
</div>
<div class="plan-item-actions">
${t.status !== 'done'
? `<button class="icon-btn do" onclick="markDone('${t.id}')" title="Marker ferdig">✓</button>`
: `<button class="icon-btn" onclick="reopenTask('${t.id}')" title="Gjenåpne">↺</button>`}
<button class="icon-btn del" onclick="removeFromPlan('${t.id}')" title="Fjern fra dagsplan">×</button>
</div>
</div>`;
}
function getDragAfterElement(container, y) {
const els = [...container.querySelectorAll('.plan-item:not(.dragging)')];
let closest = { offset: -Infinity, el: null };
for (const child of els) {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) closest = { offset, el: child };
}
return closest.el;
}
// Per-element drag-bindinger. Containeren beholder sine lyttere (satt én gang
// i bindUI), så her bindes bare de nygenererte radene/kortene.
function bindPlanDnD() {
const container = $('plan-today-body');
container.querySelectorAll('.plan-item').forEach(item => {
item.addEventListener('dragstart', e => {
item.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', item.dataset.id);
});
item.addEventListener('dragend', () => {
item.classList.remove('dragging');
const ids = [...container.querySelectorAll('.plan-item')].map(el => el.dataset.id);
if (ids.join() !== state.plan.items.join()) {
state.plan.items = ids;
savePlan();
}
});
});
$('plan-available-body').querySelectorAll('.avail-row').forEach(row => {
row.addEventListener('dragstart', e => {
row.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', row.dataset.id);
});
row.addEventListener('dragend', () => row.classList.remove('dragging'));
});
}
// Container-nivå drag-lyttere (settes én gang).
function bindPlanContainer() {
const container = $('plan-today-body');
container.addEventListener('dragover', e => {
e.preventDefault();
const dragging = container.querySelector('.plan-item.dragging');
if (!dragging) return; // drar inn et tilgjengelig-kort: håndteres i drop
const after = getDragAfterElement(container, e.clientY);
if (after == null) container.appendChild(dragging);
else container.insertBefore(dragging, after);
});
container.addEventListener('drop', e => {
e.preventDefault();
const id = e.dataTransfer.getData('text/plain');
if (id && !state.plan.items.includes(id) && findTask(id)) addToPlan(id);
});
}
async function savePlan() {
renderPlan(); // optimistisk
try {
const { date, items } = await api('PUT', '/api/plan', { date: state.plan.date, items: state.plan.items });
if (date === state.plan.date) { state.plan.items = items; renderPlan(); }
} catch (e) {
toast('Kunne ikke lagre dagsplan: ' + e.message, { kind: 'err' });
refreshPlan();
}
}
window.addToPlan = function (id) {
if (!state.plan.items.includes(id) && findTask(id)) {
state.plan.items.push(id);
savePlan();
toast('Lagt i dagsplan');
}
};
window.removeFromPlan = function (id) {
state.plan.items = state.plan.items.filter(x => x !== id);
savePlan();
};
async function refreshPlan(date = state.plan.date) {
try {
const data = await api('GET', `/api/plan?date=${encodeURIComponent(date)}`);
state.plan = { date: data.date, items: data.items || [] };
if (state.view === 'plan') renderPlan();
} catch { /* stille — neste handling prøver igjen */ }
}
function renderActivity() {
const body = $('drawer-body');
if (!state.activity.length) {
body.innerHTML = '<div class="act-entry" style="color:var(--text-faint)">Ingen aktivitet ennå.</div>';
return;
}
body.innerHTML = state.activity.slice(0, 150).map(a => {
const meta = ACT_META[a.action] || { icon: '·', verb: a.action };
const titleHtml = a.taskId && findTask(a.taskId)
? `<span class="act-task" onclick="openEdit('${a.taskId}')">${escapeHtml(a.title)}</span>`
: `<span style="color:var(--text-dim)">${escapeHtml(a.title || '')}</span>`;
const detail = (a.detail || []).join(' · ');
return `
<div class="act-entry">
<div class="act-line">
<span class="act-icon">${meta.icon}</span>
<span class="act-text"><span class="act-user">${escapeHtml(a.user)}</span> ${meta.verb} ${titleHtml}</span>
</div>
${detail ? `<div class="act-detail">${escapeHtml(detail)}</div>` : ''}
<div class="act-time">${relTime(a.at)}</div>
</div>`;
}).join('');
}
function updateOwnerSelects() {
const users = new Set(state.knownUsers);
state.tasks.forEach(t => { if (t.owner) users.add(t.owner); });
state.online.forEach(u => users.add(u));
const sorted = [...users].sort();
const sel = $('f-owner');
const current = state.filters.owner;
sel.innerHTML = '<option value="all">Alle</option><option value="__me__">Meg</option><option value="__unassigned__">Utildelt</option>'
+ sorted.map(u => `<option value="${escapeHtml(u)}">${escapeHtml(u)}</option>`).join('');
const ok = [...sel.options].some(o => o.value === current);
sel.value = ok ? current : 'all';
if (!ok) state.filters.owner = 'all';
const msel = $('f-owner-select');
msel.innerHTML = '<option value="">— velg —</option>'
+ sorted.map(u => `<option value="${escapeHtml(u)}">${escapeHtml(u)}</option>`).join('');
}
// ---------- Statuskommentar-dialog ----------
// Lover en streng (kommentar, evt. tom = hopp over) eller null (avbrutt).
let statusResolve = null;
function askStatusComment(task, status) {
return new Promise(resolve => {
statusResolve = resolve;
const label = status === 'done' ? 'Avslutningskommentar (valgfritt)'
: status === 'blocked' ? 'Hva blokkerer? (valgfritt)'
: 'Kommentar (valgfritt)';
$('status-modal-title').textContent = `${STATUS_LABELS[status]}: ${task.title.slice(0, 44)}${task.title.length > 44 ? '…' : ''}`;
$('status-modal-label').textContent = label;
$('status-comment').value = '';
$('status-modal').classList.add('open');
setTimeout(() => $('status-comment').focus(), 50);
});
}
function resolveStatus(val) {
if (!statusResolve) return;
$('status-modal').classList.remove('open');
const r = statusResolve;
statusResolve = null;
r(val);
}
// Statusbytte som tilbyr en kommentar ved fullføring/blokkering.
async function changeStatusWithComment(id, status) {
const t = findTask(id);
if (!t || t.status === status) return;
let comment = '';
if (status === 'done' || status === 'blocked') {
const res = await askStatusComment(t, status);
if (res === null) return; // avbrutt — ingen endring
comment = res;
}
await quickStatus(id, status, comment);
}
// ---------- Hurtighandlinger (rad/kort) ----------
async function quickStatus(id, status, comment = '') {
const t = findTask(id);
if (!t) return;
try {
const body = { ...t, status, v: t.v };
if (comment) body.comment = comment;
const { task } = await api('PUT', `/api/tasks/${id}`, body);
upsertTask(task);
renderAll();
if (comment) toast('Lagret med kommentar');
} catch (e) { handleSaveError(e, id); }
}
window.markDone = id => changeStatusWithComment(id, 'done');
window.reopenTask = id => quickStatus(id, 'todo');
window.claimTask = async function (id) {
const t = findTask(id);
if (!t) return;
try {
const { task } = await api('PUT', `/api/tasks/${id}`, { ...t, owner: state.user, v: t.v });
upsertTask(task);
renderAll();
toast(`Du eier nå "${task.title.slice(0, 30)}${task.title.length > 30 ? '…' : ''}"`);
} catch (e) { handleSaveError(e, id); }
};
window.deleteTask = async function (id) {
const t = findTask(id);
if (!t) return;
const copy = { ...t };
try {
await api('DELETE', `/api/tasks/${id}`);
state.tasks = state.tasks.filter(x => x.id !== id);
renderAll();
toast(`Slettet "${copy.title.slice(0, 30)}"`, {
ttl: 7000,
action: { label: 'Angre', fn: async () => {
try {
const { task } = await api('POST', '/api/tasks', copy);
upsertTask(task);
renderAll();
toast('Gjenopprettet');
} catch (e) { toast('Kunne ikke gjenopprette: ' + e.message, { kind: 'err' }); }
} },
});
} catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); }
};
// 409-håndtering: ta inn serverens versjon og be brukeren prøve igjen.
function handleSaveError(e, id) {
if (e.status === 409 && e.data && e.data.task) {
upsertTask(e.data.task);
renderAll();
const by = e.data.task.updatedBy || 'noen andre';
if (state.editingId === id) {
state.editingBaseV = e.data.task.v;
updateModalWarning(by, true);
} else {
toast(`Endret av ${by} i mellomtiden — visningen er oppdatert, prøv igjen`, { kind: 'warn', ttl: 6000 });
}
} else {
toast('Feil: ' + e.message, { kind: 'err' });
}
}
// ---------- Modal ----------
window.openEdit = function (id) {
const t = findTask(id);
if (!t) return;
state.editingId = id;
state.editingBaseV = t.v;
state.editingStatus = t.status;
$('modal-title').textContent = 'Rediger oppgave';
$('f-title').value = t.title;
$('f-desc').value = t.desc || '';
$('f-cat').value = t.category;
$('f-prio').value = t.priority;
$('f-stat').value = t.status;
$('f-est').value = t.estHours || '';
$('f-owner-input').value = t.owner || '';
$('f-owner-select').value = '';
$('f-loc').value = t.location || '';
$('f-dl').value = t.deadline || '';
$('f-tags').value = (t.tags || []).join(', ');
$('modal-delete').style.display = 'inline-flex';
$('comments-section').style.display = '';
$('modal-audit').textContent = `opprettet ${relTime(t.created)} av ${t.createdBy} · sist endret ${relTime(t.updated)} av ${t.updatedBy}`;
updateStatusNoteField();
renderComments(t);
updateModalWarning();
$('modal').classList.add('open');
sendWS({ type: 'editing', id, active: true });
};
// Viser et kommentarfelt i modalen når status endres til Ferdig/Blokkert.
function updateStatusNoteField() {
const field = $('f-statusnote-field');
if (!state.editingId) { field.style.display = 'none'; $('f-statusnote').value = ''; return; }
const val = $('f-stat').value;
if (val !== state.editingStatus && (val === 'done' || val === 'blocked')) {
$('f-statusnote-label').textContent = val === 'done' ? 'Avslutningskommentar (valgfritt)' : 'Hva blokkerer? (valgfritt)';
field.style.display = '';
} else {
field.style.display = 'none';
$('f-statusnote').value = '';
}
}
function openNew(prefill = {}) {
state.editingId = null;
state.editingBaseV = null;
state.editingStatus = null;
$('f-statusnote-field').style.display = 'none';
$('f-statusnote').value = '';
$('modal-title').textContent = 'Ny oppgave';
$('f-title').value = prefill.title || '';
$('f-desc').value = '';
$('f-cat').value = prefill.category || CATEGORIES[0].id;
$('f-prio').value = prefill.priority || 'P2';
$('f-stat').value = 'todo';
$('f-est').value = '';
$('f-owner-input').value = state.user !== 'anon' ? state.user : '';
$('f-owner-select').value = '';
$('f-loc').value = '';
$('f-dl').value = '';
$('f-tags').value = '';
$('modal-delete').style.display = 'none';
$('comments-section').style.display = 'none';
$('modal-audit').textContent = '';
updateModalWarning();
$('modal').classList.add('open');
setTimeout(() => $('f-title').focus(), 50);
}
function closeModal() {
if (state.editingId) sendWS({ type: 'editing', id: state.editingId, active: false });
$('modal').classList.remove('open');
state.editingId = null;
state.editingBaseV = null;
}
// Varsellinje i modal: andre redigerer / noen lagret nettopp / 409.
function updateModalWarning(savedBy = null, conflict = false) {
const el = $('modal-warning');
if (!$('modal').classList.contains('open')) { el.className = 'modal-warning'; return; }
const msgs = [];
if (state.editingId) {
const others = (state.editingMap[state.editingId] || []).filter(u => u !== state.user);
if (others.length) msgs.push(`${others.join(', ')} redigerer også denne oppgaven nå.`);
if (conflict) msgs.push(`${savedBy} lagret en nyere versjon. Sjekk feltene — lagrer du igjen, overskriver du.`);
else if (savedBy) msgs.push(`${savedBy} lagret nettopp endringer på denne oppgaven.`);
}
el.textContent = msgs.join(' ');
el.className = 'modal-warning' + (msgs.length ? ' show' : '');
}
async function saveForm() {
const title = $('f-title').value.trim();
if (!title) { toast('Tittel er påkrevd', { kind: 'warn' }); return; }
const data = {
title,
desc: $('f-desc').value.trim(),
category: $('f-cat').value,
priority: $('f-prio').value,
status: $('f-stat').value,
estHours: parseFloat($('f-est').value) || 0,
owner: $('f-owner-input').value.trim(),
location: $('f-loc').value.trim(),
deadline: $('f-dl').value,
tags: $('f-tags').value.split(',').map(s => s.trim()).filter(Boolean),
};
try {
let task;
if (state.editingId) {
data.v = state.editingBaseV;
// Statuskommentar (kun synlig når status endres til ferdig/blokkert)
const note = $('f-statusnote-field').style.display !== 'none' ? $('f-statusnote').value.trim() : '';
if (note) data.comment = note;
({ task } = await api('PUT', `/api/tasks/${state.editingId}`, data));
toast('Lagret');
} else {
({ task } = await api('POST', '/api/tasks', data));
toast('Opprettet');
}
upsertTask(task);
closeModal();
renderAll();
} catch (e) { handleSaveError(e, state.editingId); }
}
async function deleteFromModal() {
if (!state.editingId) return;
const id = state.editingId;
closeModal();
window.deleteTask(id);
}
// ---------- Kommentarer ----------
function renderComments(t) {
const list = $('comments-list');
$('comment-count').textContent = t.comments && t.comments.length ? `(${t.comments.length})` : '';
if (!t.comments || !t.comments.length) {
list.innerHTML = '<div class="no-comments">Ingen kommentarer ennå.</div>';
return;
}
list.innerHTML = t.comments.map(c => {
const isStatus = c.kind === 'status' && c.status;
const kindTag = isStatus
? `<span class="comment-kind status-${c.status}">${STATUS_LABELS[c.status] || c.status}</span>`
: '';
return `
<div class="comment${isStatus ? ' comment-status' : ''}">
<div class="comment-head">
<span class="comment-by ${c.by === state.user ? 'me' : ''}">${escapeHtml(c.by)}</span>
${kindTag}
<span class="comment-at">${relTime(c.at)}</span>
</div>
<div class="comment-text">${escapeHtml(c.text)}</div>
${c.by === state.user ? `<button class="icon-btn del comment-del" onclick="deleteComment('${t.id}','${c.id}')" title="Slett kommentar">×</button>` : ''}
</div>`;
}).join('');
}
async function sendComment() {
if (!state.editingId) return;
const input = $('comment-input');
const text = input.value.trim();
if (!text) return;
try {
const { task } = await api('POST', `/api/tasks/${state.editingId}/comments`, { text });
upsertTask(task);
input.value = '';
renderComments(task);
renderAll();
} catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); }
}
window.deleteComment = async function (taskId, commentId) {
try {
const { task } = await api('DELETE', `/api/tasks/${taskId}/comments/${commentId}`);
upsertTask(task);
renderComments(task);
renderAll();
} catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); }
};
// ---------- Navnevalg (cookie-auth) ----------
async function setName() {
const name = prompt('Brukernavn (a-z, 0-9, . _ @ -):', state.user === 'anon' ? '' : state.user);
if (!name) return;
try {
await api('POST', '/api/login', { name: name.trim() });
location.reload();
} catch (e) { toast(e.message, { kind: 'err' }); }
}
// ---------- Import / eksport / seed ----------
function exportJSON() {
const blob = new Blob([JSON.stringify(state.tasks, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `netops-todo-${todayISO()}.json`;
a.click();
URL.revokeObjectURL(a.href);
}
async function importJSON(file) {
try {
const data = JSON.parse(await file.text());
if (!Array.isArray(data)) throw new Error('Forventet array');
if (!confirm(`Importere ${data.length} oppgaver? Dette erstatter alt på serveren for alle brukere.`)) return;
await api('POST', '/api/replace', data);
toast('Importert');
} catch (e) { toast('Kunne ikke importere: ' + e.message, { kind: 'err' }); }
}
async function seedSamples() {
try {
const { count } = await api('POST', '/api/seed');
toast(`${count} eksempel-oppgaver lastet`);
} catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); }
}
// ---------- Bindings ----------
function bindUI() {
// Visning
$('view-board').addEventListener('click', () => { state.view = 'board'; savePrefs(); renderView(); });
$('view-table').addEventListener('click', () => { state.view = 'table'; savePrefs(); renderView(); });
$('view-plan').addEventListener('click', () => { state.view = 'plan'; savePrefs(); renderView(); });
$('plan-date').addEventListener('change', e => {
const d = e.target.value || todayISO();
refreshPlan(d);
});
bindPlanContainer();
// Filtre
const f = state.filters;
$('f-status').value = f.status; $('f-category').value = f.category;
$('f-priority').value = f.priority; $('f-search').value = f.search;
$('f-status').addEventListener('change', e => { f.status = e.target.value; savePrefs(); renderView(); });
$('f-category').addEventListener('change', e => { f.category = e.target.value; savePrefs(); renderView(); });
$('f-priority').addEventListener('change', e => { f.priority = e.target.value; savePrefs(); renderView(); });
$('f-owner').addEventListener('change', e => { f.owner = e.target.value; savePrefs(); renderView(); });
$('f-search').addEventListener('input', e => { f.search = e.target.value; savePrefs(); renderView(); });
$('clearFilters').addEventListener('click', () => {
state.filters = { status: 'open', category: 'all', priority: 'all', owner: 'all', search: '' };
$('f-status').value = 'open'; $('f-category').value = 'all';
$('f-priority').value = 'all'; $('f-owner').value = 'all'; $('f-search').value = '';
savePrefs(); renderView();
});
// Quick add
$('quickadd-input').addEventListener('input', renderQuickAddPreview);
$('quickadd-input').addEventListener('keydown', e => { if (e.key === 'Enter') submitQuickAdd(); });
$('quickadd-btn').addEventListener('click', submitQuickAdd);
// Modal
$('newTaskBtn').addEventListener('click', () => openNew());
$('modal-close').addEventListener('click', closeModal);
$('modal-cancel').addEventListener('click', closeModal);
$('modal-save').addEventListener('click', saveForm);
$('modal-delete').addEventListener('click', deleteFromModal);
$('modal').addEventListener('click', e => { if (e.target.id === 'modal') closeModal(); });
$('f-owner-select').addEventListener('change', e => {
if (e.target.value) { $('f-owner-input').value = e.target.value; e.target.value = ''; }
});
$('comment-send').addEventListener('click', sendComment);
$('comment-input').addEventListener('keydown', e => { if (e.key === 'Enter') sendComment(); });
$('f-stat').addEventListener('change', updateStatusNoteField);
// Statuskommentar-dialog
$('status-save').addEventListener('click', () => resolveStatus($('status-comment').value.trim()));
$('status-skip').addEventListener('click', () => resolveStatus(''));
$('status-modal-close').addEventListener('click', () => resolveStatus(null));
$('status-modal').addEventListener('click', e => { if (e.target.id === 'status-modal') resolveStatus(null); });
$('status-comment').addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); resolveStatus($('status-comment').value.trim()); }
});
// Aktivitets-skuff
$('activityBtn').addEventListener('click', () => $('drawer').classList.toggle('open'));
$('drawer-close').addEventListener('click', () => $('drawer').classList.remove('open'));
// Import / eksport / seed / navn
$('exportBtn').addEventListener('click', exportJSON);
$('importBtn').addEventListener('click', () => $('importFile').click());
$('importFile').addEventListener('change', e => {
if (e.target.files[0]) importJSON(e.target.files[0]);
e.target.value = '';
});
$('seedBtn').addEventListener('click', seedSamples);
$('setNameBtn').addEventListener('click', setName);
$('user-chip').addEventListener('click', () => { if (state.canSetName) setName(); });
// Sortering (tabell)
document.querySelectorAll('thead th[data-sort]').forEach(th => {
th.addEventListener('click', () => {
const field = th.dataset.sort;
if (state.sort.field === field) {
state.sort.dir = state.sort.dir === 'asc' ? 'desc' : 'asc';
} else {
state.sort.field = field;
state.sort.dir = field === 'priority' || field === 'deadline' ? 'asc' : 'desc';
}
savePrefs(); renderView();
});
});
// Tastatursnarveier
document.addEventListener('keydown', e => {
const tag = e.target.tagName;
const typing = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
if (e.key === 'Escape') {
if ($('status-modal').classList.contains('open')) { resolveStatus(null); return; }
closeModal(); $('drawer').classList.remove('open'); return;
}
if (typing) return;
if (e.key === 'n') { e.preventDefault(); openNew(); }
if (e.key === '/') { e.preventDefault(); $('f-search').focus(); }
if (e.key === 'v') {
state.view = VIEWS[(VIEWS.indexOf(state.view) + 1) % VIEWS.length];
savePrefs(); renderView();
}
if (e.key === 'p') { state.view = 'plan'; savePrefs(); renderView(); }
if (e.key === 'a') $('drawer').classList.toggle('open');
});
// Hent ferskt når fanen blir synlig igjen (mobil/sove-PC)
document.addEventListener('visibilitychange', () => {
if (!document.hidden && !state.wsOk) refreshState();
});
}
function populateCategorySelects() {
const filterSel = $('f-category');
const formSel = $('f-cat');
CATEGORIES.forEach(c => {
filterSel.insertAdjacentHTML('beforeend', `<option value="${c.id}">${c.name}</option>`);
formSel.insertAdjacentHTML('beforeend', `<option value="${c.id}">${c.name}</option>`);
});
}
// ---------- Init ----------
async function init() {
loadPrefs();
populateCategorySelects();
bindUI();
await refreshState();
connectWS();
// Oppdater relative tidsstempler i aktivitetsfeeden jevnlig
setInterval(() => { if ($('drawer').classList.contains('open')) renderActivity(); }, 60000);
}
init();