fix: an imported backup no longer rolls progress back

The progress store merged like the mark stores do, keeping whichever copy had
the earlier timestamp. That is right for a mark — `at` is when the reader made
it, membership is the whole fact, and the oldest stamp is the truest one — and
wrong here, because this record *is* the data. A status, an effort, a note and
whether an event repeats all live in it, and `at` says when one of those last
changed.

So the earlier copy winning discarded every edit made after it, in both of the
directions an import actually happens in: restoring a backup taken before an
evening's work undid the evening, and importing an old file onto a device with
newer progress rolled the device back. Neither is recoverable — there is no
account and no server holding a second copy.

The later record wins now. Nothing is removed in either direction, and taking
the maximum of two timestamps is as order-independent and idempotent as the rule
it replaces. `mergeProgress` came out of the hook to be tested, which is also
how the ignored store's opposite rule is now written down rather than assumed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-20 06:37:11 +02:00
co-authored by Claude Opus 5
parent 7bb7dc843c
commit f9ef21a366
3 changed files with 129 additions and 11 deletions
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, test } from "bun:test";
import { mergeProgress } from "../src/client/state/useProgress.ts";
import type { ProgressMap } from "../src/client/state/useProgress.ts";
/**
* How an imported file meets the progress already on the device.
*
* This store is the only copy of what the reader has said about an event —
* status, effort, note, whether it repeats — and there is no account and no
* server holding a second one. So the merge has exactly two obligations: never
* drop an id, and never roll an answer back to an older one. Both directions of
* that second clause are real, because an import is as often a backup being
* restored as it is a second device arriving.
*/
const at = (iso: string) => `2026-08-${iso}T12:00:00.000Z`;
describe("mergeProgress", () => {
test("keeps an id that only one side has, from either side", () => {
const device: ProgressMap = { a: { status: "done", at: at("10") } };
const file: ProgressMap = { b: { status: "doing", at: at("11") } };
expect(Object.keys(mergeProgress(device, file)).sort()).toEqual(["a", "b"]);
expect(Object.keys(mergeProgress(file, device)).sort()).toEqual(["a", "b"]);
});
test("the later record wins, so a newer edit is not rolled back", () => {
// The bug this replaces kept the *earlier* copy, which is right for a mark
// — where `at` is when it was made — and wrong here, where the record is
// the data and `at` is when it last changed. Restoring a backup taken
// before an evening's work would have undone the evening.
const older: ProgressMap = { a: { status: "doing", at: at("10") } };
const newer: ProgressMap = {
a: { status: "done", effort: "grind", note: "two more runs", at: at("14") },
};
expect(mergeProgress(older, newer).a).toEqual(newer.a);
// And the same answer whichever way round it is applied, so restoring an
// old file over newer progress does not roll the device back either.
expect(mergeProgress(newer, older).a).toEqual(newer.a);
});
test("merging is idempotent and order-independent", () => {
// Taking the maximum of two timestamps keeps both properties, which is what
// makes importing the same file twice harmless.
const a: ProgressMap = { x: { status: "done", at: at("10") } };
const b: ProgressMap = { x: { status: "doing", at: at("12") } };
const once = mergeProgress(a, b);
expect(mergeProgress(once, b)).toEqual(once);
expect(mergeProgress(b, a)).toEqual(once);
});
test("a record with no timestamp lands, but never wins", () => {
// An import is untrusted input: a file edited by hand or truncated can carry
// a record with no `at`. It is still the reader's data, so it is kept under
// an id nothing holds — but it must not overwrite a record that does say
// when it was touched.
const broken = { at: undefined } as unknown as ProgressMap[string];
const device: ProgressMap = { a: { status: "done", at: at("10") } };
expect(mergeProgress(device, { a: broken }).a?.status).toBe("done");
expect(mergeProgress(device, { fresh: broken }).fresh).toBe(broken);
});
test("nothing is ever removed, whatever the file says", () => {
// The one guarantee docs/DATA-MODEL.md § Import actually makes.
const device: ProgressMap = {
a: { status: "done", at: at("10") },
b: { note: "later", at: at("11") },
};
expect(Object.keys(mergeProgress(device, {})).sort()).toEqual(["a", "b"]);
});
});