// Compute which Kimai entries to create so that, per day, Kimai total == Abacus total. // // For each day: // need = abacus minutes - minutes already in Kimai // slots = abacus intervals (or [dayStart, 24:00] if only a total is known) minus existing Kimai entries // 1. fixed recurring rules (with start time first, then the others) // 2. the rest goes to the "remainder" rule of that day, or the default project/activity var Planner = (() => { function ruleApplies(rule, date) { if (!rule.enabled) return false; if (rule.validFrom && date < rule.validFrom) return false; if (rule.validTo && date > rule.validTo) return false; if (!(rule.weekdays || []).includes(U.weekday(date))) return false; const n = +rule.everyNWeeks || 1; if (n > 1) { const w = U.weeksBetween(rule.anchorDate || '2026-01-05', date); if (((w % n) + n) % n !== 0) return false; } return true; } // Carve `minutes` out of free slots (mutates slots). Starts at `from` when given. function take(slots, minutes, from = null) { const pieces = []; let left = minutes; for (let i = 0; i < slots.length && left > 0; i++) { const s = slots[i]; const st = from != null ? Math.max(s.start, from) : s.start; if (st >= s.end) continue; const en = Math.min(s.end, st + left); pieces.push({ start: st, end: en }); left -= en - st; const repl = []; if (s.start < st) repl.push({ start: s.start, end: st }); if (en < s.end) repl.push({ start: en, end: s.end }); slots.splice(i, 1, ...repl); i += repl.length - 1; } return { pieces, left }; } function subtract(slots, busy) { let out = slots.map((s) => ({ ...s })); for (const b of busy) { out = out.flatMap((s) => { if (b.end <= s.start || b.start >= s.end) return [s]; const r = []; if (s.start < b.start) r.push({ start: s.start, end: b.start }); if (b.end < s.end) r.push({ start: b.end, end: s.end }); return r; }); } return out; } /** * days: [{date, intervals, minutes}] * existing: Map date -> [{start, end, project, activity}] * rules: recurring rules * settings: {defaultProject, defaultActivity, defaultDescription, dayStart, skipIfRulePresent} */ function plan(days, existing, rules, settings) { const dayStart = U.toMin(settings.dayStart) ?? 8 * 60; return days.map((day) => { const ex = existing.get(day.date) || []; const existingMin = ex.reduce((a, e) => a + (e.end - e.start), 0); const res = { date: day.date, target: day.minutes, existingMin, existing: ex, entries: [], warnings: [] }; let need = day.minutes - existingMin; if (need < 0) res.warnings.push(`Kimai has ${U.fmtDur(-need)} more than Abacus`); if (need <= 0) return finish(res); const base = day.intervals.length ? day.intervals : [{ start: dayStart, end: 24 * 60 }]; const slots = subtract(base, [...ex].sort((a, b) => a.start - b.start)); const applicable = rules.filter((r) => ruleApplies(r, day.date)); const fixed = applicable .filter((r) => +r.minutes > 0) .sort((a, b) => (a.startTime ? 0 : 1) - (b.startTime ? 0 : 1) || (U.toMin(a.startTime) || 0) - (U.toMin(b.startTime) || 0)); const remainder = applicable.find((r) => !(+r.minutes > 0)); const push = (pieces, src) => { for (const p of pieces) { res.entries.push({ start: p.start, end: p.end, project: src.project, activity: src.activity, projectLabel: src.projectLabel, activityLabel: src.activityLabel, description: src.description || '', source: src.name || 'default', }); } }; for (const r of fixed) { if (need <= 0) break; if (settings.skipIfRulePresent && ex.some((e) => e.project == r.project && e.activity == r.activity)) continue; const want = Math.min(+r.minutes, need); let { pieces, left } = take(slots, want, U.toMin(r.startTime)); if (left > 0 && r.startTime) { const more = take(slots, left); pieces = pieces.concat(more.pieces); left = more.left; } push(pieces, r); need -= want - left; if (left > 0) res.warnings.push(`no free time left for "${r.name}" (${U.fmtDur(left)} missing)`); } if (need > 0) { const src = remainder || (settings.defaultProject && settings.defaultActivity ? { name: 'default', project: settings.defaultProject, activity: settings.defaultActivity, projectLabel: settings.defaultProjectLabel, activityLabel: settings.defaultActivityLabel, description: settings.defaultDescription } : null); if (!src) { res.warnings.push(`${U.fmtDur(need)} unassigned: set a default project/activity or a remainder rule`); } else { const { pieces, left } = take(slots, need); push(pieces, src); if (left > 0) res.warnings.push(`${U.fmtDur(left)} could not be placed (no free slot)`); } } return finish(res); }); } function finish(res) { res.entries.sort((a, b) => a.start - b.start); res.plannedMin = res.entries.reduce((a, e) => a + e.end - e.start, 0); res.diff = res.target - res.existingMin - res.plannedMin; return res; } return { plan, ruleApplies, take, subtract }; })(); if (typeof module !== 'undefined') module.exports = Planner;