package review import ( "errors" "fmt" "git.wow.st/gmp/logbook" "git.wow.st/gmp/logbook/parser" "sort" "strings" "time" ) // Performance represents one or more actual sets performed together. type Performance struct { Exercise string Type string Weights []float64 // one weight per set Reps []int // one reps count per set Times []time.Time // one timestamp per set Note string Bodyweight float64 // bodyweight at or near these times RIR []int } func (p Performance) String() string { if len(p.Weights) == 0 { return "" } var result []string result = append(result, p.Times[0].UTC().Format("2 Jan 2006") + ": " + p.Exercise) if p.Type != "" { result = append(result, "(" + p.Type + ")") } result = append(result, fmt.Sprintf("%0.0f", p.Weights[0])) currentWeight := p.Weights[0] currentReps := p.Reps[0] count := 1 firstMax := 0 // pointer to the first entry with a zero RIR if p.RIR[0] != 0 { firstMax = -1 } for i := 1; i < len(p.Weights); i++ { if p.Weights[i] == currentWeight && p.Reps[i] == currentReps { count++ } else { result = append(result, fmt.Sprintf("%dx%d", count, currentReps)) count = 1 if p.Weights[i] != currentWeight { result = append(result, fmt.Sprintf("%0.0f", p.Weights[i])) currentWeight = p.Weights[i] } currentReps = p.Reps[i] } if firstMax == -1 && p.RIR[i] == 0 { firstMax = i } } result = append(result, fmt.Sprintf("%dx%d", count, currentReps)) if firstMax != -1 { result = append(result, fmt.Sprintf("(1RM: %0.0f)", Estimated1RM(p))) } return strings.Join(result, " ") } func (p Performance) String2() string { var b strings.Builder b.WriteString(p.Times[0].UTC().Format("2 Jan 2006") + ": ") b.WriteString(p.Exercise) if p.Type != "" { b.WriteString(" (" + p.Type + ")") } wt := p.Weights[0] b.WriteString(" " + fmt.Sprintf("%0.0f", wt)) b.WriteString(" " + fmt.Sprintf("%d", p.Reps[0])) if len(p.Reps) > 1 { for i,r := range(p.Reps[1:]) { b.WriteString("-") if p.Weights[i+1] != wt { b.WriteString(fmt.Sprintf("%0.0fx",p.Weights[i+1])) } b.WriteString(fmt.Sprintf("%d", r)) } } b.WriteString(fmt.Sprintf(" (%0.0f)", Estimated1RM(p))) return b.String() } type Performances []Performance // PerformanceRepository defines interface to retrieve performances. type PerformanceRepository interface { // FindPerformances returns performances matching the filter, sorted oldest to newest. FindPerformances(filter func(Performance) bool) (Performances, error) } // performanceRepo implements PerformanceRepository using WorkoutRepository and MeasurementRepository. type performanceRepo struct { workoutRepo *logbook.WorkoutRepository measurementRepo *logbook.MeasurementRepository } // NewPerformanceRepository creates a PerformanceRepository backed by the given repositories. func NewPerformanceRepository(wr *logbook.WorkoutRepository, mr *logbook.MeasurementRepository) PerformanceRepository { return &performanceRepo{ workoutRepo: wr, measurementRepo: mr, } } func (r *performanceRepo) FindPerformances(filter func(Performance) bool) (Performances, error) { // If filter is nil, accept all SetGroups if filter == nil { filter = func(p Performance) bool { return true } } // We create a SetGroup-level filter that accepts any SetGroup // whose Exercise and Type might possibly match the Performance filter. // Because filter works on Performance (which includes weights, reps, etc), // we conservatively accept all SetGroups and filter later. // But if filter is composed of simple Exercise/Type filters, we can optimize. // Attempt to extract Exercise and Type filters from the given filter func // (This requires filter composition knowledge; otherwise fallback to accept all) // For simplicity, here we pass all SetGroups and filter in Go: setGroups, err := r.workoutRepo.FindSetGroups(func(sg *parser.SetGroup) bool { // Construct a minimal Performance with only Exercise and Type to test filter p := Performance{ Exercise: sg.Exercise, Type: sg.Type, } return filter(p) }) if err != nil { return nil, err } var performances []Performance for _, sg := range setGroups { if len(sg.ActualSets) == 0 { continue } weights := make([]float64, 0, len(sg.ActualSets)) reps := make([]int, 0, len(sg.ActualSets)) times := make([]time.Time, 0, len(sg.ActualSets)) RIR := make([]int, 0, len(sg.ActualSets)) for _, set := range sg.ActualSets { weights = append(weights, set.Weight) reps = append(reps, set.Reps) times = append(times, set.Time) RIR = append(RIR, set.RIR) } earliestTime := times[0] bodyweight, err := r.measurementRepo.GetOrPredict("bodyweight", earliestTime) if err != nil { bodyweight = 0 } p := Performance{ Exercise: sg.Exercise, Type: sg.Type, Weights: weights, Reps: reps, Times: times, Note: sg.Note, Bodyweight: bodyweight, RIR: RIR, } if filter(p) { performances = append(performances, p) } } return Performances(performances), nil } func (ps *Performances) FindPerformances(filter func(Performance) bool) ([]Performance, error) { var performances Performances for _, p := range (*ps) { if filter(p) { performances = append(performances, p) } } return performances, nil } ////////////////////////// // Filtering Utilities // ////////////////////////// // FilterByExerciseType returns a filter for a given exercise and type. func FilterByExerciseType(exercise, typ string) func(Performance) bool { return func(p Performance) bool { return p.Exercise == exercise && p.Type == typ } } // FilterByWeightRange returns a filter that matches any set weight within [min, max]. func FilterByWeightRange(min, max float64) func(Performance) bool { return func(p Performance) bool { for _, w := range p.Weights { if w >= min && w <= max { return true } } return false } } // FilterByDateRange returns a filter that matches any set time within [start, end]. func FilterByDateRange(start, end time.Time) func(Performance) bool { return func(p Performance) bool { for _, t := range p.Times { if !t.Before(start) && !t.After(end) { return true } } return false } } // And composes multiple filters with logical AND. func And(filters ...func(Performance) bool) func(Performance) bool { return func(p Performance) bool { for _, f := range filters { if !f(p) { return false } } return true } } // Or composes multiple filters with logical OR. func Or(filters ...func(Performance) bool) func(Performance) bool { return func(p Performance) bool { for _, f := range filters { if f(p) { return true } } return false } } ////////////////////////// // Aggregation Functions // ////////////////////////// // RecentPerformances returns up to n most recent performances matching filter, sorted newest first. func RecentPerformances(repo PerformanceRepository, filter func(Performance) bool, n int) ([]Performance, error) { perfs, err := repo.FindPerformances(filter) if err != nil { return nil, err } // Sort by latest set time descending sort.Slice(perfs, func(i, j int) bool { return latestTime(perfs[i]).After(latestTime(perfs[j])) }) if n > 0 && len(perfs) > n { perfs = perfs[:n] } return perfs, nil } // PersonalBest returns the performance with the highest max estimated 1RM for the given exercise, type, and weight. func PersonalBest(repo PerformanceRepository, exercise, typ string, weight float64) (Performance, error) { filter := And( FilterByExerciseType(exercise, typ), FilterByWeightRange(weight, weight), ) perfs, err := repo.FindPerformances(filter) if err != nil { return Performance{}, err } if len(perfs) == 0 { return Performance{}, errors.New("no performances found") } best := perfs[0] bestMax1RM := Estimated1RM(best) for _, p := range perfs[1:] { if m := Estimated1RM(p); m > bestMax1RM { best = p bestMax1RM = m } } return best, nil } // RepsDistribution returns a map of reps count to frequency for all sets matching exercise, type, and weight. func RepsDistribution(repo PerformanceRepository, exercise, typ string, weight float64) (map[int]int, error) { filter := And( FilterByExerciseType(exercise, typ), FilterByWeightRange(weight, weight), ) perfs, err := repo.FindPerformances(filter) if err != nil { return nil, err } dist := make(map[int]int) for _, p := range perfs { for i, w := range p.Weights { if w == weight { dist[p.Reps[i]]++ } } } return dist, nil } ////////////////////////// // Strength Estimation // ////////////////////////// // Epley1RM estimates one-rep max using Epley formula: 1RM = weight * (1 + reps/30) func Epley1RM(weight float64, reps int) float64 { if reps <= 0 { return 0 } return weight * (1 + float64(reps-1)/30.0) } // EstimateRepsAtWeight estimates how many reps can be performed at targetWeight given 1RM. func EstimateRepsAtWeight(oneRM, targetWeight float64) int { if targetWeight <= 0 || oneRM <= 0 { return 0 } reps := 30 * (oneRM/targetWeight - 1) if reps < 0 { return 0 } return int(reps + 0.5) // round to nearest int } // EstimateWeightAtReps estimates max weight for given reps and 1RM. func EstimateWeightAtReps(oneRM float64, reps int) float64 { if reps <= 0 { return 0 } return oneRM / (1 + float64(reps)/30.0) } ////////////////////////// // Sorting and Comparison // ////////////////////////// // LessFunc defines a comparison function for sorting Performances. type LessFunc func(a, b Performance) bool // ByMaxEstimated1RM compares by max estimated 1RM (default), tiebreak by lower bodyweight. func ByMaxEstimated1RM(a, b Performance) bool { max1RM_a := Estimated1RM(a) max1RM_b := Estimated1RM(b) if max1RM_a != max1RM_b { return max1RM_a < max1RM_b } // tie-breaker: lower bodyweight ranks higher return a.Bodyweight > b.Bodyweight } // ByTotalVolume compares by total volume (sum weight*reps), tiebreak by lower bodyweight. func ByTotalVolume(a, b Performance) bool { volA := totalVolume(a) volB := totalVolume(b) if volA != volB { return volA < volB } return a.Bodyweight > b.Bodyweight } // SortPerformances sorts performances in-place using the provided LessFunc. // If less is nil, uses ByMaxEstimated1RM as default. func SortPerformances(perfs []Performance, less LessFunc) { if less == nil { less = ByMaxEstimated1RM } sort.Slice(perfs, func(i, j int) bool { return less(perfs[i], perfs[j]) }) } ////////////////////////// // Internal helpers // ////////////////////////// func Estimated1RM(p Performance) float64 { isbw := IsBodyweight(p.Exercise, "") Estimate1RM := GetEstimate1RM(p.Exercise, p.Type) var bw float64 if isbw { bw = p.Bodyweight } max1RM := bw for i := range p.Weights { oneRM := Estimate1RM(p.Weights[i] + bw, p.Reps[i]) if oneRM > max1RM { max1RM = oneRM } } return max1RM - bw } func totalVolume(p Performance) float64 { vol := 0.0 for i := range p.Weights { vol += p.Weights[i] * float64(p.Reps[i]) } return vol } func latestTime(p Performance) time.Time { latest := p.Times[0] for _, t := range p.Times[1:] { if t.After(latest) { latest = t } } return latest }