Open databases in the logbook package so the user does not have to pass in a

boltDB pointer.
This commit is contained in:
Greg Pomerantz 2025-05-15 13:08:28 -04:00
parent b39deb1986
commit a6604728d9
6 changed files with 456 additions and 610 deletions

View File

@ -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 {

View File

@ -1,403 +1,321 @@
package main
import (
"fmt"
"image/color"
"log"
"os"
"os/user"
"path/filepath"
"sort"
"time"
"image"
"image/color"
"log"
"os"
"time"
"gioui.org/app"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
bolt "github.com/etcd-io/bbolt"
"git.wow.st/gmp/logbook"
"gioui.org/app"
"gioui.org/font/gofont"
"gioui.org/io/event"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
)
var (
btnPrevWorkouts widget.Clickable
btnPredictStrength widget.Clickable
btnMeasurements widget.Clickable
btnPlanWorkout widget.Clickable
btnLeft widget.Clickable
btnRight widget.Clickable
list = new(layout.List) // pointer to layout.List
)
type page int
type Page int
const (
pageLanding page = iota
pagePlanWorkout
PageLanding Page = iota
PageWorkoutReview
PagePerformance
PageMeasurements
)
type AppState struct {
Theme *material.Theme
CurrentPage Page
BtnLanding widget.Clickable
BtnWorkoutReview widget.Clickable
BtnPerformance widget.Clickable
BtnMeasurements widget.Clickable
}
func main() {
go func() {
window := new(app.Window)
if err := run(window); err != nil {
log.Fatal(err)
}
os.Exit(0)
}()
app.Main()
go func() {
w := new(app.Window)
w.Option(app.Title("Logbook Review Mockup"))
w.Option(app.Size(unit.Dp(400), unit.Dp(700)))
if err := loop(w); err != nil {
log.Fatal(err)
}
os.Exit(0)
}()
app.Main()
}
func openStandardDB() (*bolt.DB, error) {
usr, err := user.Current()
if err != nil {
return nil, err
}
dir := filepath.Join(usr.HomeDir, ".config", "logbook")
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, err
}
dbPath := filepath.Join(dir, "logbook.db")
db, err := bolt.Open(dbPath, 0600, nil)
if err != nil {
return nil, err
}
return db, nil
func loop(w *app.Window) error {
th := material.NewTheme()
th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection()))
var ops op.Ops
state := &AppState{
Theme: th,
}
events := make(chan event.Event)
acks := make(chan struct{})
go func() {
for {
ev := w.Event()
events <- ev
<-acks
if _, ok := ev.(app.DestroyEvent); ok {
return
}
}
}()
for {
select {
case e := <-events:
switch e:= e.(type) {
case app.DestroyEvent:
acks <- struct{}{}
return e.Err
case app.FrameEvent:
gtx := app.NewContext(&ops, e)
state.Layout(gtx)
e.Frame(gtx.Ops)
}
acks <- struct{}{}
}
}
}
func run(window *app.Window) error {
theme := material.NewTheme()
var ops op.Ops
db, err := openStandardDB()
if err != nil {
return fmt.Errorf("failed to open DB: %w", err)
}
defer db.Close()
workoutRepo, err := logbook.NewWorkoutRepository(db)
if err != nil {
return fmt.Errorf("failed to create workout repo: %w", err)
}
setRepo, err := logbook.NewSetRepository(db)
if err != nil {
return fmt.Errorf("failed to create set repo: %w", err)
}
// Navigation state
currentPage := pageLanding
var planPage *planWorkoutPage
// For landing page state
var planned []*logbook.Workout
var workoutIdx int
// Preload planned workouts for landing page
reloadPlanned := func() {
now := time.Now()
allWorkouts, err := workoutRepo.FindAll()
if err != nil {
planned = nil
workoutIdx = 0
return
}
planned = filterPlannedWorkoutsAfter(allWorkouts, now)
sort.Slice(planned, func(i, j int) bool {
return planned[i].Date.Before(planned[j].Date)
})
workoutIdx = 0
}
reloadPlanned()
for {
e := window.Event()
switch e := e.(type) {
case app.DestroyEvent:
return e.Err
case app.FrameEvent:
gtx := app.NewContext(&ops, e)
switch currentPage {
case pageLanding:
// Click handling flags
planWorkoutClicked := false
layout.Flex{
Axis: layout.Vertical,
}.Layout(gtx,
// Top navigation bar
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(material.Button(theme, &btnPrevWorkouts, "Previous Workouts").Layout),
layout.Rigid(material.Button(theme, &btnPredictStrength, "Predict Strength").Layout),
layout.Rigid(material.Button(theme, &btnMeasurements, "Measurements").Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
dims := material.Button(theme, &btnPlanWorkout, "Plan Workout").Layout(gtx)
if btnPlanWorkout.Clicked(gtx) {
planWorkoutClicked = true
}
return dims
}),
)
}),
// Main content: Next planned workout
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if len(planned) == 0 {
return material.H5(theme, "No planned workouts.").Layout(gtx)
}
w := planned[workoutIdx]
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return material.H4(theme, "Next Workout: "+w.Date.Format("Mon Jan 2, 2006 3:04 PM")).Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return material.H5(theme, "Type: "+w.Type).Layout(gtx)
}),
// Planned sets, one per line
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
sets := getPlannedSets(setRepo, w.SetIDs)
return list.Layout(gtx, len(sets), func(gtx layout.Context, i int) layout.Dimensions {
set := sets[i]
return material.Body1(theme, formatSet(set)).Layout(gtx)
})
}),
)
}),
// Bottom navigation: left/right arrows
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
leftClicked := false
rightClicked := false
dims := layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
btn := material.Button(theme, &btnLeft, "←")
if workoutIdx == 0 {
btn.Background = color.NRGBA{A: 80}
}
d := btn.Layout(gtx)
if btnLeft.Clicked(gtx) {
leftClicked = true
}
return d
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
btn := material.Button(theme, &btnRight, "→")
if workoutIdx == len(planned)-1 {
btn.Background = color.NRGBA{A: 80}
}
d := btn.Layout(gtx)
if btnRight.Clicked(gtx) {
rightClicked = true
}
return d
}),
)
// Handle navigation after layout
if leftClicked && workoutIdx > 0 {
workoutIdx--
}
if rightClicked && workoutIdx < len(planned)-1 {
workoutIdx++
}
return dims
}),
)
// Handle navigation to Plan Workout page after layout
if planWorkoutClicked {
planPage = newPlanWorkoutPage()
currentPage = pagePlanWorkout
}
case pagePlanWorkout:
planPage.Layout(gtx, theme, setRepo, workoutRepo, func() {
reloadPlanned()
currentPage = pageLanding
})
}
e.Frame(gtx.Ops)
}
}
func (state *AppState) Layout(gtx layout.Context) layout.Dimensions {
return layout.Flex{
Axis: layout.Vertical,
}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
switch state.CurrentPage {
case PageLanding:
return LandingPage(state, gtx)
case PageWorkoutReview:
return WorkoutReviewPage(state, gtx)
case PagePerformance:
return PerformancePage(state, gtx)
case PageMeasurements:
return MeasurementsPage(state, gtx)
default:
return layout.Dimensions{}
}
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return state.BottomNav(gtx)
}),
)
}
func filterPlannedWorkoutsAfter(workouts []*logbook.Workout, after time.Time) []*logbook.Workout {
var planned []*logbook.Workout
for _, w := range workouts {
if w.Date.After(after) {
planned = append(planned, w)
}
}
return planned
func (state *AppState) BottomNav(gtx layout.Context) layout.Dimensions {
th := state.Theme
labels := []string{"Home", "Workouts", "Performance", "Measurements"}
clicks := []*widget.Clickable{
&state.BtnLanding, &state.BtnWorkoutReview, &state.BtnPerformance, &state.BtnMeasurements,
}
// Measure button widths using a dry run
btnWidths := make([]int, len(labels))
btnHeights := make([]int, len(labels))
for i, label := range labels {
btn := material.Button(th, clicks[i], label)
// Use a scratch context with unconstrained width
mctx := gtx
mctx.Constraints = layout.Constraints{
Min: image.Pt(0, 0),
Max: image.Pt(gtx.Dp(1000), gtx.Constraints.Max.Y),
}
dims := btn.Layout(mctx)
btnWidths[i] = dims.Size.X
btnHeights[i] = dims.Size.Y
}
gap := gtx.Dp(8)
totalWidth := btnWidths[0] + btnWidths[1] + btnWidths[2] + btnWidths[3] + gap*3
if totalWidth <= gtx.Constraints.Max.X {
// All buttons fit in one row
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if state.BtnLanding.Clicked(gtx) {
state.CurrentPage = PageLanding
}
return material.Button(th, &state.BtnLanding, "Home").Layout(gtx)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if state.BtnWorkoutReview.Clicked(gtx) {
state.CurrentPage = PageWorkoutReview
}
return material.Button(th, &state.BtnWorkoutReview, "Workouts").Layout(gtx)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if state.BtnPerformance.Clicked(gtx) {
state.CurrentPage = PagePerformance
}
return material.Button(th, &state.BtnPerformance, "Performance").Layout(gtx)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if state.BtnMeasurements.Clicked(gtx) {
state.CurrentPage = PageMeasurements
}
return material.Button(th, &state.BtnMeasurements, "Measurements").Layout(gtx)
}),
)
} else {
// Two rows, two buttons per row
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if state.BtnLanding.Clicked(gtx) {
state.CurrentPage = PageLanding
}
return material.Button(th, &state.BtnLanding, "Home").Layout(gtx)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if state.BtnWorkoutReview.Clicked(gtx) {
state.CurrentPage = PageWorkoutReview
}
return material.Button(th, &state.BtnWorkoutReview, "Workouts").Layout(gtx)
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if state.BtnPerformance.Clicked(gtx) {
state.CurrentPage = PagePerformance
}
return material.Button(th, &state.BtnPerformance, "Performance").Layout(gtx)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if state.BtnMeasurements.Clicked(gtx) {
state.CurrentPage = PageMeasurements
}
return material.Button(th, &state.BtnMeasurements, "Measurements").Layout(gtx)
}),
)
}),
)
}
}
// Use setRepo.GetByID for efficient lookup
func getPlannedSets(setRepo *logbook.SetRepository, setIDs []string) []logbook.Set {
var sets []logbook.Set
for _, id := range setIDs {
set, err := setRepo.GetByID(id)
if err == nil && set != nil {
sets = append(sets, *set)
}
}
return sets
func LandingPage(state *AppState, gtx layout.Context) layout.Dimensions {
th := state.Theme
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H4(th, "Next Planned Workout").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(material.Body1(th, "Date: "+time.Now().Add(24*time.Hour).Format("2006-01-02")).Layout),
layout.Rigid(material.Body1(th, "Type: Upper Body").Layout),
layout.Rigid(material.Body1(th, "Exercises: Bench Press, Row, Curl").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
layout.Rigid(material.Button(th, new(widget.Clickable), "Modify Plan").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(material.Button(th, new(widget.Clickable), "Start Session").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(32)}.Layout),
layout.Rigid(material.H6(th, "Quick Stats").Layout),
layout.Rigid(material.Body2(th, "Bodyweight: 175 lb").Layout),
layout.Rigid(material.Body2(th, "Current 1RM (Bench): 225 lb").Layout),
)
})
}
func formatSet(set logbook.Set) string {
if set.Weight > 0 {
return fmt.Sprintf("%s: %d reps @ %.0f lb", set.Exercise, set.Reps, set.Weight)
}
return fmt.Sprintf("%s: %d reps (bodyweight)", set.Exercise, set.Reps)
func WorkoutReviewPage(state *AppState, gtx layout.Context) layout.Dimensions {
th := state.Theme
workouts := []struct {
Date string
Type string
Exercises string
Note string
}{
{"2025-05-13", "Upper Body", "Bench Press, Row, Curl", "Felt strong, PR on bench."},
{"2025-05-10", "Lower Body", "Squat, Deadlift", "Tired, but completed all sets."},
}
var list layout.List
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H4(th, "Past Workouts").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return list.Layout(gtx, len(workouts), func(gtx layout.Context, i int) layout.Dimensions {
w := workouts[i]
return layout.UniformInset(unit.Dp(8)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
// Use a colored rectangle instead of material.Card
dr := image.Rect(0, 0, gtx.Constraints.Max.X, 70)
paint.FillShape(gtx.Ops, color.NRGBA{R: 0xee, G: 0xee, B: 0xff, A: 0xff}, clip.Rect(dr).Op())
return layout.Inset{Left: unit.Dp(8), Top: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body1(th, w.Date+" - "+w.Type).Layout),
layout.Rigid(material.Body2(th, w.Exercises).Layout),
layout.Rigid(material.Caption(th, w.Note).Layout),
)
})
})
})
}),
)
})
}
// --- Plan Workout Page Implementation ---
type planWorkoutPage struct {
dateEditor widget.Editor
typeEditor widget.Editor
addSetBtn widget.Clickable
saveBtn widget.Clickable
sets []plannedSet
setEditors []plannedSetEditors
errorMsg string
successMsg string
backBtn widget.Clickable
func PerformancePage(state *AppState, gtx layout.Context) layout.Dimensions {
th := state.Theme
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H4(th, "Bench Press 1RM Trend").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
// Just a colored rectangle as a placeholder for a graph
cs := gtx.Constraints
sz := cs.Constrain(image.Pt(300, 100))
dr := image.Rectangle{Max: sz}
paintColor := color.NRGBA{R: 0x80, G: 0x80, B: 0xff, A: 0xff}
paint.FillShape(gtx.Ops, paintColor, clip.Rect(dr).Op())
return layout.Dimensions{Size: sz}
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(material.Body2(th, "Actual (blue), Predicted (red)").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
layout.Rigid(material.Body2(th, "Best: 225 lb (Aug)").Layout),
)
})
}
type plannedSet struct {
Exercise string
Reps int
Weight float64
}
type plannedSetEditors struct {
exerciseEditor widget.Editor
repsEditor widget.Editor
weightEditor widget.Editor
}
func newPlanWorkoutPage() *planWorkoutPage {
p := &planWorkoutPage{}
p.dateEditor.SingleLine = true
p.dateEditor.SetText(time.Now().Format("2006-01-02"))
p.typeEditor.SingleLine = true
p.setEditors = []plannedSetEditors{newPlannedSetEditors()}
return p
}
func newPlannedSetEditors() plannedSetEditors {
var e plannedSetEditors
e.exerciseEditor.SingleLine = true
e.repsEditor.SingleLine = true
e.weightEditor.SingleLine = true
return e
}
func (p *planWorkoutPage) Layout(gtx layout.Context, th *material.Theme, setRepo *logbook.SetRepository, workoutRepo *logbook.WorkoutRepository, navBack func()) layout.Dimensions {
var ls = new(layout.List)
ls.Axis = layout.Vertical
// Handle Add Set button
if p.addSetBtn.Clicked(gtx) {
p.setEditors = append(p.setEditors, newPlannedSetEditors())
}
// Handle Save button
if p.saveBtn.Clicked(gtx) {
p.errorMsg = ""
p.successMsg = ""
// Parse date
date, err := time.Parse("2006-01-02", p.dateEditor.Text())
if err != nil {
p.errorMsg = "Invalid date format (use YYYY-MM-DD)"
} else {
// Collect sets
var setIDs []string
for _, ed := range p.setEditors {
exercise := ed.exerciseEditor.Text()
reps, _ := parseInt(ed.repsEditor.Text())
weight, _ := parseFloat(ed.weightEditor.Text())
if exercise == "" || reps == 0 {
continue // skip incomplete sets
}
set := &logbook.Set{
Exercise: exercise,
Reps: reps,
Weight: weight,
Date: date,
Plan: true,
}
if err := setRepo.Save(set); err == nil {
setIDs = append(setIDs, set.ID)
}
}
if len(setIDs) == 0 {
p.errorMsg = "No valid sets entered."
} else {
workout := &logbook.Workout{
Date: date,
Type: p.typeEditor.Text(),
SetIDs: setIDs,
}
if err := workoutRepo.Save(workout); err != nil {
p.errorMsg = "Failed to save workout: " + err.Error()
} else {
p.successMsg = "Workout planned!"
navBack()
}
}
}
}
// Handle Back button
if p.backBtn.Clicked(gtx) {
navBack()
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H4(th, "Plan Workout").Layout),
layout.Rigid(material.Body1(th, "Date (YYYY-MM-DD):").Layout),
layout.Rigid(material.Editor(th, &p.dateEditor, "2025-05-05").Layout),
layout.Rigid(material.Body1(th, "Type:").Layout),
layout.Rigid(material.Editor(th, &p.typeEditor, "e.g. Strength, Hypertrophy").Layout),
layout.Rigid(material.Body1(th, "Sets:").Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return ls.Layout(gtx, len(p.setEditors), func(gtx layout.Context, i int) layout.Dimensions {
ed := &p.setEditors[i]
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Right: unit.Dp(8)}.Layout(gtx, material.Editor(th, &ed.exerciseEditor, "Exercise").Layout)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Right: unit.Dp(8)}.Layout(gtx, material.Editor(th, &ed.repsEditor, "Reps").Layout)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return material.Editor(th, &ed.weightEditor, "Weight (lb)").Layout(gtx)
}),
)
})
}),
layout.Rigid(material.Button(th, &p.addSetBtn, "Add Set").Layout),
layout.Rigid(material.Button(th, &p.saveBtn, "Save Workout").Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if p.errorMsg != "" {
return material.Body1(th, "[Error] "+p.errorMsg).Layout(gtx)
}
if p.successMsg != "" {
return material.Body1(th, p.successMsg).Layout(gtx)
}
return layout.Dimensions{}
}),
layout.Rigid(material.Button(th, &p.backBtn, "Back").Layout),
)
}
func parseInt(s string) (int, error) {
var i int
_, err := fmt.Sscanf(s, "%d", &i)
return i, err
}
func parseFloat(s string) (float64, error) {
var f float64
_, err := fmt.Sscanf(s, "%f", &f)
return f, err
func MeasurementsPage(state *AppState, gtx layout.Context) layout.Dimensions {
th := state.Theme
dates := []string{"2025-05-01", "2025-05-08", "2025-05-15"}
weights := []string{"174.8", "175.2", "175.0"}
waist := []string{"34.1", "34.0", "33.9"}
var list layout.List
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H4(th, "Measurements").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return list.Layout(gtx, len(dates), func(gtx layout.Context, i int) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(material.Body2(th, dates[i]).Layout),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(material.Body2(th, weights[i]+" lb").Layout),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(material.Body2(th, waist[i]+" in").Layout),
)
})
}),
)
})
}

View File

@ -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
View File

@ -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 ---

View File

@ -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")

View File

@ -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