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