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 ||
!(typeof message === "object") ||
!("type" in message) ||
!("payload" in message) ||
!("id" in message)
)
return;
const { type, payload, id } = message;
const { type, id } = message;
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(".");
if (!(controllerName in controllers)) return;
try {

View File

@ -15,3 +15,6 @@ interface LootboxResult {
lootbox: string;
}
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 { loginTokenAtom } from "../atoms";
import Gems from "./Gems";
import { ConnectionStatus } from "./ConnectionStatus";
const Header = () => {
const [, setLocation] = useLocation();
@ -28,7 +29,8 @@ const Header = () => {
const { data: gems } = useWSQuery("user.getOwnGems", null);
return (
<div className="w-full flex gap-4">
<div className="w-full flex gap-4 items-center">
<ConnectionStatus />
<div className="grow" />
{username ? (

View File

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

View File

@ -1,6 +1,8 @@
import type { Routes } from "../backend/router";
import type { Events } from "../shared/events";
import { queryClient } from "./queryClient";
import { connectionStatusAtom } from "./atoms";
import { getDefaultStore } from "jotai";
const connectionString = import.meta.env.DEV
? "ws://localhost:8072/ws"
@ -21,11 +23,43 @@ const emitMessage = (event: MessageEvent) => {
};
const createWSClient = () => {
const store = getDefaultStore();
let ws = new WebSocket(connectionString);
let reconnectAttempts = 0;
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 = () => {
store.set(connectionStatusAtom, "reconnecting");
stopHeartbeat();
if (reconnectAttempts < maxReconnectAttempts) {
setTimeout(() => {
reconnectAttempts++;
@ -33,14 +67,18 @@ const createWSClient = () => {
}, 1000 * reconnectAttempts);
} else {
console.error("Max reconnect attempts reached");
store.set(connectionStatusAtom, "disconnected");
}
};
const connect = () => {
store.set(connectionStatusAtom, "connecting");
ws = new WebSocket(connectionString);
ws.onopen = async () => {
reconnectAttempts = 0;
store.set(connectionStatusAtom, "connected");
startHeartbeat();
const token = localStorage.getItem("loginToken");
if (token) {
try {
@ -69,25 +107,36 @@ const createWSClient = () => {
});
addMessageListener((event: MessageEvent) => {
const data = JSON.parse(event.data) as Events;
if (data.type === "updateGame") {
const data = JSON.parse(event.data);
// 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(
["game.getGameState", data.game],
data.gameState,
["game.getGameState", eventData.game],
eventData.gameState,
);
}
if (data.type === "loss") {
if (eventData.type === "loss") {
queryClient.invalidateQueries({
queryKey: ["scoreboard.getScoreBoard", 10],
});
}
if (data.type === "gemsRewarded") {
if (eventData.type === "gemsRewarded") {
queryClient.invalidateQueries({
queryKey: ["user.getOwnGems", null],
});
}
if (import.meta.env.DEV) {
console.log("Received message", data);
console.log("Received message", eventData);
}
});