Compare commits

...
6 Commits
Author SHA1 Message Date
gisle 53be464d7e move timed-out sprints down.
continuous-integration/drone/push Build is passing
2022-08-05 15:24:46 +02:00
gisle b381e2cbed move requirement aggregate + amounts to the bottom.
continuous-integration/drone/push Build is passing
2022-07-29 13:15:20 +02:00
gisle d7c1fbb21c add more space between paragraphs.
continuous-integration/drone/push Build is passing
2022-07-29 12:16:21 +02:00
gisle d4141f5eb3 add date under items in projects so you don't need to open the modal to see when they're acquired. 2022-07-29 12:14:47 +02:00
gisle 1232721921 add burndown stats for items too.
continuous-integration/drone/push Build is passing
2022-07-28 18:38:16 +02:00
gisle a70bb4bb23 add burndown stats to sprint API.
continuous-integration/drone/push Build is passing
2022-07-28 18:33:48 +02:00
11 changed files with 273 additions and 9 deletions
@@ -15,7 +15,14 @@
div.markdown :global(p) {
padding: 0;
margin: 0.25em 0;
margin: 0.5em 0;
&:first-of-type {
margin-top: 0.25em;
}
&:last-of-type {
margin-bottom: 0;
}
}
div.markdown :global(code) {
@@ -11,6 +11,7 @@
import Icon from "../layout/Icon.svelte";
import { formatTime } from "$lib/utils/date";
import RequirementReference from "./RequirementReference.svelte";
import ItemTimeInfo from "./ItemTimeInfo.svelte";
export let item: Item;
export let event: "created" | "scheduled" | "acquired" | "none" = "none";
@@ -57,6 +58,8 @@
</OptionsRow>
{#if item.project != null}
<RequirementReference project={item.project} requirement={item.requirement} scopeId={item.scopeId} />
{:else}
<ItemTimeInfo item={item} />
{/if}
{#if !compact}
<Markdown source={item.description} />
@@ -0,0 +1,64 @@
<script lang="ts">
import type Item from "$lib/models/item";
import { formatPrettyTime } from "$lib/utils/date";
import { getTimeContext } from "../contexts/TimeContext.svelte";
export let item: Item
const {now} = getTimeContext();
let action = "create";
let colorClass = "";
let timeStr = "";
$: {
const ct = new Date(item.createdTime);
let t = ct;
if (item.acquiredTime != null) {
const at = new Date(item.acquiredTime);
t = at;
if (Math.abs(at.getTime() - ct.getTime()) < 300000) {
action = "Created/Acquired on"
} else {
action = "Acquired"
}
colorClass = "acquired"
} else if (item.scheduledDate != null) {
t = new Date(item.scheduledDate);
action = "Scheduled for"
colorClass = "scheduled"
} else {
action = "Created"
colorClass = "created"
}
timeStr = formatPrettyTime(t, $now);
}
</script>
<div class="item-time {colorClass}">
<span class="action">{action}</span>
<span class="timeStr">{timeStr}</span>
</div>
<style lang="sass">
@import "../../css/colors"
div.item-time
font-size: 0.8em
color: $color-entry3
&.created
color: $sc-1-dim
&.acquired
color: $sc-4-dim
&.scheduled
color: $sc-0
> span.timeStr
font-weight: 800
</style>
@@ -21,9 +21,6 @@ import AggregateAmountRow from "./AggregateAmountRow.svelte";
<Section title={requirement.name} icon={STATUS_ICONS[requirement.status]} status={requirement.status}>
<Progress alwaysSmooth titlePercentageOnly thin green status={requirement.status} count={requirement.totalAcquired} target={requirement.totalRequired} />
<Progress alwaysSmooth titlePercentageOnly thinner gray count={requirement.totalPlanned} target={requirement.totalRequired} />
{#if requirement.aggregateRequired > 0}
<AggregateAmountRow items={requirement.items} totalAcquired={requirement.totalAcquired} aggregateRequired={requirement.aggregateRequired} />
{/if}
<Markdown source={requirement.description} />
<OptionsRow slot="right">
<Option open={{name: "item.create", requirement}}><Icon name="plus" /></Option>
@@ -40,4 +37,7 @@ import AggregateAmountRow from "./AggregateAmountRow.svelte";
{#each requirement.items as item (item.id)}
<ItemEntry item={item} />
{/each}
{#if requirement.aggregateRequired > 0}
<AggregateAmountRow items={requirement.items} totalAcquired={requirement.totalAcquired} aggregateRequired={requirement.aggregateRequired} />
{/if}
</Section>
+2
View File
@@ -25,9 +25,11 @@ $opacity-entry6: 0.80
$sc-0: #78c9ff
$sc-0-dark: #34424b
$sc-1: #e7e55e
$sc-1-dim: #a3a352
$sc-2: #e7a75e
$sc-3: $color-entry12
$sc-4: #78ff78
$sc-4-dim: #549454
$sc-4-dark: #2f422f
$sc-5: #c94339
$sc-6: #e75ed0
+21 -1
View File
@@ -114,7 +114,7 @@ export function endOfDay(now: Date): Date {
}
export function monthName(date: Date): string {
return monthNames[date.getMonth()]
return monthNames[date.getMonth() - 1]
}
export function addDays(date: Date, days: number): Date {
@@ -133,4 +133,24 @@ export function addYears(date: Date, years: number): Date {
const newDate = new Date(date.getTime());
newDate.setFullYear(newDate.getFullYear() + years);
return newDate;
}
export function formatPrettyTime(date: Date, now: Date) {
const d = startOfDay(date);
const n = startOfDay(now);
const diff = (d.getTime() - n.getTime()) / (86400000);
const hhmm = `${pad(date.getHours())}:${pad(date.getHours())}`;
if (diff === 0) {
return `today at ${hhmm}`;
} else if (diff === 1) {
return `tomorrow at ${hhmm}`;
} else if (diff === -1) {
return `yesterday at ${hhmm}`;
} else if (diff > -3 && diff < 6) {
return `${weekDay[date.getDay()]} at ${hhmm}`;
} else {
return `${monthName(date)} ${date.getDate()} at ${hhmm}`;
}
}
+1
View File
@@ -9,6 +9,7 @@ require (
github.com/awslabs/aws-lambda-go-api-proxy v0.13.3
github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1
github.com/gin-gonic/gin v1.7.7
github.com/go-playground/assert/v2 v2.0.1
github.com/go-sql-driver/mysql v1.6.0
github.com/lestrrat-go/jwx v1.2.25
golang.org/x/sync v0.0.0-20220513210516-0976fa681c29
+71
View File
@@ -0,0 +1,71 @@
package sprints
import (
"git.aiterp.net/stufflog3/stufflog3/models"
"time"
)
type BurndownDataPoint struct {
Date models.Date `json:"date"`
Value float64 `json:"value"`
}
type burndownGenerator struct {
Points []BurndownDataPoint
}
func (g *burndownGenerator) Add(t time.Time, v float64) {
t = t.In(tz).Add(-time.Minute * 300)
yyyy, mm, dd := t.Date()
date := models.Date{yyyy, int(mm), dd}
found := false
insertIndex := -1
for i, p := range g.Points {
if date == p.Date {
found = true
break
} else if date.Before(p.Date) {
insertIndex = i
break
}
}
if !found {
if insertIndex == -1 {
value := 0.0
if len(g.Points) > 0 {
value = g.Points[len(g.Points)-1].Value
}
g.Points = append(g.Points, BurndownDataPoint{
Date: date,
Value: value,
})
} else {
g.Points = append(g.Points[:insertIndex+1], g.Points[insertIndex:]...)
g.Points[insertIndex].Date = date
if insertIndex > 0 {
g.Points[insertIndex].Value = g.Points[insertIndex-1].Value
} else {
g.Points[insertIndex].Value = 0
}
}
}
for i, p := range g.Points {
if !p.Date.Before(date) {
g.Points[i].Value += v
}
}
}
var tz *time.Location
func init() {
var err error
tz, err = time.LoadLocation("Europe/Oslo")
if err != nil {
panic(err)
}
}
+72
View File
@@ -0,0 +1,72 @@
package sprints
import (
"git.aiterp.net/stufflog3/stufflog3/models"
"github.com/go-playground/assert/v2"
"testing"
"time"
)
func TestBurndownGenerator_Add(t *testing.T) {
bg := burndownGenerator{}
// This is too early on the second, and counts as the first.
bg.Add(td(3, 2, 20), 14)
assert.Equal(t, bg.Points, []BurndownDataPoint{
{Date: d(2), Value: 14},
})
// Add some more data points in order
bg.Add(td(3, 14, 11), 4)
bg.Add(td(4, 6, 44), 3)
bg.Add(td(21, 11, 24), 20)
assert.Equal(t, bg.Points, []BurndownDataPoint{
{Date: d(2), Value: 14},
{Date: d(3), Value: 18},
{Date: d(4), Value: 21},
{Date: d(21), Value: 41},
})
// Add ones out of order.
bg.Add(td(7, 21, 12), 2)
bg.Add(td(2, 15, 22), 1)
assert.Equal(t, bg.Points, []BurndownDataPoint{
{Date: d(2), Value: 15},
{Date: d(3), Value: 19},
{Date: d(4), Value: 22},
{Date: d(7), Value: 24},
{Date: d(21), Value: 44},
})
// Add one at the start
bg.Add(td(1, 11, 16), 1)
assert.Equal(t, bg.Points, []BurndownDataPoint{
{Date: d(1), Value: 1},
{Date: d(2), Value: 16},
{Date: d(3), Value: 20},
{Date: d(4), Value: 23},
{Date: d(7), Value: 25},
{Date: d(21), Value: 45},
})
// Add one more in the middle for good measure
bg.Add(td(5, 14, 33), 10)
assert.Equal(t, bg.Points, []BurndownDataPoint{
{Date: d(1), Value: 1},
{Date: d(2), Value: 16},
{Date: d(3), Value: 20},
{Date: d(4), Value: 23},
{Date: d(5), Value: 33},
{Date: d(7), Value: 35},
{Date: d(21), Value: 55},
})
}
func d(dd int) models.Date {
return models.Date{2022, 7, dd}
}
func td(dd, hh, mm int) time.Time {
return time.Date(2022, time.July, dd, hh, mm, 17, 0, time.UTC)
}
+6 -4
View File
@@ -17,10 +17,12 @@ type Result struct {
PartIDs []int `json:"partIds"` // Just for simplifying code.
Items []items.Result `json:"items,omitempty"`
Stats []entities.Stat `json:"stats,omitempty"`
Requirements []projects.RequirementResult `json:"requirements,omitempty"`
Progress []ResultProgress `json:"progress,omitempty"`
Items []items.Result `json:"items,omitempty"`
Stats []entities.Stat `json:"stats,omitempty"`
Requirements []projects.RequirementResult `json:"requirements,omitempty"`
Progress []ResultProgress `json:"progress,omitempty"`
ItemBurndown []BurndownDataPoint `json:"itemBurndown,omitempty"`
AggregateBurndown []BurndownDataPoint `json:"aggregateBurndown,omitempty"`
}
type ResultProgress struct {
+22
View File
@@ -85,6 +85,7 @@ func (s *Service) ListScopedBetween(ctx context.Context, from, to time.Time) ([]
}
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]
@@ -93,6 +94,12 @@ func (s *Service) fillMany(ctx context.Context, sprints []entities.Sprint) ([]Re
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)
}
@@ -550,17 +557,32 @@ func (s *Service) fill(ctx context.Context, sprint entities.Sprint, parts []enti
})
// 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 {