Legg til dagsplan + kommentar ved statusbytte
Dagsplan: tredje visning ved siden av tavle/tabell. To-kolonne planlegger der man plukker oppgaver fra backlog og bygger en ordnet plan for dagen (drag-sortering, kapasitet i timer, datovelger). Per bruker, per dato, lagret i plans.json; synkes mellom egne faner via plan-WS-melding. Kommentar ved statusbytte: når en oppgave markeres ferdig/blokkert (knapp, kanban-drag eller modal) tilbys en valgfri kommentar som festes til oppgaven (kind:status) og legges i aktivitetsloggen — atomisk i ett update-kall. 20 integrasjonstester (8 nye), alle grønne. README + CLAUDE.md oppdatert. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
6b9dbf8254
commit
be166034de
8 changed files with 686 additions and 32 deletions
|
|
@ -16,6 +16,10 @@ 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
|
||||||
|
ved å plukke fra backlog. Drag-sortering, kapasitet i timer, synk mellom egne faner.
|
||||||
|
- **Kommentar ved statusbytte** — valgfri `comment` på `PUT /api/tasks/:id`
|
||||||
|
festes som kommentar med `kind:'status'` og legges i aktivitets-detaljen.
|
||||||
- **Kommentarer** per oppgave
|
- **Kommentarer** per oppgave
|
||||||
- **Aktivitetslogg** (hvem gjorde hva, server-side, capped til 400 entries)
|
- **Aktivitetslogg** (hvem gjorde hva, server-side, capped til 400 entries)
|
||||||
- **Optimistisk låsing** — `v`-felt per oppgave, 409 ved konflikt
|
- **Optimistisk låsing** — `v`-felt per oppgave, 409 ved konflikt
|
||||||
|
|
@ -34,8 +38,10 @@ netops-todo-node/
|
||||||
│ ├── app.js All frontend-logikk (vanilla JS, ingen rammeverk).
|
│ ├── app.js All frontend-logikk (vanilla JS, ingen rammeverk).
|
||||||
│ └── style.css Mørkt tema, samme visuelle identitet som PHP-versjonen.
|
│ └── style.css Mørkt tema, samme visuelle identitet som PHP-versjonen.
|
||||||
├── seed.json 16 eksempel-oppgaver.
|
├── seed.json 16 eksempel-oppgaver.
|
||||||
|
├── setup-debian13.sh Engangs-oppsett på Debian 13 LXC (apt + npm ci + systemd).
|
||||||
├── test/api.test.js Integrasjonstester (node:test) — REST + WS ende-til-ende.
|
├── test/api.test.js Integrasjonstester (node:test) — REST + WS ende-til-ende.
|
||||||
├── data/ Opprettes ved kjøring: tasks.json, activity.json, users.json.
|
├── data/ Opprettes ved kjøring: tasks.json, activity.json,
|
||||||
|
│ users.json, plans.json.
|
||||||
└── README.md Installasjon, proxy-oppsett, API-referanse.
|
└── README.md Installasjon, proxy-oppsett, API-referanse.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -58,6 +64,7 @@ netops-todo-node/
|
||||||
| `task` | `op: create/update/delete`, `task`/`id`, `by`, `activity` | Etter hver mutasjon |
|
| `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`) |
|
||||||
|
|
||||||
Klient → server: kun `{ type: 'editing', id, active }`.
|
Klient → server: kun `{ type: 'editing', id, active }`.
|
||||||
|
|
||||||
|
|
@ -68,16 +75,33 @@ Som PHP-versjonen, pluss:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"v": 3,
|
"v": 3,
|
||||||
"comments": [{ "id": "c_…", "by": "jon", "at": "ISO", "text": "≤1000" }]
|
"comments": [{ "id": "c_…", "by": "jon", "at": "ISO", "text": "≤1000",
|
||||||
|
"kind": "status", "status": "done" }]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`v` bumpes ved hver felt-oppdatering (ikke ved kommentarer — bevisst, så en
|
`v` bumpes ved hver felt-oppdatering (også når en `comment` følger med et
|
||||||
kommentar ikke gir falsk konflikt for noen som redigerer felter samtidig).
|
statusbytte via `update()`, siden det ER en oppdatering — men IKKE via det
|
||||||
|
separate `POST .../comments`-endepunktet, så en ren kommentar ikke gir falsk
|
||||||
|
konflikt for noen som redigerer felter samtidig). `kind`/`status` settes kun på
|
||||||
|
kommentarer som kommer fra et statusbytte; vanlige kommentarer mangler dem.
|
||||||
Klienten sender `v` den redigerte fra; mismatch → 409 med serverens task i
|
Klienten sender `v` den redigerte fra; mismatch → 409 med serverens task i
|
||||||
svaret. Frontend håndterer det i `handleSaveError()`: oppdaterer visningen,
|
svaret. Frontend håndterer det i `handleSaveError()`: oppdaterer visningen,
|
||||||
viser varsel, og lar brukeren lagre på nytt (som da overskriver bevisst).
|
viser varsel, og lar brukeren lagre på nytt (som da overskriver bevisst).
|
||||||
|
|
||||||
|
### Dagsplan-skjema (`plans.json`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "jon": { "2026-06-15": ["t_abc", "t_def"] } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Per bruker, per dato, ordnet liste av task-ids. `getPlan()` filtrerer bort
|
||||||
|
slettede oppgaver ved lesing; `setPlan()` deduperer og validerer mot
|
||||||
|
eksisterende oppgaver; `_scrubFromPlans()` rydder ved sletting; `_prunePlans()`
|
||||||
|
dropper datoer eldre enn 30 dager ved oppstart. Planen er bevisst privat (kun
|
||||||
|
`owner`-attribusjon på selve oppgavene er delt), men synkes mellom samme
|
||||||
|
brukers faner via `plan`-WS-meldingen.
|
||||||
|
|
||||||
## Designvalg verdt å vite
|
## Designvalg verdt å vite
|
||||||
|
|
||||||
- **Ingen database, ingen frontend-rammeverk, to npm-avhengigheter**
|
- **Ingen database, ingen frontend-rammeverk, to npm-avhengigheter**
|
||||||
|
|
@ -87,6 +111,14 @@ viser varsel, og lar brukeren lagre på nytt (som da overskriver bevisst).
|
||||||
UI-et fungerer selv om WS er nede (da poller frontend hvert 30s).
|
UI-et fungerer selv om WS er nede (da poller frontend hvert 30s).
|
||||||
- **Delvis update er lov:** `PUT /api/tasks/:id` med bare `{v, status}` —
|
- **Delvis update er lov:** `PUT /api/tasks/:id` med bare `{v, status}` —
|
||||||
normalisering merger mot eksisterende. Quick-actions bruker dette.
|
normalisering merger mot eksisterende. Quick-actions bruker dette.
|
||||||
|
- **Statuskommentar går gjennom samme update-kall** (transient `comment`-felt),
|
||||||
|
ikke et eget endepunkt — så statusbytte + kommentar + aktivitetslinje blir
|
||||||
|
atomisk og ett enkelt WS-broadcast. `changeStatusWithComment()` i app.js viser
|
||||||
|
dialogen for ferdig/blokkert; tavle-drag og tabell-✓ ruter gjennom den.
|
||||||
|
- **Dagsplanen er optimistisk i frontend:** `addToPlan`/`removeFromPlan`/drag
|
||||||
|
muterer `state.plan.items` og kaller `savePlan()` (PUT) som reconciler mot
|
||||||
|
serverens validerte svar. Container-nivå drag-lyttere bindes én gang
|
||||||
|
(`bindPlanContainer`); per-element bindes ved hver render (`bindPlanDnD`).
|
||||||
- **Kommentar-sletting er begrenset til egen bruker** (eneste
|
- **Kommentar-sletting er begrenset til egen bruker** (eneste
|
||||||
autorisasjonsregel i appen — alt annet er åpent for alle innloggede).
|
autorisasjonsregel i appen — alt annet er åpent for alle innloggede).
|
||||||
- Chart.js fra CDN; frontend degraderer pent (skjuler grafer) uten nett.
|
- Chart.js fra CDN; frontend degraderer pent (skjuler grafer) uten nett.
|
||||||
|
|
@ -117,11 +149,15 @@ strenglengde-klamping, brukernavn-regex `[A-Za-z0-9._@-]{1,64}`), pluss:
|
||||||
|
|
||||||
## Testing-status
|
## Testing-status
|
||||||
|
|
||||||
- `npm test`: 12 integrasjonstester (node:test), booter ekte server på
|
- `npm test`: 20 integrasjonstester (node:test), booter ekte server på
|
||||||
tilfeldig port med temp-datakatalog. Dekker normalisering, versjonskonflikt,
|
tilfeldig port med temp-datakatalog. Dekker normalisering, versjonskonflikt,
|
||||||
kommentar-autorisasjon, aktivitetslogg, WS-broadcast, persistens på disk,
|
kommentar-autorisasjon, statuskommentar-på-update, dagsplan (CRUD, per-bruker,
|
||||||
cookie-auth. Alle grønne per 2026-06-12 (Node 25).
|
validering, scrub ved sletting, dato-fallback), aktivitetslogg, WS-broadcast +
|
||||||
- Manuell røyk-test utført: `/healthz`, statiske filer, seed, state, og
|
per-bruker plan-push, persistens på disk, cookie-auth. Alle grønne per
|
||||||
WS-push av REST-endring verifisert ende-til-ende.
|
2026-06-15 (Node 25).
|
||||||
|
- Manuell røyk-test utført: `/healthz`, statiske filer, seed, state, plan
|
||||||
|
set/get med rekkefølge, ferdig-med-kommentar, og per-bruker-isolasjon av plan
|
||||||
|
verifisert ende-til-ende over HTTP.
|
||||||
- Ikke testet i nettleser med flere samtidige brukere — første ekte test bør
|
- Ikke testet i nettleser med flere samtidige brukere — første ekte test bør
|
||||||
være to nettleservinduer mot `npm start` og flytte kort på tavla.
|
være to nettleservinduer mot `npm start`: flytt kort på tavla, bygg en
|
||||||
|
dagsplan og dra for å sortere, og marker ferdig for å se kommentar-dialogen.
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,12 @@ WantedBy=multi-user.target
|
||||||
- **Sanntidssynk** — alle endringer pushes til alle åpne faner via WebSocket
|
- **Sanntidssynk** — alle endringer pushes til alle åpne faner via WebSocket
|
||||||
- **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
|
||||||
|
(dra for å sortere, kapasitet i timer, datovelger). Planen er personlig og
|
||||||
|
synkes mellom dine egne faner.
|
||||||
|
- **Kommentar ved statusbytte** — når du markerer ferdig eller blokkert (via
|
||||||
|
knapp, kanban-drag eller modal) tilbys du å skrive en kommentar som festes
|
||||||
|
til oppgaven og vises i aktivitetsloggen
|
||||||
- **Hurtig-registrering** med tokens: `Bytt SFP #l1 p2 @me !imorgen ~1h loc:DC-OSL-1`
|
- **Hurtig-registrering** med tokens: `Bytt SFP #l1 p2 @me !imorgen ~1h loc:DC-OSL-1`
|
||||||
- **Kommentarer** per oppgave
|
- **Kommentarer** per oppgave
|
||||||
- **Aktivitetslogg** — hvem gjorde hva, når (sidepanel)
|
- **Aktivitetslogg** — hvem gjorde hva, når (sidepanel)
|
||||||
|
|
@ -143,7 +149,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 · `a` aktivitet · `esc` lukk
|
- Tastatursnarveier: `n` ny · `/` søk · `v` bytt visning · `p` dagsplan · `a` aktivitet · `esc` lukk
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
|
|
@ -152,12 +158,14 @@ 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, hvem som er online |
|
| GET | `/api/state` | Bruker, oppgaver, aktivitet, kjente brukere, online, og dagens plan |
|
||||||
| 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) |
|
| 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) |
|
||||||
|
| PUT | `/api/plan` | Lagre egen dagsplan `{ date, items: [id…] }` |
|
||||||
| 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å) |
|
||||||
|
|
@ -175,5 +183,7 @@ 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). Én node-prosess per datakatalog. Holder fint til tusenvis av
|
prosessen): `tasks.json`, `activity.json`, `users.json` og `plans.json`
|
||||||
oppgaver; blir det trangt er SQLite (`node:sqlite`) neste naturlige steg.
|
(dagsplaner per bruker per dato; planer eldre enn 30 dager ryddes ved oppstart).
|
||||||
|
Én node-prosess per datakatalog. Holder fint til tusenvis av oppgaver; blir det
|
||||||
|
trangt er SQLite (`node:sqlite`) neste naturlige steg.
|
||||||
|
|
|
||||||
|
|
@ -146,15 +146,19 @@ function diffDetail(before, after) {
|
||||||
return detail;
|
return detail;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PLAN_RETENTION_DAYS = 30;
|
||||||
|
|
||||||
class Store {
|
class Store {
|
||||||
constructor(dataDir) {
|
constructor(dataDir) {
|
||||||
this.dataDir = dataDir;
|
this.dataDir = dataDir;
|
||||||
this.tasksFile = path.join(dataDir, 'tasks.json');
|
this.tasksFile = path.join(dataDir, 'tasks.json');
|
||||||
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.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
|
||||||
this._writeChain = Promise.resolve();
|
this._writeChain = Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -163,6 +167,8 @@ class Store {
|
||||||
this.tasks = this._readJson(this.tasksFile, []);
|
this.tasks = this._readJson(this.tasksFile, []);
|
||||||
this.activity = this._readJson(this.activityFile, []);
|
this.activity = this._readJson(this.activityFile, []);
|
||||||
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._prunePlans();
|
||||||
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]);
|
||||||
|
|
@ -172,10 +178,28 @@ class Store {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dropp dagsplaner eldre enn PLAN_RETENTION_DAYS så plans.json ikke vokser i det uendelige.
|
||||||
|
_prunePlans() {
|
||||||
|
const cutoff = new Date(Date.now() - PLAN_RETENTION_DAYS * 86400000).toISOString().slice(0, 10);
|
||||||
|
for (const user of Object.keys(this.plans)) {
|
||||||
|
const byDate = this.plans[user];
|
||||||
|
if (!byDate || typeof byDate !== 'object') { delete this.plans[user]; continue; }
|
||||||
|
for (const date of Object.keys(byDate)) {
|
||||||
|
if (date < cutoff || !Array.isArray(byDate[date])) delete byDate[date];
|
||||||
|
}
|
||||||
|
if (Object.keys(byDate).length === 0) delete this.plans[user];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leser JSON og validerer mot fallback-typen (array vs. objekt).
|
||||||
_readJson(file, fallback) {
|
_readJson(file, fallback) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
return Array.isArray(data) ? data : fallback;
|
if (Array.isArray(fallback)) return Array.isArray(data) ? data : fallback;
|
||||||
|
if (fallback && typeof fallback === 'object') {
|
||||||
|
return data && typeof data === 'object' && !Array.isArray(data) ? data : fallback;
|
||||||
|
}
|
||||||
|
return data ?? fallback;
|
||||||
} catch {
|
} catch {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
@ -195,6 +219,7 @@ 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); }
|
||||||
|
|
||||||
rememberUser(user) {
|
rememberUser(user) {
|
||||||
if (user && !this.knownUsers.has(user)) {
|
if (user && !this.knownUsers.has(user)) {
|
||||||
|
|
@ -238,7 +263,7 @@ class Store {
|
||||||
return { task, activity };
|
return { task, activity };
|
||||||
}
|
}
|
||||||
|
|
||||||
update(id, input, user) {
|
update(id, input, user, opts = {}) {
|
||||||
const existing = this.get(id);
|
const existing = this.get(id);
|
||||||
if (!existing) throw new NotFoundError('Fant ikke oppgaven');
|
if (!existing) throw new NotFoundError('Fant ikke oppgaven');
|
||||||
// Optimistisk låsing: klienten sender versjonen den redigerte fra.
|
// Optimistisk låsing: klienten sender versjonen den redigerte fra.
|
||||||
|
|
@ -250,13 +275,27 @@ class Store {
|
||||||
this.tasks[idx] = updated;
|
this.tasks[idx] = updated;
|
||||||
this.rememberUser(user);
|
this.rememberUser(user);
|
||||||
if (updated.owner) this.rememberUser(updated.owner);
|
if (updated.owner) this.rememberUser(updated.owner);
|
||||||
|
|
||||||
|
// Valgfri kommentar knyttet til endringen (typisk ved statusbytte: hva ble
|
||||||
|
// gjort / hva blokkerer). Lagres som vanlig kommentar, men merket kind:status.
|
||||||
|
const noteText = sanitizeStr(opts.comment, 1000);
|
||||||
|
if (noteText) {
|
||||||
|
const comment = {
|
||||||
|
id: uid('c'), by: user, at: new Date().toISOString(),
|
||||||
|
text: noteText, kind: 'status', status: updated.status,
|
||||||
|
};
|
||||||
|
updated.comments.push(comment);
|
||||||
|
if (updated.comments.length > MAX_COMMENTS) updated.comments.shift();
|
||||||
|
}
|
||||||
this._persistTasks();
|
this._persistTasks();
|
||||||
|
|
||||||
let action = 'update';
|
let action = 'update';
|
||||||
if (existing.status !== 'done' && updated.status === 'done') action = 'done';
|
if (existing.status !== 'done' && updated.status === 'done') action = 'done';
|
||||||
else if (existing.status === 'done' && updated.status !== 'done') action = 'reopen';
|
else if (existing.status === 'done' && updated.status !== 'done') action = 'reopen';
|
||||||
|
const detail = diffDetail(existing, updated);
|
||||||
|
if (noteText) detail.push('💬 ' + noteText.slice(0, 80));
|
||||||
const activity = this.addActivity(user, action, {
|
const activity = this.addActivity(user, action, {
|
||||||
taskId: id, title: updated.title, detail: diffDetail(existing, updated),
|
taskId: id, title: updated.title, detail,
|
||||||
});
|
});
|
||||||
return { task: updated, activity };
|
return { task: updated, activity };
|
||||||
}
|
}
|
||||||
|
|
@ -266,10 +305,52 @@ class Store {
|
||||||
if (!existing) throw new NotFoundError('Fant ikke oppgaven');
|
if (!existing) throw new NotFoundError('Fant ikke oppgaven');
|
||||||
this.tasks = this.tasks.filter(t => t.id !== id);
|
this.tasks = this.tasks.filter(t => t.id !== id);
|
||||||
this._persistTasks();
|
this._persistTasks();
|
||||||
|
this._scrubFromPlans(id);
|
||||||
const activity = this.addActivity(user, 'delete', { taskId: id, title: existing.title });
|
const activity = this.addActivity(user, 'delete', { taskId: id, title: existing.title });
|
||||||
return { task: existing, activity };
|
return { task: existing, activity };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Dagsplan (per bruker, per dato) ----------
|
||||||
|
getPlan(user, date) {
|
||||||
|
const ids = (this.plans[user] && this.plans[user][date]) || [];
|
||||||
|
// Filtrer bort oppgaver som er slettet siden planen ble lagret.
|
||||||
|
return ids.filter(id => this.get(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
setPlan(user, date, ids) {
|
||||||
|
const valid = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const raw of Array.isArray(ids) ? ids : []) {
|
||||||
|
if (typeof raw !== 'string') continue;
|
||||||
|
const id = raw.replace(/[^a-z0-9_]/gi, '').slice(0, 32);
|
||||||
|
if (!id || seen.has(id) || !this.get(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
valid.push(id);
|
||||||
|
if (valid.length >= 200) break;
|
||||||
|
}
|
||||||
|
if (!this.plans[user]) this.plans[user] = {};
|
||||||
|
if (valid.length) this.plans[user][date] = valid;
|
||||||
|
else delete this.plans[user][date];
|
||||||
|
if (Object.keys(this.plans[user]).length === 0) delete this.plans[user];
|
||||||
|
this._persistPlans();
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
_scrubFromPlans(taskId) {
|
||||||
|
let changed = false;
|
||||||
|
for (const user of Object.keys(this.plans)) {
|
||||||
|
const byDate = this.plans[user];
|
||||||
|
for (const date of Object.keys(byDate)) {
|
||||||
|
const before = byDate[date].length;
|
||||||
|
byDate[date] = byDate[date].filter(x => x !== taskId);
|
||||||
|
if (byDate[date].length !== before) changed = true;
|
||||||
|
if (byDate[date].length === 0) delete byDate[date];
|
||||||
|
}
|
||||||
|
if (Object.keys(byDate).length === 0) delete this.plans[user];
|
||||||
|
}
|
||||||
|
if (changed) this._persistPlans();
|
||||||
|
}
|
||||||
|
|
||||||
addComment(id, text, user) {
|
addComment(id, text, user) {
|
||||||
const task = this.get(id);
|
const task = this.get(id);
|
||||||
if (!task) throw new NotFoundError('Fant ikke oppgaven');
|
if (!task) throw new NotFoundError('Fant ikke oppgaven');
|
||||||
|
|
|
||||||
|
|
@ -50,11 +50,14 @@ const state = {
|
||||||
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' },
|
||||||
view: 'board',
|
view: 'board', // 'board' | 'table' | 'plan'
|
||||||
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
|
||||||
|
plan: { date: '', items: [] }, // dagsplan for valgt dato (ordnet liste av task-ids)
|
||||||
wsOk: false,
|
wsOk: false,
|
||||||
};
|
};
|
||||||
|
const VIEWS = ['board', 'table', 'plan'];
|
||||||
let charts = {};
|
let charts = {};
|
||||||
let ws = null;
|
let ws = null;
|
||||||
let wsRetry = 0;
|
let wsRetry = 0;
|
||||||
|
|
@ -69,7 +72,7 @@ function loadPrefs() {
|
||||||
const p = JSON.parse(localStorage.getItem('netops.prefs') || '{}');
|
const p = JSON.parse(localStorage.getItem('netops.prefs') || '{}');
|
||||||
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 (p.view === 'table' || p.view === 'board') state.view = p.view;
|
if (VIEWS.includes(p.view)) state.view = p.view;
|
||||||
} catch { /* ignorer korrupt localStorage */ }
|
} catch { /* ignorer korrupt localStorage */ }
|
||||||
}
|
}
|
||||||
function savePrefs() {
|
function savePrefs() {
|
||||||
|
|
@ -229,11 +232,21 @@ function handleWSMessage(msg) {
|
||||||
updateModalWarning();
|
updateModalWarning();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'plan':
|
||||||
|
// Dagsplanen synkes mellom samme brukers faner. Bruk bare hvis den
|
||||||
|
// gjelder datoen vi ser på nå.
|
||||||
|
if (msg.date === state.plan.date) {
|
||||||
|
state.plan.items = msg.items || [];
|
||||||
|
if (state.view === 'plan') renderPlan();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case 'task': {
|
case 'task': {
|
||||||
const mine = msg.by === state.user;
|
const mine = msg.by === state.user;
|
||||||
pushActivity(msg.activity);
|
pushActivity(msg.activity);
|
||||||
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);
|
||||||
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 });
|
||||||
|
|
@ -263,6 +276,8 @@ 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 (!state.plan.date) state.plan = { date: todayISO(), items: [] };
|
||||||
renderAll();
|
renderAll();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast('Kunne ikke laste: ' + e.message, { kind: 'err' });
|
toast('Kunne ikke laste: ' + e.message, { kind: 'err' });
|
||||||
|
|
@ -505,10 +520,15 @@ function renderCharts() {
|
||||||
function renderView() {
|
function renderView() {
|
||||||
$('view-board').className = state.view === 'board' ? 'active-view' : '';
|
$('view-board').className = state.view === 'board' ? 'active-view' : '';
|
||||||
$('view-table').className = state.view === 'table' ? 'active-view' : '';
|
$('view-table').className = state.view === 'table' ? 'active-view' : '';
|
||||||
$('fg-status').style.display = state.view === 'board' ? 'none' : '';
|
$('view-plan').className = state.view === 'plan' ? 'active-view' : '';
|
||||||
|
// Statusfilteret gir bare mening i tabellvisningen (tavle/plan grupperer selv).
|
||||||
|
$('fg-status').style.display = state.view === 'table' ? '' : 'none';
|
||||||
$('board').style.display = state.view === 'board' ? '' : 'none';
|
$('board').style.display = state.view === 'board' ? '' : 'none';
|
||||||
$('table-panel').style.display = state.view === 'table' ? '' : 'none';
|
$('table-panel').style.display = state.view === 'table' ? '' : 'none';
|
||||||
if (state.view === 'board') renderBoard(); else renderTable();
|
$('planner').style.display = state.view === 'plan' ? '' : 'none';
|
||||||
|
if (state.view === 'board') renderBoard();
|
||||||
|
else if (state.view === 'table') renderTable();
|
||||||
|
else renderPlan();
|
||||||
flashIds.clear();
|
flashIds.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -613,6 +633,12 @@ function bindBoardDnD() {
|
||||||
async function moveTask(id, status) {
|
async function moveTask(id, status) {
|
||||||
const t = findTask(id);
|
const t = findTask(id);
|
||||||
if (!t || t.status === status) return;
|
if (!t || t.status === status) return;
|
||||||
|
// Drag til Ferdig/Blokkert → tilby kommentar (ingen optimistisk flytting,
|
||||||
|
// siden brukeren kan avbryte i dialogen).
|
||||||
|
if (status === 'done' || status === 'blocked') {
|
||||||
|
changeStatusWithComment(id, status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const prev = t.status;
|
const prev = t.status;
|
||||||
t.status = status; // optimistisk — broadcast/svar korrigerer
|
t.status = status; // optimistisk — broadcast/svar korrigerer
|
||||||
renderAll();
|
renderAll();
|
||||||
|
|
@ -673,6 +699,183 @@ function renderTable() {
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Dagsplan ----------
|
||||||
|
// Oppgavene i planen, i lagret rekkefølge, med slettede filtrert bort.
|
||||||
|
function planTasks() {
|
||||||
|
return state.plan.items.map(findTask).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlan() {
|
||||||
|
$('empty').style.display = 'none';
|
||||||
|
|
||||||
|
const dateInput = $('plan-date');
|
||||||
|
if (document.activeElement !== dateInput) dateInput.value = state.plan.date;
|
||||||
|
|
||||||
|
const planned = planTasks();
|
||||||
|
const planIds = new Set(planned.map(t => t.id));
|
||||||
|
|
||||||
|
// Venstre: åpne oppgaver som ikke alt er i planen (respekterer kat/prio/eier/søk).
|
||||||
|
const avail = sortTasks(
|
||||||
|
applyFilters(state.tasks, { ignoreStatus: true })
|
||||||
|
.filter(t => t.status !== 'done' && !planIds.has(t.id))
|
||||||
|
);
|
||||||
|
$('plan-avail-count').textContent = avail.length ? `(${avail.length})` : '';
|
||||||
|
$('plan-available-body').innerHTML = avail.length
|
||||||
|
? avail.map(renderAvailRow).join('')
|
||||||
|
: '<div class="plan-empty">Ingen åpne oppgaver igjen — alt er planlagt eller filtrert bort.</div>';
|
||||||
|
|
||||||
|
// Høyre: kapasitet + ordnet liste.
|
||||||
|
const remaining = planned.filter(t => t.status !== 'done');
|
||||||
|
const hours = remaining.reduce((s, t) => s + (t.estHours || 0), 0);
|
||||||
|
const doneCount = planned.length - remaining.length;
|
||||||
|
const cap = $('plan-capacity');
|
||||||
|
cap.innerHTML = planned.length
|
||||||
|
? `${planned.length} oppg. · <b>${fmtHours(hours)}</b> planlagt${doneCount ? ` · ${doneCount} ferdig` : ''}`
|
||||||
|
: '';
|
||||||
|
cap.className = 'col-meta' + (hours > 8 ? ' over-capacity' : '');
|
||||||
|
|
||||||
|
$('plan-today-body').innerHTML = planned.length
|
||||||
|
? planned.map(renderPlanItem).join('')
|
||||||
|
: '<div class="plan-empty">Tomt. Legg til oppgaver fra venstre med +, eller dra dem hit. Dra for å sortere.</div>';
|
||||||
|
|
||||||
|
bindPlanDnD();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAvailRow(t) {
|
||||||
|
const dl = formatDeadline(t.deadline);
|
||||||
|
return `
|
||||||
|
<div class="avail-row" draggable="true" data-id="${t.id}">
|
||||||
|
<div class="avail-main">
|
||||||
|
<div class="avail-title" onclick="openEdit('${t.id}')">${escapeHtml(t.title)}</div>
|
||||||
|
<div class="avail-meta">
|
||||||
|
<span class="badge prio-${t.priority}">${t.priority}</span>
|
||||||
|
${catBadge(t)}
|
||||||
|
${t.estHours ? `<span>~${t.estHours}t</span>` : ''}
|
||||||
|
${t.deadline ? `<span class="time-cell ${dl.cls}">${dl.text}</span>` : ''}
|
||||||
|
${t.owner ? ownerBadge(t) : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="avail-add" onclick="addToPlan('${t.id}')" title="Legg til i dagsplan">+</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlanItem(t, i) {
|
||||||
|
const dl = formatDeadline(t.deadline);
|
||||||
|
const doneCls = t.status === 'done' ? ' done-item' : '';
|
||||||
|
return `
|
||||||
|
<div class="plan-item${doneCls}" draggable="true" data-id="${t.id}">
|
||||||
|
<span class="plan-handle" title="Dra for å sortere">⠿</span>
|
||||||
|
<span class="plan-num">${i + 1}</span>
|
||||||
|
<div class="plan-item-main">
|
||||||
|
<div class="plan-item-title" onclick="openEdit('${t.id}')">${escapeHtml(t.title)}</div>
|
||||||
|
<div class="plan-item-meta">
|
||||||
|
<span class="badge prio-${t.priority}">${t.priority}</span>
|
||||||
|
${catBadge(t)}
|
||||||
|
<span class="status-badge status-${t.status}">${STATUS_LABELS[t.status] || t.status}</span>
|
||||||
|
${t.estHours ? `<span>~${t.estHours}t</span>` : ''}
|
||||||
|
${t.deadline ? `<span class="time-cell ${dl.cls}">${dl.text}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="plan-item-actions">
|
||||||
|
${t.status !== 'done'
|
||||||
|
? `<button class="icon-btn do" onclick="markDone('${t.id}')" title="Marker ferdig">✓</button>`
|
||||||
|
: `<button class="icon-btn" onclick="reopenTask('${t.id}')" title="Gjenåpne">↺</button>`}
|
||||||
|
<button class="icon-btn del" onclick="removeFromPlan('${t.id}')" title="Fjern fra dagsplan">×</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDragAfterElement(container, y) {
|
||||||
|
const els = [...container.querySelectorAll('.plan-item:not(.dragging)')];
|
||||||
|
let closest = { offset: -Infinity, el: null };
|
||||||
|
for (const child of els) {
|
||||||
|
const box = child.getBoundingClientRect();
|
||||||
|
const offset = y - box.top - box.height / 2;
|
||||||
|
if (offset < 0 && offset > closest.offset) closest = { offset, el: child };
|
||||||
|
}
|
||||||
|
return closest.el;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-element drag-bindinger. Containeren beholder sine lyttere (satt én gang
|
||||||
|
// i bindUI), så her bindes bare de nygenererte radene/kortene.
|
||||||
|
function bindPlanDnD() {
|
||||||
|
const container = $('plan-today-body');
|
||||||
|
container.querySelectorAll('.plan-item').forEach(item => {
|
||||||
|
item.addEventListener('dragstart', e => {
|
||||||
|
item.classList.add('dragging');
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/plain', item.dataset.id);
|
||||||
|
});
|
||||||
|
item.addEventListener('dragend', () => {
|
||||||
|
item.classList.remove('dragging');
|
||||||
|
const ids = [...container.querySelectorAll('.plan-item')].map(el => el.dataset.id);
|
||||||
|
if (ids.join() !== state.plan.items.join()) {
|
||||||
|
state.plan.items = ids;
|
||||||
|
savePlan();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$('plan-available-body').querySelectorAll('.avail-row').forEach(row => {
|
||||||
|
row.addEventListener('dragstart', e => {
|
||||||
|
row.classList.add('dragging');
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/plain', row.dataset.id);
|
||||||
|
});
|
||||||
|
row.addEventListener('dragend', () => row.classList.remove('dragging'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Container-nivå drag-lyttere (settes én gang).
|
||||||
|
function bindPlanContainer() {
|
||||||
|
const container = $('plan-today-body');
|
||||||
|
container.addEventListener('dragover', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const dragging = container.querySelector('.plan-item.dragging');
|
||||||
|
if (!dragging) return; // drar inn et tilgjengelig-kort: håndteres i drop
|
||||||
|
const after = getDragAfterElement(container, e.clientY);
|
||||||
|
if (after == null) container.appendChild(dragging);
|
||||||
|
else container.insertBefore(dragging, after);
|
||||||
|
});
|
||||||
|
container.addEventListener('drop', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const id = e.dataTransfer.getData('text/plain');
|
||||||
|
if (id && !state.plan.items.includes(id) && findTask(id)) addToPlan(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePlan() {
|
||||||
|
renderPlan(); // optimistisk
|
||||||
|
try {
|
||||||
|
const { date, items } = await api('PUT', '/api/plan', { date: state.plan.date, items: state.plan.items });
|
||||||
|
if (date === state.plan.date) { state.plan.items = items; renderPlan(); }
|
||||||
|
} catch (e) {
|
||||||
|
toast('Kunne ikke lagre dagsplan: ' + e.message, { kind: 'err' });
|
||||||
|
refreshPlan();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addToPlan = function (id) {
|
||||||
|
if (!state.plan.items.includes(id) && findTask(id)) {
|
||||||
|
state.plan.items.push(id);
|
||||||
|
savePlan();
|
||||||
|
toast('Lagt i dagsplan');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.removeFromPlan = function (id) {
|
||||||
|
state.plan.items = state.plan.items.filter(x => x !== id);
|
||||||
|
savePlan();
|
||||||
|
};
|
||||||
|
|
||||||
|
async function refreshPlan(date = state.plan.date) {
|
||||||
|
try {
|
||||||
|
const data = await api('GET', `/api/plan?date=${encodeURIComponent(date)}`);
|
||||||
|
state.plan = { date: data.date, items: data.items || [] };
|
||||||
|
if (state.view === 'plan') renderPlan();
|
||||||
|
} catch { /* stille — neste handling prøver igjen */ }
|
||||||
|
}
|
||||||
|
|
||||||
function renderActivity() {
|
function renderActivity() {
|
||||||
const body = $('drawer-body');
|
const body = $('drawer-body');
|
||||||
if (!state.activity.length) {
|
if (!state.activity.length) {
|
||||||
|
|
@ -716,17 +919,57 @@ function updateOwnerSelects() {
|
||||||
+ sorted.map(u => `<option value="${escapeHtml(u)}">${escapeHtml(u)}</option>`).join('');
|
+ sorted.map(u => `<option value="${escapeHtml(u)}">${escapeHtml(u)}</option>`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Statuskommentar-dialog ----------
|
||||||
|
// Lover en streng (kommentar, evt. tom = hopp over) eller null (avbrutt).
|
||||||
|
let statusResolve = null;
|
||||||
|
function askStatusComment(task, status) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
statusResolve = resolve;
|
||||||
|
const label = status === 'done' ? 'Avslutningskommentar (valgfritt)'
|
||||||
|
: status === 'blocked' ? 'Hva blokkerer? (valgfritt)'
|
||||||
|
: 'Kommentar (valgfritt)';
|
||||||
|
$('status-modal-title').textContent = `${STATUS_LABELS[status]}: ${task.title.slice(0, 44)}${task.title.length > 44 ? '…' : ''}`;
|
||||||
|
$('status-modal-label').textContent = label;
|
||||||
|
$('status-comment').value = '';
|
||||||
|
$('status-modal').classList.add('open');
|
||||||
|
setTimeout(() => $('status-comment').focus(), 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function resolveStatus(val) {
|
||||||
|
if (!statusResolve) return;
|
||||||
|
$('status-modal').classList.remove('open');
|
||||||
|
const r = statusResolve;
|
||||||
|
statusResolve = null;
|
||||||
|
r(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statusbytte som tilbyr en kommentar ved fullføring/blokkering.
|
||||||
|
async function changeStatusWithComment(id, status) {
|
||||||
|
const t = findTask(id);
|
||||||
|
if (!t || t.status === status) return;
|
||||||
|
let comment = '';
|
||||||
|
if (status === 'done' || status === 'blocked') {
|
||||||
|
const res = await askStatusComment(t, status);
|
||||||
|
if (res === null) return; // avbrutt — ingen endring
|
||||||
|
comment = res;
|
||||||
|
}
|
||||||
|
await quickStatus(id, status, comment);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Hurtighandlinger (rad/kort) ----------
|
// ---------- Hurtighandlinger (rad/kort) ----------
|
||||||
async function quickStatus(id, status) {
|
async function quickStatus(id, status, comment = '') {
|
||||||
const t = findTask(id);
|
const t = findTask(id);
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
try {
|
try {
|
||||||
const { task } = await api('PUT', `/api/tasks/${id}`, { ...t, status, v: t.v });
|
const body = { ...t, status, v: t.v };
|
||||||
|
if (comment) body.comment = comment;
|
||||||
|
const { task } = await api('PUT', `/api/tasks/${id}`, body);
|
||||||
upsertTask(task);
|
upsertTask(task);
|
||||||
renderAll();
|
renderAll();
|
||||||
|
if (comment) toast('Lagret med kommentar');
|
||||||
} catch (e) { handleSaveError(e, id); }
|
} catch (e) { handleSaveError(e, id); }
|
||||||
}
|
}
|
||||||
window.markDone = id => quickStatus(id, 'done');
|
window.markDone = id => changeStatusWithComment(id, 'done');
|
||||||
window.reopenTask = id => quickStatus(id, 'todo');
|
window.reopenTask = id => quickStatus(id, 'todo');
|
||||||
|
|
||||||
window.claimTask = async function (id) {
|
window.claimTask = async function (id) {
|
||||||
|
|
@ -785,6 +1028,7 @@ window.openEdit = function (id) {
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
state.editingId = id;
|
state.editingId = id;
|
||||||
state.editingBaseV = t.v;
|
state.editingBaseV = t.v;
|
||||||
|
state.editingStatus = t.status;
|
||||||
$('modal-title').textContent = 'Rediger oppgave';
|
$('modal-title').textContent = 'Rediger oppgave';
|
||||||
$('f-title').value = t.title;
|
$('f-title').value = t.title;
|
||||||
$('f-desc').value = t.desc || '';
|
$('f-desc').value = t.desc || '';
|
||||||
|
|
@ -800,15 +1044,33 @@ window.openEdit = function (id) {
|
||||||
$('modal-delete').style.display = 'inline-flex';
|
$('modal-delete').style.display = 'inline-flex';
|
||||||
$('comments-section').style.display = '';
|
$('comments-section').style.display = '';
|
||||||
$('modal-audit').textContent = `opprettet ${relTime(t.created)} av ${t.createdBy} · sist endret ${relTime(t.updated)} av ${t.updatedBy}`;
|
$('modal-audit').textContent = `opprettet ${relTime(t.created)} av ${t.createdBy} · sist endret ${relTime(t.updated)} av ${t.updatedBy}`;
|
||||||
|
updateStatusNoteField();
|
||||||
renderComments(t);
|
renderComments(t);
|
||||||
updateModalWarning();
|
updateModalWarning();
|
||||||
$('modal').classList.add('open');
|
$('modal').classList.add('open');
|
||||||
sendWS({ type: 'editing', id, active: true });
|
sendWS({ type: 'editing', id, active: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Viser et kommentarfelt i modalen når status endres til Ferdig/Blokkert.
|
||||||
|
function updateStatusNoteField() {
|
||||||
|
const field = $('f-statusnote-field');
|
||||||
|
if (!state.editingId) { field.style.display = 'none'; $('f-statusnote').value = ''; return; }
|
||||||
|
const val = $('f-stat').value;
|
||||||
|
if (val !== state.editingStatus && (val === 'done' || val === 'blocked')) {
|
||||||
|
$('f-statusnote-label').textContent = val === 'done' ? 'Avslutningskommentar (valgfritt)' : 'Hva blokkerer? (valgfritt)';
|
||||||
|
field.style.display = '';
|
||||||
|
} else {
|
||||||
|
field.style.display = 'none';
|
||||||
|
$('f-statusnote').value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openNew(prefill = {}) {
|
function openNew(prefill = {}) {
|
||||||
state.editingId = null;
|
state.editingId = null;
|
||||||
state.editingBaseV = null;
|
state.editingBaseV = null;
|
||||||
|
state.editingStatus = null;
|
||||||
|
$('f-statusnote-field').style.display = 'none';
|
||||||
|
$('f-statusnote').value = '';
|
||||||
$('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 = '';
|
||||||
|
|
@ -870,6 +1132,9 @@ async function saveForm() {
|
||||||
let task;
|
let task;
|
||||||
if (state.editingId) {
|
if (state.editingId) {
|
||||||
data.v = state.editingBaseV;
|
data.v = state.editingBaseV;
|
||||||
|
// Statuskommentar (kun synlig når status endres til ferdig/blokkert)
|
||||||
|
const note = $('f-statusnote-field').style.display !== 'none' ? $('f-statusnote').value.trim() : '';
|
||||||
|
if (note) data.comment = note;
|
||||||
({ task } = await api('PUT', `/api/tasks/${state.editingId}`, data));
|
({ task } = await api('PUT', `/api/tasks/${state.editingId}`, data));
|
||||||
toast('Lagret');
|
toast('Lagret');
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -897,15 +1162,22 @@ function renderComments(t) {
|
||||||
list.innerHTML = '<div class="no-comments">Ingen kommentarer ennå.</div>';
|
list.innerHTML = '<div class="no-comments">Ingen kommentarer ennå.</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
list.innerHTML = t.comments.map(c => `
|
list.innerHTML = t.comments.map(c => {
|
||||||
<div class="comment">
|
const isStatus = c.kind === 'status' && c.status;
|
||||||
|
const kindTag = isStatus
|
||||||
|
? `<span class="comment-kind status-${c.status}">${STATUS_LABELS[c.status] || c.status}</span>`
|
||||||
|
: '';
|
||||||
|
return `
|
||||||
|
<div class="comment${isStatus ? ' comment-status' : ''}">
|
||||||
<div class="comment-head">
|
<div class="comment-head">
|
||||||
<span class="comment-by ${c.by === state.user ? 'me' : ''}">${escapeHtml(c.by)}</span>
|
<span class="comment-by ${c.by === state.user ? 'me' : ''}">${escapeHtml(c.by)}</span>
|
||||||
|
${kindTag}
|
||||||
<span class="comment-at">${relTime(c.at)}</span>
|
<span class="comment-at">${relTime(c.at)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-text">${escapeHtml(c.text)}</div>
|
<div class="comment-text">${escapeHtml(c.text)}</div>
|
||||||
${c.by === state.user ? `<button class="icon-btn del comment-del" onclick="deleteComment('${t.id}','${c.id}')" title="Slett kommentar">×</button>` : ''}
|
${c.by === state.user ? `<button class="icon-btn del comment-del" onclick="deleteComment('${t.id}','${c.id}')" title="Slett kommentar">×</button>` : ''}
|
||||||
</div>`).join('');
|
</div>`;
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendComment() {
|
async function sendComment() {
|
||||||
|
|
@ -973,6 +1245,12 @@ function bindUI() {
|
||||||
// Visning
|
// Visning
|
||||||
$('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(); });
|
||||||
|
$('plan-date').addEventListener('change', e => {
|
||||||
|
const d = e.target.value || todayISO();
|
||||||
|
refreshPlan(d);
|
||||||
|
});
|
||||||
|
bindPlanContainer();
|
||||||
|
|
||||||
// Filtre
|
// Filtre
|
||||||
const f = state.filters;
|
const f = state.filters;
|
||||||
|
|
@ -1007,6 +1285,16 @@ function bindUI() {
|
||||||
});
|
});
|
||||||
$('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);
|
||||||
|
|
||||||
|
// Statuskommentar-dialog
|
||||||
|
$('status-save').addEventListener('click', () => resolveStatus($('status-comment').value.trim()));
|
||||||
|
$('status-skip').addEventListener('click', () => resolveStatus(''));
|
||||||
|
$('status-modal-close').addEventListener('click', () => resolveStatus(null));
|
||||||
|
$('status-modal').addEventListener('click', e => { if (e.target.id === 'status-modal') resolveStatus(null); });
|
||||||
|
$('status-comment').addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); resolveStatus($('status-comment').value.trim()); }
|
||||||
|
});
|
||||||
|
|
||||||
// Aktivitets-skuff
|
// Aktivitets-skuff
|
||||||
$('activityBtn').addEventListener('click', () => $('drawer').classList.toggle('open'));
|
$('activityBtn').addEventListener('click', () => $('drawer').classList.toggle('open'));
|
||||||
|
|
@ -1041,14 +1329,18 @@ function bindUI() {
|
||||||
document.addEventListener('keydown', e => {
|
document.addEventListener('keydown', e => {
|
||||||
const tag = e.target.tagName;
|
const tag = e.target.tagName;
|
||||||
const typing = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
|
const typing = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
|
||||||
if (e.key === 'Escape') { closeModal(); $('drawer').classList.remove('open'); return; }
|
if (e.key === 'Escape') {
|
||||||
|
if ($('status-modal').classList.contains('open')) { resolveStatus(null); return; }
|
||||||
|
closeModal(); $('drawer').classList.remove('open'); return;
|
||||||
|
}
|
||||||
if (typing) return;
|
if (typing) return;
|
||||||
if (e.key === 'n') { e.preventDefault(); openNew(); }
|
if (e.key === 'n') { e.preventDefault(); openNew(); }
|
||||||
if (e.key === '/') { e.preventDefault(); $('f-search').focus(); }
|
if (e.key === '/') { e.preventDefault(); $('f-search').focus(); }
|
||||||
if (e.key === 'v') {
|
if (e.key === 'v') {
|
||||||
state.view = state.view === 'board' ? 'table' : 'board';
|
state.view = VIEWS[(VIEWS.indexOf(state.view) + 1) % VIEWS.length];
|
||||||
savePrefs(); renderView();
|
savePrefs(); renderView();
|
||||||
}
|
}
|
||||||
|
if (e.key === 'p') { state.view = 'plan'; savePrefs(); renderView(); }
|
||||||
if (e.key === 'a') $('drawer').classList.toggle('open');
|
if (e.key === 'a') $('drawer').classList.toggle('open');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,7 @@
|
||||||
<div class="view-toggle">
|
<div class="view-toggle">
|
||||||
<button id="view-board" title="Tavle (v)">▦ Tavle</button>
|
<button id="view-board" title="Tavle (v)">▦ Tavle</button>
|
||||||
<button id="view-table" title="Tabell (v)">☰ Tabell</button>
|
<button id="view-table" title="Tabell (v)">☰ Tabell</button>
|
||||||
|
<button id="view-plan" title="Dagsplan (p)">📅 Dagsplan</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-group" id="fg-status">
|
<div class="filter-group" id="fg-status">
|
||||||
<label>Status</label>
|
<label>Status</label>
|
||||||
|
|
@ -133,6 +134,26 @@
|
||||||
|
|
||||||
<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">
|
||||||
|
<div class="plan-col">
|
||||||
|
<div class="plan-col-head">
|
||||||
|
<h3>Tilgjengelige oppgaver <span class="col-meta" id="plan-avail-count"></span></h3>
|
||||||
|
<span class="plan-col-hint">klikk + for å planlegge</span>
|
||||||
|
</div>
|
||||||
|
<div class="plan-col-body" id="plan-available-body"></div>
|
||||||
|
</div>
|
||||||
|
<div class="plan-col plan-today">
|
||||||
|
<div class="plan-col-head">
|
||||||
|
<div class="plan-date-row">
|
||||||
|
<h3>Dagsplan</h3>
|
||||||
|
<input type="date" id="plan-date" title="Velg dag å planlegge">
|
||||||
|
</div>
|
||||||
|
<span class="col-meta" id="plan-capacity"></span>
|
||||||
|
</div>
|
||||||
|
<div class="plan-col-body" id="plan-today-body"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="table-panel" id="table-panel" style="display:none">
|
<div class="table-panel" id="table-panel" style="display:none">
|
||||||
<div class="table-head">
|
<div class="table-head">
|
||||||
<h3>Oppgaveliste <span class="count" id="tbl-count">0</span></h3>
|
<h3>Oppgaveliste <span class="count" id="tbl-count">0</span></h3>
|
||||||
|
|
@ -169,7 +190,8 @@
|
||||||
Sanntidssynk via WebSocket · snarveier:
|
Sanntidssynk via WebSocket · snarveier:
|
||||||
<span class="kbd">n</span> ny ·
|
<span class="kbd">n</span> ny ·
|
||||||
<span class="kbd">/</span> søk ·
|
<span class="kbd">/</span> søk ·
|
||||||
<span class="kbd">v</span> tavle/tabell ·
|
<span class="kbd">v</span> bytt visning ·
|
||||||
|
<span class="kbd">p</span> dagsplan ·
|
||||||
<span class="kbd">a</span> aktivitet ·
|
<span class="kbd">a</span> aktivitet ·
|
||||||
<span class="kbd">esc</span> lukk
|
<span class="kbd">esc</span> lukk
|
||||||
</footer>
|
</footer>
|
||||||
|
|
@ -246,6 +268,10 @@
|
||||||
<label>Tags (komma-separert)</label>
|
<label>Tags (komma-separert)</label>
|
||||||
<input type="text" id="f-tags" placeholder="vedlikeholdsvindu, kunde-X, audit">
|
<input type="text" id="f-tags" placeholder="vedlikeholdsvindu, kunde-X, audit">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-field full status-note-field" id="f-statusnote-field" style="display:none">
|
||||||
|
<label id="f-statusnote-label">Avslutningskommentar (valgfritt)</label>
|
||||||
|
<input type="text" id="f-statusnote" placeholder="Legges som kommentar når du lagrer">
|
||||||
|
</div>
|
||||||
<div class="comments-section" id="comments-section" style="display:none">
|
<div class="comments-section" id="comments-section" style="display:none">
|
||||||
<h4>Kommentarer <span id="comment-count" style="font-family:var(--mono)"></span></h4>
|
<h4>Kommentarer <span id="comment-count" style="font-family:var(--mono)"></span></h4>
|
||||||
<div id="comments-list"></div>
|
<div id="comments-list"></div>
|
||||||
|
|
@ -264,6 +290,26 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="status-modal">
|
||||||
|
<div class="modal" style="max-width:460px">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h2 id="status-modal-title">Kommentar</h2>
|
||||||
|
<button class="icon-btn" id="status-modal-close" style="font-size:18px">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="grid-template-columns:1fr">
|
||||||
|
<div class="form-field full">
|
||||||
|
<label id="status-modal-label">Legg til en kommentar (valgfritt)</label>
|
||||||
|
<textarea id="status-comment" placeholder="F.eks. hva som ble gjort, eller hva som blokkerer …"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<span style="flex:1;font-size:11px;color:var(--text-faint)">⏎ for å lagre · Esc for å avbryte</span>
|
||||||
|
<button id="status-skip">Hopp over</button>
|
||||||
|
<button class="primary" id="status-save">Lagre</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="toast-stack" id="toast-stack"></div>
|
<div class="toast-stack" id="toast-stack"></div>
|
||||||
|
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -315,6 +315,66 @@ tbody tr.status-done .task-title { text-decoration: line-through; }
|
||||||
.comment-input-row input { flex: 1; }
|
.comment-input-row input { flex: 1; }
|
||||||
.no-comments { color: var(--text-faint); font-size: 12px; font-style: italic; }
|
.no-comments { color: var(--text-faint); font-size: 12px; font-style: italic; }
|
||||||
|
|
||||||
|
/* ---------- Status-kommentar i tråd ---------- */
|
||||||
|
.comment.comment-status { border-left: 3px solid var(--accent); }
|
||||||
|
.comment-kind { font-family: var(--mono); font-size: 10px; font-weight: 600; padding: 1px 6px;
|
||||||
|
border-radius: 4px; text-transform: uppercase; letter-spacing: 0.4px; }
|
||||||
|
.comment-kind.status-done { background: rgba(74,222,128,0.12); color: var(--ok); }
|
||||||
|
.comment-kind.status-blocked { background: rgba(255,83,112,0.15); color: var(--err); }
|
||||||
|
.comment-kind.status-progress { background: rgba(255,159,67,0.15); color: var(--warn); }
|
||||||
|
.comment-kind.status-todo { background: rgba(127,184,255,0.12); color: #7fb8ff; }
|
||||||
|
.status-note-field input { border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* ---------- Dagsplan ---------- */
|
||||||
|
.planner { display: grid; grid-template-columns: 1fr 1fr; gap: var(--gap); align-items: start; }
|
||||||
|
.plan-col { background: var(--bg-soft); border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
display: flex; flex-direction: column; min-height: 300px; }
|
||||||
|
.plan-col.plan-today { border-color: var(--border-strong); }
|
||||||
|
.plan-col-head { display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 14px 16px 10px; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.plan-col-head h3 { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px;
|
||||||
|
display: flex; align-items: center; gap: 8px; }
|
||||||
|
.plan-col-head .col-meta { font-family: var(--mono); font-size: 12px; color: var(--text-dim); }
|
||||||
|
.plan-col-head .col-meta.over-capacity { color: var(--warn); font-weight: 600; }
|
||||||
|
.plan-col-head .col-meta b { color: var(--text); }
|
||||||
|
.plan-col-hint { font-size: 11px; color: var(--text-faint); font-family: var(--mono); }
|
||||||
|
.plan-date-row { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.plan-date-row input { padding: 5px 8px; font-size: 12px; }
|
||||||
|
.plan-col-body { display: flex; flex-direction: column; gap: 8px; padding: 4px 12px 14px; flex: 1; }
|
||||||
|
.plan-empty { color: var(--text-faint); font-size: 13px; font-style: italic; padding: 20px 4px; text-align: center; }
|
||||||
|
|
||||||
|
/* Tilgjengelig-rad (venstre kolonne) */
|
||||||
|
.avail-row { display: flex; align-items: center; gap: 10px; background: var(--card);
|
||||||
|
border: 1px solid var(--border); border-radius: 6px; padding: 8px 10px; cursor: grab; }
|
||||||
|
.avail-row:hover { border-color: var(--border-strong); }
|
||||||
|
.avail-row.dragging { opacity: 0.4; }
|
||||||
|
.avail-main { flex: 1; min-width: 0; }
|
||||||
|
.avail-title { font-size: 13px; font-weight: 500; cursor: pointer; }
|
||||||
|
.avail-title:hover { color: var(--accent); }
|
||||||
|
.avail-meta { display: flex; gap: 8px; align-items: center; margin-top: 3px;
|
||||||
|
font-size: 11px; font-family: var(--mono); color: var(--text-faint); flex-wrap: wrap; }
|
||||||
|
.avail-add { flex-shrink: 0; background: var(--accent-soft); color: var(--accent);
|
||||||
|
border: 1px solid rgba(79,195,247,0.3); border-radius: 5px; width: 30px; height: 30px;
|
||||||
|
font-size: 18px; line-height: 1; padding: 0; justify-content: center; }
|
||||||
|
.avail-add:hover { background: var(--accent); color: #06151f; }
|
||||||
|
|
||||||
|
/* Plan-item (høyre kolonne) */
|
||||||
|
.plan-item { display: flex; align-items: center; gap: 10px; background: var(--card);
|
||||||
|
border: 1px solid var(--border); border-radius: 6px; padding: 8px 10px; cursor: grab;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s; }
|
||||||
|
.plan-item:hover { border-color: var(--border-strong); }
|
||||||
|
.plan-item.dragging { opacity: 0.4; box-shadow: 0 6px 16px rgba(0,0,0,0.35); }
|
||||||
|
.plan-item.done-item { opacity: 0.6; }
|
||||||
|
.plan-item.done-item .plan-item-title { text-decoration: line-through; color: var(--text-faint); }
|
||||||
|
.plan-handle { color: var(--text-faint); font-size: 14px; cursor: grab; user-select: none; flex-shrink: 0; }
|
||||||
|
.plan-num { font-family: var(--mono); font-size: 12px; color: var(--text-faint); width: 18px; text-align: right; flex-shrink: 0; }
|
||||||
|
.plan-item-main { flex: 1; min-width: 0; }
|
||||||
|
.plan-item-title { font-size: 13px; font-weight: 500; cursor: pointer; }
|
||||||
|
.plan-item-title:hover { color: var(--accent); }
|
||||||
|
.plan-item-meta { display: flex; gap: 8px; align-items: center; margin-top: 3px;
|
||||||
|
font-size: 11px; font-family: var(--mono); color: var(--text-faint); flex-wrap: wrap; }
|
||||||
|
.plan-item-actions { display: flex; gap: 2px; flex-shrink: 0; }
|
||||||
|
|
||||||
/* ---------- Toast ---------- */
|
/* ---------- Toast ---------- */
|
||||||
.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; }
|
||||||
|
|
@ -345,6 +405,7 @@ footer .kbd { display: inline-block; padding: 0 5px; border: 1px solid var(--bor
|
||||||
|
|
||||||
@media (max-width: 1250px) { .board { grid-template-columns: repeat(2, 1fr); } }
|
@media (max-width: 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: 720px) {
|
@media (max-width: 720px) {
|
||||||
.chart-row { grid-template-columns: 1fr; }
|
.chart-row { grid-template-columns: 1fr; }
|
||||||
.board { grid-template-columns: 1fr; }
|
.board { grid-template-columns: 1fr; }
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,15 @@ function createServer({ dataDir = path.join(__dirname, 'data') } = {}) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send kun til samme brukers tilkoblinger (dagsplan er privat, men skal synke
|
||||||
|
// mellom f.eks. desktop og laptop hos samme bruker).
|
||||||
|
function sendToUser(user, obj) {
|
||||||
|
const msg = JSON.stringify(obj);
|
||||||
|
for (const [ws, meta] of clients) {
|
||||||
|
if (meta.user === user && ws.readyState === ws.OPEN) ws.send(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onlineUsers() {
|
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);
|
||||||
|
|
@ -154,10 +163,14 @@ function createServer({ dataDir = path.join(__dirname, 'data') } = {}) {
|
||||||
heartbeat.unref();
|
heartbeat.unref();
|
||||||
|
|
||||||
// ---------- REST API ----------
|
// ---------- REST API ----------
|
||||||
|
const todayStr = () => new Date().toISOString().slice(0, 10);
|
||||||
|
const validDate = d => typeof d === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(d);
|
||||||
|
|
||||||
app.get('/healthz', (req, res) => res.json({ ok: true, tasks: store.tasks.length }));
|
app.get('/healthz', (req, res) => res.json({ ok: true, tasks: store.tasks.length }));
|
||||||
|
|
||||||
app.get('/api/state', (req, res) => {
|
app.get('/api/state', (req, res) => {
|
||||||
store.rememberUser(req.user);
|
store.rememberUser(req.user);
|
||||||
|
const today = todayStr();
|
||||||
res.json({
|
res.json({
|
||||||
user: req.user,
|
user: req.user,
|
||||||
canSetName: COOKIE_AUTH_ENABLED,
|
canSetName: COOKIE_AUTH_ENABLED,
|
||||||
|
|
@ -165,9 +178,22 @@ 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) },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/plan', (req, res) => {
|
||||||
|
const date = validDate(req.query.date) ? req.query.date : todayStr();
|
||||||
|
res.json({ date, items: store.getPlan(req.user, date) });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/plan', (req, res) => {
|
||||||
|
const date = validDate(req.body && req.body.date) ? req.body.date : todayStr();
|
||||||
|
const items = store.setPlan(req.user, date, req.body && req.body.items);
|
||||||
|
sendToUser(req.user, { type: 'plan', date, items });
|
||||||
|
res.json({ date, items });
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/login', (req, res) => {
|
app.post('/api/login', (req, res) => {
|
||||||
if (!COOKIE_AUTH_ENABLED) return res.status(403).json({ error: 'Navnevalg er deaktivert — auth styres av proxy' });
|
if (!COOKIE_AUTH_ENABLED) return res.status(403).json({ error: 'Navnevalg er deaktivert — auth styres av proxy' });
|
||||||
const raw = req.body && req.body.name;
|
const raw = req.body && req.body.name;
|
||||||
|
|
@ -188,7 +214,10 @@ function createServer({ dataDir = path.join(__dirname, 'data') } = {}) {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/tasks/:id', (req, res) => {
|
app.put('/api/tasks/:id', (req, res) => {
|
||||||
const { task, activity } = store.update(req.params.id, req.body, req.user);
|
// Valgfri `comment` følger med ved statusbytte; normalizeTask ignorerer
|
||||||
|
// ukjente felter, så den blir ikke en del av selve oppgaven.
|
||||||
|
const comment = req.body && typeof req.body.comment === 'string' ? req.body.comment : '';
|
||||||
|
const { task, activity } = store.update(req.params.id, req.body, req.user, { comment });
|
||||||
broadcast({ type: 'task', op: 'update', task, by: req.user, activity });
|
broadcast({ type: 'task', op: 'update', task, by: req.user, activity });
|
||||||
res.json({ task });
|
res.json({ task });
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -189,6 +189,105 @@ test('replace erstatter alt og persisterer til disk', async () => {
|
||||||
assert.equal(onDisk[0].title, 'Eneste oppgave');
|
assert.equal(onDisk[0].title, 'Eneste oppgave');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('update med comment legger til status-kommentar og aktivitets-detalj', async () => {
|
||||||
|
let res = await req('POST', '/api/tasks', { title: 'Kommentar-ved-status' });
|
||||||
|
const { task } = await res.json();
|
||||||
|
|
||||||
|
res = await req('PUT', `/api/tasks/${task.id}`, { v: task.v, status: 'done', comment: 'Verifisert i lab, rullet ut' });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const { task: done } = await res.json();
|
||||||
|
assert.equal(done.status, 'done');
|
||||||
|
assert.equal(done.comments.length, 1);
|
||||||
|
assert.equal(done.comments[0].kind, 'status');
|
||||||
|
assert.equal(done.comments[0].status, 'done');
|
||||||
|
assert.equal(done.comments[0].text, 'Verifisert i lab, rullet ut');
|
||||||
|
|
||||||
|
// Aktivitetsloggen skal nevne kommentaren
|
||||||
|
res = await req('GET', '/api/state');
|
||||||
|
const { activity } = await res.json();
|
||||||
|
const entry = activity.find(a => a.taskId === task.id && a.action === 'done');
|
||||||
|
assert.ok(entry.detail.some(d => d.includes('Verifisert i lab')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update uten comment legger ikke til kommentar', async () => {
|
||||||
|
let res = await req('POST', '/api/tasks', { title: 'Ingen-kommentar' });
|
||||||
|
const { task } = await res.json();
|
||||||
|
res = await req('PUT', `/api/tasks/${task.id}`, { v: task.v, status: 'progress' });
|
||||||
|
const { task: upd } = await res.json();
|
||||||
|
assert.equal(upd.comments.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dagsplan: tom som standard, sett ordnet liste, hent tilbake', async () => {
|
||||||
|
// Lag to oppgaver å planlegge
|
||||||
|
const a = (await (await req('POST', '/api/tasks', { title: 'Plan A' })).json()).task;
|
||||||
|
const b = (await (await req('POST', '/api/tasks', { title: 'Plan B' })).json()).task;
|
||||||
|
|
||||||
|
let res = await req('GET', '/api/plan?date=2026-06-15');
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.deepEqual((await res.json()).items, []);
|
||||||
|
|
||||||
|
res = await req('PUT', '/api/plan', { date: '2026-06-15', items: [b.id, a.id] });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.deepEqual((await res.json()).items, [b.id, a.id]); // rekkefølge bevart
|
||||||
|
|
||||||
|
res = await req('GET', '/api/plan?date=2026-06-15');
|
||||||
|
assert.deepEqual((await res.json()).items, [b.id, a.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dagsplan: er per bruker', async () => {
|
||||||
|
const a = (await (await req('POST', '/api/tasks', { title: 'Privat plan' })).json()).task;
|
||||||
|
await req('PUT', '/api/plan', { date: '2026-06-16', items: [a.id] }, { 'X-Remote-User': 'jon' });
|
||||||
|
|
||||||
|
// Annen bruker ser sin egen (tomme) plan
|
||||||
|
const res = await req('GET', '/api/plan?date=2026-06-16', null, { 'X-Remote-User': 'kari' });
|
||||||
|
assert.deepEqual((await res.json()).items, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dagsplan: ugyldige/dupliserte/ukjente ider filtreres bort', async () => {
|
||||||
|
const a = (await (await req('POST', '/api/tasks', { title: 'Gyldig plan-task' })).json()).task;
|
||||||
|
const res = await req('PUT', '/api/plan', {
|
||||||
|
date: '2026-06-17', items: [a.id, a.id, 't_finnesikke', 12345, null],
|
||||||
|
});
|
||||||
|
assert.deepEqual((await res.json()).items, [a.id]); // dedupet + kun eksisterende
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dagsplan: sletting av oppgave fjerner den fra planen', async () => {
|
||||||
|
const a = (await (await req('POST', '/api/tasks', { title: 'Skal slettes fra plan' })).json()).task;
|
||||||
|
await req('PUT', '/api/plan', { date: '2026-06-18', items: [a.id] });
|
||||||
|
await req('DELETE', `/api/tasks/${a.id}`);
|
||||||
|
const res = await req('GET', '/api/plan?date=2026-06-18');
|
||||||
|
assert.deepEqual((await res.json()).items, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dagsplan: ugyldig dato faller tilbake til i dag', async () => {
|
||||||
|
const res = await req('GET', '/api/plan?date=tull');
|
||||||
|
const data = await res.json();
|
||||||
|
assert.match(data.date, /^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('websocket: dagsplan-endring pushes kun til samme bruker', async () => {
|
||||||
|
const wsUrl = baseUrl.replace('http', 'ws') + '/ws';
|
||||||
|
const mk = user => new Promise((resolve, reject) => {
|
||||||
|
const c = new WebSocket(wsUrl, { headers: { 'X-Remote-User': user } });
|
||||||
|
c.msgs = [];
|
||||||
|
c.on('message', raw => c.msgs.push(JSON.parse(raw)));
|
||||||
|
c.on('open', () => resolve(c));
|
||||||
|
c.on('error', reject);
|
||||||
|
});
|
||||||
|
const jon = await mk('testuser'); // samme bruker som req() bruker
|
||||||
|
const kari = await mk('kari');
|
||||||
|
await new Promise(r => setTimeout(r, 150));
|
||||||
|
|
||||||
|
const a = (await (await req('POST', '/api/tasks', { title: 'WS-plan' })).json()).task;
|
||||||
|
await req('PUT', '/api/plan', { date: '2026-06-19', items: [a.id] });
|
||||||
|
await new Promise(r => setTimeout(r, 150));
|
||||||
|
|
||||||
|
assert.ok(jon.msgs.some(m => m.type === 'plan' && m.items.includes(a.id)), 'jon fikk plan-push');
|
||||||
|
assert.ok(!kari.msgs.some(m => m.type === 'plan'), 'kari skal ikke få plan-push');
|
||||||
|
|
||||||
|
jon.close(); kari.close();
|
||||||
|
});
|
||||||
|
|
||||||
test('cookie-auth: login setter cookie, ugyldig navn avvises', async () => {
|
test('cookie-auth: login setter cookie, ugyldig navn avvises', async () => {
|
||||||
let res = await fetch(baseUrl + '/api/login', {
|
let res = await fetch(baseUrl + '/api/login', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue