422 lines
16 KiB
JavaScript
422 lines
16 KiB
JavaScript
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<DayRecord, string>,
|
||
* entries: import('dexie').Table<EntryRecord, number>,
|
||
* settings: import('dexie').Table<SettingsRecord, string>,
|
||
* syncState: import('dexie').Table<any, string>,
|
||
* syncObjects: import('dexie').Table<any, string>,
|
||
* syncVersions: import('dexie').Table<any, string>,
|
||
* syncOutbox: import('dexie').Table<any, string>,
|
||
* syncConflicts: import('dexie').Table<any, string>,
|
||
* }} 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<EntryRecord, 'kind'|'task'|'client'> & {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<number|null>} 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<number|undefined>} 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 });
|
||
});
|
||
}
|