Fitness dashboard without calorie tracker
Does not compute correctly yet
This commit is contained in:
@@ -19,13 +19,6 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
<line class="chart-axis" x1="{{.PlotX}}" y1="{{.PlotY}}" x2="{{.PlotX}}" y2="{{.PlotB}}"/>
|
<line class="chart-axis" x1="{{.PlotX}}" y1="{{.PlotY}}" x2="{{.PlotX}}" y2="{{.PlotB}}"/>
|
||||||
<line class="chart-axis" x1="{{.PlotX}}" y1="{{.PlotB}}" x2="{{.PlotR}}" y2="{{.PlotB}}"/>
|
<line class="chart-axis" x1="{{.PlotX}}" y1="{{.PlotB}}" x2="{{.PlotR}}" y2="{{.PlotB}}"/>
|
||||||
{{range .Bars}}
|
|
||||||
<g class="chart-bar-group">
|
|
||||||
<title>{{.Title}}</title>
|
|
||||||
<rect class="chart-bar" x="{{.X}}" y="{{.Y}}" width="{{.W}}" height="{{.H}}"/>
|
|
||||||
<line class="chart-net" x1="{{.X}}" y1="{{.NetY}}" x2="{{.X2}}" y2="{{.NetY}}"/>
|
|
||||||
</g>
|
|
||||||
{{end}}
|
|
||||||
{{range .Lines}}
|
{{range .Lines}}
|
||||||
<polyline class="chart-line" points="{{.}}"/>
|
<polyline class="chart-line" points="{{.}}"/>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -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-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-line { fill: none; stroke: var(--link); stroke-width: 1.5; }
|
||||||
.fitness-svg .chart-dot { fill: var(--link); }
|
.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 { stroke: var(--primary-hover); stroke-dasharray: 4 3; }
|
||||||
.fitness-svg .chart-goal-label { fill: var(--primary-hover); font-size: var(--font-xs); }
|
.fitness-svg .chart-goal-label { fill: var(--primary-hover); font-size: var(--font-xs); }
|
||||||
|
|
||||||
|
|||||||
+22
-317
@@ -3,7 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"html/template"
|
"html/template"
|
||||||
"log"
|
"log"
|
||||||
"math"
|
"math"
|
||||||
@@ -41,9 +40,8 @@ func (f *fitnessHandler) handle(root, fsPath, urlPath string, r *http.Request) *
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
weightSel := validFitnessRange(r.URL.Query().Get("weight"), "3m")
|
weightSel := validFitnessRange(r.URL.Query().Get("weight"), "3m")
|
||||||
calSel := validFitnessRange(r.URL.Query().Get("calories"), "1m")
|
|
||||||
return &specialPage{
|
return &specialPage{
|
||||||
Content: renderFitnessDashboard(fsPath, weightSel, calSel),
|
Content: renderFitnessDashboard(fsPath, weightSel),
|
||||||
SuppressTOC: true,
|
SuppressTOC: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,44 +103,20 @@ func (n *wlNum) UnmarshalJSON(b []byte) error {
|
|||||||
return nil
|
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 {
|
type wlExport struct {
|
||||||
Diary []wlDiaryEntry `json:"diary"`
|
Diary []wlDiaryEntry `json:"diary"`
|
||||||
FoodList []wlFood `json:"foodList"`
|
|
||||||
Recipes []wlRecipe `json:"recipes"`
|
|
||||||
Settings json.RawMessage `json:"settings"`
|
Settings json.RawMessage `json:"settings"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type wlDiaryEntry struct {
|
type wlDiaryEntry struct {
|
||||||
DateTime string `json:"dateTime"`
|
DateTime string `json:"dateTime"`
|
||||||
Items []wlItem `json:"items"`
|
|
||||||
Stats struct {
|
Stats struct {
|
||||||
Weight wlNum `json:"weight"`
|
Weight wlNum `json:"weight"`
|
||||||
} `json:"stats"`
|
} `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
|
// exportDate extracts the calendar day from a Waistline dateTime. The values
|
||||||
// are UTC-midnight timestamps; taking the first 10 characters avoids time
|
// are UTC-midnight timestamps; taking the first 10 characters avoids time
|
||||||
// zone conversions shifting the day.
|
// zone conversions shifting the day.
|
||||||
@@ -182,23 +156,16 @@ func goalValue(settings json.RawMessage, key string) (float64, bool) {
|
|||||||
return n.val, n.ok
|
return n.val, n.ok
|
||||||
}
|
}
|
||||||
|
|
||||||
func exportUnits(settings json.RawMessage) (weight, energy string) {
|
func exportWeightUnit(settings json.RawMessage) string {
|
||||||
weight, energy = "kg", "kcal"
|
|
||||||
var s struct {
|
var s struct {
|
||||||
Units struct {
|
Units struct {
|
||||||
Weight string `json:"weight"`
|
Weight string `json:"weight"`
|
||||||
Energy string `json:"energy"`
|
|
||||||
} `json:"units"`
|
} `json:"units"`
|
||||||
}
|
}
|
||||||
if json.Unmarshal(settings, &s) == nil {
|
if json.Unmarshal(settings, &s) == nil && s.Units.Weight != "" {
|
||||||
if s.Units.Weight != "" {
|
return s.Units.Weight
|
||||||
weight = s.Units.Weight
|
|
||||||
}
|
}
|
||||||
if s.Units.Energy != "" {
|
return "kg"
|
||||||
energy = s.Units.Energy
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Series extraction ===
|
// === Series extraction ===
|
||||||
@@ -208,144 +175,24 @@ type weightPoint struct {
|
|||||||
value float64
|
value float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// calorieBarData is one bar: a day in per-day mode, or an ISO week of mean
|
// extractWeights computes the weight series from the export: one point per
|
||||||
// daily values after weekly aggregation (days > 0 marks the weekly case).
|
// diary entry with stats.weight. Duplicate dates (shouldn't happen) — last
|
||||||
type calorieBarData struct {
|
// one wins.
|
||||||
date time.Time
|
func extractWeights(ex *wlExport) []weightPoint {
|
||||||
gross float64
|
byDate := map[time.Time]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{}
|
|
||||||
for _, e := range ex.Diary {
|
for _, e := range ex.Diary {
|
||||||
day, ok := exportDate(e.DateTime)
|
day, ok := exportDate(e.DateTime)
|
||||||
if !ok {
|
if !ok || !e.Stats.Weight.ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if e.Stats.Weight.ok {
|
byDate[day] = e.Stats.Weight.val
|
||||||
weightByDate[day] = e.Stats.Weight.val
|
|
||||||
}
|
}
|
||||||
gross, net, counted := dayCalories(e.Items, foods, recipes)
|
weights := make([]weightPoint, 0, len(byDate))
|
||||||
if counted > 0 {
|
for d, v := range byDate {
|
||||||
calByDate[day] = calorieBarData{date: day, gross: gross, net: net}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
weights := make([]weightPoint, 0, len(weightByDate))
|
|
||||||
for d, v := range weightByDate {
|
|
||||||
weights = append(weights, weightPoint{d, v})
|
weights = append(weights, weightPoint{d, v})
|
||||||
}
|
}
|
||||||
sort.Slice(weights, func(i, j int) bool { return weights[i].date.Before(weights[j].date) })
|
sort.Slice(weights, func(i, j int) bool { return weights[i].date.Before(weights[j].date) })
|
||||||
|
return weights
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// === SVG geometry ===
|
// === SVG geometry ===
|
||||||
@@ -367,10 +214,6 @@ func svgNum(v float64) string {
|
|||||||
return strconv.FormatFloat(math.Round(v*100)/100, 'f', -1, 64)
|
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 {
|
type chartScale struct {
|
||||||
x0, x1 time.Time
|
x0, x1 time.Time
|
||||||
y0, y1 float64
|
y0, y1 float64
|
||||||
@@ -430,10 +273,6 @@ type fitnessDotVM struct {
|
|||||||
X, Y, Title string
|
X, Y, Title string
|
||||||
}
|
}
|
||||||
|
|
||||||
type fitnessBarVM struct {
|
|
||||||
X, Y, W, H, X2, NetY, Title string
|
|
||||||
}
|
|
||||||
|
|
||||||
type fitnessGoalVM struct {
|
type fitnessGoalVM struct {
|
||||||
Y, LabelY, Label string
|
Y, LabelY, Label string
|
||||||
}
|
}
|
||||||
@@ -451,7 +290,6 @@ type fitnessChartVM struct {
|
|||||||
Goal *fitnessGoalVM
|
Goal *fitnessGoalVM
|
||||||
Lines []string // polyline points attributes
|
Lines []string // polyline points attributes
|
||||||
Dots []fitnessDotVM
|
Dots []fitnessDotVM
|
||||||
Bars []fitnessBarVM
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type fitnessDashVM struct {
|
type fitnessDashVM struct {
|
||||||
@@ -588,95 +426,6 @@ func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, sel, unit s
|
|||||||
return vm
|
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 {
|
func filterWeights(points []weightPoint, from time.Time) []weightPoint {
|
||||||
var out []weightPoint
|
var out []weightPoint
|
||||||
for _, p := range points {
|
for _, p := range points {
|
||||||
@@ -687,16 +436,6 @@ func filterWeights(points []weightPoint, from time.Time) []weightPoint {
|
|||||||
return out
|
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
|
// 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.
|
// axis; the last label is end-anchored so it stays inside the viewBox.
|
||||||
func timeXTicks(sc chartScale, n int) []fitnessTickVM {
|
func timeXTicks(sc chartScale, n int) []fitnessTickVM {
|
||||||
@@ -719,43 +458,12 @@ func timeXTicks(sc chartScale, n int) []fitnessTickVM {
|
|||||||
return out
|
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 ===
|
// === Rendering ===
|
||||||
|
|
||||||
var fitnessTmpl = template.Must(template.ParseFS(assets, "assets/fitness/main.html"))
|
var fitnessTmpl = template.Must(template.ParseFS(assets, "assets/fitness/main.html"))
|
||||||
|
|
||||||
func renderFitnessDashboard(fsPath, weightSel, calSel string) template.HTML {
|
func renderFitnessDashboard(fsPath, weightSel string) template.HTML {
|
||||||
data := buildFitnessDash(fsPath, weightSel, calSel, time.Now())
|
data := buildFitnessDash(fsPath, weightSel, time.Now())
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := fitnessTmpl.Execute(&buf, data); err != nil {
|
if err := fitnessTmpl.Execute(&buf, data); err != nil {
|
||||||
log.Printf("fitness template: %v", err)
|
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
|
// buildFitnessDash reads and parses the export per request — no caching, no
|
||||||
// indexes. A read or parse failure (including a truncated mid-upload file)
|
// indexes. A read or parse failure (including a truncated mid-upload file)
|
||||||
// becomes an inline notice; the page itself always renders.
|
// 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))
|
raw, err := os.ReadFile(filepath.Join(fsPath, waistlineExportFile))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fitnessDashVM{Notice: "No Waistline export found — upload " + waistlineExportFile + " to this folder."}
|
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)
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
weights, calories := extractSeries(&ex)
|
weights := extractWeights(&ex)
|
||||||
weightUnit, energyUnit := exportUnits(ex.Settings)
|
|
||||||
wGoal, hasWGoal := goalValue(ex.Settings, "weight")
|
wGoal, hasWGoal := goalValue(ex.Settings, "weight")
|
||||||
cGoal, hasCGoal := goalValue(ex.Settings, "calories")
|
|
||||||
|
|
||||||
return fitnessDashVM{Charts: []fitnessChartVM{
|
return fitnessDashVM{Charts: []fitnessChartVM{
|
||||||
buildWeightChart(weights, wGoal, hasWGoal, weightSel, weightUnit, today),
|
buildWeightChart(weights, wGoal, hasWGoal, weightSel, exportWeightUnit(ex.Settings), today),
|
||||||
buildCaloriesChart(calories, cGoal, hasCGoal, calSel, energyUnit, today),
|
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user