Skip to content

Commit

Permalink
feat(Overview): Replace outdated view
Browse files Browse the repository at this point in the history
  • Loading branch information
PrettyCoffee committed Mar 29, 2024
1 parent 66c2351 commit 2d02c06
Show file tree
Hide file tree
Showing 15 changed files with 169 additions and 310 deletions.
2 changes: 1 addition & 1 deletion src/data/games.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export interface Game extends Omit<RawGame, "playerId"> {
player?: Player
}

const gamesAtom = atom<RawGame[]>({
export const gamesAtom = atom<RawGame[]>({
defaultValue: [],
name: "games",
middleware: [
Expand Down
31 changes: 29 additions & 2 deletions src/data/gamesExternal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ import { useCallback, useEffect } from "react"
import { reduxDevtools } from "@yaasl/devtools"
import { atom, expiration, localStorage, useAtom } from "@yaasl/react"

import { createId } from "~/utils/createId"
import { WEEK } from "~/utils/date"
import { parseMarkdownTable } from "~/utils/parseMarkdownTable"

import { gamesAtom } from "./games"
import { githubAtom } from "./github"
import { playersAtom } from "./players"
import { fetchRepoFile } from "./service/fetchRepoFile"

export interface UserStats {
Expand All @@ -23,7 +26,7 @@ export interface GameStats {
player2: UserStats
}

const gamesAtom = atom<GameStats[] | null>({
export const externalGamesAtom = atom<GameStats[] | null>({
defaultValue: null,
name: "games-external",
middleware: [
Expand All @@ -46,7 +49,7 @@ const splitUserStats = (userStats: string) => {
}
}
export const useGames = () => {
const [games, setGames] = useAtom(gamesAtom)
const [games, setGames] = useAtom(externalGamesAtom)
const [{ filePath }] = useAtom(githubAtom)

const refreshGames = useCallback(() => {
Expand Down Expand Up @@ -80,3 +83,27 @@ export const useGames = () => {

return { games, refreshGames }
}

externalGamesAtom.subscribe(value => {
const players = playersAtom.get()
const j = players.find(player => player.name.startsWith("J")) ?? players[0]!
const f = players.find(player => player.name.startsWith("F")) ?? players[1]!
gamesAtom.set(
(value ?? []).map(({ date, name, player1, player2 }) => ({
id: createId(),
name,
date,
playerId: "",
stats: {
[f.id]: {
playtime: player1.playtime,
rating: player1.rating,
},
[j.id]: {
playtime: player2.playtime,
rating: player2.rating,
},
},
}))
)
})
11 changes: 1 addition & 10 deletions src/pages/Pages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { ErrorBoundary } from "~/components/ErrorBoundary"
import { Icon } from "~/components/Icon"
import { Link } from "~/components/Link"
import { Navigation } from "~/components/Navigation"
import { useGithub } from "~/data/github"
import { usePlayers } from "~/data/players"
import { useSettings } from "~/data/settings"
import { useHashRouter } from "~/hooks/useHashRouter"
Expand Down Expand Up @@ -50,7 +49,6 @@ const ErrorFallback = () => (
export const Pages = () => {
const [current, setCurrent] = useHashRouter({ fallback: routes[0], routes })
const [{ compactNavigation }] = useSettings()
const { token, filePath, repoName, repoOwner } = useGithub()

const { players } = usePlayers()
const [showInit, setShowInit] = useState(players.length < 1)
Expand All @@ -68,18 +66,11 @@ export const Pages = () => {
const currentRoute = routes.find(({ value }) => value === current)
const ActiveView = currentRoute?.component ?? (() => null)

const hasAllGithubOptions = token && filePath && repoName && repoOwner
const enabledRoutes = routes.map(route =>
route.value === "overview" && hasAllGithubOptions
? { ...route, disabled: false, hint: undefined }
: route
)

return (
<div className={cn("h-full flex-1 overflow-hidden", layout)}>
<nav className={cn("-my-2 p-2", navigation)}>
<Navigation
items={enabledRoutes}
items={routes}
value={current}
onClick={setCurrent}
compact={compactNavigation}
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
269 changes: 69 additions & 200 deletions src/pages/overview/Overview.tsx
Original file line number Diff line number Diff line change
@@ -1,219 +1,88 @@
import { useCallback, useEffect, useRef, useState } from "react"
import { useState } from "react"

import { RefreshCw, Star } from "lucide-react"

import { Icon } from "~/components/Icon"
import { LoadingData } from "~/components/LoadingData"
import { NoData } from "~/components/NoData"
import { Button } from "~/components/ui/button"
import { Table } from "~/components/ui/table"
import { GameStats, UserStats, useGames } from "~/data/gamesExternal"
import { usePlayers } from "~/data/players"
import { textColor } from "~/utils/colors"
import { noOverflow } from "~/utils/styles"
import { cn } from "~/utils/utils"

const averageStat = (
games: GameStats[],
user: keyof Omit<GameStats, "name" | "date" | "color">,
stat: keyof UserStats
) => {
const validGames = games.filter(game => typeof game[user][stat] === "number")
const sum = validGames.reduce((acc, game) => {
const userValue = game[user][stat] ?? 0
return acc + userValue
}, 0)
return sum / validGames.length
}

const averageStats = (games: GameStats[]) => ({
online: {
playtime: averageStat(games, "online", "playtime"),
rating: averageStat(games, "online", "rating"),
},
player1: {
playtime: averageStat(games, "player1", "playtime"),
rating: averageStat(games, "player1", "rating"),
},
player2: {
playtime: averageStat(games, "player2", "playtime"),
rating: averageStat(games, "player2", "rating"),
},
})

const formatNumber = (number?: number) =>
number?.toFixed(1).replace(/\.0$/, "") ?? "-"
import { Game, useGames } from "~/data/games"
import { today } from "~/utils/date"

const Hours = ({ hours }: { hours?: number }) => (
<span className="inline-flex items-center gap-2 whitespace-nowrap">
{formatNumber(hours)} <span className="text-muted-foreground">h</span>
</span>
)
import { GameModal } from "./GameModal"
import { GamesTable } from "./GamesTable"

const Rating = ({ rating }: { rating?: number }) => (
<span className={cn("inline-flex items-center gap-2 whitespace-nowrap")}>
{formatNumber(rating)}
<Icon icon={Star} size="sm" className={textColor({ color: "yellow" })} />
</span>
)

const GamesTable = ({ games }: { games: GameStats[] }) => {
const { players } = usePlayers()
const average = averageStats(games)
const AddGame = ({ label }: { label: string }) => {
const { addGame } = useGames()
const [adding, setAdding] = useState(false)

return (
<Table.Root>
<Table.Header>
<Table.Row className="border-none">
<Table.Head>Name</Table.Head>
<Table.Head>Date</Table.Head>
<Table.Head colSpan={2} className="text-center">
Online
</Table.Head>
{players.map(player => (
<Table.Head key={player.id} colSpan={2} className="text-center">
{player.name}
</Table.Head>
))}
</Table.Row>

{/*
<Table.Row>
<Table.Head>Name</Table.Head>
<Table.Head>Date</Table.Head>
<Table.Head className="text-center">
<Icon icon={Clock} color="current" size="sm" />
</Table.Head>
<Table.Head className="text-center">
<Icon icon={Star} color="current" size="sm" />
</Table.Head>
<Table.Head className="text-center">
<Icon icon={Clock} color="current" size="sm" />
</Table.Head>
<Table.Head className="text-center">
<Icon icon={Star} color="current" size="sm" />
</Table.Head>
<Table.Head className="text-center">
<Icon icon={Clock} color="current" size="sm" />
</Table.Head>
<Table.Head className="text-center">
<Icon icon={Star} color="current" size="sm" />
</Table.Head>
</Table.Row>
*/}
</Table.Header>

<Table.Body>
{games.map(game => (
<Table.Row key={game.name}>
<Table.Cell>{game.name}</Table.Cell>
<Table.Cell className={noOverflow}>{game.date}</Table.Cell>
<Table.Cell className="text-end">
<Hours hours={game.online.playtime} />
</Table.Cell>
<Table.Cell className="text-end">
<Rating rating={game.online.rating} />
</Table.Cell>
<Table.Cell className="text-end">
<Hours hours={game.player1.playtime} />
</Table.Cell>
<Table.Cell className="text-end">
<Rating rating={game.player1.rating} />
</Table.Cell>
<Table.Cell className="text-end">
<Hours hours={game.player2.playtime} />
</Table.Cell>
<Table.Cell className="text-end">
<Rating rating={game.player2.rating} />
</Table.Cell>
</Table.Row>
))}
</Table.Body>

<Table.Footer>
<Table.Row>
<Table.Cell colSpan={2}>Average</Table.Cell>
<Table.Cell align="right">
<Hours hours={average.online.playtime} />
</Table.Cell>
<Table.Cell align="right">
<Rating rating={average.online.rating} />
</Table.Cell>
<Table.Cell align="right">
<Hours hours={average.player1.playtime} />
</Table.Cell>
<Table.Cell align="right">
<Rating rating={average.player1.rating} />
</Table.Cell>
<Table.Cell align="right">
<Hours hours={average.player2.playtime} />
</Table.Cell>
<Table.Cell align="right">
<Rating rating={average.player2.rating} />
</Table.Cell>
</Table.Row>
</Table.Footer>
</Table.Root>
<>
{adding && (
<GameModal
initialValue={{ date: today() }}
onCancel={() => setAdding(false)}
title="Add game"
description={`Fill in th information about your game and click on "Save" to add it.`}
onConfirm={({ date, name, player }) => {
setAdding(false)
if (name && date)
addGame({ date, name, playerId: player?.id ?? "" })
}}
/>
)}
<Button variant="ghost" onClick={() => setAdding(true)}>
{label}
</Button>
</>
)
}

const useDelayValueChange = <T,>(value: T, delay = 500) => {
const [blockedValue, setBlockedValue] = useState(value)
const isBlocked = useRef(false)
const latestValue = useRef(value)
const timeout = useRef<NodeJS.Timeout>()

const blockChanges = useCallback(() => {
isBlocked.current = true
if (timeout.current) {
clearTimeout(timeout.current)
}
timeout.current = setTimeout(() => {
setBlockedValue(latestValue.current)
isBlocked.current = false
}, delay)
}, [delay])

useEffect(() => {
if (value == null) {
setBlockedValue(value)
blockChanges()
return
}

if (isBlocked.current) {
latestValue.current = value
return
}

setBlockedValue(value)
}, [value, blockChanges])

return blockedValue
}

export const Overview = () => {
const { games, refreshGames } = useGames()
const { games, removeGame, editGame } = useGames()
const [editing, setEditing] = useState<Game | undefined>()

const debouncedGames = useDelayValueChange(games, 2000)

if (!debouncedGames)
if (games.length === 0)
return (
<div className="flex size-full items-center justify-center">
<LoadingData label="Loading data..." />
<div className="flex size-full flex-col items-center justify-center gap-4">
<NoData
label={
<>
You didn&apos;t roll any games yet.
<br />
Visit the Game Picker to change that!
</>
}
/>
<AddGame label="Or add one manually" />
</div>
)

return (
<div className="-mr-2 -mt-2 flex h-full flex-col gap-2">
<div className="flex flex-col overflow-auto [&>*]:h-full [&>*]:flex-1">
<GamesTable games={debouncedGames} />
</div>
<div>
<Button variant="flat" onClick={refreshGames}>
<Icon icon={RefreshCw} size="sm" />
Reload
</Button>
<>
{editing && (
<GameModal
initialValue={editing}
onCancel={() => setEditing(undefined)}
title="Edit game"
description={`Edit "${editing.name}" to your liking and click on "Save" to confirm.`}
onConfirm={({ id, name, player, ...rest }) => {
setEditing(undefined)
if (id)
editGame(id, {
name: name?.slice(0, 50),
playerId: player?.id,
...rest,
})
}}
/>
)}

<div className="-m-2 flex h-[calc(100%+theme(height.4))] flex-col gap-2">
<div className="flex flex-col overflow-auto [&>*]:h-full [&>*]:flex-1">
<GamesTable
data={games}
onEdit={setEditing}
onDelete={({ id }) => removeGame(id)}
/>
</div>
</div>
</div>
</>
)
}
File renamed without changes.
Loading

0 comments on commit 2d02c06

Please sign in to comment.