src

Go monorepo.
Log | Files | Refs

commit e902c1afe6c1dea29bb0fc93bbff7f1132d56ba3
parent e659052ca30eaf75fb7d90b8872a7866f8951807
Author: dwrz <dwrz@dwrz.net>
Date:   Sat, 24 Dec 2022 14:53:08 +0000

Add dqs

Diffstat:
Acmd/dqs/config/config.go | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acmd/dqs/dqs.go | 29+++++++++++++++++++++++++++++
Apkg/dqs/app.go | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/dqs/category/abbreviations.go | 22++++++++++++++++++++++
Apkg/dqs/category/category.go | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/dqs/category/high-quality.go | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/dqs/category/low-quality.go | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/dqs/command/add.go | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/dqs/command/body-fat.go | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/dqs/command/command.go | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/dqs/command/config.go | 203+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/dqs/command/delete.go | 26++++++++++++++++++++++++++
Apkg/dqs/command/entry.go | 40++++++++++++++++++++++++++++++++++++++++
Apkg/dqs/command/export.go | 40++++++++++++++++++++++++++++++++++++++++
Apkg/dqs/command/help.go | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/dqs/command/help/add.go | 34++++++++++++++++++++++++++++++++++
Apkg/dqs/command/help/body-fat.go | 24++++++++++++++++++++++++
Apkg/dqs/command/help/config.go | 33+++++++++++++++++++++++++++++++++
Apkg/dqs/command/help/delete.go | 26++++++++++++++++++++++++++
Apkg/dqs/command/help/entry.go | 25+++++++++++++++++++++++++
Apkg/dqs/command/help/export.go | 21+++++++++++++++++++++
Apkg/dqs/command/help/help.go | 39+++++++++++++++++++++++++++++++++++++++
Apkg/dqs/command/help/note.go | 33+++++++++++++++++++++++++++++++++
Apkg/dqs/command/help/portions.go | 32++++++++++++++++++++++++++++++++
Apkg/dqs/command/help/remove.go | 34++++++++++++++++++++++++++++++++++
Apkg/dqs/command/help/report.go | 25+++++++++++++++++++++++++
Apkg/dqs/command/help/user.go | 20++++++++++++++++++++
Apkg/dqs/command/help/weight.go | 21+++++++++++++++++++++
Apkg/dqs/command/note.go | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/dqs/command/parameters.go | 25+++++++++++++++++++++++++
Apkg/dqs/command/remove.go | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/dqs/command/report.go | 27+++++++++++++++++++++++++++
Apkg/dqs/command/user.go | 31+++++++++++++++++++++++++++++++
Apkg/dqs/command/weight.go | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/dqs/config.go | 15+++++++++++++++
Apkg/dqs/diet/diet.go | 48++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/dqs/diet/omnivore.go | 23+++++++++++++++++++++++
Apkg/dqs/diet/vegan.go | 21+++++++++++++++++++++
Apkg/dqs/diet/vegetarian.go | 22++++++++++++++++++++++
Apkg/dqs/entry/entry.go | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/dqs/entry/format.go | 157+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/dqs/entry/units.go | 32++++++++++++++++++++++++++++++++
Apkg/dqs/portion/portion.go | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/dqs/report/format.go | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/dqs/report/report.go | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/dqs/stats/bmi.go | 17+++++++++++++++++
Apkg/dqs/stats/stats.go | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/dqs/store/entries.go | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/dqs/store/store.go | 27+++++++++++++++++++++++++++
Apkg/dqs/store/store_test.go | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/dqs/store/user.go | 42++++++++++++++++++++++++++++++++++++++++++
Apkg/dqs/user/format.go | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/dqs/user/units.go | 40++++++++++++++++++++++++++++++++++++++++
Apkg/dqs/user/units/convert.go | 17+++++++++++++++++
Apkg/dqs/user/units/units.go | 46++++++++++++++++++++++++++++++++++++++++++++++
Apkg/dqs/user/user.go | 40++++++++++++++++++++++++++++++++++++++++
56 files changed, 3025 insertions(+), 0 deletions(-)

