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:
+243
@@ -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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
[
|
||||
{ "date": "2026-07-26", "text": "New 🌳 Pedigree page: look up any dog in SKK Hunddata by chip or registration number and explore its ancestry as a tree — the first generations show at once, then the line fills in further back as it's traced" },
|
||||
{ "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-15", "text": "The Sleep trend y-axis is stretched above 10h, giving the hours around the sleep goal most of the chart" },
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
<div id="puppy-age" class="puppy-age" hidden></div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" id="pedigree-btn" class="ghost icon-btn" aria-label="Pedigree" title="Pedigree">🌳</button>
|
||||
<button type="button" id="settings-btn" class="ghost icon-btn" aria-label="Settings" title="Settings">⚙️</button>
|
||||
<button type="button" id="logout-btn" class="ghost icon-btn" aria-label="Log out" title="Log out">🚪</button>
|
||||
<div id="online-status" class="status-pill"></div>
|
||||
@@ -272,6 +273,32 @@
|
||||
</footer>
|
||||
</div><!-- /#app -->
|
||||
|
||||
<!-- Pedigree lookup. A distinct full-screen view (hides #app while open)
|
||||
that resolves a dog by chip / registration number / name against SKK
|
||||
and renders its ancestry as a tree. Online-only. -->
|
||||
<div id="pedigree-screen" class="pedigree-screen" hidden>
|
||||
<header class="pedigree-header">
|
||||
<button type="button" id="pedigree-back" class="ghost icon-btn" aria-label="Back to tracker" title="Back">←</button>
|
||||
<h1>🌳 Pedigree</h1>
|
||||
</header>
|
||||
<main class="pedigree-main">
|
||||
<form id="pedigree-form" class="pedigree-form">
|
||||
<input type="text" id="pedigree-q" autocomplete="off" spellcheck="false"
|
||||
placeholder="Chip or registration number" aria-label="Dog chip or registration number" />
|
||||
<button type="submit" id="pedigree-lookup">Look up</button>
|
||||
</form>
|
||||
<p class="muted-note pedigree-hint">
|
||||
Looks up a dog in <strong>SKK Hunddata</strong> by ISO chip number
|
||||
(e.g. 752095600044144) or registration number (e.g. SE23536/2026),
|
||||
then traces its ancestry. Needs an internet connection.
|
||||
</p>
|
||||
<p id="pedigree-status" class="pedigree-status" hidden></p>
|
||||
<div id="pedigree-choose" class="pedigree-choose" hidden></div>
|
||||
<div id="pedigree-subject" class="pedigree-subject" hidden></div>
|
||||
<div id="pedigree-tree" class="pedigree-tree"></div>
|
||||
</main>
|
||||
</div><!-- /#pedigree-screen -->
|
||||
|
||||
<dialog id="changelog-dialog">
|
||||
<form method="dialog" id="changelog-form">
|
||||
<h3>Changelog</h3>
|
||||
|
||||
+158
@@ -1053,3 +1053,161 @@ section.collapsible > h2::after {
|
||||
section.collapsed > h2::after { transform: translateY(-50%) rotate(-90deg); }
|
||||
section.collapsed > h2 { margin-bottom: 0; }
|
||||
section.collapsed > :not(h2) { display: none; }
|
||||
|
||||
/* ---------- pedigree lookup ---------- */
|
||||
.pedigree-screen {
|
||||
padding-top: 12px;
|
||||
}
|
||||
.pedigree-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 4px 8px;
|
||||
}
|
||||
.pedigree-header h1 { font-size: 1.4rem; }
|
||||
|
||||
.pedigree-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 8px 0 6px;
|
||||
}
|
||||
.pedigree-form input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
font-size: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
.pedigree-form button {
|
||||
flex: 0 0 auto;
|
||||
padding: 10px 16px;
|
||||
font-size: 1rem;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pedigree-hint { margin: 4px 0 12px; }
|
||||
|
||||
.pedigree-status {
|
||||
margin: 10px 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.pedigree-status.busy::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 12px; height: 12px;
|
||||
margin-right: 8px;
|
||||
vertical-align: -1px;
|
||||
border: 2px solid var(--accent);
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: ped-spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes ped-spin { to { transform: rotate(360deg); } }
|
||||
.pedigree-status.error { color: var(--danger); }
|
||||
.pedigree-status.done { color: var(--gain); }
|
||||
|
||||
/* disambiguation list */
|
||||
.pedigree-choose {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 8px 0 16px;
|
||||
}
|
||||
.ped-match {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ped-match:hover { border-color: var(--accent); }
|
||||
.ped-match-name { font-weight: 600; }
|
||||
.ped-match-meta { font-size: 0.8rem; color: var(--muted); }
|
||||
|
||||
/* looked-up dog */
|
||||
.pedigree-subject {
|
||||
margin: 6px 0 14px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-left: 4px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
.ped-subject-name { font-size: 1.15rem; font-weight: 700; }
|
||||
.ped-subject-meta { font-size: 0.85rem; color: var(--muted); margin-top: 2px; }
|
||||
|
||||
/* the tree */
|
||||
.pedigree-tree {
|
||||
overflow-x: auto;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
.ped-node { position: relative; }
|
||||
.ped-card {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
padding: 6px 24px 6px 28px;
|
||||
margin: 3px 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.ped-name { font-weight: 600; font-size: 0.95rem; }
|
||||
.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-reg {
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* lineage spine: children indented under the card */
|
||||
.ped-children {
|
||||
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-dam > .ped-card::after {
|
||||
position: absolute;
|
||||
right: 7px;
|
||||
top: 6px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.ped-sire > .ped-card::after { content: "♂"; color: var(--sleep); }
|
||||
.ped-dam > .ped-card::after { content: "♀"; color: var(--training); }
|
||||
|
||||
/* expand/collapse toggle */
|
||||
.ped-toggle {
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 18px; height: 18px;
|
||||
padding: 0;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: var(--bg);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ped-toggle:hover { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
Reference in New Issue
Block a user