Compare commits

...
26 Commits
Author SHA1 Message Date
gisle 396b87eea1 add option to show only active items.
continuous-integration/drone/push Build is passing
2022-07-06 22:13:21 +02:00
gisle 1d59182d43 make overview item sprints even more compact by hiding stats.
continuous-integration/drone/push Build is passing
2022-07-06 22:04:56 +02:00
gisle 951be980c0 change item sprint picker to sort projects and reqs by id instead of name. 2022-07-06 22:01:44 +02:00
gisle 3a8bb9709f improve sprint item picker.
continuous-integration/drone/push Build is passing
2022-07-06 19:56:10 +02:00
gisle 015c0c18b9 add item list on sprint page.
continuous-integration/drone/push Build is passing
2022-07-05 18:36:00 +02:00
gisle ed828b0227 add item-based progress bar on requirements with only 0-required stats.
continuous-integration/drone/push Build is passing
2022-07-04 19:59:38 +02:00
gisle d4ab0a8555 add loose items and better item list handling.
continuous-integration/drone/push Build is passing
2022-07-04 19:30:34 +02:00
gisle 22885734c7 fix project progress being rounded down. 2022-07-04 18:11:51 +02:00
gisle 795d49a942 change mobile/desktop cutoff from 800 to 750px.
continuous-integration/drone/push Build is passing
2022-07-04 18:03:32 +02:00
gisle d083c9b1e8 fix sprint viewing.
continuous-integration/drone/push Build is passing
2022-07-03 19:16:10 +02:00
gisle 0e9b1a4930 tweaked header icons and such to better accomadate japanese text.
continuous-integration/drone/push Build is passing
2022-07-03 19:07:09 +02:00
gisle 6bbbf33c83 make weighted totals render better. 2022-07-03 18:57:02 +02:00
gisle ad53ec5c7e add total weighted for items. 2022-07-03 18:50:44 +02:00
gisle 1195f6ab03 add sorting to project requirements.
continuous-integration/drone/push Build is passing
2022-07-03 17:36:46 +02:00
gisle 2bd68930fe ...and make it somewhat persistent. 2022-07-03 17:28:45 +02:00
gisle 0ef4e38b8a add filtering on history item groups, though it is non-persistent.
continuous-integration/drone/push Build is passing
2022-07-03 17:24:13 +02:00
gisle 48b5c17166 change default stat behavior on item create to check all >0 req stats in requirement.
continuous-integration/drone/push Build is passing
2022-07-03 15:51:28 +02:00
gisle 2fcb6a5d1a allow checking scope/requirement stats on item edits. 2022-07-03 15:45:23 +02:00
gisle 0dfda26c09 fix project progress bars.
continuous-integration/drone/push Build is passing
2022-07-03 15:04:30 +02:00
gisle 701932546d geesed the utf8.
continuous-integration/drone/push Build is passing
2022-07-02 18:57:18 +02:00
gisle 287b18e2e1 blürgh.
continuous-integration/drone/push Build is passing
2022-07-02 16:48:11 +02:00
gisle 75dd79127e blurgh.
continuous-integration/drone/push Build is passing
2022-07-02 16:38:57 +02:00
gisle da6cdfdb0d add item times.
continuous-integration/drone/push Build is passing
2022-07-02 16:24:01 +02:00
gisle 7eb5f07299 fix item stat sorting to respect scope stat order.
continuous-integration/drone/push Build is passing
2022-07-02 14:04:46 +02:00
gisle d114f0d38c fix coarse affecting stat options in created item, change sorting of requirements. 2022-07-02 13:43:57 +02:00
gisle a3336b183c add coarse option for project requirement. 2022-07-02 13:30:12 +02:00
61 changed files with 1047 additions and 273 deletions
+1
View File
@@ -64,6 +64,7 @@ func main() {
Stats: statsService,
Repository: db.Items(),
RequirementFetcher: db.Projects(),
ProjectFetcher: db.Projects(),
}
projectsService := &projects.Service{
Auth: authService,
+1
View File
@@ -62,6 +62,7 @@ func main() {
Stats: statsService,
Repository: db.Items(),
RequirementFetcher: db.Projects(),
ProjectFetcher: db.Projects(),
}
projectsService := &projects.Service{
Auth: authService,
+4
View File
@@ -36,6 +36,7 @@ type Requirement struct {
ProjectID int `json:"projectId"`
Name string `json:"name"`
Description string `json:"description"`
IsCoarse bool `json:"isCoarse"`
Status models.Status `json:"status"`
}
@@ -49,6 +50,9 @@ func (requirement *Requirement) Update(update models.RequirementUpdate) {
if update.Status != nil && update.Status.Valid() {
requirement.Status = *update.Status
}
if update.IsCoarse != nil {
requirement.IsCoarse = *update.IsCoarse
}
}
type RequirementStat struct {
+1
View File
@@ -13,6 +13,7 @@ declare namespace App {
user?: import("$lib/models/user").default
idToken?: string
req?: any
groupOptions?: import("$lib/utils/items").GroupItemsOptions
amplifyConfig: any
}
+4 -2
View File
@@ -73,8 +73,10 @@ export default class SL3APIClient {
return this.fetch<{project: Project}>("PUT", `scopes/${scopeId}/projects/${projectId}`, input).then(r => r.project);
}
async listItems(scopeId: number, filter: ItemFilter): Promise<Item[]> {
return this.fetch<{items: Item[]}>("GET", `scopes/${scopeId}/items?filter=${encodeURIComponent(JSON.stringify(filter))}`).then(r => r.items);
async listItems(scopeId: number | "ALL", filter: ItemFilter): Promise<Item[]> {
const scopePrefix = (scopeId !== "ALL") ? `scopes/${scopeId}` : "all-scopes";
return this.fetch<{items: Item[]}>("GET", `${scopePrefix}/items?filter=${encodeURIComponent(JSON.stringify(filter))}`).then(r => r.items);
}
async createItem(scopeId: number, input: ItemInput): Promise<Item> {
@@ -1,16 +1,32 @@
<script lang="ts">
import PlusMinus from "../layout/PlusMinus.svelte"
import shortestDecimal from "$lib/utils/numbers";
import PlusMinus from "../layout/PlusMinus.svelte"
export let label: string
export let value: number
export let target: number = null
export let right: boolean = false;
export let dark: boolean = false;
let valueRounded: number;
let targetRounded: number;
let valueString: string;
let targetString: string;
$: {
valueRounded = Math.round(value * 1000) / 1000;
if (target != null) {
targetRounded = Math.round(target * 1000) / 1000;
}
}
</script>
<div class="amount">
<span class="value">{value}&times;</span>
<div class="amount" class:right>
<span class:dark class="value">{shortestDecimal(valueRounded)}&times;</span>
<span class="label">{label}</span>
{ #if target != null && target != value }
<span class="target">(<PlusMinus offset={value - target} />)</span>
{ #if target != null && targetRounded != valueRounded }
<span class="target">(<PlusMinus offset={valueRounded - targetRounded} />)</span>
{ /if }
</div>
@@ -19,9 +35,14 @@ import PlusMinus from "../layout/PlusMinus.svelte"
div.amount {
padding-right: 1ch;
&.right {
padding-right: 0;
margin-left: auto;
}
}
span.label {
span.label, span.dark {
opacity: $opacity-entry4;
}
@@ -8,24 +8,26 @@
<div class="par">(</div>
<div class="selector">
<select value={value} on:change>
<option value="last:7d">Last 7 days</option>
<option value="last:14d">Last 14 days</option>
<option value="last:30d">Last 30 days</option>
<option value="last:90d">Last 90 days</option>
<option value="last:1m">Last 1 month</option>
<option value="last:3m">Last 3 months</option>
<option value="last:6m">Last 6 months</option>
<option value="last:1y">Last 1 year</option>
<option value="this_week">This week</option>
<option value="next_week">Next week</option>
<option value="last_week">Last week</option>
<option value="this_month">This month</option>
<option value="next_month">Next month</option>
<option value="last_month">Last month</option>
<option value="all_time">All time</option>
<option selected={value === "last:7d" } value="last:7d">Last 7 days</option>
<option selected={value === "last:14d" } value="last:14d">Last 14 days</option>
<option selected={value === "last:30d" } value="last:30d">Last 30 days</option>
<option selected={value === "last:90d" } value="last:90d">Last 90 days</option>
<option selected={value === "last:1m" } value="last:1m">Last 1 month</option>
<option selected={value === "last:3m" } value="last:3m">Last 3 months</option>
<option selected={value === "last:6m" } value="last:6m">Last 6 months</option>
<option selected={value === "last:1y" } value="last:1y">Last 1 year</option>
<option selected={value === "this_week" } value="this_week">This week</option>
<option selected={value === "next_week" } value="next_week">Next week</option>
<option selected={value === "last_week" } value="last_week">Last week</option>
<option selected={value === "this_month" } value="this_month">This month</option>
<option selected={value === "next_month" } value="next_month">Next month</option>
<option selected={value === "last_month" } value="last_month">Last month</option>
<option selected={value === "all_time" } value="all_time">All time</option>
</select>
</div>
<div class="par">)</div>
<div class="par">)
<slot></slot>
</div>
</div>
<style lang="sass">
@@ -42,7 +42,7 @@
display: inline;
}
@media screen and (max-width: 800px) {
@media screen and (max-width: 749px) {
> div {
display: block;
}
@@ -0,0 +1,71 @@
<script lang="ts" context="module">
const contextKey = {ctx: "itemMultiListContext"};
interface ItemMultiListContextData {
lists: Readable<Record<string, Item[]>>,
reloadItemLists(): void,
};
const fallback: ItemMultiListContextData = {
lists: readable({}),
reloadItemLists() {},
}
export function getItemMultiListContext() {
return getContext(contextKey) as ItemMultiListContextData || fallback
}
</script>
<script lang="ts">
import { readable, writable, type Readable } from "svelte/store";
import { getContext, setContext } from "svelte";
import type Item from "$lib/models/item";
import type { ItemFilter } from "$lib/models/item";
import { getScopeContext } from "./ScopeContext.svelte";
import deepEqual from "$lib/utils/object";
import { sl3 } from "$lib/clients/sl3";
const {scope} = getScopeContext();
export let lists: Record<string, Item[]>
export let filters: Record<string, ItemFilter>
let loaded: Record<string, ItemFilter> = {...filters};
let loading: Record<string, boolean> = {};
const listsWritable = writable<Record<string, Item[]>>({...lists});
function reloadItemLists() {
loaded = {};
}
async function runLoad(key: string, filter: ItemFilter) {
loading = {...loading, [key]: true};
try {
const items = await sl3(fetch).listItems($scope.id, filter)
listsWritable.update(l => ({...l, [key.replace("Filter", "Items")]: items}));
} catch(_err) {
// TODO: Use err
}
loading = {...loading, [key]: false};
loaded = {...loaded, [key]: filter};
}
setContext<ItemMultiListContextData>(contextKey, {
lists: {subscribe: listsWritable.subscribe},
reloadItemLists,
});
$: for (const key in filters) {
if (loading[key]) {
continue;
}
if (loaded[key] == null || !deepEqual(filters[key], loaded[key])) {
runLoad(key, {...filters[key]})
}
}
</script>
<slot></slot>
@@ -7,7 +7,7 @@
};
const fallback: ProjectContextData = {
project: writable({} as Project),
project: readable({requirements: []} as Project),
reloadProject: () => Promise.resolve()
};
@@ -18,7 +18,7 @@
<script lang="ts">
import type Project from "$lib/models/project";
import { writable, type Readable } from "svelte/store";
import { readable, writable, type Readable } from "svelte/store";
import { getContext, setContext } from "svelte";
import { sl3 } from "$lib/clients/sl3";
import { getScopeContext } from "./ScopeContext.svelte";
@@ -1,10 +1,30 @@
<script lang="ts" context="module">
export interface PartOptionGroup {
id: number
label: string
subs: PartOptionGroup[]
options: PartOption[]
}
export interface PartOption {
partId: number
name: string
enabled: boolean
required: number
}
const SO = [
Status.Active, Status.Background, Status.Available, Status.Blocked,
Status.Completed, Status.Failed, Status.Dropped,
]
function compareStatus<T extends {status: Status, id: number}>(a: T, b: T): number {
if (a.status === b.status) {
return a.id - b.id;
}
return SO.indexOf(a.status) - SO.indexOf(b.status)
}
</script>
<script lang="ts">
@@ -12,6 +32,8 @@
import { sl3 } from "$lib/clients/sl3";
import { getScopeContext } from "$lib/components/contexts/ScopeContext.svelte";
import { SprintKind, type SprintInputPart } from "$lib/models/sprint";
import Status from "$lib/models/status";
import shortestDecimal from "$lib/utils/numbers";
import Checkbox from "../layout/Checkbox.svelte";
export let value: SprintInputPart[] = [];
@@ -20,50 +42,96 @@
const {scope} = getScopeContext();
let error = "";
let options: PartOption[] = [];
let groups: PartOptionGroup[] = [];
let showRequired = false;
let loading = false;
let onlyActive = true;
let loaded: SprintKind = SprintKind.Invalid
function forceReload() {
loaded = SprintKind.Invalid;
}
async function load(kind: SprintKind) {
loading = true;
try {
switch (kind) {
case SprintKind.Items: {
const items = await sl3(fetch).listItems($scope.id, {});
options = items.map(i => ({
enabled: false,
name: `${i.name}${i.acquiredTime?" ✓":""}`,
partId: i.id,
required: 0,
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);
const reqProjectIdMap = items.filter(i => i.projectId != null && i.requirementId != null).reduce((p, c) => ({
...p,
[c.requirementId]: c.projectId,
}), {} as Record<number, string>)
groups = projects.filter(p => !onlyActive || p.status === Status.Active || p.status === Status.Background).map(p => ({
id: p.id,
label: `${p.name} (${p.statusName})`,
options: [],
subs: requirements.filter(r => !onlyActive || r.status === Status.Active || r.status === Status.Background).filter(r => reqProjectIdMap[r.id] === p.id).map(r => ({
id: r.id,
label: `${r.name} (${r.statusName})`,
options: items.filter(i => i.requirementId === r.id).map(i => ({
enabled: false,
partId: i.id,
name: `${i.name} (${shortestDecimal(i.weightedRequired)})${!!i.acquiredTime?" ✓":""}`,
required: 0,
})).sort((a,b) => a.partId - b.partId),
subs: [],
}))
}))
groups.push({id: -1, label: "Loose items", subs: [], options: loose.map(i => ({
enabled: false,
partId: i.id,
name: `${i.name} (${shortestDecimal(i.weightedRequired)})${!!i.acquiredTime?" ✓":""}`,
required: 0,
})).sort((a,b) => a.partId - b.partId)})
showRequired = false;
break;
}
case SprintKind.Requirements: {
options = [];
groups = [];
throw "Requirement selection is not yet supported the UI."
}
case SprintKind.Stats: {
options = $scope.stats.map(s => ({partId: s.id, name: s.name, enabled: false, required: 0}));
groups = [{
id: -1,
label: "",
subs: [],
options: $scope.stats.map(s => ({partId: s.id, name: s.name, enabled: false, required: 0})),
}];
showRequired = true;
break;
}
case SprintKind.Scope: {
options = [];
groups = [];
showRequired = false;
break;
}
}
error = "";
for (const opt of options) {
const val = value.find(v => v.partId === opt.partId);
if (val != null) {
opt.enabled = true;
opt.required = val.required;
for (const group of groups) {
for (const opt of group.options) {
const val = value.find(v => v.partId === opt.partId);
if (val != null) {
opt.enabled = true;
opt.required = val.required;
}
}
for (const subGroup of group.subs) {
for (const opt of subGroup.options) {
const val = value.find(v => v.partId === opt.partId);
if (val != null) {
opt.enabled = true;
opt.required = val.required;
}
}
}
}
} catch(err) {
@@ -74,8 +142,13 @@
loading = false;
}
// Hax to make it reload if onlyActive changed.
$: if (onlyActive != null) {
forceReload();
}
$: if (loaded === kind && error === "") {
value = options.filter(o => o.enabled).map(o => ({partId: o.partId, required: o.required}))
value = groups.flatMap(g => [...g.subs.flatMap(g => g.options), ...g.options]).filter(o => o.enabled).map(o => ({partId: o.partId, required: o.required}))
}
$: if (browser && !loading && loaded !== kind) {
@@ -85,15 +158,34 @@
<div class="part-input">
<div class="error">{error}</div>
{#each options as option (option.partId)}
<div class="part">
<Checkbox noLabel bind:checked={option.enabled} />
<div class="name" class:disabled={!option.enabled}>{option.name}</div>
{#if showRequired && option.enabled}
<input type="number" class="required" bind:value={option.required} />
{/if}
</div>
{#each groups as group (group.id)}
<div class="label">{group.label}</div>
{#each group.subs as group (group.id)}
<div class="label label-2">{group.label}</div>
{#each group.options as option (option.partId)}
<div class="part">
<Checkbox noLabel bind:checked={option.enabled} />
<div class="name" class:disabled={!option.enabled}>{option.name}</div>
{#if showRequired && option.enabled}
<input type="number" class="required" bind:value={option.required} />
{/if}
</div>
{/each}
{/each}
{#each group.options as option (option.partId)}
<div class="part">
<Checkbox noLabel bind:checked={option.enabled} />
<div class="name" class:disabled={!option.enabled}>{option.name}</div>
{#if showRequired && option.enabled}
<input type="number" class="required" bind:value={option.required} />
{/if}
</div>
{/each}
{/each}
{#if kind === SprintKind.Items}
<hr />
<Checkbox label="Show only active items" bind:checked={onlyActive} />
{/if}
</div>
<style lang="scss">
@@ -103,7 +195,6 @@
display: flex;
margin: 0;
padding: 0;
border-top: 1px solid $color-entry1;
&:first-of-type {
border-top: none;
@@ -123,7 +214,7 @@
}
div {
padding: 0.3em 0.5ch;
padding: 0.25em 0.5ch;
&.disabled {
color: $color-entry2;
@@ -131,11 +222,37 @@
}
}
hr {
margin-top: 2em;
border: 0;
border-bottom: 1px solid $color-entry2;
}
div.label {
margin-top: 0.5em;
border-bottom: 1px solid $color-entry2;
&:empty {
margin-top: 0;
border-bottom: none;
}
&.label-2 {
margin-top: 1em;
font-size: 0.75em;
border-bottom: none;
}
&+div.label-2 {
margin-top: 0.5em;
}
}
div.part-input {
width: calc(100% - 2ch);
margin-bottom: 1em;
margin-top: 0.20em;
height: 12em;
height: 26em;
overflow-y: auto;
background: $color-entryhalf;
@@ -9,6 +9,7 @@
export let hideUnseen = false;
export let zeroDeletes = false;
export let noToggle = false;
export let initOnList: number[] = [];
let acquiredMap: Record<number, number>;
let requiredMap: Record<number, number>;
@@ -21,7 +22,7 @@
if (hideUnseen) {
for (const v of value) {
enabledMap[v.statId] = zeroDeletes ? v.required > 0 : v.required >= 0;
enabledMap[v.statId] = initOnList.includes(v.statId) || (zeroDeletes ? v.required > 0 : v.required >= 0);
requiredMap[v.statId] = v.required;
acquiredMap[v.statId] = v.acquired;
}
@@ -19,10 +19,10 @@
<style lang="sass">
div.desktop
@media screen and (max-width: 800px)
@media screen and (max-width: 749px)
display: none
div.mobile
@media screen and (min-width: 800px)
@media screen and (min-width: 750px)
display: none
</style>
@@ -0,0 +1,33 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import Icon, { type IconName } from "../layout/Icon.svelte";
export let name: IconName;
export let value: boolean;
export let inverse: boolean = false;
const dispatcher = createEventDispatcher();
function onClick() {
value = !value;
dispatcher("click");
}
</script>
<span on:click={onClick} class="toggle-icon" class:checked={value !== inverse}>
<Icon name={name} />
</span>
<style lang="sass">
@import "../../css/colors"
.toggle-icon
font-size: 0.666em
color: $color-entry1
user-select: none
-moz-user-select: none
-webkit-user-select: none
&.checked
color: $color-entry7
</style>
@@ -14,6 +14,6 @@
flex-basis: 50
&.hideMobile
@media screen and (max-width: 800px)
@media screen and (max-width: 749px)
display: none
</style>
@@ -25,7 +25,7 @@
&.fullwidth
width: 100%
@media screen and (max-width: 800px)
@media screen and (max-width: 749px)
width: 100%
flex-direction: column
@@ -40,6 +40,8 @@
import { faLightbulb } from "@fortawesome/free-solid-svg-icons/faLightbulb";
import { faChevronRight } from "@fortawesome/free-solid-svg-icons/faChevronRight";
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";
const icons = {
"clock": faClock,
@@ -62,6 +64,8 @@
"search": faSearch,
"chevron_right": faChevronRight,
"chevron_down": faChevronDown,
"trash": faTrash,
"check_slot": faCheckToSlot,
};
export type IconName = keyof typeof icons;
@@ -51,21 +51,6 @@
div.header
display: flex
h2
font-size: 1.25em
margin: 0
color: $color-entry10
span.sub
color: $color-entry5
&.big
margin-top: 1em
font-size: 2em
&.small
font-size: 1em
div.right
color: $color-entry2-transparent
margin-left: auto
@@ -1,8 +1,9 @@
<script lang="ts">
export let centered = false;
export let vertical = false;
</script>
<div class="option-row" class:centered>
<div class="option-row" class:vertical class:centered>
<slot></slot>
</div>
@@ -10,10 +11,18 @@
div.option-row {
margin-left: -0.4ch;
margin-right: -0.4ch;
display: flex;
flex-direction: row;
}
div.option-row.centered {
text-align: center;
padding-bottom: 0.5em;
}
div.vertical {
display: flex;
flex-direction: column;
font-size: 1em;
}
</style>
@@ -1,9 +1,12 @@
<script lang="ts">
import shortestDecimal from "$lib/utils/numbers";
export let offset: number;
let value: string;
let negative: boolean
$: value = (offset > 0) ? `+${offset.toFixed()}` : offset.toFixed();
$: value = (offset > 0) ? `+${shortestDecimal(offset)}` : shortestDecimal(offset);
$: negative = offset < 0
</script>
@@ -11,9 +11,10 @@
export let noProgress = false;
export let icon: IconName = null;
export let status: Status = null;
export let anchor: string = "";
</script>
<div class="sl3-entry">
<div id={anchor} class="sl3-entry">
<div class="header">
<div class="entry-icon">
{#if icon != null}
@@ -44,33 +45,20 @@
opacity: 0.6
margin-right: 0.5ch
font-size: 1em
margin-top: 0.225em
margin-top: 0.15em
&:empty
display: none
div.header
display: flex
h2
font-size: 1.25em
margin: 0
color: $color-entry10
span.sub
color: $color-entry5
&.big
margin-top: 1em
font-size: 2em
&.small
font-size: 1em
line-height: 1.25em
div.right
color: $color-entry2-transparent
margin-left: auto
margin-top: auto
margin-bottom: 0
&.noProgress
margin-top: -0.25em
@@ -43,6 +43,7 @@
div.sub-section
margin: 0.5em 0.4ch
padding: 0.33em 0.75ch
padding-bottom: 0.25em
background: $color-entry1-transparent
border-radius: 0.125em
border-bottom-right-radius: 0.5em
@@ -54,7 +55,6 @@
opacity: 0.6
margin-right: 1.5ch
font-size: 0.75em
margin-top: auto
span.event
font-size: 1.33em
@@ -64,7 +64,8 @@
display: none
&.blankEvent
margin-right: 0.5ch
margin-top: 0.35em
margin-right: 0.75ch
span.event
display: none
@@ -78,6 +79,6 @@
margin-top: auto
&.noProgress
margin-top: -0.25em
margin-top: 0em
margin-bottom: auto
</style>
@@ -8,58 +8,76 @@
import Amount from "../common/Amount.svelte";
import AmountRow from "../common/AmountRow.svelte";
import type { IconName } from "../layout/Icon.svelte";
import Icon from "../layout/Icon.svelte";
import { formatTime } from "$lib/utils/date";
export let item: Item;
export let event: "created" | "scheduled" | "acquired" | "none" = "none";
export let compact = false;
export let showAcquiredTime = false;
export let hideStats = false;
let icon: IconName
let status: Status
let eventText: string
$: {
icon = void(0);
if (event === "none" || event === "acquired") {
if (item.acquiredTime) {
icon = "check";
status = Status.Completed;
eventText = (showAcquiredTime || event === "acquired") ? formatTime(item.acquiredTime) : void(0)
}
} else if (event === "scheduled") {
if (item.acquiredTime) {
icon = "hourglass";
status = Status.Blocked;
eventText = "scheduled"
}
} else if (event === "created") {
icon = "lightbulb";
status = Status.Available;
icon = "lightbulb";
status = Status.Available;
eventText = formatTime(item.createdTime)
}
}
</script>
<SubSection small noProgress title={item.name} icon={icon} status={status} event={event}>
<SubSection small noProgress title={item.name} icon={icon} status={status} event={eventText}>
<OptionsRow slot="right">
{#if item.acquiredTime == null}
<Option open={{name: "item.acquire", item}} color="green">Acquire</Option>
<Option open={{name: "item.acquire", item}} color="green"><Icon name="check_slot" /></Option>
{/if}
{#if !compact}
<Option open={{name: "item.edit", item}}>Edit</Option>
<Option open={{name: "item.delete", item}} color="red">Delete</Option>
<Option open={{name: "item.edit", item}}><Icon name="pen" /></Option>
<Option open={{name: "item.delete", item}} color="red"><Icon name="trash" /></Option>
{/if}
</OptionsRow>
{#if !compact}
<Markdown source={item.description} />
{/if}
<AmountRow>
{#if item.acquiredTime != null}
{#each item.stats as stat (stat.id)}
{#if stat.required > 0}
<Amount label={stat.name} value={stat.acquired} target={stat.required} />
{#if !hideStats}
{#if item.acquiredTime != null}
{#each item.stats as stat (stat.id)}
{#if stat.required > 0}
<Amount label={stat.name} value={stat.acquired} target={stat.required} />
{/if}
{/each}
{#if item.weightedRequired}
<Amount dark right label="" value={item.weightedAcquired} target={item.weightedRequired} />
{/if}
{/each}
{:else}
{#each item.stats as stat (stat.id)}
{#if stat.required > 0}
<Amount label={stat.name} value={stat.required} />
{:else}
{#each item.stats as stat (stat.id)}
{#if stat.required > 0}
<Amount label={stat.name} value={stat.required} />
{/if}
{/each}
{#if item.weightedRequired}
<Amount dark right label="" value={item.weightedRequired} />
{/if}
{/each}
{/if}
{/if}
</AmountRow>
</SubSection>
@@ -5,6 +5,7 @@
import Option from "$lib/components/layout/Option.svelte";
import OptionsRow from "$lib/components/layout/OptionsRow.svelte";
import { getProjectContext } from "../contexts/ProjectContext.svelte";
import Icon from "../layout/Icon.svelte";
import RequirementEntry from "./RequirementSection.svelte";
const {project} = getProjectContext();
@@ -15,9 +16,9 @@
<Progress alwaysSmooth titlePercentageOnly thinner gray count={$project.totalPlanned} target={$project.totalRequired} />
<Markdown source={$project.description} />
<OptionsRow slot="right">
<Option open={{name: "requirement.create", project: $project}}>Add Requirement</Option>
<Option open={{name: "project.edit", project: $project}}>Edit</Option>
<Option open={{name: "project.delete", project: $project}} color="red">Delete</Option>
<Option open={{name: "requirement.create", project: $project}}><Icon name="plus" /></Option>
<Option open={{name: "project.edit", project: $project}}><Icon name="pen" /></Option>
<Option open={{name: "project.delete", project: $project}} color="red"><Icon name="trash" /></Option>
</OptionsRow>
{#each $project.requirements as requirement (requirement.id)}
<RequirementEntry requirement={requirement} />
@@ -9,6 +9,7 @@
import LabeledProgressRow from "../common/LabeledProgressRow.svelte";
import { STATUS_ICONS } from "../common/StatusIcon.svelte";
import ItemEntry from "./ItemSubSection.svelte";
import Icon from "../layout/Icon.svelte";
export let requirement: Requirement;
</script>
@@ -18,13 +19,15 @@
<Progress alwaysSmooth titlePercentageOnly thinner gray count={requirement.totalPlanned} target={requirement.totalRequired} />
<Markdown source={requirement.description} />
<OptionsRow slot="right">
<Option open={{name: "item.create", requirement}}>Add Item</Option>
<Option open={{name: "requirement.edit", requirement}}>Edit</Option>
<Option open={{name: "requirement.delete", requirement}} color="red">Delete</Option>
<Option open={{name: "item.create", requirement}}><Icon name="plus" /></Option>
<Option open={{name: "requirement.edit", requirement}}><Icon name="pen" /></Option>
<Option open={{name: "requirement.delete", requirement}} color="red"><Icon name="trash" /></Option>
</OptionsRow>
<LabeledProgressRow>
{#each requirement.stats as stat (stat.id)}
<LabeledProgress count={stat.acquired} target={stat.required} name={stat.name} />
{#if !requirement.isCoarse || stat.required > 0}
<LabeledProgress count={stat.acquired} target={stat.required} name={stat.name} />
{/if}
{/each}
</LabeledProgressRow>
{#each requirement.items as item (item.id)}
@@ -0,0 +1,20 @@
<script lang="ts">
import { getItemMultiListContext } from "../contexts/ItemMultiListContext.svelte";
import Row from "../layout/Row.svelte";
import ItemSubSection from "../project/ItemSubSection.svelte";
export let key: string;
export let title: string;
export let showAcquiredTime: boolean = false;
const {lists} = getItemMultiListContext();
</script>
{#if ($lists[key]||[]).length > 0}
<Row title={title}>
{#each $lists[key] as item (item.id)}
<ItemSubSection showAcquiredTime={showAcquiredTime} item={item} />
{/each}
</Row>
{/if}
@@ -9,6 +9,7 @@
import ItemSubSection from "../project/ItemSubSection.svelte";
export let sprint: Sprint
export let compact: boolean = false;
let timeRange: [Date, Date];
@@ -34,12 +35,18 @@
{/if}
{#if sprint.kind === SprintKind.Items}
{#each (sprint.items||[]) as item (item.id)}
{#if !item.acquiredTime}
<ItemSubSection compact item={item} />
{#if !compact || !item.acquiredTime}
<ItemSubSection compact={compact} hideStats={compact} item={item} />
{/if}
{/each}
{:else}
<AmountRow>
<Amount label="Acquired Items" value={(sprint.items||[]).length} />
</AmountRow>
{#if compact}
<AmountRow>
<Amount label="Acquired Items" value={(sprint.items||[]).length} />
</AmountRow>
{:else}
{#each (sprint.items||[]) as item (item.id)}
<ItemSubSection compact item={item} />
{/each}
{/if}
{/if}
@@ -1,25 +1,17 @@
<script lang="ts">
import { goto } from "$app/navigation";
import type Sprint from "$lib/models/sprint";
import Option from "../layout/Option.svelte";
import OptionsRow from "../layout/OptionsRow.svelte";
import SprintBody from "./SprintBody.svelte";
import { projectPrettyId, scopePrettyId } from "$lib/utils/prettyIds";
import { getScopeContext } from "../contexts/ScopeContext.svelte";
import { projectPrettyId } from "$lib/utils/prettyIds";
import Section from "../layout/Section.svelte";
export let sprint: Sprint;
const {scope} = getScopeContext();
function gotoSprint() {
goto(`/${scopePrettyId($scope)}/sprints#${projectPrettyId(sprint)}`)
}
</script>
<Section noProgress title={sprint.name}>
<Section anchor={projectPrettyId(sprint)} 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>
</OptionsRow>
@@ -10,10 +10,10 @@
import { getScopeContext } from "../contexts/ScopeContext.svelte";
export let sprint: Sprint;
const {scope} = getScopeContext();
const {scope, lastHistoryRange} = getScopeContext();
function gotoSprint() {
goto(`/${scopePrettyId($scope)}/sprints#${projectPrettyId(sprint)}`)
goto(`/${scopePrettyId($scope)}/sprints/${$lastHistoryRange}#${projectPrettyId(sprint)}`)
}
</script>
@@ -23,5 +23,5 @@
<Option open={{name: "sprint.edit", sprint}}>Edit</Option>
<Option open={{name: "sprint.delete", sprint}} color="red">Delete</Option>
</OptionsRow>
<SprintBody sprint={sprint} />
<SprintBody compact sprint={sprint} />
</SubSection>
+24 -5
View File
@@ -4,6 +4,7 @@
import Modal from "$lib/components/common/Modal.svelte";
import ModalBody from "$lib/components/common/ModalBody.svelte";
import { getItemListContext } from "$lib/components/contexts/ItemListContext.svelte";
import { getItemMultiListContext } from "$lib/components/contexts/ItemMultiListContext.svelte";
import { getModalContext } from "$lib/components/contexts/ModalContext.svelte";
import { getProjectContext } from "$lib/components/contexts/ProjectContext.svelte";
import { getScopeContext } from "$lib/components/contexts/ScopeContext.svelte";
@@ -22,6 +23,7 @@ import { getSprintListContext } from "$lib/components/contexts/SprintListContext
const {project, reloadProject} = getProjectContext();
const {reloadItemList} = getItemListContext();
const {reloadSprintList} = getSprintListContext();
const {reloadItemLists} = getItemMultiListContext();
let item: ItemInput
let itemId: number
@@ -29,6 +31,7 @@ import { getSprintListContext } from "$lib/components/contexts/SprintListContext
let openedDate: Date
let requirementName: string
let op: string
let initOnList: number[] = [];
let error: string
let loading: boolean
@@ -38,9 +41,11 @@ import { getSprintListContext } from "$lib/components/contexts/SprintListContext
case "item.create":
initCreate($scope, $currentModal.requirement)
break;
case "item.edit":
initEdit($currentModal.item)
case "item.edit": {
const reqId = $currentModal.item.requirementId;
initEdit($currentModal.item, $project.requirements.find(r => r.id === reqId), $scope)
break;
}
default:
loading = false;
@@ -52,6 +57,8 @@ import { getSprintListContext } from "$lib/components/contexts/SprintListContext
let stats = requirement?.stats.map(s => ({statId: s.id, required: 0, acquired: 0}));
if (stats == null) {
stats = scope.stats.map(s => ({statId: s.id, required: 0, acquired: 0}))
} else {
initOnList = requirement.stats.filter(s => s.required > 0).map(s => s.id);
}
item = {
@@ -69,14 +76,19 @@ import { getSprintListContext } from "$lib/components/contexts/SprintListContext
show = true;
}
function initEdit(current: Item) {
function initEdit(current: Item, requirement?: Requirement, scope?: Scope) {
const req = $project.requirements?.find(r => r.id === current.requirementId);
let ctxStats = requirement?.stats.map(s => ({statId: s.id, required: 0, acquired: 0}));
if (ctxStats == null || ctxStats.length === 0) {
ctxStats = scope.stats.map(s => ({statId: s.id, required: 0, acquired: 0}))
}
const inputStats = current.stats.map(s => ({statId: s.id, acquired: s.acquired, required: s.required}));
item = {
name: current.name,
description: current.description,
requirementId: current.requirementId,
stats: current.stats.map(s => ({statId: s.id, acquired: s.acquired, required: s.required})),
stats: ctxStats.map(s => inputStats.find(s2 => s2.statId === s.statId) || s),
acquiredTime: formatFormTime(current.acquiredTime),
scheduledDate: current.scheduledDate,
};
@@ -134,6 +146,7 @@ import { getSprintListContext } from "$lib/components/contexts/SprintListContext
await reloadItemList();
await reloadSprintList();
reloadItemLists();
closeModal();
} catch(err) {
@@ -165,7 +178,13 @@ import { getSprintListContext } from "$lib/components/contexts/SprintListContext
<input disabled name="req" type="text" value={requirementName} />
{/if}
<label for="stats">Stats</label>
<StatInput showAcquired={op === "Edit" && !!item.acquiredTime} showRequired hideUnseen={!!item.requirementId} bind:value={item.stats} />
<StatInput
zeroDeletes showRequired
initOnList={initOnList}
showAcquired={op === "Edit" && !!item.acquiredTime}
hideUnseen={!!item.requirementId}
bind:value={item.stats}
/>
</ModalBody>
</Modal>
</form>
@@ -8,6 +8,7 @@
import { getScopeContext } from "$lib/components/contexts/ScopeContext.svelte";
import StatInput from "$lib/components/controls/StatInput.svelte";
import StatusSelect from "$lib/components/controls/StatusSelect.svelte";
import Checkbox from "$lib/components/layout/Checkbox.svelte";
import type { ProjectEntry, Requirement, RequirementInput } from "$lib/models/project";
import Status from "$lib/models/status";
import { statDiff } from "$lib/utils/stat";
@@ -49,23 +50,25 @@ import { statDiff } from "$lib/utils/stat";
description: "",
stats: [],
status: Status.Available,
isCoarse: false,
}
projectId = project.id;
show = true;
}
function initEdit(prev: Requirement, project: ProjectEntry) {
function initEdit(current: Requirement, project: ProjectEntry) {
requirement = {
name: prev.name,
description: prev.description,
status: prev.status,
stats: prev.stats.map(s => ({acquired: s.acquired, required: s.required, statId: s.id})),
name: current.name,
description: current.description,
status: current.status,
stats: current.stats.map(s => ({acquired: s.acquired, required: s.required, statId: s.id})),
isCoarse: current.isCoarse,
}
oldStats = [...requirement.stats];
projectId = project.id;
requirementId = prev.id;
requirementId = current.id;
show = true;
}
@@ -118,6 +121,7 @@ import { statDiff } from "$lib/utils/stat";
<StatusSelect bind:status={requirement.status} />
<label for="stats">Stats</label>
<StatInput showRequired bind:value={requirement.stats} />
<Checkbox bind:checked={requirement.isCoarse} label="Hide 0-requirement stats." />
</ModalBody>
</Modal>
</form>
@@ -153,15 +153,15 @@ import { partsDiff } from "$lib/utils/sprint";
<label for="aggregateValue">Aggregate Goal</label>
<input name="aggregateValue" type="number" bind:value={sprint.aggregateRequired} />
{/if}
<Checkbox bind:checked={sprint.isTimed} label="Sprint is timed" />
<Checkbox bind:checked={sprint.isCoarse} label="Show only aggregate" />
<Checkbox bind:checked={sprint.isUnweighted} label="Aggregate is unweighted" />
</ModalBody>
<ModalBody>
<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} />
<Checkbox bind:checked={sprint.isTimed} label="Sprint is timed" />
<Checkbox bind:checked={sprint.isCoarse} label="Show only aggregate" />
<Checkbox bind:checked={sprint.isUnweighted} label="Aggregate is unweighted" />
</ModalBody>
</Modal>
</form>
+22
View File
@@ -1,5 +1,6 @@
import type { TimeInterval } from "./common";
import type { StatValueInput, StatProgress } from "./stat";
import type Status from "./status";
export default interface Item {
id: number
@@ -13,6 +14,25 @@ export default interface Item {
acquiredTime?: string
scheduledDate?: string
stats: StatProgress[]
weightedAcquired: number
weightedRequired: number
project: ItemProject
requirement: ItemRequirement
}
interface ItemProject {
id: number
name: string
status: Status
statusName: string
createdTime: string
}
interface ItemRequirement {
id: number
name: string
status: Status
statusName: string
}
export interface ItemInput {
@@ -36,6 +56,8 @@ export interface ItemFilter {
createdTime?: TimeInterval<Date | string>
acquiredTime?: TimeInterval<Date | string>
loose?: boolean
unAcquired?: boolean
unScheduled?: boolean
}
export interface ItemGroup {
+2
View File
@@ -37,6 +37,7 @@ export interface Requirement {
totalAcquired: number
totalRequired: number
totalPlanned: number
isCoarse: boolean
stats: StatProgressWithPlanned[]
items: Item[]
}
@@ -45,5 +46,6 @@ export interface RequirementInput {
name: string
description: string
status: Status
isCoarse: boolean
stats: StatValueInput[]
}
+23 -7
View File
@@ -2,7 +2,17 @@ import type { ItemGroup, ItemGroupReference } from "$lib/models/item";
import type Item from "$lib/models/item";
import { formatDate, formatDateTime, formatTime, formatWeekdayDate } from "./date";
export function groupItems(items: Item[], minDate?: string): ItemGroup[] {
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) {
@@ -10,16 +20,22 @@ export function groupItems(items: Item[], minDate?: string): ItemGroup[] {
const createKey = formatDate(item.createdTime)
const createSortKey = `${createKey} ${formatTime(item.createdTime)}`
if (!item.acquiredTime || item.acquiredTime >= item.createdTime) {
addItem(groups, createKey, i, createSortKey, item, "created");
if (!opts.hideCreated) {
if (!item.acquiredTime || item.acquiredTime >= item.createdTime) {
addItem(groups, createKey, i, createSortKey, item, "created");
}
}
if (item.scheduledDate) {
addItem(groups, item.scheduledDate, i, `${item.scheduledDate} 23:59:59`, item, "scheduled");
if (!opts.hideScheduled) {
if (item.scheduledDate) {
addItem(groups, item.scheduledDate, i, `${item.scheduledDate} 23:59:59`, item, "scheduled");
}
}
if (item.acquiredTime) {
addItem(groups, formatDate(item.acquiredTime), i, formatDateTime(item.acquiredTime), item, "acquired");
if (!opts.hideAcquired) {
if (item.acquiredTime) {
addItem(groups, formatDate(item.acquiredTime), i, formatDateTime(item.acquiredTime), item, "acquired");
}
}
}
+11
View File
@@ -0,0 +1,11 @@
export default function shortestDecimal(n: number) {
let s = n.toFixed(3);
while (s.endsWith("0")) {
s = s.slice(0, -1)
}
if (s.endsWith(".")) {
s = s.slice(0, -1)
}
return s;
}
+56
View File
@@ -0,0 +1,56 @@
export default function deepEqual(a: Object, b: Object) {
if (a === void(0)) {
if (b !== void(0)) {
return false;
}
return true;
}
if (a === null) {
if (b !== null) {
return false;
}
return true;
}
if (a instanceof Date) {
if (b instanceof Date) {
b = b.toISOString();
}
a = a.toISOString();
return a === b;
}
if (Array.isArray(a)) {
if (!Array.isArray(b) || b.length !== a.length) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (!deepEqual(a[i], b[i])) {
return false;
}
}
return true;
}
if (typeof(a) === "object") {
if (typeof(b) !== "object") {
return false;
}
for (const key in a) {
if (!a.hasOwnProperty(key)) {
continue
}
if (!deepEqual(a[key], b[key])) {
return false
}
}
return true;
}
return a === b;
}
+1 -1
View File
@@ -2,7 +2,7 @@ import type { TimeInterval } from "../models/common";
import { addDays, addMonths, addYears, formatDate, startOfDay, startOfMonth, startOfWeek, startOfYear } from "./date";
export default function parseInterval(s: string, date: Date): TimeInterval<Date> {
const [verb, args] = s.split(":")
const [verb, args] = s.split(";")[0].split(":")
switch (verb) {
case "":
@@ -3,11 +3,11 @@
import { sl3 } from "$lib/clients/sl3";
import parseInterval, { datesOf, intervalLabel } from "$lib/utils/timeinterval";
import { groupItems } from "$lib/utils/items";
import { groupItemOptionsQuery, groupItems, type GroupItemsOptions } from "$lib/utils/items";
import type Item from "$lib/models/item";
import type { ItemFilter, ItemGroup } from "$lib/models/item";
export const load: Load = async({ fetch, params }) => {
export const load: Load = async({ fetch, params, session }) => {
const scopeId = parseInt(params.scope.split("-")[0]);
const interval = parseInterval(params.interval, new Date());
@@ -22,22 +22,26 @@
props: {
items, filter,
intervalString: params.interval,
groups: groupItems(items, datesOf(interval)?.min),
groups: groupItems(items, datesOf(interval)?.min, session.groupOptions || {}),
},
};
}
</script>
<script lang="ts">
import { goto } from "$app/navigation";
import { session } from "$app/stores";
import { browser } from "$app/env";
import Main from "$lib/components/layout/Main.svelte";
import { getScopeContext } from "$lib/components/contexts/ScopeContext.svelte";
import DeletionModal from "$lib/modals/DeletionModal.svelte";
import ItemAcquireModal from "$lib/modals/ItemAcquireModal.svelte";
import ItemCreateModal from "$lib/modals/ItemCreateModal.svelte";
import { goto } from "$app/navigation";
import HistorySelector from "$lib/components/common/HistorySelector.svelte";
import { getScopeContext } from "$lib/components/contexts/ScopeContext.svelte";
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[]
@@ -51,13 +55,38 @@
lastHistoryRange.set(target.value);
goto(`./${target.value}`);
goto(`./${target.value}`, {replaceState: true});
}
function triggerReload() {
goto(`./${intervalString}`, {replaceState: true});
}
$: {
if (browser) {
if ($session.groupOptions == null) {
try {
$session.groupOptions = JSON.parse(localStorage.getItem("sl3.history.group_options")||"{}");
triggerReload();
} catch(_) {
$session.groupOptions = {};
}
} else {
localStorage.setItem("sl3.history.group_options", JSON.stringify($session.groupOptions));
}
}
}
</script>
<ItemListContext filter={filter} groups={groups} items={items}>
<Main big>
<HistorySelector value={intervalString} on:change={changeInterval} />
<HistorySelector value={intervalString} on:change={changeInterval} >
{#if $session.groupOptions != null}
<ToggleIcon inverse name="lightbulb" bind:value={$session.groupOptions.hideCreated} on:click={triggerReload} />
<ToggleIcon inverse name="check_slot" bind:value={$session.groupOptions.hideAcquired} on:click={triggerReload} />
<ToggleIcon inverse name="calendar" bind:value={$session.groupOptions.hideScheduled} on:click={triggerReload} />
{/if}
</HistorySelector>
<HistoryGroupList />
</Main>
@@ -1,24 +1,35 @@
<script lang="ts" context="module">
import type { Load } from "@sveltejs/kit/types/internal";
function generateItemFilters(now: Date): {scheduledFilter: ItemFilter, acquiredFilter: ItemFilter, looseFilter: ItemFilter} {
return {
scheduledFilter: {
scheduledDate: datesOf(parseInterval("next:7d", new Date)),
},
acquiredFilter: {
acquiredTime: parseInterval("today", new Date),
},
looseFilter: {
loose: true,
unAcquired: true,
unScheduled: true,
},
}
}
export const load: Load = async({params, url, fetch}) => {
const scopeId = parseInt(params.scope.split("-")[0]);
console.log(
datesOf(parseInterval("next:7d", new Date)),
parseInterval("today", new Date),
)
const {acquiredFilter, looseFilter, scheduledFilter} = generateItemFilters(new Date());
const scheduledItems = (await sl3(fetch).listItems(scopeId, {
scheduledDate: datesOf(parseInterval("next:7d", new Date)),
}))
const acquiredItems = (await sl3(fetch).listItems(scopeId, {
acquiredTime: parseInterval("today", new Date),
}))
const scheduledItems = (await sl3(fetch).listItems(scopeId, scheduledFilter));
const acquiredItems = (await sl3(fetch).listItems(scopeId, acquiredFilter));
const looseItems = (await sl3(fetch).listItems(scopeId, looseFilter));
const sprints = await sl3(fetch).listSprints(scopeId);
return {
props: {scheduledItems, acquiredItems, sprints}
props: {scheduledItems, acquiredItems, looseItems, sprints}
};
}
</script>
@@ -48,13 +59,19 @@
import { getModalContext } from "$lib/components/contexts/ModalContext.svelte";
import ProjectCreateEditModal from "$lib/modals/ProjectCreateEditModal.svelte";
import ScopeCreateUpdateModal from "$lib/modals/ScopeCreateUpdateModal.svelte";
import type { ItemFilter } from "$lib/models/item";
import ItemMultiListContext from "$lib/components/contexts/ItemMultiListContext.svelte";
import { getTimeContext } from "$lib/components/contexts/TimeContext.svelte";
import ItemListRow from "$lib/components/scope/ItemListRow.svelte";
export let acquiredItems: Item[];
export let scheduledItems: Item[];
export let looseItems: Item[];
export let sprints: Sprint[];
const {scope} = getScopeContext();
const {openModal} = getModalContext();
const {now} = getTimeContext();
function openCreateProject() {
openModal({name: "project.create"});
@@ -73,53 +90,44 @@
}
</script>
<SprintListContext sprints={sprints} intervalString="">
<Columns fullwidth>
<Column>
<Row title="Options">
<Card on:click={openCreateProject} pointerCursor><CardHeader>Create Project</CardHeader></Card>
<Card on:click={openPostItem} pointerCursor><CardHeader>Post Item</CardHeader></Card>
<Card on:click={openEditScope} pointerCursor><CardHeader>Edit Scope</CardHeader></Card>
<Card on:click={openDeleteScope} pointerCursor><CardHeader>Delete Scope</CardHeader></Card>
</Row>
{#if scheduledItems.length > 0}
<Row title="Schedule">
{#each scheduledItems as item (item.id)}
<ItemSubSection item={item} />
<ItemMultiListContext lists={{acquiredItems, scheduledItems, looseItems}} filters={generateItemFilters($now)}>
<SprintListContext sprints={sprints} intervalString="">
<Columns fullwidth>
<Column>
<Row title="Options">
<Card on:click={openCreateProject} pointerCursor><CardHeader>Create Project</CardHeader></Card>
<Card on:click={openPostItem} pointerCursor><CardHeader>Create Item</CardHeader></Card>
<Card on:click={openEditScope} pointerCursor><CardHeader>Edit Scope</CardHeader></Card>
<Card on:click={openDeleteScope} pointerCursor><CardHeader>Delete Scope</CardHeader></Card>
</Row>
<ItemListRow title="Scheduled" key="scheduleItems" />
<ItemListRow title="Today" key="acquiredItems" showAcquiredTime />
<ItemListRow title="Loose" key="looseItems" />
</Column>
<Column>
<Row title="Sprints">
<OptionsRow slot="right">
<Option open={{name: "sprint.create"}}>Create</Option>
</OptionsRow>
<SprintList sub />
</Row>
<Row title="Stats">
<OptionsRow slot="right">
<Option open={{name: "stat.create"}}>Create</Option>
</OptionsRow>
{#each $scope.stats as stat (stat.id)}
<StatSubSection stat={stat} />
{/each}
</Row>
{/if}
{#if acquiredItems.length > 0}
<Row title="Today">
{#each acquiredItems as item (item.id)}
<ItemSubSection item={item} />
{/each}
</Row>
{/if}
</Column>
<Column>
<Row title="Sprints">
<OptionsRow slot="right">
<Option open={{name: "sprint.create"}}>Create</Option>
</OptionsRow>
<SprintList sub />
</Row>
<Row title="Stats">
<OptionsRow slot="right">
<Option open={{name: "stat.create"}}>Create</Option>
</OptionsRow>
{#each $scope.stats as stat (stat.id)}
<StatSubSection stat={stat} />
{/each}
</Row>
</Column>
</Columns>
<ItemCreateModal />
<ItemAcquireModal />
<StatCreateEditModal />
<DeletionModal />
<SprintCreateUpdateModal />
<ProjectCreateEditModal />
<ScopeCreateUpdateModal />
</SprintListContext>
</Column>
</Columns>
<ItemCreateModal />
<ItemAcquireModal />
<StatCreateEditModal />
<DeletionModal />
<SprintCreateUpdateModal />
<ProjectCreateEditModal />
<ScopeCreateUpdateModal />
</SprintListContext>
</ItemMultiListContext>
+2
View File
@@ -25,4 +25,6 @@ type ItemFilter struct {
RequirementIDs []int `json:"requirementIds,omitempty"`
StatIDs []int `json:"statIds,omitempty"`
Loose bool `json:"loose,omitempty"`
UnScheduled bool `json:"unScheduled,omitempty"`
UnAcquired bool `json:"unAcquired,omitempty"`
}
+1
View File
@@ -11,4 +11,5 @@ type RequirementUpdate struct {
Name *string `json:"name"`
Description *string `json:"description"`
Status *Status `json:"status"`
IsCoarse *bool `json:"isCoarse"`
}
+29
View File
@@ -14,6 +14,35 @@ func (s Status) Name() string {
return StatusLabels[s]
}
func (s Status) Less(s2 Status) bool {
if s == Active && s2 != Active {
return true
}
if s != Active && s2 == Active {
return false
}
if s == Background && s2 != Background {
return true
}
if s != Background && s2 == Background {
return false
}
if s == Available && s2 != Available {
return true
}
if s != Available && s2 == Available {
return false
}
if s == Blocked && s2 != Blocked {
return true
}
if s != Blocked && s2 == Blocked {
return false
}
return s < s2
}
const (
Blocked Status = iota
Available
+2 -6
View File
@@ -1,7 +1,6 @@
package mysql
import (
"context"
"database/sql"
"encoding/json"
"fmt"
@@ -75,11 +74,8 @@ func Connect(host string, port int, username, password, database string) (*Datab
return nil, err
}
q, err := mysqlcore.Prepare(context.Background(), db)
if err != nil {
return nil, err
}
q := mysqlcore.New(db)
return &Database{db: db, q: q}, nil
}
+6 -2
View File
@@ -67,13 +67,17 @@ func (r *itemRepository) Fetch(ctx context.Context, filter models.ItemFilter) ([
squirrel.Lt{"i.created_time": filter.CreatedTime.Max},
})
}
if filter.AcquiredTime != nil {
if filter.UnAcquired {
sq = sq.Where("i.acquired_time IS NULL")
} else if filter.AcquiredTime != nil {
dateOr = append(dateOr, squirrel.And{
squirrel.GtOrEq{"i.acquired_time": filter.AcquiredTime.Min},
squirrel.Lt{"i.acquired_time": filter.AcquiredTime.Max},
})
}
if filter.ScheduledDate != nil {
if filter.UnScheduled {
sq = sq.Where("i.scheduled_date IS NULL")
} else if filter.ScheduledDate != nil {
dateOr = append(dateOr, squirrel.And{
squirrel.GtOrEq{"i.scheduled_date": filter.ScheduledDate.Min.AsTime()},
squirrel.Lt{"i.scheduled_date": filter.ScheduledDate.Max.AsTime()},
+1
View File
@@ -47,6 +47,7 @@ type ProjectRequirement struct {
Name string
Status int
Description string
IsCoarse bool
}
type ProjectRequirementStat struct {
+10 -4
View File
@@ -156,8 +156,8 @@ 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)
VALUES (?, ?, ?, ?, ?)
INSERT INTO project_requirement (scope_id, project_id, name, status, description, is_coarse)
VALUES (?, ?, ?, ?, ?, ?)
`
type InsertProjectRequirementParams struct {
@@ -166,6 +166,7 @@ type InsertProjectRequirementParams struct {
Name string
Status int
Description string
IsCoarse bool
}
func (q *Queries) InsertProjectRequirement(ctx context.Context, arg InsertProjectRequirementParams) (sql.Result, error) {
@@ -175,11 +176,12 @@ func (q *Queries) InsertProjectRequirement(ctx context.Context, arg InsertProjec
arg.Name,
arg.Status,
arg.Description,
arg.IsCoarse,
)
}
const listProjectRequirements = `-- name: ListProjectRequirements :many
SELECT id, scope_id, project_id, name, status, description FROM project_requirement WHERE project_id = ?
SELECT id, scope_id, project_id, name, status, description, is_coarse FROM project_requirement WHERE project_id = ?
`
func (q *Queries) ListProjectRequirements(ctx context.Context, projectID int) ([]ProjectRequirement, error) {
@@ -198,6 +200,7 @@ func (q *Queries) ListProjectRequirements(ctx context.Context, projectID int) ([
&i.Name,
&i.Status,
&i.Description,
&i.IsCoarse,
); err != nil {
return nil, err
}
@@ -326,7 +329,8 @@ const updateProjectRequirement = `-- name: UpdateProjectRequirement :exec
UPDATE project_requirement
SET name = ?,
status = ?,
description = ?
description = ?,
is_coarse = ?
WHERE id = ? AND scope_id = ?
`
@@ -334,6 +338,7 @@ type UpdateProjectRequirementParams struct {
Name string
Status int
Description string
IsCoarse bool
ID int
ScopeID int
}
@@ -343,6 +348,7 @@ func (q *Queries) UpdateProjectRequirement(ctx context.Context, arg UpdateProjec
arg.Name,
arg.Status,
arg.Description,
arg.IsCoarse,
arg.ID,
arg.ScopeID,
)
+60 -1
View File
@@ -35,6 +35,61 @@ func (r *projectRepository) Find(ctx context.Context, scopeID, projectID int) (*
}, nil
}
func (r *projectRepository) FetchProjects(ctx context.Context, scopeID int, ids ...int) ([]entities.Project, error) {
if len(ids) == 0 {
return []entities.Project{}, nil
} else if len(ids) == 1 && scopeID != -1 {
project, err := r.Find(ctx, scopeID, ids[0])
if err != nil {
return nil, err
}
return []entities.Project{*project}, nil
}
sq := squirrel.Select("id,scope_id,owner_id,name,status,description,created_time").
From("project").
Where(squirrel.Eq{"id": ids})
if scopeID != -1 {
sq = sq.Where(squirrel.Eq{"scope_id": scopeID})
}
query, args, err := sq.ToSql()
if err != nil {
return nil, err
}
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
projects := make([]entities.Project, 0, len(ids))
for rows.Next() {
project := entities.Project{}
if err := rows.Scan(
&project.ID,
&project.ScopeID,
&project.OwnerID,
&project.Name,
&project.Status,
&project.Description,
&project.CreatedTime,
); err != nil {
return nil, err
}
projects = append(projects, project)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return projects, nil
}
func (r *projectRepository) List(ctx context.Context, scopeID int) ([]entities.Project, error) {
rows, err := r.q.ListProjects(ctx, scopeID)
if err != nil {
@@ -139,7 +194,7 @@ func (r *projectRepository) FetchRequirements(ctx context.Context, scopeID int,
return []entities.Requirement{}, []entities.RequirementStat{}, nil
}
query, args, err := squirrel.Select("id, scope_id, project_id, name, status, description").
query, args, err := squirrel.Select("id, scope_id, project_id, name, status, description, is_coarse").
From("project_requirement").
Where(squirrel.Eq{"scope_id": scopeID}).
Where(squirrel.Eq{"id": requirementIDs}).
@@ -162,6 +217,7 @@ func (r *projectRepository) FetchRequirements(ctx context.Context, scopeID int,
&requirement.Name,
&requirement.Status,
&requirement.Description,
&requirement.IsCoarse,
); err != nil {
return nil, nil, err
}
@@ -217,6 +273,7 @@ func (r *projectRepository) ListRequirements(ctx context.Context, projectID int)
ProjectID: row.ProjectID,
Name: row.Name,
Description: row.Description,
IsCoarse: row.IsCoarse,
Status: models.Status(row.Status),
})
}
@@ -240,6 +297,7 @@ func (r *projectRepository) CreateRequirement(ctx context.Context, requirement e
Name: requirement.Name,
Status: int(requirement.Status),
Description: requirement.Description,
IsCoarse: requirement.IsCoarse,
})
if err != nil {
return nil, err
@@ -261,6 +319,7 @@ func (r *projectRepository) UpdateRequirement(ctx context.Context, requirement e
Name: requirement.Name,
Status: int(requirement.Status),
Description: requirement.Description,
IsCoarse: requirement.IsCoarse,
ID: requirement.ID,
ScopeID: requirement.ScopeID,
})
+4 -3
View File
@@ -23,14 +23,15 @@ 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)
VALUES (?, ?, ?, ?, ?);
INSERT INTO project_requirement (scope_id, project_id, name, status, description, is_coarse)
VALUES (?, ?, ?, ?, ?, ?);
-- name: UpdateProjectRequirement :exec
UPDATE project_requirement
SET name = ?,
status = ?,
description = ?
description = ?,
is_coarse = ?
WHERE id = ? AND scope_id = ?;
-- name: DeleteProjectRequirement :exec
@@ -0,0 +1,9 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE project_requirement ADD COLUMN is_coarse BOOLEAN NOT NULL DEFAULT 0;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE project_requirement DROP COLUMN IF EXISTS is_coarse;
-- +goose StatementEnd
@@ -0,0 +1,9 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE item CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
SELECT 'down SQL query';
-- +goose StatementEnd
@@ -0,0 +1,9 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE project CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
SELECT 'down SQL query';
-- +goose StatementEnd
@@ -0,0 +1,9 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE project_requirement CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
SELECT 'down SQL query';
-- +goose StatementEnd
@@ -0,0 +1,9 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE scope CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
SELECT 'down SQL query';
-- +goose StatementEnd
@@ -0,0 +1,9 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE sprint CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
SELECT 'down SQL query';
-- +goose StatementEnd
@@ -0,0 +1,9 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE stat CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
SELECT 'down SQL query';
-- +goose StatementEnd
+4
View File
@@ -16,6 +16,10 @@ type Repository interface {
UpdateStat(ctx context.Context, progress entities.ItemStat) error
}
type ProjectFetcher interface {
FetchProjects(ctx context.Context, scopeID int, ids ...int) ([]entities.Project, error)
}
type RequirementFetcher interface {
FetchRequirements(ctx context.Context, scopeID int, requirementIDs ...int) ([]entities.Requirement, []entities.RequirementStat, error)
}
+74 -11
View File
@@ -2,15 +2,34 @@ package items
import (
"git.aiterp.net/stufflog3/stufflog3/entities"
"git.aiterp.net/stufflog3/stufflog3/models"
"git.aiterp.net/stufflog3/stufflog3/usecases/scopes"
"sort"
"time"
)
type Result struct {
entities.Item
Requirement *entities.Requirement `json:"requirement,omitempty"`
Stats []ResultStat `json:"stats"`
Requirement *ResultRequirement `json:"requirement,omitempty"`
Project *ResultProject `json:"project,omitempty"`
Stats []ResultStat `json:"stats"`
WeightedAcquired float64 `json:"weightedAcquired"`
WeightedRequired float64 `json:"weightedRequired"`
}
type ResultRequirement struct {
ID int `json:"id"`
Name string `json:"name"`
Status models.Status `json:"status"`
StatusName string `json:"statusName"`
}
type ResultProject struct {
ID int `json:"id"`
CreatedTime time.Time `json:"createdTime"`
Name string `json:"name"`
Status models.Status `json:"status"`
StatusName string `json:"statusName"`
}
func (r *Result) Stat(statID int) *ResultStat {
@@ -53,13 +72,23 @@ func (r *Result) AddStat(scope scopes.Result, stat entities.ItemStat) {
Acquired: stat.Acquired,
Required: stat.Required,
})
r.SortStats()
r.SortStats(scope)
r.RecalculateWeighted()
}
func (r *Result) SortStats() {
sort.Slice(r.Stats, func(i, j int) bool {
return r.Stats[i].Name < r.Stats[j].Name
})
func (r *Result) SortStats(scope scopes.Result) {
sortedStats := make([]ResultStat, 0, len(r.Stats))
for _, scopeStat := range scope.Stats {
for _, itemStat := range r.Stats {
if itemStat.ID == scopeStat.ID {
sortedStats = append(sortedStats, itemStat)
break
}
}
}
r.Stats = sortedStats
}
func (r *Result) RemoveStat(id int) {
@@ -68,6 +97,18 @@ func (r *Result) RemoveStat(id int) {
r.Stats = append(r.Stats[:i], r.Stats[i+1:]...)
}
}
r.RecalculateWeighted()
}
func (r *Result) RecalculateWeighted() {
r.WeightedAcquired = 0
r.WeightedRequired = 0
for _, stat := range r.Stats {
r.WeightedAcquired += float64(stat.Acquired) * stat.Weight
r.WeightedRequired += float64(stat.Required) * stat.Weight
}
}
type ResultStat struct {
@@ -78,7 +119,7 @@ type ResultStat struct {
Required int `json:"required"`
}
func generateResult(item entities.Item, scope scopes.Result, progresses []entities.ItemStat, requirements []entities.Requirement) Result {
func generateResult(item entities.Item, scope scopes.Result, progresses []entities.ItemStat, requirements []entities.Requirement, projects []entities.Project) Result {
res := Result{
Item: item,
Stats: make([]ResultStat, 0, 8),
@@ -87,7 +128,28 @@ func generateResult(item entities.Item, scope scopes.Result, progresses []entiti
if item.RequirementID != nil {
for _, req := range requirements {
if req.ID == *item.RequirementID {
res.Requirement = &req
res.Requirement = &ResultRequirement{
ID: req.ID,
Name: req.Name,
Status: req.Status,
StatusName: scope.StatusName(req.Status),
}
break
}
}
}
if item.ProjectID != nil {
for _, project := range projects {
if project.ID == *item.ProjectID {
res.Project = &ResultProject{
ID: project.ID,
CreatedTime: project.CreatedTime,
Name: project.Name,
Status: project.Status,
StatusName: scope.StatusName(project.Status),
}
break
}
}
}
@@ -112,7 +174,8 @@ func generateResult(item entities.Item, scope scopes.Result, progresses []entiti
}
}
res.SortStats()
res.SortStats(scope)
res.RecalculateWeighted()
return res
}
+47 -5
View File
@@ -17,6 +17,7 @@ type Service struct {
Scopes *scopes.Service
Stats *stats.Service
Repository Repository
ProjectFetcher ProjectFetcher
RequirementFetcher RequirementFetcher
}
@@ -44,7 +45,15 @@ func (s *Service) Find(ctx context.Context, id int) (*Result, error) {
}
}
result := generateResult(*item, sc.Scope, itemStats, requirements)
var projects []entities.Project
if item.ProjectID != nil {
projects, err = s.ProjectFetcher.FetchProjects(ctx, item.ScopeID, *item.ProjectID)
if err != nil {
return nil, err
}
}
result := generateResult(*item, sc.Scope, itemStats, requirements, projects)
return &result, nil
}
@@ -88,10 +97,14 @@ func (s *Service) ListScoped(ctx context.Context, filter models.ItemFilter) ([]R
}
reqIds := genutils.Set[int]{}
projIds := genutils.Set[int]{}
for _, item := range items {
if item.RequirementID != nil {
reqIds.Add(*item.RequirementID)
}
if item.ProjectID != nil {
projIds.Add(*item.ProjectID)
}
}
var requirements []entities.Requirement
if reqIds.Len() > 0 {
@@ -100,10 +113,17 @@ func (s *Service) ListScoped(ctx context.Context, filter models.ItemFilter) ([]R
return nil, err
}
}
var projects []entities.Project
if projIds.Len() > 0 {
projects, err = s.ProjectFetcher.FetchProjects(ctx, sc.ID, projIds.Values()...)
if err != nil {
return nil, err
}
}
res := make([]Result, 0, len(items))
for _, item := range items {
res = append(res, generateResult(item, sc.Scope, progresses, requirements))
res = append(res, generateResult(item, sc.Scope, progresses, requirements, projects))
}
return res, nil
@@ -122,6 +142,7 @@ func (s *Service) Create(ctx context.Context, item entities.Item, stats []entiti
}
}
var requirements []entities.Requirement
var projects []entities.Project
if item.RequirementID != nil {
reqs, _, err := s.RequirementFetcher.FetchRequirements(ctx, scope.ID, *item.RequirementID)
if err != nil {
@@ -133,6 +154,8 @@ func (s *Service) Create(ctx context.Context, item entities.Item, stats []entiti
item.ProjectID = &reqs[0].ProjectID
requirements = reqs
projects, _ = s.ProjectFetcher.FetchProjects(ctx, scope.ID, *item.ProjectID)
}
item.ScopeID = scope.ID
item.OwnerID = user.ID
@@ -161,7 +184,7 @@ func (s *Service) Create(ctx context.Context, item entities.Item, stats []entiti
goodStats = append(goodStats, stat)
}
result := generateResult(*newItem, scope, goodStats, requirements)
result := generateResult(*newItem, scope, goodStats, requirements, projects)
return &result, nil
}
@@ -197,7 +220,26 @@ func (s *Service) Update(ctx context.Context, id int, update models.ItemUpdate,
}
item.ProjectID = genutils.Ptr(req[0].ProjectID)
item.Requirement = &req[0]
item.Requirement = &ResultRequirement{
ID: req[0].ID,
Name: req[0].Name,
Status: req[0].Status,
StatusName: scope.StatusName(req[0].Status),
}
projects, err := s.ProjectFetcher.FetchProjects(ctx, scope.ID, *item.ProjectID)
if err != nil {
return nil, err
}
if len(projects) == 1 {
item.Project = &ResultProject{
ID: projects[0].ID,
CreatedTime: projects[0].CreatedTime,
Name: projects[0].Name,
Status: projects[0].Status,
StatusName: scope.StatusName(projects[0].Status),
}
}
} else {
item.ProjectID = nil
}
@@ -211,7 +253,7 @@ func (s *Service) Update(ctx context.Context, id int, update models.ItemUpdate,
for _, stat := range stats {
stat.ItemID = item.ID
err = s.Repository.UpdateStat(ctx, stat)
if err == nil {
item.AddStat(scope, stat)
+1
View File
@@ -9,6 +9,7 @@ import (
type Repository interface {
Find(ctx context.Context, scopeID, projectID int) (*entities.Project, error)
List(ctx context.Context, scopeID int) ([]entities.Project, error)
FetchProjects(ctx context.Context, scopeID int, ids ...int) ([]entities.Project, error)
Insert(ctx context.Context, project entities.Project) (*entities.Project, error)
Update(ctx context.Context, project entities.Project, update models.ProjectUpdate) error
Delete(ctx context.Context, project entities.Project) error
+65 -15
View File
@@ -6,6 +6,7 @@ import (
"git.aiterp.net/stufflog3/stufflog3/usecases/items"
"git.aiterp.net/stufflog3/stufflog3/usecases/scopes"
"math"
"sort"
"time"
)
@@ -35,9 +36,9 @@ type Result struct {
entities.Project
OwnerName string `json:"ownerName"`
StatusName string `json:"statusName"`
TotalAcquired int `json:"totalAcquired"`
TotalRequired int `json:"totalRequired"`
TotalPlanned int `json:"totalPlanned"`
TotalAcquired float64 `json:"totalAcquired"`
TotalRequired float64 `json:"totalRequired"`
TotalPlanned float64 `json:"totalPlanned"`
Requirements []RequirementResult `json:"requirements"`
}
@@ -57,9 +58,10 @@ type RequirementResult struct {
Description string `json:"description"`
Status models.Status `json:"status"`
StatusName string `json:"statusName"`
TotalAcquired int `json:"totalAcquired"`
TotalRequired int `json:"totalRequired"`
TotalPlanned int `json:"totalPlanned"`
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"`
@@ -140,6 +142,16 @@ func generateResult(
res.Requirements = append(res.Requirements, resReq)
}
sort.Slice(res.Requirements, func(i, j int) bool {
ri := res.Requirements[i]
rj := res.Requirements[j]
if ri.Status != rj.Status {
return ri.Status.Less(rj.Status)
}
return ri.ID < rj.ID
})
return &res
}
@@ -150,6 +162,7 @@ func generateRequirementResult(req entities.Requirement, scope scopes.Result, re
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),
@@ -191,9 +204,27 @@ func generateRequirementResult(req entities.Requirement, scope scopes.Result, re
}
}
item.Project = nil
item.Requirement = nil
resReq.Items = append(resReq.Items, item)
}
// Sort items so that they're in order of created, with acquired items being in order of acquired below.
sort.Slice(resReq.Items, func(i, j int) bool {
ii := resReq.Items[i]
ij := resReq.Items[j]
if ii.AcquiredTime != nil && ij.AcquiredTime != nil {
return ii.AcquiredTime.Before(*ij.AcquiredTime)
} else if ii.AcquiredTime != nil && ij.AcquiredTime == nil {
return false
} else if ii.AcquiredTime == nil && ij.AcquiredTime != nil {
return true
}
return ii.CreatedTime.Before(ij.CreatedTime)
})
for _, stat := range scope.Stats {
if rs := resStats[stat.ID]; rs != nil && rs.Required > 0 {
resReq.Stats = append(resReq.Stats, *rs)
@@ -205,19 +236,38 @@ func generateRequirementResult(req entities.Requirement, scope scopes.Result, re
}
}
totalAcquired := 0.0
totalRequired := 0.0
totalPlanned := 0.0
hasRequiredStats := false
for _, stat := range resReq.Stats {
if stat.Required > 0 {
totalAcquired += float64(stat.Acquired) * stat.Weight
totalRequired += float64(stat.Required) * stat.Weight
totalPlanned += float64(stat.Planned) * stat.Weight
hasRequiredStats = true
break
}
}
resReq.TotalRequired += int(totalRequired)
resReq.TotalAcquired += int(math.Min(totalAcquired, totalRequired))
resReq.TotalPlanned += int(math.Min(totalPlanned, totalRequired))
if hasRequiredStats {
totalAcquired := 0.0
totalRequired := 0.0
totalPlanned := 0.0
for _, stat := range resReq.Stats {
if stat.Required > 0 {
totalAcquired += math.Min(float64(stat.Acquired), float64(stat.Required)) * stat.Weight
totalRequired += float64(stat.Required) * stat.Weight
totalPlanned += math.Min(float64(stat.Planned), float64(stat.Required)) * stat.Weight
}
}
resReq.TotalRequired += totalRequired
resReq.TotalAcquired += math.Min(totalAcquired, totalRequired)
resReq.TotalPlanned += math.Min(totalPlanned, totalRequired)
} else {
for _, item := range resReq.Items {
if item.AcquiredTime != nil {
resReq.TotalAcquired += item.WeightedRequired
}
resReq.TotalRequired += item.WeightedRequired
}
resReq.TotalPlanned = resReq.TotalRequired
}
return resReq
}