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 @@