First working prototype

This commit is contained in:
2026-09-06 13:50:58 +02:00
parent ecf08f8307
commit f61b9025eb
44 changed files with 5118 additions and 905 deletions

View File

@@ -1,45 +1,240 @@
import Dexie from 'dexie';
import { DAY, localDateKey } from './time.js';
export const db = new Dexie('TimeLogDB');
/**
* 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
*/
/**
* One stretch of the day. `endTs === null` marks the single running entry; every
* other entry's `endTs` equals the next entry's `startTs`.
*
* @typedef {object} EntryRecord
* @property {number} [id]
* @property {string} date
* @property {number} seq position within the day, dense from 0
* @property {'travel'|'work'|'lunch'} kind
* @property {string} task
* @property {string|null} client
* @property {number} startTs
* @property {number|null} endTs
*/
/**
* @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>,
* }} 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' // for retention policy
settings: 'key',
});
// Helper functions
export async function addLog(log) {
return await db.timeLogs.add(log);
db.version(2).stores({
timeLogs: null, // drop the prototype table
days: 'date, status',
entries: '++id, date, [date+seq], startTs',
settings: 'key',
});
/* ------------------------------------------------------------------ settings */
export async function readSettings() {
const rec = await db.settings.get('app');
return rec?.value ?? null;
}
export async function getLogsByDate(date) {
return await db.timeLogs.where('date').equals(date).toArray();
export async function writeSettings(value) {
await db.settings.put({ key: 'app', value });
}
export async function getRecentLogs(limit = 10) {
return await db.timeLogs.orderBy('id').reverse().limit(limit).toArray();
/* ---------------------------------------------------------------------- days */
export function getDay(dateKey) {
return db.days.get(dateKey);
}
export async function getLastLog() {
return await db.timeLogs.orderBy('id').reverse().first();
export function putDay(day) {
return db.days.put(day);
}
export async function setRetentionDays(days) {
await db.settings.put({ key: 'retentionDays', value: days });
export function patchDay(dateKey, patch) {
return db.days.update(dateKey, patch);
}
// Retention logic
export async function cleanupOldLogs() {
const retentionDays = await db.settings.get('retentionDays') || 30;
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
// Keep the latest day's report
const latestDate = await db.timeLogs.orderBy('date').reverse().first();
if (latestDate) {
await db.timeLogs
.where('date')
.below(cutoffDate.toISOString().split('T')[0])
.and(log => log.date !== latestDate.date)
.delete();
}
/** 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'>|null} next
* Pass null to only close the running entry (end of day).
* @returns {Promise<number|null>} id of the new entry
*/
export function closeAndStart(dateKey, ts, next) {
return db.transaction('rw', db.entries, async () => {
const list = await db.entries.where('date').equals(dateKey).sortBy('seq');
const running = list.find((e) => e.endTs == 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);
}
/**
* 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.startTs : 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 });
if (prev) await db.entries.update(prev.id, { endTs: stored });
else 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) {
// 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 (next) {
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() {
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) {
if (!retentionDays || retentionDays <= 0) return 0;
const cutoff = localDateKey(Date.now() - retentionDays * DAY);
const keep = await getLastClosedDay();
const stale = (await db.days.toArray()).filter(
(d) => d.date < cutoff && d.date !== keep?.date,
);
if (!stale.length) return 0;
const keys = stale.map((d) => d.date);
await db.transaction('rw', db.days, db.entries, async () => {
await db.days.bulkDelete(keys);
await db.entries.where('date').anyOf(keys).delete();
});
return keys.length;
}