diff --git a/web/app/layout.tsx b/web/app/layout.jsx similarity index 70% rename from web/app/layout.tsx rename to web/app/layout.jsx index ae170cd..d2e98a1 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.jsx @@ -1,4 +1,3 @@ -import type { Metadata } from "next"; import { Geist } from "next/font/google"; import "./globals.css"; @@ -7,16 +6,14 @@ const geistSans = Geist({ subsets: ["latin"], }); -export const metadata: Metadata = { +/** @type {import('next').Metadata} */ +export const metadata = { title: "Lô tô", description: "Bàn số của trò chơi Lô tô", }; -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { +/** @param {{ children: React.ReactNode }} props */ +export default function RootLayout({ children }) { return ( {children} diff --git a/web/app/master/page.tsx b/web/app/master/page.jsx similarity index 88% rename from web/app/master/page.tsx rename to web/app/master/page.jsx index e6748a6..58ff768 100644 --- a/web/app/master/page.tsx +++ b/web/app/master/page.jsx @@ -6,11 +6,22 @@ import PlayerBoard from "@/components/player-board"; const STORAGE_KEY = "loto_master"; -/** Build the 9x10 board: columns 0-8 map to number ranges 1-9, 10-19, ..., 80-90 */ -function buildBoard(): number[][] { - const board: number[][] = []; +/** + * @typedef {Object} MasterState + * @property {number[]} called numbers drawn so far, in order + * @property {number[]} remaining numbers left to draw, pre-shuffled + */ + +/** + * Build the 9x10 board: columns 0-8 map to number ranges 1-9, 10-19, ..., 80-90. + * @returns {number[][]} + */ +function buildBoard() { + /** @type {number[][]} */ + const board = []; for (let row = 0; row < 10; row++) { - const cells: number[] = []; + /** @type {number[]} */ + const cells = []; for (let col = 0; col < 9; col++) { const num = col === 0 ? row + 1 : col * 10 + row; // Column 0: 1-9 (row 9 is empty), Columns 1-8: 10-19, ..., 80-89 (row 9 has 90 for col 8) @@ -29,12 +40,8 @@ function buildBoard(): number[][] { return board; } -interface MasterState { - called: number[]; - remaining: number[]; -} - -function createFreshState(): MasterState { +/** @returns {MasterState} */ +function createFreshState() { const all = Array.from({ length: 90 }, (_, i) => i + 1); // Shuffle for (let i = all.length - 1; i > 0; i--) { @@ -44,11 +51,13 @@ function createFreshState(): MasterState { return { called: [], remaining: all }; } -function saveState(state: MasterState): void { +/** @param {MasterState} state */ +function saveState(state) { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } -function loadState(): MasterState | null { +/** @returns {MasterState | null} */ +function loadState() { const data = localStorage.getItem(STORAGE_KEY); if (!data) return null; try { @@ -58,14 +67,14 @@ function loadState(): MasterState | null { } } -const BOARD: ReadonlyArray> = Object.freeze( - buildBoard().map((row) => Object.freeze(row)) -); -const BOARD_FLAT: ReadonlyArray = Object.freeze(BOARD.flatMap((r) => r)); +const BOARD = Object.freeze(buildBoard().map((row) => Object.freeze(row))); +const BOARD_FLAT = Object.freeze(BOARD.flatMap((r) => r)); export default function MasterPage() { - const [state, setState] = useState(null); - const [lastCalled, setLastCalled] = useState(null); + /** @type {[MasterState | null, (s: MasterState | null) => void]} */ + const [state, setState] = useState(/** @type {MasterState | null} */ (null)); + /** @type {[number | null, (n: number | null) => void]} */ + const [lastCalled, setLastCalled] = useState(/** @type {number | null} */ (null)); useEffect(() => { const saved = loadState(); diff --git a/web/app/page.tsx b/web/app/page.jsx similarity index 100% rename from web/app/page.tsx rename to web/app/page.jsx diff --git a/web/components/player-board.tsx b/web/components/player-board.jsx similarity index 86% rename from web/components/player-board.tsx rename to web/components/player-board.jsx index 3cfaca7..46babb9 100644 --- a/web/components/player-board.tsx +++ b/web/components/player-board.jsx @@ -11,20 +11,28 @@ import { saveGrid, } from "@/lib/game-logic"; -interface PlayerBoardProps { - /** localStorage key prefix; allows multiple independent boards (e.g. user vs master) */ - storagePrefix?: string; -} +/** + * @typedef {Object} PlayerBoardProps + * @property {string} [storagePrefix] localStorage key prefix; allows multiple + * independent boards (e.g. user vs master) + */ -export default function PlayerBoard({ storagePrefix = "loto" }: PlayerBoardProps) { - const [grid, setGrid] = useState(null); - const [crossed, setCrossed] = useState([]); +/** @param {PlayerBoardProps} props */ +export default function PlayerBoard({ storagePrefix = "loto" } = {}) { + /** @type {[number[][] | null, (g: number[][] | null) => void]} */ + const [grid, setGrid] = useState(/** @type {number[][] | null} */ (null)); + /** @type {[boolean[][], React.Dispatch>]} */ + const [crossed, setCrossed] = useState(/** @type {boolean[][]} */ ([])); const [showCongrats, setShowCongrats] = useState(false); - const [congratsRow, setCongratsRow] = useState(-1); - const [toast, setToast] = useState(null); - const toastTimer = useRef | null>(null); - const celebratedRows = useRef>(new Set()); - const notifiedWaitingRows = useRef>(new Set()); + const [congratsRow, setCongratsRow] = useState(-1); + /** @type {[string | null, (s: string | null) => void]} */ + const [toast, setToast] = useState(/** @type {string | null} */ (null)); + /** @type {React.MutableRefObject | null>} */ + const toastTimer = useRef(null); + /** @type {React.MutableRefObject>} */ + const celebratedRows = useRef(new Set()); + /** @type {React.MutableRefObject>} */ + const notifiedWaitingRows = useRef(new Set()); const dismissToast = useCallback(() => { setToast(null); @@ -35,7 +43,8 @@ export default function PlayerBoard({ storagePrefix = "loto" }: PlayerBoardProps }, []); const showToast = useCallback( - (msg: string) => { + /** @param {string} msg */ + (msg) => { dismissToast(); setToast(msg); toastTimer.current = setTimeout(() => setToast(null), 5000); @@ -115,13 +124,20 @@ export default function PlayerBoard({ storagePrefix = "loto" }: PlayerBoardProps dismissToast(); }, [grid, dismissToast, storagePrefix]); - const handleCellClick = useCallback((row: number, col: number) => { - setCrossed((prev) => { - const next = prev.map((r) => [...r]); - next[row][col] = !next[row][col]; - return next; - }); - }, []); + const handleCellClick = useCallback( + /** + * @param {number} row + * @param {number} col + */ + (row, col) => { + setCrossed((prev) => { + const next = prev.map((r) => [...r]); + next[row][col] = !next[row][col]; + return next; + }); + }, + [] + ); return ( <> diff --git a/web/docs/code-standards.md b/web/docs/code-standards.md index 02b307d..fb0db5f 100644 --- a/web/docs/code-standards.md +++ b/web/docs/code-standards.md @@ -2,12 +2,12 @@ ## File Naming & Structure -- **Kebab-case** for all files: `player-board.tsx`, `game-logic.ts`. +- **Kebab-case** for all files: `player-board.jsx`, `game-logic.js`. - **Descriptive names**: Long names are preferred for self-documentation. Avoid ambiguity. - **Single responsibility**: Each file has one primary export (component or utilities). - **Max 200 lines per file**: Split larger components into smaller focused ones. -## React & TypeScript Conventions +## React & JavaScript Conventions ### Hooks - **useState**: For local UI state (grid, crossed, form inputs). @@ -15,10 +15,12 @@ - **useCallback**: For event handlers to stabilize function identity across renders. - **useRef**: For mutable values that don't trigger renders (timers, celebratedRows set). -### Typing -- Explicit `export interface Props { ... }` for component props. -- Use `Readonly<{ children: React.ReactNode }>` for layout children. +### Typing (JSDoc) +- Author types in JSDoc comments — `jsconfig.json` has `checkJs: true`, so `tsc --noEmit` validates them. +- Component props: `@typedef {Object} Props { ... }` followed by `@param {Props} props` on the function. +- Layout children: `@param {{ children: React.ReactNode }} props`. - Avoid `any`; use precise types (e.g., `number[][]` for grid). +- Generics: `@template T` then reference `T` in `@param` / `@returns`. ### Client-Only Constraint - All interactive pages must have `"use client"` at the top. @@ -47,15 +49,23 @@ ## localStorage Patterns ### Saving -```typescript -function saveGrid(grid: number[][], prefix = "loto"): void { +```js +/** + * @param {number[][]} grid + * @param {string} [prefix] + */ +function saveGrid(grid, prefix = "loto") { localStorage.setItem(`${prefix}_grid`, JSON.stringify(grid)); } ``` ### Loading -```typescript -function loadGrid(prefix = "loto"): number[][] | null { +```js +/** + * @param {string} [prefix] + * @returns {number[][] | null} + */ +function loadGrid(prefix = "loto") { const data = localStorage.getItem(`${prefix}_grid`); if (!data) return null; try { @@ -81,18 +91,22 @@ function loadGrid(prefix = "loto"): number[][] | null { | camelCase | `handleCellClick`, `storagePrefix` | variables, functions, props | | PascalCase | `PlayerBoard`, `MasterPage` | components, types | | UPPER_SNAKE | `STORAGE_KEY`, `NUM_ROWS` | constants | -| kebab-case | `player-board.tsx` | file names | +| kebab-case | `player-board.jsx` | file names | ## Comment Style - Document **why**, not **what**. The code shows what it does. - Use `/** JSDoc */` for exported functions. -- Inline comments for complex logic (e.g., weighted random selection in `lib/game-logic.ts:19–28`). +- Inline comments for complex logic (e.g., weighted random selection in `lib/game-logic.js:19–28`). ### Example -```typescript -/** Weighted random selection of a column index */ -function randomANumberInRow(weights: number[]): number { +```js +/** + * Weighted random selection of a column index. + * @param {number[]} weights + * @returns {number} + */ +function randomANumberInRow(weights) { // Convert weights to cumulative distribution for O(n) lookup const tempWeight = [...weights]; for (let i = 1; i < tempWeight.length; i++) { @@ -108,7 +122,7 @@ function randomANumberInRow(weights: number[]): number { 2. Third-party imports 3. Local component/utility imports -```typescript +```js import { useCallback, useState } from "react"; import Link from "next/link"; import PlayerBoard from "@/components/player-board"; diff --git a/web/docs/codebase-summary.md b/web/docs/codebase-summary.md index ccd2a7b..7e0c703 100644 --- a/web/docs/codebase-summary.md +++ b/web/docs/codebase-summary.md @@ -5,19 +5,19 @@ ### Routing & Layout | File | Purpose | |------|---------| -| `app/layout.tsx` | Root HTML layout. Sets Vietnamese lang, imports Geist font, applies global flex layout. | -| `app/page.tsx` | Player page (`/`). Instructions toggle, PlayerBoard component, indigo gradient branding. | -| `app/master/page.tsx` | Host page (`/master`). Controls (new game, draw number), 9×10 master board, host's player card. | +| `app/layout.jsx` | Root HTML layout. Sets Vietnamese lang, imports Geist font, applies global flex layout. | +| `app/page.jsx` | Player page (`/`). Instructions toggle, PlayerBoard component, indigo gradient branding. | +| `app/master/page.jsx` | Host page (`/master`). Controls (new game, draw number), 9×10 master board, host's player card. | ### Shared Components | File | Purpose | |------|---------| -| `components/player-board.tsx` | Reusable player card (9×9 grid). Handles crossed state, bingo popup, "Chờ X" toast. Accepts `storagePrefix` prop for multi-card isolation. | +| `components/player-board.jsx` | Reusable player card (9×9 grid). Handles crossed state, bingo popup, "Chờ X" toast. Accepts `storagePrefix` prop for multi-card isolation. | ### Game Logic | File | Purpose | |------|---------| -| `lib/game-logic.ts` | Stateless utilities: generateGrid (weighted column selection), saveGrid, loadGrid, saveCrossedState, loadCrossedState, isRowComplete, getWaitingNumber. | +| `lib/game-logic.js` | Stateless utilities: generateGrid (weighted column selection), saveGrid, loadGrid, saveCrossedState, loadCrossedState, isRowComplete, getWaitingNumber. | ### Styling | File | Purpose | @@ -27,7 +27,7 @@ ### Configuration | File | Purpose | |------|---------| -| `next.config.ts` | Dual basePath: prod `/loto`, codeserver `/absproxy/{PORT}`. Exports static HTML. HMR-aware. | +| `next.config.mjs` | Dual basePath: prod `/loto`, codeserver `/absproxy/{PORT}`. Exports static HTML. HMR-aware. | | `package.json` | Next 16.2.2, React 19.2.4, Tailwind 4. Scripts: dev, dev:codeserver, build, start, lint. | | `eslint.config.mjs` | ESLint 9 config (Next.js preset). | | `.gitignore` | Excludes node_modules, .next, .env.local, etc. | @@ -66,10 +66,10 @@ RootLayout | Function | Location | Effect | |----------|----------|--------| -| `generateGrid()` | game-logic.ts:52 | Creates 9×9 with weighted column selection (5 nums/row). | -| `isRowComplete()` | game-logic.ts:108 | Boolean: all non-zero cells in row crossed? | -| `getWaitingNumber()` | game-logic.ts:120 | Returns the single uncrossed number in row, or null. | -| `handleCellClick()` | player-board.tsx:112 | Toggle crossed[row][col]. | -| `handleDrawNext()` | master/page.tsx:88 | Pop first number from remaining, add to called. | +| `generateGrid()` | game-logic.js:52 | Creates 9×9 with weighted column selection (5 nums/row). | +| `isRowComplete()` | game-logic.js:108 | Boolean: all non-zero cells in row crossed? | +| `getWaitingNumber()` | game-logic.js:120 | Returns the single uncrossed number in row, or null. | +| `handleCellClick()` | player-board.jsx:112 | Toggle crossed[row][col]. | +| `handleDrawNext()` | master/page.jsx:88 | Pop first number from remaining, add to called. | Last reviewed: 2026-04-26 diff --git a/web/docs/deployment-guide.md b/web/docs/deployment-guide.md index 818ef9e..6959f08 100644 --- a/web/docs/deployment-guide.md +++ b/web/docs/deployment-guide.md @@ -11,7 +11,7 @@ The app auto-deploys from the `master` branch via `.github/workflows/deploy.yml` 3. Static export written to `out/` directory 4. Pages deployed to `https://{user}.github.io/loto` -**Note**: basePath is set to `/loto` in production (`next.config.ts:23`). +**Note**: basePath is set to `/loto` in production (`next.config.mjs:23`). ### Manual Deploy (if needed) ```bash @@ -55,7 +55,7 @@ Replace `your-machine.example.com` with your actual hostname/IP (must match the npm run dev:codeserver ``` -This sets `NEXT_DEV_PROFILE=codeserver`, triggering the code-server config path in `next.config.ts`. +This sets `NEXT_DEV_PROFILE=codeserver`, triggering the code-server config path in `next.config.mjs`. **4. Access via browser**: Navigate to: @@ -88,7 +88,7 @@ Generates: - `.next/` — Build cache (not needed for deployment) ### Export Settings -- `output: "export"` in `next.config.ts` +- `output: "export"` in `next.config.mjs` - No server-side rendering - All pages pre-rendered to HTML + JS bundles diff --git a/web/docs/system-architecture.md b/web/docs/system-architecture.md index 06996f0..34f19f7 100644 --- a/web/docs/system-architecture.md +++ b/web/docs/system-architecture.md @@ -86,9 +86,9 @@ All pages use `"use client"` because: - Hydration requires client-only state initialization Files with `"use client"`: -- `app/page.tsx` -- `app/master/page.tsx` -- `components/player-board.tsx` +- `app/page.jsx` +- `app/master/page.jsx` +- `components/player-board.jsx` ## Data Flow: Mark a Cell diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs index 05e726d..b5be235 100644 --- a/web/eslint.config.mjs +++ b/web/eslint.config.mjs @@ -1,18 +1,9 @@ import { defineConfig, globalIgnores } from "eslint/config"; import nextVitals from "eslint-config-next/core-web-vitals"; -import nextTs from "eslint-config-next/typescript"; const eslintConfig = defineConfig([ ...nextVitals, - ...nextTs, - // Override default ignores of eslint-config-next. - globalIgnores([ - // Default ignores of eslint-config-next: - ".next/**", - "out/**", - "build/**", - "next-env.d.ts", - ]), + globalIgnores([".next/**", "out/**", "build/**"]), ]); export default eslintConfig; diff --git a/web/tsconfig.json b/web/jsconfig.json similarity index 51% rename from web/tsconfig.json rename to web/jsconfig.json index 3a13f90..cce012d 100644 --- a/web/tsconfig.json +++ b/web/jsconfig.json @@ -1,34 +1,20 @@ { "compilerOptions": { "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", + "checkJs": true, + "allowJs": true, + "jsx": "react-jsx", + "lib": ["dom", "dom.iterable", "esnext"], + "esModuleInterop": true, "resolveJsonModule": true, "isolatedModules": true, - "jsx": "react-jsx", - "incremental": true, - "plugins": [ - { - "name": "next" - } - ], - "paths": { - "@/*": ["./*"] - } + "skipLibCheck": true, + "strict": false, + "noEmit": true, + "paths": { "@/*": ["./*"] } }, - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - ".next/types/**/*.ts", - ".next/dev/types/**/*.ts", - "**/*.mts" - ], - "exclude": ["node_modules"] + "include": ["**/*.js", "**/*.jsx", "**/*.mjs"], + "exclude": ["node_modules", ".next", "out"] } diff --git a/web/lib/game-logic.ts b/web/lib/game-logic.js similarity index 58% rename from web/lib/game-logic.ts rename to web/lib/game-logic.js index 845f3a8..48a6a6d 100644 --- a/web/lib/game-logic.ts +++ b/web/lib/game-logic.js @@ -1,5 +1,12 @@ +// @ts-check + +/** + * Lô tô card generation, persistence, and row-state helpers. + * @module lib/game-logic + */ + /** Number ranges for each column (0-8) in the lô tô grid */ -const NUM_IN_COL: number[][] = [ +const NUM_IN_COL = [ [1, 2, 3, 4, 5, 6, 7, 8, 9], [10, 11, 12, 13, 14, 15, 16, 17, 18, 19], [20, 21, 22, 23, 24, 25, 26, 27, 28, 29], @@ -15,8 +22,12 @@ const NUM_ROWS = 9; const NUM_COLS = 9; const NUM_PER_ROW = 5; -/** Weighted random selection of a column index */ -function randomANumberInRow(weights: number[]): number { +/** + * Weighted random selection of a column index. + * @param {number[]} weights + * @returns {number} + */ +function randomANumberInRow(weights) { const tempWeight = [...weights]; for (let i = 1; i < tempWeight.length; i++) { tempWeight[i] += tempWeight[i - 1]; @@ -28,10 +39,15 @@ function randomANumberInRow(weights: number[]): number { return 0; } -/** Select NUM_PER_ROW columns for a row using weighted random */ -function randomARow(baseWeight: number[]): number[] { +/** + * Select NUM_PER_ROW columns for a row using weighted random. Mutates baseWeight. + * @param {number[]} baseWeight + * @returns {number[]} + */ +function randomARow(baseWeight) { const tempWeight = [...baseWeight]; - const selectedCols: number[] = []; + /** @type {number[]} */ + const selectedCols = []; for (let i = 0; i < NUM_PER_ROW; i++) { const col = randomANumberInRow(tempWeight); selectedCols.push(col); @@ -41,15 +57,23 @@ function randomARow(baseWeight: number[]): number[] { return selectedCols; } -/** Pick random numbers from a column's range */ -function randomNumbersInCol(num: number, col: number): number[] { +/** + * Pick `num` random numbers from column `col`'s range. + * @param {number} num + * @param {number} col + * @returns {number[]} + */ +function randomNumbersInCol(num, col) { const arr = [...NUM_IN_COL[col]]; arr.sort(() => 0.5 - Math.random()); return arr.slice(0, num); } -/** Generate a 9x9 lô tô grid. Returns cell values (0 = empty, >0 = number). */ -export function generateGrid(): number[][] { +/** + * Generate a 9x9 lô tô grid. Cell values: 0 = empty, >0 = number. + * @returns {number[][]} + */ +export function generateGrid() { const cell = Array.from({ length: NUM_ROWS }, () => new Array(NUM_COLS).fill(0) ); @@ -76,17 +100,24 @@ export function generateGrid(): number[][] { return cell; } -function safeParse(raw: string | null, validate: (v: unknown) => v is T): T | null { +/** + * @template T + * @param {string | null} raw + * @param {(v: unknown) => boolean} validate runtime guard; cast result to T on success + * @returns {T | null} + */ +function safeParse(raw, validate) { if (!raw) return null; try { - const parsed = JSON.parse(raw) as unknown; - return validate(parsed) ? parsed : null; + const parsed = JSON.parse(raw); + return validate(parsed) ? /** @type {T} */ (parsed) : null; } catch { return null; } } -function isNumberMatrix(v: unknown): v is number[][] { +/** @param {unknown} v @returns {boolean} */ +function isNumberMatrix(v) { return ( Array.isArray(v) && v.length === NUM_ROWS && @@ -99,7 +130,8 @@ function isNumberMatrix(v: unknown): v is number[][] { ); } -function isBoolMatrix(v: unknown): v is boolean[][] { +/** @param {unknown} v @returns {boolean} */ +function isBoolMatrix(v) { return ( Array.isArray(v) && v.length === NUM_ROWS && @@ -112,7 +144,11 @@ function isBoolMatrix(v: unknown): v is boolean[][] { ); } -export function saveGrid(grid: number[][], prefix = "loto"): void { +/** + * @param {number[][]} grid + * @param {string} [prefix] + */ +export function saveGrid(grid, prefix = "loto") { try { localStorage.setItem(`${prefix}_grid`, JSON.stringify(grid)); } catch { @@ -120,7 +156,11 @@ export function saveGrid(grid: number[][], prefix = "loto"): void { } } -export function loadGrid(prefix = "loto"): number[][] | null { +/** + * @param {string} [prefix] + * @returns {number[][] | null} + */ +export function loadGrid(prefix = "loto") { try { return safeParse(localStorage.getItem(`${prefix}_grid`), isNumberMatrix); } catch { @@ -128,7 +168,11 @@ export function loadGrid(prefix = "loto"): number[][] | null { } } -export function saveCrossedState(crossed: boolean[][], prefix = "loto"): void { +/** + * @param {boolean[][]} crossed + * @param {string} [prefix] + */ +export function saveCrossedState(crossed, prefix = "loto") { try { localStorage.setItem(`${prefix}_crossed`, JSON.stringify(crossed)); } catch { @@ -136,7 +180,11 @@ export function saveCrossedState(crossed: boolean[][], prefix = "loto"): void { } } -export function loadCrossedState(prefix = "loto"): boolean[][] | null { +/** + * @param {string} [prefix] + * @returns {boolean[][] | null} + */ +export function loadCrossedState(prefix = "loto") { try { return safeParse(localStorage.getItem(`${prefix}_crossed`), isBoolMatrix); } catch { @@ -144,12 +192,14 @@ export function loadCrossedState(prefix = "loto"): boolean[][] | null { } } -/** 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 { +/** + * Check if a row has all its numbers crossed (and has at least one number). + * @param {number[][]} grid + * @param {boolean[][]} crossed + * @param {number} row + * @returns {boolean} + */ +export function isRowComplete(grid, crossed, row) { let hasNumber = false; for (let col = 0; col < NUM_COLS; col++) { if (grid[row][col] > 0) { @@ -160,13 +210,16 @@ export function isRowComplete( return hasNumber; } -/** Find the single remaining uncrossed number in a row, or null if != 1 remaining */ -export function getWaitingNumber( - grid: number[][], - crossed: boolean[][], - row: number -): number | null { - let remaining: number | null = null; +/** + * Find the single remaining uncrossed number in a row, or null if != 1 remaining. + * @param {number[][]} grid + * @param {boolean[][]} crossed + * @param {number} row + * @returns {number | null} + */ +export function getWaitingNumber(grid, crossed, row) { + /** @type {number | null} */ + let remaining = null; for (let col = 0; col < NUM_COLS; col++) { if (grid[row][col] > 0 && !crossed[row]?.[col]) { if (remaining !== null) return null; diff --git a/web/next-env.d.ts b/web/next-env.d.ts deleted file mode 100644 index 9edff1c..0000000 --- a/web/next-env.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -/// -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. diff --git a/web/next.config.ts b/web/next.config.mjs similarity index 94% rename from web/next.config.ts rename to web/next.config.mjs index b496f8c..29d953d 100644 --- a/web/next.config.ts +++ b/web/next.config.mjs @@ -1,4 +1,4 @@ -import type { NextConfig } from "next"; +// @ts-check const isProd = process.env.NODE_ENV === "production"; const isCodeserver = process.env.NEXT_DEV_PROFILE === "codeserver"; @@ -24,7 +24,8 @@ const cs = isCodeserver ? codeserverConfig() : null; const basePath = process.env.NEXT_BASE_PATH ?? cs?.basePath ?? (isProd ? "/loto" : ""); -const nextConfig: NextConfig = { +/** @type {import('next').NextConfig} */ +const nextConfig = { output: "export", basePath, assetPrefix: basePath || undefined, diff --git a/web/package-lock.json b/web/package-lock.json index 0bda505..7bd40dd 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,11 +1,11 @@ { - "name": "nextjs-temp", + "name": "loto", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "nextjs-temp", + "name": "loto", "version": "0.1.0", "dependencies": { "next": "16.2.2", @@ -14,13 +14,9 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.2.2", - "tailwindcss": "^4", - "typescript": "^5" + "tailwindcss": "^4" } }, "node_modules/@alloc/quick-lru": { @@ -1545,36 +1541,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/node": { - "version": "20.19.39", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", - "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.58.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", @@ -2654,13 +2620,6 @@ "node": ">= 8" } }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -6276,6 +6235,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6327,13 +6287,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, "node_modules/unrs-resolver": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", diff --git a/web/package.json b/web/package.json index adb4a7f..7ad817e 100644 --- a/web/package.json +++ b/web/package.json @@ -16,12 +16,8 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.2.2", - "tailwindcss": "^4", - "typescript": "^5" + "tailwindcss": "^4" } } diff --git a/web/plans/260426-1934-ts-to-jsdoc-refactor/phase-01-tooling-swap.md b/web/plans/260426-1934-ts-to-jsdoc-refactor/phase-01-tooling-swap.md new file mode 100644 index 0000000..148ed11 --- /dev/null +++ b/web/plans/260426-1934-ts-to-jsdoc-refactor/phase-01-tooling-swap.md @@ -0,0 +1,68 @@ +--- +phase: 1 +title: Tooling swap +priority: high +effort: S +status: planned +--- + +# Phase 1 — Tooling swap + +Replace TS toolchain with JS + JSDoc equivalents. No source-file changes yet. + +## Steps + +1. **Create `jsconfig.json`** at repo root, mirroring `tsconfig.json`'s essentials: + +```json +{ + "compilerOptions": { + "target": "ES2017", + "module": "esnext", + "moduleResolution": "bundler", + "checkJs": true, + "allowJs": true, + "jsx": "react-jsx", + "lib": ["dom", "dom.iterable", "esnext"], + "strict": false, + "paths": { "@/*": ["./*"] } + }, + "include": ["**/*.js", "**/*.jsx", "**/*.mjs"], + "exclude": ["node_modules", ".next", "out"] +} +``` + + Notes: `checkJs: true` enables JSDoc type checking. `strict: false` because JSDoc strict mode trips on plain values; keep narrowing opt-in. + +2. **Delete `tsconfig.json`** and `next-env.d.ts`. + +3. **Edit `package.json`** — remove from `devDependencies`: + - `typescript` + - `@types/node` + - `@types/react` + - `@types/react-dom` + + Keep `eslint-config-next` (works with both). + +4. **`eslint.config.mjs`** — read it; if it imports TS-specific parser/rules, swap to JS equivalents. Most likely no change needed. + +5. **Run `npm install`** to prune the TS deps from `node_modules` and update `package-lock.json`. + +## Files affected + +- create: `jsconfig.json` +- delete: `tsconfig.json`, `next-env.d.ts`, `tsconfig.tsbuildinfo` (already gitignored) +- modify: `package.json`, `package-lock.json` +- maybe modify: `eslint.config.mjs` + +## Verify + +- `ls *.ts *.tsx` returns nothing +- `npm run lint` doesn't fail because of missing TS parser +- `npm run dev` doesn't error on the now-removed `tsconfig` + +## Out of scope + +Source file conversion (Phase 2). + +## Status: planned diff --git a/web/plans/260426-1934-ts-to-jsdoc-refactor/phase-02-source-conversion.md b/web/plans/260426-1934-ts-to-jsdoc-refactor/phase-02-source-conversion.md new file mode 100644 index 0000000..28d6672 --- /dev/null +++ b/web/plans/260426-1934-ts-to-jsdoc-refactor/phase-02-source-conversion.md @@ -0,0 +1,120 @@ +--- +phase: 2 +title: Source conversion + JSDoc +priority: high +effort: M +status: planned +--- + +# Phase 2 — Source conversion + JSDoc + +Rename source files and replace TS syntax with JSDoc. + +## Files + +| From | To | +|---|---| +| `next.config.ts` | `next.config.mjs` | +| `app/layout.tsx` | `app/layout.jsx` | +| `app/page.tsx` | `app/page.jsx` | +| `app/master/page.tsx` | `app/master/page.jsx` | +| `components/player-board.tsx` | `components/player-board.jsx` | +| `lib/game-logic.ts` | `lib/game-logic.js` | + +Use `git mv` so history follows. Then strip TS-only syntax: type annotations on params/returns, `interface`, `type` aliases, generics on calls (`useState` → `useState`), `as` assertions, `!` non-null. Replace each with JSDoc. + +## JSDoc patterns to apply + +### Function with simple types +```js +/** + * @param {number[][]} grid + * @param {boolean[][]} crossed + * @param {number} row + * @returns {boolean} + */ +export function isRowComplete(grid, crossed, row) { ... } +``` + +### Generic helper (was `safeParse`) +```js +/** + * @template T + * @param {string | null} raw + * @param {(v: unknown) => v is T} validate + * @returns {T | null} + */ +function safeParse(raw, validate) { ... } +``` + +### Type predicate (validators) +Keep them; JSDoc has `v is T` syntax inside `@param` parens. + +### Component props (was `interface PlayerBoardProps`) +```js +/** + * @typedef {Object} PlayerBoardProps + * @property {string} [storagePrefix] localStorage key prefix + */ + +/** @param {PlayerBoardProps} props */ +export default function PlayerBoard({ storagePrefix = "loto" }) { ... } +``` + +### `useState` initial +Inferred from initial value — usually no JSDoc needed. For nullable state, type the initial: +```js +/** @type {[number[][] | null, (v: number[][] | null) => void]} */ +const [grid, setGrid] = useState(null); +``` +Or simpler: just trust inference, only annotate when checkJs complains. + +### `useRef` for Set +```js +/** @type {React.MutableRefObject>} */ +const celebratedRows = useRef(new Set()); +``` + +### `next.config.mjs` +```js +/** @type {import('next').NextConfig} */ +const nextConfig = { ... }; +export default nextConfig; +``` + +### Layout child types +```js +/** @param {{ children: React.ReactNode }} props */ +export default function RootLayout({ children }) { ... } +``` + +## Per-file checklist + +- [ ] **`next.config.mjs`** — add `@type` import. Remove `import type { NextConfig }`. Keep all logic. +- [ ] **`lib/game-logic.js`** — add `// @ts-check` at top. JSDoc on every exported function. Type predicates for `isNumberMatrix`, `isBoolMatrix`. `@template T` on `safeParse`. Remove explicit `: number[][]`, `: boolean`, `void`, etc. +- [ ] **`components/player-board.jsx`** — `@typedef` for props. JSDoc on `useRef>`. JSDoc on `useState` only where inference fails (e.g. nullable grid). +- [ ] **`app/layout.jsx`** — strip `Readonly<{...}>`, replace `Metadata` import with JSDoc. +- [ ] **`app/page.jsx`** — minimal; mostly remove `useState` etc. +- [ ] **`app/master/page.jsx`** — same; the `MasterState` interface becomes a `@typedef`. The `BOARD: ReadonlyArray<...>` annotation drops; runtime `Object.freeze` keeps the immutability guarantee. + +## Strategy + +1. Convert `lib/game-logic.ts` first — it has the most TS-heavy code and proves the pattern. +2. Run `npx tsc --noEmit` (still works on `.js` with checkJs) after each file to catch JSDoc syntax mistakes. +3. Convert UI files top-down: layout → page → master/page → player-board. +4. Convert `next.config.ts` last; verify `npm run build` survives. + +## Verify + +- `npx tsc --noEmit` passes (with `checkJs: true` in jsconfig, this still type-checks via JSDoc) +- `npm run build` produces same routes table +- `npm run dev` boots without warnings about missing types +- `grep -rn ":\s*\(string\|number\|boolean\|void\|any\)" app components lib` returns no TS-style annotations + +## Out of scope + +- Adding new types or improving existing ones +- Refactoring component structure +- Tests + +## Status: planned diff --git a/web/plans/260426-1934-ts-to-jsdoc-refactor/phase-03-verify-and-docs.md b/web/plans/260426-1934-ts-to-jsdoc-refactor/phase-03-verify-and-docs.md new file mode 100644 index 0000000..ccbdb5c --- /dev/null +++ b/web/plans/260426-1934-ts-to-jsdoc-refactor/phase-03-verify-and-docs.md @@ -0,0 +1,78 @@ +--- +phase: 3 +title: Verify, docs, commit +priority: high +effort: S +status: planned +--- + +# Phase 3 — Verify, docs, commit + +Final pass: end-to-end verify, update docs, commit + push. + +## Steps + +1. **Build check** + ```bash + npm run build + ``` + Expect: same routes (`/`, `/_not-found`, `/master`), all static. + +2. **Type check via JSDoc** + ```bash + npx tsc --noEmit + ``` + `checkJs: true` makes tsc validate JSDoc types in `.js` / `.jsx`. Treat any new error as a regression — fix the JSDoc, don't disable the check. + +3. **Lint** + ```bash + npx eslint app components lib next.config.mjs + ``` + Expect ≤3 errors (the pre-existing `react-hooks/set-state-in-effect`). + +4. **Smoke test both dev modes** + ```bash + npm run dev # localhost:3000/ + npm run dev:codeserver # /absproxy/{port}/ + ``` + Verify: + - Generate a card, mark a row, see "Kinh!" popup + - Master page draws numbers, player card on /master also works (own storage prefix) + - localStorage persists across reload + +5. **Update docs** + - `docs/code-standards.md` — replace TS examples with JS+JSDoc, update language references + - `docs/codebase-summary.md` — file extensions in tables (`.tsx` → `.jsx`, `.ts` → `.js`) + - `docs/system-architecture.md` — same + - `docs/development-roadmap.md` — mark "JSDoc migration" if listed; otherwise no change + - `README.md` — if it mentions TypeScript, update + +6. **Commit on `dev`** + ``` + refactor: convert from TypeScript to JavaScript with JSDoc + + - Replace .ts/.tsx with .js/.jsx + - Author types as JSDoc with checkJs: true so tsc still validates + - Drop typescript and @types/* devDependencies + - tsconfig.json -> jsconfig.json (same @/* alias) + - next.config.ts -> next.config.mjs + - Delete vendored next-env.d.ts (not needed for pure-JS Next projects) + - Update docs to reflect new file extensions + ``` + +7. **Push** — `git push`. Branch `dev` already tracks `origin/dev`. + +## Acceptance gates (must pass before commit) + +- [ ] `npm run build` succeeds +- [ ] `npx tsc --noEmit` succeeds (zero new errors vs. pre-refactor) +- [ ] Lint error count unchanged or lower +- [ ] Both dev profiles boot +- [ ] Manual smoke: generate card, mark row, see bingo +- [ ] No `*.ts` / `*.tsx` files remain in `app/`, `components/`, `lib/`, root config + +## Rollback + +Single `git revert` of the conversion commit recovers the TS state. + +## Status: planned diff --git a/web/plans/260426-1934-ts-to-jsdoc-refactor/plan.md b/web/plans/260426-1934-ts-to-jsdoc-refactor/plan.md new file mode 100644 index 0000000..5525241 --- /dev/null +++ b/web/plans/260426-1934-ts-to-jsdoc-refactor/plan.md @@ -0,0 +1,79 @@ +--- +slug: ts-to-jsdoc-refactor +created: 2026-04-26 +status: planned +mode: fast +blockedBy: [] +blocks: [] +--- + +# Refactor TypeScript → JavaScript + JSDoc + +Convert all `.ts` / `.tsx` source to `.js` / `.jsx`. Replace inline TS types with JSDoc `@type` / `@param` / `@returns`. Drop the TS toolchain. + +## Why (per user request) + +- Reduce config surface (no `tsconfig.json`, no TS deps) +- Author types in comments rather than syntax + +## Why this is risky (read before starting) + +| Concern | Reality | +|---|---| +| Lost compile-time type safety | JSDoc types are checked **only** if `// @ts-check` (or `checkJs: true`) is on, and even then catch a subset of what TS catches (no const generics, no template literal types, weaker inference). | +| Recent hardening regresses | `lib/game-logic.ts`'s `isNumberMatrix` / `isBoolMatrix` runtime guards stay, but the surrounding type narrowing weakens. | +| Verbosity | JSDoc blocks add 3-7 lines per non-trivial function vs. inline TS. | +| Next.js examples assume TS | Snippets in `docs/code-standards.md` need rewrites; future Stack-Overflow copy-paste fits less cleanly. | +| Tooling friction | Some IDE refactors (Rename Symbol across files, find-references) are weaker without TS server backing TS files. | + +If after reading this you don't have a concrete reason JSDoc is better for *this* project, stop and don't run the plan. + +## Scope + +- 4 source files: `app/page.tsx`, `app/master/page.tsx`, `app/layout.tsx`, `components/player-board.tsx` +- 1 logic file: `lib/game-logic.ts` +- 1 config file: `next.config.ts` +- 1 generated file to delete: `next-env.d.ts` +- `tsconfig.json` → `jsconfig.json` (Next reads jsconfig for path aliases) +- `package.json` — drop `typescript`, `@types/*` deps +- `eslint.config.mjs` — already JS, may need rule tweaks +- `docs/code-standards.md` — update snippets + +Out of scope: behavior changes, new features, test addition. + +## Phases + +| # | Phase | File | Effort | +|---|---|---|---| +| 1 | Tooling swap | `phase-01-tooling-swap.md` | S | +| 2 | Source conversion + JSDoc | `phase-02-source-conversion.md` | M | +| 3 | Verify, docs, commit | `phase-03-verify-and-docs.md` | S | + +Total: ~1-2h focused. Mechanical work, no architecture decisions. + +## Acceptance criteria + +- [ ] No `.ts` or `.tsx` files remain in `app/`, `components/`, `lib/` +- [ ] `next.config.mjs` replaces `next.config.ts` +- [ ] `jsconfig.json` replaces `tsconfig.json` with the same `@/*` path alias +- [ ] `npm run build` produces same `Route (app)` table as before (`/`, `/_not-found`, `/master`, all static) +- [ ] `npm run lint` produces no NEW errors (3 pre-existing `react-hooks/set-state-in-effect` allowed) +- [ ] `npm run dev` and `npm run dev:codeserver` both start cleanly +- [ ] `package.json` `dependencies` and `devDependencies` no longer reference `typescript`, `@types/node`, `@types/react`, `@types/react-dom` +- [ ] All public functions in `lib/game-logic.js` have JSDoc with `@param` / `@returns` +- [ ] All component prop interfaces have a `@typedef` block +- [ ] `docs/code-standards.md` snippets use JS+JSDoc +- [ ] Single commit (or coherent series) on `dev` branch + +## Risks / mitigations + +1. **`output: "export"` + JSX in `next.config.mjs`** — Next supports `next.config.mjs`, no concern. +2. **`@/*` alias** — Works in `jsconfig.json` identically. +3. **`eslint-config-next`** — Works with both; remove TS-specific rules if any. +4. **Type narrowing in `safeParse`** — Generic stays expressible in JSDoc as `@template`. Verify the validators still narrow correctly via `// @ts-check`. +5. **`React.FC` vs function declaration** — Codebase uses plain function declarations; no change. +6. **Vendor `next-env.d.ts`** — Pure-JS Next projects don't need it; delete and verify. + +## Rollback + +Single revert of the conversion commit. The previous TS state is preserved on `master` and earlier `dev` commits.