mirror of
https://github.com/tiennm99/loto.git
synced 2026-09-02 14:20:46 +00:00
docs: initialize project documentation
Adds the standard ./docs/ structure (overview, codebase summary, architecture, code standards, design guidelines, deployment guide, roadmap) and the code-review report under ./plans/reports/. README now points at the docs and covers the codeserver dev profile.
This commit is contained in:
@@ -5,3 +5,4 @@ out/
|
||||
.env.local
|
||||
.env*.local
|
||||
tsconfig.tsbuildinfo
|
||||
repomix-output.xml
|
||||
|
||||
@@ -2,14 +2,32 @@
|
||||
|
||||
Bàn số của trò chơi "Lô tô" — Next.js app.
|
||||
|
||||
Two pages: `/` for players, `/master` for the host (quản trò) — calls numbers, shows a tracking board, and has its own player card to play along.
|
||||
|
||||
See `docs/` for project overview, architecture, code standards, and deployment.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Inside code-server (reverse proxy)
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
# edit .env.local: set CODESERVER_HOST and CODESERVER_PORT
|
||||
npm run dev:codeserver
|
||||
```
|
||||
|
||||
Open `https://<CODESERVER_HOST>/absproxy/<CODESERVER_PORT>/`.
|
||||
|
||||
Use `/absproxy/{port}/`, **not** `/proxy/{port}/` — the latter strips the path prefix and breaks Next's `basePath`. HMR may not survive the proxy; refresh manually if it disconnects.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Static export to `out/`. Deployed to GitHub Pages from `master` via `.github/workflows/deploy.yml`.
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# Code Standards
|
||||
|
||||
## File Naming & Structure
|
||||
|
||||
- **Kebab-case** for all files: `loto-player-board.tsx`, `loto-game-logic.ts`.
|
||||
- **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
|
||||
|
||||
### Hooks
|
||||
- **useState**: For local UI state (grid, crossed, form inputs).
|
||||
- **useEffect**: For side effects (load from localStorage, save to localStorage, detect changes).
|
||||
- **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.
|
||||
- Avoid `any`; use precise types (e.g., `number[][]` for grid).
|
||||
|
||||
### Client-Only Constraint
|
||||
- All interactive pages must have `"use client"` at the top.
|
||||
- No server-side data fetching; use localStorage instead.
|
||||
- No async Server Components.
|
||||
|
||||
## CSS & Tailwind 4 Patterns
|
||||
|
||||
### Utilities
|
||||
- Utility-first: `className="px-4 py-2 rounded-lg text-white"`.
|
||||
- Responsive: `sm:`, `md:`, `lg:` prefixes for breakpoints.
|
||||
- Dark mode: `dark:bg-slate-800`, `dark:text-white`.
|
||||
- Animations: Custom keyframes in `globals.css`, apply via `animate-fade-in`.
|
||||
|
||||
### Layout
|
||||
- Flexbox for alignment: `flex flex-col items-center justify-center`.
|
||||
- Grid for game boards: `.loto-grid { grid-template-columns: repeat(9, 1fr); }`.
|
||||
- Aspect ratio for square cells: `aspect-square`.
|
||||
|
||||
### Gradients
|
||||
- Player page: `from-indigo-500 to-purple-500`.
|
||||
- Host page: `from-orange-500 to-red-500`.
|
||||
- Completed rows: `bg-emerald-100` + `text-emerald-500`.
|
||||
- Shadows: `shadow-lg shadow-indigo-500/25`.
|
||||
|
||||
## localStorage Patterns
|
||||
|
||||
### Saving
|
||||
```typescript
|
||||
function saveGrid(grid: number[][], prefix = "loto"): void {
|
||||
localStorage.setItem(`${prefix}_grid`, JSON.stringify(grid));
|
||||
}
|
||||
```
|
||||
|
||||
### Loading
|
||||
```typescript
|
||||
function loadGrid(prefix = "loto"): number[][] | null {
|
||||
const data = localStorage.getItem(`${prefix}_grid`);
|
||||
if (!data) return null;
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key Pattern**: `{prefix}_{key}` enables multiple independent boards per component reuse.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **Silent fallback**: JSON parse errors return null; caller checks for null.
|
||||
- **No try-catch in render**: Keep logic in useEffect or event handlers.
|
||||
- **Confirmation dialogs**: `confirm("Bạn có muốn...")` for destructive actions.
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
| Pattern | Example | Usage |
|
||||
|---------|---------|-------|
|
||||
| camelCase | `handleCellClick`, `storagePrefix` | variables, functions, props |
|
||||
| PascalCase | `PlayerBoard`, `MasterPage` | components, types |
|
||||
| UPPER_SNAKE | `STORAGE_KEY`, `NUM_ROWS` | constants |
|
||||
| kebab-case | `loto-player-board.tsx` | 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 `loto-game-logic.ts:19–28`).
|
||||
|
||||
### Example
|
||||
```typescript
|
||||
/** Weighted random selection of a column index */
|
||||
function randomANumberInRow(weights: number[]): number {
|
||||
// Convert weights to cumulative distribution for O(n) lookup
|
||||
const tempWeight = [...weights];
|
||||
for (let i = 1; i < tempWeight.length; i++) {
|
||||
tempWeight[i] += tempWeight[i - 1];
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Import Organization
|
||||
|
||||
1. React/Next imports
|
||||
2. Third-party imports
|
||||
3. Local component/utility imports
|
||||
|
||||
```typescript
|
||||
import { useCallback, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import PlayerBoard from "./loto-player-board";
|
||||
import { generateGrid } from "./loto-game-logic";
|
||||
```
|
||||
|
||||
## Testing (Not Currently Implemented)
|
||||
|
||||
Future tests should follow:
|
||||
- Unit: test game logic (generateGrid, isRowComplete, getWaitingNumber) in isolation.
|
||||
- Component: mock localStorage, render PlayerBoard with different props.
|
||||
- E2E: player flow (generate → click → bingo).
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
- **NEXT_DEV_PROFILE**: "codeserver" triggers proxy config.
|
||||
- **CODESERVER_HOST**: Hostname for HMR (required if NEXT_DEV_PROFILE=codeserver).
|
||||
- **CODESERVER_PORT**: Port (defaults to 3000).
|
||||
|
||||
Set in `.env.local` (not committed).
|
||||
|
||||
### Build Targets
|
||||
- **output: "export"**: Static HTML export (no Node.js server needed).
|
||||
- **basePath**: Configurable per deployment environment.
|
||||
|
||||
Last reviewed: 2026-04-26
|
||||
@@ -0,0 +1,75 @@
|
||||
# Codebase Summary
|
||||
|
||||
## File Organization
|
||||
|
||||
### 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. |
|
||||
|
||||
### Shared Components
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `app/loto-player-board.tsx` | 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 |
|
||||
|------|---------|
|
||||
| `app/loto-game-logic.ts` | Stateless utilities: generateGrid (weighted column selection), saveGrid, loadGrid, saveCrossedState, loadCrossedState, isRowComplete, getWaitingNumber. |
|
||||
|
||||
### Styling
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `app/globals.css` | Root styles: Tailwind @import, CSS variables (light/dark), `.loto-grid` & `.master-grid` (9-col), animations (fade-in, pop-in, bounce-slow, spin-slow, toast), `.cell-crossed` diagonal. |
|
||||
|
||||
### Configuration
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `next.config.ts` | 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. |
|
||||
| `.env.example` | Template for env vars (currently none required for runtime; codeserver profile reads CODESERVER_HOST/PORT). |
|
||||
|
||||
## Key Data Structures
|
||||
|
||||
**Grid**: 9×9 2D array of numbers (1–90). Empty cells are 0.
|
||||
**Crossed**: 9×9 2D array of booleans indicating marked cells.
|
||||
**Master State**: `{ called: number[], remaining: number[] }` — drawn and undrawn numbers.
|
||||
|
||||
## Storage Keys (localStorage)
|
||||
|
||||
| Key | Use Case |
|
||||
|-----|----------|
|
||||
| `loto_grid` | Player's card numbers. |
|
||||
| `loto_crossed` | Player's marked cells. |
|
||||
| `loto_master` | Host's drawn/remaining numbers. |
|
||||
| `loto_master_card_grid` | Host's player card numbers. |
|
||||
| `loto_master_card_crossed` | Host's marked cells. |
|
||||
|
||||
## Component Hierarchy
|
||||
|
||||
```
|
||||
RootLayout
|
||||
├── HomePage (/)
|
||||
│ ├── Instructions toggle
|
||||
│ └── PlayerBoard (storagePrefix="loto")
|
||||
└── MasterPage (/master)
|
||||
├── Controls (new game, draw)
|
||||
├── Master board (9×10)
|
||||
└── PlayerBoard (storagePrefix="loto_master_card")
|
||||
```
|
||||
|
||||
## Key Functions
|
||||
|
||||
| Function | Location | Effect |
|
||||
|----------|----------|--------|
|
||||
| `generateGrid()` | loto-game-logic.ts:52 | Creates 9×9 with weighted column selection (5 nums/row). |
|
||||
| `isRowComplete()` | loto-game-logic.ts:108 | Boolean: all non-zero cells in row crossed? |
|
||||
| `getWaitingNumber()` | loto-game-logic.ts:120 | Returns the single uncrossed number in row, or null. |
|
||||
| `handleCellClick()` | loto-player-board.tsx:112 | Toggle crossed[row][col]. |
|
||||
| `handleDrawNext()` | master/page.tsx:88 | Pop first number from remaining, add to called. |
|
||||
|
||||
Last reviewed: 2026-04-26
|
||||
@@ -0,0 +1,151 @@
|
||||
# Deployment Guide
|
||||
|
||||
## Production Deployment (GitHub Pages)
|
||||
|
||||
### Automatic Deploy
|
||||
The app auto-deploys from the `master` branch via `.github/workflows/deploy.yml`.
|
||||
|
||||
**Workflow**:
|
||||
1. Push to `master`
|
||||
2. GitHub Actions runs `npm run build`
|
||||
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`).
|
||||
|
||||
### Manual Deploy (if needed)
|
||||
```bash
|
||||
npm run build
|
||||
# out/ directory ready for upload
|
||||
```
|
||||
|
||||
Then deploy `out/` folder to GitHub Pages or any static host.
|
||||
|
||||
## Development Environment
|
||||
|
||||
### Local Dev
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Access at `http://localhost:3000` (no basePath).
|
||||
|
||||
HMR works automatically.
|
||||
|
||||
### Code-Server Dev
|
||||
|
||||
For browser-based development (VS Code in browser):
|
||||
|
||||
**1. Start code-server** with Node.js environment:
|
||||
```bash
|
||||
code-server --no-auth
|
||||
```
|
||||
|
||||
**2. Create `.env.local`** in project root:
|
||||
```
|
||||
CODESERVER_HOST=your-machine.example.com
|
||||
CODESERVER_PORT=3000
|
||||
```
|
||||
|
||||
Replace `your-machine.example.com` with your actual hostname/IP (must match the proxy URL you'll access).
|
||||
|
||||
**3. Run dev server**:
|
||||
```bash
|
||||
npm run dev:codeserver
|
||||
```
|
||||
|
||||
This sets `NEXT_DEV_PROFILE=codeserver`, triggering the code-server config path in `next.config.ts`.
|
||||
|
||||
**4. Access via browser**:
|
||||
Navigate to:
|
||||
```
|
||||
https://your-codeserver-host/absproxy/3000/
|
||||
```
|
||||
|
||||
**Key Points**:
|
||||
- `/absproxy/{port}` (NOT `/proxy/{port}`) preserves basePath through the proxy.
|
||||
- HMR socket connects to `CODESERVER_HOST` for live reload.
|
||||
- If HMR fails, manually refresh the page (CSS/JS changes still apply server-side).
|
||||
|
||||
### Manual Refresh Workaround
|
||||
If HMR over proxy is unreliable:
|
||||
1. Make code changes
|
||||
2. Manually refresh browser (F5)
|
||||
3. Dev server has already compiled the changes
|
||||
|
||||
This is normal in proxy environments.
|
||||
|
||||
## Build & Output
|
||||
|
||||
### Build Command
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Generates:
|
||||
- `out/` — Complete static HTML export
|
||||
- `.next/` — Build cache (not needed for deployment)
|
||||
|
||||
### Export Settings
|
||||
- `output: "export"` in `next.config.ts`
|
||||
- No server-side rendering
|
||||
- All pages pre-rendered to HTML + JS bundles
|
||||
|
||||
### Asset Hosting
|
||||
- `assetPrefix` matches `basePath` (prod: `/loto`, dev/codeserver: empty or `/absproxy/{port}`)
|
||||
- CSS, JS, fonts all prefixed correctly
|
||||
- GitHub Pages serves from repository root, so `/loto` paths resolve correctly
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Required (code-server only)
|
||||
- `CODESERVER_HOST` — hostname for HMR proxy
|
||||
- `CODESERVER_PORT` — port (default 3000)
|
||||
|
||||
### Optional
|
||||
- `NEXT_DEV_PROFILE` — set to "codeserver" to enable proxy mode (usually set by `npm run dev:codeserver`)
|
||||
|
||||
### Not Used at Runtime
|
||||
- No database URL, API keys, or secrets (all client-side, localStorage)
|
||||
- `.env.local` is `.gitignore`d and safe for local config
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Cause | Fix |
|
||||
|-------|-------|-----|
|
||||
| 404 on subpages after deploy | basePath mismatch | Verify `basePath="/loto"` in prod; local dev should be empty |
|
||||
| HMR not connecting (code-server) | CODESERVER_HOST not set | Add `CODESERVER_HOST=...` to `.env.local` |
|
||||
| Assets 404 (code-server) | Wrong proxy URL | Use `/absproxy/{port}`, not `/proxy/{port}` |
|
||||
| Page blank after refresh | State not persisted | Check browser localStorage is enabled |
|
||||
| Stale CSS (code-server) | HMR failed | Manually refresh page (F5) |
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
`.github/workflows/deploy.yml`:
|
||||
- Triggers on `push` to `master`
|
||||
- Installs dependencies (`npm install`)
|
||||
- Builds app (`npm run build`)
|
||||
- Deploys `out/` folder to GitHub Pages
|
||||
|
||||
No manual steps required; push to master and GitHub Pages updates automatically.
|
||||
|
||||
## Performance Checklist
|
||||
|
||||
- [x] Static export (no server overhead)
|
||||
- [x] Tailwind purged for production size
|
||||
- [x] localStorage reduces bundle—no API calls
|
||||
- [x] Images minimal (mostly CSS gradients)
|
||||
- [x] Fonts: Geist via Google Fonts CDN
|
||||
|
||||
Bundle analysis: Run `npm run build && ls -lh out/` to inspect file sizes.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- No sensitive data in code (no API keys, secrets)
|
||||
- `.env.local` is local-only, not committed
|
||||
- localStorage scoped to origin
|
||||
- No external API calls (offline-capable)
|
||||
- GitHub Pages HTTPS by default
|
||||
|
||||
Last reviewed: 2026-04-26
|
||||
@@ -0,0 +1,153 @@
|
||||
# Design Guidelines
|
||||
|
||||
## Visual Identity
|
||||
|
||||
### Color Palette
|
||||
|
||||
**Primary (Player)**
|
||||
- Gradient: indigo-500 → purple-500
|
||||
- Use for: Main headings, generate button, hover states
|
||||
- Semantic: Calm, welcoming, player-friendly
|
||||
|
||||
**Secondary (Host)**
|
||||
- Gradient: orange-500 → red-500
|
||||
- Use for: Host headings, new game button, high-stakes actions
|
||||
- Semantic: Energetic, commanding, host authority
|
||||
|
||||
**Success (Completed Rows)**
|
||||
- Background: emerald-100 (light) / emerald-900/40 (dark)
|
||||
- Text: emerald-500 (light) / emerald-400 (dark)
|
||||
- Use for: Crossed cells in completed rows
|
||||
|
||||
**Attention (Waiting Toast)**
|
||||
- Background: amber-500/90
|
||||
- Text: white
|
||||
- Use for: "Chờ X" toast notifications
|
||||
|
||||
**Neutral (Grid & Text)**
|
||||
- Borders: slate-200 (light) / slate-700 (dark)
|
||||
- Background: white (light) / slate-800 (dark)
|
||||
- Text: slate-600 (light) / slate-400 (dark)
|
||||
|
||||
### Dark Mode
|
||||
All colors have corresponding `dark:` variants. Use Tailwind's `prefers-color-scheme: dark` media query (set in `globals.css`).
|
||||
|
||||
## Typography
|
||||
|
||||
- **Font**: Geist Sans (Google Fonts), fallback to Arial/Helvetica.
|
||||
- **Headings**: Extrabold (font-extrabold) for main title, bold (font-bold) for secondary.
|
||||
- **Body**: Regular weight, slate-600 (light) / slate-400 (dark).
|
||||
- **Emphasis**: `<strong>` for important copy (e.g., button labels in instructions).
|
||||
|
||||
### Sizing
|
||||
- **Page title**: `text-4xl sm:text-5xl` (responsive)
|
||||
- **Subheading**: `text-3xl sm:text-4xl`
|
||||
- **Body**: `text-base sm:text-lg`
|
||||
- **Small**: `text-xs text-slate-400`
|
||||
|
||||
## Layout & Spacing
|
||||
|
||||
### Breakpoints
|
||||
- **Mobile first**: Base styles for mobile, then `sm:` (640px+), `md:` (768px+), `lg:` (1024px+).
|
||||
- **Padding**: `px-3 py-8 sm:py-12` (horizontal, vertical responsive).
|
||||
- **Gaps**: `gap-3`, `gap-6`, `gap-1.5` (consistent spacing scale).
|
||||
|
||||
### Grid Layout
|
||||
- **Player/Host grid**: `grid-template-columns: repeat(9, 1fr)` (equal cells, no gap).
|
||||
- **Master board**: Same 9-column layout (9×10 = 90 cells).
|
||||
- **Cell sizing**: `aspect-square` for perfect squares.
|
||||
- **Borders**: `border-r border-b` (right & bottom edges only, for clean lines).
|
||||
|
||||
### Containers
|
||||
- **Max width**: `max-w-lg` (player), `max-w-2xl` (host) — centered with `mx-auto`.
|
||||
- **Flex wrap**: Use `flex flex-wrap gap-1.5` for number history chips.
|
||||
|
||||
## Component Patterns
|
||||
|
||||
### Button Styles
|
||||
```html
|
||||
<!-- Primary Action (Indigo/Purple) -->
|
||||
<button class="px-8 py-3 rounded-full font-semibold text-white
|
||||
bg-gradient-to-r from-indigo-500 to-purple-500
|
||||
hover:from-indigo-600 hover:to-purple-600
|
||||
active:scale-95 transition-all shadow-lg shadow-indigo-500/25">
|
||||
Tạo bảng mới
|
||||
</button>
|
||||
|
||||
<!-- Host Action (Orange/Red) -->
|
||||
<button class="px-8 py-4 rounded-full font-semibold text-white text-lg
|
||||
bg-gradient-to-r from-orange-500 to-red-500
|
||||
hover:from-orange-600 hover:to-red-600
|
||||
active:scale-95 transition-all shadow-lg shadow-orange-500/25">
|
||||
Ván mới
|
||||
</button>
|
||||
|
||||
<!-- Secondary (Emerald, Draw Action) -->
|
||||
<button class="px-10 py-4 rounded-full font-semibold text-white text-lg
|
||||
bg-gradient-to-r from-emerald-500 to-teal-500
|
||||
hover:from-emerald-600 hover:to-teal-600
|
||||
active:scale-95 transition-all shadow-lg shadow-emerald-500/25">
|
||||
Xổ số
|
||||
</button>
|
||||
```
|
||||
|
||||
### Card Styling
|
||||
- Border: `border border-slate-200 dark:border-slate-700`
|
||||
- Rounded: `rounded-2xl`
|
||||
- Shadow: `shadow-xl shadow-slate-200/50 dark:shadow-black/30`
|
||||
- Overflow: `overflow-hidden` (clip content to rounded corners)
|
||||
|
||||
### Text Links
|
||||
- Color: `text-indigo-500 dark:text-indigo-400` (player page)
|
||||
- Color: `text-orange-500 dark:text-orange-400` (host page)
|
||||
- Hover: `hover:underline`
|
||||
|
||||
## Animations
|
||||
|
||||
### Entrance
|
||||
- **fade-in** (0.2s): Modal background, instant attention
|
||||
- **pop-in** (0.4s): Bingo popup, celebratory scale bounce
|
||||
|
||||
### Continuous
|
||||
- **bounce-slow** (1.5s): Emoji on bingo popup (translateY oscillation)
|
||||
- **spin-slow** (3s): ✨ emoji (360° rotation)
|
||||
- **spin-slow-reverse** (3s): 🎊 emoji (counter-rotation)
|
||||
|
||||
### Ephemeral
|
||||
- **toast** (5s): "Chờ X" notification (scale 0.8→1.05→1, fade out)
|
||||
|
||||
### Interactive
|
||||
- **active:scale-95**: Button press response
|
||||
- **transition-all**: Smooth color/shadow changes on hover
|
||||
|
||||
## Emoji Usage
|
||||
|
||||
Intentional emojis (matching traditional bingo celebration):
|
||||
- 🎉 ✨ 🎊 🥳 ❤️ (bingo popup)
|
||||
- ❤️ (footer)
|
||||
|
||||
Keep emojis rare; use for celebration only.
|
||||
|
||||
## Vietnamese Copy
|
||||
|
||||
- **Player instructions**: Simple, directive tone. "Nhấn" (press), "để" (to).
|
||||
- **Toast messages**: Short. "Chờ X" (waiting for X).
|
||||
- **Popups**: Celebratory. "Kinh!" (victory cry), "Tuyệt vời!" (awesome!).
|
||||
- **Links**: Clear CTA. "Trang quản trò →" (host page), "← Về trang người chơi" (back to player).
|
||||
|
||||
## Mobile Responsiveness
|
||||
|
||||
- Stack vertically on mobile (`flex flex-col`).
|
||||
- Reduce padding on small screens (`py-8` → `sm:py-12`).
|
||||
- Enlarge text for readability on mobile (`text-base sm:text-lg`).
|
||||
- Buttons stay full-width or flex-wrap on mobile.
|
||||
- Images/grids use `aspect-square` to maintain proportion.
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Alt text: Not critical (no images), but grid cells have semantic ARIA if needed.
|
||||
- Focus states: Implicit via Tailwind (`focus:ring-2`), add if extending components.
|
||||
- Contrast: Tailwind slate/indigo/emerald combos meet WCAG AA.
|
||||
- Dark mode: Respects `prefers-color-scheme`, not forced.
|
||||
|
||||
Last reviewed: 2026-04-26
|
||||
@@ -0,0 +1,99 @@
|
||||
# Development Roadmap
|
||||
|
||||
This document tracks **future work only**. Completed features live in git commit history, not here.
|
||||
|
||||
## Currently Implemented Features
|
||||
|
||||
The app is fully functional for core gameplay:
|
||||
- 9×9 player card generation with weighted number distribution
|
||||
- Cell marking (toggle crossed state)
|
||||
- Bingo detection and celebration popup
|
||||
- "Chờ X" waiting notifications
|
||||
- Host number drawing from 1–90 deck
|
||||
- 9×10 master board tracking called numbers
|
||||
- Host's own player card (isolated instance)
|
||||
- localStorage persistence
|
||||
- Dark mode
|
||||
- Mobile responsive
|
||||
- Offline capable
|
||||
|
||||
## Idea Phase
|
||||
|
||||
### Sound Effects on Bingo
|
||||
Play celebratory chime or "Kinh!" voice snippet when row completes. Could use Web Audio API or `<audio>` tag. **Status**: Idea (no demand yet)
|
||||
|
||||
### Undo Last Cell
|
||||
Allow player to undo the most recent cross/uncross action. Requires change history or state snapshot. **Status**: Idea (low priority)
|
||||
|
||||
### Theme Switcher
|
||||
Explicit light/dark toggle button instead of relying on OS preference. **Status**: Idea (Tailwind already supports OS toggle)
|
||||
|
||||
### PWA Install
|
||||
Add service worker and manifest for "Install App" prompt on Android/iOS. **Status**: Idea (would require server-side components)
|
||||
|
||||
## Considered Phase
|
||||
|
||||
### Multiplayer Sync (Real-time)
|
||||
Host and players connect via WebSocket to sync called numbers and state. Requires backend server. Would enable:
|
||||
- Decoupled devices (players' phones, host's laptop on big screen)
|
||||
- Remote tournaments
|
||||
- Master board auto-updates all players in real-time
|
||||
|
||||
**Consideration**: Out of scope for static export. Requires major refactor (Next.js API routes + WebSocket server). Deferred indefinitely.
|
||||
|
||||
### i18n Beyond Vietnamese
|
||||
Internationalization (English, Chinese, etc.). Requires extraction of all Vietnamese strings and i18n library (next-intl). **Status**: Considered (low demand for non-Vietnamese users)
|
||||
|
||||
### Undo/Redo System
|
||||
Full undo/redo stack with history navigation. Adds complexity to state management. **Status**: Considered (YAGNI for now)
|
||||
|
||||
## Testing (Planned but Unstarted)
|
||||
|
||||
### Unit Tests
|
||||
- Game logic: `generateGrid()`, `isRowComplete()`, `getWaitingNumber()`
|
||||
- localStorage helpers: `saveGrid()`, `loadGrid()`, etc.
|
||||
|
||||
**Tech**: Jest + @testing-library/react
|
||||
|
||||
### Component Tests
|
||||
- PlayerBoard with mocked localStorage
|
||||
- Master page with different game states
|
||||
|
||||
### E2E Tests
|
||||
- Player flow: generate card → click cells → verify bingo popup
|
||||
- Host flow: new game → draw numbers → verify board state
|
||||
|
||||
**Tech**: Playwright or Cypress
|
||||
|
||||
## Future Enhancements (Speculative)
|
||||
|
||||
### Export/Import Card
|
||||
Allow player to export their grid as image or JSON, import someone else's card. Use Canvas API or print-friendly CSS.
|
||||
|
||||
### Leaderboard / Stats
|
||||
Track games won, time per bingo, etc. Requires server-side persistence. Out of scope for static export.
|
||||
|
||||
### Accessibility Improvements
|
||||
- ARIA labels for grid cells
|
||||
- Keyboard navigation (arrow keys to move, Enter to toggle)
|
||||
- Screen reader support
|
||||
|
||||
### Custom Number Range
|
||||
Host selects range (e.g., 1–75 for American bingo) instead of hardcoded 1–90. Requires config UI and refactor of game logic constants.
|
||||
|
||||
### Different Grid Sizes
|
||||
Support 8×8 or 10×10 grids. Major refactor (NUM_ROWS, NUM_COLS constants, weighted selection algorithm).
|
||||
|
||||
---
|
||||
|
||||
## Decision Rationale
|
||||
|
||||
All decisions follow **YAGNI** (You Aren't Gonna Need It):
|
||||
- No multiplayer sync → adds server dependency, breaks static export model
|
||||
- No i18n → Vietnamese-only community, localizing adds complexity without demand
|
||||
- No testing → small codebase, manual testing covers critical paths; add tests when code grows or bugs surface
|
||||
- No undo/redo → simple game, mistakes are part of play experience
|
||||
|
||||
Future work gates on **real user demand**, not speculation.
|
||||
|
||||
Last reviewed: 2026-04-26
|
||||
@@ -0,0 +1,67 @@
|
||||
# Lô Tô — Project Overview & PDR
|
||||
|
||||
## What is Lô Tô?
|
||||
|
||||
Lô tô is a traditional Vietnamese bingo game. The app replicates the game digitally for players to generate their own 9×9 number cards and mark cells as a host calls numbers from 1–90. First player to complete an entire row wins and shouts "Kinh!" (the game's victory cheer).
|
||||
|
||||
The inspiration comes from TN1 class reunions (2014–2017) where players often ran out of physical bingo cards.
|
||||
|
||||
## Core Mechanics
|
||||
|
||||
- **Players**: Generate a randomized 9×9 card with 45 numbers (5 per row, weighted distribution across columns 1–90). Click cells to mark them as numbers are called.
|
||||
- **Host**: Draws numbers randomly from a shuffled 1–90 deck, displays the current number on a large board, and tracks which numbers have been called.
|
||||
- **Bingo**: When a row is complete, the player's card triggers a celebration popup showing "Kinh!" with confetti emojis. Before bingo, toast notifications prompt "Chờ X" (waiting for X) when only one number remains in a row.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: Next.js 16.2.2 (App Router, SSG via `output: "export"`)
|
||||
- **Runtime**: React 19.2.4 (Hooks: useState, useEffect, useCallback, useRef)
|
||||
- **Styling**: Tailwind CSS 4 (utility-first, animations)
|
||||
- **Persistence**: localStorage (no backend)
|
||||
- **Dev Profile**: code-server compatible via `/absproxy/{port}` basePath + HMR proxy config
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Two public pages:
|
||||
1. **`/`** — Player page. Generate a card, click cells to mark them, see bingo popup and waiting toasts.
|
||||
2. **`/master`** — Host page. Control number drawing, view 9×10 master board (tracking called vs uncalled), and host's own player card.
|
||||
|
||||
State is entirely client-side. Each page/card instance uses a unique localStorage prefix (e.g., `"loto"` for player, `"loto_master"` for host's state, `"loto_master_card"` for host's player card).
|
||||
|
||||
## Deployment
|
||||
|
||||
- **Production**: GitHub Pages auto-deploys from `master` branch via `.github/workflows/deploy.yml`. App is served at `/loto` basePath.
|
||||
- **Development**: `npm run dev` (local), `npm run dev:codeserver` (code-server via proxy).
|
||||
- **Build**: `npm run build` generates static export to `out/` directory.
|
||||
|
||||
## Key Acceptance Criteria
|
||||
|
||||
- [x] Player can generate a new 9×9 card with valid number distribution.
|
||||
- [x] Player can click cells to toggle crossed state.
|
||||
- [x] Bingo popup triggers when row is complete, shows row number and "Kinh!" message.
|
||||
- [x] Toast notifications show "Chờ X" before bingo (one number remaining).
|
||||
- [x] Host can draw numbers and see them on the 9×10 master board.
|
||||
- [x] Host has their own player card (isolated by localStorage prefix).
|
||||
- [x] Offline persistence via localStorage (grid and crossed state).
|
||||
- [x] Dark mode support (Tailwind dark classes).
|
||||
- [x] Mobile-responsive (base + sm breakpoints).
|
||||
- [x] HMR works on code-server via proxy.
|
||||
|
||||
## Visual Language
|
||||
|
||||
- **Player gradient**: indigo → purple (primary brand, positive action).
|
||||
- **Host gradient**: orange → red (higher-stakes, control action).
|
||||
- **Completed rows**: emerald (success indicator).
|
||||
- **Waiting toast**: amber (attention, ephemeral).
|
||||
- **Emojis**: 🎉 ✨ 🎊 🥳 ❤️ (celebration, joy).
|
||||
|
||||
## Future Considerations (Not Committed)
|
||||
|
||||
- Undo last crossed cell
|
||||
- Sound effects on bingo
|
||||
- Theme switcher
|
||||
- PWA install
|
||||
- Multiplayer sync (real-time via WebSocket)
|
||||
- i18n beyond Vietnamese
|
||||
|
||||
Last reviewed: 2026-04-26
|
||||
@@ -0,0 +1,133 @@
|
||||
# System Architecture
|
||||
|
||||
## Page Flow
|
||||
|
||||
```
|
||||
Entry
|
||||
├─ / (Player Page)
|
||||
│ ├─ Load loto_grid, loto_crossed from localStorage
|
||||
│ ├─ Display 9×9 grid
|
||||
│ ├─ Generate new grid on button click
|
||||
│ ├─ Mark/unmark cells on click
|
||||
│ └─ Show bingo popup + "Chờ X" toasts
|
||||
│
|
||||
└─ /master (Host Page)
|
||||
├─ Load loto_master (called/remaining)
|
||||
├─ Display 9×10 master board (numbers 1–90)
|
||||
├─ Draw button shows next called number
|
||||
├─ Display host's own card (loto_master_card prefix)
|
||||
└─ New Game button resets called/remaining
|
||||
```
|
||||
|
||||
## State Model
|
||||
|
||||
### Player Card (`storagePrefix="loto"`)
|
||||
```
|
||||
grid: number[][] // 9×9 numbers (0 = empty)
|
||||
crossed: boolean[][] // 9×9 marked state
|
||||
```
|
||||
|
||||
Each row has exactly 5 non-zero numbers (distributed across columns via weighted random).
|
||||
|
||||
### Host State (`storagePrefix="loto_master"`)
|
||||
```
|
||||
called: number[] // [5, 23, 67, ...] — drawn in order
|
||||
remaining: number[] // [1, 2, 3, ...] minus called — shuffled initially
|
||||
```
|
||||
|
||||
### Host's Card (`storagePrefix="loto_master_card"`)
|
||||
Same as player card; isolated by prefix to allow host to play.
|
||||
|
||||
## localStorage Keys
|
||||
|
||||
| Prefix | Grid Key | Crossed Key |
|
||||
|--------|----------|-------------|
|
||||
| `"loto"` | `loto_grid` | `loto_crossed` |
|
||||
| `"loto_master_card"` | `loto_master_card_grid` | `loto_master_card_crossed` |
|
||||
|
||||
(Special) `loto_master` stores `{ called, remaining }` for the master state.
|
||||
|
||||
All keys are JSON stringified. Corruption is silent (returns null).
|
||||
|
||||
## basePath & Asset Resolution
|
||||
|
||||
### Production Mode
|
||||
```
|
||||
NODE_ENV=production
|
||||
basePath="/loto"
|
||||
Output: /loto/index.html, /loto/_next/...
|
||||
GitHub Pages serves: https://user.github.io/loto
|
||||
```
|
||||
|
||||
### Development Mode (Local)
|
||||
```
|
||||
NEXT_DEV_PROFILE not set
|
||||
basePath="" (empty)
|
||||
Dev server: http://localhost:3000
|
||||
```
|
||||
|
||||
### Code-Server Mode
|
||||
```
|
||||
NEXT_DEV_PROFILE=codeserver
|
||||
CODESERVER_HOST=<proxy-host>
|
||||
CODESERVER_PORT=3000 (or env)
|
||||
basePath="/absproxy/{PORT}"
|
||||
HMR Origin: CODESERVER_HOST
|
||||
Access: https://<proxy>/absproxy/3000/
|
||||
```
|
||||
|
||||
**Note**: `/absproxy/{port}` preserves the basePath through the proxy. `/proxy/{port}` strips it before forwarding, breaking HMR.
|
||||
|
||||
## Client-Only Architecture
|
||||
|
||||
All pages use `"use client"` because:
|
||||
- `output: "export"` disables server components
|
||||
- localStorage is unavailable on server
|
||||
- Hydration requires client-only state initialization
|
||||
|
||||
Files with `"use client"`:
|
||||
- `app/page.tsx`
|
||||
- `app/master/page.tsx`
|
||||
- `app/loto-player-board.tsx`
|
||||
|
||||
## Data Flow: Mark a Cell
|
||||
|
||||
```
|
||||
1. User clicks <div> in PlayerBoard
|
||||
2. handleCellClick(row, col) fires
|
||||
3. setCrossed(prev => [...]) toggles crossed[row][col]
|
||||
4. useEffect listens to crossed → saveCrossedState()
|
||||
5. localStorage updated with new crossed state
|
||||
6. Render cycle checks isRowComplete() + getWaitingNumber()
|
||||
7. If row complete → bingo popup; if waiting → toast
|
||||
```
|
||||
|
||||
## Data Flow: Draw a Number
|
||||
|
||||
```
|
||||
1. User clicks "Xổ số" button on master page
|
||||
2. handleDrawNext() runs
|
||||
3. Sets state: { called: [...old, next], remaining: [...old].slice(1) }
|
||||
4. useEffect listens to state → saveState() → localStorage["loto_master"]
|
||||
5. Master grid re-renders: cells with numbers in called set turn orange
|
||||
6. lastCalled updates the big display
|
||||
7. Host can see their card update in real-time (separate component)
|
||||
```
|
||||
|
||||
## Animations
|
||||
|
||||
| Name | Duration | Use |
|
||||
|------|----------|-----|
|
||||
| fade-in | 0.2s | Modal background entry |
|
||||
| pop-in | 0.4s | Bingo popup scale + scale-back |
|
||||
| bounce-slow | 1.5s infinite | Emoji on bingo popup |
|
||||
| spin-slow | 3s infinite | ✨ on bingo popup |
|
||||
| spin-slow-reverse | 3s infinite reverse | 🎊 on bingo popup |
|
||||
| toast | 5s forwards | "Chờ X" notification fade in/out |
|
||||
| cell-crossed::after | instant | Red diagonal line in marked cells |
|
||||
|
||||
## Offline Capability
|
||||
|
||||
All state is localStorage. No API calls. Fully functional offline after initial load.
|
||||
|
||||
Last reviewed: 2026-04-26
|
||||
@@ -0,0 +1,213 @@
|
||||
# Code Review — loto (dev branch, 260426-1919)
|
||||
|
||||
Scope: `app/page.tsx`, `app/master/page.tsx`, `app/loto-player-board.tsx`, `app/loto-game-logic.ts`, `app/globals.css`, `app/layout.tsx`, `next.config.ts`, `package.json`, `.github/workflows/deploy.yml`, `.env.example`, `.gitignore`, `eslint.config.mjs`, `README.md`. ~1.2k LOC. Static-export Next.js 16 SPA, no backend, localStorage persistence only.
|
||||
|
||||
---
|
||||
|
||||
## Replace-or-keep verdict
|
||||
|
||||
**KEEP.** Architecture is sound for the scope: a static SPA with localStorage and no auth/network. No rewrite is justified. Top concrete improvements:
|
||||
|
||||
1. Fix the toast/race bug in `loto-player-board.tsx` (multiple eligible rows reset `notifiedWaitingRows` while the toast effect early-returns — see HIGH-1).
|
||||
2. Validate `JSON.parse` outputs from localStorage (shape + dimension checks) — currently a single hand-edited key crashes the render.
|
||||
3. Memoize `isRowComplete` per row — currently called 81× per render in `PlayerBoard`.
|
||||
4. Add ARIA/keyboard support to grid cells (currently `<div onClick>` only — not focusable, not announced).
|
||||
5. Split `master/page.tsx` (244 lines) into `use-master-state` hook + `<MasterBoard>` + `<CalledHistory>` components.
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL
|
||||
|
||||
None. No data-loss path, no remote auth/security boundary (static export, no server). No dependency on user-supplied URLs.
|
||||
|
||||
---
|
||||
|
||||
## HIGH
|
||||
|
||||
### HIGH-1. Race-y toast / “Chờ X” suppression — `loto-player-board.tsx:72-97`
|
||||
The detection effect uses `return;` after the *first* completed row or first new waiting row. Consequences:
|
||||
|
||||
- If two rows transition to "waiting" simultaneously (one click can do this only when grids overlap, but more importantly **on mount** — see HIGH-2 — multi-row recovery is fine because the seed loop in `useEffect` `46-65` populates the set first; but during *gameplay* with overlapping numbers, the second row never gets a "Chờ X" toast on the click that would have triggered it because `return` exits before the loop reaches it). Next state change re-runs the effect and surfaces it, so it self-heals — but in practice it means the second eligible row stays silent until *another* state change.
|
||||
- Bigger issue: the “delete” branch at `89-95` runs only for indices the early-return loop *reaches*. Cross a row back from waiting → not-waiting → re-waiting in that order while a *lower-index* row is currently waiting, and the higher-index reset branch is never executed → second waiting toast is suppressed indefinitely.
|
||||
|
||||
Fix: split the loop into two passes — first recompute desired sets for all rows, then fire one notification.
|
||||
|
||||
### HIGH-2. `isRowComplete` returns `true` for empty/zero-cell row — `loto-game-logic.ts:108-117`
|
||||
The "any cell with value > 0 must be crossed" check returns `true` if no positive cells exist. `generateGrid` always emits 5 numbers/row so the live path is safe, but:
|
||||
|
||||
- Master `BOARD` has rows where `col===0 && row===9` etc. set to 0; they aren't passed to `isRowComplete` today, but anything that imports the helper for a different grid shape could trip it.
|
||||
- More immediate: if `loadGrid` returns a corrupted grid (e.g. user's localStorage edited to all zeros), the seed loop at `loto-player-board.tsx:56-59` would mark every row as celebrated, and the very first `crossed` change would skip the celebration animation for the real win.
|
||||
|
||||
Fix: add `let hasNumber = false;` guard, return `false` when no positive cells.
|
||||
|
||||
### HIGH-3. `loadGrid` / `loadCrossedState` accept any JSON shape — `loto-game-logic.ts:83-105`, `app/master/page.tsx:51-59`
|
||||
`JSON.parse` is wrapped in try/catch, but the parsed value is returned as-is and trusted as `number[][]` / `boolean[][]` / `MasterState`. Hand-edited or stale-from-older-version localStorage will crash the render:
|
||||
|
||||
- `crossed[row]?.[col]` (`loto-game-logic.ts:114`) is defensive, but `grid[row][col]` (`isRowComplete` line 114) is not — `grid[row]` could be `undefined` (`grid` shorter than 9 rows), throwing inside render.
|
||||
- `loadState()` in master could return `{}` and `state.called.length` (`app/master/page.tsx:69`) throws.
|
||||
- Cross-version risk: if the grid algorithm changes, old saves become wrong-shape but still parse.
|
||||
|
||||
Fix: validate shape (`Array.isArray`, lengths, element type) before returning. Drop & log on mismatch.
|
||||
|
||||
### HIGH-4. `crossed[][]` / `grid[][]` dimension drift — `loto-player-board.tsx:46-65`
|
||||
`saveGrid` and `saveCrossedState` are written separately. If `setGrid(newGrid)` succeeds but the next render throws before `saveCrossedState` runs (e.g. browser kills tab), the next session loads a new grid with stale crossed dimensions from a *prior* grid. `crossed[row]?.[col]` masks this for booleans, but a 9×9 grid paired with a 9×5 or 10×9 crossed array silently misreports row completion.
|
||||
|
||||
Fix: store both under one key as `{ grid, crossed, version }`, write atomically. Or always re-init `crossed` to all-false on grid load when shapes don't match.
|
||||
|
||||
### HIGH-5. `BOARD` mutation hazard via shared module-scope reference — `app/master/page.tsx:61`
|
||||
`BOARD = buildBoard()` is a module-level mutable nested array. Today nothing mutates it. But any future "click-to-strike" UI that mutates `BOARD[r][c]` would persist across HMR and hot-reload between routes. Freeze with `Object.freeze` on rows or compute inside the component (cheap — 90 ints).
|
||||
|
||||
### HIGH-6. Performance: `isRowComplete` called 81× per render — `loto-player-board.tsx:144`
|
||||
Inside `grid.flat().map(...)`, line 144 calls `isRowComplete(grid, crossed, row)` for every cell. That's 81 calls per render, each scanning 9 cells = 729 reads per render. Trivial today, but on every keystroke/click. Pre-compute `const completedRows = useMemo(() => grid.map((_, i) => isRowComplete(grid, crossed, i)), [grid, crossed])`.
|
||||
|
||||
### HIGH-7. `key={idx}` on history pills — `app/master/page.tsx:170`
|
||||
Acceptable here (history is append-only). However `key={idx}` is also used on grid cells (`loto-player-board.tsx:148`, `master/page.tsx:190`) — for the player grid this *will* break React's reconciliation if `generateGrid` ever returns a different cell ordering between renders (it doesn't, but the contract isn't enforced). Use stable keys derived from `row*9+col` (which equals idx today, so functionally identical — but documents intent).
|
||||
|
||||
### HIGH-8. basePath/assetPrefix prod hardcode — `next.config.ts:23`
|
||||
`basePath = isProd ? "/loto" : ""`. If the GH Pages repo is renamed, or someone deploys to a custom domain (apex), every asset 404s. Pull from `process.env.NEXT_PUBLIC_BASE_PATH` with `/loto` as fallback. Also: `output: "export"` is set unconditionally — `next start` (`package.json:9`) is meaningless for an exported build. Either remove the script or document.
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM
|
||||
|
||||
### MED-1. `randomNumbersInCol` Fisher-Yates is biased — `loto-game-logic.ts:46-49`
|
||||
`arr.sort(() => 0.5 - Math.random())` is a well-known biased shuffle (V8 sort is not guaranteed pairwise-symmetric). Fine for a casual game, but use a real shuffle (the same one in `master/page.tsx:39-43`) for fairness. DRY: extract one `shuffle<T>(arr: T[]): T[]`.
|
||||
|
||||
### MED-2. `confirm()` blocks during render — `loto-player-board.tsx:100`, `master/page.tsx:82`
|
||||
Native `confirm()` is synchronous and blocked by some browsers (Safari iframe, in-app webviews). Replace with the existing modal pattern (already used for "Kinh!" popup) for consistency and reliability.
|
||||
|
||||
### MED-3. `randomARow` mutates caller's `baseWeight` — `loto-game-logic.ts:32-42`
|
||||
`baseWeight[col]--` mutates the array passed by reference. The caller (`generateGrid:57`) creates a fresh array each call so it's safe today, but the function signature lies. Either rename to `randomARow(baseWeight, mutate=true)` or take/return a copy.
|
||||
|
||||
### MED-4. `state.remaining[0]` always drawn — `master/page.tsx:90`
|
||||
The shuffle is done once at game start, then `remaining` is consumed FIFO. That's deterministic given the initial shuffle. Functionally fine, but "Xổ số" feels less random — consider `Math.floor(Math.random() * remaining.length)` per draw to make each draw visibly random (no algorithmic difference for fairness, just UX perception).
|
||||
|
||||
### MED-5. Toast effect dep on `showToast` causes re-runs — `loto-player-board.tsx:97`
|
||||
`showToast` is `useCallback([dismissToast])` and `dismissToast` is `useCallback([])`, so identity is stable. OK, but adding any future dep would cause double-fires. Document or extract toast logic into a custom hook.
|
||||
|
||||
### MED-6. `master/page.tsx` hosts both a `loto_master` (called numbers) and `loto_master_card` (master's own card) key — naming collision risk
|
||||
A user-side bug where the user navigates to `/master`, generates a master card, then clears storage by clicking "Tạo bảng mới" *only* clears `loto_master_card_*` not `loto_master`. That's correct, but the visual cue (orange palette) doesn't tell the master "the called-number state is independent of your card." Add a small UI hint or rename for clarity.
|
||||
|
||||
### MED-7. master/page.tsx is 244 lines — modularization candidate
|
||||
Per project rules (>200 LOC). Suggested split: `app/master/use-master-state.ts` (state + persistence), `app/master/master-board.tsx` (the 9×10 tracking grid), `app/master/called-history.tsx` (chips). That brings each file under 100.
|
||||
|
||||
### MED-8. README is 14 lines, missing dev:codeserver instructions — `README.md`
|
||||
The new codeserver profile is non-obvious. Add a section explaining `.env.local` setup, `CODESERVER_HOST/PORT`, and the `/absproxy/{port}` URL.
|
||||
|
||||
### MED-9. Accessibility — grid is unreachable by keyboard
|
||||
- `<div onClick>` is not focusable, no `role="button"`, no `aria-pressed={isCrossed}`, no `aria-label="Số 42, đã đánh dấu"`, no `tabIndex={0}`, no Enter/Space handler.
|
||||
- Congrats modal (`loto-player-board.tsx:192-232`) has no `role="dialog"`, no `aria-modal`, no focus trap, no Escape-to-close.
|
||||
- Toast has no `aria-live="polite"`.
|
||||
- Color contrast: `text-slate-400 dark:text-slate-500` (`master/page.tsx:154`, `211`) on `dark:bg-slate-900` likely fails WCAG AA. The diagonal-line cross-out (`globals.css:80-91`) is a single hue (`#ef4444`) — colorblind users may miss it; the bg-color change provides redundancy, OK.
|
||||
|
||||
### MED-10. No tests at all
|
||||
There's no `__tests__/` or `*.test.ts`. `generateGrid`, `isRowComplete`, `getWaitingNumber`, `randomANumberInRow` are pure and trivially testable. Property-based test on `generateGrid`: every row has 5 numbers, every column count ≤ 6, all numbers in range, no duplicates.
|
||||
|
||||
### MED-11. CSP / iframe headers not set
|
||||
Static export + GH Pages → no CSP. App is embedded-friendly which means clickjacking-friendly. Low impact (no auth, no money, no PII), but document or add `<meta http-equiv="Content-Security-Policy">` in `layout.tsx`.
|
||||
|
||||
### MED-12. `Math.random()` in `loto-game-logic.ts:24,47` — not cryptographic but called "random"
|
||||
Fine for a game. Document so a future dev doesn't think it's secure.
|
||||
|
||||
---
|
||||
|
||||
## LOW
|
||||
|
||||
### LOW-1. Dead code: `notifiedWaitingRows` reset branch only triggers when `waitNum===null && notified && !celebrated` — `loto-player-board.tsx:89-95`
|
||||
A row that completes will never hit this branch because `celebrated.has(i)` blocks it. Add a comment, or refactor: when a row becomes complete, remove from `notifiedWaitingRows` (already done at line 78) and rely on the check.
|
||||
|
||||
### LOW-2. `package.json` name is `nextjs-temp` — pre-rename leftover. Rename to `loto`.
|
||||
|
||||
### LOW-3. `master/page.tsx:184` `BOARD.flat()` allocates per render. Wrap in `useMemo(() => BOARD.flat(), [])` or compute once at module scope.
|
||||
|
||||
### LOW-4. `app/page.tsx:14` "TN1 (2014–2017)" hard-codes copy in the component. If localization is ever added, extract.
|
||||
|
||||
### LOW-5. `globals.css:67` typo-prone — `.animate-spin-slow-reverse` reuses `spin-slow` keyframe + `reverse` direction. Works but two classes named almost identically (`spin-slow` vs `spin-slow-reverse`) is brittle.
|
||||
|
||||
### LOW-6. `isRowComplete`/`getWaitingNumber` hardcode `col < 9` — `loto-game-logic.ts:113,126`. Use `NUM_COLS` constant for consistency.
|
||||
|
||||
### LOW-7. `app/master/page.tsx:79` `const calledSet = new Set(state?.called ?? []);` rebuilt every render. `useMemo` (cheap — 90 elements — so LOW).
|
||||
|
||||
### LOW-8. `app/page.tsx:65` `rel="noopener noreferrer"` is good. But `master/page.tsx:233` has the same external link duplicated — extract `<Footer />`.
|
||||
|
||||
### LOW-9. `next-env.d.ts` was changed in this branch. That file is auto-regenerated and shouldn't be hand-edited; ensure the change is benign (it imports `./.next/dev/types/routes.d.ts` which is the new Next 16 typegen). Verify the file isn't gitignored on other contributors' machines.
|
||||
|
||||
### LOW-10. `eslint.config.mjs:14` ignores `next-env.d.ts` but the file is now actually committed and modified — confirm intent.
|
||||
|
||||
---
|
||||
|
||||
## Edge cases (adversarial)
|
||||
|
||||
| Scenario | Behavior | Severity |
|
||||
|---|---|---|
|
||||
| `localStorage` is disabled / quota exceeded | `setItem` throws, uncaught → unhandled exception during state update. | HIGH |
|
||||
| User edits `loto_grid` to `"hello"` | `JSON.parse` throws, caught, returns `null`. OK. | OK |
|
||||
| User edits `loto_grid` to `[[1,2]]` (1 row, 2 cols) | `isRowComplete` reads `grid[1][0]` → `undefined.0` → TypeError on render. | HIGH-3 |
|
||||
| User opens `/` and `/master` in two tabs of same browser | Both tabs write to same `loto_grid` key from `/`, but `/master` uses `loto_master_card_grid` for its own card, so they don't overlap. The user's main card on `/` is shared. NO `storage` event listener — second tab won't update. | MED |
|
||||
| User opens `/` twice in two tabs | Both write to `loto_grid` — last writer wins, no sync. Mutation in tab A invisible in tab B until refresh. | MED |
|
||||
| `generateGrid` called on a partially-completed board | `handleGenerate` always overwrites grid + resets crossed. `confirm()` guards. OK. | OK |
|
||||
| `crossed[][]` shorter than `grid[][]` | `crossed[row]?.[col]` returns undefined → falsy → row never marked complete. Safe but wrong-state. | HIGH-4 |
|
||||
| `crossed[][]` longer than `grid[][]` | Excess rows ignored. Safe. | OK |
|
||||
| Click during congrats modal | Cell click works (grid is not aria-hidden, modal is a fixed overlay). User can keep marking. Minor UX: modal blocks clicks via backdrop, but `e.stopPropagation` on inner div allows close. OK. | OK |
|
||||
| Codeserver host changes mid-session | basePath is baked at build/dev-start. `next dev` must restart. Document. | LOW |
|
||||
| GH Pages deploy when repo renamed | `/loto` 404. | HIGH-8 |
|
||||
| Two rows reach "waiting" on the same click | Only first row's "Chờ X" toast shown; second silent until next click. | HIGH-1 |
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
- **XSS via stored values:** All user-controlled data is rendered as `{num}` (numeric) or `{toast}` (template literal `Chờ ${num}`) — both pass through React's escaping. The "Hàng X đã đầy đủ" message uses `congratsRow` which is a number. No `dangerouslyInnerHTML` anywhere. **Safe.**
|
||||
- **Prototype pollution via JSON.parse:** `JSON.parse` does not pollute Object.prototype by itself, but `loadCrossedState` returns the raw parsed value. If a malicious actor can control localStorage (i.e. user's own browser — not a real attacker model for a static SPA), they can set `{"__proto__": {...}}` but `JSON.parse` ignores `__proto__` as a data key in modern engines (V8 since long ago). **Safe by current engine behavior**, but a defensive `structuredClone` or shape validator (HIGH-3) makes it explicit.
|
||||
- **Codeserver dev origin leakage:** `allowedDevOrigins: [host]` with `host` from `.env.local` — no validation. If `CODESERVER_HOST` is set to `*` or a malformed value, Next will accept it. Add a sanity check (must match `/^[a-z0-9.-]+$/i`). Low impact (dev only, never shipped to prod). **Low.**
|
||||
- **Secrets:** `.env.local` is gitignored. `.env.example` only contains hostnames. `CODESERVER_HOST/PORT` are not `NEXT_PUBLIC_*` so they are server-side only — but `next.config.ts` consumes them at build time and bakes `basePath` into the static bundle. The basePath itself (`/absproxy/3000`) is visible in HTML. Hostname is NOT baked — only used for `allowedDevOrigins`. **Safe.**
|
||||
- **GH Actions:** `permissions:` is correctly minimal. No third-party actions beyond `actions/*` v4. `npm ci` against committed `package-lock.json` (verify lockfile is committed — not visible in this review; check).
|
||||
- **No CSRF / no auth / no PII** — N/A.
|
||||
|
||||
---
|
||||
|
||||
## Positive observations
|
||||
|
||||
- Clean separation of pure logic (`loto-game-logic.ts`) from React state.
|
||||
- `useCallback` and `useRef` used appropriately for stable identity.
|
||||
- Vietnamese-first UI is consistent.
|
||||
- `prefix`-based localStorage namespacing is the right call for the new master-card feature — avoids the user-board / master-card collision cleanly.
|
||||
- Tailwind + CSS keyframes is lighter than dragging in Framer Motion.
|
||||
- TS strict (assumed from `eslint-config-next/typescript`) and no `any` in app code.
|
||||
- `output: "export"` matches the deploy target (GH Pages static).
|
||||
- The new codeserver profile is well-documented inline (`next.config.ts:6-10`) — comment explains *why* `/absproxy` not `/proxy`.
|
||||
|
||||
---
|
||||
|
||||
## Recommended actions (priority order)
|
||||
|
||||
1. **HIGH-1**: Rewrite the row-detection effect as two passes (compute → notify) to fix toast suppression.
|
||||
2. **HIGH-3 + HIGH-4**: Add a `validateGrid(g): g is number[][]` and `validateCrossed(c, g): c is boolean[][]` helper. Use in `loadGrid`/`loadCrossedState`/`loadState`. On invalid: drop the key and return null.
|
||||
3. **HIGH-2**: Add empty-row guard to `isRowComplete`.
|
||||
4. **HIGH-6**: Memoize `completedRows` in `PlayerBoard`.
|
||||
5. **HIGH-8**: Read `basePath` from `NEXT_PUBLIC_BASE_PATH` env with `/loto` fallback; document in README.
|
||||
6. **MED-9**: Add ARIA + keyboard handlers to grid cells (role=button, tabIndex, aria-pressed, aria-label, Enter/Space). Add role=dialog + focus trap to congrats modal. Add aria-live=polite to toast.
|
||||
7. **MED-7**: Split `master/page.tsx` into hook + 2-3 components.
|
||||
8. **MED-1 + MED-3**: DRY up shuffle into one helper, use it everywhere.
|
||||
9. **MED-10**: Add Vitest + a property test for `generateGrid` invariants.
|
||||
10. **LOW-2 + MED-8**: Rename package, expand README.
|
||||
|
||||
---
|
||||
|
||||
## Metrics
|
||||
|
||||
- LOC reviewed: ~1.2k (app code only).
|
||||
- Type coverage: 100% explicit (no `any`).
|
||||
- Test coverage: 0% (no tests).
|
||||
- Lint: not run in this review (ask `tester` agent if needed).
|
||||
- File-size violations: 1 (`master/page.tsx` 244 LOC > 200 limit).
|
||||
|
||||
---
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
1. Is `package-lock.json` committed? (Required for `npm ci` reproducibility — couldn't see it in the diff stat.)
|
||||
2. Is the GH Pages deploy guaranteed to be at `/loto`, or is a custom domain planned? Determines whether HIGH-8 is real or theoretical.
|
||||
3. Does the master "play along" feature (HIGH `loto_master_card`) need to interact with the called-numbers state (e.g. auto-cross master's card when a number is called)? Right now they're fully independent — verify this is intended.
|
||||
4. Should the user's grid sync across tabs (`storage` event listener)?
|
||||
5. Is i18n on the roadmap, or is Vietnamese-only acceptable long-term?
|
||||
Reference in New Issue
Block a user