Skip to content

Commit

Permalink
feat: exclude date ranges from forecasts
Browse files Browse the repository at this point in the history
  • Loading branch information
jbrunton committed Jan 12, 2025
1 parent f3eef0c commit b108be9
Show file tree
Hide file tree
Showing 19 changed files with 486 additions and 195 deletions.
395 changes: 252 additions & 143 deletions apps/client/src/app/projects/reports/forecast/forecast-page.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ const chartParamsSchema = z.object({
includeLeadTimes: boolean.schema.default(boolean.True),
excludeOutliers: boolean.schema.default(boolean.False),
showPercentileLabels: boolean.schema.default(boolean.True),
exclusions: z
.array(z.object({ start: z.coerce.date(), end: z.coerce.date() }))
.optional(),
});

const defaults = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export const TimeSpentPage = () => {
issues,
fromClientFilter(
{ ...filter, hierarchyLevel: HierarchyLevel.Story },
DateFilterType.Intersects,
DateFilterType.Overlaps,
),
);
const epics = issues.filter(
Expand Down
7 changes: 7 additions & 0 deletions packages/charts/src/forecast/forecast-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import { ChartStyle, buildFontSpec } from "../util/style";
import { isDate, mergeDeep } from "remeda";
import { getAnnotationOptions } from "../util/annotations";
import { addDays } from "date-fns";
import { ReactElement } from "react";

export type ForecastChartProps = {
result: SummaryResult;
showPercentiles: boolean;
renderNoData: () => ReactElement;
style?: ChartStyle;
options?: ChartOptions<"bar">;
};
Expand All @@ -19,6 +21,7 @@ export const ForecastChart: React.FC<ForecastChartProps> = ({
showPercentiles,
style,
options: overrideOptions,
renderNoData,
}) => {
const { rows, percentiles, startDate } = result;

Expand Down Expand Up @@ -112,6 +115,10 @@ export const ForecastChart: React.FC<ForecastChartProps> = ({

const options = mergeDeep(defaultOptions, overrideOptions ?? {});

if (!rows.length) {
return renderNoData();
}

return <Bar data={data} options={options} />;
};

Expand Down
8 changes: 7 additions & 1 deletion packages/components/src/control-bars/popdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ type PopdownProps<T> = {
value: T;
renderLabel: (value: T) => ReactNode;
onValueChanged: (value: T) => void;
onClose?: () => void;
};

export const Popdown = <T,>({
Expand All @@ -17,14 +18,19 @@ export const Popdown = <T,>({
value,
renderLabel,
onValueChanged,
onClose,
}: PopdownProps<T>) => {
const [state, setState] = useState(value);
return (
<Popconfirm
title={title}
icon={null}
description={children(state, setState)}
onConfirm={() => onValueChanged(state)}
onConfirm={() => {
onValueChanged(state);
onClose?.();
}}
onCancel={() => onClose?.()}
placement="bottom"
onOpenChange={(open) => {
if (open) {
Expand Down
11 changes: 7 additions & 4 deletions packages/components/src/date-selector/date-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
import { getDateRanges } from "./ranges";
import { endOfDay } from "date-fns";
import { isNumber } from "remeda";
import { SizeType } from "antd/es/config-provider/SizeContext";

export type DateSelectorProps = {
dates?: Interval;
Expand Down Expand Up @@ -60,11 +61,13 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
);
};

const AbsolutePicker: FC<{
export const AbsolutePicker: FC<{
dates: AbsoluteInterval;
onChange: (interval: Interval) => void;
}> = ({ dates, onChange }) => (
onChange: (interval: AbsoluteInterval) => void;
size?: SizeType;
}> = ({ dates, onChange, size }) => (
<DatePicker.RangePicker
size={size}
suffixIcon={false}
style={{ width: "100%", zIndex: 10000 }}
allowClear={false}
Expand All @@ -87,7 +90,7 @@ const timeUnitOptions: SelectProps["options"] = [
TimeUnit.Month,
].map((unit) => ({ label: `${unit}s ago`, value: unit }));

const RelativePicker: FC<{
export const RelativePicker: FC<{
dates: RelativeInterval;
onChange: (interval: Interval) => void;
}> = ({ dates, onChange }) => {
Expand Down
8 changes: 6 additions & 2 deletions packages/components/src/filters/issue-filter-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ import { Popdown } from "../control-bars/popdown";
import { IssueAttributesFilterForm } from "./issue-attributes-filter-form";
import { summariseFilter } from "./summarise-filter";
import { DateSelector } from "../date-selector";
import { formatDate, Interval, isAbsolute } from "@agileplanning-io/flow-lib";
import {
formatInterval,
Interval,
isAbsolute,
} from "@agileplanning-io/flow-lib";
import { Tag, theme, Typography } from "antd";
import { isNonNullish } from "remeda";
import { useFilterOptions } from "./use-filter-options";
Expand Down Expand Up @@ -162,7 +166,7 @@ const summariseDatesFilter = (dates?: Interval) => {
}

if (isAbsolute(dates)) {
return `${formatDate(dates.start)}-${formatDate(dates.end)}`;
return formatInterval(dates);
} else {
return `Last ${dates.unitCount} ${
dates.unitCount === 1 ? dates.unit : `${dates.unit}s`
Expand Down
4 changes: 4 additions & 0 deletions packages/lib/src/format.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { format } from "date-fns";
import { isNil } from "remeda";
import { AbsoluteInterval } from "./intervals";

export const formatNumber = (x?: number): string | undefined => {
if (!isNil(x)) {
Expand All @@ -17,6 +18,9 @@ export const formatDate = (
}
};

export const formatInterval = (interval: AbsoluteInterval) =>
`${formatDate(interval.start)}-${formatDate(interval.end)}`;

export const formatTime = (date?: Date): string | undefined => {
if (date) {
return format(date, "PPp");
Expand Down
29 changes: 28 additions & 1 deletion packages/lib/src/intervals.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { addDays, subDays } from "date-fns";
import { getSpanningInterval, getSpanningSet } from "./intervals";
import {
AbsoluteInterval,
getSpanningInterval,
getSpanningSet,
intervalContainsDate,
} from "./intervals";

describe("intervals", () => {
const now = new Date();
Expand Down Expand Up @@ -50,4 +55,26 @@ describe("intervals", () => {
expect(spanningSet).toEqual([earlierInterval, spanningInterval]);
});
});

describe("intervalContainsDate", () => {
it("checks if an interval contains a date", () => {
const interval: AbsoluteInterval = {
start: new Date("2024-03-02"),
end: new Date("2024-03-04"),
};

expect(intervalContainsDate(interval, interval.start)).toEqual(true);
expect(intervalContainsDate(interval, interval.end)).toEqual(true);
expect(intervalContainsDate(interval, new Date("2024-03-03"))).toEqual(
true,
);

expect(intervalContainsDate(interval, new Date("2024-03-01"))).toEqual(
false,
);
expect(intervalContainsDate(interval, new Date("2024-03-05"))).toEqual(
false,
);
});
});
});
14 changes: 9 additions & 5 deletions packages/lib/src/intervals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,13 @@ export const getSpanningSet = (
return spans;
};

export const intervalContainsDate = (
interval: AbsoluteInterval,
date: Date,
) => {
return interval.start <= date && date <= interval.end;
};

export const addTime = (date: Date, count: number, unit: TimeUnit): Date => {
switch (unit) {
case TimeUnit.Day:
Expand Down Expand Up @@ -175,9 +182,6 @@ export const difference = (
}
};

export const defaultDateRange = (): Interval => {
const today = new Date();
//const defaultStart = startOfDay(subDays(today, 30));
const defaultEnd = endOfDay(today);
return { end: defaultEnd, unit: TimeUnit.Day, unitCount: 30 };
export const defaultDateRange = (): RelativeInterval => {
return { unit: TimeUnit.Day, unitCount: 30 };
};
17 changes: 16 additions & 1 deletion packages/lib/src/query-params.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ describe("qsParse", () => {
expect(qsParse("foo.bar[0]=baz")).toEqual({ foo: { bar: ["baz"] } });
});

it("parses objects in arrays", () => {
expect(qsParse("foo.bar[0].baz=qux")).toEqual({
foo: { bar: [{ baz: "qux" }] },
});
});

it("parses booleans", () => {
expect(qsParse("foo=true")).toEqual({ foo: true });
});
Expand All @@ -15,7 +21,16 @@ describe("qsStringify", () => {
const params = {
foo: { bar: ["baz"] },
};
expect(decodeURIComponent(qsStringify(params))).toEqual("foo.bar[]=baz");
expect(decodeURIComponent(qsStringify(params))).toEqual("foo.bar[0]=baz");
});

it("stringifies objects in arrays", () => {
const params = {
foo: { bar: [{ baz: "qux" }] },
};
expect(decodeURIComponent(qsStringify(params))).toEqual(
"foo.bar[0].baz=qux",
);
});

it("formats dates", () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/lib/src/query-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,5 @@ export const qsStringify = (obj: unknown) =>
skipNulls: true,
serializeDate: (date: Date) => format(date, "yyyy-MM-dd"),
allowDots: true,
arrayFormat: "brackets",
arrayFormat: "indices",
});
1 change: 1 addition & 0 deletions packages/metrics/src/fixtures/issue-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const buildIssue = (

type BuildCompletedIssueParams = Partial<Omit<CompletedIssue, "metrics">> & {
metrics: {
started?: Date;
completed: Date;
cycleTime: number;
};
Expand Down
14 changes: 13 additions & 1 deletion packages/metrics/src/forecast/forecast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { SeedRandomGenerator } from "./simulation/select";
import { computeInputs } from "./inputs/inputs";
import { CompletedIssue } from "../issues";
import {
AbsoluteInterval,
Interval,
Percentile,
getPercentiles,
Expand All @@ -15,6 +16,7 @@ export type ForecastParams = {
selectedIssues: CompletedIssue[];
issueCount: number;
startDate?: Date;
exclusions?: AbsoluteInterval[];
excludeOutliers: boolean;
includeLeadTimes: boolean;
includeLongTail: boolean;
Expand Down Expand Up @@ -52,13 +54,23 @@ export const forecast = ({
selectedIssues,
issueCount,
startDate,
exclusions,
excludeOutliers,
includeLeadTimes,
includeLongTail,
seed,
}: ForecastParams): SummaryResult => {
const generator = new SeedRandomGenerator(seed);
const inputs = computeInputs(interval, selectedIssues, excludeOutliers);
const inputs = computeInputs(
interval,
selectedIssues,
excludeOutliers,
exclusions,
);

if (!Object.values(inputs.throughputs).length) {
return { startDate, rows: [], percentiles: [] };
}

const runs = runSimulation({
issueCount,
Expand Down
13 changes: 13 additions & 0 deletions packages/metrics/src/forecast/inputs/inputs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,4 +106,17 @@ describe("computeInputs", () => {
},
});
});

it("optionally ignores data from excluded intervals", () => {
const exclusions = [
{ start: new Date("2020-01-06"), end: new Date("2020-01-07") },
];
expect(computeInputs(interval, issues, true, exclusions)).toEqual({
cycleTimes: [1, 3, 200],
throughputs: {
weekend: [0, 0],
weekday: [2, 0],
},
});
});
});
23 changes: 18 additions & 5 deletions packages/metrics/src/forecast/inputs/inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
asAbsolute,
excludeOutliersFromSeq,
categorizeWeekday,
AbsoluteInterval,
intervalContainsDate,
} from "@agileplanning-io/flow-lib";
import { CompletedIssue } from "../../issues";

Expand Down Expand Up @@ -40,19 +42,30 @@ export const computeInputs = (
interval: Interval,
issues: CompletedIssue[],
excludeCycleTimeOutliers: boolean,
exclusions?: AbsoluteInterval[],
): SimulationInputs => {
const excludeDate = (date: Date) =>
exclusions?.some((interval) => intervalContainsDate(interval, date));

const throughputs: Record<string, number[]> = {};
for (const { date, count } of computeThroughput(interval, issues)) {
const category = categorizeWeekday(getISODay(date));
if (!throughputs[category]) {
throughputs[category] = [];
if (!excludeDate(date)) {
const category = categorizeWeekday(getISODay(date));
if (!throughputs[category]) {
throughputs[category] = [];
}
throughputs[category].push(count);
}
throughputs[category].push(count);
}
let cycleTimes = issues.map((issue) => issue.metrics.cycleTime);

let cycleTimes = issues
.filter((issue) => !excludeDate(issue.metrics.completed))
.map((issue) => issue.metrics.cycleTime);

if (excludeCycleTimeOutliers) {
cycleTimes = excludeOutliersFromSeq(cycleTimes, (x: number) => x);
}

return {
cycleTimes,
throughputs,
Expand Down
Loading

0 comments on commit b108be9

Please sign in to comment.