'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 ----------
// Innebygde kategorier (samme som serverens DEFAULT_CATEGORIES). Brukes som
// startverdi før /api/state svarer; serveren sender den effektive lista
// (innebygde + egendefinerte) som overstyrer denne.
const DEFAULT_CATEGORIES = [
{ id: 'L1', name: 'L1 Fysisk', color: '#ff9f43', desc: 'Kabling, fiber, patch, SFP, rack' },
{ id: 'L2', name: 'L2 Switching', color: '#4fc3f7', desc: 'VLAN, STP, MAC, trunking' },
{ id: 'L3', name: 'L3 Ruting', color: '#7fb8ff', desc: 'OSPF, ISIS, statisk, VRF' },
{ id: 'L4', name: 'L4 Lastbalansering', color: '#bb86fc', desc: 'HAProxy, NGINX, F5, BIG-IP' },
{ id: 'BGP', name: 'BGP', color: '#4ade80', desc: 'iBGP / eBGP, policies, RR' },
{ id: 'PEER', name: 'Peering / IX', color: '#26de81', desc: 'IXP, private peering, PeeringDB' },
{ id: 'SEC', name: 'Firewall / Security', color: '#ff5370', desc: 'ACL, firewall, IPS, audit' },
{ id: 'NMS', name: 'Overvåking / NMS', color: '#ffd93d', desc: 'LibreNMS, Grafana, alerts' },
{ id: 'AUTO', name: 'Automatisering', color: '#00d2d3', desc: 'Ansible, scripts, CI/CD' },
{ id: 'DOC', name: 'Dokumentasjon', color: '#a0a0a0', desc: 'Diagrammer, IPAM, runbooks' },
{ id: 'INC', name: 'Incident / On-call', color: '#ff6b6b', desc: 'Feilsøking, postmortem' },
{ id: 'PROJ', name: 'Prosjekt / Plan', color: '#8172B3', desc: 'Design, anskaffelse, oppgraderinger' },
];
const NEW_CAT = '__new__'; // sentinel-verdi i kategori-nedtrekk for "ny kategori"
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' },
category: { icon: '🏷', verb: 'la til kategori' },
};
// Kategori-oppslag mot gjeldende (dynamiske) liste i state.
function cats() { return state.categories.length ? state.categories : DEFAULT_CATEGORIES; }
function catById(id) { return cats().find(c => c.id === id); }
function catColor(id) { return (catById(id) || {}).color || '#4fc3f7'; }
function catName(id) { return (catById(id) || {}).name || id; }
// ---------- State ----------
const state = {
user: 'anon',
canSetName: false,
tasks: [],
activity: [],
knownUsers: [],
online: [],
categories: DEFAULT_CATEGORIES,
planIndex: {}, // taskId -> [{user, date, name}] (delte planer + egne)
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
// Dagsplan i visning: date + hvem sin plan (viewUser), navn, items, delt.
plan: { date: '', viewUser: '', name: '', items: [], shared: true },
fieldMode: false, // feltmodus (større knapper for mobil)
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() {
let p = {};
try { p = JSON.parse(localStorage.getItem('netops.prefs') || '{}') || {}; }
catch { /* ignorer korrupt localStorage */ }
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;
if (typeof p.fieldMode === 'boolean') state.fieldMode = p.fieldMode;
// Slå på feltmodus automatisk på små skjermer hvis brukeren ikke har valgt selv,
// og land på dagsplanen hvis det er første besøk (ingen lagret visning).
if (localStorage.getItem('netops.fieldModeSet') !== '1' && window.innerWidth < 640) {
state.fieldMode = true;
if (!VIEWS.includes(p.view)) state.view = 'plan';
}
}
function savePrefs() {
localStorage.setItem('netops.prefs', JSON.stringify({
filters: state.filters, sort: state.sort, view: state.view, fieldMode: state.fieldMode,
}));
}
// ---------- 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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': {
// Oppdater badge-indeksen for (bruker, dato), og den viste planen hvis vi
// ser på akkurat denne. Delte planer mottas av alle; private kun av eier.
applyPlanToIndex(msg.user, msg.date, msg.items, msg.name);
if (msg.user === state.plan.viewUser && msg.date === state.plan.date) {
state.plan.name = msg.name || '';
state.plan.items = msg.items || [];
state.plan.shared = msg.shared !== false;
}
renderAll();
break;
}
case 'categories':
if (Array.isArray(msg.categories)) {
state.categories = msg.categories;
populateCategorySelects();
renderAll();
}
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);
delete state.planIndex[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 (Array.isArray(data.categories) && data.categories.length) state.categories = data.categories;
state.planIndex = data.planIndex || {};
if (data.plan) {
state.plan = {
date: data.plan.date, viewUser: data.plan.user || data.user,
name: data.plan.name || '', items: data.plan.items || [],
shared: data.plan.shared !== false,
};
}
if (!state.plan.date) state.plan = { date: todayISO(), viewUser: data.user, name: '', items: [], shared: true };
populateCategorySelects();
renderAll();
} catch (e) {
toast('Kunne ikke laste: ' + e.message, { kind: 'err' });
}
}
function ownPlan() { return state.plan.viewUser === state.user; }
// ---------- 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 = cats().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) => `${label} ${escapeHtml(val)}`;
chips.push(chip('tittel', p.title || '(mangler)'));
if (p.category) chips.push(chip('kategori', catName(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 => `${escapeHtml(u)}`).join('')
+ (others.length > 6 ? `+${others.length - 6}` : '')
: '';
}
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]) => catName(k)),
datasets: [{
data: catEntries.map(([, v]) => v),
backgroundColor: catEntries.map(([k]) => catColor(k) + 'CC'),
borderColor: catEntries.map(([k]) => catColor(k)),
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 `${t.owner ? escapeHtml(t.owner) : 'utildelt'}`;
}
function catBadge(t) {
const cat = catById(t.category);
const col = catColor(t.category);
return `${escapeHtml(t.category)}`;
}
// Plan-badge(r) for en oppgave: hvilke(n) dag(er)/person(er) den er planlagt.
function planBadges(t) {
const entries = state.planIndex[t.id];
if (!entries || !entries.length) return '';
// Sorter på dato; vis nærmeste, resten som "+N".
const sorted = [...entries].sort((a, b) => a.date.localeCompare(b.date));
const fmt = e => {
const me = e.user === state.user;
const d = e.date.slice(8, 10) + '.' + e.date.slice(5, 7);
const who = me ? 'meg' : e.user;
const title = e.name ? `${e.name} — ${who} ${e.date}` : `Planlagt ${e.date} av ${who}`;
return `📅 ${d} ${escapeHtml(who)}`;
};
let html = fmt(sorted[0]);
if (sorted.length > 1) html += ` +${sorted.length - 1}`;
return html;
}
function editingTag(t) {
const editors = (state.editingMap[t.id] || []).filter(u => u !== state.user);
return editors.length ? `✎ ${escapeHtml(editors[0])}` : '';
}
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 = `
+ ${items.length - 15} eldre ferdige (se tabell-visning)
`;
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 `
${STATUS_LABELS[status]}
${items.length}${est > 0 && status !== 'done' ? ` · ${fmtHours(est)}` : ''}
${items.map(renderCard).join('')}
${moreNote}
`;
}).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 => `#${escapeHtml(tag)}`).join(' ');
return `
${editingTag(t)}
${t.priority}
${catBadge(t)}
${t.location ? `${escapeHtml(t.location)}` : ''}
${escapeHtml(t.title)}
${ownerBadge(t)}
${planBadges(t)}
${t.deadline ? `${dl.text}` : ''}
${t.estHours ? `~${t.estHours}t` : ''}
${t.comments && t.comments.length ? `` : ''}
${tags}
`;
}
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 => `#${escapeHtml(tag)}`).join(' ');
const editors = (state.editingMap[t.id] || []).filter(u => u !== state.user);
return `
| ${t.priority} |
${escapeHtml(t.title)}
${t.comments && t.comments.length ? `` : ''}
${editors.length ? `✎ ${escapeHtml(editors.join(', '))} redigerer` : ''}
${t.desc ? `${escapeHtml(t.desc.slice(0, 160))}${t.desc.length > 160 ? '…' : ''} ` : ''}
${tags || planBadges(t) ? `${planBadges(t)} ${tags} ` : ''}
|
${catBadge(t)} |
${STATUS_LABELS[t.status] || t.status} |
${ownerBadge(t)} |
${escapeHtml(t.location || '—')} |
${t.estHours || '—'} |
${dl.text} |
${t.status !== 'done'
? ``
: ``}
${!t.owner ? `` : ''}
|
`;
}).join('');
}
// ---------- Dagsplan ----------
// Oppgavene i planen, i lagret rekkefølge, med slettede filtrert bort.
function planTasks() {
return state.plan.items.map(findTask).filter(Boolean);
}
// Oppdater badge-indeksen for én (bruker, dato): fjern gamle bidrag, legg til nye.
function applyPlanToIndex(user, date, items, name) {
for (const id of Object.keys(state.planIndex)) {
const kept = state.planIndex[id].filter(e => !(e.user === user && e.date === date));
if (kept.length) state.planIndex[id] = kept; else delete state.planIndex[id];
}
for (const id of items || []) {
(state.planIndex[id] ||= []).push({ user, date, name: name || '' });
}
}
function renderPlan() {
$('empty').style.display = 'none';
const own = ownPlan();
// Header-felter
const dateInput = $('plan-date');
if (document.activeElement !== dateInput) dateInput.value = state.plan.date;
const nameInput = $('plan-name');
if (document.activeElement !== nameInput) nameInput.value = state.plan.name || '';
nameInput.readOnly = !own;
nameInput.placeholder = own ? 'Navngi planen (valgfritt) …' : '(uten navn)';
$('plan-shared').checked = state.plan.shared !== false;
$('plan-shared').disabled = !own;
$('plan-shared').closest('.plan-share').style.display = own ? '' : 'none';
$('plan-move').style.display = own ? '' : 'none';
populatePlanUserSelect();
// Lesemodus-banner når man ser en annens plan
const ro = $('plan-readonly');
if (!own) {
ro.textContent = `Du ser ${state.plan.viewUser} sin dagsplan (skrivebeskyttet).`;
ro.classList.add('show');
} else {
ro.classList.remove('show');
}
// Venstre kolonne (legg til) gir bare mening for egen plan
$('plan-available-col').style.display = own ? '' : 'none';
$('planner').style.gridTemplateColumns = own ? '' : '1fr';
const planned = planTasks();
const planIds = new Set(planned.map(t => t.id));
if (own) {
// 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('')
: 'Ingen åpne oppgaver igjen — alt er planlagt eller filtrert bort.
';
}
// 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. · ${fmtHours(hours)} planlagt${doneCount ? ` · ${doneCount} ferdig` : ''}`
: '';
cap.className = 'col-meta' + (hours > 8 ? ' over-capacity' : '');
const emptyMsg = own
? 'Tomt. Legg til oppgaver fra venstre med +, eller dra dem hit. Dra for å sortere.'
: 'Denne dagsplanen er tom.';
$('plan-today-body').innerHTML = planned.length
? planned.map((t, i) => renderPlanItem(t, i, own)).join('')
: `${emptyMsg}
`;
if (own) bindPlanDnD();
}
function populatePlanUserSelect() {
const sel = $('plan-view-user');
const users = new Set([state.user, ...state.knownUsers, ...state.online]);
const sorted = [...users].filter(Boolean).sort();
sel.innerHTML = sorted.map(u =>
``).join('');
sel.value = state.plan.viewUser;
}
function renderAvailRow(t) {
const dl = formatDeadline(t.deadline);
return `
${escapeHtml(t.title)}
${t.priority}
${catBadge(t)}
${t.estHours ? `~${t.estHours}t` : ''}
${t.deadline ? `${dl.text}` : ''}
${t.owner ? ownerBadge(t) : ''}
`;
}
function renderPlanItem(t, i, own = true) {
const dl = formatDeadline(t.deadline);
const doneCls = t.status === 'done' ? ' done-item' : '';
return `
${own ? '
⠿' : ''}
${i + 1}
${escapeHtml(t.title)}
${t.location ? `
📍 ${escapeHtml(t.location)}
` : ''}
${t.priority}
${catBadge(t)}
${STATUS_LABELS[t.status] || t.status}
${t.estHours ? `~${t.estHours}t` : ''}
${t.deadline ? `${dl.text}` : ''}
${own ? `
${t.status !== 'done'
? ``
: ``}
` : ''}
`;
}
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() {
if (!ownPlan()) return; // kan ikke endre andres plan
renderPlan(); // optimistisk
try {
const { name, items, shared } = await api('PUT', '/api/plan', {
date: state.plan.date, items: state.plan.items, name: state.plan.name, shared: state.plan.shared,
});
state.plan.name = name; state.plan.items = items; state.plan.shared = shared;
renderPlan();
} catch (e) {
toast('Kunne ikke lagre dagsplan: ' + e.message, { kind: 'err' });
refreshPlan();
}
}
window.addToPlan = function (id) {
if (!ownPlan()) return;
if (!state.plan.items.includes(id) && findTask(id)) {
state.plan.items.push(id);
savePlan();
toast('Lagt i dagsplan');
}
};
window.removeFromPlan = function (id) {
if (!ownPlan()) return;
state.plan.items = state.plan.items.filter(x => x !== id);
savePlan();
};
// Lagre planens navn (kalles på blur/enter)
async function savePlanName() {
if (!ownPlan()) return;
const name = $('plan-name').value.trim();
if (name === (state.plan.name || '')) return;
state.plan.name = name;
savePlan();
}
// Slå deling av/på for egen plan på valgt dato
async function togglePlanShared() {
if (!ownPlan()) return;
const shared = $('plan-shared').checked;
state.plan.shared = shared;
try {
await api('PUT', '/api/plan/share', { date: state.plan.date, shared });
toast(shared ? 'Planen er nå synlig for alle' : 'Planen er nå privat');
} catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); refreshPlan(); }
}
// Flytt hele planen til en annen dato
async function movePlanToDate() {
if (!ownPlan()) return;
const to = $('plan-move-date').value;
if (!to) { toast('Velg en måldato først', { kind: 'warn' }); return; }
if (to === state.plan.date) { toast('Planen er allerede på denne datoen', { kind: 'warn' }); return; }
const from = state.plan.date;
try {
const data = await api('PUT', '/api/plan/move', { from, to });
state.plan = { date: data.date, viewUser: state.user, name: data.name, items: data.items, shared: data.shared !== false };
$('plan-move-date').value = '';
renderPlan();
toast(`Dagsplanen er flyttet til ${to}`);
} catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); }
}
async function refreshPlan(date = state.plan.date, user = state.plan.viewUser) {
try {
const data = await api('GET', `/api/plan?date=${encodeURIComponent(date)}&user=${encodeURIComponent(user || state.user)}`);
state.plan = {
date: data.date, viewUser: data.user || user || state.user,
name: data.name || '', items: data.items || [], shared: data.shared !== false,
};
if (state.view === 'plan') renderPlan();
} catch (e) {
if (e.status === 403) {
toast(`${user} sin plan denne dagen er privat`, { kind: 'warn' });
state.plan.viewUser = state.user;
refreshPlan(date, state.user);
}
}
}
function renderActivity() {
const body = $('drawer-body');
if (!state.activity.length) {
body.innerHTML = 'Ingen aktivitet ennå.
';
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)
? `${escapeHtml(a.title)}`
: `${escapeHtml(a.title || '')}`;
const detail = (a.detail || []).join(' · ');
return `
${meta.icon}
${escapeHtml(a.user)} ${meta.verb} ${titleHtml}
${detail ? `
${escapeHtml(detail)}
` : ''}
${relTime(a.at)}
`;
}).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 = ''
+ sorted.map(u => ``).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 = ''
+ sorted.map(u => ``).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 || cats()[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 = '';
return;
}
list.innerHTML = t.comments.map(c => {
const isStatus = c.kind === 'status' && c.status;
const kindTag = isStatus
? ``
: '';
return `
`;
}).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' }); }
};
// ---------- Feltmodus ----------
function applyFieldMode() {
document.body.classList.toggle('field-mode', state.fieldMode);
$('fieldBtn').className = state.fieldMode ? 'active-view' : '';
}
function toggleFieldMode() {
state.fieldMode = !state.fieldMode;
localStorage.setItem('netops.fieldModeSet', '1'); // brukeren har valgt selv
// I felt vil man ha dagsplanen først; hopp dit når feltmodus slås på.
if (state.fieldMode) state.view = 'plan';
savePrefs();
applyFieldMode();
renderView();
toast(state.fieldMode ? 'Feltmodus på — viser dagsplan' : 'Feltmodus av');
}
// ---------- 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 => refreshPlan(e.target.value || todayISO(), state.plan.viewUser));
$('plan-view-user').addEventListener('change', e => refreshPlan(state.plan.date, e.target.value));
$('plan-name').addEventListener('change', savePlanName);
$('plan-name').addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); e.target.blur(); } });
$('plan-shared').addEventListener('change', togglePlanShared);
$('plan-move-btn').addEventListener('click', movePlanToDate);
$('fieldBtn').addEventListener('click', toggleFieldMode);
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 = ''; }
});
$('f-cat').addEventListener('change', e => { if (e.target.value === NEW_CAT) addCategoryFlow(); });
$('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 === 'f') toggleFieldMode();
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');
const opts = cats().map(c => ``).join('');
// Filter: behold valgt verdi
filterSel.innerHTML = '' + opts;
filterSel.value = [...filterSel.options].some(o => o.value === state.filters.category) ? state.filters.category : 'all';
// Modal: behold valgt verdi, og tilby "ny kategori"
const prev = formSel.value;
formSel.innerHTML = opts + ``;
if ([...formSel.options].some(o => o.value === prev)) formSel.value = prev;
}
// Oppretter en ny kategori via prompt og velger den i modalen.
async function addCategoryFlow() {
const editing = state.editingId && findTask(state.editingId);
const fallback = editing ? editing.category : cats()[0].id;
const name = prompt('Navn på ny kategori (f.eks. «Trådløst», «Telefoni»):', '');
if (!name || !name.trim()) { $('f-cat').value = fallback; return; }
try {
const { category } = await api('POST', '/api/categories', { name: name.trim() });
// state.categories oppdateres også via WS-broadcast, men sett lokalt nå for respons
if (!state.categories.some(c => c.id === category.id)) state.categories = [...state.categories, category];
populateCategorySelects();
$('f-cat').value = category.id;
toast(`Kategori «${category.name}» (${category.id}) opprettet`);
} catch (e) {
$('f-cat').value = fallback;
toast('Feil: ' + e.message, { kind: 'err' });
}
}
// ---------- Init ----------
async function init() {
loadPrefs();
applyFieldMode();
populateCategorySelects();
bindUI();
await refreshState();
connectWS();
// Oppdater relative tidsstempler i aktivitetsfeeden jevnlig
setInterval(() => { if ($('drawer').classList.contains('open')) renderActivity(); }, 60000);
}
init();