Compare commits

...
20 Commits
Author SHA1 Message Date
gisle cbfbd7c3e7 fix lambda dep.
continuous-integration/drone/push Build is passing
2022-09-05 21:43:13 +02:00
gisle 344f743dc6 add useful titles to pages.
continuous-integration/drone/push Build is passing
2022-09-05 20:47:57 +02:00
gisle 1b73c83b3b add day summary for history.
continuous-integration/drone/push Build is passing
2022-08-10 21:32:03 +02:00
gisle 3a1142a965 add significant decimals where they make sense.
continuous-integration/drone/push Build is passing
2022-08-10 21:20:33 +02:00
gisle 3737a80c46 fix crash on front page when acqing items.
continuous-integration/drone/push Build is passing
2022-08-10 21:02:03 +02:00
gisle 0d2e027816 floating sprint progress.
continuous-integration/drone/push Build is passing
2022-08-09 21:41:42 +02:00
gisle cac4ac0bf6 filter history events by interval.
continuous-integration/drone/push Build is passing
2022-08-08 18:53:58 +02:00
gisle f5dee09e40 fix item created/acquired time in projects being shown as HH:HH 2022-08-08 18:42:04 +02:00
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
gisle 0ba65038e9 remove console.log from stat input.
continuous-integration/drone/push Build is passing
2022-07-27 18:28:40 +02:00
gisle 6c3fdad79c show requirement name in item modal on other pages too. 2022-07-27 18:27:50 +02:00
gisle e307ed6021 add requirement picker for items (only within project). 2022-07-27 18:24:54 +02:00
gisle e4c41e1d47 add aggregate requirement for requirements that estimate on weights instead.
continuous-integration/drone/push Build is passing
2022-07-27 18:10:23 +02:00
gisle ae47f1a0e5 fix two-wide modals on mobile devices.
continuous-integration/drone/push Build is passing
2022-07-26 19:03:47 +02:00
gisle b9ffe256e9 change status picker from dropdown to icon bar.
continuous-integration/drone/push Build is passing
2022-07-26 18:58:47 +02:00
48 changed files with 643 additions and 154 deletions
+11 -7
View File
@@ -31,13 +31,14 @@ func (project *Project) Update(update models.ProjectUpdate) {
} }
type Requirement struct { type Requirement struct {
ID int `json:"id"` ID int `json:"id"`
ScopeID int `json:"scopeId"` ScopeID int `json:"scopeId"`
ProjectID int `json:"projectId"` ProjectID int `json:"projectId"`
Name string `json:"name"` AggregateRequired int `json:"aggregateRequired"`
Description string `json:"description"` Name string `json:"name"`
IsCoarse bool `json:"isCoarse"` Description string `json:"description"`
Status models.Status `json:"status"` IsCoarse bool `json:"isCoarse"`
Status models.Status `json:"status"`
} }
func (requirement *Requirement) Update(update models.RequirementUpdate) { func (requirement *Requirement) Update(update models.RequirementUpdate) {
@@ -53,6 +54,9 @@ func (requirement *Requirement) Update(update models.RequirementUpdate) {
if update.IsCoarse != nil { if update.IsCoarse != nil {
requirement.IsCoarse = *update.IsCoarse requirement.IsCoarse = *update.IsCoarse
} }
if update.AggregateRequired != nil {
requirement.AggregateRequired = *update.AggregateRequired
}
} }
type RequirementStat struct { type RequirementStat struct {
-1
View File
@@ -13,7 +13,6 @@
"devDependencies": { "devDependencies": {
"@aws-amplify/auth": "^4.5.6", "@aws-amplify/auth": "^4.5.6",
"@aws-amplify/core": "^4.5.6", "@aws-amplify/core": "^4.5.6",
"@djverrall/sveltekit-lambda-adapter": "^0.0.14",
"@fortawesome/free-solid-svg-icons": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^6.1.1",
"@nikso/adapter-serverless": "^0.1.0", "@nikso/adapter-serverless": "^0.1.0",
"@sveltejs/adapter-auto": "next", "@sveltejs/adapter-auto": "next",
+1
View File
@@ -18,6 +18,7 @@ declare namespace App {
} }
interface Stuff { interface Stuff {
title?: string,
scope?: import("$lib/models/scope").default scope?: import("$lib/models/scope").default
} }
} }
@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import shortestDecimal from "$lib/utils/numbers"; import { significantDecimals } from "$lib/utils/numbers";
import PlusMinus from "../layout/PlusMinus.svelte" import PlusMinus from "../layout/PlusMinus.svelte"
export let label: string export let label: string
@@ -8,25 +7,17 @@ import shortestDecimal from "$lib/utils/numbers";
export let target: number = null export let target: number = null
export let right: boolean = false; export let right: boolean = false;
export let dark: boolean = false; export let dark: boolean = false;
export let weighted: boolean = false;
let valueRounded: number;
let targetRounded: number;
let valueString: string; let valueString: string;
let targetString: string; $: valueString = !weighted ? Math.round(value).toFixed() : significantDecimals(value);
$: {
valueRounded = Math.round(value * 1000) / 1000;
if (target != null) {
targetRounded = Math.round(target * 1000) / 1000;
}
}
</script>
</script> </script>
<span class:dark class="value">{shortestDecimal(valueRounded)}&times;</span> <div class="amount" class:right>
<span class:dark class="value">{valueString}&times;</span> <span class:dark class="value">{valueString}&times;</span>
{ #if target != null && targetRounded != valueRounded } <span class="label">{label}</span>
<span class="target">(<PlusMinus offset={valueRounded - targetRounded} />)</span> { #if target != null && target != value }
<span class="target">(<PlusMinus integersOnly={!weighted} offset={value - target} />)</span> <span class="target">(<PlusMinus integersOnly={!weighted} offset={value - target} />)</span>
{ /if } { /if }
</div> </div>
@@ -1,9 +1,12 @@
<script lang="ts"> <script lang="ts">
import { significantDecimals } from "$lib/utils/numbers";
export let target = 1; export let target = 1;
export let count = 0; export let count = 0;
export let green = false; export let green = false;
export let alwaysSmooth = false; export let alwaysSmooth = false;
export let percentageOnly = false; export let percentageOnly = false;
export let weighted = false;
export let compact = false; export let compact = false;
export let fullwidth = false; export let fullwidth = false;
@@ -38,7 +41,11 @@
{#if percentageOnly} {#if percentageOnly}
<span class="ackreq">({((count / target) * 100).toFixed(0)}%)</span> <span class="ackreq">({((count / target) * 100).toFixed(0)}%)</span>
{:else} {:else}
<span class="ackreq">({Math.floor(count)} / {Math.ceil(target)})</span> {#if weighted}
<span class="ackreq">({significantDecimals(count)} / {significantDecimals(target)})</span>
{:else}
<span class="ackreq">({Math.round(count)} / {Math.round(target)})</span>
{/if}
{/if} {/if}
{/if} {/if}
</div> </div>
@@ -15,7 +15,14 @@
div.markdown :global(p) { div.markdown :global(p) {
padding: 0; 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) { div.markdown :global(code) {
@@ -94,6 +94,10 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@media screen and (max-width: 749px) {
display: block;
}
} }
div.title { div.title {
@@ -6,6 +6,10 @@
padding-left: 0 padding-left: 0
flex: 1 flex: 1
div.modal-body:first-of-type @media screen and (max-width: 749px)
padding-left: 1em padding-left: 1em
padding: 0 1em
&:first-of-type
padding-left: 1em
</style> </style>
@@ -67,6 +67,11 @@
// Mark it non-segmented if the target is too high. This prevents a clunky progress bar, // Mark it non-segmented if the target is too high. This prevents a clunky progress bar,
// or a browser freeze if you set the target very high. // or a browser freeze if you set the target very high.
nonSegmented = alwaysSmooth || (target >= 60); nonSegmented = alwaysSmooth || (target >= 60);
if (!nonSegmented) {
ons = Math.round(ons);
offs = Math.round(offs);
}
} else { } else {
offClass = "none"; offClass = "none";
nonSegmented = alwaysSmooth; nonSegmented = alwaysSmooth;
@@ -16,6 +16,6 @@
3: "spinner", 3: "spinner",
4: "check", 4: "check",
5: "times", 5: "times",
6: "times", 6: "trash",
} }
</script> </script>
@@ -49,7 +49,7 @@
} }
try { try {
const res = await sl3(fetch).listItems($scope.id, filter, true) const res = await sl3(fetch).listItems($scope.id, filter, true, {});
itemsWritable.set(res.items); itemsWritable.set(res.items);
groupsWritable.set(res.groups); groupsWritable.set(res.groups);
} catch(_) {} } catch(_) {}
@@ -0,0 +1,13 @@
<script lang="ts">
import type { Requirement } from "$lib/models/project";
export let value: number;
export let requirements: Requirement[];
export let disabled: boolean = false;
</script>
<select disabled={disabled} bind:value={value}>
{#each requirements as req (req.id)}
<option value={req.id}>{req.name} ({req.statusName})</option>
{/each}
</select>
@@ -57,8 +57,6 @@
required: requiredMap[s.id], required: requiredMap[s.id],
acquired: acquiredMap[s.id], acquired: acquiredMap[s.id],
})); }));
$: console.log(value);
</script> </script>
<div class="stat-input"> <div class="stat-input">
@@ -1,34 +1,68 @@
<script lang="ts"> <script lang="ts" context="module">
import Status from "$lib/models/status"; const STATUS_ENTRIES: [Status, IconName][] = Object.entries(STATUS_ICONS).map(e => [parseInt(e[0]) as Status, e[1]])
export let status: Status;
</script> </script>
<select class={`sc-${status}`} bind:value={status}> <script lang="ts">
<option class="sc-0" value={Status.Blocked}>Blocked</option> import type Status from "$lib/models/status";
<option class="sc-1" value={Status.Available}>Available</option> import { STATUS_NAMES } from "$lib/models/status";
<option class="sc-2" value={Status.Background}>Background</option> import { STATUS_ICONS } from "../common/StatusIcon.svelte";
<option class="sc-3" value={Status.Active}>Active</option> import Icon, { type IconName } from "../layout/Icon.svelte";
<option class="sc-4" value={Status.Completed}>Completed</option>
<option class="sc-5" value={Status.Failed}>Failed</option> export let status: Status;
<option class="sc-6" value={Status.Dropped}>Dropped</option>
</select> console.log(STATUS_ENTRIES, status)
</script>
<div class="status-select">
{#each STATUS_ENTRIES as [id, iconName] (id)}
<div on:click={() => {status = id}} class="option sc-{id}" class:selected={status === id}>
<Icon name={iconName} />
</div>
{/each}
<div class="status-name">{STATUS_NAMES[status]}</div>
</div>
<style lang="sass"> <style lang="sass">
@import "../../css/colors" @import "../../css/colors"
.sc-0 div.status-select
color: $sc-0 !important background-color: $color-entryhalf
.sc-1 display: flex
color: $sc-1 !important flex-direction: row
.sc-2 margin-bottom: 0.8em
color: $sc-2 !important margin-top: 0.2em
.sc-3
color: $sc-3 !important div.status-name
.sc-4 margin-left: auto
color: $sc-4 !important margin-top: 0.275em
.sc-5 margin-right: 1ch
color: $sc-5 !important color: $color-entry7
.sc-6
color: $sc-6 !important div.option
padding: 0.2em 0.5ch
font-size: 1.175em
color: $color-entry2
cursor: pointer
line-height: 1em
&.sc-4, &.sc-2
border-left: 1px solid $color-entry1
&:hover
color: $color-entry3
&.selected.sc-0
color: $sc-0 !important
&.selected.sc-1
color: $sc-1 !important
&.selected.sc-2
color: $sc-2 !important
&.selected.sc-3
color: $sc-3 !important
&.selected.sc-4
color: $sc-4 !important
&.selected.sc-5
color: $sc-5 !important
&.selected.sc-6
color: $sc-6 !important
</style> </style>
@@ -1,10 +1,21 @@
<script lang="ts"> <script lang="ts">
import type { TimeInterval } from "$lib/models/common";
import parseInterval, { datesOf } from "$lib/utils/timeinterval";
import { getItemListContext } from "../contexts/ItemListContext.svelte"; import { getItemListContext } from "../contexts/ItemListContext.svelte";
import { getTimeContext } from "../contexts/TimeContext.svelte";
import HistoryGroupSection from "./HistoryGroupSection.svelte"; import HistoryGroupSection from "./HistoryGroupSection.svelte";
export let intervalString: string;
const {groups, items} = getItemListContext(); const {groups, items} = getItemListContext();
const {now} = getTimeContext();
let interval: TimeInterval<string>
$: interval = datesOf(parseInterval(intervalString, $now));
</script> </script>
{#each $groups as group (group.label)} {#each $groups as group (group.label)}
<HistoryGroupSection group={group} items={$items} /> {#if (group.label >= interval.min && group.label <= interval.max)}
<HistoryGroupSection group={group} items={$items} />
{/if}
{/each} {/each}
@@ -2,14 +2,19 @@
import type Item from "$lib/models/item"; import type Item from "$lib/models/item";
import type { ItemGroup } from "$lib/models/item"; import type { ItemGroup } from "$lib/models/item";
import Section from "../layout/Section.svelte"; import Section from "../layout/Section.svelte";
import AggregateAmountRow from "../project/AggregateAmountRow.svelte";
import ItemSubSection from "../project/ItemSubSection.svelte"; import ItemSubSection from "../project/ItemSubSection.svelte";
export let group: ItemGroup; export let group: ItemGroup;
export let items: Item[]; export let items: Item[];
let acquiredItems: Item[];
$: acquiredItems = group.list.filter(ref => ref.event === "acquired").map(ref => items[ref.idx]);
</script> </script>
<Section title={group.label}> <Section title={group.label}>
{#each group.list as ref (ref.idx)} {#each group.list as ref (ref.idx)}
<ItemSubSection item={items[ref.idx]} event={ref.event} /> <ItemSubSection item={items[ref.idx]} event={ref.event} />
{/each} {/each}
<AggregateAmountRow items={acquiredItems} />
</Section> </Section>
@@ -1,12 +1,18 @@
<script lang="ts"> <script lang="ts">
import shortestDecimal from "$lib/utils/numbers"; import { significantDecimals } from "$lib/utils/numbers";
export let offset: number; export let offset: number;
export let integersOnly: boolean = false;
let value: string; let value: string;
let negative: boolean let negative: boolean;
$: value = (offset > 0) ? `+${shortestDecimal(offset)}` : shortestDecimal(offset); $: {
if (integersOnly) {
value = (offset > 0) ? `+${Math.round(offset).toFixed()}` : Math.round(offset).toFixed();
} else {
value = (offset > 0) ? `+${significantDecimals(offset)}` : significantDecimals(offset);
}
}
$: negative = offset < 0 $: negative = offset < 0
</script> </script>
@@ -0,0 +1,53 @@
<script lang="ts">
import type Item from "$lib/models/item";
import Amount from "../common/Amount.svelte";
import AmountRow from "../common/AmountRow.svelte";
import { getScopeContext } from "../contexts/ScopeContext.svelte";
export let items: Item[];
export let totalAcquired: number = null;
export let aggregateRequired: number = null;
const {scope} = getScopeContext();
let amounts: {name: string, amount: number}[] = [];
let total;
let required;
$: {
const statMap: Record<number, number> = {};
total = 0;
required = 0;
for (const item of items) {
for (const stat of item.stats) {
statMap[stat.id] = (statMap[stat.id] || 0) + stat.acquired;
required += stat.required * stat.weight;
total += stat.acquired * stat.weight;
}
}
if (totalAcquired != null) {
total = totalAcquired;
}
if (aggregateRequired != null) {
required = aggregateRequired;
}
amounts = [];
for (const stat of $scope.stats) {
if (!!statMap[stat.id]) {
amounts.push({name: stat.name, amount: statMap[stat.id]});
}
}
}
</script>
<AmountRow>
{#each amounts as {name, amount} (name)}
<Amount label={name} value={amount} />
{/each}
<Amount weighted right label="" dark value={total} target={required} />
</AmountRow>
@@ -11,6 +11,7 @@
import Icon from "../layout/Icon.svelte"; import Icon from "../layout/Icon.svelte";
import { formatTime } from "$lib/utils/date"; import { formatTime } from "$lib/utils/date";
import RequirementReference from "./RequirementReference.svelte"; import RequirementReference from "./RequirementReference.svelte";
import ItemTimeInfo from "./ItemTimeInfo.svelte";
export let item: Item; export let item: Item;
export let event: "created" | "scheduled" | "acquired" | "none" = "none"; export let event: "created" | "scheduled" | "acquired" | "none" = "none";
@@ -57,6 +58,8 @@
</OptionsRow> </OptionsRow>
{#if item.project != null} {#if item.project != null}
<RequirementReference project={item.project} requirement={item.requirement} scopeId={item.scopeId} /> <RequirementReference project={item.project} requirement={item.requirement} scopeId={item.scopeId} />
{:else}
<ItemTimeInfo item={item} />
{/if} {/if}
{#if !compact} {#if !compact}
<Markdown source={item.description} /> <Markdown source={item.description} />
@@ -70,7 +73,7 @@
{/if} {/if}
{/each} {/each}
{#if item.weightedRequired} {#if item.weightedRequired}
<Amount dark right label="" value={item.weightedAcquired} target={item.weightedRequired} /> <Amount weighted dark right label="" value={item.weightedAcquired} target={item.weightedRequired} />
{/if} {/if}
{:else} {:else}
{#each item.stats as stat (stat.id)} {#each item.stats as stat (stat.id)}
@@ -79,7 +82,7 @@
{/if} {/if}
{/each} {/each}
{#if item.weightedRequired} {#if item.weightedRequired}
<Amount dark right label="" value={item.weightedRequired} /> <Amount weighted dark right label="" value={item.weightedRequired} />
{/if} {/if}
{/if} {/if}
{/if} {/if}
@@ -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"
} 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>
@@ -11,6 +11,8 @@
import ItemEntry from "./ItemSubSection.svelte"; import ItemEntry from "./ItemSubSection.svelte";
import Icon from "../layout/Icon.svelte"; import Icon from "../layout/Icon.svelte";
import { projectPrettyId } from "$lib/utils/prettyIds"; import { projectPrettyId } from "$lib/utils/prettyIds";
import AmountRow from "../common/AmountRow.svelte";
import AggregateAmountRow from "./AggregateAmountRow.svelte";
export let requirement: Requirement; export let requirement: Requirement;
</script> </script>
@@ -35,4 +37,7 @@
{#each requirement.items as item (item.id)} {#each requirement.items as item (item.id)}
<ItemEntry item={item} /> <ItemEntry item={item} />
{/each} {/each}
{#if requirement.aggregateRequired > 0}
<AggregateAmountRow items={requirement.items} totalAcquired={requirement.totalAcquired} aggregateRequired={requirement.aggregateRequired} />
{/if}
</Section> </Section>
@@ -24,7 +24,7 @@
{#if sprint.kind === SprintKind.Items} {#if sprint.kind === SprintKind.Items}
<LabeledProgress timeRange={timeRange} green name={sprint.aggregateName || "Items"} count={sprint.itemsAcquired} target={sprint.itemsRequired} /> <LabeledProgress timeRange={timeRange} green name={sprint.aggregateName || "Items"} count={sprint.itemsAcquired} target={sprint.itemsRequired} />
{:else} {:else}
<LabeledProgress timeRange={timeRange} name={sprint.aggregateName} count={sprint.aggregateAcquired} target={sprint.aggregateRequired} /> <LabeledProgress timeRange={timeRange} name={sprint.aggregateName} count={sprint.aggregateAcquired} target={sprint.aggregateRequired} weighted={!sprint.isUnweighted} />
{/if} {/if}
{#if !sprint.isCoarse} {#if !sprint.isCoarse}
<LabeledProgressRow> <LabeledProgressRow>
@@ -19,7 +19,7 @@
<Markdown source={stat.description} /> <Markdown source={stat.description} />
{#if stat.weight !== 1} {#if stat.weight !== 1}
<AmountRow> <AmountRow>
<Amount label="Weight" value={stat.weight} /> <Amount weighted label="Weight" value={stat.weight} />
</AmountRow> </AmountRow>
{/if} {/if}
</SubSection> </SubSection>
+2
View File
@@ -25,9 +25,11 @@ $opacity-entry6: 0.80
$sc-0: #78c9ff $sc-0: #78c9ff
$sc-0-dark: #34424b $sc-0-dark: #34424b
$sc-1: #e7e55e $sc-1: #e7e55e
$sc-1-dim: #a3a352
$sc-2: #e7a75e $sc-2: #e7a75e
$sc-3: $color-entry12 $sc-3: $color-entry12
$sc-4: #78ff78 $sc-4: #78ff78
$sc-4-dim: #549454
$sc-4-dark: #2f422f $sc-4-dark: #2f422f
$sc-5: #c94339 $sc-5: #c94339
$sc-6: #e75ed0 $sc-6: #e75ed0
@@ -11,6 +11,7 @@
import { getScopeListContext } from "$lib/components/contexts/ScopeListContext.svelte"; import { getScopeListContext } from "$lib/components/contexts/ScopeListContext.svelte";
import { getSprintListContext } from "$lib/components/contexts/SprintListContext.svelte"; import { getSprintListContext } from "$lib/components/contexts/SprintListContext.svelte";
import AcquiredTimeInput from "$lib/components/controls/AcquiredTimeInput.svelte"; import AcquiredTimeInput from "$lib/components/controls/AcquiredTimeInput.svelte";
import RequirementSelect from "$lib/components/controls/RequirementSelect.svelte";
import StatInput from "$lib/components/controls/StatInput.svelte"; import StatInput from "$lib/components/controls/StatInput.svelte";
import type Item from "$lib/models/item"; import type Item from "$lib/models/item";
import type { ItemInput } from "$lib/models/item"; import type { ItemInput } from "$lib/models/item";
@@ -107,7 +108,7 @@
op = "Edit" op = "Edit"
openedDate = new Date(); openedDate = new Date();
requirementName = req?.name; requirementName = current.requirement?.name;
show = true; show = true;
} }
@@ -185,7 +186,11 @@
<ModalBody> <ModalBody>
{#if requirementName != null} {#if requirementName != null}
<label for="req">Project Requirement</label> <label for="req">Project Requirement</label>
<input disabled name="req" type="text" value={requirementName} /> {#if $project.id != null}
<RequirementSelect disabled={op === "Create"} requirements={$project.requirements} bind:value={item.requirementId} />
{:else}
<input disabled name="req" type="text" value={requirementName} />
{/if}
{/if} {/if}
<label for="stats">Stats</label> <label for="stats">Stats</label>
<StatInput <StatInput
@@ -21,6 +21,7 @@ import { statDiff } from "$lib/utils/stat";
let projectId: number let projectId: number
let requirementId: number let requirementId: number
let oldStats: RequirementInput["stats"] let oldStats: RequirementInput["stats"]
let showAggregateRequired: boolean
let error: string let error: string
let loading: boolean let loading: boolean
@@ -44,6 +45,8 @@ import { statDiff } from "$lib/utils/stat";
show = false; show = false;
} }
$: showAggregateRequired = requirement?.stats?.find(s => s.required > 0) == null;
function initCreate(project: ProjectEntry) { function initCreate(project: ProjectEntry) {
requirement = { requirement = {
name: "", name: "",
@@ -51,6 +54,7 @@ import { statDiff } from "$lib/utils/stat";
stats: [], stats: [],
status: Status.Available, status: Status.Available,
isCoarse: false, isCoarse: false,
aggregateRequired: 0,
} }
projectId = project.id; projectId = project.id;
@@ -64,6 +68,7 @@ import { statDiff } from "$lib/utils/stat";
status: current.status, status: current.status,
stats: current.stats.map(s => ({acquired: s.acquired, required: s.required, statId: s.id})), stats: current.stats.map(s => ({acquired: s.acquired, required: s.required, statId: s.id})),
isCoarse: current.isCoarse, isCoarse: current.isCoarse,
aggregateRequired: current.aggregateRequired,
} }
oldStats = [...requirement.stats]; oldStats = [...requirement.stats];
@@ -76,6 +81,10 @@ import { statDiff } from "$lib/utils/stat";
error = null; error = null;
loading = true; loading = true;
if (requirement.stats.find(s => s.required > 0)) {
requirement.aggregateRequired = 0;
}
try { try {
switch (op) { switch (op) {
case "Create": case "Create":
@@ -115,6 +124,10 @@ import { statDiff } from "$lib/utils/stat";
<input name="name" type="text" bind:value={requirement.name} /> <input name="name" type="text" bind:value={requirement.name} />
<label for="description">Description</label> <label for="description">Description</label>
<textarea name="description" bind:value={requirement.description} /> <textarea name="description" bind:value={requirement.description} />
{#if showAggregateRequired}
<label for="aggregateRequired">Aggregate Required (Zero for old behavior)</label>
<input name="aggregateRequired" type="number" bind:value={requirement.aggregateRequired} />
{/if}
</ModalBody> </ModalBody>
<ModalBody> <ModalBody>
<label for="stats">Status</label> <label for="stats">Status</label>
+2
View File
@@ -38,6 +38,7 @@ export interface Requirement {
totalRequired: number totalRequired: number
totalPlanned: number totalPlanned: number
isCoarse: boolean isCoarse: boolean
aggregateRequired: number
stats: StatProgressWithPlanned[] stats: StatProgressWithPlanned[]
items: Item[] items: Item[]
} }
@@ -47,5 +48,6 @@ export interface RequirementInput {
description: string description: string
status: Status status: Status
isCoarse: boolean isCoarse: boolean
aggregateRequired: number
stats: StatValueInput[] stats: StatValueInput[]
} }
+10
View File
@@ -8,4 +8,14 @@ enum Status {
Dropped, Dropped,
}; };
export const STATUS_NAMES: Record<Status, string> = {
[Status.Blocked]: "Blocked",
[Status.Available]: "Available",
[Status.Background]: "Background",
[Status.Active]: "Active",
[Status.Completed]: "Completed",
[Status.Failed]: "Failed",
[Status.Dropped]: "Dropped",
}
export default Status; export default Status;
+21 -1
View File
@@ -114,7 +114,7 @@ export function endOfDay(now: Date): Date {
} }
export function monthName(date: Date): string { export function monthName(date: Date): string {
return monthNames[date.getMonth()] return monthNames[date.getMonth() - 1]
} }
export function addDays(date: Date, days: number): Date { 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()); const newDate = new Date(date.getTime());
newDate.setFullYear(newDate.getFullYear() + years); newDate.setFullYear(newDate.getFullYear() + years);
return newDate; 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.getMinutes())}`;
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}`;
}
} }
+17 -1
View File
@@ -8,4 +8,20 @@ export default function shortestDecimal(n: number) {
} }
return s; return s;
} }
export function significantDecimals(n: number): string {
if (n >= 99.95) {
return n.toFixed();
} else if (n >= 9.95) {
return n.toFixed(1);
} else if (n >= 0.01) {
return n.toFixed(2);
} else if (n >= 0.001) {
return n.toFixed(3);
} else if (n >= 0) {
return n.toFixed(2);
} else {
return "-" + significantDecimals(-n);
}
}
@@ -35,6 +35,8 @@
$: hideMobile = $page.url.pathname !== `/${scopePrettyId(scope)}` $: hideMobile = $page.url.pathname !== `/${scopePrettyId(scope)}`
</script> </script>
<svelte:head><title>{scope.abbreviation}: {$page.stuff.title} – Stufflog 3</title></svelte:head>
<ScopeContext scope={scope}> <ScopeContext scope={scope}>
<ProjectListContext projects={projects}> <ProjectListContext projects={projects}>
<Columns wide> <Columns wide>
@@ -2,7 +2,7 @@
import type { Load } from "@sveltejs/kit/types/internal"; import type { Load } from "@sveltejs/kit/types/internal";
import { sl3 } from "$lib/clients/sl3"; import { sl3 } from "$lib/clients/sl3";
import parseInterval, { datesOf, intervalLabel } from "$lib/utils/timeinterval"; import parseInterval, { datesOf } from "$lib/utils/timeinterval";
import type Item from "$lib/models/item"; import type Item from "$lib/models/item";
import type { ItemFilter, ItemGroup } from "$lib/models/item"; import type { ItemFilter, ItemGroup } from "$lib/models/item";
@@ -18,6 +18,7 @@
const {items, groups} = await sl3(fetch).listItems(scopeId, filter, true, session.groupOptions); const {items, groups} = await sl3(fetch).listItems(scopeId, filter, true, session.groupOptions);
return { return {
stuff: { title: "History" },
props: { props: {
items, groups, filter, items, groups, filter,
intervalString: params.interval, intervalString: params.interval,
@@ -84,7 +85,7 @@
<ToggleIcon inverse name="calendar" bind:value={$session.groupOptions.hideScheduled} on:click={triggerReload} /> <ToggleIcon inverse name="calendar" bind:value={$session.groupOptions.hideScheduled} on:click={triggerReload} />
{/if} {/if}
</HistorySelector> </HistorySelector>
<HistoryGroupList /> <HistoryGroupList intervalString={intervalString} />
</Main> </Main>
<ItemCreateModal /> <ItemCreateModal />
@@ -29,6 +29,7 @@
const sprints = await sl3(fetch).listSprints(scopeId); const sprints = await sl3(fetch).listSprints(scopeId);
return { return {
stuff: { title: "Overview" },
props: {scheduledItems, acquiredItems, looseItems, sprints} props: {scheduledItems, acquiredItems, looseItems, sprints}
}; };
} }
@@ -10,6 +10,7 @@
const project = await sl3(fetch).findProject(scopeId, projectId); const project = await sl3(fetch).findProject(scopeId, projectId);
return { return {
stuff: { title: project.name },
props: {project} props: {project}
}; };
} }
@@ -14,7 +14,10 @@
const sprints = await sl3(fetch).listSprints(scopeId, interval); const sprints = await sl3(fetch).listSprints(scopeId, interval);
return { return {
props: { stuff: {
title: "Sprints",
},
props: {
sprints, sprints,
intervalString: params.interval, intervalString: params.interval,
}, },
+4
View File
@@ -71,6 +71,10 @@
<Header fullwidth wide subtitle="Logging your stuff">Stufflog</Header> <Header fullwidth wide subtitle="Logging your stuff">Stufflog</Header>
<svelte:head>
<title>Stufflog 3</title>
</svelte:head>
<ItemMultiListContext global lists={{acquiredItems, scheduledItems, looseItems}} filters={generateItemFilters($now)}> <ItemMultiListContext global lists={{acquiredItems, scheduledItems, looseItems}} filters={generateItemFilters($now)}>
<ScopeListContext scopes={scopes}> <ScopeListContext scopes={scopes}>
<SprintListContext global sprints={sprints} intervalString=""> <SprintListContext global sprints={sprints} intervalString="">
+1
View File
@@ -9,6 +9,7 @@ require (
github.com/awslabs/aws-lambda-go-api-proxy v0.13.3 github.com/awslabs/aws-lambda-go-api-proxy v0.13.3
github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1 github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1
github.com/gin-gonic/gin v1.7.7 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/go-sql-driver/mysql v1.6.0
github.com/lestrrat-go/jwx v1.2.25 github.com/lestrrat-go/jwx v1.2.25
golang.org/x/sync v0.0.0-20220513210516-0976fa681c29 golang.org/x/sync v0.0.0-20220513210516-0976fa681c29
+5 -4
View File
@@ -8,8 +8,9 @@ type ProjectUpdate struct {
} }
type RequirementUpdate struct { type RequirementUpdate struct {
Name *string `json:"name"` Name *string `json:"name"`
Description *string `json:"description"` Description *string `json:"description"`
Status *Status `json:"status"` Status *Status `json:"status"`
IsCoarse *bool `json:"isCoarse"` IsCoarse *bool `json:"isCoarse"`
AggregateRequired *int `json:"aggregateRequired"`
} }
+8 -7
View File
@@ -41,13 +41,14 @@ type Project struct {
} }
type ProjectRequirement struct { type ProjectRequirement struct {
ID int ID int
ScopeID int ScopeID int
ProjectID int ProjectID int
Name string Name string
Status int Status int
Description string Description string
IsCoarse bool IsCoarse bool
AggregateRequired int
} }
type ProjectRequirementStat struct { type ProjectRequirementStat struct {
+22 -16
View File
@@ -156,17 +156,18 @@ func (q *Queries) InsertProject(ctx context.Context, arg InsertProjectParams) (s
} }
const insertProjectRequirement = `-- name: InsertProjectRequirement :execresult const insertProjectRequirement = `-- name: InsertProjectRequirement :execresult
INSERT INTO project_requirement (scope_id, project_id, name, status, description, is_coarse) INSERT INTO project_requirement (scope_id, project_id, name, status, description, is_coarse, aggregate_required)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
` `
type InsertProjectRequirementParams struct { type InsertProjectRequirementParams struct {
ScopeID int ScopeID int
ProjectID int ProjectID int
Name string Name string
Status int Status int
Description string Description string
IsCoarse bool IsCoarse bool
AggregateRequired int
} }
func (q *Queries) InsertProjectRequirement(ctx context.Context, arg InsertProjectRequirementParams) (sql.Result, error) { func (q *Queries) InsertProjectRequirement(ctx context.Context, arg InsertProjectRequirementParams) (sql.Result, error) {
@@ -177,11 +178,12 @@ func (q *Queries) InsertProjectRequirement(ctx context.Context, arg InsertProjec
arg.Status, arg.Status,
arg.Description, arg.Description,
arg.IsCoarse, arg.IsCoarse,
arg.AggregateRequired,
) )
} }
const listProjectRequirements = `-- name: ListProjectRequirements :many const listProjectRequirements = `-- name: ListProjectRequirements :many
SELECT id, scope_id, project_id, name, status, description, is_coarse FROM project_requirement WHERE project_id = ? SELECT id, scope_id, project_id, name, status, description, is_coarse, aggregate_required FROM project_requirement WHERE project_id = ?
` `
func (q *Queries) ListProjectRequirements(ctx context.Context, projectID int) ([]ProjectRequirement, error) { func (q *Queries) ListProjectRequirements(ctx context.Context, projectID int) ([]ProjectRequirement, error) {
@@ -201,6 +203,7 @@ func (q *Queries) ListProjectRequirements(ctx context.Context, projectID int) ([
&i.Status, &i.Status,
&i.Description, &i.Description,
&i.IsCoarse, &i.IsCoarse,
&i.AggregateRequired,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -330,17 +333,19 @@ UPDATE project_requirement
SET name = ?, SET name = ?,
status = ?, status = ?,
description = ?, description = ?,
is_coarse = ? is_coarse = ?,
aggregate_required = ?
WHERE id = ? AND scope_id = ? WHERE id = ? AND scope_id = ?
` `
type UpdateProjectRequirementParams struct { type UpdateProjectRequirementParams struct {
Name string Name string
Status int Status int
Description string Description string
IsCoarse bool IsCoarse bool
ID int AggregateRequired int
ScopeID int ID int
ScopeID int
} }
func (q *Queries) UpdateProjectRequirement(ctx context.Context, arg UpdateProjectRequirementParams) error { func (q *Queries) UpdateProjectRequirement(ctx context.Context, arg UpdateProjectRequirementParams) error {
@@ -349,6 +354,7 @@ func (q *Queries) UpdateProjectRequirement(ctx context.Context, arg UpdateProjec
arg.Status, arg.Status,
arg.Description, arg.Description,
arg.IsCoarse, arg.IsCoarse,
arg.AggregateRequired,
arg.ID, arg.ID,
arg.ScopeID, arg.ScopeID,
) )
+24 -20
View File
@@ -194,7 +194,7 @@ func (r *projectRepository) FetchRequirements(ctx context.Context, scopeID int,
return []entities.Requirement{}, []entities.RequirementStat{}, nil return []entities.Requirement{}, []entities.RequirementStat{}, nil
} }
sq := squirrel.Select("id, scope_id, project_id, name, status, description, is_coarse"). sq := squirrel.Select("id, scope_id, project_id, name, status, description, is_coarse, aggregate_required").
From("project_requirement"). From("project_requirement").
Where(squirrel.Eq{"id": requirementIDs}) Where(squirrel.Eq{"id": requirementIDs})
if scopeID != -1 { if scopeID != -1 {
@@ -221,6 +221,7 @@ func (r *projectRepository) FetchRequirements(ctx context.Context, scopeID int,
&requirement.Status, &requirement.Status,
&requirement.Description, &requirement.Description,
&requirement.IsCoarse, &requirement.IsCoarse,
&requirement.AggregateRequired,
); err != nil { ); err != nil {
return nil, nil, err return nil, nil, err
} }
@@ -271,13 +272,14 @@ func (r *projectRepository) ListRequirements(ctx context.Context, projectID int)
requirements := make([]entities.Requirement, 0, len(reqRows)) requirements := make([]entities.Requirement, 0, len(reqRows))
for _, row := range reqRows { for _, row := range reqRows {
requirements = append(requirements, entities.Requirement{ requirements = append(requirements, entities.Requirement{
ID: row.ID, ID: row.ID,
ScopeID: row.ScopeID, ScopeID: row.ScopeID,
ProjectID: row.ProjectID, ProjectID: row.ProjectID,
Name: row.Name, Name: row.Name,
Description: row.Description, Description: row.Description,
IsCoarse: row.IsCoarse, IsCoarse: row.IsCoarse,
Status: models.Status(row.Status), AggregateRequired: row.AggregateRequired,
Status: models.Status(row.Status),
}) })
} }
@@ -295,12 +297,13 @@ func (r *projectRepository) ListRequirements(ctx context.Context, projectID int)
func (r *projectRepository) CreateRequirement(ctx context.Context, requirement entities.Requirement) (*entities.Requirement, error) { func (r *projectRepository) CreateRequirement(ctx context.Context, requirement entities.Requirement) (*entities.Requirement, error) {
res, err := r.q.InsertProjectRequirement(ctx, mysqlcore.InsertProjectRequirementParams{ res, err := r.q.InsertProjectRequirement(ctx, mysqlcore.InsertProjectRequirementParams{
ScopeID: requirement.ScopeID, ScopeID: requirement.ScopeID,
ProjectID: requirement.ProjectID, ProjectID: requirement.ProjectID,
Name: requirement.Name, Name: requirement.Name,
Status: int(requirement.Status), Status: int(requirement.Status),
Description: requirement.Description, Description: requirement.Description,
IsCoarse: requirement.IsCoarse, IsCoarse: requirement.IsCoarse,
AggregateRequired: requirement.AggregateRequired,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@@ -319,12 +322,13 @@ func (r *projectRepository) UpdateRequirement(ctx context.Context, requirement e
requirement.Update(update) requirement.Update(update)
return r.q.UpdateProjectRequirement(ctx, mysqlcore.UpdateProjectRequirementParams{ return r.q.UpdateProjectRequirement(ctx, mysqlcore.UpdateProjectRequirementParams{
Name: requirement.Name, Name: requirement.Name,
Status: int(requirement.Status), Status: int(requirement.Status),
Description: requirement.Description, Description: requirement.Description,
IsCoarse: requirement.IsCoarse, IsCoarse: requirement.IsCoarse,
ID: requirement.ID, ID: requirement.ID,
ScopeID: requirement.ScopeID, ScopeID: requirement.ScopeID,
AggregateRequired: requirement.AggregateRequired,
}) })
} }
+4 -3
View File
@@ -23,15 +23,16 @@ DELETE FROM project WHERE id = ? AND scope_id = ?;
SELECT * FROM project_requirement WHERE project_id = ?; SELECT * FROM project_requirement WHERE project_id = ?;
-- name: InsertProjectRequirement :execresult -- name: InsertProjectRequirement :execresult
INSERT INTO project_requirement (scope_id, project_id, name, status, description, is_coarse) INSERT INTO project_requirement (scope_id, project_id, name, status, description, is_coarse, aggregate_required)
VALUES (?, ?, ?, ?, ?, ?); VALUES (?, ?, ?, ?, ?, ?, ?);
-- name: UpdateProjectRequirement :exec -- name: UpdateProjectRequirement :exec
UPDATE project_requirement UPDATE project_requirement
SET name = ?, SET name = ?,
status = ?, status = ?,
description = ?, description = ?,
is_coarse = ? is_coarse = ?,
aggregate_required = ?
WHERE id = ? AND scope_id = ?; WHERE id = ? AND scope_id = ?;
-- name: DeleteProjectRequirement :exec -- name: DeleteProjectRequirement :exec
@@ -0,0 +1,9 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE project_requirement ADD COLUMN aggregate_required INT NOT NULL DEFAULT 0;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE project_requirement DROP COLUMN aggregate_required;
-- +goose StatementEnd
+25 -19
View File
@@ -53,17 +53,18 @@ func (r *Result) Requirement(id int) *RequirementResult {
} }
type RequirementResult struct { type RequirementResult struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
Status models.Status `json:"status"` Status models.Status `json:"status"`
StatusName string `json:"statusName"` StatusName string `json:"statusName"`
TotalAcquired float64 `json:"totalAcquired"` TotalAcquired float64 `json:"totalAcquired"`
TotalRequired float64 `json:"totalRequired"` TotalRequired float64 `json:"totalRequired"`
TotalPlanned float64 `json:"totalPlanned"` TotalPlanned float64 `json:"totalPlanned"`
IsCoarse bool `json:"isCoarse"` IsCoarse bool `json:"isCoarse"`
Stats []RequirementResultStat `json:"stats"` AggregateRequired int `json:"aggregateRequired"`
Items []items.Result `json:"items"` Stats []RequirementResultStat `json:"stats"`
Items []items.Result `json:"items"`
Requirement entities.Requirement `json:"-"` Requirement entities.Requirement `json:"-"`
} }
@@ -161,14 +162,15 @@ func generateResult(
func generateRequirementResult(req entities.Requirement, scope scopes.Result, requirementStats []entities.RequirementStat, projectItems []items.Result) RequirementResult { func generateRequirementResult(req entities.Requirement, scope scopes.Result, requirementStats []entities.RequirementStat, projectItems []items.Result) RequirementResult {
resReq := RequirementResult{ resReq := RequirementResult{
ID: req.ID, ID: req.ID,
Name: req.Name, Name: req.Name,
Description: req.Description, Description: req.Description,
Status: req.Status, Status: req.Status,
StatusName: scope.StatusName(req.Status), StatusName: scope.StatusName(req.Status),
IsCoarse: req.IsCoarse, IsCoarse: req.IsCoarse,
Stats: make([]RequirementResultStat, 0, 8), AggregateRequired: req.AggregateRequired,
Items: make([]items.Result, 0, 8), Stats: make([]RequirementResultStat, 0, 8),
Items: make([]items.Result, 0, 8),
Requirement: req, Requirement: req,
} }
@@ -271,6 +273,10 @@ func generateRequirementResult(req entities.Requirement, scope scopes.Result, re
resReq.TotalRequired += item.WeightedRequired resReq.TotalRequired += item.WeightedRequired
} }
resReq.TotalPlanned = resReq.TotalRequired resReq.TotalPlanned = resReq.TotalRequired
if req.AggregateRequired > 0 {
resReq.TotalRequired = float64(req.AggregateRequired)
}
} }
return resReq return resReq
+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)
}
+8 -6
View File
@@ -9,18 +9,20 @@ import (
type Result struct { type Result struct {
entities.Sprint entities.Sprint
AggregateAcquired int `json:"aggregateAcquired"` AggregateAcquired float64 `json:"aggregateAcquired"`
AggregatePlanned int `json:"aggregatePlanned"` AggregatePlanned float64 `json:"aggregatePlanned"`
ItemsAcquired *int `json:"itemsAcquired,omitempty"` ItemsAcquired *int `json:"itemsAcquired,omitempty"`
ItemsRequired *int `json:"itemsRequired,omitempty"` ItemsRequired *int `json:"itemsRequired,omitempty"`
PartIDs []int `json:"partIds"` // Just for simplifying code. PartIDs []int `json:"partIds"` // Just for simplifying code.
Items []items.Result `json:"items,omitempty"` Items []items.Result `json:"items,omitempty"`
Stats []entities.Stat `json:"stats,omitempty"` Stats []entities.Stat `json:"stats,omitempty"`
Requirements []projects.RequirementResult `json:"requirements,omitempty"` Requirements []projects.RequirementResult `json:"requirements,omitempty"`
Progress []ResultProgress `json:"progress,omitempty"` Progress []ResultProgress `json:"progress,omitempty"`
ItemBurndown []BurndownDataPoint `json:"itemBurndown,omitempty"`
AggregateBurndown []BurndownDataPoint `json:"aggregateBurndown,omitempty"`
} }
type ResultProgress struct { type ResultProgress struct {
+25 -4
View File
@@ -9,7 +9,6 @@ import (
"git.aiterp.net/stufflog3/stufflog3/usecases/projects" "git.aiterp.net/stufflog3/stufflog3/usecases/projects"
"git.aiterp.net/stufflog3/stufflog3/usecases/scopes" "git.aiterp.net/stufflog3/stufflog3/usecases/scopes"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"math"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -85,6 +84,7 @@ func (s *Service) ListScopedBetween(ctx context.Context, from, to time.Time) ([]
} }
func (s *Service) fillMany(ctx context.Context, sprints []entities.Sprint) ([]Result, error) { func (s *Service) fillMany(ctx context.Context, sprints []entities.Sprint) ([]Result, error) {
now := time.Now()
sort.Slice(sprints, func(i, j int) bool { sort.Slice(sprints, func(i, j int) bool {
si := sprints[i] si := sprints[i]
sj := sprints[j] sj := sprints[j]
@@ -93,6 +93,12 @@ func (s *Service) fillMany(ctx context.Context, sprints []entities.Sprint) ([]Re
return si.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) { if !si.FromTime.Equal(sj.FromTime) {
return si.FromTime.Before(sj.FromTime) return si.FromTime.Before(sj.FromTime)
} }
@@ -550,17 +556,32 @@ func (s *Service) fill(ctx context.Context, sprint entities.Sprint, parts []enti
}) })
// Measure progress // Measure progress
itemBurndown := burndownGenerator{}
aggregateBurndown := burndownGenerator{}
for _, item := range res.Items { for _, item := range res.Items {
if !item.AcquiredBetween(sprint.FromTime, sprint.ToTime) { if !item.AcquiredBetween(sprint.FromTime, sprint.ToTime) {
continue continue
} }
itemBurndown.Add(*item.AcquiredTime, 1)
itemAggregate := 0.0
for _, stat := range item.Stats { for _, stat := range item.Stats {
if pi, ok := progressMap[stat.ID]; ok { if pi, ok := progressMap[stat.ID]; ok {
res.Progress[pi].Acquired += stat.Acquired 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 // For requirement sprints, remove items array after enumeration
if res.Kind == models.SprintKindRequirements { if res.Kind == models.SprintKindRequirements {
@@ -583,11 +604,11 @@ func (s *Service) fill(ctx context.Context, sprint entities.Sprint, parts []enti
} }
} }
} }
res.AggregateAcquired = int(math.Round(aggregateAcquired)) res.AggregateAcquired = aggregateAcquired
res.AggregatePlanned = int(math.Round(aggregatePlanned)) res.AggregatePlanned = aggregatePlanned
if res.AggregateRequired < 0 { if res.AggregateRequired < 0 {
res.AggregateRequired = res.AggregatePlanned res.AggregateRequired = int(res.AggregatePlanned)
} }
return &res, nil return &res, nil