mirror of
https://github.com/muety/wakapi.git
synced 2025-12-05 22:20:24 -08:00
494 lines
14 KiB
Go
494 lines
14 KiB
Go
package models
|
|
|
|
import (
|
|
"errors"
|
|
"math"
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/duke-git/lancet/v2/mathutil"
|
|
"github.com/duke-git/lancet/v2/slice"
|
|
)
|
|
|
|
const (
|
|
NSummaryTypes uint8 = 99
|
|
SummaryUnknown uint8 = 98
|
|
SummaryProject uint8 = 0
|
|
SummaryLanguage uint8 = 1
|
|
SummaryEditor uint8 = 2
|
|
SummaryOS uint8 = 3
|
|
SummaryMachine uint8 = 4
|
|
SummaryLabel uint8 = 5
|
|
SummaryBranch uint8 = 6
|
|
SummaryEntity uint8 = 7
|
|
SummaryCategory uint8 = 8
|
|
)
|
|
|
|
const UnknownSummaryKey = "unknown"
|
|
const DefaultProjectLabel = "default"
|
|
|
|
type Summaries []*Summary
|
|
|
|
type Summary struct {
|
|
ID uint `json:"-" gorm:"primary_key; size:32"`
|
|
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
|
UserID string `json:"user_id" gorm:"not null; index:idx_time_summary_user"`
|
|
FromTime CustomTime `json:"from" gorm:"not null; index:idx_time_summary_user" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"` // filled by gorm if not set, see https://gorm.io/docs/conventions.html#CreatedAt
|
|
ToTime CustomTime `json:"to" gorm:"not null; index:idx_time_summary_user" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"` // filled by gorm if not set, see https://gorm.io/docs/conventions.html#CreatedAt
|
|
|
|
// Previously, all the following properties created a cascade foreign key constraint on the summary_items table
|
|
// back to this summary table resulting in 5 identical foreign key constraints on the summary_items table.
|
|
// This is not a problem for PostgreSQL, MySQL and SQLite, but for MSSQL (not supported anymore though), which complains about circular cascades on
|
|
// update/delete between these two tables. All of these created foreign key constraints are identical, so only one constraint is enough.
|
|
// MySQL will create a foreign key constraint for every property referencing other structs, even no constraint is specified in tags.
|
|
// So explicitly set gorm:"-" in all other properties to avoid creating duplicate foreign key constraints
|
|
Projects SummaryItems `json:"projects" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
|
Languages SummaryItems `json:"languages" gorm:"-"`
|
|
Editors SummaryItems `json:"editors" gorm:"-"`
|
|
OperatingSystems SummaryItems `json:"operating_systems" gorm:"-"`
|
|
Machines SummaryItems `json:"machines" gorm:"-"`
|
|
Labels SummaryItems `json:"labels" gorm:"-"` // labels are not persisted, but calculated at runtime, i.e. when summary is retrieved
|
|
Branches SummaryItems `json:"branches" gorm:"-"` // branches are not persisted, but calculated at runtime in case a project Filter is applied
|
|
Entities SummaryItems `json:"entities" gorm:"-"` // entities are not persisted, but calculated at runtime in case a project Filter is applied
|
|
Categories SummaryItems `json:"categories" gorm:"-"`
|
|
NumHeartbeats int `json:"-"`
|
|
}
|
|
|
|
type SummaryItems []*SummaryItem
|
|
|
|
type SummaryItem struct {
|
|
ID uint64 `json:"-" gorm:"primary_key"`
|
|
Summary *Summary `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
|
SummaryID uint `json:"-" gorm:"size:32"`
|
|
Type uint8 `json:"-" gorm:"index:idx_type"`
|
|
Key string `json:"key" gorm:"size:255"`
|
|
Total time.Duration `json:"total" swaggertype:"primitive,integer"`
|
|
}
|
|
|
|
type SummaryItemContainer struct {
|
|
Type uint8
|
|
Items []*SummaryItem
|
|
}
|
|
|
|
type SummaryParams struct {
|
|
From time.Time
|
|
To time.Time
|
|
User *User
|
|
Filters *Filters
|
|
Recompute bool
|
|
}
|
|
|
|
// don't forget to adapt models.GetEntityColumn() when changing these
|
|
|
|
func SummaryTypes() []uint8 {
|
|
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine, SummaryLabel, SummaryBranch, SummaryEntity, SummaryCategory}
|
|
}
|
|
|
|
func NativeSummaryTypes() []uint8 {
|
|
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine, SummaryBranch, SummaryEntity, SummaryCategory}
|
|
}
|
|
|
|
func PersistedSummaryTypes() []uint8 {
|
|
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine, SummaryCategory}
|
|
}
|
|
|
|
func NewEmptySummary() *Summary {
|
|
return &Summary{
|
|
Projects: SummaryItems{},
|
|
Languages: SummaryItems{},
|
|
Editors: SummaryItems{},
|
|
OperatingSystems: SummaryItems{},
|
|
Machines: SummaryItems{},
|
|
Labels: SummaryItems{},
|
|
Branches: SummaryItems{},
|
|
Entities: SummaryItems{},
|
|
Categories: SummaryItems{},
|
|
}
|
|
}
|
|
|
|
func (s *Summary) Sorted() *Summary {
|
|
sort.Sort(sort.Reverse(s.Projects))
|
|
sort.Sort(sort.Reverse(s.Machines))
|
|
sort.Sort(sort.Reverse(s.OperatingSystems))
|
|
sort.Sort(sort.Reverse(s.Languages))
|
|
sort.Sort(sort.Reverse(s.Editors))
|
|
sort.Sort(sort.Reverse(s.Labels))
|
|
sort.Sort(sort.Reverse(s.Branches))
|
|
sort.Sort(sort.Reverse(s.Entities))
|
|
sort.Sort(sort.Reverse(s.Categories))
|
|
return s
|
|
}
|
|
|
|
func (s *Summary) Types() []uint8 {
|
|
return SummaryTypes()
|
|
}
|
|
|
|
func (s *Summary) MappedItems() map[uint8]*SummaryItems {
|
|
return map[uint8]*SummaryItems{
|
|
SummaryProject: &s.Projects,
|
|
SummaryLanguage: &s.Languages,
|
|
SummaryEditor: &s.Editors,
|
|
SummaryOS: &s.OperatingSystems,
|
|
SummaryMachine: &s.Machines,
|
|
SummaryLabel: &s.Labels,
|
|
SummaryBranch: &s.Branches,
|
|
SummaryEntity: &s.Entities,
|
|
SummaryCategory: &s.Categories,
|
|
}
|
|
}
|
|
|
|
func (s *Summary) GetByType(summaryType uint8) *SummaryItems {
|
|
switch summaryType {
|
|
case SummaryProject:
|
|
return &s.Projects
|
|
case SummaryLanguage:
|
|
return &s.Languages
|
|
case SummaryEditor:
|
|
return &s.Editors
|
|
case SummaryOS:
|
|
return &s.OperatingSystems
|
|
case SummaryMachine:
|
|
return &s.Machines
|
|
case SummaryLabel:
|
|
return &s.Labels
|
|
case SummaryBranch:
|
|
return &s.Branches
|
|
case SummaryEntity:
|
|
return &s.Entities
|
|
case SummaryCategory:
|
|
return &s.Categories
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Summary) SetByType(summaryType uint8, items *SummaryItems) {
|
|
switch summaryType {
|
|
case SummaryProject:
|
|
s.Projects = *items
|
|
break
|
|
case SummaryLanguage:
|
|
s.Languages = *items
|
|
break
|
|
case SummaryEditor:
|
|
s.Editors = *items
|
|
break
|
|
case SummaryOS:
|
|
s.OperatingSystems = *items
|
|
break
|
|
case SummaryMachine:
|
|
s.Machines = *items
|
|
break
|
|
case SummaryLabel:
|
|
s.Labels = *items
|
|
break
|
|
case SummaryBranch:
|
|
s.Branches = *items
|
|
break
|
|
case SummaryEntity:
|
|
s.Entities = *items
|
|
break
|
|
case SummaryCategory:
|
|
s.Categories = *items
|
|
break
|
|
}
|
|
}
|
|
|
|
func (s *Summary) KeepOnly(types map[uint8]bool) *Summary {
|
|
if len(types) == 0 {
|
|
return s
|
|
}
|
|
|
|
for _, t := range SummaryTypes() {
|
|
if keep, ok := types[t]; !keep || !ok {
|
|
*s.GetByType(t) = []*SummaryItem{}
|
|
}
|
|
}
|
|
|
|
return s
|
|
}
|
|
|
|
// ApplyFilter drops all summary elements of the given type that don't match the given query.
|
|
// Please note: this only makes sense if you're eventually interested in nothing but the total time of that specific type,
|
|
// because the summary will be inconsistent after this operation (e.g. when filtering by project, languages, editors, etc. won't match up anymore).
|
|
// Therefore, use with caution.
|
|
func (s *Summary) ApplyFilter(filter FilterElement) *Summary {
|
|
items := SummaryItems(slice.Filter[*SummaryItem](*s.GetByType(filter.Entity), func(i int, item *SummaryItem) bool {
|
|
return filter.Filter.MatchAny(item.Key)
|
|
}))
|
|
s.SetByType(filter.Entity, &items)
|
|
return s
|
|
}
|
|
|
|
/*
|
|
Augments the summary in a way that at least one item is present for every type.
|
|
|
|
If a summary has zero items for a given type, but one or more for any of the other types,
|
|
the total summary duration can be derived from those and inserted as a dummy-item with key "unknown"
|
|
for the missing type.
|
|
For instance, the machine type was introduced post hoc. Accordingly, no "machine"-information is present in
|
|
the data for old heartbeats and summaries. If a user has two years of data without machine information and
|
|
one day with such, a "machine"-chart plotted from that data will reference a way smaller absolute total amount
|
|
of time than the other ones.
|
|
To avoid having to modify persisted data retrospectively, i.e. inserting a dummy SummaryItem for the new type,
|
|
such is generated dynamically here, considering the "machine" for all old heartbeats "unknown".
|
|
*/
|
|
func (s *Summary) FillMissing() {
|
|
types := s.Types()
|
|
typeItems := s.MappedItems()
|
|
missingTypes := make([]uint8, 0)
|
|
|
|
for _, t := range types {
|
|
if len(*typeItems[t]) == 0 {
|
|
missingTypes = append(missingTypes, t)
|
|
}
|
|
}
|
|
|
|
// can't proceed if entire summary is empty
|
|
if len(missingTypes) == len(types) {
|
|
return
|
|
}
|
|
|
|
// construct dummy item for all missing types
|
|
presentType, err := s.findFirstPresentType()
|
|
if err != nil {
|
|
return // all types are either zero or missing entirely, nothing to fill
|
|
}
|
|
for _, t := range missingTypes {
|
|
s.FillBy(presentType, t)
|
|
}
|
|
}
|
|
|
|
// inplace!
|
|
func (s *Summary) FillBy(fromType uint8, toType uint8) {
|
|
typeItems := s.MappedItems()
|
|
totalWanted := s.TotalTimeBy(fromType)
|
|
totalActual := s.TotalTimeBy(toType)
|
|
|
|
key := UnknownSummaryKey
|
|
if toType == SummaryLabel {
|
|
key = DefaultProjectLabel
|
|
}
|
|
|
|
existingEntryIdx := -1
|
|
for i, item := range *typeItems[toType] {
|
|
if item.Key == key {
|
|
existingEntryIdx = i
|
|
break
|
|
}
|
|
}
|
|
|
|
total := (totalWanted - totalActual) / time.Second // workaround
|
|
if total > 0 {
|
|
if existingEntryIdx >= 0 {
|
|
(*typeItems[toType])[existingEntryIdx].Total = total
|
|
} else {
|
|
*typeItems[toType] = append(*typeItems[toType], &SummaryItem{
|
|
Type: toType,
|
|
Key: key,
|
|
Total: total,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Summary) TotalTime() time.Duration {
|
|
var timeSum time.Duration
|
|
|
|
mappedItems := s.MappedItems()
|
|
t, err := s.findFirstPresentType()
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
for _, item := range *mappedItems[t] {
|
|
timeSum += item.Total
|
|
}
|
|
|
|
return timeSum * time.Second
|
|
}
|
|
|
|
func (s *Summary) TotalTimeBy(entityType uint8) (timeSum time.Duration) {
|
|
mappedItems := s.MappedItems()
|
|
if items := mappedItems[entityType]; len(*items) > 0 {
|
|
for _, item := range *items {
|
|
timeSum = timeSum + item.Total*time.Second
|
|
}
|
|
}
|
|
return timeSum
|
|
}
|
|
|
|
func (s *Summary) TotalTimeByKey(entityType uint8, key string) (timeSum time.Duration) {
|
|
mappedItems := s.MappedItems()
|
|
if items := mappedItems[entityType]; len(*items) > 0 {
|
|
for _, item := range *items {
|
|
if item.Key != key {
|
|
continue
|
|
}
|
|
timeSum = timeSum + item.Total*time.Second
|
|
}
|
|
}
|
|
return timeSum
|
|
}
|
|
|
|
func (s *Summary) TotalTimeByFilter(filter FilterElement) time.Duration {
|
|
var total time.Duration
|
|
for _, f := range filter.Filter {
|
|
total += s.TotalTimeByKey(filter.Entity, f)
|
|
}
|
|
return total
|
|
}
|
|
|
|
func (s *Summary) MaxBy(entityType uint8) *SummaryItem {
|
|
var max *SummaryItem
|
|
mappedItems := s.MappedItems()
|
|
if items := mappedItems[entityType]; len(*items) > 0 {
|
|
for _, item := range *items {
|
|
if max == nil || item.Total > max.Total {
|
|
max = item
|
|
}
|
|
}
|
|
}
|
|
return max
|
|
}
|
|
|
|
func (s *Summary) MaxByToString(entityType uint8) string {
|
|
max := s.MaxBy(entityType)
|
|
if max == nil {
|
|
return "-"
|
|
}
|
|
return max.Key
|
|
}
|
|
|
|
func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary {
|
|
processAliases := func(items []*SummaryItem) []*SummaryItem {
|
|
if items == nil {
|
|
return nil
|
|
}
|
|
itemsAliased := make([]*SummaryItem, 0)
|
|
|
|
findItem := func(key string) *SummaryItem {
|
|
for _, item := range itemsAliased {
|
|
if item.Key == key {
|
|
return item
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
for _, item := range items {
|
|
// Add all "top-level" items, i.e. such without aliases
|
|
if key := resolve(item.Type, item.Key); key == item.Key {
|
|
itemsAliased = append(itemsAliased, item)
|
|
}
|
|
}
|
|
|
|
for _, item := range items {
|
|
// Add all remaining projects and merge with their alias
|
|
if key := resolve(item.Type, item.Key); key != item.Key {
|
|
if targetItem := findItem(key); targetItem != nil {
|
|
targetItem.Total += item.Total
|
|
} else {
|
|
itemsAliased = append(itemsAliased, &SummaryItem{
|
|
ID: item.ID,
|
|
SummaryID: item.SummaryID,
|
|
Type: item.Type,
|
|
Key: key,
|
|
Total: item.Total,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return itemsAliased
|
|
}
|
|
|
|
// Resolve aliases
|
|
s.Projects = processAliases(s.Projects)
|
|
s.Editors = processAliases(s.Editors)
|
|
s.Languages = processAliases(s.Languages)
|
|
s.OperatingSystems = processAliases(s.OperatingSystems)
|
|
s.Machines = processAliases(s.Machines)
|
|
s.Labels = processAliases(s.Labels)
|
|
s.Branches = processAliases(s.Branches)
|
|
s.Categories = processAliases(s.Categories)
|
|
// no aliases for entities / files
|
|
|
|
return s
|
|
}
|
|
|
|
// inplace!
|
|
func (s *Summary) InTZ(tz *time.Location) *Summary {
|
|
// time zone madness, see https://github.com/muety/wakapi/issues/719#issuecomment-2599365514
|
|
s.FromTime = CustomTime(s.FromTime.T().In(tz))
|
|
s.ToTime = CustomTime(s.ToTime.T().In(tz))
|
|
return s
|
|
}
|
|
|
|
func (s *Summary) findFirstPresentType() (uint8, error) {
|
|
for _, t := range s.Types() {
|
|
if s.TotalTimeBy(t) != 0 {
|
|
return t, nil
|
|
}
|
|
}
|
|
return 127, errors.New("no type present")
|
|
}
|
|
|
|
func (s *SummaryParams) HasFilters() bool {
|
|
return s.Filters != nil && !s.Filters.IsEmpty()
|
|
}
|
|
|
|
func (s *SummaryParams) IsProjectDetails() bool {
|
|
if !s.HasFilters() {
|
|
return false
|
|
}
|
|
_, entity, filters := s.Filters.One()
|
|
return entity == SummaryProject && len(filters) == 1 // exactly one
|
|
}
|
|
|
|
func (s *SummaryParams) GetProjectFilter() string {
|
|
if !s.IsProjectDetails() {
|
|
return ""
|
|
}
|
|
_, _, filters := s.Filters.One()
|
|
return filters[0]
|
|
}
|
|
|
|
func (s *SummaryParams) RangeDays() int {
|
|
return int(math.Floor(s.To.Sub(s.From).Hours() / 24))
|
|
}
|
|
|
|
func (s *SummaryItem) TotalFixed() time.Duration {
|
|
// this is a workaround, since currently, the total time of a summary item is mistakenly represented in seconds
|
|
// TODO: fix some day, while migrating persisted summary items
|
|
return s.Total * time.Second
|
|
}
|
|
|
|
func (s Summaries) MaxTotalTime() time.Duration {
|
|
return mathutil.Max(slice.Map[*Summary, time.Duration](s, func(i int, item *Summary) time.Duration {
|
|
return item.TotalTime()
|
|
})...)
|
|
}
|
|
|
|
func (s Summaries) Len() int {
|
|
return len(s)
|
|
}
|
|
|
|
func (s Summaries) Less(i, j int) bool {
|
|
return s[i].FromTime.T().Before(s[j].FromTime.T())
|
|
}
|
|
|
|
func (s Summaries) Swap(i, j int) {
|
|
s[i], s[j] = s[j], s[i]
|
|
}
|
|
|
|
func (s SummaryItems) Len() int {
|
|
return len(s)
|
|
}
|
|
|
|
func (s SummaryItems) Less(i, j int) bool {
|
|
return s[i].Total < s[j].Total
|
|
}
|
|
|
|
func (s SummaryItems) Swap(i, j int) {
|
|
s[i], s[j] = s[j], s[i]
|
|
}
|