Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/api/useNewPlayersChart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import useSWR from 'swr'
import buildError from '../utils/buildError'
import { Game } from '../entities/game'
import makeValidatedGetRequest from './makeValidatedGetRequest'
import { z } from 'zod'
import { convertDateToUTC } from '../utils/convertDateToUTC'

export const playersChartPayloadSchema = z.object({
date: z.number(),
count: z.number(),
change: z.number()
})

export function useNewPlayersChart(activeGame: Game, startDate: string, endDate: string) {
const fetcher = async ([url]: [string]) => {
const qs = new URLSearchParams({
startDate: convertDateToUTC(startDate),
endDate: convertDateToUTC(endDate)
}).toString()

const res = await makeValidatedGetRequest(`${url}?${qs}`, z.object({
data: z.array(playersChartPayloadSchema)
}))

return res
}

const { data, error } = useSWR(
activeGame && startDate && endDate ? [`/games/${activeGame.id}/charts/new-players`, startDate, endDate] : null,
fetcher
)

return {
players: data?.data ?? [],
loading: !data && !error,
error: error && buildError(error)
}
}
103 changes: 103 additions & 0 deletions src/components/charts/BarChartCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { ReactElement } from 'react'
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
import ChartTick from './ChartTick'
import { format } from 'date-fns'
import ErrorMessage, { TaloError } from '../ErrorMessage'
import TimePeriodPicker from '../TimePeriodPicker'
import { timePeriods } from '../../utils/useTimePeriodAndDates'
import { TimePeriod } from '../../utils/useTimePeriod'
import { LabelledTimePeriod } from '../TimePeriodPicker'

export type BarChartCardBar = {
dataKey: string
fill: string
stackId?: string
}

type BarChartCardProps = {
title: string
data: { date: number, [key: string]: number }[]
bars: BarChartCardBar[]
loading: boolean
error: TaloError | null
emptyMessage: string
timePeriod: TimePeriod | null
onTimePeriodChange: (period: LabelledTimePeriod) => void
height?: number
tooltip: ReactElement
}

export function BarChartCard({
title,
data,
bars,
loading,
error,
emptyMessage,
timePeriod,
onTimePeriodChange,
height = 300,
tooltip
}: BarChartCardProps) {
return (
<div className='hidden md:block border-2 border-gray-700 rounded bg-black space-y-8 p-4'>
<div className='flex items-start justify-between'>
<h2 className='text-xl font-semibold'>{title}</h2>
<TimePeriodPicker
periods={timePeriods}
onPick={onTimePeriodChange}
selectedPeriod={timePeriod}
/>
</div>

{error?.hasKeys === false && <ErrorMessage error={error} />}

{!loading && data.length === 0 &&
<p>{emptyMessage}</p>
}

{data.length > 0 &&
<ResponsiveContainer height={height}>
<BarChart data={data} margin={{ bottom: 20, left: 10 }}>
<CartesianGrid strokeDasharray='4' stroke='#444' vertical={false} />

<XAxis
dataKey='date'
type='category'
tick={(
<ChartTick
transform={(x, y) => `translate(${x},${y}) rotate(-30)`}
formatter={(tick) => format(new Date(tick), 'd MMM')}
/>
)}
/>

<YAxis
allowDecimals={false}
tick={(
<ChartTick
transform={(x, y) => `translate(${x! - 4},${y! - 12})`}
formatter={(tick) => tick}
/>
)}
/>

<Tooltip
content={tooltip}
cursor={{ fill: '#444', opacity: 0.4 }}
/>

{bars.map((bar) => (
<Bar
key={bar.dataKey}
dataKey={bar.dataKey}
fill={bar.fill}
stackId={bar.stackId}
/>
))}
</BarChart>
</ResponsiveContainer>
}
</div>
)
}
22 changes: 22 additions & 0 deletions src/components/charts/BarChartTooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { format } from 'date-fns'
import { ReactNode } from 'react'

type BarChartTooltipProps = {
active?: boolean
payload?: { payload: Record<string, number> }[]
label?: number
formatter: (payload: Record<string, number>) => ReactNode
}

