diff --git a/netops-todo-node/CLAUDE.md b/netops-todo-node/CLAUDE.md index 47bf795..bc9fd33 100644 --- a/netops-todo-node/CLAUDE.md +++ b/netops-todo-node/CLAUDE.md @@ -16,6 +16,10 @@ Nytt mot PHP-versjonen: - **WebSocket-sanntidssynk** (erstatter 60s-polling) + presence + "redigerer nå" - **Kanban-tavle** med drag & drop, i tillegg til tabellvisning +- **Dagsplan** (tredje visning) — per bruker, per dato; ordnet liste man bygger + ved å plukke fra backlog. Drag-sortering, kapasitet i timer, synk mellom egne faner. +- **Kommentar ved statusbytte** — valgfri `comment` på `PUT /api/tasks/:id` + festes som kommentar med `kind:'status'` og legges i aktivitets-detaljen. - **Kommentarer** per oppgave - **Aktivitetslogg** (hvem gjorde hva, server-side, capped til 400 entries) - **Optimistisk låsing** — `v`-felt per oppgave, 409 ved konflikt @@ -34,8 +38,10 @@ netops-todo-node/ │ ├── app.js All frontend-logikk (vanilla JS, ingen rammeverk). │ └── style.css Mørkt tema, samme visuelle identitet som PHP-versjonen. ├── seed.json 16 eksempel-oppgaver. +├── setup-debian13.sh Engangs-oppsett på Debian 13 LXC (apt + npm ci + systemd). ├── test/api.test.js Integrasjonstester (node:test) — REST + WS ende-til-ende. -├── data/ Opprettes ved kjøring: tasks.json, activity.json, users.json. +├── data/ Opprettes ved kjøring: tasks.json, activity.json, +│ users.json, plans.json. └── README.md Installasjon, proxy-oppsett, API-referanse. ``` @@ -58,6 +64,7 @@ netops-todo-node/ | `task` | `op: create/update/delete`, `task`/`id`, `by`, `activity` | Etter hver mutasjon | | `presence` | `online[]` | Når noen kobler til/fra | | `editing` | `editing{}` = `{taskId: [brukere]}` | Når noen åpner/lukker modal | +| `plan` | `date`, `items[]` | Etter `PUT /api/plan` — sendes KUN til samme brukers faner (`sendToUser`) | Klient → server: kun `{ type: 'editing', id, active }`. @@ -68,16 +75,33 @@ Som PHP-versjonen, pluss: ```json { "v": 3, - "comments": [{ "id": "c_…", "by": "jon", "at": "ISO", "text": "≤1000" }] + "comments": [{ "id": "c_…", "by": "jon", "at": "ISO", "text": "≤1000", + "kind": "status", "status": "done" }] } ``` -`v` bumpes ved hver felt-oppdatering (ikke ved kommentarer — bevisst, så en -kommentar ikke gir falsk konflikt for noen som redigerer felter samtidig). +`v` bumpes ved hver felt-oppdatering (også når en `comment` følger med et +statusbytte via `update()`, siden det ER en oppdatering — men IKKE via det +separate `POST .../comments`-endepunktet, så en ren kommentar ikke gir falsk +konflikt for noen som redigerer felter samtidig). `kind`/`status` settes kun på +kommentarer som kommer fra et statusbytte; vanlige kommentarer mangler dem. Klienten sender `v` den redigerte fra; mismatch → 409 med serverens task i svaret. Frontend håndterer det i `handleSaveError()`: oppdaterer visningen, viser varsel, og lar brukeren lagre på nytt (som da overskriver bevisst). +### Dagsplan-skjema (`plans.json`) + +```json +{ "jon": { "2026-06-15": ["t_abc", "t_def"] } } +``` + +Per bruker, per dato, ordnet liste av task-ids. `getPlan()` filtrerer bort +slettede oppgaver ved lesing; `setPlan()` deduperer og validerer mot +eksisterende oppgaver; `_scrubFromPlans()` rydder ved sletting; `_prunePlans()` +dropper datoer eldre enn 30 dager ved oppstart. Planen er bevisst privat (kun +`owner`-attribusjon på selve oppgavene er delt), men synkes mellom samme +brukers faner via `plan`-WS-meldingen. + ## Designvalg verdt å vite - **Ingen database, ingen frontend-rammeverk, to npm-avhengigheter** @@ -87,6 +111,14 @@ viser varsel, og lar brukeren lagre på nytt (som da overskriver bevisst). UI-et fungerer selv om WS er nede (da poller frontend hvert 30s). - **Delvis update er lov:** `PUT /api/tasks/:id` med bare `{v, status}` — normalisering merger mot eksisterende. Quick-actions bruker dette. +- **Statuskommentar går gjennom samme update-kall** (transient `comment`-felt), + ikke et eget endepunkt — så statusbytte + kommentar + aktivitetslinje blir + atomisk og ett enkelt WS-broadcast. `changeStatusWithComment()` i app.js viser + dialogen for ferdig/blokkert; tavle-drag og tabell-✓ ruter gjennom den. +- **Dagsplanen er optimistisk i frontend:** `addToPlan`/`removeFromPlan`/drag + muterer `state.plan.items` og kaller `savePlan()` (PUT) som reconciler mot + serverens validerte svar. Container-nivå drag-lyttere bindes én gang + (`bindPlanContainer`); per-element bindes ved hver render (`bindPlanDnD`). - **Kommentar-sletting er begrenset til egen bruker** (eneste autorisasjonsregel i appen — alt annet er åpent for alle innloggede). - Chart.js fra CDN; frontend degraderer pent (skjuler grafer) uten nett. @@ -117,11 +149,15 @@ strenglengde-klamping, brukernavn-regex `[A-Za-z0-9._@-]{1,64}`), pluss: ## Testing-status -- `npm test`: 12 integrasjonstester (node:test), booter ekte server på +- `npm test`: 20 integrasjonstester (node:test), booter ekte server på tilfeldig port med temp-datakatalog. Dekker normalisering, versjonskonflikt, - kommentar-autorisasjon, aktivitetslogg, WS-broadcast, persistens på disk, - cookie-auth. Alle grønne per 2026-06-12 (Node 25). -- Manuell røyk-test utført: `/healthz`, statiske filer, seed, state, og - WS-push av REST-endring verifisert ende-til-ende. + kommentar-autorisasjon, statuskommentar-på-update, dagsplan (CRUD, per-bruker, + validering, scrub ved sletting, dato-fallback), aktivitetslogg, WS-broadcast + + per-bruker plan-push, persistens på disk, cookie-auth. Alle grønne per + 2026-06-15 (Node 25). +- Manuell røyk-test utført: `/healthz`, statiske filer, seed, state, plan + set/get med rekkefølge, ferdig-med-kommentar, og per-bruker-isolasjon av plan + verifisert ende-til-ende over HTTP. - Ikke testet i nettleser med flere samtidige brukere — første ekte test bør - være to nettleservinduer mot `npm start` og flytte kort på tavla. + være to nettleservinduer mot `npm start`: flytt kort på tavla, bygg en + dagsplan og dra for å sortere, og marker ferdig for å se kommentar-dialogen. diff --git a/netops-todo-node/README.md b/netops-todo-node/README.md index b74410d..30ed0f3 100644 --- a/netops-todo-node/README.md +++ b/netops-todo-node/README.md @@ -134,6 +134,12 @@ WantedBy=multi-user.target - **Sanntidssynk** — alle endringer pushes til alle åpne faner via WebSocket - **Presence** — se hvem som er pålogget akkurat nå - **Kanban-tavle** med drag & drop mellom statuskolonner, pluss klassisk tabellvisning +- **Dagsplan** — plukk oppgaver fra lista og bygg en ordnet plan for dagen + (dra for å sortere, kapasitet i timer, datovelger). Planen er personlig og + synkes mellom dine egne faner. +- **Kommentar ved statusbytte** — når du markerer ferdig eller blokkert (via + knapp, kanban-drag eller modal) tilbys du å skrive en kommentar som festes + til oppgaven og vises i aktivitetsloggen - **Hurtig-registrering** med tokens: `Bytt SFP #l1 p2 @me !imorgen ~1h loc:DC-OSL-1` - **Kommentarer** per oppgave - **Aktivitetslogg** — hvem gjorde hva, når (sidepanel) @@ -143,7 +149,7 @@ WantedBy=multi-user.target - **Angre sletting** direkte fra toast - KPI-er, grafer (kategori/prioritet/eier), filtre, sortering, søk - Eksport/import av JSON, eksempeldata for demo -- Tastatursnarveier: `n` ny · `/` søk · `v` bytt visning · `a` aktivitet · `esc` lukk +- Tastatursnarveier: `n` ny · `/` søk · `v` bytt visning · `p` dagsplan · `a` aktivitet · `esc` lukk ## API @@ -152,12 +158,14 @@ optimistisk låsing — send med ved update, få 409 hvis noen andre har lagret. | Metode | Endepunkt | Beskrivelse | |---|---|---| -| GET | `/api/state` | Bruker, oppgaver, aktivitet, kjente brukere, hvem som er online | +| GET | `/api/state` | Bruker, oppgaver, aktivitet, kjente brukere, online, og dagens plan | | POST | `/api/tasks` | Opprett oppgave (`title` påkrevd) | -| PUT | `/api/tasks/:id` | Oppdater (delvis OK; send `v` for konfliktsjekk) | +| PUT | `/api/tasks/:id` | Oppdater (delvis OK; send `v` for konfliktsjekk; valgfri `comment` festes som status-kommentar) | | DELETE | `/api/tasks/:id` | Slett | | POST | `/api/tasks/:id/comments` | Legg til kommentar `{ text }` | | DELETE | `/api/tasks/:id/comments/:cid` | Slett egen kommentar | +| GET | `/api/plan?date=YYYY-MM-DD` | Hent egen dagsplan (ordnet liste av task-ids) | +| PUT | `/api/plan` | Lagre egen dagsplan `{ date, items: [id…] }` | | POST | `/api/replace` | Erstatt hele listen (import) | | POST | `/api/seed` | Last eksempeldata (kun hvis tomt) | | POST | `/api/login` | Sett navn-cookie (hvis cookie-auth er på) | @@ -175,5 +183,7 @@ curl -s -X POST localhost:3000/api/tasks \ ## Lagring JSON-filer i `DATA_DIR` (atomisk skriv via tmp-fil + rename, serialisert i -prosessen). Én node-prosess per datakatalog. Holder fint til tusenvis av -oppgaver; blir det trangt er SQLite (`node:sqlite`) neste naturlige steg. +prosessen): `tasks.json`, `activity.json`, `users.json` og `plans.json` +(dagsplaner per bruker per dato; planer eldre enn 30 dager ryddes ved oppstart). +Én node-prosess per datakatalog. Holder fint til tusenvis av oppgaver; blir det +trangt er SQLite (`node:sqlite`) neste naturlige steg. diff --git a/netops-todo-node/lib/store.js b/netops-todo-node/lib/store.js index 91228d4..2beb2f9 100644 --- a/netops-todo-node/lib/store.js +++ b/netops-todo-node/lib/store.js @@ -146,15 +146,19 @@ function diffDetail(before, after) { return detail; } +const PLAN_RETENTION_DAYS = 30; + class Store { constructor(dataDir) { this.dataDir = dataDir; this.tasksFile = path.join(dataDir, 'tasks.json'); this.activityFile = path.join(dataDir, 'activity.json'); this.usersFile = path.join(dataDir, 'users.json'); + this.plansFile = path.join(dataDir, 'plans.json'); this.tasks = []; this.activity = []; this.knownUsers = new Set(); + this.plans = {}; // { [user]: { [YYYY-MM-DD]: [taskId, ...] } } — per bruker, ordnet this._writeChain = Promise.resolve(); } @@ -163,6 +167,8 @@ class Store { this.tasks = this._readJson(this.tasksFile, []); this.activity = this._readJson(this.activityFile, []); for (const u of this._readJson(this.usersFile, [])) this.knownUsers.add(u); + this.plans = this._readJson(this.plansFile, {}); + this._prunePlans(); for (const t of this.tasks) { for (const k of ['owner', 'createdBy', 'updatedBy', 'completedBy']) { if (t[k]) this.knownUsers.add(t[k]); @@ -172,10 +178,28 @@ class Store { } } + // Dropp dagsplaner eldre enn PLAN_RETENTION_DAYS så plans.json ikke vokser i det uendelige. + _prunePlans() { + const cutoff = new Date(Date.now() - PLAN_RETENTION_DAYS * 86400000).toISOString().slice(0, 10); + for (const user of Object.keys(this.plans)) { + const byDate = this.plans[user]; + if (!byDate || typeof byDate !== 'object') { delete this.plans[user]; continue; } + for (const date of Object.keys(byDate)) { + if (date < cutoff || !Array.isArray(byDate[date])) delete byDate[date]; + } + if (Object.keys(byDate).length === 0) delete this.plans[user]; + } + } + + // Leser JSON og validerer mot fallback-typen (array vs. objekt). _readJson(file, fallback) { try { const data = JSON.parse(fs.readFileSync(file, 'utf8')); - return Array.isArray(data) ? data : fallback; + if (Array.isArray(fallback)) return Array.isArray(data) ? data : fallback; + if (fallback && typeof fallback === 'object') { + return data && typeof data === 'object' && !Array.isArray(data) ? data : fallback; + } + return data ?? fallback; } catch { return fallback; } @@ -195,6 +219,7 @@ class Store { _persistTasks() { return this._persist(this.tasksFile, this.tasks); } _persistActivity() { return this._persist(this.activityFile, this.activity); } + _persistPlans() { return this._persist(this.plansFile, this.plans); } rememberUser(user) { if (user && !this.knownUsers.has(user)) { @@ -238,7 +263,7 @@ class Store { return { task, activity }; } - update(id, input, user) { + update(id, input, user, opts = {}) { const existing = this.get(id); if (!existing) throw new NotFoundError('Fant ikke oppgaven'); // Optimistisk låsing: klienten sender versjonen den redigerte fra. @@ -250,13 +275,27 @@ class Store { this.tasks[idx] = updated; this.rememberUser(user); if (updated.owner) this.rememberUser(updated.owner); + + // Valgfri kommentar knyttet til endringen (typisk ved statusbytte: hva ble + // gjort / hva blokkerer). Lagres som vanlig kommentar, men merket kind:status. + const noteText = sanitizeStr(opts.comment, 1000); + if (noteText) { + const comment = { + id: uid('c'), by: user, at: new Date().toISOString(), + text: noteText, kind: 'status', status: updated.status, + }; + updated.comments.push(comment); + if (updated.comments.length > MAX_COMMENTS) updated.comments.shift(); + } this._persistTasks(); let action = 'update'; if (existing.status !== 'done' && updated.status === 'done') action = 'done'; else if (existing.status === 'done' && updated.status !== 'done') action = 'reopen'; + const detail = diffDetail(existing, updated); + if (noteText) detail.push('💬 ' + noteText.slice(0, 80)); const activity = this.addActivity(user, action, { - taskId: id, title: updated.title, detail: diffDetail(existing, updated), + taskId: id, title: updated.title, detail, }); return { task: updated, activity }; } @@ -266,10 +305,52 @@ class Store { if (!existing) throw new NotFoundError('Fant ikke oppgaven'); this.tasks = this.tasks.filter(t => t.id !== id); this._persistTasks(); + this._scrubFromPlans(id); const activity = this.addActivity(user, 'delete', { taskId: id, title: existing.title }); return { task: existing, activity }; } + // ---------- Dagsplan (per bruker, per dato) ---------- + getPlan(user, date) { + const ids = (this.plans[user] && this.plans[user][date]) || []; + // Filtrer bort oppgaver som er slettet siden planen ble lagret. + return ids.filter(id => this.get(id)); + } + + setPlan(user, date, ids) { + const valid = []; + const seen = new Set(); + for (const raw of Array.isArray(ids) ? ids : []) { + if (typeof raw !== 'string') continue; + const id = raw.replace(/[^a-z0-9_]/gi, '').slice(0, 32); + if (!id || seen.has(id) || !this.get(id)) continue; + seen.add(id); + valid.push(id); + if (valid.length >= 200) break; + } + if (!this.plans[user]) this.plans[user] = {}; + if (valid.length) this.plans[user][date] = valid; + else delete this.plans[user][date]; + if (Object.keys(this.plans[user]).length === 0) delete this.plans[user]; + this._persistPlans(); + return valid; + } + + _scrubFromPlans(taskId) { + let changed = false; + for (const user of Object.keys(this.plans)) { + const byDate = this.plans[user]; + for (const date of Object.keys(byDate)) { + const before = byDate[date].length; + byDate[date] = byDate[date].filter(x => x !== taskId); + if (byDate[date].length !== before) changed = true; + if (byDate[date].length === 0) delete byDate[date]; + } + if (Object.keys(byDate).length === 0) delete this.plans[user]; + } + if (changed) this._persistPlans(); + } + addComment(id, text, user) { const task = this.get(id); if (!task) throw new NotFoundError('Fant ikke oppgaven'); diff --git a/netops-todo-node/public/app.js b/netops-todo-node/public/app.js index 8eba751..05e5478 100644 --- a/netops-todo-node/public/app.js +++ b/netops-todo-node/public/app.js @@ -50,11 +50,14 @@ const state = { editingMap: {}, // taskId -> [brukere med modal åpen] filters: { status: 'open', category: 'all', priority: 'all', owner: 'all', search: '' }, sort: { field: 'priority', dir: 'asc' }, - view: 'board', + view: 'board', // 'board' | 'table' | 'plan' editingId: null, // id i åpen modal (null = ny oppgave) editingBaseV: null, // versjonen modalen redigerer fra + editingStatus: null, // statusen oppgaven hadde da modalen ble åpnet + plan: { date: '', items: [] }, // dagsplan for valgt dato (ordnet liste av task-ids) wsOk: false, }; +const VIEWS = ['board', 'table', 'plan']; let charts = {}; let ws = null; let wsRetry = 0; @@ -69,7 +72,7 @@ function loadPrefs() { const p = JSON.parse(localStorage.getItem('netops.prefs') || '{}'); if (p.filters) Object.assign(state.filters, p.filters); if (p.sort) state.sort = p.sort; - if (p.view === 'table' || p.view === 'board') state.view = p.view; + if (VIEWS.includes(p.view)) state.view = p.view; } catch { /* ignorer korrupt localStorage */ } } function savePrefs() { @@ -229,11 +232,21 @@ function handleWSMessage(msg) { updateModalWarning(); break; + case 'plan': + // Dagsplanen synkes mellom samme brukers faner. Bruk bare hvis den + // gjelder datoen vi ser på nå. + if (msg.date === state.plan.date) { + state.plan.items = msg.items || []; + if (state.view === 'plan') renderPlan(); + } + break; + case 'task': { const mine = msg.by === state.user; pushActivity(msg.activity); if (msg.op === 'delete') { state.tasks = state.tasks.filter(t => t.id !== msg.id); + state.plan.items = state.plan.items.filter(id => id !== msg.id); if (!mine && state.editingId === msg.id) { closeModal(); toast(`Oppgaven du redigerte ble slettet av ${msg.by}`, { kind: 'warn', ttl: 6000 }); @@ -263,6 +276,8 @@ async function refreshState() { state.activity = data.activity || []; state.knownUsers = data.known_users || []; state.online = data.online || []; + if (data.plan) state.plan = data.plan; + if (!state.plan.date) state.plan = { date: todayISO(), items: [] }; renderAll(); } catch (e) { toast('Kunne ikke laste: ' + e.message, { kind: 'err' }); @@ -505,10 +520,15 @@ function renderCharts() { function renderView() { $('view-board').className = state.view === 'board' ? 'active-view' : ''; $('view-table').className = state.view === 'table' ? 'active-view' : ''; - $('fg-status').style.display = state.view === 'board' ? 'none' : ''; + $('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'; - if (state.view === 'board') renderBoard(); else renderTable(); + $('planner').style.display = state.view === 'plan' ? '' : 'none'; + if (state.view === 'board') renderBoard(); + else if (state.view === 'table') renderTable(); + else renderPlan(); flashIds.clear(); } @@ -613,6 +633,12 @@ function bindBoardDnD() { 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(); @@ -673,6 +699,183 @@ function renderTable() { }).join(''); } +// ---------- Dagsplan ---------- +// Oppgavene i planen, i lagret rekkefølge, med slettede filtrert bort. +function planTasks() { + return state.plan.items.map(findTask).filter(Boolean); +} + +function renderPlan() { + $('empty').style.display = 'none'; + + const dateInput = $('plan-date'); + if (document.activeElement !== dateInput) dateInput.value = state.plan.date; + + const planned = planTasks(); + const planIds = new Set(planned.map(t => t.id)); + + // Venstre: åpne oppgaver som ikke alt er i planen (respekterer kat/prio/eier/søk). + const avail = sortTasks( + applyFilters(state.tasks, { ignoreStatus: true }) + .filter(t => t.status !== 'done' && !planIds.has(t.id)) + ); + $('plan-avail-count').textContent = avail.length ? `(${avail.length})` : ''; + $('plan-available-body').innerHTML = avail.length + ? avail.map(renderAvailRow).join('') + : '
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' : ''); + + $('plan-today-body').innerHTML = planned.length + ? planned.map(renderPlanItem).join('') + : '
Tomt. Legg til oppgaver fra venstre med +, eller dra dem hit. Dra for å sortere.
'; + + bindPlanDnD(); +} + +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) { + const dl = formatDeadline(t.deadline); + const doneCls = t.status === 'done' ? ' done-item' : ''; + return ` +
+ + ${i + 1} +
+
${escapeHtml(t.title)}
+
+ ${t.priority} + ${catBadge(t)} + ${STATUS_LABELS[t.status] || t.status} + ${t.estHours ? `~${t.estHours}t` : ''} + ${t.deadline ? `${dl.text}` : ''} +
+
+
+ ${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() { + renderPlan(); // optimistisk + try { + const { date, items } = await api('PUT', '/api/plan', { date: state.plan.date, items: state.plan.items }); + if (date === state.plan.date) { state.plan.items = items; renderPlan(); } + } catch (e) { + toast('Kunne ikke lagre dagsplan: ' + e.message, { kind: 'err' }); + refreshPlan(); + } +} + +window.addToPlan = function (id) { + if (!state.plan.items.includes(id) && findTask(id)) { + state.plan.items.push(id); + savePlan(); + toast('Lagt i dagsplan'); + } +}; + +window.removeFromPlan = function (id) { + state.plan.items = state.plan.items.filter(x => x !== id); + savePlan(); +}; + +async function refreshPlan(date = state.plan.date) { + try { + const data = await api('GET', `/api/plan?date=${encodeURIComponent(date)}`); + state.plan = { date: data.date, items: data.items || [] }; + if (state.view === 'plan') renderPlan(); + } catch { /* stille — neste handling prøver igjen */ } +} + function renderActivity() { const body = $('drawer-body'); if (!state.activity.length) { @@ -716,17 +919,57 @@ function updateOwnerSelects() { + 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) { +async function quickStatus(id, status, comment = '') { const t = findTask(id); if (!t) return; try { - const { task } = await api('PUT', `/api/tasks/${id}`, { ...t, status, v: t.v }); + 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 => quickStatus(id, 'done'); +window.markDone = id => changeStatusWithComment(id, 'done'); window.reopenTask = id => quickStatus(id, 'todo'); window.claimTask = async function (id) { @@ -785,6 +1028,7 @@ window.openEdit = function (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 || ''; @@ -800,15 +1044,33 @@ window.openEdit = function (id) { $('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 = ''; @@ -870,6 +1132,9 @@ async function saveForm() { 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 { @@ -897,15 +1162,22 @@ function renderComments(t) { list.innerHTML = '
Ingen kommentarer ennå.
'; return; } - list.innerHTML = t.comments.map(c => ` -
+ list.innerHTML = t.comments.map(c => { + const isStatus = c.kind === 'status' && c.status; + const kindTag = isStatus + ? `${STATUS_LABELS[c.status] || c.status}` + : ''; + return ` +
${escapeHtml(c.by)} + ${kindTag} ${relTime(c.at)}
${escapeHtml(c.text)}
${c.by === state.user ? `` : ''} -
`).join(''); +
`; + }).join(''); } async function sendComment() { @@ -973,6 +1245,12 @@ function bindUI() { // Visning $('view-board').addEventListener('click', () => { state.view = 'board'; savePrefs(); renderView(); }); $('view-table').addEventListener('click', () => { state.view = 'table'; savePrefs(); renderView(); }); + $('view-plan').addEventListener('click', () => { state.view = 'plan'; savePrefs(); renderView(); }); + $('plan-date').addEventListener('change', e => { + const d = e.target.value || todayISO(); + refreshPlan(d); + }); + bindPlanContainer(); // Filtre const f = state.filters; @@ -1007,6 +1285,16 @@ function bindUI() { }); $('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')); @@ -1041,14 +1329,18 @@ function bindUI() { document.addEventListener('keydown', e => { const tag = e.target.tagName; const typing = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; - if (e.key === 'Escape') { closeModal(); $('drawer').classList.remove('open'); return; } + 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 = state.view === 'board' ? 'table' : 'board'; + state.view = VIEWS[(VIEWS.indexOf(state.view) + 1) % VIEWS.length]; savePrefs(); renderView(); } + if (e.key === 'p') { state.view = 'plan'; savePrefs(); renderView(); } if (e.key === 'a') $('drawer').classList.toggle('open'); }); diff --git a/netops-todo-node/public/index.html b/netops-todo-node/public/index.html index 57d4132..fd24e8f 100644 --- a/netops-todo-node/public/index.html +++ b/netops-todo-node/public/index.html @@ -93,6 +93,7 @@
+
@@ -133,6 +134,26 @@ + + + +
diff --git a/netops-todo-node/public/style.css b/netops-todo-node/public/style.css index 69bb776..f0012c5 100644 --- a/netops-todo-node/public/style.css +++ b/netops-todo-node/public/style.css @@ -315,6 +315,66 @@ tbody tr.status-done .task-title { text-decoration: line-through; } .comment-input-row input { flex: 1; } .no-comments { color: var(--text-faint); font-size: 12px; font-style: italic; } +/* ---------- Status-kommentar i tråd ---------- */ +.comment.comment-status { border-left: 3px solid var(--accent); } +.comment-kind { font-family: var(--mono); font-size: 10px; font-weight: 600; padding: 1px 6px; + border-radius: 4px; text-transform: uppercase; letter-spacing: 0.4px; } +.comment-kind.status-done { background: rgba(74,222,128,0.12); color: var(--ok); } +.comment-kind.status-blocked { background: rgba(255,83,112,0.15); color: var(--err); } +.comment-kind.status-progress { background: rgba(255,159,67,0.15); color: var(--warn); } +.comment-kind.status-todo { background: rgba(127,184,255,0.12); color: #7fb8ff; } +.status-note-field input { border-color: var(--accent); } + +/* ---------- Dagsplan ---------- */ +.planner { display: grid; grid-template-columns: 1fr 1fr; gap: var(--gap); align-items: start; } +.plan-col { background: var(--bg-soft); border: 1px solid var(--border); border-radius: var(--radius); + display: flex; flex-direction: column; min-height: 300px; } +.plan-col.plan-today { border-color: var(--border-strong); } +.plan-col-head { display: flex; justify-content: space-between; align-items: center; + padding: 14px 16px 10px; gap: 10px; flex-wrap: wrap; } +.plan-col-head h3 { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px; + display: flex; align-items: center; gap: 8px; } +.plan-col-head .col-meta { font-family: var(--mono); font-size: 12px; color: var(--text-dim); } +.plan-col-head .col-meta.over-capacity { color: var(--warn); font-weight: 600; } +.plan-col-head .col-meta b { color: var(--text); } +.plan-col-hint { font-size: 11px; color: var(--text-faint); font-family: var(--mono); } +.plan-date-row { display: flex; align-items: center; gap: 10px; } +.plan-date-row input { padding: 5px 8px; font-size: 12px; } +.plan-col-body { display: flex; flex-direction: column; gap: 8px; padding: 4px 12px 14px; flex: 1; } +.plan-empty { color: var(--text-faint); font-size: 13px; font-style: italic; padding: 20px 4px; text-align: center; } + +/* Tilgjengelig-rad (venstre kolonne) */ +.avail-row { display: flex; align-items: center; gap: 10px; background: var(--card); + border: 1px solid var(--border); border-radius: 6px; padding: 8px 10px; cursor: grab; } +.avail-row:hover { border-color: var(--border-strong); } +.avail-row.dragging { opacity: 0.4; } +.avail-main { flex: 1; min-width: 0; } +.avail-title { font-size: 13px; font-weight: 500; cursor: pointer; } +.avail-title:hover { color: var(--accent); } +.avail-meta { display: flex; gap: 8px; align-items: center; margin-top: 3px; + font-size: 11px; font-family: var(--mono); color: var(--text-faint); flex-wrap: wrap; } +.avail-add { flex-shrink: 0; background: var(--accent-soft); color: var(--accent); + border: 1px solid rgba(79,195,247,0.3); border-radius: 5px; width: 30px; height: 30px; + font-size: 18px; line-height: 1; padding: 0; justify-content: center; } +.avail-add:hover { background: var(--accent); color: #06151f; } + +/* Plan-item (høyre kolonne) */ +.plan-item { display: flex; align-items: center; gap: 10px; background: var(--card); + border: 1px solid var(--border); border-radius: 6px; padding: 8px 10px; cursor: grab; + transition: border-color 0.15s, box-shadow 0.15s; } +.plan-item:hover { border-color: var(--border-strong); } +.plan-item.dragging { opacity: 0.4; box-shadow: 0 6px 16px rgba(0,0,0,0.35); } +.plan-item.done-item { opacity: 0.6; } +.plan-item.done-item .plan-item-title { text-decoration: line-through; color: var(--text-faint); } +.plan-handle { color: var(--text-faint); font-size: 14px; cursor: grab; user-select: none; flex-shrink: 0; } +.plan-num { font-family: var(--mono); font-size: 12px; color: var(--text-faint); width: 18px; text-align: right; flex-shrink: 0; } +.plan-item-main { flex: 1; min-width: 0; } +.plan-item-title { font-size: 13px; font-weight: 500; cursor: pointer; } +.plan-item-title:hover { color: var(--accent); } +.plan-item-meta { display: flex; gap: 8px; align-items: center; margin-top: 3px; + font-size: 11px; font-family: var(--mono); color: var(--text-faint); flex-wrap: wrap; } +.plan-item-actions { display: flex; gap: 2px; flex-shrink: 0; } + /* ---------- Toast ---------- */ .toast-stack { position: fixed; bottom: 20px; right: 20px; z-index: 200; display: flex; flex-direction: column; gap: 8px; align-items: flex-end; } @@ -345,6 +405,7 @@ footer .kbd { display: inline-block; padding: 0 5px; border: 1px solid var(--bor @media (max-width: 1250px) { .board { grid-template-columns: repeat(2, 1fr); } } @media (max-width: 1100px) { .chart-row { grid-template-columns: 1fr 1fr; } } +@media (max-width: 820px) { .planner { grid-template-columns: 1fr; } } @media (max-width: 720px) { .chart-row { grid-template-columns: 1fr; } .board { grid-template-columns: 1fr; } diff --git a/netops-todo-node/server.js b/netops-todo-node/server.js index 7739a52..c76d421 100644 --- a/netops-todo-node/server.js +++ b/netops-todo-node/server.js @@ -93,6 +93,15 @@ function createServer({ dataDir = path.join(__dirname, 'data') } = {}) { } } + // Send kun til samme brukers tilkoblinger (dagsplan er privat, men skal synke + // mellom f.eks. desktop og laptop hos samme bruker). + function sendToUser(user, obj) { + const msg = JSON.stringify(obj); + for (const [ws, meta] of clients) { + if (meta.user === user && ws.readyState === ws.OPEN) ws.send(msg); + } + } + function onlineUsers() { const set = new Set(); for (const meta of clients.values()) set.add(meta.user); @@ -154,10 +163,14 @@ function createServer({ dataDir = path.join(__dirname, 'data') } = {}) { heartbeat.unref(); // ---------- REST API ---------- + const todayStr = () => new Date().toISOString().slice(0, 10); + const validDate = d => typeof d === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(d); + app.get('/healthz', (req, res) => res.json({ ok: true, tasks: store.tasks.length })); app.get('/api/state', (req, res) => { store.rememberUser(req.user); + const today = todayStr(); res.json({ user: req.user, canSetName: COOKIE_AUTH_ENABLED, @@ -165,9 +178,22 @@ function createServer({ dataDir = path.join(__dirname, 'data') } = {}) { activity: store.activity, known_users: [...store.knownUsers].sort(), online: onlineUsers(), + plan: { date: today, items: store.getPlan(req.user, today) }, }); }); + app.get('/api/plan', (req, res) => { + const date = validDate(req.query.date) ? req.query.date : todayStr(); + res.json({ date, items: store.getPlan(req.user, date) }); + }); + + app.put('/api/plan', (req, res) => { + const date = validDate(req.body && req.body.date) ? req.body.date : todayStr(); + const items = store.setPlan(req.user, date, req.body && req.body.items); + sendToUser(req.user, { type: 'plan', date, items }); + res.json({ date, items }); + }); + app.post('/api/login', (req, res) => { if (!COOKIE_AUTH_ENABLED) return res.status(403).json({ error: 'Navnevalg er deaktivert — auth styres av proxy' }); const raw = req.body && req.body.name; @@ -188,7 +214,10 @@ function createServer({ dataDir = path.join(__dirname, 'data') } = {}) { }); app.put('/api/tasks/:id', (req, res) => { - const { task, activity } = store.update(req.params.id, req.body, req.user); + // Valgfri `comment` følger med ved statusbytte; normalizeTask ignorerer + // ukjente felter, så den blir ikke en del av selve oppgaven. + const comment = req.body && typeof req.body.comment === 'string' ? req.body.comment : ''; + const { task, activity } = store.update(req.params.id, req.body, req.user, { comment }); broadcast({ type: 'task', op: 'update', task, by: req.user, activity }); res.json({ task }); }); diff --git a/netops-todo-node/test/api.test.js b/netops-todo-node/test/api.test.js index f890e01..4f949ef 100644 --- a/netops-todo-node/test/api.test.js +++ b/netops-todo-node/test/api.test.js @@ -189,6 +189,105 @@ test('replace erstatter alt og persisterer til disk', async () => { assert.equal(onDisk[0].title, 'Eneste oppgave'); }); +test('update med comment legger til status-kommentar og aktivitets-detalj', async () => { + let res = await req('POST', '/api/tasks', { title: 'Kommentar-ved-status' }); + const { task } = await res.json(); + + res = await req('PUT', `/api/tasks/${task.id}`, { v: task.v, status: 'done', comment: 'Verifisert i lab, rullet ut' }); + assert.equal(res.status, 200); + const { task: done } = await res.json(); + assert.equal(done.status, 'done'); + assert.equal(done.comments.length, 1); + assert.equal(done.comments[0].kind, 'status'); + assert.equal(done.comments[0].status, 'done'); + assert.equal(done.comments[0].text, 'Verifisert i lab, rullet ut'); + + // Aktivitetsloggen skal nevne kommentaren + res = await req('GET', '/api/state'); + const { activity } = await res.json(); + const entry = activity.find(a => a.taskId === task.id && a.action === 'done'); + assert.ok(entry.detail.some(d => d.includes('Verifisert i lab'))); +}); + +test('update uten comment legger ikke til kommentar', async () => { + let res = await req('POST', '/api/tasks', { title: 'Ingen-kommentar' }); + const { task } = await res.json(); + res = await req('PUT', `/api/tasks/${task.id}`, { v: task.v, status: 'progress' }); + const { task: upd } = await res.json(); + assert.equal(upd.comments.length, 0); +}); + +test('dagsplan: tom som standard, sett ordnet liste, hent tilbake', async () => { + // Lag to oppgaver å planlegge + const a = (await (await req('POST', '/api/tasks', { title: 'Plan A' })).json()).task; + const b = (await (await req('POST', '/api/tasks', { title: 'Plan B' })).json()).task; + + let res = await req('GET', '/api/plan?date=2026-06-15'); + assert.equal(res.status, 200); + assert.deepEqual((await res.json()).items, []); + + res = await req('PUT', '/api/plan', { date: '2026-06-15', items: [b.id, a.id] }); + assert.equal(res.status, 200); + assert.deepEqual((await res.json()).items, [b.id, a.id]); // rekkefølge bevart + + res = await req('GET', '/api/plan?date=2026-06-15'); + assert.deepEqual((await res.json()).items, [b.id, a.id]); +}); + +test('dagsplan: er per bruker', async () => { + const a = (await (await req('POST', '/api/tasks', { title: 'Privat plan' })).json()).task; + await req('PUT', '/api/plan', { date: '2026-06-16', items: [a.id] }, { 'X-Remote-User': 'jon' }); + + // Annen bruker ser sin egen (tomme) plan + const res = await req('GET', '/api/plan?date=2026-06-16', null, { 'X-Remote-User': 'kari' }); + assert.deepEqual((await res.json()).items, []); +}); + +test('dagsplan: ugyldige/dupliserte/ukjente ider filtreres bort', async () => { + const a = (await (await req('POST', '/api/tasks', { title: 'Gyldig plan-task' })).json()).task; + const res = await req('PUT', '/api/plan', { + date: '2026-06-17', items: [a.id, a.id, 't_finnesikke', 12345, null], + }); + assert.deepEqual((await res.json()).items, [a.id]); // dedupet + kun eksisterende +}); + +test('dagsplan: sletting av oppgave fjerner den fra planen', async () => { + const a = (await (await req('POST', '/api/tasks', { title: 'Skal slettes fra plan' })).json()).task; + await req('PUT', '/api/plan', { date: '2026-06-18', items: [a.id] }); + await req('DELETE', `/api/tasks/${a.id}`); + const res = await req('GET', '/api/plan?date=2026-06-18'); + assert.deepEqual((await res.json()).items, []); +}); + +test('dagsplan: ugyldig dato faller tilbake til i dag', async () => { + const res = await req('GET', '/api/plan?date=tull'); + const data = await res.json(); + assert.match(data.date, /^\d{4}-\d{2}-\d{2}$/); +}); + +test('websocket: dagsplan-endring pushes kun til samme bruker', async () => { + const wsUrl = baseUrl.replace('http', 'ws') + '/ws'; + const mk = user => new Promise((resolve, reject) => { + const c = new WebSocket(wsUrl, { headers: { 'X-Remote-User': user } }); + c.msgs = []; + c.on('message', raw => c.msgs.push(JSON.parse(raw))); + c.on('open', () => resolve(c)); + c.on('error', reject); + }); + const jon = await mk('testuser'); // samme bruker som req() bruker + const kari = await mk('kari'); + await new Promise(r => setTimeout(r, 150)); + + const a = (await (await req('POST', '/api/tasks', { title: 'WS-plan' })).json()).task; + await req('PUT', '/api/plan', { date: '2026-06-19', items: [a.id] }); + await new Promise(r => setTimeout(r, 150)); + + assert.ok(jon.msgs.some(m => m.type === 'plan' && m.items.includes(a.id)), 'jon fikk plan-push'); + assert.ok(!kari.msgs.some(m => m.type === 'plan'), 'kari skal ikke få plan-push'); + + jon.close(); kari.close(); +}); + test('cookie-auth: login setter cookie, ugyldig navn avvises', async () => { let res = await fetch(baseUrl + '/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' },