Skip to content

Commit

Permalink
Add function groupByIntervalAndWaitStatus()
Browse files Browse the repository at this point in the history
Added function that “groups” IssueActivity arrays by interval
(properties ‘start’ and ‘end’) and wait status (‘isWaiting’). For some
applications like Gantt charts this is the more natural way of
showing activity.
  • Loading branch information
fschopp committed Jul 31, 2019
1 parent 1068b0c commit 02f329b
Show file tree
Hide file tree
Showing 6 changed files with 260 additions and 20 deletions.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"devDependencies": {
"@babel/core": "^7.5.5",
"@babel/preset-env": "^7.5.5",
"@types/jest": "^24.0.15",
"@types/jest": "^24.0.16",
"@types/jsdom": "^12.2.4",
"babel-plugin-unassert": "^3.0.1",
"jest": "^24.8.0",
Expand All @@ -63,7 +63,7 @@
"rollup-plugin-terser": "^5.1.1",
"ts-jest": "^24.0.2",
"tslint": "^5.18.0",
"typedoc": "^0.14.2",
"typedoc": "^0.15.0",
"typescript": "^3.5.3"
},
"jest": {
Expand Down
38 changes: 26 additions & 12 deletions src/main/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,12 @@ export interface IssueActivity {
assignee: string;

/**
* The start timestamp of the activity.
* The start timestamp of the activity (including).
*/
start: number;

/**
* The end timestamp of the activity.
* The end timestamp of the activity (excluding).
*
* If the issue activity has no scheduled end, this property is `Number.MAX_SAFE_INTEGER`.
*/
Expand All @@ -66,12 +66,27 @@ export interface IssueActivity {
/**
* Whether this activity represents wait time.
*
* If true, this activity does not prevent other issues to be assigned concurrently to the same assignee. However,
* any dependent issue can only start once all work on this issue has been finished and the wait time has elapsed.
* If true, this activity does not prevent other issues from being assigned concurrently to the same assignee.
* However, any dependent issue can only start once all work on this issue has finished and all wait time has elapsed.
*/
isWaiting: boolean;
}

/**
* An issue activity with one or more assignees.
*
* See {@link IssueActivity} and {@link groupByIntervalAndWaitStatus}().
*/
export interface MultiAssigneeIssueActivity extends Omit<IssueActivity, 'assignee'> {
/**
* Assignees for this issue activity, during the time interval from {@link IssueActivity.start} to
* {@link IssueActivity.end}.
*
* The same guarantees hold as for {@link IssueActivity.assignee}. Additionally, the array is non-empty.
*/
assignees: string[];
}

/**
* Callback for progress updates.
*
Expand Down Expand Up @@ -220,11 +235,6 @@ export type Schedule = ScheduledIssue[];
* Scheduled activities for an issue with remaining effort or wait time.
*
* The same guarantees hold as for {@link YouTrackIssue.issueActivities}.
*
* The activities are sorted by {@link IssueActivity.end}. If there are several issue activities with the same end
* timestamp but different assignees, the order among them is undefined (though deterministic). Issue activities with
* the same assignee are guaranteed to not overlap. Moreover, if `a` and `b` are two consecutive activities with
* `a.end === b.start`, then they differ in {@link IssueActivity.isWaiting}.
*/
export type ScheduledIssue = IssueActivity[];

Expand Down Expand Up @@ -457,12 +467,16 @@ export interface YouTrackIssue extends Required<SchedulableIssue> {
/**
* Issue activities; that is, periods in which the issue is active/scheduled.
*
* The same guarantees hold as for {@link ScheduledIssue}.
*
* The activities are sorted by {@link IssueActivity.end}. If there are several issue activities with the same end
* timestamp but different assignees, the order among them is undefined (though deterministic). Issue activities with
* the same assignee are guaranteed to not overlap. Moreover, if `a` and `b` are two consecutive activities with
* the same assignee are guaranteed to not overlap (assuming each activity is a half-closed interval that excludes its
* end timestamp). Moreover, if `a` and `b` are two activities with `a.assignee === b.assignee` and
* `a.end === b.start`, then they differ in {@link IssueActivity.isWaiting}.
*
* It is guaranteed that activities representing wait time (where {@link IssueActivity.isWaiting} is true) do not
* overlap with any other activities.
*
* Note that {@link groupByIntervalAndWaitStatus}() can be used if activities need to be grouped by interval.
*/
issueActivities: IssueActivity[];

Expand Down
147 changes: 147 additions & 0 deletions src/main/scheduling.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import * as ProjectPlanningJs from '@fschopp/project-planning-js';
import { strict as assert } from 'assert';
import {
Contributor,
Failure,
IssueActivity,
MultiAssigneeIssueActivity,
ProjectPlan,
SchedulableIssue,
Schedule,
Expand Down Expand Up @@ -175,6 +177,83 @@ export function appendSchedule(projectPlan: ProjectPlan, schedule: Schedule, div
return newProjectPlan;
}

/**
* Returns the given issue activities grouped by interval and wait status.
*
* Every point in time is represented by at most two {@link MultiAssigneeIssueActivity} elements in the returned array;
* one for all assignees that are not waiting, and one for all that are. Each {@link MultiAssigneeIssueActivity} element
* has maximum length. In other words, no two {@link MultiAssigneeIssueActivity} elements could be merged into one.
*
* This function can be thought to work as follows: It first separates the activities according to wait status. Then,
* for both groups: It projects all start and end timestamps of the given activities onto a single timeline. It then
* iterates over these timestamps, and whenever the set of assignees changes:
* 1. The current {@link MultiAssigneeIssueActivity} (if any) is ended.
* 2. A new {@link MultiAssigneeIssueActivity} is added if the new set of assignees is non-empty.
* As last step, the non-waiting and waiting activities are merged (and sorted).
*
* Note that all functions in this package that return arrays of {@link IssueActivity} guarantee a “normalized” form.
* See, for instance, {@link YouTrackIssue.issueActivities}. With these extra guarantees, no activities in the returned
* array ever overlap.
*
* @param activities The issue activities. The array does not have to be “normalized.”
* @return The array of issue activities grouped by interval and wait status. The array will be sorted by the `start`
* and then by the `isWaiting` properties. The {@link MultiAssigneeIssueActivity.assignees} property of each element
* is guaranteed to be sorted, too.
*/
export function groupByIntervalAndWaitStatus(activities: IssueActivity[]): MultiAssigneeIssueActivity[] {
enum IssueEventType {
ADDED = 0,
REMOVED = 1,
}
interface IssueEvent {
type: IssueEventType;
assignee: string;
timestamp: number;
isWaiting: boolean;
}
const result: MultiAssigneeIssueActivity[] = [];
for (const isWaiting of [false, true]) {
const events: IssueEvent[] = [];
for (const activity of filter(activities, (filterActivity) => filterActivity.isWaiting === isWaiting)) {
const assignee = activity.assignee;
events.push(
{type: IssueEventType.ADDED, assignee, timestamp: activity.start, isWaiting},
{type: IssueEventType.REMOVED, assignee, timestamp: activity.end, isWaiting}
);
}
events.sort((first, second) => first.timestamp - second.timestamp);

let lastActivity: MultiAssigneeIssueActivity = {
assignees: [],
start: Number.MIN_SAFE_INTEGER,
end: Number.MAX_SAFE_INTEGER,
isWaiting: false,
};
let lastTimestamp: number = Number.MIN_SAFE_INTEGER;
const assigneeToActivityCount = new Map<string, number>();
for (const event of events) {
if (event.timestamp > lastTimestamp) {
lastActivity = timePassed(lastActivity, lastTimestamp, assigneeToActivityCount, result, isWaiting);
}

let assigneeActiveCount: number = coalesce(assigneeToActivityCount.get(event.assignee), 0);
if (event.type === IssueEventType.REMOVED) {
--assigneeActiveCount;
} else {
++assigneeActiveCount;
}
assert(assigneeActiveCount >= 0, 'count cannot become negative');
assigneeToActivityCount.set(event.assignee, assigneeActiveCount);
lastTimestamp = event.timestamp;
}
timePassed(lastActivity, lastTimestamp, assigneeToActivityCount, result, isWaiting);
}
result.sort((first, second) => first.start === second.start
? (+first.isWaiting) - (+second.isWaiting)
: first.start - second.start);
return result;
}

/**
* Returns a new object with values for the optional properties of {@link SchedulableIssue}.
*/
Expand Down Expand Up @@ -203,3 +282,71 @@ function newDefaultSchedulingOptions(): OnlyOptionals<SchedulingOptions> {
* The number of minutes per week, in real time.
*/
const MINUTES_PER_WEEK_REAL_TIME = 7 * 24 * 60;

/**
* Commits the last issue activity if the set of assignees changed at the last timestamp.
*
* This function is called because time progressed from `lastTimestamp` to `x`, so the interval between `lastTimestamp`
* and `x` becomes “settled.”
*
* Note that there are 3 logical timestamps of relevance here:
* 1. The timestamp when `lastActivity` started. This is simply `lastActivity.start`.
* 2. The timestamp of the last event prior to the current time. This is `lastTimestamp`.
* 3. The current time. This function does not need an exact value, so it may be an arbitrary value
* `x > lastTimestamp`.
*
* @param lastActivity The activity that is known to have lasted (at least) until timestamp `lastTimestamp`. That is,
* `lastActivity.assignees` contains the set of assignees between timestamps `lastActivity.start` and
* `lastTimestamp`.
* @param lastTimestamp The timestamp of the last event (prior to the current time `x`). It holds that
* `lastTimestamp < x`. If the set of assignees changed at `lastTimestamp`, then this function updates
* `lastActivity` and adds it to `result` (assuming there was at least one assignee between timestamps
* `lastActivity.start` and `lastTimestamp`).
* @param currentAssignees The set of assignees between timestamp `lastTimestamp` and `x`.
* @param result The array of activities that `lastActivity` will be added to if the set of assignees changed at
* timestamp `lastTimestamp`.
* @param isWaiting If this function returns a new activity (starting at `lastTimestamp`), the value for the
* `isWaiting` property.
* @return The current activity that is known to have lasted (at least) until timestamp `x`.
*/
function timePassed(lastActivity: MultiAssigneeIssueActivity, lastTimestamp: number,
currentAssignees: Map<string, number>, result: MultiAssigneeIssueActivity[], isWaiting: boolean):
MultiAssigneeIssueActivity {
let assigneesChanged: boolean = false;
for (const assignee of lastActivity.assignees) {
if (coalesce(currentAssignees.get(assignee), 0) <= 0) {
assigneesChanged = true;
break;
}
}
const assignees: string[] = [];
for (const [assignee, activeCount] of currentAssignees.entries()) {
if (activeCount > 0) {
assignees.push(assignee);
}
}
assigneesChanged = assigneesChanged || lastActivity.assignees.length !== assignees.length;
if (assigneesChanged) {
if (lastActivity.assignees.length > 0) {
lastActivity.end = lastTimestamp;
result.push(lastActivity);
}
assignees.sort();
return {
assignees,
start: lastTimestamp,
end: Number.MAX_SAFE_INTEGER,
isWaiting,
};
} else {
return lastActivity;
}
}

function* filter<T>(iterable: Iterable<T>, predicate: (val: T) => boolean): Iterable<T> {
for (const value of iterable) {
if (predicate(value)) {
yield value;
}
}
}
6 changes: 3 additions & 3 deletions src/spec/fields-parameters.spec.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { extractProperties, FieldsParameters, parseFieldsParameter } from './fields-parameters';

test.each([
test.each<[string, FieldsParameters]>([
['id', {id: {}}],
['id,value(isResolved)', {id: {}, value: {isResolved: {}}}],
['foo(bar(baz,qux)))', {foo: {bar: {baz: {}, qux: {}}}}],
] as [string, FieldsParameters][])('parse of fields parameter "%s" succeeds', (expression, expected) => {
])('parse of fields parameter "%s" succeeds', (expression, expected) => {
expect(parseFieldsParameter(expression)).toEqual(expected);
});

test.each([
test.each<string>([
'id(',
',',
')',
Expand Down
80 changes: 80 additions & 0 deletions src/spec/scheduling.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import * as ProjectPlanningJs from '@fschopp/project-planning-js';
import {
appendSchedule,
Failure,
groupByIntervalAndWaitStatus,
IssueActivity,
MultiAssigneeIssueActivity,
ProjectPlan,
SchedulableIssue,
Schedule,
Expand Down Expand Up @@ -319,3 +322,80 @@ describe('appendSchedule()', () => {
expect(result).toEqual(expected);
});
});

describe('groupByIntervalAndWaitStatus()', () => {
test('handles trivial input', () => {
expect(groupByIntervalAndWaitStatus([])).toEqual([]);
});

describe('handles elementary transformations', () => {
test.each<[IssueActivity[], MultiAssigneeIssueActivity[]]>([
[
[{assignee: 'a', start: 0, end: 1, isWaiting: false}, {assignee: 'a', start: 2, end: 3, isWaiting: false}],
[
{assignees: ['a'], start: 0, end: 1, isWaiting: false},
{assignees: ['a'], start: 2, end: 3, isWaiting: false},
],
],
[
[{assignee: 'a', start: 0, end: 1, isWaiting: false}, {assignee: 'a', start: 1, end: 2, isWaiting: true}],
[{assignees: ['a'], start: 0, end: 1, isWaiting: false}, {assignees: ['a'], start: 1, end: 2, isWaiting: true}],
],
])('groupByIntervalAndWaitStatus(%j) === %j', (activities, expected) => {
expect(groupByIntervalAndWaitStatus(activities)).toEqual(expected);
});
});

describe('handles non-normalized input', () => {
// Supported even though such an IssueActivity[] would not be returned by any of our API.
test.each<[IssueActivity[], MultiAssigneeIssueActivity[]]>([
[
[{assignee: 'a', start: 0, end: 1, isWaiting: false}, {assignee: 'a', start: 1, end: 2, isWaiting: false}],
[{assignees: ['a'], start: 0, end: 2, isWaiting: false}],
],
[
[{assignee: 'a', start: 0, end: 2, isWaiting: false}, {assignee: 'a', start: 1, end: 3, isWaiting: false}],
[{assignees: ['a'], start: 0, end: 3, isWaiting: false}],
],
])('groupByIntervalAndWaitStatus(%j) === %j', (activities, expected) => {
expect(groupByIntervalAndWaitStatus(activities)).toEqual(expected);
});
});

describe('handles merging multiple users', () => {
test.each<[IssueActivity[], MultiAssigneeIssueActivity[]]>([
[
[{assignee: 'a', start: 0, end: 2, isWaiting: false}, {assignee: 'b', start: 1, end: 3, isWaiting: false}],
[
{assignees: ['a'], start: 0, end: 1, isWaiting: false},
{assignees: ['a', 'b'], start: 1, end: 2, isWaiting: false},
{assignees: ['b'], start: 2, end: 3, isWaiting: false},
],
],
])('groupByIntervalAndWaitStatus(%j) === %j', (activities, expected) => {
expect(groupByIntervalAndWaitStatus(activities)).toEqual(expected);
});
});

describe('groups also by wait status, and returns sorted output', () => {
test.each<[IssueActivity[], MultiAssigneeIssueActivity[]]>([
[
[
{assignee: 'b', start: 1, end: 5, isWaiting: true},
{assignee: 'c', start: 1, end: 2, isWaiting: false},
{assignee: 'a', start: 5, end: 7, isWaiting: false},
{assignee: 'c', start: 3, end: 6, isWaiting: false},
],
[
{assignees: ['c'], start: 1, end: 2, isWaiting: false},
{assignees: ['b'], start: 1, end: 5, isWaiting: true},
{assignees: ['c'], start: 3, end: 5, isWaiting: false},
{assignees: ['a', 'c'], start: 5, end: 6, isWaiting: false},
{assignees: ['a'], start: 6, end: 7, isWaiting: false},
],
],
])('groupByIntervalAndWaitStatus(%j) === %j', (activities, expected) => {
expect(groupByIntervalAndWaitStatus(activities)).toEqual(expected);
});
});
});
5 changes: 2 additions & 3 deletions src/spec/util.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { assignDefined } from '../main/util';

test.each([
test.each<[{[key: string]: any}, {[key: string]: any}, {[key: string]: any}]>([
[{foo: 'bar'}, {foo: undefined}, {foo: 'bar'}],
[{}, {foo: undefined}, {foo: undefined}],
] as [{[key: string]: any}, {[key: string]: any}, {[key: string]: any}][])(
'assignDefined(%j, %j) === %j', (target, source, expected) => {
])('assignDefined(%j, %j) === %j', (target, source, expected) => {
expect(assignDefined(target, source)).toEqual(expected);
});

0 comments on commit 02f329b

Please sign in to comment.