Skip to content

Commit

Permalink
Website: Clean up RecipientStatsCalculator and various small fixes/ad…
Browse files Browse the repository at this point in the history
…justments (#1045)
  • Loading branch information
mkue authored Feb 13, 2025
1 parent 4a127f8 commit dd2e3fb
Show file tree
Hide file tree
Showing 8 changed files with 111 additions and 133 deletions.
3 changes: 2 additions & 1 deletion shared/src/types/contribution.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { DocumentReference } from 'firebase-admin/firestore';
import { Campaign } from './campaign';
import { Currency } from './currency';
import { Timestamp } from './timestamp';

Expand Down Expand Up @@ -33,7 +34,7 @@ type BaseContribution = {
amount_chf: number; // Amount donated in CHF, including fees
fees_chf: number; // Transaction fees in CHF
currency: Currency;
campaign_path?: DocumentReference;
campaign_path?: DocumentReference<Campaign>;
};

export type StripeContribution = BaseContribution & {
Expand Down
109 changes: 52 additions & 57 deletions shared/src/utils/stats/RecipientStatsCalculator.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { DocumentReference, Timestamp } from 'firebase-admin/firestore';
import { Timestamp } from 'firebase-admin/firestore';
import functions from 'firebase-functions-test';
import { FirestoreAdmin } from '../../firebase/admin/FirestoreAdmin';
import { getOrInitializeFirebaseAdmin } from '../../firebase/admin/app';
import { PARTNER_ORGANISATION_FIRESTORE_PATH, PartnerOrganisation } from '../../types/partner-organisation';
import {
RECIPIENT_FIRESTORE_PATH,
Recipient,
RECIPIENT_FIRESTORE_PATH,
RecipientMainLanguage,
RecipientProgramStatus,
} from '../../types/recipient';
Expand All @@ -22,41 +22,23 @@ beforeAll(async () => {
calculator = await RecipientStatsCalculator.build(firestoreAdmin);
});

test('totalRecipients(): Calculate total recipients', async () => {
expect(calculator.allStats().totalRecipients.total).toEqual(3);
});

test('totalRecipients(): Calculate active recipients', async () => {
expect(calculator.allStats().totalRecipients.active).toEqual(1);
});

test('totalRecipientsByOrganization(): Calculate total recipients for a particular organisation', async () => {
expect(calculator.allStats().totalRecipientsByOrganization['aurora'].total).toEqual(2);
});

test('totalRecipientsByOrganization(): Calculate active recipients for a particular organisation', async () => {
expect(calculator.allStats().totalRecipientsByOrganization['aurora'].active).toEqual(1);
});

test('totalRecipientsByOrganization(): Calculate suspended recipients for a particular organisation', async () => {
expect(calculator.allStats().totalRecipientsByOrganization['aurora'].suspended).toEqual(1);
});

test('totalRecipientsByOrganization(): Calculate recipients for a non-existing organisation', async () => {
expect(calculator.allStats().totalRecipientsByOrganization['socialincome']?.total).toEqual(undefined);
});

const org1: PartnerOrganisation = {
name: 'aurora',
name: 'Aurora',
contactName: 'test1',
contactNumber: '123',
communitySize: 10,
};
const org1Ref = firestoreAdmin.collection<PartnerOrganisation>(PARTNER_ORGANISATION_FIRESTORE_PATH).doc('aurora');

const org2: PartnerOrganisation = {
name: 'socialincome',
name: 'Social Income',
contactName: 'test2',
contactNumber: '456',
communitySize: 20,
};
const org2Ref = firestoreAdmin
.collection<PartnerOrganisation>(PARTNER_ORGANISATION_FIRESTORE_PATH)
.doc('social-income');

const recipient1: Recipient = {
birth_date: new Date('1990-05-15'),
Expand All @@ -76,7 +58,7 @@ const recipient1: Recipient = {
phone: 9876543210,
has_whatsapp: true,
},
organisation: { id: 'aurora' } as DocumentReference<PartnerOrganisation>,
organisation: org1Ref,
om_uid: 12345,
profession: 'Software Engineer',
progr_status: RecipientProgramStatus.Active,
Expand Down Expand Up @@ -104,7 +86,7 @@ const recipient2: Recipient = {
phone: 9876543210,
has_whatsapp: true,
},
organisation: { id: 'aurora' } as DocumentReference<PartnerOrganisation>,
organisation: org2Ref,
om_uid: 12345,
profession: 'Software Engineer',
progr_status: RecipientProgramStatus.Suspended,
Expand Down Expand Up @@ -132,7 +114,7 @@ const recipient3: Recipient = {
phone: 9876543210,
has_whatsapp: true,
},
organisation: { id: 'socialincome' } as DocumentReference<PartnerOrganisation>,
organisation: org2Ref,
om_uid: 12345,
profession: 'Software Engineer',
progr_status: RecipientProgramStatus.Waitlisted,
Expand All @@ -143,30 +125,43 @@ const recipient3: Recipient = {
};

const insertTestData = async () => {
await firestoreAdmin.collection<PartnerOrganisation>(PARTNER_ORGANISATION_FIRESTORE_PATH).doc('aurora').set(org1);
await firestoreAdmin
.collection<PartnerOrganisation>(PARTNER_ORGANISATION_FIRESTORE_PATH)
.doc('socialincome')
.set(org2);
const recipient1WithOrgRef: Recipient = {
...recipient1,
organisation: firestoreAdmin
.collection<PartnerOrganisation>(PARTNER_ORGANISATION_FIRESTORE_PATH)
.doc('aurora') as DocumentReference<PartnerOrganisation>,
};
const recipient2WithOrgRef: Recipient = {
...recipient2,
organisation: firestoreAdmin
.collection<PartnerOrganisation>(PARTNER_ORGANISATION_FIRESTORE_PATH)
.doc('aurora') as DocumentReference<PartnerOrganisation>,
};
const recipient3WithOrgRef: Recipient = {
...recipient3,
organisation: firestoreAdmin
.collection<PartnerOrganisation>(PARTNER_ORGANISATION_FIRESTORE_PATH)
.doc('socialincome') as DocumentReference<PartnerOrganisation>,
};
await firestoreAdmin.collection<Recipient>(RECIPIENT_FIRESTORE_PATH).add(recipient1WithOrgRef);
await firestoreAdmin.collection<Recipient>(RECIPIENT_FIRESTORE_PATH).add(recipient2WithOrgRef);
await firestoreAdmin.collection<Recipient>(RECIPIENT_FIRESTORE_PATH).add(recipient3WithOrgRef);
await org1Ref.set(org1);
await org2Ref.set(org2);
await firestoreAdmin.collection<Recipient>(RECIPIENT_FIRESTORE_PATH).add(recipient1);
await firestoreAdmin.collection<Recipient>(RECIPIENT_FIRESTORE_PATH).add(recipient2);
await firestoreAdmin.collection<Recipient>(RECIPIENT_FIRESTORE_PATH).add(recipient3);
};

test('Calculate recipient statistics', async () => {
const stats = calculator.allStats();

// Verify total number of recipients across all organizations
expect(stats.recipientsCountByStatus['total']).toEqual(3);

// Verify number of recipients by status
expect(stats.recipientsCountByStatus[RecipientProgramStatus.Active]).toEqual(1);
expect(stats.recipientsCountByStatus[RecipientProgramStatus.Suspended]).toEqual(1);
expect(stats.recipientsCountByStatus[RecipientProgramStatus.Waitlisted]).toEqual(1);
expect(stats.recipientsCountByStatus[RecipientProgramStatus.Former]).toEqual(undefined);

// Verify total number of recipients for organization 1
expect(stats.recipientsCountByOrganisationAndStatus[org1Ref.id]['total']).toEqual(1);

// Verify number of active recipients for organization 1
expect(stats.recipientsCountByOrganisationAndStatus[org1Ref.id][RecipientProgramStatus.Active]).toEqual(1);

// Verify suspended recipients status:
// - Organization 1 should have no suspended recipients (undefined) and 1 active recipient
// - Organization 2 should have 1 suspended recipient
expect(stats.recipientsCountByOrganisationAndStatus[org1Ref.id][RecipientProgramStatus.Suspended]).toEqual(undefined);
expect(stats.recipientsCountByOrganisationAndStatus[org1Ref.id][RecipientProgramStatus.Active]).toEqual(1);
expect(stats.recipientsCountByOrganisationAndStatus[org2Ref.id][RecipientProgramStatus.Suspended]).toEqual(1);
expect(stats.recipientsCountByOrganisationAndStatus[org2Ref.id][RecipientProgramStatus.Active]).toEqual(undefined);
expect(stats.recipientsCountByOrganisationAndStatus[org2Ref.id][RecipientProgramStatus.Waitlisted]).toEqual(1);

// Verify total recipients count for each organization:
// - Organization 1 should have 1 recipient
// - Organization 2 should have 2 recipients
expect(stats.recipientsCountByOrganisationAndStatus[org1Ref.id].total).toEqual(1);
expect(stats.recipientsCountByOrganisationAndStatus[org2Ref.id].total).toEqual(2);
});
88 changes: 35 additions & 53 deletions shared/src/utils/stats/RecipientStatsCalculator.ts
Original file line number Diff line number Diff line change
@@ -1,81 +1,63 @@
import _ from 'lodash';
import { FirestoreAdmin } from '../../firebase/admin/FirestoreAdmin';
import { RECIPIENT_FIRESTORE_PATH, Recipient, RecipientProgramStatus, recipientNGOs } from '../../types/recipient';
import { Recipient, RECIPIENT_FIRESTORE_PATH, RecipientProgramStatus } from '../../types/recipient';

export interface RecipientStats {
totalRecipients: TotalRecipientsByStatus;
totalRecipientsByOrganization: OrganisationRecipientsByStatus;
recipientsCountByStatus: TotalRecipientsByStatus;
recipientsCountByOrganisationAndStatus: OrganisationRecipientsByStatus;
}

/**
* Simplified version of Recipient, for easy computation of several contribution related stats
*/
type RecipientStatsEntry = {
progr_status: RecipientProgramStatus;
organisation: string;
};

export type TotalRecipientsByStatus = {
total: number;
active: number;
former: number;
suspended: number;
};
[status in RecipientProgramStatus]: number;
} & { total: number };

export type OrganisationRecipientsByStatus = {
[orgId: string]: TotalRecipientsByStatus;
};

export class RecipientStatsCalculator {
constructor(readonly recipients: _.Collection<RecipientStatsEntry>) {}
constructor(readonly recipients: _.Collection<Pick<Recipient, 'progr_status' | 'organisation'>>) {}

/**
* Calls the firestore database to retrieve the payments and constructs the
* RecipientStatsCalculator with the flattened intermediate data structure.
*/
static async build(firestoreAdmin: FirestoreAdmin): Promise<RecipientStatsCalculator> {
const completeRecipientsData = await firestoreAdmin.collection<Recipient>(RECIPIENT_FIRESTORE_PATH).get();
const recipientStatsEntries = await Promise.all(
completeRecipientsData.docs
.filter((recipientData) => !recipientData.data().test_recipient)
.map(async (recipientData) => {
const organisationSnapshot = await recipientData.data().organisation?.get();
return {
progr_status: recipientData.data().progr_status,
organisation: organisationSnapshot?.id,
};
}),
);
const recipientStatsEntries = completeRecipientsData.docs
.filter((recipientData) => !recipientData.get('test_recipient'))
.map((recipientData) => ({
progr_status: recipientData.get('progr_status'),
organisation: recipientData.get('organisation').id,
}));
return new RecipientStatsCalculator(_(recipientStatsEntries));
}

totalRecipients = (): TotalRecipientsByStatus => {
const recipientsGroupedByProgStatus = _.groupBy(this.recipients.toJSON(), (x) => x.progr_status);
return {
recipientsCountByStatus = (): TotalRecipientsByStatus =>
({
total: this.recipients.size(),
active: recipientsGroupedByProgStatus['active']?.length,
former: recipientsGroupedByProgStatus['former']?.length,
suspended: recipientsGroupedByProgStatus['suspended']?.length,
};
};

totalRecipientsByOrganization = () => {
const orgRecipientsObject: OrganisationRecipientsByStatus = {};
recipientNGOs.forEach((orgId) => {
const totalRecipients = this.recipients.filter((recipient) => recipient.organisation === orgId);
const recipientsGroupedByProgStatus = _.groupBy(totalRecipients.toJSON(), (x) => x.progr_status);
orgRecipientsObject[orgId] = {
total: totalRecipients?.size(),
active: recipientsGroupedByProgStatus['active']?.length,
former: recipientsGroupedByProgStatus['former']?.length,
suspended: recipientsGroupedByProgStatus['suspended']?.length,
};
});
return orgRecipientsObject;
};
...this.recipients
.groupBy('progr_status')
.map((recipients, status) => ({ [status]: recipients.length }))
.reduce((a, b) => ({ ...a, ...b }), {}),
}) as TotalRecipientsByStatus;

recipientsCountByOrganisationAndStatus = () =>
this.recipients
.groupBy('organisation')
.map((recipients, organisation) => ({
[organisation]: {
total: recipients.length,
..._(recipients)
.groupBy('progr_status')
.map((recipients, status) => ({ [status]: recipients.length }))
.reduce((a, b) => ({ ...a, ...b }), {}),
},
}))
.reduce((a, b) => ({ ...a, ...b }), {}) as OrganisationRecipientsByStatus;

allStats = (): RecipientStats => ({
totalRecipients: this.totalRecipients(),
totalRecipientsByOrganization: this.totalRecipientsByOrganization(),
recipientsCountByStatus: this.recipientsCountByStatus(),
recipientsCountByOrganisationAndStatus: this.recipientsCountByOrganisationAndStatus(),
});
}
22 changes: 10 additions & 12 deletions ui/src/components/card/glow-hover-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,15 @@ import React, { LegacyRef } from 'react';
import { cn } from '../../lib/utils';
import { useGlowHover } from '../use-glow-hover';

export const GlowHoverCard = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }) => {
const refCard = useGlowHover({ lightColor: '#CEFF00' });
export const GlowHoverCard: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => {
const refCard = useGlowHover({ lightColor: '#CEFF00' });

return (
<div
ref={refCard as LegacyRef<HTMLDivElement>}
className={cn('bg-card text-card-foreground rounded-lg border-2', className)}
{...props}
/>
);
},
);
return (
<div
ref={refCard as LegacyRef<HTMLDivElement>}
className={cn('bg-card text-card-foreground rounded-lg border-2', className)}
{...props}
/>
);
};
GlowHoverCard.displayName = 'GlowHoverCard';
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ export async function NgoList({ lang, region }: DefaultParams) {
const ngoCardPropsArray: NgoCardProps[] = [];

const recipientCalculator = await RecipientStatsCalculator.build(firestoreAdmin);
const recipientStats: OrganisationRecipientsByStatus = recipientCalculator.allStats().totalRecipientsByOrganization;
const recipientStats: OrganisationRecipientsByStatus =
recipientCalculator.allStats().recipientsCountByOrganisationAndStatus;

for (let i = 0; i < ngoArray.length; ++i) {
const currentOrgRecipientStats = recipientStats[ngos[i]];
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { RecipientProgramStatus } from '@socialincome/shared/src/types/recipient';
import { Translator } from '@socialincome/shared/src/utils/i18n';
import { Badge, Card, CardContent, Typography } from '@socialincome/ui';
import { Section1Props } from './page';
Expand Down Expand Up @@ -38,17 +39,16 @@ export async function Section1({ params, paymentStats, contributionStats, recipi
{translator.t('section-1.totalRecipients', {
context: {
value:
recipientStats?.totalRecipients?.active +
recipientStats?.totalRecipients?.former +
recipientStats?.totalRecipients?.suspended,
recipientStats.recipientsCountByStatus['total'] -
recipientStats.recipientsCountByStatus[RecipientProgramStatus.Waitlisted],
},
})}
</Typography>
<Badge variant="interactive-accent">
<Typography size="sm" weight="normal">
{translator.t('section-1.activeRecipients', {
context: {
value: recipientStats?.totalRecipients.active,
value: recipientStats.recipientsCountByStatus[RecipientProgramStatus.Active],
},
})}
</Typography>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@ export default function Page() {
const { currency } = useI18n();

useEffect(() => {
redirect('./recipient-selection/' + currency?.toLowerCase());
if (currency) redirect('./recipient-selection/' + currency.toLowerCase());
}, [currency]);
}
9 changes: 5 additions & 4 deletions website/src/components/navbar/navbar-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -289,16 +289,17 @@ const DesktopNavigation = ({ lang, region, languages, regions, currencies, navig
</div>
<SIIcon className="-mb-2.5 block h-9 pr-20 lg:hidden" />
</Link>
<div className="absolute left-0 mt-[50px] hidden flex-col justify-start overflow-visible whitespace-nowrap group-hover/navbar:flex group-active/navbar:flex">
<NavbarLink href={`/${lang}/${region}/me`}>{translations.myProfile}</NavbarLink>
<div className="absolute left-0 mt-[50px] hidden flex-col justify-start space-y-2 overflow-visible whitespace-nowrap group-hover/navbar:flex group-active/navbar:flex">
<NavbarLink href={`/${lang}/${region}/journal`}>{translations.journal}</NavbarLink>
<NavbarLink className="mt-auto" href={`/${lang}/${region}/me`}>
{translations.myProfile}
</NavbarLink>
<div className="flex-inline mt-auto flex items-center space-x-2">
<DonateIcon className="h-4 w-4" />
<NavbarLink href={`/${lang}/${region}/donate/individual`} className="text-accent">
{translations.donate}
</NavbarLink>
</div>

<NavbarLink href={`/${lang}/${region}/journal`}>{translations.journal}</NavbarLink>
</div>
</div>
<div className="flex flex-row items-center justify-evenly gap-x-10 overflow-visible">
Expand Down

0 comments on commit dd2e3fb

Please sign in to comment.