First working prototype
This commit is contained in:
251
src/lib/db.js
251
src/lib/db.js
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user