Repository: brkdnmz/queenzva
Pull request #1: Statistics page
Author: login: alikemalcoskun
Created: 2025-03-18T21:42:13Z | Merged: 2025-03-19T17:52:05Z

Added a statistics page including an info card, a line/area chart of solve times of completed games, and game history.


<img width="1253" alt="Screenshot 2025-03-19 at 00 39 27" src="https://github.com/user-attachments/assets/581e4872-6148-478b-851a-ce7479402019" />



Diff:
diff --git a/package-lock.json b/package-lock.json
index 713fb81..75898d4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,12 +11,15 @@
         "axios": "^1.7.5",
         "clsx": "^2.1.1",
         "date-fns": "^4.1.0",
+        "echarts-for-react": "^3.0.3",
         "puppeteer": "^23.9.0",
         "rand-seed": "^2.1.7",
         "react": "^18.3.1",
         "react-dom": "^18.3.1",
+        "react-router-dom": "^7.3.0",
         "socket.io": "^4.8.1",
-        "socket.io-client": "^4.8.1"
+        "socket.io-client": "^4.8.1",
+        "uuid": "^11.1.0"
       },
       "devDependencies": {
         "@eslint/js": "^9.9.0",
@@ -2202,6 +2205,38 @@
       "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
       "dev": true
     },
+    "node_modules/echarts": {
+      "version": "5.6.0",
+      "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz",
+      "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
+      "license": "Apache-2.0",
+      "peer": true,
+      "dependencies": {
+        "tslib": "2.3.0",
+        "zrender": "5.6.1"
+      }
+    },
+    "node_modules/echarts-for-react": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.3.tgz",
+      "integrity": "sha512-KdvZGkCwmx5DTHl7vjo0CBodSaPY31hPWRC4NZ5B+utQfoW+M54OTBvkoCmktR0kJ+1Bj6rP7pIJJnxPdySyug==",
+      "license": "MIT",
+      "dependencies": {
+        "fast-deep-equal": "^3.1.3",
+        "size-sensor": "^1.0.1"
+      },
+      "peerDependencies": {
+        "echarts": "^3.0.0 || ^4.0.0 || ^5.0.0",
+        "react": "^15.0.0 || >=16.0.0"
+      }
+    },
+    "node_modules/echarts/node_modules/tslib": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
+      "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
+      "license": "0BSD",
+      "peer": true
+    },
     "node_modules/electron-to-chromium": {
       "version": "1.5.76",
       "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.76.tgz",
@@ -2594,8 +2629,7 @@
     "node_modules/fast-deep-equal": {
       "version": "3.1.3",
       "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
-      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
-      "dev": true
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
     },
     "node_modules/fast-fifo": {
       "version": "1.3.2",
@@ -3970,6 +4004,61 @@
         "react": "^18.3.1"
       }
     },
+    "node_modules/react-router": {
+      "version": "7.3.0",
+      "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.3.0.tgz",
+      "integrity": "sha512-466f2W7HIWaNXTKM5nHTqNxLrHTyXybm7R0eBlVSt0k/u55tTCDO194OIx/NrYD4TS5SXKTNekXfT37kMKUjgw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/cookie": "^0.6.0",
+        "cookie": "^1.0.1",
+        "set-cookie-parser": "^2.6.0",
+        "turbo-stream": "2.4.0"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=18",
+        "react-dom": ">=18"
+      },
+      "peerDependenciesMeta": {
+        "react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/react-router-dom": {
+      "version": "7.3.0",
+      "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.3.0.tgz",
+      "integrity": "sha512-z7Q5FTiHGgQfEurX/FBinkOXhWREJIAB2RiU24lvcBa82PxUpwqvs/PAXb9lJyPjTs2jrl6UkLvCZVGJPeNuuQ==",
+      "license": "MIT",
+      "dependencies": {
+        "react-router": "7.3.0"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=18",
+        "react-dom": ">=18"
+      }
+    },
+    "node_modules/react-router/node_modules/@types/cookie": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
+      "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
+      "license": "MIT"
+    },
+    "node_modules/react-router/node_modules/cookie": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
+      "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
     "node_modules/read-cache": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -4114,6 +4203,12 @@
         "node": ">=10"
       }
     },
+    "node_modules/set-cookie-parser": {
+      "version": "2.7.1",
+      "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
+      "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
+      "license": "MIT"
+    },
     "node_modules/shebang-command": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -4147,6 +4242,12 @@
         "url": "https://github.com/sponsors/isaacs"
       }
     },
+    "node_modules/size-sensor": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.2.tgz",
+      "integrity": "sha512-2NCmWxY7A9pYKGXNBfteo4hy14gWu47rg5692peVMst6lQLPKrVjhY+UTEsPI5ceFRJSl3gVgMYaUi/hKuaiKw==",
+      "license": "ISC"
+    },
     "node_modules/slash": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@@ -4541,6 +4642,12 @@
       "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
       "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
     },
+    "node_modules/turbo-stream": {
+      "version": "2.4.0",
+      "resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz",
+      "integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==",
+      "license": "ISC"
+    },
     "node_modules/type-check": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -4666,6 +4773,19 @@
       "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
       "dev": true
     },
+    "node_modules/uuid": {
+      "version": "11.1.0",
+      "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
+      "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
+      "funding": [
+        "https://github.com/sponsors/broofa",
+        "https://github.com/sponsors/ctavan"
+      ],
+      "license": "MIT",
+      "bin": {
+        "uuid": "dist/esm/bin/uuid"
+      }
+    },
     "node_modules/vary": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -4897,6 +5017,23 @@
       "funding": {
         "url": "https://github.com/sponsors/colinhacks"
       }
+    },
+    "node_modules/zrender": {
+      "version": "5.6.1",
+      "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz",
+      "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
+      "license": "BSD-3-Clause",
+      "peer": true,
+      "dependencies": {
+        "tslib": "2.3.0"
+      }
+    },
+    "node_modules/zrender/node_modules/tslib": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
+      "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
+      "license": "0BSD",
+      "peer": true
     }
   }
 }
diff --git a/package.json b/package.json
index 051e240..a249bd7 100644
--- a/package.json
+++ b/package.json
@@ -13,12 +13,15 @@
     "axios": "^1.7.5",
     "clsx": "^2.1.1",
     "date-fns": "^4.1.0",
+    "echarts-for-react": "^3.0.3",
     "puppeteer": "^23.9.0",
     "rand-seed": "^2.1.7",
     "react": "^18.3.1",
     "react-dom": "^18.3.1",
