diff --git a/api/item.go b/api/item.go new file mode 100644 index 0000000..8eba5cf --- /dev/null +++ b/api/item.go @@ -0,0 +1,130 @@ +package api + +import ( + "github.com/gin-gonic/gin" + "github.com/gisle/stufflog/database" + "github.com/gisle/stufflog/models" + "github.com/gisle/stufflog/services" + "github.com/gisle/stufflog/slerrors" + "sort" +) + +func Items(g *gin.RouterGroup, db database.Database, auth *services.AuthService) { + type resObj struct { + Item *models.Item `json:"item,omitempty"` + Items []*models.Item `json:"items,omitempty"` + } + + g.Use(auth.GinSessionMiddleware(true)) + + // GET / – List own items + g.GET("/", func(c *gin.Context) { + user := auth.UserFromContext(c) + + items, err := db.Items().ListUser(c.Request.Context(), *user) + if err != nil { + slerrors.GinRespond(c, err) + return + } + if items == nil { + items = []*models.Item{} + } + + sort.Sort(models.ItemsByName(items)) + + c.JSON(200, resObj{Items: items}) + }) + + // GET /:id – Find an item + g.GET("/:id", func(c *gin.Context) { + user := auth.UserFromContext(c) + + item, err := db.Items().FindID(c.Request.Context(), c.Param("id")) + if err != nil { + slerrors.GinRespond(c, err) + return + } + if item.UserID != user.ID { + slerrors.GinRespond(c, slerrors.NotFound("Item")) + return + } + + c.JSON(200, resObj{Item: item}) + }) + + // POST / – Create an item + g.POST("/", func(c *gin.Context) { + user := auth.UserFromContext(c) + item := models.Item{} + + err := c.BindJSON(&item) + if err != nil { + slerrors.GinRespond(c, &slerrors.SLError{Code: 400, Text: err.Error()}) + return + } + + item.GenerateID() + item.UserID = user.ID + + err = db.Items().Insert(c.Request.Context(), item) + if err != nil { + slerrors.GinRespond(c, err) + return + } + + c.JSON(200, resObj{Item: &item}) + }) + + // PATCH /:id – Update an item + g.PATCH("/:id", func(c *gin.Context) { + user := auth.UserFromContext(c) + updates := make([]*models.ItemUpdate, 0, 8) + + err := c.BindJSON(&updates) + if err != nil { + slerrors.GinRespond(c, &slerrors.SLError{Code: 400, Text: err.Error()}) + return + } + + item, err := db.Items().FindID(c.Request.Context(), c.Param("id")) + if err != nil { + slerrors.GinRespond(c, err) + return + } + if item.UserID != user.ID { + slerrors.GinRespond(c, slerrors.NotFound("Item")) + return + } + + item, err = db.Items().Update(c.Request.Context(), *item, updates) + if err != nil { + slerrors.GinRespond(c, err) + return + } + + c.JSON(200, resObj{Item: item}) + }) + + // DELETE /:id – Delete an item + g.DELETE("/:id", func(c *gin.Context) { + user := auth.UserFromContext(c) + + item, err := db.Items().FindID(c.Request.Context(), c.Param("id")) + if err != nil { + slerrors.GinRespond(c, err) + return + } + if item.UserID != user.ID { + slerrors.GinRespond(c, slerrors.NotFound("Period")) + return + } + + err = db.Items().Remove(c.Request.Context(), *item) + if err != nil { + slerrors.GinRespond(c, err) + return + } + + c.JSON(200, resObj{Item: item}) + }) +} diff --git a/api/period.go b/api/period.go index 491a531..4728684 100644 --- a/api/period.go +++ b/api/period.go @@ -78,21 +78,12 @@ func Period(g *gin.RouterGroup, db database.Database, scoring *services.ScoringS } period.GenerateIDs() - period.RemoveDuplicateTags() period.UserID = user.ID - period.Logs = make([]models.PeriodLog, 0) + period.Logs = make([]models.Log, 0) period.ShouldReScore = false - if period.Tags == nil { - period.Tags = make([]string, 0) - } if period.Goals == nil { - period.Goals = make([]models.PeriodGoal, 0) - } - for i := range period.Goals { - if period.Goals[i].SubGoals == nil { - period.Goals[i].SubGoals = make([]models.PeriodSubGoal, 0) - } + period.Goals = make([]models.Goal, 0) } err = db.Periods().Insert(c.Request.Context(), period) diff --git a/database/database.go b/database/database.go index 8f55af7..ba5bd79 100644 --- a/database/database.go +++ b/database/database.go @@ -13,6 +13,7 @@ type Database interface { UserSessions() repositories.UserSessionRepository Activities() repositories.ActivityRepository Periods() repositories.PeriodRepository + Items() repositories.ItemRepository } // Init gets you database based on the configuration provided. diff --git a/database/drivers/bolt/db.go b/database/drivers/bolt/db.go index a64f2fa..6d57b82 100644 --- a/database/drivers/bolt/db.go +++ b/database/drivers/bolt/db.go @@ -15,6 +15,7 @@ type Database struct { userSessions repositories.UserSessionRepository activities repositories.ActivityRepository periods repositories.PeriodRepository + items repositories.ItemRepository } func (database *Database) Users() repositories.UserRepository { @@ -33,6 +34,10 @@ func (database *Database) Periods() repositories.PeriodRepository { return database.periods } +func (database *Database) Items() repositories.ItemRepository { + return database.items +} + func Init(cfg config.Database) (*Database, error) { opts := *bbolt.DefaultOptions opts.Timeout = time.Second * 5 @@ -57,12 +62,17 @@ func Init(cfg config.Database) (*Database, error) { if err != nil { return nil, err } + items, err := newItemRepository(db) + if err != nil { + return nil, err + } database := &Database{ users: users, userSessions: userSessions, periods: periods, activities: activities, + items: items, } return database, nil diff --git a/database/drivers/bolt/item.go b/database/drivers/bolt/item.go new file mode 100644 index 0000000..093b06b --- /dev/null +++ b/database/drivers/bolt/item.go @@ -0,0 +1,197 @@ +package bolt + +import ( + "context" + "github.com/gisle/stufflog/database/repositories" + "github.com/gisle/stufflog/models" + "github.com/gisle/stufflog/slerrors" + "github.com/vmihailenco/msgpack/v4" + "go.etcd.io/bbolt" +) + +var bnItems = []byte("Item") + +type itemRepository struct { + db *bbolt.DB + userIdIdx *index +} + +func (r *itemRepository) FindID(ctx context.Context, id string) (*models.Item, error) { + item := new(models.Item) + err := r.db.View(func(tx *bbolt.Tx) error { + value := tx.Bucket(bnItems).Get(unsafeStringToBytes(id)) + if value == nil { + return slerrors.NotFound("Period") + } + err := msgpack.Unmarshal(value, item) + if err != nil { + return err + } + return nil + }) + if err != nil { + return nil, err + } + + return item, nil +} + +func (r *itemRepository) List(ctx context.Context) ([]*models.Item, error) { + items := make([]*models.Item, 0, 16) + err := r.db.View(func(tx *bbolt.Tx) error { + cursor := tx.Bucket(bnItems).Cursor() + + for key, value := cursor.First(); key != nil; key, value = cursor.Next() { + item := new(models.Item) + err := msgpack.Unmarshal(value, item) + if err != nil { + return err + } + + items = append(items, item) + } + + return nil + }) + if err != nil { + return nil, err + } + + return items, nil +} + +func (r *itemRepository) ListUser(ctx context.Context, user models.User) ([]*models.Item, error) { + items := make([]*models.Item, 0, 16) + err := r.db.View(func(tx *bbolt.Tx) error { + bucket := tx.Bucket(bnItems) + + ids, err := r.userIdIdx.WithTx(tx).Get(user.ID) + if err != nil { + return err + } + + for _, id := range ids { + value := bucket.Get(id) + if value == nil { + continue + } + + item := new(models.Item) + err := msgpack.Unmarshal(value, item) + if err != nil { + return err + } + + items = append(items, item) + } + + return nil + }) + if err != nil { + return nil, err + } + + return items, nil +} + +func (r *itemRepository) Insert(ctx context.Context, item models.Item) error { + value, err := msgpack.Marshal(&item) + if err != nil { + return err + } + + return r.db.Update(func(tx *bbolt.Tx) error { + err := tx.Bucket(bnItems).Put(unsafeStringToBytes(item.ID), value) + if err != nil { + return err + } + + err = r.index(tx, &item) + if err != nil { + return err + } + + return nil + }) +} + +func (r *itemRepository) Update(ctx context.Context, item models.Item, updates []*models.ItemUpdate) (*models.Item, error) { + for _, update := range updates { + err := item.ApplyUpdate(*update) + if err != nil { + return nil, err + } + } + + value, err := msgpack.Marshal(&item) + if err != nil { + return nil, err + } + + err = r.db.Update(func(tx *bbolt.Tx) error { + return tx.Bucket(bnItems).Put(unsafeStringToBytes(item.ID), value) + }) + if err != nil { + return nil, err + } + + return &item, nil +} + +func (r *itemRepository) Remove(ctx context.Context, item models.Item) error { + return r.db.Update(func(tx *bbolt.Tx) error { + err := tx.Bucket(bnItems).Delete(unsafeStringToBytes(item.ID)) + if err != nil { + return err + } + + err = r.unIndex(tx, &item) + if err != nil { + return err + } + + return nil + }) +} + +func (r *itemRepository) index(tx *bbolt.Tx, item *models.Item) error { + idBytes := unsafeStringToBytes(item.ID) + + err := r.userIdIdx.WithTx(tx).Set(idBytes, item.UserID) + if err != nil { + return err + } + + return nil +} + +func (r *itemRepository) unIndex(tx *bbolt.Tx, item *models.Item) error { + idBytes := unsafeStringToBytes(item.ID) + + err := r.userIdIdx.WithTx(tx).Set(idBytes) + if err != nil { + return err + } + + return nil +} + +func newItemRepository(db *bbolt.DB) (repositories.ItemRepository, error) { + err := db.Update(func(tx *bbolt.Tx) error { + _, err := tx.CreateBucketIfNotExists(bnItems) + return err + }) + if err != nil { + return nil, err + } + + userIdIdx, err := newModelIndex(db, "Item", "UserID") + if err != nil { + return nil, err + } + + return &itemRepository{ + db: db, + userIdIdx: userIdIdx, + }, nil +} diff --git a/database/repositories/item.go b/database/repositories/item.go new file mode 100644 index 0000000..110a151 --- /dev/null +++ b/database/repositories/item.go @@ -0,0 +1,15 @@ +package repositories + +import ( + "context" + "github.com/gisle/stufflog/models" +) + +type ItemRepository interface { + FindID(ctx context.Context, id string) (*models.Item, error) + List(ctx context.Context) ([]*models.Item, error) + ListUser(ctx context.Context, user models.User) ([]*models.Item, error) + Insert(ctx context.Context, item models.Item) error + Update(ctx context.Context, item models.Item, updates []*models.ItemUpdate) (*models.Item, error) + Remove(ctx context.Context, item models.Item) error +} diff --git a/main.go b/main.go index a2d1931..ecce7a4 100644 --- a/main.go +++ b/main.go @@ -64,6 +64,7 @@ func main() { api.User(router.Group("/api/user"), auth) api.Activity(router.Group("/api/activity"), db, auth) api.Period(router.Group("/api/period"), db, scoring, auth) + api.Items(router.Group("/api/item"), db, auth) // Setup UI if uiRoot != "" { @@ -71,6 +72,9 @@ func main() { router.GET("/activities/", func(c *gin.Context) { http.ServeFile(c.Writer, c.Request, path.Join(uiRoot, "index.html")) }) + router.GET("/items/", func(c *gin.Context) { + http.ServeFile(c.Writer, c.Request, path.Join(uiRoot, "index.html")) + }) } // Start listening diff --git a/models/goal.go b/models/goal.go new file mode 100644 index 0000000..6d0fed3 --- /dev/null +++ b/models/goal.go @@ -0,0 +1,9 @@ +package models + +// A Goal is a declared investment into the goal. +type Goal struct { + ID string `json:"id"` + ActivityID string `json:"activityId"` + ItemID string `json:"itemId"` + PointCount int `json:"pointCount"` +} diff --git a/models/item.go b/models/item.go new file mode 100644 index 0000000..bbd860a --- /dev/null +++ b/models/item.go @@ -0,0 +1,45 @@ +package models + +import "github.com/gisle/stufflog/internal/generate" + +type Item struct { + ID string `json:"id" msgpack:"id"` + UserID string `json:"userId" msgpack:"userId"` + Active bool `json:"active" msgpack:"active"` + Name string `json:"name" msgpack:"name"` + Multiplier float64 `json:"multiplier" msgpack:"multiplier"` +} + +func (item *Item) GenerateID() { + item.ID = generate.ID("I", 16) +} + +func (item *Item) ApplyUpdate(update ItemUpdate) error { + if update.SetActive != nil { + item.Active = *update.SetActive + } + if update.SetName != nil { + item.Name = *update.SetName + } + + return nil +} + +type ItemUpdate struct { + SetName *string `json:"setName"` + SetActive *bool `json:"setActive"` +} + +type ItemsByName []*Item + +func (items ItemsByName) Len() int { + return len(items) +} + +func (items ItemsByName) Less(i, j int) bool { + return items[i].Name < items[j].Name +} + +func (items ItemsByName) Swap(i, j int) { + items[i], items[j] = items[j], items[i] +} diff --git a/models/log.go b/models/log.go new file mode 100644 index 0000000..e820413 --- /dev/null +++ b/models/log.go @@ -0,0 +1,17 @@ +package models + +import "time" + +// Log is a logged performance of an activity during the period. SubGoalID is optional, but GoalID is not. The +// points is the points calculated on the time of submission and does not need to reflect the latest. +type Log struct { + Date time.Time `json:"date"` + ID string `json:"id"` + SubActivityID string `json:"subActivityId"` + GoalID string `json:"goalId"` + SubGoalID string `json:"subGoalId"` + Description string `json:"description"` + SubmitTime time.Time `json:"submitTime"` + Amount int `json:"amount"` + Score *Score `json:"score"` +} diff --git a/models/period.go b/models/period.go index 9c2e8a4..7c946f0 100644 --- a/models/period.go +++ b/models/period.go @@ -16,9 +16,8 @@ type Period struct { ShouldReScore bool `json:"-" msgpack:"-"` - Tags []string `json:"tags"` - Goals []PeriodGoal `json:"goals"` - Logs []PeriodLog `json:"logs"` + Goals []Goal `json:"goals"` + Logs []Log `json:"logs"` } // GenerateIDs generates IDs. It should only be used initially. @@ -26,10 +25,6 @@ func (period *Period) GenerateIDs() { period.ID = generate.ID("P", 12) for i := range period.Goals { period.Goals[i].ID = generate.ID("PG", 16) - - for j := range period.Goals[i].SubGoals { - period.Goals[i].SubGoals[j].ID = generate.ID("PGS", 20) - } } for i := range period.Logs { period.Logs[i].ID = generate.ID("PL", 16) @@ -46,7 +41,6 @@ func (period *Period) Clear() { period.ShouldReScore = false - period.Tags = period.Tags[:0] period.Goals = period.Goals[:0] period.Logs = period.Logs[:0] } @@ -55,11 +49,9 @@ func (period *Period) Clear() { func (period *Period) Copy() *Period { periodCopy := *period - periodCopy.Tags = make([]string, len(period.Tags)) - copy(periodCopy.Tags, period.Tags) - periodCopy.Goals = make([]PeriodGoal, len(period.Goals)) + periodCopy.Goals = make([]Goal, len(period.Goals)) copy(periodCopy.Goals, period.Goals) - periodCopy.Logs = make([]PeriodLog, len(period.Logs)) + periodCopy.Logs = make([]Log, len(period.Logs)) copy(periodCopy.Logs, period.Logs) return &periodCopy @@ -87,29 +79,6 @@ func (period *Period) ApplyUpdate(update PeriodUpdate) (changed bool, err error) period.Name = *update.SetName } - if update.RemoveTag != nil { - for i, existingTag := range period.Tags { - if *update.AddTag == existingTag { - period.Tags = append(period.Tags[:i], period.Tags[i+1:]...) - changed = true - break - } - } - } - if update.AddTag != nil { - found := false - for _, existingTag := range period.Tags { - if *update.AddTag == existingTag { - found = true - break - } - } - if !found { - changed = true - period.Tags = append(period.Tags, *update.AddTag) - } - } - if update.AddGoal != nil { goal := *update.AddGoal goal.ID = generate.ID("PG", 16) @@ -125,9 +94,6 @@ func (period *Period) ApplyUpdate(update PeriodUpdate) (changed bool, err error) if update.AddLog != nil && update.AddLog.GoalID == "NEW" { update.AddLog.GoalID = goal.ID } - for i := range goal.SubGoals { - goal.SubGoals[i].ID = generate.ID("PGS", 20) - } changed = true } @@ -139,7 +105,6 @@ func (period *Period) ApplyUpdate(update PeriodUpdate) (changed bool, err error) } goal.PointCount = update.ReplaceGoal.PointCount - goal.SubGoals = update.ReplaceGoal.SubGoals changed = true period.ShouldReScore = true @@ -176,6 +141,30 @@ func (period *Period) ApplyUpdate(update PeriodUpdate) (changed bool, err error) period.Logs = append(period.Logs, log) changed = true } + if update.ReplaceLog != nil { + log := *update.ReplaceLog + + found := false + for i, log2 := range period.Logs { + if log.ID == log2.ID { + found = true + + goal := period.Goal(log.GoalID) + if goal == nil { + err = slerrors.NotFound("Goal") + return + } + + period.Logs[i] = log + break + } + } + + if !found { + err = slerrors.NotFound("Log") + return + } + } if update.RemoveLog != nil { found := false for i, log := range period.Logs { @@ -197,7 +186,7 @@ func (period *Period) ApplyUpdate(update PeriodUpdate) (changed bool, err error) } func (period *Period) RemoveBrokenLogs() { - goodLogs := make([]PeriodLog, 0, len(period.Logs)) + goodLogs := make([]Log, 0, len(period.Logs)) for _, log := range period.Logs { goal := period.Goal(log.GoalID) @@ -205,34 +194,13 @@ func (period *Period) RemoveBrokenLogs() { continue } - if log.SubGoalID != "" { - if goal.SubGoal(log.SubGoalID) == nil { - continue - } - } - goodLogs = append(goodLogs, log) } period.Logs = goodLogs } -func (period *Period) RemoveDuplicateTags() { - deleteList := make([]int, 0, 2) - - found := make(map[string]bool) - for i, tag := range period.Tags { - if found[tag] { - deleteList = append(deleteList, i-len(deleteList)) - } - found[tag] = true - } - for _, index := range deleteList { - period.Tags = append(period.Tags[:index], period.Tags[index+1:]...) - } -} - -func (period *Period) Goal(id string) *PeriodGoal { +func (period *Period) Goal(id string) *Goal { for i, goal := range period.Goals { if goal.ID == id { return &period.Goals[i] @@ -263,79 +231,33 @@ func (period *Period) DayHadActivity(date time.Time, activityID string) bool { return false } -// A PeriodGoal is a declared investment into the goal. -type PeriodGoal struct { - ID string `json:"id"` - ActivityID string `json:"activityId"` - PointCount int `json:"pointCount"` - - SubGoals []PeriodSubGoal `json:"subGoals"` -} - -func (goal *PeriodGoal) SubGoal(id string) *PeriodSubGoal { - if goal == nil { - return nil - } - - for i, subGoal := range goal.SubGoals { - if subGoal.ID == id { - return &goal.SubGoals[i] - } - } - - return nil -} - -// A PeriodSubGoal is a specific sub-goal that should either punish or boost performance. For example, it could represent -// a project or technique that should take priority, or a guilty pleasure that should definitely not be. -type PeriodSubGoal struct { - ID string `json:"id"` - Name string `json:"name"` - Multiplier float64 `json:"multiplier"` -} - -// PeriodLog is a logged performance of an activity during the period. SubGoalID is optional, but GoalID is not. The -// points is the points calculated on the time of submission and does not need to reflect the latest. -type PeriodLog struct { - Date time.Time `json:"date"` - ID string `json:"id"` - SubActivityID string `json:"subActivityId"` - GoalID string `json:"goalId"` - SubGoalID string `json:"subGoalId"` - Description string `json:"description"` - SubmitTime time.Time `json:"submitTime"` - Amount int `json:"amount"` - Score *Score `json:"score"` -} - // PeriodUpdate describes a change to a period type PeriodUpdate struct { SetFrom *time.Time `json:"setFrom"` SetTo *time.Time `json:"setTo"` SetName *string `json:"setName"` - AddLog *PeriodLog `json:"addLog"` - RemoveLog *string `json:"removeLog"` - AddGoal *PeriodGoal `json:"addGoal"` - ReplaceGoal *PeriodGoal `json:"replaceGoal"` - RemoveGoal *string `json:"removeGoal"` - AddTag *string `json:"addTag"` - RemoveTag *string `json:"removeTag"` + AddLog *Log `json:"addLog"` + ReplaceLog *Log `json:"replaceGoal"` + RemoveLog *string `json:"removeLog"` + AddGoal *Goal `json:"addGoal"` + ReplaceGoal *Goal `json:"replaceGoal"` + RemoveGoal *string `json:"removeGoal"` } type Score struct { - ActivityScore float64 `json:"activityScore"` - Amount int `json:"amount"` - SubGoalMultiplier *float64 `json:"subGoalMultiplier"` - DailyBonus int `json:"dailyBonus"` + ActivityScore float64 `json:"activityScore"` + Amount int `json:"amount"` + ItemMultiplier *float64 `json:"subGoalMultiplier"` + DailyBonus int `json:"dailyBonus"` Total int64 `json:"total"` } func (s *Score) Calc() { subGoalMultiplier := 1.0 - if s.SubGoalMultiplier != nil { - subGoalMultiplier = *s.SubGoalMultiplier + if s.ItemMultiplier != nil { + subGoalMultiplier = *s.ItemMultiplier } s.Total = int64(s.DailyBonus) + int64(s.ActivityScore*float64(s.Amount)*subGoalMultiplier) diff --git a/services/scoring.go b/services/scoring.go index 0367743..a504268 100644 --- a/services/scoring.go +++ b/services/scoring.go @@ -13,11 +13,11 @@ type ScoringService struct { db database.Database } -func (s *ScoringService) ScoreOne(ctx context.Context, period models.Period, log models.PeriodLog) (*models.Score, error) { +func (s *ScoringService) ScoreOne(ctx context.Context, period models.Period, log models.Log) (*models.Score, error) { return s.scoreOne(ctx, period, log) } -func (s *ScoringService) scoreOne(ctx context.Context, period models.Period, log models.PeriodLog) (*models.Score, error) { +func (s *ScoringService) scoreOne(ctx context.Context, period models.Period, log models.Log) (*models.Score, error) { goal := period.Goal(log.GoalID) if goal == nil { return nil, slerrors.NotFound("Goal") @@ -37,16 +37,6 @@ func (s *ScoringService) scoreOne(ctx context.Context, period models.Period, log Amount: log.Amount, } - if log.SubGoalID != "" { - subGoal := goal.SubGoal(log.SubGoalID) - if subGoal == nil { - return nil, slerrors.NotFound("Sub-Goal") - } - - multiplier := subGoal.Multiplier - score.SubGoalMultiplier = &multiplier - } - if activity.DailyBonus > 0 { isFirst := true for _, log2 := range period.Logs { diff --git a/svelte-ui/src/App.svelte b/svelte-ui/src/App.svelte index 8ae50a5..819ddab 100644 --- a/svelte-ui/src/App.svelte +++ b/svelte-ui/src/App.svelte @@ -3,6 +3,7 @@ import LogPage from "./routes/LogPage"; import ActivitiesPage from "./routes/ActivitiesPage"; + import ItemPage from "./routes/ItemPage"; import LoginModal from "./modals/LoginModal"; import CreateActivityModal from "./modals/CreateActivityModal"; @@ -31,7 +32,9 @@