added connection status

This commit is contained in:
MasterGordon 2025-09-11 14:47:51 +02:00
parent 53a3a5d44f
commit f2183f0d15
6 changed files with 153 additions and 46 deletions

View File

@ -37,12 +37,20 @@ export const handleRequest = async (
!message || !message ||
!(typeof message === "object") || !(typeof message === "object") ||
!("type" in message) || !("type" in message) ||
!("payload" in message) ||
!("id" in message) !("id" in message)
) )
return; return;
const { type, payload, id } = message; const { type, id } = message;
if (!(typeof type === "string")) return; if (!(typeof type === "string")) return;
// Handle ping message
if (type === 'ping') {
ws.send(JSON.stringify({ type: 'pong', id }));
return;
}
if (!("payload" in message)) return;
const { payload } = message;
const [controllerName, action] = type.split("."); const [controllerName, action] = type.split(".");
if (!(controllerName in controllers)) return; if (!(controllerName in controllers)) return;
try { try {

View File

@ -15,3 +15,6 @@ interface LootboxResult {
lootbox: string; lootbox: string;
} }
export const lootboxResultAtom = atom<LootboxResult | undefined>(); export const lootboxResultAtom = atom<LootboxResult | undefined>();
export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'reconnecting';
export const connectionStatusAtom = atom<ConnectionStatus>('connecting');

View File

@ -0,0 +1,40 @@
import { useAtom } from "jotai";
import { connectionStatusAtom, type ConnectionStatus as ConnectionStatusType } from "../atoms";
import { Wifi, WifiOff } from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
const statusConfig: Record<ConnectionStatusType, { color: string; icon: React.ReactNode; label: string }> = {
connecting: { color: "text-yellow-400", icon: <Wifi className="w-3 h-3" />, label: "Connecting..." },
connected: { color: "text-green-400", icon: <Wifi className="w-3 h-3" />, label: "Connected" },
disconnected: { color: "text-red-400", icon: <WifiOff className="w-3 h-3" />, label: "Disconnected" },
reconnecting: { color: "text-yellow-400", icon: <Wifi className="w-3 h-3" />, label: "Reconnecting..." },
};
export const ConnectionStatus = () => {
const [status] = useAtom(connectionStatusAtom);
const config = statusConfig[status];
// Only show when not connected
const shouldShow = status !== 'connected';
return (
<AnimatePresence>
{shouldShow && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className={`flex items-center gap-1.5 text-xs ${config.color} bg-white/5 px-2 py-1 rounded-md backdrop-blur-sm`}
>
<motion.div
animate={status === 'connecting' || status === 'reconnecting' ? { rotate: 360 } : {}}
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
>
{config.icon}
</motion.div>
<span className="font-medium">{config.label}</span>
</motion.div>
)}
</AnimatePresence>
);
};

View File

