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:
2026-04-26 19:27:18 +07:00
parent fd2d144428
commit dd64aa6ed3
10 changed files with 1048 additions and 0 deletions
+1
View File
@@ -5,3 +5,4 @@ out/
.env.local
.env*.local
tsconfig.tsbuildinfo
repomix-output.xml
+18
View File
@@ -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`.
+138
View File
@@ -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:1928`).
### 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
+75
View File
@@ -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 (190). 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
+151
View File
@@ -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
+153
View File
@@ -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
+99
View File
@@ -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 190 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., 175 for American bingo) instead of hardcoded 190. 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
+67
View File
@@ -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 190. First player to complete an entire row wins and shouts "Kinh!" (the game's victory cheer).
The inspiration comes from TN1 class reunions (20142017) 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 190). Click cells to mark them as numbers are called.
- **Host**: Draws numbers randomly from a shuffled 190 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
+133
View File
@@ -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 190)
├─ 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 (20142017)" 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?