package main import ( "errors" "flag" "fmt" "math" "os" "strconv" "strings" "time" "git.fireandbrimst.one/aw/go-chart" "git.fireandbrimst.one/aw/go-chart/drawing" util "git.fireandbrimst.one/aw/go-chart/util" goutil "git.fireandbrimst.one/aw/goutil/misc" ) 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 readFile(file string) (map[time.Time]float64, time.Time, time.Time) { optPanic := func(err error) { if err != nil { panic("process:" + err.Error()) } } weights, err := goutil.ReadFile(file) if err != nil { return nil, time.Time{}, time.Time{} } lines := strings.Split(weights, "\n") var firstDay time.Time firstDayIsSet := false var lastDay time.Time dateToWeight := make(map[time.Time]float64) for _, line := range lines { line = strings.TrimSpace(line) if len(line) == 0 { continue } splitline := strings.Split(line, "\t") if len(splitline) < 2 { optPanic(errors.New("invalid line in weight file " + file + ": " + line)) } date, err := time.Parse(dateLayout, splitline[0]) optPanic(err) weight, err := strconv.ParseFloat(splitline[1], 64) optPanic(err) if !firstDayIsSet { firstDay = date firstDayIsSet = true } lastDay = date dateToWeight[date] = weight } return dateToWeight, firstDay, lastDay } func process(file string) []point { dateToWeight, firstDay, lastDay := readFile(file) if dateToWeight == nil { return []point{} } trend := 0.0 points := []point{} for d := firstDay; d.Before(lastDay) || d == lastDay; d = d.AddDate(0, 0, 1) { var weight float64 if w, ok := dateToWeight[d]; ok { weight = w } else { prevPoint := points[len(points)-1] prevDay := prevPoint.date prevWeight := prevPoint.weight var nextWeight float64 var nextDay time.Time for nextDay = d.AddDate(0, 0, 1); nextDay.Before(lastDay) || nextDay == lastDay; nextDay = nextDay.AddDate(0, 0, 1) { if w, ok := dateToWeight[nextDay]; ok { nextWeight = w break } } days := nextDay.Sub(prevDay).Hours() / 24 changePerDay := (nextWeight - prevWeight) / days weight = prevWeight + changePerDay } if trend == 0.0 { trend = weight } else { trend = math.Floor((trend+0.1*(weight-trend))*100) / 100 } points = append(points, point{d, weight, trend}) } return points } type showFlags struct { from DateFlag to DateFlag nodisplay bool } func show(args showFlags) error { points := process(logfile) 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) xRange, 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, xRange, 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, xRange, 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(xticks(xRange), 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 := "" 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 xticks(d dateInterval) []chart.Tick { ticks := []chart.Tick{} start := d.min end := d.max stepDays := int(end.Sub(start).Hours() / 24 / 10) if stepDays < 1 { stepDays = 1 } for i := 0; start.AddDate(0, 0, i).Before(end.AddDate(0, 0, stepDays)); i += stepDays { val := start.AddDate(0, 0, i) label := val.Format(dateLayout) ticks = append(ticks, chart.Tick{util.Time.ToFloat64(val), label}) } return ticks } func addPointsGetRange(s *chart.TimeSeries, points []point, di dateInterval, project func(point) float64) (dateInterval, yRange) { XRange := dateInterval{time.Now().AddDate(10000, 0, 0), time.Now().AddDate(-10000, 0, 0)} 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)) { xVal := p.date s.XValues = append(s.XValues, xVal) if xVal.Before(XRange.min) { XRange.min = xVal } if xVal.After(XRange.max) { XRange.max = xVal } yVal := project(p) s.YValues = append(s.YValues, yVal) if yVal < YRange.min { YRange.min = yVal } if yVal > YRange.max { YRange.max = yVal } } } return XRange, YRange } func timeSeries(name string, color drawing.Color) chart.TimeSeries { return chart.TimeSeries{Name: name, Style: chart.Style{Show: true, StrokeColor: color}} } func graph(xticks []chart.Tick, 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, Ticks: xticks, }, 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 } }