diff --git a/netops-todo-node/CLAUDE.md b/netops-todo-node/CLAUDE.md index bc9fd33..9e02534 100644 --- a/netops-todo-node/CLAUDE.md +++ b/netops-todo-node/CLAUDE.md @@ -17,7 +17,13 @@ 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. + ved å plukke fra backlog. Drag-sortering, kapasitet i timer, navn, flytt hele + planen til en annen dato, og delbar (default delt; toggle for privat). Andres + delte planer kan ses skrivebeskyttet. Plan-membership vises som badge på + oppgavekort/-rader (dag + person). +- **Egendefinerte kategorier** — i tillegg til de 12 innebygde; lagres i + categories.json, deles via WS, valideres dynamisk. +- **Feltmodus** — `body.field-mode`-toggle for større berøringsmål på mobil. - **Kommentar ved statusbytte** — valgfri `comment` på `PUT /api/tasks/:id` festes som kommentar med `kind:'status'` og legges i aktivitets-detaljen. - **Kommentarer** per oppgave @@ -41,7 +47,7 @@ netops-todo-node/ ├── 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, plans.json. +│ users.json, plans.json, categories.json. └── README.md Installasjon, proxy-oppsett, API-referanse. ``` @@ -64,7 +70,8 @@ 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`) | +| `plan` | `user`, `date`, `name`, `items[]`, `shared` | Etter plan-endring. Delte planer → alle; private → kun eieren (andre får tom `items` for å fjerne badge) | +| `categories` | `categories[]`, `by` | Etter `POST /api/categories` — til alle | Klient → server: kun `{ type: 'editing', id, active }`. @@ -92,15 +99,28 @@ 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"] } } +{ "jon": { "2026-06-15": { "name": "Feltdag Bergen", "items": ["t_abc", "t_def"], "shared": true } } } ``` -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. +Per bruker, per dato. `shared` default `true` (planer er synlige for alle som +hovedregel). `_planEntry()` normaliserer både gammelt array-format og nytt +objektformat (migrering skjer i `_prunePlans()` ved oppstart). `getPlan()` +filtrerer bort slettede oppgaver; `getPlanFor(viewer, owner, date)` håndhever +tilgang (egen, eller delt → ellers null/403); `setPlan/setPlanName/setPlanShared` +muterer; `movePlan()` slår sammen til måldato uten datatap; `_scrubFromPlans()` +rydder ved sletting; `_prunePlans()` dropper datoer eldre enn 30 dager. +`planIndex(viewer)` returnerer `{taskId: [{user,date,name}]}` filtrert per viewer +(delte + egne private) — driver plan-badgene på kort/rader. + +### Kategorier (`categories.json`) + +`DEFAULT_CATEGORIES` (12, i koden) + egendefinerte fra `categories.json` +(`[{id,name,color,desc,custom:true}]`). `store.categories()`/`categoryIds()` gir +det effektive settet; `normalizeTask` validerer mot `opts.validCategories = +this.categoryIds()`. `addCategory()` genererer en unik id fra navnet og tildeler +farge fra en palett. Frontend får lista fra `/api/state` og via `categories`-WS; +`state.categories` + `catColor/catName/catById`-hjelperne erstatter den gamle +hardkodede `CATEGORIES`/`CAT_COLORS`. ## Designvalg verdt å vite @@ -149,15 +169,17 @@ strenglengde-klamping, brukernavn-regex `[A-Za-z0-9._@-]{1,64}`), pluss: ## Testing-status -- `npm test`: 20 integrasjonstester (node:test), booter ekte server på +- `npm test`: 26 integrasjonstester (node:test), booter ekte server på tilfeldig port med temp-datakatalog. Dekker normalisering, versjonskonflikt, 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 + validering, scrub ved sletting, dato-fallback, navn, flytt/merge, deling + + tilgang, planIndex per viewer), egendefinerte kategorier, aktivitetslogg, + WS-broadcast (delt vs. privat plan), persistens, 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. +- Manuell røyk-test over HTTP: kategori-opprettelse (auto-id + farge), navngitt + delt plan, annen brukers tilgang (delt 200 / privat 403), flytt med bevart + navn+delingsstatus, og planIndex verifisert. - Ikke testet i nettleser med flere samtidige brukere — første ekte test bør - 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. + være to nettleservinduer mot `npm start`: del en dagsplan og se den hos den + andre brukeren + badge på oppgaven, flytt planen til en annen dag, legg til en + egendefinert kategori, og slå på feltmodus på en smal skjerm. diff --git a/netops-todo-node/README.md b/netops-todo-node/README.md index 30ed0f3..888ef1f 100644 --- a/netops-todo-node/README.md +++ b/netops-todo-node/README.md @@ -135,8 +135,16 @@ WantedBy=multi-user.target - **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. + (dra for å sortere, kapasitet i timer, datovelger). Kan navngis, flyttes til + en annen dato i ett klikk, og er som hovedregel synlig for alle (med en + «Synlig for alle»-toggle for å gjøre den privat). Andres delte planer kan ses + skrivebeskyttet via «Plan for»-velgeren. +- **Plan-badge på oppgaver** — hvert kort/rad viser om oppgaven er planlagt, + hvilken dag og av hvem. +- **Egendefinerte kategorier** — legg til nye kategorier fra kategori-nedtrekket + («+ Ny kategori …»); de deles med hele teamet umiddelbart. +- **Feltmodus** — toggle (📱 / `f`) for større knapper og mer tommelvennlig + layout på mobil; slås på automatisk på små skjermer. - **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 @@ -149,7 +157,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 · `p` dagsplan · `a` aktivitet · `esc` lukk +- Tastatursnarveier: `n` ny · `/` søk · `v` bytt visning · `p` dagsplan · `f` feltmodus · `a` aktivitet · `esc` lukk ## API @@ -158,14 +166,17 @@ 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, online, og dagens plan | +| GET | `/api/state` | Bruker, oppgaver, aktivitet, kjente brukere, online, kategorier, dagens plan, planIndex | | POST | `/api/tasks` | Opprett oppgave (`title` påkrevd) | | 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…] }` | +| GET | `/api/plan?date=YYYY-MM-DD&user=navn` | Hent dagsplan (egen, eller en annens hvis delt — ellers 403) | +| PUT | `/api/plan` | Lagre egen dagsplan `{ date, items: [id…], name?, shared? }` | +| PUT | `/api/plan/share` | Slå deling av/på `{ date, shared }` | +| PUT | `/api/plan/move` | Flytt hele planen `{ from, to }` (slår sammen på måldato) | +| POST | `/api/categories` | Legg til egendefinert kategori `{ name, color? }` | | 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å) | @@ -183,7 +194,8 @@ curl -s -X POST localhost:3000/api/tasks \ ## Lagring JSON-filer i `DATA_DIR` (atomisk skriv via tmp-fil + rename, serialisert i -prosessen): `tasks.json`, `activity.json`, `users.json` og `plans.json` -(dagsplaner per bruker per dato; planer eldre enn 30 dager ryddes ved oppstart). +prosessen): `tasks.json`, `activity.json`, `users.json`, `plans.json` +(dagsplaner `{name, items, shared}` per bruker per dato; eldre enn 30 dager +ryddes ved oppstart) og `categories.json` (egendefinerte kategorier). É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 2beb2f9..e4be007 100644 --- a/netops-todo-node/lib/store.js +++ b/netops-todo-node/lib/store.js @@ -14,7 +14,27 @@ const crypto = require('crypto'); const VALID_STATUS = ['todo', 'progress', 'blocked', 'done']; const VALID_PRIORITIES = ['P1', 'P2', 'P3', 'P4']; -const VALID_CATEGORIES = ['L1', 'L2', 'L3', 'L4', 'BGP', 'PEER', 'SEC', 'NMS', 'AUTO', 'DOC', 'INC', 'PROJ']; + +// Innebygde kategorier (id, navn, farge, beskrivelse). Egendefinerte kategorier +// lagres i data/categories.json og legges på toppen av disse ved kjøring. +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' }, +]; +// Farger som tildeles egendefinerte kategorier automatisk når ingen er valgt. +const CATEGORY_PALETTE = ['#5eead4', '#f0abfc', '#fca5a5', '#fcd34d', '#93c5fd', '#86efac', '#fdba74', '#c4b5fd']; + +const VALID_CATEGORIES = DEFAULT_CATEGORIES.map(c => c.id); const STATUS_LABELS = { todo: 'Å gjøre', progress: 'Pågår', blocked: 'Blokkert', done: 'Ferdig' }; @@ -78,8 +98,9 @@ function normalizeTask(input, user, existing = null, opts = {}) { t.title = sanitizeStr(inp.title ?? ex.title ?? '', 200); t.desc = sanitizeStr(inp.desc ?? ex.desc ?? '', 4000); + const validCats = (opts.validCategories && opts.validCategories.length) ? opts.validCategories : VALID_CATEGORIES; const cat = inp.category ?? ex.category ?? 'L3'; - t.category = VALID_CATEGORIES.includes(cat) ? cat : 'L3'; + t.category = validCats.includes(cat) ? cat : 'L3'; const prio = inp.priority ?? ex.priority ?? 'P3'; t.priority = VALID_PRIORITIES.includes(prio) ? prio : 'P3'; @@ -155,10 +176,13 @@ class Store { this.activityFile = path.join(dataDir, 'activity.json'); this.usersFile = path.join(dataDir, 'users.json'); this.plansFile = path.join(dataDir, 'plans.json'); + this.categoriesFile = path.join(dataDir, 'categories.json'); this.tasks = []; this.activity = []; this.knownUsers = new Set(); - this.plans = {}; // { [user]: { [YYYY-MM-DD]: [taskId, ...] } } — per bruker, ordnet + // { [user]: { [YYYY-MM-DD]: { name, items: [taskId,...] } } } — per bruker, ordnet + this.plans = {}; + this.customCategories = []; // egendefinerte, på toppen av DEFAULT_CATEGORIES this._writeChain = Promise.resolve(); } @@ -169,6 +193,7 @@ class Store { for (const u of this._readJson(this.usersFile, [])) this.knownUsers.add(u); this.plans = this._readJson(this.plansFile, {}); this._prunePlans(); + this._loadCategories(); for (const t of this.tasks) { for (const k of ['owner', 'createdBy', 'updatedBy', 'completedBy']) { if (t[k]) this.knownUsers.add(t[k]); @@ -178,19 +203,40 @@ class Store { } } - // Dropp dagsplaner eldre enn PLAN_RETENTION_DAYS så plans.json ikke vokser i det uendelige. + // Dropp dagsplaner eldre enn PLAN_RETENTION_DAYS, og migrer gammelt array-format + // ({date: [ids]}) til nytt objektformat ({date: {name, items}}). _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]; + const val = byDate[date]; + const valid = Array.isArray(val) || (val && typeof val === 'object' && Array.isArray(val.items)); + if (date < cutoff || !valid) { delete byDate[date]; continue; } + if (Array.isArray(val)) byDate[date] = { name: '', items: val }; // migrer } if (Object.keys(byDate).length === 0) delete this.plans[user]; } } + _loadCategories() { + const raw = this._readJson(this.categoriesFile, []); + const builtinIds = new Set(DEFAULT_CATEGORIES.map(c => c.id)); + const seen = new Set(builtinIds); + this.customCategories = []; + for (const c of Array.isArray(raw) ? raw : []) { + if (!c || typeof c !== 'object') continue; + const id = typeof c.id === 'string' ? c.id.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 12) : ''; + const name = sanitizeStr(c.name, 40); + if (!id || !name || seen.has(id)) continue; + const color = typeof c.color === 'string' && /^#[0-9a-fA-F]{6}$/.test(c.color) + ? c.color : CATEGORY_PALETTE[this.customCategories.length % CATEGORY_PALETTE.length]; + seen.add(id); + this.customCategories.push({ id, name, color, desc: sanitizeStr(c.desc, 120), custom: true }); + } + } + // Leser JSON og validerer mot fallback-typen (array vs. objekt). _readJson(file, fallback) { try { @@ -217,9 +263,31 @@ class Store { return this._writeChain; } - _persistTasks() { return this._persist(this.tasksFile, this.tasks); } - _persistActivity() { return this._persist(this.activityFile, this.activity); } - _persistPlans() { return this._persist(this.plansFile, this.plans); } + _persistTasks() { return this._persist(this.tasksFile, this.tasks); } + _persistActivity() { return this._persist(this.activityFile, this.activity); } + _persistPlans() { return this._persist(this.plansFile, this.plans); } + _persistCategories() { return this._persist(this.categoriesFile, this.customCategories); } + + // ---------- Kategorier (innebygde + egendefinerte) ---------- + categories() { return [...DEFAULT_CATEGORIES, ...this.customCategories]; } + categoryIds() { return this.categories().map(c => c.id); } + + addCategory({ name, color } = {}, user) { + const cleanName = sanitizeStr(name, 40); + if (!cleanName) throw new Error('Kategorinavn er påkrevd'); + // Generer en id fra navnet (store bokstaver/tall), gjør den unik. + let base = cleanName.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 10) || 'CAT'; + let id = base, n = 2; + const taken = new Set(this.categoryIds()); + while (taken.has(id)) id = base.slice(0, 9) + (n++); + const col = typeof color === 'string' && /^#[0-9a-fA-F]{6}$/.test(color) + ? color : CATEGORY_PALETTE[this.customCategories.length % CATEGORY_PALETTE.length]; + const cat = { id, name: cleanName, color: col, desc: '', custom: true }; + this.customCategories.push(cat); + this._persistCategories(); + if (user) this.addActivity(user, 'category', { title: cleanName }); + return cat; + } rememberUser(user) { if (user && !this.knownUsers.has(user)) { @@ -252,7 +320,7 @@ class Store { create(input, user) { // importComments: lar "angre sletting" og import gjenopprette kommentarer - const task = normalizeTask(input, user, null, { importComments: true }); + const task = normalizeTask(input, user, null, { importComments: true, validCategories: this.categoryIds() }); if (!task.title) throw new Error('title er påkrevd'); if (this.get(task.id)) task.id = uid('t'); // unngå kollisjon ved gjenbrukt id (angre-slett) this.tasks.push(task); @@ -270,7 +338,7 @@ class Store { if (input && input.v != null && Number(input.v) !== existing.v) { throw new ConflictError('Oppgaven er endret av noen andre', existing); } - const updated = normalizeTask(input, user, existing); + const updated = normalizeTask(input, user, existing, { validCategories: this.categoryIds() }); const idx = this.tasks.indexOf(existing); this.tasks[idx] = updated; this.rememberUser(user); @@ -311,13 +379,36 @@ class Store { } // ---------- 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)); + // Normaliser lagret entry til { name, items, shared } (tåler gammelt array-format). + // shared default true: planer er som hovedregel synlige for alle. + _planEntry(user, date) { + const raw = this.plans[user] && this.plans[user][date]; + if (Array.isArray(raw)) return { name: '', items: raw, shared: true }; + if (raw && typeof raw === 'object') { + return { + name: typeof raw.name === 'string' ? raw.name : '', + items: Array.isArray(raw.items) ? raw.items : [], + shared: raw.shared !== false, + }; + } + return { name: '', items: [], shared: true }; } - setPlan(user, date, ids) { + getPlan(user, date) { + const e = this._planEntry(user, date); + // Filtrer bort oppgaver som er slettet siden planen ble lagret. + return { name: e.name, items: e.items.filter(id => this.get(id)), shared: e.shared }; + } + + // Hent en annens plan med tilgangssjekk: kun egen plan eller en som er delt. + // Returnerer null hvis planen er privat og viewer ikke er eier. + getPlanFor(viewer, owner, date) { + if (owner !== viewer && !this._planEntry(owner, date).shared) return null; + return this.getPlan(owner, date); + } + + // name/shared === undefined beholder eksisterende verdi. + setPlan(user, date, ids, name, shared) { const valid = []; const seen = new Set(); for (const raw of Array.isArray(ids) ? ids : []) { @@ -328,12 +419,56 @@ class Store { valid.push(id); if (valid.length >= 200) break; } + const cur = this._planEntry(user, date); + const cleanName = name === undefined ? cur.name : sanitizeStr(name, 80); + const isShared = shared === undefined ? cur.shared : shared !== false; if (!this.plans[user]) this.plans[user] = {}; - if (valid.length) this.plans[user][date] = valid; + if (valid.length || cleanName) this.plans[user][date] = { name: cleanName, items: valid, shared: isShared }; else delete this.plans[user][date]; if (Object.keys(this.plans[user]).length === 0) delete this.plans[user]; this._persistPlans(); - return valid; + return { name: cleanName, items: valid, shared: isShared }; + } + + setPlanName(user, date, name) { + const e = this._planEntry(user, date); + return this.setPlan(user, date, e.items, name, e.shared); + } + + setPlanShared(user, date, shared) { + const e = this._planEntry(user, date); + return this.setPlan(user, date, e.items, e.name, shared); + } + + // Flytt hele planen til en annen dato. Slår sammen med en evt. eksisterende + // plan på måldatoen (uten å miste data), og tømmer kildedatoen. + movePlan(user, fromDate, toDate) { + if (fromDate === toDate) return this.getPlan(user, toDate); + const src = this._planEntry(user, fromDate); + const dst = this._planEntry(user, toDate); + const merged = [...dst.items]; + for (const id of src.items) if (!merged.includes(id)) merged.push(id); + const name = dst.name || src.name; + const shared = this.plans[user] && this.plans[user][toDate] ? dst.shared : src.shared; + if (this.plans[user]) delete this.plans[user][fromDate]; + return this.setPlan(user, toDate, merged, name, shared); // persisterer + } + + // Hvem har planlagt hvilke oppgaver på hvilke datoer — for badges på kort/rad. + // Filtrert per viewer: delte planer + viewerens egne private. + planIndex(viewer) { + const idx = {}; + for (const user of Object.keys(this.plans)) { + for (const date of Object.keys(this.plans[user])) { + const e = this._planEntry(user, date); + if (!e.shared && user !== viewer) continue; + for (const id of e.items) { + if (!this.get(id)) continue; + (idx[id] ||= []).push({ user, date, name: e.name }); + } + } + } + return idx; } _scrubFromPlans(taskId) { @@ -341,10 +476,12 @@ class Store { 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]; + const e = this._planEntry(user, date); + const filtered = e.items.filter(x => x !== taskId); + if (filtered.length === e.items.length) continue; + changed = true; + if (filtered.length === 0 && !e.name) delete byDate[date]; + else byDate[date] = { name: e.name, items: filtered }; } if (Object.keys(byDate).length === 0) delete this.plans[user]; } @@ -383,7 +520,7 @@ class Store { const out = []; for (const raw of arr) { if (!raw || typeof raw !== 'object' || !raw.title) continue; - out.push(normalizeTask(raw, user, null, { importComments: true })); + out.push(normalizeTask(raw, user, null, { importComments: true, validCategories: this.categoryIds() })); } this.tasks = out; this._persistTasks(); diff --git a/netops-todo-node/public/app.js b/netops-todo-node/public/app.js index 05e5478..1884a9d 100644 --- a/netops-todo-node/public/app.js +++ b/netops-todo-node/public/app.js @@ -6,38 +6,43 @@ */ // ---------- Konstanter ---------- -const CATEGORIES = [ - { id: 'L1', name: 'L1 Fysisk', desc: 'Kabling, fiber, patch, SFP, rack' }, - { id: 'L2', name: 'L2 Switching', desc: 'VLAN, STP, MAC, trunking' }, - { id: 'L3', name: 'L3 Ruting', desc: 'OSPF, ISIS, statisk, VRF' }, - { id: 'L4', name: 'L4 Lastbalansering', desc: 'HAProxy, NGINX, F5, BIG-IP' }, - { id: 'BGP', name: 'BGP', desc: 'iBGP / eBGP, policies, RR' }, - { id: 'PEER', name: 'Peering / IX', desc: 'IXP, private peering, PeeringDB' }, - { id: 'SEC', name: 'Firewall / Security',desc: 'ACL, firewall, IPS, audit' }, - { id: 'NMS', name: 'Overvåking / NMS', desc: 'LibreNMS, Grafana, alerts' }, - { id: 'AUTO', name: 'Automatisering', desc: 'Ansible, scripts, CI/CD' }, - { id: 'DOC', name: 'Dokumentasjon', desc: 'Diagrammer, IPAM, runbooks' }, - { id: 'INC', name: 'Incident / On-call', desc: 'Feilsøking, postmortem' }, - { id: 'PROJ', name: 'Prosjekt / Plan', desc: 'Design, anskaffelse, oppgraderinger' }, +// 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 CAT_COLORS = { - L1: '#ff9f43', L2: '#4fc3f7', L3: '#7fb8ff', L4: '#bb86fc', - BGP: '#4ade80', PEER: '#26de81', SEC: '#ff5370', NMS: '#ffd93d', - AUTO: '#00d2d3', DOC: '#a0a0a0', INC: '#ff6b6b', PROJ: '#8172B3' -}; +const 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' }, + 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 = { @@ -47,6 +52,8 @@ const state = { 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' }, @@ -54,7 +61,9 @@ const state = { 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) + // 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']; @@ -73,11 +82,16 @@ function loadPrefs() { 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; } catch { /* ignorer korrupt localStorage */ } + // Slå på feltmodus automatisk på små skjermer hvis brukeren ikke har valgt selv. + if (localStorage.getItem('netops.fieldModeSet') !== '1' && window.innerWidth < 640) { + state.fieldMode = true; + } } function savePrefs() { localStorage.setItem('netops.prefs', JSON.stringify({ - filters: state.filters, sort: state.sort, view: state.view, + filters: state.filters, sort: state.sort, view: state.view, fieldMode: state.fieldMode, })); } @@ -232,12 +246,24 @@ 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) { + 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 || []; - if (state.view === 'plan') renderPlan(); + state.plan.shared = msg.shared !== false; + } + renderAll(); + break; + } + + case 'categories': + if (Array.isArray(msg.categories)) { + state.categories = msg.categories; + populateCategorySelects(); + renderAll(); } break; @@ -247,6 +273,7 @@ function handleWSMessage(msg) { 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 }); @@ -276,13 +303,23 @@ 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: [] }; + 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) { @@ -293,7 +330,7 @@ function parseQuickAdd(text) { let m; if (/^p[1-4]$/i.test(tok)) { out.priority = tok.toUpperCase(); continue; } if ((m = tok.match(/^#(.+)$/))) { - const cat = CATEGORIES.find(c => c.id.toLowerCase() === m[1].toLowerCase()); + 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; @@ -326,7 +363,7 @@ function renderQuickAddPreview() { const chips = []; const chip = (label, val) => `${label} ${escapeHtml(val)}`; chips.push(chip('tittel', p.title || '(mangler)')); - if (p.category) chips.push(chip('kategori', (CATEGORIES.find(c => c.id === p.category) || {}).name || p.category)); + if (p.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)); @@ -468,11 +505,11 @@ function renderCharts() { charts.cat = new Chart($('catChart'), { type: 'bar', data: { - labels: catEntries.map(([k]) => (CATEGORIES.find(c => c.id === k) || { name: k }).name), + labels: catEntries.map(([k]) => catName(k)), datasets: [{ data: catEntries.map(([, v]) => v), - backgroundColor: catEntries.map(([k]) => (CAT_COLORS[k] || '#666') + 'CC'), - borderColor: catEntries.map(([k]) => CAT_COLORS[k] || '#666'), + backgroundColor: catEntries.map(([k]) => catColor(k) + 'CC'), + borderColor: catEntries.map(([k]) => catColor(k)), borderWidth: 1, borderRadius: 4, }] }, @@ -538,9 +575,27 @@ function ownerBadge(t) { return `${t.owner ? escapeHtml(t.owner) : 'utildelt'}`; } function catBadge(t) { - const cat = CATEGORIES.find(c => c.id === t.category); - const col = CAT_COLORS[t.category] || '#4fc3f7'; - return `${escapeHtml(t.category)}`; + const cat = catById(t.category); + const col = catColor(t.category); + return `${escapeHtml(t.category)}`; +} + +// Plan-badge(r) for en oppgave: hvilke(n) dag(er)/person(er) den er planlagt. +function planBadges(t) { + const entries = state.planIndex[t.id]; + if (!entries || !entries.length) return ''; + // Sorter på dato; vis nærmeste, resten som "+N". + const sorted = [...entries].sort((a, b) => a.date.localeCompare(b.date)); + const fmt = e => { + const me = e.user === state.user; + const d = e.date.slice(8, 10) + '.' + e.date.slice(5, 7); + const who = me ? 'meg' : e.user; + const title = e.name ? `${e.name} — ${who} ${e.date}` : `Planlagt ${e.date} av ${who}`; + return `📅 ${d} ${escapeHtml(who)}`; + }; + let html = fmt(sorted[0]); + if (sorted.length > 1) html += ` +${sorted.length - 1}`; + return html; } function editingTag(t) { const editors = (state.editingMap[t.id] || []).filter(u => u !== state.user); @@ -601,6 +656,7 @@ function renderCard(t) {