+    "react-router-dom": "^7.3.0",
     "socket.io": "^4.8.1",
-    "socket.io-client": "^4.8.1"
+    "socket.io-client": "^4.8.1",
+    "uuid": "^11.1.0"
   },
   "devDependencies": {
     "@eslint/js": "^9.9.0",
diff --git a/src/App.tsx b/src/App.tsx
index 65baca3..941e55f 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,187 +1,21 @@
-import clsx from "clsx";
-import {
-  differenceInMilliseconds,
-  differenceInMinutes,
-  differenceInSeconds,
-} from "date-fns";
-import Rand from "rand-seed";
-import { useEffect, useState } from "react";
-import {
-  M,
-  N,
-  precalculateValidQueensPositions,
-  prepareQueensBoard,
-} from "./board-util";
-import { HashInput } from "./components/hash-input";
-import { InzvaQueen } from "./inzva-queen";
-import { random } from "./random";
-
-precalculateValidQueensPositions();
-
-function isInDanger(
-  row: number,
-  col: number,
-  queenInserted: Record<number, string>
-) {
-  const sameRowCol = Object.entries(queenInserted).some(([key, value]) => {
-    const i = Math.floor(+key / N);
-    const j = +key % M;
-    return (
-      value === "q" && (i === row || j === col) && !(i === row && j === col)
-    );
-  });
-  // queen in one in 8 neighbors
-  // copilot ate it copilot is so hungry
-  const neighbor = Object.entries(queenInserted).some(([key, value]) => {
-    const i = Math.floor(+key / N);
-    const j = +key % M;
-    return value === "q" && Math.abs(i - row) === 1 && Math.abs(j - col) === 1;
-  });
-  return sameRowCol || neighbor;
-}
-
-function hasWon(board: string[][], queenInserted: Record<string, string>) {
-  const queenCnt: Record<string, number> = {};
-  let filledCnt = 0;
-  for (let i = 0; i < N; i++) {
-    for (let j = 0; j < N; j++) {
-      if (queenInserted[N * i + j] === "q") {
-        if (isInDanger(i, j, queenInserted)) return false;
-        queenCnt[board[i][j]] ??= 0;
-        if (queenCnt[board[i][j]]++ > 0) return false;
-        filledCnt++;
-      }
-    }
-  }
-
-  return filledCnt === N;
-}
+import CreateGame from "./pages/create-game";
+import { Route, Routes, BrowserRouter } from "react-router-dom";
+import Game from "./pages/game";
+import Stats from "./pages/stats";
 
 function App() {
-  const [board, setBoard] = useState<string[][]>();
-  const [queenInserted, setQueenInserted] = useState<Record<string, string>>(
-    {}
-  );
-  const [startTime, setStartTime] = useState(new Date());
-  const [currentTime, setCurrentTime] = useState(new Date());
-  const [prepared, setPrepared] = useState(false);
-  const [started, setStarted] = useState(false);
-
-  const won = hasWon(board!, queenInserted);
-
-  useEffect(() => {
-    if (won) return;
-    const interval = setInterval(() => {
-      setCurrentTime(new Date());
-    }, 1000);
-
-    return () => clearInterval(interval);
-  }, [won]);
-
-  if (won) {
-    console.log("You win!");
-  }
-
-  const onClear = () => {
-    setQueenInserted({});
-  };
-
   return (
-    <div className="flex justify-center items-center h-screen font-mono flex-col gap-2">
-      <HashInput
-        visible={!prepared}
-        onEnter={(hash) => {
-          random.generator = new Rand(hash);
-          setBoard(prepareQueensBoard());
-          setPrepared(true);
-          setStartTime(new Date());
-        }}
-      />
-
-      {prepared && !started && (
-        <button
-          onClick={() => {
-            setStarted(true);
-            setStartTime(new Date());
-          }}
-          className="text-xl bg-green-500 text-white p-2 rounded-md hover:bg-green-600"
-        >
-          Başlat
-        </button>
-      )}
-
-      {started && (
-        <>
-          <button
-            onClick={onClear}
-            className="text-xl bg-red-500 text-white p-2 rounded-md hover:bg-red-600"
-          >
-            Temizle
-          </button>
-          <div className="text-4xl">
-            {differenceInMinutes(currentTime, startTime)
-              .toString()
-              .padStart(2, "0")}
-            :
-            {(differenceInSeconds(currentTime, startTime) % 60)
-              .toString()
-              .padStart(2, "0")}
-            {won
-              ? (
-                  "." +
-                  (differenceInMilliseconds(currentTime, startTime) % 1000)
-                )
-                  .toString()
-                  .padStart(3, "0")
-              : null}
-          </div>
-          {won && <InzvaQueen won={false} />}
-          <div
-            className={`inline-grid ${N === 8 ? "grid-cols-8" : "grid-cols-9"}`}
-          >
-            {board!.map((row, i) => (
-              <div
-                key={i}
-                className="grid"
-              >
-                {row.map((cellColor, j) => (
-                  <div
-                    key={j}
-                    className={clsx(
-                      "flex select-none justify-center items-center h-12 w-12 text-2xl cursor-pointer border border-black p-3"
-                    )}
-                    style={{
-                      background:
-                        !won || queenInserted[N * i + j] !== "q"
-                          ? cellColor
-                          : "gold",
-                    }}
-                    onClick={() =>
-                      setQueenInserted((prev) => ({
-                        ...prev,
-                        [N * i + j]:
-                          (prev[N * i + j] || "") === ""
-                            ? "x"
-                            : prev[N * i + j] === "x"
-                            ? "q"
-                            : "",
-                      }))
-                    }
-                  >
-                    {queenInserted[N * i + j] === "q" ? (
-                      <InzvaQueen won={won} />
-                    ) : isInDanger(i, j, queenInserted) ? (
-                      "x"
-                    ) : (
-                      queenInserted[N * i + j]
-                    )}
-                  </div>
-                ))}
-              </div>
-            ))}
-          </div>
-        </>
-      )}
+    <div className="w-screen h-screen">
+      <BrowserRouter basename="/queenzva">
+        <Routes>
+          <Route path="/" element={<CreateGame />} />
+          <Route path="/g/:hash" element={<Game />} />
+          <Route path="/g/" element={<Game />} />
+          <Route path="/game/:hash" element={<Game />} />
+          <Route path="/game" element={<Game />} />
+          <Route path="/stats" element={<Stats />} />
+        </Routes>
+      </BrowserRouter>
     </div>
   );
 }
diff --git a/src/board-util.ts b/src/board-util.ts
index 005f5a3..da0ef93 100644
--- a/src/board-util.ts
+++ b/src/board-util.ts
@@ -56,7 +56,7 @@ export function prepareQueensBoard() {
       Math.floor(random.generator!.next() * VALID_QUEENS_POSITIONS.length)
     ];
 
-  console.log(Date.now());
+  // console.log(Date.now());
 
   let foundBoard: string[][] = Array.from({ length: N }, () =>
     Array.from({ length: M }, () => "")
diff --git a/src/components/name-tag.tsx b/src/components/name-tag.tsx
new file mode 100644
index 0000000..199267d
--- /dev/null
+++ b/src/components/name-tag.tsx
@@ -0,0 +1,37 @@
+import { useRef, useState } from "react";
+import User from "../utils/user";
+
+type NameTagProps = {
+  changeable?: boolean;
+};
+
+function NameTag({ changeable = false }: NameTagProps) {
+  const user = useRef(new User());
+  const [name, setName] = useState(user.current.getUser().name);
+
+  const updateName = (name: string) => {
+    user.current.setName(name);
+    setName(name);
+  };
+
+  return (
+    <div className="flex flex-row items-center gap-2">
+      {changeable && (
+        <div className="relative flex items-center">
+          <input
+            type="text"
+            value={name}
+            onChange={(e) => updateName(e.target.value)}
+            style={{ width: `${Math.max(name.length * 0.8, 4)}em` }}
+            className="px-2 min-w-[4em] text-center border-b border-gray-300 focus:border-blue-500 focus:outline-none transition-colors"
+          />
+        </div>
+      )}
+      {!changeable && (
+        <div className="text-md text-center">{name}</div>
+      )}
+    </div>
+  );
+}
+
+export default NameTag;
diff --git a/src/components/stats-duration-chart.tsx b/src/components/stats-duration-chart.tsx
new file mode 100644
index 0000000..63971be
--- /dev/null
+++ b/src/components/stats-duration-chart.tsx
@@ -0,0 +1,75 @@
+import { StatsEntry } from "../types/user";
+import ReactECharts from 'echarts-for-react';
+import { formatDuration } from "../utils/datetime";
+
+type StatsDurationChartProps = {
+    stats: StatsEntry[];
+}
+
+// Line chart for duration of stats
+function StatsDurationChart({ stats }: StatsDurationChartProps) {
+    const option = {
+        title: {
+            text: 'Son 100 kazanılan oyunda harcanan süre',
+            left: 'center',
+            textStyle: {
+                fontSize: 12
+            }
+        },
+        grid: {
+            containLabel: true,
+            left: '3%',
+            right: '8%',
+            bottom: '3%'
+        },
+        tooltip: {
+            trigger: 'axis' as const,
+            formatter: (params: any) => {
+                return `Süre: ${formatDuration(params[0].value)} - Tarih: ${new Date(stats.filter(stat => stat.completed)[params[0].dataIndex].startTime).toLocaleString()}`;
+            }
+        },
+        xAxis: {
+            type: 'category' as const,
+            name: 'Tarih',
+            nameLocation: 'end' as const,
+            nameGap: 30,
+            data: stats.filter(stat => stat.completed).map(stat => new Date(stat.startTime).toLocaleDateString()).slice(0, 100),
+            axisLabel: {
+                rotate: 45,
+                fontSize: 10
+            }
+        },
+        yAxis: {
+            type: 'value' as const,
+            name: 'Süre',
+            nameLocation: 'end' as const,
+            nameGap: 30,
+            axisLabel: {
+                formatter: (value: number) => {
+                    return `${formatDuration(value)}`;
+                },
+                fontSize: 10
+            }
+        },
+        series: [{
+            data: stats.filter(stat => stat.completed).map(stat => stat.duration).slice(0, 100),
+            type: 'line' as const,
+            smooth: true,
+            areaStyle: {
+            },
+        }],
+        toolbox: {
+            feature: {
+                saveAsImage: {}
+            }
+        }
+    }
+    return <div className="w-full max-w-4xl h-[300px] md:h-[400px] px-4">
+        <ReactECharts 
+            option={option} 
+            opts={{ renderer: 'svg' }}
+        />
+    </div>
+}
+
+export default StatsDurationChart;
\ No newline at end of file
diff --git a/src/components/stats-info.tsx b/src/components/stats-info.tsx
new file mode 100644
index 0000000..4633efb
--- /dev/null
+++ b/src/components/stats-info.tsx
@@ -0,0 +1,25 @@
+import { StatsEntry } from "../types/user";
+import { formatDuration } from "../utils/datetime";
+
+type StatsInfoProps = {
+    stats: StatsEntry[];
+}
+
+function StatsInfo({ stats }: StatsInfoProps) {
+    return (
+        <div className="flex flex-col items-center justify-start h-screen p-8 gap-6">
+
+            <div className="flex flex-col gap-2 text-sm mb-4">
+                <div>Toplam oyun: <span className="font-bold">{stats.length}</span></div>
+                <div>Kazanılan oyun: <span className="font-bold">{stats.filter((stat) => stat.completed).length || 0}</span></div>
+                <div>Ortalama oyun süresi(Tümü): <span className="font-bold">{formatDuration(Math.floor(stats.reduce((acc, stat) => acc + stat?.duration, 0) / stats.length) || 0)}</span></div>
+                <div>Ortalama oyun süresi(Kazanılanlar): <span className="font-bold">{formatDuration(Math.floor(stats.filter((stat) => stat.completed).reduce((acc, stat) => acc + stat.duration, 0) / stats.filter((stat) => stat.completed).length) || 0)}</span></div>
+                <div>Ortalama hamle sayısı(Tümü): <span className="font-bold">{(stats.reduce((acc, stat) => acc + stat.moves, 0) / stats.length || 0).toFixed(2)}</span></div>
+                <div>Ortalama hamle sayısı(Kazanılanlar): <span className="font-bold">{(stats.filter((stat) => stat.completed).reduce((acc, stat) => acc + stat.moves, 0) / stats.filter((stat) => stat.completed).length || 0).toFixed(2)}</span></div>
+                <div>En iyi süre: <span className="font-bold">{formatDuration(Math.floor(stats.filter((stat) => stat.completed).reduce((best, current) => current?.duration < best?.duration ? current : best, stats.filter((stat) => stat.completed)[0])?.duration) || 0)}</span></div>
+            </div>
+        </div>
+    );
+}
+
+export default StatsInfo;
\ No newline at end of file
diff --git a/src/components/stats-table.tsx b/src/components/stats-table.tsx
new file mode 100644
index 0000000..d13d268
--- /dev/null
+++ b/src/components/stats-table.tsx
@@ -0,0 +1,123 @@
+import { useState, useMemo } from "react";
+import { StatsEntry } from "../types/user";
+import { useNavigate } from "react-router-dom";
+import { formatDuration } from "../utils/datetime";
+
+type StatsTableProps = {
+    stats: StatsEntry[];
+}
+
+function StatsTable({ stats }: StatsTableProps) {
+    const navigate = useNavigate();
+    const [currentPage, setCurrentPage] = useState(1);
+    const itemsPerPage = 10;
+  
+    const filteredStats = useMemo(() => {
+      // TODO: Search and sort by hash, date, duration, moves
+      return stats.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime());
+    }, [stats]);
+  
+    const paginatedStats = useMemo(() => {
+      const startIndex = (currentPage - 1) * itemsPerPage;
+      return filteredStats.slice(startIndex, startIndex + itemsPerPage);
+    }, [filteredStats, currentPage]);
+  
+    const totalPages = Math.ceil(filteredStats.length / itemsPerPage);
+
+    if (stats.length === 0) {
+        return <div>Oyun bulunamadı</div>;
+    }
+
+    return (
+        <div className="flex flex-col items-center justify-center w-full">
+            <div className="w-full max-w-full overflow-x-auto">
+              <table className="w-full bg-white border rounded-lg whitespace-nowrap">
+                <thead>
+                  <tr className="bg-gray-100">
+                    <th 
+                      className="px-2 md:px-4 py-2 cursor-pointer hover:bg-gray-200 max-w-[100px] text-center"
+                    >
+                      <div className="truncate">Kod</div>
+                    </th>
+                    <th 
+                      className="px-2 md:px-4 py-2 cursor-pointer hover:bg-gray-200 whitespace-nowrap text-center"
+                    >
+                      Süre
+                    </th>
+                    <th 
+                      className="px-2 md:px-4 py-2 cursor-pointer hover:bg-gray-200 whitespace-nowrap text-center"
+                    >
+                      Hamle
+                    </th>
+                    <th 
+                      className="px-2 md:px-4 py-2 cursor-pointer hover:bg-gray-200 whitespace-nowrap text-center"
+                    >
+                      Durum
+                    </th>
+                    <th 
+                      className="px-2 md:px-4 py-2 cursor-pointer hover:bg-gray-200 whitespace-nowrap text-center"
+                    >
+                      Tarih
+                    </th>
+                    <th 
+                      className="px-2 md:px-4 py-2 cursor-pointer hover:bg-gray-200 whitespace-nowrap text-center"
+                    >
+                      Tekrar Oyna
+                    </th>
+                  </tr>
+                </thead>
+                <tbody>
+                  {paginatedStats.map((stat, index) => (
+                    <tr key={index} className="border-t hover:bg-gray-50">
+                      <td className="px-2 md:px-4 py-2 text-xs md:text-sm max-w-[100px] text-center">
+                        <div className="truncate" title={stat.hash}>{stat.hash}</div>
+                      </td>
+                      <td className="px-2 md:px-4 py-2 text-xs md:text-sm whitespace-nowrap text-center">{formatDuration(stat.duration)}</td>
+                      <td className="px-2 md:px-4 py-2 text-xs md:text-sm whitespace-nowrap text-center">{stat.moves}</td>
+                      <td className="px-2 md:px-4 py-2 text-xs md:text-sm whitespace-nowrap text-center">
+                        <span className={`px-2 py-1 rounded-full text-xs ${stat.completed ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
+                          {stat.completed ? 'Kazandın' : 'Tamamlanmadı'}
+                        </span>
+                      </td>
+                      <td className="px-2 md:px-4 py-2 text-xs md:text-sm whitespace-nowrap text-center">{new Date(stat.startTime).toLocaleDateString() + " " + new Date(stat.startTime).toLocaleTimeString()}</td>
+                      <td className="px-2 md:px-4 py-2 text-xs md:text-sm whitespace-nowrap text-center">
+                        <button className="px-2 py-1 rounded-full text-xs bg-blue-100 text-blue-800"
+                          onClick={() => {
+                            navigate(`/g/${stat.hash}`);
+                          }}
+                        >
+                        Oyna
+                        </button>
+                      </td>
+                    </tr>
+                  ))}
+                </tbody>
+              </table>
+            </div>
+    
+            {totalPages > 1 && (
+              <div className="flex justify-center gap-2 mt-4">
+                <button
+                  onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
+                  disabled={currentPage === 1}
+                  className="px-3 py-1 border rounded hover:bg-gray-100 disabled:opacity-50"
+                >
+                  ←
+                </button>
+                <span className="px-4 py-1">
+                  Sayfa {currentPage} / {totalPages}
+                </span>
+                <button
+                  onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
+                  disabled={currentPage === totalPages}
+                  className="px-3 py-1 border rounded hover:bg-gray-100 disabled:opacity-50"
+                >
+                  →
+                </button>
+              </div>
+            )}
+          </div>
+      );
+}
+
+export default StatsTable;
\ No newline at end of file
diff --git a/src/pages/Game.tsx b/src/pages/Game.tsx
new file mode 100644
index 0000000..67b1021
--- /dev/null
+++ b/src/pages/Game.tsx
@@ -0,0 +1,254 @@
+import clsx from "clsx";
+import {
+  differenceInMilliseconds,
+  differenceInMinutes,
+  differenceInSeconds,
+} from "date-fns";
+import Rand from "rand-seed";
+import { useEffect, useState } from "react";
+import {
+  M,
+  N,
+  precalculateValidQueensPositions,
+  prepareQueensBoard,
+} from "../board-util";
+import { InzvaQueen } from "../inzva-queen";
+import { random } from "../random";
+import { useParams } from "react-router-dom";
+import User from "../utils/user";
+import { StatsEntry } from "../types/user";
+import { v4 as uuidv4 } from 'uuid';
+
+precalculateValidQueensPositions();
+
+function isInDanger(
+  row: number,
+  col: number,
+  queenInserted: Record<number, string>
+) {
+  const sameRowCol = Object.entries(queenInserted).some(([key, value]) => {
+    const i = Math.floor(+key / N);
+    const j = +key % M;
+    return (
+      value === "q" && (i === row || j === col) && !(i === row && j === col)
+    );
+  });
+  // queen in one in 8 neighbors
+  // copilot ate it copilot is so hungry
+  const neighbor = Object.entries(queenInserted).some(([key, value]) => {
+    const i = Math.floor(+key / N);
+    const j = +key % M;
+    return value === "q" && Math.abs(i - row) === 1 && Math.abs(j - col) === 1;
+  });
+  return sameRowCol || neighbor;
+}
+
+function hasWon(board: string[][], queenInserted: Record<string, string>) {
+  const queenCnt: Record<string, number> = {};
+  let filledCnt = 0;
+  for (let i = 0; i < N; i++) {
+    for (let j = 0; j < N; j++) {
+      if (queenInserted[N * i + j] === "q") {
+        if (isInDanger(i, j, queenInserted)) return false;
+        queenCnt[board[i][j]] ??= 0;
+        if (queenCnt[board[i][j]]++ > 0) return false;
+        filledCnt++;
+      }
+    }
+  }
+
+  return filledCnt === N;
+}
+
+function Game() {
+  const { hash } = useParams();
+  const user = new User();
+  const [stats, setStats] = useState<StatsEntry | null>(null);
+  const [statsSaved, setStatsSaved] = useState(false);
+  const [statsBestTime, setStatsBestTime] = useState(false);
+
+  const [board, setBoard] = useState<string[][]>();
+  const [queenInserted, setQueenInserted] = useState<Record<string, string>>(
+    {}
+  );
+  const [startTime, setStartTime] = useState(new Date());
+  const [currentTime, setCurrentTime] = useState(new Date());
+  const [prepared, setPrepared] = useState(false);
+  const [started, setStarted] = useState(false);
+  const [error, setError] = useState("");
+  const won = hasWon(board!, queenInserted);
+
+  // Handle game completion
+  useEffect(() => {
+    if (won && stats && !statsSaved) {
+      const updatedStats = {
+        ...stats,
+        duration: differenceInMilliseconds(currentTime, startTime),
+        moves: Object.keys(queenInserted).length,
+        completed: true
+      };
+
+      const bestTime = user.checkBestTime(updatedStats);
+      if (bestTime) {
+        setStatsBestTime(true);
+      }
+
+      user.addStats(updatedStats);
+      setStatsSaved(true);
+    }
+  }, [won, stats, statsSaved, currentTime, startTime, queenInserted]);
+
+  // Handle timer
+  useEffect(() => {
+    if (won) return;
+    const interval = setInterval(() => {
+      setCurrentTime(new Date());
+    }, 1000);
+
+    return () => clearInterval(interval);
+  }, [won]);
+
+  if (won) {
+    console.log("You win!");
+  }
+
+  const onClear = () => {
+    setQueenInserted({});
+  };
+
+  // Check if the hash is valid
+  const isValidHash = (hash: string | undefined) => {
+    return typeof hash === "string" && hash.length > 0;
+  };
+
+  // Get the hash from the url and prepare the board
+  const prepareGameBoard = () => {
+    if (!hash) return;
+    random.generator = new Rand(hash);
+    setBoard(prepareQueensBoard());
+    setPrepared(true);
+    setStartTime(new Date());
+  };
+
+  useEffect(() => {
+    if (!isValidHash(hash)) {
+      setError("Hatalı kod");
+      return;
+    }
+
+    prepareGameBoard();
+  }, [hash]);
+
+  return (
+    <div className="flex justify-center items-center h-screen font-mono flex-col gap-2">  
+      {error && (
+        <div className="text-red-500 text-2xl">{error}</div>
+      )}
+
+      {!prepared && !error && (
+        <div className="text-2xl">Loading...</div>
+      )}
+
+
+      {prepared && !started && !error && (
+        <button
+          onClick={() => {
+            setStarted(true);
+            setStartTime(new Date());
+            setStats({
+              hash: hash!,
+              duration: 0,
+              moves: 0,
+              completed: false,
+              startTime: startTime,
+              id: uuidv4(),
+            });
+          }}
+          className="text-xl bg-green-500 text-white p-2 rounded-md hover:bg-green-600"
+        >
+          Başlat
+        </button>
+      )}
+
+      {started && !error && (
+        <>
+          <button
+            onClick={onClear}
+            className="text-xl bg-red-500 text-white p-2 rounded-md hover:bg-red-600"
+          >
+            Temizle
+          </button>
+          <div className="text-4xl">
+            {differenceInMinutes(currentTime, startTime)
+              .toString()
+              .padStart(2, "0")}
+            :
+            {(differenceInSeconds(currentTime, startTime) % 60)
+              .toString()
+              .padStart(2, "0")}
+            {won
+              ? (
+                  "." +
+                  (differenceInMilliseconds(currentTime, startTime) % 1000)
+                )
+                  .toString()
+                  .padStart(3, "0")
+              : null}
+          </div>
+          {won && <InzvaQueen won={false} />}
+          {statsBestTime && (
+            <div className="text-green-800 text-2xl">
+              Yeni en iyi süre!
+            </div>
+          )}
+          <div
+            className={`inline-grid ${N === 8 ? "grid-cols-8" : "grid-cols-9"}`}
+          >
+            {board!.map((row, i) => (
+              <div
+                key={i}
+                className="grid"
+              >
+                {row.map((cellColor, j) => (
+                  <div
+                    key={j}
+                    className={clsx(
+                      "flex select-none justify-center items-center h-12 w-12 text-2xl cursor-pointer border border-black p-3"
+                    )}
+                    style={{
+                      background:
+                        !won || queenInserted[N * i + j] !== "q"
+                          ? cellColor
+                          : "gold",
+                    }}
+                    onClick={() =>
+                      setQueenInserted((prev) => ({
+                        ...prev,
+                        [N * i + j]:
+                          (prev[N * i + j] || "") === ""
+                            ? "x"
+                            : prev[N * i + j] === "x"
+                            ? "q"
+                            : "",
+                      }))
+                    }
+                  >
+                    {queenInserted[N * i + j] === "q" ? (
+                      <InzvaQueen won={won} />
+                    ) : isInDanger(i, j, queenInserted) ? (
+                      "x"
+                    ) : (
+                      queenInserted[N * i + j]
+                    )}
+                  </div>
+                ))}
+              </div>
+            ))}
+          </div>
+        </>
+      )}
+    </div>
+  );
+}
+
+export default Game;
diff --git a/src/pages/create-game.tsx b/src/pages/create-game.tsx
new file mode 100644
index 0000000..4a9376c
--- /dev/null
+++ b/src/pages/create-game.tsx
@@ -0,0 +1,39 @@
+import { useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { HashInput } from "../components/hash-input";
+import NameTag from "../components/name-tag";
+
+function CreateGame() {
+  const navigate = useNavigate();
+  const [directing, setDirecting] = useState(false);
+
+  return (
+      <div className="flex justify-center items-center h-screen font-mono flex-col gap-2">
+        {!directing && (
+        <>
+          <div className="flex flex-row gap-2 mb-4">
+            <NameTag changeable={true}/>
+          </div>
+          <div className="flex flex-row gap-2 mb-12">
+            <button onClick={() => navigate("/stats")}>
+              <div className="text-sm">
+                İstatistikler
+              </div>
+            </button>
+          </div>
+        </>
+        )}
+      <HashInput
+        visible={!directing}
+        onEnter={(hash) => {
+          setDirecting(true);
+
+          // Direct to the game page
+          navigate(`/g/${hash}`);
+        }}
+      />
+    </div>
+  );
+}
+
+export default CreateGame;
diff --git a/src/pages/stats.tsx b/src/pages/stats.tsx
new file mode 100644
index 0000000..2ebbf97
--- /dev/null
+++ b/src/pages/stats.tsx
@@ -0,0 +1,29 @@
+import User from "../utils/user";
+import NameTag from "../components/name-tag";
+import StatsTable from "../components/stats-table";
+import StatsInfo from "../components/stats-info";
+import StatsDurationChart from "../components/stats-duration-chart";
+function Stats() {
+  const user = new User();
+  const stats = user.getStats();
+
+  return (
+    <div className="flex flex-col items-center justify-start h-screen p-8 gap-6">
+      <NameTag changeable={true} />
+
+      {stats.length > 0 ? (
+        <>
+          <StatsInfo stats={stats} />
+          <StatsDurationChart stats={stats} />
+          <StatsTable stats={stats} />
+        </>
+      ) : (
+        <div className="text-center text-gray-500">
+          Oyun bulunamadı.
+        </div>
+      )}
+    </div>
+  );
+}
+
+export default Stats;
\ No newline at end of file
diff --git a/src/types/user.tsx b/src/types/user.tsx
new file mode 100644
index 0000000..d50f360
--- /dev/null
+++ b/src/types/user.tsx
@@ -0,0 +1,16 @@
+type StatsEntry = {
+  id: string;
+  hash: string;
+  startTime: Date;
+  duration: number;
+  completed: boolean;
+  moves: number;
+};
+
+type User = {
+  id: string;
+  name: string;
+  stats: StatsEntry[];
+};
+
+export type { StatsEntry, User };
\ No newline at end of file
diff --git a/src/utils/datetime.tsx b/src/utils/datetime.tsx
new file mode 100644
index 0000000..1324503
--- /dev/null
+++ b/src/utils/datetime.tsx
@@ -0,0 +1,7 @@
+export const formatDuration = (ms: number) => {
+    const minutes = Math.floor(ms / 60000);
+    const seconds = Math.floor((ms % 60000) / 1000);
+    const milliseconds = Math.floor(ms % 1000);
+
+    return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}`;
+  };
\ No newline at end of file
diff --git a/src/utils/storage.tsx b/src/utils/storage.tsx
new file mode 100644
index 0000000..d428342
--- /dev/null
+++ b/src/utils/storage.tsx
@@ -0,0 +1,28 @@
+// Class for LocalStorage operations
+class Storage {
+  private storage;
+
+  constructor() {
+    this.storage = window.localStorage;
+  }
+
+  setItem(key: string, value: string) {
+    this.storage.setItem(key, value);
+  }
+  
+  getItem(key: string): Record<string, any> | null {
+    const item = this.storage.getItem(key);
+    if (!item) return null;
+    return JSON.parse(item);
+  }
+
+  removeItem(key: string) {
+    this.storage.removeItem(key);
+  }
+
+  checkItem(key: string) {
+    return this.storage.getItem(key) !== null;
+  }
+}
+
+export default Storage;
diff --git a/src/utils/user.tsx b/src/utils/user.tsx
new file mode 100644
index 0000000..9b12589
--- /dev/null
+++ b/src/utils/user.tsx
@@ -0,0 +1,82 @@
+import { StatsEntry, User as UserType } from "../types/user";
+import Storage from "./storage";
+import { v4 as uuidv4 } from 'uuid';
+
+const USER_KEY = "queenzva-user";
+
+// Class for user operations
+class User {
+  private user: UserType;
+  private storage: Storage;
+
+  constructor() {
+    this.storage = new Storage();
+    // Get user from localStorage
+    if (!this.storage.checkItem(USER_KEY)) {
+      this.user = {
+        id: uuidv4(),
+        name: "oyuncu",
+        stats: [],
+      };
+      this.storage.setItem(USER_KEY, JSON.stringify(this.user));
+    }
+    this.user = this.storage.getItem(USER_KEY) as UserType;
+  }
+
+  // Getters
+  getUser(): UserType {
+    return this.user;
+  }
+
+  getName(): string {
+    return this.user.name;
+  }
+
+  getStats(): StatsEntry[] {
+    return this.user.stats;
+  }
+
+  // Setters
+  setUser(user: UserType) {
+    this.user = user;
+    this.storage.setItem(USER_KEY, JSON.stringify(this.user));
+  }
+
+  setName(name: string) {
+    this.user.name = name;
+    this.storage.setItem(USER_KEY, JSON.stringify(this.user));
+  }
+
+  // Functions
+  addStats(stats: StatsEntry) {
+    // Check if the stats already exists
+    if (this.user.stats.find((stat) => stat.id === stats.id)) {
+      return;
+    }
+    this.user.stats.push(stats);
+    this.storage.setItem(USER_KEY, JSON.stringify(this.user));
+  }
+
+  getStatsByHash(hash: string): StatsEntry | undefined {
+    return this.user.stats.find((stat) => stat.hash === hash);
+  }
+
+  getBestTime() {
+    if (this.user.stats.length === 0) return null;
+    const completed = this.user.stats.filter((stat) => stat.completed);
+    if (completed.length === 0) return null;
+    return completed.reduce((best, current) => {
+      return current.duration < best.duration ? current : best;
+    }, completed[0]);
+  }
+  
+  checkBestTime(stats: StatsEntry) {
+    const bestTime = this.getBestTime();
+    console.log(bestTime);
+    if (!bestTime) return true;
+    console.log(stats.duration, bestTime.duration);
+    return stats.duration < bestTime.duration;
+  }
+}
+
+export default User;
\ No newline at end of file


Reviews:
user: login: brkdnmz
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-18T21:52:47Z
user: login: brkdnmz
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-18T21:53:38Z
user: login: brkdnmz
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-18T21:55:08Z
user: login: brkdnmz
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-18T21:58:17Z
user: login: brkdnmz
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-18T22:02:35Z
user: login: brkdnmz
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-18T22:14:14Z
user: login: alikemalcoskun
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T14:45:11Z
user: login: alikemalcoskun
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T14:47:32Z
user: login: alikemalcoskun
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T14:53:59Z
user: login: alikemalcoskun
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T14:56:38Z
user: login: brkdnmz
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-19T17:35:07Z
user: login: brkdnmz
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-19T17:41:02Z
user: login: brkdnmz
body: What an unexpected amazing PR really... Much appreciated.

It has healed the spagettiness. 🌹
state: APPROVED
author_association: OWNER
submitted_at: 2025-03-19T17:44:39Z

Review comments:
diff_hunk: @@ -10,15 +10,21 @@
     "preview": "vite preview"
   },
   "dependencies": {
+    "@types/uuid": "^10.0.0",
path: package.json
user: login: brkdnmz
body: As I see on [npm](https://www.npmjs.com/package/uuid), this package does not need the `@types/...` package for its types. Also, it could be in `devDependencies` by `npm i -D ...`.
created_at: 2025-03-18T21:52:47Z
author_association: OWNER
diff_hunk: @@ -10,15 +10,21 @@
     "preview": "vite preview"
   },
   "dependencies": {
+    "@types/uuid": "^10.0.0",
     "axios": "^1.7.5",
     "clsx": "^2.1.1",
     "date-fns": "^4.1.0",
+    "echarts-for-react": "^3.0.3",
+    "i": "^0.3.7",
+    "npm": "^11.2.0",
path: package.json
user: login: brkdnmz
body: Did these appear after a possible `npm i npm i ...` command?
created_at: 2025-03-18T21:53:38Z
author_association: OWNER
diff_hunk: @@ -1,187 +1,21 @@
-import clsx from "clsx";
-import {
-  differenceInMilliseconds,
-  differenceInMinutes,
-  differenceInSeconds,
-} from "date-fns";
-import Rand from "rand-seed";
-import { useEffect, useState } from "react";
-import {
-  M,
-  N,
-  precalculateValidQueensPositions,
-  prepareQueensBoard,
-} from "./board-util";
-import { HashInput } from "./components/hash-input";
-import { InzvaQueen } from "./inzva-queen";
-import { random } from "./random";
-
-precalculateValidQueensPositions();
-
-function isInDanger(
-  row: number,
-  col: number,
-  queenInserted: Record<number, string>
-) {
-  const sameRowCol = Object.entries(queenInserted).some(([key, value]) => {
-    const i = Math.floor(+key / N);
-    const j = +key % M;
-    return (
-      value === "q" && (i === row || j === col) && !(i === row && j === col)
-    );
-  });
-  // queen in one in 8 neighbors
-  // copilot ate it copilot is so hungry
-  const neighbor = Object.entries(queenInserted).some(([key, value]) => {
-    const i = Math.floor(+key / N);
-    const j = +key % M;
-    return value === "q" && Math.abs(i - row) === 1 && Math.abs(j - col) === 1;
-  });
-  return sameRowCol || neighbor;
-}
-
-function hasWon(board: string[][], queenInserted: Record<string, string>) {
-  const queenCnt: Record<string, number> = {};
-  let filledCnt = 0;
-  for (let i = 0; i < N; i++) {
-    for (let j = 0; j < N; j++) {
-      if (queenInserted[N * i + j] === "q") {
-        if (isInDanger(i, j, queenInserted)) return false;
-        queenCnt[board[i][j]] ??= 0;
-        if (queenCnt[board[i][j]]++ > 0) return false;
-        filledCnt++;
-      }
-    }
-  }
-
-  return filledCnt === N;
-}
+import CreateGame from "./pages/create-game";
+import { Route, Routes, BrowserRouter } from "react-router-dom";
+import Game from "./pages/game";
+import Stats from "./pages/stats";
 
 function App() {
-  const [board, setBoard] = useState<string[][]>();
-  const [queenInserted, setQueenInserted] = useState<Record<string, string>>(
-    {}
-  );
-  const [startTime, setStartTime] = useState(new Date());
-  const [currentTime, setCurrentTime] = useState(new Date());
-  const [prepared, setPrepared] = useState(false);
-  const [started, setStarted] = useState(false);
-
-  const won = hasWon(board!, queenInserted);
-
-  useEffect(() => {
-    if (won) return;
-    const interval = setInterval(() => {
-      setCurrentTime(new Date());
-    }, 1000);
-
-    return () => clearInterval(interval);
-  }, [won]);
-
-  if (won) {
-    console.log("You win!");
-  }
-
-  const onClear = () => {
-    setQueenInserted({});
-  };
-
   return (
-    <div className="flex justify-center items-center h-screen font-mono flex-col gap-2">
-      <HashInput
-        visible={!prepared}
-        onEnter={(hash) => {
-          random.generator = new Rand(hash);
-          setBoard(prepareQueensBoard());
-          setPrepared(true);
-          setStartTime(new Date());
-        }}
-      />
-
-      {prepared && !started && (
-        <button
-          onClick={() => {
-            setStarted(true);
-            setStartTime(new Date());
-          }}
-          className="text-xl bg-green-500 text-white p-2 rounded-md hover:bg-green-600"
-        >
-          Başlat
-        </button>
-      )}
-
-      {started && (
-        <>
-          <button
-            onClick={onClear}
-            className="text-xl bg-red-500 text-white p-2 rounded-md hover:bg-red-600"
-          >
-            Temizle
-          </button>
-          <div className="text-4xl">
-            {differenceInMinutes(currentTime, startTime)
-              .toString()
-              .padStart(2, "0")}
-            :
-            {(differenceInSeconds(currentTime, startTime) % 60)
-              .toString()
-              .padStart(2, "0")}
-            {won
-              ? (
-                  "." +
-                  (differenceInMilliseconds(currentTime, startTime) % 1000)
-                )
-                  .toString()
-                  .padStart(3, "0")
-              : null}
-          </div>
-          {won && <InzvaQueen won={false} />}
-          <div
-            className={`inline-grid ${N === 8 ? "grid-cols-8" : "grid-cols-9"}`}
-          >
-            {board!.map((row, i) => (
-              <div
-                key={i}
-                className="grid"
-              >
-                {row.map((cellColor, j) => (
-                  <div
-                    key={j}
-                    className={clsx(
-                      "flex select-none justify-center items-center h-12 w-12 text-2xl cursor-pointer border border-black p-3"
-                    )}
-                    style={{
-                      background:
-                        !won || queenInserted[N * i + j] !== "q"
-                          ? cellColor
-                          : "gold",
-                    }}
-                    onClick={() =>
-                      setQueenInserted((prev) => ({
-                        ...prev,
-                        [N * i + j]:
-                          (prev[N * i + j] || "") === ""
-                            ? "x"
-                            : prev[N * i + j] === "x"
-                            ? "q"
-                            : "",
-                      }))
-                    }
-                  >
-                    {queenInserted[N * i + j] === "q" ? (
-                      <InzvaQueen won={won} />
-                    ) : isInDanger(i, j, queenInserted) ? (
-                      "x"
-                    ) : (
-                      queenInserted[N * i + j]
-                    )}
-                  </div>
-                ))}
-              </div>
-            ))}
-          </div>
-        </>
-      )}
+    <div className="w-screen h-screen">
+      <BrowserRouter basename="/queenzva">
+        <Routes>
+          <Route path="/" element={<CreateGame />} />
+          <Route path="/g/:hash" element={<Game />} />
+          <Route path="/g/" element={<Game />} />
+          <Route path="/game/:hash" element={<Game />} />
+          <Route path="/game" element={<Game />} />
+          <Route path="/stats" element={<Stats />} />
+        </Routes>
+      </BrowserRouter>
path: src/App.tsx
user: login: brkdnmz
body: Indeed a necessary addition, thanks!
created_at: 2025-03-18T21:55:07Z
author_association: OWNER
diff_hunk: @@ -140,8 +140,8 @@ export function prepareQueensBoard() {
     // console.log(solutionCount);
     if (solutionCount !== 1) continue;
 
-    console.log(solutionCount);
-    console.log(Date.now());
+    // console.log(solutionCount);
+    // console.log(Date.now());
path: src/board-util.ts
user: login: brkdnmz
body: Logging the current times was a feature I wanted in production, too :D For observing the runtime of the algorithm.

However, it wasn't really necessary. We can toggle them whenever we want for testing purposes.
created_at: 2025-03-18T21:58:17Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,28 @@
+// Class for LocalStorage operations
+class Storage {
path: src/utils/storage.tsx
user: login: brkdnmz
body: Maybe we can utilize zustand + persist middleware instead. Two of the advantages are type-safety and reactivity.

It would require specifying each store type used throughout the app, though, which is pretty fine.
created_at: 2025-03-18T22:02:35Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,37 @@
+import { useState } from "react";
+import User from "../utils/user";
+
+type NameTagProps = {
+  changeable?: boolean;
+};
+
+function NameTag({ changeable = false }: NameTagProps) {
+  const user = new User();
path: src/components/name-tag.tsx
user: login: brkdnmz
body: On each rerender, a new `User` unnecessarily gets created. However, it does not lead to any error I suppose.
created_at: 2025-03-18T22:14:14Z
author_association: OWNER
diff_hunk: @@ -10,15 +10,21 @@
     "preview": "vite preview"
   },
   "dependencies": {
+    "@types/uuid": "^10.0.0",
path: package.json
user: login: alikemalcoskun
body: types confusion fixed. I use uuid to create IDs for game history entries. It is unnecessary currently, but it could be useful in case of sorting, filtering, or comparing the entries (if we add these options :p) [09723a7](https://github.com/brkdnmz/queenzva/pull/1/commits/09723a7fb7324f29c61adde4ed3d747061568430)
created_at: 2025-03-19T14:45:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,15 +10,21 @@
     "preview": "vite preview"
   },
   "dependencies": {
+    "@types/uuid": "^10.0.0",
     "axios": "^1.7.5",
     "clsx": "^2.1.1",
     "date-fns": "^4.1.0",
+    "echarts-for-react": "^3.0.3",
+    "i": "^0.3.7",
+    "npm": "^11.2.0",
path: package.json
user: login: alikemalcoskun
body: It seems some dangerous npm commands were executed... Fixed: [e36253a](https://github.com/brkdnmz/queenzva/pull/1/commits/e36253aae4cdb98b7581748a72a24fcfa64fc153)
created_at: 2025-03-19T14:47:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -140,8 +140,8 @@ export function prepareQueensBoard() {
     // console.log(solutionCount);
     if (solutionCount !== 1) continue;
 
-    console.log(solutionCount);
-    console.log(Date.now());
+    // console.log(solutionCount);
+    // console.log(Date.now());
path: src/board-util.ts
user: login: alikemalcoskun
body: I accidentally used this function in an infinite loop and these logs fried my browser :D Reverted in [ccca511](https://github.com/brkdnmz/queenzva/pull/1/commits/ccca511dcbedea88296e038d6cc792ae174cf68d)
created_at: 2025-03-19T14:53:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,37 @@
+import { useState } from "react";
+import User from "../utils/user";
+
+type NameTagProps = {
+  changeable?: boolean;
+};
+
+function NameTag({ changeable = false }: NameTagProps) {
+  const user = new User();
path: src/components/name-tag.tsx
user: login: alikemalcoskun
body: Used useRef but I am still not quite sure: [7415e1e](https://github.com/brkdnmz/queenzva/pull/1/commits/7415e1e2b0464b8b6d0747a48522f22ce8ba356a)
created_at: 2025-03-19T14:56:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,15 +10,21 @@
     "preview": "vite preview"
   },
   "dependencies": {
+    "@types/uuid": "^10.0.0",
path: package.json
user: login: brkdnmz
body: No doubts that it could be useful. 👌
created_at: 2025-03-19T17:35:07Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,37 @@
+import { useState } from "react";
+import User from "../utils/user";
+
+type NameTagProps = {
+  changeable?: boolean;
+};
+
+function NameTag({ changeable = false }: NameTagProps) {
+  const user = new User();
path: src/components/name-tag.tsx
user: login: brkdnmz
body: There are a few other places having the same line, too. That won't be enough I suppose. Seems like a global instance is necessary.

I am thinking of utilizing zustand for this, too. A legit fix.

The current code won't hurt, though.
created_at: 2025-03-19T17:41:02Z
author_association: OWNER

PR comments:
user: login: brkdnmz
created_at: 2025-03-18T21:49:47Z
body: Had to post this before reviewing:

![image](https://github.com/user-attachments/assets/3b224468-628c-4ff2-84f4-89bc634ed3a6)

*Eline sağlık!*
author_association: OWNER
user: login: brkdnmz
created_at: 2025-03-18T22:19:49Z
body: There are several changes that I'd like to introduce with other PRs.

This much effort requires some claps. 👏 👏 
author_association: OWNER