refactor: convert from TypeScript to JavaScript with JSDoc

Author types in JSDoc comments. jsconfig.json keeps checkJs: true so
the editor's bundled TS server still validates them; CI can validate
with npx -p typescript tsc --noEmit on demand.

Renames (history preserved via git mv):
- 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
- tsconfig.json         -> jsconfig.json (same @/* alias)

Drops typescript and @types/node, @types/react, @types/react-dom
from devDependencies. Removes vendored next-env.d.ts. eslint config
no longer pulls in eslint-config-next/typescript.

Behavior unchanged. Build, lint, and dev profiles verified.
This commit is contained in:
tiennm99 committed 2026-04-26 19:45:36 +07:00
1 parent e23ddcc7bc
commit 308a999a76
19 files changed
+563 -208

No files matched your search

+4 -7
View File
@@ -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 (
<html lang="vi" className={`${geistSans.variable} h-full antialiased`}>
<body className="min-h-full flex flex-col">{children}</body>
+27 -18
View File
@@ -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<ReadonlyArray<number>> = Object.freeze(
buildBoard().map((row) => Object.freeze(row))
);
const BOARD_FLAT: ReadonlyArray<number> = 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<MasterState | null>(null);
const [lastCalled, setLastCalled] = useState<number | null>(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();
View File
File renamed without changes.
@@ -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<number[][] | null>(null);
const [crossed, setCrossed] = useState<boolean[][]>([]);
/** @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<React.SetStateAction<boolean[][]>>]} */
const [crossed, setCrossed] = useState(/** @type {boolean[][]} */ ([]));
const [showCongrats, setShowCongrats] = useState(false);
const [congratsRow, setCongratsRow] = useState<number>(-1);
const [toast, setToast] = useState<string | null>(null);
const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const celebratedRows = useRef<Set<number>>(new Set());
const notifiedWaitingRows = useRef<Set<number>>(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<ReturnType<typeof setTimeout> | null>} */
const toastTimer = useRef(null);
/** @type {React.MutableRefObject<Set<number>>} */
const celebratedRows = useRef(new Set());
/** @type {React.MutableRefObject<Set<number>>} */
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 (
<>
+29 -15
View File
@@ -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";
+11 -11
View File
@@ -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
+3 -3
View File
@@ -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
+3 -3
View File
@@ -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
+1 -10
View File
@@ -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;
+11 -25
View File
@@ -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"]
}
+85 -32
View File
@@ -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<T>(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;
-6
View File
@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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 -2
View File
@@ -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,
+4 -51
View File
@@ -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",
+1 -5
View File
@@ -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"
}
}
@@ -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
@@ -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<T>` → `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<T>`)
```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<Set<number>>} */
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<Set<number>>`. 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<boolean>` 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
@@ -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
@@ -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<T>`** — 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.