404 lines
9.7 KiB
Go
404 lines
9.7 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"git.gutmet.org/go-chart.git"
|
|
"git.gutmet.org/go-chart.git/drawing"
|
|
util "git.gutmet.org/go-chart.git/util"
|
|
goutil "git.gutmet.org/goutil.git/misc"
|
|
"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 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
|
|
}
|
|
}
|