From ae4ca7ef00d7a2463b88793cc921878f2dd30cca Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Tue, 8 Oct 2024 13:00:01 -0500 Subject: [PATCH 1/6] Fix typo in received field --- pages/api/Schema/reports/pledgeHistories/dataHandler.ts | 2 +- .../Schema/reports/pledgeHistories/pledgeHistories.graphql | 2 +- .../MonthlyCommitment/MonthlyCommitment.graphql | 2 +- .../MonthlyCommitment/MonthlyCommitment.test.tsx | 6 +++--- .../CoachingDetail/MonthlyCommitment/MonthlyCommitment.tsx | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) 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..890fc3113 100644 --- a/pages/api/Schema/reports/pledgeHistories/pledgeHistories.graphql +++ b/pages/api/Schema/reports/pledgeHistories/pledgeHistories.graphql @@ -5,7 +5,7 @@ extend type Query { type ReportsPledgeHistories { id: ID! pledged: Float - recieved: Float + received: Float endDate: ISO8601DateTime startDate: ISO8601DateTime createdAt: ISO8601DateTime diff --git a/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.graphql b/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.graphql index 7aa32d516..103ae7799 100644 --- a/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.graphql +++ b/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.graphql @@ -4,6 +4,6 @@ query GetReportsPledgeHistories($coachingId: ID!) { startDate endDate pledged - recieved + received } } diff --git a/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.test.tsx b/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.test.tsx index 1ec178879..9139c0579 100644 --- a/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.test.tsx +++ b/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.test.tsx @@ -46,7 +46,7 @@ describe('MonthlyCommitment', () => { reportPledgeHistories: [...Array(12)].map((x, i) => ({ startDate: DateTime.local().minus({ month: i }).toISO(), endDate: DateTime.local().minus({ month: i }).toISO(), - recieved: i * 5, + received: i * 5, pledged: i * 10, })), }, @@ -74,13 +74,13 @@ describe('MonthlyCommitment', () => { { startDate: null, endDate: null, - recieved: null, + received: null, pledged: null, }, { startDate: DateTime.local().toISO(), endDate: DateTime.local().toISO(), - recieved: 100, + received: 100, pledged: 200, }, ], diff --git a/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.tsx b/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.tsx index 8c30e9a52..5464b31b8 100644 --- a/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.tsx +++ b/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.tsx @@ -52,7 +52,7 @@ export const MonthlyCommitment: React.FC = ({ year: '2-digit', }) : ''; - const received = Math.round(pledge?.recieved ?? 0); + const received = Math.round(pledge?.received ?? 0); const committed = Math.round(pledge?.pledged ?? 0); return { startDate, received, committed }; }) ?? []; From d7a2ecc7d212dd7072b815f11f91963b982bc9af Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Tue, 8 Oct 2024 16:08:16 -0500 Subject: [PATCH 2/6] Fix designation account id filter --- pages/api/graphql-rest.page.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pages/api/graphql-rest.page.ts b/pages/api/graphql-rest.page.ts index 37cc299dc..d018b664c 100644 --- a/pages/api/graphql-rest.page.ts +++ b/pages/api/graphql-rest.page.ts @@ -447,7 +447,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 +467,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}`, From eb3e89ce35131ec0929463ff0afccbf656d3282e Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Tue, 8 Oct 2024 16:11:27 -0500 Subject: [PATCH 3/6] Add range and endDate arguments to reportPledgeHistories --- .../reports/pledgeHistories/pledgeHistories.graphql | 6 +++++- pages/api/Schema/reports/pledgeHistories/resolvers.ts | 8 ++++++-- pages/api/graphql-rest.page.ts | 10 ++++++++-- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/pages/api/Schema/reports/pledgeHistories/pledgeHistories.graphql b/pages/api/Schema/reports/pledgeHistories/pledgeHistories.graphql index 890fc3113..ef76a212d 100644 --- a/pages/api/Schema/reports/pledgeHistories/pledgeHistories.graphql +++ b/pages/api/Schema/reports/pledgeHistories/pledgeHistories.graphql @@ -1,5 +1,9 @@ extend type Query { - reportPledgeHistories(accountListId: ID!): [ReportsPledgeHistories] + reportPledgeHistories( + accountListId: ID! + range: String + endDate: String + ): [ReportsPledgeHistories] } type ReportsPledgeHistories { 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 d018b664c..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); } From c2e1ba7cafb27c3e3d0ab7fdc5e31ee2d2ccf652 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Wed, 9 Oct 2024 13:49:17 -0500 Subject: [PATCH 4/6] Extract reusable CoachingLink from Activity --- .../CoachingDetail/Activity/Activity.test.tsx | 25 -------- .../CoachingDetail/Activity/Activity.tsx | 61 +++++++------------ .../CoachingDetail/CoachingLink.test.tsx | 25 ++++++++ .../Coaching/CoachingDetail/CoachingLink.tsx | 26 ++++++++ 4 files changed, 74 insertions(+), 63 deletions(-) create mode 100644 src/components/Coaching/CoachingDetail/CoachingLink.test.tsx create mode 100644 src/components/Coaching/CoachingDetail/CoachingLink.tsx 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/CoachingLink.test.tsx b/src/components/Coaching/CoachingDetail/CoachingLink.test.tsx new file mode 100644 index 000000000..ad3a4379c --- /dev/null +++ b/src/components/Coaching/CoachingDetail/CoachingLink.test.tsx @@ -0,0 +1,25 @@ +import { render } from '@testing-library/react'; +import { AccountListTypeEnum } from './CoachingDetail'; +import { CoachingLink } from './CoachingLink'; + +describe('CoachingLink', () => { + 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} + ); From 92f3f62b34a787f7a74b3697375ac0574cb62392 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Wed, 9 Oct 2024 13:56:10 -0500 Subject: [PATCH 5/6] Move start date formatting into a helper function --- .../MonthlyCommitment/MonthlyCommitment.tsx | 20 ++++++------------- .../MonthlyCommitment/helpers.test.ts | 11 ++++++++++ .../MonthlyCommitment/helpers.ts | 15 ++++++++++++++ 3 files changed, 32 insertions(+), 14 deletions(-) create mode 100644 src/components/Coaching/CoachingDetail/MonthlyCommitment/helpers.test.ts create mode 100644 src/components/Coaching/CoachingDetail/MonthlyCommitment/helpers.ts diff --git a/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.tsx b/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.tsx index 5464b31b8..c1ee0775c 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, @@ -24,6 +23,7 @@ import AnimatedCard from 'src/components/AnimatedCard'; import { useLocale } from 'src/hooks/useLocale'; import theme from 'src/theme'; import { useGetReportsPledgeHistoriesQuery } from './MonthlyCommitment.generated'; +import { formatStartDate } from './helpers'; interface MonthlyCommitmentProps { coachingId: string; @@ -43,19 +43,11 @@ export const MonthlyCommitment: React.FC = ({ }); 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?.received ?? 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 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..9618f0e91 --- /dev/null +++ b/src/components/Coaching/CoachingDetail/MonthlyCommitment/helpers.test.ts @@ -0,0 +1,11 @@ +import { 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(''); + }); +}); diff --git a/src/components/Coaching/CoachingDetail/MonthlyCommitment/helpers.ts b/src/components/Coaching/CoachingDetail/MonthlyCommitment/helpers.ts new file mode 100644 index 000000000..54aabe5ba --- /dev/null +++ b/src/components/Coaching/CoachingDetail/MonthlyCommitment/helpers.ts @@ -0,0 +1,15 @@ +import { DateTime } from 'luxon'; + +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', + }); +}; From ca47d0ef51510398b717a859b20a6dae87554c27 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Wed, 9 Oct 2024 14:13:10 -0500 Subject: [PATCH 6/6] Add monthly commitment average and goal --- .../CoachingDetail/CoachingDetail.tsx | 3 +- .../MonthlyCommitment.graphql | 12 ++ .../MonthlyCommitment.stories.tsx | 7 +- .../MonthlyCommitment.test.tsx | 144 +++++++++--------- .../MonthlyCommitment/MonthlyCommitment.tsx | 78 ++++++++-- .../MonthlyCommitment/helpers.test.ts | 45 +++++- .../MonthlyCommitment/helpers.ts | 35 +++++ .../useMonthlyCommitmentAverage.test.tsx | 130 ++++++++++++++++ .../useMonthlyCommitmentAverage.ts | 97 ++++++++++++ 9 files changed, 467 insertions(+), 84 deletions(-) create mode 100644 src/components/Coaching/CoachingDetail/MonthlyCommitment/useMonthlyCommitmentAverage.test.tsx create mode 100644 src/components/Coaching/CoachingDetail/MonthlyCommitment/useMonthlyCommitmentAverage.ts 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 = ({ /> { > @@ -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 9139c0579..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(), - received: 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, - received: null, - pledged: null, - }, - { - startDate: DateTime.local().toISO(), - endDate: DateTime.local().toISO(), - received: 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 c1ee0775c..d2cd59c78 100644 --- a/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.tsx +++ b/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.tsx @@ -20,20 +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 { formatStartDate } from './helpers'; +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,6 +58,13 @@ 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) => ({ startDate: formatStartDate(pledge?.startDate, locale), @@ -58,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, + )} + + + )} } /> @@ -99,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 index 9618f0e91..3c4d7cb96 100644 --- a/src/components/Coaching/CoachingDetail/MonthlyCommitment/helpers.test.ts +++ b/src/components/Coaching/CoachingDetail/MonthlyCommitment/helpers.test.ts @@ -1,4 +1,4 @@ -import { formatStartDate } from './helpers'; +import { calculateMonthlyCommitmentGoal, formatStartDate } from './helpers'; describe('formatStartDate', () => { it('formats the date with the month abbreviation and 2-digit year', () => { @@ -9,3 +9,46 @@ describe('formatStartDate', () => { 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 index 54aabe5ba..e276eeb19 100644 --- a/src/components/Coaching/CoachingDetail/MonthlyCommitment/helpers.ts +++ b/src/components/Coaching/CoachingDetail/MonthlyCommitment/helpers.ts @@ -1,4 +1,5 @@ import { DateTime } from 'luxon'; +import { AccountList } from 'src/graphql/types.generated'; export const formatStartDate = ( startDate: string | null | undefined, @@ -13,3 +14,37 @@ export const formatStartDate = ( 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, + }; +};