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:
+34
-16
@@ -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
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user