Compare commits
20 Commits
a1a6ec8720
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f9894abfc9 | |||
| 09d9d38c12 | |||
| a44c75d2d4 | |||
| e2f99590f1 | |||
| 52c50c97b1 | |||
| 1ed325b834 | |||
| 69e312175b | |||
| a2c9aa9716 | |||
| 12a5bd0548 | |||
| 01d64b682b | |||
| 8157e95066 | |||
| 86e51bb851 | |||
| c2f74e64c8 | |||
| 26ebe3bd86 | |||
| 374e630d8f | |||
| 22a5ea76aa | |||
| 0ebaa11c93 | |||
| 897311465c | |||
| 8fe8f9d417 | |||
| c24d59e672 |
@@ -94,6 +94,26 @@ 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
|
||||
|
||||
Set your dog's SKK chip or registration number in **Settings** (it rides the
|
||||
synced profile, next to name and birthday). Once set, a 🌳 button appears that
|
||||
opens a page rendering that dog's ancestry as a tree.
|
||||
|
||||
- SKK has no public API, so the server drives the interactive site the way a
|
||||
browser would: it resolves the id 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), and
|
||||
the id→dog resolution is memoised, so a dog is only ever crawled once and repeat
|
||||
opens hit SKK zero times. The client also mirrors the finished tree in
|
||||
`localStorage`, so the page paints instantly and shows the last-known tree even
|
||||
offline.
|
||||
- The lookup is behind auth like the rest of `/api/*`; the first trace of a new
|
||||
dog needs to reach SKK, but after that it works from cache (including offline).
|
||||
|
||||
## Use it on NixOS
|
||||
|
||||
In your system flake:
|
||||
|
||||
@@ -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" ];
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 <span> (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 ""
|
||||
}
|
||||
+75
-18
@@ -28,9 +28,9 @@ type Event struct {
|
||||
Type string `json:"type"`
|
||||
At int64 `json:"at"`
|
||||
Note string `json:"note"`
|
||||
PhotoID string `json:"photoId,omitempty"` // photo UUIDs, comma-separated (legacy events hold one)
|
||||
Weight float64 `json:"weight,omitempty"` // kilograms, for "weight" events
|
||||
Grams float64 `json:"grams,omitempty"` // food eaten, for "eat" events
|
||||
PhotoID string `json:"photoId,omitempty"` // photo UUIDs, comma-separated (legacy events hold one)
|
||||
Weight float64 `json:"weight,omitempty"` // kilograms, for "weight" events
|
||||
Grams float64 `json:"grams,omitempty"` // food eaten, for "eat" events
|
||||
ExerciseID string `json:"exerciseId,omitempty"` // for "training" events
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
Deleted bool `json:"deleted,omitempty"`
|
||||
@@ -60,9 +60,12 @@ func validBirthday(s string) bool { return s == "" || birthdayRE.MatchString(s)
|
||||
// every client sees the same values without configuring each device. UpdatedAt
|
||||
// drives last-write-wins, mirroring how events sync.
|
||||
type Config struct {
|
||||
Name string `json:"name"`
|
||||
Birthday string `json:"birthday"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
Name string `json:"name"`
|
||||
Birthday string `json:"birthday"`
|
||||
// PedigreeID is the dog's SKK chip or registration number. When set, the app
|
||||
// unlocks the pedigree view and looks this dog up; empty means no pedigree.
|
||||
PedigreeID string `json:"pedigreeId"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type ConfigStore struct {
|
||||
@@ -78,8 +81,8 @@ func (cs *ConfigStore) get(userID string) Config {
|
||||
// One profile row per user. A missing row is the pre-configuration state,
|
||||
// so a zero-value Config is the right answer.
|
||||
err := cs.db.QueryRow(
|
||||
`SELECT name, birthday, updated FROM config WHERE user_id = ?`, userID,
|
||||
).Scan(&c.Name, &c.Birthday, &c.UpdatedAt)
|
||||
`SELECT name, birthday, pedigree_id, updated FROM config WHERE user_id = ?`, userID,
|
||||
).Scan(&c.Name, &c.Birthday, &c.PedigreeID, &c.UpdatedAt)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
log.Printf("config get: %v", err)
|
||||
}
|
||||
@@ -89,18 +92,32 @@ func (cs *ConfigStore) get(userID string) Config {
|
||||
// merge applies an incoming config for one user with last-write-wins by
|
||||
// UpdatedAt and returns the resulting stored config (which the caller sends back).
|
||||
func (cs *ConfigStore) merge(userID string, in Config) (Config, error) {
|
||||
// The upsert's WHERE clause enforces last-write-wins: the incoming row only
|
||||
// replaces the stored one when it is strictly newer.
|
||||
// Name/birthday/updated are last-write-wins: the incoming row replaces the
|
||||
// stored one only when strictly newer. The pedigree id is stickier — an empty
|
||||
// incoming value never clears a stored one, so a clock race between devices
|
||||
// can't drop it; when both are set, the newer profile's id wins with the rest.
|
||||
_, err := cs.db.Exec(`
|
||||
INSERT INTO config (user_id, name, birthday, updated)
|
||||
VALUES (?, ?, ?, ?)
|
||||
INSERT INTO config (user_id, name, birthday, pedigree_id, updated)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
name = excluded.name, birthday = excluded.birthday, updated = excluded.updated
|
||||
name = excluded.name, birthday = excluded.birthday,
|
||||
pedigree_id = CASE WHEN excluded.pedigree_id != '' THEN excluded.pedigree_id ELSE config.pedigree_id END,
|
||||
updated = excluded.updated
|
||||
WHERE excluded.updated > config.updated`,
|
||||
userID, in.Name, in.Birthday, in.UpdatedAt)
|
||||
userID, in.Name, in.Birthday, in.PedigreeID, in.UpdatedAt)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
// Adopt a pedigree id the server is missing even from an older-stamped profile,
|
||||
// so a device that set it isn't blocked by another device's newer name/birthday
|
||||
// edit. (A set id is only ever changed by a newer profile that also sets one.)
|
||||
if in.PedigreeID != "" {
|
||||
if _, err := cs.db.Exec(
|
||||
`UPDATE config SET pedigree_id = ? WHERE user_id = ? AND pedigree_id = ''`,
|
||||
in.PedigreeID, userID); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
}
|
||||
return cs.get(userID), nil
|
||||
}
|
||||
|
||||
@@ -295,10 +312,11 @@ func openDB(path string) (*sql.DB, error) {
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_exercises_user ON exercises(user_id);
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
birthday TEXT NOT NULL DEFAULT '',
|
||||
updated INTEGER NOT NULL DEFAULT 0
|
||||
user_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
birthday TEXT NOT NULL DEFAULT '',
|
||||
pedigree_id TEXT NOT NULL DEFAULT '',
|
||||
updated INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -311,6 +329,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()
|
||||
@@ -384,6 +409,15 @@ func migrateSchema(db *sql.DB) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
hasPedigree, err := columnExists(db, "config", "pedigree_id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasPedigree {
|
||||
if _, err := db.Exec(`ALTER TABLE config ADD COLUMN pedigree_id TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -600,6 +634,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 {
|
||||
@@ -695,6 +730,10 @@ func main() {
|
||||
if len(in.Name) > 100 {
|
||||
in.Name = in.Name[:100]
|
||||
}
|
||||
in.PedigreeID = strings.TrimSpace(in.PedigreeID)
|
||||
if len(in.PedigreeID) > 64 {
|
||||
in.PedigreeID = in.PedigreeID[:64]
|
||||
}
|
||||
if !validBirthday(in.Birthday) {
|
||||
http.Error(w, "invalid birthday (want YYYY-MM-DD)", http.StatusBadRequest)
|
||||
return
|
||||
@@ -711,6 +750,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"))
|
||||
})
|
||||
|
||||
@@ -0,0 +1,912 @@
|
||||
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 <input type=hidden> 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 <td>, mirroring how
|
||||
// SKK marks them up: a <a __doPostBack> holds the reg number (subject cells use
|
||||
// a bold <span> instead), a <font> holds championship titles, and the last plain
|
||||
// <span> 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 <span> 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<<uint(col)) + uint64(pos)
|
||||
key := strconv.FormatUint(gpos, 10)
|
||||
node := tree[key]
|
||||
node.Reg, node.Name, node.Titles = cell.reg, cell.name, cell.titles
|
||||
if col == 0 {
|
||||
node.Hundid = item.hundid
|
||||
}
|
||||
tree[key] = node
|
||||
if g := bitsLen(gpos); g > 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)
|
||||
|
||||
resolveMu sync.Mutex
|
||||
resolved map[string]string // query -> hundid, so a cache hit skips SKK entirely
|
||||
}
|
||||
|
||||
func newPedManager(db *sql.DB) *pedManager {
|
||||
return &pedManager{db: db, jobs: map[string]*pedJob{}, resolved: map[string]string{}}
|
||||
}
|
||||
|
||||
func (m *pedManager) rememberResolve(q, hundid string) {
|
||||
if q == "" || hundid == "" {
|
||||
return
|
||||
}
|
||||
m.resolveMu.Lock()
|
||||
m.resolved[q] = hundid
|
||||
m.resolveMu.Unlock()
|
||||
}
|
||||
|
||||
func (m *pedManager) resolvedHundid(q string) string {
|
||||
m.resolveMu.Lock()
|
||||
defer m.resolveMu.Unlock()
|
||||
return m.resolved[q]
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Fast path: if we've resolved this query before and its tree is cached, serve
|
||||
// it without contacting SKK at all (repeat opens of your own dog's pedigree).
|
||||
if hundid := m.resolvedHundid(q); hundid != "" {
|
||||
if subj, nodes, ok := m.cached(hundid); ok {
|
||||
writeJSON(w, pedLookupResponse{
|
||||
Status: "done", Hundid: subj.Hundid, Subject: &subj,
|
||||
Generations: maxGenerations(nodes), Nodes: nodes,
|
||||
})
|
||||
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])
|
||||
m.rememberResolve(q, subject.Hundid)
|
||||
|
||||
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
|
||||
}
|
||||
+892
-56
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,21 @@
|
||||
[
|
||||
{ "date": "2026-08-02", "text": "Added free-text notes: tap 📝 Note to jot down things that happened on a day — vaccinations, vet visits, milestones — with a date, optional photo, and any text. All your notes are collected in a new Notes section that stays visible whatever day you're viewing, so you can see at a glance when things like a tick vaccination were done" },
|
||||
{ "date": "2026-08-02", "text": "The Daily counts chart now has Pees / Poos / Meals checkboxes so you can focus on just the metrics you care about — untick the rest to see, say, only poos; your choice is remembered" },
|
||||
{ "date": "2026-08-01", "text": "Logging a pee or poo now sets off 💧/💩 fireworks that shoot up from the bottom of the screen — a little celebration you can switch off in Settings (and it honours a reduced-motion preference)" },
|
||||
{ "date": "2026-08-01", "text": "Tidied the header on long names and ages — the name now truncates instead of shoving the buttons, and the age reads as a compact \"16 wk · 3 mo 3 wk\"; weight-log rows are a single line again (\"Aug 1 · 16 wk\")" },
|
||||
{ "date": "2026-07-26", "text": "Added a fan-chart view of the pedigree (toggle it in the header): your dog at the centre with each generation fanning outward as a ring, so many generations fit at once without the tree sprawling sideways — tap a wedge for that dog, and repeated ancestors keep their colour" },
|
||||
{ "date": "2026-07-26", "text": "Added a Collapse all / Expand all toggle to the pedigree, to fold the whole tree down to your dog or open every branch at once" },
|
||||
{ "date": "2026-07-26", "text": "The pedigree is now zoomable — use the +/− buttons, ⌘/Ctrl-scroll, or pinch on a phone — to fit a wide tree on screen or zoom in for detail; your zoom level is remembered" },
|
||||
{ "date": "2026-07-26", "text": "In the pedigree, a dog that fills more than one spot (pedigree collapse, common in a breed's older lines) now carries a ×N badge — tap it to highlight every place that dog appears in the tree" },
|
||||
{ "date": "2026-07-26", "text": "The pedigree now reads top-down like a family tree — your dog on top with its sire and dam branching below — showing three generations at a glance, with each dog expandable to trace the line further back" },
|
||||
{ "date": "2026-07-26", "text": "The pedigree ID set in Settings now syncs reliably to your other devices — it's no longer dropped when two devices' clocks disagree" },
|
||||
{ "date": "2026-07-26", "text": "New 🌳 Pedigree page: add your dog's SKK chip or registration number in Settings to unlock it, then explore its ancestry as a tree — the first generations show at once and the line fills in further back as it's traced from SKK Hunddata. It's cached, so it reopens instantly and works offline" },
|
||||
{ "date": "2026-07-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" },
|
||||
{ "date": "2026-07-15", "text": "The Sleep trend chart shows the sleep goal for your puppy's age as a shaded band — the projection chip gets a ✓ when today is on track" },
|
||||
{ "date": "2026-07-15", "text": "Logging a training session while viewing another day puts it on that day (the snackbar tells you where it went)" },
|
||||
{ "date": "2026-07-15", "text": "The Sleep trend yesterday line is orange instead of gray, which was hard to see" },
|
||||
{ "date": "2026-07-15", "text": "The Sleep trend average line is teal now, so it doesn't blend in with today's blue line and its projection" },
|
||||
{ "date": "2026-07-15", "text": "Tapping a day in a chart selects it without jumping down to the history" },
|
||||
{ "date": "2026-07-15", "text": "Meals with an amount logged show their grams in the day's history" },
|
||||
|
||||
+57
-6
@@ -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" hidden>🌳</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>
|
||||
@@ -112,6 +113,7 @@
|
||||
<button class="action pee" data-type="pee">💧 Pee</button>
|
||||
<button class="action poo" data-type="poo">💩 Poo</button>
|
||||
<button class="action weight" data-type="weight">⚖️ Weigh-in</button>
|
||||
<button class="action note" data-type="note">📝 Note</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -202,10 +204,10 @@
|
||||
<div class="chart">
|
||||
<div class="chart-title">Daily counts</div>
|
||||
<svg id="chart-counts" class="chart-svg" viewBox="0 0 320 180" role="img" aria-label="Pee, poo and meal counts per day"></svg>
|
||||
<div class="legend">
|
||||
<span class="lg pee"><span class="sw"></span>Pees</span>
|
||||
<span class="lg poo"><span class="sw"></span>Poos</span>
|
||||
<span class="lg eat"><span class="sw"></span>Meals</span>
|
||||
<div class="legend legend-toggle" id="counts-metrics" role="group" aria-label="Which counts to show">
|
||||
<label class="lg pee"><input type="checkbox" data-metric="pees" checked /><span class="sw"></span>Pees</label>
|
||||
<label class="lg poo"><input type="checkbox" data-metric="poos" checked /><span class="sw"></span>Poos</label>
|
||||
<label class="lg eat"><input type="checkbox" data-metric="meals" checked /><span class="sw"></span>Meals</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart" id="grams-chart-wrap" hidden>
|
||||
@@ -222,14 +224,15 @@
|
||||
|
||||
<section class="patterns" data-panel="sleep-trend">
|
||||
<h2>Sleep trend</h2>
|
||||
<svg id="chart-sleep-trend" class="chart-svg" viewBox="0 0 320 220" role="img" aria-label="Cumulative sleep hours through the day: today, yesterday, the recent average and the projected end-of-day total"></svg>
|
||||
<svg id="chart-sleep-trend" class="chart-svg" viewBox="0 0 320 220" role="img" aria-label="Cumulative sleep hours through the selected day, the day before it, the recent average and (for today) the projected end-of-day total, with the age-based sleep goal band"></svg>
|
||||
<div class="legend">
|
||||
<span class="lg trend-today"><span class="sw"></span><span id="legend-trend-today-text">Today</span></span>
|
||||
<span class="lg trend-projected" id="legend-trend-projected" hidden><span class="sw"></span><span id="legend-trend-projected-text">Projected</span></span>
|
||||
<span class="lg trend-yesterday" id="legend-trend-yesterday"><span class="sw"></span><span id="legend-trend-yesterday-text">Yesterday</span></span>
|
||||
<span class="lg trend-avg" id="legend-trend-avg"><span class="sw"></span><span id="legend-trend-avg-text">7-day avg</span></span>
|
||||
<span class="lg trend-goal" id="legend-trend-goal" hidden><span class="sw"></span><span id="legend-trend-goal-text">Goal</span></span>
|
||||
</div>
|
||||
<p class="muted-note">Hours slept so far at each point of the day, against yesterday and the average over the picked chart window. The dashed tail continues today's line the way the average day usually plays out.</p>
|
||||
<p class="muted-note">Hours slept so far at each point of the day, against yesterday and the average over the picked chart window. The dashed tail continues today's line the way the average day usually plays out. The axis is stretched above 10h to give the hours around the goal more room.</p>
|
||||
</section>
|
||||
|
||||
<section class="patterns" data-panel="hour-heatmap">
|
||||
@@ -259,6 +262,12 @@
|
||||
<p id="weight-empty" class="empty">No weigh-ins logged yet.</p>
|
||||
</section>
|
||||
|
||||
<section class="notes-log" data-panel="notes">
|
||||
<h2>Notes</h2>
|
||||
<ul id="notes-list" class="event-list"></ul>
|
||||
<p id="notes-empty" class="empty">No notes yet. Use the 📝 Note button to jot down things like vaccinations or vet visits — they'll be listed here across every day.</p>
|
||||
</section>
|
||||
|
||||
<section class="history" data-panel="history">
|
||||
<h2>History</h2>
|
||||
<ul id="event-list" class="event-list"></ul>
|
||||
@@ -271,6 +280,39 @@
|
||||
</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>
|
||||
<div class="ped-controls">
|
||||
<button type="button" id="ped-view" class="ghost">Fan view</button>
|
||||
<button type="button" id="ped-foldall" class="ghost">Collapse all</button>
|
||||
<div class="ped-zoom" role="group" aria-label="Zoom">
|
||||
<button type="button" id="ped-zoom-out" class="ghost icon-btn" aria-label="Zoom out" title="Zoom out">−</button>
|
||||
<button type="button" id="ped-zoom-reset" class="ghost" aria-label="Reset zoom" title="Reset zoom">100%</button>
|
||||
<button type="button" id="ped-zoom-in" class="ghost icon-btn" aria-label="Zoom in" title="Zoom in">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="pedigree-main">
|
||||
<p class="muted-note pedigree-hint">
|
||||
Your dog's ancestry from <strong>SKK Hunddata</strong>, traced from the
|
||||
ID set in Settings. The first generations show at once, then the line
|
||||
fills in further back.
|
||||
<button type="button" id="pedigree-refresh" class="linklike">Refresh</button>
|
||||
</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>
|
||||
<p id="pedigree-repeat-note" class="muted-note pedigree-repeat-note" hidden>Some ancestors appear in more than one place further back (pedigree collapse). Expand the tree to reveal their ×N badges, then tap one to highlight every spot that dog appears.</p>
|
||||
<div id="pedigree-tree" class="pedigree-tree"></div>
|
||||
<p id="pedigree-caption" class="pedigree-caption" hidden></p>
|
||||
</main>
|
||||
</div><!-- /#pedigree-screen -->
|
||||
|
||||
<dialog id="changelog-dialog">
|
||||
<form method="dialog" id="changelog-form">
|
||||
<h3>Changelog</h3>
|
||||
@@ -291,10 +333,19 @@
|
||||
<label>Birthday
|
||||
<input type="date" id="settings-birthday" />
|
||||
</label>
|
||||
<label>Pedigree ID
|
||||
<input type="text" id="settings-pedigree" autocomplete="off" spellcheck="false"
|
||||
placeholder="SKK chip or reg. number (optional)" />
|
||||
</label>
|
||||
<p class="settings-hint">Set your dog's SKK chip or registration number to unlock the 🌳 pedigree page.</p>
|
||||
<label class="toggle-row">
|
||||
<span>Dark mode</span>
|
||||
<input type="checkbox" id="settings-theme" role="switch" class="switch" />
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<span>Pee/poo confetti 💩</span>
|
||||
<input type="checkbox" id="settings-confetti" role="switch" class="switch" />
|
||||
</label>
|
||||
<menu>
|
||||
<button value="cancel" class="ghost">Cancel</button>
|
||||
<button value="save" id="settings-save">Save</button>
|
||||
|
||||
+348
-4
@@ -11,6 +11,7 @@
|
||||
--poo: #8a5a3b;
|
||||
--weight: #2bb3a3;
|
||||
--training: #b04ecf;
|
||||
--note: #6f7a90;
|
||||
--danger: #d64545;
|
||||
--gain: #2e9e5b;
|
||||
--border: #e9e6f5;
|
||||
@@ -78,13 +79,24 @@ h1 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
/* Truncate the name so a long one never runs into the action buttons. Scoped to
|
||||
the header title so the auth-screen heading is unaffected. */
|
||||
#app-title {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.puppy-age {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
/* One line in the common case; on a very narrow screen it wraps rather than
|
||||
truncating, so the age is never cut off. The name (#app-title) is what
|
||||
truncates to keep the buttons clear. */
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
@@ -255,6 +267,7 @@ button.action.eat { background: var(--eat); }
|
||||
button.action.pee { background: var(--pee); color: #2b240a; }
|
||||
button.action.poo { background: var(--poo); }
|
||||
button.action.weight { background: var(--weight); }
|
||||
button.action.note { background: var(--note); }
|
||||
/* Unlikely given the current sleep state (see renderActionHints) — dimmed
|
||||
but fully tappable, so corrections are never blocked. */
|
||||
button.action.unlikely { opacity: 0.4; }
|
||||
@@ -324,6 +337,7 @@ button.danger { background: var(--danger); }
|
||||
font-weight: normal;
|
||||
}
|
||||
.timing-hint { margin: 10px 4px 0; line-height: 1.4; }
|
||||
.settings-hint { color: var(--muted); font-size: 0.8rem; margin: -4px 0 4px; line-height: 1.4; }
|
||||
|
||||
.history-controls {
|
||||
display: flex;
|
||||
@@ -423,11 +437,16 @@ textarea { resize: vertical; }
|
||||
.event[data-type="poo"] .dot { background: var(--poo); }
|
||||
.event[data-type="weight"] .dot { background: var(--weight); }
|
||||
.event[data-type="training"] .dot { background: var(--training); }
|
||||
.event[data-type="note"] .dot { background: var(--note); }
|
||||
|
||||
.event .time { font-variant-numeric: tabular-nums; color: var(--muted); min-width: 60px; }
|
||||
.event .label { font-weight: 600; min-width: 110px; }
|
||||
.event .note { color: var(--muted); font-size: 0.9rem; flex: 1; }
|
||||
|
||||
/* Notes log rows: a date instead of a time-of-day, then the note text. */
|
||||
.event .note-date { font-weight: 600; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.event .note-text { flex: 1; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
@@ -643,7 +662,16 @@ dialog menu {
|
||||
.weight-summary .stat-value.up { color: var(--gain); }
|
||||
.weight-summary .stat-value.down { color: var(--danger); }
|
||||
.ww.weight-ww { cursor: pointer; }
|
||||
.ww.weight-ww .ww-dur { text-align: right; }
|
||||
/* Keep each weight row to one line: the date/age column shrinks and ellipses,
|
||||
the weight value stays a fixed size, right-aligned and always visible. */
|
||||
.ww.weight-ww .ww-range {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.ww.weight-ww .ww-dur { flex: 0 0 auto; text-align: right; }
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
@@ -664,6 +692,15 @@ dialog menu {
|
||||
.lg.poo .sw { background: var(--poo); }
|
||||
.lg.eat .sw { background: var(--eat); }
|
||||
|
||||
/* Interactive legend: each item is a checkbox that toggles its metric. */
|
||||
.legend-toggle label.lg { cursor: pointer; user-select: none; }
|
||||
.legend-toggle input[type="checkbox"] { margin: 0; cursor: pointer; }
|
||||
.legend-toggle label.pee input { accent-color: var(--pee); }
|
||||
.legend-toggle label.poo input { accent-color: var(--poo); }
|
||||
.legend-toggle label.eat input { accent-color: var(--eat); }
|
||||
/* Dim an unchecked item so it's clear its bars are hidden. */
|
||||
.legend-toggle label.lg:has(input:not(:checked)) { opacity: 0.5; }
|
||||
|
||||
/* ---------- auth (login / register) ---------- */
|
||||
.auth-screen {
|
||||
position: fixed;
|
||||
@@ -927,11 +964,11 @@ input.switch:checked::after { transform: translateX(18px); }
|
||||
fill: none;
|
||||
}
|
||||
.chart-svg .trend-yesterday {
|
||||
stroke: var(--muted);
|
||||
stroke: var(--eat); /* orange — the gray it had before sank into the grid */
|
||||
stroke-width: 1.5;
|
||||
stroke-linejoin: round;
|
||||
fill: none;
|
||||
opacity: 0.75;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.chart-svg .trend-avg {
|
||||
stroke: var(--weight); /* teal — keeps it apart from today's blue and its blue projected tail */
|
||||
@@ -952,9 +989,11 @@ input.switch:checked::after { transform: translateX(18px); }
|
||||
fill: none;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.chart-svg .trend-goal { fill: var(--sleep); fill-opacity: 0.12; }
|
||||
.lg.trend-goal .sw { background: color-mix(in srgb, var(--sleep) 20%, var(--surface)); }
|
||||
.lg.trend-today .sw { background: var(--sleep); }
|
||||
.lg.trend-projected .sw { background: color-mix(in srgb, var(--sleep) 40%, var(--surface)); }
|
||||
.lg.trend-yesterday .sw { background: var(--muted); }
|
||||
.lg.trend-yesterday .sw { background: var(--eat); }
|
||||
.lg.trend-avg .sw { background: var(--weight); }
|
||||
.lg[hidden] { display: none; }
|
||||
|
||||
@@ -1051,3 +1090,308 @@ 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 { flex-wrap: wrap; }
|
||||
.pedigree-header h1 { font-size: 1.4rem; }
|
||||
.ped-controls {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#ped-foldall { padding: 5px 10px; font-size: 0.8rem; white-space: nowrap; }
|
||||
.ped-zoom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ped-zoom .icon-btn { padding: 4px 9px; font-size: 1.1rem; }
|
||||
#ped-zoom-reset {
|
||||
padding: 5px 8px;
|
||||
font-size: 0.8rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
.pedigree-hint { margin: 4px 0 12px; }
|
||||
/* The tree scales with the CSS `zoom` property, which reflows so the container
|
||||
still scrolls to reach the edges at any zoom. */
|
||||
.pedigree-tree { touch-action: pan-x pan-y; }
|
||||
|
||||
.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; }
|
||||
|
||||
/* Top-down family tree: the dog on top, parents branching below, connected by
|
||||
lines drawn with each <li>'s ::before/::after (the classic CSS tree). Wider
|
||||
than the screen once expanded, so the container scrolls horizontally. */
|
||||
.pedigree-tree {
|
||||
overflow-x: auto;
|
||||
padding: 8px 0 28px;
|
||||
}
|
||||
.ped-tree-h, .ped-tree-h ul {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.ped-tree-h {
|
||||
/* "safe" centers when the tree fits and falls back to start-aligned (no
|
||||
clipped/unreachable left edge) once it's wider than the screen. */
|
||||
justify-content: safe center;
|
||||
min-width: max-content;
|
||||
padding: 4px 16px 12px;
|
||||
}
|
||||
.ped-tree-h ul {
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
padding-top: 22px; /* room for the connector from the parent above */
|
||||
}
|
||||
.ped-tree-h li {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 22px 6px 0;
|
||||
}
|
||||
/* Elbow from each child up to the horizontal bar shared by its siblings. */
|
||||
.ped-tree-h li::before,
|
||||
.ped-tree-h li::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 50%;
|
||||
height: 22px;
|
||||
border-top: 2px solid var(--border);
|
||||
}
|
||||
.ped-tree-h li::before { right: 50%; }
|
||||
.ped-tree-h li::after { left: 50%; border-left: 2px solid var(--border); }
|
||||
/* Vertical drop from a parent card down to its children's bar. */
|
||||
.ped-tree-h ul::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
height: 22px;
|
||||
border-left: 2px solid var(--border);
|
||||
}
|
||||
/* A lone parent connects with a straight line, no elbow. */
|
||||
.ped-tree-h li:only-child::before,
|
||||
.ped-tree-h li:only-child::after { display: none; }
|
||||
/* Trim the outer half-lines at the ends of a sibling row. */
|
||||
.ped-tree-h li:first-child::before,
|
||||
.ped-tree-h li:last-child::after { border: 0 none; }
|
||||
.ped-tree-h li:last-child::before { border-right: 2px solid var(--border); }
|
||||
/* The dog sits on top with no connector above it. */
|
||||
.ped-tree-h > li { padding-top: 0; }
|
||||
.ped-tree-h > li::before,
|
||||
.ped-tree-h > li::after { display: none; }
|
||||
/* Collapsed: hide the ancestry below this dog (and its connectors go with it). */
|
||||
.ped-tree-h li.collapsed > ul { display: none; }
|
||||
|
||||
.ped-card {
|
||||
position: relative;
|
||||
width: 140px;
|
||||
box-sizing: border-box;
|
||||
padding: 7px 18px 7px 20px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
text-align: center;
|
||||
}
|
||||
.ped-name { font-weight: 600; font-size: 0.85rem; line-height: 1.2; }
|
||||
.ped-name.ped-unknown { color: var(--muted); font-weight: 500; font-style: italic; }
|
||||
.ped-titles { font-size: 0.66rem; color: var(--accent); margin-top: 2px; line-height: 1.2; }
|
||||
.ped-reg {
|
||||
font-size: 0.66rem;
|
||||
color: var(--muted);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
margin-top: 1px;
|
||||
}
|
||||
/* sire ♂ (blue) / dam ♀ (purple) accents on the parent cards */
|
||||
.ped-sire > .ped-card { border-top: 3px solid var(--sleep); }
|
||||
.ped-dam > .ped-card { border-top: 3px solid var(--training); }
|
||||
.ped-sire > .ped-card::after,
|
||||
.ped-dam > .ped-card::after {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 5px;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.ped-sire > .ped-card::after { content: "♂"; color: var(--sleep); }
|
||||
.ped-dam > .ped-card::after { content: "♀"; color: var(--training); }
|
||||
|
||||
/* pedigree collapse: a dog filling more than one position gets a ×N badge, a
|
||||
stable hue, and lights up (with every copy) when tapped. */
|
||||
.ped-card.ped-repeat { cursor: pointer; }
|
||||
.ped-repeat-badge {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
bottom: 5px;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
padding: 2px 5px;
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
background: hsl(var(--repeat-hue, 0), 58%, 48%);
|
||||
}
|
||||
.ped-card.ped-lit {
|
||||
border-color: hsl(var(--repeat-hue, 0), 70%, 50%);
|
||||
box-shadow: 0 0 0 2px hsl(var(--repeat-hue, 0), 70%, 50%), var(--shadow);
|
||||
}
|
||||
.pedigree-repeat-note { margin: 0 0 10px; }
|
||||
|
||||
/* expand/collapse toggle (top-left of the card) */
|
||||
.ped-toggle {
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 5px;
|
||||
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); }
|
||||
|
||||
/* ---- radial fan chart ---- */
|
||||
.ped-fan { display: block; margin: 0 auto; max-width: none; }
|
||||
.ped-wedge {
|
||||
cursor: pointer;
|
||||
stroke: var(--border);
|
||||
stroke-width: 1;
|
||||
/* outer rings tint gradually darker for depth */
|
||||
fill: color-mix(in srgb, var(--accent-soft) calc(var(--gen, 1) * 7%), var(--surface));
|
||||
transition: filter 0.1s ease;
|
||||
}
|
||||
.ped-wedge:hover { filter: brightness(0.95); }
|
||||
/* a dog that appears more than once is filled with its stable hue */
|
||||
.ped-wedge-repeat { fill: hsl(var(--repeat-hue, 0), 60%, 80%); stroke: hsl(var(--repeat-hue, 0), 45%, 60%); }
|
||||
.ped-wedge.ped-lit {
|
||||
fill: hsl(var(--repeat-hue, 0), 72%, 62%);
|
||||
stroke: hsl(var(--repeat-hue, 0), 72%, 38%);
|
||||
stroke-width: 2;
|
||||
}
|
||||
.ped-wedge-label {
|
||||
font-size: 8px;
|
||||
fill: var(--text);
|
||||
text-anchor: middle;
|
||||
dominant-baseline: central;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ped-fan-center { fill: var(--accent); stroke: none; cursor: pointer; }
|
||||
.ped-fan-center-label {
|
||||
fill: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
dominant-baseline: central;
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .ped-wedge-label { fill: var(--text); }
|
||||
}
|
||||
.pedigree-caption {
|
||||
margin: 10px 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ---- pee/poo confetti ---- */
|
||||
#confetti-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
z-index: 9999;
|
||||
}
|
||||
.confetti-piece {
|
||||
position: absolute;
|
||||
line-height: 1;
|
||||
will-change: transform, opacity;
|
||||
animation-name: potty-firework;
|
||||
animation-timing-function: ease-out;
|
||||
/* `both` so the 0% state (invisible, at the bottom) also applies during the
|
||||
per-piece launch delay — no flash before it takes off. */
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
/* Launch up from the bottom, rise to a peak while spreading sideways, slowing
|
||||
(ease-out) and fading out as it reaches the top — a firework fountain.
|
||||
Distances come from JS custom props. */
|
||||
@keyframes potty-firework {
|
||||
0% { opacity: 0; transform: translate(-50%, -50%) scale(0.5) rotate(0deg); }
|
||||
10% { opacity: 1; }
|
||||
70% { opacity: 1; }
|
||||
100% { opacity: 0; transform: translate(calc(-50% + var(--dx)), calc(-50% + var(--peakY))) scale(1) rotate(var(--rot)); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.confetti-piece { display: none; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user