diff --git a/cmd/dqs/config/config.go b/cmd/dqs/config/config.go @@ -0,0 +1,76 @@ +package config + +import ( + "flag" + "fmt" + "os" + "time" + + "code.dwrz.net/src/pkg/dqs/entry" +) + +const ( + defaultDir = "" + dirUsage = "the path to the dqs directory" + + defaultDate = "" + dateUsage = "the entry date to use, formatted as YYYYMMDD" +) + +type Config struct { + Date time.Time + Dir string +} + +func Get() (*Config, error) { + var ( + cfg = &Config{} + date string + ) + + flag.StringVar(&cfg.Dir, "dir", defaultDir, dirUsage) + flag.StringVar(&date, "d", defaultDate, dateUsage) + flag.StringVar(&date, "date", defaultDate, dateUsage) + + flag.Parse() + + // If unset, use the default directory. + if cfg.Dir == "" { + dir, err := DefaultDirectory() + if err != nil { + return nil, err + } + + cfg.Dir = dir + } + + // Parse and set the date. + // If unset, use the current date. + switch date { + case defaultDate: + now := time.Now() + cfg.Date = time.Date( + now.Year(), now.Month(), now.Day(), + 0, 0, 0, 0, time.Local, + ) + default: + var err error + cfg.Date, err = time.Parse(entry.DateFormat, date) + if err != nil { + return nil, fmt.Errorf("invalid date: %w", err) + } + } + + return cfg, nil +} + +func DefaultDirectory() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf( + "failed to get user config dir: %w", err, + ) + } + + return fmt.Sprintf("%s/dqs", dir), nil +} diff --git a/cmd/dqs/dqs.go b/cmd/dqs/dqs.go @@ -0,0 +1,29 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "code.dwrz.net/src/cmd/dqs/config" + "code.dwrz.net/src/pkg/dqs" +) + +func main() { + cfg, err := config.Get() + if err != nil { + fmt.Fprintf(os.Stderr, "bad config: %v\n", err) + return + } + + app, err := dqs.New(dqs.Config{Dir: cfg.Dir}) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return + } + + if err := app.Run(flag.Args(), cfg.Date); err != nil { + fmt.Fprintln(os.Stderr, err) + return + } +} diff --git a/pkg/dqs/app.go b/pkg/dqs/app.go @@ -0,0 +1,50 @@ +package dqs + +import ( + "time" + + "code.dwrz.net/src/pkg/dqs/command" + "code.dwrz.net/src/pkg/dqs/store" +) + +type App struct { + store *store.Store +} + +func New(cfg Config) (*App, error) { + if err := cfg.Validate(); err != nil { + return nil, err + } + + store, err := store.Open(cfg.Dir) + if err != nil { + return nil, err + } + + return &App{ + store: store, + }, nil +} + +func (a *App) Run(args []string, date time.Time) error { + params := command.Parameters{ + Args: args, + Date: date, + Store: a.store, + } + + // If no arguments were provided, print the entry. + if len(args) == 0 { + return command.Entry.Execute(params) + } + + // Process the command. + params.Args = args[1:] + + cmd, err := command.Match(args[0]) + if err != nil { + return err + } + + return cmd.Execute(params) +} diff --git a/pkg/dqs/category/abbreviations.go b/pkg/dqs/category/abbreviations.go @@ -0,0 +1,22 @@ +package category + +var Abbreviations = map[string]string{ + // High Quality + "d": Dairy.Name, + "f": Fruit.Name, + "hqb": HQBeverages.Name, + "hqpf": HQProcessedFoods.Name, + "lpp": LegumesPlantProteins.Name, + "nsh": NutsSeedsHealthyOils.Name, + "ums": UnprocessedMeatSeafood.Name, + "v": Vegetables.Name, + "wg": WholeGrains.Name, + + // Low Quality + "ff": FriedFoods.Name, + "lqb": LQBeverages.Name, + "o": Other.Name, + "p": ProcessedMeat.Name, + "rg": RefinedGrains.Name, + "s": Sweets.Name, +} diff --git a/pkg/dqs/category/category.go b/pkg/dqs/category/category.go @@ -0,0 +1,115 @@ +package category + +import ( + "fmt" + + "code.dwrz.net/src/pkg/color" + "code.dwrz.net/src/pkg/dqs/portion" +) + +type Category struct { + Name string `json:"name"` + Portions []portion.Portion `json:"portions"` + HighQuality bool `json:"highQuality"` +} + +func (c *Category) Add(q float64) error { + for i := range c.Portions { + p := &c.Portions[i] + // Done if there's less than a half-portion left. + if q < 0.5 { + break + } + + switch p.Amount { + case portion.Full: + continue + case portion.Half: + p.Amount = portion.Full + q -= 0.5 + case portion.None: + if q >= 1.0 { + p.Amount = portion.Full + q -= 1.0 + } else if q >= 0.5 { + p.Amount = portion.Half + q -= 0.5 + } + } + } + + // Check if we were able to add all the portions. + if q > 0.5 { + return fmt.Errorf("too many portions of %s", c.Name) + } + + return nil +} + +func (c *Category) Score() (total float64) { + for _, p := range c.Portions { + total += p.Score() + } + + return total +} + +func (c *Category) FormatPrint() string { + var str = fmt.Sprintf("│%-26s│", c.Name) + + for _, p := range c.Portions { + var cellColor color.Color + + switch { + case p.Points > 0 && p.Amount == portion.Full: + cellColor = color.BackgroundBrightGreen + case p.Points > 0 && p.Amount == portion.Half: + cellColor = color.BrightGreen + case p.Points == 0 && p.Amount == portion.Full: + cellColor = color.BackgroundBrightYellow + case p.Points == 0 && p.Amount == portion.Half: + cellColor = color.BrightYellow + case p.Points < 0 && p.Amount == portion.Full: + cellColor = color.BackgroundBrightRed + case p.Points < 0 && p.Amount == portion.Half: + cellColor = color.BrightRed + } + + str += fmt.Sprintf("%s%+d%s│", cellColor, p.Points, color.Reset) + } + + return str +} + +func (c *Category) Remove(q float64) error { + for i := len(c.Portions) - 1; i >= 0; i-- { + p := &c.Portions[i] + // Done if there's less than a half-portion left. + if q < 0.5 { + break + } + + switch p.Amount { + case portion.None: + continue + case portion.Half: + p.Amount = portion.None + q -= 0.5 + case portion.Full: + if q >= 1.0 { + p.Amount = portion.None + q -= 1.0 + } else if q >= 0.5 { + p.Amount = portion.Half + q -= 0.5 + } + } + } + + // Check if we were able to remove all the portions. + if q > 0.5 { + return fmt.Errorf("too many portions of %s", c.Name) + } + + return nil +} diff --git a/pkg/dqs/category/high-quality.go b/pkg/dqs/category/high-quality.go @@ -0,0 +1,122 @@ +package category + +import "code.dwrz.net/src/pkg/dqs/portion" + +var ( + Dairy = Category{ + Name: "Dairy", + Portions: []portion.Portion{ + {Points: 2}, + {Points: 1}, + {Points: 1}, + {Points: 0}, + {Points: -1}, + {Points: -2}, + }, + HighQuality: true, + } + + Fruit = Category{ + Name: "Fruit", + Portions: []portion.Portion{ + {Points: 2}, + {Points: 2}, + {Points: 2}, + {Points: 1}, + {Points: 0}, + {Points: 0}, + }, + HighQuality: true, + } + + HQBeverages = Category{ + Name: "High Quality Beverages", + Portions: []portion.Portion{ + {Points: 1}, + {Points: 1}, + {Points: 0}, + {Points: 0}, + {Points: -1}, + {Points: -2}, + }, + HighQuality: true, + } + + HQProcessedFoods = Category{ + Name: "Processed Foods", + Portions: []portion.Portion{ + {Points: 1}, + {Points: 0}, + {Points: -1}, + {Points: -2}, + {Points: -2}, + {Points: -2}, + }, + HighQuality: true, + } + + LegumesPlantProteins = Category{ + Name: "Legumes & Plant Proteins", + Portions: []portion.Portion{ + {Points: 2}, + {Points: 2}, + {Points: 1}, + {Points: 0}, + {Points: -1}, + {Points: -1}, + }, + HighQuality: true, + } + + NutsSeedsHealthyOils = Category{ + Name: "Nuts, Seeds, Healthy Oils", + Portions: []portion.Portion{ + {Points: 2}, + {Points: 2}, + {Points: 1}, + {Points: 0}, + {Points: 0}, + {Points: -1}, + }, + HighQuality: true, + } + + UnprocessedMeatSeafood = Category{ + Name: "Unprocessed Meat & Seafood", + Portions: []portion.Portion{ + {Points: 2}, + {Points: 1}, + {Points: 1}, + {Points: 0}, + {Points: -1}, + {Points: -2}, + }, + HighQuality: true, + } + + Vegetables = Category{ + Name: "Vegetables", + Portions: []portion.Portion{ + {Points: 2}, + {Points: 2}, + {Points: 2}, + {Points: 1}, + {Points: 0}, + {Points: 0}, + }, + HighQuality: true, + } + + WholeGrains = Category{ + Name: "Whole Grains", + Portions: []portion.Portion{ + {Points: 2}, + {Points: 2}, + {Points: 1}, + {Points: 0}, + {Points: 0}, + {Points: -1}, + }, + HighQuality: true, + } +) diff --git a/pkg/dqs/category/low-quality.go b/pkg/dqs/category/low-quality.go @@ -0,0 +1,84 @@ +package category + +import dqs "code.dwrz.net/src/pkg/dqs/portion" + +// Low Quality +var ( + FriedFoods = Category{ + Name: "Fried Foods", + Portions: []dqs.Portion{ + {Points: -2}, + {Points: -2}, + {Points: -2}, + {Points: -2}, + {Points: -2}, + {Points: -2}, + }, + HighQuality: false, + } + + LQBeverages = Category{ + Name: "Low Quality Beverages", + Portions: []dqs.Portion{ + {Points: -2}, + {Points: -2}, + {Points: -2}, + {Points: -2}, + {Points: -2}, + {Points: -2}, + }, + HighQuality: false, + } + + Other = Category{ + Name: "Other", + Portions: []dqs.Portion{ + {Points: -1}, + {Points: -2}, + {Points: -2}, + {Points: -2}, + {Points: -2}, + {Points: -2}, + }, + HighQuality: false, + } + + ProcessedMeat = Category{ + Name: "Processed Meat", + Portions: []dqs.Portion{ + {Points: -2}, + {Points: -2}, + {Points: -2}, + {Points: -2}, + {Points: -2}, + {Points: -2}, + }, + HighQuality: false, + } + + RefinedGrains = Category{ + Name: "Refined Grains", + Portions: []dqs.Portion{ + {Points: -1}, + {Points: -1}, + {Points: -2}, + {Points: -2}, + {Points: -2}, + {Points: -2}, + }, + HighQuality: false, + } + + Sweets = Category{ + Name: "Sweets", + Portions: []dqs.Portion{ + {Points: -2}, + {Points: -2}, + {Points: -2}, + {Points: -2}, + {Points: -2}, + {Points: -2}, + }, + HighQuality: false, + } +) diff --git a/pkg/dqs/command/add.go b/pkg/dqs/command/add.go @@ -0,0 +1,75 @@ +package command + +import ( + "errors" + "fmt" + "os" + "time" + + "code.dwrz.net/src/pkg/dqs/command/help" + "code.dwrz.net/src/pkg/dqs/entry" + "code.dwrz.net/src/pkg/dqs/portion" + "code.dwrz.net/src/pkg/dqs/store" +) + +var Add = &command{ + execute: addPortions, + + description: "add portions to an entry", + help: help.Add, + name: "add", +} + +func addPortions(args []string, date time.Time, store *store.Store) error { + u, err := store.GetUser() + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("failed to get user: %w", err) + } + if u == nil { + return Config.execute(args, date, store) + } + + e, err := store.GetEntry(date.Format(entry.DateFormat)) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("failed to get entry: %w", err) + } + if e == nil { + e = entry.New(date, u) + } + + if len(args) < 2 { + return fmt.Errorf("missing category and quantity") + } + if len(args)%2 != 0 { + return fmt.Errorf("uneven number of arguments") + } + + for i := 0; i < len(args); i += 2 { + portionCategory := args[i] + quantity := args[i+1] + + c, err := e.Category(portionCategory) + if err != nil { + return err + } + + q, err := portion.Parse(quantity) + if err != nil { + return err + } + + if err := c.Add(q); err != nil { + return fmt.Errorf( + "failed to add portions: %w", err, + ) + } + } + + if err := store.UpdateEntry(e); err != nil { + return fmt.Errorf("failed to store entry: %w", err) + } + + fmt.Println(e.FormatPrint(u)) + + return nil +} diff --git a/pkg/dqs/command/body-fat.go b/pkg/dqs/command/body-fat.go @@ -0,0 +1,68 @@ +package command + +import ( + "errors" + "fmt" + "os" + "strconv" + "time" + + "code.dwrz.net/src/pkg/dqs/command/help" + "code.dwrz.net/src/pkg/dqs/entry" + "code.dwrz.net/src/pkg/dqs/store" +) + +var BodyFat = &command{ + execute: setBodyFat, + + description: "set the user's body fat percentage on an entry", + help: help.BodyFat, + name: "body-fat", +} + +func setBodyFat(args []string, date time.Time, store *store.Store) error { + u, err := store.GetUser() + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("failed to get user: %w", err) + } + if u == nil { + return Config.execute(args, date, store) + } + + e, err := store.GetEntry(date.Format(entry.DateFormat)) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("failed to get entry: %w", err) + } + if e == nil { + e = entry.New(date, u) + } + + if len(args) == 0 { + return fmt.Errorf("missing body fat percentage") + } + + bf, err := strconv.ParseFloat(args[0], 64) + if err != nil { + return err + } + + e.BodyFat = bf + + if err := store.UpdateEntry(e); err != nil { + return fmt.Errorf("failed to store entry: %w", err) + } + + // If the entry is for today, update the user. + var currentDate = time.Now().Format(entry.DateFormat) + if currentDate == date.Format(entry.DateFormat) { + u.BodyFat = bf + + if err := store.UpdateUser(u); err != nil { + return fmt.Errorf("failed to store user: %w", err) + } + } + + fmt.Println(u.FormatPrint()) + + return nil +} diff --git a/pkg/dqs/command/command.go b/pkg/dqs/command/command.go @@ -0,0 +1,53 @@ +package command + +import ( + "fmt" + "time" + + "code.dwrz.net/src/pkg/dqs/store" +) + +type command struct { + execute func(args []string, date time.Time, store *store.Store) error + + description string + help func() string + name string +} + +func (c command) Execute(p Parameters) error { + if err := p.Validate(); err != nil { + return err + } + + return c.execute(p.Args, p.Date, p.Store) +} + +var commands []*command + +func init() { + commands = []*command{ + Add, + BodyFat, + Config, + Delete, + Entry, + Export, + Help, + Note, + Remove, + Report, + User, + Weight, + } +} + +func Match(name string) (*command, error) { + for _, command := range commands { + if name == command.name { + return command, nil + } + } + + return nil, fmt.Errorf("unrecognized command: %s", name) +} diff --git a/pkg/dqs/command/config.go b/pkg/dqs/command/config.go @@ -0,0 +1,203 @@ +package command + +import ( + "bufio" + "errors" + "fmt" + "os" + "strconv" + "strings" + "time" + + "code.dwrz.net/src/pkg/color" + "code.dwrz.net/src/pkg/dqs/command/help" + "code.dwrz.net/src/pkg/dqs/diet" + "code.dwrz.net/src/pkg/dqs/store" + "code.dwrz.net/src/pkg/dqs/user" + "code.dwrz.net/src/pkg/dqs/user/units" +) + +var Config = &command{ + execute: configure, + + description: "configure the user settings", + help: help.Config, + name: "config", +} + +func configure(args []string, date time.Time, store *store.Store) error { + u, err := store.GetUser() + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + if u == nil { + u = &user.DefaultUser + } + + fmt.Printf( + "%sPress enter to skip a field.%s\n", + color.Italic, color.Reset, + ) + + var in = bufio.NewReader(os.Stdin) + + // Parse the name. + fmt.Print("Name: ") + line, err := in.ReadString('\n') + if err != nil { + return err + } + + if line = strings.TrimSpace(line); line != "" { + u.Name = line + } + + // Parse the birthday. + fmt.Print("Birthday (YYYYMMDD): ") + line, err = in.ReadString('\n') + if err != nil { + return err + } + + if line = strings.TrimSpace(line); line != "" { + birthday, err := time.Parse("20060102", line) + if err != nil { + return err + } + + u.Birthday = birthday + } + + // Parse the units. + fmt.Print("Units (imperial|metric): ") + line, err = in.ReadString('\n') + if err != nil { + return err + } + + if line = strings.TrimSpace(line); line != "" { + system := units.System(line) + + if !units.Valid(system) { + return fmt.Errorf("invalid units") + } + + u.Units = system + } + + // Parse the height. + fmt.Print(fmt.Sprintf("Height (%s): ", u.Units.Height())) + line, err = in.ReadString('\n') + if err != nil { + return err + } + + if line = strings.TrimSpace(line); line != "" { + height, err := strconv.ParseFloat(line, 64) + if err != nil { + return err + } + if height < 0 { + return fmt.Errorf("invalid height") + } + + // Store all units in metric. + if u.Units == units.Imperial { + height = units.InchesToCentimeter(height) + } + + u.Height = height + } + + // Parse the weight. + fmt.Print(fmt.Sprintf("Weight (%s): ", u.Units.Weight())) + line, err = in.ReadString('\n') + if err != nil { + return err + } + + if line = strings.TrimSpace(line); line != "" { + weight, err := strconv.ParseFloat(line, 64) + if err != nil { + return err + } + if weight < 0 { + return fmt.Errorf("invalid weight") + } + + // Store all units in metric. + if u.Units == units.Imperial { + weight = units.PoundsToKilogram(weight) + } + + u.Weight = weight + } + + // Parse the body fat. + fmt.Print("Body Fat (%): ") + line, err = in.ReadString('\n') + if err != nil { + return err + } + + if line = strings.TrimSpace(line); line != "" { + bf, err := strconv.ParseFloat(line, 64) + if err != nil { + return err + } + + if bf < 0 || bf > 100 { + return fmt.Errorf("invalid body fat percentage") + } + + u.BodyFat = bf + } + + // Parse the diet. + fmt.Print("Diet (omnivore|vegan|vegetarian): ") + line, err = in.ReadString('\n') + if err != nil { + return err + } + + if line = strings.TrimSpace(line); line != "" { + if !diet.Valid(diet.Diet(line)) { + return fmt.Errorf("unrecognized diet") + } + + u.Diet = diet.Diet(line) + } + + // Parse the Target Weight. + fmt.Print(fmt.Sprintf("Target Weight (%s): ", u.Units.Weight())) + line, err = in.ReadString('\n') + if err != nil { + return err + } + + if line = strings.TrimSpace(line); line != "" { + tw, err := strconv.ParseFloat(line, 64) + if err != nil { + return err + } + + if tw < 0 { + return fmt.Errorf("invalid target weight") + } + + // Store all units in metric. + if u.Units == units.Imperial { + tw = units.PoundsToKilogram(tw) + } + + u.TargetWeight = tw + } + + if err := store.UpdateUser(u); err != nil { + return fmt.Errorf( + "failed to store user: %w", err, + ) + } + + return nil +} diff --git a/pkg/dqs/command/delete.go b/pkg/dqs/command/delete.go @@ -0,0 +1,26 @@ +package command + +import ( + "fmt" + "time" + + "code.dwrz.net/src/pkg/dqs/command/help" + "code.dwrz.net/src/pkg/dqs/entry" + "code.dwrz.net/src/pkg/dqs/store" +) + +var Delete = &command{ + execute: func(args []string, date time.Time, store *store.Store) error { + id := date.Format(entry.DateFormat) + + if err := store.DeleteEntry(id); err != nil { + return fmt.Errorf("failed to delete entry: %w", err) + } + + return nil + }, + + description: "delete an entry", + help: help.Delete, + name: "delete", +} diff --git a/pkg/dqs/command/entry.go b/pkg/dqs/command/entry.go @@ -0,0 +1,40 @@ +package command + +import ( + "errors" + "fmt" + "os" + "time" + + "code.dwrz.net/src/pkg/dqs/command/help" + "code.dwrz.net/src/pkg/dqs/entry" + "code.dwrz.net/src/pkg/dqs/store" +) + +var Entry = &command{ + execute: func(args []string, date time.Time, store *store.Store) error { + u, err := store.GetUser() + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("failed to get user: %w", err) + } + if u == nil { + return Config.execute(args, date, store) + } + + e, err := store.GetEntry(date.Format(entry.DateFormat)) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("failed to get entry: %w", err) + } + if e == nil { + e = entry.New(date, u) + } + + fmt.Println(e.FormatPrint(u)) + + return nil + }, + + description: "display a date's entry (default)", + help: help.Entry, + name: "entry", +} diff --git a/pkg/dqs/command/export.go b/pkg/dqs/command/export.go @@ -0,0 +1,40 @@ +package command + +import ( + "fmt" + "strings" + "time" + + "code.dwrz.net/src/pkg/dqs/command/help" + "code.dwrz.net/src/pkg/dqs/store" +) + +var Export = &command{ + execute: func(args []string, date time.Time, store *store.Store) error { + entries, err := store.GetAllEntries() + if err != nil { + return err + } + + var str strings.Builder + str.WriteString("date,body-fat,dqs,weight,\n") + + for _, e := range entries { + str.WriteString(fmt.Sprintf( + "%s,%.2f,%.1f,%.2f,\n", + e.Date.Format("20060102"), + e.BodyFat, + e.Score(), + e.Weight, + )) + } + + fmt.Println(str.String()) + + return nil + }, + + description: "export entries to csv", + help: help.Export, + name: "export", +} diff --git a/pkg/dqs/command/help.go b/pkg/dqs/command/help.go @@ -0,0 +1,63 @@ +package command + +import ( + "fmt" + "strings" + "time" + + "code.dwrz.net/src/pkg/dqs/command/help" + "code.dwrz.net/src/pkg/dqs/store" +) + +const ( + helpIntro = `dqs — diet quality score + +Usage: + dqs [flags] <command> [arguments] + + +Run dqs -help or dqs -h to see information on available flags. + +The following commands are available: + +` + helpMore = "Run dqs help <command> to see the command's documentation." +) + +var Help = &command{ + execute: showHelp, + + description: "show built-in command documentation", + help: help.Help, + name: "help", +} + +func showHelp(args []string, date time.Time, store *store.Store) error { + if len(args) == 0 { + var str strings.Builder + + str.WriteString(helpIntro) + + for _, c := range commands { + str.WriteString( + fmt.Sprintf("%-11s%s\n", c.name, c.description), + ) + } + + str.WriteString("\n") + str.WriteString(helpMore) + + fmt.Println(str.String()) + + return nil + } + + cmd, err := Match(args[0]) + if err != nil { + return err + } + + fmt.Println(cmd.help()) + + return nil +} diff --git a/pkg/dqs/command/help/add.go b/pkg/dqs/command/help/add.go @@ -0,0 +1,34 @@ +package help + +import ( + "fmt" + "sort" + "strings" + + "code.dwrz.net/src/pkg/dqs/category" +) + +var add = strings.ReplaceAll(portions, "%s", "add") + +func Add() string { + var str strings.Builder + + str.WriteString(add) + + var abbreviations = [][2]string{} + for abbreviation, name := range category.Abbreviations { + abbreviations = append(abbreviations, [2]string{ + name, abbreviation, + }) + } + + sort.Slice(abbreviations, func(i, j int) bool { + return abbreviations[i][0] < abbreviations[j][0] + }) + + for _, a := range abbreviations { + str.WriteString(fmt.Sprintf("%-5s %s\n", a[1], a[0])) + } + + return str.String() +} diff --git a/pkg/dqs/command/help/body-fat.go b/pkg/dqs/command/help/body-fat.go @@ -0,0 +1,24 @@ +package help + +import "strings" + +const bodyFat = `The body-fat command sets the body fat percentage on an entry. + +It accepts a single argument, a number which represents the body fat +percentage. The number may have a decimal component. + +If the date used is the current date, your user data will also be +updated to use the body fat percentage. This will default future entries +to the inputted percentage. + +Example: +dqs body-fat 24.25 +` + +func BodyFat() string { + var str strings.Builder + + str.WriteString(bodyFat) + + return str.String() +} diff --git a/pkg/dqs/command/help/config.go b/pkg/dqs/command/help/config.go @@ -0,0 +1,33 @@ +package help + +import "strings" + +const config = `The config command is used to configure dqs. + +It allows for entry of the following fields: + +Name +Birthday +Units (imperial or metric*) +Height +Weight +Body Fat Percentage +Diet (omnivore, vegan, or vegetarian*) +Target Weight + +All fields are optional. Units default to metric; the default diet is +vegetarian. + +This command will be invoked automatically the first time dqs is run. + +Example: +dqs config +` + +func Config() string { + var str strings.Builder + + str.WriteString(config) + + return str.String() +} diff --git a/pkg/dqs/command/help/delete.go b/pkg/dqs/command/help/delete.go @@ -0,0 +1,26 @@ +package help + +import "strings" + +const deleteEntry = `The delete command deletes an entry. + +The entry date is specified via the -date flag (run dqs -help to read +more about the flag). + +There are no arguments. + +There is no way to undo a deletion. + +The command returns an error if no entry exists for the specified date. + +Example: +dqs -date 20210620 delete +` + +func Delete() string { + var str strings.Builder + + str.WriteString(deleteEntry) + + return str.String() +} diff --git a/pkg/dqs/command/help/entry.go b/pkg/dqs/command/help/entry.go @@ -0,0 +1,25 @@ +package help + +import "strings" + +const entry = `The entry command displays the date's entry. + +It is the default command run, if no command is specified. + +The entry date is specified via the -date flag (run dqs -help to read +more about the flag). + +Example: +dqs entry + +The command is equivalent to: +dqs +` + +func Entry() string { + var str strings.Builder + + str.WriteString(entry) + + return str.String() +} diff --git a/pkg/dqs/command/help/export.go b/pkg/dqs/command/help/export.go @@ -0,0 +1,21 @@ +package help + +import "strings" + +const export = `The export command prints statistics from all entries. + +The columns are date, body-fat, diet quality score, and weight. + +The export format is Comma Separate Values (CSV). + +Example: +dqs export +` + +func Export() string { + var str strings.Builder + + str.WriteString(export) + + return str.String() +} diff --git a/pkg/dqs/command/help/help.go b/pkg/dqs/command/help/help.go @@ -0,0 +1,39 @@ +package help + +import "strings" + +const help = `dqs is designed to help you keep track of your Diet +Quality Score, as well as your progress on reaching a target weight. + +The typical use case is to use the *add* command to keep track of +portions consumed. By default the application uses the current day, but +if you need to catch up on a past date, the -d or -date flags can be +used to refer to a past date or entry. For example, if you want to add +two fruit portions for today, you can run: + +dqs add fruit 2 + +To do the same for a past date -- for example, August 8, 2008 -- you +can run: + +dqs -date 20080808 fruit 2 + +or + +dqs -d 20080808 fruit 2 + +Refer to dqs help add for more information on the *add* command. + +Weight and body fat may be tracked with the body-fat and weight +commands. + +To check on progress towards goals, use the dqs report command. +` + +func Help() string { + var str strings.Builder + + str.WriteString(help) + + return str.String() +} diff --git a/pkg/dqs/command/help/note.go b/pkg/dqs/command/help/note.go @@ -0,0 +1,33 @@ +package help + +import "strings" + +const note = `The note command is used to set a note on an entry. + +There are four subcommands available: + +append — used to add text to a note. + +delete — used to delete a note. + +edit — used to edit a note with a text editor (specified by the EDITOR +environment variable). + +set — used to set (or overwrite) a note. + +If not subcommand is specified, the entry's note is displayed. + +Examples: +dqs note append Hello World +dqs -date 20111111 note delete +dqs note edit +dqs note set This is a note. +` + +func Note() string { + var str strings.Builder + + str.WriteString(note) + + return str.String() +} diff --git a/pkg/dqs/command/help/portions.go b/pkg/dqs/command/help/portions.go @@ -0,0 +1,32 @@ +package help + +const portions = `The %s command %ss portions to an entry. + +It accepts pairs of arguments -- a category name followed by a portion +amount. + +For example: +dqs %s fruit 1 + +This will %s one portion to the Fruit category. + +It is possible to specify a half portion, e.g.: +dqs %s vegetables 2.5 + +Will %s 2½ portions to the Vegetable category. + +It is also possible to specify multiple pairs of arguments, e.g.: +dqs %s dairy 1 "Whole Grains" 2 + +This will %s 1 dairy portion, and 2 whole grain portions to an entry. +Note that category names may be cased in any manner, but names with +a space in them must be quoted, as in the "Whole Grains" example above. + +To reduce typing, abbreviations are available for category names, e.g.: +dqs %s d 1 hqb 1 v 2 wg 1.5 + +Will %s 1 portion of dairy, 1 portion of high quality beverages, 2 +portions of vegetables, and 1½ portions of whole grains. + +The following abbreviations are available: +` diff --git a/pkg/dqs/command/help/remove.go b/pkg/dqs/command/help/remove.go @@ -0,0 +1,34 @@ +package help + +import ( + "fmt" + "sort" + "strings" + + "code.dwrz.net/src/pkg/dqs/category" +) + +var remove = strings.ReplaceAll(portions, "%s", "remove") + +func Remove() string { + var str strings.Builder + + str.WriteString(remove) + + var abbreviations = [][2]string{} + for abbreviation, name := range category.Abbreviations { + abbreviations = append(abbreviations, [2]string{ + name, abbreviation, + }) + } + + sort.Slice(abbreviations, func(i, j int) bool { + return abbreviations[i][0] < abbreviations[j][0] + }) + + for _, a := range abbreviations { + str.WriteString(fmt.Sprintf("%-5s %s\n", a[1], a[0])) + } + + return str.String() +} diff --git a/pkg/dqs/command/help/report.go b/pkg/dqs/command/help/report.go @@ -0,0 +1,25 @@ +package help + +import "strings" + +const report = `The report command displays a report on your statistics +and usage. + +The command will display the statistics on the following: + +Entries +Body Fat +DQS +Weight + +It will also display recommendations as to which food categories you +should eat more or less of, given past entries. +` + +func Report() string { + var str strings.Builder + + str.WriteString(report) + + return str.String() +} diff --git a/pkg/dqs/command/help/user.go b/pkg/dqs/command/help/user.go @@ -0,0 +1,20 @@ +package help + +import "strings" + +const user = `The user command displays your user data. + +This command may be used to confirm settings -- such as diet -- or the +user's current measurements -- such as height and weight. + +Example: +dqs user +` + +func User() string { + var str strings.Builder + + str.WriteString(user) + + return str.String() +} diff --git a/pkg/dqs/command/help/weight.go b/pkg/dqs/command/help/weight.go @@ -0,0 +1,21 @@ +package help + +import "strings" + +const weight = `The weight command sets your weight on an entry. + +It accepts a single argument, a number which represents your weight. +The number may have a decimal component. + +If the date used is the current date, your user data will also be updated. +This will default future entries to the inputted weight. + +Example: +dqs weight 100.00 +` + +func Weight() string { + var str strings.Builder + + return str.String() +} diff --git a/pkg/dqs/command/note.go b/pkg/dqs/command/note.go @@ -0,0 +1,139 @@ +package command + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + "time" + + "code.dwrz.net/src/pkg/dqs/command/help" + "code.dwrz.net/src/pkg/dqs/entry" + "code.dwrz.net/src/pkg/dqs/store" +) + +var Note = &command{ + execute: setNote, + + description: "set a note on an entry", + help: help.Note, + name: "note", +} + +func setNote(args []string, date time.Time, store *store.Store) error { + u, err := store.GetUser() + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("failed to get user: %w", err) + } + if u == nil { + return Config.execute(args, date, store) + } + + e, err := store.GetEntry(date.Format(entry.DateFormat)) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("failed to get entry: %w", err) + } + if e == nil { + e = entry.New(date, u) + } + + if len(args) == 0 { + fmt.Println(e.Note) + + return nil + } + + update, args := args[0], args[1:] + switch update { + case "append": + if len(args) >= 1 { + if len(e.Note) == 0 { + e.Note = strings.Join(args, " ") + } else { + e.Note = fmt.Sprintf( + "%s\n%s", e.Note, + strings.Join(args, " "), + ) + } + } + + case "delete": + e.Note = "" + + case "edit": + var err error + e.Note, err = editNote(e.Note) + if err != nil { + return err + } + + case "set": + if len(args) >= 1 { + e.Note = strings.Join(args, " ") + } + + default: + fmt.Println(e.Note) + } + + if err := store.UpdateEntry(e); err != nil { + return fmt.Errorf("failed to store entry: %w", err) + } + + return nil +} + +func editNote(note string) (string, error) { + // If no note is specified, take input from the user's editor. + // Write the entry to the temporary file. + temp, err := os.CreateTemp(os.TempDir(), "dqs-*") + if err != nil { + return "", err + } + defer temp.Close() + + _, err = temp.Write([]byte(note)) + if err != nil { + return "", err + } + + editor := os.Getenv("EDITOR") + + args := strings.Split(editor, " ") + args = append(args, temp.Name()) + + path, err := exec.LookPath(args[0]) + if err != nil { + return "", fmt.Errorf( + "failed to find $EDITOR %s: %w", editor, err, + ) + } + + cmd := exec.Cmd{ + Path: path, + Args: args, + Stdin: os.Stdin, + Stdout: os.Stdout, + Stderr: os.Stderr, + } + if err := cmd.Run(); err != nil { + return "", err + } + + if _, err := temp.Seek(0, 0); err != nil { + return "", err + } + + data, err := io.ReadAll(temp) + if err != nil { + return "", err + } + + if err := os.Remove(temp.Name()); err != nil { + return "", err + } + + return string(data), nil +} diff --git a/pkg/dqs/command/parameters.go b/pkg/dqs/command/parameters.go @@ -0,0 +1,25 @@ +package command + +import ( + "fmt" + "time" + + "code.dwrz.net/src/pkg/dqs/store" +) + +type Parameters struct { + Args []string + Date time.Time + Store *store.Store +} + +func (p *Parameters) Validate() error { + if p.Args == nil { + return fmt.Errorf("missing arguments") + } + if p.Store == nil { + return fmt.Errorf("missing store") + } + + return nil +} diff --git a/pkg/dqs/command/remove.go b/pkg/dqs/command/remove.go @@ -0,0 +1,75 @@ +package command + +import ( + "errors" + "fmt" + "os" + "time" + + "code.dwrz.net/src/pkg/dqs/command/help" + "code.dwrz.net/src/pkg/dqs/entry" + "code.dwrz.net/src/pkg/dqs/portion" + "code.dwrz.net/src/pkg/dqs/store" +) + +var Remove = &command{ + execute: removePortions, + + description: "remove portions from an entry", + help: help.Remove, + name: "remove", +} + +func removePortions(args []string, date time.Time, store *store.Store) error { + u, err := store.GetUser() + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("failed to get user: %w", err) + } + if u == nil { + return Config.execute(args, date, store) + } + + e, err := store.GetEntry(date.Format(entry.DateFormat)) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("failed to get entry: %w", err) + } + if e == nil { + e = entry.New(date, u) + } + + if len(args) < 2 { + return fmt.Errorf("missing category and quantity") + } + if len(args)%2 != 0 { + return fmt.Errorf("uneven number of arguments") + } + + for i := 0; i < len(args); i += 2 { + portionCategory := args[i] + quantity := args[i+1] + + c, err := e.Category(portionCategory) + if err != nil { + return err + } + + q, err := portion.Parse(quantity) + if err != nil { + return err + } + + if err := c.Remove(q); err != nil { + return fmt.Errorf( + "failed to remove portions: %w", err, + ) + } + } + + if err := store.UpdateEntry(e); err != nil { + return fmt.Errorf("failed to store entry: %w", err) + } + + fmt.Println(e.FormatPrint(u)) + + return nil +} diff --git a/pkg/dqs/command/report.go b/pkg/dqs/command/report.go @@ -0,0 +1,27 @@ +package command + +import ( + "fmt" + "time" + + "code.dwrz.net/src/pkg/dqs/command/help" + "code.dwrz.net/src/pkg/dqs/report" + "code.dwrz.net/src/pkg/dqs/store" +) + +var Report = &command{ + execute: func(args []string, date time.Time, store *store.Store) error { + entries, err := store.GetAllEntries() + if err != nil { + return err + } + + fmt.Println(report.New(entries).Format()) + + return nil + }, + + description: "report user and entry statistics", + help: help.Report, + name: "report", +} diff --git a/pkg/dqs/command/user.go b/pkg/dqs/command/user.go @@ -0,0 +1,31 @@ +package command + +import ( + "errors" + "fmt" + "os" + "time" + + "code.dwrz.net/src/pkg/dqs/command/help" + "code.dwrz.net/src/pkg/dqs/store" +) + +var User = &command{ + execute: func(args []string, date time.Time, store *store.Store) error { + u, err := store.GetUser() + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("failed to get user: %w", err) + } + if u == nil { + return Config.execute(args, date, store) + } + + fmt.Println(u.FormatPrint()) + + return nil + }, + + description: "display user data and settings", + help: help.User, + name: "user", +} diff --git a/pkg/dqs/command/weight.go b/pkg/dqs/command/weight.go @@ -0,0 +1,74 @@ +package command + +import ( + "errors" + "fmt" + "os" + "strconv" + "time" + + "code.dwrz.net/src/pkg/dqs/command/help" + "code.dwrz.net/src/pkg/dqs/entry" + "code.dwrz.net/src/pkg/dqs/store" + "code.dwrz.net/src/pkg/dqs/user/units" +) + +var Weight = &command{ + execute: setWeight, + + description: "set the user's weight on an entry", + help: help.Weight, + name: "weight", +} + +func setWeight(args []string, date time.Time, store *store.Store) error { + u, err := store.GetUser() + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("failed to get user: %w", err) + } + if u == nil { + return Config.execute(args, date, store) + } + + e, err := store.GetEntry(date.Format(entry.DateFormat)) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("failed to get entry: %w", err) + } + if e == nil { + e = entry.New(date, u) + } + + if len(args) == 0 { + return fmt.Errorf("missing weight") + } + + w, err := strconv.ParseFloat(args[0], 64) + if err != nil { + return err + } + + // Store all units in metric. + if u.Units == units.Imperial { + w = units.PoundsToKilogram(w) + } + + e.Weight = w + + if err := store.UpdateEntry(e); err != nil { + return fmt.Errorf("failed to store entry: %w", err) + } + + // If the entry is for today, update the user. + var currentDate = time.Now().Format(entry.DateFormat) + if currentDate == date.Format(entry.DateFormat) { + u.Weight = w + + if err := store.UpdateUser(u); err != nil { + return fmt.Errorf("failed to store user: %w", err) + } + } + + fmt.Println(u.FormatPrint()) + + return nil +} diff --git a/pkg/dqs/config.go b/pkg/dqs/config.go @@ -0,0 +1,15 @@ +package dqs + +import "fmt" + +type Config struct { + Dir string +} + +func (c *Config) Validate() error { + if c.Dir == "" { + return fmt.Errorf("missing dqs directory") + } + + return nil +} diff --git a/pkg/dqs/diet/diet.go b/pkg/dqs/diet/diet.go @@ -0,0 +1,48 @@ +package diet + +import "code.dwrz.net/src/pkg/dqs/category" + +type Diet string + +const ( + Omnivore Diet = "omnivore" + Vegan Diet = "vegan" + Vegetarian Diet = "vegetarian" +) + +func Valid(d Diet) bool { + switch d { + case Omnivore, Vegan, Vegetarian: + return true + default: + return false + } +} + +func (d Diet) Template() map[string]category.Category { + switch d { + case Omnivore: + return omnivore + case Vegan: + return vegan + case Vegetarian: + return vegetarian + default: + return nil + } +} + +func (d Diet) MaxScore() int { + tmpl := d.Template() + + var max int + for _, category := range tmpl { + for _, portion := range category.Portions { + if portion.Points > 0 { + max += portion.Points + } + } + } + + return max +} diff --git a/pkg/dqs/diet/omnivore.go b/pkg/dqs/diet/omnivore.go @@ -0,0 +1,23 @@ +package diet + +import "code.dwrz.net/src/pkg/dqs/category" + +var omnivore = map[string]category.Category{ + // High Quality + category.Dairy.Name: category.Dairy, + category.Fruit.Name: category.Fruit, + category.HQBeverages.Name: category.HQBeverages, + category.HQProcessedFoods.Name: category.HQProcessedFoods, + category.NutsSeedsHealthyOils.Name: category.NutsSeedsHealthyOils, + category.UnprocessedMeatSeafood.Name: category.UnprocessedMeatSeafood, + category.Vegetables.Name: category.Vegetables, + category.WholeGrains.Name: category.WholeGrains, + + // Low Quality + category.FriedFoods.Name: category.FriedFoods, + category.LQBeverages.Name: category.LQBeverages, + category.Other.Name: category.Other, + category.ProcessedMeat.Name: category.ProcessedMeat, + category.RefinedGrains.Name: category.RefinedGrains, + category.Sweets.Name: category.Sweets, +} diff --git a/pkg/dqs/diet/vegan.go b/pkg/dqs/diet/vegan.go @@ -0,0 +1,21 @@ +package diet + +import "code.dwrz.net/src/pkg/dqs/category" + +var vegan = map[string]category.Category{ + // High Quality + category.Fruit.Name: category.Fruit, + category.HQBeverages.Name: category.HQBeverages, + category.HQProcessedFoods.Name: category.HQProcessedFoods, + category.LegumesPlantProteins.Name: category.LegumesPlantProteins, + category.NutsSeedsHealthyOils.Name: category.NutsSeedsHealthyOils, + category.Vegetables.Name: category.Vegetables, + category.WholeGrains.Name: category.WholeGrains, + + // Low Quality + category.FriedFoods.Name: category.FriedFoods, + category.LQBeverages.Name: category.LQBeverages, + category.Other.Name: category.Other, + category.RefinedGrains.Name: category.RefinedGrains, + category.Sweets.Name: category.Sweets, +} diff --git a/pkg/dqs/diet/vegetarian.go b/pkg/dqs/diet/vegetarian.go @@ -0,0 +1,22 @@ +package diet + +import "code.dwrz.net/src/pkg/dqs/category" + +var vegetarian = map[string]category.Category{ + // High Quality + category.Dairy.Name: category.Dairy, + category.Fruit.Name: category.Fruit, + category.HQBeverages.Name: category.HQBeverages, + category.HQProcessedFoods.Name: category.HQProcessedFoods, + category.LegumesPlantProteins.Name: category.LegumesPlantProteins, + category.NutsSeedsHealthyOils.Name: category.NutsSeedsHealthyOils, + category.Vegetables.Name: category.Vegetables, + category.WholeGrains.Name: category.WholeGrains, + + // Low Quality + category.FriedFoods.Name: category.FriedFoods, + category.LQBeverages.Name: category.LQBeverages, + category.Other.Name: category.Other, + category.RefinedGrains.Name: category.RefinedGrains, + category.Sweets.Name: category.Sweets, +} diff --git a/pkg/dqs/entry/entry.go b/pkg/dqs/entry/entry.go @@ -0,0 +1,76 @@ +package entry + +import ( + "fmt" + "strings" + "time" + + "code.dwrz.net/src/pkg/dqs/category" + "code.dwrz.net/src/pkg/dqs/diet" + "code.dwrz.net/src/pkg/dqs/user" +) + +const ( + DateFormat = "20060102" + dateDisplayFormat = "2006-01-02" +) + +type Entry struct { + BodyFat float64 `json:"bodyFat"` + Categories map[string]category.Category `json:"categories"` + Date time.Time `json:"date"` + Diet diet.Diet `json:"diet"` + Height float64 `json:"height"` + Note string `json:"note"` + Weight float64 `json:"weight"` +} + +func New(date time.Time, u *user.User) *Entry { + var e = &Entry{ + Categories: u.Diet.Template(), + Date: date, + Diet: u.Diet, + } + + var currentDate = time.Now().Format(DateFormat) + if currentDate == date.Format(DateFormat) { + e.BodyFat = u.BodyFat + e.Height = u.Height + e.Weight = u.Weight + } + + return e +} + +// Key returns the key used to retrieve an entry from the store. +func (e *Entry) Key() string { + return e.Date.Format(DateFormat) +} + +func (e *Entry) Score() (total float64) { + for _, c := range e.Categories { + total += c.Score() + } + + return total +} + +func (e *Entry) Category(c string) (*category.Category, error) { + // Try expanding an abbreviation. + category, exists := e.Categories[category.Abbreviations[c]] + if !exists { + // Check if a lowercase full category name was used. + category, exists = e.Categories[strings.Title(c)] + if !exists { + // Check the full, capitalized name. + category, exists = e.Categories[c] + if !exists { + return nil, fmt.Errorf( + "category %s not found", c, + ) + } + } + } + + return &category, nil +} diff --git a/pkg/dqs/entry/format.go b/pkg/dqs/entry/format.go @@ -0,0 +1,157 @@ +package entry + +import ( + "fmt" + + "sort" + "strconv" + "strings" + + "code.dwrz.net/src/pkg/color" + "code.dwrz.net/src/pkg/dqs/category" + "code.dwrz.net/src/pkg/dqs/stats" + "code.dwrz.net/src/pkg/dqs/user" +) + +const ( + width = 44 + top = "┌────────────────────────────────────────────┐\n" + hr = "├────────────────────────────────────────────┤\n" + bottom = "└────────────────────────────────────────────┘\n" +) + +func centerPad(s string, width int) string { + return fmt.Sprintf( + "%[1]*s", -width, fmt.Sprintf("%[1]*s", (width+len(s))/2, s), + ) +} + +// FormatPrint formats an entry for display to the user. +func (e *Entry) FormatPrint(u *user.User) string { + // Assemble the data for display. + var ( + highQuality []category.Category + lowQuality []category.Category + ) + + // Separate high quality and low quality categories. + for _, c := range e.Categories { + if c.HighQuality { + highQuality = append(highQuality, c) + continue + } + lowQuality = append(lowQuality, c) + } + + // Sort alphabetically for consistent appearance. + sort.Slice(highQuality, func(i, j int) bool { + return highQuality[i].Name < highQuality[j].Name + }) + sort.Slice(lowQuality, func(i, j int) bool { + return lowQuality[i].Name < lowQuality[j].Name + }) + + // Prepare a string for display to the user. + var str strings.Builder + + // Format the date. + str.WriteString(top) + str.WriteString(fmt.Sprintf( + "│%s%s%s│\n", + color.BrightBlack, + centerPad(e.Date.Format(dateDisplayFormat), width), + color.Reset, + )) + str.WriteString(hr) + + // Format high-quality categories. + str.WriteString(fmt.Sprintf("│%-44s│\n", "High Quality")) + str.WriteString(hr) + for _, c := range highQuality { + str.WriteString(c.FormatPrint()) + str.WriteString("\n") + } + + // Format low-quality categories. + str.WriteString(hr) + str.WriteString(fmt.Sprintf("│%-44s│\n", "Low Quality")) + str.WriteString(hr) + for _, c := range lowQuality { + str.WriteString(c.FormatPrint()) + str.WriteString("\n") + } + str.WriteString(hr) + + // Format the total. + var totalColor color.Color + total := e.Score() + switch { + case total >= 15: + totalColor = color.BrightGreen + case total >= 0: + totalColor = color.BrightYellow + default: + totalColor = color.BrightRed + + } + + max := e.Diet.MaxScore() + formattedTotal := strconv.FormatFloat(total, 'f', -1, 64) + digits := len(fmt.Sprintf("%s / %d", formattedTotal, max)) + space := width - digits + format := fmt.Sprintf("%%-%ds%%s%%s%%s / %%d │\n", space) + str.WriteString(fmt.Sprintf( + format, + "│Total / Max: ", totalColor, formattedTotal, color.Reset, max, + )) + str.WriteString(hr) + + // Format the weight, delta, and body fat. + digits = len(fmt.Sprintf( + "%.2f / %.2f", e.UnitWeight(u.Units), u.UnitTargetWeight(), + )) + space = width - digits + format = fmt.Sprintf("%%-%ds%%.2f / %%.2f │\n", space) + str.WriteString(fmt.Sprintf( + format, + "│Weight / Target:", + e.UnitWeight(u.Units), + u.UnitTargetWeight(), + )) + + diff := e.UnitWeight(u.Units) - u.UnitTargetWeight() + digits = len(fmt.Sprintf("%+.2f", diff)) + space = width - digits + format = fmt.Sprintf("%%-%ds%%+.2f │\n", space) + str.WriteString(fmt.Sprintf(format, "│Weight Δ:", diff)) + + bfw := e.UnitBodyFatWeight(u.Units) + digits = len(fmt.Sprintf( + "%.2f%% (%.2f %s)", + e.BodyFat, bfw, u.Units.Weight(), + )) + space = width - digits + format = fmt.Sprintf("%%-%ds%%.2f%%%% (%%.2f %%s) │\n", space) + str.WriteString(fmt.Sprintf( + format, + "│Body Fat:", + e.BodyFat, bfw, u.Units.Weight(), + )) + + bmi := stats.BMI(u.Height, e.Weight) + digits = len(fmt.Sprintf("%.2f", bmi)) + space = width - digits + format = fmt.Sprintf("%%-%ds%%.2f │\n", space) + str.WriteString(fmt.Sprintf(format, "│BMI:", bmi)) + + str.WriteString(bottom) + + // If a note is set, format it. + if e.Note != "" { + str.WriteString(fmt.Sprintf( + "\n%s%s%s\n", color.Italic, e.Note, color.Reset, + )) + } + + return str.String() +} diff --git a/pkg/dqs/entry/units.go b/pkg/dqs/entry/units.go @@ -0,0 +1,32 @@ +package entry + +import ( + "code.dwrz.net/src/pkg/dqs/stats" + "code.dwrz.net/src/pkg/dqs/user/units" +) + +func (e *Entry) UnitBodyFatWeight(system units.System) float64 { + bfw := stats.BodyFatWeight(e.Weight, e.BodyFat) + + if system == units.Metric { + return bfw + } + + return units.KilogramToPounds(bfw) +} + +func (e *Entry) UnitHeight(system units.System) float64 { + if system == units.Metric { + return e.Height + } + + return units.CentimeterToInches(e.Height) +} + +func (e *Entry) UnitWeight(system units.System) float64 { + if system == units.Metric { + return e.Weight + } + + return units.KilogramToPounds(e.Weight) +} diff --git a/pkg/dqs/portion/portion.go b/pkg/dqs/portion/portion.go @@ -0,0 +1,63 @@ +package portion + +import ( + "fmt" + "math" + "strconv" +) + +type Amount string + +const ( + None Amount = "" + Full Amount = "full" + Half Amount = "half" +) + +func (a Amount) Claimed() bool { + switch a { + case Half, Full: + return true + default: + return false + } +} + +type Portion struct { + Points int `json:"points"` + Amount Amount `json:"amount"` +} + +func (p *Portion) Score() float64 { + switch p.Amount { + case Full: + return float64(p.Points) + case Half: + return float64(p.Points) / 2 + case None: + fallthrough + default: + return float64(0) + } +} + +func Parse(quantity string) (float64, error) { + q, err := strconv.ParseFloat(quantity, 64) + if err != nil { + return 0, fmt.Errorf( + "failed to parse quantity %s: %w", quantity, err, + ) + } + if q < 0 { + return 0, fmt.Errorf( + "negative portions (%s) are not allowed", quantity, + ) + } + if math.Mod(q, 0.5) != 0 { + return 0, fmt.Errorf( + "quantity %f is not a full or half portion", q, + ) + } + + return q, nil +} diff --git a/pkg/dqs/report/format.go b/pkg/dqs/report/format.go @@ -0,0 +1,126 @@ +package report + +import ( + "fmt" + "strings" + + "code.dwrz.net/src/pkg/color" +) + +func (r *Report) Format() string { + var str strings.Builder + + fmt.Fprintf(&str, "%sEntries%s\n", color.BrightBlack, color.Reset) + fmt.Fprintf(&str, "Count: %d\n", len(r.Entries)) + if len(r.Entries) > 0 { + fmt.Fprintf( + &str, + "First: %s\n", + r.Entries[0].Date.Format("2006-01-02"), + ) + fmt.Fprintf( + &str, + "Latest: %s\n", + r.Entries[len(r.Entries)-1].Date.Format("2006-01-02"), + ) + str.WriteString("\n") + } + + if len(r.BodyFat.Values) > 0 { + fmt.Fprintf( + &str, + "%sBody Fat%s\n", color.BrightBlack, color.Reset, + ) + fmt.Fprintf( + &str, + "Max: %.2f on %v\n", + r.BodyFat.Max.Value, + r.BodyFat.Max.Date.Format("2006-01-02"), + ) + fmt.Fprintf( + &str, + "Min: %.2f on %v\n", + r.BodyFat.Min.Value, + r.BodyFat.Min.Date.Format("2006-01-02"), + ) + fmt.Fprintf(&str, "Average: %.2f\n", *r.BodyFat.Average) + fmt.Fprintf(&str, "Median: %.2f\n", *r.BodyFat.Median) + str.WriteString("\n") + } + + if len(r.DQS.Values) > 0 { + fmt.Fprintf(&str, "%sDQS%s\n", color.BrightBlack, color.Reset) + fmt.Fprintf( + &str, + "Max: %.2f on %v\n", + r.DQS.Max.Value, + r.DQS.Max.Date.Format("2006-01-02"), + ) + fmt.Fprintf( + &str, + "Min: %.2f on %v\n", + r.DQS.Min.Value, + r.DQS.Min.Date.Format("2006-01-02"), + ) + fmt.Fprintf(&str, "Average: %.2f\n", *r.DQS.Average) + fmt.Fprintf(&str, "Median: %.2f\n", *r.DQS.Median) + str.WriteString("\n") + } + + if len(r.Weight.Values) > 0 { + fmt.Fprintf( + &str, "%sWeight%s\n", color.BrightBlack, color.Reset, + ) + fmt.Fprintf( + &str, + "Max: %.2f on %v\n", + r.Weight.Max.Value, + r.Weight.Max.Date.Format("2006-01-02"), + ) + fmt.Fprintf( + &str, + "Min: %.2f on %v\n", + r.Weight.Min.Value, + r.Weight.Min.Date.Format("2006-01-02"), + ) + fmt.Fprintf(&str, "Average: %.2f\n", *r.Weight.Average) + fmt.Fprintf(&str, "Median: %.2f\n", *r.Weight.Median) + str.WriteString("\n") + } + + if len(r.More) > 0 || len(r.Less) > 0 { + fmt.Fprintf( + &str, + "%sRecommendations%s\n", color.BrightBlack, color.Reset, + ) + } + if len(r.More) > 0 { + fmt.Fprintf(&str, "You should eat more:\n\n") + for i := 0; i < 3 && i < len(r.More); i++ { + rec := r.More[i] + fmt.Fprintf( + &str, + "%s: %d lost points (%.2f per entry).\n", + rec.Name, + rec.Points, + float64(rec.Points)/float64(len(r.Entries)), + ) + } + } + str.WriteString("\n") + if len(r.Less) > 0 { + fmt.Fprintf(&str, "You should eat less:\n") + for i := 0; i < 2 && i < len(r.Less); i++ { + rec := r.Less[i] + fmt.Fprintf( + &str, + "%s: %d lost points (%.2f per entry).\n", + rec.Name, + rec.Points, + float64(rec.Points)/float64(len(r.Entries)), + ) + } + } + + return str.String() +} diff --git a/pkg/dqs/report/report.go b/pkg/dqs/report/report.go @@ -0,0 +1,94 @@ +package report + +import ( + "sort" + + "code.dwrz.net/src/pkg/dqs/entry" + "code.dwrz.net/src/pkg/dqs/stats" +) + +type Recommendation struct { + Name string + Points int +} + +type Report struct { + BodyFat stats.Stats + DQS stats.Stats + Entries []*entry.Entry + Weight stats.Stats + + Less []*Recommendation + More []*Recommendation +} + +func New(entries []*entry.Entry) *Report { + var ( + bf = []stats.DateValue{} + dqs = []stats.DateValue{} + w = []stats.DateValue{} + + unclaimed = map[string]int{} + negative = map[string]int{} + ) + for _, e := range entries { + bf = append(bf, stats.DateValue{ + Date: e.Date, + Value: e.BodyFat, + }) + dqs = append(dqs, stats.DateValue{ + Date: e.Date, + Value: e.Score(), + }) + w = append(w, stats.DateValue{ + Date: e.Date, + Value: e.Weight, + }) + + // Iterate through high quality categories. + // Collect all unclaimed positive points. + // Collect all claimed negative points. + for _, c := range e.Categories { + for _, p := range c.Portions { + if p.Points > 0 && !p.Amount.Claimed() { + unclaimed[c.Name] += p.Points + } + if p.Points < 0 && p.Amount.Claimed() { + negative[c.Name] += p.Points + } + } + } + } + + // Assemble the recommendations. + var more = []*Recommendation{} + var less = []*Recommendation{} + for name, points := range unclaimed { + more = append(more, &Recommendation{ + Name: name, + Points: points, + }) + } + for name, points := range negative { + less = append(less, &Recommendation{ + Name: name, + Points: points, + }) + } + sort.Slice(more, func(i, j int) bool { + return more[i].Points > more[j].Points + }) + sort.Slice(less, func(i, j int) bool { + return less[i].Points < less[j].Points + }) + + return &Report{ + BodyFat: stats.New(bf), + DQS: stats.New(dqs), + Weight: stats.New(w), + Entries: entries, + + More: more, + Less: less, + } +} diff --git a/pkg/dqs/stats/bmi.go b/pkg/dqs/stats/bmi.go @@ -0,0 +1,17 @@ +package stats + +import ( + "math" +) + +func BMI(height, weight float64) float64 { + if height > 0 { + return weight / math.Pow(height/100, 2) + } + + return 0.0 +} + +func BodyFatWeight(weight, bodyFat float64) float64 { + return (weight * bodyFat) / 100 +} diff --git a/pkg/dqs/stats/stats.go b/pkg/dqs/stats/stats.go @@ -0,0 +1,68 @@ +package stats + +import ( + "sort" + "time" +) + +type DateValue struct { + Date time.Time + Value float64 +} + +type Stats struct { + Values []DateValue + Average *float64 + Max *DateValue + Median *float64 + Min *DateValue +} + +func New(values []DateValue) Stats { + sort.Slice(values, func(i, j int) bool { + return values[i].Value < values[j].Value + }) + + var ( + max *DateValue + min *DateValue + sum float64 + ) + for i := range values { + v := values[i] + + if max == nil || v.Value >= max.Value { + max = &v + } + if min == nil || v.Value <= min.Value { + min = &v + } + + sum += v.Value + } + + var stats = Stats{ + Max: max, + Min: min, + Values: values, + } + if len(values) > 0 { + avg := sum / float64(len(values)) + stats.Average = &avg + + middle := len(values) / 2 + + if len(values)%2 != 0 { + median := values[middle] + + stats.Median = &median.Value + } else { + median := (values[middle-1].Value + + values[middle].Value) / 2 + + stats.Median = &median + } + } + + return stats +} diff --git a/pkg/dqs/store/entries.go b/pkg/dqs/store/entries.go @@ -0,0 +1,113 @@ +package store + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "sync" + + "code.dwrz.net/src/pkg/dqs/entry" +) + +func (s *Store) DeleteEntry(date string) error { + name := fmt.Sprintf("%s/%s.json", s.Dir, date) + + if err := os.Remove(name); err != nil { + return err + } + + return nil +} + +func (s *Store) GetAllEntries() ([]*entry.Entry, error) { + files, err := os.ReadDir(s.Dir) + if err != nil { + return nil, err + } + + var ( + echan = make(chan *entry.Entry, len(files)) + errs = make(chan error, len(files)) + concurrency = 24 + sem = make(chan struct{}, concurrency) + wg sync.WaitGroup + ) + + wg.Add(len(files)) + + for _, f := range files { + go func(name string) { + defer func() { <-sem }() + defer wg.Done() + + sem <- struct{}{} + + if name == userFile { + return + } + + date := name[:len(name)-len(filepath.Ext(name))] + + entry, err := s.GetEntry(date) + if err != nil { + errs <- err + } + + echan <- entry + }(f.Name()) + } + + wg.Wait() + close(errs) + close(echan) + + if len(errs) > 0 { + return nil, <-errs + } + + var entries = []*entry.Entry{} + for entry := range echan { + entries = append(entries, entry) + } + + sort.Slice(entries, func(i, j int) bool { + return entries[i].Date.Before(entries[j].Date) + }) + + return entries, nil +} + +func (s *Store) GetEntry(date string) (*entry.Entry, error) { + name := fmt.Sprintf("%s/%s.json", s.Dir, date) + + data, err := os.ReadFile(name) + if err != nil { + return nil, fmt.Errorf("failed to open entry file: %w", err) + } + + var e = &entry.Entry{} + if err := json.Unmarshal(data, e); err != nil { + return nil, fmt.Errorf("failed to parse entry file: %w", err) + } + + return e, nil +} + +func (s *Store) UpdateEntry(e *entry.Entry) error { + data, err := json.Marshal(e) + if err != nil { + return err + } + + name := fmt.Sprintf( + "%s/%s.json", s.Dir, e.Date.Format(entry.DateFormat), + ) + + if err := os.WriteFile(name, data, permissions); err != nil { + return err + } + + return nil +} diff --git a/pkg/dqs/store/store.go b/pkg/dqs/store/store.go @@ -0,0 +1,27 @@ +package store + +import ( + "fmt" + "os" +) + +const permissions = 0700 + +type Store struct { + Dir string +} + +func Open(dir string) (*Store, error) { + var store = &Store{Dir: dir} + + // Create the directory, if it doesn't exist. + if _, err := os.Stat(dir); os.IsNotExist(err) { + if err := os.MkdirAll(dir, os.ModeDir|permissions); err != nil { + return nil, fmt.Errorf( + "failed to create dqs directory: %w", err, + ) + } + } + + return store, nil +} diff --git a/pkg/dqs/store/store_test.go b/pkg/dqs/store/store_test.go @@ -0,0 +1,56 @@ +package store + +import ( + "os" + "testing" + "time" + + "code.dwrz.net/src/pkg/dqs/diet" + "code.dwrz.net/src/pkg/dqs/entry" +) + +func BenchmarkGetAllEntries(b *testing.B) { + b.StopTimer() + + temp, err := os.MkdirTemp(os.TempDir(), "dqs") + if err != nil { + b.Errorf("failed to setup temp dir: %v", err) + return + } + + s, err := Open(temp) + if err != nil { + b.Errorf("failed to open store: %v", err) + return + } + + var start = time.Date(1900, 1, 1, 0, 0, 0, 0, time.UTC) + var end = time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) + + for t := start; t.Before(end); t = t.AddDate(0, 0, 1) { + if err := s.UpdateEntry(&entry.Entry{ + Categories: diet.Vegetarian.Template(), + Date: t, + Diet: diet.Vegetarian, + }); err != nil { + b.Errorf("failed to create entry: %v", err) + return + } + } + + b.ResetTimer() + + b.StartTimer() + for i := 0; i < b.N; i++ { + if _, err := s.GetAllEntries(); err != nil { + b.Errorf("failed to get all entries: %v", err) + return + } + } + b.StopTimer() + + // if err := os.RemoveAll(temp); err != nil { + // b.Errorf("failed to remove %s directory: %v", temp, err) + // return + // } +} diff --git a/pkg/dqs/store/user.go b/pkg/dqs/store/user.go @@ -0,0 +1,42 @@ +package store + +import ( + "encoding/json" + "fmt" + "os" + + "code.dwrz.net/src/pkg/dqs/user" +) + +const userFile = "user.json" + +func (s *Store) GetUser() (*user.User, error) { + name := fmt.Sprintf("%s/%s", s.Dir, userFile) + + data, err := os.ReadFile(name) + if err != nil { + return nil, err + } + + var u = &user.User{} + if err := json.Unmarshal(data, u); err != nil { + return nil, err + } + + return u, nil +} + +func (s *Store) UpdateUser(u *user.User) error { + data, err := json.Marshal(u) + if err != nil { + return err + } + + name := fmt.Sprintf("%s/%s", s.Dir, userFile) + + if err := os.WriteFile(name, data, permissions); err != nil { + return err + } + + return nil +} diff --git a/pkg/dqs/user/format.go b/pkg/dqs/user/format.go @@ -0,0 +1,78 @@ +package user + +import ( + "fmt" + "strings" + + "code.dwrz.net/src/pkg/color" + "code.dwrz.net/src/pkg/dqs/stats" +) + +func (u *User) FormatPrint() string { + var str strings.Builder + + str.WriteString(color.BrightBlack) + str.WriteString("Name: ") + str.WriteString(color.Reset) + str.WriteString(u.Name) + str.WriteString("\n") + + str.WriteString(color.BrightBlack) + str.WriteString("Birthday: ") + str.WriteString(color.Reset) + str.WriteString(u.Birthday.Format("2006-01-02")) + str.WriteString("\n") + + str.WriteString(color.BrightBlack) + str.WriteString("Units: ") + str.WriteString(color.Reset) + str.WriteString(fmt.Sprintf("%s", u.Units)) + str.WriteString("\n") + + str.WriteString(color.BrightBlack) + str.WriteString("Height: ") + str.WriteString(color.Reset) + str.WriteString(fmt.Sprintf( + "%.2f %s", u.UnitHeight(), u.Units.Height()), + ) + str.WriteString("\n") + + str.WriteString(color.BrightBlack) + str.WriteString("Weight: ") + str.WriteString(color.Reset) + str.WriteString(fmt.Sprintf( + "%.2f %s", u.UnitWeight(), u.Units.Weight()), + ) + str.WriteString("\n") + + str.WriteString(color.BrightBlack) + str.WriteString("Body Fat: ") + str.WriteString(color.Reset) + str.WriteString(fmt.Sprintf( + "%.2f%% (%.2f %s)", + u.BodyFat, u.UnitBodyFatWeight(), u.Units.Weight(), + )) + str.WriteString("\n") + + str.WriteString(color.BrightBlack) + str.WriteString("BMI: ") + str.WriteString(color.Reset) + str.WriteString(fmt.Sprintf("%.2f", stats.BMI(u.Height, u.Weight))) + str.WriteString("\n") + + str.WriteString(color.BrightBlack) + str.WriteString("Diet: ") + str.WriteString(color.Reset) + str.WriteString(fmt.Sprintf("%s", u.Diet)) + str.WriteString("\n") + + str.WriteString(color.BrightBlack) + str.WriteString("Target Weight: ") + str.WriteString(color.Reset) + str.WriteString(fmt.Sprintf( + "%.2f %s", u.UnitTargetWeight(), u.Units.Weight(), + )) + str.WriteString("\n") + + return str.String() +} diff --git a/pkg/dqs/user/units.go b/pkg/dqs/user/units.go @@ -0,0 +1,40 @@ +package user + +import ( + "code.dwrz.net/src/pkg/dqs/stats" + "code.dwrz.net/src/pkg/dqs/user/units" +) + +func (u *User) UnitBodyFatWeight() float64 { + bfw := stats.BodyFatWeight(u.Weight, u.BodyFat) + + if u.Units == units.Metric { + return bfw + } + + return units.KilogramToPounds(bfw) +} + +func (u *User) UnitHeight() float64 { + if u.Units == units.Metric { + return u.Height + } + + return units.CentimeterToInches(u.Height) +} + +func (u *User) UnitTargetWeight() float64 { + if u.Units == units.Metric { + return u.TargetWeight + } + + return units.KilogramToPounds(u.TargetWeight) +} + +func (u *User) UnitWeight() float64 { + if u.Units == units.Metric { + return u.Weight + } + + return units.KilogramToPounds(u.Weight) +} diff --git a/pkg/dqs/user/units/convert.go b/pkg/dqs/user/units/convert.go @@ -0,0 +1,17 @@ +package units + +func InchesToCentimeter(inches float64) float64 { + return inches * 2.54 +} + +func CentimeterToInches(cm float64) float64 { + return cm / 2.54 +} + +func PoundsToKilogram(lbs float64) float64 { + return lbs * 0.45359237 +} + +func KilogramToPounds(kg float64) float64 { + return kg * 2.20462262185 +} diff --git a/pkg/dqs/user/units/units.go b/pkg/dqs/user/units/units.go @@ -0,0 +1,46 @@ +package units + +type System string + +const ( + Default System = "metric" + Imperial System = "imperial" + Metric System = "metric" +) + +func Valid(s System) bool { + switch s { + case Imperial, Metric: + return true + default: + return false + } +} + +type Unit string + +const ( + Centimeter Unit = "cm" + Kilogram Unit = "kg" + + Inches Unit = "in" + Pounds Unit = "lbs" +) + +func (s System) Weight() Unit { + switch s { + case Imperial: + return Pounds + default: + return Kilogram + } +} + +func (s System) Height() Unit { + switch s { + case Imperial: + return Inches + default: + return Centimeter + } +} diff --git a/pkg/dqs/user/user.go b/pkg/dqs/user/user.go @@ -0,0 +1,40 @@ +package user + +import ( + "fmt" + "time" + + "code.dwrz.net/src/pkg/dqs/diet" + "code.dwrz.net/src/pkg/dqs/user/units" +) + +type User struct { + Birthday time.Time `json:"birthday"` + BodyFat float64 `json:"bodyFat"` + Diet diet.Diet `json:"diet"` + Height float64 `json:"height"` + Name string `json:"name"` + TargetWeight float64 `json:"targetWeight"` + Units units.System `json:"units"` + Weight float64 `json:"weight"` +} + +var DefaultUser = User{ + Diet: diet.Vegetarian, + Units: units.Metric, +} + +func (u *User) SetDiet(d diet.Diet) error { + switch d { + case diet.Omnivore: + u.Diet = diet.Omnivore + case diet.Vegan: + u.Diet = diet.Vegan + case diet.Vegetarian: + u.Diet = diet.Vegetarian + default: + return fmt.Errorf("unrecognized diet: %s", d) + } + + return nil +}