// 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 }; })();