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()
|
||||
switch e := e.(type) {
|
||||
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)
|
||||
}
|
||||
acks <- struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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 planned
|
||||
}
|
||||
|
||||
// Use setRepo.GetByID for efficient lookup
|
||||
func getPlannedSets(setRepo *logbook.SetRepository, setIDs []string) []logbook.Set {
|
||||
var sets []logbook.Set
|
||||
for _, id := range setIDs {
|
||||
set, err := setRepo.GetByID(id)
|
||||
if err == nil && set != nil {
|
||||
sets = append(sets, *set)
|
||||
}
|
||||
}
|
||||
return sets
|
||||
}
|
||||
|
||||
func formatSet(set logbook.Set) string {
|
||||
if set.Weight > 0 {
|
||||
return fmt.Sprintf("%s: %d reps @ %.0f lb", set.Exercise, set.Reps, set.Weight)
|
||||
}
|
||||
return fmt.Sprintf("%s: %d reps (bodyweight)", set.Exercise, set.Reps)
|
||||
}
|
||||
|
||||
// --- Plan Workout Page Implementation ---
|
||||
|
||||
type planWorkoutPage struct {
|
||||
dateEditor widget.Editor
|
||||
typeEditor widget.Editor
|
||||
addSetBtn widget.Clickable
|
||||
saveBtn widget.Clickable
|
||||
sets []plannedSet
|
||||
setEditors []plannedSetEditors
|
||||
errorMsg string
|
||||
successMsg string
|
||||
backBtn widget.Clickable
|
||||
}
|
||||
|
||||
type plannedSet struct {
|
||||
Exercise string
|
||||
Reps int
|
||||
Weight float64
|
||||
}
|
||||
|
||||
type plannedSetEditors struct {
|
||||
exerciseEditor widget.Editor
|
||||
repsEditor widget.Editor
|
||||
weightEditor widget.Editor
|
||||
}
|
||||
|
||||
func newPlanWorkoutPage() *planWorkoutPage {
|
||||
p := &planWorkoutPage{}
|
||||
p.dateEditor.SingleLine = true
|
||||
p.dateEditor.SetText(time.Now().Format("2006-01-02"))
|
||||
p.typeEditor.SingleLine = true
|
||||
p.setEditors = []plannedSetEditors{newPlannedSetEditors()}
|
||||
return p
|
||||
}
|
||||
|
||||
func newPlannedSetEditors() plannedSetEditors {
|
||||
var e plannedSetEditors
|
||||
e.exerciseEditor.SingleLine = true
|
||||
e.repsEditor.SingleLine = true
|
||||
e.weightEditor.SingleLine = true
|
||||
return e
|
||||
}
|
||||
|
||||
func (p *planWorkoutPage) Layout(gtx layout.Context, th *material.Theme, setRepo *logbook.SetRepository, workoutRepo *logbook.WorkoutRepository, navBack func()) layout.Dimensions {
|
||||
var ls = new(layout.List)
|
||||
ls.Axis = layout.Vertical
|
||||
// Handle Add Set button
|
||||
if p.addSetBtn.Clicked(gtx) {
|
||||
p.setEditors = append(p.setEditors, newPlannedSetEditors())
|
||||
}
|
||||
|
||||
// Handle Save button
|
||||
if p.saveBtn.Clicked(gtx) {
|
||||
p.errorMsg = ""
|
||||
p.successMsg = ""
|
||||
// Parse date
|
||||
date, err := time.Parse("2006-01-02", p.dateEditor.Text())
|
||||
if err != nil {
|
||||
p.errorMsg = "Invalid date format (use YYYY-MM-DD)"
|
||||
} else {
|
||||
// Collect sets
|
||||
var setIDs []string
|
||||
for _, ed := range p.setEditors {
|
||||
exercise := ed.exerciseEditor.Text()
|
||||
reps, _ := parseInt(ed.repsEditor.Text())
|
||||
weight, _ := parseFloat(ed.weightEditor.Text())
|
||||
if exercise == "" || reps == 0 {
|
||||
continue // skip incomplete sets
|
||||
}
|
||||
set := &logbook.Set{
|
||||
Exercise: exercise,
|
||||
Reps: reps,
|
||||
Weight: weight,
|
||||
Date: date,
|
||||
Plan: true,
|
||||
}
|
||||
if err := setRepo.Save(set); err == nil {
|
||||
setIDs = append(setIDs, set.ID)
|
||||
}
|
||||
}
|
||||
if len(setIDs) == 0 {
|
||||
p.errorMsg = "No valid sets entered."
|
||||
} else {
|
||||
workout := &logbook.Workout{
|
||||
Date: date,
|
||||
Type: p.typeEditor.Text(),
|
||||
SetIDs: setIDs,
|
||||
}
|
||||
if err := workoutRepo.Save(workout); err != nil {
|
||||
p.errorMsg = "Failed to save workout: " + err.Error()
|
||||
} else {
|
||||
p.successMsg = "Workout planned!"
|
||||
navBack()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Back button
|
||||
if p.backBtn.Clicked(gtx) {
|
||||
navBack()
|
||||
}
|
||||
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(material.H4(th, "Plan Workout").Layout),
|
||||
layout.Rigid(material.Body1(th, "Date (YYYY-MM-DD):").Layout),
|
||||
layout.Rigid(material.Editor(th, &p.dateEditor, "2025-05-05").Layout),
|
||||
layout.Rigid(material.Body1(th, "Type:").Layout),
|
||||
layout.Rigid(material.Editor(th, &p.typeEditor, "e.g. Strength, Hypertrophy").Layout),
|
||||
layout.Rigid(material.Body1(th, "Sets:").Layout),
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
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