diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09e8b1f..3989ebd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,8 +37,7 @@ jobs: node-version-file: "package.json" cache: "pnpm" - run: pnpm install --prefer-offline --frozen-lockfile - #- run: pnpm run typecheck - - run: echo "Type Checks are disabled ✅" + - run: pnpm run typecheck vitest: needs: typecheck diff --git a/.vscode/settings.json b/.vscode/settings.json index 00b93fb..fb52593 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -39,5 +39,8 @@ "Readme*": "AUTHORS, Authors, BACKERS*, Backers*, CHANGELOG*, CITATION*, CODEOWNERS, CODE_OF_CONDUCT*, CONTRIBUTING*, CONTRIBUTORS, COPYING*, CREDITS, Changelog*, Citation*, Code_Of_Conduct*, Codeowners, Contributing*, Contributors, Copying*, Credits, GOVERNANCE.MD, Governance.md, HISTORY.MD, History.md, LICENSE*, License*, MAINTAINERS, Maintainers, README-*, README_*, RELEASE_NOTES*, ROADMAP.MD, Readme-*, Readme_*, Release_Notes*, Roadmap.md, SECURITY.MD, SPONSORS*, Security.md, Sponsors*, authors, backers*, changelog*, citation*, code_of_conduct*, codeowners, contributing*, contributors, copying*, credits, governance.md, history.md, license*, maintainers, readme-*, readme_*, release_notes*, roadmap.md, security.md, sponsors*", "README*": "AUTHORS, Authors, BACKERS*, Backers*, CHANGELOG*, CITATION*, CODEOWNERS, CODE_OF_CONDUCT*, CONTRIBUTING*, CONTRIBUTORS, COPYING*, CREDITS, Changelog*, Citation*, Code_Of_Conduct*, Codeowners, Contributing*, Contributors, Copying*, Credits, GOVERNANCE.MD, Governance.md, HISTORY.MD, History.md, LICENSE*, License*, MAINTAINERS, Maintainers, README-*, README_*, RELEASE_NOTES*, ROADMAP.MD, Readme-*, Readme_*, Release_Notes*, Roadmap.md, SECURITY.MD, SPONSORS*, Security.md, Sponsors*, authors, backers*, changelog*, citation*, code_of_conduct*, codeowners, contributing*, contributors, copying*, credits, governance.md, history.md, license*, maintainers, readme-*, readme_*, release_notes*, roadmap.md, security.md, sponsors*", "Dockerfile": "*.dockerfile, .devcontainer.*, .dockerignore, captain-definition, compose.*, docker-compose.*, dockerfile*" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome" } } diff --git a/app/components/game/PlayerCursors.tsx b/app/components/game/PlayerCursors.tsx deleted file mode 100644 index 3aa9cfa..0000000 --- a/app/components/game/PlayerCursors.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { Player } from "~/routes/rooms.$roomId" - -export function PlayerCursors({ players }: { players: Player[] }) { - return ( - <> - {players.map( - (player) => - player.isActive && ( -
-
{player.player?.name}
-
- ) - )} - - ) -} diff --git a/app/realtime/player.ts b/app/realtime/player.ts new file mode 100644 index 0000000..f479e5b --- /dev/null +++ b/app/realtime/player.ts @@ -0,0 +1,38 @@ +import type { RealtimeMessage } from "@supabase/supabase-js" +import { queryClient } from "~/root" +import type { RoomLoaderData } from "~/routes/rooms.$roomId" + +export const handlePlayerUpdate = + (roomId: string, serverLoader: () => Promise) => async (payload: RealtimeMessage["payload"]) => { + const newItem = payload.new as { + player_id: string + isActive: boolean + score: number + id: string + scrollPosition: { + x: number + y: number + } + } + // biome-ignore lint/style/noNonNullAssertion: This will be there + const data = queryClient.getQueryData(["room", roomId])! + if (!data.players.find((p) => p.playerId === newItem.player_id)) { + const data = await serverLoader() + + return queryClient.setQueryData(["room", roomId], data) + } + queryClient.setQueryData(["room", roomId], { + ...data, + players: data.players.map((player) => { + if (player.playerId === newItem.player_id) { + return { + ...player, + isActive: newItem.isActive, + score: newItem.score, + scrollPosition: newItem.scrollPosition, + } + } + return player + }), + }) + } diff --git a/app/realtime/room.ts b/app/realtime/room.ts new file mode 100644 index 0000000..8406724 --- /dev/null +++ b/app/realtime/room.ts @@ -0,0 +1,20 @@ +import type { RealtimeMessage } from "@supabase/supabase-js" +import { queryClient } from "~/root" +import type { RoomLoaderData } from "~/routes/rooms.$roomId" + +export const handleRoomUpdate = (roomId: string) => (payload: RealtimeMessage["payload"]) => { + const newRoomInfo = payload.new + // biome-ignore lint/style/noNonNullAssertion: This will be there + const data = queryClient.getQueryData(["room", roomId])! + if (payload.new.id !== data.room.id) return + queryClient.setQueryData(["room", data.room.id], { + ...data, + room: { + ...data.room, + ...newRoomInfo, + flippedIndices: newRoomInfo.flippedIndices ?? data.room.flippedIndices, + matchedPairs: newRoomInfo.matchedPairs ?? data.room.matchedPairs, + currentTurn: newRoomInfo.current_turn, + }, + }) +} diff --git a/app/root.tsx b/app/root.tsx index 2772926..ff5c830 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,3 +1,4 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { useTranslation } from "react-i18next" import { Form, @@ -16,7 +17,8 @@ import type { Route } from "./+types/root" import { getUserFromRequest } from "./queries/user.server" import { commitServerSession, getServerSession } from "./session.server" import tailwindcss from "./tailwind.css?url" - +// Create a client +export const queryClient = new QueryClient() export async function loader({ context, request }: Route.LoaderArgs) { const { lang, clientEnv } = context const player = await getUserFromRequest(request) @@ -75,7 +77,7 @@ export const Layout = ({ children }: { children: React.ReactNode }) => { - {children} + {children} diff --git a/app/routes/rooms.$roomId.tsx b/app/routes/rooms.$roomId.tsx index 7ec20fe..05f7eae 100644 --- a/app/routes/rooms.$roomId.tsx +++ b/app/routes/rooms.$roomId.tsx @@ -1,11 +1,15 @@ +import { useQuery } from "@tanstack/react-query" import { QRCodeSVG } from "qrcode.react" -import { useEffect, useState } from "react" -import { type LoaderFunctionArgs, redirect, useRevalidator, useSubmit } from "react-router" +import { useEffect } from "react" +import { type LoaderFunctionArgs, redirect, useSubmit } from "react-router" import { Grid } from "~/components/game/Grid" import { Leaderboard } from "~/components/game/Leaderboard" import { VictoryScreen } from "~/components/game/VictoryScreen" import { db } from "~/db.server" import { getUserFromRequest } from "~/queries/user.server" +import { handlePlayerUpdate } from "~/realtime/player" +import { handleRoomUpdate } from "~/realtime/room" +import { queryClient } from "~/root" import { supabase } from "~/utils/supabase" import type { Route } from "./+types/rooms.$roomId" @@ -51,6 +55,10 @@ export async function loader({ params, request }: LoaderFunctionArgs) { return { room, players: room.activePlayers, cards: room.cards, user, qrCode } } +export type RoomLoaderData = Awaited> +export type Player = Awaited>["players"][0] +export type Room = Awaited>["room"] + export const action = async ({ request }: Route.ActionArgs) => { const formData = await request.formData() const playerId = formData.get("playerId") as string | null @@ -70,25 +78,60 @@ export const action = async ({ request }: Route.ActionArgs) => { return redirect("/") } -export type Player = Awaited>["players"][0] -export type Room = Awaited>["room"] +export const clientLoader = async ({ serverLoader, params }: Route.ClientLoaderArgs) => { + // Try to get the data from the cache + const cachedData = queryClient.getQueryData(["room", params.roomId]) + // Either used the cached data or fetch it from the server + const data = cachedData ?? (await serverLoader()) + // Don't set the data if it's already there + if (!cachedData) { + queryClient.setQueryData(["room", params.roomId], data) + } + // Subscribe to real-time updates on the room + const roomSubscription = supabase + .channel("rooms") + .on("postgres_changes", { event: "*", schema: "public", table: "rooms" }, handleRoomUpdate(params.roomId)) + .subscribe() + // Subscribe to real-time updates on the active players + const playerSubscription = supabase + .channel("active_players") + .on( + "postgres_changes", + { event: "*", schema: "public", table: "active_players" }, + handlePlayerUpdate(params.roomId, serverLoader) + ) + .subscribe() + + const unsubscribe = () => { + roomSubscription.unsubscribe() + playerSubscription.unsubscribe() + } + return { + ...data, + unsubscribe, + } +} -export default function Room({ loaderData }: Route.ComponentProps) { - const { room, players, cards, user, qrCode } = loaderData - const [activeRoom, setActiveRoom] = useState(room) - const [activePlayers, setActivePlayers] = useState(players) - const showVictory = activeRoom.status === "complete" - const submit = useSubmit() - useEffect(() => { - setActiveRoom(room) - }, [room]) +clientLoader.hydrate = true +export default function Room({ loaderData, params }: Route.ComponentProps) { + const { room, cards, user, qrCode } = loaderData + const submit = useSubmit() + const { data } = useQuery({ queryKey: ["room", params.roomId] }) + // Close the channels when the user leaves the page useEffect(() => { - setActivePlayers(players) - }, [players]) + return () => { + if ("unsubscribe" in loaderData) { + loaderData.unsubscribe() + } + } + }, [loaderData]) + if (!data) return
Loading...
+ const activeRoom = data.room + const activePlayers = data.players + const showVictory = activeRoom.status === "complete" - const { revalidate } = useRevalidator() - const markAsInactive = () => { + const leaveGame = () => { const formData = new FormData() const activePlayer = activePlayers.find((p) => p.playerId === user.id) if (!activePlayer) return @@ -96,113 +139,51 @@ export default function Room({ loaderData }: Route.ComponentProps) { formData.append("playerId", user.id) submit(formData, { method: "POST" }) } - // Subscribe to real-time updates - useEffect(() => { - const roomSubscription = supabase - .channel("rooms") - .on("postgres_changes", { event: "*", schema: "public", table: "rooms" }, (payload) => { - const newItem = payload.new as { - id: string - created_at: Date - gridSize: number - status: string - created_by: string - current_turn: string | null - winnerId: string | null - matchedPairs: string[] - flippedIndices: number[] - cards: string[] - name: string - } - setActiveRoom({ - ...room, - ...newItem, - flippedIndices: newItem.flippedIndices ?? room.flippedIndices, - matchedPairs: newItem.matchedPairs ?? room.matchedPairs, - currentTurn: newItem.current_turn, - }) - }) - .subscribe() - - const playerSubscription = supabase - .channel("active_players") - .on("postgres_changes", { event: "*", schema: "public", table: "active_players" }, (payload) => { - const newItem = payload.new as { - player_id: string - isActive: boolean - score: number - id: string - scrollPosition: { - x: number - y: number - } - } - - if (!activePlayers.find((p) => p.playerId === newItem.player_id)) { - revalidate() - } - setActivePlayers( - activePlayers.map((player) => { - if (player.playerId === newItem.player_id) { - return { - ...player, - isActive: newItem.isActive, - score: newItem.score, - scrollPosition: newItem.scrollPosition, - } - } - return player - }) - ) - - //revalidate() - }) - .subscribe() - - return () => { - roomSubscription.unsubscribe() - playerSubscription.unsubscribe() - } - }, [revalidate, activePlayers, room]) - + // Update room data + const updateRoom = async (data: Partial) => { + await supabase.from("rooms").update(data).eq("id", activeRoom.id) + } + const updatePlayer = async (data: Partial) => { + await supabase.from("active_players").update(data).eq("room_id", activeRoom.id).eq("player_id", user.id) + } // Handle card clicks const handleCardClick = async (index: number) => { + // Don't do anything if there are already two flipped cards if (activeRoom.flippedIndices.length === 2) return + // Add the clicked card to the flipped indices const newFlippedIndices = [...activeRoom.flippedIndices, index] - await supabase - .from("rooms") - .update({ flippedIndices: [...newFlippedIndices] }) - .eq("id", room.id) + // Update the room with the new flipped indices so everyone can see the change + await updateRoom({ flippedIndices: [...newFlippedIndices] }) + // Check if it's a match if two cards are flipped if (activeRoom.flippedIndices.length === 1) { + // Check if the two cards are the same const isMatch = cards[activeRoom.flippedIndices[0]] === cards[index] const currentPlayerIndex = activePlayers.findIndex((p) => p.player?.name === activeRoom.currentTurn) const currentPlayer = activePlayers[currentPlayerIndex] + // If it's a match, update the player's score and the room's status if (isMatch) { const newMatchedPairs = [...activeRoom.matchedPairs, cards[index]] - const isFinished = newMatchedPairs.length === room.gridSize * 2 - await supabase - .from("active_players") - .update({ score: currentPlayer.score + 1 }) - .eq("room_id", room.id) - .eq("player_id", user.id) - await supabase - .from("rooms") - .update({ matchedPairs: newMatchedPairs, status: isFinished ? "complete" : "waiting" }) - .eq("id", room.id) - if (isFinished) { - return - } + // Check if the game is finished and update the room and the player + const isFinished = newMatchedPairs.length === activeRoom.gridSize * 2 + // Update the player's score + await updatePlayer({ score: currentPlayer.score + 1 }) + // Update the room's status and matched pairs + await updateRoom({ matchedPairs: newMatchedPairs, status: isFinished ? "complete" : "waiting" }) + // Revert the flipped cards so the game can go on + setTimeout(async () => { + await updateRoom({ flippedIndices: [] }) + }, 1000) + // Exit early so that we don't go into the setTimeout below + return } setTimeout(async () => { - await supabase.from("rooms").update({ flippedIndices: [] }).eq("id", room.id) - const nextPlayer = activePlayers[currentPlayerIndex + 1] - if (nextPlayer === undefined) { - await supabase.from("rooms").update({ current_turn: activePlayers[0].player?.name }).eq("id", room.id) - } else { - await supabase.from("rooms").update({ current_turn: nextPlayer.player?.name }).eq("id", room.id) - } + // Move to the next player + await updateRoom({ + flippedIndices: [], + current_turn: !nextPlayer ? activePlayers[0].player?.name : nextPlayer.player?.name, + }) }, 1000) } } @@ -215,7 +196,7 @@ export default function Room({ loaderData }: Route.ComponentProps) {

{activeRoom.name} -

@@ -224,7 +205,7 @@ export default function Room({ loaderData }: Route.ComponentProps) {
- size={activeRoom.gridSize as any} + size={room.gridSize as any} cards={cards} flippedIndices={activeRoom.flippedIndices} matchedPairs={activeRoom.matchedPairs} diff --git a/package.json b/package.json index 368f62c..b6976aa 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "test": "vitest run", "test:ui": "vitest --ui --api 9527", "test:cov": "vitest run --coverage", - "typecheck": "tsc", + "typecheck": "react-router typegen && tsc", "validate": "pnpm run check && pnpm run typecheck && pnpm run test && pnpm run check:unused", "check": "biome check .", "check:fix": "biome check --fix .", @@ -38,6 +38,7 @@ "@prisma/client": "6.0.1", "@react-router/node": "7.0.1", "@supabase/supabase-js": "2.39.3", + "@tanstack/react-query": "5.62.2", "clsx": "2.1.1", "hono": "4.6.12", "i18next": "23.15.2", @@ -86,7 +87,7 @@ }, "engines": { "node": ">=v22.11.0", - "pnpm": ">=9.14.4" + "pnpm": ">=9.14.2" }, - "packageManager": "pnpm@9.14.4" + "packageManager": "pnpm@9.14.2" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e1e10c..86368c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@supabase/supabase-js': specifier: 2.39.3 version: 2.39.3 + '@tanstack/react-query': + specifier: 5.62.2 + version: 5.62.2(react@18.3.1) clsx: specifier: 2.1.1 version: 2.1.1 @@ -1423,6 +1426,14 @@ packages: '@supabase/supabase-js@2.39.3': resolution: {integrity: sha512-NoltJSaJNKDJNutO5sJPAAi5RIWrn1z2XH+ig1+cHDojT6BTN7TvZPNa3Kq3gFQWfO5H1N9El/bCTZJ3iFW2kQ==} + '@tanstack/query-core@5.62.2': + resolution: {integrity: sha512-LcwVcC5qpsDpHcqlXUUL5o9SaOBwhNkGeV+B06s0GBoyBr8FqXPuXT29XzYXR36lchhnerp6XO+CWc84/vh7Zg==} + + '@tanstack/react-query@5.62.2': + resolution: {integrity: sha512-fkTpKKfwTJtVPKVR+ag7YqFgG/7TRVVPzduPAUF9zRCiiA8Wu305u+KJl8rCrh98Qce77vzIakvtUyzWLtaPGA==} + peerDependencies: + react: ^18 || ^19 + '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} @@ -4462,6 +4473,13 @@ snapshots: - bufferutil - utf-8-validate + '@tanstack/query-core@5.62.2': {} + + '@tanstack/react-query@5.62.2(react@18.3.1)': + dependencies: + '@tanstack/query-core': 5.62.2 + react: 18.3.1 + '@types/cookie@0.6.0': {} '@types/d3-hierarchy@1.1.11': {}