From a6604728d938c3089994b2c08938ae84d94d7db7 Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Thu, 15 May 2025 13:08:28 -0400 Subject: [PATCH] Open databases in the logbook package so the user does not have to pass in a boltDB pointer. --- cmd/lb/main.go | 59 +++- cmd/logbook/main.go | 670 ++++++++++++++++--------------------- cmd/logbook/main.go-backup | 197 ----------- main.go | 16 +- measurement.go | 12 +- review/estimate.go | 112 ++++++- 6 files changed, 456 insertions(+), 610 deletions(-) delete mode 100644 cmd/logbook/main.go-backup diff --git a/cmd/lb/main.go b/cmd/lb/main.go index 24a9c29..094efe5 100644 --- a/cmd/lb/main.go +++ b/cmd/lb/main.go @@ -12,7 +12,6 @@ import ( "git.wow.st/gmp/logbook" "git.wow.st/gmp/logbook/parser" "git.wow.st/gmp/logbook/review" - bolt "go.etcd.io/bbolt" ) func expandPath(path string) string { @@ -71,6 +70,7 @@ func getPerformances(wr *logbook.WorkoutRepository, mr *logbook.MeasurementRepos ps, err := pr.FindPerformances(filter) return ps, err } + func main() { defaultDB := "~/.config/logbook/logbook.db" var dbPath string @@ -85,22 +85,13 @@ func main() { dbPath = expandPath(dbPath) - db, err := bolt.Open(dbPath, 0600, nil) + wr, mr, err := logbook.NewRepositories(dbPath) if err != nil { - fmt.Fprintf(os.Stderr, "Error opening database: %v\n", err) - os.Exit(1) - } - defer db.Close() - wr, err := logbook.NewWorkoutRepository(db) - if err != nil { - fmt.Fprintf(os.Stderr, "Error opening workout repository: %v\n", err) - os.Exit(1) - } - mr, err := logbook.NewMeasurementRepository(db) - if err != nil { - fmt.Fprintf(os.Stderr, "Error opening measurement repository: %v\n", err) + fmt.Fprintf(os.Stderr, "Error opening repositories: %v\n", err) os.Exit(1) } + defer wr.Close() + defer mr.Close() cmd := flag.Arg(0) switch cmd { @@ -232,7 +223,6 @@ func main() { } } } - //a, b := review.FitPowerLaw(weights, reps, date, 14.0) est := review.NewEstimator(review.WithHalfLife(14.0)) err = est.Fit(weights, reps, dates) if err != nil { @@ -244,7 +234,44 @@ func main() { if isbw { adj = ps[len(ps)-1].Bodyweight } - fmt.Printf("%d: %0.0f\n", i, est.EstimateMaxWeight(time.Now(), float64(i)) - adj) + fmt.Printf("%d: %0.0f\n", i, est.EstimateMaxWeight(float64(i)) - adj) + } + case "weekly-estimate": + ps, err := getPerformances(wr, mr) + if err != nil { + fmt.Printf("Error finding performance: %v\n", err) + os.Exit(1) + } + var weights, reps []float64 + var dates []time.Time + isbw := review.IsBodyweight(ps[0].Exercise, ps[0].Type) + + for _, p := range (ps) { + for i, w := range (p.Weights) { + if isbw { + w = w + p.Bodyweight + } + if p.RIR[i] == 0 { + weights = append(weights, w) + reps = append(reps, float64(p.Reps[i])) + dates = append(dates, p.Times[i].UTC()) + fmt.Printf("Set: weight %0.0f, reps %0.0f\n", w, float64(p.Reps[i])) + } + } + } + var adj float64 + if isbw { + adj = ps[len(ps)-1].Bodyweight + } + est := review.NewEstimator(review.WithHalfLife(14.0)) + err = est.Fit(weights, reps, dates) + if err != nil { + fmt.Printf("Error estimating performance: %v\n", err) + os.Exit(1) + } + times, vals := est.TimelineEstimate1RM(7*24*time.Hour) + for i, t := range times { + fmt.Printf("%s: %0.0f\n", t.Format("2006-01-02"), vals[i] - adj) } case "predict": if flag.NArg() != 3 { diff --git a/cmd/logbook/main.go b/cmd/logbook/main.go index 88915f3..e5e84b4 100644 --- a/cmd/logbook/main.go +++ b/cmd/logbook/main.go @@ -1,403 +1,321 @@ package main import ( - "fmt" - "image/color" - "log" - "os" - "os/user" - "path/filepath" - "sort" - "time" + "image" + "image/color" + "log" + "os" + "time" - "gioui.org/app" - "gioui.org/layout" - "gioui.org/op" - "gioui.org/unit" - "gioui.org/widget" - "gioui.org/widget/material" - - bolt "github.com/etcd-io/bbolt" - "git.wow.st/gmp/logbook" + "gioui.org/app" + "gioui.org/font/gofont" + "gioui.org/io/event" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/op/clip" + "gioui.org/op/paint" + "gioui.org/text" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" ) -var ( - btnPrevWorkouts widget.Clickable - btnPredictStrength widget.Clickable - btnMeasurements widget.Clickable - btnPlanWorkout widget.Clickable - btnLeft widget.Clickable - btnRight widget.Clickable - list = new(layout.List) // pointer to layout.List -) - -type page int +type Page int const ( - pageLanding page = iota - pagePlanWorkout + PageLanding Page = iota + PageWorkoutReview + PagePerformance + PageMeasurements ) +type AppState struct { + Theme *material.Theme + CurrentPage Page + + BtnLanding widget.Clickable + BtnWorkoutReview widget.Clickable + BtnPerformance widget.Clickable + BtnMeasurements widget.Clickable +} + func main() { - go func() { - window := new(app.Window) - if err := run(window); err != nil { - log.Fatal(err) - } - os.Exit(0) - }() - app.Main() + go func() { + w := new(app.Window) + w.Option(app.Title("Logbook Review Mockup")) + w.Option(app.Size(unit.Dp(400), unit.Dp(700))) + if err := loop(w); err != nil { + log.Fatal(err) + } + os.Exit(0) + }() + app.Main() } -func openStandardDB() (*bolt.DB, error) { - usr, err := user.Current() - if err != nil { - return nil, err - } - dir := filepath.Join(usr.HomeDir, ".config", "logbook") - if err := os.MkdirAll(dir, 0700); err != nil { - return nil, err - } - dbPath := filepath.Join(dir, "logbook.db") - db, err := bolt.Open(dbPath, 0600, nil) - if err != nil { - return nil, err - } - return db, nil +func loop(w *app.Window) error { + th := material.NewTheme() + th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection())) + var ops op.Ops + state := &AppState{ + Theme: th, + } + events := make(chan event.Event) + acks := make(chan struct{}) + + go func() { + for { + ev := w.Event() + events <- ev + <-acks + if _, ok := ev.(app.DestroyEvent); ok { + return + } + } + }() + for { + select { + case e := <-events: + switch e:= e.(type) { + case app.DestroyEvent: + acks <- struct{}{} + return e.Err + case app.FrameEvent: + gtx := app.NewContext(&ops, e) + state.Layout(gtx) + e.Frame(gtx.Ops) + } + acks <- struct{}{} + } + } } -func run(window *app.Window) error { - theme := material.NewTheme() - var ops op.Ops - - db, err := openStandardDB() - if err != nil { - return fmt.Errorf("failed to open DB: %w", err) - } - defer db.Close() - workoutRepo, err := logbook.NewWorkoutRepository(db) - if err != nil { - return fmt.Errorf("failed to create workout repo: %w", err) - } - setRepo, err := logbook.NewSetRepository(db) - if err != nil { - return fmt.Errorf("failed to create set repo: %w", err) - } - - // Navigation state - currentPage := pageLanding - var planPage *planWorkoutPage - - // For landing page state - var planned []*logbook.Workout - var workoutIdx int - - // Preload planned workouts for landing page - reloadPlanned := func() { - now := time.Now() - allWorkouts, err := workoutRepo.FindAll() - if err != nil { - planned = nil - workoutIdx = 0 - return - } - planned = filterPlannedWorkoutsAfter(allWorkouts, now) - sort.Slice(planned, func(i, j int) bool { - return planned[i].Date.Before(planned[j].Date) - }) - workoutIdx = 0 - } - reloadPlanned() - - for { - e := window.Event() - switch e := e.(type) { - case app.DestroyEvent: - return e.Err - case app.FrameEvent: - gtx := app.NewContext(&ops, e) - - switch currentPage { - case pageLanding: - // Click handling flags - planWorkoutClicked := false - - layout.Flex{ - Axis: layout.Vertical, - }.Layout(gtx, - // Top navigation bar - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, - layout.Rigid(material.Button(theme, &btnPrevWorkouts, "Previous Workouts").Layout), - layout.Rigid(material.Button(theme, &btnPredictStrength, "Predict Strength").Layout), - layout.Rigid(material.Button(theme, &btnMeasurements, "Measurements").Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - dims := material.Button(theme, &btnPlanWorkout, "Plan Workout").Layout(gtx) - if btnPlanWorkout.Clicked(gtx) { - planWorkoutClicked = true - } - return dims - }), - ) - }), - // Main content: Next planned workout - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if len(planned) == 0 { - return material.H5(theme, "No planned workouts.").Layout(gtx) - } - w := planned[workoutIdx] - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return material.H4(theme, "Next Workout: "+w.Date.Format("Mon Jan 2, 2006 3:04 PM")).Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return material.H5(theme, "Type: "+w.Type).Layout(gtx) - }), - // Planned sets, one per line - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - sets := getPlannedSets(setRepo, w.SetIDs) - return list.Layout(gtx, len(sets), func(gtx layout.Context, i int) layout.Dimensions { - set := sets[i] - return material.Body1(theme, formatSet(set)).Layout(gtx) - }) - }), - ) - }), - // Bottom navigation: left/right arrows - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - leftClicked := false - rightClicked := false - dims := layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - btn := material.Button(theme, &btnLeft, "←") - if workoutIdx == 0 { - btn.Background = color.NRGBA{A: 80} - } - d := btn.Layout(gtx) - if btnLeft.Clicked(gtx) { - leftClicked = true - } - return d - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - btn := material.Button(theme, &btnRight, "→") - if workoutIdx == len(planned)-1 { - btn.Background = color.NRGBA{A: 80} - } - d := btn.Layout(gtx) - if btnRight.Clicked(gtx) { - rightClicked = true - } - return d - }), - ) - // Handle navigation after layout - if leftClicked && workoutIdx > 0 { - workoutIdx-- - } - if rightClicked && workoutIdx < len(planned)-1 { - workoutIdx++ - } - return dims - }), - ) - - // Handle navigation to Plan Workout page after layout - if planWorkoutClicked { - planPage = newPlanWorkoutPage() - currentPage = pagePlanWorkout - } - - case pagePlanWorkout: - planPage.Layout(gtx, theme, setRepo, workoutRepo, func() { - reloadPlanned() - currentPage = pageLanding - }) - } - - e.Frame(gtx.Ops) - } - } +func (state *AppState) Layout(gtx layout.Context) layout.Dimensions { + return layout.Flex{ + Axis: layout.Vertical, + }.Layout(gtx, + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + switch state.CurrentPage { + case PageLanding: + return LandingPage(state, gtx) + case PageWorkoutReview: + return WorkoutReviewPage(state, gtx) + case PagePerformance: + return PerformancePage(state, gtx) + case PageMeasurements: + return MeasurementsPage(state, gtx) + default: + return layout.Dimensions{} + } + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return state.BottomNav(gtx) + }), + ) } -func filterPlannedWorkoutsAfter(workouts []*logbook.Workout, after time.Time) []*logbook.Workout { - var planned []*logbook.Workout - for _, w := range workouts { - if w.Date.After(after) { - planned = append(planned, w) - } - } - return planned +func (state *AppState) BottomNav(gtx layout.Context) layout.Dimensions { + th := state.Theme + + labels := []string{"Home", "Workouts", "Performance", "Measurements"} + clicks := []*widget.Clickable{ + &state.BtnLanding, &state.BtnWorkoutReview, &state.BtnPerformance, &state.BtnMeasurements, + } + + // Measure button widths using a dry run + btnWidths := make([]int, len(labels)) + btnHeights := make([]int, len(labels)) + for i, label := range labels { + btn := material.Button(th, clicks[i], label) + // Use a scratch context with unconstrained width + mctx := gtx + mctx.Constraints = layout.Constraints{ + Min: image.Pt(0, 0), + Max: image.Pt(gtx.Dp(1000), gtx.Constraints.Max.Y), + } + dims := btn.Layout(mctx) + btnWidths[i] = dims.Size.X + btnHeights[i] = dims.Size.Y + } + + gap := gtx.Dp(8) + totalWidth := btnWidths[0] + btnWidths[1] + btnWidths[2] + btnWidths[3] + gap*3 + + if totalWidth <= gtx.Constraints.Max.X { + // All buttons fit in one row + return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if state.BtnLanding.Clicked(gtx) { + state.CurrentPage = PageLanding + } + return material.Button(th, &state.BtnLanding, "Home").Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if state.BtnWorkoutReview.Clicked(gtx) { + state.CurrentPage = PageWorkoutReview + } + return material.Button(th, &state.BtnWorkoutReview, "Workouts").Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if state.BtnPerformance.Clicked(gtx) { + state.CurrentPage = PagePerformance + } + return material.Button(th, &state.BtnPerformance, "Performance").Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if state.BtnMeasurements.Clicked(gtx) { + state.CurrentPage = PageMeasurements + } + return material.Button(th, &state.BtnMeasurements, "Measurements").Layout(gtx) + }), + ) + } else { + // Two rows, two buttons per row + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if state.BtnLanding.Clicked(gtx) { + state.CurrentPage = PageLanding + } + return material.Button(th, &state.BtnLanding, "Home").Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if state.BtnWorkoutReview.Clicked(gtx) { + state.CurrentPage = PageWorkoutReview + } + return material.Button(th, &state.BtnWorkoutReview, "Workouts").Layout(gtx) + }), + ) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if state.BtnPerformance.Clicked(gtx) { + state.CurrentPage = PagePerformance + } + return material.Button(th, &state.BtnPerformance, "Performance").Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if state.BtnMeasurements.Clicked(gtx) { + state.CurrentPage = PageMeasurements + } + return material.Button(th, &state.BtnMeasurements, "Measurements").Layout(gtx) + }), + ) + }), + ) + } } -// Use setRepo.GetByID for efficient lookup -func getPlannedSets(setRepo *logbook.SetRepository, setIDs []string) []logbook.Set { - var sets []logbook.Set - for _, id := range setIDs { - set, err := setRepo.GetByID(id) - if err == nil && set != nil { - sets = append(sets, *set) - } - } - return sets +func LandingPage(state *AppState, gtx layout.Context) layout.Dimensions { + th := state.Theme + return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(material.H4(th, "Next Planned Workout").Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(material.Body1(th, "Date: "+time.Now().Add(24*time.Hour).Format("2006-01-02")).Layout), + layout.Rigid(material.Body1(th, "Type: Upper Body").Layout), + layout.Rigid(material.Body1(th, "Exercises: Bench Press, Row, Curl").Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout), + layout.Rigid(material.Button(th, new(widget.Clickable), "Modify Plan").Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(material.Button(th, new(widget.Clickable), "Start Session").Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(32)}.Layout), + layout.Rigid(material.H6(th, "Quick Stats").Layout), + layout.Rigid(material.Body2(th, "Bodyweight: 175 lb").Layout), + layout.Rigid(material.Body2(th, "Current 1RM (Bench): 225 lb").Layout), + ) + }) } -func formatSet(set logbook.Set) string { - if set.Weight > 0 { - return fmt.Sprintf("%s: %d reps @ %.0f lb", set.Exercise, set.Reps, set.Weight) - } - return fmt.Sprintf("%s: %d reps (bodyweight)", set.Exercise, set.Reps) +func WorkoutReviewPage(state *AppState, gtx layout.Context) layout.Dimensions { + th := state.Theme + workouts := []struct { + Date string + Type string + Exercises string + Note string + }{ + {"2025-05-13", "Upper Body", "Bench Press, Row, Curl", "Felt strong, PR on bench."}, + {"2025-05-10", "Lower Body", "Squat, Deadlift", "Tired, but completed all sets."}, + } + var list layout.List + return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(material.H4(th, "Past Workouts").Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return list.Layout(gtx, len(workouts), func(gtx layout.Context, i int) layout.Dimensions { + w := workouts[i] + return layout.UniformInset(unit.Dp(8)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { + // Use a colored rectangle instead of material.Card + dr := image.Rect(0, 0, gtx.Constraints.Max.X, 70) + paint.FillShape(gtx.Ops, color.NRGBA{R: 0xee, G: 0xee, B: 0xff, A: 0xff}, clip.Rect(dr).Op()) + return layout.Inset{Left: unit.Dp(8), Top: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(material.Body1(th, w.Date+" - "+w.Type).Layout), + layout.Rigid(material.Body2(th, w.Exercises).Layout), + layout.Rigid(material.Caption(th, w.Note).Layout), + ) + }) + }) + }) + }), + ) + }) } -// --- Plan Workout Page Implementation --- - -type planWorkoutPage struct { - dateEditor widget.Editor - typeEditor widget.Editor - addSetBtn widget.Clickable - saveBtn widget.Clickable - sets []plannedSet - setEditors []plannedSetEditors - errorMsg string - successMsg string - backBtn widget.Clickable +func PerformancePage(state *AppState, gtx layout.Context) layout.Dimensions { + th := state.Theme + return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(material.H4(th, "Bench Press 1RM Trend").Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + // Just a colored rectangle as a placeholder for a graph + cs := gtx.Constraints + sz := cs.Constrain(image.Pt(300, 100)) + dr := image.Rectangle{Max: sz} + paintColor := color.NRGBA{R: 0x80, G: 0x80, B: 0xff, A: 0xff} + paint.FillShape(gtx.Ops, paintColor, clip.Rect(dr).Op()) + return layout.Dimensions{Size: sz} + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(material.Body2(th, "Actual (blue), Predicted (red)").Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout), + layout.Rigid(material.Body2(th, "Best: 225 lb (Aug)").Layout), + ) + }) } -type plannedSet struct { - Exercise string - Reps int - Weight float64 -} - -type plannedSetEditors struct { - exerciseEditor widget.Editor - repsEditor widget.Editor - weightEditor widget.Editor -} - -func newPlanWorkoutPage() *planWorkoutPage { - p := &planWorkoutPage{} - p.dateEditor.SingleLine = true - p.dateEditor.SetText(time.Now().Format("2006-01-02")) - p.typeEditor.SingleLine = true - p.setEditors = []plannedSetEditors{newPlannedSetEditors()} - return p -} - -func newPlannedSetEditors() plannedSetEditors { - var e plannedSetEditors - e.exerciseEditor.SingleLine = true - e.repsEditor.SingleLine = true - e.weightEditor.SingleLine = true - return e -} - -func (p *planWorkoutPage) Layout(gtx layout.Context, th *material.Theme, setRepo *logbook.SetRepository, workoutRepo *logbook.WorkoutRepository, navBack func()) layout.Dimensions { - var ls = new(layout.List) - ls.Axis = layout.Vertical - // Handle Add Set button - if p.addSetBtn.Clicked(gtx) { - p.setEditors = append(p.setEditors, newPlannedSetEditors()) - } - - // Handle Save button - if p.saveBtn.Clicked(gtx) { - p.errorMsg = "" - p.successMsg = "" - // Parse date - date, err := time.Parse("2006-01-02", p.dateEditor.Text()) - if err != nil { - p.errorMsg = "Invalid date format (use YYYY-MM-DD)" - } else { - // Collect sets - var setIDs []string - for _, ed := range p.setEditors { - exercise := ed.exerciseEditor.Text() - reps, _ := parseInt(ed.repsEditor.Text()) - weight, _ := parseFloat(ed.weightEditor.Text()) - if exercise == "" || reps == 0 { - continue // skip incomplete sets - } - set := &logbook.Set{ - Exercise: exercise, - Reps: reps, - Weight: weight, - Date: date, - Plan: true, - } - if err := setRepo.Save(set); err == nil { - setIDs = append(setIDs, set.ID) - } - } - if len(setIDs) == 0 { - p.errorMsg = "No valid sets entered." - } else { - workout := &logbook.Workout{ - Date: date, - Type: p.typeEditor.Text(), - SetIDs: setIDs, - } - if err := workoutRepo.Save(workout); err != nil { - p.errorMsg = "Failed to save workout: " + err.Error() - } else { - p.successMsg = "Workout planned!" - navBack() - } - } - } - } - - // Handle Back button - if p.backBtn.Clicked(gtx) { - navBack() - } - - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(material.H4(th, "Plan Workout").Layout), - layout.Rigid(material.Body1(th, "Date (YYYY-MM-DD):").Layout), - layout.Rigid(material.Editor(th, &p.dateEditor, "2025-05-05").Layout), - layout.Rigid(material.Body1(th, "Type:").Layout), - layout.Rigid(material.Editor(th, &p.typeEditor, "e.g. Strength, Hypertrophy").Layout), - layout.Rigid(material.Body1(th, "Sets:").Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return ls.Layout(gtx, len(p.setEditors), func(gtx layout.Context, i int) layout.Dimensions { - ed := &p.setEditors[i] - return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return layout.Inset{Right: unit.Dp(8)}.Layout(gtx, material.Editor(th, &ed.exerciseEditor, "Exercise").Layout) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return layout.Inset{Right: unit.Dp(8)}.Layout(gtx, material.Editor(th, &ed.repsEditor, "Reps").Layout) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return material.Editor(th, &ed.weightEditor, "Weight (lb)").Layout(gtx) - }), - ) - }) - }), - layout.Rigid(material.Button(th, &p.addSetBtn, "Add Set").Layout), - layout.Rigid(material.Button(th, &p.saveBtn, "Save Workout").Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if p.errorMsg != "" { - return material.Body1(th, "[Error] "+p.errorMsg).Layout(gtx) - } - if p.successMsg != "" { - return material.Body1(th, p.successMsg).Layout(gtx) - } - return layout.Dimensions{} - }), - layout.Rigid(material.Button(th, &p.backBtn, "Back").Layout), - ) -} - -func parseInt(s string) (int, error) { - var i int - _, err := fmt.Sscanf(s, "%d", &i) - return i, err -} -func parseFloat(s string) (float64, error) { - var f float64 - _, err := fmt.Sscanf(s, "%f", &f) - return f, err +func MeasurementsPage(state *AppState, gtx layout.Context) layout.Dimensions { + th := state.Theme + dates := []string{"2025-05-01", "2025-05-08", "2025-05-15"} + weights := []string{"174.8", "175.2", "175.0"} + waist := []string{"34.1", "34.0", "33.9"} + var list layout.List + return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(material.H4(th, "Measurements").Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return list.Layout(gtx, len(dates), func(gtx layout.Context, i int) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, + layout.Rigid(material.Body2(th, dates[i]).Layout), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(material.Body2(th, weights[i]+" lb").Layout), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(material.Body2(th, waist[i]+" in").Layout), + ) + }) + }), + ) + }) } diff --git a/cmd/logbook/main.go-backup b/cmd/logbook/main.go-backup deleted file mode 100644 index e47659c..0000000 --- a/cmd/logbook/main.go-backup +++ /dev/null @@ -1,197 +0,0 @@ -package main - -import ( - "fmt" - "image/color" - "log" - "os" - "os/user" - "path/filepath" - "sort" - "time" - - "gioui.org/app" - "gioui.org/layout" - "gioui.org/op" - "gioui.org/widget" - "gioui.org/widget/material" - - bolt "github.com/etcd-io/bbolt" - "git.wow.st/gmp/logbook" -) - -var ( - btnPrevWorkouts widget.Clickable - btnPredictStrength widget.Clickable - btnMeasurements widget.Clickable - btnPlanWorkout widget.Clickable - btnLeft widget.Clickable - btnRight widget.Clickable - list widget.List -) - -func main() { - go func() { - window := new(app.Window) - if err := run(window); err != nil { - log.Fatal(err) - } - os.Exit(0) - }() - app.Main() -} - -func openStandardDB() (*bolt.DB, error) { - usr, err := user.Current() - if err != nil { - return nil, err - } - dir := filepath.Join(usr.HomeDir, ".config", "logbook") - if err := os.MkdirAll(dir, 0700); err != nil { - return nil, err - } - dbPath := filepath.Join(dir, "logbook.db") - db, err := bolt.Open(dbPath, 0600, nil) - if err != nil { - return nil, err - } - return db, nil -} - -func run(window *app.Window) error { - theme := material.NewTheme() - var ops op.Ops - - // --- Open database and repositories --- - db, err := openStandardDB() - if err != nil { - return fmt.Errorf("failed to open DB: %w", err) - } - defer db.Close() - workoutRepo, err := logbook.NewWorkoutRepository(db) - if err != nil { - return fmt.Errorf("failed to create workout repo: %w", err) - } - setRepo, err := logbook.NewSetRepository(db) - if err != nil { - return fmt.Errorf("failed to create set repo: %w", err) - } - - // --- Load all planned workouts after now --- - now := time.Now() - allWorkouts, err := workoutRepo.FindAll() - if err != nil { - return fmt.Errorf("failed to load workouts: %w", err) - } - planned := filterPlannedWorkoutsAfter(allWorkouts, now) - sort.Slice(planned, func(i, j int) bool { - return planned[i].Date.Before(planned[j].Date) - }) - workoutIdx := 0 - - for { - e := window.Event() - switch e := e.(type) { - case app.DestroyEvent: - return e.Err - case app.FrameEvent: - gtx := app.NewContext(&ops, e) - - // Handle left/right navigation - if btnLeft.Clicked(gtx) && workoutIdx > 0 { - workoutIdx-- - } - if btnRight.Clicked(gtx) && workoutIdx < len(planned)-1 { - workoutIdx++ - } - - layout.Flex{ - Axis: layout.Vertical, - }.Layout(gtx, - // Top navigation bar - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, - layout.Rigid(material.Button(theme, &btnPrevWorkouts, "Previous Workouts").Layout), - layout.Rigid(material.Button(theme, &btnPredictStrength, "Predict Strength").Layout), - layout.Rigid(material.Button(theme, &btnMeasurements, "Measurements").Layout), - layout.Rigid(material.Button(theme, &btnPlanWorkout, "Plan Workout").Layout), - ) - }), - // Main content: Next planned workout - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if len(planned) == 0 { - return material.H5(theme, "No planned workouts.").Layout(gtx) - } - w := planned[workoutIdx] - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return material.H4(theme, "Next Workout: "+w.Date.Format("Mon Jan 2, 2006 3:04 PM")).Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return material.H5(theme, "Type: "+w.Type).Layout(gtx) - }), - // Planned sets, one per line - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - sets := getPlannedSets(setRepo, w.SetIDs) - return list.Layout(gtx, len(sets), func(gtx layout.Context, i int) layout.Dimensions { - set := sets[i] - return material.Body1(theme, formatSet(set)).Layout(gtx) - }) - }), - ) - }), - // Bottom navigation: left/right arrows - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - btn := material.Button(theme, &btnLeft, "←") - if workoutIdx == 0 { - btn.Background = color.NRGBA{A: 80} - } - return btn.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - btn := material.Button(theme, &btnRight, "→") - if workoutIdx == len(planned)-1 { - btn.Background = color.NRGBA{A: 80} - } - return btn.Layout(gtx) - }), - ) - }), - ) - e.Frame(gtx.Ops) - } - } -} - -// Filter for planned workouts after the current date -func filterPlannedWorkoutsAfter(workouts []*logbook.Workout, after time.Time) []*logbook.Workout { - var planned []*logbook.Workout - for _, w := range workouts { - if w.Date.After(after) { - planned = append(planned, w) - } - } - return planned -} - -// Load planned sets for a workout from the SetRepository -func getPlannedSets(setRepo *logbook.SetRepository, setIDs []string) []logbook.Set { - var sets []logbook.Set - for _, id := range setIDs { - set, err := setRepo.GetByID(id) - if err == nil && set != nil { - sets = append(sets, *set) - } - } - return sets -} - -// Format a set as a string for display -func formatSet(set logbook.Set) string { - if set.Weight > 0 { - return fmt.Sprintf("%s: %d reps @ %.0f lb", set.Exercise, set.Reps, set.Weight) - } - return fmt.Sprintf("%s: %d reps (bodyweight)", set.Exercise, set.Reps) -} diff --git a/main.go b/main.go index cc5fb2b..165135d 100644 --- a/main.go +++ b/main.go @@ -26,8 +26,12 @@ type WorkoutRepository struct { db *bolt.DB } -func NewWorkoutRepository(db *bolt.DB) (*WorkoutRepository, error) { - err := db.Update(func(tx *bolt.Tx) error { +func NewRepositories(dbPath string) (*WorkoutRepository, *MeasurementRepository, error) { + db, err := bolt.Open(dbPath, 0600, nil) + if err != nil { + return nil, nil, err + } + err = db.Update(func(tx *bolt.Tx) error { _, err := tx.CreateBucketIfNotExists(bucketSets) if err != nil { return err @@ -43,9 +47,13 @@ func NewWorkoutRepository(db *bolt.DB) (*WorkoutRepository, error) { return nil }) if err != nil { - return nil, err + return nil, nil, err } - return &WorkoutRepository{db: db}, nil + return &WorkoutRepository{db: db}, &MeasurementRepository{db: db}, nil +} + +func (wr *WorkoutRepository) Close() { + wr.db.Close() } // --- Helper functions --- diff --git a/measurement.go b/measurement.go index 8ca22b6..fc4a3ec 100644 --- a/measurement.go +++ b/measurement.go @@ -30,8 +30,12 @@ type MeasurementRepository struct { db *bolt.DB } -func NewMeasurementRepository(db *bolt.DB) (*MeasurementRepository, error) { - err := db.Update(func(tx *bolt.Tx) error { +func NewMeasurementRepository(dbpath string) (*MeasurementRepository, error) { + db, err := bolt.Open(dbpath, 0600, nil) + if err != nil { + return nil, err + } + err = db.Update(func(tx *bolt.Tx) error { _, err := tx.CreateBucketIfNotExists(bucketMeasurements) return err }) @@ -41,6 +45,10 @@ func NewMeasurementRepository(db *bolt.DB) (*MeasurementRepository, error) { return &MeasurementRepository{db: db}, nil } +func (mr *MeasurementRepository) Close() { + mr.db.Close() +} + func (r *MeasurementRepository) Save(m *Measurement) error { if m.ID == "" { m.ID = generateDateID(m.Date, "measurement-" + "m.Variable") diff --git a/review/estimate.go b/review/estimate.go index a255c08..418ad63 100644 --- a/review/estimate.go +++ b/review/estimate.go @@ -12,10 +12,15 @@ import ( // Estimator is the main interface for incremental, time-aware strength estimation. type Estimator interface { Fit(weight, reps []float64, dates []time.Time) error - Estimate1RM(t time.Time) float64 - EstimateReps(t time.Time, targetWeight float64) float64 - EstimateMaxWeight(t time.Time, nReps float64) float64 - Params(t time.Time) []float64 + + Estimate1RM(times ...time.Time) float64 + EstimateReps(targetWeight float64, times ...time.Time) float64 + EstimateMaxWeight(nReps float64, times ...time.Time) float64 + Params(times ...time.Time) []float64 + + TimelineEstimate1RM(interval time.Duration, starts ...time.Time) ([]time.Time, []float64) + TimelineEstimateReps(targetWeight float64, interval time.Duration, starts ...time.Time) ([]time.Time, []float64) + TimelineEstimateMaxWeight(nReps float64, interval time.Duration, starts ...time.Time) ([]time.Time, []float64) } // --- Functional Options --- @@ -117,14 +122,16 @@ func (e *estimatorImpl) Fit(weight, reps []float64, dates []time.Time) error { return nil } -// Estimate1RM returns the smoothed 1RM estimate at time t. -func (e *estimatorImpl) Estimate1RM(t time.Time) float64 { +// --- Estimate Functions with Variadic Time Parameter --- + +func (e *estimatorImpl) Estimate1RM(times ...time.Time) float64 { + t := e.resolveTime(times...) a, b := e.smoothedParamsAt(t) return a * math.Pow(1, b) } -// EstimateReps returns the predicted number of reps at a given weight and time. -func (e *estimatorImpl) EstimateReps(t time.Time, targetWeight float64) float64 { +func (e *estimatorImpl) EstimateReps(targetWeight float64, times ...time.Time) float64 { + t := e.resolveTime(times...) a, b := e.smoothedParamsAt(t) if a == 0 || b == 0 { return 0 @@ -132,21 +139,98 @@ func (e *estimatorImpl) EstimateReps(t time.Time, targetWeight float64) float64 return math.Pow(targetWeight/a, 1/b) } -// EstimateMaxWeight returns the predicted max weight for a given number of reps at time t. -func (e *estimatorImpl) EstimateMaxWeight(t time.Time, nReps float64) float64 { +func (e *estimatorImpl) EstimateMaxWeight(nReps float64, times ...time.Time) float64 { + t := e.resolveTime(times...) a, b := e.smoothedParamsAt(t) return a * math.Pow(nReps, b) } -// Params returns the smoothed model parameters at time t. -func (e *estimatorImpl) Params(t time.Time) []float64 { +func (e *estimatorImpl) Params(times ...time.Time) []float64 { + t := e.resolveTime(times...) a, b := e.smoothedParamsAt(t) return []float64{a, b} } +// --- Timeline Functions with Variadic Start Parameter --- + +func (e *estimatorImpl) TimelineEstimate1RM(interval time.Duration, starts ...time.Time) ([]time.Time, []float64) { + return e.timelineEstimate( + func(a, b float64) float64 { return a * math.Pow(1, b) }, + interval, starts..., + ) +} + +func (e *estimatorImpl) TimelineEstimateReps(targetWeight float64, interval time.Duration, starts ...time.Time) ([]time.Time, []float64) { + return e.timelineEstimate( + func(a, b float64) float64 { + if a == 0 || b == 0 { + return 0 + } + return math.Pow(targetWeight/a, 1/b) + }, + interval, starts..., + ) +} + +func (e *estimatorImpl) TimelineEstimateMaxWeight(nReps float64, interval time.Duration, starts ...time.Time) ([]time.Time, []float64) { + return e.timelineEstimate( + func(a, b float64) float64 { return a * math.Pow(nReps, b) }, + interval, starts..., + ) +} + +func (e *estimatorImpl) timelineEstimate( + f func(a, b float64) float64, + interval time.Duration, + starts ...time.Time, +) ([]time.Time, []float64) { + var times []time.Time + var values []float64 + if len(e.data) == 0 { + return times, values + } + start := e.resolveTimelineStart(starts...) + end := e.data[len(e.data)-1].date + for t := start; !t.After(end); t = t.Add(interval) { + a, b := e.smoothedParamsAt(t) + times = append(times, t) + values = append(values, f(a, b)) + } + return times, values +} + // --- Internal Helpers --- -// smoothedParamsAt returns the smoothed parameters for the closest time point <= t. +// resolveTime returns the correct time to use based on the variadic argument. +// Panics if more than one time is provided. +func (e *estimatorImpl) resolveTime(times ...time.Time) time.Time { + if len(times) == 0 { + if len(e.data) == 0 { + return time.Time{} + } + return e.data[len(e.data)-1].date + } + if len(times) == 1 { + return times[0] + } + panic("at most one time argument allowed") +} + +// resolveTimelineStart returns the timeline start time based on the variadic argument. +// Panics if more than one start time is provided. +func (e *estimatorImpl) resolveTimelineStart(starts ...time.Time) time.Time { + if len(starts) == 0 { + if len(e.data) == 0 { + return time.Time{} + } + return e.data[0].date + } + if len(starts) == 1 { + return starts[0] + } + panic("at most one start time argument allowed") +} + func (e *estimatorImpl) smoothedParamsAt(t time.Time) (float64, float64) { if len(e.data) == 0 { return 0, 0 @@ -163,7 +247,6 @@ func (e *estimatorImpl) smoothedParamsAt(t time.Time) (float64, float64) { return e.smoothedA[idx-1], e.smoothedB[idx-1] } -// fitPowerLaw fits a power law model to the data. func fitPowerLaw(weight, reps []float64, dates []time.Time, halfLifeDays float64) (a, b float64) { now := dates[len(dates)-1] params := []float64{max(weight), -0.1} @@ -218,7 +301,6 @@ func extractB(data []timePoint) []float64 { return out } -// exponentialSmoothing applies exponential smoothing to a time series. func exponentialSmoothing(series []float64, alpha float64) []float64 { if len(series) == 0 { return nil