Gisle Aune
2 years ago
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
-
19models/item.go
-
9models/sprint.go
-
41ports/httpapi/common.go
-
12ports/httpapi/items.go
-
47ports/httpapi/sprints.go
-
10ports/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
-
93usecases/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