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, + }; +};