Browse Source

clean up svelte ui big time, and add beginnings of item stuff.

master
Gisle Aune 5 years ago
parent
commit
bb13195f4b
  1. 130
      api/item.go
  2. 13
      api/period.go
  3. 1
      database/database.go
  4. 10
      database/drivers/bolt/db.go
  5. 197
      database/drivers/bolt/item.go
  6. 15
      database/repositories/item.go
  7. 4
      main.go
  8. 9
      models/goal.go
  9. 45
      models/item.go
  10. 17
      models/log.go
  11. 162
      models/period.go
  12. 14
      services/scoring.go
  13. 6
      svelte-ui/src/App.svelte
  14. 78
      svelte-ui/src/api/stufflog.js
  15. 23
      svelte-ui/src/components/ActivityAmount.svelte
  16. 26
      svelte-ui/src/components/ActivityDisplay.svelte
  17. 4
      svelte-ui/src/components/ActivityIcon.svelte
  18. 10
      svelte-ui/src/components/AddBoi.svelte
  19. 51
      svelte-ui/src/components/Col.svelte
  20. 20
      svelte-ui/src/components/ItemDisplay.svelte
  21. 2
      svelte-ui/src/components/Menu.svelte
  22. 2
      svelte-ui/src/components/MenuItem.svelte
  23. 5
      svelte-ui/src/components/PointsBar.svelte
  24. 8
      svelte-ui/src/components/Row.svelte
  25. 51
      svelte-ui/src/components/Table.svelte
  26. 38
      svelte-ui/src/components/tables/GoalTable.svelte
  27. 30
      svelte-ui/src/components/tables/ItemTable.svelte
  28. 66
      svelte-ui/src/components/tables/LogTable.svelte
  29. 30
      svelte-ui/src/components/tables/SubActivityTable.svelte
  30. 42
      svelte-ui/src/modals/AddPeriodGoalModal.svelte
  31. 30
      svelte-ui/src/modals/AddPeriodLogModal.svelte
  32. 24
      svelte-ui/src/modals/InfoPeriodLogModal.svelte
  33. 2
      svelte-ui/src/modals/RemovePeriodLogModal.svelte
  34. 6
      svelte-ui/src/modals/RemoveSubActivityModal.svelte
  35. 12
      svelte-ui/src/models/item.d.ts
  36. 1
      svelte-ui/src/models/item.js
  37. 74
      svelte-ui/src/routes/ActivitiesPage.svelte
  38. 49
      svelte-ui/src/routes/ItemPage.svelte
  39. 166
      svelte-ui/src/routes/LogPage.svelte
  40. 58
      svelte-ui/src/stores/items.js
  41. 1
      svelte-ui/src/stores/modal.js
  42. 23
      svelte-ui/src/stores/stufflog.js

130
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})
})
}

13
api/period.go

@ -78,21 +78,12 @@ func Period(g *gin.RouterGroup, db database.Database, scoring *services.ScoringS
} }
period.GenerateIDs() period.GenerateIDs()
period.RemoveDuplicateTags()
period.UserID = user.ID period.UserID = user.ID
period.Logs = make([]models.PeriodLog, 0)
period.Logs = make([]models.Log, 0)
period.ShouldReScore = false period.ShouldReScore = false
if period.Tags == nil {
period.Tags = make([]string, 0)
}
if period.Goals == nil { 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) err = db.Periods().Insert(c.Request.Context(), period)

1
database/database.go

@ -13,6 +13,7 @@ type Database interface {
UserSessions() repositories.UserSessionRepository UserSessions() repositories.UserSessionRepository
Activities() repositories.ActivityRepository Activities() repositories.ActivityRepository
Periods() repositories.PeriodRepository Periods() repositories.PeriodRepository
Items() repositories.ItemRepository
} }
// Init gets you database based on the configuration provided. // Init gets you database based on the configuration provided.

10
database/drivers/bolt/db.go

