Add pedigree lookup and ancestry tree page

New 🌳 Pedigree view: enter a dog's ISO chip or SKK registration number
and see its ancestry rendered as a tree. SKK has no public API, so the
server drives SKK Hunddata like a browser: it resolves the input to an
internal hundid via the Hund_sok.aspx/HundData page-method, renders 7
generations per pedigree page, parses the rowspan grid into ahnentafel
positions, and follows each generation's leaves deeper by reading their
hundid out of the __doPostBack response viewstate.

A lookup returns the first 7 generations immediately and crawls deeper in
the background; the client polls and fills the tree in as ancestors
arrive. Finished trees are cached per dog in a new pedigree_cache table
(pedigrees don't change), so a dog is crawled once and repeats are instant.
The endpoints sit behind auth like the rest of /api/*, and the crawl is
kept polite (warmed session, delay between requests, one coalesced job per
dog, hard caps).
This commit is contained in:
Alexander Heldt
2026-07-26 09:22:14 +00:00
parent 374e630d8f
commit 26ebe3bd86
11 changed files with 1458 additions and 1 deletions
+243
View File
@@ -2415,6 +2415,249 @@
settingsDialog.close();
});
// ---------- pedigree lookup ----------
// A separate full-screen view that resolves a dog against SKK Hunddata by
// chip / registration number / name and renders its ancestry as a tree. The
// server returns the first generations immediately and crawls deeper in the
// background; we poll for that and re-render as ancestors arrive. Online-only.
const pedScreen = document.getElementById("pedigree-screen");
const pedForm = document.getElementById("pedigree-form");
const pedQ = document.getElementById("pedigree-q");
const pedStatus = document.getElementById("pedigree-status");
const pedChoose = document.getElementById("pedigree-choose");
const pedSubject = document.getElementById("pedigree-subject");
const pedTree = document.getElementById("pedigree-tree");
const PED_OPEN_DEPTH = 4; // generations shown expanded by default; deeper collapse
let pedPollTimer = null;
let pedNodes = {}; // latest ancestry map, for the progress count
const pedQKey = () => `puppy-tracker:${currentUser.id}:pedigree-q:v1`;
function openPedigree() {
appEl.hidden = true;
pedScreen.hidden = false;
if (!pedQ.value) {
try { pedQ.value = localStorage.getItem(pedQKey()) || ""; } catch { /* ignore */ }
}
setTimeout(() => pedQ.focus(), 50);
}
function closePedigree() {
stopPedPoll();
pedScreen.hidden = true;
appEl.hidden = false;
}
function stopPedPoll() {
if (pedPollTimer) { clearTimeout(pedPollTimer); pedPollTimer = null; }
}
document.getElementById("pedigree-btn").addEventListener("click", openPedigree);
document.getElementById("pedigree-back").addEventListener("click", closePedigree);
pedForm.addEventListener("submit", (e) => {
e.preventDefault();
const q = pedQ.value.trim();
if (!q) return;
try { localStorage.setItem(pedQKey(), q); } catch { /* ignore */ }
lookupPedigree(q);
});
async function lookupPedigree(q) {
stopPedPoll();
pedChoose.hidden = true; pedChoose.textContent = "";
pedSubject.hidden = true; pedSubject.textContent = "";
pedTree.textContent = "";
if (!navigator.onLine) {
setPedStatus("Pedigree lookup needs an internet connection.", "error");
return;
}
setPedStatus("Looking up…", "busy");
let res;
try {
res = await fetch("api/pedigree", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ q }),
});
} catch {
setPedStatus("Couldn't reach the server. Check your connection and try again.", "error");
return;
}
if (res.status === 401) { handleLoggedOut(); return; }
if (res.status === 404) { setPedStatus(`No dog found for “${q}”.`, "error"); return; }
if (!res.ok) {
const msg = (await res.text().catch(() => "")).trim();
setPedStatus(msg || `Lookup failed (HTTP ${res.status}).`, "error");
return;
}
const data = await res.json();
if (data.status === "choose") { renderChoose(data.matches || []); return; }
renderSubject(data.subject);
renderTree(data.nodes || {});
if (data.status === "done") {
setPedDone();
} else {
setPedStatus(`Tracing ancestry… ${pedCountText()}`, "busy");
pollPedigree(data.jobId);
}
}
function pollPedigree(jobId) {
stopPedPoll();
const tick = async () => {
let res;
try { res = await fetch(`api/pedigree/status?job=${encodeURIComponent(jobId)}`); }
catch { pedPollTimer = setTimeout(tick, 3000); return; }
if (res.status === 401) { handleLoggedOut(); return; }
if (!res.ok) { setPedStatus("Lost track of the pedigree crawl.", "error"); return; }
const data = await res.json();
renderTree(data.nodes || {});
if (data.status === "done") { setPedDone(); return; }
if (data.status === "error") { setPedStatus(data.error || "Pedigree crawl failed.", "error"); return; }
setPedStatus(`Tracing ancestry… ${pedCountText()}`, "busy");
pedPollTimer = setTimeout(tick, 1500);
};
pedPollTimer = setTimeout(tick, 1500);
}
// Counts derived from the ancestry map we already hold, so the summary is
// right whether it came from a fresh crawl, a poll, or a cache hit. Distinct
// ancestors are keyed by registration number (pedigree collapse means one dog
// fills many positions); "generations back" is the depth of the deepest
// position (floor(log2(pos)), since sire = 2·pos and dam = 2·pos+1).
function pedCounts() {
const seen = new Set();
let maxPos = 1;
for (const k in pedNodes) {
const n = pedNodes[k];
const key = n.reg || n.name;
if (key) seen.add(key);
const p = Number(k);
if (p > maxPos) maxPos = p;
}
return { distinct: seen.size, gens: Math.floor(Math.log2(maxPos)) };
}
function pedCountText() {
const { distinct: a, gens: g } = pedCounts();
return `${a} ancestor${a === 1 ? "" : "s"} back ${g} generation${g === 1 ? "" : "s"}`;
}
function setPedDone() { setPedStatus(`Traced ${pedCountText()}.`, "done"); }
function setPedStatus(text, kind) {
pedStatus.hidden = false;
pedStatus.textContent = text;
pedStatus.className = "pedigree-status" + (kind ? " " + kind : "");
}
function renderSubject(s) {
if (!s) { pedSubject.hidden = true; return; }
pedSubject.hidden = false;
pedSubject.textContent = "";
const name = document.createElement("div");
name.className = "ped-subject-name";
name.textContent = s.name || "(unnamed)";
const meta = document.createElement("div");
meta.className = "ped-subject-meta";
const bits = [];
if (s.breed) bits.push(s.breed);
if (s.reg) bits.push(s.reg);
if (s.sex) bits.push(s.sex === "H" ? "♂" : s.sex === "T" ? "♀" : s.sex);
meta.textContent = bits.join(" · ");
pedSubject.append(name, meta);
}
function renderChoose(matches) {
pedTree.textContent = "";
pedSubject.hidden = true;
setPedStatus(`${matches.length} matches — pick one:`, "");
pedChoose.hidden = false;
pedChoose.textContent = "";
matches.slice(0, 50).forEach((m) => {
const b = document.createElement("button");
b.type = "button";
b.className = "ped-match";
const nm = (m.hundnamn || "").trim() || "(unnamed)";
const nameEl = document.createElement("span");
nameEl.className = "ped-match-name";
nameEl.textContent = nm;
const metaEl = document.createElement("span");
metaEl.className = "ped-match-meta";
metaEl.textContent = [m.Regnr, m.rastext].filter(Boolean).join(" · ");
b.append(nameEl, metaEl);
b.addEventListener("click", () => {
pedChoose.hidden = true;
const q = (m.Regnr || "").trim() || nm;
pedQ.value = q;
try { localStorage.setItem(pedQKey(), q); } catch { /* ignore */ }
lookupPedigree(q);
});
pedChoose.append(b);
});
}
// 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.
function renderTree(nodes) {
pedNodes = nodes;
pedTree.textContent = "";
const root = buildPedNode(nodes, 1, 0);
if (root) pedTree.append(root);
}
function buildPedNode(nodes, pos, depth) {
const n = nodes[String(pos)];
const hasSire = !!nodes[String(pos * 2)];
const hasDam = !!nodes[String(pos * 2 + 1)];
if (!n && !hasSire && !hasDam) return null;
const wrap = document.createElement("div");
wrap.className = "ped-node";
const card = document.createElement("div");
card.className = "ped-card";
const nameEl = document.createElement("div");
nameEl.className = "ped-name";
nameEl.textContent = n ? (n.name || "(unnamed)") : "Unknown";
if (!n) nameEl.classList.add("ped-unknown");
card.append(nameEl);
if (n && n.titles) {
const t = document.createElement("div");
t.className = "ped-titles";
t.textContent = n.titles;
card.append(t);
}
if (n && n.reg) {
const r = document.createElement("div");
r.className = "ped-reg";
r.textContent = n.reg;
card.append(r);
}
if (hasSire || hasDam) {
const kids = document.createElement("div");
kids.className = "ped-children";
const s = buildPedNode(nodes, pos * 2, depth + 1);
const d = buildPedNode(nodes, pos * 2 + 1, depth + 1);
if (s) { s.classList.add("ped-sire"); kids.append(s); }
if (d) { d.classList.add("ped-dam"); kids.append(d); }
const collapsed = depth >= PED_OPEN_DEPTH;
if (collapsed) wrap.classList.add("collapsed");
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "ped-toggle";
toggle.setAttribute("aria-label", "Toggle ancestors");
toggle.textContent = collapsed ? "+" : "";
toggle.addEventListener("click", () => {
const nowCollapsed = wrap.classList.toggle("collapsed");
toggle.textContent = nowCollapsed ? "+" : "";
});
card.prepend(toggle);
wrap.append(card, kids);
} else {
wrap.append(card);
}
return wrap;
}
// ---------- changelog dialog ----------
// Shows the *loaded* build's full changelog: the plain URL is served
// cache-first by the controlling service worker, so the list always matches