Fixed a few minor bugs

This commit is contained in:
2026-09-19 19:58:13 +02:00
parent 870457955b
commit 9bb9260c09
11 changed files with 520 additions and 135 deletions

View File

@@ -1,6 +1,6 @@
import Dexie from 'dexie';
import { BackupError, buildBackup, EXPORT_FORMAT, EXPORT_VERSION, parseBackup } from './backup.js';
import { DAY, localDateKey } from './time.js';
import { DAY, HOUR, localDateKey } from './time.js';
/**
* A single working day. `plannedStartTs` / `plannedEndTs` are snapshotted from the
@@ -88,6 +88,59 @@ export function patchDay(dateKey, patch) {
return db.days.update(dateKey, patch);
}
/** Create once, together with the first entry, even from competing tabs. */
export function createDay(day, first) {
return db.transaction('rw', db.days, db.entries, async () => {
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;
});
}
/** Undo a premature close; no new entry or unaccounted interval is introduced. */
export function reopenDay(date, now) {
return db.transaction('rw', db.days, db.entries, async () => {
const day = await getDay(date);
if (!day || day.status !== 'closed' || await getActiveDay()) return false;
const entries = await getEntries(date);
const last = entries.at(-1);
if (!last) return false;
await db.entries.update(last.id, { 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();
@@ -116,12 +169,16 @@ export function getEntries(dateKey) {
* @param {number} ts
* @param {Pick<EntryRecord, 'kind'|'task'|'client'>|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) {
return db.transaction('rw', db.entries, async () => {
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) {
@@ -211,33 +268,30 @@ export function deleteEntry(id) {
/** 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 };
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) {
export async function cleanupOldDays(retentionDays, now = Date.now()) {
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 () => {
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;
});
return keys.length;
}
/**