diff --git a/internal/dqs/category/abbreviations.go b/internal/dqs/category/abbreviations.go new file mode 100644 index 0000000..8c743bd --- /dev/null +++ b/internal/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/internal/dqs/category/category.go b/internal/dqs/category/category.go new file mode 100644 index 0000000..dba0e10 --- /dev/null +++ b/internal/dqs/category/category.go @@ -0,0 +1,130 @@ +package category + +import ( + "fmt" + "strings" + + "code.chimeric.al/dwrz/dqs/internal/dqs/portion" + "code.chimeric.al/dwrz/dqs/pkg/color" +) + +// Category represents a food category and its six daily portion slots. +// Portions are ordered from first consumed to last consumed; point values +// decrease as more portions are eaten. +type Category struct { + Name string `json:"name"` + Portions []portion.Portion `json:"portions"` + HighQuality bool `json:"highQuality"` +} + +// Add fills portion slots from left to right. +// Half-filled slots are completed before empty slots are used. +// It returns an error if q exceeds the remaining capacity of the category. +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 +} + +// Score returns the sum of points across all portion slots in the category. +func (c *Category) Score() (total float64) { + for _, p := range c.Portions { + total += p.Score() + } + + return total +} + +// FormatPrint formats the category as a single table row with ANSI colors. +// Full portions color the cell background; half portions color the text. +func (c *Category) FormatPrint() string { + var str strings.Builder + fmt.Fprintf(&str, "│%-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 + } + + fmt.Fprintf( + &str, "%s%+d%s│", cellColor, p.Points, color.Reset, + ) + } + + return str.String() +} + +// Remove clears portion slots from right to left, reversing the order of Add. +// It returns an error if q exceeds the consumed portions in the category. +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/internal/dqs/category/category_test.go b/internal/dqs/category/category_test.go new file mode 100644 index 0000000..0eb48a9 --- /dev/null +++ b/internal/dqs/category/category_test.go @@ -0,0 +1,60 @@ +package category + +import ( + "testing" + + "code.chimeric.al/dwrz/dqs/internal/dqs/portion" +) + +func testDairy() Category { + var c = Dairy + c.Portions = make([]portion.Portion, len(Dairy.Portions)) + copy(c.Portions, Dairy.Portions) + return c +} + +func TestAddRemoveScore(t *testing.T) { + c := testDairy() + + // Dairy points: +2, +1, +1, 0, -1, -2. + if err := c.Add(1.5); err != nil { + t.Fatalf("Add(1.5) error = %v", err) + } + if got := c.Score(); got != 2.5 { + t.Errorf("Score() = %v, want 2.5", got) + } + + // Add 0.5 to complete the second slot (+1) and 1.0 to fill the third (+1). + if err := c.Add(1.5); err != nil { + t.Fatalf("Add(1.5) error = %v", err) + } + if got := c.Score(); got != 4.0 { + t.Errorf("Score() = %v, want 4.0", got) + } + + // Fill remaining 3 slots (0, -1, -2). + if err := c.Add(3.0); err != nil { + t.Fatalf("Add(3.0) error = %v", err) + } + if got := c.Score(); got != 1.0 { + t.Errorf("Score() = %v, want 1.0", got) + } + + // Adding even 0.5 more must fail when full. + if err := c.Add(0.5); err == nil { + t.Errorf("Add(0.5) on full category succeeded, want error") + } + + // Remove 2.5 from the right (-2, -1, and half of 0). + if err := c.Remove(2.5); err != nil { + t.Fatalf("Remove(2.5) error = %v", err) + } + if got := c.Score(); got != 4.0 { + t.Errorf("Score() = %v, want 4.0", got) + } + + // Removing more than remaining (3.5) must fail. + if err := c.Remove(4.0); err == nil { + t.Errorf("Remove(4.0) with 3.5 remaining succeeded, want error") + } +} diff --git a/internal/dqs/category/high-quality.go b/internal/dqs/category/high-quality.go new file mode 100644 index 0000000..e7371f7 --- /dev/null +++ b/internal/dqs/category/high-quality.go @@ -0,0 +1,122 @@ +package category + +import "code.chimeric.al/dwrz/dqs/internal/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/internal/dqs/category/low-quality.go b/internal/dqs/category/low-quality.go new file mode 100644 index 0000000..ffab3ed --- /dev/null +++ b/internal/dqs/category/low-quality.go @@ -0,0 +1,84 @@ +package category + +import dqs "code.chimeric.al/dwrz/dqs/internal/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/internal/dqs/command/add.go b/internal/dqs/command/add.go new file mode 100644 index 0000000..d39cc13 --- /dev/null +++ b/internal/dqs/command/add.go @@ -0,0 +1,75 @@ +package command + +import ( + "errors" + "fmt" + "os" + "time" + + "code.chimeric.al/dwrz/dqs/internal/dqs/command/help" + "code.chimeric.al/dwrz/dqs/internal/dqs/entry" + "code.chimeric.al/dwrz/dqs/internal/dqs/portion" + "code.chimeric.al/dwrz/dqs/internal/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/internal/dqs/command/body-fat.go b/internal/dqs/command/body-fat.go new file mode 100644 index 0000000..43e221c --- /dev/null +++ b/internal/dqs/command/body-fat.go @@ -0,0 +1,68 @@ +package command + +import ( + "errors" + "fmt" + "os" + "strconv" + "time" + + "code.chimeric.al/dwrz/dqs/internal/dqs/command/help" + "code.chimeric.al/dwrz/dqs/internal/dqs/entry" + "code.chimeric.al/dwrz/dqs/internal/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/internal/dqs/command/command.go b/internal/dqs/command/command.go new file mode 100644 index 0000000..c77a766 --- /dev/null +++ b/internal/dqs/command/command.go @@ -0,0 +1,53 @@ +package command + +import ( + "fmt" + "time" + + "code.chimeric.al/dwrz/dqs/internal/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/internal/dqs/command/config.go b/internal/dqs/command/config.go new file mode 100644 index 0000000..92cff87 --- /dev/null +++ b/internal/dqs/command/config.go @@ -0,0 +1,204 @@ +package command + +import ( + "bufio" + "errors" + "fmt" + "os" + "strconv" + "strings" + "time" + + "code.chimeric.al/dwrz/dqs/internal/dqs/command/help" + "code.chimeric.al/dwrz/dqs/internal/dqs/diet" + "code.chimeric.al/dwrz/dqs/internal/dqs/store" + "code.chimeric.al/dwrz/dqs/internal/dqs/user" + "code.chimeric.al/dwrz/dqs/internal/dqs/user/units" + "code.chimeric.al/dwrz/dqs/pkg/color" +) + +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 { + defaultUser := user.DefaultUser + u = &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.Printf("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.Printf("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.Printf("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/internal/dqs/command/delete.go b/internal/dqs/command/delete.go new file mode 100644 index 0000000..fd737bc --- /dev/null +++ b/internal/dqs/command/delete.go @@ -0,0 +1,26 @@ +package command + +import ( + "fmt" + "time" + + "code.chimeric.al/dwrz/dqs/internal/dqs/command/help" + "code.chimeric.al/dwrz/dqs/internal/dqs/entry" + "code.chimeric.al/dwrz/dqs/internal/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/internal/dqs/command/entry.go b/internal/dqs/command/entry.go new file mode 100644 index 0000000..1237b6f --- /dev/null +++ b/internal/dqs/command/entry.go @@ -0,0 +1,40 @@ +package command + +import ( + "errors" + "fmt" + "os" + "time" + + "code.chimeric.al/dwrz/dqs/internal/dqs/command/help" + "code.chimeric.al/dwrz/dqs/internal/dqs/entry" + "code.chimeric.al/dwrz/dqs/internal/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/internal/dqs/command/export.go b/internal/dqs/command/export.go new file mode 100644 index 0000000..e4d57d3 --- /dev/null +++ b/internal/dqs/command/export.go @@ -0,0 +1,40 @@ +package command + +import ( + "fmt" + "strings" + "time" + + "code.chimeric.al/dwrz/dqs/internal/dqs/command/help" + "code.chimeric.al/dwrz/dqs/internal/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/internal/dqs/command/help.go b/internal/dqs/command/help.go new file mode 100644 index 0000000..193639b --- /dev/null +++ b/internal/dqs/command/help.go @@ -0,0 +1,63 @@ +package command + +import ( + "fmt" + "strings" + "time" + + "code.chimeric.al/dwrz/dqs/internal/dqs/command/help" + "code.chimeric.al/dwrz/dqs/internal/dqs/store" +) + +const ( + helpIntro = `dqs — diet quality score + +Usage: + dqs [flags] [arguments] + + +Run dqs -help or dqs -h to see information on available flags. + +The following commands are available: + +` + helpMore = "Run dqs help 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/internal/dqs/command/help/add.go b/internal/dqs/command/help/add.go new file mode 100644 index 0000000..3da2d7d --- /dev/null +++ b/internal/dqs/command/help/add.go @@ -0,0 +1,34 @@ +package help + +import ( + "fmt" + "sort" + "strings" + + "code.chimeric.al/dwrz/dqs/internal/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/internal/dqs/command/help/body-fat.go b/internal/dqs/command/help/body-fat.go new file mode 100644 index 0000000..eeb51a2 --- /dev/null +++ b/internal/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/internal/dqs/command/help/config.go b/internal/dqs/command/help/config.go new file mode 100644 index 0000000..129b5e2 --- /dev/null +++ b/internal/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/internal/dqs/command/help/delete.go b/internal/dqs/command/help/delete.go new file mode 100644 index 0000000..6cb4979 --- /dev/null +++ b/internal/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/internal/dqs/command/help/entry.go b/internal/dqs/command/help/entry.go new file mode 100644 index 0000000..c1f216a --- /dev/null +++ b/internal/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/internal/dqs/command/help/export.go b/internal/dqs/command/help/export.go new file mode 100644 index 0000000..6941e32 --- /dev/null +++ b/internal/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/internal/dqs/command/help/help.go b/internal/dqs/command/help/help.go new file mode 100644 index 0000000..37537e4 --- /dev/null +++ b/internal/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/internal/dqs/command/help/note.go b/internal/dqs/command/help/note.go new file mode 100644 index 0000000..76e35fd --- /dev/null +++ b/internal/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/internal/dqs/command/help/portions.go b/internal/dqs/command/help/portions.go new file mode 100644 index 0000000..62afaeb --- /dev/null +++ b/internal/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/internal/dqs/command/help/remove.go b/internal/dqs/command/help/remove.go new file mode 100644 index 0000000..73315e3 --- /dev/null +++ b/internal/dqs/command/help/remove.go @@ -0,0 +1,34 @@ +package help + +import ( + "fmt" + "sort" + "strings" + + "code.chimeric.al/dwrz/dqs/internal/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/internal/dqs/command/help/report.go b/internal/dqs/command/help/report.go new file mode 100644 index 0000000..2abcb85 --- /dev/null +++ b/internal/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/internal/dqs/command/help/user.go b/internal/dqs/command/help/user.go new file mode 100644 index 0000000..fab7c79 --- /dev/null +++ b/internal/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/internal/dqs/command/help/weight.go b/internal/dqs/command/help/weight.go new file mode 100644 index 0000000..aeba414 --- /dev/null +++ b/internal/dqs/command/help/weight.go @@ -0,0 +1,23 @@ +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 + + str.WriteString(weight) + + return str.String() +} diff --git a/internal/dqs/command/note.go b/internal/dqs/command/note.go new file mode 100644 index 0000000..645cc03 --- /dev/null +++ b/internal/dqs/command/note.go @@ -0,0 +1,135 @@ +package command + +import ( + "errors" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "code.chimeric.al/dwrz/dqs/internal/dqs/command/help" + "code.chimeric.al/dwrz/dqs/internal/dqs/entry" + "code.chimeric.al/dwrz/dqs/internal/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) { + editor := strings.TrimSpace(os.Getenv("EDITOR")) + if editor == "" { + return "", fmt.Errorf("$EDITOR is not set") + } + + // Write the note to a temporary file for the external editor. + temp, err := os.CreateTemp(os.TempDir(), "dqs-*") + if err != nil { + return "", err + } + defer os.Remove(temp.Name()) + + if _, err := temp.Write([]byte(note)); err != nil { + temp.Close() + return "", err + } + if err := temp.Close(); err != nil { + return "", err + } + + args := strings.Fields(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 + } + + data, err := os.ReadFile(temp.Name()) + if err != nil { + return "", err + } + + return string(data), nil +} diff --git a/internal/dqs/command/parameters.go b/internal/dqs/command/parameters.go new file mode 100644 index 0000000..cc8584d --- /dev/null +++ b/internal/dqs/command/parameters.go @@ -0,0 +1,25 @@ +package command + +import ( + "fmt" + "time" + + "code.chimeric.al/dwrz/dqs/internal/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/internal/dqs/command/remove.go b/internal/dqs/command/remove.go new file mode 100644 index 0000000..c6cb0a5 --- /dev/null +++ b/internal/dqs/command/remove.go @@ -0,0 +1,75 @@ +package command + +import ( + "errors" + "fmt" + "os" + "time" + + "code.chimeric.al/dwrz/dqs/internal/dqs/command/help" + "code.chimeric.al/dwrz/dqs/internal/dqs/entry" + "code.chimeric.al/dwrz/dqs/internal/dqs/portion" + "code.chimeric.al/dwrz/dqs/internal/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/internal/dqs/command/report.go b/internal/dqs/command/report.go new file mode 100644 index 0000000..74179b3 --- /dev/null +++ b/internal/dqs/command/report.go @@ -0,0 +1,27 @@ +package command + +import ( + "fmt" + "time" + + "code.chimeric.al/dwrz/dqs/internal/dqs/command/help" + "code.chimeric.al/dwrz/dqs/internal/dqs/report" + "code.chimeric.al/dwrz/dqs/internal/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/internal/dqs/command/user.go b/internal/dqs/command/user.go new file mode 100644 index 0000000..d27efb1 --- /dev/null +++ b/internal/dqs/command/user.go @@ -0,0 +1,31 @@ +package command + +import ( + "errors" + "fmt" + "os" + "time" + + "code.chimeric.al/dwrz/dqs/internal/dqs/command/help" + "code.chimeric.al/dwrz/dqs/internal/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/internal/dqs/command/weight.go b/internal/dqs/command/weight.go new file mode 100644 index 0000000..567cf29 --- /dev/null +++ b/internal/dqs/command/weight.go @@ -0,0 +1,74 @@ +package command + +import ( + "errors" + "fmt" + "os" + "strconv" + "time" + + "code.chimeric.al/dwrz/dqs/internal/dqs/command/help" + "code.chimeric.al/dwrz/dqs/internal/dqs/entry" + "code.chimeric.al/dwrz/dqs/internal/dqs/store" + "code.chimeric.al/dwrz/dqs/internal/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/internal/dqs/diet/diet.go b/internal/dqs/diet/diet.go new file mode 100644 index 0000000..877f0d8 --- /dev/null +++ b/internal/dqs/diet/diet.go @@ -0,0 +1,67 @@ +package diet + +import ( + "code.chimeric.al/dwrz/dqs/internal/dqs/category" + "code.chimeric.al/dwrz/dqs/internal/dqs/portion" +) + +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 + } +} + +// Template returns a fresh category map for the diet. +// Each category and its portion slice are copied so mutations on an entry do +// not modify the package-level template definitions. +func (d Diet) Template() map[string]*category.Category { + var src map[string]category.Category + switch d { + case Omnivore: + src = omnivore + case Vegan: + src = vegan + case Vegetarian: + src = vegetarian + default: + return nil + } + + var tmpl = make(map[string]*category.Category, len(src)) + for name, c := range src { + portions := make([]portion.Portion, len(c.Portions)) + copy(portions, c.Portions) + c.Portions = portions + tmpl[name] = &c + } + + return tmpl +} + +// MaxScore returns the maximum possible score for the diet, calculated as the +// sum of all positive portion points across the diet's categories. +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/internal/dqs/diet/omnivore.go b/internal/dqs/diet/omnivore.go new file mode 100644 index 0000000..d6cba56 --- /dev/null +++ b/internal/dqs/diet/omnivore.go @@ -0,0 +1,23 @@ +package diet + +import "code.chimeric.al/dwrz/dqs/internal/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/internal/dqs/diet/vegan.go b/internal/dqs/diet/vegan.go new file mode 100644 index 0000000..40f477e --- /dev/null +++ b/internal/dqs/diet/vegan.go @@ -0,0 +1,21 @@ +package diet + +import "code.chimeric.al/dwrz/dqs/internal/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/internal/dqs/diet/vegetarian.go b/internal/dqs/diet/vegetarian.go new file mode 100644 index 0000000..18b8b47 --- /dev/null +++ b/internal/dqs/diet/vegetarian.go @@ -0,0 +1,22 @@ +package diet + +import "code.chimeric.al/dwrz/dqs/internal/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/internal/dqs/dqs.go b/internal/dqs/dqs.go new file mode 100644 index 0000000..3453fe1 --- /dev/null +++ b/internal/dqs/dqs.go @@ -0,0 +1,63 @@ +package dqs + +import ( + "fmt" + "time" + + "code.chimeric.al/dwrz/dqs/internal/dqs/command" + "code.chimeric.al/dwrz/dqs/internal/dqs/store" +) + +type Config struct { + Dir string +} + +func (c *Config) Validate() error { + if c.Dir == "" { + return fmt.Errorf("missing dqs directory") + } + + return nil +} + +type Command struct { + store *store.Store +} + +func New(cfg Config) (*Command, error) { + if err := cfg.Validate(); err != nil { + return nil, err + } + + store, err := store.Open(cfg.Dir) + if err != nil { + return nil, err + } + + return &Command{ + store: store, + }, nil +} + +func (a *Command) 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/internal/dqs/entry/entry.go b/internal/dqs/entry/entry.go new file mode 100644 index 0000000..abca05b --- /dev/null +++ b/internal/dqs/entry/entry.go @@ -0,0 +1,78 @@ +package entry + +import ( + "fmt" + "strings" + "time" + + "code.chimeric.al/dwrz/dqs/internal/dqs/category" + "code.chimeric.al/dwrz/dqs/internal/dqs/diet" + "code.chimeric.al/dwrz/dqs/internal/dqs/user" +) + +const ( + DateFormat = "20060102" + dateDisplayFormat = "2006-01-02" +) + +// Entry holds the diet log and body measurements for a single calendar date. +// Height and Weight are stored in metric units (cm and kg). +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"` +} + +// New creates an entry initialized with the category template for the user's +// diet. If date is today, the entry inherits the user's current body fat, +// height, and weight; entries for other dates start with zero measurements. +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) +} + +// Score returns the total Diet Quality Score across all categories in the entry. +func (e *Entry) Score() (total float64) { + for _, c := range e.Categories { + total += c.Score() + } + + return total +} + +// Category looks up a category by abbreviation or case-insensitive name. +func (e *Entry) Category(c string) (*category.Category, error) { + // Try expanding an abbreviation. + if cat, exists := e.Categories[category.Abbreviations[c]]; exists { + return cat, nil + } + // Check if a category name was used (case-insensitive). + for name, cat := range e.Categories { + if strings.EqualFold(name, c) { + return cat, nil + } + } + + return nil, fmt.Errorf("category %s not found", c) +} diff --git a/internal/dqs/entry/format.go b/internal/dqs/entry/format.go new file mode 100644 index 0000000..008bca9 --- /dev/null +++ b/internal/dqs/entry/format.go @@ -0,0 +1,157 @@ +package entry + +import ( + "fmt" + + "sort" + "strconv" + "strings" + + "code.chimeric.al/dwrz/dqs/internal/dqs/category" + "code.chimeric.al/dwrz/dqs/internal/dqs/stats" + "code.chimeric.al/dwrz/dqs/internal/dqs/user" + "code.chimeric.al/dwrz/dqs/pkg/color" +) + +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/internal/dqs/entry/units.go b/internal/dqs/entry/units.go new file mode 100644 index 0000000..f7739c2 --- /dev/null +++ b/internal/dqs/entry/units.go @@ -0,0 +1,32 @@ +package entry + +import ( + "code.chimeric.al/dwrz/dqs/internal/dqs/stats" + "code.chimeric.al/dwrz/dqs/internal/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/internal/dqs/portion/portion.go b/internal/dqs/portion/portion.go new file mode 100644 index 0000000..03a47ae --- /dev/null +++ b/internal/dqs/portion/portion.go @@ -0,0 +1,70 @@ +package portion + +import ( + "fmt" + "math" + "strconv" +) + +// Amount indicates how much of a single portion slot has been consumed. +type Amount string + +const ( + None Amount = "" + Full Amount = "full" + Half Amount = "half" +) + +// Claimed reports whether any part of the portion slot has been consumed. +func (a Amount) Claimed() bool { + switch a { + case Half, Full: + return true + default: + return false + } +} + +// Portion represents one portion slot in a food category. +// Points is the value of a full portion; a half portion scores half of Points. +type Portion struct { + Points int `json:"points"` + Amount Amount `json:"amount"` +} + +// Score returns the points earned for this portion slot. +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) + } +} + +// Parse parses a portion quantity string. +// The quantity must be a non-negative multiple of 0.5. +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/internal/dqs/portion/portion_test.go b/internal/dqs/portion/portion_test.go new file mode 100644 index 0000000..80f2a82 --- /dev/null +++ b/internal/dqs/portion/portion_test.go @@ -0,0 +1,84 @@ +package portion + +import "testing" + +func TestParse(t *testing.T) { + var tests = []struct { + in string + want float64 + wantErr bool + }{ + {in: "0", want: 0}, + {in: "0.5", want: 0.5}, + {in: "1", want: 1.0}, + {in: "1.5", want: 1.5}, + {in: "6", want: 6.0}, + {in: "-0.5", wantErr: true}, + {in: "-1", wantErr: true}, + {in: "0.25", wantErr: true}, + {in: "1.3", wantErr: true}, + {in: "abc", wantErr: true}, + {in: "", wantErr: true}, + } + + for _, tc := range tests { + got, err := Parse(tc.in) + if tc.wantErr { + if err == nil { + t.Errorf("Parse(%q) = %v, want error", tc.in, got) + } + continue + } + if err != nil { + t.Errorf("Parse(%q) unexpected error: %v", tc.in, err) + continue + } + if got != tc.want { + t.Errorf("Parse(%q) = %v, want %v", tc.in, got, tc.want) + } + } +} + +func TestScoreAndClaimed(t *testing.T) { + var tests = []struct { + portion Portion + wantScore float64 + wantClaimed bool + }{ + { + portion: Portion{Points: 2, Amount: None}, + wantScore: 0, + wantClaimed: false, + }, + { + portion: Portion{Points: 2, Amount: Half}, + wantScore: 1.0, + wantClaimed: true, + }, + { + portion: Portion{Points: 2, Amount: Full}, + wantScore: 2.0, + wantClaimed: true, + }, + { + portion: Portion{Points: -1, Amount: Half}, + wantScore: -0.5, + wantClaimed: true, + }, + } + + for _, tc := range tests { + if got := tc.portion.Score(); got != tc.wantScore { + t.Errorf( + "%+v.Score() = %v, want %v", + tc.portion, got, tc.wantScore, + ) + } + if got := tc.portion.Amount.Claimed(); got != tc.wantClaimed { + t.Errorf( + "%+v.Amount.Claimed() = %v, want %v", + tc.portion, got, tc.wantClaimed, + ) + } + } +} diff --git a/internal/dqs/report/format.go b/internal/dqs/report/format.go new file mode 100644 index 0000000..7ab27e0 --- /dev/null +++ b/internal/dqs/report/format.go @@ -0,0 +1,126 @@ +package report + +import ( + "fmt" + "strings" + + "code.chimeric.al/dwrz/dqs/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/internal/dqs/report/report.go b/internal/dqs/report/report.go new file mode 100644 index 0000000..0e171c2 --- /dev/null +++ b/internal/dqs/report/report.go @@ -0,0 +1,101 @@ +package report + +import ( + "sort" + + "code.chimeric.al/dwrz/dqs/internal/dqs/entry" + "code.chimeric.al/dwrz/dqs/internal/dqs/stats" +) + +// Recommendation identifies a category and the total points lost in it across +// all entries in a report. +type Recommendation struct { + Name string + Points int +} + +// Report collects summary statistics and dietary recommendations across a set +// of entries. +type Report struct { + BodyFat stats.Stats + DQS stats.Stats + Entries []*entry.Entry + Weight stats.Stats + + Less []*Recommendation + More []*Recommendation +} + +// New constructs a Report. Recommendations for "More" are high-quality +// categories with unclaimed positive points; "Less" are categories (high or low +// quality) with claimed negative points. +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/internal/dqs/stats/bmi.go b/internal/dqs/stats/bmi.go new file mode 100644 index 0000000..10a43fe --- /dev/null +++ b/internal/dqs/stats/bmi.go @@ -0,0 +1,21 @@ +package stats + +import ( + "math" +) + +// BMI calculates Body Mass Index from height in centimeters and weight in +// kilograms. It returns 0 if height is not positive. +func BMI(height, weight float64) float64 { + if height > 0 { + return weight / math.Pow(height/100, 2) + } + + return 0.0 +} + +// BodyFatWeight returns the mass of body fat given total weight and body fat +// percentage (0–100). The result has the same mass unit as weight. +func BodyFatWeight(weight, bodyFat float64) float64 { + return (weight * bodyFat) / 100 +} diff --git a/internal/dqs/stats/stats.go b/internal/dqs/stats/stats.go new file mode 100644 index 0000000..fda7ad2 --- /dev/null +++ b/internal/dqs/stats/stats.go @@ -0,0 +1,70 @@ +package stats + +import ( + "sort" + "time" +) + +type DateValue struct { + Date time.Time + Value float64 +} + +// Stats holds summary statistics across a series of dated values. +// Average, Max, Median, and Min are nil when Values is empty. +type Stats struct { + Values []DateValue + Average *float64 + Max *DateValue + Median *float64 + Min *DateValue +} + +// New sorts values in ascending order and computes their min, max, average, +// and median. +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 { + if max == nil || values[i].Value >= max.Value { + max = &values[i] + } + if min == nil || values[i].Value <= min.Value { + min = &values[i] + } + + sum += values[i].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/internal/dqs/stats/stats_test.go b/internal/dqs/stats/stats_test.go new file mode 100644 index 0000000..895e1b0 --- /dev/null +++ b/internal/dqs/stats/stats_test.go @@ -0,0 +1,73 @@ +package stats + +import ( + "math" + "testing" + "time" +) + +func TestBMI(t *testing.T) { + if got := BMI(0, 70); got != 0 { + t.Errorf("BMI(0, 70) = %v, want 0", got) + } + if got := BMI(-10, 70); got != 0 { + t.Errorf("BMI(-10, 70) = %v, want 0", got) + } + + got := BMI(175, 70) + want := 70 / (1.75 * 1.75) + if math.Abs(got-want) > 1e-9 { + t.Errorf("BMI(175, 70) = %v, want %v", got, want) + } +} + +func TestBodyFatWeight(t *testing.T) { + got := BodyFatWeight(80, 15) + if got != 12 { + t.Errorf("BodyFatWeight(80, 15) = %v, want 12", got) + } +} + +func TestNew(t *testing.T) { + empty := New(nil) + if empty.Average != nil || empty.Median != nil || + empty.Max != nil || empty.Min != nil { + t.Fatalf("New(nil) = %+v, want nil pointers", empty) + } + + d1 := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + d2 := time.Date(2020, 1, 2, 0, 0, 0, 0, time.UTC) + d3 := time.Date(2020, 1, 3, 0, 0, 0, 0, time.UTC) + d4 := time.Date(2020, 1, 4, 0, 0, 0, 0, time.UTC) + + odd := New([]DateValue{ + {Date: d1, Value: 30}, + {Date: d2, Value: 10}, + {Date: d3, Value: 20}, + }) + if odd.Min.Value != 10 || !odd.Min.Date.Equal(d2) { + t.Errorf("odd.Min = %+v, want 10 on %v", odd.Min, d2) + } + if odd.Max.Value != 30 || !odd.Max.Date.Equal(d1) { + t.Errorf("odd.Max = %+v, want 30 on %v", odd.Max, d1) + } + if *odd.Average != 20 { + t.Errorf("odd.Average = %v, want 20", *odd.Average) + } + if *odd.Median != 20 { + t.Errorf("odd.Median = %v, want 20", *odd.Median) + } + + even := New([]DateValue{ + {Date: d1, Value: 40}, + {Date: d2, Value: 10}, + {Date: d3, Value: 20}, + {Date: d4, Value: 30}, + }) + if *even.Average != 25 { + t.Errorf("even.Average = %v, want 25", *even.Average) + } + if *even.Median != 25 { + t.Errorf("even.Median = %v, want 25", *even.Median) + } +} diff --git a/internal/dqs/store/entries.go b/internal/dqs/store/entries.go new file mode 100644 index 0000000..c281556 --- /dev/null +++ b/internal/dqs/store/entries.go @@ -0,0 +1,116 @@ +package store + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "sync" + + "code.chimeric.al/dwrz/dqs/internal/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 + ) + + for _, f := range files { + name := f.Name() + if f.IsDir() || filepath.Ext(name) != ".json" || name == userFile { + continue + } + + wg.Add(1) + sem <- struct{}{} + + go func(name string) { + defer func() { + <-sem + wg.Done() + }() + + date := name[:len(name)-len(filepath.Ext(name))] + + entry, err := s.GetEntry(date) + if err != nil { + errs <- err + return + } + + echan <- entry + }(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/internal/dqs/store/store.go b/internal/dqs/store/store.go new file mode 100644 index 0000000..73b295a --- /dev/null +++ b/internal/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/internal/dqs/store/store_test.go b/internal/dqs/store/store_test.go new file mode 100644 index 0000000..0f85890 --- /dev/null +++ b/internal/dqs/store/store_test.go @@ -0,0 +1,122 @@ +package store + +import ( + "testing" + "time" + + "code.chimeric.al/dwrz/dqs/internal/dqs/diet" + "code.chimeric.al/dwrz/dqs/internal/dqs/entry" + "code.chimeric.al/dwrz/dqs/internal/dqs/user" + "code.chimeric.al/dwrz/dqs/internal/dqs/user/units" +) + +func TestStoreUserAndEntries(t *testing.T) { + s, err := Open(t.TempDir()) + if err != nil { + t.Fatalf("Open() error = %v", err) + } + + u := &user.User{ + Name: "Test", + Diet: diet.Vegetarian, + Units: units.Metric, + Height: 175, + Weight: 70, + } + if err := s.UpdateUser(u); err != nil { + t.Fatalf("UpdateUser() error = %v", err) + } + + gotUser, err := s.GetUser() + if err != nil { + t.Fatalf("GetUser() error = %v", err) + } + if gotUser.Name != u.Name || gotUser.Diet != u.Diet { + t.Errorf("GetUser() = %+v, want %+v", gotUser, u) + } + + d1 := time.Date(2020, 1, 2, 0, 0, 0, 0, time.UTC) + d2 := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + + e1 := entry.New(d1, u) + c, err := e1.Category("f") + if err != nil { + t.Fatalf("Category(f) error = %v", err) + } + if err := c.Add(2); err != nil { + t.Fatalf("Add(2) error = %v", err) + } + if err := s.UpdateEntry(e1); err != nil { + t.Fatalf("UpdateEntry(e1) error = %v", err) + } + + e2 := entry.New(d2, u) + if err := s.UpdateEntry(e2); err != nil { + t.Fatalf("UpdateEntry(e2) error = %v", err) + } + + // Verify that mutating e1 did not mutate e2's template categories. + if got := e2.Score(); got != 0 { + t.Errorf("e2.Score() = %v, want 0", got) + } + + entries, err := s.GetAllEntries() + if err != nil { + t.Fatalf("GetAllEntries() error = %v", err) + } + if len(entries) != 2 { + t.Fatalf("len(entries) = %d, want 2", len(entries)) + } + if !entries[0].Date.Equal(d2) || !entries[1].Date.Equal(d1) { + t.Errorf( + "entries dates = [%v, %v], want [%v, %v]", + entries[0].Date, entries[1].Date, d2, d1, + ) + } + if got := entries[1].Score(); got != 4 { + t.Errorf("entries[1].Score() = %v, want 4", got) + } + + if err := s.DeleteEntry(d1.Format(entry.DateFormat)); err != nil { + t.Fatalf("DeleteEntry() error = %v", err) + } + entries, err = s.GetAllEntries() + if err != nil { + t.Fatalf("GetAllEntries() error = %v", err) + } + if len(entries) != 1 { + t.Errorf("len(entries) after delete = %d, want 1", len(entries)) + } +} + +func BenchmarkGetAllEntries(b *testing.B) { + b.StopTimer() + + s, err := Open(b.TempDir()) + if err != nil { + b.Fatalf("failed to open store: %v", err) + } + + 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.Fatalf("failed to create entry: %v", err) + } + } + + b.ResetTimer() + + b.StartTimer() + for i := 0; i < b.N; i++ { + if _, err := s.GetAllEntries(); err != nil { + b.Fatalf("failed to get all entries: %v", err) + } + } + b.StopTimer() +} diff --git a/internal/dqs/store/user.go b/internal/dqs/store/user.go new file mode 100644 index 0000000..645c055 --- /dev/null +++ b/internal/dqs/store/user.go @@ -0,0 +1,42 @@ +package store + +import ( + "encoding/json" + "fmt" + "os" + + "code.chimeric.al/dwrz/dqs/internal/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/internal/dqs/user/format.go b/internal/dqs/user/format.go new file mode 100644 index 0000000..ddcb68f --- /dev/null +++ b/internal/dqs/user/format.go @@ -0,0 +1,78 @@ +package user + +import ( + "fmt" + "strings" + + "code.chimeric.al/dwrz/dqs/internal/dqs/stats" + "code.chimeric.al/dwrz/dqs/pkg/color" +) + +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(string(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(string(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/internal/dqs/user/units.go b/internal/dqs/user/units.go new file mode 100644 index 0000000..a0a0faa --- /dev/null +++ b/internal/dqs/user/units.go @@ -0,0 +1,40 @@ +package user + +import ( + "code.chimeric.al/dwrz/dqs/internal/dqs/stats" + "code.chimeric.al/dwrz/dqs/internal/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/internal/dqs/user/units/convert.go b/internal/dqs/user/units/convert.go new file mode 100644 index 0000000..7e79aa3 --- /dev/null +++ b/internal/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/internal/dqs/user/units/units.go b/internal/dqs/user/units/units.go new file mode 100644 index 0000000..4a608dd --- /dev/null +++ b/internal/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/internal/dqs/user/units/units_test.go b/internal/dqs/user/units/units_test.go new file mode 100644 index 0000000..fe36a29 --- /dev/null +++ b/internal/dqs/user/units/units_test.go @@ -0,0 +1,32 @@ +package units + +import ( + "math" + "testing" +) + +func TestConversions(t *testing.T) { + const inches = 70.0 + cm := InchesToCentimeter(inches) + if math.Abs(CentimeterToInches(cm)-inches) > 1e-9 { + t.Errorf("round-trip inches = %v, want %v", CentimeterToInches(cm), inches) + } + + const lbs = 160.0 + kg := PoundsToKilogram(lbs) + if math.Abs(KilogramToPounds(kg)-lbs) > 1e-6 { + t.Errorf("round-trip lbs = %v, want %v", KilogramToPounds(kg), lbs) + } +} + +func TestSystem(t *testing.T) { + if !Valid(Metric) || !Valid(Imperial) || Valid("other") { + t.Errorf("unexpected Valid() results") + } + if Metric.Weight() != Kilogram || Imperial.Weight() != Pounds { + t.Errorf("unexpected Weight() units") + } + if Metric.Height() != Centimeter || Imperial.Height() != Inches { + t.Errorf("unexpected Height() units") + } +} diff --git a/internal/dqs/user/user.go b/internal/dqs/user/user.go new file mode 100644 index 0000000..14bd463 --- /dev/null +++ b/internal/dqs/user/user.go @@ -0,0 +1,43 @@ +package user + +import ( + "fmt" + "time" + + "code.chimeric.al/dwrz/dqs/internal/dqs/diet" + "code.chimeric.al/dwrz/dqs/internal/dqs/user/units" +) + +// User holds user settings and latest measurements. +// Height, TargetWeight, and Weight are stored in metric units (cm and kg), +// regardless of the display system selected in 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 +}