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:
Alexander Heldt
2026-07-26 09:36:49 +00:00
parent 26ebe3bd86
commit c2f74e64c8
7 changed files with 147 additions and 88 deletions
+11 -7
View File
@@ -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
+24 -6
View File
@@ -62,6 +62,9 @@ func validBirthday(s string) bool { return s == "" || birthdayRE.MatchString(s)
type Config struct { type Config struct {
Name string `json:"name"` Name string `json:"name"`
Birthday string `json:"birthday"` 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"` UpdatedAt int64 `json:"updatedAt"`
} }
@@ -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
} }
@@ -298,6 +302,7 @@ func openDB(path string) (*sql.DB, error) {
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 '',
pedigree_id TEXT NOT NULL DEFAULT '',
updated INTEGER NOT NULL DEFAULT 0 updated INTEGER NOT NULL DEFAULT 0
); );
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {