mirror of
https://github.com/tiennm99/loto.git
synced 2026-10-13 12:15:02 +00:00
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:
1 parent
254a6dc39a
commit
95545c9308
19 files changed
+563
-208
No files matched your search
@@ -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>
|
||||
@@ -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();
|
||||
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
@@ -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";
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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;
|
||||
Vendored
-6
@@ -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.
|
||||
@@ -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,
|
||||
Generated
+4
-51
@@ -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
@@ -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.
|
||||
Reference in new issue
Block a user