export function BarChartTooltip({ active, payload, label, formatter }: BarChartTooltipProps) {
if (!active || !payload?.length || label === undefined) return null

return (
<div className='bg-white p-4 rounded'>
<p className='text-black font-medium text-sm'>{format(new Date(label), 'dd MMM yyyy')}</p>
<p className='text-black font-mono text-sm font-medium mt-2'>
{formatter(payload[0].payload)}
</p>
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ type Item = {
payload: Payload
}

export default function ChartTooltip({ active, payload, label }: ChartTooltipProps) {
export function EventChartTooltip({ active, payload, label }: ChartTooltipProps) {
const filteredItems = payload?.filter((item: Item) => item.payload.count > 0)

if (!active || filteredItems?.length === 0) return null
Expand Down
64 changes: 64 additions & 0 deletions src/components/charts/NewPlayersChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { useRecoilValue } from 'recoil'
import activeGameState, { SelectedActiveGame } from '../../state/activeGameState'
import useTimePeriodAndDates from '../../utils/useTimePeriodAndDates'
import { BarChartCard } from './BarChartCard'
import { BarChartTooltip } from './BarChartTooltip'
import { useNewPlayersChart } from '../../api/useNewPlayersChart'
import clsx from 'clsx'

const bars = [{ dataKey: 'count', fill: '#6366f1' }]

export function NewPlayersChart() {
const activeGame = useRecoilValue(activeGameState) as SelectedActiveGame

const {
timePeriod,
setTimePeriod,
debouncedStartDate,
debouncedEndDate
} = useTimePeriodAndDates('newPlayersChart')

const { players: chartData, loading, error } = useNewPlayersChart(activeGame, debouncedStartDate, debouncedEndDate)

return (
<BarChartCard
title='New players'
data={chartData}
bars={bars}
loading={loading}
error={error}
emptyMessage='There are no new players for this date range'
timePeriod={timePeriod}
onTimePeriodChange={(period) => setTimePeriod(period.id)}
tooltip={(
<BarChartTooltip
formatter={(p) => {
const count = p.count
const change = p.change

return (
<ul className='text-black grid grid-cols-[1fr_0.5fr] mt-4'>
<li className='flex items-center justify-end font-mono text-sm font-medium'>
{count.toLocaleString()} {count === 1 ? 'player' : 'players'}
</li>

<li
className={clsx(
'ml-2 text-xs text-center p-1 rounded',
{
'bg-red-100 text-red-600': change < 0,
'bg-green-100 text-green-600': change > 0,
'bg-gray-100 text-gray-600': change === 0
}
)}
>
{(change * 100).toFixed(1)}%
</li>
</ul>
)
}}
/>
)}
/>
)
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { render, screen } from '@testing-library/react'
import ChartTooltip from '../ChartTooltip'
import { EventChartTooltip } from '../EventChartTooltip'

const listItemsPerEvent = 3

describe('<ChartTooltip />', () => {
describe('<EventChartTooltip />', () => {
it('should only show events where the count is greater than 0', () => {
render(
<ChartTooltip
<EventChartTooltip
active
payload={[
{
Expand Down Expand Up @@ -35,7 +35,7 @@ describe('<ChartTooltip />', () => {

it('should only render unique event names', () => {
render(
<ChartTooltip
<EventChartTooltip
active
payload={[
{
Expand Down Expand Up @@ -72,7 +72,7 @@ describe('<ChartTooltip />', () => {

it('should not render if there are no items with a count greater than 0', () => {
render(
<ChartTooltip
<EventChartTooltip
active
payload={[
{
Expand Down
6 changes: 3 additions & 3 deletions src/components/events/EventsDisplay.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import ErrorMessage from '../../components/ErrorMessage'
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
import ChartTooltip from '../../components/charts/ChartTooltip'
import { EventChartTooltip } from '../charts/EventChartTooltip'
import ChartTick from '../../components/charts/ChartTick'
import { format } from 'date-fns'
import getEventColour from '../../utils/getEventColour'
Expand Down Expand Up @@ -58,7 +58,7 @@ export default function EventsDisplay({
<div className='pt-4 pl-4 pb-4 w-full'>
<ResponsiveContainer height={600}>
<LineChart margin={{ top: 20, bottom: 20, right: 10 }}>
<CartesianGrid strokeDasharray='4' stroke='#555' vertical={false} />
<CartesianGrid strokeDasharray='4' stroke='#444' vertical={false} />

<XAxis
dataKey='date'
Expand Down Expand Up @@ -88,7 +88,7 @@ export default function EventsDisplay({
)}
/>

{selectedEventNames.length > 0 && <Tooltip content={<ChartTooltip />} />}
{selectedEventNames.length > 0 && <Tooltip content={<EventChartTooltip />} />}

{selectedEventNames.map((eventName) => (
<Line
Expand Down
3 changes: 3 additions & 0 deletions src/pages/Players.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import Page from '../components/Page'
import Table from '../components/tables/Table'
import { Player } from '../entities/player'
import useSearch from '../utils/useSearch'
import { NewPlayersChart } from '../components/charts/NewPlayersChart'

export default function Players() {
const initialSearch = new URLSearchParams(window.location.search).get('search')
Expand All @@ -32,6 +33,8 @@ export default function Players() {

return (
<Page title='Players' isLoading={loading} showBackButton={Boolean(initialSearch)}>
<NewPlayersChart />

{(players.length > 0 || debouncedSearch.length > 0) &&
<div className='flex items-center'>
<div className='w-1/2 grow md:grow-0 lg:w-1/4'>
Expand Down