Files
abacus-to-kimai-filler/lib/planner.js
T
2026-10-07 15:53:12 +02:00

137 lines
5.4 KiB
JavaScript

// 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;