netops/netops-todo-node/public/app.js
Jon Vanvik 4f24592dd0 Mer neon i appen, mobiltilpasning og gruppert søkbar planoversikt
- Neon-tema trukket videre inn i hoved-GUI: animert topp-stripe, glødende
  panel-kanter/overskrifter, KPI-glød, fokus-glød på input, kort-hover.
- Mobil (≤600px): fjerner horisontal overflyt — planner-header, filtre,
  hurtig-add, planoversikt-panel, admin-skjema og skuff fyller/wrapper bredden.
- Planvelger → «📋 Planoversikt (N)»: gruppert i I dag/Kommende/Tidligere med
  relative datoer, søk på navn/dato/person, markerer aktiv plan.

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

1802 lines
73 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 ----------
// 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,
auth: { mode: 'proxy', via: null, hasPassword: false, role: 'standard', isAdmin: 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) {
// Sesjon utløpt / ikke innlogget i app-modus → til login-siden.
if (res.status === 401 && data && data.login) { location.href = '/login'; }
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': {
// 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;
if (data.auth) state.auth = data.auth;
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) => `<span class="qa-chip">${label} <b>${escapeHtml(val)}</b></span>`;
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;
const chipClickable = state.canSetName || state.auth.mode === 'app';
$('user-chip').className = 'user-chip' + (chipClickable ? ' clickable' : '');
$('user-chip').title = state.auth.mode === 'app' ? 'Konto: passord / logg ut' : 'Pålogget bruker';
$('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]) => 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 `<span class="owner-badge ${cls}">${t.owner ? escapeHtml(t.owner) : 'utildelt'}</span>`;
}
function catBadge(t) {
const cat = catById(t.category);
const col = catColor(t.category);
return `<span class="cat-badge" style="color:${col};border-color:${col}40;background:${col}15" title="${cat ? escapeHtml(cat.desc || cat.name) : ''}">${escapeHtml(t.category)}</span>`;
}
// 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 `<span class="plan-badge ${me ? 'me' : ''}" title="${escapeHtml(title)}">📅 ${d} ${escapeHtml(who)}</span>`;
};
let html = fmt(sorted[0]);
if (sorted.length > 1) html += ` <span class="plan-badge more" title="Planlagt i ${sorted.length} planer">+${sorted.length - 1}</span>`;
return html;
}
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)}
${planBadges(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 || planBadges(t) ? `<div style="margin-top:4px;display:flex;gap:6px;flex-wrap:wrap;align-items:center">${planBadges(t)} ${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);
}
// 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';
const nPlans = derivePlanList().length;
$('plan-picker-btn').textContent = `📋 Planoversikt${nPlans ? ` (${nPlans})` : ''}`;
// Hold panelet oppdatert hvis det er åpent
if ($('plan-picker-panel').classList.contains('open')) renderPlanPicker($('plan-picker-search').value);
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('')
: '<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' : '');
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('')
: `<div class="plan-empty">${emptyMsg}</div>`;
if (own) bindPlanDnD();
}
// ---------- Søkbar planliste ----------
// Avled plansammendrag fra badge-indeksen (allerede filtrert til synlige planer).
function derivePlanList() {
const map = new Map();
for (const entries of Object.values(state.planIndex)) {
for (const e of entries) {
const key = e.user + '|' + e.date;
let g = map.get(key);
if (!g) { g = { user: e.user, date: e.date, name: e.name || '', count: 0 }; map.set(key, g); }
g.count++;
if (!g.name && e.name) g.name = e.name;
}
}
return [...map.values()].sort((a, b) => {
const am = a.user === state.user ? 0 : 1, bm = b.user === state.user ? 0 : 1;
if (am !== bm) return am - bm; // mine planer først
return b.date.localeCompare(a.date); // så nyeste dato først
});
}
function planDateLabel(date) {
const today = todayISO();
if (date === today) return 'I dag';
if (date === todayISO(1)) return 'I morgen';
if (date === todayISO(-1)) return 'I går';
const diff = Math.round((new Date(date) - new Date(today)) / 86400000);
if (diff > 1 && diff <= 7) return `om ${diff} dager`;
if (diff < -1 && diff >= -7) return `${Math.abs(diff)} dager siden`;
return date;
}
function planPickRow(p) {
const me = p.user === state.user;
const cur = p.user === state.plan.viewUser && p.date === state.plan.date;
return `<div class="plan-pick-row${cur ? ' current' : ''}" onclick="pickPlan('${escapeHtml(p.user)}','${p.date}')">
<span class="pp-date">${planDateLabel(p.date)}</span>
<span class="pp-name ${p.name ? '' : 'unnamed'}">${p.name ? escapeHtml(p.name) : '(uten navn)'}</span>
<span class="pp-who ${me ? 'me' : ''}">${me ? 'meg' : escapeHtml(p.user)}</span>
<span class="pp-count" title="oppgaver">${p.count}</span>
</div>`;
}
function renderPlanPicker(filter = '') {
const q = filter.trim().toLowerCase();
const list = derivePlanList().filter(p => {
if (!q) return true;
return [p.name, p.date, planDateLabel(p.date), p.user === state.user ? 'meg' : p.user].join(' ').toLowerCase().includes(q);
});
const el = $('plan-picker-list');
if (!list.length) {
el.innerHTML = `<div class="plan-pick-empty">${q ? 'Ingen planer matcher søket.' : 'Ingen planer ennå — lag en ved å legge oppgaver i dagens plan.'}</div>`;
return;
}
const today = todayISO();
const groups = { today: [], upcoming: [], past: [] };
for (const p of list) {
if (p.date === today) groups.today.push(p);
else if (p.date > today) groups.upcoming.push(p);
else groups.past.push(p);
}
groups.upcoming.sort((a, b) => a.date.localeCompare(b.date)); // nærmeste først
groups.past.sort((a, b) => b.date.localeCompare(a.date)); // nyeste fortid først
const section = (title, arr) => arr.length ? `<div class="pp-group">${title}</div>` + arr.map(planPickRow).join('') : '';
el.innerHTML = section('I dag', groups.today) + section('Kommende', groups.upcoming) + section('Tidligere', groups.past);
}
function togglePlanPicker(open) {
const panel = $('plan-picker-panel');
const show = open != null ? open : !panel.classList.contains('open');
panel.classList.toggle('open', show);
if (show) {
$('plan-picker-search').value = '';
renderPlanPicker('');
setTimeout(() => $('plan-picker-search').focus(), 30);
}
}
window.pickPlan = function (user, date) {
togglePlanPicker(false);
refreshPlan(date, user);
};
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 =>
`<option value="${escapeHtml(u)}">${u === state.user ? 'Meg' : escapeHtml(u)}</option>`).join('');
sel.value = state.plan.viewUser;
}
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, own = true) {
const dl = formatDeadline(t.deadline);
const doneCls = t.status === 'done' ? ' done-item' : '';
return `
<div class="plan-item${doneCls}"${own ? ' draggable="true"' : ''} data-id="${t.id}">
${own ? '<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>
${t.location ? `<div class="plan-item-loc">📍 ${escapeHtml(t.location)}</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>
${own ? `<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() {
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 = '<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 || 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 = '<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' }); }
};
// ---------- 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' }); }
}
// ---------- Konto (app-innlogging) ----------
function openAccount() {
const viaProxy = state.auth.via === 'proxy';
const has = state.auth.hasPassword;
const status = $('account-status');
if (viaProxy && !has) {
status.textContent = `Du er innlogget via proxy (${state.user}). Sett et app-passord her, så kan du logge inn uten proxy og skru av Basic Auth.`;
} else if (viaProxy && has) {
status.textContent = `Innlogget via proxy som ${state.user}. Du har allerede et app-passord — du kan endre det her.`;
} else {
status.textContent = `Innlogget som ${state.user} (app-sesjon).`;
}
status.className = 'modal-warning show';
// Nåværende passord kreves kun når du endrer via en app-sesjon
$('account-current-field').style.display = (has && state.auth.via === 'session') ? '' : 'none';
$('account-pw-label').textContent = has ? 'Nytt passord' : 'Velg passord';
$('account-current').value = '';
$('account-newpw').value = '';
$('account-newpw2').value = '';
// Logg ut alle enheter krever en lagret konto (ikke ren proxy-bypass)
$('account-logout-all').style.display = has ? '' : 'none';
// Rolle-badge
const badge = $('account-role-badge');
badge.textContent = state.auth.role || 'standard';
badge.className = 'role-badge ' + (state.auth.isAdmin ? 'admin' : 'standard');
badge.style.display = '';
// Admin-seksjon
$('account-admin').style.display = state.auth.isAdmin ? '' : 'none';
if (state.auth.isAdmin) loadUserList();
$('account-modal').classList.add('open');
setTimeout(() => $('account-newpw').focus(), 50);
}
function closeAccount() { $('account-modal').classList.remove('open'); }
// ---------- Kontoadministrasjon (admin) ----------
async function loadUserList() {
const box = $('admin-userlist');
try {
const { users } = await api('GET', '/api/auth/users');
box.innerHTML = users.map(u => {
const me = u.user === state.user;
const toRole = u.role === 'admin' ? 'standard' : 'admin';
return `<div class="admin-user">
<span class="au-name">${escapeHtml(u.user)}</span>
<span class="role-badge ${u.role}">${u.role}</span>
${me ? '<span class="au-me">deg</span>' : `<div class="au-actions">
<button class="icon-btn" title="Gjør til ${toRole}" onclick="adminSetRole('${escapeHtml(u.user)}','${toRole}')">⇅</button>
<button class="icon-btn" title="Tilbakestill passord" onclick="adminResetPassword('${escapeHtml(u.user)}')">🔑</button>
<button class="icon-btn del" title="Slett konto" onclick="adminDeleteUser('${escapeHtml(u.user)}')">🗑</button>
</div>`}
</div>`;
}).join('');
} catch (e) { box.innerHTML = `<div class="plan-empty">Kunne ikke laste brukere: ${escapeHtml(e.message)}</div>`; }
}
async function adminCreateUser() {
const user = $('admin-new-user').value.trim();
const password = $('admin-new-pass').value;
const role = $('admin-new-role').value;
if (!user) { toast('Skriv et brukernavn', { kind: 'warn' }); return; }
if (password.length < 6) { toast('Passord må være minst 6 tegn', { kind: 'warn' }); return; }
try {
await api('POST', '/api/auth/users', { user, password, role });
$('admin-new-user').value = ''; $('admin-new-pass').value = '';
loadUserList();
toast(`Konto «${user}» (${role}) opprettet`);
} catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); }
}
window.adminSetRole = async function (user, role) {
try { await api('PUT', `/api/auth/users/${encodeURIComponent(user)}/role`, { role }); loadUserList(); toast(`${user} er nå ${role}`); }
catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); }
};
window.adminResetPassword = async function (user) {
const pw = prompt(`Nytt passord for ${user} (min 6 tegn). Brukeren blir logget ut overalt:`);
if (pw == null) return;
try { await api('POST', `/api/auth/users/${encodeURIComponent(user)}/password`, { password: pw }); toast(`Passord tilbakestilt for ${user}`); }
catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); }
};
window.adminDeleteUser = async function (user) {
if (!confirm(`Slette kontoen «${user}»? Dette kan ikke angres.`)) return;
try { await api('DELETE', `/api/auth/users/${encodeURIComponent(user)}`); loadUserList(); toast(`Konto «${user}» slettet`); }
catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); }
};
async function logoutAll() {
if (!confirm('Logg ut alle enheter? Du forblir innlogget her, men alle andre økter avsluttes.')) return;
try { await api('POST', '/api/auth/logout-all'); toast('Alle andre økter er logget ut'); }
catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); }
}
async function saveAccountPassword() {
const pw = $('account-newpw').value;
const pw2 = $('account-newpw2').value;
if (pw.length < 6) { toast('Passord må være minst 6 tegn', { kind: 'warn' }); return; }
if (pw !== pw2) { toast('Passordene er ikke like', { kind: 'warn' }); return; }
const body = { password: pw };
if ($('account-current-field').style.display !== 'none') body.current = $('account-current').value;
try {
const r = await api('POST', '/api/auth/password', body);
state.auth.hasPassword = true;
state.auth.via = 'session';
if (r.role) { state.auth.role = r.role; state.auth.isAdmin = r.role === 'admin'; }
closeAccount();
renderHeader();
toast('Passord lagret — du er nå innlogget med app-sesjon');
} catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); }
}
async function logout() {
try { await api('POST', '/api/auth/logout'); } catch { /* uansett til login */ }
location.href = '/login';
}
// ---------- 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);
$('plan-picker-btn').addEventListener('click', e => { e.stopPropagation(); togglePlanPicker(); });
$('plan-picker-search').addEventListener('input', e => renderPlanPicker(e.target.value));
$('plan-picker-panel').addEventListener('click', e => e.stopPropagation());
document.addEventListener('click', () => togglePlanPicker(false)); // klikk utenfor lukker
$('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.auth.mode === 'app') openAccount();
else if (state.canSetName) setName();
});
// Konto-modal
$('account-close').addEventListener('click', closeAccount);
$('account-cancel').addEventListener('click', closeAccount);
$('account-save').addEventListener('click', saveAccountPassword);
$('account-logout').addEventListener('click', logout);
$('account-logout-all').addEventListener('click', logoutAll);
$('admin-create-btn').addEventListener('click', adminCreateUser);
$('admin-new-pass').addEventListener('keydown', e => { if (e.key === 'Enter') adminCreateUser(); });
$('account-modal').addEventListener('click', e => { if (e.target.id === 'account-modal') closeAccount(); });
$('account-newpw2').addEventListener('keydown', e => { if (e.key === 'Enter') saveAccountPassword(); });
// 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; }
if ($('account-modal').classList.contains('open')) { closeAccount(); 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 => `<option value="${escapeHtml(c.id)}">${escapeHtml(c.name)}</option>`).join('');
// Filter: behold valgt verdi
filterSel.innerHTML = '<option value="all">Alle</option>' + 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 + `<option value="${NEW_CAT}"> Ny kategori …</option>`;
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();