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 @@
+
@@ -272,6 +273,32 @@
+ + +

Changelog

diff --git a/src/style.css b/src/style.css index afb1654..4975dc9 100644 --- a/src/style.css +++ b/src/style.css @@ -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); }