Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53be464d7e | ||
|
|
b381e2cbed | ||
|
|
d7c1fbb21c | ||
|
|
d4141f5eb3 | ||
|
|
1232721921 | ||
|
|
a70bb4bb23 |
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user