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
|
|
@ -14,7 +14,27 @@ const crypto = require('crypto');
|
|||
|
||||
const VALID_STATUS = ['todo', 'progress', 'blocked', 'done'];
|
||||
const VALID_PRIORITIES = ['P1', 'P2', 'P3', 'P4'];
|
||||
const VALID_CATEGORIES = ['L1', 'L2', 'L3', 'L4', 'BGP', 'PEER', 'SEC', 'NMS', 'AUTO', 'DOC', 'INC', 'PROJ'];
|
||||
|
||||
// Innebygde kategorier (id, navn, farge, beskrivelse). Egendefinerte kategorier
|
||||
// lagres i data/categories.json og legges på toppen av disse ved kjøring.
|
||||
const DEFAULT_CATEGORIES = [
|
||||
{ id: 'L1', name: 'L1 Fysisk', color: '#ff9f43', desc: 'Kabling, fiber, patch, SFP, rack' },
|
||||
{ id: 'L2', name: 'L2 Switching', color: '#4fc3f7', desc: 'VLAN, STP, MAC, trunking' },
|
||||
{ id: 'L3', name: 'L3 Ruting', color: '#7fb8ff', desc: 'OSPF, ISIS, statisk, VRF' },
|
||||
{ id: 'L4', name: 'L4 Lastbalansering', color: '#bb86fc', desc: 'HAProxy, NGINX, F5, BIG-IP' },
|
||||
{ id: 'BGP', name: 'BGP', color: '#4ade80', desc: 'iBGP / eBGP, policies, RR' },
|
||||
{ id: 'PEER', name: 'Peering / IX', color: '#26de81', desc: 'IXP, private peering, PeeringDB' },
|
||||
{ id: 'SEC', name: 'Firewall / Security', color: '#ff5370', desc: 'ACL, firewall, IPS, audit' },
|
||||
{ id: 'NMS', name: 'Overvåking / NMS', color: '#ffd93d', desc: 'LibreNMS, Grafana, alerts' },
|
||||
{ id: 'AUTO', name: 'Automatisering', color: '#00d2d3', desc: 'Ansible, scripts, CI/CD' },
|
||||
{ id: 'DOC', name: 'Dokumentasjon', color: '#a0a0a0', desc: 'Diagrammer, IPAM, runbooks' },
|
||||
{ id: 'INC', name: 'Incident / On-call', color: '#ff6b6b', desc: 'Feilsøking, postmortem' },
|
||||
{ id: 'PROJ', name: 'Prosjekt / Plan', color: '#8172B3', desc: 'Design, anskaffelse, oppgraderinger' },
|
||||
];
|
||||
// Farger som tildeles egendefinerte kategorier automatisk når ingen er valgt.
|
||||
const CATEGORY_PALETTE = ['#5eead4', '#f0abfc', '#fca5a5', '#fcd34d', '#93c5fd', '#86efac', '#fdba74', '#c4b5fd'];
|
||||
|
||||
const VALID_CATEGORIES = DEFAULT_CATEGORIES.map(c => c.id);
|
||||
|
||||
const STATUS_LABELS = { todo: 'Å gjøre', progress: 'Pågår', blocked: 'Blokkert', done: 'Ferdig' };
|
||||
|
||||
|
|
@ -78,8 +98,9 @@ function normalizeTask(input, user, existing = null, opts = {}) {
|
|||
t.title = sanitizeStr(inp.title ?? ex.title ?? '', 200);
|
||||
t.desc = sanitizeStr(inp.desc ?? ex.desc ?? '', 4000);
|
||||
|
||||
const validCats = (opts.validCategories && opts.validCategories.length) ? opts.validCategories : VALID_CATEGORIES;
|
||||
const cat = inp.category ?? ex.category ?? 'L3';
|
||||
t.category = VALID_CATEGORIES.includes(cat) ? cat : 'L3';
|
||||
t.category = validCats.includes(cat) ? cat : 'L3';
|
||||
|
||||
const prio = inp.priority ?? ex.priority ?? 'P3';
|
||||
t.priority = VALID_PRIORITIES.includes(prio) ? prio : 'P3';
|
||||
|
|
@ -155,10 +176,13 @@ class Store {
|
|||
this.activityFile = path.join(dataDir, 'activity.json');
|
||||
this.usersFile = path.join(dataDir, 'users.json');
|
||||
this.plansFile = path.join(dataDir, 'plans.json');
|
||||
this.categoriesFile = path.join(dataDir, 'categories.json');
|
||||
this.tasks = [];
|
||||
this.activity = [];
|
||||
this.knownUsers = new Set();
|
||||
this.plans = {}; // { [user]: { [YYYY-MM-DD]: [taskId, ...] } } — per bruker, ordnet
|
||||
// { [user]: { [YYYY-MM-DD]: { name, items: [taskId,...] } } } — per bruker, ordnet
|
||||
this.plans = {};
|
||||
this.customCategories = []; // egendefinerte, på toppen av DEFAULT_CATEGORIES
|
||||
this._writeChain = Promise.resolve();
|
||||
}
|
||||
|
||||
|
|
@ -169,6 +193,7 @@ class Store {
|
|||
for (const u of this._readJson(this.usersFile, [])) this.knownUsers.add(u);
|
||||
this.plans = this._readJson(this.plansFile, {});
|
||||
this._prunePlans();
|
||||
this._loadCategories();
|
||||
for (const t of this.tasks) {
|
||||
for (const k of ['owner', 'createdBy', 'updatedBy', 'completedBy']) {
|
||||
if (t[k]) this.knownUsers.add(t[k]);
|
||||
|
|
@ -178,19 +203,40 @@ class Store {
|
|||
}
|
||||
}
|
||||
|
||||
// Dropp dagsplaner eldre enn PLAN_RETENTION_DAYS så plans.json ikke vokser i det uendelige.
|
||||
// Dropp dagsplaner eldre enn PLAN_RETENTION_DAYS, og migrer gammelt array-format
|
||||
// ({date: [ids]}) til nytt objektformat ({date: {name, items}}).
|
||||
_prunePlans() {
|
||||
const cutoff = new Date(Date.now() - PLAN_RETENTION_DAYS * 86400000).toISOString().slice(0, 10);
|
||||
for (const user of Object.keys(this.plans)) {
|
||||
const byDate = this.plans[user];
|
||||
if (!byDate || typeof byDate !== 'object') { delete this.plans[user]; continue; }
|
||||
for (const date of Object.keys(byDate)) {
|
||||
if (date < cutoff || !Array.isArray(byDate[date])) delete byDate[date];
|
||||
const val = byDate[date];
|
||||
const valid = Array.isArray(val) || (val && typeof val === 'object' && Array.isArray(val.items));
|
||||
if (date < cutoff || !valid) { delete byDate[date]; continue; }
|
||||
if (Array.isArray(val)) byDate[date] = { name: '', items: val }; // migrer
|
||||
}
|
||||
if (Object.keys(byDate).length === 0) delete this.plans[user];
|
||||
}
|
||||
}
|
||||
|
||||
_loadCategories() {
|
||||
const raw = this._readJson(this.categoriesFile, []);
|
||||
const builtinIds = new Set(DEFAULT_CATEGORIES.map(c => c.id));
|
||||
const seen = new Set(builtinIds);
|
||||
this.customCategories = [];
|
||||
for (const c of Array.isArray(raw) ? raw : []) {
|
||||
if (!c || typeof c !== 'object') continue;
|
||||
const id = typeof c.id === 'string' ? c.id.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 12) : '';
|
||||
const name = sanitizeStr(c.name, 40);
|
||||
if (!id || !name || seen.has(id)) continue;
|
||||
const color = typeof c.color === 'string' && /^#[0-9a-fA-F]{6}$/.test(c.color)
|
||||
? c.color : CATEGORY_PALETTE[this.customCategories.length % CATEGORY_PALETTE.length];
|
||||
seen.add(id);
|
||||
this.customCategories.push({ id, name, color, desc: sanitizeStr(c.desc, 120), custom: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Leser JSON og validerer mot fallback-typen (array vs. objekt).
|
||||
_readJson(file, fallback) {
|
||||
try {
|
||||
|
|
@ -217,9 +263,31 @@ class Store {
|
|||
return this._writeChain;
|
||||
}
|
||||
|
||||
_persistTasks() { return this._persist(this.tasksFile, this.tasks); }
|
||||
_persistActivity() { return this._persist(this.activityFile, this.activity); }
|
||||
_persistPlans() { return this._persist(this.plansFile, this.plans); }
|
||||
_persistTasks() { return this._persist(this.tasksFile, this.tasks); }
|
||||
_persistActivity() { return this._persist(this.activityFile, this.activity); }
|
||||
_persistPlans() { return this._persist(this.plansFile, this.plans); }
|
||||
_persistCategories() { return this._persist(this.categoriesFile, this.customCategories); }
|
||||
|
||||
// ---------- Kategorier (innebygde + egendefinerte) ----------
|
||||
categories() { return [...DEFAULT_CATEGORIES, ...this.customCategories]; }
|
||||
categoryIds() { return this.categories().map(c => c.id); }
|
||||
|
||||
addCategory({ name, color } = {}, user) {
|
||||
const cleanName = sanitizeStr(name, 40);
|
||||
if (!cleanName) throw new Error('Kategorinavn er påkrevd');
|
||||
// Generer en id fra navnet (store bokstaver/tall), gjør den unik.
|
||||
let base = cleanName.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 10) || 'CAT';
|
||||
let id = base, n = 2;
|
||||
const taken = new Set(this.categoryIds());
|
||||
while (taken.has(id)) id = base.slice(0, 9) + (n++);
|
||||
const col = typeof color === 'string' && /^#[0-9a-fA-F]{6}$/.test(color)
|
||||
? color : CATEGORY_PALETTE[this.customCategories.length % CATEGORY_PALETTE.length];
|
||||
const cat = { id, name: cleanName, color: col, desc: '', custom: true };
|
||||
this.customCategories.push(cat);
|
||||
this._persistCategories();
|
||||
if (user) this.addActivity(user, 'category', { title: cleanName });
|
||||
return cat;
|
||||
}
|
||||
|
||||
rememberUser(user) {
|
||||
if (user && !this.knownUsers.has(user)) {
|
||||
|
|
@ -252,7 +320,7 @@ class Store {
|
|||
|
||||
create(input, user) {
|
||||
// importComments: lar "angre sletting" og import gjenopprette kommentarer
|
||||
const task = normalizeTask(input, user, null, { importComments: true });
|
||||
const task = normalizeTask(input, user, null, { importComments: true, validCategories: this.categoryIds() });
|
||||
if (!task.title) throw new Error('title er påkrevd');
|
||||
if (this.get(task.id)) task.id = uid('t'); // unngå kollisjon ved gjenbrukt id (angre-slett)
|
||||
this.tasks.push(task);
|
||||
|
|
@ -270,7 +338,7 @@ class Store {
|
|||
if (input && input.v != null && Number(input.v) !== existing.v) {
|
||||
throw new ConflictError('Oppgaven er endret av noen andre', existing);
|
||||
}
|
||||
const updated = normalizeTask(input, user, existing);
|
||||
const updated = normalizeTask(input, user, existing, { validCategories: this.categoryIds() });
|
||||
const idx = this.tasks.indexOf(existing);
|
||||
this.tasks[idx] = updated;
|
||||
this.rememberUser(user);
|
||||
|
|
@ -311,13 +379,36 @@ class Store {
|
|||
}
|
||||
|
||||
// ---------- Dagsplan (per bruker, per dato) ----------
|
||||
getPlan(user, date) {
|
||||
const ids = (this.plans[user] && this.plans[user][date]) || [];
|
||||
// Filtrer bort oppgaver som er slettet siden planen ble lagret.
|
||||
return ids.filter(id => this.get(id));
|
||||
// Normaliser lagret entry til { name, items, shared } (tåler gammelt array-format).
|
||||
// shared default true: planer er som hovedregel synlige for alle.
|
||||
_planEntry(user, date) {
|
||||
const raw = this.plans[user] && this.plans[user][date];
|
||||
if (Array.isArray(raw)) return { name: '', items: raw, shared: true };
|
||||
if (raw && typeof raw === 'object') {
|
||||
return {
|
||||
name: typeof raw.name === 'string' ? raw.name : '',
|
||||
items: Array.isArray(raw.items) ? raw.items : [],
|
||||
shared: raw.shared !== false,
|
||||
};
|
||||
}
|
||||
return { name: '', items: [], shared: true };
|
||||
}
|
||||
|
||||
setPlan(user, date, ids) {
|
||||
getPlan(user, date) {
|
||||
const e = this._planEntry(user, date);
|
||||
// Filtrer bort oppgaver som er slettet siden planen ble lagret.
|
||||
return { name: e.name, items: e.items.filter(id => this.get(id)), shared: e.shared };
|
||||
}
|
||||
|
||||
// Hent en annens plan med tilgangssjekk: kun egen plan eller en som er delt.
|
||||
// Returnerer null hvis planen er privat og viewer ikke er eier.
|
||||
getPlanFor(viewer, owner, date) {
|
||||
if (owner !== viewer && !this._planEntry(owner, date).shared) return null;
|
||||
return this.getPlan(owner, date);
|
||||
}
|
||||
|
||||
// name/shared === undefined beholder eksisterende verdi.
|
||||
setPlan(user, date, ids, name, shared) {
|
||||
const valid = [];
|
||||
const seen = new Set();
|
||||
for (const raw of Array.isArray(ids) ? ids : []) {
|
||||
|
|
@ -328,12 +419,56 @@ class Store {
|
|||
valid.push(id);
|
||||
if (valid.length >= 200) break;
|
||||
}
|
||||
const cur = this._planEntry(user, date);
|
||||
const cleanName = name === undefined ? cur.name : sanitizeStr(name, 80);
|
||||
const isShared = shared === undefined ? cur.shared : shared !== false;
|
||||
if (!this.plans[user]) this.plans[user] = {};
|
||||
if (valid.length) this.plans[user][date] = valid;
|
||||
if (valid.length || cleanName) this.plans[user][date] = { name: cleanName, items: valid, shared: isShared };
|
||||
else delete this.plans[user][date];
|
||||
if (Object.keys(this.plans[user]).length === 0) delete this.plans[user];
|
||||
this._persistPlans();
|
||||
return valid;
|
||||
return { name: cleanName, items: valid, shared: isShared };
|
||||
}
|
||||
|
||||
setPlanName(user, date, name) {
|
||||
const e = this._planEntry(user, date);
|
||||
return this.setPlan(user, date, e.items, name, e.shared);
|
||||
}
|
||||
|
||||
setPlanShared(user, date, shared) {
|
||||
const e = this._planEntry(user, date);
|
||||
return this.setPlan(user, date, e.items, e.name, shared);
|
||||
}
|
||||
|
||||
// Flytt hele planen til en annen dato. Slår sammen med en evt. eksisterende
|
||||
// plan på måldatoen (uten å miste data), og tømmer kildedatoen.
|
||||
movePlan(user, fromDate, toDate) {
|
||||
if (fromDate === toDate) return this.getPlan(user, toDate);
|
||||
const src = this._planEntry(user, fromDate);
|
||||
const dst = this._planEntry(user, toDate);
|
||||
const merged = [...dst.items];
|
||||
for (const id of src.items) if (!merged.includes(id)) merged.push(id);
|
||||
const name = dst.name || src.name;
|
||||
const shared = this.plans[user] && this.plans[user][toDate] ? dst.shared : src.shared;
|
||||
if (this.plans[user]) delete this.plans[user][fromDate];
|
||||
return this.setPlan(user, toDate, merged, name, shared); // persisterer
|
||||
}
|
||||
|
||||
// Hvem har planlagt hvilke oppgaver på hvilke datoer — for badges på kort/rad.
|
||||
// Filtrert per viewer: delte planer + viewerens egne private.
|
||||
planIndex(viewer) {
|
||||
const idx = {};
|
||||
for (const user of Object.keys(this.plans)) {
|
||||
for (const date of Object.keys(this.plans[user])) {
|
||||
const e = this._planEntry(user, date);
|
||||
if (!e.shared && user !== viewer) continue;
|
||||
for (const id of e.items) {
|
||||
if (!this.get(id)) continue;
|
||||
(idx[id] ||= []).push({ user, date, name: e.name });
|
||||
}
|
||||
}
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
_scrubFromPlans(taskId) {
|
||||
|
|
@ -341,10 +476,12 @@ class Store {
|
|||
for (const user of Object.keys(this.plans)) {
|
||||
const byDate = this.plans[user];
|
||||
for (const date of Object.keys(byDate)) {
|
||||
const before = byDate[date].length;
|
||||
byDate[date] = byDate[date].filter(x => x !== taskId);
|
||||
if (byDate[date].length !== before) changed = true;
|
||||
if (byDate[date].length === 0) delete byDate[date];
|
||||
const e = this._planEntry(user, date);
|
||||
const filtered = e.items.filter(x => x !== taskId);
|
||||
if (filtered.length === e.items.length) continue;
|
||||
changed = true;
|
||||
if (filtered.length === 0 && !e.name) delete byDate[date];
|
||||
else byDate[date] = { name: e.name, items: filtered };
|
||||
}
|
||||
if (Object.keys(byDate).length === 0) delete this.plans[user];
|
||||
}
|
||||
|
|
@ -383,7 +520,7 @@ class Store {
|
|||
const out = [];
|
||||
for (const raw of arr) {
|
||||
if (!raw || typeof raw !== 'object' || !raw.title) continue;
|
||||
out.push(normalizeTask(raw, user, null, { importComments: true }));
|
||||
out.push(normalizeTask(raw, user, null, { importComments: true, validCategories: this.categoryIds() }));
|
||||
}
|
||||
this.tasks = out;
|
||||
this._persistTasks();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue