commit 30e1bc8250382e73fec63f26da383bd5fff4c6c2 Author: gutmet Date: Tue Jan 1 19:18:13 2019 +0100 purge history since March 2016 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee749da --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +diet.txt +weight.txt +go.sum +.hdiet +weightplot.png +hdiet +.gitdist + diff --git a/License b/License new file mode 100644 index 0000000..ddc9ea7 --- /dev/null +++ b/License @@ -0,0 +1,18 @@ +hdiet: Adaptation of the Hacker's Diet computer tools by John Walker. +Copyright (C) 2016 Alexander Weinhold + +Unless explicitly stated otherwise, the following applies: + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..bf0e291 --- /dev/null +++ b/Readme.md @@ -0,0 +1,56 @@ +hdiet +======== + +hdiet is an adaptation of the Hacker's Diet computer tools for weight tracking. You can find releases at [releases.gutmet.org](https://releases.gutmet.org) or build it yourself. + +The Hacker's Diet +================= + +"The Hacker's Diet" is an ingenious [book](https://www.fourmilab.ch/hackdiet/) by John Walker which applies a systems and signals view to bodyweight. If you haven't already, go read it. + +Walker provides excellent online tools for tracking and Excel-/Palm-based tools for offline tracking. I'm not comfortable with either, though, and as it [turns out](https://www.fourmilab.ch/hackdiet/e4/pencilpaper.html), the calculations needed for trend calculation are simple. + +build +===== + +You need go v1.9 or higher. If you have go module support enabled, check the repository out wherever and run go build hdiet.go - otherwise use go get. + +usage +===== + +Get a release or build hdiet yourself, then put it somewhere in your [path](https://en.wikipedia.org/wiki/PATH_(variable)) + +hdiet has the following commands: + +* init: initialize the directory (create an empty file '.hdiet' as marker for the program) +* log: write today's weight to the log file +* show: plot weight to image and display +* calcdiet: calculate a planned diet + +Weights are logged as plain text, each line consisting of YYYY-MM-DD tab WEIGHT. The same holds for a calculated diet. + +The trend is displayed in red, the actual weight in blue. Red is what you should be looking at. + + +quick start +=========== + +``` +cd /path/to/your/log/folder +hdiet init +hdiet log 82.7 +``` + +You will get a warning that you need at least 2 values the first time you log. + +Example +======= + +Example of a filled weight log: + +![](../plain/weight_example.png) + + +Example of a trend graph without calculated diet: + +![](../plain/plot_example.png) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..97504cc --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module git.gutmet.org/hdiet.git + +require ( + git.gutmet.org/go-chart.git v3.0.1+incompatible // indirect + git.gutmet.org/golang-freetype.git v2.0.0+incompatible // indirect + git.gutmet.org/golang-image.git v2.0.0+incompatible // indirect + git.gutmet.org/goutil.git v0.0.0-20181209084938-d1f435a43662 // indirect +) diff --git a/hdiet.go b/hdiet.go new file mode 100644 index 0000000..33f4d7a --- /dev/null +++ b/hdiet.go @@ -0,0 +1,327 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "git.gutmet.org/go-chart.git" + "git.gutmet.org/go-chart.git/drawing" + "git.gutmet.org/goutil.git" + "math" + "os" + "strconv" + "strings" + "time" +) + +const ( + initflag = ".hdiet" + logfile = "weight.txt" + dietfile = "diet.txt" + dateLayout = "2006-01-02" + imgfile = "weightplot.png" + width = 1600 + height = 900 +) + +type DateFlag struct { + date *time.Time +} + +func (d DateFlag) String() string { + if d.date == nil { + return "" + } else { + return time.Time(*d.date).Format(dateLayout) + } +} + +func (d *DateFlag) Set(s string) error { + if d == nil { + return errors.New("func (d *DateFlag) Set(s string): d is nil") + } + val, err := time.Parse(dateLayout, s) + if err == nil { + d.date = &val + } + return err +} + +func isInitialized() bool { + return goutil.PathExists(initflag) +} + +func errNotInitialized() error { + dir, err := os.Getwd() + if err == nil { + err = errors.New(dir + ": " + "is not an initialized hdiet directory") + } + return err +} + +func initHdiet(args []string) error { + if !isInitialized() { + return goutil.WriteFile(initflag, "") + } + return nil +} + +func log(args []string) error { + if !isInitialized() { + return errNotInitialized() + } + if len(args) != 1 { + return errors.New("log: Please provide weight as argument (floating point number)") + } + weight, err := strconv.ParseFloat(args[0], 64) + if err != nil { + return errors.New("log: " + err.Error()) + } + date := time.Now().Format(dateLayout) + err = goutil.AppendToFile(logfile, fmt.Sprintf("%s\t%.1f\n", date, weight)) + if err != nil { + return err + } + return show(showFlags{}) +} + +type point struct { + date time.Time + weight float64 + trend float64 +} + +type dateInterval struct { + min time.Time + max time.Time +} + +type yRange struct { + min float64 + max float64 +} + +func process(file string) ([]point, error) { + weights, err := goutil.ReadFile(file) + if err != nil { + return nil, err + } + lines := strings.Split(weights, "\n") + trend := 0.0 + s := []point{} + for _, line := range lines { + line = strings.TrimSpace(line) + if len(line) == 0 { + continue + } + splitline := strings.Split(line, "\t") + if len(splitline) < 2 { + return nil, errors.New("process - invalid line in weight file: " + line) + } + date, _ := time.Parse(dateLayout, splitline[0]) + weight, _ := strconv.ParseFloat(splitline[1], 64) + if trend == 0.0 { + trend = weight + } else { + trend = math.Floor((trend+0.1*(weight-trend))*100) / 100 + } + s = append(s, point{date, weight, trend}) + } + return s, nil +} + +type showFlags struct { + from DateFlag + to DateFlag +} + +func show(args showFlags) error { + points, err := process(logfile) + if err != nil { + return err + } + dietpoints, _ := process(dietfile) + var to time.Time + var from time.Time + if args.to.date != nil { + to = *args.to.date + } else { + to = time.Now() + } + if args.from.date != nil { + from = *args.from.date + } else { + from = to.AddDate(0, 0, -31) + } + + fromTo := dateInterval{from, to} + s1 := timeSeries("weight", chart.ColorCyan) + yRange1 := addPointsGetRange(&s1, points, fromTo, func(p point) float64 { return p.weight }) + s2 := timeSeries("trend", drawing.Color{R: 255, A: 255}) + yRange2 := addPointsGetRange(&s2, points, fromTo, func(p point) float64 { return p.trend }) + s3 := timeSeries("diet", drawing.Color{R: 255, G: 211, A: 255}) + yRange3 := yRange2 + if dietpoints != nil { + yRange3 = addPointsGetRange(&s3, dietpoints, fromTo, func(p point) float64 { return p.weight }) + } + YRange := yRange{math.Min(math.Min(yRange1.min, yRange2.min), yRange3.min), math.Max(math.Max(yRange1.max, yRange2.max), yRange3.max)} + YRange.min = math.Floor(YRange.min) - 1.0 + YRange.max = math.Ceil(YRange.max) + 1.0 + graph := graph(s1, s2, s3, YRange) + err = writeGraphToFile(graph) + if err != nil { + return err + } + return openGraphFile() +} + +func showCommand() (goutil.CommandFlagsInit, goutil.CommandFunc) { + sf := showFlags{} + flagsInit := func(s *flag.FlagSet) { + s.Var(&sf.from, "from", "YYYY-MM-DD (optional)") + s.Var(&sf.to, "to", "YYYY-MM-DD (optional)") + } + return flagsInit, func([]string) error { return show(sf) } +} + +func writeDietFile(from time.Time, initial float64, goal float64, change float64) error { + date := from + weight := initial + s := "#Date\tTarget\n" + s += fmt.Sprintf("%s\t%.1f\n", date.Format(dateLayout), weight) + for weight > goal { + date = date.AddDate(0, 0, 1) + weight += change / 7.0 + s += fmt.Sprintf("%s\t%.3f\n", date.Format(dateLayout), weight) + } + err := goutil.WriteFile(dietfile, s) + if err == nil { + fmt.Println("Wrote to " + dietfile) + } + return err +} + +func diet([]string) error { + if !isInitialized() { + return errNotInitialized() + } + fromStr, err := goutil.AskFor("From? (YYYY-MM-DD) [today]") + var from time.Time + if fromStr == "" { + from = time.Now() + } else { + from, err = time.Parse(dateLayout, fromStr) + } + initial, err := parseFloat(askFor(err, "Initial weight?")) + goal, err := parseFloat(askFor(err, "Goal weight?")) + change, err := parseFloat(askFor(err, "Change per week?")) + if err != nil { + return err + } + if change == 0 { + return errors.New("change is zero") + } + if (initial >= goal && change > 0) || (initial <= goal && change < 0) { + change *= -1 + } + return writeDietFile(from, initial, goal, change) +} + +func main() { + commands := []goutil.Command{ + goutil.NewCommand("init", initHdiet, "init an hdiet folder"), + goutil.NewCommand("log", log, "log today's weight (given as argument)"), + goutil.NewCommandWithFlags("show", showCommand, "show the trend curve (today's or [from] [to])"), + goutil.NewCommand("calcdiet", diet, "calculate a diet curve"), + } + err := goutil.Execute(commands) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(-1) + } +} + +/*****************************/ + +func addPointsGetRange(s *chart.TimeSeries, points []point, di dateInterval, project func(point) float64) yRange { + YRange := yRange{math.MaxFloat64, -math.MaxFloat64} + for _, p := range points { + if (p.date.After(di.min) || p.date.Equal(di.min)) && (p.date.Before(di.max) || p.date.Equal(di.max)) && !math.IsNaN(project(p)) { + s.XValues = append(s.XValues, p.date) + yVal := project(p) + s.YValues = append(s.YValues, yVal) + if yVal < YRange.min { + YRange.min = yVal + } + if yVal > YRange.max { + YRange.max = yVal + } + } + } + return YRange +} + +func timeSeries(name string, color drawing.Color) chart.TimeSeries { + return chart.TimeSeries{Name: name, Style: chart.Style{Show: true, StrokeColor: color}} +} + +func graph(s1 chart.TimeSeries, s2 chart.TimeSeries, s3 chart.TimeSeries, YRange yRange) chart.Chart { + graph := chart.Chart{ + Width: width, + Height: height, + XAxis: chart.XAxis{ + Name: "date", + NameStyle: chart.StyleShow(), + Style: chart.StyleShow(), + ValueFormatter: chart.TimeDateValueFormatter, + }, + YAxis: chart.YAxis{ + Name: "kg", + NameStyle: chart.StyleShow(), + Style: chart.StyleShow(), + Range: &chart.ContinuousRange{ + Min: YRange.min, + Max: YRange.max, + }, + }, + Background: chart.Style{ + Padding: chart.Box{ + Top: 20, + Left: 80, + }, + }, + Series: []chart.Series{s1, s2, s3}, + } + graph.Elements = []chart.Renderable{chart.LegendLeft(&graph)} + return graph +} + +func writeGraphToFile(graph chart.Chart) error { + f, err := os.OpenFile(imgfile, os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + return err + } + err = graph.Render(chart.PNG, f) + f.Close() + return err +} + +func openGraphFile() error { + return goutil.OpenInDefaultApp(imgfile, false) +} + +func askFor(err error, q string) (string, error) { + if err == nil { + return goutil.AskFor(q) + } else { + return "", err + } +} + +func parseFloat(s string, err error) (float64, error) { + if err == nil { + return strconv.ParseFloat(s, 64) + } else { + return 0, err + } +} diff --git a/plot_example.png b/plot_example.png new file mode 100644 index 0000000..8213af8 Binary files /dev/null and b/plot_example.png differ diff --git a/weight_example.png b/weight_example.png new file mode 100644 index 0000000..1d6ee52 Binary files /dev/null and b/weight_example.png differ