Open databases in the logbook package so the user does not have to pass in a
boltDB pointer.
This commit is contained in:
		
							parent
							
								
									b39deb1986
								
							
						
					
					
						commit
						a6604728d9
					
				| 
						 | 
					@ -12,7 +12,6 @@ import (
 | 
				
			||||||
	"git.wow.st/gmp/logbook"
 | 
						"git.wow.st/gmp/logbook"
 | 
				
			||||||
	"git.wow.st/gmp/logbook/parser"
 | 
						"git.wow.st/gmp/logbook/parser"
 | 
				
			||||||
	"git.wow.st/gmp/logbook/review"
 | 
						"git.wow.st/gmp/logbook/review"
 | 
				
			||||||
	bolt "go.etcd.io/bbolt"
 | 
					 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func expandPath(path string) string {
 | 
					func expandPath(path string) string {
 | 
				
			||||||
| 
						 | 
					@ -71,6 +70,7 @@ func getPerformances(wr *logbook.WorkoutRepository, mr *logbook.MeasurementRepos
 | 
				
			||||||
	ps, err := pr.FindPerformances(filter)
 | 
						ps, err := pr.FindPerformances(filter)
 | 
				
			||||||
	return ps, err
 | 
						return ps, err
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func main() {
 | 
					func main() {
 | 
				
			||||||
	defaultDB := "~/.config/logbook/logbook.db"
 | 
						defaultDB := "~/.config/logbook/logbook.db"
 | 
				
			||||||
	var dbPath string
 | 
						var dbPath string
 | 
				
			||||||
| 
						 | 
					@ -85,22 +85,13 @@ func main() {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	dbPath = expandPath(dbPath)
 | 
						dbPath = expandPath(dbPath)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	db, err := bolt.Open(dbPath, 0600, nil)
 | 
						wr, mr, err := logbook.NewRepositories(dbPath)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		fmt.Fprintf(os.Stderr, "Error opening database: %v\n", err)
 | 
							fmt.Fprintf(os.Stderr, "Error opening repositories: %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)
 | 
					 | 
				
			||||||
		os.Exit(1)
 | 
							os.Exit(1)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
						defer wr.Close()
 | 
				
			||||||
 | 
						defer mr.Close()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	cmd := flag.Arg(0)
 | 
						cmd := flag.Arg(0)
 | 
				
			||||||
	switch cmd {
 | 
						switch cmd {
 | 
				
			||||||
| 
						 | 
					@ -232,7 +223,6 @@ func main() {
 | 
				
			||||||
				}
 | 
									}
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		//a, b := review.FitPowerLaw(weights, reps, date, 14.0)
 | 
					 | 
				
			||||||
		est := review.NewEstimator(review.WithHalfLife(14.0))
 | 
							est := review.NewEstimator(review.WithHalfLife(14.0))
 | 
				
			||||||
		err = est.Fit(weights, reps, dates)
 | 
							err = est.Fit(weights, reps, dates)
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
| 
						 | 
					@ -244,7 +234,44 @@ func main() {
 | 
				
			||||||
			if isbw {
 | 
								if isbw {
 | 
				
			||||||
				adj = ps[len(ps)-1].Bodyweight
 | 
									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":
 | 
						case "predict":
 | 
				
			||||||
		if flag.NArg() != 3 {
 | 
							if flag.NArg() != 3 {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,47 +1,50 @@
 | 
				
			||||||
package main
 | 
					package main
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
    "fmt"
 | 
						"image"
 | 
				
			||||||
	"image/color"
 | 
						"image/color"
 | 
				
			||||||
	"log"
 | 
						"log"
 | 
				
			||||||
	"os"
 | 
						"os"
 | 
				
			||||||
    "os/user"
 | 
					 | 
				
			||||||
    "path/filepath"
 | 
					 | 
				
			||||||
    "sort"
 | 
					 | 
				
			||||||
	"time"
 | 
						"time"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"gioui.org/app"
 | 
						"gioui.org/app"
 | 
				
			||||||
 | 
						"gioui.org/font/gofont"
 | 
				
			||||||
 | 
						"gioui.org/io/event"
 | 
				
			||||||
	"gioui.org/layout"
 | 
						"gioui.org/layout"
 | 
				
			||||||
	"gioui.org/op"
 | 
						"gioui.org/op"
 | 
				
			||||||
 | 
						"gioui.org/op/clip"
 | 
				
			||||||
 | 
						"gioui.org/op/paint"
 | 
				
			||||||
 | 
						"gioui.org/text"
 | 
				
			||||||
	"gioui.org/unit"
 | 
						"gioui.org/unit"
 | 
				
			||||||
	"gioui.org/widget"
 | 
						"gioui.org/widget"
 | 
				
			||||||
	"gioui.org/widget/material"
 | 
						"gioui.org/widget/material"
 | 
				
			||||||
 | 
					 | 
				
			||||||
    bolt "github.com/etcd-io/bbolt"
 | 
					 | 
				
			||||||
    "git.wow.st/gmp/logbook"
 | 
					 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
var (
 | 
					type Page int
 | 
				
			||||||
    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
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const (
 | 
					const (
 | 
				
			||||||
    pageLanding page = iota
 | 
						PageLanding Page = iota
 | 
				
			||||||
    pagePlanWorkout
 | 
						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() {
 | 
					func main() {
 | 
				
			||||||
	go func() {
 | 
						go func() {
 | 
				
			||||||
        window := new(app.Window)
 | 
							w := new(app.Window)
 | 
				
			||||||
        if err := run(window); err != nil {
 | 
							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)
 | 
								log.Fatal(err)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		os.Exit(0)
 | 
							os.Exit(0)
 | 
				
			||||||
| 
						 | 
					@ -49,355 +52,270 @@ func main() {
 | 
				
			||||||
	app.Main()
 | 
						app.Main()
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func openStandardDB() (*bolt.DB, error) {
 | 
					func loop(w *app.Window) error {
 | 
				
			||||||
    usr, err := user.Current()
 | 
						th := material.NewTheme()
 | 
				
			||||||
    if err != nil {
 | 
						th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection()))
 | 
				
			||||||
        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
 | 
						var ops op.Ops
 | 
				
			||||||
 | 
						state := &AppState{
 | 
				
			||||||
    db, err := openStandardDB()
 | 
							Theme: th,
 | 
				
			||||||
    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)
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
						events := make(chan event.Event)
 | 
				
			||||||
 | 
						acks := make(chan struct{})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Navigation state
 | 
						go func() {
 | 
				
			||||||
    currentPage := pageLanding
 | 
						    for {
 | 
				
			||||||
    var planPage *planWorkoutPage
 | 
							ev := w.Event()
 | 
				
			||||||
 | 
							events <- ev
 | 
				
			||||||
    // For landing page state
 | 
							<-acks
 | 
				
			||||||
    var planned []*logbook.Workout
 | 
							if _, ok := ev.(app.DestroyEvent); ok {
 | 
				
			||||||
    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
 | 
							    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 {
 | 
						for {
 | 
				
			||||||
        e := window.Event()
 | 
						    select {
 | 
				
			||||||
        switch e := e.(type) {
 | 
						    case e := <-events:
 | 
				
			||||||
 | 
							switch e:= e.(type) {
 | 
				
			||||||
		case app.DestroyEvent:
 | 
							case app.DestroyEvent:
 | 
				
			||||||
 | 
							    acks <- struct{}{}
 | 
				
			||||||
		    return e.Err
 | 
							    return e.Err
 | 
				
			||||||
		case app.FrameEvent:
 | 
							case app.FrameEvent:
 | 
				
			||||||
			gtx := app.NewContext(&ops, e)
 | 
								gtx := app.NewContext(&ops, e)
 | 
				
			||||||
 | 
								state.Layout(gtx)
 | 
				
			||||||
            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)
 | 
								e.Frame(gtx.Ops)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
							acks <- struct{}{}
 | 
				
			||||||
 | 
						    }
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func filterPlannedWorkoutsAfter(workouts []*logbook.Workout, after time.Time) []*logbook.Workout {
 | 
					func (state *AppState) Layout(gtx layout.Context) layout.Dimensions {
 | 
				
			||||||
    var planned []*logbook.Workout
 | 
						return layout.Flex{
 | 
				
			||||||
    for _, w := range workouts {
 | 
							Axis: layout.Vertical,
 | 
				
			||||||
        if w.Date.After(after) {
 | 
						}.Layout(gtx,
 | 
				
			||||||
            planned = append(planned, w)
 | 
							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{}
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
    }
 | 
							}),
 | 
				
			||||||
    return planned
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// 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 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)
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// --- 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
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
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 {
 | 
							layout.Rigid(func(gtx layout.Context) layout.Dimensions {
 | 
				
			||||||
            return ls.Layout(gtx, len(p.setEditors), func(gtx layout.Context, i int) layout.Dimensions {
 | 
								return state.BottomNav(gtx)
 | 
				
			||||||
                ed := &p.setEditors[i]
 | 
							}),
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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,
 | 
							return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
 | 
				
			||||||
			layout.Rigid(func(gtx layout.Context) layout.Dimensions {
 | 
								layout.Rigid(func(gtx layout.Context) layout.Dimensions {
 | 
				
			||||||
                        return layout.Inset{Right: unit.Dp(8)}.Layout(gtx, material.Editor(th, &ed.exerciseEditor, "Exercise").Layout)
 | 
									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 {
 | 
								layout.Rigid(func(gtx layout.Context) layout.Dimensions {
 | 
				
			||||||
                        return layout.Inset{Right: unit.Dp(8)}.Layout(gtx, material.Editor(th, &ed.repsEditor, "Reps").Layout)
 | 
									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 {
 | 
								layout.Rigid(func(gtx layout.Context) layout.Dimensions {
 | 
				
			||||||
                        return material.Editor(th, &ed.weightEditor, "Weight (lb)").Layout(gtx)
 | 
									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)
 | 
				
			||||||
 | 
										}),
 | 
				
			||||||
 | 
									)
 | 
				
			||||||
 | 
								}),
 | 
				
			||||||
 | 
							)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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 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),
 | 
				
			||||||
 | 
												)
 | 
				
			||||||
 | 
											})
 | 
				
			||||||
 | 
										})
 | 
				
			||||||
 | 
									})
 | 
				
			||||||
			}),
 | 
								}),
 | 
				
			||||||
		)
 | 
							)
 | 
				
			||||||
	})
 | 
						})
 | 
				
			||||||
        }),
 | 
					}
 | 
				
			||||||
        layout.Rigid(material.Button(th, &p.addSetBtn, "Add Set").Layout),
 | 
					
 | 
				
			||||||
        layout.Rigid(material.Button(th, &p.saveBtn, "Save Workout").Layout),
 | 
					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 {
 | 
								layout.Rigid(func(gtx layout.Context) layout.Dimensions {
 | 
				
			||||||
            if p.errorMsg != "" {
 | 
									// Just a colored rectangle as a placeholder for a graph
 | 
				
			||||||
                return material.Body1(th, "[Error] "+p.errorMsg).Layout(gtx)
 | 
									cs := gtx.Constraints
 | 
				
			||||||
            }
 | 
									sz := cs.Constrain(image.Pt(300, 100))
 | 
				
			||||||
            if p.successMsg != "" {
 | 
									dr := image.Rectangle{Max: sz}
 | 
				
			||||||
                return material.Body1(th, p.successMsg).Layout(gtx)
 | 
									paintColor := color.NRGBA{R: 0x80, G: 0x80, B: 0xff, A: 0xff}
 | 
				
			||||||
            }
 | 
									paint.FillShape(gtx.Ops, paintColor, clip.Rect(dr).Op())
 | 
				
			||||||
            return layout.Dimensions{}
 | 
									return layout.Dimensions{Size: sz}
 | 
				
			||||||
			}),
 | 
								}),
 | 
				
			||||||
        layout.Rigid(material.Button(th, &p.backBtn, "Back").Layout),
 | 
								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),
 | 
				
			||||||
		)
 | 
							)
 | 
				
			||||||
 | 
						})
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func parseInt(s string) (int, error) {
 | 
					func MeasurementsPage(state *AppState, gtx layout.Context) layout.Dimensions {
 | 
				
			||||||
    var i int
 | 
						th := state.Theme
 | 
				
			||||||
    _, err := fmt.Sscanf(s, "%d", &i)
 | 
						dates := []string{"2025-05-01", "2025-05-08", "2025-05-15"}
 | 
				
			||||||
    return i, err
 | 
						weights := []string{"174.8", "175.2", "175.0"}
 | 
				
			||||||
}
 | 
						waist := []string{"34.1", "34.0", "33.9"}
 | 
				
			||||||
func parseFloat(s string) (float64, error) {
 | 
						var list layout.List
 | 
				
			||||||
    var f float64
 | 
						return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
 | 
				
			||||||
    _, err := fmt.Sscanf(s, "%f", &f)
 | 
							return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
 | 
				
			||||||
    return f, err
 | 
								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),
 | 
				
			||||||
 | 
										)
 | 
				
			||||||
 | 
									})
 | 
				
			||||||
 | 
								}),
 | 
				
			||||||
 | 
							)
 | 
				
			||||||
 | 
						})
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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)
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
							
								
								
									
										16
									
								
								main.go
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								main.go
									
									
									
									
									
								
							| 
						 | 
					@ -26,8 +26,12 @@ type WorkoutRepository struct {
 | 
				
			||||||
	db *bolt.DB
 | 
						db *bolt.DB
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func NewWorkoutRepository(db *bolt.DB) (*WorkoutRepository, error) {
 | 
					func NewRepositories(dbPath string) (*WorkoutRepository, *MeasurementRepository, error) {
 | 
				
			||||||
    err := db.Update(func(tx *bolt.Tx) 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)
 | 
					        _, err := tx.CreateBucketIfNotExists(bucketSets)
 | 
				
			||||||
        if err != nil {
 | 
					        if err != nil {
 | 
				
			||||||
		return err
 | 
							return err
 | 
				
			||||||
| 
						 | 
					@ -43,9 +47,13 @@ func NewWorkoutRepository(db *bolt.DB) (*WorkoutRepository, error) {
 | 
				
			||||||
	return nil
 | 
						return nil
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
    if err != 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 ---
 | 
					// --- Helper functions ---
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -30,8 +30,12 @@ type MeasurementRepository struct {
 | 
				
			||||||
    db *bolt.DB
 | 
					    db *bolt.DB
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func NewMeasurementRepository(db *bolt.DB) (*MeasurementRepository, error) {
 | 
					func NewMeasurementRepository(dbpath string) (*MeasurementRepository, error) {
 | 
				
			||||||
    err := db.Update(func(tx *bolt.Tx) 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)
 | 
					        _, err := tx.CreateBucketIfNotExists(bucketMeasurements)
 | 
				
			||||||
        return err
 | 
					        return err
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
| 
						 | 
					@ -41,6 +45,10 @@ func NewMeasurementRepository(db *bolt.DB) (*MeasurementRepository, error) {
 | 
				
			||||||
    return &MeasurementRepository{db: db}, nil
 | 
					    return &MeasurementRepository{db: db}, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (mr *MeasurementRepository) Close() {
 | 
				
			||||||
 | 
					    mr.db.Close()
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (r *MeasurementRepository) Save(m *Measurement) error {
 | 
					func (r *MeasurementRepository) Save(m *Measurement) error {
 | 
				
			||||||
    if m.ID == "" {
 | 
					    if m.ID == "" {
 | 
				
			||||||
        m.ID = generateDateID(m.Date, "measurement-" + "m.Variable")
 | 
					        m.ID = generateDateID(m.Date, "measurement-" + "m.Variable")
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -12,10 +12,15 @@ import (
 | 
				
			||||||
// Estimator is the main interface for incremental, time-aware strength estimation.
 | 
					// Estimator is the main interface for incremental, time-aware strength estimation.
 | 
				
			||||||
type Estimator interface {
 | 
					type Estimator interface {
 | 
				
			||||||
	Fit(weight, reps []float64, dates []time.Time) error
 | 
						Fit(weight, reps []float64, dates []time.Time) error
 | 
				
			||||||
	Estimate1RM(t time.Time) float64
 | 
					
 | 
				
			||||||
	EstimateReps(t time.Time, targetWeight float64) float64
 | 
						Estimate1RM(times ...time.Time) float64
 | 
				
			||||||
	EstimateMaxWeight(t time.Time, nReps float64) float64
 | 
						EstimateReps(targetWeight float64, times ...time.Time) float64
 | 
				
			||||||
	Params(t 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 ---
 | 
					// --- Functional Options ---
 | 
				
			||||||
| 
						 | 
					@ -117,14 +122,16 @@ func (e *estimatorImpl) Fit(weight, reps []float64, dates []time.Time) error {
 | 
				
			||||||
	return nil
 | 
						return nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Estimate1RM returns the smoothed 1RM estimate at time t.
 | 
					// --- Estimate Functions with Variadic Time Parameter ---
 | 
				
			||||||
func (e *estimatorImpl) Estimate1RM(t time.Time) float64 {
 | 
					
 | 
				
			||||||
 | 
					func (e *estimatorImpl) Estimate1RM(times ...time.Time) float64 {
 | 
				
			||||||
 | 
						t := e.resolveTime(times...)
 | 
				
			||||||
	a, b := e.smoothedParamsAt(t)
 | 
						a, b := e.smoothedParamsAt(t)
 | 
				
			||||||
	return a * math.Pow(1, b)
 | 
						return a * math.Pow(1, b)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// EstimateReps returns the predicted number of reps at a given weight and time.
 | 
					func (e *estimatorImpl) EstimateReps(targetWeight float64, times ...time.Time) float64 {
 | 
				
			||||||
func (e *estimatorImpl) EstimateReps(t time.Time, targetWeight float64) float64 {
 | 
						t := e.resolveTime(times...)
 | 
				
			||||||
	a, b := e.smoothedParamsAt(t)
 | 
						a, b := e.smoothedParamsAt(t)
 | 
				
			||||||
	if a == 0 || b == 0 {
 | 
						if a == 0 || b == 0 {
 | 
				
			||||||
		return 0
 | 
							return 0
 | 
				
			||||||
| 
						 | 
					@ -132,21 +139,98 @@ func (e *estimatorImpl) EstimateReps(t time.Time, targetWeight float64) float64
 | 
				
			||||||
	return math.Pow(targetWeight/a, 1/b)
 | 
						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(nReps float64, times ...time.Time) float64 {
 | 
				
			||||||
func (e *estimatorImpl) EstimateMaxWeight(t time.Time, nReps float64) float64 {
 | 
						t := e.resolveTime(times...)
 | 
				
			||||||
	a, b := e.smoothedParamsAt(t)
 | 
						a, b := e.smoothedParamsAt(t)
 | 
				
			||||||
	return a * math.Pow(nReps, b)
 | 
						return a * math.Pow(nReps, b)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Params returns the smoothed model parameters at time t.
 | 
					func (e *estimatorImpl) Params(times ...time.Time) []float64 {
 | 
				
			||||||
func (e *estimatorImpl) Params(t time.Time) []float64 {
 | 
						t := e.resolveTime(times...)
 | 
				
			||||||
	a, b := e.smoothedParamsAt(t)
 | 
						a, b := e.smoothedParamsAt(t)
 | 
				
			||||||
	return []float64{a, b}
 | 
						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 ---
 | 
					// --- 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) {
 | 
					func (e *estimatorImpl) smoothedParamsAt(t time.Time) (float64, float64) {
 | 
				
			||||||
	if len(e.data) == 0 {
 | 
						if len(e.data) == 0 {
 | 
				
			||||||
		return 0, 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]
 | 
						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) {
 | 
					func fitPowerLaw(weight, reps []float64, dates []time.Time, halfLifeDays float64) (a, b float64) {
 | 
				
			||||||
	now := dates[len(dates)-1]
 | 
						now := dates[len(dates)-1]
 | 
				
			||||||
	params := []float64{max(weight), -0.1}
 | 
						params := []float64{max(weight), -0.1}
 | 
				
			||||||
| 
						 | 
					@ -218,7 +301,6 @@ func extractB(data []timePoint) []float64 {
 | 
				
			||||||
	return out
 | 
						return out
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// exponentialSmoothing applies exponential smoothing to a time series.
 | 
					 | 
				
			||||||
func exponentialSmoothing(series []float64, alpha float64) []float64 {
 | 
					func exponentialSmoothing(series []float64, alpha float64) []float64 {
 | 
				
			||||||
	if len(series) == 0 {
 | 
						if len(series) == 0 {
 | 
				
			||||||
		return nil
 | 
							return nil
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
		Reference in New Issue
	
	Block a user