Compare commits

...

3 Commits

Author SHA1 Message Date
Alexander Heldt 01d64b682b Flag pedigree collapse with repeat badges
When a dog fills more than one position in the pedigree (pedigree collapse),
mark each copy with a ×N badge and a stable per-dog colour, and let tapping
any copy highlight every place that dog appears. Identity is the registration
number (falling back to name), so the same ancestor at different positions is
treated as one. The summary line also reports how many ancestors recur.
2026-07-26 10:39:51 +00:00
Alexander Heldt 8157e95066 Render the pedigree top-down as a family tree
Replace the indented ancestry list with a top-down chart: the dog on top,
sire (left) and dam (right) branching below, connected by lines drawn with
each node's CSS ::before/::after (a nested <ul>/<li> tree). It shows three
generations by default and each dog expands to trace the line further back;
deeper levels get wide, so the view scrolls horizontally.
2026-07-26 10:39:28 +00:00
Alexander Heldt 86e51bb851 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.
2026-07-26 10:19:01 +00:00
5 changed files with 225 additions and 53 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 // 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). // UpdatedAt and returns the resulting stored config (which the caller sends back).
func (cs *ConfigStore) merge(userID string, in Config) (Config, error) { func (cs *ConfigStore) merge(userID string, in Config) (Config, error) {
// The upsert's WHERE clause enforces last-write-wins: the incoming row only // Name/birthday/updated are last-write-wins: the incoming row replaces the
// replaces the stored one when it is strictly newer. // 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(` _, err := cs.db.Exec(`
INSERT INTO config (user_id, name, birthday, pedigree_id, updated) INSERT INTO config (user_id, name, birthday, pedigree_id, updated)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET ON CONFLICT(user_id) DO UPDATE SET
name = excluded.name, birthday = excluded.birthday, 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`, WHERE excluded.updated > config.updated`,
userID, in.Name, in.Birthday, in.PedigreeID, in.UpdatedAt) userID, in.Name, in.Birthday, in.PedigreeID, in.UpdatedAt)
if err != nil { if err != nil {
return Config{}, err 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 return cs.get(userID), nil
} }
+104 -22
View File
@@ -230,6 +230,28 @@
localStorage.setItem(configKey(), JSON.stringify(cfg)); 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, // 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 // 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. // at that point). Returns null for a missing/invalid birthday or a date before it.
@@ -2040,12 +2062,14 @@
pedigreeId: body.pedigreeId || "", pedigreeId: body.pedigreeId || "",
updatedAt: Number.isFinite(body.updatedAt) ? body.updatedAt : 0, updatedAt: Number.isFinite(body.updatedAt) ? body.updatedAt : 0,
}; };
if (server.updatedAt > local.updatedAt) { const merged = reconcileConfig(local, server);
saveConfig(server); if (!sameConfig(merged, local)) {
saveConfig(merged);
renderHeader(); renderHeader();
refreshPedigreeButton(); refreshPedigreeButton();
} else if (local.updatedAt > server.updatedAt) { }
await pushConfig(local); if (!sameConfig(merged, server)) {
await pushConfig(merged);
} }
} catch (err) { } catch (err) {
console.warn("config sync failed:", err); console.warn("config sync failed:", err);
@@ -2434,8 +2458,9 @@
const pedTree = document.getElementById("pedigree-tree"); const pedTree = document.getElementById("pedigree-tree");
const pedBtn = document.getElementById("pedigree-btn"); const pedBtn = document.getElementById("pedigree-btn");
const pedRefresh = document.getElementById("pedigree-refresh"); const pedRefresh = document.getElementById("pedigree-refresh");
const pedRepeatNote = document.getElementById("pedigree-repeat-note");
const PED_OPEN_DEPTH = 4; // generations shown expanded by default; deeper collapse const PED_OPEN_DEPTH = 3; // show 3 generations expanded by default; deeper collapses
let pedPollTimer = null; let pedPollTimer = null;
let pedNodes = {}; // latest ancestry map, for the progress count let pedNodes = {}; // latest ancestry map, for the progress count
@@ -2556,20 +2581,24 @@
// fills many positions); "generations back" is the depth of the deepest // fills many positions); "generations back" is the depth of the deepest
// position (floor(log2(pos)), since sire = 2·pos and dam = 2·pos+1). // position (floor(log2(pos)), since sire = 2·pos and dam = 2·pos+1).
function pedCounts() { function pedCounts() {
const seen = new Set(); const counts = new Map();
let maxPos = 1; let maxPos = 1;
for (const k in pedNodes) { for (const k in pedNodes) {
const n = pedNodes[k]; const n = pedNodes[k];
const key = n.reg || n.name; const key = n.reg || n.name;
if (key) seen.add(key); if (key) counts.set(key, (counts.get(key) || 0) + 1);
const p = Number(k); const p = Number(k);
if (p > maxPos) maxPos = p; if (p > maxPos) maxPos = p;
} }
return { distinct: seen.size, gens: Math.floor(Math.log2(maxPos)) }; let repeated = 0;
counts.forEach((c) => { if (c > 1) repeated++; });
return { distinct: counts.size, repeated, gens: Math.floor(Math.log2(maxPos)) };
} }
function pedCountText() { function pedCountText() {
const { distinct: a, gens: g } = pedCounts(); const { distinct: a, repeated: r, gens: g } = pedCounts();
return `${a} ancestor${a === 1 ? "" : "s"} back ${g} generation${g === 1 ? "" : "s"}`; let s = `${a} ancestor${a === 1 ? "" : "s"} back ${g} generation${g === 1 ? "" : "s"}`;
if (r > 0) s += `, ${r} appearing more than once`;
return s;
} }
function setPedDone() { setPedStatus(`Traced ${pedCountText()}.`, "done"); } function setPedDone() { setPedStatus(`Traced ${pedCountText()}.`, "done"); }
function setPedStatus(text, kind) { function setPedStatus(text, kind) {
@@ -2622,12 +2651,49 @@
} }
// The tree is ahnentafel-indexed: the dog is position 1, its sire 2n and dam // The tree is ahnentafel-indexed: the dog is position 1, its sire 2n and dam
// 2n+1. We build recursively (sire above dam) and collapse below PED_OPEN_DEPTH. // 2n+1. We render it top-down like a family tree — the dog on top, parents
// branching below — as a nested <ul>/<li> so CSS can draw the connectors.
// Built recursively (sire left, dam right) and collapsed below PED_OPEN_DEPTH.
// A dog's identity for spotting pedigree collapse: its registration number,
// or its name when it has none. Ancestors sharing a key are the same dog.
function dogKey(n) { return n ? (n.reg || n.name || "") : ""; }
// Stable hue per repeated dog so its badge/highlight colour is consistent
// everywhere it appears.
function hueFor(s) {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
return h % 360;
}
let pedRepeat = {}; // dogKey -> occurrence count, for the current tree
function renderTree(nodes) { function renderTree(nodes) {
pedNodes = nodes; pedNodes = nodes;
pedTree.textContent = ""; pedTree.textContent = "";
// Count how many positions each dog fills, to flag pedigree collapse.
pedRepeat = {};
for (const k in nodes) {
const id = dogKey(nodes[k]);
if (id) pedRepeat[id] = (pedRepeat[id] || 0) + 1;
}
const anyRepeat = Object.values(pedRepeat).some((c) => c > 1);
if (pedRepeatNote) pedRepeatNote.hidden = !anyRepeat;
const root = buildPedNode(nodes, 1, 0); const root = buildPedNode(nodes, 1, 0);
if (root) pedTree.append(root); if (!root) return;
const ul = document.createElement("ul");
ul.className = "ped-tree-h";
ul.append(root);
pedTree.append(ul);
}
// Highlight (or unhighlight) every card that is the same dog as `key`.
function togglePedHighlight(key) {
const cards = pedTree.querySelectorAll(".ped-card[data-dogkey]");
let lit = false;
cards.forEach((c) => {
if (c.dataset.dogkey === key && c.classList.contains("ped-lit")) lit = true;
});
cards.forEach((c) => c.classList.remove("ped-lit"));
if (!lit) cards.forEach((c) => { if (c.dataset.dogkey === key) c.classList.add("ped-lit"); });
} }
function buildPedNode(nodes, pos, depth) { function buildPedNode(nodes, pos, depth) {
@@ -2636,8 +2702,7 @@
const hasDam = !!nodes[String(pos * 2 + 1)]; const hasDam = !!nodes[String(pos * 2 + 1)];
if (!n && !hasSire && !hasDam) return null; if (!n && !hasSire && !hasDam) return null;
const wrap = document.createElement("div"); const li = document.createElement("li");
wrap.className = "ped-node";
const card = document.createElement("div"); const card = document.createElement("div");
card.className = "ped-card"; card.className = "ped-card";
@@ -2659,31 +2724,48 @@
card.append(r); card.append(r);
} }
// Mark dogs that fill more than one position (pedigree collapse). A ×N badge
// shows how many times, a stable colour ties the copies together, and tapping
// the card lights up every place this dog appears.
const key = dogKey(n);
if (key && pedRepeat[key] > 1) {
card.classList.add("ped-repeat");
card.dataset.dogkey = key;
card.style.setProperty("--repeat-hue", hueFor(key));
const badge = document.createElement("span");
badge.className = "ped-repeat-badge";
badge.textContent = "×" + pedRepeat[key];
badge.title = "Appears " + pedRepeat[key] + " times in this pedigree — tap to highlight them all";
card.append(badge);
card.addEventListener("click", (e) => {
if (e.target.closest(".ped-toggle")) return; // let the expander do its job
togglePedHighlight(key);
});
}
li.append(card);
if (hasSire || hasDam) { if (hasSire || hasDam) {
const kids = document.createElement("div"); const kids = document.createElement("ul");
kids.className = "ped-children";
const s = buildPedNode(nodes, pos * 2, depth + 1); const s = buildPedNode(nodes, pos * 2, depth + 1);
const d = buildPedNode(nodes, pos * 2 + 1, depth + 1); const d = buildPedNode(nodes, pos * 2 + 1, depth + 1);
if (s) { s.classList.add("ped-sire"); kids.append(s); } if (s) { s.classList.add("ped-sire"); kids.append(s); }
if (d) { d.classList.add("ped-dam"); kids.append(d); } if (d) { d.classList.add("ped-dam"); kids.append(d); }
const collapsed = depth >= PED_OPEN_DEPTH; const collapsed = depth >= PED_OPEN_DEPTH;
if (collapsed) wrap.classList.add("collapsed"); if (collapsed) li.classList.add("collapsed");
const toggle = document.createElement("button"); const toggle = document.createElement("button");
toggle.type = "button"; toggle.type = "button";
toggle.className = "ped-toggle"; toggle.className = "ped-toggle";
toggle.setAttribute("aria-label", "Toggle ancestors"); toggle.setAttribute("aria-label", "Toggle ancestors");
toggle.textContent = collapsed ? "+" : ""; toggle.textContent = collapsed ? "+" : "";
toggle.addEventListener("click", () => { toggle.addEventListener("click", () => {
const nowCollapsed = wrap.classList.toggle("collapsed"); const nowCollapsed = li.classList.toggle("collapsed");
toggle.textContent = nowCollapsed ? "+" : ""; toggle.textContent = nowCollapsed ? "+" : "";
}); });
card.prepend(toggle); card.prepend(toggle);
wrap.append(card, kids); li.append(kids);
} else {
wrap.append(card);
} }
return wrap; return li;
} }
// ---------- changelog dialog ---------- // ---------- changelog dialog ----------
+3
View File
@@ -1,4 +1,7 @@
[ [
{ "date": "2026-07-26", "text": "In the pedigree, a dog that fills more than one spot (pedigree collapse, common in a breed's older lines) now carries a ×N badge — tap it to highlight every place that dog appears in the tree" },
{ "date": "2026-07-26", "text": "The pedigree now reads top-down like a family tree — your dog on top with its sire and dam branching below — showing three generations at a glance, with each dog expandable to trace the line further back" },
{ "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-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-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" }, { "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" },
+1
View File
@@ -291,6 +291,7 @@
<p id="pedigree-status" class="pedigree-status" hidden></p> <p id="pedigree-status" class="pedigree-status" hidden></p>
<div id="pedigree-choose" class="pedigree-choose" hidden></div> <div id="pedigree-choose" class="pedigree-choose" hidden></div>
<div id="pedigree-subject" class="pedigree-subject" hidden></div> <div id="pedigree-subject" class="pedigree-subject" hidden></div>
<p id="pedigree-repeat-note" class="muted-note pedigree-repeat-note" hidden>Some ancestors appear in more than one place further back (pedigree collapse). Expand the tree to reveal their ×N badges, then tap one to highlight every spot that dog appears.</p>
<div id="pedigree-tree" class="pedigree-tree"></div> <div id="pedigree-tree" class="pedigree-tree"></div>
</main> </main>
</div><!-- /#pedigree-screen --> </div><!-- /#pedigree-screen -->
+101 -28
View File
@@ -1123,59 +1123,132 @@ section.collapsed > :not(h2) { display: none; }
.ped-subject-name { font-size: 1.15rem; font-weight: 700; } .ped-subject-name { font-size: 1.15rem; font-weight: 700; }
.ped-subject-meta { font-size: 0.85rem; color: var(--muted); margin-top: 2px; } .ped-subject-meta { font-size: 0.85rem; color: var(--muted); margin-top: 2px; }
/* the tree */ /* Top-down family tree: the dog on top, parents branching below, connected by
lines drawn with each <li>'s ::before/::after (the classic CSS tree). Wider
than the screen once expanded, so the container scrolls horizontally. */
.pedigree-tree { .pedigree-tree {
overflow-x: auto; overflow-x: auto;
padding-bottom: 24px; padding: 8px 0 28px;
} }
.ped-node { position: relative; } .ped-tree-h, .ped-tree-h ul {
display: flex;
list-style: none;
margin: 0;
padding: 0;
}
.ped-tree-h {
/* "safe" centers when the tree fits and falls back to start-aligned (no
clipped/unreachable left edge) once it's wider than the screen. */
justify-content: safe center;
min-width: max-content;
padding: 4px 16px 12px;
}
.ped-tree-h ul {
justify-content: center;
position: relative;
padding-top: 22px; /* room for the connector from the parent above */
}
.ped-tree-h li {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
padding: 22px 6px 0;
}
/* Elbow from each child up to the horizontal bar shared by its siblings. */
.ped-tree-h li::before,
.ped-tree-h li::after {
content: "";
position: absolute;
top: 0;
width: 50%;
height: 22px;
border-top: 2px solid var(--border);
}
.ped-tree-h li::before { right: 50%; }
.ped-tree-h li::after { left: 50%; border-left: 2px solid var(--border); }
/* Vertical drop from a parent card down to its children's bar. */
.ped-tree-h ul::before {
content: "";
position: absolute;
top: 0;
left: 50%;
height: 22px;
border-left: 2px solid var(--border);
}
/* A lone parent connects with a straight line, no elbow. */
.ped-tree-h li:only-child::before,
.ped-tree-h li:only-child::after { display: none; }
/* Trim the outer half-lines at the ends of a sibling row. */
.ped-tree-h li:first-child::before,
.ped-tree-h li:last-child::after { border: 0 none; }
.ped-tree-h li:last-child::before { border-right: 2px solid var(--border); }
/* The dog sits on top with no connector above it. */
.ped-tree-h > li { padding-top: 0; }
.ped-tree-h > li::before,
.ped-tree-h > li::after { display: none; }
/* Collapsed: hide the ancestry below this dog (and its connectors go with it). */
.ped-tree-h li.collapsed > ul { display: none; }
.ped-card { .ped-card {
position: relative; position: relative;
display: inline-block; width: 140px;
padding: 6px 24px 6px 28px; box-sizing: border-box;
margin: 3px 0; padding: 7px 18px 7px 20px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 10px; border-radius: 10px;
background: var(--surface); background: var(--surface);
box-shadow: var(--shadow); box-shadow: var(--shadow);
text-align: center;
} }
.ped-name { font-weight: 600; font-size: 0.95rem; } .ped-name { font-weight: 600; font-size: 0.85rem; line-height: 1.2; }
.ped-name.ped-unknown { color: var(--muted); font-weight: 500; font-style: italic; } .ped-name.ped-unknown { color: var(--muted); font-weight: 500; font-style: italic; }
.ped-titles { font-size: 0.72rem; color: var(--accent); margin-top: 1px; } .ped-titles { font-size: 0.66rem; color: var(--accent); margin-top: 2px; line-height: 1.2; }
.ped-reg { .ped-reg {
font-size: 0.72rem; font-size: 0.66rem;
color: var(--muted); color: var(--muted);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
margin-top: 1px; margin-top: 1px;
} }
/* sire ♂ (blue) / dam ♀ (purple) accents on the parent cards */
/* lineage spine: children indented under the card */ .ped-sire > .ped-card { border-top: 3px solid var(--sleep); }
.ped-children { .ped-dam > .ped-card { border-top: 3px solid var(--training); }
margin-left: 16px;
padding-left: 14px;
border-left: 2px solid var(--border);
}
.ped-node.collapsed > .ped-children { display: none; }
/* sire ♂ (blue) above dam ♀ (purple) markers on the parent-role cards */
.ped-sire > .ped-card { border-left: 3px solid var(--sleep); }
.ped-dam > .ped-card { border-left: 3px solid var(--training); }
.ped-sire > .ped-card::after, .ped-sire > .ped-card::after,
.ped-dam > .ped-card::after { .ped-dam > .ped-card::after {
position: absolute; position: absolute;
right: 7px; right: 6px;
top: 6px; top: 5px;
font-size: 0.75rem; font-size: 0.72rem;
} }
.ped-sire > .ped-card::after { content: "♂"; color: var(--sleep); } .ped-sire > .ped-card::after { content: "♂"; color: var(--sleep); }
.ped-dam > .ped-card::after { content: "♀"; color: var(--training); } .ped-dam > .ped-card::after { content: "♀"; color: var(--training); }
/* expand/collapse toggle */ /* pedigree collapse: a dog filling more than one position gets a ×N badge, a
stable hue, and lights up (with every copy) when tapped. */
.ped-card.ped-repeat { cursor: pointer; }
.ped-repeat-badge {
position: absolute;
right: 5px;
bottom: 5px;
font-size: 0.6rem;
font-weight: 700;
line-height: 1;
padding: 2px 5px;
border-radius: 999px;
color: #fff;
background: hsl(var(--repeat-hue, 0), 58%, 48%);
}
.ped-card.ped-lit {
border-color: hsl(var(--repeat-hue, 0), 70%, 50%);
box-shadow: 0 0 0 2px hsl(var(--repeat-hue, 0), 70%, 50%), var(--shadow);
}
.pedigree-repeat-note { margin: 0 0 10px; }
/* expand/collapse toggle (top-left of the card) */
.ped-toggle { .ped-toggle {
position: absolute; position: absolute;
left: 6px; left: 5px;
top: 50%; top: 5px;
transform: translateY(-50%);
width: 18px; height: 18px; width: 18px; height: 18px;
padding: 0; padding: 0;
font-size: 0.9rem; font-size: 0.9rem;