diff --git a/README.md b/README.md
index ae3c833..82822ac 100644
--- a/README.md
+++ b/README.md
@@ -94,6 +94,22 @@ events, profile and photos.
when you pass `-secure-cookies` (enable it behind a TLS proxy), so passwords
aren't sent in the clear.
+## Pedigree lookup
+
+The 🌳 page looks up a dog in **SKK Hunddata** by ISO chip number or
+registration number and renders its ancestry as a tree.
+
+- SKK has no public API, so the server drives the interactive site the way a
+ browser would: it resolves the input to SKK's internal dog id, fetches the
+ pedigree page (7 generations per request), and follows each generation's leaves
+ deeper. A lookup returns the first generations immediately and keeps crawling in
+ the background; the client polls and fills the tree in as ancestors arrive.
+- Because a deep crawl is dozens of sequential upstream requests, finished trees
+ are cached per dog in the `pedigree_cache` table (pedigrees don't change), so a
+ dog is only ever crawled once and repeat lookups are instant.
+- The lookup is behind auth like the rest of `/api/*`, and is **online-only** —
+ it needs to reach SKK.
+
## Use it on NixOS
In your system flake:
diff --git a/flake.nix b/flake.nix
index 0da1ed8..84a6b58 100644
--- a/flake.nix
+++ b/flake.nix
@@ -26,7 +26,7 @@
pname = "puppy-tracker-server";
version = "0.2.0";
src = ./server;
- vendorHash = "sha256-z9Kf7i4WfLAHmceRi8T42+uMitjxEzr0pmOn+STpsAU=";
+ vendorHash = "sha256-J1lYhwbaRh2PeAh3SzyB9WgUZa1gCNXBWdaJ5isUedA=";
# Pure-Go build for a tiny static binary.
env.CGO_ENABLED = "0";
ldflags = [ "-s" "-w" ];
diff --git a/server/go.mod b/server/go.mod
index 0baa3c3..86a155f 100644
--- a/server/go.mod
+++ b/server/go.mod
@@ -4,6 +4,7 @@ go 1.25.0
require (
golang.org/x/crypto v0.54.0
+ golang.org/x/net v0.57.0
modernc.org/sqlite v1.53.0
)
diff --git a/server/go.sum b/server/go.sum
index 5456856..b4ef0d4 100644
--- a/server/go.sum
+++ b/server/go.sum
@@ -16,6 +16,8 @@ golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
+golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
+golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
diff --git a/server/htmlutil.go b/server/htmlutil.go
new file mode 100644
index 0000000..c467de2
--- /dev/null
+++ b/server/htmlutil.go
@@ -0,0 +1,102 @@
+package main
+
+// Small helpers over golang.org/x/net/html for walking the SKK pedigree markup.
+
+import (
+ "strings"
+
+ "golang.org/x/net/html"
+)
+
+func attr(n *html.Node, key string) string {
+ for _, a := range n.Attr {
+ if a.Key == key {
+ return a.Val
+ }
+ }
+ return ""
+}
+
+// findByID returns the first element in the tree with the given id attribute.
+func findByID(n *html.Node, id string) *html.Node {
+ if n.Type == html.ElementNode && attr(n, "id") == id {
+ return n
+ }
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ if got := findByID(c, id); got != nil {
+ return got
+ }
+ }
+ return nil
+}
+
+// descendants returns every element with the given tag anywhere under n, in
+// document order.
+func descendants(n *html.Node, tag string) []*html.Node {
+ var out []*html.Node
+ var walk func(*html.Node)
+ walk = func(x *html.Node) {
+ for c := x.FirstChild; c != nil; c = c.NextSibling {
+ if c.Type == html.ElementNode && c.Data == tag {
+ out = append(out, c)
+ }
+ walk(c)
+ }
+ }
+ walk(n)
+ return out
+}
+
+// directChildElements returns the immediate element children of n with the tag.
+func directChildElements(n *html.Node, tag string) []*html.Node {
+ var out []*html.Node
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ if c.Type == html.ElementNode && c.Data == tag {
+ out = append(out, c)
+ }
+ }
+ return out
+}
+
+// findElement returns the first descendant element with the given tag.
+func findElement(n *html.Node, tag string) *html.Node {
+ els := descendants(n, tag)
+ if len(els) == 0 {
+ return nil
+ }
+ return els[0]
+}
+
+// text concatenates all text under n.
+func text(n *html.Node) string {
+ var sb strings.Builder
+ var walk func(*html.Node)
+ walk = func(x *html.Node) {
+ if x.Type == html.TextNode {
+ sb.WriteString(x.Data)
+ }
+ for c := x.FirstChild; c != nil; c = c.NextSibling {
+ walk(c)
+ }
+ }
+ walk(n)
+ return sb.String()
+}
+
+// normalizeText trims and collapses internal whitespace.
+func normalizeText(s string) string {
+ return strings.TrimSpace(wsRE.ReplaceAllString(s, " "))
+}
+
+// findBoldSpan returns the normalized text of the first bold (how subject
+// cells carry the registration number), or "".
+func findBoldSpan(n *html.Node) string {
+ for _, sp := range descendants(n, "span") {
+ if strings.Contains(strings.ReplaceAll(attr(sp, "style"), " ", ""), "font-weight:bold") {
+ if t := normalizeText(text(sp)); t != "" {
+ return t
+ }
+ }
+ }
+ return ""
+}
diff --git a/server/main.go b/server/main.go
index dafb505..be5a17a 100644
--- a/server/main.go
+++ b/server/main.go
@@ -311,6 +311,13 @@ func openDB(path string) (*sql.DB, error) {
user_id TEXT NOT NULL,
created INTEGER NOT NULL,
expires INTEGER NOT NULL
+ );
+ CREATE TABLE IF NOT EXISTS pedigree_cache (
+ hundid TEXT PRIMARY KEY,
+ subject TEXT NOT NULL DEFAULT '',
+ nodes TEXT NOT NULL DEFAULT '',
+ generations INTEGER NOT NULL DEFAULT 0,
+ fetched INTEGER NOT NULL DEFAULT 0
);`
if _, err := db.Exec(schema); err != nil {
db.Close()
@@ -600,6 +607,7 @@ func main() {
store := newStore(db)
configStore := newConfigStore(db)
exerciseStore := newExerciseStore(db)
+ pedigrees := newPedManager(db)
photosDir := filepath.Join(filepath.Dir(*dataPath), "photos")
if err := os.MkdirAll(photosDir, 0o755); err != nil {
@@ -711,6 +719,24 @@ func main() {
}
}))
+ // POST /api/pedigree — resolve a dog by chip / registration number / name and
+ // return its ancestry tree (immediately for the first generations, then a
+ // background crawl deepens it). GET /api/pedigree/status polls that crawl.
+ mux.HandleFunc("/api/pedigree", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ pedigrees.handleLookup(w, r)
+ }))
+ mux.HandleFunc("/api/pedigree/status", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ pedigrees.handleStatus(w, r)
+ }))
+
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
diff --git a/server/pedigree.go b/server/pedigree.go
new file mode 100644
index 0000000..26ff5ca
--- /dev/null
+++ b/server/pedigree.go
@@ -0,0 +1,881 @@
+package main
+
+// Pedigree lookup: resolve a dog by chip / registration number / name against
+// SKK (Svenska Kennelklubben) HUNDDATA, then crawl its ancestry and expose it as
+// an ahnentafel-positioned tree. SKK has no public API, so this scrapes the
+// interactive ASP.NET WebForms app the same way a browser drives it:
+//
+// 1. Resolve — POST Hund_sok.aspx/HundData (a JSON page-method) → hundid.
+// 2. Fetch — GET Hund_Stamtavla.aspx?hundid=X, then POST ddlGenerationer=7
+// to render 7 generations in one page; parse its rowspan grid.
+// 3. Deepen — each generation-7 leaf links via __doPostBack; POST that link
+// and read the ancestor's hundid out of the response __VIEWSTATE,
+// then recurse. BFS terminates when the ancestry runs out.
+//
+// A deep crawl is dozens of sequential requests, so a lookup returns the first
+// 7 generations immediately and keeps crawling in the background; the finished
+// tree is cached per hundid (pedigrees don't change) so a dog is crawled once.
+
+import (
+ "bytes"
+ "context"
+ "database/sql"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "net/http/cookiejar"
+ "net/url"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "golang.org/x/net/html"
+)
+
+const (
+ skkBase = "https://hundar.skk.se/hunddata/"
+ skkUA = "Mozilla/5.0 (X11; Linux x86_64) puppy-tracker pedigree lookup"
+ skkDelay = 250 * time.Millisecond // politeness between upstream requests
+ crawlGens = 7 // generations SKK renders per page
+
+ maxCrawlPages = 400 // hard caps so a crawl can never run away
+ maxCrawlRequests = 3000
+ crawlDeadline = 5 * time.Minute
+ maxActiveJobs = 4 // concurrent background crawls, total
+ firstPageWait = 20 * time.Second
+)
+
+// pedNode is one dog at an ahnentafel position (1 = subject, sire = 2n, dam = 2n+1).
+type pedNode struct {
+ Reg string `json:"reg,omitempty"`
+ Name string `json:"name,omitempty"`
+ Titles string `json:"titles,omitempty"`
+ Hundid string `json:"hundid,omitempty"`
+}
+
+// pedSubject is the looked-up dog's headline info, from the resolver row.
+type pedSubject struct {
+ Hundid string `json:"hundid"`
+ Reg string `json:"reg"`
+ Name string `json:"name"`
+ Breed string `json:"breed"`
+ Chip string `json:"chip"`
+ Sex string `json:"sex,omitempty"`
+}
+
+// skkClient is a single browser-like session against SKK (cookie jar + UA).
+type skkClient struct {
+ hc *http.Client
+}
+
+func newSKKClient() (*skkClient, error) {
+ jar, err := cookiejar.New(nil)
+ if err != nil {
+ return nil, err
+ }
+ return &skkClient{hc: &http.Client{Jar: jar, Timeout: 30 * time.Second}}, nil
+}
+
+func (c *skkClient) do(req *http.Request) (*http.Response, error) {
+ req.Header.Set("User-Agent", skkUA)
+ return c.hc.Do(req)
+}
+
+// warm establishes an ASP.NET session (SessionId + anti-XSRF cookies) that the
+// resolver and pedigree pages both require.
+func (c *skkClient) warm(ctx context.Context) error {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, skkBase+"Hund_sok.aspx", nil)
+ if err != nil {
+ return err
+ }
+ resp, err := c.do(req)
+ if err != nil {
+ return err
+ }
+ io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
+ resp.Body.Close()
+ return nil
+}
+
+// hundDataRow mirrors the fields the resolver page-method returns.
+type hundDataRow struct {
+ Hundid string `json:"hundid"`
+ Regnr string `json:"Regnr"`
+ Hundnamn string `json:"hundnamn"`
+ Chipnr string `json:"chipnr"`
+ Rastext string `json:"rastext"`
+ Kon string `json:"Kon"`
+ IDnummer string `json:"IDnummer"`
+ Antal string `json:"Antal"`
+ IsError bool `json:"IsError"`
+ ErrorText string `json:"ErrorText"`
+}
+
+var digitsRE = regexp.MustCompile(`^\d+$`)
+
+// resolve turns a user query (chip number, registration number, or name) into
+// matching dogs. The field is chosen by shape: a long all-digit string is a
+// chip; anything with a letter or slash is a registration number; otherwise a
+// name search (which may return several rows to disambiguate).
+func (c *skkClient) resolve(ctx context.Context, q string) ([]hundDataRow, error) {
+ body := map[string]string{
+ "txtRegnr": "", "txtIDnummer": "", "txtChipnr": "",
+ "txtHundnamn": "", "ddlRasIn": "", "ddlKon": "", "txtLicensnr": "",
+ }
+ switch {
+ case digitsRE.MatchString(q) && len(q) >= 10:
+ body["txtChipnr"] = q
+ case strings.ContainsAny(q, "/") || strings.IndexFunc(q, isLetter) >= 0:
+ body["txtRegnr"] = q
+ default:
+ body["txtHundnamn"] = q
+ }
+ buf, _ := json.Marshal(body)
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost,
+ skkBase+"Hund_sok.aspx/HundData", bytes.NewReader(buf))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "application/json;charset=utf-8")
+ req.Header.Set("Referer", skkBase+"Hund_sok.aspx")
+ resp, err := c.do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("resolver HTTP %d", resp.StatusCode)
+ }
+ var wrap struct {
+ D []hundDataRow `json:"d"`
+ }
+ if err := json.Unmarshal(raw, &wrap); err != nil {
+ return nil, fmt.Errorf("resolver response: %w", err)
+ }
+ // SKK signals "no matches" (and other soft failures like a query needing more
+ // input) via a single IsError row rather than an HTTP error. Treat it as an
+ // empty result so the caller reports a clean "not found" instead of a 502.
+ if len(wrap.D) == 1 && wrap.D[0].IsError {
+ return nil, nil
+ }
+ return wrap.D, nil
+}
+
+func isLetter(r rune) bool {
+ return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
+}
+
+// fetchPage GETs a dog's pedigree then POSTs ddlGenerationer=7 (with titles on)
+// to render 7 generations, returning that page's HTML. The returned HTML is used
+// for both grid parsing and the __doPostBack calls that resolve its leaf dogs,
+// so its hidden fields (viewstate / event validation) match its ctl ids.
+func (c *skkClient) fetchPage(ctx context.Context, hundid string) (string, string, error) {
+ pageURL := skkBase + "Hund_Stamtavla.aspx?hundid=" + url.QueryEscape(hundid)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, nil)
+ if err != nil {
+ return "", pageURL, err
+ }
+ req.Header.Set("Referer", skkBase+"Hund_sok.aspx")
+ resp, err := c.do(req)
+ if err != nil {
+ return "", pageURL, err
+ }
+ raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
+ resp.Body.Close()
+ if err != nil {
+ return "", pageURL, err
+ }
+ first := string(raw)
+
+ form := hiddenFields(first)
+ form.Set("ctl00$bodyContent$ddlGenerationer", strconv.Itoa(crawlGens))
+ form.Set("ctl00$bodyContent$ddlTitlar", "J")
+ form.Set("__EVENTTARGET", "ctl00$bodyContent$ddlGenerationer")
+ form.Set("__EVENTARGUMENT", "")
+ html7, err := c.postForm(ctx, pageURL, form)
+ if err != nil {
+ return "", pageURL, err
+ }
+ return html7, pageURL, nil
+}
+
+func (c *skkClient) postForm(ctx context.Context, pageURL string, form url.Values) (string, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, pageURL,
+ strings.NewReader(form.Encode()))
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Referer", pageURL)
+ resp, err := c.do(req)
+ if err != nil {
+ return "", err
+ }
+ raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
+ resp.Body.Close()
+ if err != nil {
+ return "", err
+ }
+ return string(raw), nil
+}
+
+var (
+ viewstateRE = regexp.MustCompile(`name="__VIEWSTATE" id="__VIEWSTATE" value="([^"]+)"`)
+ // The clicked dog's internal hundid, encoded in the response viewstate as the
+ // string "hundid", a type byte (\x05), a length byte, then ASCII digits.
+ vsHundidRE = regexp.MustCompile(`(?s)hundid\x05.(\d+)`)
+)
+
+// postbackHundid clicks an ancestor's __doPostBack link on the given page and
+// recovers that ancestor's hundid from the response viewstate. The rendered
+// pedigree table never re-roots on such a click, but the viewstate carries the
+// clicked dog's id — which is exactly the handle needed to fetch its own page.
+func (c *skkClient) postbackHundid(ctx context.Context, pageHTML, ctlid, pageURL string) (string, error) {
+ form := hiddenFields(pageHTML)
+ form.Set("ctl00$bodyContent$ddlGenerationer", strconv.Itoa(crawlGens))
+ form.Set("ctl00$bodyContent$ddlTitlar", "J")
+ form.Set("__EVENTTARGET", ctlid)
+ form.Set("__EVENTARGUMENT", "")
+ resp, err := c.postForm(ctx, pageURL, form)
+ if err != nil {
+ return "", err
+ }
+ m := viewstateRE.FindStringSubmatch(resp)
+ if m == nil {
+ return "", nil
+ }
+ dec := decodeB64(m[1])
+ mm := vsHundidRE.FindSubmatch(dec)
+ if mm == nil {
+ return "", nil
+ }
+ return string(mm[1]), nil
+}
+
+func decodeB64(s string) []byte {
+ if m := len(s) % 4; m != 0 {
+ s += strings.Repeat("=", 4-m)
+ }
+ b, err := base64.StdEncoding.DecodeString(s)
+ if err != nil {
+ return nil
+ }
+ return b
+}
+
+// hiddenFields collects every on a page into a form value
+// set, so an ASP.NET postback can echo back __VIEWSTATE / __EVENTVALIDATION etc.
+func hiddenFields(pageHTML string) url.Values {
+ vals := url.Values{}
+ node, err := html.Parse(strings.NewReader(pageHTML))
+ if err != nil {
+ return vals
+ }
+ var walk func(*html.Node)
+ walk = func(n *html.Node) {
+ if n.Type == html.ElementNode && n.Data == "input" {
+ var typ, name, val string
+ for _, a := range n.Attr {
+ switch a.Key {
+ case "type":
+ typ = a.Val
+ case "name":
+ name = a.Val
+ case "value":
+ val = a.Val
+ }
+ }
+ if typ == "hidden" && name != "" {
+ vals.Set(name, val)
+ }
+ }
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ walk(c)
+ }
+ }
+ walk(node)
+ return vals
+}
+
+// gridCell is a parsed pedigree table cell.
+type gridCell struct {
+ reg, name, titles, ctlid string
+ occupied bool
+}
+
+var doPostBackRE = regexp.MustCompile(`__doPostBack\('([^']+)'`)
+var wsRE = regexp.MustCompile(`\s+`)
+
+// parseGrid reconstructs the rowspan-based pedigree table into columns. Column c
+// holds 2^c cells top-to-bottom; a cell's index within its column is its
+// ahnentafel offset. Returns column index -> ordered cells.
+func parseGrid(pageHTML string) map[int][]gridCell {
+ node, err := html.Parse(strings.NewReader(pageHTML))
+ if err != nil {
+ return nil
+ }
+ tbl := findByID(node, "bodyContent_tblStamtavla")
+ if tbl == nil {
+ return nil
+ }
+ occ := map[[2]int]bool{}
+ type placed struct {
+ r, c int
+ cell gridCell
+ }
+ var placedCells []placed
+ r := 0
+ for _, tr := range descendants(tbl, "tr") {
+ c := 0
+ for _, td := range directChildElements(tr, "td") {
+ for occ[[2]int{r, c}] {
+ c++
+ }
+ rs := 1
+ if v := attr(td, "rowspan"); v != "" {
+ if n, err := strconv.Atoi(v); err == nil && n > 0 {
+ rs = n
+ }
+ }
+ for dr := 0; dr < rs; dr++ {
+ occ[[2]int{r + dr, c}] = true
+ }
+ placedCells = append(placedCells, placed{r, c, parseCell(td)})
+ c++
+ }
+ r++
+ }
+ byCol := map[int][]placed{}
+ for _, p := range placedCells {
+ byCol[p.c] = append(byCol[p.c], p)
+ }
+ out := map[int][]gridCell{}
+ for col, lst := range byCol {
+ sort.SliceStable(lst, func(i, j int) bool { return lst[i].r < lst[j].r })
+ cells := make([]gridCell, len(lst))
+ for i, p := range lst {
+ cells[i] = p.cell
+ }
+ out[col] = cells
+ }
+ return out
+}
+
+// parseCell extracts reg / name / titles / ctlid from one
, mirroring how
+// SKK marks them up: a holds the reg number (subject cells use
+// a bold instead), a holds championship titles, and the last plain
+// holds the dog's name. "Uppgift saknas" placeholders are left unoccupied.
+func parseCell(td *html.Node) gridCell {
+ var cell gridCell
+ if a := findElement(td, "a"); a != nil {
+ if href := attr(a, "href"); strings.Contains(href, "__doPostBack") {
+ cell.reg = normalizeText(text(a))
+ if m := doPostBackRE.FindStringSubmatch(html.UnescapeString(href)); m != nil {
+ cell.ctlid = m[1]
+ }
+ }
+ }
+ if f := findElement(td, "font"); f != nil {
+ cell.titles = normalizeText(text(f))
+ }
+ // Name: the last whose text isn't the titles string. Subject cells put
+ // the reg in a leading bold span; if we found no link reg, adopt it.
+ for _, sp := range descendants(td, "span") {
+ t := normalizeText(text(sp))
+ if t == "" || t == cell.titles {
+ continue
+ }
+ cell.name = t
+ }
+ if cell.reg == "" {
+ if b := findBoldSpan(td); b != "" {
+ cell.reg = b
+ if cell.name == b {
+ cell.name = ""
+ }
+ }
+ }
+ if strings.EqualFold(cell.name, "Uppgift saknas") {
+ cell.name = ""
+ }
+ cell.occupied = cell.reg != "" || cell.name != ""
+ return cell
+}
+
+// crawlProgress is the mutable snapshot a running crawl publishes.
+type crawlProgress struct {
+ Pages int
+ Distinct int
+ MaxGen int
+ Nodes map[string]pedNode
+}
+
+// crawl performs the breadth-first ancestry walk from a subject hundid, calling
+// report after each page with a fresh snapshot. It places every dog at its global
+// ahnentafel position and resolves each generation-7 leaf to a hundid to recurse.
+func (c *skkClient) crawl(ctx context.Context, hundid string, report func(crawlProgress)) (map[string]pedNode, error) {
+ tree := map[string]pedNode{}
+ edges := map[string]string{} // subjectHundid|ctlid -> ancestor hundid
+ done := map[string]bool{}
+ type qitem struct {
+ hundid string
+ basePos uint64
+ }
+ queue := []qitem{{hundid, 1}}
+ pages, requests, maxGen := 0, 0, 0
+
+ snapshot := func() crawlProgress {
+ nodes := make(map[string]pedNode, len(tree))
+ for k, v := range tree {
+ nodes[k] = v
+ }
+ return crawlProgress{Pages: pages, Distinct: countDistinct(tree), MaxGen: maxGen, Nodes: nodes}
+ }
+
+ for len(queue) > 0 {
+ if err := ctx.Err(); err != nil {
+ return tree, err
+ }
+ if pages >= maxCrawlPages || requests >= maxCrawlRequests {
+ log.Printf("pedigree crawl %s: hit cap (pages=%d requests=%d)", hundid, pages, requests)
+ break
+ }
+ item := queue[0]
+ queue = queue[1:]
+ if done[item.hundid] {
+ continue
+ }
+ done[item.hundid] = true
+
+ time.Sleep(skkDelay)
+ pageHTML, pageURL, err := c.fetchPage(ctx, item.hundid)
+ requests += 2
+ if err != nil {
+ if pages == 0 {
+ return tree, err // couldn't even fetch the subject
+ }
+ log.Printf("pedigree crawl %s: fetch %s failed: %v", hundid, item.hundid, err)
+ continue
+ }
+ pages++
+ grid := parseGrid(pageHTML)
+
+ var frontier []struct {
+ gpos uint64
+ ctlid string
+ }
+ for col := 0; col <= crawlGens-1; col++ {
+ cells, ok := grid[col]
+ if !ok {
+ continue
+ }
+ for pos, cell := range cells {
+ if !cell.occupied {
+ continue
+ }
+ gpos := item.basePos*(1< maxGen {
+ maxGen = g
+ }
+ if col == crawlGens-1 && cell.ctlid != "" {
+ frontier = append(frontier, struct {
+ gpos uint64
+ ctlid string
+ }{gpos, cell.ctlid})
+ }
+ }
+ }
+ report(snapshot())
+
+ for _, f := range frontier {
+ if err := ctx.Err(); err != nil {
+ return tree, err
+ }
+ if requests >= maxCrawlRequests {
+ break
+ }
+ ekey := item.hundid + "|" + f.ctlid
+ hid, ok := edges[ekey]
+ if !ok {
+ time.Sleep(skkDelay)
+ hid, err = c.postbackHundid(ctx, pageHTML, f.ctlid, pageURL)
+ requests++
+ if err != nil {
+ continue
+ }
+ edges[ekey] = hid
+ }
+ if hid != "" {
+ key := strconv.FormatUint(f.gpos, 10)
+ node := tree[key]
+ node.Hundid = hid
+ tree[key] = node
+ queue = append(queue, qitem{hid, f.gpos})
+ }
+ }
+ }
+ report(snapshot())
+ return tree, nil
+}
+
+func bitsLen(x uint64) int {
+ n := 0
+ for x > 0 {
+ n++
+ x >>= 1
+ }
+ return n
+}
+
+// ---- job manager ---------------------------------------------------------
+
+type jobState string
+
+const (
+ jobRunning jobState = "running"
+ jobDone jobState = "done"
+ jobError jobState = "error"
+)
+
+type pedJob struct {
+ id string
+ hundid string
+ subject pedSubject
+ firstPage chan struct{} // closed once the subject's own page is parsed
+
+ mu sync.Mutex
+ state jobState
+ pages int
+ distinct int
+ maxGen int
+ nodes map[string]pedNode
+ errMsg string
+}
+
+func (j *pedJob) apply(p crawlProgress) {
+ j.mu.Lock()
+ j.pages, j.distinct, j.maxGen, j.nodes = p.Pages, p.Distinct, p.MaxGen, p.Nodes
+ j.mu.Unlock()
+}
+
+type pedManager struct {
+ db *sql.DB
+
+ mu sync.Mutex
+ jobs map[string]*pedJob // keyed by hundid (coalesces duplicate lookups)
+}
+
+func newPedManager(db *sql.DB) *pedManager {
+ return &pedManager{db: db, jobs: map[string]*pedJob{}}
+}
+
+func (m *pedManager) activeCount() int {
+ n := 0
+ for _, j := range m.jobs {
+ j.mu.Lock()
+ if j.state == jobRunning {
+ n++
+ }
+ j.mu.Unlock()
+ }
+ return n
+}
+
+// startOrAttach returns the running/finished job for a hundid, or starts a new
+// background crawl. The boolean reports whether a fresh job was created.
+func (m *pedManager) startOrAttach(client *skkClient, subject pedSubject) (*pedJob, bool, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if j, ok := m.jobs[subject.Hundid]; ok {
+ return j, false, nil
+ }
+ if m.activeCount() >= maxActiveJobs {
+ return nil, false, errors.New("busy: too many pedigree lookups in progress, try again shortly")
+ }
+ j := &pedJob{
+ id: subject.Hundid,
+ hundid: subject.Hundid,
+ subject: subject,
+ firstPage: make(chan struct{}),
+ state: jobRunning,
+ nodes: map[string]pedNode{},
+ }
+ m.jobs[subject.Hundid] = j
+ go m.run(client, j)
+ return j, true, nil
+}
+
+func (m *pedManager) run(client *skkClient, j *pedJob) {
+ ctx, cancel := context.WithTimeout(context.Background(), crawlDeadline)
+ defer cancel()
+
+ firstDone := false
+ report := func(p crawlProgress) {
+ j.apply(p)
+ if !firstDone && p.Pages >= 1 {
+ firstDone = true
+ close(j.firstPage)
+ }
+ }
+ nodes, err := client.crawl(ctx, j.hundid, report)
+ if !firstDone {
+ close(j.firstPage) // unblock waiters even if the very first fetch failed
+ }
+ j.mu.Lock()
+ if err != nil && len(nodes) == 0 {
+ j.state = jobError
+ j.errMsg = err.Error()
+ } else {
+ j.state = jobDone
+ }
+ j.mu.Unlock()
+
+ if len(nodes) > 0 {
+ m.persist(j.subject, nodes)
+ }
+}
+
+// snapshot copies a job's current public state under its lock.
+func (j *pedJob) snapshot() (jobState, int, int, int, map[string]pedNode, string) {
+ j.mu.Lock()
+ defer j.mu.Unlock()
+ nodes := make(map[string]pedNode, len(j.nodes))
+ for k, v := range j.nodes {
+ nodes[k] = v
+ }
+ return j.state, j.pages, j.distinct, j.maxGen, nodes, j.errMsg
+}
+
+// ---- persistent cache ----------------------------------------------------
+
+func (m *pedManager) persist(subject pedSubject, nodes map[string]pedNode) {
+ sj, _ := json.Marshal(subject)
+ nj, _ := json.Marshal(nodes)
+ _, err := m.db.Exec(`
+ INSERT INTO pedigree_cache (hundid, subject, nodes, generations, fetched)
+ VALUES (?, ?, ?, ?, ?)
+ ON CONFLICT(hundid) DO UPDATE SET
+ subject = excluded.subject, nodes = excluded.nodes,
+ generations = excluded.generations, fetched = excluded.fetched`,
+ subject.Hundid, string(sj), string(nj), maxGenerations(nodes), time.Now().UnixMilli())
+ if err != nil {
+ log.Printf("pedigree cache save %s: %v", subject.Hundid, err)
+ }
+}
+
+// cached returns a stored tree for a hundid, if present.
+func (m *pedManager) cached(hundid string) (pedSubject, map[string]pedNode, bool) {
+ var sj, nj string
+ err := m.db.QueryRow(
+ `SELECT subject, nodes FROM pedigree_cache WHERE hundid = ?`, hundid,
+ ).Scan(&sj, &nj)
+ if err != nil {
+ if !errors.Is(err, sql.ErrNoRows) {
+ log.Printf("pedigree cache get %s: %v", hundid, err)
+ }
+ return pedSubject{}, nil, false
+ }
+ var subject pedSubject
+ var nodes map[string]pedNode
+ json.Unmarshal([]byte(sj), &subject)
+ json.Unmarshal([]byte(nj), &nodes)
+ return subject, nodes, true
+}
+
+func maxGenerations(nodes map[string]pedNode) int {
+ max := 0
+ for k := range nodes {
+ if p, err := strconv.ParseUint(k, 10, 64); err == nil {
+ if g := bitsLen(p); g > max {
+ max = g
+ }
+ }
+ }
+ return max
+}
+
+// ---- HTTP handlers -------------------------------------------------------
+
+type pedLookupResponse struct {
+ Status string `json:"status"` // done | running | choose
+ JobID string `json:"jobId,omitempty"`
+ Hundid string `json:"hundid,omitempty"`
+ Subject *pedSubject `json:"subject,omitempty"`
+ Generations int `json:"generations,omitempty"`
+ Nodes map[string]pedNode `json:"nodes,omitempty"`
+ Matches []hundDataRow `json:"matches,omitempty"`
+}
+
+func rowToSubject(r hundDataRow) pedSubject {
+ return pedSubject{
+ Hundid: r.Hundid,
+ Reg: strings.TrimSpace(r.Regnr),
+ Name: strings.TrimSpace(r.Hundnamn),
+ Breed: strings.TrimSpace(r.Rastext),
+ Chip: strings.TrimSpace(r.Chipnr),
+ Sex: strings.TrimSpace(r.Kon),
+ }
+}
+
+// handleLookup resolves a query and returns a cached tree, a disambiguation list,
+// or an immediate 7-generation tree with a background job crawling deeper.
+func (m *pedManager) handleLookup(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Q string `json:"q"`
+ }
+ if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
+ http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+ q := strings.TrimSpace(req.Q)
+ if q == "" {
+ http.Error(w, "empty query", http.StatusBadRequest)
+ return
+ }
+
+ client, err := newSKKClient()
+ if err != nil {
+ http.Error(w, "server error", http.StatusInternalServerError)
+ return
+ }
+ ctx := r.Context()
+ if err := client.warm(ctx); err != nil {
+ http.Error(w, "upstream unavailable", http.StatusBadGateway)
+ return
+ }
+ rows, err := client.resolve(ctx, q)
+ if err != nil {
+ http.Error(w, "lookup failed: "+err.Error(), http.StatusBadGateway)
+ return
+ }
+ rows = withHundid(rows)
+ switch {
+ case len(rows) == 0:
+ http.Error(w, "no dog found for "+q, http.StatusNotFound)
+ return
+ case len(rows) > 1:
+ writeJSON(w, pedLookupResponse{Status: "choose", Matches: rows})
+ return
+ }
+ subject := rowToSubject(rows[0])
+
+ if subj, nodes, ok := m.cached(subject.Hundid); ok {
+ writeJSON(w, pedLookupResponse{
+ Status: "done", Hundid: subj.Hundid, Subject: &subj,
+ Generations: maxGenerations(nodes), Nodes: nodes,
+ })
+ return
+ }
+
+ job, _, err := m.startOrAttach(client, subject)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusServiceUnavailable)
+ return
+ }
+ select {
+ case <-job.firstPage:
+ case <-time.After(firstPageWait):
+ case <-ctx.Done():
+ return
+ }
+ state, _, _, gen, nodes, msg := job.snapshot()
+ if state == jobError {
+ http.Error(w, "pedigree fetch failed: "+msg, http.StatusBadGateway)
+ return
+ }
+ status := "running"
+ if state == jobDone {
+ status = "done"
+ }
+ writeJSON(w, pedLookupResponse{
+ Status: status, JobID: job.id, Hundid: subject.Hundid,
+ Subject: &subject, Generations: gen, Nodes: nodes,
+ })
+}
+
+type pedStatusResponse struct {
+ Status string `json:"status"`
+ Pages int `json:"pages"`
+ Distinct int `json:"distinct"`
+ Generations int `json:"generations"`
+ Nodes map[string]pedNode `json:"nodes,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+// handleStatus returns a running crawl's current partial tree so the client can
+// fill the view in progressively, and the final tree when it finishes.
+func (m *pedManager) handleStatus(w http.ResponseWriter, r *http.Request) {
+ id := r.URL.Query().Get("job")
+ m.mu.Lock()
+ job := m.jobs[id]
+ m.mu.Unlock()
+ if job == nil {
+ // A finished job may have been evicted, but the cache still has the tree.
+ if _, nodes, ok := m.cached(id); ok {
+ writeJSON(w, pedStatusResponse{
+ Status: "done", Generations: maxGenerations(nodes),
+ Distinct: countDistinct(nodes), Nodes: nodes, Pages: 0,
+ })
+ return
+ }
+ http.Error(w, "unknown job", http.StatusNotFound)
+ return
+ }
+ state, pages, distinct, gen, nodes, msg := job.snapshot()
+ writeJSON(w, pedStatusResponse{
+ Status: string(state), Pages: pages, Distinct: distinct,
+ Generations: gen, Nodes: nodes, Error: msg,
+ })
+}
+
+// countDistinct counts unique ancestors, keyed by registration number (falling
+// back to name). Pedigree collapse means one dog can fill many positions, so
+// this is smaller than the number of occupied positions.
+func countDistinct(nodes map[string]pedNode) int {
+ seen := map[string]bool{}
+ for _, n := range nodes {
+ k := n.Reg
+ if k == "" {
+ k = n.Name
+ }
+ if k != "" {
+ seen[k] = true
+ }
+ }
+ return len(seen)
+}
+
+func writeJSON(w http.ResponseWriter, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "no-store")
+ _ = json.NewEncoder(w).Encode(v)
+}
+
+// withHundid drops resolver rows lacking a usable hundid (defensive).
+func withHundid(rows []hundDataRow) []hundDataRow {
+ out := rows[:0]
+ for _, r := range rows {
+ if strings.TrimSpace(r.Hundid) != "" {
+ out = append(out, r)
+ }
+ }
+ return out
+}
diff --git a/src/app.js b/src/app.js
index cfb77f3..885722e 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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
diff --git a/src/changelog.json b/src/changelog.json
index 199ba67..f54077d 100644
--- a/src/changelog.json
+++ b/src/changelog.json
@@ -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" },
diff --git a/src/index.html b/src/index.html
index 4c631b0..aec3357 100644
--- a/src/index.html
+++ b/src/index.html
@@ -70,6 +70,7 @@
+
+
+
+
+
+
+ Looks up a dog in SKK Hunddata by ISO chip number
+ (e.g. 752095600044144) or registration number (e.g. SE23536/2026),
+ then traces its ancestry. Needs an internet connection.
+
+
+
+
+
+
+
+
|