commit e902c1afe6c1dea29bb0fc93bbff7f1132d56ba3
parent e659052ca30eaf75fb7d90b8872a7866f8951807
Author: dwrz <dwrz@dwrz.net>
Date: Sat, 24 Dec 2022 14:53:08 +0000
Add dqs
Diffstat:
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
+}