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 nodisplay bool } 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 } if args.nodisplay { return nil } else { 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)") s.BoolVar(&sf.nodisplay, "nodisplay", false, "don't display graph image") } 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 } }