Compare commits

...
28 Commits
Author SHA1 Message Date
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
gisle eff677cc69 change background.
continuous-integration/drone/push Build is passing
2022-07-20 20:02:47 +02:00
gisle 702b2254c8 Count requirement as 100% for project progress if it is completed, failed or dropped.
continuous-integration/drone/push Build is passing
2022-07-18 19:14:45 +02:00
gisle 972ad748f2 add style for <a> to markdown.
continuous-integration/drone/push Build is passing
2022-07-18 17:54:16 +02:00
gisle 2f4c03b9af fix last:Xd interval counting one day too many.
continuous-integration/drone/push Build is passing
2022-07-18 17:48:22 +02:00
gisle 0bbfe3689e fix scope sprints progress showing as progress bars, and fix sprint progress counting to avoid counting items acquired outside of the sprint.
continuous-integration/drone/push Build is passing
2022-07-17 20:02:17 +02:00
gisle a93ff5b292 Move group to backend and change intervals and such to use 05:00 as start of day.
continuous-integration/drone/push Build is passing
2022-07-16 12:44:52 +02:00
gisle ae408390a5 optimize sprint search query. 2022-07-16 00:01:59 +02:00
gisle 5987b7c8e1 fix sprint sorting.
continuous-integration/drone/push Build is passing
2022-07-13 22:13:47 +02:00
gisle e268c1fd97 add plus for scopes too.
continuous-integration/drone/push Build is passing
2022-07-13 20:16:12 +02:00
gisle 7b7a46eae9 fix scope view button not working in scope overview page.
continuous-integration/drone/push Build is passing
2022-07-13 20:13:58 +02:00
gisle 6825584b8d switch out remaining text option labels with icons.
continuous-integration/drone/push Build is passing
2022-07-13 18:28:03 +02:00
gisle a530bed697 fix front page scope stat ordering.
continuous-integration/drone/push Build is passing
2022-07-13 18:15:24 +02:00
gisle e65349662a fix cookies expiring when the browser is closed.
continuous-integration/drone/push Build is passing
2022-07-13 17:44:00 +02:00
gisle fd5d732851 fix acquire on front page not allowing stat inputs.
continuous-integration/drone/push Build is passing
2022-07-10 18:10:31 +02:00
gisle 9259609efe fix stat input deleting 0-required stats inrequirements.
continuous-integration/drone/push Build is passing
2022-07-10 17:48:15 +02:00
gisle a5b9f85eda fix req progress only counting acquired items.
continuous-integration/drone/push Build is passing
2022-07-10 17:20:56 +02:00
gisle c503b7a30e add front page sprints.
continuous-integration/drone/push Build is passing
2022-07-10 16:30:35 +02:00
69 changed files with 1080 additions and 350 deletions
+2
View File
@@ -100,6 +100,8 @@ func main() {
apiV1AllScopes := apiV1.Group("/all-scopes")
apiV1AllScopes.Use(httpapi.AllScopeMiddleware(scopesService, authService))
httpapi.ItemsAllScopes(apiV1AllScopes, itemsService)
httpapi.SprintsAllScopes(apiV1AllScopes.Group("/sprints"), sprintsService)
ginLambda := ginadapter.New(server)
lambda.Start(func(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
return ginLambda.ProxyWithContext(ctx, request)
+1
View File
@@ -98,6 +98,7 @@ func main() {
apiV1AllScopes := apiV1.Group("/all-scopes")
apiV1AllScopes.Use(httpapi.AllScopeMiddleware(scopesService, authService))
httpapi.ItemsAllScopes(apiV1AllScopes.Group("/items"), itemsService)
httpapi.SprintsAllScopes(apiV1AllScopes.Group("/sprints"), sprintsService)
exitSignal := make(chan os.Signal)
signal.Notify(exitSignal, os.Interrupt, os.Kill, syscall.SIGTERM)
+8
View File
@@ -25,6 +25,14 @@ type ItemStat struct {
Required int `json:"required"`
}
func (item *Item) AcquiredOutside(fromTime, toTime time.Time) bool {
return item.AcquiredTime != nil && (item.AcquiredTime.Before(fromTime) || !item.AcquiredTime.Before(toTime))
}
func (item *Item) AcquiredBetween(fromTime, toTime time.Time) bool {
return item.AcquiredTime != nil && !item.AcquiredTime.Before(fromTime) && item.AcquiredTime.Before(toTime)
}
func (item *Item) ApplyUpdate(update models.ItemUpdate) {
if update.RequirementID != nil {
if *update.RequirementID <= 0 {
+11 -7
View File
@@ -31,13 +31,14 @@ func (project *Project) Update(update models.ProjectUpdate) {
}
type Requirement struct {
ID int `json:"id"`
ScopeID int `json:"scopeId"`
ProjectID int `json:"projectId"`
Name string `json:"name"`
Description string `json:"description"`
IsCoarse bool `json:"isCoarse"`
Status models.Status `json:"status"`
ID int `json:"id"`
ScopeID int `json:"scopeId"`
ProjectID int `json:"projectId"`
AggregateRequired int `json:"aggregateRequired"`
Name string `json:"name"`
Description string `json:"description"`
IsCoarse bool `json:"isCoarse"`
Status models.Status `json:"status"`
}
func (requirement *Requirement) Update(update models.RequirementUpdate) {
@@ -53,6 +54,9 @@ func (requirement *Requirement) Update(update models.RequirementUpdate) {
if update.IsCoarse != nil {
requirement.IsCoarse = *update.IsCoarse
}
if update.AggregateRequired != nil {
requirement.AggregateRequired = *update.AggregateRequired
}
}
type RequirementStat struct {
+1 -1
View File
@@ -13,7 +13,7 @@ declare namespace App {
user?: import("$lib/models/user").default
idToken?: string
req?: any
groupOptions?: import("$lib/utils/items").GroupItemsOptions
groupOptions?: import("$lib/models/items").GroupItemsOptions
amplifyConfig: any
}
+2
View File
@@ -58,6 +58,8 @@ export const handle: Handle = async({ event, resolve }) => {
for (const key in newCookies) {
response.headers.append("Set-Cookie", cookie.serialize(key, newCookies[key], {
path: "/",
maxAge: 8640000,
expires: new Date(Date.now() + 8640000000),
}));
}
+16 -5
View File
@@ -1,6 +1,6 @@
import type { TimeInterval } from "$lib/models/common";
import type Item from "$lib/models/item";
import type { ItemFilter, ItemInput } from "$lib/models/item";
import type { GroupItemsOptions, ItemFilter, ItemGroup, ItemInput } from "$lib/models/item";
import type Project from "$lib/models/project";
import type { ProjectEntry, ProjectInput, Requirement, RequirementInput } from "$lib/models/project";
import type { ScopeInput } from "$lib/models/scope";
@@ -73,10 +73,19 @@ export default class SL3APIClient {
return this.fetch<{project: Project}>("PUT", `scopes/${scopeId}/projects/${projectId}`, input).then(r => r.project);
}
async listItems(scopeId: number | "ALL", filter: ItemFilter): Promise<Item[]> {
async listItems(scopeId: number | "ALL", filter: ItemFilter, grouped: true, groupOptions: GroupItemsOptions): Promise<{items: Item[], groups: ItemGroup[]}>
async listItems(scopeId: number | "ALL", filter: ItemFilter, grouped?: false): Promise<Item[]>
async listItems(scopeId: number | "ALL", filter: ItemFilter, grouped?: boolean, groupOptions?: GroupItemsOptions): Promise<Item[] | {items: Item[], groups: ItemGroup[]}> {
const scopePrefix = (scopeId !== "ALL") ? `scopes/${scopeId}` : "all-scopes";
const groupedSuffix = !!grouped ? `&grouped=true&hideCreated=${groupOptions?.hideCreated}&hideAcquired=${groupOptions?.hideAcquired}&hideScheduled=${groupOptions?.hideScheduled}` : "";
return this.fetch<{items: Item[]}>("GET", `${scopePrefix}/items?filter=${encodeURIComponent(JSON.stringify(filter))}`).then(r => r.items);
return this.fetch<{items: Item[], groups: ItemGroup[]}>("GET", `${scopePrefix}/items?filter=${encodeURIComponent(JSON.stringify(filter))}${groupedSuffix}`).then(r => {
if (!grouped) {
return r.items
}
return {groups: r.groups, items: r.items}
});
}
async createItem(scopeId: number, input: ItemInput): Promise<Item> {
@@ -99,15 +108,17 @@ export default class SL3APIClient {
return this.fetch<{sprint: Sprint}>("GET", `scopes/${scopeId}/sprints/${sprintId}`).then(r => r.sprint);
}
async listSprints(scopeId: number, interval?: TimeInterval<Date | string>): Promise<Sprint[]> {
async listSprints(scopeId: number | "ALL", interval?: TimeInterval<Date | string>): Promise<Sprint[]> {
let qs = "";
if (interval != null) {
const minStr = (interval.min instanceof Date) ? interval.min.toISOString() : interval.min;
const maxStr = (interval.max instanceof Date) ? interval.max.toISOString() : interval.max;
qs = `?from=${encodeURIComponent(minStr)}&to=${encodeURIComponent(maxStr)}`
}
let prefix = (scopeId === "ALL") ? `all-scopes` : `scopes/${scopeId}`
return this.fetch<{sprints: Sprint[]}>("GET", `scopes/${scopeId}/sprints${qs}`).then(r => r.sprints);
return this.fetch<{sprints: Sprint[]}>("GET", `${prefix}/sprints${qs}`).then(r => r.sprints);
}
async createSprint(scopeId: number, input: SprintInput): Promise<Sprint> {
@@ -10,10 +10,19 @@
<div class="markdown">{@html outputHtml}</div>
<style>
<style lang="scss">
@import "../../css/colors.sass";
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) {
@@ -58,6 +67,17 @@
counter-reset: li;
}
div.markdown :global(a) {
color: #d1a31b;
text-decoration: none;
transition: 250ms;
&:hover {
color: #FFCC11;
text-decoration: underline;
}
}
div.markdown :global(ul) :global(li:before) {
content: "\2022"; /* Add content: \2022 is the CSS Code/unicode for a bullet */
color: #333; /* Change the color */
@@ -94,6 +94,10 @@
display: flex;
flex-direction: row;
@media screen and (max-width: 749px) {
display: block;
}
}
div.title {
@@ -6,6 +6,10 @@
padding-left: 0
flex: 1
div.modal-body:first-of-type
padding-left: 1em
@media screen and (max-width: 749px)
padding-left: 1em
padding: 0 1em
&:first-of-type
padding-left: 1em
</style>
@@ -16,6 +16,6 @@
3: "spinner",
4: "check",
5: "times",
6: "times",
6: "trash",
}
</script>
@@ -25,7 +25,6 @@
import { getScopeContext } from "./ScopeContext.svelte";
import type Item from "$lib/models/item";
import type { ItemFilter, ItemGroup } from "$lib/models/item";
import { groupItems } from "$lib/utils/items";
export let items: Item[];
export let groups: ItemGroup[];
@@ -50,9 +49,9 @@
}
try {
const newItems = await sl3(fetch).listItems($scope.id, filter)
itemsWritable.set(newItems);
groupsWritable.set(groupItems(newItems, filter.scheduledDate.min))
const res = await sl3(fetch).listItems($scope.id, filter, true, {});
itemsWritable.set(res.items);
groupsWritable.set(res.groups);
} catch(_) {}
loading = false;
@@ -11,7 +11,7 @@
const fallback: ScopeContextData = {
scope: readable({stats: []} as Scope),
lastHistoryRange: writable(""),
lastHistoryRange: writable("last:14d"),
upsertStat: () => {},
deleteStat: () => {},
updateScope: () => {},
@@ -27,6 +27,7 @@
export let sprints: Sprint[];
export let intervalString: string;
export let global: boolean = false;
const {scope} = getScopeContext();
const {now} = getTimeContext();
@@ -46,7 +47,7 @@
}
try {
const newSprints = await sl3(fetch).listSprints($scope.id, parseInterval(intervalString, $now));
const newSprints = await sl3(fetch).listSprints(global ? "ALL" : $scope.id, parseInterval(intervalString, $now));
sprintsWritable.set(newSprints);
} catch(_) {}
@@ -31,15 +31,19 @@
import { browser } from "$app/env";
import { sl3 } from "$lib/clients/sl3";
import { getScopeContext } from "$lib/components/contexts/ScopeContext.svelte";
import type Scope from "$lib/models/scope";
import { SprintKind, type SprintInputPart } from "$lib/models/sprint";
import Status from "$lib/models/status";
import shortestDecimal from "$lib/utils/numbers";
import { getScopeListContext } from "../contexts/ScopeListContext.svelte";
import Checkbox from "../layout/Checkbox.svelte";
export let value: SprintInputPart[] = [];
export let kind: SprintKind;
export let scopeId: number;
const {scope} = getScopeContext();
const {scopes} = getScopeListContext();
let error = "";
let groups: PartOptionGroup[] = [];
@@ -48,18 +52,19 @@
let onlyActive = true;
let loaded: SprintKind = SprintKind.Invalid
let hiddenSelections: SprintInputPart[] = [];
let actualScope: Scope;
function forceReload() {
loaded = SprintKind.Invalid;
}
async function load(kind: SprintKind) {
async function load(kind: SprintKind, scope: Scope) {
loading = true;
try {
switch (kind) {
case SprintKind.Items: {
const items = await sl3(fetch).listItems($scope.id, {unAcquired: onlyActive});
const items = await sl3(fetch).listItems(scope.id, {unAcquired: onlyActive});
const projects = items.filter(i => i.project != null).map(i => i.project).sort(compareStatus).filter((p, i, a) => p.id != a[i-1]?.id);
const requirements = items.filter(i => i.requirement != null).map(i => i.requirement).sort(compareStatus).filter((r, i, a) => r.id != a[i-1]?.id);
const loose = items.filter(i => i.requirementId == null);
@@ -106,7 +111,7 @@
id: -1,
label: "",
subs: [],
options: $scope.stats.map(s => ({partId: s.id, name: s.name, enabled: false, required: 0})),
options: scope.stats.map(s => ({partId: s.id, name: s.name, enabled: false, required: 0})),
}];
showRequired = true;
break;
@@ -158,8 +163,10 @@
]
}
$: actualScope = ($scope.id === scopeId || scopeId == null) ? $scope : $scopes.find(s => s.id === scopeId);
$: if (browser && !loading && loaded !== kind) {
load(kind);
load(kind, actualScope);
}
</script>
@@ -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>
@@ -37,7 +37,7 @@
for (const stat of actualScope.stats) {
const found = value.find(v => v.statId == stat.id);
enabledMap[stat.id] = found != null && (!!found?.required || !!found?.acquired);
enabledMap[stat.id] = found != null && (found?.required > (zeroDeletes ? 0 : -1));
requiredMap[stat.id] = found?.required || 0;
acquiredMap[stat.id] = found?.acquired || 0;
}
@@ -57,8 +57,6 @@
required: requiredMap[s.id],
acquired: acquiredMap[s.id],
}));
$: console.log(value);
</script>
<div class="stat-input">
@@ -1,34 +1,68 @@
<script lang="ts">
import Status from "$lib/models/status";
export let status: Status;
<script lang="ts" context="module">
const STATUS_ENTRIES: [Status, IconName][] = Object.entries(STATUS_ICONS).map(e => [parseInt(e[0]) as Status, e[1]])
</script>
<select class={`sc-${status}`} bind:value={status}>
<option class="sc-0" value={Status.Blocked}>Blocked</option>
<option class="sc-1" value={Status.Available}>Available</option>
<option class="sc-2" value={Status.Background}>Background</option>
<option class="sc-3" value={Status.Active}>Active</option>
<option class="sc-4" value={Status.Completed}>Completed</option>
<option class="sc-5" value={Status.Failed}>Failed</option>
<option class="sc-6" value={Status.Dropped}>Dropped</option>
</select>
<script lang="ts">
import type Status from "$lib/models/status";
import { STATUS_NAMES } from "$lib/models/status";
import { STATUS_ICONS } from "../common/StatusIcon.svelte";
import Icon, { type IconName } from "../layout/Icon.svelte";
export let status: Status;
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">
@import "../../css/colors"
.sc-0
color: $sc-0 !important
.sc-1
color: $sc-1 !important
.sc-2
color: $sc-2 !important
.sc-3
color: $sc-3 !important
.sc-4
color: $sc-4 !important
.sc-5
color: $sc-5 !important
.sc-6
color: $sc-6 !important
div.status-select
background-color: $color-entryhalf
display: flex
flex-direction: row
margin-bottom: 0.8em
margin-top: 0.2em
div.status-name
margin-left: auto
margin-top: 0.275em
margin-right: 1ch
color: $color-entry7
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>
@@ -1,6 +1,6 @@
<script lang="ts">
import { formatFormTime } from "$lib/utils/date";
import parseInterval from "$lib/utils/timeinterval";
import parseInterval, { morningInterval } from "$lib/utils/timeinterval";
export let from: string;
export let to: string;
@@ -9,7 +9,7 @@
$: {
if (intervalName !== "specific_dates") {
const int = parseInterval(intervalName, openDate);
const int = morningInterval(parseInterval(intervalName, openDate));
from = formatFormTime(int.min);
to = formatFormTime(int.max);
}
@@ -42,6 +42,7 @@
import { faChevronDown } from "@fortawesome/free-solid-svg-icons/faChevronDown";
import { faTrash } from "@fortawesome/free-solid-svg-icons/faTrash";
import { faCheckToSlot } from "@fortawesome/free-solid-svg-icons/faCheckToSlot";
import { faEye } from "@fortawesome/free-solid-svg-icons/faEye";
const icons = {
"clock": faClock,
@@ -66,6 +67,7 @@
"chevron_down": faChevronDown,
"trash": faTrash,
"check_slot": faCheckToSlot,
"eye": faEye,
};
export type IconName = keyof typeof icons;
@@ -0,0 +1,38 @@
<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;
export let aggregateRequired: number;
const {scope} = getScopeContext();
let amounts: {name: string, amount: number}[] = [];
$: {
const statMap: Record<number, number> = {};
for (const item of items) {
for (const stat of item.stats) {
statMap[stat.id] = (statMap[stat.id] || 0) + stat.acquired
}
}
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 right label="" dark value={totalAcquired} target={aggregateRequired} />
</AmountRow>
@@ -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>
@@ -11,6 +11,8 @@
import ItemEntry from "./ItemSubSection.svelte";
import Icon from "../layout/Icon.svelte";
import { projectPrettyId } from "$lib/utils/prettyIds";
import AmountRow from "../common/AmountRow.svelte";
import AggregateAmountRow from "./AggregateAmountRow.svelte";
export let requirement: Requirement;
</script>
@@ -35,4 +37,7 @@
{#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>
@@ -6,14 +6,15 @@
import SprintBody from "./SprintBody.svelte";
import { projectPrettyId } from "$lib/utils/prettyIds";
import Section from "../layout/Section.svelte";
import Icon from "../layout/Icon.svelte";
export let sprint: Sprint;
</script>
<Section anchor={projectPrettyId(sprint)} noProgress title={sprint.name}>
<OptionsRow slot="right">
<Option open={{name: "sprint.edit", sprint}}>Edit</Option>
<Option open={{name: "sprint.delete", sprint}} color="red">Delete</Option>
<Option open={{name: "sprint.edit", sprint}}><Icon name="pen" /></Option>
<Option open={{name: "sprint.delete", sprint}} color="red"><Icon name="trash" /></Option>
</OptionsRow>
<SprintBody sprint={sprint} />
</Section>
@@ -8,20 +8,28 @@
import SprintBody from "./SprintBody.svelte";
import { projectPrettyId, scopePrettyId } from "$lib/utils/prettyIds";
import { getScopeContext } from "../contexts/ScopeContext.svelte";
import { getScopeListContext } from "../contexts/ScopeListContext.svelte";
import Icon from "../layout/Icon.svelte";
export let sprint: Sprint;
const {scope, lastHistoryRange} = getScopeContext();
const {scopes} = getScopeListContext();
function gotoSprint() {
goto(`/${scopePrettyId($scope)}/sprints/${$lastHistoryRange}#${projectPrettyId(sprint)}`)
let sc = $scope;
if (sc.id !== sprint.scopeId) {
sc = $scopes.find(s => s.id === sprint.scopeId);
}
goto(`/${scopePrettyId(sc)}/sprints/${$lastHistoryRange}#${projectPrettyId(sprint)}`)
}
</script>
<SubSection noProgress title={sprint.name}>
<OptionsRow slot="right">
<Option on:click={gotoSprint}>View</Option>
<Option open={{name: "sprint.edit", sprint}}>Edit</Option>
<Option open={{name: "sprint.delete", sprint}} color="red">Delete</Option>
<Option on:click={gotoSprint}><Icon name="eye" /></Option>
<Option open={{name: "sprint.edit", sprint}}><Icon name="pen" /></Option>
<Option open={{name: "sprint.delete", sprint}} color="red"><Icon name="trash" /></Option>
</OptionsRow>
<SprintBody compact sprint={sprint} />
</SubSection>
@@ -3,6 +3,7 @@
import Amount from "../common/Amount.svelte";
import AmountRow from "../common/AmountRow.svelte";
import Markdown from "../common/Markdown.svelte";
import Icon from "../layout/Icon.svelte";
import Option from "../layout/Option.svelte";
import OptionsRow from "../layout/OptionsRow.svelte";
import SubSection from "../layout/SubSection.svelte";
@@ -12,8 +13,8 @@
<SubSection noProgress title={stat.name}>
<OptionsRow slot="right">
<Option open={{name: "stat.edit", stat}}>Edit</Option>
<Option open={{name: "stat.delete", stat}} color="red">Delete</Option>
<Option open={{name: "stat.edit", stat}}><Icon name="pen" /></Option>
<Option open={{name: "stat.delete", stat}} color="red"><Icon name="trash" /></Option>
</OptionsRow>
<Markdown source={stat.description} />
{#if stat.weight !== 1}
+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
@@ -96,7 +96,7 @@
<label for="description">Description</label>
<textarea name="description" bind:value={item.description} />
<label for="stats">Stats</label>
<StatInput showAcquired hideUnseen noToggle bind:value={item.stats} />
<StatInput overrideScopeId={scopeId} showAcquired hideUnseen noToggle bind:value={item.stats} />
</ModalBody>
</Modal>
</form>
@@ -11,6 +11,7 @@
import { getScopeListContext } from "$lib/components/contexts/ScopeListContext.svelte";
import { getSprintListContext } from "$lib/components/contexts/SprintListContext.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 type Item from "$lib/models/item";
import type { ItemInput } from "$lib/models/item";
@@ -107,7 +108,7 @@
op = "Edit"
openedDate = new Date();
requirementName = req?.name;
requirementName = current.requirement?.name;
show = true;
}
@@ -185,7 +186,11 @@
<ModalBody>
{#if requirementName != null}
<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}
<label for="stats">Stats</label>
<StatInput
@@ -21,6 +21,7 @@ import { statDiff } from "$lib/utils/stat";
let projectId: number
let requirementId: number
let oldStats: RequirementInput["stats"]
let showAggregateRequired: boolean
let error: string
let loading: boolean
@@ -44,6 +45,8 @@ import { statDiff } from "$lib/utils/stat";
show = false;
}
$: showAggregateRequired = requirement?.stats?.find(s => s.required > 0) == null;
function initCreate(project: ProjectEntry) {
requirement = {
name: "",
@@ -51,6 +54,7 @@ import { statDiff } from "$lib/utils/stat";
stats: [],
status: Status.Available,
isCoarse: false,
aggregateRequired: 0,
}
projectId = project.id;
@@ -64,6 +68,7 @@ import { statDiff } from "$lib/utils/stat";
status: current.status,
stats: current.stats.map(s => ({acquired: s.acquired, required: s.required, statId: s.id})),
isCoarse: current.isCoarse,
aggregateRequired: current.aggregateRequired,
}
oldStats = [...requirement.stats];
@@ -76,6 +81,10 @@ import { statDiff } from "$lib/utils/stat";
error = null;
loading = true;
if (requirement.stats.find(s => s.required > 0)) {
requirement.aggregateRequired = 0;
}
try {
switch (op) {
case "Create":
@@ -115,6 +124,10 @@ import { statDiff } from "$lib/utils/stat";
<input name="name" type="text" bind:value={requirement.name} />
<label for="description">Description</label>
<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>
<label for="stats">Status</label>
@@ -22,6 +22,7 @@ import { partsDiff } from "$lib/utils/sprint";
let sprint: SprintInput
let sprintId: number
let scopeId: number
let oldParts: SprintInputPart[]
let intervalName: string;
@@ -60,6 +61,7 @@ import { partsDiff } from "$lib/utils/sprint";
parts: [],
}
intervalName = "this_month";
scopeId = scope.id;
op = "Create"
openedDate = new Date();
@@ -80,6 +82,7 @@ import { partsDiff } from "$lib/utils/sprint";
isUnweighted: current.isUnweighted,
};
sprintId = current.id;
scopeId = current.scopeId;
intervalName = "specific_dates";
if (sprint.kind === SprintKind.Stats) {
@@ -107,16 +110,16 @@ import { partsDiff } from "$lib/utils/sprint";
try {
switch (op) {
case "Create":
await sl3(fetch).createSprint($scope.id, submission);
await sl3(fetch).createSprint(scopeId, submission);
break;
case "Edit":
await sl3(fetch).updateSprint($scope.id, sprintId, submission);
await sl3(fetch).updateSprint(scopeId, sprintId, submission);
const {added, removed} = partsDiff(oldParts, submission.parts)
for (const part of added) {
await sl3(fetch).upsertSprintPart($scope.id, sprintId, part).catch(() => {});
await sl3(fetch).upsertSprintPart(scopeId, sprintId, part).catch(() => {});
}
for (const part of removed) {
await sl3(fetch).deleteSprintPart($scope.id, sprintId, part).catch(() => {});
await sl3(fetch).deleteSprintPart(scopeId, sprintId, part).catch(() => {});
}
break;
@@ -161,7 +164,7 @@ import { partsDiff } from "$lib/utils/sprint";
<label for="kind">Kind</label>
<SprintKindSelect disabled={op === "Edit"} bind:kind={sprint.kind} />
<label for="parts">Parts</label>
<PartInput bind:value={sprint.parts} kind={sprint.kind} />
<PartInput bind:value={sprint.parts} kind={sprint.kind} scopeId={scopeId} />
</ModalBody>
</Modal>
</form>
@@ -103,7 +103,7 @@ import Checkbox from "$lib/components/layout/Checkbox.svelte";
</script>
<form on:submit|preventDefault={submit}>
<Modal closable show={show} verb={op} noun="Item" disabled={loading} error={error}>
<Modal closable show={show} verb={op} noun="Stat" disabled={loading} error={error}>
<ModalBody>
<label for="name">Name</label>
<input name="name" type="text" bind:value={stat.name} />
+8
View File
@@ -68,9 +68,17 @@ export interface ItemGroup {
export interface ItemGroupReference {
idx: number
event: "created" | "acquired" | "scheduled"
time: string
sortKey?: string
}
export interface GroupItemsOptions {
hideCreated?: boolean
hideAcquired?: boolean
hideScheduled?: boolean
}
// this_week
// this_month
// all_time
+2
View File
@@ -38,6 +38,7 @@ export interface Requirement {
totalRequired: number
totalPlanned: number
isCoarse: boolean
aggregateRequired: number
stats: StatProgressWithPlanned[]
items: Item[]
}
@@ -47,5 +48,6 @@ export interface RequirementInput {
description: string
status: Status
isCoarse: boolean
aggregateRequired: number
stats: StatValueInput[]
}
+10
View File
@@ -8,4 +8,14 @@ enum Status {
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;
+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}`;
}
}
-71
View File
@@ -1,71 +0,0 @@
import type { ItemGroup, ItemGroupReference } from "$lib/models/item";
import type Item from "$lib/models/item";
import { formatDate, formatDateTime, formatTime, formatWeekdayDate } from "./date";
export interface GroupItemsOptions {
hideCreated?: boolean
hideScheduled?: boolean
hideAcquired?: boolean
}
export function groupItemOptionsQuery(opts: GroupItemsOptions): string {
return `hideCreated=${opts.hideCreated},hideScheduled=${opts.hideScheduled},hideAcquired=${opts.hideAcquired}`;
}
export function groupItems(items: Item[], minDate?: string, opts: GroupItemsOptions = {}): ItemGroup[] {
let groups: Record<string, ItemGroup> = {};
for (let i = 0; i < items.length; ++i) {
const item = items[i];
const createKey = formatDate(item.createdTime)
const createSortKey = `${createKey} ${formatTime(item.createdTime)}`
if (!opts.hideCreated) {
if (!item.acquiredTime || item.acquiredTime >= item.createdTime) {
addItem(groups, createKey, i, createSortKey, item, "created");
}
}
if (!opts.hideScheduled) {
if (item.scheduledDate) {
addItem(groups, item.scheduledDate, i, `${item.scheduledDate} 23:59:59`, item, "scheduled");
}
}
if (!opts.hideAcquired) {
if (item.acquiredTime) {
addItem(groups, formatDate(item.acquiredTime), i, formatDateTime(item.acquiredTime), item, "acquired");
}
}
}
return Object.keys(groups).sort((a,b) => (
b.localeCompare(a)
)).map(k => ({
...groups[k],
list: groups[k].list.sort((a, b) => {
return b.sortKey.localeCompare(a.sortKey);
}).map(r => ({...r, sortKey: void(0)})),
})).filter(g => minDate == null || g.label > minDate);
}
function addItem(groups: Record<string, ItemGroup>, key: string, index: number, sortKey: string, item: Item, event: ItemGroupReference["event"]) {
if (groups[key] == null) {
groups[key] = {
label: formatWeekdayDate(sortKey),
list: [],
}
}
const i = groups[key].list.findIndex(r => r.idx === index)
if (i == -1) {
groups[key].list.push({
idx: index,
event: event,
sortKey: sortKey,
})
} else {
groups[key].list[i].event = event;
groups[key].list[i].sortKey = sortKey;
}
}
+15 -2
View File
@@ -25,9 +25,9 @@ export default function parseInterval(s: string, date: Date): TimeInterval<Date>
const [amount, unit] = unitNumber(args);
switch (unit) {
case "d": case "days":
return {display: "days", min: startOfDay(addDays(date, -amount)), max: startOfDay(addDays(date, 1))}
return {display: "days", min: startOfDay(addDays(date, -(amount - 1))), max: startOfDay(addDays(date, 1))}
case "w": case "weeks":
return {display: "weeks", min: startOfDay(addDays(date, -amount * 7)), max: startOfDay(addDays(date, 1))}
return {display: "weeks", min: startOfDay(addDays(date, -((amount * 7) - 1))), max: startOfDay(addDays(date, 1))}
case "m": case "months":
return {display: "days", min: startOfDay(addMonths(date, -amount)), max: startOfDay(addDays(date, 1))}
case "y": case "years":
@@ -55,6 +55,19 @@ export default function parseInterval(s: string, date: Date): TimeInterval<Date>
case "last_year":
return {display: "year", min: startOfYear(addYears(date, -1)), max: startOfYear(date)}
}
return null
}
export function morningInterval(interval: TimeInterval<Date>) {
if (!interval) {
return null
}
return {
min: new Date(interval.min.getTime() + 3600000 * 5),
max: new Date(interval.max.getTime() + 3600000 * 5),
}
}
export function intervalLabel(s: string): string {
@@ -3,7 +3,6 @@
import { sl3 } from "$lib/clients/sl3";
import parseInterval, { datesOf, intervalLabel } from "$lib/utils/timeinterval";
import { groupItemOptionsQuery, groupItems, type GroupItemsOptions } from "$lib/utils/items";
import type Item from "$lib/models/item";
import type { ItemFilter, ItemGroup } from "$lib/models/item";
@@ -16,13 +15,12 @@
acquiredTime: interval,
scheduledDate: datesOf(interval),
};
const items = await sl3(fetch).listItems(scopeId, filter);
const {items, groups} = await sl3(fetch).listItems(scopeId, filter, true, session.groupOptions);
return {
props: {
items, filter,
items, groups, filter,
intervalString: params.interval,
groups: groupItems(items, datesOf(interval)?.min, session.groupOptions || {}),
},
};
}
@@ -41,7 +39,6 @@
import ItemListContext from "$lib/components/contexts/ItemListContext.svelte";
import HistoryGroupList from "$lib/components/history/HistoryGroupList.svelte";
import ToggleIcon from "$lib/components/history/ToggleIcon.svelte";
import Status from "$lib/models/status";
export let intervalString: string
export let items: Item[]
@@ -7,7 +7,7 @@
scheduledDate: datesOf(parseInterval("next:7d", now)),
},
acquiredFilter: {
acquiredTime: parseInterval("today", now),
acquiredTime: morningInterval(parseInterval("today", now)),
},
looseFilter: {
loose: true,
@@ -35,6 +35,7 @@
</script>
<script lang="ts">
import { sl3 } from "$lib/clients/sl3";
import { getScopeContext } from "$lib/components/contexts/ScopeContext.svelte";
import Column from "$lib/components/layout/Column.svelte";
import Columns from "$lib/components/layout/Columns.svelte";
@@ -44,8 +45,7 @@
import StatSubSection from "$lib/components/scope/StatSubSection.svelte";
import DeletionModal from "$lib/modals/DeletionModal.svelte";
import StatCreateEditModal from "$lib/modals/StatCreateEditModal.svelte";
import { sl3 } from "$lib/clients/sl3";
import parseInterval, { datesOf } from "$lib/utils/timeinterval";
import parseInterval, { datesOf, morningInterval } from "$lib/utils/timeinterval";
import type Item from "$lib/models/item";
import ItemCreateModal from "$lib/modals/ItemCreateModal.svelte";
import Card from "$lib/components/common/Card.svelte";
@@ -62,6 +62,7 @@
import ItemMultiListContext from "$lib/components/contexts/ItemMultiListContext.svelte";
import { getTimeContext } from "$lib/components/contexts/TimeContext.svelte";
import ItemListRow from "$lib/components/scope/ItemListRow.svelte";
import Icon from "$lib/components/layout/Icon.svelte";
export let acquiredItems: Item[];
export let scheduledItems: Item[];
@@ -106,13 +107,13 @@
<Column>
<Row title="Sprints">
<OptionsRow slot="right">
<Option open={{name: "sprint.create"}}>Create</Option>
<Option open={{name: "sprint.create"}}><Icon name="plus" /></Option>
</OptionsRow>
<SprintList sub />
</Row>
<Row title="Stats">
<OptionsRow slot="right">
<Option open={{name: "stat.create"}}>Create</Option>
<Option open={{name: "stat.create"}}><Icon name="plus" /></Option>
</OptionsRow>
{#each $scope.stats as stat (stat.id)}
<StatSubSection stat={stat} />
+37 -24
View File
@@ -8,7 +8,7 @@
scheduledDate: datesOf(parseInterval("next:7d", now)),
},
acquiredFilter: {
acquiredTime: parseInterval("today", now),
acquiredTime: morningInterval(parseInterval("today", now)),
},
looseFilter: {
loose: true,
@@ -18,7 +18,7 @@
}
}
export const load: Load = async({ fetch, session }) => {
export const load: Load = async({ fetch }) => {
const scopes = await sl3(fetch).listScopes();
const {acquiredFilter, looseFilter, scheduledFilter} = generateItemFilters(new Date());
@@ -26,9 +26,10 @@
const scheduledItems = (await sl3(fetch).listItems("ALL", scheduledFilter));
const acquiredItems = (await sl3(fetch).listItems("ALL", acquiredFilter));
const looseItems = (await sl3(fetch).listItems("ALL", looseFilter));
const sprints = await sl3(fetch).listSprints("ALL");
return {
props: { scopes, scheduledItems, acquiredItems, looseItems }
props: { scopes, scheduledItems, acquiredItems, looseItems, sprints }
};
}
</script>
@@ -44,7 +45,7 @@
import OptionsRow from "$lib/components/layout/OptionsRow.svelte";
import Option from "$lib/components/layout/Option.svelte";
import ScopeListContext from "$lib/components/contexts/ScopeListContext.svelte";
import parseInterval, { datesOf } from "$lib/utils/timeinterval";
import parseInterval, { datesOf, morningInterval } from "$lib/utils/timeinterval";
import ItemMultiListContext from "$lib/components/contexts/ItemMultiListContext.svelte";
import type Item from "$lib/models/item";
import DeletionModal from "$lib/modals/DeletionModal.svelte";
@@ -53,11 +54,17 @@
import { getTimeContext } from "$lib/components/contexts/TimeContext.svelte";
import type { ItemFilter } from "$lib/models/item";
import ItemCreateModal from "$lib/modals/ItemCreateModal.svelte";
import SprintList from "$lib/components/scope/SprintList.svelte";
import SprintCreateUpdateModal from "$lib/modals/SprintCreateUpdateModal.svelte";
import SprintListContext from "$lib/components/contexts/SprintListContext.svelte";
import type Sprint from "$lib/models/sprint";
import Icon from "$lib/components/layout/Icon.svelte";
export let scopes: Scope[] = [];
export let acquiredItems: Item[];
export let scheduledItems: Item[];
export let looseItems: Item[];
export let sprints: Sprint[];
const {now} = getTimeContext();
</script>
@@ -66,25 +73,31 @@
<ItemMultiListContext global lists={{acquiredItems, scheduledItems, looseItems}} filters={generateItemFilters($now)}>
<ScopeListContext scopes={scopes}>
<Columns wide>
<Column>
<Row title="Scopes">
<OptionsRow slot="right">
<Option open={{name: "scope.create"}}>Create</Option>
</OptionsRow>
<ScopeLinkList />
</Row>
<ItemListRow title="Scheduled" key="scheduleItems" />
<ItemListRow title="Today" key="acquiredItems" showAcquiredTime />
<ItemListRow title="Loose" key="looseItems" />
</Column>
<Column>
</Column>
</Columns>
<ItemCreateModal />
<ItemAcquireModal />
<DeletionModal />
<ScopeCreateUpdateModal />
<SprintListContext global sprints={sprints} intervalString="">
<Columns wide>
<Column>
<Row title="Scopes">
<OptionsRow slot="right">
<Option open={{name: "scope.create"}}><Icon name="plus" /></Option>
</OptionsRow>
<ScopeLinkList />
</Row>
<ItemListRow title="Scheduled" key="scheduleItems" />
<ItemListRow title="Today" key="acquiredItems" showAcquiredTime />
<ItemListRow title="Loose" key="looseItems" />
</Column>
<Column>
<Row title="Sprints">
<SprintList sub />
</Row>
</Column>
</Columns>
<ItemCreateModal />
<ItemAcquireModal />
<DeletionModal />
<ScopeCreateUpdateModal />
<SprintCreateUpdateModal />
</SprintListContext>
</ScopeListContext>
</ItemMultiListContext>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 495 KiB

After

Width:  |  Height:  |  Size: 112 KiB

+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
+10
View File
@@ -0,0 +1,10 @@
package genutils
func Flatten2[T any](arrs [][]T) []T {
res := make([]T, 0, 128)
for _, arr := range arrs {
res = append(res, arr...)
}
return res
}
+9
View File
@@ -8,3 +8,12 @@ func Map[I any, O any](arr []I, cb func(a I) O) []O {
return res
}
func MapValues[K comparable, V any](m map[K]V) []V {
res := make([]V, 0, len(m))
for _, v := range m {
res = append(res, v)
}
return res
}
+8
View File
@@ -46,6 +46,14 @@ func (d Date) ToTime() time.Time {
return time.Date(d[0], time.Month(d[1]), d[2], 0, 0, 0, 0, time.UTC)
}
func (d Date) WithTime(hh, mm, ss int) time.Time {
return d.WithTimeAndLocation(hh, mm, ss, time.UTC)
}
func (d Date) WithTimeAndLocation(hh, mm, ss int, loc *time.Location) time.Time {
return time.Date(d[0], time.Month(d[1]), d[2], hh, mm, ss, 0, loc)
}
func (d *Date) UnmarshalJSON(b []byte) error {
var str string
err := json.Unmarshal(b, &str)
+5 -4
View File
@@ -8,8 +8,9 @@ type ProjectUpdate struct {
}
type RequirementUpdate struct {
Name *string `json:"name"`
Description *string `json:"description"`
Status *Status `json:"status"`
IsCoarse *bool `json:"isCoarse"`
Name *string `json:"name"`
Description *string `json:"description"`
Status *Status `json:"status"`
IsCoarse *bool `json:"isCoarse"`
AggregateRequired *int `json:"aggregateRequired"`
}
+9
View File
@@ -43,6 +43,15 @@ func (s Status) Less(s2 Status) bool {
return s < s2
}
func (s Status) Ended() bool {
switch s {
case Completed, Failed, Dropped:
return true
default:
return false
}
}
const (
Blocked Status = iota
Available
+9
View File
@@ -55,6 +55,10 @@ func getterHandler[O any](key, idKey string, callback func(ctx context.Context,
}
func handler(key string, callback func(c *gin.Context) (interface{}, error)) gin.HandlerFunc {
type overrider interface {
OverrideJSONResponse() bool
}
return func(c *gin.Context) {
res, err := callback(c)
if err != nil {
@@ -82,6 +86,11 @@ func handler(key string, callback func(c *gin.Context) (interface{}, error)) gin
return
}
if o, ok := res.(overrider); ok && o.OverrideJSONResponse() {
c.JSON(200, res)
return
}
resJson := make(map[string]interface{}, 1)
resJson[key] = res
+51 -10
View File
@@ -12,25 +12,50 @@ import (
"time"
)
func ItemsAllScopes(g *gin.RouterGroup, items *items.Service) {
type groupedRes struct {
Items []items.Result `json:"items"`
Groups []items.Group `json:"groups"`
}
func (g groupedRes) OverrideJSONResponse() bool {
return true
}
func ItemsAllScopes(g *gin.RouterGroup, itemService *items.Service) {
g.GET("", handler("items", func(c *gin.Context) (interface{}, error) {
filter, err := getItemFilterFromQuery(c)
if err != nil {
return nil, err
}
return items.ListAllScopes(c.Request.Context(), filter)
res, err := itemService.ListAllScopes(c.Request.Context(), filter)
if err != nil {
return nil, err
}
if c.Query("grouped") == "true" {
return groupedRes{
Items: res,
Groups: items.GenerateGroups(res, items.GroupOptions{
HideCreated: c.Query("hideCreated") == "true",
HideAcquired: c.Query("hideAcquired") == "true",
HideScheduled: c.Query("hideScheduled") == "true",
}),
}, nil
}
return res, nil
}))
}
func Items(g *gin.RouterGroup, items *items.Service) {
func Items(g *gin.RouterGroup, itemService *items.Service) {
g.GET("/:item_id", handler("project", func(c *gin.Context) (interface{}, error) {
id, err := reqInt(c, "item_id")
if err != nil {
return nil, err
}
return items.Find(c.Request.Context(), id)
return itemService.Find(c.Request.Context(), id)
}))
g.GET("", handler("items", func(c *gin.Context) (interface{}, error) {
@@ -39,7 +64,23 @@ func Items(g *gin.RouterGroup, items *items.Service) {
return nil, err
}
return items.ListScoped(c.Request.Context(), filter)
res, err := itemService.ListScoped(c.Request.Context(), filter)
if err != nil {
return nil, err
}
if c.Query("grouped") == "true" {
return groupedRes{
Items: res,
Groups: items.GenerateGroups(res, items.GroupOptions{
HideCreated: c.Query("hideCreated") == "true",
HideAcquired: c.Query("hideAcquired") == "true",
HideScheduled: c.Query("hideScheduled") == "true",
}),
}, nil
}
return res, nil
}))
g.POST("", handler("item", func(c *gin.Context) (interface{}, error) {
@@ -55,7 +96,7 @@ func Items(g *gin.RouterGroup, items *items.Service) {
}
}
return items.Create(c.Request.Context(), input.Item, input.Stats)
return itemService.Create(c.Request.Context(), input.Item, input.Stats)
}))
g.PUT("/:item_id", handler("item", func(c *gin.Context) (interface{}, error) {
@@ -76,7 +117,7 @@ func Items(g *gin.RouterGroup, items *items.Service) {
return nil, err
}
return items.Update(c.Request.Context(), id, input.ItemUpdate, input.Stats)
return itemService.Update(c.Request.Context(), id, input.ItemUpdate, input.Stats)
}))
g.PUT("/:item_id/stats/:stat_id", handler("item", func(c *gin.Context) (interface{}, error) {
@@ -100,7 +141,7 @@ func Items(g *gin.RouterGroup, items *items.Service) {
input.StatID = statID
return items.UpdateStat(c.Request.Context(), itemID, input)
return itemService.UpdateStat(c.Request.Context(), itemID, input)
}))
g.DELETE("/:item_id/stats/:stat_id", handler("item", func(c *gin.Context) (interface{}, error) {
@@ -113,7 +154,7 @@ func Items(g *gin.RouterGroup, items *items.Service) {
return nil, err
}
return items.DeleteStat(c.Request.Context(), itemID, statID)
return itemService.DeleteStat(c.Request.Context(), itemID, statID)
}))
g.DELETE("/:item_id", handler("item", func(c *gin.Context) (interface{}, error) {
@@ -122,7 +163,7 @@ func Items(g *gin.RouterGroup, items *items.Service) {
return nil, err
}
return items.Delete(c.Request.Context(), id)
return itemService.Delete(c.Request.Context(), id)
}))
}
+44 -24
View File
@@ -9,34 +9,22 @@ import (
"time"
)
func Sprints(g *gin.RouterGroup, sprintsService *sprints.Service) {
func SprintsAllScopes(g *gin.RouterGroup, sprintsService *sprints.Service) {
g.GET("", handler("sprints", func(c *gin.Context) (interface{}, error) {
from := time.Now().Add(-time.Hour * 24 * 7)
if fromQuery := c.Query("from"); fromQuery != "" {
parsed, err := time.Parse(time.RFC3339Nano, fromQuery)
if err != nil {
return nil, models.BadInputError{
Object: "Query",
Field: "from",
Problem: "Incorrect date format: " + err.Error(),
}
}
from = parsed
from, to, err := getFromToFromQuery(c)
if err != nil {
return nil, err
}
to := time.Now().Add(time.Hour * 24 * 7)
if toQuery := c.Query("to"); toQuery != "" {
parsed, err := time.Parse(time.RFC3339Nano, toQuery)
if err != nil {
return nil, models.BadInputError{
Object: "Query",
Field: "to",
Problem: "Incorrect date format: " + err.Error(),
}
}
return sprintsService.ListBetween(c.Request.Context(), from, to)
}))
}
to = parsed
func Sprints(g *gin.RouterGroup, sprintsService *sprints.Service) {
g.GET("", handler("sprints", func(c *gin.Context) (interface{}, error) {
from, to, err := getFromToFromQuery(c)
if err != nil {
return nil, err
}
return sprintsService.ListScopedBetween(c.Request.Context(), from, to)
@@ -126,3 +114,35 @@ func Sprints(g *gin.RouterGroup, sprintsService *sprints.Service) {
return sprintsService.Delete(c.Request.Context(), sprintID)
}))
}
func getFromToFromQuery(c *gin.Context) (time.Time, time.Time, error) {
from := time.Now().Add(-time.Hour * 24 * 7)
if fromQuery := c.Query("from"); fromQuery != "" {
parsed, err := time.Parse(time.RFC3339Nano, fromQuery)
if err != nil {
return time.Time{}, time.Time{}, models.BadInputError{
Object: "Query",
Field: "from",
Problem: "Incorrect date format: " + err.Error(),
}
}
from = parsed
}
to := time.Now().Add(time.Hour * 24 * 7)
if toQuery := c.Query("to"); toQuery != "" {
parsed, err := time.Parse(time.RFC3339Nano, toQuery)
if err != nil {
return time.Time{}, time.Time{}, models.BadInputError{
Object: "Query",
Field: "to",
Problem: "Incorrect date format: " + err.Error(),
}
}
to = parsed
}
return from, to, nil
}
+8 -7
View File
@@ -41,13 +41,14 @@ type Project struct {
}
type ProjectRequirement struct {
ID int
ScopeID int
ProjectID int
Name string
Status int
Description string
IsCoarse bool
ID int
ScopeID int
ProjectID int
Name string
Status int
Description string
IsCoarse bool
AggregateRequired int
}
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
INSERT INTO project_requirement (scope_id, project_id, name, status, description, is_coarse)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO project_requirement (scope_id, project_id, name, status, description, is_coarse, aggregate_required)
VALUES (?, ?, ?, ?, ?, ?, ?)
`
type InsertProjectRequirementParams struct {
ScopeID int
ProjectID int
Name string
Status int
Description string
IsCoarse bool
ScopeID int
ProjectID int
Name string
Status int
Description string
IsCoarse bool
AggregateRequired int
}
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.Description,
arg.IsCoarse,
arg.AggregateRequired,
)
}
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) {
@@ -201,6 +203,7 @@ func (q *Queries) ListProjectRequirements(ctx context.Context, projectID int) ([
&i.Status,
&i.Description,
&i.IsCoarse,
&i.AggregateRequired,
); err != nil {
return nil, err
}
@@ -330,17 +333,19 @@ UPDATE project_requirement
SET name = ?,
status = ?,
description = ?,
is_coarse = ?
is_coarse = ?,
aggregate_required = ?
WHERE id = ? AND scope_id = ?
`
type UpdateProjectRequirementParams struct {
Name string
Status int
Description string
IsCoarse bool
ID int
ScopeID int
Name string
Status int
Description string
IsCoarse bool
AggregateRequired int
ID int
ScopeID int
}
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.Description,
arg.IsCoarse,
arg.AggregateRequired,
arg.ID,
arg.ScopeID,
)
+9 -25
View File
@@ -191,37 +191,21 @@ func (q *Queries) ListSprintsAt(ctx context.Context, arg ListSprintsAtParams) ([
}
const listSprintsBetween = `-- name: ListSprintsBetween :many
SELECT id, scope_id, name, description, from_time, to_time, is_timed, is_coarse, is_unweighted, kind, aggregate_name, aggregate_required FROM sprint WHERE scope_id = ?
AND (
-- from time is within from..to
(from_time <= ? AND to_time > ?)
-- or to time is within from..to
OR (from_time <= ? AND to_time > ?)
-- or from and to time are on each their side of from..to
OR (from_time >= ? AND to_time < ?)
)
SELECT id, scope_id, name, description, from_time, to_time, is_timed, is_coarse, is_unweighted, kind, aggregate_name, aggregate_required FROM sprint
WHERE scope_id = ?
AND from_time < ?
AND to_time >= ?
ORDER BY from_time, name
`
type ListSprintsBetweenParams struct {
ScopeID int
FromTime time.Time
ToTime time.Time
FromTime_2 time.Time
ToTime_2 time.Time
FromTime_3 time.Time
ToTime_3 time.Time
ScopeID int
ToTime time.Time
FromTime time.Time
}
func (q *Queries) ListSprintsBetween(ctx context.Context, arg ListSprintsBetweenParams) ([]Sprint, error) {
rows, err := q.query(ctx, q.listSprintsBetweenStmt, listSprintsBetween,
arg.ScopeID,
arg.FromTime,
arg.ToTime,
arg.FromTime_2,
arg.ToTime_2,
arg.FromTime_3,
arg.ToTime_3,
)
rows, err := q.query(ctx, q.listSprintsBetweenStmt, listSprintsBetween, arg.ScopeID, arg.ToTime, arg.FromTime)
if err != nil {
return nil, err
}
+24 -20
View File
@@ -194,7 +194,7 @@ func (r *projectRepository) FetchRequirements(ctx context.Context, scopeID int,
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").
Where(squirrel.Eq{"id": requirementIDs})
if scopeID != -1 {
@@ -221,6 +221,7 @@ func (r *projectRepository) FetchRequirements(ctx context.Context, scopeID int,
&requirement.Status,
&requirement.Description,
&requirement.IsCoarse,
&requirement.AggregateRequired,
); err != nil {
return nil, nil, err
}
@@ -271,13 +272,14 @@ func (r *projectRepository) ListRequirements(ctx context.Context, projectID int)
requirements := make([]entities.Requirement, 0, len(reqRows))
for _, row := range reqRows {
requirements = append(requirements, entities.Requirement{
ID: row.ID,
ScopeID: row.ScopeID,
ProjectID: row.ProjectID,
Name: row.Name,
Description: row.Description,
IsCoarse: row.IsCoarse,
Status: models.Status(row.Status),
ID: row.ID,
ScopeID: row.ScopeID,
ProjectID: row.ProjectID,
Name: row.Name,
Description: row.Description,
IsCoarse: row.IsCoarse,
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) {
res, err := r.q.InsertProjectRequirement(ctx, mysqlcore.InsertProjectRequirementParams{
ScopeID: requirement.ScopeID,
ProjectID: requirement.ProjectID,
Name: requirement.Name,
Status: int(requirement.Status),
Description: requirement.Description,
IsCoarse: requirement.IsCoarse,
ScopeID: requirement.ScopeID,
ProjectID: requirement.ProjectID,
Name: requirement.Name,
Status: int(requirement.Status),
Description: requirement.Description,
IsCoarse: requirement.IsCoarse,
AggregateRequired: requirement.AggregateRequired,
})
if err != nil {
return nil, err
@@ -319,12 +322,13 @@ func (r *projectRepository) UpdateRequirement(ctx context.Context, requirement e
requirement.Update(update)
return r.q.UpdateProjectRequirement(ctx, mysqlcore.UpdateProjectRequirementParams{
Name: requirement.Name,
Status: int(requirement.Status),
Description: requirement.Description,
IsCoarse: requirement.IsCoarse,
ID: requirement.ID,
ScopeID: requirement.ScopeID,
Name: requirement.Name,
Status: int(requirement.Status),
Description: requirement.Description,
IsCoarse: requirement.IsCoarse,
ID: requirement.ID,
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 = ?;
-- name: InsertProjectRequirement :execresult
INSERT INTO project_requirement (scope_id, project_id, name, status, description, is_coarse)
VALUES (?, ?, ?, ?, ?, ?);
INSERT INTO project_requirement (scope_id, project_id, name, status, description, is_coarse, aggregate_required)
VALUES (?, ?, ?, ?, ?, ?, ?);
-- name: UpdateProjectRequirement :exec
UPDATE project_requirement
SET name = ?,
status = ?,
description = ?,
is_coarse = ?
is_coarse = ?,
aggregate_required = ?
WHERE id = ? AND scope_id = ?;
-- name: DeleteProjectRequirement :exec
+5 -10
View File
@@ -2,16 +2,11 @@
SELECT * FROM sprint WHERE scope_id = ? AND from_time <= sqlc.arg(time) AND to_time > sqlc.arg(time);
-- name: ListSprintsBetween :many
SELECT * FROM sprint WHERE scope_id = ?
AND (
-- from time is within from..to
(from_time <= ? AND to_time > ?)
-- or to time is within from..to
OR (from_time <= ? AND to_time > ?)
-- or from and to time are on each their side of from..to
OR (from_time >= ? AND to_time < ?)
);
SELECT * FROM sprint
WHERE scope_id = sqlc.arg(scope_id)
AND from_time < sqlc.arg(to_time)
AND to_time >= sqlc.arg(from_time)
ORDER BY from_time, name;
-- name: GetSprint :one
SELECT * FROM sprint WHERE id = ? AND scope_id = ?;
+3 -7
View File
@@ -75,13 +75,9 @@ func (r *sprintRepository) ListAt(ctx context.Context, scopeID int, at time.Time
func (r *sprintRepository) ListBetween(ctx context.Context, scopeID int, from, to time.Time) ([]entities.Sprint, error) {
rows, err := r.q.ListSprintsBetween(ctx, mysqlcore.ListSprintsBetweenParams{
ScopeID: scopeID,
FromTime: from,
ToTime: from,
FromTime_2: to,
ToTime_2: to,
FromTime_3: from,
ToTime_3: to,
ScopeID: scopeID,
FromTime: from,
ToTime: to,
})
if err != nil {
if err == sql.ErrNoRows {
+1
View File
@@ -88,6 +88,7 @@ func (r *statsRepository) List(ctx context.Context, scopeIDs ...int) ([]entities
Select("id, scope_id, name, description, weight, allowed_amounts, is_primary").
From("stat").
Where(squirrel.Eq{"scope_id": scopeIDs}).
OrderBy("is_primary DESC", "name").
RunWith(r.db).
Query()
if err == sql.ErrNoRows {
@@ -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
+105
View File
@@ -0,0 +1,105 @@
package items
import (
"git.aiterp.net/stufflog3/stufflog3/internal/genutils"
"sort"
"time"
)
type Group struct {
Label string `json:"label,omitempty"`
List []GroupReference `json:"list,omitempty"`
}
type GroupOptions struct {
HideCreated bool `json:"hideCreated,omitempty"`
HideScheduled bool `json:"hideScheduled,omitempty"`
HideAcquired bool `json:"hideAcquired,omitempty"`
}
type GroupReference struct {
Index int `json:"idx"`
Event string `json:"event"`
Time string `json:"time"`
sortKey string
}
func GenerateGroups(items []Result, opts GroupOptions) []Group {
groups := make(map[string]Group, 16)
// TODO: Get the scope from somewhere else, if necessary.
for i, item := range items {
if !opts.HideCreated && (item.AcquiredTime == nil || item.AcquiredTime.After(item.CreatedTime)) {
addItem(groups, i, "created", item.CreatedTime, tz)
}
if !opts.HideScheduled && item.ScheduledDate != nil {
addItem(groups, i, "scheduled", item.ScheduledDate.WithTimeAndLocation(23, 59, 59, tz), tz)
}
if !opts.HideAcquired && item.AcquiredTime != nil {
addItem(groups, i, "acquired", *item.AcquiredTime, tz)
}
}
for _, group := range groups {
sort.Slice(group.List, func(i, j int) bool {
return group.List[j].sortKey < group.List[i].sortKey
})
}
groupSlice := genutils.MapValues(groups)
sort.Slice(groupSlice, func(i, j int) bool {
return groupSlice[j].Label < groupSlice[i].Label
})
return groupSlice
}
func addItem(groups map[string]Group, index int, event string, eventDate time.Time, tz *time.Location) {
groupDate := eventDate.In(tz).Add(-time.Minute * 300)
eventDate = eventDate.In(tz)
groupKey := groupDate.Format("2006-01-02")
var sortKey string
if event == "scheduled" {
sortKey = eventDate.Format("2006-01-02") + " 23:59:59.999"
} else {
sortKey = eventDate.Format("2006-01-02 15:04:05")
}
// Why, Go, must you be so.
group, ok := groups[groupKey]
if !ok {
group = Group{
Label: groupDate.Format("2006-01-02 (Monday)"),
List: make([]GroupReference, 0, 16),
}
}
for i, ref := range group.List {
if ref.Index == index {
group.List = append(group.List[:i], group.List[i+1:]...)
break
}
}
group.List = append(group.List, GroupReference{
Index: index,
Event: event,
Time: eventDate.Format("15:04"),
sortKey: sortKey,
})
groups[groupKey] = group
}
var tz *time.Location
func init() {
var err error
tz, err = time.LoadLocation("Europe/Oslo")
if err != nil {
panic(err)
}
}
+21 -2
View File
@@ -125,7 +125,7 @@ func (s *Service) ListScoped(ctx context.Context, filter models.ItemFilter) ([]R
return nil, models.PermissionDeniedError{}
}
if filter.ScopeIDs != nil {
if len(filter.ScopeIDs) > 0 {
userScopes, err := sc.Scopes(ctx)
if err != nil {
return nil, err
@@ -185,7 +185,26 @@ func (s *Service) ListScoped(ctx context.Context, filter models.ItemFilter) ([]R
res := make([]Result, 0, len(items))
for _, item := range items {
res = append(res, generateResult(item, sc.Scope, progresses, requirements, projects))
scope := &sc.Scope
if scope.ID != item.ScopeID {
userScopes, err := sc.Scopes(ctx)
if err != nil {
return nil, err
}
for _, userScope := range userScopes {
if userScope.ID == item.ScopeID {
scope = &userScope
break
}
}
if scope.ID != item.ScopeID {
return nil, models.NotFoundError("Scope")
}
}
res = append(res, generateResult(item, *scope, progresses, requirements, projects))
}
return res, nil
+34 -24
View File
@@ -53,17 +53,18 @@ func (r *Result) Requirement(id int) *RequirementResult {
}
type RequirementResult struct {
ID int `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Status models.Status `json:"status"`
StatusName string `json:"statusName"`
TotalAcquired float64 `json:"totalAcquired"`
TotalRequired float64 `json:"totalRequired"`
TotalPlanned float64 `json:"totalPlanned"`
IsCoarse bool `json:"isCoarse"`
Stats []RequirementResultStat `json:"stats"`
Items []items.Result `json:"items"`
ID int `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Status models.Status `json:"status"`
StatusName string `json:"statusName"`
TotalAcquired float64 `json:"totalAcquired"`
TotalRequired float64 `json:"totalRequired"`
TotalPlanned float64 `json:"totalPlanned"`
IsCoarse bool `json:"isCoarse"`
AggregateRequired int `json:"aggregateRequired"`
Stats []RequirementResultStat `json:"stats"`
Items []items.Result `json:"items"`
Requirement entities.Requirement `json:"-"`
}
@@ -136,8 +137,12 @@ func generateResult(
resReq := generateRequirementResult(req, scope, requirementStats, projectItems)
res.TotalRequired += resReq.TotalRequired
res.TotalAcquired += resReq.TotalAcquired
res.TotalPlanned += resReq.TotalPlanned
if req.Status.Ended() {
res.TotalAcquired += resReq.TotalRequired
} else {
res.TotalAcquired += resReq.TotalAcquired
}
res.Requirements = append(res.Requirements, resReq)
}
@@ -157,14 +162,15 @@ func generateResult(
func generateRequirementResult(req entities.Requirement, scope scopes.Result, requirementStats []entities.RequirementStat, projectItems []items.Result) RequirementResult {
resReq := RequirementResult{
ID: req.ID,
Name: req.Name,
Description: req.Description,
Status: req.Status,
StatusName: scope.StatusName(req.Status),
IsCoarse: req.IsCoarse,
Stats: make([]RequirementResultStat, 0, 8),
Items: make([]items.Result, 0, 8),
ID: req.ID,
Name: req.Name,
Description: req.Description,
Status: req.Status,
StatusName: scope.StatusName(req.Status),
IsCoarse: req.IsCoarse,
AggregateRequired: req.AggregateRequired,
Stats: make([]RequirementResultStat, 0, 8),
Items: make([]items.Result, 0, 8),
Requirement: req,
}
@@ -195,12 +201,12 @@ func generateRequirementResult(req entities.Requirement, scope scopes.Result, re
continue
}
if item.AcquiredTime != nil {
for _, stat := range item.Stats {
if resStats[stat.ID] != nil {
for _, stat := range item.Stats {
if resStats[stat.ID] != nil {
if item.AcquiredTime != nil {
resStats[stat.ID].Acquired += stat.Acquired
resStats[stat.ID].Planned += stat.Required
}
resStats[stat.ID].Planned += stat.Required
}
}
@@ -267,6 +273,10 @@ func generateRequirementResult(req entities.Requirement, scope scopes.Result, re
resReq.TotalRequired += item.WeightedRequired
}
resReq.TotalPlanned = resReq.TotalRequired
if req.AggregateRequired > 0 {
resReq.TotalRequired = float64(req.AggregateRequired)
}
}
return resReq
+1
View File
@@ -68,6 +68,7 @@ func generateResult(scope entities.Scope, members []entities.ScopeMember, stats
Owner: member.Owner,
}
}
if stats != nil {
res.Stats = make([]ResultStat, 0, len(stats))
for _, stat := range stats {
+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 {
+108 -9
View File
@@ -37,25 +37,82 @@ func (s *Service) Find(ctx context.Context, id int) (*Result, error) {
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) {
sort.Slice(sprints, func(i, j int) bool {
si := sprints[i]
sj := sprints[j]
if si.IsTimed != sj.IsTimed {
return si.IsTimed
}
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, ctx := errgroup.WithContext(ctx)
eg, ctx2 := errgroup.WithContext(ctx)
results := make([]Result, len(sprints))
for i := range sprints {
iCopy := i
eg.Go(func() error {
res, err := s.fill(ctx, sprints[iCopy], parts)
res, err := s.fill(ctx2, sprints[iCopy], parts)
if err != nil {
return err
}
@@ -309,7 +366,28 @@ func (s *Service) Delete(ctx context.Context, id int) (*Result, error) {
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 {
@@ -317,8 +395,6 @@ func (s *Service) fill(ctx context.Context, sprint entities.Sprint, parts []enti
}
}
allStats := sc.Scope.Stats
progressMap := make(map[int]int, len(allStats))
res.Progress = make([]ResultProgress, 0, len(allStats))
res.PartIDs = partIDs
@@ -326,7 +402,8 @@ func (s *Service) fill(ctx context.Context, sprint entities.Sprint, parts []enti
switch res.Kind {
case models.SprintKindItems:
pickedItems, err := s.Items.ListScoped(ctx, models.ItemFilter{
IDs: partIDs,
IDs: partIDs,
ScopeIDs: []int{sprint.ScopeID},
})
if err != nil {
return nil, err
@@ -344,6 +421,10 @@ func (s *Service) fill(ctx context.Context, sprint entities.Sprint, parts []enti
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
}
@@ -425,6 +506,7 @@ func (s *Service) fill(ctx context.Context, sprint entities.Sprint, parts []enti
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},
})
if err != nil {
return nil, err
@@ -434,6 +516,7 @@ func (s *Service) fill(ctx context.Context, sprint entities.Sprint, parts []enti
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},
})
if err != nil {
return nil, err
@@ -443,9 +526,10 @@ func (s *Service) fill(ctx context.Context, sprint entities.Sprint, parts []enti
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,
ID: stat.ID,
Name: stat.Name,
Weight: stat.Weight,
Required: genutils.Ptr(0),
})
}
}
@@ -466,17 +550,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.AcquiredTime == nil || item.AcquiredTime.Before(sprint.FromTime) || !item.AcquiredTime.Before(sprint.ToTime) {
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 {