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.