-
-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
11 changed files
with
930 additions
and
250 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
</> | ||
) | ||
} |
Oops, something went wrong.