first version
This commit is contained in:
+136
@@ -0,0 +1,136 @@
|
||||
// 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;
|
||||
Reference in New Issue
Block a user