diff --git a/server/main.go b/server/main.go index 0bceb04..0fa8837 100644 --- a/server/main.go +++ b/server/main.go @@ -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 } diff --git a/src/app.js b/src/app.js index f755ce1..da08b9b 100644 --- a/src/app.js +++ b/src/app.js @@ -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); diff --git a/src/changelog.json b/src/changelog.json index c58ff9a..f466f8c 100644 --- a/src/changelog.json +++ b/src/changelog.json @@ -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" },