Track age and weight
This commit is contained in:
+128
-7
@@ -17,19 +17,98 @@ import (
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
At int64 `json:"at"`
|
||||
Note string `json:"note"`
|
||||
PhotoID string `json:"photoId,omitempty"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
Deleted bool `json:"deleted,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
At int64 `json:"at"`
|
||||
Note string `json:"note"`
|
||||
PhotoID string `json:"photoId,omitempty"`
|
||||
Weight float64 `json:"weight,omitempty"` // kilograms, for "weight" events
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
Deleted bool `json:"deleted,omitempty"`
|
||||
}
|
||||
|
||||
var uuidRE = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
|
||||
|
||||
func validUUID(s string) bool { return uuidRE.MatchString(s) }
|
||||
|
||||
var birthdayRE = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
|
||||
|
||||
func validBirthday(s string) bool { return s == "" || birthdayRE.MatchString(s) }
|
||||
|
||||
// Config is the shared puppy profile (name + birthday). It lives on the host so
|
||||
// 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"`
|
||||
}
|
||||
|
||||
type ConfigStore struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
cfg Config
|
||||
}
|
||||
|
||||
func newConfigStore(path string) (*ConfigStore, error) {
|
||||
cs := &ConfigStore{path: path}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return cs, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
if err := json.NewDecoder(f).Decode(&cs.cfg); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, err
|
||||
}
|
||||
return cs, nil
|
||||
}
|
||||
|
||||
func (cs *ConfigStore) get() Config {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
return cs.cfg
|
||||
}
|
||||
|
||||
// merge applies an incoming config with last-write-wins by UpdatedAt and
|
||||
// returns the resulting stored config (which the caller sends back).
|
||||
func (cs *ConfigStore) merge(in Config) (Config, error) {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
if in.UpdatedAt > cs.cfg.UpdatedAt {
|
||||
cs.cfg = in
|
||||
if err := cs.saveLocked(); err != nil {
|
||||
return cs.cfg, err
|
||||
}
|
||||
}
|
||||
return cs.cfg, nil
|
||||
}
|
||||
|
||||
// Caller must hold cs.mu.
|
||||
func (cs *ConfigStore) saveLocked() error {
|
||||
if err := os.MkdirAll(filepath.Dir(cs.path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := cs.path + ".tmp"
|
||||
f, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
enc := json.NewEncoder(f)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(cs.cfg); err != nil {
|
||||
f.Close()
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, cs.path)
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
@@ -143,6 +222,11 @@ func main() {
|
||||
log.Fatalf("load store: %v", err)
|
||||
}
|
||||
|
||||
configStore, err := newConfigStore(filepath.Join(filepath.Dir(*dataPath), "config.json"))
|
||||
if err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
|
||||
photosDir := filepath.Join(filepath.Dir(*dataPath), "photos")
|
||||
if err := os.MkdirAll(photosDir, 0o755); err != nil {
|
||||
log.Fatalf("mkdir photos: %v", err)
|
||||
@@ -174,6 +258,43 @@ func main() {
|
||||
})
|
||||
})
|
||||
|
||||
// GET /api/config — return the shared puppy profile.
|
||||
// PUT /api/config — update it (last-write-wins by updatedAt).
|
||||
mux.HandleFunc("/api/config", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeConfig := func(c Config) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(c)
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
writeConfig(configStore.get())
|
||||
case http.MethodPut, http.MethodPost:
|
||||
var in Config
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&in); err != nil {
|
||||
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
if len(in.Name) > 100 {
|
||||
in.Name = in.Name[:100]
|
||||
}
|
||||
if !validBirthday(in.Birthday) {
|
||||
http.Error(w, "invalid birthday (want YYYY-MM-DD)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
merged, err := configStore.merge(in)
|
||||
if err != nil {
|
||||
log.Printf("config save: %v", err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeConfig(merged)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user