Add training tracking: exercises with instructions, one-tap session log, consistency overview
Exercises (name + how-to note) are a new synced collection with the same LWW/tombstone contract as events, served by POST /api/exercises/sync. Training sessions are ordinary events (type "training") referencing an exercise by id, so they ride the existing event sync unchanged. The Training panel lists each exercise with last-trained / this-week / streak stats, expandable instructions, and a one-tap Log button with the usual undo/add-note snackbar. An exercise-by-day heatmap shows the last 14 days of consistency, and history and the daily overview count training sessions like any other event.
This commit is contained in:
@@ -173,6 +173,9 @@ func (a *Auth) adopt(userID string) error {
|
||||
if _, err := a.db.Exec(`UPDATE config SET user_id = ? WHERE user_id = ''`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.db.Exec(`UPDATE exercises SET user_id = ? WHERE user_id = ''`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.adoptPhotos(userID)
|
||||
}
|
||||
|
||||
@@ -402,6 +405,7 @@ func (a *Auth) deleteAccount(userID string) error {
|
||||
defer tx.Rollback()
|
||||
for _, q := range []string{
|
||||
`DELETE FROM events WHERE user_id = ?`,
|
||||
`DELETE FROM exercises WHERE user_id = ?`,
|
||||
`DELETE FROM config WHERE user_id = ?`,
|
||||
`DELETE FROM sessions WHERE user_id = ?`,
|
||||
`DELETE FROM users WHERE id = ?`,
|
||||
|
||||
+153
-22
@@ -24,14 +24,27 @@ 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"`
|
||||
Weight float64 `json:"weight,omitempty"` // kilograms, for "weight" events
|
||||
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
|
||||
ExerciseID string `json:"exerciseId,omitempty"` // for "training" events
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
Deleted bool `json:"deleted,omitempty"`
|
||||
}
|
||||
|
||||
// Exercise is a user-defined training exercise (e.g. "Sit", "Leash walking"):
|
||||
// a name plus optional instruction text. Training sessions reference one by
|
||||
// ExerciseID on the event. Exercises sync exactly like events: UUID ids,
|
||||
// last-write-wins on UpdatedAt, tombstoned deletes.
|
||||
type Exercise struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Note string `json:"note"` // instructions / reminder how to do it
|
||||
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}$`)
|
||||
@@ -114,11 +127,12 @@ func (s *Store) sync(userID string, client []Event) ([]Event, error) {
|
||||
// clobber another's row even if a client forges a colliding event ID —
|
||||
// the row stays put and, because reads are scoped, stays invisible to them.
|
||||
stmt, err := tx.Prepare(`
|
||||
INSERT INTO events (id, type, at, note, photo_id, weight, updated, deleted, user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO events (id, type, at, note, photo_id, weight, exercise_id, updated, deleted, user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
type = excluded.type, at = excluded.at, note = excluded.note,
|
||||
photo_id = excluded.photo_id, weight = excluded.weight,
|
||||
exercise_id = excluded.exercise_id,
|
||||
updated = excluded.updated, deleted = excluded.deleted
|
||||
WHERE excluded.updated > events.updated
|
||||
AND events.user_id = excluded.user_id`)
|
||||
@@ -132,7 +146,7 @@ func (s *Store) sync(userID string, client []Event) ([]Event, error) {
|
||||
continue
|
||||
}
|
||||
if _, err := stmt.Exec(
|
||||
ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.UpdatedAt, ce.Deleted, userID,
|
||||
ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.ExerciseID, ce.UpdatedAt, ce.Deleted, userID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -146,7 +160,7 @@ func (s *Store) sync(userID string, client []Event) ([]Event, error) {
|
||||
// all returns one user's events, tombstones included.
|
||||
func (s *Store) all(userID string) ([]Event, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, type, at, note, photo_id, weight, updated, deleted
|
||||
`SELECT id, type, at, note, photo_id, weight, exercise_id, updated, deleted
|
||||
FROM events WHERE user_id = ?`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -156,7 +170,7 @@ func (s *Store) all(userID string) ([]Event, error) {
|
||||
for rows.Next() {
|
||||
var e Event
|
||||
if err := rows.Scan(
|
||||
&e.ID, &e.Type, &e.At, &e.Note, &e.PhotoID, &e.Weight, &e.UpdatedAt, &e.Deleted,
|
||||
&e.ID, &e.Type, &e.At, &e.Note, &e.PhotoID, &e.Weight, &e.ExerciseID, &e.UpdatedAt, &e.Deleted,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -165,6 +179,72 @@ func (s *Store) all(userID string) ([]Event, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ExerciseStore mirrors Store for the exercises collection: same LWW sync by
|
||||
// UpdatedAt, same user_id guard against cross-user id collisions, same
|
||||
// tombstone propagation.
|
||||
type ExerciseStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func newExerciseStore(db *sql.DB) *ExerciseStore {
|
||||
return &ExerciseStore{db: db}
|
||||
}
|
||||
|
||||
func (s *ExerciseStore) sync(userID string, client []Exercise) ([]Exercise, error) {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
stmt, err := tx.Prepare(`
|
||||
INSERT INTO exercises (id, name, note, updated, deleted, user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name, note = excluded.note,
|
||||
updated = excluded.updated, deleted = excluded.deleted
|
||||
WHERE excluded.updated > exercises.updated
|
||||
AND exercises.user_id = excluded.user_id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, ce := range client {
|
||||
if ce.ID == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := stmt.Exec(
|
||||
ce.ID, ce.Name, ce.Note, ce.UpdatedAt, ce.Deleted, userID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.all(userID)
|
||||
}
|
||||
|
||||
func (s *ExerciseStore) all(userID string) ([]Exercise, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, name, note, updated, deleted
|
||||
FROM exercises WHERE user_id = ?`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]Exercise, 0)
|
||||
for rows.Next() {
|
||||
var e Exercise
|
||||
if err := rows.Scan(&e.ID, &e.Name, &e.Note, &e.UpdatedAt, &e.Deleted); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// openDB opens (creating if needed) the SQLite database and ensures the schema
|
||||
// exists. WAL mode plays nicely with concurrent readers during a sync write;
|
||||
// busy_timeout avoids spurious "database is locked" errors under contention.
|
||||
@@ -191,17 +271,27 @@ func openDB(path string) (*sql.DB, error) {
|
||||
// the first account adopts it (see Auth.adopt).
|
||||
schema := `
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL DEFAULT '',
|
||||
at INTEGER NOT NULL DEFAULT 0,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
photo_id TEXT NOT NULL DEFAULT '',
|
||||
weight REAL NOT NULL DEFAULT 0,
|
||||
updated INTEGER NOT NULL DEFAULT 0,
|
||||
deleted INTEGER NOT NULL DEFAULT 0,
|
||||
user_id TEXT NOT NULL DEFAULT ''
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL DEFAULT '',
|
||||
at INTEGER NOT NULL DEFAULT 0,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
photo_id TEXT NOT NULL DEFAULT '',
|
||||
weight REAL NOT NULL DEFAULT 0,
|
||||
exercise_id TEXT NOT NULL DEFAULT '',
|
||||
updated INTEGER NOT NULL DEFAULT 0,
|
||||
deleted INTEGER NOT NULL DEFAULT 0,
|
||||
user_id TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_user ON events(user_id);
|
||||
CREATE TABLE IF NOT EXISTS exercises (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
updated INTEGER NOT NULL DEFAULT 0,
|
||||
deleted INTEGER NOT NULL DEFAULT 0,
|
||||
user_id TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
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 '',
|
||||
@@ -249,6 +339,15 @@ func migrateSchema(db *sql.DB) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
hasExercise, err := columnExists(db, "events", "exercise_id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasExercise {
|
||||
if _, err := db.Exec(`ALTER TABLE events ADD COLUMN exercise_id TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
oldConfig, err := columnExists(db, "config", "id")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -383,6 +482,14 @@ type syncResponse struct {
|
||||
ServerNow int64 `json:"serverNow"`
|
||||
}
|
||||
|
||||
type exerciseSyncRequest struct {
|
||||
Exercises []Exercise `json:"exercises"`
|
||||
}
|
||||
|
||||
type exerciseSyncResponse struct {
|
||||
Exercises []Exercise `json:"exercises"`
|
||||
}
|
||||
|
||||
type cacheControlFS struct {
|
||||
root http.FileSystem
|
||||
}
|
||||
@@ -481,6 +588,7 @@ func main() {
|
||||
|
||||
store := newStore(db)
|
||||
configStore := newConfigStore(db)
|
||||
exerciseStore := newExerciseStore(db)
|
||||
|
||||
photosDir := filepath.Join(filepath.Dir(*dataPath), "photos")
|
||||
if err := os.MkdirAll(photosDir, 0o755); err != nil {
|
||||
@@ -532,6 +640,29 @@ func main() {
|
||||
})
|
||||
}))
|
||||
|
||||
// POST /api/exercises/sync — merge the caller's training exercises, same
|
||||
// LWW contract as /api/events/sync.
|
||||
mux.HandleFunc("/api/exercises/sync", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req exerciseSyncRequest
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 8<<20)).Decode(&req); err != nil {
|
||||
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
merged, err := exerciseStore.sync(userID(r), req.Exercises)
|
||||
if err != nil {
|
||||
log.Printf("exercises sync: %v", err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(exerciseSyncResponse{Exercises: merged})
|
||||
}))
|
||||
|
||||
// GET /api/config — return the caller's puppy profile.
|
||||
// PUT /api/config — update it (last-write-wins by updatedAt).
|
||||
mux.HandleFunc("/api/config", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
Reference in New Issue
Block a user