diff --git a/README.md b/README.md index 82822ac..0dd65c6 100644 --- a/README.md +++ b/README.md @@ -96,19 +96,23 @@ events, profile and photos. ## 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. +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 input to SKK's internal dog id, fetches the + 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), 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. + 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 diff --git a/server/main.go b/server/main.go index be5a17a..0bceb04 100644 --- a/server/main.go +++ b/server/main.go @@ -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) } @@ -92,12 +95,13 @@ 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. _, 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 = excluded.pedigree_id, 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 } @@ -295,10 +299,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, @@ -391,6 +396,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 } @@ -703,6 +717,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 diff --git a/server/pedigree.go b/server/pedigree.go index 26ff5ca..9c2c646 100644 --- a/server/pedigree.go +++ b/server/pedigree.go @@ -581,10 +581,28 @@ type pedManager struct { 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{}} + 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 { @@ -751,6 +769,18 @@ func (m *pedManager) handleLookup(w http.ResponseWriter, r *http.Request) { 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) @@ -776,6 +806,7 @@ func (m *pedManager) handleLookup(w http.ResponseWriter, r *http.Request) { return } subject := rowToSubject(rows[0]) + m.rememberResolve(q, subject.Hundid) if subj, nodes, ok := m.cached(subject.Hundid); ok { writeJSON(w, pedLookupResponse{ diff --git a/src/app.js b/src/app.js index 885722e..f755ce1 100644 --- a/src/app.js +++ b/src/app.js @@ -214,14 +214,15 @@ function loadConfig() { try { const parsed = JSON.parse(localStorage.getItem(configKey())); - if (!parsed || typeof parsed !== "object") return { name: "", birthday: "", updatedAt: 0 }; + if (!parsed || typeof parsed !== "object") return { name: "", birthday: "", pedigreeId: "", updatedAt: 0 }; return { name: parsed.name || "", birthday: parsed.birthday || "", + pedigreeId: parsed.pedigreeId || "", updatedAt: Number.isFinite(parsed.updatedAt) ? parsed.updatedAt : 0, }; } catch { - return { name: "", birthday: "", updatedAt: 0 }; + return { name: "", birthday: "", pedigreeId: "", updatedAt: 0 }; } } @@ -2036,11 +2037,13 @@ const server = { name: body.name || "", birthday: body.birthday || "", + pedigreeId: body.pedigreeId || "", updatedAt: Number.isFinite(body.updatedAt) ? body.updatedAt : 0, }; if (server.updatedAt > local.updatedAt) { saveConfig(server); renderHeader(); + refreshPedigreeButton(); } else if (local.updatedAt > server.updatedAt) { await pushConfig(local); } @@ -2060,7 +2063,7 @@ const body = await res.json(); // Adopt the server's answer if it turned out to be newer (another client won). if (Number.isFinite(body.updatedAt) && body.updatedAt > cfg.updatedAt) { - saveConfig({ name: body.name || "", birthday: body.birthday || "", updatedAt: body.updatedAt }); + saveConfig({ name: body.name || "", birthday: body.birthday || "", pedigreeId: body.pedigreeId || "", updatedAt: body.updatedAt }); renderHeader(); } } @@ -2375,6 +2378,7 @@ const settingsForm = document.getElementById("settings-form"); const settingsName = document.getElementById("settings-name"); const settingsBirthday = document.getElementById("settings-birthday"); + const settingsPedigree = document.getElementById("settings-pedigree"); const settingsTheme = document.getElementById("settings-theme"); // Apply live so the toggle previews immediately (independent of Save/Cancel). @@ -2386,6 +2390,7 @@ const cfg = loadConfig(); settingsName.value = cfg.name; settingsBirthday.value = cfg.birthday; + settingsPedigree.value = cfg.pedigreeId; settingsTheme.checked = effectiveTheme() === "dark"; settingsDialog.showModal(); setTimeout(() => settingsName.focus(), 50); @@ -2398,10 +2403,12 @@ const cfg = { name: settingsName.value.trim(), birthday: settingsBirthday.value, + pedigreeId: settingsPedigree.value.trim(), updatedAt: Date.now(), }; saveConfig(cfg); // cache locally for instant + offline paint renderHeader(); + refreshPedigreeButton(); settingsDialog.close(); try { await pushConfig(cfg); @@ -2421,25 +2428,39 @@ // 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 pedBtn = document.getElementById("pedigree-btn"); + const pedRefresh = document.getElementById("pedigree-refresh"); 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`; + + // The looked-up tree is cached locally per dog id, so reopening the page paints + // instantly and still shows the last-known tree offline. The server caches it + // too (per dog, permanently); this is just the client-side mirror. + const pedCacheKey = (id) => `puppy-tracker:${currentUser.id}:pedigree:${id}:v1`; + function loadPedCache(id) { + try { return JSON.parse(localStorage.getItem(pedCacheKey(id))) || null; } catch { return null; } + } + function savePedCache(id, subject, nodes) { + try { localStorage.setItem(pedCacheKey(id), JSON.stringify({ subject, nodes })); } catch { /* ignore */ } + } + + // Show the 🌳 button only once a pedigree id is set in Settings. + function refreshPedigreeButton() { + pedBtn.hidden = !loadConfig().pedigreeId; + } function openPedigree() { + const id = loadConfig().pedigreeId; appEl.hidden = true; pedScreen.hidden = false; - if (!pedQ.value) { - try { pedQ.value = localStorage.getItem(pedQKey()) || ""; } catch { /* ignore */ } - } - setTimeout(() => pedQ.focus(), 50); + if (!id) { setPedStatus("Set your dog's SKK id in Settings to see its pedigree.", ""); return; } + lookupPedigree(id); } function closePedigree() { stopPedPoll(); @@ -2450,15 +2471,11 @@ if (pedPollTimer) { clearTimeout(pedPollTimer); pedPollTimer = null; } } - document.getElementById("pedigree-btn").addEventListener("click", openPedigree); + pedBtn.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); + pedRefresh.addEventListener("click", () => { + const id = loadConfig().pedigreeId; + if (id) lookupPedigree(id); }); async function lookupPedigree(q) { @@ -2466,11 +2483,20 @@ pedChoose.hidden = true; pedChoose.textContent = ""; pedSubject.hidden = true; pedSubject.textContent = ""; pedTree.textContent = ""; + pedNodes = {}; + + // Paint the cached tree first so the page is instant (and works offline). + const cached = loadPedCache(q); + if (cached && cached.nodes) { + renderSubject(cached.subject); + renderTree(cached.nodes); + } if (!navigator.onLine) { - setPedStatus("Pedigree lookup needs an internet connection.", "error"); + setPedStatus(cached ? "Offline — showing the last saved pedigree." : "Pedigree needs an internet connection.", + cached ? "" : "error"); return; } - setPedStatus("Looking up…", "busy"); + setPedStatus(cached ? "Refreshing…" : "Looking up…", "busy"); let res; try { res = await fetch("api/pedigree", { @@ -2479,11 +2505,15 @@ body: JSON.stringify({ q }), }); } catch { - setPedStatus("Couldn't reach the server. Check your connection and try again.", "error"); + setPedStatus(cached ? "Offline — showing the last saved pedigree." : "Couldn't reach the server. Try again.", + cached ? "" : "error"); return; } if (res.status === 401) { handleLoggedOut(); return; } - if (res.status === 404) { setPedStatus(`No dog found for “${q}”.`, "error"); return; } + if (res.status === 404) { + setPedStatus("Couldn't find that dog in SKK — check the ID in Settings.", "error"); + return; + } if (!res.ok) { const msg = (await res.text().catch(() => "")).trim(); setPedStatus(msg || `Lookup failed (HTTP ${res.status}).`, "error"); @@ -2494,14 +2524,15 @@ renderSubject(data.subject); renderTree(data.nodes || {}); if (data.status === "done") { + savePedCache(q, data.subject, data.nodes || {}); setPedDone(); } else { setPedStatus(`Tracing ancestry… ${pedCountText()}`, "busy"); - pollPedigree(data.jobId); + pollPedigree(data.jobId, q, data.subject); } } - function pollPedigree(jobId) { + function pollPedigree(jobId, q, subject) { stopPedPoll(); const tick = async () => { let res; @@ -2511,7 +2542,7 @@ 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 === "done") { savePedCache(q, subject, data.nodes || {}); setPedDone(); return; } if (data.status === "error") { setPedStatus(data.error || "Pedigree crawl failed.", "error"); return; } setPedStatus(`Tracing ancestry… ${pedCountText()}`, "busy"); pedPollTimer = setTimeout(tick, 1500); @@ -2584,10 +2615,7 @@ 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); + lookupPedigree((m.Regnr || "").trim() || nm); }); pedChoose.append(b); }); @@ -3113,6 +3141,7 @@ setStatus(); render(); + refreshPedigreeButton(); sync(); syncConfig(); } diff --git a/src/changelog.json b/src/changelog.json index f54077d..c58ff9a 100644 --- a/src/changelog.json +++ b/src/changelog.json @@ -1,5 +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-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" }, diff --git a/src/index.html b/src/index.html index aec3357..a53d464 100644 --- a/src/index.html +++ b/src/index.html @@ -70,7 +70,7 @@
- +
@@ -282,15 +282,11 @@

🌳 Pedigree

-
- - -

- Looks up a dog in SKK Hunddata by ISO chip number - (e.g. 752095600044144) or registration number (e.g. SE23536/2026), - then traces its ancestry. Needs an internet connection. + Your dog's ancestry from SKK Hunddata, traced from the + ID set in Settings. The first generations show at once, then the line + fills in further back. +

@@ -319,6 +315,11 @@ + +

Set your dog's SKK chip or registration number to unlock the 🌳 pedigree page.