Gate pedigree behind a dog id set in settings
The pedigree view is now opt-in and tied to your own dog rather than an
always-present free-text search. Add the dog's SKK chip or registration
number in Settings (it rides the synced profile alongside name and
birthday); the 🌳 button stays hidden until one is set, then opens the
page and loads that dog's ancestry directly.
Make repeat opens cheap: memoise the id->hundid resolution server-side so
a cached tree is served without contacting SKK at all, and mirror the
finished tree in localStorage so the page paints instantly and shows the
last-known tree offline.
Adds config.pedigree_id (with an in-place migration for existing DBs).
This commit is contained in:
@@ -96,19 +96,23 @@ events, profile and photos.
|
|||||||
|
|
||||||
## Pedigree lookup
|
## Pedigree lookup
|
||||||
|
|
||||||
The 🌳 page looks up a dog in **SKK Hunddata** by ISO chip number or
|
Set your dog's SKK chip or registration number in **Settings** (it rides the
|
||||||
registration number and renders its ancestry as a tree.
|
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
|
- 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
|
pedigree page (7 generations per request), and follows each generation's leaves
|
||||||
deeper. A lookup returns the first generations immediately and keeps crawling in
|
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.
|
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
|
- 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
|
are cached per dog in the `pedigree_cache` table (pedigrees don't change), and
|
||||||
dog is only ever crawled once and repeat lookups are instant.
|
the id→dog resolution is memoised, so a dog is only ever crawled once and repeat
|
||||||
- The lookup is behind auth like the rest of `/api/*`, and is **online-only** —
|
opens hit SKK zero times. The client also mirrors the finished tree in
|
||||||
it needs to reach SKK.
|
`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
|
## Use it on NixOS
|
||||||
|
|
||||||
|
|||||||
+34
-16
@@ -28,9 +28,9 @@ type Event struct {
|
|||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
At int64 `json:"at"`
|
At int64 `json:"at"`
|
||||||
Note string `json:"note"`
|
Note string `json:"note"`
|
||||||
PhotoID string `json:"photoId,omitempty"` // photo UUIDs, comma-separated (legacy events hold one)
|
PhotoID string `json:"photoId,omitempty"` // photo UUIDs, comma-separated (legacy events hold one)
|
||||||
Weight float64 `json:"weight,omitempty"` // kilograms, for "weight" events
|
Weight float64 `json:"weight,omitempty"` // kilograms, for "weight" events
|
||||||
Grams float64 `json:"grams,omitempty"` // food eaten, for "eat" events
|
Grams float64 `json:"grams,omitempty"` // food eaten, for "eat" events
|
||||||
ExerciseID string `json:"exerciseId,omitempty"` // for "training" events
|
ExerciseID string `json:"exerciseId,omitempty"` // for "training" events
|
||||||
UpdatedAt int64 `json:"updatedAt"`
|
UpdatedAt int64 `json:"updatedAt"`
|
||||||
Deleted bool `json:"deleted,omitempty"`
|
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
|
// every client sees the same values without configuring each device. UpdatedAt
|
||||||
// drives last-write-wins, mirroring how events sync.
|
// drives last-write-wins, mirroring how events sync.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Birthday string `json:"birthday"`
|
Birthday string `json:"birthday"`
|
||||||
UpdatedAt int64 `json:"updatedAt"`
|
// 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 {
|
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,
|
// One profile row per user. A missing row is the pre-configuration state,
|
||||||
// so a zero-value Config is the right answer.
|
// so a zero-value Config is the right answer.
|
||||||
err := cs.db.QueryRow(
|
err := cs.db.QueryRow(
|
||||||
`SELECT name, birthday, updated FROM config WHERE user_id = ?`, userID,
|
`SELECT name, birthday, pedigree_id, updated FROM config WHERE user_id = ?`, userID,
|
||||||
).Scan(&c.Name, &c.Birthday, &c.UpdatedAt)
|
).Scan(&c.Name, &c.Birthday, &c.PedigreeID, &c.UpdatedAt)
|
||||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
log.Printf("config get: %v", err)
|
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
|
// The upsert's WHERE clause enforces last-write-wins: the incoming row only
|
||||||
// replaces the stored one when it is strictly newer.
|
// replaces the stored one when it is strictly newer.
|
||||||
_, err := cs.db.Exec(`
|
_, err := cs.db.Exec(`
|
||||||
INSERT INTO config (user_id, name, birthday, updated)
|
INSERT INTO config (user_id, name, birthday, pedigree_id, updated)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(user_id) DO UPDATE SET
|
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`,
|
WHERE excluded.updated > config.updated`,
|
||||||
userID, in.Name, in.Birthday, in.UpdatedAt)
|
userID, in.Name, in.Birthday, in.PedigreeID, in.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Config{}, err
|
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 INDEX IF NOT EXISTS idx_exercises_user ON exercises(user_id);
|
||||||
CREATE TABLE IF NOT EXISTS config (
|
CREATE TABLE IF NOT EXISTS config (
|
||||||
user_id TEXT PRIMARY KEY,
|
user_id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL DEFAULT '',
|
name TEXT NOT NULL DEFAULT '',
|
||||||
birthday TEXT NOT NULL DEFAULT '',
|
birthday TEXT NOT NULL DEFAULT '',
|
||||||
updated INTEGER NOT NULL DEFAULT 0
|
pedigree_id TEXT NOT NULL DEFAULT '',
|
||||||
|
updated INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id TEXT PRIMARY KEY,
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -703,6 +717,10 @@ func main() {
|
|||||||
if len(in.Name) > 100 {
|
if len(in.Name) > 100 {
|
||||||
in.Name = 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) {
|
if !validBirthday(in.Birthday) {
|
||||||
http.Error(w, "invalid birthday (want YYYY-MM-DD)", http.StatusBadRequest)
|
http.Error(w, "invalid birthday (want YYYY-MM-DD)", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
|
|||||||
+32
-1
@@ -581,10 +581,28 @@ type pedManager struct {
|
|||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
jobs map[string]*pedJob // keyed by hundid (coalesces duplicate lookups)
|
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 {
|
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 {
|
func (m *pedManager) activeCount() int {
|
||||||
@@ -751,6 +769,18 @@ func (m *pedManager) handleLookup(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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()
|
client, err := newSKKClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "server error", http.StatusInternalServerError)
|
http.Error(w, "server error", http.StatusInternalServerError)
|
||||||
@@ -776,6 +806,7 @@ func (m *pedManager) handleLookup(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
subject := rowToSubject(rows[0])
|
subject := rowToSubject(rows[0])
|
||||||
|
m.rememberResolve(q, subject.Hundid)
|
||||||
|
|
||||||
if subj, nodes, ok := m.cached(subject.Hundid); ok {
|
if subj, nodes, ok := m.cached(subject.Hundid); ok {
|
||||||
writeJSON(w, pedLookupResponse{
|
writeJSON(w, pedLookupResponse{
|
||||||
|
|||||||
+58
-29
@@ -214,14 +214,15 @@
|
|||||||
function loadConfig() {
|
function loadConfig() {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(localStorage.getItem(configKey()));
|
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 {
|
return {
|
||||||
name: parsed.name || "",
|
name: parsed.name || "",
|
||||||
birthday: parsed.birthday || "",
|
birthday: parsed.birthday || "",
|
||||||
|
pedigreeId: parsed.pedigreeId || "",
|
||||||
updatedAt: Number.isFinite(parsed.updatedAt) ? parsed.updatedAt : 0,
|
updatedAt: Number.isFinite(parsed.updatedAt) ? parsed.updatedAt : 0,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { name: "", birthday: "", updatedAt: 0 };
|
return { name: "", birthday: "", pedigreeId: "", updatedAt: 0 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2036,11 +2037,13 @@
|
|||||||
const server = {
|
const server = {
|
||||||
name: body.name || "",
|
name: body.name || "",
|
||||||
birthday: body.birthday || "",
|
birthday: body.birthday || "",
|
||||||
|
pedigreeId: body.pedigreeId || "",
|
||||||
updatedAt: Number.isFinite(body.updatedAt) ? body.updatedAt : 0,
|
updatedAt: Number.isFinite(body.updatedAt) ? body.updatedAt : 0,
|
||||||
};
|
};
|
||||||
if (server.updatedAt > local.updatedAt) {
|
if (server.updatedAt > local.updatedAt) {
|
||||||
saveConfig(server);
|
saveConfig(server);
|
||||||
renderHeader();
|
renderHeader();
|
||||||
|
refreshPedigreeButton();
|
||||||
} else if (local.updatedAt > server.updatedAt) {
|
} else if (local.updatedAt > server.updatedAt) {
|
||||||
await pushConfig(local);
|
await pushConfig(local);
|
||||||
}
|
}
|
||||||
@@ -2060,7 +2063,7 @@
|
|||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
// Adopt the server's answer if it turned out to be newer (another client won).
|
// Adopt the server's answer if it turned out to be newer (another client won).
|
||||||
if (Number.isFinite(body.updatedAt) && body.updatedAt > cfg.updatedAt) {
|
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();
|
renderHeader();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2375,6 +2378,7 @@
|
|||||||
const settingsForm = document.getElementById("settings-form");
|
const settingsForm = document.getElementById("settings-form");
|
||||||
const settingsName = document.getElementById("settings-name");
|
const settingsName = document.getElementById("settings-name");
|
||||||
const settingsBirthday = document.getElementById("settings-birthday");
|
const settingsBirthday = document.getElementById("settings-birthday");
|
||||||
|
const settingsPedigree = document.getElementById("settings-pedigree");
|
||||||
const settingsTheme = document.getElementById("settings-theme");
|
const settingsTheme = document.getElementById("settings-theme");
|
||||||
|
|
||||||
// Apply live so the toggle previews immediately (independent of Save/Cancel).
|
// Apply live so the toggle previews immediately (independent of Save/Cancel).
|
||||||
@@ -2386,6 +2390,7 @@
|
|||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
settingsName.value = cfg.name;
|
settingsName.value = cfg.name;
|
||||||
settingsBirthday.value = cfg.birthday;
|
settingsBirthday.value = cfg.birthday;
|
||||||
|
settingsPedigree.value = cfg.pedigreeId;
|
||||||
settingsTheme.checked = effectiveTheme() === "dark";
|
settingsTheme.checked = effectiveTheme() === "dark";
|
||||||
settingsDialog.showModal();
|
settingsDialog.showModal();
|
||||||
setTimeout(() => settingsName.focus(), 50);
|
setTimeout(() => settingsName.focus(), 50);
|
||||||
@@ -2398,10 +2403,12 @@
|
|||||||
const cfg = {
|
const cfg = {
|
||||||
name: settingsName.value.trim(),
|
name: settingsName.value.trim(),
|
||||||
birthday: settingsBirthday.value,
|
birthday: settingsBirthday.value,
|
||||||
|
pedigreeId: settingsPedigree.value.trim(),
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
};
|
};
|
||||||
saveConfig(cfg); // cache locally for instant + offline paint
|
saveConfig(cfg); // cache locally for instant + offline paint
|
||||||
renderHeader();
|
renderHeader();
|
||||||
|
refreshPedigreeButton();
|
||||||
settingsDialog.close();
|
settingsDialog.close();
|
||||||
try {
|
try {
|
||||||
await pushConfig(cfg);
|
await pushConfig(cfg);
|
||||||
@@ -2421,25 +2428,39 @@
|
|||||||
// server returns the first generations immediately and crawls deeper in the
|
// server returns the first generations immediately and crawls deeper in the
|
||||||
// background; we poll for that and re-render as ancestors arrive. Online-only.
|
// background; we poll for that and re-render as ancestors arrive. Online-only.
|
||||||
const pedScreen = document.getElementById("pedigree-screen");
|
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 pedStatus = document.getElementById("pedigree-status");
|
||||||
const pedChoose = document.getElementById("pedigree-choose");
|
const pedChoose = document.getElementById("pedigree-choose");
|
||||||
const pedSubject = document.getElementById("pedigree-subject");
|
const pedSubject = document.getElementById("pedigree-subject");
|
||||||
const pedTree = document.getElementById("pedigree-tree");
|
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
|
const PED_OPEN_DEPTH = 4; // generations shown expanded by default; deeper collapse
|
||||||
let pedPollTimer = null;
|
let pedPollTimer = null;
|
||||||
let pedNodes = {}; // latest ancestry map, for the progress count
|
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() {
|
function openPedigree() {
|
||||||
|
const id = loadConfig().pedigreeId;
|
||||||
appEl.hidden = true;
|
appEl.hidden = true;
|
||||||
pedScreen.hidden = false;
|
pedScreen.hidden = false;
|
||||||
if (!pedQ.value) {
|
if (!id) { setPedStatus("Set your dog's SKK id in Settings to see its pedigree.", ""); return; }
|
||||||
try { pedQ.value = localStorage.getItem(pedQKey()) || ""; } catch { /* ignore */ }
|
lookupPedigree(id);
|
||||||
}
|
|
||||||
setTimeout(() => pedQ.focus(), 50);
|
|
||||||
}
|
}
|
||||||
function closePedigree() {
|
function closePedigree() {
|
||||||
stopPedPoll();
|
stopPedPoll();
|
||||||
@@ -2450,15 +2471,11 @@
|
|||||||
if (pedPollTimer) { clearTimeout(pedPollTimer); pedPollTimer = null; }
|
if (pedPollTimer) { clearTimeout(pedPollTimer); pedPollTimer = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("pedigree-btn").addEventListener("click", openPedigree);
|
pedBtn.addEventListener("click", openPedigree);
|
||||||
document.getElementById("pedigree-back").addEventListener("click", closePedigree);
|
document.getElementById("pedigree-back").addEventListener("click", closePedigree);
|
||||||
|
pedRefresh.addEventListener("click", () => {
|
||||||
pedForm.addEventListener("submit", (e) => {
|
const id = loadConfig().pedigreeId;
|
||||||
e.preventDefault();
|
if (id) lookupPedigree(id);
|
||||||
const q = pedQ.value.trim();
|
|
||||||
if (!q) return;
|
|
||||||
try { localStorage.setItem(pedQKey(), q); } catch { /* ignore */ }
|
|
||||||
lookupPedigree(q);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function lookupPedigree(q) {
|
async function lookupPedigree(q) {
|
||||||
@@ -2466,11 +2483,20 @@
|
|||||||
pedChoose.hidden = true; pedChoose.textContent = "";
|
pedChoose.hidden = true; pedChoose.textContent = "";
|
||||||
pedSubject.hidden = true; pedSubject.textContent = "";
|
pedSubject.hidden = true; pedSubject.textContent = "";
|
||||||
pedTree.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) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
setPedStatus("Looking up…", "busy");
|
setPedStatus(cached ? "Refreshing…" : "Looking up…", "busy");
|
||||||
let res;
|
let res;
|
||||||
try {
|
try {
|
||||||
res = await fetch("api/pedigree", {
|
res = await fetch("api/pedigree", {
|
||||||
@@ -2479,11 +2505,15 @@
|
|||||||
body: JSON.stringify({ q }),
|
body: JSON.stringify({ q }),
|
||||||
});
|
});
|
||||||
} catch {
|
} 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;
|
return;
|
||||||
}
|
}
|
||||||
if (res.status === 401) { handleLoggedOut(); 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) {
|
if (!res.ok) {
|
||||||
const msg = (await res.text().catch(() => "")).trim();
|
const msg = (await res.text().catch(() => "")).trim();
|
||||||
setPedStatus(msg || `Lookup failed (HTTP ${res.status}).`, "error");
|
setPedStatus(msg || `Lookup failed (HTTP ${res.status}).`, "error");
|
||||||
@@ -2494,14 +2524,15 @@
|
|||||||
renderSubject(data.subject);
|
renderSubject(data.subject);
|
||||||
renderTree(data.nodes || {});
|
renderTree(data.nodes || {});
|
||||||
if (data.status === "done") {
|
if (data.status === "done") {
|
||||||
|
savePedCache(q, data.subject, data.nodes || {});
|
||||||
setPedDone();
|
setPedDone();
|
||||||
} else {
|
} else {
|
||||||
setPedStatus(`Tracing ancestry… ${pedCountText()}`, "busy");
|
setPedStatus(`Tracing ancestry… ${pedCountText()}`, "busy");
|
||||||
pollPedigree(data.jobId);
|
pollPedigree(data.jobId, q, data.subject);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function pollPedigree(jobId) {
|
function pollPedigree(jobId, q, subject) {
|
||||||
stopPedPoll();
|
stopPedPoll();
|
||||||
const tick = async () => {
|
const tick = async () => {
|
||||||
let res;
|
let res;
|
||||||
@@ -2511,7 +2542,7 @@
|
|||||||
if (!res.ok) { setPedStatus("Lost track of the pedigree crawl.", "error"); return; }
|
if (!res.ok) { setPedStatus("Lost track of the pedigree crawl.", "error"); return; }
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
renderTree(data.nodes || {});
|
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; }
|
if (data.status === "error") { setPedStatus(data.error || "Pedigree crawl failed.", "error"); return; }
|
||||||
setPedStatus(`Tracing ancestry… ${pedCountText()}`, "busy");
|
setPedStatus(`Tracing ancestry… ${pedCountText()}`, "busy");
|
||||||
pedPollTimer = setTimeout(tick, 1500);
|
pedPollTimer = setTimeout(tick, 1500);
|
||||||
@@ -2584,10 +2615,7 @@
|
|||||||
b.append(nameEl, metaEl);
|
b.append(nameEl, metaEl);
|
||||||
b.addEventListener("click", () => {
|
b.addEventListener("click", () => {
|
||||||
pedChoose.hidden = true;
|
pedChoose.hidden = true;
|
||||||
const q = (m.Regnr || "").trim() || nm;
|
lookupPedigree((m.Regnr || "").trim() || nm);
|
||||||
pedQ.value = q;
|
|
||||||
try { localStorage.setItem(pedQKey(), q); } catch { /* ignore */ }
|
|
||||||
lookupPedigree(q);
|
|
||||||
});
|
});
|
||||||
pedChoose.append(b);
|
pedChoose.append(b);
|
||||||
});
|
});
|
||||||
@@ -3113,6 +3141,7 @@
|
|||||||
|
|
||||||
setStatus();
|
setStatus();
|
||||||
render();
|
render();
|
||||||
|
refreshPedigreeButton();
|
||||||
sync();
|
sync();
|
||||||
syncConfig();
|
syncConfig();
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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-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-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 y-axis is stretched above 10h, giving the hours around the sleep goal most of the chart" },
|
||||||
|
|||||||
+10
-9
@@ -70,7 +70,7 @@
|
|||||||
<div id="puppy-age" class="puppy-age" hidden></div>
|
<div id="puppy-age" class="puppy-age" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button type="button" id="pedigree-btn" class="ghost icon-btn" aria-label="Pedigree" title="Pedigree">🌳</button>
|
<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="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>
|
<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>
|
<div id="online-status" class="status-pill"></div>
|
||||||
@@ -282,15 +282,11 @@
|
|||||||
<h1>🌳 Pedigree</h1>
|
<h1>🌳 Pedigree</h1>
|
||||||
</header>
|
</header>
|
||||||
<main class="pedigree-main">
|
<main class="pedigree-main">
|
||||||
<form id="pedigree-form" class="pedigree-form">
|
|
||||||
<input type="text" id="pedigree-q" autocomplete="off" spellcheck="false"
|
|
||||||
placeholder="Chip or registration number" aria-label="Dog chip or registration number" />
|
|
||||||
<button type="submit" id="pedigree-lookup">Look up</button>
|
|
||||||
</form>
|
|
||||||
<p class="muted-note pedigree-hint">
|
<p class="muted-note pedigree-hint">
|
||||||
Looks up a dog in <strong>SKK Hunddata</strong> by ISO chip number
|
Your dog's ancestry from <strong>SKK Hunddata</strong>, traced from the
|
||||||
(e.g. 752095600044144) or registration number (e.g. SE23536/2026),
|
ID set in Settings. The first generations show at once, then the line
|
||||||
then traces its ancestry. Needs an internet connection.
|
fills in further back.
|
||||||
|
<button type="button" id="pedigree-refresh" class="linklike">Refresh</button>
|
||||||
</p>
|
</p>
|
||||||
<p id="pedigree-status" class="pedigree-status" hidden></p>
|
<p id="pedigree-status" class="pedigree-status" hidden></p>
|
||||||
<div id="pedigree-choose" class="pedigree-choose" hidden></div>
|
<div id="pedigree-choose" class="pedigree-choose" hidden></div>
|
||||||
@@ -319,6 +315,11 @@
|
|||||||
<label>Birthday
|
<label>Birthday
|
||||||
<input type="date" id="settings-birthday" />
|
<input type="date" id="settings-birthday" />
|
||||||
</label>
|
</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">
|
<label class="toggle-row">
|
||||||
<span>Dark mode</span>
|
<span>Dark mode</span>
|
||||||
<input type="checkbox" id="settings-theme" role="switch" class="switch" />
|
<input type="checkbox" id="settings-theme" role="switch" class="switch" />
|
||||||
|
|||||||
+1
-25
@@ -324,6 +324,7 @@ button.danger { background: var(--danger); }
|
|||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
.timing-hint { margin: 10px 4px 0; line-height: 1.4; }
|
.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 {
|
.history-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1066,31 +1067,6 @@ section.collapsed > :not(h2) { display: none; }
|
|||||||
}
|
}
|
||||||
.pedigree-header h1 { font-size: 1.4rem; }
|
.pedigree-header h1 { font-size: 1.4rem; }
|
||||||
|
|
||||||
.pedigree-form {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin: 8px 0 6px;
|
|
||||||
}
|
|
||||||
.pedigree-form input {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 10px 12px;
|
|
||||||
font-size: 1rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
.pedigree-form button {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
padding: 10px 16px;
|
|
||||||
font-size: 1rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
background: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.pedigree-hint { margin: 4px 0 12px; }
|
.pedigree-hint { margin: 4px 0 12px; }
|
||||||
|
|
||||||
.pedigree-status {
|
.pedigree-status {
|
||||||
|
|||||||
Reference in New Issue
Block a user