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
+34 -16
View File
@@ -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
+32 -1
View File
@@ -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{