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),
}}
}