Files
pardis/src/lib/db.js
Adrien W. 5686ee5072
All checks were successful
Tests et déploiement / validate (push) Successful in 2m44s
Tests et déploiement / deploy (push) Successful in 51s
Added optionnal online storage and cross-client sync
2026-10-10 16:01:06 +02:00

422 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 });
});
}