Add dqs pkg

This commit is contained in:
dwrz
2026-10-26 15:14:43 +00:00
parent e0378d5e3a
commit 0399a8777a
57 changed files with 3294 additions and 0 deletions
+22
View File
@@ -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,
}
+130
View File
@@ -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
}
+60
View File
@@ -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")
}
}
+122
View File
@@ -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,
}
)
+84
View File
@@ -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,
}
)
+75
View File
@@ -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
}
+68
View File
@@ -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
}
+53
View File
@@ -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)
}
+204
View File
@@ -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
}
+26
View File
@@ -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",
}
+40
View File
@@ -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",
}
+40
View File
@@ -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",
}
+63
View File
@@ -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
}
+34
View File
@@ -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()
}
+24
View File
@@ -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()
}
+33
View File
@@ -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()
}
+26
View File
@@ -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()
}
+25
View File
@@ -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()
}
+21
View File
@@ -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()
}
+39
View File
@@ -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()
}
+33
View File
@@ -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()
}
+32
View File
@@ -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:
`
+34
View File
@@ -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()
}
+25
View File
@@ -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()
}
+20
View File
@@ -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()
}
+23
View File
@@ -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()
}
+135
View File
@@ -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
}
+25
View File
@@ -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
}
+75
View File
@@ -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
}
+27
View File
@@ -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",
}
+31
View File
@@ -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",
}
+74
View File
@@ -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
}
+67
View File
@@ -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
}
+23
View File
@@ -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,
}
+21
View File
@@ -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,
}
+22
View File
@@ -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,
}
+63
View File
@@ -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)
}
+78
View File
@@ -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)
}
+157
View File
@@ -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()
}
+32
View File
@@ -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)
}
+70
View File
@@ -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
}
+84
View File
@@ -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,
)
}
}
}
+126
View File
@@ -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()
}
+101
View File
@@ -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,
}
}
+21
View File
@@ -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
}
+70
View File
@@ -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
}
+73
View File
@@ -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)
}
}
+116
View File
@@ -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
}
+27
View File
@@ -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
}
+122
View File
@@ -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()
}
+42
View File
@@ -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
}
+78
View File
@@ -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()
}
+40
View File
@@ -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)
}
+17
View File
@@ -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
}
+46
View File
@@ -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
}
}
+32
View File
@@ -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")
}
}
+43
View File
@@ -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
}