Skip to content

Commit

Permalink
added campaign donation chart
Browse files Browse the repository at this point in the history
  • Loading branch information
tongo-angelov committed Nov 14, 2023
1 parent bd5784c commit 87af6fc
Show file tree
Hide file tree
Showing 17 changed files with 740 additions and 0 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"axios": "1.6.0",
"axios-hooks": "2.7.0",
"chart.js": "^4.4.0",
"chartjs-plugin-annotation": "^3.0.1",
"chartjs-plugin-datalabels": "^2.2.0",
"date-fns": "2.24.0",
"dompurify": "^3.0.3",
Expand Down
19 changes: 19 additions & 0 deletions public/locales/bg/campaigns.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,25 @@
"deleteContent": "Това действие ще изтрие елемента завинаги!",
"deleteAllContent": "Това действие ще изтрие избраните елементи завинаги!",
"actions": "Действия",
"statistics": {
"button": "Статистика за даренията",
"backButton": "Обратно към кампанията",
"cachedMessage": "Графиките използват кеширани данни!",
"cumulativeTitle": "История на натрупване на сумата от даренията",
"cumulativeDesc": "Графиката показва как се е натрупвала сумата на дарения през времето от началото на кампанията досега. Периоди с равна графика показват затишие, когато е нямало нови дарения.",
"total": "Общо",
"groupedTitle": "Брой дарения в периода",
"groupedDesc": "Графиката показва колко броя дарения са се случили през периода групирани по ден, седмица или месец.",
"count": "Дарения",
"groupBy": "Групиране по",
"day": "Ден",
"week": "Седмица",
"month": "Месец",
"uniqueTitle": "Предпочитани суми за дарения",
"uniqueDesc": "Графиката показва уникалните суми и техния брой и дава видимост върху какви суми са склонни да даряват потребителите.",
"hourlyTitle": "Предпочитани часове за дарение",
"hourlyDesc": "Графиката показва колко дарения са направени в кои часове от деня и дава видимост за активността на дарителите през деня."
},
"alerts": {
"selectRow": "Моля изберете ред",
"create": "Кампанията беше създадено успешно!",
Expand Down
19 changes: 19 additions & 0 deletions public/locales/en/campaigns.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,25 @@
"deleteContent": "This action cannot be undone.",
"deleteAllContent": "This action cannot be undone.",
"actions": "Actions",
"statistics": {
"button": "Donation statistics",
"backButton": "Go back",
"cachedMessage": "Please note that the charts are using cached data!",
"cumulativeTitle": "Cumulative donations chart",
"cumulativeDesc": "Shows total donated amount to date",
"total": "Total",
"groupedTitle": "Grouped donations chart",
"groupedDesc": "Shows total donated amount per selected period",
"count": "Donations",
"groupBy": "Group by",
"day": "Day",
"week": "Week",
"month": "Month",
"uniqueTitle": "Unique donations chart",
"uniqueDesc": "Shows number of donations with the same unique amount",
"hourlyTitle": "Hourly donations chart",
"hourlyDesc": "Shows number of donations for each hour of the day"
},
"alerts": {
"selectRow": "Please select a row",
"create": "Campaign created successfully",
Expand Down
23 changes: 23 additions & 0 deletions src/common/hooks/campaigns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,16 @@ import {
AdminCampaignResponse,
AdminSingleCampaignResponse,
CampaignDonationHistoryResponse,
CampaignGroupedDonations,
CampaignUniqueDonations,
CampaignHourlyDonations,
} from 'gql/campaigns'
import { DonationStatus } from 'gql/donations.enums'
import { apiClient } from 'service/apiClient'
import { useCurrentPerson } from 'common/util/useCurrentPerson'
import { isAdmin } from 'common/util/roles'
import { AxiosError } from 'axios'
import { StatisticsGroupBy } from 'components/client/campaigns/helpers/campaign.enums'

// NOTE: shuffling the campaigns so that each gets its fair chance to be on top row
export const campaignsOrderQueryFunction: QueryFunction<CampaignResponse[]> = async ({
Expand Down Expand Up @@ -109,6 +113,25 @@ export function useCampaignDetailsPage(id: string) {
)
}

export function useCampaignGroupedDonations(
campaignId: string,
groupBy: StatisticsGroupBy = StatisticsGroupBy.DAY,
) {
return useQuery<CampaignGroupedDonations[]>([
endpoints.statistics.getGroupedDonations(campaignId, groupBy).url,
])
}
export function useCampaignUniqueDonations(campaignId: string) {
return useQuery<CampaignUniqueDonations[]>([
endpoints.statistics.getUniqueDonations(campaignId).url,
])
}
export function useCampaignHourlyDonations(campaignId: string) {
return useQuery<CampaignHourlyDonations[]>([
endpoints.statistics.getHourlyDonations(campaignId).url,
])
}

export function useCampaignDonationHistory(
campaignId?: string,
pageindex?: number,
Expand Down
3 changes: 3 additions & 0 deletions src/common/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ export const routes = {
slug ? `/campaigns/${slug}/news?page=${page}` : `/campaigns/news?page=${page}`,
newsAdminPanel: (slug: string) => `/campaigns/${slug}/news/admin-panel`,
},
statistics: {
viewBySlug: (slug: string) => `/campaigns/${slug}/statistics`,
},
},
donation: {
viewCertificate: (donationId: string) => `/api/pdf/certificate/${donationId}`,
Expand Down
8 changes: 8 additions & 0 deletions src/common/util/chartjs.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { TooltipPositionerFunction } from 'chart.js'

declare module 'chart.js' {
// Extend tooltip positioner map
interface TooltipPositionerMap {
cursor: TooltipPositionerFunction<ChartType>
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Box, Chip, Grid } from '@mui/material'

import { getExactDate } from 'common/util/date'
import theme from 'common/theme'
import { routes } from 'common/routes'

import { StatusText, StatusLabel, RowWrapper, InfoStatusWrapper } from './CampaignInfo.styled'

Expand Down Expand Up @@ -55,6 +56,16 @@ export default function CampaignInfoStatus({ campaign, showExpensesLink }: Props
size="small"
sx={{ backgroundColor: theme.palette.primary.light }}
/>
{!!campaign.summary.reachedAmount && (
<Chip
component="a"
label={t('campaigns:statistics.button')}
href={routes.campaigns.statistics.viewBySlug(campaign.slug)}
clickable
size="small"
sx={{ backgroundColor: theme.palette.primary.light }}
/>
)}
</Box>
<InfoStatusWrapper>
<Grid item xs={12} md={6}>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import React from 'react'
import { useTranslation } from 'next-i18next'
import { addHours } from 'date-fns'

import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip as ChartTooltip,
Filler,
Legend,
ChartOptions,
TooltipItem,
Plugin,
} from 'chart.js'
import { Line } from 'react-chartjs-2'
import annotationPlugin from 'chartjs-plugin-annotation'
import ChartDataLabels from 'chartjs-plugin-datalabels'

import { fromMoney, moneyPublic, toMoney } from 'common/util/money'
import { CampaignGroupedDonations } from 'gql/campaigns'
import { useCampaignGroupedDonations } from 'common/hooks/campaigns'
import { Box, Stack, Typography } from '@mui/material'

ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
ChartTooltip,
Filler,
Legend,
annotationPlugin,
ChartDataLabels,
)

ChartTooltip.positioners.cursor = function (chartElements, coordinates) {
return coordinates
}

const plugins: Plugin[] = [
{
id: 'tooltipLine',
afterDraw: (chart) => {
if (chart.tooltip?.opacity === 1) {
const { ctx } = chart
const { caretX } = chart.tooltip
const topY = chart.scales.y.top
const bottomY = chart.scales.y.bottom

ctx.save()
ctx.setLineDash([3, 3])
ctx.beginPath()
ctx.moveTo(caretX, topY - 5)
ctx.lineTo(caretX, bottomY)
ctx.lineWidth = 1
ctx.stroke()
ctx.restore()
}
},
},
]

type Props = {
campaignId: string
startDate?: Date
endDate?: Date
targetAmount: number
}

export default function CumulativeDonationsChart({
campaignId,
startDate,
endDate,
targetAmount,
}: Props) {
const { t } = useTranslation()
const { data: statistics } = useCampaignGroupedDonations(campaignId)
if (!statistics?.length) return <div />

const finalData: CampaignGroupedDonations[] = []
let sum = 0

let previousDate = startDate ? new Date(startDate) : new Date(statistics[0].date)

const end = endDate ? new Date(endDate) : new Date()

statistics.forEach((donation) => {
while (previousDate < new Date(donation.date)) {
finalData.push({ date: previousDate.toISOString(), count: 0, sum })
previousDate = addHours(previousDate, 24)
}
sum += donation.sum
finalData.push({ ...donation, sum })
previousDate = addHours(previousDate, 24)
})

while (previousDate < end) {
finalData.push({ date: previousDate.toISOString(), count: 0, sum })
previousDate = addHours(previousDate, 24)
}

const options: ChartOptions<'line'> = {
elements: {
point: {
radius: 0,
},
},
responsive: true,
plugins: {
legend: {
display: false,
},
tooltip: {
displayColors: false,
position: 'cursor',
callbacks: {
label: (context: TooltipItem<'line'>) => {
return ` ${context.dataset.label + ':' || ''} ${moneyPublic(toMoney(context.parsed.y))}`
},
},
},
datalabels: {
display: false,
},
annotation: {
annotations: [
{
type: 'line',
yMin: fromMoney(targetAmount),
yMax: fromMoney(targetAmount),
borderColor: 'rgb(85, 195, 132)',
borderWidth: 2,
},
],
},
},
interaction: {
intersect: false,
mode: 'index',
},
}

const data = {
labels: finalData.map((donation) => donation.date.slice(0, 10)),
datasets: [
{
fill: true,
label: t('campaigns:statistics.total'),
data: finalData.map((donation) => fromMoney(donation.sum)),
borderColor: 'rgb(53, 162, 235)',
backgroundColor: 'rgba(53, 162, 235, 0.5)',
},
],
}

return (
<Box>
<Stack style={{ marginBottom: '10px' }}>
<Typography variant="h6">{t('campaigns:statistics.cumulativeTitle')}</Typography>
<Typography variant="caption">{t('campaigns:statistics.cumulativeDesc')}</Typography>
</Stack>
<Line options={options} data={data} plugins={plugins} />
</Box>
)
}
Loading

0 comments on commit 87af6fc

Please sign in to comment.