diff --git a/apps/client/src/app/projects/reports/forecast/forecast-page.tsx b/apps/client/src/app/projects/reports/forecast/forecast-page.tsx index ee9a3707..0e9c3265 100644 --- a/apps/client/src/app/projects/reports/forecast/forecast-page.tsx +++ b/apps/client/src/app/projects/reports/forecast/forecast-page.tsx @@ -17,29 +17,37 @@ import { ChartParams, newSeed, useChartParams } from "./use-chart-params"; import { chartStyleAtom } from "../chart-style"; import { useFilterParams } from "@app/filter/use-filter-params"; import { + AbsoluteInterval, asAbsolute, defaultDateRange, + ellipsize, formatDate, + formatInterval, } from "@agileplanning-io/flow-lib"; import { Project } from "@data/projects"; import { + AbsolutePicker, ControlBar, DatePicker, FormControl, + HelpIcon, IssueFilterForm, Popdown, ReportType, } from "@agileplanning-io/flow-components"; import { + Alert, Button, Checkbox, + Flex, Form, InputNumber, Space, + Tag, Tooltip, Typography, } from "antd"; -import { isNonNullish } from "remeda"; +import { isNonNullish, splice } from "remeda"; import { RedoOutlined } from "@ant-design/icons"; import { IssueDetailsDrawer } from "../components/issue-details-drawer"; @@ -100,6 +108,13 @@ export const ForecastPage = () => { ( + + )} showPercentiles={chartParams.showPercentileLabels} /> ) : null} @@ -140,159 +155,253 @@ const ChartParamsForm: FC = ({ chartParams, setChartParams, }) => { - return ( - - - ( - - {value.issueCount} issues - · - Start {formatDate(value.startDate)} - - )} - > - {(value, setValue) => ( -
- - { - if (isNonNullish(issueCount)) { - setValue({ ...value, issueCount }); - } - }} - /> - - - - { - setValue({ - ...value, - startDate: e ?? undefined, - }); - }} - /> - + const [newExclusion, setNewExclusion] = useState(() => + asAbsolute(defaultDateRange()), + ); - + + + ( + + {value.issueCount} issues + · + Start {formatDate(value.startDate)} + + )} + > + {(value, setValue) => ( + - + { + if (isNonNullish(issueCount)) { + setValue({ ...value, issueCount }); + } + }} + /> + + + + { - if (e) { - setValue({ ...value, seed: e }); - } + setValue({ + ...value, + startDate: e ?? undefined, + }); }} /> - - + + + )} + + + + + ( + + {value.includeLongTail ? "Incl. long tail" : "Excl. long tail"} + · + {value.includeLeadTimes + ? "Incl. lead times" + : "Excl. lead times"} + · + {value.excludeOutliers ? "Excl. outliers" : "Incl. outliers"} + + )} + > + {(value, setValue) => ( + + + setValue({ + ...value, + includeLongTail: e.target.checked, + }) + } + > + Include long tail + + + setValue({ + ...value, + includeLeadTimes: e.target.checked, + }) + } + > + Include lead times + + + setValue({ + ...value, + excludeOutliers: e.target.checked, + }) + } + > + Exclude cycle time outliers + + + )} + + + + + + + setChartParams({ + ...chartParams, + showPercentileLabels: e.target.checked, + }) + } + > + Show percentile labels + + + + ); }; diff --git a/apps/client/src/app/projects/reports/forecast/use-chart-params.ts b/apps/client/src/app/projects/reports/forecast/use-chart-params.ts index 8ff3e056..c6209f20 100644 --- a/apps/client/src/app/projects/reports/forecast/use-chart-params.ts +++ b/apps/client/src/app/projects/reports/forecast/use-chart-params.ts @@ -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 = { diff --git a/apps/client/src/app/projects/reports/time-spent/time-spent-page.tsx b/apps/client/src/app/projects/reports/time-spent/time-spent-page.tsx index f64ddebf..7895f1e5 100644 --- a/apps/client/src/app/projects/reports/time-spent/time-spent-page.tsx +++ b/apps/client/src/app/projects/reports/time-spent/time-spent-page.tsx @@ -60,7 +60,7 @@ export const TimeSpentPage = () => { issues, fromClientFilter( { ...filter, hierarchyLevel: HierarchyLevel.Story }, - DateFilterType.Intersects, + DateFilterType.Overlaps, ), ); const epics = issues.filter( diff --git a/packages/charts/src/forecast/forecast-chart.tsx b/packages/charts/src/forecast/forecast-chart.tsx index 8f163d47..9ae99f4c 100644 --- a/packages/charts/src/forecast/forecast-chart.tsx +++ b/packages/charts/src/forecast/forecast-chart.tsx @@ -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">; }; @@ -19,6 +21,7 @@ export const ForecastChart: React.FC = ({ showPercentiles, style, options: overrideOptions, + renderNoData, }) => { const { rows, percentiles, startDate } = result; @@ -112,6 +115,10 @@ export const ForecastChart: React.FC = ({ const options = mergeDeep(defaultOptions, overrideOptions ?? {}); + if (!rows.length) { + return renderNoData(); + } + return ; }; diff --git a/packages/components/src/control-bars/popdown.tsx b/packages/components/src/control-bars/popdown.tsx index 71bd012c..681b2137 100644 --- a/packages/components/src/control-bars/popdown.tsx +++ b/packages/components/src/control-bars/popdown.tsx @@ -9,6 +9,7 @@ type PopdownProps = { value: T; renderLabel: (value: T) => ReactNode; onValueChanged: (value: T) => void; + onClose?: () => void; }; export const Popdown = ({ @@ -17,6 +18,7 @@ export const Popdown = ({ value, renderLabel, onValueChanged, + onClose, }: PopdownProps) => { const [state, setState] = useState(value); return ( @@ -24,7 +26,11 @@ export const Popdown = ({ title={title} icon={null} description={children(state, setState)} - onConfirm={() => onValueChanged(state)} + onConfirm={() => { + onValueChanged(state); + onClose?.(); + }} + onCancel={() => onClose?.()} placement="bottom" onOpenChange={(open) => { if (open) { diff --git a/packages/components/src/date-selector/date-selector.tsx b/packages/components/src/date-selector/date-selector.tsx index 1192ee92..70039da6 100644 --- a/packages/components/src/date-selector/date-selector.tsx +++ b/packages/components/src/date-selector/date-selector.tsx @@ -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; @@ -60,11 +61,13 @@ export const DateSelector: React.FC = ({ ); }; -const AbsolutePicker: FC<{ +export const AbsolutePicker: FC<{ dates: AbsoluteInterval; - onChange: (interval: Interval) => void; -}> = ({ dates, onChange }) => ( + onChange: (interval: AbsoluteInterval) => void; + size?: SizeType; +}> = ({ dates, onChange, size }) => ( ({ label: `${unit}s ago`, value: unit })); -const RelativePicker: FC<{ +export const RelativePicker: FC<{ dates: RelativeInterval; onChange: (interval: Interval) => void; }> = ({ dates, onChange }) => { diff --git a/packages/components/src/filters/issue-filter-form.tsx b/packages/components/src/filters/issue-filter-form.tsx index 3e08c408..4014c900 100644 --- a/packages/components/src/filters/issue-filter-form.tsx +++ b/packages/components/src/filters/issue-filter-form.tsx @@ -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"; @@ -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` diff --git a/packages/lib/src/format.ts b/packages/lib/src/format.ts index d9e08585..5c950d4b 100644 --- a/packages/lib/src/format.ts +++ b/packages/lib/src/format.ts @@ -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)) { @@ -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"); diff --git a/packages/lib/src/intervals.spec.ts b/packages/lib/src/intervals.spec.ts index 2024230e..4a66381a 100644 --- a/packages/lib/src/intervals.spec.ts +++ b/packages/lib/src/intervals.spec.ts @@ -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(); @@ -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, + ); + }); + }); }); diff --git a/packages/lib/src/intervals.ts b/packages/lib/src/intervals.ts index e50a234a..8eec6be5 100644 --- a/packages/lib/src/intervals.ts +++ b/packages/lib/src/intervals.ts @@ -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: @@ -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 }; }; diff --git a/packages/lib/src/query-params.spec.ts b/packages/lib/src/query-params.spec.ts index 6500562c..26ad6e4d 100644 --- a/packages/lib/src/query-params.spec.ts +++ b/packages/lib/src/query-params.spec.ts @@ -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 }); }); @@ -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", () => { diff --git a/packages/lib/src/query-params.ts b/packages/lib/src/query-params.ts index 881bfbff..2905a5c0 100644 --- a/packages/lib/src/query-params.ts +++ b/packages/lib/src/query-params.ts @@ -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", }); diff --git a/packages/metrics/src/fixtures/issue-factory.ts b/packages/metrics/src/fixtures/issue-factory.ts index ec7d1cf2..eae72f8c 100644 --- a/packages/metrics/src/fixtures/issue-factory.ts +++ b/packages/metrics/src/fixtures/issue-factory.ts @@ -56,6 +56,7 @@ export const buildIssue = ( type BuildCompletedIssueParams = Partial> & { metrics: { + started?: Date; completed: Date; cycleTime: number; }; diff --git a/packages/metrics/src/forecast/forecast.ts b/packages/metrics/src/forecast/forecast.ts index 74bcd0de..0512ae13 100644 --- a/packages/metrics/src/forecast/forecast.ts +++ b/packages/metrics/src/forecast/forecast.ts @@ -5,6 +5,7 @@ import { SeedRandomGenerator } from "./simulation/select"; import { computeInputs } from "./inputs/inputs"; import { CompletedIssue } from "../issues"; import { + AbsoluteInterval, Interval, Percentile, getPercentiles, @@ -15,6 +16,7 @@ export type ForecastParams = { selectedIssues: CompletedIssue[]; issueCount: number; startDate?: Date; + exclusions?: AbsoluteInterval[]; excludeOutliers: boolean; includeLeadTimes: boolean; includeLongTail: boolean; @@ -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, diff --git a/packages/metrics/src/forecast/inputs/inputs.spec.ts b/packages/metrics/src/forecast/inputs/inputs.spec.ts index bef7f819..d068b066 100644 --- a/packages/metrics/src/forecast/inputs/inputs.spec.ts +++ b/packages/metrics/src/forecast/inputs/inputs.spec.ts @@ -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], + }, + }); + }); }); diff --git a/packages/metrics/src/forecast/inputs/inputs.ts b/packages/metrics/src/forecast/inputs/inputs.ts index 7165a8c9..527e93ef 100644 --- a/packages/metrics/src/forecast/inputs/inputs.ts +++ b/packages/metrics/src/forecast/inputs/inputs.ts @@ -5,6 +5,8 @@ import { asAbsolute, excludeOutliersFromSeq, categorizeWeekday, + AbsoluteInterval, + intervalContainsDate, } from "@agileplanning-io/flow-lib"; import { CompletedIssue } from "../../issues"; @@ -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 = {}; 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, diff --git a/packages/metrics/src/issues/filter.spec.ts b/packages/metrics/src/issues/filter.spec.ts index 72d591c5..244a0a6a 100644 --- a/packages/metrics/src/issues/filter.spec.ts +++ b/packages/metrics/src/issues/filter.spec.ts @@ -1,7 +1,8 @@ import { clone } from "remeda"; -import { buildIssue } from "../fixtures/issue-factory"; +import { buildCompletedIssue, buildIssue } from "../fixtures/issue-factory"; import { HierarchyLevel } from "../issues"; import { + DateFilterType, FilterType, FilterUseCase, IssueAttributesFilter, @@ -22,6 +23,69 @@ describe("filterIssues", () => { expect(filteredIssues).toEqual([story]); }); + describe("date filters", () => { + const issue1Started = new Date("2024-03-01"); + const issue1Completed = new Date("2024-03-03"); + const issue2Started = new Date("2024-02-02"); + const issue2Completed = new Date("2024-03-04"); + const issue3Started = new Date("2024-03-03"); + const issue3Completed = new Date("2024-03-05"); + const issue4Completed = new Date("2024-03-06"); + + const issue1 = buildCompletedIssue({ + metrics: { + started: issue1Started, + completed: issue1Completed, + cycleTime: 1, + }, + }); + const issue2 = buildCompletedIssue({ + metrics: { + started: issue2Started, + completed: issue2Completed, + cycleTime: 1, + }, + }); + const issue3 = buildCompletedIssue({ + metrics: { + started: issue3Started, + completed: issue3Completed, + cycleTime: 1, + }, + }); + const issue4 = buildCompletedIssue({ + metrics: { + completed: issue4Completed, + cycleTime: 1, + }, + }); + const issues = [issue1, issue2, issue3, issue4]; + + describe("when the filter type is 'completed'", () => { + const interval = { start: issue1Started, end: issue2Completed }; + const filterType = DateFilterType.Completed; + + it("filters issues completed within the given interval", () => { + const filteredIssues = filterIssues(issues, { + dates: { filterType, interval }, + }); + expect(filteredIssues).toEqual([issue1, issue2]); + }); + }); + + describe("when the filter type is 'overlaps'", () => { + it("filters issues which overlap the given interval", () => { + const filteredIssues = filterIssues(issues, { + dates: { + filterType: DateFilterType.Overlaps, + interval: { start: issue1Started, end: issue2Completed }, + }, + }); + expect(filteredIssues).toEqual([issue1, issue2, issue3]); + }); + }); + }); + describe("issueType filters", () => { it("filters the included issueTypes when issueTypeFilterType = include", () => { const filteredIssues = filterIssues([story, bug], { diff --git a/packages/metrics/src/issues/filter.ts b/packages/metrics/src/issues/filter.ts index 4d81a2a8..fd249b63 100644 --- a/packages/metrics/src/issues/filter.ts +++ b/packages/metrics/src/issues/filter.ts @@ -1,10 +1,15 @@ import { intersection, isDeepEqual, isNonNullish, isNullish } from "remeda"; import { CompletedIssue, HierarchyLevel, Issue, isCompleted } from "./issues"; -import { Interval, asAbsolute } from "@agileplanning-io/flow-lib"; +import { + AbsoluteInterval, + Interval, + asAbsolute, + intervalContainsDate, +} from "@agileplanning-io/flow-lib"; export enum DateFilterType { Completed = "completed", - Intersects = "intersects", + Overlaps = "overlaps", } export enum FilterType { @@ -122,34 +127,11 @@ export const filterIssues = ( const interval = asAbsolute(filter.dates.interval); if (filter.dates.filterType === DateFilterType.Completed) { - if (!issue.metrics.completed) { + if (!completedInInterval(issue, interval)) { return false; } - - if (issue.metrics.completed < interval.start) { - return false; - } - - if (issue.metrics.completed > interval.end) { - return false; - } - } else { - if (!issue.metrics.started) { - return false; - } - - if (issue.metrics.started > interval.end) { - return false; - } - - if ( - issue.metrics.completed && - issue.metrics.completed < interval.start - ) { - return false; - } - - return true; + } else if (!overlapsInterval(issue, interval)) { + return false; } } @@ -204,3 +186,27 @@ export const isAttributesFilterEqual = ( isValuesFilterEqual(filter1[field], filter2[field]), ); }; + +const completedInInterval = (issue: Issue, interval: AbsoluteInterval) => { + if (!issue.metrics.completed) { + return false; + } + + return intervalContainsDate(interval, issue.metrics.completed); +}; + +const overlapsInterval = (issue: Issue, interval: AbsoluteInterval) => { + if (!issue.metrics.started) { + return false; + } + + if (issue.metrics.started > interval.end) { + return false; + } + + if (issue.metrics.completed && issue.metrics.completed < interval.start) { + return false; + } + + return true; +}; diff --git a/packages/metrics/src/issues/schema.ts b/packages/metrics/src/issues/schema.ts index 850c288d..32509438 100644 --- a/packages/metrics/src/issues/schema.ts +++ b/packages/metrics/src/issues/schema.ts @@ -42,7 +42,7 @@ export const intervalSchema: z.Schema = z.union([ const dateFilterSchema: z.Schema = z.object({ interval: intervalSchema, - filterType: z.enum([DateFilterType.Completed, DateFilterType.Intersects]), + filterType: z.enum([DateFilterType.Completed, DateFilterType.Overlaps]), }); export const filterSchema = z.object({