UBER-1052: Fix remainings (#3844)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2023-10-16 23:02:42 +07:00 committed by GitHub
parent 0b3b5725ca
commit 3c0ff4c049
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 226 additions and 39 deletions

View File

@ -34,6 +34,7 @@ const object: AttachedData<Issue> = {
subIssues: 0,
parents: [],
reportedTime: 0,
remainingTime: 0,
estimation: 0,
reports: 0,
childInfo: []
@ -96,6 +97,7 @@ async function genIssue (client: TxOperations, statuses: Ref<IssueStatus>[]): Pr
dueDate: object.dueDate,
parents: [],
reportedTime: 0,
remainingTime: 0,
estimation: object.estimation,
reports: 0,
relations: [],

View File

@ -39,8 +39,10 @@ import {
TProjectIssueTargetOptions,
TRelatedIssueTarget,
TTimeSpendReport,
TTypeEstimation,
TTypeIssuePriority,
TTypeMilestoneStatus,
TTypeRemainingTime,
TTypeReportedTime
} from './types'
import { defineViewlets } from './viewlets'
@ -430,7 +432,9 @@ export function createModel (builder: Builder): void {
TTimeSpendReport,
TTypeReportedTime,
TProjectIssueTargetOptions,
TRelatedIssueTarget
TRelatedIssueTarget,
TTypeEstimation,
TTypeRemainingTime
)
defineViewlets(builder)

View File

@ -171,6 +171,22 @@ async function fixEstimation (client: MigrationClient): Promise<void> {
}
}
async function fixRemainingTime (client: MigrationClient): Promise<void> {
while (true) {
const issues = await client.find<Issue>(DOMAIN_TASK, { remainingTime: { $exists: false } }, { limit: 1000 })
for (const issue of issues) {
await client.update(
DOMAIN_TASK,
{ _id: issue._id },
{ remainingTime: Math.max(0, issue.estimation - issue.reportedTime) }
)
}
if (issues.length === 0) {
break
}
}
}
async function moveIssues (client: MigrationClient): Promise<void> {
const docs = await client.find(DOMAIN_TRACKER, { _class: tracker.class.Issue })
if (docs.length > 0) {
@ -218,6 +234,10 @@ export const trackerOperation: MigrateOperation = {
{
state: 'estimationDayToHour',
func: fixEstimation
},
{
state: 'fixRemainingTime',
func: fixRemainingTime
}
])
},

View File

@ -43,7 +43,6 @@ export default mergeIds(trackerId, tracker, {
Unarchive: '' as IntlString,
UnarchiveConfirm: '' as IntlString,
AllProjects: '' as IntlString,
RemainingTime: '' as IntlString,
MapRelatedIssues: '' as IntlString
},
activity: {

View File

@ -138,7 +138,21 @@ export function definePresenters (builder: Builder): void {
classPresenter(
builder,
tracker.class.TypeReportedTime,
view.component.NumberPresenter,
tracker.component.TimePresenter,
tracker.component.ReportedTimeEditor
)
classPresenter(
builder,
tracker.class.TypeEstimation,
tracker.component.TimePresenter,
tracker.component.EstimationValueEditor
)
classPresenter(
builder,
tracker.class.TypeRemainingTime,
tracker.component.TimePresenter,
tracker.component.EstimationValueEditor
)
}

View File

@ -143,17 +143,31 @@ export class TRelatedIssueTarget extends TDoc implements RelatedIssueTarget {
rule!: RelatedClassRule | RelatedSpaceRule
}
/**
* @public
*/
export function TypeReportedTime (): Type<number> {
return { _class: tracker.class.TypeReportedTime, label: core.string.Number }
return { _class: tracker.class.TypeReportedTime, label: tracker.string.ReportedTime }
}
/**
* @public
*/
export function TypeRemainingTime (): Type<number> {
return { _class: tracker.class.TypeRemainingTime, label: tracker.string.RemainingTime }
}
/**
* @public
*/
export function TypeEstimation (): Type<number> {
return { _class: tracker.class.TypeEstimation, label: tracker.string.Estimation }
}
/**
* @public
*/
@Model(tracker.class.Issue, task.class.Task)
@UX(tracker.string.Issue, tracker.icon.Issue, 'TSK', 'title')
export class TIssue extends TTask implements Issue {
@ -229,18 +243,15 @@ export class TIssue extends TTask implements Issue {
@Index(IndexKind.Indexed)
milestone!: Ref<Milestone> | null
@Prop(TypeNumber(), tracker.string.Estimation)
@Prop(TypeEstimation(), tracker.string.Estimation)
estimation!: number
@Prop(TypeReportedTime(), tracker.string.ReportedTime)
@ReadOnly()
reportedTime!: number
// A fully virtual property with calculated content.
// TODO: Add proper support for this kind of fields
@Prop(TypeNumber(), tracker.string.RemainingTime)
@Prop(TypeRemainingTime(), tracker.string.RemainingTime)
@ReadOnly()
@Hidden()
remainingTime!: number
@Prop(Collection(tracker.class.TimeSpendReport), tracker.string.TimeSpendReports)
@ -283,7 +294,7 @@ export class TIssueTemplate extends TDoc implements IssueTemplate {
@Prop(TypeRef(tracker.class.Milestone), tracker.string.Milestone)
milestone!: Ref<Milestone> | null
@Prop(TypeNumber(), tracker.string.Estimation)
@Prop(TypeEstimation(), tracker.string.Estimation)
estimation!: number
@Prop(ArrOf(TypeRef(tracker.class.IssueTemplate)), tracker.string.IssueTemplate)
@ -386,3 +397,11 @@ export class TMilestone extends TDoc implements Milestone {
@UX(core.string.Number)
@Model(tracker.class.TypeReportedTime, core.class.Type)
export class TTypeReportedTime extends TType {}
@UX(core.string.Number)
@Model(tracker.class.TypeEstimation, core.class.Type)
export class TTypeEstimation extends TType {}
@UX(core.string.Number)
@Model(tracker.class.TypeRemainingTime, core.class.Type)
export class TTypeRemainingTime extends TType {}

View File

@ -24,14 +24,28 @@ import tracker from './plugin'
import tags from '@hcengineering/tags'
export const issuesOptions = (kanban: boolean): ViewOptionsModel => ({
groupBy: ['status', 'assignee', 'priority', 'component', 'milestone', 'createdBy', 'modifiedBy'],
groupBy: [
'status',
'assignee',
'priority',
'component',
'milestone',
'createdBy',
'modifiedBy',
'estimation',
'remainingTime',
'reportedTime'
],
orderBy: [
['status', SortingOrder.Ascending],
['priority', SortingOrder.Descending],
['modifiedOn', SortingOrder.Descending],
['createdOn', SortingOrder.Descending],
['dueDate', SortingOrder.Ascending],
['rank', SortingOrder.Ascending]
['rank', SortingOrder.Ascending],
['estimation', SortingOrder.Descending],
['remainingTime', SortingOrder.Descending],
['reportedTime', SortingOrder.Descending]
],
other: [
{
@ -202,6 +216,7 @@ export function defineViewlets (builder: Builder): void {
'component',
'milestone',
'estimation',
'remainingTime',
'status',
'dueDate',
'attachedTo',
@ -246,6 +261,7 @@ export function defineViewlets (builder: Builder): void {
'dueDate',
'milestone',
'estimation',
'remainingTime',
'createdBy',
'modifiedBy'
]
@ -287,6 +303,7 @@ export function defineViewlets (builder: Builder): void {
'dueDate',
'milestone',
'estimation',
'remainingTime',
'createdBy',
'modifiedBy'
]
@ -328,6 +345,7 @@ export function defineViewlets (builder: Builder): void {
'dueDate',
'component',
'estimation',
'remainingTime',
'createdBy',
'modifiedBy'
]
@ -355,7 +373,17 @@ export function defineViewlets (builder: Builder): void {
},
configOptions: {
strict: true,
hiddenKeys: ['milestone', 'estimation', 'component', 'title', 'description', 'createdBy', 'modifiedBy']
hiddenKeys: [
'milestone',
'estimation',
'remainingTime',
'reportedTime',
'component',
'title',
'description',
'createdBy',
'modifiedBy'
]
},
config: [
// { key: '', presenter: tracker.component.PriorityEditor, props: { kind: 'list', size: 'small' } },

View File

@ -140,6 +140,7 @@
dueDate: null,
parents: [],
reportedTime: 0,
remainingTime: 0,
estimation: template.estimation,
reports: 0,
relations: [{ _id: id, _class: recruit.class.Vacancy }],

View File

@ -388,6 +388,7 @@
? [{ parentId: parentIssue._id, parentTitle: parentIssue.title }, ...parentIssue.parents]
: [],
reportedTime: 0,
remainingTime: 0,
estimation: object.estimation,
reports: 0,
relations: relatedTo !== undefined ? [{ _id: relatedTo._id, _class: relatedTo._class }] : [],

View File

@ -92,6 +92,7 @@
dueDate: null,
parents,
reportedTime: 0,
remainingTime: 0,
estimation: subIssue.estimation,
reports: 0,
relations: [],

View File

@ -25,6 +25,7 @@
import SubIssuesEstimations from './SubIssuesEstimations.svelte'
import TimeSpendReportPopup from './TimeSpendReportPopup.svelte'
import TimeSpendReports from './TimeSpendReports.svelte'
import TimePresenter from './TimePresenter.svelte'
export let format: 'text' | 'password' | 'number'
export let kind: EditStyle = 'search-style'
@ -104,6 +105,10 @@
>
<EstimationStatsPresenter value={object} estimation={_value} />
</div>
<Label label={tracker.string.RemainingTime} />
<div class="ml-2 mr-4">
<TimePresenter value={object.remainingTime} />
</div>
</div>
</svelte:fragment>

View File

@ -0,0 +1,79 @@
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import type { IntlString } from '@hcengineering/platform'
import type { ButtonSize } from '@hcengineering/ui'
import { EditBox, Label, showPopup, eventToHTMLElement, Button } from '@hcengineering/ui'
import { EditBoxPopup } from '@hcengineering/view-resources'
import TimePresenter from './TimePresenter.svelte'
// export let label: IntlString
export let placeholder: IntlString
export let value: number | undefined
export let autoFocus: boolean = false
// export let maxWidth: string = '10rem'
export let onChange: (value: number | undefined) => void
export let kind: 'no-border' | 'link' | 'button' = 'no-border'
export let readonly = false
export let size: ButtonSize = 'small'
export let justify: 'left' | 'center' = 'center'
export let width: string | undefined = 'fit-content'
let shown: boolean = false
function _onchange (ev: Event) {
const value = (ev.target as HTMLInputElement).valueAsNumber
if (Number.isFinite(value)) {
onChange(value)
}
}
</script>
{#if kind === 'button' || kind === 'link'}
<Button
kind={kind === 'button' ? 'regular' : kind}
{size}
{justify}
{width}
on:click={(ev) => {
if (!shown && !readonly) {
showPopup(EditBoxPopup, { value, format: 'number' }, eventToHTMLElement(ev), (res) => {
if (Number.isFinite(res)) {
value = res
onChange(value)
}
shown = false
})
}
}}
>
<svelte:fragment slot="content">
{#if value != null}
<span class="caption-color overflow-label pointer-events-none"><TimePresenter {value} /></span>
{:else}
<span class="content-dark-color pointer-events-none"><Label label={placeholder} /></span>
{/if}
</svelte:fragment>
</Button>
{:else if readonly}
{#if value != null}
<span class="caption-color overflow-label"><TimePresenter {value} /></span>
{:else}
<span class="content-dark-color"><Label label={placeholder} /></span>
{/if}
{:else}
<EditBox {placeholder} bind:value format={'number'} {autoFocus} on:change={_onchange} />
{/if}

View File

@ -62,9 +62,13 @@
{#if kind === 'link'}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div id="ReportedTimeEditor" class="link-container {size} flex-between" on:click={showReports}>
<div
id="ReportedTimeEditor"
class="link-container antiButton link {size} flex-grow flex-between"
on:click={showReports}
>
{#if value !== undefined}
<span class="overflow-label">
<span class="flex-row-center">
<TimePresenter {value} />
{#if childTime !== 0}
/ <TimePresenter value={childTime} />
@ -78,7 +82,7 @@
</div>
</div>
{:else if value !== undefined}
<span class="overflow-label">
<span class="flex-row-center">
<TimePresenter {value} />
{#if childTime !== 0}
/ <TimePresenter value={childTime} />
@ -90,24 +94,7 @@
<style lang="scss">
.link-container {
display: flex;
align-items: center;
padding: 0 0.875rem;
width: 100%;
color: var(--theme-caption-color);
border: 1px solid transparent;
border-radius: 0.25rem;
cursor: pointer;
&.small {
height: 1.5rem;
}
&.medium {
height: 2rem;
}
&.large {
height: 2.25rem;
}
padding: 0px 0.75rem;
.add-action {
visibility: hidden;
}

View File

@ -46,7 +46,7 @@
<Label label={tracker.string.ReportedTime} />:
<span class="caption-color"><TimePresenter value={reportedTime} /></span>.
<Label label={tracker.string.TimeSpendReports} />:
<span class="caption-color"><TimePresenter value={floorFractionDigits(total / 8, 3)} /></span>
<span class="caption-color"><TimePresenter value={floorFractionDigits(total, 3)} /></span>
</span>
</svelte:fragment>
<TimeSpendReportsList {reports} />

View File

@ -142,6 +142,9 @@ import ProjectSpacePresenter from './components/projects/ProjectSpacePresenter.s
import { get } from 'svelte/store'
import TimePresenter from './components/issues/timereport/TimePresenter.svelte'
import EstimationValueEditor from './components/issues/timereport/EstimationValueEditor.svelte'
export { default as SubIssueList } from './components/issues/edit/SubIssueList.svelte'
export { default as IssueStatusIcon } from './components/issues/IssueStatusIcon.svelte'
export { default as StatusPresenter } from './components/issues/StatusPresenter.svelte'
@ -479,7 +482,9 @@ export default async (): Promise<Resources> => ({
ProjectFilterValuePresenter,
ComponentFilterValuePresenter,
EditRelatedTargets,
EditRelatedTargetsPopup
EditRelatedTargetsPopup,
TimePresenter,
EstimationValueEditor
},
completion: {
IssueQuery: async (client: Client, query: string, filter?: { in?: RelatedDocument[], nin?: RelatedDocument[] }) =>

View File

@ -261,6 +261,7 @@ export default mergeIds(trackerId, tracker, {
Estimation: '' as IntlString,
ReportedTime: '' as IntlString,
RemainingTime: '' as IntlString,
TimeSpendReport: '' as IntlString,
TimeSpendReportAdd: '' as IntlString,
TimeSpendReports: '' as IntlString,
@ -355,6 +356,8 @@ export default mergeIds(trackerId, tracker, {
MilestoneDatePresenter: '' as AnyComponent,
EditMilestone: '' as AnyComponent,
ReportedTimeEditor: '' as AnyComponent,
TimePresenter: '' as AnyComponent,
EstimationValueEditor: '' as AnyComponent,
TimeSpendReport: '' as AnyComponent,
EstimationEditor: '' as AnyComponent,
TemplateEstimationEditor: '' as AnyComponent,

View File

@ -208,6 +208,9 @@ export interface Issue extends Task {
// Estimation in man hours
estimation: number
// Remaining time in man hours
remainingTime: number
// ReportedTime time, auto updated using trigger.
reportedTime: number
// Collection of reportedTime entries, for proper time estimations per person.
@ -393,6 +396,8 @@ export default plugin(trackerId, {
TypeMilestoneStatus: '' as Ref<Class<Type<MilestoneStatus>>>,
TimeSpendReport: '' as Ref<Class<TimeSpendReport>>,
TypeReportedTime: '' as Ref<Class<Type<number>>>,
TypeEstimation: '' as Ref<Class<Type<number>>>,
TypeRemainingTime: '' as Ref<Class<Type<number>>>,
RelatedIssueTarget: '' as Ref<Class<RelatedIssueTarget>>
},
ids: {

View File

@ -77,7 +77,7 @@
}
mergedModel.groupBy = Array.from(new Set([...mergedModel.groupBy, ...customAttributes]))
mergedModel.groupBy = mergedModel.groupBy.filter((it, idx, arr) => arr.indexOf(it) === idx)
mergedModel.orderBy = mergedModel.orderBy.filter((it, idx, arr) => arr.indexOf(it) === idx)
mergedModel.orderBy = mergedModel.orderBy.filter((it, idx, arr) => arr.findIndex((q) => it[0] === q[0]) === idx)
mergedModel.other = mergedModel.other.filter((it, idx, arr) => arr.findIndex((q) => q.key === it.key) === idx)
showPopup(

View File

@ -270,6 +270,7 @@ async function doTimeReportUpdate (cud: TxCUD<TimeSpendReport>, tx: Tx, control:
]
const [currentIssue] = await control.findAll(tracker.class.Issue, { _id: parentTx.objectId }, { limit: 1 })
currentIssue.reportedTime += ccud.attributes.value
currentIssue.remainingTime = Math.max(0, currentIssue.estimation - currentIssue.reportedTime)
updateIssueParentEstimations(currentIssue, res, control, currentIssue.parents, currentIssue.parents)
return res
}
@ -294,6 +295,7 @@ async function doTimeReportUpdate (cud: TxCUD<TimeSpendReport>, tx: Tx, control:
)
currentIssue.reportedTime -= doc.value
currentIssue.reportedTime += upd.operations.value
currentIssue.remainingTime = Math.max(0, currentIssue.estimation - currentIssue.reportedTime)
}
updateIssueParentEstimations(currentIssue, res, control, currentIssue.parents, currentIssue.parents)
@ -317,6 +319,7 @@ async function doTimeReportUpdate (cud: TxCUD<TimeSpendReport>, tx: Tx, control:
]
const [currentIssue] = await control.findAll(tracker.class.Issue, { _id: parentTx.objectId }, { limit: 1 })
currentIssue.reportedTime -= doc.value
currentIssue.remainingTime = Math.max(0, currentIssue.estimation - currentIssue.reportedTime)
updateIssueParentEstimations(currentIssue, res, control, currentIssue.parents, currentIssue.parents)
return res
}
@ -377,12 +380,23 @@ async function doIssueUpdate (
if (
Object.prototype.hasOwnProperty.call(updateTx.operations, 'estimation') ||
Object.prototype.hasOwnProperty.call(updateTx.operations, 'reportedTime')
Object.prototype.hasOwnProperty.call(updateTx.operations, 'reportedTime') ||
(Object.prototype.hasOwnProperty.call(updateTx.operations, '$inc') &&
Object.prototype.hasOwnProperty.call(updateTx.operations.$inc, 'reportedTime')) ||
(Object.prototype.hasOwnProperty.call(updateTx.operations, '$dec') &&
Object.prototype.hasOwnProperty.call(updateTx.operations.$inc, 'reportedTime'))
) {
const issue = await getCurrentIssue()
issue.estimation = updateTx.operations.estimation ?? issue.estimation
issue.reportedTime = updateTx.operations.reportedTime ?? issue.reportedTime
issue.remainingTime = Math.max(0, issue.estimation - issue.reportedTime)
res.push(
control.txFactory.createTxUpdateDoc(tracker.class.Issue, issue.space, issue._id, {
remainingTime: issue.remainingTime
})
)
updateIssueParentEstimations(issue, res, control, issue.parents, issue.parents)
}