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
|
|
@ -146,15 +146,19 @@ function diffDetail(before, after) {
|
|||
return detail;
|
||||
}
|
||||
|
||||
const PLAN_RETENTION_DAYS = 30;
|
||||
|
||||
class Store {
|
||||
constructor(dataDir) {
|
||||
this.dataDir = dataDir;
|
||||
this.tasksFile = path.join(dataDir, 'tasks.json');
|
||||
this.activityFile = path.join(dataDir, 'activity.json');
|
||||
this.usersFile = path.join(dataDir, 'users.json');
|
||||
this.plansFile = path.join(dataDir, 'plans.json');
|
||||
this.tasks = [];
|
||||
this.activity = [];
|
||||
this.knownUsers = new Set();
|
||||
this.plans = {}; // { [user]: { [YYYY-MM-DD]: [taskId, ...] } } — per bruker, ordnet
|
||||
this._writeChain = Promise.resolve();
|
||||
}
|
||||
|
||||
|
|
@ -163,6 +167,8 @@ class Store {
|
|||
this.tasks = this._readJson(this.tasksFile, []);
|
||||
this.activity = this._readJson(this.activityFile, []);
|
||||
for (const u of this._readJson(this.usersFile, [])) this.knownUsers.add(u);
|
||||
this.plans = this._readJson(this.plansFile, {});
|
||||
this._prunePlans();
|
||||
for (const t of this.tasks) {
|
||||
for (const k of ['owner', 'createdBy', 'updatedBy', 'completedBy']) {
|
||||
if (t[k]) this.knownUsers.add(t[k]);
|
||||
|
|
@ -172,10 +178,28 @@ class Store {
|
|||
}
|
||||
}
|
||||
|
||||
// Dropp dagsplaner eldre enn PLAN_RETENTION_DAYS så plans.json ikke vokser i det uendelige.
|
||||
_prunePlans() {
|
||||
const cutoff = new Date(Date.now() - PLAN_RETENTION_DAYS * 86400000).toISOString().slice(0, 10);
|
||||
for (const user of Object.keys(this.plans)) {
|
||||
const byDate = this.plans[user];
|
||||
if (!byDate || typeof byDate !== 'object') { delete this.plans[user]; continue; }
|
||||
for (const date of Object.keys(byDate)) {
|
||||
if (date < cutoff || !Array.isArray(byDate[date])) delete byDate[date];
|
||||
}
|
||||
if (Object.keys(byDate).length === 0) delete this.plans[user];
|
||||
}
|
||||
}
|
||||
|
||||
// Leser JSON og validerer mot fallback-typen (array vs. objekt).
|
||||
_readJson(file, fallback) {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
return Array.isArray(data) ? data : fallback;
|
||||
if (Array.isArray(fallback)) return Array.isArray(data) ? data : fallback;
|
||||
if (fallback && typeof fallback === 'object') {
|
||||
return data && typeof data === 'object' && !Array.isArray(data) ? data : fallback;
|
||||
}
|
||||
return data ?? fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
|
|
@ -195,6 +219,7 @@ class Store {
|
|||
|
||||
_persistTasks() { return this._persist(this.tasksFile, this.tasks); }
|
||||
_persistActivity() { return this._persist(this.activityFile, this.activity); }
|
||||
_persistPlans() { return this._persist(this.plansFile, this.plans); }
|
||||
|
||||
rememberUser(user) {
|
||||
if (user && !this.knownUsers.has(user)) {
|
||||
|
|
@ -238,7 +263,7 @@ class Store {
|
|||
return { task, activity };
|
||||
}
|
||||
|
||||
update(id, input, user) {
|
||||
update(id, input, user, opts = {}) {
|
||||
const existing = this.get(id);
|
||||
if (!existing) throw new NotFoundError('Fant ikke oppgaven');
|
||||
// Optimistisk låsing: klienten sender versjonen den redigerte fra.
|
||||
|
|
@ -250,13 +275,27 @@ class Store {
|
|||
this.tasks[idx] = updated;
|
||||
this.rememberUser(user);
|
||||
if (updated.owner) this.rememberUser(updated.owner);
|
||||
|
||||
// Valgfri kommentar knyttet til endringen (typisk ved statusbytte: hva ble
|
||||
// gjort / hva blokkerer). Lagres som vanlig kommentar, men merket kind:status.
|
||||
const noteText = sanitizeStr(opts.comment, 1000);
|
||||
if (noteText) {
|
||||
const comment = {
|
||||
id: uid('c'), by: user, at: new Date().toISOString(),
|
||||
text: noteText, kind: 'status', status: updated.status,
|
||||
};
|
||||
updated.comments.push(comment);
|
||||
if (updated.comments.length > MAX_COMMENTS) updated.comments.shift();
|
||||
}
|
||||
this._persistTasks();
|
||||
|
||||
let action = 'update';
|
||||
if (existing.status !== 'done' && updated.status === 'done') action = 'done';
|
||||
else if (existing.status === 'done' && updated.status !== 'done') action = 'reopen';
|
||||
const detail = diffDetail(existing, updated);
|
||||
if (noteText) detail.push('💬 ' + noteText.slice(0, 80));
|
||||
const activity = this.addActivity(user, action, {
|
||||
taskId: id, title: updated.title, detail: diffDetail(existing, updated),
|
||||
taskId: id, title: updated.title, detail,
|
||||
});
|
||||
return { task: updated, activity };
|
||||
}
|
||||
|
|
@ -266,10 +305,52 @@ class Store {
|
|||
if (!existing) throw new NotFoundError('Fant ikke oppgaven');
|
||||
this.tasks = this.tasks.filter(t => t.id !== id);
|
||||
this._persistTasks();
|
||||
this._scrubFromPlans(id);
|
||||
const activity = this.addActivity(user, 'delete', { taskId: id, title: existing.title });
|
||||
return { task: existing, activity };
|
||||
}
|
||||
|
||||
// ---------- Dagsplan (per bruker, per dato) ----------
|
||||
getPlan(user, date) {
|
||||
const ids = (this.plans[user] && this.plans[user][date]) || [];
|
||||
// Filtrer bort oppgaver som er slettet siden planen ble lagret.
|
||||
return ids.filter(id => this.get(id));
|
||||
}
|
||||
|
||||
setPlan(user, date, ids) {
|
||||
const valid = [];
|
||||
const seen = new Set();
|
||||
for (const raw of Array.isArray(ids) ? ids : []) {
|
||||
if (typeof raw !== 'string') continue;
|
||||
const id = raw.replace(/[^a-z0-9_]/gi, '').slice(0, 32);
|
||||
if (!id || seen.has(id) || !this.get(id)) continue;
|
||||
seen.add(id);
|
||||
valid.push(id);
|
||||
if (valid.length >= 200) break;
|
||||
}
|
||||
if (!this.plans[user]) this.plans[user] = {};
|
||||
if (valid.length) this.plans[user][date] = valid;
|
||||
else delete this.plans[user][date];
|
||||
if (Object.keys(this.plans[user]).length === 0) delete this.plans[user];
|
||||
this._persistPlans();
|
||||
return valid;
|
||||
}
|
||||
|
||||
_scrubFromPlans(taskId) {
|
||||
let changed = false;
|
||||
for (const user of Object.keys(this.plans)) {
|
||||
const byDate = this.plans[user];
|
||||
for (const date of Object.keys(byDate)) {
|
||||
const before = byDate[date].length;
|
||||
byDate[date] = byDate[date].filter(x => x !== taskId);
|
||||
if (byDate[date].length !== before) changed = true;
|
||||
if (byDate[date].length === 0) delete byDate[date];
|
||||
}
|
||||
if (Object.keys(byDate).length === 0) delete this.plans[user];
|
||||
}
|
||||
if (changed) this._persistPlans();
|
||||
}
|
||||
|
||||
addComment(id, text, user) {
|
||||
const task = this.get(id);
|
||||
if (!task) throw new NotFoundError('Fant ikke oppgaven');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue