Skip to content

Commit

Permalink
Add full history heat map dialog
Browse files Browse the repository at this point in the history
  • Loading branch information
O4epegb committed Sep 15, 2024
1 parent e01f3ca commit 99f88ec
Show file tree
Hide file tree
Showing 11 changed files with 930 additions and 250 deletions.
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
"@floating-ui/react": "^0.26.19",
"@radix-ui/react-dialog": "^1.1.1",
"@react-hookz/web": "^24.0.4",
"@vercel/analytics": "^1.3.1",
"axios": "^1.7.2",
Expand Down
55 changes: 55 additions & 0 deletions apps/web/src/components/ui/Dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { PropsWithChildren, ReactNode } from 'react'

export const Dialog = DialogPrimitive.Root
export const DialogTrigger = DialogPrimitive.Trigger
export const DialogTitle = DialogPrimitive.Title

export const DialogContent = ({
title,
children,
}: PropsWithChildren<{
title?: ReactNode
}>) => {
return (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 bg-black/50 data-[state=open]:animate-overlayShow" />
<DialogPrimitive.Content
aria-describedby={undefined}
className="//w-[750px] //w-full fixed left-[50%] top-[50%] flex max-h-[85vh] max-w-[90vw] translate-x-[-50%] translate-y-[-50%] flex-col overflow-hidden rounded bg-white text-gray-900 focus:outline-none data-[state=open]:animate-contentShow dark:border dark:border-zinc-700 dark:bg-black dark:text-white"
>
<div className="relative flex items-center justify-between p-5">
{title && (
<DialogPrimitive.Title className="m-0 text-xl font-medium">
{title}
</DialogPrimitive.Title>
)}
<DialogPrimitive.Close asChild>
<button
aria-label="Close"
className="flex size-6 appearance-none items-center justify-center rounded-full text-gray-500 hover:bg-gray-100 focus:shadow-[0_0_0_2px] focus:shadow-gray-400 focus:outline-none"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="size-5"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</DialogPrimitive.Close>
</div>
{
// remove aria-describedby={undefined} is description will be used
}
{/* <DialogPrimitive.Description className="text-gray-600 mt-[10px] mb-5 text-[15px] leading-normal">
Description
</DialogPrimitive.Description> */}
<div className="overflow-y-auto px-5 pb-5">{children}</div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
)
}
207 changes: 207 additions & 0 deletions apps/web/src/screens/Player/Calendar/HeatMap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import dayjs from 'dayjs'
import { range } from 'lodash-es'
import { Fragment } from 'react'
import { Tooltip } from '~/components/ui/Tooltip'
import { cn, pluralize, formatNumber } from '~/utils'

export type DayData = {
date: dayjs.Dayjs
games: number
wins: number
winrate: number
}

export const cellSize = 20
export const cellGap = 2
export const monthGap = 8
export const cellPlusGap = cellSize + cellGap

export const HeatMap = ({
monthesWithDays,
maxGames,
invisible,
}: {
monthesWithDays: DayData[][]
maxGames: number
invisible?: boolean
}) => {
return (
<div className={cn('flex gap-2', invisible && 'opacity-0')}>
{monthesWithDays.map((month, monthIndex) => {
const firstDay = month[0]

const games = month.reduce((acc, day) => acc + day.games, 0)
const wins = month.reduce((acc, day) => acc + day.wins, 0)

return (
<div key={monthIndex} className="month space-y-1">
<div className="flex">
<Tooltip
content={
<>
<span className="font-medium">{firstDay.date.format('MMMM YYYY')}</span>
<br />
{games} {pluralize('game', games)}
<br />
{wins} {pluralize('win', wins)}
<br />
{formatNumber((wins / (games || 1)) * 100, {
maximumFractionDigits: 2,
})}
% WR
</>
}
>
<span className="text-xs font-semibold">{firstDay.date.format('MMM')}</span>
</Tooltip>
</div>
<div className="grid grid-flow-col grid-rows-7 gap-0.5">
{month.map((day, dayIndex) => {
let daysToPad = 0

if (dayIndex === 0) {
const startOfWeek = day.date.startOf('week')
daysToPad = day.date.diff(startOfWeek, 'days')
}

return (
<Fragment key={dayIndex}>
{daysToPad > 0 &&
range(daysToPad).map((i) => (
<div key={i} className="day text-center text-transparent" />
))}

<Tooltip
key={dayIndex}
content={
<>
<span className="font-medium">
{day.date.format('LL')}, {day.date.format('dddd')}
</span>
<br />
{day.games} {pluralize('game', day.games)}
<br />
{day.wins} wins
<br />
{formatNumber((day.wins / (day.games || 1)) * 100, {
maximumFractionDigits: 2,
})}
% WR
</>
}
>
<div
className={cn('flex h-5 w-5 items-center justify-center rounded', {
'border border-zinc-300 dark:border-zinc-400': day.games < maxGames * 0.9,
})}
>
<div
className={cn('rounded', {
'size-1': day.games > 0,
'size-2': day.games >= maxGames * 0.2,
'size-3': day.games >= maxGames * 0.55,
'size-full': day.games >= maxGames * 0.9,
'bg-amber-300': day.winrate === 0,
'bg-emerald-300': day.winrate > 0,
'bg-emerald-400': day.winrate >= 0.1,
'bg-emerald-500': day.winrate >= 0.25,
'bg-emerald-600': day.winrate >= 0.5,
})}
/>
</div>
</Tooltip>
</Fragment>
)
})}
</div>
</div>
)
})}
</div>
)
}

