import Dexie from 'dexie'; import { BackupError, buildBackup, EXPORT_FORMAT, EXPORT_VERSION, parseBackup } from './backup.js'; import { DAY, HOUR, localDateKey } from './time.js'; /** * A single working day. `plannedStartTs` / `plannedEndTs` are snapshotted from the * settings in force when the day was created, so editing settings later never * rewrites history. * * @typedef {object} DayRecord * @property {string} date local 'YYYY-MM-DD', primary key * @property {'active'|'closed'} status * @property {number} plannedStartTs * @property {number} plannedEndTs * @property {number} startedTs * @property {number|null} endedTs * @property {'panier'|'restaurant'|null} lunchType * @property {string|null} restaurant * @property {number|null} closePromptedTs * @property {boolean} [autoLunchHandled] * @property {number|null} [autoLunchEndTs] * @property {boolean} [lunchManual] */ /** * One stretch of the day. `endTs === null` marks the single running entry; every * other entry has a finite end; imported histories may contain gaps. * * @typedef {object} EntryRecord * @property {number} [id] * @property {string} date * @property {number} seq position within the day, dense from 0 * @property {'travel'|'work'|'lunch'|'break'} kind * @property {string} task * @property {string|null} client * @property {number} startTs * @property {number|null} endTs * @property {boolean} [automatic] true only for unconfirmed automatic entries */ /** * @typedef {object} SettingsRecord * @property {string} key * @property {any} value */ /** * @typedef {Dexie & { * days: import('dexie').Table, * entries: import('dexie').Table, * settings: import('dexie').Table, * syncState: import('dexie').Table, * syncObjects: import('dexie').Table, * syncVersions: import('dexie').Table, * syncOutbox: import('dexie').Table, * syncConflicts: import('dexie').Table, * }} TimeLogDB */ export const db = /** @type {TimeLogDB} */ (new Dexie('TimeLogDB')); // v1 was the throwaway prototype schema; declared so Dexie has an upgrade path. db.version(1).stores({ timeLogs: '++id, date, startTime, endTime, taskName, clientName', settings: 'key', }); db.version(2).stores({ timeLogs: null, // drop the prototype table days: 'date, status', entries: '++id, date, [date+seq], startTs', settings: 'key', }); db.version(3).stores({ syncState: 'key', syncObjects: 'key', syncVersions: 'id, key', syncOutbox: 'id', syncConflicts: 'key', }); // Journal every local mutation in its native IndexedDB transaction, including // direct writes by editing, automation, import and purge. The durable source // rows + last captured values are the outbox until revisions are materialised. db.use({ stack: 'dbcore', name: 'sync-dirty-journal', create(core) { return { ...core, transaction(stores, mode, options) { const names = mode === 'readwrite' && stores.some(s => ['days', 'entries', 'settings'].includes(s)) ? [...new Set([...stores, 'syncState'])] : stores; return core.transaction(names, mode, options); }, table(name) { const table = core.table(name); if (!['days', 'entries', 'settings'].includes(name)) return table; return { ...table, async mutate(req) { const result = await table.mutate(req); const marker = await core.table('syncState').mutate({ type: 'put', trans: req.trans, values: [{ key: 'dirty', value: true }] }); if (marker.numFailures) throw marker.failures[0]; return result; } }; }, }; }, }); /* ------------------------------------------------------------------ settings */ export async function readSettings() { const rec = await db.settings.get('app'); return rec?.value ?? null; } export async function writeSettings(value) { await db.settings.put({ key: 'app', value }); } /* ---------------------------------------------------------------------- days */ export function getDay(dateKey) { return db.days.get(dateKey); } export function putDay(day) { return db.days.put(day); } export function patchDay(dateKey, patch) { return db.days.update(dateKey, patch); } /** Create once. Automatic callers supply exclusions, checked again under lock. */ export function createDay(day, first, nonWorkingDates = null) { return db.transaction('rw', db.days, db.entries, db.settings, async () => { if (nonWorkingDates != null) { const stored = await readSettings(); if ((stored?.nonWorkingDates ?? nonWorkingDates).includes(day.date)) return false; } if (await getDay(day.date) || await getActiveDay()) return false; await db.days.add(day); await closeAndStart(day.date, day.startedTs, first); return true; }); } /** Close the day and its last entry at the same authoritative timestamp. */ export function closeDay(date, ts, stale = false) { return db.transaction('rw', db.days, db.entries, async () => { const day = await getDay(date); if (!day || day.status !== 'active') return false; const entries = await getEntries(date); const lastActivity = entries.reduce((max, e) => Math.max(max, e.startTs, e.endTs ?? e.startTs), day.startedTs); const endedTs = Math.max(stale ? day.plannedEndTs : ts, lastActivity); await closeAndStart(date, endedTs, null); await patchDay(date, { status: 'closed', endedTs }); return true; }); } /** Confirmed same-day reopening; status and all resumed slots commit together. */ export function reopenDay(date, now, mode = 'continue', expected = null) { return db.transaction('rw', db.days, db.entries, async () => { const day = await getDay(date); if (!['continue', 'break'].includes(mode)) throw new Error('Choix de reprise invalide.'); if (!day || date !== localDateKey(now) || day.status !== 'closed' || await getActiveDay()) return false; const entries = await getEntries(date); const last = entries.at(-1); if (!last) return false; if (expected && (day.endedTs !== expected.endedTs || ['id', 'kind', 'task', 'client', 'startTs', 'endTs'].some(key => last[key] !== expected.last[key]))) { throw new Error('La clôture ou le dernier poste a changé. Fermez puis ouvrez la confirmation de reprise.'); } const boundary = day.endedTs; if (!Number.isFinite(boundary) || now < boundary) return false; if (mode === 'continue' && ['work', 'travel'].includes(last.kind)) { await db.entries.update(last.id, { endTs: null, automatic: false }); } else { let seq = last.seq + 1; if (now > boundary) { if (mode === 'continue') await db.entries.update(last.id, { endTs: now, automatic: false }); else await db.entries.add({ date, seq: seq++, kind: 'break', task: 'Pause', client: null, startTs: boundary, endTs: now }); } const history = await db.entries.toArray(); const work = history.filter((e) => e.kind === 'work' && e.date <= date) .sort((a, b) => b.date.localeCompare(a.date) || b.seq - a.seq)[0]; await db.entries.add({ date, seq, kind: 'work', task: work?.task || 'Travaux sur site', client: work?.client ?? null, startTs: now, endTs: null }); } await patchDay(date, { status: 'active', endedTs: null, closePromptedTs: null, plannedEndTs: Math.max(day.plannedEndTs, now + HOUR), }); return true; }); } /** Conditional day updates cannot resurrect or modify a concurrently closed day. */ export function patchActiveDay(date, makePatch) { return db.transaction('rw', db.days, async () => { const day = await getDay(date); if (day?.status !== 'active') return false; const patch = makePatch(day); if (!patch) return false; await patchDay(date, patch); return true; }); } /** The one day still running, if any — including a stale one from a past date. */ export function getActiveDay() { return db.days.where('status').equals('active').first(); } /** Most recent closed day: the one the resume screen shows. */ export async function getLastClosedDay() { const closed = await db.days.where('status').equals('closed').toArray(); if (!closed.length) return null; closed.sort((a, b) => a.date.localeCompare(b.date)); return closed[closed.length - 1]; } /* ------------------------------------------------------------------- entries */ export function getEntries(dateKey) { return db.entries.where('date').equals(dateKey).sortBy('seq'); } /** * The single place the chain invariant is enforced: closing the running entry and * opening the next one share one timestamp, in one transaction, so entry N's end * can never drift from entry N+1's start. * * @param {string} dateKey * @param {number} ts * @param {(Pick & {automatic?: boolean})|null} next * Pass null to only close the running entry (end of day). * @param {number|null} [expectedId] running entry observed by the action's caller * @returns {Promise} id of the new entry */ export function closeAndStart(dateKey, ts, next, expectedId) { return db.transaction('rw', db.days, db.entries, async () => { if ((await getDay(dateKey))?.status !== 'active') return null; const list = await db.entries.where('date').equals(dateKey).sortBy('seq'); const running = list.find((e) => e.endTs == null); // Two taps/tabs acting on the same entry may advance it only once. if (expectedId !== undefined && (running?.id ?? null) !== expectedId) return null; let boundary = ts; if (running) { // A tap can never produce an entry that ends before it started. boundary = Math.max(ts, running.startTs); await db.entries.update(running.id, { endTs: boundary }); } if (!next) return null; const seq = list.length ? list[list.length - 1].seq + 1 : 0; return db.entries.add({ date: dateKey, seq, startTs: boundary, endTs: null, ...next, }); }); } export function patchEntry(id, patch) { return db.entries.update(id, { ...patch, automatic: false }); } /** * Move the boundary at the start of an entry. Because that instant is shared with * the previous entry's end, both sides move together and the chain stays gapless. * For the first entry of a day there is no predecessor, so the day's start moves * instead — that is what makes the auto-captured start time correctable. * * The value is clamped between the previous entry's start and this entry's own * end (or now, while it is still running), so no edit can invert the timeline. * * @returns {Promise} the timestamp actually stored */ export function setEntryStart(id, ts, now = Date.now()) { return db.transaction('rw', db.entries, db.days, async () => { const entry = await db.entries.get(id); if (!entry) return undefined; const list = await db.entries.where('date').equals(entry.date).sortBy('seq'); const prev = list[list.findIndex((e) => e.id === id) - 1]; const lower = prev ? (prev.endTs === entry.startTs ? prev.startTs : prev.endTs) : Number.NEGATIVE_INFINITY; const upper = entry.endTs ?? now; const stored = Math.min(Math.max(ts, lower), upper); await db.entries.update(entry.id, { startTs: stored, ...(entry.automatic ? { automatic: false } : {}) }); if (prev && prev.endTs === entry.startTs) await db.entries.update(prev.id, { endTs: stored, ...(prev.automatic ? { automatic: false } : {}) }); else if (!prev) await db.days.update(entry.date, { startedTs: stored }); return stored; }); } /** * Remove an entry and re-stitch the chain: the previous entry absorbs the gap by * extending to the next entry's start. Keeps the timeline contiguous after a * mis-tap, which is the whole point of allowing deletes. */ export function deleteEntry(id) { return db.transaction('rw', db.entries, async () => { const victim = await db.entries.get(id); if (!victim) return; const list = await db.entries.where('date').equals(victim.date).sortBy('seq'); const i = list.findIndex((e) => e.id === id); const prev = list[i - 1]; const next = list[i + 1]; await db.entries.delete(id); if (prev && prev.endTs === victim.startTs && (!next || victim.endTs === next.startTs)) { // Extend the previous entry over the hole; if nothing follows, it inherits // the victim's end (including `null`, i.e. it becomes the running entry). await db.entries.update(prev.id, { endTs: next ? next.startTs : victim.endTs }); } else if (!prev && next && victim.endTs === next.startTs) { await db.entries.update(next.id, { startTs: victim.startTs }); } // Re-number so `seq` stays dense. const rest = list.filter((e) => e.id !== id); await Promise.all( rest.map((e, n) => (e.seq === n ? null : db.entries.update(e.id, { seq: n }))), ); }); } /* ------------------------------------------------------------------- history */ /** Everything the prediction model is built from. */ export async function getHistory() { return db.transaction('r', db.entries, db.days, async () => { const [entries, days] = await Promise.all([db.entries.toArray(), db.days.toArray()]); return { entries, days }; }); } /** * Drop days older than the retention window. The most recent closed day is always * kept — it is the app's primary output and must survive any setting. */ export async function cleanupOldDays(retentionDays, now = Date.now()) { if (!retentionDays || retentionDays <= 0) return 0; const cutoff = localDateKey(now - retentionDays * DAY); return db.transaction('rw', db.days, db.entries, async () => { const keep = await getLastClosedDay(); const stale = (await db.days.toArray()).filter( (d) => d.status === 'closed' && d.date < cutoff && d.date !== keep?.date, ); const keys = stale.map((d) => d.date); if (!keys.length) return 0; await db.days.bulkDelete(keys); await db.entries.where('date').anyOf(keys).delete(); return keys.length; }); } /** * Write an imported dataset. * * `replace` wipes the local days and entries first and adopts the file's settings. * `merge` adds only days the device does not already have and leaves settings * alone — merging two devices' settings is meaningless, and keeping the local copy * of a day the device already knows about never loses work that was done here. * * @param {{days: Array, entries: Array, settings: object|null}} payload * @param {'merge'|'replace'} mode * @returns {Promise<{added: number, skipped: number, entries: number}>} */ export function importBackup(payload, mode) { if (mode !== 'merge' && mode !== 'replace') { throw new BackupError('Mode d’import inconnu.'); } // Validate again at the write boundary and turn Svelte state proxies into plain // records. IndexedDB cannot structured-clone a proxy (DataCloneError). const { days, entries, settings } = parseBackup(JSON.stringify({ ...payload, format: EXPORT_FORMAT, version: EXPORT_VERSION, })); return db.transaction('rw', db.days, db.entries, db.settings, async () => { if (mode === 'replace') { await db.days.clear(); await db.entries.clear(); await db.settings.clear(); } const present = new Set((await db.days.toArray()).map((d) => d.date)); const incoming = days.filter((d) => !present.has(d.date)); const wanted = new Set(incoming.map((d) => d.date)); const incomingEntries = entries .filter((e) => wanted.has(e.date)) .map(({ id, ...rest }) => rest); if (incoming.length) await db.days.bulkAdd(incoming); if (incomingEntries.length) await db.entries.bulkAdd(incomingEntries); if (mode === 'replace' && settings) { await db.settings.put({ key: 'app', value: settings }); } return { added: incoming.length, skipped: days.length - incoming.length, entries: incomingEntries.length, }; }); } /** A consistent snapshot of all three stores, even while another tab writes. */ export function exportBackup() { return db.transaction('r', db.days, db.entries, db.settings, async () => { const [days, entries, settings] = await Promise.all([ db.days.toArray(), db.entries.toArray(), readSettings(), ]); return buildBackup({ days, entries, settings }); }); }