diff --git a/assets/fitness/main.html b/assets/fitness/main.html index 1dc7777..dace803 100644 --- a/assets/fitness/main.html +++ b/assets/fitness/main.html @@ -19,13 +19,6 @@ {{end}} - {{range .Bars}} - - {{.Title}} - - - - {{end}} {{range .Lines}} {{end}} diff --git a/assets/style.css b/assets/style.css index b7b59d8..6ff3282 100644 --- a/assets/style.css +++ b/assets/style.css @@ -674,8 +674,6 @@ aside.sidebar:empty { display: none; } .fitness-svg .chart-label { fill: var(--text-muted); font-size: var(--font-xs); } .fitness-svg .chart-line { fill: none; stroke: var(--link); stroke-width: 1.5; } .fitness-svg .chart-dot { fill: var(--link); } -.fitness-svg .chart-bar { fill: var(--secondary); } -.fitness-svg .chart-net { stroke: var(--link); stroke-width: 1.5; } .fitness-svg .chart-goal { stroke: var(--primary-hover); stroke-dasharray: 4 3; } .fitness-svg .chart-goal-label { fill: var(--primary-hover); font-size: var(--font-xs); } diff --git a/fitness.go b/fitness.go index b88611f..4363581 100644 --- a/fitness.go +++ b/fitness.go @@ -3,7 +3,6 @@ package main import ( "bytes" "encoding/json" - "fmt" "html/template" "log" "math" @@ -41,9 +40,8 @@ func (f *fitnessHandler) handle(root, fsPath, urlPath string, r *http.Request) * return nil } weightSel := validFitnessRange(r.URL.Query().Get("weight"), "3m") - calSel := validFitnessRange(r.URL.Query().Get("calories"), "1m") return &specialPage{ - Content: renderFitnessDashboard(fsPath, weightSel, calSel), + Content: renderFitnessDashboard(fsPath, weightSel), SuppressTOC: true, } } @@ -105,44 +103,20 @@ func (n *wlNum) UnmarshalJSON(b []byte) error { return nil } +// Calorie tracking was removed pending a rethink of the (undocumented) +// per-item formula; only the weight series is read from the export. type wlExport struct { Diary []wlDiaryEntry `json:"diary"` - FoodList []wlFood `json:"foodList"` - Recipes []wlRecipe `json:"recipes"` Settings json.RawMessage `json:"settings"` } type wlDiaryEntry struct { - DateTime string `json:"dateTime"` - Items []wlItem `json:"items"` + DateTime string `json:"dateTime"` Stats struct { Weight wlNum `json:"weight"` } `json:"stats"` } -type wlItem struct { - ID wlNum `json:"id"` - Type string `json:"type"` - Portion wlNum `json:"portion"` - Quantity wlNum `json:"quantity"` -} - -type wlFood struct { - ID wlNum `json:"id"` - Unit string `json:"unit"` - Nutrition struct { - Calories wlNum `json:"calories"` - } `json:"nutrition"` -} - -type wlRecipe struct { - ID wlNum `json:"id"` - Portion wlNum `json:"portion"` - Nutrition struct { - Calories wlNum `json:"calories"` - } `json:"nutrition"` -} - // exportDate extracts the calendar day from a Waistline dateTime. The values // are UTC-midnight timestamps; taking the first 10 characters avoids time // zone conversions shifting the day. @@ -182,23 +156,16 @@ func goalValue(settings json.RawMessage, key string) (float64, bool) { return n.val, n.ok } -func exportUnits(settings json.RawMessage) (weight, energy string) { - weight, energy = "kg", "kcal" +func exportWeightUnit(settings json.RawMessage) string { var s struct { Units struct { Weight string `json:"weight"` - Energy string `json:"energy"` } `json:"units"` } - if json.Unmarshal(settings, &s) == nil { - if s.Units.Weight != "" { - weight = s.Units.Weight - } - if s.Units.Energy != "" { - energy = s.Units.Energy - } + if json.Unmarshal(settings, &s) == nil && s.Units.Weight != "" { + return s.Units.Weight } - return + return "kg" } // === Series extraction === @@ -208,144 +175,24 @@ type weightPoint struct { value float64 } -// calorieBarData is one bar: a day in per-day mode, or an ISO week of mean -// daily values after weekly aggregation (days > 0 marks the weekly case). -type calorieBarData struct { - date time.Time - gross float64 - net float64 - days int -} - -// extractSeries computes the weight and calories-per-day series from the -// export. Duplicate dates (shouldn't happen) — last one wins. Items whose id -// resolves to nothing in foodList/recipes are skipped silently. -func extractSeries(ex *wlExport) ([]weightPoint, []calorieBarData) { - foods := make(map[int]*wlFood, len(ex.FoodList)) - for i := range ex.FoodList { - if ex.FoodList[i].ID.ok { - foods[int(ex.FoodList[i].ID.val)] = &ex.FoodList[i] - } - } - recipes := make(map[int]*wlRecipe, len(ex.Recipes)) - for i := range ex.Recipes { - if ex.Recipes[i].ID.ok { - recipes[int(ex.Recipes[i].ID.val)] = &ex.Recipes[i] - } - } - - weightByDate := map[time.Time]float64{} - calByDate := map[time.Time]calorieBarData{} +// extractWeights computes the weight series from the export: one point per +// diary entry with stats.weight. Duplicate dates (shouldn't happen) — last +// one wins. +func extractWeights(ex *wlExport) []weightPoint { + byDate := map[time.Time]float64{} for _, e := range ex.Diary { day, ok := exportDate(e.DateTime) - if !ok { + if !ok || !e.Stats.Weight.ok { continue } - if e.Stats.Weight.ok { - weightByDate[day] = e.Stats.Weight.val - } - gross, net, counted := dayCalories(e.Items, foods, recipes) - if counted > 0 { - calByDate[day] = calorieBarData{date: day, gross: gross, net: net} - } + byDate[day] = e.Stats.Weight.val } - - weights := make([]weightPoint, 0, len(weightByDate)) - for d, v := range weightByDate { + weights := make([]weightPoint, 0, len(byDate)) + for d, v := range byDate { weights = append(weights, weightPoint{d, v}) } sort.Slice(weights, func(i, j int) bool { return weights[i].date.Before(weights[j].date) }) - - calories := make([]calorieBarData, 0, len(calByDate)) - for _, c := range calByDate { - calories = append(calories, c) - } - sort.Slice(calories, func(i, j int) bool { return calories[i].date.Before(calories[j].date) }) - return weights, calories -} - -// dayCalories sums one diary day. Gross counts only positive item calories; -// net includes negative quick-adds (logged exercise). counted is the number -// of items that resolved — a day with no countable items gets no bar. -func dayCalories(items []wlItem, foods map[int]*wlFood, recipes map[int]*wlRecipe) (gross, net float64, counted int) { - for _, it := range items { - cal, ok := itemCalories(it, foods, recipes) - if !ok { - continue - } - counted++ - net += cal - if cal > 0 { - gross += cal - } - } - return -} - -// itemCalories computes one diary item's calories. The formula is inferred -// from export data (Waistline does not document it): -// - food with unit g/ml: nutrition is per 100 → calories * portion/100 * quantity -// - food with any other unit: nutrition is per portion → calories * quantity -// (this also covers quick-adds: the "Quick Add" food has calories=1, so -// quantity is the kcal value, negative for exercise) -// - recipe: recipe calories are for the whole recipe → calories / recipe -// portion * quantity -func itemCalories(it wlItem, foods map[int]*wlFood, recipes map[int]*wlRecipe) (float64, bool) { - if !it.ID.ok || !it.Quantity.ok { - return 0, false - } - id := int(it.ID.val) - qty := it.Quantity.val - if it.Type == "recipe" { - rec := recipes[id] - if rec == nil || !rec.Nutrition.Calories.ok || !rec.Portion.ok || rec.Portion.val == 0 { - return 0, false - } - return rec.Nutrition.Calories.val / rec.Portion.val * qty, true - } - food := foods[id] - if food == nil || !food.Nutrition.Calories.ok { - return 0, false - } - switch food.Unit { - case "g", "ml": - if !it.Portion.ok { - return 0, false - } - return food.Nutrition.Calories.val * it.Portion.val / 100 * qty, true - } - return food.Nutrition.Calories.val * qty, true -} - -func mondayOf(t time.Time) time.Time { - return t.AddDate(0, 0, -((int(t.Weekday()) + 6) % 7)) -} - -// aggregateWeekly buckets days into ISO weeks (Monday start). Bar values are -// mean *daily* gross/net over the days in the bucket that have logged items, -// so the daily goal line stays directly comparable. Partial weeks at the -// range edges keep whatever days they have. -func aggregateWeekly(days []calorieBarData) []calorieBarData { - byWeek := map[time.Time]*calorieBarData{} - for _, d := range days { - w := mondayOf(d.date) - a := byWeek[w] - if a == nil { - a = &calorieBarData{date: w} - byWeek[w] = a - } - a.gross += d.gross - a.net += d.net - a.days++ - } - out := make([]calorieBarData, 0, len(byWeek)) - for _, a := range byWeek { - a.gross /= float64(a.days) - a.net /= float64(a.days) - out = append(out, *a) - } - sort.Slice(out, func(i, j int) bool { return out[i].date.Before(out[j].date) }) - return out + return weights } // === SVG geometry === @@ -367,10 +214,6 @@ func svgNum(v float64) string { return strconv.FormatFloat(math.Round(v*100)/100, 'f', -1, 64) } -func kcalNum(v float64) string { - return strconv.Itoa(int(math.Round(v))) -} - type chartScale struct { x0, x1 time.Time y0, y1 float64 @@ -430,10 +273,6 @@ type fitnessDotVM struct { X, Y, Title string } -type fitnessBarVM struct { - X, Y, W, H, X2, NetY, Title string -} - type fitnessGoalVM struct { Y, LabelY, Label string } @@ -451,7 +290,6 @@ type fitnessChartVM struct { Goal *fitnessGoalVM Lines []string // polyline points attributes Dots []fitnessDotVM - Bars []fitnessBarVM } type fitnessDashVM struct { @@ -588,95 +426,6 @@ func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, sel, unit s return vm } -func buildCaloriesChart(all []calorieBarData, goal float64, hasGoal bool, sel, unit string, today time.Time) fitnessChartVM { - vm := newChartVM("Calories ("+unit+"/day)", "calories", sel) - - days := all - var x0, x1 time.Time - if m := rangeMonths(sel); m > 0 { - x0 = today.AddDate(0, -m, 0) - days = filterCalories(all, x0) - if len(days) == 0 { - vm.Empty = true - return vm - } - x1 = today - if last := days[len(days)-1].date; last.After(x1) { - x1 = last - } - } else { - if len(days) == 0 { - vm.Empty = true - return vm - } - x0 = days[0].date - x1 = days[len(days)-1].date - } - - // Long ranges aggregate to weekly bars so they stay readable. - step := 1 - if sel == "1y" || sel == "all" { - days = aggregateWeekly(days) - x0 = mondayOf(x0) - x1 = mondayOf(x1) - step = 7 - } - slots := int(x1.Sub(x0).Hours()/24)/step + 1 - - lo, hi := 0.0, 0.0 - for _, d := range days { - hi = max(hi, d.gross) - lo = min(lo, d.net) - } - if hasGoal { - hi = max(hi, goal) - } - if hi == 0 && lo == 0 { - hi = 1 - } - span := hi - lo - hi += span * 0.05 - if lo < 0 { - lo -= span * 0.05 - } - sc := chartScale{x0, x1, lo, hi} - - vm.YTicks = yTickVMs(sc, 4) - vm.XTicks = slotXTicks(x0, step, slots) - - plotW := chartW - chartLeft - chartRight - slotW := plotW / float64(slots) - barW := slotW * 0.7 - y0pos := sc.y(0) - for _, d := range days { - idx := int(d.date.Sub(x0).Hours()/24) / step - xs := chartLeft + (float64(idx)+0.15)*slotW - yTop := sc.y(d.gross) - vm.Bars = append(vm.Bars, fitnessBarVM{ - X: svgNum(xs), W: svgNum(barW), X2: svgNum(xs + barW), - Y: svgNum(yTop), H: svgNum(max(0, y0pos-yTop)), - NetY: svgNum(sc.y(d.net)), - Title: barTitle(d, unit), - }) - } - - if hasGoal { - vm.setGoal(sc, goal, "goal "+kcalNum(goal)) - } - return vm -} - -func barTitle(d calorieBarData, unit string) string { - if d.days > 0 { - return fmt.Sprintf("Week of %s (%d days)\nintake ø %s %s/day\nnet ø %s %s/day", - d.date.Format("2006-01-02"), d.days, - kcalNum(d.gross), unit, kcalNum(d.net), unit) - } - return fmt.Sprintf("%s\nintake %s %s\nnet %s %s", - d.date.Format("2006-01-02"), - kcalNum(d.gross), unit, kcalNum(d.net), unit) -} - func filterWeights(points []weightPoint, from time.Time) []weightPoint { var out []weightPoint for _, p := range points { @@ -687,16 +436,6 @@ func filterWeights(points []weightPoint, from time.Time) []weightPoint { return out } -func filterCalories(days []calorieBarData, from time.Time) []calorieBarData { - var out []calorieBarData - for _, d := range days { - if !d.date.Before(from) { - out = append(out, d) - } - } - return out -} - // timeXTicks places n+1 evenly spaced date labels across a continuous time // axis; the last label is end-anchored so it stays inside the viewBox. func timeXTicks(sc chartScale, n int) []fitnessTickVM { @@ -719,43 +458,12 @@ func timeXTicks(sc chartScale, n int) []fitnessTickVM { return out } -// slotXTicks places up to 5 date labels at slot centers of a bar axis. -func slotXTicks(x0 time.Time, step, slots int) []fitnessTickVM { - n := 4 - if slots-1 < n { - n = slots - 1 - } - slotW := (chartW - chartLeft - chartRight) / float64(slots) - var out []fitnessTickVM - prevIdx := -1 - for i := 0; i <= n; i++ { - idx := slots - 1 - if n > 0 { - idx = int(math.Round(float64(i) * float64(slots-1) / float64(n))) - } - if idx == prevIdx { - continue - } - prevIdx = idx - anchor := "middle" - if i == n { - anchor = "end" - } - out = append(out, fitnessTickVM{ - Pos: svgNum(chartLeft + (float64(idx)+0.5)*slotW), - Label: x0.AddDate(0, 0, idx*step).Format("2006-01-02"), - Anchor: anchor, - }) - } - return out -} - // === Rendering === var fitnessTmpl = template.Must(template.ParseFS(assets, "assets/fitness/main.html")) -func renderFitnessDashboard(fsPath, weightSel, calSel string) template.HTML { - data := buildFitnessDash(fsPath, weightSel, calSel, time.Now()) +func renderFitnessDashboard(fsPath, weightSel string) template.HTML { + data := buildFitnessDash(fsPath, weightSel, time.Now()) var buf bytes.Buffer if err := fitnessTmpl.Execute(&buf, data); err != nil { log.Printf("fitness template: %v", err) @@ -767,7 +475,7 @@ func renderFitnessDashboard(fsPath, weightSel, calSel string) template.HTML { // buildFitnessDash reads and parses the export per request — no caching, no // indexes. A read or parse failure (including a truncated mid-upload file) // becomes an inline notice; the page itself always renders. -func buildFitnessDash(fsPath, weightSel, calSel string, now time.Time) fitnessDashVM { +func buildFitnessDash(fsPath, weightSel string, now time.Time) fitnessDashVM { raw, err := os.ReadFile(filepath.Join(fsPath, waistlineExportFile)) if err != nil { return fitnessDashVM{Notice: "No Waistline export found — upload " + waistlineExportFile + " to this folder."} @@ -778,13 +486,10 @@ func buildFitnessDash(fsPath, weightSel, calSel string, now time.Time) fitnessDa } today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) - weights, calories := extractSeries(&ex) - weightUnit, energyUnit := exportUnits(ex.Settings) + weights := extractWeights(&ex) wGoal, hasWGoal := goalValue(ex.Settings, "weight") - cGoal, hasCGoal := goalValue(ex.Settings, "calories") return fitnessDashVM{Charts: []fitnessChartVM{ - buildWeightChart(weights, wGoal, hasWGoal, weightSel, weightUnit, today), - buildCaloriesChart(calories, cGoal, hasCGoal, calSel, energyUnit, today), + buildWeightChart(weights, wGoal, hasWGoal, weightSel, exportWeightUnit(ex.Settings), today), }} }