Make the pedigree id sticky across profile sync

The dog id in the synced profile shares one last-write-wins timestamp with
name and birthday, and that timestamp comes from each device's own clock.
A device whose profile was stamped later (clock skew, or a more recent
name/birthday edit) would refuse to adopt a newer server profile that had
just gained an id, or even push its empty id back over the stored one — so
the pedigree id set on one device never reached the others.

Make the id sticky on both sides of sync: an empty value never clears a set
one, and a device/server adopts an id it is missing regardless of the blob
timestamp. When both sides have an id, the newer profile still wins, so the
dog can still be changed. Name and birthday keep plain last-write-wins.
This commit is contained in:
Alexander Heldt
2026-07-26 10:19:01 +00:00
parent c2f74e64c8
commit 86e51bb851
3 changed files with 45 additions and 7 deletions
+16 -3
View File
@@ -92,19 +92,32 @@ func (cs *ConfigStore) get(userID string) Config {
// merge applies an incoming config for one user with last-write-wins by
// UpdatedAt and returns the resulting stored config (which the caller sends back).
func (cs *ConfigStore) merge(userID string, in Config) (Config, error) {
// The upsert's WHERE clause enforces last-write-wins: the incoming row only
// replaces the stored one when it is strictly newer.
// Name/birthday/updated are last-write-wins: the incoming row replaces the
// stored one only when strictly newer. The pedigree id is stickier — an empty
// incoming value never clears a stored one, so a clock race between devices
// can't drop it; when both are set, the newer profile's id wins with the rest.
_, err := cs.db.Exec(`
INSERT INTO config (user_id, name, birthday, pedigree_id, updated)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
name = excluded.name, birthday = excluded.birthday,
pedigree_id = excluded.pedigree_id, updated = excluded.updated
pedigree_id = CASE WHEN excluded.pedigree_id != '' THEN excluded.pedigree_id ELSE config.pedigree_id END,
updated = excluded.updated
WHERE excluded.updated > config.updated`,
userID, in.Name, in.Birthday, in.PedigreeID, in.UpdatedAt)
if err != nil {
return Config{}, err
}
// Adopt a pedigree id the server is missing even from an older-stamped profile,
// so a device that set it isn't blocked by another device's newer name/birthday
// edit. (A set id is only ever changed by a newer profile that also sets one.)
if in.PedigreeID != "" {
if _, err := cs.db.Exec(
`UPDATE config SET pedigree_id = ? WHERE user_id = ? AND pedigree_id = ''`,
in.PedigreeID, userID); err != nil {
return Config{}, err
}
}
return cs.get(userID), nil
}
+28 -4
View File
@@ -230,6 +230,28 @@
localStorage.setItem(configKey(), JSON.stringify(cfg));
}
// Reconcile a local and a server profile. Name/birthday/updatedAt are plain
// last-write-wins by timestamp. The pedigree id is sticky: a non-empty value
// never loses to an empty one — so it can't be dropped by a clock race between
// devices — and when both are set the newer profile's id wins with the rest.
// (The server merge mirrors this, so a set id is only changed, never cleared,
// by sync.)
function reconcileConfig(local, server) {
const base = server.updatedAt >= local.updatedAt ? server : local;
return {
name: base.name,
birthday: base.birthday,
pedigreeId: (local.pedigreeId && server.pedigreeId)
? base.pedigreeId
: (local.pedigreeId || server.pedigreeId),
updatedAt: base.updatedAt,
};
}
function sameConfig(a, b) {
return a.name === b.name && a.birthday === b.birthday
&& a.pedigreeId === b.pedigreeId && a.updatedAt === b.updatedAt;
}
// Age in whole days / weeks / calendar months from a "YYYY-MM-DD" birthday,
// measured at `at` (defaults to now — pass a weigh-in's timestamp for its age
// at that point). Returns null for a missing/invalid birthday or a date before it.
@@ -2040,12 +2062,14 @@
pedigreeId: body.pedigreeId || "",
updatedAt: Number.isFinite(body.updatedAt) ? body.updatedAt : 0,
};
if (server.updatedAt > local.updatedAt) {
saveConfig(server);
const merged = reconcileConfig(local, server);
if (!sameConfig(merged, local)) {
saveConfig(merged);
renderHeader();
refreshPedigreeButton();
} else if (local.updatedAt > server.updatedAt) {
await pushConfig(local);
}
if (!sameConfig(merged, server)) {
await pushConfig(merged);
}
} catch (err) {
console.warn("config sync failed:", err);
+1
View File
@@ -1,4 +1,5 @@
[
{ "date": "2026-07-26", "text": "The pedigree ID set in Settings now syncs reliably to your other devices — it's no longer dropped when two devices' clocks disagree" },
{ "date": "2026-07-26", "text": "New 🌳 Pedigree page: add your dog's SKK chip or registration number in Settings to unlock it, then explore its ancestry as a tree — the first generations show at once and the line fills in further back as it's traced from SKK Hunddata. It's cached, so it reopens instantly and works offline" },
{ "date": "2026-07-24", "text": "The age counter reads \"16 weeks (3 months and 3 weeks) old\" so weeks and months line up; past 4 months it drops the weeks and shows just months (e.g. \"5 months and 2 weeks old\")" },
{ "date": "2026-07-17", "text": "The Sleep trend chart follows the selected day — pick a past day to see its full curve against the day before and the average leading up to it" },