refactor: harden loto game per code review

- Validate localStorage shape on load (typed parse, guarded set)
- Fix isRowComplete to require at least one number per row
- Split row-scan effect into bingo + waiting passes so updates to
  later rows are not skipped by an early return
- Memoize per-row completeness; was recomputing 81 times per render
- Replace clickable divs with real <button>s; aria-label, aria-pressed,
  focus ring, dialog role + escape on the bingo modal
- Freeze the master tracking board to make module-scope state safer
- Allow NEXT_BASE_PATH to override the prod default for forks /
  custom-domain deploys
This commit is contained in:
tiennm99 committed 2026-04-26 19:27:11 +07:00
1 parent 0844027460
commit abc97aa87b
5 files changed
+128 -46

No files matched your search

+57 -13
View File
@@ -76,44 +76,88 @@ export function generateGrid(): number[][] {
return cell;
}
function safeParse<T>(raw: string | null, validate: (v: unknown) => v is T): T | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as unknown;
return validate(parsed) ? parsed : null;
} catch {
return null;
}
}
function isNumberMatrix(v: unknown): v is number[][] {
return (
Array.isArray(v) &&
v.length === NUM_ROWS &&
v.every(
(row) =>
Array.isArray(row) &&
row.length === NUM_COLS &&
row.every((n) => typeof n === "number")
)
);
}
function isBoolMatrix(v: unknown): v is boolean[][] {
return (
Array.isArray(v) &&
v.length === NUM_ROWS &&
v.every(
(row) =>
Array.isArray(row) &&
row.length === NUM_COLS &&
row.every((b) => typeof b === "boolean")
)
);
}
export function saveGrid(grid: number[][], prefix = "loto"): void {
localStorage.setItem(`${prefix}_grid`, JSON.stringify(grid));
try {
localStorage.setItem(`${prefix}_grid`, JSON.stringify(grid));
} catch {
// localStorage disabled or quota exceeded — ignore, app still works in-memory
}
}
export function loadGrid(prefix = "loto"): number[][] | null {
const data = localStorage.getItem(`${prefix}_grid`);
if (!data) return null;
try {
return JSON.parse(data);
return safeParse(localStorage.getItem(`${prefix}_grid`), isNumberMatrix);
} catch {
return null;
}
}
export function saveCrossedState(crossed: boolean[][], prefix = "loto"): void {
localStorage.setItem(`${prefix}_crossed`, JSON.stringify(crossed));
try {
localStorage.setItem(`${prefix}_crossed`, JSON.stringify(crossed));
} catch {
// see saveGrid
}
}
export function loadCrossedState(prefix = "loto"): boolean[][] | null {
const data = localStorage.getItem(`${prefix}_crossed`);
if (!data) return null;
try {
return JSON.parse(data);
return safeParse(localStorage.getItem(`${prefix}_crossed`), isBoolMatrix);
} catch {
return null;
}
}
/** Check if a row has all its numbers crossed */
/** Check if a row has all its numbers crossed (and has at least one number) */
export function isRowComplete(
grid: number[][],
crossed: boolean[][],
row: number
): boolean {
for (let col = 0; col < 9; col++) {
if (grid[row][col] > 0 && !crossed[row]?.[col]) return false;
let hasNumber = false;
for (let col = 0; col < NUM_COLS; col++) {
if (grid[row][col] > 0) {
hasNumber = true;
if (!crossed[row]?.[col]) return false;
}
}
return true;
return hasNumber;
}
/** Find the single remaining uncrossed number in a row, or null if != 1 remaining */
@@ -123,7 +167,7 @@ export function getWaitingNumber(
row: number
): number | null {
let remaining: number | null = null;
for (let col = 0; col < 9; col++) {
for (let col = 0; col < NUM_COLS; col++) {
if (grid[row][col] > 0 && !crossed[row]?.[col]) {
if (remaining !== null) return null;
remaining = grid[row][col];
+58 -28
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
generateGrid,
getWaitingNumber,
@@ -72,30 +72,36 @@ export default function PlayerBoard({ storagePrefix = "loto" }: PlayerBoardProps
useEffect(() => {
if (!grid || crossed.length === 0) return;
// Pass 1: at most one bingo popup per render
for (let i = 0; i < grid.length; i++) {
if (!celebratedRows.current.has(i) && isRowComplete(grid, crossed, i)) {
celebratedRows.current.add(i);
notifiedWaitingRows.current.add(i);
setCongratsRow(i + 1);
setShowCongrats(true);
return;
break;
}
}
// Pass 2: update waiting state for every non-celebrated row
for (let i = 0; i < grid.length; i++) {
if (celebratedRows.current.has(i)) continue;
const waitNum = getWaitingNumber(grid, crossed, i);
if (waitNum !== null && !notifiedWaitingRows.current.has(i)) {
notifiedWaitingRows.current.add(i);
showToast(`Chờ ${waitNum}`);
return;
}
if (
waitNum === null &&
notifiedWaitingRows.current.has(i) &&
!celebratedRows.current.has(i)
) {
} else if (waitNum === null && notifiedWaitingRows.current.has(i)) {
notifiedWaitingRows.current.delete(i);
}
}
}, [grid, crossed, showToast]);
// Precompute per-row completeness so we don't call isRowComplete 81×/render
const rowCompleteness = useMemo(() => {
if (!grid || crossed.length === 0) return [];
return grid.map((_, row) => isRowComplete(grid, crossed, row));
}, [grid, crossed]);
const handleGenerate = useCallback(() => {
if (grid && !confirm("Bạn có muốn tạo lại bảng không?")) return;
const newGrid = generateGrid();
@@ -133,40 +139,52 @@ export default function PlayerBoard({ storagePrefix = "loto" }: PlayerBoardProps
{grid ? (
<div className="relative">
<div className="rounded-2xl overflow-hidden shadow-xl shadow-slate-200/50 dark:shadow-black/30 border border-slate-200 dark:border-slate-700">
<div
aria-label="Bảng lô tô"
className="rounded-2xl overflow-hidden shadow-xl shadow-slate-200/50 dark:shadow-black/30 border border-slate-200 dark:border-slate-700"
>
<div className="loto-grid">
{grid.flat().map((num, idx) => {
const row = Math.floor(idx / 9);
const col = idx % 9;
const hasNumber = num > 0;
const isCrossed = hasNumber && crossed[row]?.[col];
const rowComplete =
hasNumber && isRowComplete(grid, crossed, row);
const isCrossed = hasNumber && !!crossed[row]?.[col];
const rowComplete = hasNumber && rowCompleteness[row];
if (!hasNumber) {
return (
<div
key={idx}
aria-hidden="true"
className="relative flex items-center justify-center aspect-square border-r border-b border-slate-200/80 dark:border-slate-700/60 bg-slate-50 dark:bg-slate-900/60"
/>
);
}
return (
<div
<button
key={idx}
onClick={
hasNumber ? () => handleCellClick(row, col) : undefined
}
type="button"
aria-label={`Số ${num}${isCrossed ? ", đã đánh dấu" : ""}`}
aria-pressed={isCrossed}
onClick={() => handleCellClick(row, col)}
className={`
relative flex items-center justify-center
aspect-square text-base sm:text-xl font-bold
border-r border-b border-slate-200/80 dark:border-slate-700/60
transition-all select-none
transition-all select-none cursor-pointer
focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-400
${
hasNumber
? isCrossed
? rowComplete
? "cell-crossed bg-emerald-100 dark:bg-emerald-900/40 text-emerald-500 dark:text-emerald-400 cursor-pointer"
: "cell-crossed bg-red-50 dark:bg-red-950/30 text-red-400 dark:text-red-500 cursor-pointer"
: "bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-100 cursor-pointer hover:bg-indigo-50 dark:hover:bg-indigo-950/30 hover:text-indigo-600 dark:hover:text-indigo-400"
: "bg-slate-50 dark:bg-slate-900/60"
isCrossed
? rowComplete
? "cell-crossed bg-emerald-100 dark:bg-emerald-900/40 text-emerald-500 dark:text-emerald-400"
: "cell-crossed bg-red-50 dark:bg-red-950/30 text-red-400 dark:text-red-500"
: "bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-100 hover:bg-indigo-50 dark:hover:bg-indigo-950/30 hover:text-indigo-600 dark:hover:text-indigo-400"
}
`}
>
{hasNumber ? num : ""}
</div>
{num}
</button>
);
})}
</div>
@@ -174,6 +192,8 @@ export default function PlayerBoard({ storagePrefix = "loto" }: PlayerBoardProps
{toast && (
<div
role="status"
aria-live="polite"
onClick={dismissToast}
className="absolute inset-0 flex items-center justify-center pointer-events-auto cursor-pointer z-10"
>
@@ -191,8 +211,15 @@ export default function PlayerBoard({ storagePrefix = "loto" }: PlayerBoardProps
{showCongrats && (
<div
role="dialog"
aria-modal="true"
aria-labelledby="congrats-title"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fade-in"
onClick={() => setShowCongrats(false)}
onKeyDown={(e) => {
if (e.key === "Escape") setShowCongrats(false);
}}
tabIndex={-1}
>
<div
className="relative mx-4 max-w-sm w-full rounded-3xl bg-white dark:bg-slate-800 p-8 text-center shadow-2xl animate-pop-in"
@@ -208,7 +235,10 @@ export default function PlayerBoard({ storagePrefix = "loto" }: PlayerBoardProps
🎊
</div>
<h2 className="mt-6 text-3xl font-black bg-gradient-to-r from-amber-500 via-pink-500 to-purple-500 bg-clip-text text-transparent">
<h2
id="congrats-title"
className="mt-6 text-3xl font-black bg-gradient-to-r from-amber-500 via-pink-500 to-purple-500 bg-clip-text text-transparent"
>
Kinh!
</h2>
<p className="mt-3 text-lg text-slate-600 dark:text-slate-300">
+9 -3
View File
@@ -58,7 +58,10 @@ function loadState(): MasterState | null {
}
}
const BOARD = buildBoard();
const BOARD: ReadonlyArray<ReadonlyArray<number>> = Object.freeze(
buildBoard().map((row) => Object.freeze(row))
);
const BOARD_FLAT: ReadonlyArray<number> = Object.freeze(BOARD.flatMap((r) => r));
export default function MasterPage() {
const [state, setState] = useState<MasterState | null>(null);
@@ -179,9 +182,12 @@ export default function MasterPage() {
{/* Master board 9x10 */}
{state ? (
<div className="rounded-2xl overflow-hidden shadow-xl shadow-slate-200/50 dark:shadow-black/30 border border-slate-200 dark:border-slate-700">
<div
aria-label="Bảng theo dõi số đã xổ"
className="rounded-2xl overflow-hidden shadow-xl shadow-slate-200/50 dark:shadow-black/30 border border-slate-200 dark:border-slate-700"
>
<div className="master-grid">
{BOARD.flat().map((num, idx) => {
{BOARD_FLAT.map((num, idx) => {
const hasNumber = num > 0;
const isCalled = hasNumber && calledSet.has(num);
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+3 -1
View File
@@ -20,7 +20,9 @@ function codeserverConfig() {
}
const cs = isCodeserver ? codeserverConfig() : null;
const basePath = cs?.basePath ?? (isProd ? "/loto" : "");
// NEXT_BASE_PATH wins so custom-domain / fork deploys don't have to edit code.
const basePath =
process.env.NEXT_BASE_PATH ?? cs?.basePath ?? (isProd ? "/loto" : "");
const nextConfig: NextConfig = {
output: "export",