Add dqs pkg
This commit is contained in:
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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] <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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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:
|
||||
`
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user