Loggest thine Stuff
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

636 lines
15 KiB

package sprints
import (
"context"
"git.aiterp.net/stufflog3/stufflog3/entities"
"git.aiterp.net/stufflog3/stufflog3/internal/genutils"
"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"
"sort"
"strings"
"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) ListBetween(ctx context.Context, from, to time.Time) ([]Result, error) {
allScopes, err := s.Scopes.Context(ctx).Scopes(ctx)
if err != nil {
return nil, err
}
allSprints := make([][]entities.Sprint, len(allScopes))
eg, ctx2 := errgroup.WithContext(ctx)
for i, scope := range allScopes {
list := &allSprints[i]
scopeID := scope.ID
eg.Go(func() (err error) {
*list, err = s.Repository.ListBetween(ctx2, scopeID, from, to)
return
})
}
err = eg.Wait()
if err != nil {
return nil, err
}
sprints := genutils.Flatten2(allSprints)
sort.Slice(sprints, func(i, j int) bool {
si := sprints[i]
sj := sprints[j]
if si.FromTime.Equal(sj.FromTime) {
return si.Name < sj.Name
}
return si.FromTime.Before(sj.FromTime)
})
return s.fillMany(ctx, sprints)
}
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
}
return s.fillMany(ctx, sprints)
}
func (s *Service) fillMany(ctx context.Context, sprints []entities.Sprint) ([]Result, error) {
now := time.Now()
sort.Slice(sprints, func(i, j int) bool {
si := sprints[i]
sj := sprints[j]
if si.IsTimed != sj.IsTimed {
return si.IsTimed
}
siActive := si.ToTime.After(now)
sjActive := sj.ToTime.After(now)
if sjActive != siActive {
return siActive
}
if !si.FromTime.Equal(sj.FromTime) {
return si.FromTime.Before(sj.FromTime)
}
return si.ID < sj.ID
})
parts, err := s.Repository.ListParts(ctx, sprints...)
if err != nil {
return nil, err
}
eg, ctx2 := errgroup.WithContext(ctx)
results := make([]Result, len(sprints))
for i := range sprints {
iCopy := i
eg.Go(func() error {
res, err := s.fill(ctx2, 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) Create(ctx context.Context, sprint entities.Sprint, parts []entities.SprintPart) (*Result, error) {
scope := s.Scopes.Context(ctx).Scope
sprint.Name = strings.Trim(sprint.Name, "  \t\r\n")
if sprint.Name == "" {
return nil, models.BadInputError{
Object: "SprintInput",
Field: "name",
Problem: "Empty name provided",
}
}
if !sprint.Kind.Valid() {
return nil, models.BadInputError{
Object: "SprintInput",
Field: "kind",
Problem: "Non-existent kind ID",
Min: 0,
Max: models.MaxSprintKind,
}
}
if sprint.FromTime.IsZero() {
return nil, models.BadInputError{
Object: "SprintInput",
Field: "fromTime",
Problem: "Empty from time provided",
}
}
if sprint.ToTime.IsZero() {
return nil, models.BadInputError{
Object: "SprintInput",
Field: "toTime",
Problem: "Empty from time provided",
}
}
if sprint.ToTime.Before(sprint.FromTime) {
return nil, models.BadInputError{
Object: "SprintInput",
Field: "toTime",
Problem: "Time goes only one way in this universe as fas as we know",
}
}
ids := genutils.Map(parts, func(p entities.SprintPart) int { return p.PartID })
switch sprint.Kind {
case models.SprintKindScope:
{
if len(parts) > 0 {
return nil, models.BadInputError{
Object: "SprintInput",
Field: "parts",
Problem: "Scope-sprints cannot have parts.",
}
}
}
case models.SprintKindStats:
{
for _, part := range parts {
if !scope.HasStat(part.PartID) {
return nil, models.NotFoundIDError("Stat", part.PartID)
}
}
}
case models.SprintKindItems:
{
sprintItems, err := s.Items.ListScoped(ctx, models.ItemFilter{IDs: ids})
if err != nil {
return nil, err
}
if len(sprintItems) != len(ids) {
for _, id := range ids {
if genutils.Find(sprintItems, func(i items.Result) bool { return i.ID == id }) == nil {
return nil, models.NotFoundIDError("Item", id)
}
}
}
if len(sprint.Tags) > 0 {
return nil, models.BadInputError{
Object: "SprintInput",
Field: "tags",
Problem: "Item sprints cannot have tags.",
}
}
}
case models.SprintKindRequirements:
{
sprintReqs, err := s.Projects.FetchRequirements(ctx, ids...)
if err != nil {
return nil, err
}
if len(sprintReqs) != len(ids) {
for _, id := range ids {
if genutils.Find(sprintReqs, func(i projects.RequirementResult) bool { return i.ID == id }) == nil {
return nil, models.NotFoundIDError("Requirement", id)
}
}
}
}
}
sprint.ScopeID = scope.ID
newSprint, err := s.Repository.Insert(ctx, sprint)
if err != nil {
return nil, err
}
newParts := make([]entities.SprintPart, 0, 16)
for _, part := range parts {
part.SprintID = newSprint.ID
err = s.Repository.UpdatePart(ctx, part)
if err == nil {
newParts = append(newParts, part)
}
}
return s.fill(ctx, *newSprint, newParts)
}
func (s *Service) Update(ctx context.Context, id int, update models.SprintUpdate) (*Result, error) {
scope := s.Scopes.Context(ctx).Scope
sprint, err := s.Repository.Find(ctx, scope.ID, id)
if err != nil {
return nil, err
}
if update.Name != nil && *update.Name == "" {
return nil, models.BadInputError{
Object: "SprintUpdate",
Field: "name",
Problem: "Empty name provided",
}
}
fromTime := sprint.FromTime
if update.FromTime != nil {
fromTime = *update.FromTime
}
if update.ToTime != nil && update.ToTime.Before(fromTime) {
return nil, models.BadInputError{
Object: "SprintUpdate",
Field: "toTime",
Problem: "Time goes only one way in this universe as fas as we know",
}
}
err = s.Repository.Update(ctx, *sprint, update)
if err != nil {
return nil, err
}
sprint.ApplyUpdate(update)
parts, err := s.Repository.ListParts(ctx, *sprint)
if err != nil {
return nil, err
}
return s.fill(ctx, *sprint, parts)
}
func (s *Service) AddPart(ctx context.Context, sprintPart entities.SprintPart) (*Result, error) {
scope := s.Scopes.Context(ctx).Scope
sprint, err := s.Repository.Find(ctx, scope.ID, sprintPart.SprintID)
if err != nil {
return nil, err
}
switch sprint.Kind {
case models.SprintKindItems:
{
_, err := s.Items.Find(ctx, sprintPart.PartID)
if err != nil {
return nil, err
}
}
case models.SprintKindRequirements:
{
_, err := s.Projects.FindRequirement(ctx, sprintPart.PartID)
if err != nil {
return nil, err
}
}
case models.SprintKindStats:
{
if !scope.HasStat(sprintPart.PartID) {
return nil, models.NotFoundIDError("Stat", sprintPart.PartID)
}
}
case models.SprintKindScope:
{
return nil, models.ForbiddenError("Scope-sprints cannot have parts")
}
}
err = s.Repository.UpdatePart(ctx, sprintPart)
if err != nil {
return nil, err
}
sprintParts, err := s.Repository.ListParts(ctx, *sprint)
if err != nil {
return nil, err
}
return s.fill(ctx, *sprint, sprintParts)
}
func (s *Service) RemovePart(ctx context.Context, sprintPart entities.SprintPart) (*Result, error) {
scope := s.Scopes.Context(ctx).Scope
sprint, err := s.Repository.Find(ctx, scope.ID, sprintPart.SprintID)
if err != nil {
return nil, err
}
err = s.Repository.DeletePart(ctx, sprintPart)
if err != nil {
return nil, err
}
sprintParts, err := s.Repository.ListParts(ctx, *sprint)
if err != nil {
return nil, err
}
return s.fill(ctx, *sprint, sprintParts)
}
func (s *Service) Delete(ctx context.Context, id int) (*Result, error) {
sprint, err := s.Find(ctx, id)
if err != nil {
return nil, err
}
err = s.Repository.Delete(ctx, sprint.Sprint)
if err != nil {
return nil, err
}
return sprint, nil
}
func (s *Service) fill(ctx context.Context, sprint entities.Sprint, parts []entities.SprintPart) (*Result, error) {
res := Result{Sprint: sprint}
var allStats []scopes.ResultStat
sc := s.Scopes.Context(ctx)
if sc.ID != sprint.ScopeID {
allScopes, err := sc.Scopes(ctx)
if err != nil {
return nil, err
}
for _, scope := range allScopes {
if scope.ID == sprint.ScopeID {
allStats = scope.Stats
break
}
}
if allStats == nil {
return nil, models.NotFoundError("Scope")
}
} else {
allStats = sc.Scope.Stats
}
partIDs := make([]int, 0, len(parts))
for _, part := range parts {
if part.SprintID == sprint.ID {
partIDs = append(partIDs, part.PartID)
}
}
progressMap := make(map[int]int, len(allStats))
res.Progress = make([]ResultProgress, 0, len(allStats))
res.PartIDs = partIDs
switch res.Kind {
case models.SprintKindItems:
pickedItems, err := s.Items.ListScoped(ctx, models.ItemFilter{
IDs: partIDs,
ScopeIDs: []int{sprint.ScopeID},
})
if err != nil {
return nil, err
}
res.Items = pickedItems
res.ItemsRequired = genutils.Ptr(len(pickedItems))
res.ItemsAcquired = new(int)
for _, item := range pickedItems {
if item.AcquiredTime != nil {
*res.ItemsAcquired += 1
}
}
for _, stat := range allStats {
totalRequired := 0
for _, item := range res.Items {
if item.AcquiredOutside(sprint.FromTime, sprint.ToTime) {
continue
}
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
}
res.Requirements = includedRequirements
for _, req := range res.Requirements {
for _, item := range req.Items {
disqualified := false
for _, tag := range sprint.Tags {
if !item.HasTag(tag) {
disqualified = true
break
}
}
if !disqualified {
res.Items = append(res.Items, item)
}
}
}
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,
})
}
case models.SprintKindStats:
includedStats := make([]scopes.ResultStat, 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.SprintID == sprint.ID && 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,
ScopeIDs: []int{sprint.ScopeID},
Tags: sprint.Tags,
})
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},
ScopeIDs: []int{sprint.ScopeID},
Tags: sprint.Tags,
})
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,
Required: genutils.Ptr(0),
})
}
}
sort.Slice(res.Items, func(i, j int) bool {
ii := res.Items[i]
ij := res.Items[j]
if ii.AcquiredTime == nil && ij.AcquiredTime == nil {
return res.Items[i].CreatedTime.Before(res.Items[j].CreatedTime)
} else if ii.AcquiredTime == nil && ij.AcquiredTime != nil {
return true
} else if ii.AcquiredTime != nil && ij.AcquiredTime == nil {
return false
} else {
return ii.AcquiredTime.Before(*ij.AcquiredTime)
}
})
// Measure progress
itemBurndown := burndownGenerator{}
aggregateBurndown := burndownGenerator{}
for _, item := range res.Items {
if !item.AcquiredBetween(sprint.FromTime, sprint.ToTime) {
continue
}
itemBurndown.Add(*item.AcquiredTime, 1)
itemAggregate := 0.0
for _, stat := range item.Stats {
if pi, ok := progressMap[stat.ID]; ok {
res.Progress[pi].Acquired += stat.Acquired
if res.IsUnweighted {
itemAggregate += float64(stat.Acquired)
} else {
itemAggregate += float64(stat.Acquired) * stat.Weight
}
}
}
aggregateBurndown.Add(*item.AcquiredTime, itemAggregate)
}
res.AggregateBurndown = aggregateBurndown.Points
res.ItemBurndown = itemBurndown.Points
// For requirement sprints, remove items array after enumeration
if res.Kind == models.SprintKindRequirements {
res.Items = nil
}
// Calculate aggregate values
aggregateAcquired := 0.0
aggregatePlanned := 0.0
for _, progress := range res.Progress {
if res.IsUnweighted {
aggregateAcquired += float64(progress.Acquired)
if progress.Required != nil {
aggregatePlanned += float64(*progress.Required)
}
} else {
aggregateAcquired += float64(progress.Acquired) * progress.Weight
if progress.Required != nil {
aggregatePlanned += float64(*progress.Required) * progress.Weight
}
}
}
res.AggregateAcquired = aggregateAcquired
res.AggregatePlanned = aggregatePlanned
if res.AggregateRequired < 0 {
res.AggregateRequired = int(res.AggregatePlanned)
}
return &res, nil
}