Browse Source

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

master
Gisle Aune 4 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.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)

1
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.

10
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

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.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

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:"-"`
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)

14
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 {

6
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 @@
</script>
<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">
{#if ($auth.checked && $auth.user !== null)}
@ -43,6 +46,7 @@
<Router>
<Route path="/" component={LogPage} />
<Route path="/activities/" component={ActivitiesPage} />
<Route path="/items/" component={ItemPage} />
</Router>
<Modal name="login" component={LoginModal} />

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

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

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

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

@ -2,11 +2,10 @@
import Icon from 'fa-svelte'
import { faPlus } from '@fortawesome/free-solid-svg-icons/faPlus'
export let header = "";
export let icon = null;
export let top = false;
</script>
<div on:click class="addboi">
<div on:click class="addboi" class:top>
<Icon class="addboi-icon" icon={faPlus} />
<slot></slot>
</div>
@ -26,6 +25,11 @@
transition: 250ms;
}
div.addboi.top {
margin-bottom: 0.5em;
padding: 0.25em 0;
font-size: 1.5em;
}
div.addboi:hover {
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;
margin: 0;
user-select: none;
display: flex;
flex-direction: row;
}
</style>

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

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

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

@ -21,7 +21,7 @@
v = 0;
}
if (i >= red && v === 0) {
if (i >= gold && v === 0) {
break;
}
@ -30,6 +30,7 @@
gold: i >= gold && i < red,
red: i >= red,
value: v,
empty: v === 0,
full: v === 1000,
})
}
@ -64,7 +65,7 @@
</script>
{#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>
</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 activityId = "";
let pointCount = "1000";
let subGoals = [{name:"",multiplier:"1.0"}];
function addPeriodGoal() {
error = null;
@ -26,28 +25,9 @@
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 = {
activityId: activityId,
pointCount: parsedPointCount,
subGoals: parsedSubGoals,
}
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) {
activityId = $stufflog.activities[0].id;
}
@ -91,10 +53,6 @@
</select>
<label>Points</label>
<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>
The amount of points must be in an increment of 1000, which should be

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