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/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 {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,47 +1,50 @@
 | 
			
		|||
package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
    "fmt"
 | 
			
		||||
	"image"
 | 
			
		||||
	"image/color"
 | 
			
		||||
	"log"
 | 
			
		||||
	"os"
 | 
			
		||||
    "os/user"
 | 
			
		||||
    "path/filepath"
 | 
			
		||||
    "sort"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"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"
 | 
			
		||||
 | 
			
		||||
    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               = 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 {
 | 
			
		||||
		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)
 | 
			
		||||
| 
						 | 
				
			
			@ -49,355 +52,270 @@ func main() {
 | 
			
		|||
	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()
 | 
			
		||||
func loop(w *app.Window) error {
 | 
			
		||||
	th := material.NewTheme()
 | 
			
		||||
	th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection()))
 | 
			
		||||
	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)
 | 
			
		||||
	state := &AppState{
 | 
			
		||||
		Theme: th,
 | 
			
		||||
	}
 | 
			
		||||
	events := make(chan event.Event)
 | 
			
		||||
	acks := make(chan struct{})
 | 
			
		||||
 | 
			
		||||
    // 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
 | 
			
		||||
	go func() {
 | 
			
		||||
	    for {
 | 
			
		||||
		ev := w.Event()
 | 
			
		||||
		events <- ev
 | 
			
		||||
		<-acks
 | 
			
		||||
		if _, ok := ev.(app.DestroyEvent); ok {
 | 
			
		||||
		    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()
 | 
			
		||||
	    select {
 | 
			
		||||
	    case e := <-events:
 | 
			
		||||
		switch e:= e.(type) {
 | 
			
		||||
		case app.DestroyEvent:
 | 
			
		||||
		    acks <- struct{}{}
 | 
			
		||||
		    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
 | 
			
		||||
                })
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
			state.Layout(gtx)
 | 
			
		||||
			e.Frame(gtx.Ops)
 | 
			
		||||
		}
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 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()
 | 
			
		||||
                }
 | 
			
		||||
		acks <- struct{}{}
 | 
			
		||||
	    }
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
    // Handle Back button
 | 
			
		||||
    if p.backBtn.Clicked(gtx) {
 | 
			
		||||
        navBack()
 | 
			
		||||
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{}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
    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 state.BottomNav(gtx)
 | 
			
		||||
		}),
 | 
			
		||||
	)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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 {
 | 
			
		||||
                        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 {
 | 
			
		||||
                        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 {
 | 
			
		||||
                        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 {
 | 
			
		||||
            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{}
 | 
			
		||||
				// 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(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) {
 | 
			
		||||
    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),
 | 
			
		||||
					)
 | 
			
		||||
				})
 | 
			
		||||
			}),
 | 
			
		||||
		)
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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 ---
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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")
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue
	
	Block a user