diff --git a/pages/api/Schema/reports/pledgeHistories/dataHandler.ts b/pages/api/Schema/reports/pledgeHistories/dataHandler.ts
index 22f079b75..937a5d788 100644
--- a/pages/api/Schema/reports/pledgeHistories/dataHandler.ts
+++ b/pages/api/Schema/reports/pledgeHistories/dataHandler.ts
@@ -33,7 +33,7 @@ const getReportsPledgeHistories = (
id: id,
endDate: end_date,
pledged: pledged,
- recieved: received,
+ received: received,
startDate: start_date,
updatedAt: updated_at,
updatedInDbAt: updated_in_db_at,
diff --git a/pages/api/Schema/reports/pledgeHistories/pledgeHistories.graphql b/pages/api/Schema/reports/pledgeHistories/pledgeHistories.graphql
index cda87d144..ef76a212d 100644
--- a/pages/api/Schema/reports/pledgeHistories/pledgeHistories.graphql
+++ b/pages/api/Schema/reports/pledgeHistories/pledgeHistories.graphql
@@ -1,11 +1,15 @@
extend type Query {
- reportPledgeHistories(accountListId: ID!): [ReportsPledgeHistories]
+ reportPledgeHistories(
+ accountListId: ID!
+ range: String
+ endDate: String
+ ): [ReportsPledgeHistories]
}
type ReportsPledgeHistories {
id: ID!
pledged: Float
- recieved: Float
+ received: Float
endDate: ISO8601DateTime
startDate: ISO8601DateTime
createdAt: ISO8601DateTime
diff --git a/pages/api/Schema/reports/pledgeHistories/resolvers.ts b/pages/api/Schema/reports/pledgeHistories/resolvers.ts
index 80edd0735..652306be3 100644
--- a/pages/api/Schema/reports/pledgeHistories/resolvers.ts
+++ b/pages/api/Schema/reports/pledgeHistories/resolvers.ts
@@ -6,10 +6,14 @@ const ReportsPledgeHistoriesResolvers: Resolvers = {
Query: {
reportPledgeHistories: async (
_source,
- { accountListId },
+ { accountListId, range, endDate },
{ dataSources },
) => {
- return dataSources.mpdxRestApi.getReportPldegeHistories(accountListId);
+ return dataSources.mpdxRestApi.getReportPledgeHistories(
+ accountListId,
+ range,
+ endDate,
+ );
},
},
};
diff --git a/pages/api/graphql-rest.page.ts b/pages/api/graphql-rest.page.ts
index 37cc299dc..3db1d6fb1 100644
--- a/pages/api/graphql-rest.page.ts
+++ b/pages/api/graphql-rest.page.ts
@@ -276,9 +276,15 @@ class MpdxRestApi extends RESTDataSource {
return getAppointmentResults(data);
}
- async getReportPldegeHistories(accountListId: string) {
+ async getReportPledgeHistories(
+ accountListId: string,
+ range: string | null | undefined,
+ endDate: string | null | undefined,
+ ) {
+ const rangeFilter = range ? `&filter[range]=${range}` : '';
+ const endDateFilter = endDate ? `&filter[end_date]=${endDate}` : '';
const { data } = await this.get(
- `reports/pledge_histories?filter%5Baccount_list_id%5D=${accountListId}`,
+ `reports/pledge_histories?filter[account_list_id]=${accountListId}${rangeFilter}${endDateFilter}`,
);
return getReportsPledgeHistories(data);
}
@@ -447,7 +453,7 @@ class MpdxRestApi extends RESTDataSource {
) {
const designationAccountFilter =
designationAccountId && designationAccountId.length > 0
- ? `&filter[designation_account_id=${designationAccountId.join(',')}`
+ ? `&filter[designation_account_id]=${designationAccountId.join(',')}`
: '';
const { data }: { data: FourteenMonthReportResponse } = await this.get(
`reports/${
@@ -467,7 +473,7 @@ class MpdxRestApi extends RESTDataSource {
) {
const designationAccountFilter =
designationAccountId && designationAccountId.length > 0
- ? `&filter[designation_account_id=${designationAccountId.join(',')}`
+ ? `&filter[designation_account_id]=${designationAccountId.join(',')}`
: '';
const { data }: { data: ExpectedMonthlyTotalResponse } = await this.get(
`reports/expected_monthly_totals?filter[account_list_id]=${accountListId}${designationAccountFilter}`,
diff --git a/src/components/Coaching/CoachingDetail/Activity/Activity.test.tsx b/src/components/Coaching/CoachingDetail/Activity/Activity.test.tsx
index f8ae52562..c3b72e90a 100644
--- a/src/components/Coaching/CoachingDetail/Activity/Activity.test.tsx
+++ b/src/components/Coaching/CoachingDetail/Activity/Activity.test.tsx
@@ -302,29 +302,4 @@ describe('Activity', () => {
);
});
});
-
- describe('links', () => {
- const connectionsRemainingLinkText =
- mocks.CoachingDetailActivity.accountListAnalytics.contactsByStatus.connectionsRemaining.toString();
-
- it('are hidden when viewing coaching account list', async () => {
- const { findByTestId, queryByRole } = render();
-
- expect(
- await findByTestId('CurrentRealityConnections'),
- ).toBeInTheDocument();
- expect(
- queryByRole('link', { name: connectionsRemainingLinkText }),
- ).not.toBeInTheDocument();
- });
-
- it('are shown when viewing own account list', async () => {
- const { findByRole } = render(
- ,
- );
- expect(
- await findByRole('link', { name: connectionsRemainingLinkText }),
- ).toBeInTheDocument();
- });
- });
});
diff --git a/src/components/Coaching/CoachingDetail/Activity/Activity.tsx b/src/components/Coaching/CoachingDetail/Activity/Activity.tsx
index b84f05431..a62b12fa0 100644
--- a/src/components/Coaching/CoachingDetail/Activity/Activity.tsx
+++ b/src/components/Coaching/CoachingDetail/Activity/Activity.tsx
@@ -32,6 +32,7 @@ import {
import { getLocalizedContactStatus } from 'src/utils/functions/getLocalizedContactStatus';
import { MultilineSkeleton } from '../../../Shared/MultilineSkeleton';
import { AccountListTypeEnum, CoachingPeriodEnum } from '../CoachingDetail';
+import { CoachingLink } from '../CoachingLink';
import { HelpButton } from '../HelpButton';
import { useCoachingDetailActivityQuery } from './Activity.generated';
import { AppealProgress } from './AppealProgress';
@@ -152,22 +153,6 @@ const StatsText = styled(Typography)({
fontSize: '0.9em',
});
-interface LinkProps {
- accountListType: AccountListTypeEnum;
- children: React.ReactNode;
- href: string;
-}
-
-const Link: React.FC = ({ accountListType, children, href }) =>
- // Only show links when the account list belongs to the user
- accountListType === AccountListTypeEnum.Own ? (
-
- {children}
-
- ) : (
- {children}
- );
-
interface ActivityProps {
accountListId: string;
accountListType: AccountListTypeEnum;
@@ -256,7 +241,7 @@ export const Activity: React.FC = ({
{t('Connections Remaining')}
- = ({
.connectionsRemaining
}
-
+
{loading ? (
) : (
- = ({
{getLocalizedContactStatus(t, StatusEnum.NeverContacted)}
-
+
- = ({
{getLocalizedContactStatus(t, StatusEnum.AskInFuture)}
-
+
- = ({
StatusEnum.CultivateRelationship,
)}
-
+
)}
@@ -336,7 +321,7 @@ export const Activity: React.FC = ({
{t('Partners Currently Initiating With')}
- = ({
{data?.accountListAnalytics.contactsByStatus.initiations}
-
+
{loading ? (
) : (
- = ({
StatusEnum.ContactForAppointment,
)}
-
+
- = ({
StatusEnum.AppointmentScheduled,
)}
-
+
- = ({
{getLocalizedContactStatus(t, StatusEnum.CallForDecision)}
-
+
)}
@@ -489,14 +474,14 @@ export const Activity: React.FC = ({
{data?.accountListAnalytics.contactsByStatus.financial}
)}
-
{t('Financial Partners')}
-
+
{loading ? (
@@ -506,14 +491,14 @@ export const Activity: React.FC = ({
{data?.accountListAnalytics.contactsByStatus.special}
)}
-
{t('Special Gift Partners')}
-
+
{loading ? (
@@ -523,14 +508,14 @@ export const Activity: React.FC = ({
{data?.accountListAnalytics.contactsByStatus.prayer}
)}
-
{t('Prayer Partners')}
-
+
diff --git a/src/components/Coaching/CoachingDetail/CoachingDetail.tsx b/src/components/Coaching/CoachingDetail/CoachingDetail.tsx
index 83bcb323d..4aa8a0d57 100644
--- a/src/components/Coaching/CoachingDetail/CoachingDetail.tsx
+++ b/src/components/Coaching/CoachingDetail/CoachingDetail.tsx
@@ -215,8 +215,9 @@ export const CoachingDetail: React.FC = ({
/>
{
+ it('are hidden when viewing coaching account list', () => {
+ const { queryByRole } = render(
+
+ Page
+ ,
+ );
+
+ expect(queryByRole('link', { name: 'Page' })).not.toBeInTheDocument();
+ });
+
+ it('are shown when viewing own account list', async () => {
+ const { getByRole } = render(
+
+ Page
+ ,
+ );
+
+ expect(getByRole('link', { name: 'Page' })).toBeInTheDocument();
+ });
+});
diff --git a/src/components/Coaching/CoachingDetail/CoachingLink.tsx b/src/components/Coaching/CoachingDetail/CoachingLink.tsx
new file mode 100644
index 000000000..2ed5a750d
--- /dev/null
+++ b/src/components/Coaching/CoachingDetail/CoachingLink.tsx
@@ -0,0 +1,26 @@
+import NextLink from 'next/link';
+import { Link, LinkProps as MuiLinkProps } from '@mui/material';
+import { AccountListTypeEnum } from './CoachingDetail';
+
+interface LinkProps extends MuiLinkProps {
+ accountListType: AccountListTypeEnum;
+ href: string;
+}
+
+/*
+ * Component to show links when the account list belongs to the user and hide links when viewing a
+ * coached account list.
+ */
+export const CoachingLink: React.FC = ({
+ accountListType,
+ href,
+ children,
+ ...props
+}) =>
+ accountListType === AccountListTypeEnum.Own ? (
+
+ {children}
+
+ ) : (
+ {children}
+ );
diff --git a/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.graphql b/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.graphql
index 7aa32d516..61262d66a 100644
--- a/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.graphql
+++ b/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.graphql
@@ -4,6 +4,18 @@ query GetReportsPledgeHistories($coachingId: ID!) {
startDate
endDate
pledged
- recieved
+ received
+ }
+}
+
+query MonthlyCommitmentSingleMonth($accountListId: ID!, $month: String!) {
+ reportPledgeHistories(
+ accountListId: $accountListId
+ range: "1m"
+ endDate: $month
+ ) {
+ id
+ pledged
+ received
}
}
diff --git a/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.stories.tsx b/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.stories.tsx
index d5cb4c239..b6b5c9373 100644
--- a/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.stories.tsx
+++ b/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.stories.tsx
@@ -3,6 +3,7 @@ import { MockedProvider } from '@apollo/client/testing';
import { Box } from '@mui/material';
import { DateTime } from 'luxon';
import { GqlMockedProvider } from '__tests__/util/graphqlMocking';
+import { AccountListTypeEnum } from '../CoachingDetail';
import { MonthlyCommitment } from './MonthlyCommitment';
import {
GetReportsPledgeHistoriesDocument,
@@ -33,8 +34,9 @@ export const Default = (): ReactElement => {
>
@@ -57,8 +59,9 @@ export const Loading = (): ReactElement => {
>
diff --git a/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.test.tsx b/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.test.tsx
index 1ec178879..ebe0434fa 100644
--- a/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.test.tsx
+++ b/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.test.tsx
@@ -1,18 +1,64 @@
-import { render, waitFor } from '@testing-library/react';
-import { renderHook } from '@testing-library/react-hooks';
+import { render } from '@testing-library/react';
import { DateTime } from 'luxon';
import { GqlMockedProvider } from '__tests__/util/graphqlMocking';
import {
afterTestResizeObserver,
beforeTestResizeObserver,
} from '__tests__/util/windowResizeObserver';
-import { MonthlyCommitment } from './MonthlyCommitment';
-import {
- GetReportsPledgeHistoriesQuery,
- useGetReportsPledgeHistoriesQuery,
-} from './MonthlyCommitment.generated';
+import { AccountListTypeEnum } from '../CoachingDetail';
+import { MonthlyCommitment, MonthlyCommitmentProps } from './MonthlyCommitment';
+import { GetReportsPledgeHistoriesQuery } from './MonthlyCommitment.generated';
const coachingId = 'coaching-id';
+const defaultMpdInfo = {
+ monthlyGoal: 5000,
+ activeMpdStartAt: '2019-01-15',
+ activeMpdFinishAt: '2019-04-15',
+ activeMpdMonthlyGoal: 1500,
+};
+
+interface TestComponentProps {
+ missingData?: boolean;
+ mpdInfo?: MonthlyCommitmentProps['mpdInfo'];
+}
+
+const TestComponent: React.FC = ({
+ missingData,
+ mpdInfo = defaultMpdInfo,
+}) => (
+
+ mocks={{
+ GetReportsPledgeHistories: {
+ reportPledgeHistories: [...Array(12)].map((x, i) =>
+ missingData && i === 0
+ ? null
+ : {
+ startDate: DateTime.local().minus({ month: i }).toISO(),
+ endDate: DateTime.local().minus({ month: i }).toISO(),
+ received: i * 5,
+ pledged: i * 10,
+ },
+ ),
+ },
+ MonthlyCommitmentSingleMonth: {
+ reportPledgeHistories: jest
+ .fn()
+ .mockReturnValueOnce([{ pledged: 50, received: 50 }])
+ .mockReturnValueOnce([{ pledged: 100, received: 150 }]),
+ },
+ }}
+ >
+
+
+);
+
describe('MonthlyCommitment', () => {
beforeEach(() => {
beforeTestResizeObserver();
@@ -22,77 +68,33 @@ describe('MonthlyCommitment', () => {
afterTestResizeObserver();
});
- it('query Monthly Commitment', async () => {
- const { result, waitForNextUpdate } = renderHook(
- () =>
- useGetReportsPledgeHistoriesQuery({
- variables: { coachingId: coachingId },
- }),
- {
- wrapper: GqlMockedProvider,
- },
- );
- await waitForNextUpdate();
- expect(
- result.current.data?.reportPledgeHistories?.length,
- ).toMatchInlineSnapshot(`3`);
- });
-
it('renders', async () => {
- const { getByText } = render(
-
- mocks={{
- GetReportsPledgeHistories: {
- reportPledgeHistories: [...Array(12)].map((x, i) => ({
- startDate: DateTime.local().minus({ month: i }).toISO(),
- endDate: DateTime.local().minus({ month: i }).toISO(),
- recieved: i * 5,
- pledged: i * 10,
- })),
- },
- }}
- >
-
- ,
- );
+ const { findByTestId } = render();
- await waitFor(() =>
- expect(getByText('Monthly Commitments')).toBeInTheDocument(),
+ expect(await findByTestId('MonthlyCommitmentSummary')).toHaveTextContent(
+ 'Monthly Commitment Average: $50 | Monthly Commitment Goal: $500',
);
});
it('renders with missing data', async () => {
- const { getByText } = render(
-
- mocks={{
- GetReportsPledgeHistories: {
- reportPledgeHistories: [
- {
- startDate: null,
- endDate: null,
- recieved: null,
- pledged: null,
- },
- {
- startDate: DateTime.local().toISO(),
- endDate: DateTime.local().toISO(),
- recieved: 100,
- pledged: 200,
- },
- ],
- },
- }}
- >
-
- ,
- );
+ const { findByTestId } = render();
- await waitFor(() =>
- expect(getByText('Monthly Commitments')).toBeInTheDocument(),
+ expect(await findByTestId('MonthlyCommitmentSummary')).toHaveTextContent(
+ 'Monthly Commitment Average: $50 | Monthly Commitment Goal: $500',
);
});
+
+ it('renders skeleton when MPD info is loading', () => {
+ const { getByTestId } = render();
+
+ expect(getByTestId('MonthlyCommitmentSkeleton')).toBeInTheDocument();
+ });
+
+ it('renders placeholder when MPD info is missing', async () => {
+ const { getByText } = render();
+
+ expect(
+ getByText('MPD info not set up on account list'),
+ ).toBeInTheDocument();
+ });
});
diff --git a/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.tsx b/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.tsx
index 8c30e9a52..d2cd59c78 100644
--- a/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.tsx
+++ b/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.tsx
@@ -6,7 +6,6 @@ import {
Skeleton,
Typography,
} from '@mui/material';
-import { DateTime } from 'luxon';
import { useTranslation } from 'react-i18next';
import {
Bar,
@@ -21,19 +20,36 @@ import {
YAxis,
} from 'recharts';
import AnimatedCard from 'src/components/AnimatedCard';
+import { AccountList, Maybe } from 'src/graphql/types.generated';
import { useLocale } from 'src/hooks/useLocale';
+import { currencyFormat } from 'src/lib/intlFormat';
import theme from 'src/theme';
+import { AccountListTypeEnum } from '../CoachingDetail';
+import { CoachingLink } from '../CoachingLink';
import { useGetReportsPledgeHistoriesQuery } from './MonthlyCommitment.generated';
+import { calculateMonthlyCommitmentGoal, formatStartDate } from './helpers';
+import { useMonthlyCommitmentAverage } from './useMonthlyCommitmentAverage';
-interface MonthlyCommitmentProps {
+export interface MonthlyCommitmentProps {
coachingId: string;
- goal?: number;
+ accountListType: AccountListTypeEnum;
currencyCode?: string;
+ mpdInfo: Maybe<
+ Pick<
+ AccountList,
+ | 'activeMpdMonthlyGoal'
+ | 'activeMpdStartAt'
+ | 'activeMpdFinishAt'
+ | 'monthlyGoal'
+ >
+ >;
}
+
export const MonthlyCommitment: React.FC = ({
coachingId,
- goal = 0,
+ accountListType,
currencyCode = 'USD',
+ mpdInfo,
}) => {
const { t } = useTranslation();
const locale = useLocale();
@@ -42,20 +58,19 @@ export const MonthlyCommitment: React.FC = ({
variables: { coachingId },
});
+ const { average: monthlyCommitmentAverage, loading: averageLoading } =
+ useMonthlyCommitmentAverage(coachingId, mpdInfo);
+
+ const monthlyCommitmentGoal = mpdInfo
+ ? calculateMonthlyCommitmentGoal(mpdInfo)
+ : null;
+
const pledges =
- data?.reportPledgeHistories?.map((pledge) => {
- const startDate = pledge?.startDate
- ? DateTime.fromISO(pledge.startDate)
- .toJSDate()
- .toLocaleDateString(locale, {
- month: 'short',
- year: '2-digit',
- })
- : '';
- const received = Math.round(pledge?.recieved ?? 0);
- const committed = Math.round(pledge?.pledged ?? 0);
- return { startDate, received, committed };
- }) ?? [];
+ data?.reportPledgeHistories?.map((pledge) => ({
+ startDate: formatStartDate(pledge?.startDate, locale),
+ received: Math.round(pledge?.received ?? 0),
+ committed: Math.round(pledge?.pledged ?? 0),
+ })) ?? [];
const averageCommitments =
pledges.length > 0
@@ -66,14 +81,51 @@ export const MonthlyCommitment: React.FC = ({
const domainMax = Math.max(
...pledges.map((pledge) => pledge.received),
...pledges.map((pledge) => pledge.committed),
- goal,
+ mpdInfo?.monthlyGoal ?? 0,
);
+
return (
- {t('Monthly Commitments')}
+
+ {mpdInfo &&
+ (!mpdInfo.activeMpdStartAt ||
+ !mpdInfo.activeMpdFinishAt ||
+ !mpdInfo.activeMpdMonthlyGoal) ? (
+
+ {t('MPD info not set up on account list')}
+
+ ) : !mpdInfo || averageLoading ? (
+
+ ) : (
+
+ {t('Monthly Commitment Average: ')}
+
+ {currencyFormat(
+ Math.round(monthlyCommitmentAverage ?? 0),
+ currencyCode,
+ locale,
+ )}
+
+ {' | '}
+ {t('Monthly Commitment Goal: ')}
+
+ {currencyFormat(
+ Math.round(monthlyCommitmentGoal ?? 0),
+ currencyCode,
+ locale,
+ )}
+
+
+ )}
}
/>
@@ -107,9 +159,9 @@ export const MonthlyCommitment: React.FC = ({
- {goal && (
+ {mpdInfo?.monthlyGoal && (
diff --git a/src/components/Coaching/CoachingDetail/MonthlyCommitment/helpers.test.ts b/src/components/Coaching/CoachingDetail/MonthlyCommitment/helpers.test.ts
new file mode 100644
index 000000000..3c4d7cb96
--- /dev/null
+++ b/src/components/Coaching/CoachingDetail/MonthlyCommitment/helpers.test.ts
@@ -0,0 +1,54 @@
+import { calculateMonthlyCommitmentGoal, formatStartDate } from './helpers';
+
+describe('formatStartDate', () => {
+ it('formats the date with the month abbreviation and 2-digit year', () => {
+ expect(formatStartDate('2020-01-01', 'en-US')).toBe('Jan 20');
+ });
+
+ it('returns an empty string when the date is undefined', () => {
+ expect(formatStartDate(undefined, 'en-US')).toBe('');
+ });
+});
+
+describe('calculateMonthlyCommitmentGoal', () => {
+ it('returns null when all info is missing', () => {
+ expect(calculateMonthlyCommitmentGoal({})).toBeNull();
+ });
+
+ it('returns null when goal is missing', () => {
+ expect(
+ calculateMonthlyCommitmentGoal({
+ activeMpdStartAt: '2020-01-01',
+ activeMpdFinishAt: '2020-12-31',
+ }),
+ ).toBeNull();
+ });
+
+ it('returns goal when only goal is present', () => {
+ expect(
+ calculateMonthlyCommitmentGoal({
+ activeMpdMonthlyGoal: 1000,
+ }),
+ ).toBe(1000);
+ });
+
+ it('returns goal when start and end dates are equal', () => {
+ expect(
+ calculateMonthlyCommitmentGoal({
+ activeMpdStartAt: '2020-01-01',
+ activeMpdFinishAt: '2020-01-01',
+ activeMpdMonthlyGoal: 1000,
+ }),
+ ).toBe(1000);
+ });
+
+ it('returns goal divided by number of months between start and end dates', () => {
+ expect(
+ calculateMonthlyCommitmentGoal({
+ activeMpdStartAt: '2020-05-10',
+ activeMpdFinishAt: '2020-08-20',
+ activeMpdMonthlyGoal: 1200,
+ }),
+ ).toBe(400);
+ });
+});
diff --git a/src/components/Coaching/CoachingDetail/MonthlyCommitment/helpers.ts b/src/components/Coaching/CoachingDetail/MonthlyCommitment/helpers.ts
new file mode 100644
index 000000000..e276eeb19
--- /dev/null
+++ b/src/components/Coaching/CoachingDetail/MonthlyCommitment/helpers.ts
@@ -0,0 +1,50 @@
+import { DateTime } from 'luxon';
+import { AccountList } from 'src/graphql/types.generated';
+
+export const formatStartDate = (
+ startDate: string | null | undefined,
+ locale: string,
+): string => {
+ if (!startDate) {
+ return '';
+ }
+
+ return DateTime.fromISO(startDate).toJSDate().toLocaleDateString(locale, {
+ month: 'short',
+ year: '2-digit',
+ });
+};
+
+/*
+ * Calculate the monthly commitment goal for an account list. For example, if the account list is
+ * trying to raise $1200 in 3 months, the monthly commitment goal would be $400/month.
+ */
+export const calculateMonthlyCommitmentGoal = (
+ mpdInfo: Pick<
+ AccountList,
+ 'activeMpdMonthlyGoal' | 'activeMpdStartAt' | 'activeMpdFinishAt'
+ >,
+): number | null => {
+ const {
+ activeMpdMonthlyGoal: goal,
+ activeMpdStartAt: startDate,
+ activeMpdFinishAt: endDate,
+ } = mpdInfo;
+
+ if (typeof goal !== 'number') {
+ return null;
+ }
+
+ if (typeof startDate === 'string' && typeof endDate === 'string') {
+ // Calculate the number of months that the user is on full-time MPD
+ const activeMpdMonths = DateTime.fromISO(endDate)
+ .startOf('month')
+ .diff(DateTime.fromISO(startDate).startOf('month'), 'months').months;
+
+ if (activeMpdMonths > 0) {
+ return goal / activeMpdMonths;
+ }
+ }
+
+ return goal;
+};
diff --git a/src/components/Coaching/CoachingDetail/MonthlyCommitment/useMonthlyCommitmentAverage.test.tsx b/src/components/Coaching/CoachingDetail/MonthlyCommitment/useMonthlyCommitmentAverage.test.tsx
new file mode 100644
index 000000000..45c0984ae
--- /dev/null
+++ b/src/components/Coaching/CoachingDetail/MonthlyCommitment/useMonthlyCommitmentAverage.test.tsx
@@ -0,0 +1,130 @@
+import { ReactElement } from 'react';
+import { waitFor } from '@testing-library/dom';
+import { renderHook } from '@testing-library/react-hooks';
+import { GqlMockedProvider } from '__tests__/util/graphqlMocking';
+import { useMonthlyCommitmentAverage } from './useMonthlyCommitmentAverage';
+
+const accountListId = 'account-list-1';
+const mutationSpy = jest.fn();
+
+const Wrapper = ({ children }: { children: ReactElement }) => (
+
+ {children}
+
+);
+
+describe('useMonthlyCommitmentAverage', () => {
+ it('loading is true and average is null when mpdInfo is loading', () => {
+ const { result } = renderHook(
+ () => useMonthlyCommitmentAverage(accountListId, null),
+ { wrapper: GqlMockedProvider },
+ );
+
+ expect(result.current).toEqual({ loading: true, average: null });
+ });
+
+ it('loading is false and average is null if start date or finish date are not available', () => {
+ const { result } = renderHook(
+ () =>
+ useMonthlyCommitmentAverage(accountListId, {
+ activeMpdStartAt: null,
+ activeMpdFinishAt: null,
+ }),
+ { wrapper: GqlMockedProvider },
+ );
+
+ expect(result.current).toEqual({ loading: false, average: null });
+ });
+
+ it('loading is true and average is null while start and finish months are loading', () => {
+ const { result } = renderHook(
+ () =>
+ useMonthlyCommitmentAverage(accountListId, {
+ activeMpdStartAt: '2019-01-01',
+ activeMpdFinishAt: '2019-04-30',
+ }),
+ { wrapper: GqlMockedProvider },
+ );
+
+ expect(result.current).toEqual({ loading: true, average: null });
+ });
+
+ it('loads pledges for start month and finish month', async () => {
+ const { result } = renderHook(
+ () =>
+ useMonthlyCommitmentAverage(accountListId, {
+ activeMpdStartAt: '2019-01-15',
+ activeMpdFinishAt: '2019-04-15',
+ }),
+ { wrapper: Wrapper },
+ );
+
+ await waitFor(() =>
+ expect(mutationSpy).toHaveGraphqlOperation(
+ 'MonthlyCommitmentSingleMonth',
+ { accountListId, month: '2019-01-31' },
+ ),
+ );
+ await waitFor(() =>
+ expect(mutationSpy).toHaveGraphqlOperation(
+ 'MonthlyCommitmentSingleMonth',
+ { accountListId, month: '2019-04-30' },
+ ),
+ );
+
+ // Increase of $150/month over 3 months
+ expect(result.current).toEqual({ loading: false, average: 50 });
+ });
+
+ it('clamps finish date to the end of the current month', async () => {
+ const { result } = renderHook(
+ () =>
+ useMonthlyCommitmentAverage(accountListId, {
+ activeMpdStartAt: '2019-01-15',
+ activeMpdFinishAt: '2020-04-15',
+ }),
+ { wrapper: Wrapper },
+ );
+
+ await waitFor(() =>
+ expect(mutationSpy).toHaveGraphqlOperation(
+ 'MonthlyCommitmentSingleMonth',
+ { accountListId, month: '2019-01-31' },
+ ),
+ );
+ await waitFor(() =>
+ expect(mutationSpy).toHaveGraphqlOperation(
+ 'MonthlyCommitmentSingleMonth',
+ { accountListId, month: '2020-01-31' },
+ ),
+ );
+
+ // Increase of $150/month over 12 months
+ expect(result.current).toEqual({ loading: false, average: 12.5 });
+ });
+
+ it('average is 0 when start and finish dates are in the same month', async () => {
+ const { result, waitForNextUpdate } = renderHook(
+ () =>
+ useMonthlyCommitmentAverage(accountListId, {
+ activeMpdStartAt: '2019-01-05',
+ activeMpdFinishAt: '2019-01-25',
+ }),
+ { wrapper: Wrapper },
+ );
+
+ await waitForNextUpdate();
+
+ expect(result.current).toEqual({ loading: false, average: 0 });
+ });
+});
diff --git a/src/components/Coaching/CoachingDetail/MonthlyCommitment/useMonthlyCommitmentAverage.ts b/src/components/Coaching/CoachingDetail/MonthlyCommitment/useMonthlyCommitmentAverage.ts
new file mode 100644
index 000000000..51f66a8ae
--- /dev/null
+++ b/src/components/Coaching/CoachingDetail/MonthlyCommitment/useMonthlyCommitmentAverage.ts
@@ -0,0 +1,97 @@
+import { useMemo } from 'react';
+import { DateTime } from 'luxon';
+import { AccountList, Maybe } from 'src/graphql/types.generated';
+import { useMonthlyCommitmentSingleMonthQuery } from './MonthlyCommitment.generated';
+
+type MpdInfo = Pick;
+
+interface MonthlyCommitmentAverageResult {
+ loading: boolean;
+ average: number | null;
+}
+
+/*
+ * Calculate the monthly commitment average for an account list. It represents the amount of net
+ * monthly commitments that the account list has gained per month. For example, if the account list
+ * went from $5000/month committed to $6000/month in 4 months of active MPD, the monthly commitment
+ * average would be $250/month.
+ */
+export const useMonthlyCommitmentAverage = (
+ accountListId: string,
+ mpdInfo: Maybe,
+): MonthlyCommitmentAverageResult => {
+ // Calculate the first month that the user was on active MPD
+ const startMonth = useMemo(() => {
+ if (!mpdInfo?.activeMpdStartAt) {
+ return null;
+ }
+
+ const startAt = DateTime.fromISO(mpdInfo.activeMpdStartAt);
+ if (startAt > DateTime.now()) {
+ // If the start at date is in the future, don't load commitment data for the start at date
+ return null;
+ }
+ return startAt.endOf('month');
+ }, [mpdInfo]);
+
+ // Calculate the last month that the user was on active MPD
+ const finishMonth = useMemo(() => {
+ if (!mpdInfo?.activeMpdFinishAt) {
+ return null;
+ }
+
+ const finishAt = DateTime.fromISO(mpdInfo.activeMpdFinishAt);
+ // If the finish at date is in the future, clamp it to the end of the current month
+ return DateTime.min(finishAt, DateTime.now()).endOf('month');
+ }, [mpdInfo]);
+
+ // Skip these queries until mpdInfo is available and also if the start date or finish date is not set
+ const skip = !startMonth || !finishMonth;
+ const { data: startMonthCommitment, loading: startMonthLoading } =
+ useMonthlyCommitmentSingleMonthQuery({
+ variables: {
+ accountListId,
+ month: startMonth?.toISODate() ?? '',
+ },
+ skip,
+ });
+ const { data: finishMonthCommitment, loading: finishMonthLoading } =
+ useMonthlyCommitmentSingleMonthQuery({
+ variables: {
+ accountListId,
+ month: finishMonth?.toISODate() ?? '',
+ },
+ skip,
+ });
+
+ const monthlyCommitmentAverage = useMemo(() => {
+ if (
+ !mpdInfo ||
+ !startMonth ||
+ !finishMonth ||
+ !startMonthCommitment ||
+ !finishMonthCommitment
+ ) {
+ // The queries are skipped or loading
+ return null;
+ }
+
+ const months = Math.floor(finishMonth.diff(startMonth, 'months').months);
+ if (months === 0) {
+ return 0;
+ }
+
+ const startTotal =
+ (startMonthCommitment.reportPledgeHistories?.[0]?.pledged ?? 0) +
+ (startMonthCommitment.reportPledgeHistories?.[0]?.received ?? 0);
+ const finishTotal =
+ (finishMonthCommitment.reportPledgeHistories?.[0]?.pledged ?? 0) +
+ (finishMonthCommitment.reportPledgeHistories?.[0]?.received ?? 0);
+ return (finishTotal - startTotal) / months;
+ }, [mpdInfo, startMonthCommitment, finishMonthCommitment]);
+
+ return {
+ loading: !mpdInfo || startMonthLoading || finishMonthLoading,
+ average: monthlyCommitmentAverage,
+ };
+};