@ -15,6 +15,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { loginTokenAtom } from "../atoms"; import { loginTokenAtom } from "../atoms";
import Gems from "./Gems"; import Gems from "./Gems";
import { ConnectionStatus } from "./ConnectionStatus";
const Header = () => { const Header = () => {
const [, setLocation] = useLocation(); const [, setLocation] = useLocation();
@ -28,7 +29,8 @@ const Header = () => {
const { data: gems } = useWSQuery("user.getOwnGems", null); const { data: gems } = useWSQuery("user.getOwnGems", null);
return ( return (
<div className="w-full flex gap-4"> <div className="w-full flex gap-4 items-center">
<ConnectionStatus />
<div className="grow" /> <div className="grow" />
{username ? ( {username ? (

View File

@ -9,19 +9,22 @@ import {
import PastMatch from "../../components/PastMatch"; import PastMatch from "../../components/PastMatch";
import GemsIcon from "../../components/GemIcon"; import GemsIcon from "../../components/GemIcon";
const TouchTooltip = ({ children, content, date, games }: { const TouchTooltip = ({
children: React.ReactNode; children,
content: React.ReactNode; date,
games,
}: {
children: React.ReactNode;
date: string; date: string;
games: number; games: number;
}) => { }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isTouchDevice, setIsTouchDevice] = useState(false); const [isTouchDevice, setIsTouchDevice] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout>(); const timeoutRef = useRef<Timer>();
useEffect(() => { useEffect(() => {
// Detect if device supports touch // Detect if device supports touch
setIsTouchDevice('ontouchstart' in window || navigator.maxTouchPoints > 0); setIsTouchDevice("ontouchstart" in window || navigator.maxTouchPoints > 0);
}, []); }, []);
const handleTouch = () => { const handleTouch = () => {
@ -42,7 +45,11 @@ const TouchTooltip = ({ children, content, date, games }: {
if (isTouchDevice) { if (isTouchDevice) {
return ( return (
<Tooltip open={isOpen}> <Tooltip open={isOpen}>
<TooltipTrigger asChild onClick={handleTouch} onTouchStart={handleTouch}> <TooltipTrigger
asChild
onClick={handleTouch}
onTouchStart={handleTouch}
>
{children} {children}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
@ -56,9 +63,7 @@ const TouchTooltip = ({ children, content, date, games }: {
// For non-touch devices, use default hover behavior // For non-touch devices, use default hover behavior
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>{children}</TooltipTrigger>
{children}
</TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>{date}</p> <p>{date}</p>
<p>{games} Games Played</p> <p>{games} Games Played</p>
@ -90,41 +95,40 @@ const Profile: React.FC = () => {
!!username, !!username,
); );
const now = useMemo(() => dayjs(), []); const now = useMemo(() => dayjs(), []);
const startDate = useMemo(() => now.subtract(availableWeeks, "weeks").startOf("week"), [now, availableWeeks]);
const maxHeat = heatmap ? Math.max(...heatmap) : 0; const maxHeat = heatmap ? Math.max(...heatmap) : 0;
// Calculate available space and adjust weeks accordingly // Calculate available space and adjust weeks accordingly
useEffect(() => { useEffect(() => {
const calculateAvailableWeeks = () => { const calculateAvailableWeeks = () => {
if (!containerRef.current) return; if (!containerRef.current) return;
const containerWidth = containerRef.current.offsetWidth; const containerWidth = containerRef.current.offsetWidth;
const isMobile = window.innerWidth < 640; // sm breakpoint const isMobile = window.innerWidth < 640; // sm breakpoint
// Calculate dot and gap sizes based on breakpoint // Calculate dot and gap sizes based on breakpoint
const dotWidth = isMobile ? 16 : 24; // w-4 = 16px, w-6 = 24px const dotWidth = isMobile ? 16 : 24; // w-4 = 16px, w-6 = 24px
const gapSize = isMobile ? 4 : 8; // gap-1 = 4px, gap-2 = 8px const gapSize = isMobile ? 4 : 8; // gap-1 = 4px, gap-2 = 8px
// Each week takes: dotWidth + gapSize // Each week takes: dotWidth + gapSize
const weekWidth = dotWidth + gapSize; const weekWidth = dotWidth + gapSize;
// Calculate max weeks that fit, with some padding // Calculate max weeks that fit, with some padding
const maxWeeks = Math.floor((containerWidth - 32) / weekWidth); // 32px for padding const maxWeeks = Math.floor((containerWidth - 32) / weekWidth); // 32px for padding
// Limit between 4 weeks (1 month) and 26 weeks (6 months) // Limit between 4 weeks (1 month) and 26 weeks (6 months)
const weeks = Math.max(4, Math.min(26, maxWeeks)); const weeks = Math.max(4, Math.min(26, maxWeeks));
setAvailableWeeks(weeks); setAvailableWeeks(weeks);
}; };
// Use setTimeout to ensure DOM is fully rendered // Use setTimeout to ensure DOM is fully rendered
const timer = setTimeout(calculateAvailableWeeks, 0); const timer = setTimeout(calculateAvailableWeeks, 0);
window.addEventListener('resize', calculateAvailableWeeks); window.addEventListener("resize", calculateAvailableWeeks);
return () => { return () => {
clearTimeout(timer); clearTimeout(timer);
window.removeEventListener('resize', calculateAvailableWeeks); window.removeEventListener("resize", calculateAvailableWeeks);
}; };
}, []); }, []);
@ -133,19 +137,19 @@ const Profile: React.FC = () => {
if (heatmap && containerRef.current) { if (heatmap && containerRef.current) {
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
const containerWidth = containerRef.current.offsetWidth; const containerWidth = containerRef.current.offsetWidth;
const isMobile = window.innerWidth < 640; const isMobile = window.innerWidth < 640;
const dotWidth = isMobile ? 16 : 24; const dotWidth = isMobile ? 16 : 24;
const gapSize = isMobile ? 4 : 8; const gapSize = isMobile ? 4 : 8;
const weekWidth = dotWidth + gapSize; const weekWidth = dotWidth + gapSize;
const maxWeeks = Math.floor((containerWidth - 32) / weekWidth); const maxWeeks = Math.floor((containerWidth - 32) / weekWidth);
const weeks = Math.max(4, Math.min(26, maxWeeks)); const weeks = Math.max(4, Math.min(26, maxWeeks));
setAvailableWeeks(weeks); setAvailableWeeks(weeks);
}, 100); }, 100);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
}, [heatmap]); }, [heatmap]);
@ -153,7 +157,9 @@ const Profile: React.FC = () => {
return ( return (
<div className="grid md:[grid-template-columns:_2fr_3fr] gap-6 px-2 sm:px-0"> <div className="grid md:[grid-template-columns:_2fr_3fr] gap-6 px-2 sm:px-0">
<div className="mx-4 my-8 md:m-8 text-white flex flex-col sm:flex-row self-center"> <div className="mx-4 my-8 md:m-8 text-white flex flex-col sm:flex-row self-center">
<div className="p-2 flex items-center text-2xl mb-2 sm:mb-0">{username}</div> <div className="p-2 flex items-center text-2xl mb-2 sm:mb-0">
{username}
</div>
<div className="border-l-0 sm:border-l-white sm:border-l p-2 text-lg"> <div className="border-l-0 sm:border-l-white sm:border-l p-2 text-lg">
<p>Total Games: {profile?.totalGames}</p> <p>Total Games: {profile?.totalGames}</p>
<p>Highest Stage: {profile?.highestStage}</p> <p>Highest Stage: {profile?.highestStage}</p>
@ -178,22 +184,21 @@ const Profile: React.FC = () => {
<div className="overflow-x-auto" ref={containerRef}> <div className="overflow-x-auto" ref={containerRef}>
<div className="flex gap-1 sm:gap-2 min-w-fit"> <div className="flex gap-1 sm:gap-2 min-w-fit">
{Array.from({ length: availableWeeks }).map((_, w) => ( {Array.from({ length: availableWeeks }).map((_, w) => (
<div key={w} className="w-4 sm:w-6 flex gap-1 sm:gap-2 flex-col"> <div
key={w}
className="w-4 sm:w-6 flex gap-1 sm:gap-2 flex-col"
>
{Array.from({ length: 7 }).map((_, d) => { {Array.from({ length: 7 }).map((_, d) => {
const index = (heatmap.length - (availableWeeks * 7)) + (w * 7 + d); const index =
heatmap.length - availableWeeks * 7 + (w * 7 + d);
if (index < 0 || index >= heatmap.length) return; if (index < 0 || index >= heatmap.length) return;
const date = now const date = now
.clone() .clone()
.subtract((availableWeeks * 7) - 1 - (w * 7 + d), "days") .subtract(availableWeeks * 7 - 1 - (w * 7 + d), "days")
.format("DD/MM/YYYY"); .format("DD/MM/YYYY");
return ( return (
<TouchTooltip <TouchTooltip key={d} date={date} games={heatmap[index]}>
key={d} <button
date={date}
games={heatmap[index]}
content={null}
>
<button
className="w-3 h-3 sm:w-5 sm:h-5 border border-white touch-manipulation" className="w-3 h-3 sm:w-5 sm:h-5 border border-white touch-manipulation"
type="button" type="button"
> >

View File

@ -1,6 +1,8 @@
import type { Routes } from "../backend/router"; import type { Routes } from "../backend/router";
import type { Events } from "../shared/events"; import type { Events } from "../shared/events";
import { queryClient } from "./queryClient"; import { queryClient } from "./queryClient";
import { connectionStatusAtom } from "./atoms";
import { getDefaultStore } from "jotai";
const connectionString = import.meta.env.DEV const connectionString = import.meta.env.DEV
? "ws://localhost:8072/ws" ? "ws://localhost:8072/ws"
@ -21,11 +23,43 @@ const emitMessage = (event: MessageEvent) => {
}; };
const createWSClient = () => { const createWSClient = () => {
const store = getDefaultStore();
let ws = new WebSocket(connectionString); let ws = new WebSocket(connectionString);
let reconnectAttempts = 0; let reconnectAttempts = 0;
const maxReconnectAttempts = 5; const maxReconnectAttempts = 5;
let heartbeatInterval: number | null = null;
let heartbeatTimeoutId: number | null = null;
const HEARTBEAT_INTERVAL = 30000; // 30 seconds
const HEARTBEAT_TIMEOUT = 5000; // 5 seconds to wait for pong
const startHeartbeat = () => {
stopHeartbeat();
heartbeatInterval = window.setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ping", id: crypto.randomUUID() }));
heartbeatTimeoutId = window.setTimeout(() => {
console.warn("Heartbeat timeout - closing connection");
ws.close();
}, HEARTBEAT_TIMEOUT);
}
}, HEARTBEAT_INTERVAL);
};
const stopHeartbeat = () => {
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
if (heartbeatTimeoutId) {
clearTimeout(heartbeatTimeoutId);
heartbeatTimeoutId = null;
}
};
const tryReconnect = () => { const tryReconnect = () => {
store.set(connectionStatusAtom, "reconnecting");
stopHeartbeat();
if (reconnectAttempts < maxReconnectAttempts) { if (reconnectAttempts < maxReconnectAttempts) {
setTimeout(() => { setTimeout(() => {
reconnectAttempts++; reconnectAttempts++;
@ -33,14 +67,18 @@ const createWSClient = () => {
}, 1000 * reconnectAttempts); }, 1000 * reconnectAttempts);
} else { } else {
console.error("Max reconnect attempts reached"); console.error("Max reconnect attempts reached");
store.set(connectionStatusAtom, "disconnected");
} }
}; };
const connect = () => { const connect = () => {
store.set(connectionStatusAtom, "connecting");
ws = new WebSocket(connectionString); ws = new WebSocket(connectionString);
ws.onopen = async () => { ws.onopen = async () => {
reconnectAttempts = 0; reconnectAttempts = 0;
store.set(connectionStatusAtom, "connected");
startHeartbeat();
const token = localStorage.getItem("loginToken"); const token = localStorage.getItem("loginToken");
if (token) { if (token) {
try { try {
@ -69,25 +107,36 @@ const createWSClient = () => {
}); });
addMessageListener((event: MessageEvent) => { addMessageListener((event: MessageEvent) => {
const data = JSON.parse(event.data) as Events; const data = JSON.parse(event.data);
if (data.type === "updateGame") {
// Handle pong response
if (data.type === "pong") {
if (heartbeatTimeoutId) {
clearTimeout(heartbeatTimeoutId);
heartbeatTimeoutId = null;
}
return;
}
const eventData = data as Events;
if (eventData.type === "updateGame") {
queryClient.setQueryData( queryClient.setQueryData(
["game.getGameState", data.game], ["game.getGameState", eventData.game],
data.gameState, eventData.gameState,
); );
} }
if (data.type === "loss") { if (eventData.type === "loss") {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["scoreboard.getScoreBoard", 10], queryKey: ["scoreboard.getScoreBoard", 10],
}); });
} }
if (data.type === "gemsRewarded") { if (eventData.type === "gemsRewarded") {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["user.getOwnGems", null], queryKey: ["user.getOwnGems", null],
}); });
} }
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
console.log("Received message", data); console.log("Received message", eventData);
} }
}); });