first version

This commit is contained in:
2026-10-07 15:53:12 +02:00
commit 9d8c2a0488
14 changed files with 1481 additions and 0 deletions
+128
View File
@@ -0,0 +1,128 @@
// Injected into the MyAbacus page (content script world, needs util.js + parse.js).
// Walks the "Gestion des temps" card day by day with the ‹ › buttons and stores results
// in browser.storage.local as it goes, so the popup can be closed meanwhile.
var AbacusCrawl = (() => {
const SEL = {
left: 'vaadin-button[movie-id="leftButton"]',
right: 'vaadin-button[movie-id="rightButton"]',
nav: '.proj-date-navigation',
from: 'vaadin-text-field[id^="id_history_from_"]',
to: 'vaadin-text-field[id^="id_history_to_"]',
total: '#inOut .total-label .va-label-text',
};
const DATE_RE = /(\d{1,2})\.(\d{1,2})\.(\d{4})/;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const present = () => !!document.querySelector(SEL.right);
function readDate() {
const nav = document.querySelector(SEL.nav);
if (!nav) return null;
// sr-only "Date actuelle 02.10.2026", else the date picker input
const m = DATE_RE.exec(nav.textContent) || DATE_RE.exec(nav.querySelector('input')?.value || '');
return m ? `${m[3]}-${m[2].padStart(2, '0')}-${m[1].padStart(2, '0')}` : null;
}
// Native <input slot="input"> inside the vaadin-text-field (custom element props aren't visible from here)
const fieldValue = (tf) => (tf.querySelector('input')?.value || '').trim();
const suffix = (tf) => tf.id.replace(/^id_history_(from|to)_/, '');
function readDay() {
const tos = new Map([...document.querySelectorAll(SEL.to)].map((tf) => [suffix(tf), fieldValue(tf)]));
const intervals = [], open = [];
for (const tf of document.querySelectorAll(SEL.from)) {
const s = U.toMin(fieldValue(tf)), e = U.toMin(tos.get(suffix(tf)) || '');
if (s == null) continue;
if (e == null) open.push(U.fmtTime(s));
else if (e > s) intervals.push({ start: s, end: e });
}
const totalText = document.querySelector(SEL.total)?.textContent.trim() || '';
return { date: readDate(), intervals: AbacusParse.union(intervals), open, total: U.toMin(totalText) };
}
// Wait until the date changed and the card content stopped moving
async function waitLoaded(prevDate, timeout = 15000) {
const t0 = Date.now();
while (readDate() === prevDate) {
if (Date.now() - t0 > timeout) throw new Error(`MyAbacus did not move away from ${prevDate}`);
await sleep(100);
}
let last = '', stable = 0;
while (stable < 3 && Date.now() - t0 < timeout) {
await sleep(150);
const snap = JSON.stringify(readDay());
stable = snap === last ? stable + 1 : 0;
last = snap;
}
}
async function step(dir) {
const prev = readDate();
document.querySelector(dir < 0 ? SEL.left : SEL.right).click();
await waitLoaded(prev);
}
function toLines(days) {
return days.map((d) => {
const iv = d.intervals.map((i) => `${U.fmtTime(i.start)}-${U.fmtTime(i.end)}`).join(' ');
const sum = AbacusParse.sumIntervals(d.intervals);
const notes = [];
if (d.open.length) notes.push(`clocked in at ${d.open.join(', ')} without leaving`);
if (d.total != null && d.intervals.length && d.total !== sum) notes.push(`Abacus total says ${U.fmtDur(d.total)}`);
const note = notes.length ? ` # ${notes.join('; ')}` : '';
if (iv) return `${d.date} ${iv}${note}`;
if (d.total) return `${d.date} ${U.fmtDur(d.total)}${note}`;
return `# ${d.date} nothing${note}`;
}).join('\n');
}
async function run(from, to) {
if (window.__abacusCrawling) return { error: 'already running' };
if (!present()) return { skipped: true }; // other frame
window.__abacusCrawling = true;
const progress = (p) => browser.storage.local.set({ crawl: { ...p, at: new Date().toISOString() } });
const days = [];
try {
await browser.storage.local.remove('crawlStop');
let cur = readDate();
if (!cur) throw new Error('Cannot read the current date in the card');
const maxSteps = 400;
let n = 0;
// Move to `from` (two one-way loops: never oscillates if a day can't be shown)
for (const dir of [-1, 1]) {
while (dir < 0 ? cur > from : cur < from) {
if (++n > maxSteps) throw new Error('Too many steps');
await progress({ status: 'running', message: `Navigating to ${from} (at ${cur})` });
await step(dir);
cur = readDate();
}
}
// Read each day up to `to`
for (;;) {
const { crawlStop } = await browser.storage.local.get('crawlStop');
if (crawlStop) throw new Error('Stopped');
days.push(readDay());
await progress({ status: 'running', message: `Read ${cur} (${days.length} day(s))` });
if (cur >= to || ++n > maxSteps) break;
await step(1);
cur = readDate();
}
const text = toLines(days);
const parsed = AbacusParse.fromText(text).days;
await browser.storage.local.set({
daysText: text,
extractInfo: { at: new Date().toISOString(), url: location.href, count: parsed.length, raw: days.map((d) => JSON.stringify(d)) },
});
await progress({ status: 'done', message: `Done: ${parsed.length} worked day(s) between ${from} and ${to}` });
return { days };
} catch (e) {
if (days.length) await browser.storage.local.set({ daysText: toLines(days) + `\n# stopped: ${e.message}` });
await progress({ status: 'error', message: e.message });
return { error: e.message };
} finally {
window.__abacusCrawling = false;
}
}
return { run, readDay, readDate, present };
})();
+75
View File
@@ -0,0 +1,75 @@
// Minimal Kimai REST client (https://www.kimai.org/documentation/rest-api.html)
var Kimai = (() => {
class Client {
constructor({ kimaiUrl, authMode, kimaiUser, kimaiToken }) {
this.base = String(kimaiUrl || '').replace(/\/+$/, '');
this.authMode = authMode || 'bearer';
this.user = kimaiUser;
this.token = kimaiToken;
}
headers() {
const h = { Accept: 'application/json' };
if (this.authMode === 'bearer') h.Authorization = `Bearer ${this.token}`;
else { h['X-AUTH-USER'] = this.user; h['X-AUTH-TOKEN'] = this.token; }
return h;
}
async req(path, { method = 'GET', query, body } = {}) {
if (!this.base) throw new Error('Kimai URL not configured');
const url = new URL(this.base + path);
for (const [k, v] of Object.entries(query || {})) if (v != null) url.searchParams.set(k, v);
const res = await fetch(url, {
method,
headers: { ...this.headers(), ...(body ? { 'Content-Type': 'application/json' } : {}) },
body: body ? JSON.stringify(body) : undefined,
credentials: 'omit',
});
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { /* keep raw text */ }
if (!res.ok) {
const msg = data?.message || data?.error?.message || text.slice(0, 200) || res.statusText;
const details = data?.errors ? ' ' + JSON.stringify(data.errors) : '';
throw new Error(`Kimai ${res.status}: ${msg}${details}`);
}
return { data, res };
}
async all(path, query = {}) {
const out = [];
for (let page = 1; ; page++) {
const { data, res } = await this.req(path, { query: { ...query, page, size: 250 } });
out.push(...(data || []));
const pages = +res.headers.get('X-Total-Pages') || 1;
if (page >= pages || !data?.length) return out;
}
}
version() { return this.req('/api/version').then((r) => r.data); }
me() { return this.req('/api/users/me').then((r) => r.data); }
projects() { return this.req('/api/projects', { query: { visible: 1 } }).then((r) => r.data); }
activities() { return this.req('/api/activities', { query: { visible: 1 } }).then((r) => r.data); }
// Entries of the current user between two ISO dates (inclusive)
timesheets(fromIso, toIso) {
return this.all('/api/timesheets', { begin: `${fromIso}T00:00:00`, end: `${toIso}T23:59:59` });
}
createTimesheet(entry) {
return this.req('/api/timesheets', { method: 'POST', body: entry }).then((r) => r.data);
}
}
// Kimai returns "2026-10-07T08:00:00+0200" in the user's timezone: read wall-clock directly.
function toLocal(ts) {
if (!ts.begin || !ts.end) return null; // running timer
const date = ts.begin.slice(0, 10);
const start = U.toMin(ts.begin.slice(11, 16));
let end = U.toMin(ts.end.slice(11, 16));
if (ts.end.slice(0, 10) !== date) end = 24 * 60; // crosses midnight, clamp
return { id: ts.id, date, start, end, project: ts.project, activity: ts.activity, description: ts.description };
}
return { Client, toLocal };
})();
+167
View File
@@ -0,0 +1,167 @@
// Turn text scraped/pasted from MyAbacus into day records:
// { date: 'YYYY-MM-DD', intervals: [{start, end}], minutes }
// intervals are minutes since midnight (may be empty when Abacus only gives a total).
var AbacusParse = (() => {
const DATE_RES = [
{ re: /\b(\d{4})-(\d{2})-(\d{2})\b/, get: (m) => [m[1], m[2], m[3]] },
{ re: /\b(\d{1,2})[./](\d{1,2})[./](\d{4}|\d{2})\b/, get: (m) => [m[3].length === 2 ? '20' + m[3] : m[3], m[2], m[1]] },
{ re: /\b(\d{1,2})\.(\d{1,2})\.(?!\d)/, get: (m, year) => [year, m[2], m[1]] },
];
const RANGE_RE = /(\d{1,2})[:.h](\d{2})\s*(?:-|–|—|to|bis|à)\s*(\d{1,2})[:.h](\d{2})/gi;
const TIME_RE = /(?<![\d.,:])(\d{1,2}):(\d{2})(?![\d:])/g;
// Duration tokens, in the order they appear in the row
const DUR_RES = [
{ re: /(?<![\d.,:+-])(\d{1,2})\s*h\s*(\d{1,2})\s*m(?:in)?\b/gi, get: (m) => +m[1] * 60 + +m[2] },
{ re: /(?<![\d.,:+-])(\d{1,2}):(\d{2})(?![\d:])/g, get: (m) => +m[1] * 60 + +m[2] },
{ re: /(?<![\d.,:+-])(\d{1,2})[.,](\d{1,2})(?![\d.,:])/g, get: (m) => Math.round(parseFloat(`${m[1]}.${m[2]}`) * 60) },
{ re: /(?<![\d.,:+-])(\d{1,2})\s*(?:h|std|hrs?)\b/gi, get: (m) => +m[1] * 60 },
];
function findDate(text, year) {
for (const { re, get } of DATE_RES) {
const m = re.exec(text);
if (!m) continue;
const [y, mo, d] = get(m, year);
if (+mo < 1 || +mo > 12 || +d < 1 || +d > 31) continue;
return { date: `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}`, rest: text.replace(m[0], ' ') };
}
return null;
}
const valid = (s, e) => s >= 0 && e <= 24 * 60 && e > s;
function ranges(text) {
const out = [];
for (const m of text.matchAll(RANGE_RE)) {
const s = +m[1] * 60 + +m[2], e = +m[3] * 60 + +m[4];
if (valid(s, e)) out.push({ start: s, end: e });
}
return out;
}
function pairs(text) {
const times = [...text.matchAll(TIME_RE)].map((m) => +m[1] * 60 + +m[2]);
const out = [];
for (let i = 0; i + 1 < times.length; i += 2) {
if (valid(times[i], times[i + 1])) out.push({ start: times[i], end: times[i + 1] });
}
return out;
}
function durations(text) {
const found = [];
let rest = text;
for (const { re, get } of DUR_RES) {
for (const m of rest.matchAll(re)) found.push({ idx: m.index, min: get(m) });
rest = rest.replace(re, (s) => ' '.repeat(s.length)); // don't double-match
}
return found.filter((f) => f.min > 0 && f.min <= 24 * 60).sort((a, b) => a.idx - b.idx).map((f) => f.min);
}
// Union of intervals, sorted
function union(intervals) {
const sorted = [...intervals].sort((a, b) => a.start - b.start);
const out = [];
for (const iv of sorted) {
const last = out[out.length - 1];
if (last && iv.start <= last.end) last.end = Math.max(last.end, iv.end);
else out.push({ ...iv });
}
return out;
}
const sumIntervals = (ivs) => ivs.reduce((a, iv) => a + iv.end - iv.start, 0);
/**
* rows: array of strings (one per table row or text line)
* opts: { timeMode: auto|ranges|pairs|duration, durationIndex, sameDateRows: sum|max|last,
* carryDate, year }
*/
function parseRows(rows, opts = {}) {
const o = { timeMode: 'auto', durationIndex: 0, sameDateRows: 'sum', carryDate: false, year: new Date().getFullYear(), ...opts };
const byDate = new Map();
let lastDate = null;
for (const raw of rows) {
const text = String(raw).replace(/\s+/g, ' ');
const found = findDate(text, o.year);
if (found) lastDate = found.date;
else if (!o.carryDate || !lastDate) continue;
const date = found ? found.date : lastDate;
const rest = found ? found.rest : text;
let ivs = [], dur = null;
if (o.timeMode === 'ranges' || o.timeMode === 'auto') ivs = ranges(rest);
if (o.timeMode === 'pairs') ivs = pairs(rest);
if (!ivs.length && (o.timeMode === 'duration' || o.timeMode === 'auto')) {
const ds = durations(rest);
const i = o.durationIndex < 0 ? ds.length + +o.durationIndex : +o.durationIndex;
if (ds[i] != null) dur = ds[i];
}
if (!ivs.length && dur == null) continue;
const day = byDate.get(date) || { date, intervals: [], durs: [] };
day.intervals.push(...ivs);
if (dur != null) day.durs.push(dur);
byDate.set(date, day);
}
return [...byDate.values()]
.map((d) => {
const intervals = union(d.intervals);
let minutes;
if (intervals.length) minutes = sumIntervals(intervals);
else if (o.sameDateRows === 'max') minutes = Math.max(...d.durs);
else if (o.sameDateRows === 'last') minutes = d.durs[d.durs.length - 1];
else minutes = d.durs.reduce((a, b) => a + b, 0);
return { date: d.date, intervals, minutes };
})
.filter((d) => d.minutes > 0)
.sort((a, b) => a.date.localeCompare(b.date));
}
// Normalized one-line-per-day format used in the dashboard editor:
// 2026-10-07 08:00-12:00 13:00-17:24
// 2026-10-08 8:24
function toText(days) {
return days
.map((d) => d.intervals.length
? `${d.date} ${d.intervals.map((iv) => `${U.fmtTime(iv.start)}-${U.fmtTime(iv.end)}`).join(' ')}`
: `${d.date} ${U.fmtDur(d.minutes)}`)
.join('\n');
}
function fromText(text) {
const days = [], errors = [];
text.split('\n').forEach((line, n) => {
line = line.replace(/#.*/, '').trim();
if (!line) return;
const m = /^(\d{4}-\d{2}-\d{2})\s+(.+)$/.exec(line);
if (!m) return errors.push(`line ${n + 1}: expected "YYYY-MM-DD ..."`);
const ivs = ranges(m[2]);
if (ivs.length) return days.push({ date: m[1], intervals: union(ivs), minutes: sumIntervals(union(ivs)) });
const min = U.parseHours(m[2]);
if (min) return days.push({ date: m[1], intervals: [], minutes: min });
errors.push(`line ${n + 1}: can't read "${m[2]}"`);
});
// merge duplicates
const merged = parseMerge(days);
return { days: merged, errors };
}
function parseMerge(days) {
const map = new Map();
for (const d of days) {
const cur = map.get(d.date);
if (!cur) { map.set(d.date, { ...d, intervals: [...d.intervals] }); continue; }
cur.intervals = union([...cur.intervals, ...d.intervals]);
cur.minutes = cur.intervals.length ? sumIntervals(cur.intervals) : cur.minutes + d.minutes;
}
return [...map.values()].sort((a, b) => a.date.localeCompare(b.date));
}
return { parseRows, toText, fromText, union, sumIntervals };
})();
if (typeof module !== 'undefined') module.exports = AbacusParse;
+136
View File
@@ -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;
+39
View File
@@ -0,0 +1,39 @@
// browser.storage.local wrapper with defaults
var Store = (() => {
const DEFAULT_SETTINGS = {
kimaiUrl: '',
authMode: 'bearer', // bearer (Kimai >= 2.x API token) | legacy (X-AUTH-USER / X-AUTH-TOKEN)
kimaiUser: '',
kimaiToken: '',
defaultProject: null,
defaultActivity: null,
defaultProjectLabel: '',
defaultActivityLabel: '',
defaultDescription: '',
tags: 'abacus-sync',
dayStart: '08:00',
skipIfRulePresent: true,
parse: {
rowSelector: 'tr, [role="row"]',
timeMode: 'auto',
durationIndex: 0,
sameDateRows: 'sum',
year: '',
},
};
async function get() {
const s = await browser.storage.local.get(['settings', 'rules', 'daysText', 'extractInfo', 'catalog']);
return {
settings: { ...DEFAULT_SETTINGS, ...(s.settings || {}), parse: { ...DEFAULT_SETTINGS.parse, ...(s.settings?.parse || {}) } },
rules: s.rules || [],
daysText: s.daysText || '',
extractInfo: s.extractInfo || null,
catalog: s.catalog || { projects: [], activities: [], fetchedAt: null },
};
}
const set = (obj) => browser.storage.local.set(obj);
return { get, set, DEFAULT_SETTINGS };
})();
+64
View File
@@ -0,0 +1,64 @@
// Small time/date helpers shared by popup, dashboard and planner.
// Times of day are handled as "minutes since midnight", durations as minutes.
var U = (() => {
const pad = (n) => String(n).padStart(2, '0');
const toMin = (hhmm) => {
const m = /^(\d{1,2})[:.h](\d{2})$/.exec(String(hhmm || '').trim());
return m ? +m[1] * 60 + +m[2] : null;
};
const fmtTime = (min) => `${pad(Math.floor(min / 60))}:${pad(min % 60)}`;
const fmtDur = (min) => {
const sign = min < 0 ? '-' : '';
min = Math.abs(Math.round(min));
return `${sign}${Math.floor(min / 60)}:${pad(min % 60)}`;
};
// "1", "1.5", "1,5", "1:30", "90m", "1h30" -> minutes. Empty -> null.
const parseHours = (s) => {
s = String(s ?? '').trim().toLowerCase();
if (!s) return null;
let m;
if ((m = /^(\d+)\s*m(in)?$/.exec(s))) return +m[1];
if ((m = /^(\d+)\s*[:h]\s*(\d{1,2})?\s*m?(in)?$/.exec(s))) return +m[1] * 60 + +(m[2] || 0);
if ((m = /^(\d+(?:[.,]\d+)?)$/.exec(s))) return Math.round(parseFloat(m[1].replace(',', '.')) * 60);
return null;
};
const parseDate = (iso) => {
const [y, m, d] = iso.split('-').map(Number);
return new Date(y, m - 1, d);
};
const isoDate = (dt) => `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())}`;
const addDays = (iso, n) => {
const dt = parseDate(iso);
dt.setDate(dt.getDate() + n);
return isoDate(dt);
};
// ISO weekday: 1 = Monday ... 7 = Sunday
const weekday = (iso) => parseDate(iso).getDay() || 7;
const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const weekdayName = (iso) => WEEKDAYS[weekday(iso) - 1];
// Monday of the week containing iso
const monday = (iso) => addDays(iso, 1 - weekday(iso));
const weeksBetween = (a, b) =>
Math.round((parseDate(monday(b)) - parseDate(monday(a))) / (7 * 864e5));
// Kimai wants HTML5 local datetime: 2026-10-07T08:00:00
const kimaiDateTime = (iso, min) => `${iso}T${fmtTime(min)}:00`;
const uid = () => Math.random().toString(36).slice(2, 10);
return {
pad, toMin, fmtTime, fmtDur, parseHours, parseDate, isoDate, addDays,
weekday, weekdayName, WEEKDAYS, monday, weeksBetween, kimaiDateTime, uid,
};
})();