24 changed files with 797 additions and 67 deletions
-
8cmd/stufflog3-local/main.go
-
1go.mod
-
2go.sum
-
10internal/genutils/map.go
-
4models/generics.go
-
7models/item.go
-
9models/sprint.go
-
41ports/httpapi/common.go
-
12ports/httpapi/items.go
-
47ports/httpapi/sprints.go
-
8ports/mysql/db.go
-
11ports/mysql/items.go
-
30ports/mysql/mysqlcore/db.go
-
57ports/mysql/mysqlcore/sprint.sql.go
-
65ports/mysql/projects.go
-
11ports/mysql/queries/sprint.sql
-
111ports/mysql/sprint.go
-
10usecases/items/result.go
-
1usecases/projects/repository.go
-
25usecases/projects/result.go
-
33usecases/projects/service.go
-
3usecases/sprints/repository.go
-
27usecases/sprints/result.go
-
249usecases/sprints/service.go
@ -0,0 +1,10 @@ |
|||||
|
package genutils |
||||
|
|
||||
|
func Map[I any, O any](arr []I, cb func(a I) O) []O { |
||||
|
res := make([]O, 0, len(arr)) |
||||
|
for _, value := range arr { |
||||
|
res = append(res, cb(value)) |
||||
|
} |
||||
|
|
||||
|
return res |
||||
|
} |
@ -0,0 +1,47 @@ |
|||||
|
package httpapi |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"git.aiterp.net/stufflog3/stufflog3/models" |
||||
|
"git.aiterp.net/stufflog3/stufflog3/usecases/sprints" |
||||
|
"github.com/gin-gonic/gin" |
||||
|
"time" |
||||
|
) |
||||
|
|
||||
|
func Sprints(g *gin.RouterGroup, sprintsService *sprints.Service) { |
||||
|
g.GET("", handler("sprints", func(c *gin.Context) (interface{}, error) { |
||||
|
from := time.Now().Add(-time.Hour * 24 * 7) |
||||
|
if fromQuery := c.Query("from"); fromQuery != "" { |
||||
|
parsed, err := time.Parse(time.RFC3339Nano, fromQuery) |
||||
|
if err != nil { |
||||
|
return nil, models.BadInputError{ |
||||
|
Object: "Query", |
||||
|
Field: "from", |
||||
|
Problem: "Incorrect date format: " + err.Error(), |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
from = parsed |
||||
|
} |
||||
|
|
||||
|
to := time.Now().Add(time.Hour * 24 * 7) |
||||
|
if toQuery := c.Query("to"); toQuery != "" { |
||||
|
parsed, err := time.Parse(time.RFC3339Nano, toQuery) |
||||
|
if err != nil { |
||||
|
return nil, models.BadInputError{ |
||||
|
Object: "Query", |
||||
|
Field: "to", |
||||
|
Problem: "Incorrect date format: " + err.Error(), |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
to = parsed |
||||
|
} |
||||
|
|
||||
|
return sprintsService.ListScopedBetween(c.Request.Context(), from, to) |
||||
|
})) |
||||
|
|
||||
|
g.GET("/:sprint_id", getterHandler("sprint", "sprint_id", func(ctx context.Context, id int) (*sprints.Result, error) { |
||||
|
return sprintsService.Find(ctx, id) |
||||
|
})) |
||||
|
} |
@ -0,0 +1,27 @@ |
|||||
|
package sprints |
||||
|
|
||||
|
import ( |
||||
|
"git.aiterp.net/stufflog3/stufflog3/entities" |
||||
|
"git.aiterp.net/stufflog3/stufflog3/usecases/items" |
||||
|
"git.aiterp.net/stufflog3/stufflog3/usecases/projects" |
||||
|
) |
||||
|
|
||||
|
type Result struct { |
||||
|
entities.Sprint |
||||
|
|
||||
|
AggregateAcquired int `json:"aggregateAcquired"` |
||||
|
AggregateTotal int `json:"aggregateTotal"` |
||||
|
|
||||
|
Items []items.Result `json:"items,omitempty"` |
||||
|
Stats []entities.Stat `json:"stats,omitempty"` |
||||
|
Requirements []projects.RequirementResult `json:"requirements,omitempty"` |
||||
|
Progress []ResultProgress `json:"progress,omitempty"` |
||||
|
} |
||||
|
|
||||
|
type ResultProgress struct { |
||||
|
ID int `json:"id"` |
||||
|
Name string `json:"name"` |
||||
|
Weight float64 `json:"weight"` |
||||
|
Acquired int `json:"acquired"` |
||||
|
Required *int `json:"required,omitempty"` |
||||
|
} |
@ -0,0 +1,249 @@ |
|||||
|
package sprints |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"git.aiterp.net/stufflog3/stufflog3/entities" |
||||
|
"git.aiterp.net/stufflog3/stufflog3/models" |
||||
|
"git.aiterp.net/stufflog3/stufflog3/usecases/items" |
||||
|
"git.aiterp.net/stufflog3/stufflog3/usecases/projects" |
||||
|
"git.aiterp.net/stufflog3/stufflog3/usecases/scopes" |
||||
|
"golang.org/x/sync/errgroup" |
||||
|
"math" |
||||
|
"time" |
||||
|
) |
||||
|
|
||||
|
type Service struct { |
||||
|
Scopes *scopes.Service |
||||
|
Items *items.Service |
||||
|
Projects *projects.Service |
||||
|
|
||||
|
Repository Repository |
||||
|
} |
||||
|
|
||||
|
func (s *Service) Find(ctx context.Context, id int) (*Result, error) { |
||||
|
sprint, err := s.Repository.Find(ctx, s.Scopes.Context(ctx).ID, id) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
parts, err := s.Repository.ListParts(ctx, *sprint) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
return s.fill(ctx, *sprint, parts) |
||||
|
} |
||||
|
|
||||
|
func (s *Service) ListScopedBetween(ctx context.Context, from, to time.Time) ([]Result, error) { |
||||
|
sprints, err := s.Repository.ListBetween(ctx, s.Scopes.Context(ctx).ID, from, to) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
parts, err := s.Repository.ListParts(ctx, sprints...) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
eg, ctx := errgroup.WithContext(ctx) |
||||
|
|
||||
|
results := make([]Result, len(sprints)) |
||||
|
for i := range sprints { |
||||
|
iCopy := i |
||||
|
|
||||
|
eg.Go(func() error { |
||||
|
res, err := s.fill(ctx, sprints[iCopy], parts) |
||||
|
if err != nil { |
||||
|
return err |
||||
|
} |
||||
|
|
||||
|
results[iCopy] = *res |
||||
|
|
||||
|
return nil |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
err = eg.Wait() |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
return results, nil |
||||
|
} |
||||
|
|
||||
|
func (s *Service) fill(ctx context.Context, sprint entities.Sprint, parts []entities.SprintPart) (*Result, error) { |
||||
|
res := Result{Sprint: sprint} |
||||
|
sc := s.Scopes.Context(ctx) |
||||
|
partIDs := make([]int, 0, len(parts)) |
||||
|
for _, part := range parts { |
||||
|
if part.SprintID == sprint.ID { |
||||
|
partIDs = append(partIDs, part.PartID) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
allStats, err := sc.Stats(ctx) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
progressMap := make(map[int]int, len(allStats)) |
||||
|
res.Progress = make([]ResultProgress, 0, len(allStats)) |
||||
|
|
||||
|
switch res.Kind { |
||||
|
case models.SprintKindItems: |
||||
|
pickedItems, err := s.Items.ListScoped(ctx, models.ItemFilter{ |
||||
|
IDs: partIDs, |
||||
|
}) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
res.Items = pickedItems |
||||
|
|
||||
|
for _, stat := range allStats { |
||||
|
totalRequired := 0 |
||||
|
for _, item := range res.Items { |
||||
|
if itemStat := item.Stat(stat.ID); itemStat != nil { |
||||
|
totalRequired += itemStat.Required |
||||
|
} |
||||
|
} |
||||
|
if totalRequired == 0 { |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
progressMap[stat.ID] = len(res.Progress) |
||||
|
res.Progress = append(res.Progress, ResultProgress{ |
||||
|
ID: stat.ID, |
||||
|
Name: stat.Name, |
||||
|
Weight: stat.Weight, |
||||
|
Required: &totalRequired, |
||||
|
}) |
||||
|
} |
||||
|
case models.SprintKindRequirements: |
||||
|
includedRequirements, err := s.Projects.FetchRequirements(ctx, partIDs...) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
for _, req := range includedRequirements { |
||||
|
res.Items = append(res.Items, req.Items...) |
||||
|
} |
||||
|
|
||||
|
for _, stat := range allStats { |
||||
|
totalRequired := 0 |
||||
|
for _, req := range res.Requirements { |
||||
|
if reqStat := req.Stat(stat.ID); reqStat != nil { |
||||
|
totalRequired += reqStat.Required |
||||
|
} |
||||
|
} |
||||
|
if totalRequired == 0 { |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
progressMap[stat.ID] = len(res.Progress) |
||||
|
res.Progress = append(res.Progress, ResultProgress{ |
||||
|
ID: stat.ID, |
||||
|
Name: stat.Name, |
||||
|
Weight: stat.Weight, |
||||
|
Required: &totalRequired, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
res.Requirements = includedRequirements |
||||
|
case models.SprintKindStats: |
||||
|
includedStats := make([]entities.Stat, 0, len(partIDs)) |
||||
|
for _, stat := range allStats { |
||||
|
for _, id := range partIDs { |
||||
|
if stat.ID == id { |
||||
|
includedStats = append(includedStats, stat) |
||||
|
break |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
progressMap = make(map[int]int, len(includedStats)) |
||||
|
res.Progress = make([]ResultProgress, 0, len(includedStats)) |
||||
|
for _, stat := range includedStats { |
||||
|
required := 0 |
||||
|
for _, part := range parts { |
||||
|
if part.PartID == stat.ID { |
||||
|
required = part.Required |
||||
|
break |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
progressMap[stat.ID] = len(res.Progress) |
||||
|
res.Progress = append(res.Progress, ResultProgress{ |
||||
|
ID: stat.ID, |
||||
|
Name: stat.Name, |
||||
|
Weight: stat.Weight, |
||||
|
Required: &required, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
acquiredItems, err := s.Items.ListScoped(ctx, models.ItemFilter{ |
||||
|
AcquiredTime: &models.TimeInterval[time.Time]{Min: sprint.FromTime, Max: sprint.ToTime}, |
||||
|
StatIDs: partIDs, |
||||
|
}) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
res.Items = acquiredItems |
||||
|
|
||||
|
case models.SprintKindScope: |
||||
|
acquiredItems, err := s.Items.ListScoped(ctx, models.ItemFilter{ |
||||
|
AcquiredTime: &models.TimeInterval[time.Time]{Min: sprint.FromTime, Max: sprint.ToTime}, |
||||
|
}) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
res.Items = acquiredItems |
||||
|
|
||||
|
for _, stat := range allStats { |
||||
|
progressMap[stat.ID] = len(res.Progress) |
||||
|
res.Progress = append(res.Progress, ResultProgress{ |
||||
|
ID: stat.ID, |
||||
|
Name: stat.Name, |
||||
|
Weight: stat.Weight, |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Measure progress
|
||||
|
for _, item := range res.Items { |
||||
|
if item.AcquiredTime == nil || item.AcquiredTime.Before(sprint.FromTime) || !item.AcquiredTime.Before(sprint.ToTime) { |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
for _, stat := range item.Stats { |
||||
|
if pi, ok := progressMap[stat.ID]; ok { |
||||
|
res.Progress[pi].Acquired += stat.Acquired |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// For requirement sprints, remove items array after enumeration
|
||||
|
if res.Kind == models.SprintKindRequirements { |
||||
|
res.Items = nil |
||||
|
} |
||||
|
|
||||
|
// Calculate aggregate values
|
||||
|
aggregateAcquired := 0.0 |
||||
|
aggregateTotal := 0.0 |
||||
|
for _, progress := range res.Progress { |
||||
|
if res.IsUnweighted { |
||||
|
aggregateAcquired += float64(progress.Acquired) |
||||
|
if progress.Required != nil { |
||||
|
aggregateTotal += float64(*progress.Required) |
||||
|
} |
||||
|
} else { |
||||
|
aggregateAcquired += float64(progress.Acquired) * progress.Weight |
||||
|
if progress.Required != nil { |
||||
|
aggregateTotal += float64(*progress.Required) * progress.Weight |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
res.AggregateAcquired = int(math.Round(aggregateAcquired)) |
||||
|
res.AggregateTotal = int(math.Round(aggregateTotal)) |
||||
|
|
||||
|
return &res, nil |
||||
|
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue