Dagsplan: flytt/navn/deling + plan-badge, kategorier, feltmodus
Dagsplan kan navngis og flyttes til annen dato (slår sammen uten datatap).
Delbar som hovedregel (shared-flagg default true) med toggle for privat;
andres delte planer kan ses skrivebeskyttet via "Plan for"-velger.
getPlanFor håndhever tilgang. Per-oppgave plan-badge (dag + person) via
planIndex filtrert per viewer.
Egendefinerte kategorier (categories.json) i tillegg til de 12 innebygde;
dynamisk validering, deles via WS, addes fra kategori-nedtrekket.
Feltmodus (body.field-mode): større berøringsmål for mobil, auto på <640px.
plans.json migreres array -> {name,items,shared} ved oppstart (bakoverkompat).
26 integrasjonstester (6 nye), alle grønne. README + CLAUDE.md oppdatert.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
be166034de
commit
bdeb6254b9
8 changed files with 724 additions and 147 deletions
|
|
@ -17,7 +17,13 @@ Nytt mot PHP-versjonen:
|
||||||
- **WebSocket-sanntidssynk** (erstatter 60s-polling) + presence + "redigerer nå"
|
- **WebSocket-sanntidssynk** (erstatter 60s-polling) + presence + "redigerer nå"
|
||||||
- **Kanban-tavle** med drag & drop, i tillegg til tabellvisning
|
- **Kanban-tavle** med drag & drop, i tillegg til tabellvisning
|
||||||
- **Dagsplan** (tredje visning) — per bruker, per dato; ordnet liste man bygger
|
- **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`
|
- **Kommentar ved statusbytte** — valgfri `comment` på `PUT /api/tasks/:id`
|
||||||
festes som kommentar med `kind:'status'` og legges i aktivitets-detaljen.
|
festes som kommentar med `kind:'status'` og legges i aktivitets-detaljen.
|
||||||
- **Kommentarer** per oppgave
|
- **Kommentarer** per oppgave
|
||||||
|
|
@ -41,7 +47,7 @@ netops-todo-node/
|
||||||
├── setup-debian13.sh Engangs-oppsett på Debian 13 LXC (apt + npm ci + systemd).
|
├── 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.
|
├── test/api.test.js Integrasjonstester (node:test) — REST + WS ende-til-ende.
|
||||||
├── data/ Opprettes ved kjøring: tasks.json, activity.json,
|
├── 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.
|
└── 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 |
|
| `task` | `op: create/update/delete`, `task`/`id`, `by`, `activity` | Etter hver mutasjon |
|
||||||
| `presence` | `online[]` | Når noen kobler til/fra |
|
| `presence` | `online[]` | Når noen kobler til/fra |
|
||||||
| `editing` | `editing{}` = `{taskId: [brukere]}` | Når noen åpner/lukker modal |
|
| `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 }`.
|
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`)
|
### Dagsplan-skjema (`plans.json`)
|
||||||
|
|
||||||
```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
|
Per bruker, per dato. `shared` default `true` (planer er synlige for alle som
|
||||||
slettede oppgaver ved lesing; `setPlan()` deduperer og validerer mot
|
hovedregel). `_planEntry()` normaliserer både gammelt array-format og nytt
|
||||||
eksisterende oppgaver; `_scrubFromPlans()` rydder ved sletting; `_prunePlans()`
|
objektformat (migrering skjer i `_prunePlans()` ved oppstart). `getPlan()`
|
||||||
dropper datoer eldre enn 30 dager ved oppstart. Planen er bevisst privat (kun
|
filtrerer bort slettede oppgaver; `getPlanFor(viewer, owner, date)` håndhever
|
||||||
`owner`-attribusjon på selve oppgavene er delt), men synkes mellom samme
|
tilgang (egen, eller delt → ellers null/403); `setPlan/setPlanName/setPlanShared`
|
||||||
brukers faner via `plan`-WS-meldingen.
|
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
|
## Designvalg verdt å vite
|
||||||
|
|
||||||
|
|
@ -149,15 +169,17 @@ strenglengde-klamping, brukernavn-regex `[A-Za-z0-9._@-]{1,64}`), pluss:
|
||||||
|
|
||||||
## Testing-status
|
## 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,
|
tilfeldig port med temp-datakatalog. Dekker normalisering, versjonskonflikt,
|
||||||
kommentar-autorisasjon, statuskommentar-på-update, dagsplan (CRUD, per-bruker,
|
kommentar-autorisasjon, statuskommentar-på-update, dagsplan (CRUD, per-bruker,
|
||||||
validering, scrub ved sletting, dato-fallback), aktivitetslogg, WS-broadcast +
|
validering, scrub ved sletting, dato-fallback, navn, flytt/merge, deling +
|
||||||
per-bruker plan-push, persistens på disk, cookie-auth. Alle grønne per
|
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).
|
2026-06-15 (Node 25).
|
||||||
- Manuell røyk-test utført: `/healthz`, statiske filer, seed, state, plan
|
- Manuell røyk-test over HTTP: kategori-opprettelse (auto-id + farge), navngitt
|
||||||
set/get med rekkefølge, ferdig-med-kommentar, og per-bruker-isolasjon av plan
|
delt plan, annen brukers tilgang (delt 200 / privat 403), flytt med bevart
|
||||||
verifisert ende-til-ende over HTTP.
|
navn+delingsstatus, og planIndex verifisert.
|
||||||
- Ikke testet i nettleser med flere samtidige brukere — første ekte test bør
|
- 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
|
være to nettleservinduer mot `npm start`: del en dagsplan og se den hos den
|
||||||
dagsplan og dra for å sortere, og marker ferdig for å se kommentar-dialogen.
|
andre brukeren + badge på oppgaven, flytt planen til en annen dag, legg til en
|
||||||
|
egendefinert kategori, og slå på feltmodus på en smal skjerm.
|
||||||
|
|
|
||||||
|
|
@ -135,8 +135,16 @@ WantedBy=multi-user.target
|
||||||
- **Presence** — se hvem som er pålogget akkurat nå
|
- **Presence** — se hvem som er pålogget akkurat nå
|
||||||
- **Kanban-tavle** med drag & drop mellom statuskolonner, pluss klassisk tabellvisning
|
- **Kanban-tavle** med drag & drop mellom statuskolonner, pluss klassisk tabellvisning
|
||||||
- **Dagsplan** — plukk oppgaver fra lista og bygg en ordnet plan for dagen
|
- **Dagsplan** — plukk oppgaver fra lista og bygg en ordnet plan for dagen
|
||||||
(dra for å sortere, kapasitet i timer, datovelger). Planen er personlig og
|
(dra for å sortere, kapasitet i timer, datovelger). Kan navngis, flyttes til
|
||||||
synkes mellom dine egne faner.
|
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
|
- **Kommentar ved statusbytte** — når du markerer ferdig eller blokkert (via
|
||||||
knapp, kanban-drag eller modal) tilbys du å skrive en kommentar som festes
|
knapp, kanban-drag eller modal) tilbys du å skrive en kommentar som festes
|
||||||
til oppgaven og vises i aktivitetsloggen
|
til oppgaven og vises i aktivitetsloggen
|
||||||
|
|
@ -149,7 +157,7 @@ WantedBy=multi-user.target
|
||||||
- **Angre sletting** direkte fra toast
|
- **Angre sletting** direkte fra toast
|
||||||
- KPI-er, grafer (kategori/prioritet/eier), filtre, sortering, søk
|
- KPI-er, grafer (kategori/prioritet/eier), filtre, sortering, søk
|
||||||
- Eksport/import av JSON, eksempeldata for demo
|
- 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
|
## API
|
||||||
|
|
||||||
|
|
@ -158,14 +166,17 @@ optimistisk låsing — send med ved update, få 409 hvis noen andre har lagret.
|
||||||
|
|
||||||
| Metode | Endepunkt | Beskrivelse |
|
| 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) |
|
| 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) |
|
| PUT | `/api/tasks/:id` | Oppdater (delvis OK; send `v` for konfliktsjekk; valgfri `comment` festes som status-kommentar) |
|
||||||
| DELETE | `/api/tasks/:id` | Slett |
|
| DELETE | `/api/tasks/:id` | Slett |
|
||||||
| POST | `/api/tasks/:id/comments` | Legg til kommentar `{ text }` |
|
| POST | `/api/tasks/:id/comments` | Legg til kommentar `{ text }` |
|
||||||
| DELETE | `/api/tasks/:id/comments/:cid` | Slett egen kommentar |
|
| DELETE | `/api/tasks/:id/comments/:cid` | Slett egen kommentar |
|
||||||
| GET | `/api/plan?date=YYYY-MM-DD` | Hent egen dagsplan (ordnet liste av task-ids) |
|
| 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…] }` |
|
| 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/replace` | Erstatt hele listen (import) |
|
||||||
| POST | `/api/seed` | Last eksempeldata (kun hvis tomt) |
|
| POST | `/api/seed` | Last eksempeldata (kun hvis tomt) |
|
||||||
| POST | `/api/login` | Sett navn-cookie (hvis cookie-auth er på) |
|
| POST | `/api/login` | Sett navn-cookie (hvis cookie-auth er på) |
|
||||||
|
|
@ -183,7 +194,8 @@ curl -s -X POST localhost:3000/api/tasks \
|
||||||
## Lagring
|
## Lagring
|
||||||
|
|
||||||
JSON-filer i `DATA_DIR` (atomisk skriv via tmp-fil + rename, serialisert i
|
JSON-filer i `DATA_DIR` (atomisk skriv via tmp-fil + rename, serialisert i
|
||||||
prosessen): `tasks.json`, `activity.json`, `users.json` og `plans.json`
|
prosessen): `tasks.json`, `activity.json`, `users.json`, `plans.json`
|
||||||
(dagsplaner per bruker per dato; planer eldre enn 30 dager ryddes ved oppstart).
|
(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
|
Én node-prosess per datakatalog. Holder fint til tusenvis av oppgaver; blir det
|
||||||
trangt er SQLite (`node:sqlite`) neste naturlige steg.
|
trangt er SQLite (`node:sqlite`) neste naturlige steg.
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,27 @@ const crypto = require('crypto');
|
||||||
|
|
||||||
const VALID_STATUS = ['todo', 'progress', 'blocked', 'done'];
|
const VALID_STATUS = ['todo', 'progress', 'blocked', 'done'];
|
||||||
const VALID_PRIORITIES = ['P1', 'P2', 'P3', 'P4'];
|
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' };
|
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.title = sanitizeStr(inp.title ?? ex.title ?? '', 200);
|
||||||
t.desc = sanitizeStr(inp.desc ?? ex.desc ?? '', 4000);
|
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';
|
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';
|
const prio = inp.priority ?? ex.priority ?? 'P3';
|
||||||
t.priority = VALID_PRIORITIES.includes(prio) ? prio : 'P3';
|
t.priority = VALID_PRIORITIES.includes(prio) ? prio : 'P3';
|
||||||
|
|
@ -155,10 +176,13 @@ class Store {
|
||||||
this.activityFile = path.join(dataDir, 'activity.json');
|
this.activityFile = path.join(dataDir, 'activity.json');
|
||||||
this.usersFile = path.join(dataDir, 'users.json');
|
this.usersFile = path.join(dataDir, 'users.json');
|
||||||
this.plansFile = path.join(dataDir, 'plans.json');
|
this.plansFile = path.join(dataDir, 'plans.json');
|
||||||
|
this.categoriesFile = path.join(dataDir, 'categories.json');
|
||||||
this.tasks = [];
|
this.tasks = [];
|
||||||
this.activity = [];
|
this.activity = [];
|
||||||
this.knownUsers = new Set();
|
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();
|
this._writeChain = Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -169,6 +193,7 @@ class Store {
|
||||||
for (const u of this._readJson(this.usersFile, [])) this.knownUsers.add(u);
|
for (const u of this._readJson(this.usersFile, [])) this.knownUsers.add(u);
|
||||||
this.plans = this._readJson(this.plansFile, {});
|
this.plans = this._readJson(this.plansFile, {});
|
||||||
this._prunePlans();
|
this._prunePlans();
|
||||||
|
this._loadCategories();
|
||||||
for (const t of this.tasks) {
|
for (const t of this.tasks) {
|
||||||
for (const k of ['owner', 'createdBy', 'updatedBy', 'completedBy']) {
|
for (const k of ['owner', 'createdBy', 'updatedBy', 'completedBy']) {
|
||||||
if (t[k]) this.knownUsers.add(t[k]);
|
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() {
|
_prunePlans() {
|
||||||
const cutoff = new Date(Date.now() - PLAN_RETENTION_DAYS * 86400000).toISOString().slice(0, 10);
|
const cutoff = new Date(Date.now() - PLAN_RETENTION_DAYS * 86400000).toISOString().slice(0, 10);
|
||||||
for (const user of Object.keys(this.plans)) {
|
for (const user of Object.keys(this.plans)) {
|
||||||
const byDate = this.plans[user];
|
const byDate = this.plans[user];
|
||||||
if (!byDate || typeof byDate !== 'object') { delete this.plans[user]; continue; }
|
if (!byDate || typeof byDate !== 'object') { delete this.plans[user]; continue; }
|
||||||
for (const date of Object.keys(byDate)) {
|
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];
|
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).
|
// Leser JSON og validerer mot fallback-typen (array vs. objekt).
|
||||||
_readJson(file, fallback) {
|
_readJson(file, fallback) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -220,6 +266,28 @@ class Store {
|
||||||
_persistTasks() { return this._persist(this.tasksFile, this.tasks); }
|
_persistTasks() { return this._persist(this.tasksFile, this.tasks); }
|
||||||
_persistActivity() { return this._persist(this.activityFile, this.activity); }
|
_persistActivity() { return this._persist(this.activityFile, this.activity); }
|
||||||
_persistPlans() { return this._persist(this.plansFile, this.plans); }
|
_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) {
|
rememberUser(user) {
|
||||||
if (user && !this.knownUsers.has(user)) {
|
if (user && !this.knownUsers.has(user)) {
|
||||||
|
|
@ -252,7 +320,7 @@ class Store {
|
||||||
|
|
||||||
create(input, user) {
|
create(input, user) {
|
||||||
// importComments: lar "angre sletting" og import gjenopprette kommentarer
|
// 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 (!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)
|
if (this.get(task.id)) task.id = uid('t'); // unngå kollisjon ved gjenbrukt id (angre-slett)
|
||||||
this.tasks.push(task);
|
this.tasks.push(task);
|
||||||
|
|
@ -270,7 +338,7 @@ class Store {
|
||||||
if (input && input.v != null && Number(input.v) !== existing.v) {
|
if (input && input.v != null && Number(input.v) !== existing.v) {
|
||||||
throw new ConflictError('Oppgaven er endret av noen andre', existing);
|
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);
|
const idx = this.tasks.indexOf(existing);
|
||||||
this.tasks[idx] = updated;
|
this.tasks[idx] = updated;
|
||||||
this.rememberUser(user);
|
this.rememberUser(user);
|
||||||
|
|
@ -311,13 +379,36 @@ class Store {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Dagsplan (per bruker, per dato) ----------
|
// ---------- Dagsplan (per bruker, per dato) ----------
|
||||||
getPlan(user, date) {
|
// Normaliser lagret entry til { name, items, shared } (tåler gammelt array-format).
|
||||||
const ids = (this.plans[user] && this.plans[user][date]) || [];
|
// shared default true: planer er som hovedregel synlige for alle.
|
||||||
// Filtrer bort oppgaver som er slettet siden planen ble lagret.
|
_planEntry(user, date) {
|
||||||
return ids.filter(id => this.get(id));
|
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 valid = [];
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
for (const raw of Array.isArray(ids) ? ids : []) {
|
for (const raw of Array.isArray(ids) ? ids : []) {
|
||||||
|
|
@ -328,12 +419,56 @@ class Store {
|
||||||
valid.push(id);
|
valid.push(id);
|
||||||
if (valid.length >= 200) break;
|
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 (!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];
|
else delete this.plans[user][date];
|
||||||
if (Object.keys(this.plans[user]).length === 0) delete this.plans[user];
|
if (Object.keys(this.plans[user]).length === 0) delete this.plans[user];
|
||||||
this._persistPlans();
|
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) {
|
_scrubFromPlans(taskId) {
|
||||||
|
|
@ -341,10 +476,12 @@ class Store {
|
||||||
for (const user of Object.keys(this.plans)) {
|
for (const user of Object.keys(this.plans)) {
|
||||||
const byDate = this.plans[user];
|
const byDate = this.plans[user];
|
||||||
for (const date of Object.keys(byDate)) {
|
for (const date of Object.keys(byDate)) {
|
||||||
const before = byDate[date].length;
|
const e = this._planEntry(user, date);
|
||||||
byDate[date] = byDate[date].filter(x => x !== taskId);
|
const filtered = e.items.filter(x => x !== taskId);
|
||||||
if (byDate[date].length !== before) changed = true;
|
if (filtered.length === e.items.length) continue;
|
||||||
if (byDate[date].length === 0) delete byDate[date];
|
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];
|
if (Object.keys(byDate).length === 0) delete this.plans[user];
|
||||||
}
|
}
|
||||||
|
|
@ -383,7 +520,7 @@ class Store {
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const raw of arr) {
|
for (const raw of arr) {
|
||||||
if (!raw || typeof raw !== 'object' || !raw.title) continue;
|
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.tasks = out;
|
||||||
this._persistTasks();
|
this._persistTasks();
|
||||||
|
|
|
||||||
|
|
@ -6,25 +6,24 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ---------- Konstanter ----------
|
// ---------- Konstanter ----------
|
||||||
const CATEGORIES = [
|
// Innebygde kategorier (samme som serverens DEFAULT_CATEGORIES). Brukes som
|
||||||
{ id: 'L1', name: 'L1 Fysisk', desc: 'Kabling, fiber, patch, SFP, rack' },
|
// startverdi før /api/state svarer; serveren sender den effektive lista
|
||||||
{ id: 'L2', name: 'L2 Switching', desc: 'VLAN, STP, MAC, trunking' },
|
// (innebygde + egendefinerte) som overstyrer denne.
|
||||||
{ id: 'L3', name: 'L3 Ruting', desc: 'OSPF, ISIS, statisk, VRF' },
|
const DEFAULT_CATEGORIES = [
|
||||||
{ id: 'L4', name: 'L4 Lastbalansering', desc: 'HAProxy, NGINX, F5, BIG-IP' },
|
{ id: 'L1', name: 'L1 Fysisk', color: '#ff9f43', desc: 'Kabling, fiber, patch, SFP, rack' },
|
||||||
{ id: 'BGP', name: 'BGP', desc: 'iBGP / eBGP, policies, RR' },
|
{ id: 'L2', name: 'L2 Switching', color: '#4fc3f7', desc: 'VLAN, STP, MAC, trunking' },
|
||||||
{ id: 'PEER', name: 'Peering / IX', desc: 'IXP, private peering, PeeringDB' },
|
{ id: 'L3', name: 'L3 Ruting', color: '#7fb8ff', desc: 'OSPF, ISIS, statisk, VRF' },
|
||||||
{ id: 'SEC', name: 'Firewall / Security',desc: 'ACL, firewall, IPS, audit' },
|
{ id: 'L4', name: 'L4 Lastbalansering', color: '#bb86fc', desc: 'HAProxy, NGINX, F5, BIG-IP' },
|
||||||
{ id: 'NMS', name: 'Overvåking / NMS', desc: 'LibreNMS, Grafana, alerts' },
|
{ id: 'BGP', name: 'BGP', color: '#4ade80', desc: 'iBGP / eBGP, policies, RR' },
|
||||||
{ id: 'AUTO', name: 'Automatisering', desc: 'Ansible, scripts, CI/CD' },
|
{ id: 'PEER', name: 'Peering / IX', color: '#26de81', desc: 'IXP, private peering, PeeringDB' },
|
||||||
{ id: 'DOC', name: 'Dokumentasjon', desc: 'Diagrammer, IPAM, runbooks' },
|
{ id: 'SEC', name: 'Firewall / Security', color: '#ff5370', desc: 'ACL, firewall, IPS, audit' },
|
||||||
{ id: 'INC', name: 'Incident / On-call', desc: 'Feilsøking, postmortem' },
|
{ id: 'NMS', name: 'Overvåking / NMS', color: '#ffd93d', desc: 'LibreNMS, Grafana, alerts' },
|
||||||
{ id: 'PROJ', name: 'Prosjekt / Plan', desc: 'Design, anskaffelse, oppgraderinger' },
|
{ 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 = {
|
const NEW_CAT = '__new__'; // sentinel-verdi i kategori-nedtrekk for "ny kategori"
|
||||||
L1: '#ff9f43', L2: '#4fc3f7', L3: '#7fb8ff', L4: '#bb86fc',
|
|
||||||
BGP: '#4ade80', PEER: '#26de81', SEC: '#ff5370', NMS: '#ffd93d',
|
|
||||||
AUTO: '#00d2d3', DOC: '#a0a0a0', INC: '#ff6b6b', PROJ: '#8172B3'
|
|
||||||
};
|
|
||||||
const STATUS_LABELS = { todo: 'Å gjøre', progress: 'Pågår', blocked: 'Blokkert', done: 'Ferdig' };
|
const 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 PRIO_ORDER = { P1: 1, P2: 2, P3: 3, P4: 4 };
|
||||||
const BOARD_COLS = ['todo', 'progress', 'blocked', 'done'];
|
const BOARD_COLS = ['todo', 'progress', 'blocked', 'done'];
|
||||||
|
|
@ -37,7 +36,13 @@ const ACT_META = {
|
||||||
comment: { icon: '💬', verb: 'kommenterte' },
|
comment: { icon: '💬', verb: 'kommenterte' },
|
||||||
import: { icon: '⬆', verb: 'importerte' },
|
import: { icon: '⬆', verb: 'importerte' },
|
||||||
seed: { icon: '◇', verb: 'lastet eksempeldata' },
|
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 ----------
|
// ---------- State ----------
|
||||||
const state = {
|
const state = {
|
||||||
|
|
@ -47,6 +52,8 @@ const state = {
|
||||||
activity: [],
|
activity: [],
|
||||||
knownUsers: [],
|
knownUsers: [],
|
||||||
online: [],
|
online: [],
|
||||||
|
categories: DEFAULT_CATEGORIES,
|
||||||
|
planIndex: {}, // taskId -> [{user, date, name}] (delte planer + egne)
|
||||||
editingMap: {}, // taskId -> [brukere med modal åpen]
|
editingMap: {}, // taskId -> [brukere med modal åpen]
|
||||||
filters: { status: 'open', category: 'all', priority: 'all', owner: 'all', search: '' },
|
filters: { status: 'open', category: 'all', priority: 'all', owner: 'all', search: '' },
|
||||||
sort: { field: 'priority', dir: 'asc' },
|
sort: { field: 'priority', dir: 'asc' },
|
||||||
|
|
@ -54,7 +61,9 @@ const state = {
|
||||||
editingId: null, // id i åpen modal (null = ny oppgave)
|
editingId: null, // id i åpen modal (null = ny oppgave)
|
||||||
editingBaseV: null, // versjonen modalen redigerer fra
|
editingBaseV: null, // versjonen modalen redigerer fra
|
||||||
editingStatus: null, // statusen oppgaven hadde da modalen ble åpnet
|
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,
|
wsOk: false,
|
||||||
};
|
};
|
||||||
const VIEWS = ['board', 'table', 'plan'];
|
const VIEWS = ['board', 'table', 'plan'];
|
||||||
|
|
@ -73,11 +82,16 @@ function loadPrefs() {
|
||||||
if (p.filters) Object.assign(state.filters, p.filters);
|
if (p.filters) Object.assign(state.filters, p.filters);
|
||||||
if (p.sort) state.sort = p.sort;
|
if (p.sort) state.sort = p.sort;
|
||||||
if (VIEWS.includes(p.view)) state.view = p.view;
|
if (VIEWS.includes(p.view)) state.view = p.view;
|
||||||
|
if (typeof p.fieldMode === 'boolean') state.fieldMode = p.fieldMode;
|
||||||
} catch { /* ignorer korrupt localStorage */ }
|
} 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() {
|
function savePrefs() {
|
||||||
localStorage.setItem('netops.prefs', JSON.stringify({
|
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();
|
updateModalWarning();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'plan':
|
case 'plan': {
|
||||||
// Dagsplanen synkes mellom samme brukers faner. Bruk bare hvis den
|
// Oppdater badge-indeksen for (bruker, dato), og den viste planen hvis vi
|
||||||
// gjelder datoen vi ser på nå.
|
// ser på akkurat denne. Delte planer mottas av alle; private kun av eier.
|
||||||
if (msg.date === state.plan.date) {
|
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.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;
|
break;
|
||||||
|
|
||||||
|
|
@ -247,6 +273,7 @@ function handleWSMessage(msg) {
|
||||||
if (msg.op === 'delete') {
|
if (msg.op === 'delete') {
|
||||||
state.tasks = state.tasks.filter(t => t.id !== msg.id);
|
state.tasks = state.tasks.filter(t => t.id !== msg.id);
|
||||||
state.plan.items = state.plan.items.filter(id => 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) {
|
if (!mine && state.editingId === msg.id) {
|
||||||
closeModal();
|
closeModal();
|
||||||
toast(`Oppgaven du redigerte ble slettet av ${msg.by}`, { kind: 'warn', ttl: 6000 });
|
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.activity = data.activity || [];
|
||||||
state.knownUsers = data.known_users || [];
|
state.knownUsers = data.known_users || [];
|
||||||
state.online = data.online || [];
|
state.online = data.online || [];
|
||||||
if (data.plan) state.plan = data.plan;
|
if (Array.isArray(data.categories) && data.categories.length) state.categories = data.categories;
|
||||||
if (!state.plan.date) state.plan = { date: todayISO(), items: [] };
|
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();
|
renderAll();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast('Kunne ikke laste: ' + e.message, { kind: 'err' });
|
toast('Kunne ikke laste: ' + e.message, { kind: 'err' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function ownPlan() { return state.plan.viewUser === state.user; }
|
||||||
|
|
||||||
// ---------- Quick add ----------
|
// ---------- Quick add ----------
|
||||||
function parseQuickAdd(text) {
|
function parseQuickAdd(text) {
|
||||||
|
|
@ -293,7 +330,7 @@ function parseQuickAdd(text) {
|
||||||
let m;
|
let m;
|
||||||
if (/^p[1-4]$/i.test(tok)) { out.priority = tok.toUpperCase(); continue; }
|
if (/^p[1-4]$/i.test(tok)) { out.priority = tok.toUpperCase(); continue; }
|
||||||
if ((m = tok.match(/^#(.+)$/))) {
|
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;
|
if (cat && !out.category) out.category = cat.id;
|
||||||
else out.tags.push(m[1]);
|
else out.tags.push(m[1]);
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -326,7 +363,7 @@ function renderQuickAddPreview() {
|
||||||
const chips = [];
|
const chips = [];
|
||||||
const chip = (label, val) => `<span class="qa-chip">${label} <b>${escapeHtml(val)}</b></span>`;
|
const chip = (label, val) => `<span class="qa-chip">${label} <b>${escapeHtml(val)}</b></span>`;
|
||||||
chips.push(chip('tittel', p.title || '(mangler)'));
|
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.priority) chips.push(chip('prio', p.priority));
|
||||||
if (p.owner) chips.push(chip('eier', p.owner));
|
if (p.owner) chips.push(chip('eier', p.owner));
|
||||||
if (p.deadline) chips.push(chip('frist', p.deadline));
|
if (p.deadline) chips.push(chip('frist', p.deadline));
|
||||||
|
|
@ -468,11 +505,11 @@ function renderCharts() {
|
||||||
charts.cat = new Chart($('catChart'), {
|
charts.cat = new Chart($('catChart'), {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: {
|
data: {
|
||||||
labels: catEntries.map(([k]) => (CATEGORIES.find(c => c.id === k) || { name: k }).name),
|
labels: catEntries.map(([k]) => catName(k)),
|
||||||
datasets: [{
|
datasets: [{
|
||||||
data: catEntries.map(([, v]) => v),
|
data: catEntries.map(([, v]) => v),
|
||||||
backgroundColor: catEntries.map(([k]) => (CAT_COLORS[k] || '#666') + 'CC'),
|
backgroundColor: catEntries.map(([k]) => catColor(k) + 'CC'),
|
||||||
borderColor: catEntries.map(([k]) => CAT_COLORS[k] || '#666'),
|
borderColor: catEntries.map(([k]) => catColor(k)),
|
||||||
borderWidth: 1, borderRadius: 4,
|
borderWidth: 1, borderRadius: 4,
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
|
|
@ -538,9 +575,27 @@ function ownerBadge(t) {
|
||||||
return `<span class="owner-badge ${cls}">${t.owner ? escapeHtml(t.owner) : 'utildelt'}</span>`;
|
return `<span class="owner-badge ${cls}">${t.owner ? escapeHtml(t.owner) : 'utildelt'}</span>`;
|
||||||
}
|
}
|
||||||
function catBadge(t) {
|
function catBadge(t) {
|
||||||
const cat = CATEGORIES.find(c => c.id === t.category);
|
const cat = catById(t.category);
|
||||||
const col = CAT_COLORS[t.category] || '#4fc3f7';
|
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) : ''}">${escapeHtml(t.category)}</span>`;
|
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) {
|
function editingTag(t) {
|
||||||
const editors = (state.editingMap[t.id] || []).filter(u => u !== state.user);
|
const editors = (state.editingMap[t.id] || []).filter(u => u !== state.user);
|
||||||
|
|
@ -601,6 +656,7 @@ function renderCard(t) {
|
||||||
<div class="card-title" onclick="openEdit('${t.id}')">${escapeHtml(t.title)}</div>
|
<div class="card-title" onclick="openEdit('${t.id}')">${escapeHtml(t.title)}</div>
|
||||||
<div class="card-meta">
|
<div class="card-meta">
|
||||||
${ownerBadge(t)}
|
${ownerBadge(t)}
|
||||||
|
${planBadges(t)}
|
||||||
${t.deadline ? `<span class="time-cell ${dl.cls}">${dl.text}</span>` : ''}
|
${t.deadline ? `<span class="time-cell ${dl.cls}">${dl.text}</span>` : ''}
|
||||||
${t.estHours ? `<span>~${t.estHours}t</span>` : ''}
|
${t.estHours ? `<span>~${t.estHours}t</span>` : ''}
|
||||||
${t.comments && t.comments.length ? `<span class="comment-count">💬 ${t.comments.length}</span>` : ''}
|
${t.comments && t.comments.length ? `<span class="comment-count">💬 ${t.comments.length}</span>` : ''}
|
||||||
|
|
@ -677,7 +733,7 @@ function renderTable() {
|
||||||
${editors.length ? `<span class="editing-tag">✎ ${escapeHtml(editors.join(', '))} redigerer</span>` : ''}
|
${editors.length ? `<span class="editing-tag">✎ ${escapeHtml(editors.join(', '))} redigerer</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
${t.desc ? `<div class="task-desc">${escapeHtml(t.desc.slice(0, 160))}${t.desc.length > 160 ? '…' : ''}</div>` : ''}
|
${t.desc ? `<div class="task-desc">${escapeHtml(t.desc.slice(0, 160))}${t.desc.length > 160 ? '…' : ''}</div>` : ''}
|
||||||
${tags ? `<div style="margin-top:4px">${tags}</div>` : ''}
|
${tags || planBadges(t) ? `<div style="margin-top:4px;display:flex;gap:6px;flex-wrap:wrap;align-items:center">${planBadges(t)} ${tags}</div>` : ''}
|
||||||
</td>
|
</td>
|
||||||
<td>${catBadge(t)}</td>
|
<td>${catBadge(t)}</td>
|
||||||
<td><span class="status-badge status-${t.status}">${STATUS_LABELS[t.status] || t.status}</span></td>
|
<td><span class="status-badge status-${t.status}">${STATUS_LABELS[t.status] || t.status}</span></td>
|
||||||
|
|
@ -705,15 +761,51 @@ function planTasks() {
|
||||||
return state.plan.items.map(findTask).filter(Boolean);
|
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() {
|
function renderPlan() {
|
||||||
$('empty').style.display = 'none';
|
$('empty').style.display = 'none';
|
||||||
|
const own = ownPlan();
|
||||||
|
|
||||||
|
// Header-felter
|
||||||
const dateInput = $('plan-date');
|
const dateInput = $('plan-date');
|
||||||
if (document.activeElement !== dateInput) dateInput.value = state.plan.date;
|
if (document.activeElement !== dateInput) dateInput.value = state.plan.date;
|
||||||
|
const nameInput = $('plan-name');
|
||||||
|
if (document.activeElement !== nameInput) nameInput.value = state.plan.name || '';
|
||||||
|
nameInput.readOnly = !own;
|
||||||
|
nameInput.placeholder = own ? 'Navngi planen (valgfritt) …' : '(uten navn)';
|
||||||
|
$('plan-shared').checked = state.plan.shared !== false;
|
||||||
|
$('plan-shared').disabled = !own;
|
||||||
|
$('plan-shared').closest('.plan-share').style.display = own ? '' : 'none';
|
||||||
|
$('plan-move').style.display = own ? '' : 'none';
|
||||||
|
populatePlanUserSelect();
|
||||||
|
|
||||||
|
// Lesemodus-banner når man ser en annens plan
|
||||||
|
const ro = $('plan-readonly');
|
||||||
|
if (!own) {
|
||||||
|
ro.textContent = `Du ser ${state.plan.viewUser} sin dagsplan (skrivebeskyttet).`;
|
||||||
|
ro.classList.add('show');
|
||||||
|
} else {
|
||||||
|
ro.classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Venstre kolonne (legg til) gir bare mening for egen plan
|
||||||
|
$('plan-available-col').style.display = own ? '' : 'none';
|
||||||
|
$('planner').style.gridTemplateColumns = own ? '' : '1fr';
|
||||||
|
|
||||||
const planned = planTasks();
|
const planned = planTasks();
|
||||||
const planIds = new Set(planned.map(t => t.id));
|
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).
|
// Venstre: åpne oppgaver som ikke alt er i planen (respekterer kat/prio/eier/søk).
|
||||||
const avail = sortTasks(
|
const avail = sortTasks(
|
||||||
applyFilters(state.tasks, { ignoreStatus: true })
|
applyFilters(state.tasks, { ignoreStatus: true })
|
||||||
|
|
@ -723,6 +815,7 @@ function renderPlan() {
|
||||||
$('plan-available-body').innerHTML = avail.length
|
$('plan-available-body').innerHTML = avail.length
|
||||||
? avail.map(renderAvailRow).join('')
|
? avail.map(renderAvailRow).join('')
|
||||||
: '<div class="plan-empty">Ingen åpne oppgaver igjen — alt er planlagt eller filtrert bort.</div>';
|
: '<div class="plan-empty">Ingen åpne oppgaver igjen — alt er planlagt eller filtrert bort.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
// Høyre: kapasitet + ordnet liste.
|
// Høyre: kapasitet + ordnet liste.
|
||||||
const remaining = planned.filter(t => t.status !== 'done');
|
const remaining = planned.filter(t => t.status !== 'done');
|
||||||
|
|
@ -734,11 +827,23 @@ function renderPlan() {
|
||||||
: '';
|
: '';
|
||||||
cap.className = 'col-meta' + (hours > 8 ? ' over-capacity' : '');
|
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
|
$('plan-today-body').innerHTML = planned.length
|
||||||
? planned.map(renderPlanItem).join('')
|
? planned.map((t, i) => renderPlanItem(t, i, own)).join('')
|
||||||
: '<div class="plan-empty">Tomt. Legg til oppgaver fra venstre med +, eller dra dem hit. Dra for å sortere.</div>';
|
: `<div class="plan-empty">${emptyMsg}</div>`;
|
||||||
|
|
||||||
bindPlanDnD();
|
if (own) bindPlanDnD();
|
||||||
|
}
|
||||||
|
|
||||||
|
function populatePlanUserSelect() {
|
||||||
|
const sel = $('plan-view-user');
|
||||||
|
const users = new Set([state.user, ...state.knownUsers, ...state.online]);
|
||||||
|
const sorted = [...users].filter(Boolean).sort();
|
||||||
|
sel.innerHTML = sorted.map(u =>
|
||||||
|
`<option value="${escapeHtml(u)}">${u === state.user ? 'Meg' : escapeHtml(u)}</option>`).join('');
|
||||||
|
sel.value = state.plan.viewUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAvailRow(t) {
|
function renderAvailRow(t) {
|
||||||
|
|
@ -759,12 +864,12 @@ function renderAvailRow(t) {
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPlanItem(t, i) {
|
function renderPlanItem(t, i, own = true) {
|
||||||
const dl = formatDeadline(t.deadline);
|
const dl = formatDeadline(t.deadline);
|
||||||
const doneCls = t.status === 'done' ? ' done-item' : '';
|
const doneCls = t.status === 'done' ? ' done-item' : '';
|
||||||
return `
|
return `
|
||||||
<div class="plan-item${doneCls}" draggable="true" data-id="${t.id}">
|
<div class="plan-item${doneCls}"${own ? ' draggable="true"' : ''} data-id="${t.id}">
|
||||||
<span class="plan-handle" title="Dra for å sortere">⠿</span>
|
${own ? '<span class="plan-handle" title="Dra for å sortere">⠿</span>' : ''}
|
||||||
<span class="plan-num">${i + 1}</span>
|
<span class="plan-num">${i + 1}</span>
|
||||||
<div class="plan-item-main">
|
<div class="plan-item-main">
|
||||||
<div class="plan-item-title" onclick="openEdit('${t.id}')">${escapeHtml(t.title)}</div>
|
<div class="plan-item-title" onclick="openEdit('${t.id}')">${escapeHtml(t.title)}</div>
|
||||||
|
|
@ -776,12 +881,12 @@ function renderPlanItem(t, i) {
|
||||||
${t.deadline ? `<span class="time-cell ${dl.cls}">${dl.text}</span>` : ''}
|
${t.deadline ? `<span class="time-cell ${dl.cls}">${dl.text}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="plan-item-actions">
|
${own ? `<div class="plan-item-actions">
|
||||||
${t.status !== 'done'
|
${t.status !== 'done'
|
||||||
? `<button class="icon-btn do" onclick="markDone('${t.id}')" title="Marker ferdig">✓</button>`
|
? `<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="reopenTask('${t.id}')" title="Gjenåpne">↺</button>`}
|
||||||
<button class="icon-btn del" onclick="removeFromPlan('${t.id}')" title="Fjern fra dagsplan">×</button>
|
<button class="icon-btn del" onclick="removeFromPlan('${t.id}')" title="Fjern fra dagsplan">×</button>
|
||||||
</div>
|
</div>` : ''}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -845,10 +950,14 @@ function bindPlanContainer() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function savePlan() {
|
async function savePlan() {
|
||||||
|
if (!ownPlan()) return; // kan ikke endre andres plan
|
||||||
renderPlan(); // optimistisk
|
renderPlan(); // optimistisk
|
||||||
try {
|
try {
|
||||||
const { date, items } = await api('PUT', '/api/plan', { date: state.plan.date, items: state.plan.items });
|
const { name, items, shared } = await api('PUT', '/api/plan', {
|
||||||
if (date === state.plan.date) { state.plan.items = items; renderPlan(); }
|
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) {
|
} catch (e) {
|
||||||
toast('Kunne ikke lagre dagsplan: ' + e.message, { kind: 'err' });
|
toast('Kunne ikke lagre dagsplan: ' + e.message, { kind: 'err' });
|
||||||
refreshPlan();
|
refreshPlan();
|
||||||
|
|
@ -856,6 +965,7 @@ async function savePlan() {
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addToPlan = function (id) {
|
window.addToPlan = function (id) {
|
||||||
|
if (!ownPlan()) return;
|
||||||
if (!state.plan.items.includes(id) && findTask(id)) {
|
if (!state.plan.items.includes(id) && findTask(id)) {
|
||||||
state.plan.items.push(id);
|
state.plan.items.push(id);
|
||||||
savePlan();
|
savePlan();
|
||||||
|
|
@ -864,16 +974,62 @@ window.addToPlan = function (id) {
|
||||||
};
|
};
|
||||||
|
|
||||||
window.removeFromPlan = function (id) {
|
window.removeFromPlan = function (id) {
|
||||||
|
if (!ownPlan()) return;
|
||||||
state.plan.items = state.plan.items.filter(x => x !== id);
|
state.plan.items = state.plan.items.filter(x => x !== id);
|
||||||
savePlan();
|
savePlan();
|
||||||
};
|
};
|
||||||
|
|
||||||
async function refreshPlan(date = state.plan.date) {
|
// 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 {
|
try {
|
||||||
const data = await api('GET', `/api/plan?date=${encodeURIComponent(date)}`);
|
await api('PUT', '/api/plan/share', { date: state.plan.date, shared });
|
||||||
state.plan = { date: data.date, items: data.items || [] };
|
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();
|
if (state.view === 'plan') renderPlan();
|
||||||
} catch { /* stille — neste handling prøver igjen */ }
|
} 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() {
|
function renderActivity() {
|
||||||
|
|
@ -1074,7 +1230,7 @@ function openNew(prefill = {}) {
|
||||||
$('modal-title').textContent = 'Ny oppgave';
|
$('modal-title').textContent = 'Ny oppgave';
|
||||||
$('f-title').value = prefill.title || '';
|
$('f-title').value = prefill.title || '';
|
||||||
$('f-desc').value = '';
|
$('f-desc').value = '';
|
||||||
$('f-cat').value = prefill.category || CATEGORIES[0].id;
|
$('f-cat').value = prefill.category || cats()[0].id;
|
||||||
$('f-prio').value = prefill.priority || 'P2';
|
$('f-prio').value = prefill.priority || 'P2';
|
||||||
$('f-stat').value = 'todo';
|
$('f-stat').value = 'todo';
|
||||||
$('f-est').value = '';
|
$('f-est').value = '';
|
||||||
|
|
@ -1203,6 +1359,19 @@ window.deleteComment = async function (taskId, commentId) {
|
||||||
} catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); }
|
} 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
|
||||||
|
savePrefs();
|
||||||
|
applyFieldMode();
|
||||||
|
toast(state.fieldMode ? 'Feltmodus på' : 'Feltmodus av');
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Navnevalg (cookie-auth) ----------
|
// ---------- Navnevalg (cookie-auth) ----------
|
||||||
async function setName() {
|
async function setName() {
|
||||||
const name = prompt('Brukernavn (a-z, 0-9, . _ @ -):', state.user === 'anon' ? '' : state.user);
|
const name = prompt('Brukernavn (a-z, 0-9, . _ @ -):', state.user === 'anon' ? '' : state.user);
|
||||||
|
|
@ -1246,10 +1415,13 @@ function bindUI() {
|
||||||
$('view-board').addEventListener('click', () => { state.view = 'board'; savePrefs(); renderView(); });
|
$('view-board').addEventListener('click', () => { state.view = 'board'; savePrefs(); renderView(); });
|
||||||
$('view-table').addEventListener('click', () => { state.view = 'table'; savePrefs(); renderView(); });
|
$('view-table').addEventListener('click', () => { state.view = 'table'; savePrefs(); renderView(); });
|
||||||
$('view-plan').addEventListener('click', () => { state.view = 'plan'; savePrefs(); renderView(); });
|
$('view-plan').addEventListener('click', () => { state.view = 'plan'; savePrefs(); renderView(); });
|
||||||
$('plan-date').addEventListener('change', e => {
|
$('plan-date').addEventListener('change', e => refreshPlan(e.target.value || todayISO(), state.plan.viewUser));
|
||||||
const d = e.target.value || todayISO();
|
$('plan-view-user').addEventListener('change', e => refreshPlan(state.plan.date, e.target.value));
|
||||||
refreshPlan(d);
|
$('plan-name').addEventListener('change', savePlanName);
|
||||||
});
|
$('plan-name').addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); e.target.blur(); } });
|
||||||
|
$('plan-shared').addEventListener('change', togglePlanShared);
|
||||||
|
$('plan-move-btn').addEventListener('click', movePlanToDate);
|
||||||
|
$('fieldBtn').addEventListener('click', toggleFieldMode);
|
||||||
bindPlanContainer();
|
bindPlanContainer();
|
||||||
|
|
||||||
// Filtre
|
// Filtre
|
||||||
|
|
@ -1283,6 +1455,7 @@ function bindUI() {
|
||||||
$('f-owner-select').addEventListener('change', e => {
|
$('f-owner-select').addEventListener('change', e => {
|
||||||
if (e.target.value) { $('f-owner-input').value = e.target.value; e.target.value = ''; }
|
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-send').addEventListener('click', sendComment);
|
||||||
$('comment-input').addEventListener('keydown', e => { if (e.key === 'Enter') sendComment(); });
|
$('comment-input').addEventListener('keydown', e => { if (e.key === 'Enter') sendComment(); });
|
||||||
$('f-stat').addEventListener('change', updateStatusNoteField);
|
$('f-stat').addEventListener('change', updateStatusNoteField);
|
||||||
|
|
@ -1341,6 +1514,7 @@ function bindUI() {
|
||||||
savePrefs(); renderView();
|
savePrefs(); renderView();
|
||||||
}
|
}
|
||||||
if (e.key === 'p') { state.view = 'plan'; savePrefs(); renderView(); }
|
if (e.key === 'p') { state.view = 'plan'; savePrefs(); renderView(); }
|
||||||
|
if (e.key === 'f') toggleFieldMode();
|
||||||
if (e.key === 'a') $('drawer').classList.toggle('open');
|
if (e.key === 'a') $('drawer').classList.toggle('open');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1353,15 +1527,39 @@ function bindUI() {
|
||||||
function populateCategorySelects() {
|
function populateCategorySelects() {
|
||||||
const filterSel = $('f-category');
|
const filterSel = $('f-category');
|
||||||
const formSel = $('f-cat');
|
const formSel = $('f-cat');
|
||||||
CATEGORIES.forEach(c => {
|
const opts = cats().map(c => `<option value="${escapeHtml(c.id)}">${escapeHtml(c.name)}</option>`).join('');
|
||||||
filterSel.insertAdjacentHTML('beforeend', `<option value="${c.id}">${c.name}</option>`);
|
// Filter: behold valgt verdi
|
||||||
formSel.insertAdjacentHTML('beforeend', `<option value="${c.id}">${c.name}</option>`);
|
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 ----------
|
// ---------- Init ----------
|
||||||
async function init() {
|
async function init() {
|
||||||
loadPrefs();
|
loadPrefs();
|
||||||
|
applyFieldMode();
|
||||||
populateCategorySelects();
|
populateCategorySelects();
|
||||||
bindUI();
|
bindUI();
|
||||||
await refreshState();
|
await refreshState();
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<span class="online-row" id="online-row" title="Pålogget nå"></span>
|
<span class="online-row" id="online-row" title="Pålogget nå"></span>
|
||||||
<span class="user-chip" id="user-chip" title="Pålogget bruker">…</span>
|
<span class="user-chip" id="user-chip" title="Pålogget bruker">…</span>
|
||||||
|
<button id="fieldBtn" title="Feltmodus — større knapper for mobil (f)">📱 Felt</button>
|
||||||
<button id="activityBtn" title="Aktivitetslogg (a)">☰ Aktivitet</button>
|
<button id="activityBtn" title="Aktivitetslogg (a)">☰ Aktivitet</button>
|
||||||
<button id="exportBtn" title="Eksporter JSON">⬇</button>
|
<button id="exportBtn" title="Eksporter JSON">⬇</button>
|
||||||
<button id="importBtn" title="Importer JSON (erstatter alt)">⬆</button>
|
<button id="importBtn" title="Importer JSON (erstatter alt)">⬆</button>
|
||||||
|
|
@ -135,7 +136,7 @@
|
||||||
<section class="board" id="board" style="display:none"></section>
|
<section class="board" id="board" style="display:none"></section>
|
||||||
|
|
||||||
<section class="planner" id="planner" style="display:none">
|
<section class="planner" id="planner" style="display:none">
|
||||||
<div class="plan-col">
|
<div class="plan-col" id="plan-available-col">
|
||||||
<div class="plan-col-head">
|
<div class="plan-col-head">
|
||||||
<h3>Tilgjengelige oppgaver <span class="col-meta" id="plan-avail-count"></span></h3>
|
<h3>Tilgjengelige oppgaver <span class="col-meta" id="plan-avail-count"></span></h3>
|
||||||
<span class="plan-col-hint">klikk + for å planlegge</span>
|
<span class="plan-col-hint">klikk + for å planlegge</span>
|
||||||
|
|
@ -143,13 +144,28 @@
|
||||||
<div class="plan-col-body" id="plan-available-body"></div>
|
<div class="plan-col-body" id="plan-available-body"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="plan-col plan-today">
|
<div class="plan-col plan-today">
|
||||||
<div class="plan-col-head">
|
<div class="plan-today-head">
|
||||||
<div class="plan-date-row">
|
<div class="plan-date-row">
|
||||||
<h3>Dagsplan</h3>
|
<span class="plan-cal">📅</span>
|
||||||
<input type="date" id="plan-date" title="Velg dag å planlegge">
|
<input type="date" id="plan-date" title="Velg dag å planlegge">
|
||||||
|
<input type="text" id="plan-name" class="plan-name-input" maxlength="80" autocomplete="off"
|
||||||
|
placeholder="Navngi planen (valgfritt), f.eks. «Vedlikeholdsvindu DC-OSL» …">
|
||||||
|
</div>
|
||||||
|
<div class="plan-controls">
|
||||||
|
<div class="plan-owner-row">
|
||||||
|
<label for="plan-view-user">Plan for</label>
|
||||||
|
<select id="plan-view-user" title="Se en annens dagsplan (skrivebeskyttet)"></select>
|
||||||
|
<label class="plan-share" title="Når på: andre kan se denne dagsplanen"><input type="checkbox" id="plan-shared"> Synlig for alle</label>
|
||||||
</div>
|
</div>
|
||||||
<span class="col-meta" id="plan-capacity"></span>
|
<span class="col-meta" id="plan-capacity"></span>
|
||||||
|
<div class="plan-move" id="plan-move">
|
||||||
|
<label for="plan-move-date">Flytt til</label>
|
||||||
|
<input type="date" id="plan-move-date" title="Måldato for hele planen">
|
||||||
|
<button id="plan-move-btn" title="Flytt hele planen til valgt dato">⇄ Flytt</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="plan-readonly" id="plan-readonly"></div>
|
||||||
<div class="plan-col-body" id="plan-today-body"></div>
|
<div class="plan-col-body" id="plan-today-body"></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -375,6 +375,34 @@ tbody tr.status-done .task-title { text-decoration: line-through; }
|
||||||
font-size: 11px; font-family: var(--mono); color: var(--text-faint); flex-wrap: wrap; }
|
font-size: 11px; font-family: var(--mono); color: var(--text-faint); flex-wrap: wrap; }
|
||||||
.plan-item-actions { display: flex; gap: 2px; flex-shrink: 0; }
|
.plan-item-actions { display: flex; gap: 2px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* Plan-header med navn + flytt */
|
||||||
|
.plan-today-head { display: flex; flex-direction: column; gap: 8px; padding: 12px 14px 10px; }
|
||||||
|
.plan-date-row { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.plan-cal { font-size: 15px; flex-shrink: 0; }
|
||||||
|
.plan-date-row input[type="date"] { padding: 6px 8px; font-size: 12px; flex-shrink: 0; }
|
||||||
|
.plan-name-input { flex: 1; min-width: 0; font-weight: 600; }
|
||||||
|
.plan-controls { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.plan-move { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.plan-move label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); font-weight: 600; }
|
||||||
|
.plan-move input[type="date"] { padding: 5px 7px; font-size: 12px; }
|
||||||
|
.plan-move button { padding: 6px 10px; font-size: 12px; }
|
||||||
|
.plan-owner-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.plan-owner-row > label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); font-weight: 600; }
|
||||||
|
.plan-owner-row select { padding: 5px 8px; font-size: 12px; }
|
||||||
|
.plan-share { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--text-dim);
|
||||||
|
cursor: pointer; text-transform: none; letter-spacing: 0; font-weight: 500; }
|
||||||
|
.plan-share input { width: 15px; height: 15px; accent-color: var(--accent); cursor: pointer; }
|
||||||
|
.plan-readonly { display: none; margin: 0 14px 8px; padding: 7px 12px; border-radius: 6px; font-size: 12px;
|
||||||
|
background: rgba(187,134,252,0.1); color: #d4b0ff; border: 1px solid rgba(187,134,252,0.3); }
|
||||||
|
.plan-readonly.show { display: block; }
|
||||||
|
|
||||||
|
/* Plan-badge på kort/rader: planlagt dag + person */
|
||||||
|
.plan-badge { display: inline-flex; align-items: center; gap: 4px; font-family: var(--mono);
|
||||||
|
font-size: 11px; padding: 1px 7px; border-radius: 4px; white-space: nowrap;
|
||||||
|
background: rgba(79,195,247,0.1); color: var(--accent); border: 1px solid rgba(79,195,247,0.28); }
|
||||||
|
.plan-badge.me { background: rgba(74,222,128,0.1); color: var(--ok); border-color: rgba(74,222,128,0.3); }
|
||||||
|
.plan-badge.more { opacity: 0.8; }
|
||||||
|
|
||||||
/* ---------- Toast ---------- */
|
/* ---------- Toast ---------- */
|
||||||
.toast-stack { position: fixed; bottom: 20px; right: 20px; z-index: 200;
|
.toast-stack { position: fixed; bottom: 20px; right: 20px; z-index: 200;
|
||||||
display: flex; flex-direction: column; gap: 8px; align-items: flex-end; }
|
display: flex; flex-direction: column; gap: 8px; align-items: flex-end; }
|
||||||
|
|
@ -403,6 +431,36 @@ footer { margin-top: 28px; padding-top: 16px; border-top: 1px solid var(--border
|
||||||
footer .kbd { display: inline-block; padding: 0 5px; border: 1px solid var(--border);
|
footer .kbd { display: inline-block; padding: 0 5px; border: 1px solid var(--border);
|
||||||
border-radius: 3px; background: var(--bg-soft); font-size: 11px; margin: 0 1px; }
|
border-radius: 3px; background: var(--bg-soft); font-size: 11px; margin: 0 1px; }
|
||||||
|
|
||||||
|
/* ---------- Feltmodus (mobil / på farten) ---------- */
|
||||||
|
/* Større tekst og berøringsmål, mindre pynt. Aktiveres via body.field-mode. */
|
||||||
|
#fieldBtn.active-view { background: var(--accent-soft); color: var(--accent); border-color: rgba(79,195,247,0.4); }
|
||||||
|
body.field-mode { font-size: 16px; }
|
||||||
|
body.field-mode .chart-row { display: none; } /* grafer tar plass — skjul i felt */
|
||||||
|
body.field-mode .kpi-row { grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||||
|
body.field-mode .kpi { padding: 12px 14px; }
|
||||||
|
body.field-mode .kpi-value { font-size: 22px; }
|
||||||
|
body.field-mode .container { padding: 12px; }
|
||||||
|
body.field-mode button, body.field-mode .btn {
|
||||||
|
min-height: 44px; padding: 10px 16px; font-size: 15px;
|
||||||
|
}
|
||||||
|
body.field-mode .view-toggle button { min-height: 44px; }
|
||||||
|
body.field-mode .icon-btn { min-width: 40px; min-height: 40px; font-size: 17px; justify-content: center; }
|
||||||
|
body.field-mode .quickadd input { min-height: 44px; font-size: 15px; }
|
||||||
|
body.field-mode select,
|
||||||
|
body.field-mode input[type="text"],
|
||||||
|
body.field-mode input[type="number"],
|
||||||
|
body.field-mode input[type="date"] { min-height: 44px; font-size: 15px; }
|
||||||
|
/* Tavle og planner i én kolonne for tommelvennlig scrolling */
|
||||||
|
body.field-mode .board { grid-template-columns: 1fr; }
|
||||||
|
body.field-mode .planner { grid-template-columns: 1fr; }
|
||||||
|
body.field-mode .card { padding: 14px; }
|
||||||
|
body.field-mode .card-title { font-size: 15px; }
|
||||||
|
body.field-mode .avail-row, body.field-mode .plan-item { padding: 12px; }
|
||||||
|
body.field-mode .avail-add { width: 40px; height: 40px; font-size: 22px; }
|
||||||
|
body.field-mode .row-actions { gap: 8px; }
|
||||||
|
/* Modaler fyller skjermen bedre på mobil */
|
||||||
|
body.field-mode .modal { max-width: 100%; }
|
||||||
|
|
||||||
@media (max-width: 1250px) { .board { grid-template-columns: repeat(2, 1fr); } }
|
@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: 1100px) { .chart-row { grid-template-columns: 1fr 1fr; } }
|
||||||
@media (max-width: 820px) { .planner { grid-template-columns: 1fr; } }
|
@media (max-width: 820px) { .planner { grid-template-columns: 1fr; } }
|
||||||
|
|
|
||||||
|
|
@ -93,15 +93,6 @@ 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() {
|
function onlineUsers() {
|
||||||
const set = new Set();
|
const set = new Set();
|
||||||
for (const meta of clients.values()) set.add(meta.user);
|
for (const meta of clients.values()) set.add(meta.user);
|
||||||
|
|
@ -171,6 +162,7 @@ function createServer({ dataDir = path.join(__dirname, 'data') } = {}) {
|
||||||
app.get('/api/state', (req, res) => {
|
app.get('/api/state', (req, res) => {
|
||||||
store.rememberUser(req.user);
|
store.rememberUser(req.user);
|
||||||
const today = todayStr();
|
const today = todayStr();
|
||||||
|
const plan = store.getPlan(req.user, today);
|
||||||
res.json({
|
res.json({
|
||||||
user: req.user,
|
user: req.user,
|
||||||
canSetName: COOKIE_AUTH_ENABLED,
|
canSetName: COOKIE_AUTH_ENABLED,
|
||||||
|
|
@ -178,20 +170,66 @@ function createServer({ dataDir = path.join(__dirname, 'data') } = {}) {
|
||||||
activity: store.activity,
|
activity: store.activity,
|
||||||
known_users: [...store.knownUsers].sort(),
|
known_users: [...store.knownUsers].sort(),
|
||||||
online: onlineUsers(),
|
online: onlineUsers(),
|
||||||
plan: { date: today, items: store.getPlan(req.user, today) },
|
categories: store.categories(),
|
||||||
|
plan: { date: today, user: req.user, name: plan.name, items: plan.items, shared: plan.shared },
|
||||||
|
planIndex: store.planIndex(req.user),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/plan', (req, res) => {
|
app.get('/api/plan', (req, res) => {
|
||||||
const date = validDate(req.query.date) ? req.query.date : todayStr();
|
const date = validDate(req.query.date) ? req.query.date : todayStr();
|
||||||
res.json({ date, items: store.getPlan(req.user, date) });
|
const owner = typeof req.query.user === 'string' && req.query.user ? sanitizeUser(req.query.user) : req.user;
|
||||||
|
const plan = store.getPlanFor(req.user, owner, date);
|
||||||
|
if (!plan) return res.status(403).json({ error: 'Planen er privat' });
|
||||||
|
res.json({ date, user: owner, name: plan.name, items: plan.items, shared: plan.shared, readonly: owner !== req.user });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Plan-endringer påvirker per-oppgave-badges hos andre. Delte planer
|
||||||
|
// broadcastes til alle; private sendes kun til eierens egne faner (andre får
|
||||||
|
// en tom melding så de fjerner planen fra sin badge-indeks).
|
||||||
|
function broadcastPlan(owner, date) {
|
||||||
|
const plan = store.getPlan(owner, date);
|
||||||
|
const ownerMsg = JSON.stringify({ type: 'plan', user: owner, date, name: plan.name, items: plan.items, shared: plan.shared });
|
||||||
|
const othersMsg = plan.shared
|
||||||
|
? ownerMsg
|
||||||
|
: JSON.stringify({ type: 'plan', user: owner, date, name: '', items: [], shared: false });
|
||||||
|
for (const [ws, meta] of clients) {
|
||||||
|
if (ws.readyState !== ws.OPEN) continue;
|
||||||
|
ws.send(meta.user === owner ? ownerMsg : othersMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
app.put('/api/plan', (req, res) => {
|
app.put('/api/plan', (req, res) => {
|
||||||
const date = validDate(req.body && req.body.date) ? req.body.date : todayStr();
|
const date = validDate(req.body && req.body.date) ? req.body.date : todayStr();
|
||||||
const items = store.setPlan(req.user, date, req.body && req.body.items);
|
const name = req.body && typeof req.body.name === 'string' ? req.body.name : undefined;
|
||||||
sendToUser(req.user, { type: 'plan', date, items });
|
const shared = req.body && typeof req.body.shared === 'boolean' ? req.body.shared : undefined;
|
||||||
res.json({ date, items });
|
const plan = store.setPlan(req.user, date, req.body && req.body.items, name, shared);
|
||||||
|
broadcastPlan(req.user, date);
|
||||||
|
res.json({ date, user: req.user, name: plan.name, items: plan.items, shared: plan.shared });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/plan/share', (req, res) => {
|
||||||
|
const date = validDate(req.body && req.body.date) ? req.body.date : todayStr();
|
||||||
|
const shared = !!(req.body && req.body.shared);
|
||||||
|
const plan = store.setPlanShared(req.user, date, shared);
|
||||||
|
broadcastPlan(req.user, date);
|
||||||
|
res.json({ date, user: req.user, name: plan.name, items: plan.items, shared: plan.shared });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/plan/move', (req, res) => {
|
||||||
|
const from = validDate(req.body && req.body.from) ? req.body.from : null;
|
||||||
|
const to = validDate(req.body && req.body.to) ? req.body.to : null;
|
||||||
|
if (!from || !to) return res.status(400).json({ error: 'Ugyldig dato (from/to må være YYYY-MM-DD)' });
|
||||||
|
const plan = store.movePlan(req.user, from, to);
|
||||||
|
broadcastPlan(req.user, from); // kildedatoen er nå tom
|
||||||
|
broadcastPlan(req.user, to); // måldatoen har planen
|
||||||
|
res.json({ date: to, user: req.user, name: plan.name, items: plan.items, shared: plan.shared });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/categories', (req, res) => {
|
||||||
|
const cat = store.addCategory({ name: req.body && req.body.name, color: req.body && req.body.color }, req.user);
|
||||||
|
broadcast({ type: 'categories', categories: store.categories(), by: req.user });
|
||||||
|
res.status(201).json({ category: cat });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/login', (req, res) => {
|
app.post('/api/login', (req, res) => {
|
||||||
|
|
|
||||||
|
|
@ -265,7 +265,94 @@ test('dagsplan: ugyldig dato faller tilbake til i dag', async () => {
|
||||||
assert.match(data.date, /^\d{4}-\d{2}-\d{2}$/);
|
assert.match(data.date, /^\d{4}-\d{2}-\d{2}$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('websocket: dagsplan-endring pushes kun til samme bruker', async () => {
|
test('dagsplan: navn lagres og hentes', async () => {
|
||||||
|
const a = (await (await req('POST', '/api/tasks', { title: 'Navngitt plan-task' })).json()).task;
|
||||||
|
let res = await req('PUT', '/api/plan', { date: '2026-06-20', items: [a.id], name: 'Vedlikeholdsvindu DC-OSL' });
|
||||||
|
assert.equal((await res.json()).name, 'Vedlikeholdsvindu DC-OSL');
|
||||||
|
res = await req('GET', '/api/plan?date=2026-06-20');
|
||||||
|
const data = await res.json();
|
||||||
|
assert.equal(data.name, 'Vedlikeholdsvindu DC-OSL');
|
||||||
|
assert.deepEqual(data.items, [a.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dagsplan: flytt slår sammen til måldato og tømmer kilden', async () => {
|
||||||
|
const a = (await (await req('POST', '/api/tasks', { title: 'Flytt A' })).json()).task;
|
||||||
|
const b = (await (await req('POST', '/api/tasks', { title: 'Flytt B' })).json()).task;
|
||||||
|
await req('PUT', '/api/plan', { date: '2026-07-01', items: [a.id], name: 'Mandagsplan' });
|
||||||
|
await req('PUT', '/api/plan', { date: '2026-07-02', items: [b.id] });
|
||||||
|
|
||||||
|
let res = await req('PUT', '/api/plan/move', { from: '2026-07-01', to: '2026-07-02' });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const moved = await res.json();
|
||||||
|
assert.deepEqual(moved.items, [b.id, a.id]); // måldatoens eksisterende først, så kildens
|
||||||
|
|
||||||
|
// Kilden er tom nå
|
||||||
|
res = await req('GET', '/api/plan?date=2026-07-01');
|
||||||
|
assert.deepEqual((await res.json()).items, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dagsplan: deling — privat plan er skjult for andre, delt er synlig', async () => {
|
||||||
|
const a = (await (await req('POST', '/api/tasks', { title: 'Delt plan-task' })).json()).task;
|
||||||
|
// jon lager en delt plan (default shared=true)
|
||||||
|
await req('PUT', '/api/plan', { date: '2026-07-10', items: [a.id] }, { 'X-Remote-User': 'jon' });
|
||||||
|
// kari kan se den
|
||||||
|
let res = await req('GET', '/api/plan?date=2026-07-10&user=jon', null, { 'X-Remote-User': 'kari' });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.deepEqual((await res.json()).items, [a.id]);
|
||||||
|
|
||||||
|
// jon gjør den privat
|
||||||
|
res = await req('PUT', '/api/plan/share', { date: '2026-07-10', shared: false }, { 'X-Remote-User': 'jon' });
|
||||||
|
assert.equal((await res.json()).shared, false);
|
||||||
|
// kari får 403
|
||||||
|
res = await req('GET', '/api/plan?date=2026-07-10&user=jon', null, { 'X-Remote-User': 'kari' });
|
||||||
|
assert.equal(res.status, 403);
|
||||||
|
// jon ser fortsatt sin egen
|
||||||
|
res = await req('GET', '/api/plan?date=2026-07-10', null, { 'X-Remote-User': 'jon' });
|
||||||
|
assert.deepEqual((await res.json()).items, [a.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dagsplan: planIndex viser delte planer + egne private', async () => {
|
||||||
|
const a = (await (await req('POST', '/api/tasks', { title: 'Index-task' })).json()).task;
|
||||||
|
// jon: privat plan på en egen dato
|
||||||
|
await req('PUT', '/api/plan', { date: '2026-07-15', items: [a.id] }, { 'X-Remote-User': 'jon' });
|
||||||
|
await req('PUT', '/api/plan/share', { date: '2026-07-15', shared: false }, { 'X-Remote-User': 'jon' });
|
||||||
|
|
||||||
|
// jon ser sin private i sin planIndex
|
||||||
|
let res = await req('GET', '/api/state', null, { 'X-Remote-User': 'jon' });
|
||||||
|
let idx = (await res.json()).planIndex;
|
||||||
|
assert.ok(idx[a.id] && idx[a.id].some(e => e.user === 'jon' && e.date === '2026-07-15'));
|
||||||
|
|
||||||
|
// kari ser den IKKE (privat, ikke hennes)
|
||||||
|
res = await req('GET', '/api/state', null, { 'X-Remote-User': 'kari' });
|
||||||
|
idx = (await res.json()).planIndex;
|
||||||
|
assert.ok(!idx[a.id] || !idx[a.id].some(e => e.user === 'jon' && e.date === '2026-07-15'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kategorier: legg til egendefinert, og bruk den på en oppgave', async () => {
|
||||||
|
let res = await req('POST', '/api/categories', { name: 'Trådløst' });
|
||||||
|
assert.equal(res.status, 201);
|
||||||
|
const { category } = await res.json();
|
||||||
|
assert.match(category.id, /^[A-Z0-9]+$/);
|
||||||
|
assert.equal(category.name, 'Trådløst');
|
||||||
|
|
||||||
|
// Kategorien er nå i /api/state
|
||||||
|
res = await req('GET', '/api/state');
|
||||||
|
const cats = (await res.json()).categories;
|
||||||
|
assert.ok(cats.some(c => c.id === category.id && c.custom));
|
||||||
|
|
||||||
|
// Den kan brukes på en oppgave (godtas av validering)
|
||||||
|
res = await req('POST', '/api/tasks', { title: 'Wifi-oppgave', category: category.id });
|
||||||
|
assert.equal((await res.json()).task.category, category.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kategorier: tomt navn avvises, ukjent kategori-id på oppgave faller til default', async () => {
|
||||||
|
let res = await req('POST', '/api/categories', { name: ' ' });
|
||||||
|
assert.equal(res.status, 400);
|
||||||
|
res = await req('POST', '/api/tasks', { title: 'Ukjent kat', category: 'FINNESIKKE' });
|
||||||
|
assert.equal((await res.json()).task.category, 'L3');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('websocket: delt plan broadcastes til alle, privat kun til eier', async () => {
|
||||||
const wsUrl = baseUrl.replace('http', 'ws') + '/ws';
|
const wsUrl = baseUrl.replace('http', 'ws') + '/ws';
|
||||||
const mk = user => new Promise((resolve, reject) => {
|
const mk = user => new Promise((resolve, reject) => {
|
||||||
const c = new WebSocket(wsUrl, { headers: { 'X-Remote-User': user } });
|
const c = new WebSocket(wsUrl, { headers: { 'X-Remote-User': user } });
|
||||||
|
|
@ -278,12 +365,21 @@ test('websocket: dagsplan-endring pushes kun til samme bruker', async () => {
|
||||||
const kari = await mk('kari');
|
const kari = await mk('kari');
|
||||||
await new Promise(r => setTimeout(r, 150));
|
await new Promise(r => setTimeout(r, 150));
|
||||||
|
|
||||||
const a = (await (await req('POST', '/api/tasks', { title: 'WS-plan' })).json()).task;
|
const a = (await (await req('POST', '/api/tasks', { title: 'WS-delt-plan' })).json()).task;
|
||||||
await req('PUT', '/api/plan', { date: '2026-06-19', items: [a.id] });
|
// Delt plan (default) → begge får den
|
||||||
|
await req('PUT', '/api/plan', { date: '2026-08-01', items: [a.id] });
|
||||||
await new Promise(r => setTimeout(r, 150));
|
await new Promise(r => setTimeout(r, 150));
|
||||||
|
assert.ok(jon.msgs.some(m => m.type === 'plan' && m.date === '2026-08-01' && m.items.includes(a.id)), 'jon fikk delt plan');
|
||||||
|
assert.ok(kari.msgs.some(m => m.type === 'plan' && m.date === '2026-08-01' && m.items.includes(a.id)), 'kari fikk delt plan');
|
||||||
|
|
||||||
assert.ok(jon.msgs.some(m => m.type === 'plan' && m.items.includes(a.id)), 'jon fikk plan-push');
|
// Gjør privat → kari får tom melding (fjerner badge), jon får ekte
|
||||||
assert.ok(!kari.msgs.some(m => m.type === 'plan'), 'kari skal ikke få plan-push');
|
jon.msgs.length = 0; kari.msgs.length = 0;
|
||||||
|
await req('PUT', '/api/plan/share', { date: '2026-08-01', shared: false });
|
||||||
|
await new Promise(r => setTimeout(r, 150));
|
||||||
|
const jonMsg = jon.msgs.find(m => m.type === 'plan' && m.date === '2026-08-01');
|
||||||
|
const kariMsg = kari.msgs.find(m => m.type === 'plan' && m.date === '2026-08-01');
|
||||||
|
assert.ok(jonMsg && jonMsg.items.includes(a.id), 'jon ser fortsatt sin private plan');
|
||||||
|
assert.ok(kariMsg && kariMsg.items.length === 0, 'kari får tom plan-melding for privat');
|
||||||
|
|
||||||
jon.close(); kari.close();
|
jon.close(); kari.close();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue