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:
Jon Vanvik 2026-06-15 13:59:19 +02:00
parent be166034de
commit bdeb6254b9
8 changed files with 724 additions and 147 deletions

View file

@ -93,15 +93,6 @@ function createServer({ dataDir = path.join(__dirname, 'data') } = {}) {
}
}
// Send kun til samme brukers tilkoblinger (dagsplan er privat, men skal synke
// mellom f.eks. desktop og laptop hos samme bruker).
function sendToUser(user, obj) {
const msg = JSON.stringify(obj);
for (const [ws, meta] of clients) {
if (meta.user === user && ws.readyState === ws.OPEN) ws.send(msg);
}
}
function onlineUsers() {
const set = new Set();
for (const meta of clients.values()) set.add(meta.user);
@ -171,6 +162,7 @@ function createServer({ dataDir = path.join(__dirname, 'data') } = {}) {
app.get('/api/state', (req, res) => {
store.rememberUser(req.user);
const today = todayStr();
const plan = store.getPlan(req.user, today);
res.json({
user: req.user,
canSetName: COOKIE_AUTH_ENABLED,
@ -178,20 +170,66 @@ function createServer({ dataDir = path.join(__dirname, 'data') } = {}) {
activity: store.activity,
known_users: [...store.knownUsers].sort(),
online: onlineUsers(),
plan: { date: today, items: store.getPlan(req.user, today) },
categories: store.categories(),
plan: { date: today, user: req.user, name: plan.name, items: plan.items, shared: plan.shared },
planIndex: store.planIndex(req.user),
});
});
app.get('/api/plan', (req, res) => {
const date = validDate(req.query.date) ? req.query.date : todayStr();
res.json({ date, items: store.getPlan(req.user, date) });
const owner = typeof req.query.user === 'string' && req.query.user ? sanitizeUser(req.query.user) : req.user;
const plan = store.getPlanFor(req.user, owner, date);
if (!plan) return res.status(403).json({ error: 'Planen er privat' });
res.json({ date, user: owner, name: plan.name, items: plan.items, shared: plan.shared, readonly: owner !== req.user });
});
// Plan-endringer påvirker per-oppgave-badges hos andre. Delte planer
// broadcastes til alle; private sendes kun til eierens egne faner (andre får
// en tom melding så de fjerner planen fra sin badge-indeks).
function broadcastPlan(owner, date) {
const plan = store.getPlan(owner, date);
const ownerMsg = JSON.stringify({ type: 'plan', user: owner, date, name: plan.name, items: plan.items, shared: plan.shared });
const othersMsg = plan.shared
? ownerMsg
: JSON.stringify({ type: 'plan', user: owner, date, name: '', items: [], shared: false });
for (const [ws, meta] of clients) {
if (ws.readyState !== ws.OPEN) continue;
ws.send(meta.user === owner ? ownerMsg : othersMsg);
}
}
app.put('/api/plan', (req, res) => {
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 });
const name = req.body && typeof req.body.name === 'string' ? req.body.name : undefined;
const shared = req.body && typeof req.body.shared === 'boolean' ? req.body.shared : undefined;
const plan = store.setPlan(req.user, date, req.body && req.body.items, name, shared);
broadcastPlan(req.user, date);
res.json({ date, user: req.user, name: plan.name, items: plan.items, shared: plan.shared });
});
app.put('/api/plan/share', (req, res) => {
const date = validDate(req.body && req.body.date) ? req.body.date : todayStr();
const shared = !!(req.body && req.body.shared);
const plan = store.setPlanShared(req.user, date, shared);
broadcastPlan(req.user, date);
res.json({ date, user: req.user, name: plan.name, items: plan.items, shared: plan.shared });
});
app.put('/api/plan/move', (req, res) => {
const from = validDate(req.body && req.body.from) ? req.body.from : null;
const to = validDate(req.body && req.body.to) ? req.body.to : null;
if (!from || !to) return res.status(400).json({ error: 'Ugyldig dato (from/to må være YYYY-MM-DD)' });
const plan = store.movePlan(req.user, from, to);
broadcastPlan(req.user, from); // kildedatoen er nå tom
broadcastPlan(req.user, to); // måldatoen har planen
res.json({ date: to, user: req.user, name: plan.name, items: plan.items, shared: plan.shared });
});
app.post('/api/categories', (req, res) => {
const cat = store.addCategory({ name: req.body && req.body.name, color: req.body && req.body.color }, req.user);
broadcast({ type: 'categories', categories: store.categories(), by: req.user });
res.status(201).json({ category: cat });
});
app.post('/api/login', (req, res) => {