@ -15,6 +15,7 @@ type Database struct {
userSessions repositories.UserSessionRepository userSessions repositories.UserSessionRepository
activities repositories.ActivityRepository activities repositories.ActivityRepository
periods repositories.PeriodRepository periods repositories.PeriodRepository
items repositories.ItemRepository
} }
func (database *Database) Users() repositories.UserRepository { func (database *Database) Users() repositories.UserRepository {
@ -33,6 +34,10 @@ func (database *Database) Periods() repositories.PeriodRepository {
return database.periods return database.periods
} }
func (database *Database) Items() repositories.ItemRepository {
return database.items
}
func Init(cfg config.Database) (*Database, error) { func Init(cfg config.Database) (*Database, error) {
opts := *bbolt.DefaultOptions opts := *bbolt.DefaultOptions
opts.Timeout = time.Second * 5 opts.Timeout = time.Second * 5
@ -57,12 +62,17 @@ func Init(cfg config.Database) (*Database, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
items, err := newItemRepository(db)
if err != nil {
return nil, err
}
database := &Database{ database := &Database{
users: users, users: users,
userSessions: userSessions, userSessions: userSessions,
periods: periods, periods: periods,
activities: activities, activities: activities,
items: items,
} }
return database, nil return database, nil

197
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
}

15
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
}

4
main.go

@ -64,6 +64,7 @@ func main() {
api.User(router.Group("/api/user"), auth) api.User(router.Group("/api/user"), auth)
api.Activity(router.Group("/api/activity"), db, auth) api.Activity(router.Group("/api/activity"), db, auth)
api.Period(router.Group("/api/period"), db, scoring, auth) api.Period(router.Group("/api/period"), db, scoring, auth)
api.Items(router.Group("/api/item"), db, auth)
// Setup UI // Setup UI
if uiRoot != "" { if uiRoot != "" {
@ -71,6 +72,9 @@ func main() {
router.GET("/activities/", func(c *gin.Context) { router.GET("/activities/", func(c *gin.Context) {
http.ServeFile(c.Writer, c.Request, path.Join(uiRoot, "index.html")) 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 // Start listening

9
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"`
}

45
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]
}

17
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"`
}

162
models/period.go

@ -16,9 +16,8 @@ type Period struct {
ShouldReScore bool `json:"-" msgpack:"-"` 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. // GenerateIDs generates IDs. It should only be used initially.
@ -26,10 +25,6 @@ func (period *Period) GenerateIDs() {
period.ID = generate.ID("P", 12) period.ID = generate.ID("P", 12)
for i := range period.Goals { for i := range period.Goals {
period.Goals[i].ID = generate.ID("PG", 16) 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 { for i := range period.Logs {
period.Logs[i].ID = generate.ID("PL", 16) period.Logs[i].ID = generate.ID("PL", 16)
@ -46,7 +41,6 @@ func (period *Period) Clear() {
period.ShouldReScore = false period.ShouldReScore = false
period.Tags = period.Tags[:0]
period.Goals = period.Goals[:0] period.Goals = period.Goals[:0]
period.Logs = period.Logs[:0] period.Logs = period.Logs[:0]
} }
@ -55,11 +49,9 @@ func (period *Period) Clear() {
func (period *Period) Copy() *Period { func (period *Period) Copy() *Period {
periodCopy := *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) copy(periodCopy.Goals, period.Goals)
periodCopy.Logs = make([]PeriodLog, len(period.Logs))
periodCopy.Logs = make([]Log, len(period.Logs))
copy(periodCopy.Logs, period.Logs) copy(periodCopy.Logs, period.Logs)
return &periodCopy return &periodCopy
@ -87,29 +79,6 @@ func (period *Period) ApplyUpdate(update PeriodUpdate) (changed bool, err error)
period.Name = *update.SetName 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 { if update.AddGoal != nil {
goal := *update.AddGoal goal := *update.AddGoal
goal.ID = generate.ID("PG", 16) 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" { if update.AddLog != nil && update.AddLog.GoalID == "NEW" {
update.AddLog.GoalID = goal.ID update.AddLog.GoalID = goal.ID
} }
for i := range goal.SubGoals {
goal.SubGoals[i].ID = generate.ID("PGS", 20)
}
changed = true changed = true
} }
@ -139,7 +105,6 @@ func (period *Period) ApplyUpdate(update PeriodUpdate) (changed bool, err error)
} }
goal.PointCount = update.ReplaceGoal.PointCount goal.PointCount = update.ReplaceGoal.PointCount
goal.SubGoals = update.ReplaceGoal.SubGoals
changed = true changed = true
period.ShouldReScore = true period.ShouldReScore = true
@ -176,6 +141,30 @@ func (period *Period) ApplyUpdate(update PeriodUpdate) (changed bool, err error)
period.Logs = append(period.Logs, log) period.Logs = append(period.Logs, log)
changed = true 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 { if update.RemoveLog != nil {
found := false found := false
for i, log := range period.Logs { for i, log := range period.Logs {
@ -197,7 +186,7 @@ func (period *Period) ApplyUpdate(update PeriodUpdate) (changed bool, err error)
} }
func (period *Period) RemoveBrokenLogs() { func (period *Period) RemoveBrokenLogs() {
goodLogs := make([]PeriodLog, 0, len(period.Logs))
goodLogs := make([]Log, 0, len(period.Logs))
for _, log := range period.Logs { for _, log := range period.Logs {
goal := period.Goal(log.GoalID) goal := period.Goal(log.GoalID)
@ -205,34 +194,13 @@ func (period *Period) RemoveBrokenLogs() {
continue continue
} }
if log.SubGoalID != "" {
if goal.SubGoal(log.SubGoalID) == nil {
continue
}
}
goodLogs = append(goodLogs, log) goodLogs = append(goodLogs, log)
} }
period.Logs = goodLogs 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 { for i, goal := range period.Goals {
if goal.ID == id { if goal.ID == id {
return &period.Goals[i] return &period.Goals[i]
@ -263,79 +231,33 @@ func (period *Period) DayHadActivity(date time.Time, activityID string) bool {
return false 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 // PeriodUpdate describes a change to a period
type PeriodUpdate struct { type PeriodUpdate struct {
SetFrom *time.Time `json:"setFrom"` SetFrom *time.Time `json:"setFrom"`
SetTo *time.Time `json:"setTo"` SetTo *time.Time `json:"setTo"`
SetName *string `json:"setName"` 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 { 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"` Total int64 `json:"total"`
} }
func (s *Score) Calc() { func (s *Score) Calc() {
subGoalMultiplier := 1.0 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) s.Total = int64(s.DailyBonus) + int64(s.ActivityScore*float64(s.Amount)*subGoalMultiplier)

14
services/scoring.go

@ -13,11 +13,11 @@ type ScoringService struct {
db database.Database 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) 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) goal := period.Goal(log.GoalID)
if goal == nil { if goal == nil {
return nil, slerrors.NotFound("Goal") return nil, slerrors.NotFound("Goal")
@ -37,16 +37,6 @@ func (s *ScoringService) scoreOne(ctx context.Context, period models.Period, log
Amount: log.Amount, 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 { if activity.DailyBonus > 0 {
isFirst := true isFirst := true
for _, log2 := range period.Logs { for _, log2 := range period.Logs {

6
svelte-ui/src/App.svelte

@ -3,6 +3,7 @@
import LogPage from "./routes/LogPage"; import LogPage from "./routes/LogPage";
import ActivitiesPage from "./routes/ActivitiesPage"; import ActivitiesPage from "./routes/ActivitiesPage";
import ItemPage from "./routes/ItemPage";
import LoginModal from "./modals/LoginModal"; import LoginModal from "./modals/LoginModal";
import CreateActivityModal from "./modals/CreateActivityModal"; import CreateActivityModal from "./modals/CreateActivityModal";
@ -31,7 +32,9 @@
</script> </script>
<Menu> <Menu>
<MenuItem href="/">Stufflog</MenuItem><MenuItem href="/activities/">Activities</MenuItem>
<MenuItem href="/">Stufflog</MenuItem>
<MenuItem href="/activities/">Activities</MenuItem>
<MenuItem href="/items/">Items</MenuItem>
<div slot="right"> <div slot="right">
{#if ($auth.checked && $auth.user !== null)} {#if ($auth.checked && $auth.user !== null)}
@ -43,6 +46,7 @@
<Router> <Router>
<Route path="/" component={LogPage} /> <Route path="/" component={LogPage} />
<Route path="/activities/" component={ActivitiesPage} /> <Route path="/activities/" component={ActivitiesPage} />
<Route path="/items/" component={ItemPage} />
</Router> </Router>
<Modal name="login" component={LoginModal} /> <Modal name="login" component={LoginModal} />

78
svelte-ui/src/api/stufflog.js

@ -1,6 +1,7 @@
import ky from "ky"; import ky from "ky";
import Activity, {ActivityUpdate} from "../models/activity"; import Activity, {ActivityUpdate} from "../models/activity";
import Period, {PeriodUpdate} from "../models/period"; import Period, {PeriodUpdate} from "../models/period";
import Item, {ItemUpdate} from "../models/item";
function json(data) { function json(data) {
return { return {
@ -33,7 +34,7 @@ export class StuffLogAPI {
/** /**
* List activities * List activities
* *
* @returns {Activity[]}
* @returns {Promise<Activity[]>}
*/ */
async listActivities() { async listActivities() {
const data = await k.get("/api/activity/").json(); const data = await k.get("/api/activity/").json();
@ -42,7 +43,7 @@ export class StuffLogAPI {
/** /**
* List periods * List periods
* *
* @returns {{periods: Period[], activities: Activity[]}}
* @returns {Promise<{>periods: Period[], activities: Activity[]}}
*/ */
async listPeriods() { async listPeriods() {
const data = await k.get("/api/period/").json(); const data = await k.get("/api/period/").json();
@ -53,7 +54,7 @@ export class StuffLogAPI {
* Find activity * Find activity
* *
* @param {string} id * @param {string} id
* @returns {Activity}
* @returns {Promise<Activity>}
*/ */
async findActivity(id) { async findActivity(id) {
const data = await k.get(`/api/activity/${id}`).json(); const data = await k.get(`/api/activity/${id}`).json();
@ -63,7 +64,7 @@ export class StuffLogAPI {
* Find period * Find period
* *
* @param {string} id * @param {string} id
* @returns {Period}
* @returns {Promise<Period>}
*/ */
async findPeriod(id) { async findPeriod(id) {
const data = await k.get(`/api/period/${id}`).json(); const data = await k.get(`/api/period/${id}`).json();
@ -73,7 +74,7 @@ export class StuffLogAPI {
* Create a new activity * Create a new activity
* *
* @param {Activity} activity * @param {Activity} activity
* @returns {Activity}
* @returns {Promise<Activity>}
*/ */
async postActivity(activity) { async postActivity(activity) {
const data = await k.post(`/api/activity/`, json(activity)).json(); const data = await k.post(`/api/activity/`, json(activity)).json();
@ -83,7 +84,7 @@ export class StuffLogAPI {
* Create a new period * Create a new period
* *
* @param {Period} period * @param {Period} period
* @returns {Period}
* @returns {Promise<Period>}
*/ */
async postPeriod(period) { async postPeriod(period) {
const data = await k.post(`/api/period/`, json(period)).json(); const data = await k.post(`/api/period/`, json(period)).json();
@ -94,7 +95,7 @@ export class StuffLogAPI {
* *
* @param {string} id * @param {string} id
* @param {ActivityUpdate[]} updates * @param {ActivityUpdate[]} updates
* @returns {Activity}
* @returns {Promise<Activity>}
*/ */
async patchActivity(id, ...updates) { async patchActivity(id, ...updates) {
const data = await k.patch(`/api/activity/${id}`, json(updates)).json(); const data = await k.patch(`/api/activity/${id}`, json(updates)).json();
@ -105,7 +106,7 @@ export class StuffLogAPI {
* *
* @param {string} id * @param {string} id
* @param {PeriodUpdate[]} updates * @param {PeriodUpdate[]} updates
* @returns {Period}
* @returns {Promise<Period>}
*/ */
async patchPeriod(id, ...updates) { async patchPeriod(id, ...updates) {
const data = await k.patch(`/api/period/${id}`, json(updates)).json(); const data = await k.patch(`/api/period/${id}`, json(updates)).json();
@ -115,23 +116,78 @@ export class StuffLogAPI {
* Update an activity * Update an activity
* *
* @param {string} id * @param {string} id
* @returns {Activity}
* @returns {Promise<Activity>}
*/ */
async deleteActivity(id) { async deleteActivity(id) {
const data = await k.delete(`/api/activity/${id}`).json(); const data = await k.delete(`/api/activity/${id}`).json();
return new Activity(data.activity); return new Activity(data.activity);
} }
/** /**
* Update an period * Update an period
* *
* @param {string} id * @param {string} id
* @returns {Period}
* @returns {Promise<Period>}
*/ */
async deletePeriod(id) { async deletePeriod(id) {
const data = await k.delete(`/api/period/${id}`).json(); const data = await k.delete(`/api/period/${id}`).json();
return new Period(data.period); return new Period(data.period);
} }
/**
* Get items
*
* @param {string} id
* @returns {Promise<Item[]>}
*/
async listItems() {
const data = await k.get(`/api/item/`).json();
return data.items
}
/**
* Get an item
*
* @param {string} id
* @returns {Promise<Item>}
*/
async getItem(id) {
const data = await k.get(`/api/item/${id}`).json();
return data.item
}
/**
* Post an item
*
* @param {Item} item
* @returns {Promise<Item>}
*/
async postItem(item) {
const data = await k.post(`/api/item/`, json(item)).json();
return data.item
}
/**
* Delete an item
*
* @param {string} id
* @param {ItemUpdate} updates
* @returns {Promise<Item>}
*/
async updateItem(id, ...updates) {
const data = await k.patch(`/api/item/${id}`, json(updates)).json();
return data.item
}
/**
* Delete an item
*
* @param {string} id
* @returns {Promise<Item>}
*/
async deleteItem(id) {
const data = await k.delete(`/api/item/${id}`).json();
return data.item
}
} }
const slApi = new StuffLogAPI(); const slApi = new StuffLogAPI();

23
svelte-ui/src/components/ActivityAmount.svelte

@ -0,0 +1,23 @@
<script>
import pluralize from "pluralize";
import ActivityIcon from "./ActivityIcon.svelte";
export let amount = 0
export let subActivity = null
</script>
{#if subActivity != null}
<span>{amount} {pluralize(subActivity.unitName, amount)}</span>
{:else}
<span class="unknown">{amount} (unknown unit)</span>
{/if}
<style>
span {
display: inline-block;
}
span.unknown {
color: #555;
}
</style>

26
svelte-ui/src/components/ActivityDisplay.svelte

@ -0,0 +1,26 @@
<script>
import ActivityIcon from "./ActivityIcon.svelte";
export let activity = null
export let subActivity = null
</script>
{#if activity != null && !activity.deleted}
<div class="icon"><ActivityIcon name={activity.icon} /></div>
<span class="name">{activity.name}{(subActivity && !subActivity.deleted) ? " " + subActivity.name : ""}</span>
{:else}
<div class="icon unknown"><ActivityIcon name="question" /></div>
<span class="name unknown">(Unknown)</span>
{/if}
<style>
div.icon {
position: relative;
top: 0.175em;
display: inline-block;
}
div.unknown, span.unknown {
color: #555;
}
</style>

4
svelte-ui/src/components/ActivityIcon.svelte

@ -14,6 +14,8 @@
import { faCodeBranch } from "@fortawesome/free-solid-svg-icons/faCodeBranch"; import { faCodeBranch } from "@fortawesome/free-solid-svg-icons/faCodeBranch";
import { faGuitar } from "@fortawesome/free-solid-svg-icons/faGuitar"; import { faGuitar } from "@fortawesome/free-solid-svg-icons/faGuitar";
import { faMusic } from "@fortawesome/free-solid-svg-icons/faMusic"; import { faMusic } from "@fortawesome/free-solid-svg-icons/faMusic";
import { faArchive } from "@fortawesome/free-solid-svg-icons/faArchive";
import { faCheck } from "@fortawesome/free-solid-svg-icons/faCheck";
const icons = { const icons = {
"plus": faPlus, "plus": faPlus,
@ -29,6 +31,8 @@
"code": faCode, "code": faCode,
"code_branch": faCodeBranch, "code_branch": faCodeBranch,
"guitar": faGuitar, "guitar": faGuitar,
"archive": faArchive,
"check": faCheck,
}; };
export const iconNames = Object.keys(icons); export const iconNames = Object.keys(icons);

10
svelte-ui/src/components/AddBoi.svelte

@ -2,11 +2,10 @@
import Icon from 'fa-svelte' import Icon from 'fa-svelte'
import { faPlus } from '@fortawesome/free-solid-svg-icons/faPlus' import { faPlus } from '@fortawesome/free-solid-svg-icons/faPlus'
export let header = "";
export let icon = null;
export let top = false;
</script> </script>
<div on:click class="addboi">
<div on:click class="addboi" class:top>
<Icon class="addboi-icon" icon={faPlus} /> <Icon class="addboi-icon" icon={faPlus} />
<slot></slot> <slot></slot>
</div> </div>
@ -26,6 +25,11 @@
transition: 250ms; transition: 250ms;
} }
div.addboi.top {
margin-bottom: 0.5em;
padding: 0.25em 0;
font-size: 1.5em;
}
div.addboi:hover { div.addboi:hover {
color: #777; color: #777;

51
svelte-ui/src/components/Col.svelte

@ -1,51 +0,0 @@
<script>
export let size = 1;
</script>
<div class="col col-{size}"><slot></slot></div>
<style>
div.col {
display: inline-block;
box-sizing: border-box;
padding: 0;
margin: 0;
}
div.col-1 {
width: calc((100% / 12) * 1)
}
div.col-2 {
width: calc((100% / 12) * 2)
}
div.col-3 {
width: calc((100% / 12) * 3)
}
div.col-4 {
width: calc((100% / 12) * 4)
}
div.col-5 {
width: calc((100% / 12) * 5)
}
div.col-6 {
width: calc((100% / 12) * 6)
}
div.col-7 {
width: calc((100% / 12) * 7)
}
div.col-8 {
width: calc((100% / 12) * 8)
}
div.col-9 {
width: calc((100% / 12) * 9)
}
div.col-10 {
width: calc((100% / 12) * 10)
}
div.col-11 {
width: calc((100% / 12) * 11)
}
div.col-12 {
width: 100%
}
</style>

20
svelte-ui/src/components/ItemDisplay.svelte

@ -0,0 +1,20 @@
<script>
import ActivityIcon from "./ActivityIcon.svelte";
export let item = null
export let id = ""
</script>
{#if item != null}
<span>{item.name}</span>
{:else if id != ""}
<span class="unknown">(Deleted)</span>
{:else}
<span class="unknown">(None)</span>
{/if}
<style>
span.unknown {
color: #555;
}
</style>

2
svelte-ui/src/components/Menu.svelte

@ -16,5 +16,7 @@
background-color: #222; background-color: #222;
margin: 0; margin: 0;
user-select: none; user-select: none;
display: flex;
flex-direction: row;
} }
</style> </style>

2
svelte-ui/src/components/MenuItem.svelte

@ -8,7 +8,7 @@
<style> <style>
a.menu-item { a.menu-item {
display: inline-block;
display: flex;
background-color: #222; background-color: #222;
margin: 0; margin: 0;
padding: 0.25em 1ch; padding: 0.25em 1ch;

5
svelte-ui/src/components/PointsBar.svelte

@ -21,7 +21,7 @@
v = 0; v = 0;
} }
if (i >= red && v === 0) {
if (i >= gold && v === 0) {
break; break;
} }
@ -30,6 +30,7 @@
gold: i >= gold && i < red, gold: i >= gold && i < red,
red: i >= red, red: i >= red,
value: v, value: v,
empty: v === 0,
full: v === 1000, full: v === 1000,
}) })
} }
@ -64,7 +65,7 @@
</script> </script>
{#each bars as bar} {#each bars as bar}
<div class="bar" class:red={bar.red} class:green={bar.green} class:gold={bar.gold} class:full={bar.full}>
<div class="bar" class:red={bar.red} class:green={bar.green} class:gold={bar.gold} class:empty={bar.empty} class:full={bar.full}>
<div class="content" style={`height: ${bar.value / 10}%; top: ${(1000 - bar.value) / 10}%`}> <div class="content" style={`height: ${bar.value / 10}%; top: ${(1000 - bar.value) / 10}%`}>
</div> </div>
</div> </div>

8
svelte-ui/src/components/Row.svelte

@ -1,8 +0,0 @@
<div class="row"><slot></slot></div>
<style>
div.row {
display: flex;
flex-wrap: wrap;
}
</style>

51
svelte-ui/src/components/Table.svelte

@ -0,0 +1,51 @@
<script>
export let headers = [];
export let percentages = [];
let zipped = [];
$: zipped = headers.map((h, i) => ({header: h, pct: percentages[i]}))
$: if (percentages.length !== headers.length) {
throw new Error(`Mismatching headers (${headers.join(",")}) and percentages (${percentages.join(",")})`)
}
</script>
<table>
<thead>
<tr>
{#each zipped as h}
<th style={`width: ${h.pct}%;`}>{h.header}</th>
{/each}
</tr>
</thead>
<tbody>
<slot></slot>
</tbody>
</table>
<style>
table {
width: 100%;
padding: 0.5em 0.75ch;
}
table :global(th), table :global(td) {
box-sizing: border-box;
}
table :global(th) {
text-align: left;
font-size: 0.75em;
}
table :global(td.ellipsis) {
max-width: 1px; /* Don't ask me why this works... */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
table :global(td:last-of-type), table :global(th:last-of-type) {
text-align: right;
}
</style>

38
svelte-ui/src/components/tables/GoalTable.svelte

@ -0,0 +1,38 @@
<script>
import { createEventDispatcher } from 'svelte';
import pluralize from "pluralize";
import Link from "../Link.svelte";
import Table from "../Table.svelte";
import ActivityIcon from "../ActivityIcon.svelte";
import PointsBar from "../PointsBar.svelte";
import ActivityDisplay from "../ActivityDisplay.svelte";
export let period = {goals: []};
export let activities = [];
const dispatch = createEventDispatcher();
let activityMap = {};
function onOption(name, goalId) {
const goal = period.goals.find(g => g.id === goalId);
const activity = activities.find(a => a.id === goal.activityId)
dispatch("option", {name, period, goal, activity})
}
</script>
<Table headers={["Activity", "Points", "Options"]} percentages={[25, 55, 20]}>
{#each period.goals as goal (goal.id)}
<tr>
<td class="ellipsis">
<ActivityDisplay activity={activities.find(a => a.id === goal.activityId)} />
</td>
<td><PointsBar value={period.scores[goal.id]} goal={goal.pointCount} /></td>
<td class="td-options">
<Link on:click={() => onOption("periodgoal.remove", goal.id)}>Delete</Link>
</td>
</tr>
{/each}
</Table>

30
svelte-ui/src/components/tables/ItemTable.svelte

@ -0,0 +1,30 @@
<script>
import { createEventDispatcher } from 'svelte';
import Link from "../Link.svelte";
import Table from "../Table.svelte";
export let items = [];
const dispatch = createEventDispatcher();
function onOption(name, id) {
const item = items.find(i => i.id === id)
dispatch("option", {name, item})
}
</script>
<Table headers={["Name", "Multiplier", "Options"]} percentages={[60, 5, 35]}>
{#each items as item (item.id)}
<tr>
<td class="ellipsis">{item.name}</td>
<td>{item.multiplier.toFixed(2)}</td>
<td>
<Link on:click={() => onOption("item.stats", item.id)}>Stats</Link>,
<Link on:click={() => onOption("item.edit", item.id)}>Edit</Link>,
<Link on:click={() => onOption("item.remove", item.id)}>Delete</Link>
</td>
</tr>
{/each}
</Table>

66
svelte-ui/src/components/tables/LogTable.svelte

@ -0,0 +1,66 @@
<script>
import pluralize from "pluralize";
import Link from "../Link.svelte";
import Table from "../Table.svelte";
import { createEventDispatcher } from 'svelte';
import ActivityIcon from "../ActivityIcon.svelte";
import ActivityDisplay from "../ActivityDisplay.svelte";
import ActivityAmount from "../ActivityAmount.svelte";
import ItemDisplay from "../ItemDisplay.svelte";
import PointsBar from "../PointsBar.svelte";
import dateStr from "../../utils/dateStr";
export let period = {goals: []};
export let activities = [];
export let items = [];
const dispatch = createEventDispatcher();
let subActivityMap = {};
function onOption(name, logId) {
const log = period.logs.find(l => l.id === logId);
const goal = period.goals.find(g => g.id === log.goalId)
const activity = goal != null ? activities.find(a => a.id === goal.activityId) : null;
const subActivity = activity != null ? activity.subActivities.find(s => s.id === log.subActivityId) : null;
dispatch("option", {name, period, log, goal, activity, subActivity})
}
$: subActivityMap = activities.map(a => a.subActivities).flat().reduce((p, v) => ({...p, [v.id]: v}), {});
</script>
<Table
headers={["Date", "Goal", "Item", "Amount", "Points", "Options"]}
percentages={[25, 25, 15, 15, 5, 15]}
>
{#each period.logs as log (log.id)}
<tr>
<td>{dateStr(log.date)}</td>
<td class="ellipsis">
<ActivityDisplay
activity={activities.find(a => a.id === (period.goals.find(g => g.id === log.goalId) || {}).activityId)}
subActivity={subActivityMap[log.subActivityId]}
/>
</td>
<td>
<ItemDisplay id={log.itemId} item={items.find(i => i.id === log.itemId)} />
</td>
<td>
<ActivityAmount
amount={log.amount}
subActivity={subActivityMap[log.subActivityId]}
/>
</td>
<td>
<Link on:click={() => onOption("periodlog.info", log.id)}>{log.score.total}</Link>
</td>
<td class="td-options">
<Link on:click={() => onOption("periodlog.remove", log.id)}>Delete</Link>
</td>
</tr>
{/each}
</Table>

30
svelte-ui/src/components/tables/SubActivityTable.svelte

@ -0,0 +1,30 @@
<script>
import { createEventDispatcher } from 'svelte';
import pluralize from "pluralize";
import Link from "../Link.svelte";
import Table from "../Table.svelte";
export let activity = {subActivities: []}
const dispatch = createEventDispatcher();
function onOption(name, subId) {
const subActivity = activity.subActivities.find(s => s.id === subId);
dispatch("option", {name, activity, subActivity})
}
</script>
<Table headers={["Sub-Activity", "Value", "Options"]} percentages={[50, 25, 25]}>
{#each activity.subActivities as subActivtiy (subActivtiy.id)}
<tr>
<td>{subActivtiy.name}</td>
<td>{subActivtiy.value} per {pluralize(subActivtiy.unitName, 1)}</td>
<td>
<Link on:click={() => onOption("subactivity.edit", subActivtiy.id)}>Edit</Link>,
<Link on:click={() => onOption("subactivity.remove", subActivtiy.id)}>Delete</Link>
</td>
</tr>
{/each}
</Table>

42
svelte-ui/src/modals/AddPeriodGoalModal.svelte

@ -11,7 +11,6 @@
let error = null; let error = null;
let activityId = ""; let activityId = "";
let pointCount = "1000"; let pointCount = "1000";
let subGoals = [{name:"",multiplier:"1.0"}];
function addPeriodGoal() { function addPeriodGoal() {
error = null; error = null;
@ -26,28 +25,9 @@
return return
} }
const parsedSubGoals = [];
for (const subGoal of subGoals) {
if (subGoal.name === "") {
continue;
}
const parsedMultiplier = parseFloat(subGoal.multiplier);
if (Number.isNaN(parsedMultiplier) || parsedMultiplier < 0) {
error = `Sub goal ${subGoal.name} needs a numeric multiplier.`;
return
}
parsedSubGoals.push({
name: subGoal.name,
multiplier: parsedMultiplier,
})
}
const addGoal = { const addGoal = {
activityId: activityId, activityId: activityId,
pointCount: parsedPointCount, pointCount: parsedPointCount,
subGoals: parsedSubGoals,
} }
stufflog.updatePeriod(period.id, {addGoal}).then(() => { stufflog.updatePeriod(period.id, {addGoal}).then(() => {
@ -58,24 +38,6 @@
}); });
} }
function updateSubGoalForm(subGoals) {
let last = -1;
for (let i = 0; i < subGoals.length; ++i) {
if (subGoals[i].name != "") {
last = i;
}
}
if (last == subGoals.length - 1) {
return [...subGoals, {name: "", value: "1.0"}];
} else if (last + 2 < subGoals.length && subGoals.length > 1) {
return subGoals.slice(0, last + 2);
} else {
return subGoals;
}
}
$: subGoals = updateSubGoalForm(subGoals);
$: if (activityId === "" && $stufflog.activities.length > 0) { $: if (activityId === "" && $stufflog.activities.length > 0) {
activityId = $stufflog.activities[0].id; activityId = $stufflog.activities[0].id;
} }
@ -91,10 +53,6 @@
</select> </select>
<label>Points</label> <label>Points</label>
<input class="nolast" type="string" bind:value={pointCount} /> <input class="nolast" type="string" bind:value={pointCount} />
<label>Subgoals</label>
{#each subGoals as subGoal}
<SubGoalInput bind:name={subGoal.name} bind:value={subGoal.multiplier} />
{/each}
<p> <p>
The amount of points must be in an increment of 1000, which should be The amount of points must be in an increment of 1000, which should be

30
svelte-ui/src/modals/AddPeriodLogModal.svelte

@ -16,12 +16,13 @@
let goal = null; let goal = null;
let activity = null; let activity = null;
let subActivity = null; let subActivity = null;
let dateInput = dateStr(period.from);
let dateInput = dateStr(new Date);
let goalId = ""; let goalId = "";
let subGoalId = ""; let subGoalId = "";
let subActivityId = ""; let subActivityId = "";
let amount = "200"; let amount = "200";
let description = ""; let description = "";
let dateInPeriod = false;
function addPeriodLog() { function addPeriodLog() {
error = null; error = null;
@ -56,9 +57,8 @@
subActivityId = ""; subActivityId = "";
} }
} }
$: if (goal != null && !goal.subGoals.find(s => s.id === subGoalId)) {
subGoalId = "";
}
$: dateInPeriod = Date.parse(dateInput) > Date.parse(period.from) && Date.parse(dateInput) < Date.parse(period.to)
</script> </script>
<ModalFrame title={`Add ${period.name} Log`} error={error} closable on:close={() => modal.close()}> <ModalFrame title={`Add ${period.name} Log`} error={error} closable on:close={() => modal.close()}>
@ -66,6 +66,10 @@
<label>Date</label> <label>Date</label>
<input type="date" bind:value={dateInput} /> <input type="date" bind:value={dateInput} />
{#if (!dateInPeriod)}
<p class="warning">Date is outside period!</p>
{/if}
<label>Goal</label> <label>Goal</label>
<select bind:value={goalId}> <select bind:value={goalId}>
{#each period.goals as goal (goal.id)} {#each period.goals as goal (goal.id)}
@ -82,17 +86,6 @@
</select> </select>
{/if} {/if}
{#if (goal != null && goal.subGoals.length > 0)}
<label>Sub-Goal</label>
<select bind:value={subGoalId}>
<option value="">None</option>
{#each goal.subGoals as subGoal (subGoal.id)}
<option value={subGoal.id}>{subGoal.name} ({subGoal.multiplier.toFixed(2)})</option>
{/each}
</select>
{/if}
{#if (subActivity != null)} {#if (subActivity != null)}
<label>{pluralize(capitalize(subActivity.unitName))}</label> <label>{pluralize(capitalize(subActivity.unitName))}</label>
<input type="number" bind:value={amount} /> <input type="number" bind:value={amount} />
@ -103,9 +96,14 @@
<hr /> <hr />
<button type="submit">Add Goal</button>
<button type="submit">Add Log</button>
</form> </form>
</ModalFrame> </ModalFrame>
<style> <style>
p.warning {
color: #FB1;
margin: 0;
text-align: center;
}
</style> </style>

24
svelte-ui/src/modals/InfoPeriodLogModal.svelte

@ -14,18 +14,20 @@
export let log = {}; export let log = {};
export let activity = {}; export let activity = {};
export let subActivity = {}; export let subActivity = {};
export let subGoal = null;
export let item = null;
let activityPoints = 0; let activityPoints = 0;
let subGoalBonus = 0;
let itemBonus = 0;
let roundingError = 0; let roundingError = 0;
let title = ""
$: activityPoints = log.score.amount * log.score.activityScore; $: activityPoints = log.score.amount * log.score.activityScore;
$: subGoalBonus = subGoal.multiplier ? activityPoints * (subGoal.multiplier - 1) : 0;
$: roundingError = (log.score.total) - (Math.floor(activityPoints) + Math.floor(subGoalBonus) + log.score.dailyBonus);
$: itemBonus = item ? activityPoints * (item.multiplier - 1) : 0;
$: roundingError = (log.score.total) - (Math.floor(activityPoints) + Math.floor(itemBonus) + log.score.dailyBonus);
$: title = `${dateStr(log.date)} - ${(activity||{name:"(Unknown)"}).name} ${(subActivity||{name:"(Unknown)"}).name}`
</script> </script>
<ModalFrame title={`${dateStr(log.date)} - ${activity.name} ${subActivity.name}`} closable on:close={() => modal.close()}>
<ModalFrame title={title} closable on:close={() => modal.close()}>
<form on:submit|preventDefault={() => modal.close()}> <form on:submit|preventDefault={() => modal.close()}>
{#if log.description !== ""} {#if log.description !== ""}
<Property label="Description"> <Property label="Description">
@ -35,12 +37,16 @@
<Property label="Activity Points"> <Property label="Activity Points">
{Math.floor(activityPoints + roundingError)} points {Math.floor(activityPoints + roundingError)} points
({log.score.amount} {pluralize(subActivity.unitName, log.score.amount)})
{#if (subActivity != null)}
<span>
({log.score.amount} {pluralize(subActivity.unitName, log.score.amount)})
</span>
{/if}
</Property> </Property>
{#if subGoal.multiplier != null}
{#if item != null}
<Property label="Sub-Goal"> <Property label="Sub-Goal">
{Math.floor(subGoalBonus)} points
({subGoal.name})
{Math.floor(itemBonus)} points
({item.name})
</Property> </Property>
{/if} {/if}
{#if log.score.dailyBonus != 0} {#if log.score.dailyBonus != 0}

2
svelte-ui/src/modals/RemovePeriodLogModal.svelte

@ -27,7 +27,7 @@ let error = null;
<form on:submit|preventDefault={() => removePeriodLog()}> <form on:submit|preventDefault={() => removePeriodLog()}>
<p> <p>
Are you sure you want to remove log <b>{log.description}</b> Are you sure you want to remove log <b>{log.description}</b>
(<b>{dateStr(log.date)}</b>, <b>{activity.name}</b>) from
(<b>{dateStr(log.date)}</b>, <b>{activity ? activity.name : "(Unknown)"}</b>) from
period <b>{period.name}</b>? period <b>{period.name}</b>?
</p> </p>

6
svelte-ui/src/modals/RemoveSubActivityModal.svelte

@ -6,12 +6,12 @@
import stufflog from "../stores/stufflog"; import stufflog from "../stores/stufflog";
export let activity = {}; export let activity = {};
export let subActivtiy = {};
export let subActivity = {};
let error = null; let error = null;
function removeSubActivity() { function removeSubActivity() {
stufflog.updateActivity(activity.id, {removeSub: subActivtiy.id}).then(() => {
stufflog.updateActivity(activity.id, {removeSub: subActivity.id}).then(() => {
modal.close(); modal.close();
}).catch(err => { }).catch(err => {
error = err.message || err; error = err.message || err;
@ -23,7 +23,7 @@
<ModalFrame title={`Remove ${activity.name} Sub-Activity`} error={error} closable on:close={() => modal.close()}> <ModalFrame title={`Remove ${activity.name} Sub-Activity`} error={error} closable on:close={() => modal.close()}>
<form on:submit|preventDefault={() => removeSubActivity()}> <form on:submit|preventDefault={() => removeSubActivity()}>
<p> <p>
Are you sure you want to remove sub-acitvity <b>{subActivtiy.name || "(unnamed)"}</b> from
Are you sure you want to remove sub-acitvity <b>{subActivity.name || "(Unnamed)"}</b> from
activity <b>{activity.name}</b>? activity <b>{activity.name}</b>?
</p> </p>

12
svelte-ui/src/models/item.d.ts

@ -0,0 +1,12 @@
export default interface Item {
id: string
userId: string
active: boolean
name: string
multiplier: number
}
export interface ItemUpdate {
setName: string
setActive: boolean
}

1
svelte-ui/src/models/item.js

@ -0,0 +1 @@
module.exports = {}

74
svelte-ui/src/routes/ActivitiesPage.svelte

@ -10,55 +10,42 @@
import Boi from "../components/Boi.svelte"; import Boi from "../components/Boi.svelte";
import AddBoi from "../components/AddBoi.svelte"; import AddBoi from "../components/AddBoi.svelte";
import Property from "../components/Property.svelte";
import Row from "../components/Row.svelte";
import Col from "../components/Col.svelte";
import Link from "../components/Link.svelte"; import Link from "../components/Link.svelte";
function openModal(modalName, id, subId) {
import Table from "../components/Table.svelte";
import SubActivityTable from "../components/tables/SubActivityTable";
function onActivityOption(modalName, id) {
const activity = get(stufflog).activities.find(a => a.id === id); const activity = get(stufflog).activities.find(a => a.id === id);
const subActivity = (activity || {subActivities:[]}).subActivities.find(s => s.id === subId);
modal.open(modalName, {activity, subActivity})
modal.open(modalName, {activity})
} }
</script> </script>
<div class="page"> <div class="page">
<AddBoi top on:click={() => modal.open("activity.create")}>Activity</AddBoi>
{#each $stufflog.activities as activity (activity.id)} {#each $stufflog.activities as activity (activity.id)}
<Boi header={activity.name} icon={activity.icon}> <Boi header={activity.name} icon={activity.icon}>
<Row>
<Col size=3><Property label="ID" value={activity.id} /></Col>
<Col size=3><Property label="Daily Bonus" value={activity.dailyBonus} /></Col>
<Col size=6>
<Property label="Options">
<Link on:click={() => openModal("activity.edit", activity.id)}>Edit Activity</Link>,
<Link on:click={() => openModal("subactivity.add", activity.id)}>Add Sub-Activity</Link>,
<Link on:click={() => openModal("activity.delete", activity.id)}>Delete Activity</Link>
</Property>
</Col>
</Row>
<table>
<Table headers={["ID", "Daily Bonus", "Options"]} percentages={[25, 25, 50]}>
<tr> <tr>
<th class="th-name">Sub-Activity</th>
<th class="th-value">Value</th>
<th class="th-options">Options</th>
<td>{activity.id}</td>
<td>{activity.dailyBonus}</td>
<td>
<Link on:click={() => onActivityOption("activity.edit", activity.id)}>Edit Activity</Link>,
<Link on:click={() => onActivityOption("subactivity.add", activity.id)}>Add Sub-Activity</Link>,
<Link on:click={() => onActivityOption("activity.delete", activity.id)}>Delete Activity</Link>
</td>
</tr> </tr>
{#each activity.subActivities as subActivtiy (subActivtiy.id)}
<tr>
<td>{subActivtiy.name}</td>
<td>{subActivtiy.value} per {pluralize(subActivtiy.unitName, 1)}</td>
<td>
<Link on:click={() => openModal("subactivity.edit", activity.id, subActivtiy.id)}>Edit</Link>,
<Link on:click={() => openModal("subactivity.remove", activity.id, subActivtiy.id)}>Delete</Link>
</td>
</tr>
{/each}
</table>
</Table>
<SubActivityTable
activity={activity}
on:option={(e) => modal.open(e.detail.name, e.detail)}
/>
</Boi> </Boi>
{:else} {:else}
<div class="empty">No data.</div> <div class="empty">No data.</div>
{/each} {/each}
<AddBoi on:click={() => modal.open("activity.create")}>Activity</AddBoi>
</div> </div>
<style> <style>
@ -67,25 +54,4 @@
max-width: 90%; max-width: 90%;
margin: auto; margin: auto;
} }
table {
width: 100%;
padding: 0.5em 0;
}
th, td {
padding: 0em 1ch;
}
th {
text-align: left;
font-size: 0.75em;
}
th.th-name {
width: 50%;
}
th.th-value {
width: 25%;
}
th.th-options {
width: 25%;
}
</style> </style>

49
svelte-ui/src/routes/ItemPage.svelte

@ -0,0 +1,49 @@
<script>
import {onMount} from "svelte";
import {get} from "svelte/store";
import items from "../stores/items";
import modal from "../stores/modal";
import Boi from "../components/Boi.svelte";
import AddBoi from "../components/AddBoi.svelte";
import Link from "../components/Link.svelte";
import ItemTable from "../components/tables/ItemTable.svelte";
let activeItems = [];
let inactiveItems = [];
onMount(() => {
items.listItems().catch(err => {
console.warn("Item fetch failed:", err)
})
})
function openModal(modalName, id) {
const list = get(items)
const item = list.find(i => i.id === id)
modal.open(modalName, {item})
}
$: activeItems = $items.filter(i => i.active)
$: inactiveItems = $items.filter(i => !i.active)
</script>
<div class="page">
<AddBoi top on:click={() => modal.open("item.create")}>Item</AddBoi>
<Boi header="Active Items" icon="cubes">
<ItemTable items={activeItems} />
</Boi>
<Boi header="Archived Items" icon="archive">
<ItemTable items={inactiveItems} />
</Boi>
</div>
<style>
div.page {
width: 100ch;
max-width: 90%;
margin: auto;
}
</style>

166
svelte-ui/src/routes/LogPage.svelte

@ -1,117 +1,56 @@
<script context="module">
const UNKNOWN = (Object.freeze||(o => o))({
name: "(Unknown)",
subGoals: [],
subActivities: [],
});
</script>
<script> <script>
import { get } from "svelte/store"; import { get } from "svelte/store";
import pluralize from "pluralize"; import pluralize from "pluralize";
import Boi from "../components/Boi.svelte"; import Boi from "../components/Boi.svelte";
import AddBoi from "../components/AddBoi.svelte"; import AddBoi from "../components/AddBoi.svelte";
import Property from "../components/Property.svelte";
import Row from "../components/Row.svelte";
import Col from "../components/Col.svelte";
import Link from "../components/Link.svelte"; import Link from "../components/Link.svelte";
import ActivityIcon from "../components/ActivityIcon.svelte";
import PointsBar from "../components/PointsBar.svelte";
import Table from "../components/Table.svelte";
import GoalTable from "../components/tables/GoalTable.svelte";
import LogTable from "../components/tables/LogTable.svelte";
import stufflog from "../stores/stufflog"; import stufflog from "../stores/stufflog";
import modal from "../stores/modal"; import modal from "../stores/modal";
import items from "../stores/items";
import dateStr from "../utils/dateStr"; import dateStr from "../utils/dateStr";
let activityMap = {};
let scores = {};
function openGoalModal(modalName, id, goalId, subGoalId) {
const period = get(stufflog).periods.find(a => a.id === id);
const goal = period ? period.goals.find(g => g.id === goalId) : null;
const subGoal = goal ? goal.subGoals.find(s => s.id === subGoalId) : null;
const activity = goal ? get(stufflog).activities.find(a => a.id === goal.activityId) : null
modal.open(modalName, {period, goal, subGoal, activity})
function getPeriod(id) {
return get(stufflog).periods.find(a => a.id === id)
} }
function openLogModal(modalName, id, logId) {
const period = get(stufflog).periods.find(a => a.id === id);
const row = period ? period.table.find(r => r.log.id === logId) : {};
modal.open(modalName, {period, ...row})
}
$: activityMap = $stufflog.activities.reduce((p, v) => ({...p, [v.id]: v}), {});
</script> </script>
<div class="page"> <div class="page">
<AddBoi top on:click={() => modal.open("period.create")}>Period</AddBoi>
{#each $stufflog.periods as period (period.id)} {#each $stufflog.periods as period (period.id)}
<Boi header={period.name}> <Boi header={period.name}>
<Row>
<Col size=3><Property label="From" value={dateStr(period.from)} /></Col>
<Col size=3><Property label="To" value={dateStr(period.to)} /></Col>
<Col size=6>
<Property label="Options">
<Link on:click={() => openGoalModal("period.edit", period.id)}>Edit Period</Link>,
<Link on:click={() => openGoalModal("periodgoal.add", period.id)}>Add Goal</Link>,
<Table headers={["From", "To", "Options"]} percentages={[25, 25, 50]}>
<td>{dateStr(period.from)}</td>
<td>{dateStr(period.to)}</td>
<td>
<Link on:click={() => modal.open("period.edit", {period: getPeriod(period.id)})}>Edit Period</Link>,
<Link on:click={() => modal.open("periodgoal.add", {period: getPeriod(period.id)})}>Add Goal</Link>,
{#if period.goals.length > 0} {#if period.goals.length > 0}
<Link on:click={() => openGoalModal("periodlog.add", period.id)}>Add Log</Link>,
<Link on:click={() => modal.open("periodlog.add", {period: getPeriod(period.id)})}>Add Log</Link>,
{/if} {/if}
<Link on:click={() => openGoalModal("period.delete", period.id)}>Delete Period</Link>
</Property>
</Col>
</Row>
<table>
<tr>
<th class="th-name">Activity</th>
<th class="th-points">Points</th>
<th class="th-options">Options</th>
</tr>
{#each period.goals as goal (goal.id)}
<tr>
<td>
<div class="icon"><ActivityIcon name={(activityMap[goal.activityId] || {name: "(Unknown)"}).icon} /></div>
<div class="name">{(activityMap[goal.activityId] || {name: "(Unknown)"}).name}</div>
</td>
<td><PointsBar value={period.scores[goal.id]} goal={goal.pointCount} /></td>
<td class="td-options">
<Link on:click={() => openGoalModal("periodgoal.remove", period.id, goal.id)}>Delete</Link>
</td>
</tr>
{/each}
</table>
<table>
<tr>
<th class="th-date">Date</th>
<th class="th-log">Goal</th>
<th class="th-log">Sub-Activity</th>
<th class="th-points2">Amount</th>
<th class="th-points2">Points</th>
<th class="th-options">Options</th>
</tr>
{#each period.table as row (row.log.id)}
<tr>
<td>{dateStr(row.log.date)}</td>
<td class:dark={row.activity.name.startsWith("(")}>
<div class="icon"><ActivityIcon name={row.activity.icon} /></div>
<div class="name">{row.activity.name} {row.subActivity.name}</div>
</td>
<td class:dark={row.subGoal.name.startsWith("(")}>{row.subGoal.name}</td>
<td>{row.log.amount} {pluralize(row.subActivity.unitName, row.log.amount)}</td>
<td>
<Link on:click={() => openLogModal("periodlog.info", period.id, row.log.id)}>{row.log.score.total}</Link>
</td>
<td class="td-options">
<Link on:click={() => openLogModal("periodlog.remove", period.id, row.log.id)}>Delete</Link>
</td>
</tr>
{/each}
</table>
<Link on:click={() => modal.open("period.delete", {period: getPeriod(period.id)})}>Delete Period</Link>
</td>
</Table>
<GoalTable
activities={$stufflog.activities}
period={period}
on:option={(e) => modal.open(e.detail.name, e.detail)}
/>
<LogTable
activities={$stufflog.activities}
items={$items}
period={period}
on:option={(e) => modal.open(e.detail.name, e.detail)}
/>
</Boi> </Boi>
{/each} {/each}
<AddBoi on:click={() => modal.open("period.create")}>Period</AddBoi>
</div> </div>
<style> <style>
@ -120,49 +59,4 @@
max-width: 90%; max-width: 90%;
margin: auto; margin: auto;
} }
table {
width: 100%;
padding: 0.5em 0;
}
table th, table td {
padding: 0em 1ch;
}
table th {
text-align: left;
font-size: 0.75em;
}
table th.th-name {
width: 25%;
}
table th.th-date {
width: 25%;
}
table th.th-points {
width: 62.5%;
}
table th.th-options, table td.td-options {
width: 12.5%;
text-align: right;
}
table th.th-log {
width: calc(37.5%/2);
}
table th.th-points2 {
width: 12.5%;
}
table td.dark {
color: #666;
}
table td div.icon {
position: relative;
top: 0.175em;
display: inline-block;
}
table td div.name {
display: inline-block;
}
</style> </style>

58
svelte-ui/src/stores/items.js

@ -0,0 +1,58 @@
import { writable } from "svelte/store";
import slApi from "../api/stufflog";
import stufflogStore from "./stufflog";
function createItemStore() {
const {set, update, subscribe} = writable([])
return {
subscribe,
listItems() {
return slApi.listItems().then(items => {
set(items)
console.log(items)
return items
})
},
findItem(id) {
return slApi.getItem(id).then(item => {
update(s => replaceItem(s, item))
return item
})
},
createItem(item) {
return slApi.postItem(item).then(item => {
update(s => replaceItem(s, item))
return item
})
},
updateItem(id, ...updates) {
return slApi.updateItem(id, ...updates).then(item => {
update(s => replaceItem(s, item))
return item
})
},
deleteItem(id) {
return slApi.deleteItem(id).then(item => {
update(s => s.items.filter(i => i.id !== item.id))
return item
})
},
}
}
function replaceItem(s, item) {
return ([
...s.items.filter(i => i.id !== item.id),
item
].sort((a, b) => a.name.localeCompare(b.name)))
}
export default createItemStore();

1
svelte-ui/src/stores/modal.js

@ -7,6 +7,7 @@ function createModalStore() {
subscribe, subscribe,
open(name, data = {}) { open(name, data = {}) {
console.log("Opening modal", name, data)
set({name, data}); set({name, data});
}, },

23
svelte-ui/src/stores/stufflog.js

@ -18,25 +18,6 @@ function calculateTotalScore(period) {
)}), {}) )}), {})
} }
function generateLogTable(activities, period) {
const newTable = [];
for (const log of period.logs) {
const goal = period.goals.find(g => g.id === log.goalId) || UNKNOWN_GOAL;
const subGoal = log.subGoalId
? goal.subGoals.find(s => s.id === log.subGoalId) || UNKNOWN_SUB_GOAL
: NO_SUB_GOAL;
const activity = activities.find(a => a.id === goal.activityId) || UNKNOWN_ACTIVITY;
const subActivity = activity.subActivities.find(s => s.id === log.subActivityId) || UNKNOWN_SUB_ACTIVITY;
newTable.push({log, goal, subGoal, activity, subActivity})
}
newTable.sort((a, b) => a.log.date - b.log.date);
return newTable;
}
function replaceActivity(d, activity) { function replaceActivity(d, activity) {
const data = { const data = {
...d, ...d,
@ -49,7 +30,6 @@ function replaceActivity(d, activity) {
data.periods = data.periods.map(p => ({ data.periods = data.periods.map(p => ({
...p, ...p,
scores: calculateTotalScore(p), scores: calculateTotalScore(p),
table: generateLogTable(data.activities, p),
})); }));
return data; return data;
@ -63,7 +43,6 @@ function replacePeriod(d, period) {
{ {
...period, ...period,
scores: calculateTotalScore(period), scores: calculateTotalScore(period),
table: generateLogTable(d.activities, period),
}, },
].sort((a,b) => b.from - a.from), ].sort((a,b) => b.from - a.from),
}; };
@ -100,7 +79,6 @@ function createStufflogStore() {
periods: d.periods.map(p => ({ periods: d.periods.map(p => ({
...p, ...p,
scores: calculateTotalScore(p), scores: calculateTotalScore(p),
table: generateLogTable(activities, p),
})), })),
})); }));
}, },
@ -117,7 +95,6 @@ function createStufflogStore() {
periods: periods.sort((a,b) => b.from - a.from).map(p => ({ periods: periods.sort((a,b) => b.from - a.from).map(p => ({
...p, ...p,
scores: calculateTotalScore(p), scores: calculateTotalScore(p),
table: generateLogTable(d.activities, p),
})), })),
})); }));
}, },

Loading…
Cancel
Save