export const HeatMapFlat = ({
monthesWithDays,
maxGames,
invisible,
}: {
monthesWithDays: DayData[][]
maxGames: number
invisible?: boolean
}) => {
return (
<div
className={cn(
'relative grid auto-cols-[16px] grid-flow-col grid-rows-[repeat(7,16px)] items-start justify-start gap-0.5 pt-5',
invisible && 'opacity-0',
)}
>
{monthesWithDays.flatMap((month, monthIndex) => {
const firstDay = month[0]
const games = month.reduce((acc, day) => acc + day.games, 0)
const wins = month.reduce((acc, day) => acc + day.wins, 0)

return month.map((day, dayIndex) => {
let daysToPad = 0

if (monthIndex === 0 && dayIndex === 0) {
const startOfWeek = day.date.startOf('week')
daysToPad = day.date.diff(startOfWeek, 'days')
}

return (
<Fragment key={`${dayIndex}-${monthIndex}`}>
{daysToPad > 0 &&
range(daysToPad).map((i) => (
<div key={i} className="text-center text-transparent" />
))}

<div
className={cn('flex size-full items-center justify-center rounded', {
'border border-zinc-300 dark:border-zinc-400': day.games < maxGames * 0.75,
})}
>
{dayIndex === 0 && (
<Tooltip
content={
<>
<span className="font-medium">{firstDay.date.format('MMMM YYYY')}</span>
<br />
{games} {pluralize('game', games)}
<br />
{wins} {pluralize('win', wins)}
<br />
{formatNumber((wins / (games || 1)) * 100, {
maximumFractionDigits: 2,
})}
% WR
</>
}
>
<div className="absolute top-0 text-xs font-semibold">
{day.date.format('MMM')}
</div>
</Tooltip>
)}
<div
className={cn('rounded', {
'size-[30%]': day.games > 0,
'size-[50%]': day.games >= maxGames * 0.25,
'size-[75%]': day.games >= maxGames * 0.5,
'size-full': day.games >= maxGames * 0.75,
'bg-amber-300': day.winrate === 0,
'bg-emerald-300': day.winrate > 0,
'bg-emerald-400': day.winrate >= 0.1,
'bg-emerald-500': day.winrate >= 0.25,
'bg-emerald-600': day.winrate >= 0.5,
})}
/>
</div>
</Fragment>
)
})
})}
</div>
)
}
110 changes: 110 additions & 0 deletions apps/web/src/screens/Player/Calendar/HistoryDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { groupBy, range } from 'lodash-es'
import { useMemo } from 'react'
import useSWRImmutable from 'swr/immutable'
import { api } from '~/api'
import { Dialog, DialogContent, DialogTrigger } from '~/components/ui/Dialog'
import { Loader } from '~/components/ui/Loader'
import { usePlayerPageContext } from '~/screens/Player/context'
import { date } from '~/utils'
import { DayData, HeatMapFlat } from './HeatMap'

export const HistoryDialog = () => {
return (
<Dialog>
<DialogTrigger asChild>
<button className="py-0.5 text-sm text-blue-400 hover:underline">Show full</button>
</DialogTrigger>
<DialogContent title="All time game history">
<Content />
</DialogContent>
</Dialog>
)
}

const Content = () => {
const { player, firstGame } = usePlayerPageContext()

const {
data: calendarData,
isLoading,
error,
} = useSWRImmutable([`/players/${player.id}/calendar`], ([url]) =>
api
.get<{
games: {
endAt: string
isWin: boolean
}[]
}>(url)
.then((res) => groupBy(res.data.games, (game) => date(game.endAt).format('YYYY-MM-DD'))),
)

const yearlyData = useMemo(() => {
const year = date().year()
const startYear = firstGame ? date(firstGame.endAt).year() : year

return range(startYear, year + 1)
.map((year) => {
const start = date().year(year).startOf('year')
const end = date().year(year).endOf('year')

let current = start
const data: DayData[] = []

while (current.isBefore(end)) {
const formattedDate = current.format('YYYY-MM-DD')
const games = calendarData?.[formattedDate]?.length || 0
const wins = calendarData?.[formattedDate]?.filter((game) => game.isWin).length || 0

data.push({
date: current,
games,
wins,
winrate: games ? wins / games : 0,
})

current = current.add(1, 'day')
}

let acc: DayData[] = []
const monthesWithDays: DayData[][] = []
let currentMonth = -1

for (const day of data) {
if (day.date.month() !== currentMonth) {
currentMonth = day.date.month()
acc = []
monthesWithDays.push(acc)
}

acc.push(day)
}

return {
year,
monthesWithDays,
}
})
.reverse()
}, [calendarData])

return (
<>
<div className="flex items-center gap-2">
{isLoading && <Loader />}
{error && <div className="text-sm text-red-600">Error fetching data</div>}
</div>

<div className="space-y-3">
{yearlyData.map((yearData, yearIndex) => {
return (
<div key={yearIndex}>
<h3 className="text-sm font-bold">{yearData.year}</h3>
<HeatMapFlat maxGames={50} monthesWithDays={yearData.monthesWithDays} />
</div>
)
})}
</div>
</>
)
}
Loading

0 comments on commit 99f88ec

Please sign in to comment.