Compare commits
1 Commits
main
..
1a6639f45a
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a6639f45a |
@@ -1,18 +0,0 @@
|
|||||||
.git
|
|
||||||
.gitignore
|
|
||||||
.next
|
|
||||||
.pnpm-store
|
|
||||||
.turbo
|
|
||||||
coverage
|
|
||||||
docker-compose.override.yml
|
|
||||||
node_modules
|
|
||||||
playwright-report
|
|
||||||
test-results
|
|
||||||
|
|
||||||
.DS_Store
|
|
||||||
*.log
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
.env.*.local
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
DATABASE_URL=
|
|
||||||
REDIS_URL=
|
|
||||||
NEXTAUTH_SECRET=
|
|
||||||
NEXTAUTH_URL=
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
DATABASE_URL=
|
|
||||||
REDIS_URL=
|
|
||||||
NEXTAUTH_SECRET=
|
|
||||||
NEXTAUTH_URL=https://math-quiz.jisangs.com
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": ["next/core-web-vitals", "next/typescript"]
|
|
||||||
}
|
|
||||||
-81
@@ -1,81 +0,0 @@
|
|||||||
# ===========================
|
|
||||||
# macOS
|
|
||||||
# ===========================
|
|
||||||
.DS_Store
|
|
||||||
.AppleDouble
|
|
||||||
.LSOverride
|
|
||||||
Icon?
|
|
||||||
._*
|
|
||||||
.Spotlight-V100
|
|
||||||
.Trashes
|
|
||||||
.AppleDB
|
|
||||||
.AppleDesktop
|
|
||||||
Network Trash Folder
|
|
||||||
Temporary Items
|
|
||||||
.apdisk
|
|
||||||
|
|
||||||
# ===========================
|
|
||||||
# IntelliJ IDEA
|
|
||||||
# ===========================
|
|
||||||
.idea/
|
|
||||||
|
|
||||||
# 모듈·프로젝트 파일 (구형 포맷)
|
|
||||||
*.iml
|
|
||||||
*.ipr
|
|
||||||
*.iws
|
|
||||||
|
|
||||||
# ===========================
|
|
||||||
# Next.js
|
|
||||||
# ===========================
|
|
||||||
.next/
|
|
||||||
out/
|
|
||||||
build/
|
|
||||||
dist/
|
|
||||||
|
|
||||||
# ===========================
|
|
||||||
# Node.js / pnpm
|
|
||||||
# ===========================
|
|
||||||
node_modules/
|
|
||||||
.pnpm-store/
|
|
||||||
.pnpm-debug.log*
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
|
|
||||||
# ===========================
|
|
||||||
# 환경변수 (보안)
|
|
||||||
# ===========================
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
.env.*.local
|
|
||||||
# .env.example 은 커밋 대상 (키 이름만 포함)
|
|
||||||
|
|
||||||
# ===========================
|
|
||||||
# Prisma
|
|
||||||
# ===========================
|
|
||||||
prisma/migrations/dev/
|
|
||||||
|
|
||||||
# ===========================
|
|
||||||
# Vitest / Playwright
|
|
||||||
# ===========================
|
|
||||||
coverage/
|
|
||||||
playwright-report/
|
|
||||||
test-results/
|
|
||||||
.playwright/
|
|
||||||
|
|
||||||
# ===========================
|
|
||||||
# Docker
|
|
||||||
# ===========================
|
|
||||||
docker-compose.override.yml
|
|
||||||
|
|
||||||
# ===========================
|
|
||||||
# TypeScript
|
|
||||||
# ===========================
|
|
||||||
*.tsbuildinfo
|
|
||||||
next-env.d.ts
|
|
||||||
|
|
||||||
# ===========================
|
|
||||||
# 기타
|
|
||||||
# ===========================
|
|
||||||
*.log
|
|
||||||
.turbo/
|
|
||||||
Generated
-10
@@ -1,10 +0,0 @@
|
|||||||
# 디폴트 무시된 파일
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
||||||
# 에디터 기반 HTTP 클라이언트 요청
|
|
||||||
/httpRequests/
|
|
||||||
# 쿼리 파일을 포함한 무시된 디폴트 폴더
|
|
||||||
/queries/
|
|
||||||
# Datasource local storage ignored files
|
|
||||||
/dataSources/
|
|
||||||
/dataSources.local.xml
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
# AGENTS.md
|
|
||||||
|
|
||||||
AI 에이전트(Claude Code 등)가 이 프로젝트에서 작업할 때 따라야 할 규칙과 맥락을 정의합니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 프로젝트 개요
|
|
||||||
|
|
||||||
중학교 2학년 대상 **수학 실수 유형 반복 연습** 교육용 게임 서비스.
|
|
||||||
문제 생성은 AI 없이 자체 알고리즘 엔진으로 처리한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 기술 스택 요약
|
|
||||||
|
|
||||||
| 레이어 | 기술 |
|
|
||||||
|---|---|
|
|
||||||
| 프레임워크 | Next.js 14 (App Router) + TypeScript 5 |
|
|
||||||
| 스타일 | Tailwind CSS 3 |
|
|
||||||
| 상태 관리 | Zustand |
|
|
||||||
| 수식 렌더링 | KaTeX |
|
|
||||||
| 애니메이션 | Framer Motion |
|
|
||||||
| 인증 | NextAuth.js 4 |
|
|
||||||
| ORM | Prisma (MariaDB 11) |
|
|
||||||
| 캐시 | Redis 7 |
|
|
||||||
| 유효성 검증 | Zod |
|
|
||||||
| 패키지 매니저 | pnpm |
|
|
||||||
| 테스트 | Vitest (단위) / Playwright (E2E) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 디렉토리 구조 및 파일 위치 규칙
|
|
||||||
|
|
||||||
```
|
|
||||||
app/ # Next.js App Router 루트
|
|
||||||
(game)/ # 게임 화면 라우트 그룹
|
|
||||||
number-parentheses/ # 숫자 괄호 준비 화면
|
|
||||||
play/ # 숫자 괄호 풀이 화면
|
|
||||||
variable-parentheses/ # 미지수 괄호 준비 화면
|
|
||||||
play/ # 미지수 괄호 풀이 화면
|
|
||||||
select-mode/ # 게임 모드 선택 (미구현)
|
|
||||||
api/
|
|
||||||
auth/ # NextAuth.js 핸들러
|
|
||||||
problem/ # 문제 생성 API
|
|
||||||
record/ # 풀이 기록 저장 API
|
|
||||||
layout.tsx
|
|
||||||
page.tsx # 시작 화면 (학습 내용 선택)
|
|
||||||
components/
|
|
||||||
math/ # KaTeX 수식 컴포넌트
|
|
||||||
game/ # 게임 UI 컴포넌트
|
|
||||||
lib/
|
|
||||||
engine/ # 문제 생성 알고리즘 엔진
|
|
||||||
parenthesis-sign.ts # 숫자 괄호 엔진
|
|
||||||
parenthesis-latex.ts # 괄호 식 KaTeX 포맷 및 현재 항 강조 유틸
|
|
||||||
variable-parenthesis.ts # 미지수 괄호 엔진
|
|
||||||
types.ts # 숫자 괄호 타입
|
|
||||||
variable-types.ts # 미지수 괄호 타입
|
|
||||||
input-device.ts # 입력 장치 감지 미디어 쿼리 상수
|
|
||||||
prisma.ts # Prisma Client 싱글턴
|
|
||||||
redis.ts # Redis Client 싱글턴
|
|
||||||
store/
|
|
||||||
gameStore.ts # 숫자 괄호 Zustand 전역 상태
|
|
||||||
variableGameStore.ts # 미지수 괄호 Zustand 전역 상태
|
|
||||||
types/
|
|
||||||
*.d.ts # 타입 선언이 없는 서드파티 모듈 보강
|
|
||||||
prisma/
|
|
||||||
schema.prisma # MariaDB 모델 정의
|
|
||||||
.gitea/workflows/deploy.yml # CI/CD 워크플로우
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 코드 작성 규칙
|
|
||||||
|
|
||||||
### Next.js / React
|
|
||||||
|
|
||||||
- 페이지 컴포넌트는 반드시 `app/` 디렉토리에 위치한다.
|
|
||||||
- 클라이언트 컴포넌트에는 `'use client'` 지시어를 파일 최상단에 명시한다.
|
|
||||||
- 서버 컴포넌트가 기본값이므로, 클라이언트 기능(훅, 이벤트)이 필요할 때만 `'use client'`를 붙인다.
|
|
||||||
|
|
||||||
### 수식 렌더링
|
|
||||||
|
|
||||||
- 모든 수학 수식은 KaTeX로 렌더링한다. 일반 텍스트나 Unicode 수식 문자를 직접 사용하지 않는다.
|
|
||||||
- KaTeX 컴포넌트는 `components/math/` 하위에 위치한다.
|
|
||||||
- 현재 수식 컴포넌트는 `katex.renderToString()`을 직접 사용한다. 별도 래퍼 라이브러리를 추가하기 전에 Next.js App Router 클라이언트 번들에서 런타임 문제가 없는지 확인한다.
|
|
||||||
- 괄호 식 수식 조립과 풀이 중 현재 항 강조는 `lib/engine/parenthesis-latex.ts`의 공통 유틸을 사용한다. 숫자/미지수 풀이 화면에 같은 포맷 로직을 중복 구현하지 않는다.
|
|
||||||
|
|
||||||
### 상태 관리
|
|
||||||
|
|
||||||
- 숫자 괄호 전역 게임 상태는 `store/gameStore.ts`, 미지수 괄호 전역 게임 상태는 `store/variableGameStore.ts`의 Zustand store로 관리한다. 학습 유형별로 store를 분리한다.
|
|
||||||
- 컴포넌트 내부의 로컬 상태는 `useState`를 사용한다.
|
|
||||||
- Zustand selector 안에서 매번 새 객체·배열을 반환하는 함수(`score()` 등)를 호출하지 않는다. 필요한 파생값은 구독한 state를 기준으로 컴포넌트 내부에서 계산하거나 안정적인 selector로 분리한다.
|
|
||||||
- store에는 세션 원본 상태와 상태 변경 action을 우선 두고, 렌더링 전용 파생값은 컴포넌트 또는 별도 순수 유틸에서 계산한다.
|
|
||||||
|
|
||||||
### 컴포넌트 배치
|
|
||||||
|
|
||||||
- 게임 화면에서 재사용되는 입력, 피드백, 점수 요약, 진행 단계 UI는 `components/game/` 하위에 둔다.
|
|
||||||
- 정답·오답 판정 라인처럼 피드백 화면과 결과 화면이 공유하는 표시는 `components/game/` 하위 공통 컴포넌트로 분리한다.
|
|
||||||
- 수식 렌더링 전용 컴포넌트는 `components/math/` 하위에 둔다.
|
|
||||||
- 라우트별 조립 컴포넌트(`NumberPlayClient`, `NumberParenthesesClient` 등)는 해당 `app/` 라우트 폴더에 둘 수 있다.
|
|
||||||
|
|
||||||
### 접근성 / 키보드 조작
|
|
||||||
|
|
||||||
- 문제 풀이 화면은 키보드만으로 진행할 수 있어야 한다.
|
|
||||||
- 정답·오답 피드백이 표시되면 다음 진행 버튼에 focus를 둔다.
|
|
||||||
- 최종 결과 화면이 표시되면 다시 시작 버튼에 focus를 둔다.
|
|
||||||
- 아이콘이나 기호만으로 상태를 표시할 때는 `aria-label`, `sr-only` 텍스트 등으로 의미를 함께 제공한다.
|
|
||||||
|
|
||||||
#### 숫자 괄호 풀이 화면 (`/number-parentheses/play`)
|
|
||||||
|
|
||||||
- 모든 항을 하나의 공유 input으로 입력한다.
|
|
||||||
- input 위 우상단에는 각 항의 진행 상태를 progress 스타일로 표시한다.
|
|
||||||
- 진행 상태는 값 입력 전에는 괄호 안 원래 항 값을 표시하고, 값 입력 중이거나 입력 후에는 사용자가 입력한 값을 표시한다.
|
|
||||||
- Enter를 누르면 다음 항 입력으로 전환되고, 마지막 항에서 Enter를 누르면 제출 동작을 실행한다. 별도 제출 버튼이 없어도 form submit 흐름이 유지되어야 한다.
|
|
||||||
- 풀이 중인 항의 수, 곱해지는 수, 해당 괄호는 KaTeX 색상으로 강조하고 나머지 항은 강조하지 않는다.
|
|
||||||
- 터치 기반 입력 환경에서는 input focus 시 커스텀 숫자 키패드를 표시한다. 키패드는 숫자, `+/-`, `.`, `⌫`, `↵` 버튼을 제공한다.
|
|
||||||
- 마우스/트랙패드처럼 hover 가능한 정밀 포인터가 있는 환경은 물리 키보드 사용 환경으로 간주하고 커스텀 숫자 키패드를 숨긴다. 웹 표준 API로 물리 키보드 연결 여부 자체를 안정적으로 판별할 수 없으므로 해상도 기준으로 분기하지 않는다. (`lib/input-device.ts`의 `DESKTOP_INPUT_DEVICE_QUERY` 사용)
|
|
||||||
- 입력 중에는 `-`, `1.` 같은 임시 값을 허용할 수 있지만 제출 시에는 정상적인 양의 실수 또는 음의 실수 표현만 통과시킨다.
|
|
||||||
- 문제 풀이 화면 최초 진입 시 모바일 키보드를 강제로 열지 않는다. 사용자가 input을 직접 focus하거나 피드백 후 다음 문제로 넘어갈 때 input에 focus를 둔다.
|
|
||||||
- 터치 기반 입력 환경에서 input focus 시 `span.katex` 상단이 브라우저 화면 상단에 오도록 조정한다.
|
|
||||||
|
|
||||||
#### 미지수 괄호 풀이 화면 (`/variable-parentheses/play`)
|
|
||||||
|
|
||||||
- 답을 직접 입력하지 않는다. 각 항의 보기 버튼 중에서 선택한다.
|
|
||||||
- 항을 선택하면 자동으로 다음 항 보기로 focus가 이동한다.
|
|
||||||
- 마지막 항을 선택하면 별도 `정답 확인` 버튼 없이 즉시 제출되어 피드백 화면으로 전환된다.
|
|
||||||
- 입력 항 진행 상태는 progress 스타일로 표시한다. 선택 전에는 괄호 안 원래 항 값을 표시하고, 선택 후에는 고른 보기 값을 표시한다.
|
|
||||||
- 선택된 보기 버튼은 `aria-pressed`로 표시한다.
|
|
||||||
- 풀이 중인 항의 수, 곱해지는 수, 해당 괄호는 KaTeX 색상으로 강조하고 나머지 항은 강조하지 않는다.
|
|
||||||
|
|
||||||
### API / 백엔드
|
|
||||||
|
|
||||||
- API는 `app/api/` 하위의 Route Handlers로만 구현한다. 별도의 백엔드 서버를 추가하지 않는다.
|
|
||||||
- 모든 API 요청 입력값은 Zod 스키마로 검증한다.
|
|
||||||
- DB 접근은 `lib/prisma.ts`의 Prisma Client 싱글턴을 통해서만 한다.
|
|
||||||
- Redis 접근은 `lib/redis.ts`의 Redis Client 싱글턴을 통해서만 한다.
|
|
||||||
|
|
||||||
### 문제 생성 엔진
|
|
||||||
|
|
||||||
- 문제 생성 로직은 `lib/engine/` 하위에 위치한다.
|
|
||||||
- 문제는 AI 호출 없이 알고리즘으로 생성한다. 외부 AI API를 문제 생성에 사용하지 않는다.
|
|
||||||
- 계수는 항상 정수이어야 한다.
|
|
||||||
- 문제 세션에서 중복을 방지할 때는 랜덤 id가 아니라 수학적 문제 조건을 나타내는 안정적인 key를 기준으로 한다.
|
|
||||||
- 모든 엔진은 전체 가능한 시드 목록을 모듈 로드 시 한 번 생성(`createAllProblemSeeds`)하고, 세션 생성 시 셔플 후 슬라이스하는 방식으로 중복 없이 출제한다.
|
|
||||||
- 숫자 괄호 엔진: `lib/engine/parenthesis-sign.ts` / 타입: `lib/engine/types.ts`
|
|
||||||
- 문제 구조는 `terms`, `operators`, `distributedTerms` 배열을 기준으로 한다. `a`, `b`, `distributedA`, `distributedB` 같은 2항 전용 필드를 추가하지 않는다.
|
|
||||||
- 채점 입력은 `{ terms: number[] }` 형태만 사용한다.
|
|
||||||
- 미지수 괄호 엔진: `lib/engine/variable-parenthesis.ts` / 타입: `lib/engine/variable-types.ts`
|
|
||||||
- 괄호 안 항 중 정확히 하나가 `정수 × x` 항이다.
|
|
||||||
- 문제 구조는 `terms`, `operators`, `correctTerms`, `choices` 배열을 기준으로 한다. `firstTerm`, `secondTerm`, `operator` 같은 2항 전용 필드를 추가하지 않는다.
|
|
||||||
- 각 항의 보기는 정답 계수의 절대값 기준 음수/양수 두 개를 셔플해 제공한다.
|
|
||||||
- 채점은 choice id 기반으로 수행한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 환경변수
|
|
||||||
|
|
||||||
새로운 환경변수를 추가할 때는 `.env.example`에 키 이름만 추가하고 값은 비워둔다.
|
|
||||||
`.env.local`은 절대 Git에 커밋하지 않는다.
|
|
||||||
|
|
||||||
```
|
|
||||||
DATABASE_URL=mysql://user:pass@mariadb:3306/mathgame
|
|
||||||
REDIS_URL=redis://redis:6379
|
|
||||||
NEXTAUTH_SECRET=your-secret
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 보안 규칙
|
|
||||||
|
|
||||||
- `.env.local`을 Git에 커밋하거나 코드에 하드코딩하지 않는다.
|
|
||||||
- MariaDB, Redis 포트는 컨테이너 내부 통신 전용이다. 외부 노출 설정을 추가하지 않는다.
|
|
||||||
- SQL 쿼리는 반드시 Prisma를 통해 실행한다. Raw query 사용 시 파라미터 바인딩을 반드시 사용한다.
|
|
||||||
- 사용자 입력은 API 경계에서 Zod로 검증한다. 클라이언트 측 검증만으로 보안을 의존하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 게임 도메인 컨텍스트
|
|
||||||
|
|
||||||
### 학습 유형
|
|
||||||
|
|
||||||
| 유형 | 상태 | 라우트 | 답변 방식 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 숫자 괄호 풀기 | 구현 완료 | `/number-parentheses` → `/number-parentheses/play` | 직접 입력 |
|
|
||||||
| 미지수 괄호 풀기 | 구현 완료 | `/variable-parentheses` → `/variable-parentheses/play` | 보기 선택 |
|
|
||||||
| 분수 괄호 풀기 | 미구현 | — | — |
|
|
||||||
| 실수 괄호 풀기 | 미구현 | — | — |
|
|
||||||
|
|
||||||
### 게임 모드
|
|
||||||
|
|
||||||
| 모드 | 설명 |
|
|
||||||
|---|---|
|
|
||||||
| 스피드 퀴즈 | 제한 시간 내 최대 문제 풀기, Redis Sorted Set 기반 실시간 점수판 (미구현) |
|
|
||||||
| 일반 풀이 | 시간 제한 없음, 틀린 유형 반복 출제 (미구현) |
|
|
||||||
|
|
||||||
### 화면 흐름
|
|
||||||
|
|
||||||
```
|
|
||||||
학습 내용 선택(/) → 준비 화면 → 문제 풀이 → 결과
|
|
||||||
```
|
|
||||||
|
|
||||||
- 시작 화면(`/`)은 학습 내용 선택 화면이다. `숫자 괄호 풀기`, `미지수 괄호 풀기`가 활성화되어 있고 분수/실수 괄호는 disabled 상태다.
|
|
||||||
- 준비 화면은 문제 수 `[5] [10]`, 괄호 종류, 괄호 안 최대 항 수 `[2] [3] [4]` 버튼 그룹을 제공한다. 기본 문제 수는 `10`, 기본 최대 항 수는 `2`이며, 괄호 종류는 현재 `()`만 선택 가능하다.
|
|
||||||
- 준비 화면의 `연습 시작`은 Zustand 세션을 초기화하고 풀이 화면으로 이동한다.
|
|
||||||
- 풀이 화면은 URL의 `count`, `terms` 쿼리와 Zustand의 선택 문제 수·최대 항 수를 동기화한다. 허용 문제 수는 `5`, `10`이며 유효하지 않은 값은 `10`으로 처리한다. 허용 최대 항 수는 `2`, `3`, `4`이며 유효하지 않은 값은 `2`로 처리한다.
|
|
||||||
- 풀이 화면의 문제 수식은 현재 풀고 있는 항의 수, 곱해지는 수, 해당 괄호를 강조 색상으로 표시하고 나머지 항은 기본 색상으로 표시한다.
|
|
||||||
- 정답·오답 피드백은 `계산식 = 내가 입력한(고른) 답` 형식으로 표시한다. 정답은 `○`, 오답은 붉은색 답과 `×`, 굵은 정답값을 함께 보여준다.
|
|
||||||
- 최종 결과 화면은 지금까지 푼 문제를 `문제 = 내가 입력한(고른) 답` 형식으로 나열하고, 피드백 화면과 같은 정답·오답 색상 및 기호 규칙을 사용한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## CI/CD
|
|
||||||
|
|
||||||
- `.gitea/workflows/deploy.yml`이 CI/CD 진입점이다. main 브랜치 push 시 자동으로 빌드·배포된다.
|
|
||||||
- 패키지 설치는 반드시 `pnpm`을 사용한다. `npm` / `yarn` 명령을 사용하지 않는다.
|
|
||||||
- 빌드 확인은 `pnpm build`로 실행한다. standalone 배포 산출물에 필요한 정적 파일 복사까지 `postbuild` 스크립트가 처리한다.
|
|
||||||
- 배포 환경: Synology NAS + Docker Compose + Nginx Proxy Manager.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 작업 시 체크리스트
|
|
||||||
|
|
||||||
새 기능 또는 수정 작업 전에 다음을 확인한다.
|
|
||||||
|
|
||||||
- [ ] 클라이언트 컴포넌트에 `'use client'` 선언 여부
|
|
||||||
- [ ] 수식이 KaTeX로 렌더링되는지 여부
|
|
||||||
- [ ] API 입력값이 Zod로 검증되는지 여부
|
|
||||||
- [ ] DB 접근이 Prisma 싱글턴을 통하는지 여부
|
|
||||||
- [ ] 환경변수가 `.env.local`에만 존재하는지 여부 (`.env.example`에 키 이름 추가)
|
|
||||||
- [ ] 문제 생성 로직이 `lib/engine/` 내에서 알고리즘으로만 처리되는지 여부
|
|
||||||
-35
@@ -1,35 +0,0 @@
|
|||||||
# syntax=docker/dockerfile:1
|
|
||||||
|
|
||||||
FROM node:22-alpine AS base
|
|
||||||
WORKDIR /app
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
|
||||||
RUN corepack enable
|
|
||||||
|
|
||||||
FROM base AS deps
|
|
||||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
FROM base AS builder
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
|
||||||
COPY . .
|
|
||||||
RUN pnpm build
|
|
||||||
|
|
||||||
FROM node:22-alpine AS runner
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
|
||||||
ENV HOSTNAME=0.0.0.0
|
|
||||||
ENV PORT=3000
|
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs \
|
|
||||||
&& adduser --system --uid 1001 nextjs
|
|
||||||
|
|
||||||
COPY --from=builder /app/.next/standalone ./
|
|
||||||
COPY --from=builder /app/.next/static ./.next/static
|
|
||||||
|
|
||||||
USER nextjs
|
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
|
||||||
@@ -1,173 +1,3 @@
|
|||||||
# Math Quiz
|
# math-quiz
|
||||||
|
|
||||||
중학교 2학년 대상 **수학 실수 유형 반복 연습** 교육용 게임 서비스.
|
자주 틀리는 수학 문제를 반복 풀이
|
||||||
|
|
||||||
문제 생성은 AI 없이 자체 알고리즘 엔진으로 처리합니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 기술 스택
|
|
||||||
|
|
||||||
| 레이어 | 기술 |
|
|
||||||
|---|---|
|
|
||||||
| 프레임워크 | Next.js 14 (App Router) + TypeScript 5 |
|
|
||||||
| 스타일 | Tailwind CSS 3 |
|
|
||||||
| 상태 관리 | Zustand |
|
|
||||||
| 수식 렌더링 | KaTeX |
|
|
||||||
| 애니메이션 | Framer Motion |
|
|
||||||
| 인증 | NextAuth.js 4 |
|
|
||||||
| ORM | Prisma (MariaDB 11) |
|
|
||||||
| 캐시 | Redis 7 |
|
|
||||||
| 유효성 검증 | Zod |
|
|
||||||
| 패키지 매니저 | pnpm |
|
|
||||||
| 테스트 | Vitest (단위) / Playwright (E2E) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 학습 유형
|
|
||||||
|
|
||||||
| 유형 | 상태 | 답변 방식 |
|
|
||||||
|---|---|---|
|
|
||||||
| 숫자 괄호 풀기 | ✅ 구현 완료 | 직접 입력 |
|
|
||||||
| 미지수 괄호 풀기 | ✅ 구현 완료 | 보기 선택 |
|
|
||||||
| 분수 괄호 풀기 | 준비 중 | — |
|
|
||||||
| 실수 괄호 풀기 | 준비 중 | — |
|
|
||||||
|
|
||||||
## 화면 흐름
|
|
||||||
|
|
||||||
```
|
|
||||||
학습 내용 선택(/) → 준비 화면 → 문제 풀이 → 결과
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 현재 구현 상태
|
|
||||||
|
|
||||||
### 공통
|
|
||||||
|
|
||||||
- 시작 화면(`/`)에서 학습 내용을 선택합니다. `숫자 괄호 풀기`, `미지수 괄호 풀기`가 활성 상태이고 분수·실수 괄호는 준비 중으로 비활성화되어 있습니다.
|
|
||||||
- 문제와 정답 수식은 KaTeX로 렌더링합니다.
|
|
||||||
- 풀이 중인 문제 수식은 현재 항, 곱해지는 수, 해당 괄호를 강조 색상으로 표시하고 나머지 항은 기본 색상으로 표시합니다.
|
|
||||||
- 진행 단계는 현재 문제, 완료된 정답, 완료된 오답, 남은 문제를 구분해서 표시합니다.
|
|
||||||
- 정답·오답 피드백은 `계산식 = 내가 입력한(고른) 답` 형식으로 표시합니다.
|
|
||||||
- 정답은 정답 색상 박스와 `○` 표시, 오답은 오답 색상 박스, 붉은색 답, `×`, 굵은 정답값을 표시합니다.
|
|
||||||
- 결과 화면은 전체 문제를 `문제 = 내가 입력한(고른) 답` 형식으로 나열하며, 피드백과 같은 색상·기호 규칙을 사용합니다.
|
|
||||||
- 피드백 화면 진입 시 다음 진행 버튼에, 결과 화면 진입 시 다시 시작 버튼에 자동으로 focus가 이동합니다.
|
|
||||||
|
|
||||||
### 숫자 괄호 풀기 (`/number-parentheses` → `/number-parentheses/play`)
|
|
||||||
|
|
||||||
- 준비 화면에서 문제 수 5 / 10과 괄호 안 최대 항 수 2 / 3 / 4를 선택하고 연습을 시작합니다. 기본값은 문제 수 10, 최대 항 수 2입니다.
|
|
||||||
- 괄호 종류는 현재 `()`만 선택 가능하고 `{}`, `[]`는 비활성화되어 있습니다.
|
|
||||||
- 풀이 화면에서 각 항을 직접 숫자로 입력합니다.
|
|
||||||
- 모든 항은 하나의 input에서 순서대로 입력하며, Enter로 다음 항으로 이동하고 마지막 항에서 Enter를 누르면 제출합니다.
|
|
||||||
- input 위 우상단의 progress 표시에서 각 항의 진행 상태를 보여줍니다. 입력 전에는 괄호 안 원래 항 값, 입력 중/입력 후에는 사용자가 입력한 값을 표시합니다.
|
|
||||||
- 터치 환경에서는 커스텀 숫자 키패드(숫자, `+/-`, `.`, `⌫`, `↵`)가 표시되며, 정밀 포인터가 있는 환경(마우스/트랙패드)에서는 숨깁니다.
|
|
||||||
|
|
||||||
### 미지수 괄호 풀기 (`/variable-parentheses` → `/variable-parentheses/play`)
|
|
||||||
|
|
||||||
- 준비 화면 구성은 숫자 괄호와 동일합니다.
|
|
||||||
- 풀이 화면에서 각 항을 보기 중에서 선택합니다.
|
|
||||||
- 각 항의 보기는 정답 계수의 절대값을 기준으로 음수/양수 두 개를 제공합니다.
|
|
||||||
- 항 선택 후 자동으로 다음 항 보기로 focus가 이동합니다.
|
|
||||||
- 마지막 항을 선택하면 별도 `정답 확인` 버튼 없이 즉시 피드백 화면으로 전환됩니다.
|
|
||||||
- 입력 항 progress 표시는 선택 전에는 괄호 안 원래 항 값, 선택 후에는 고른 보기 값을 표시합니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 시작하기
|
|
||||||
|
|
||||||
### 사전 요구사항
|
|
||||||
|
|
||||||
- Node.js 20+
|
|
||||||
- pnpm
|
|
||||||
- Docker (MariaDB, Redis 실행용)
|
|
||||||
|
|
||||||
### 설치
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm install
|
|
||||||
```
|
|
||||||
|
|
||||||
### 환경변수 설정
|
|
||||||
|
|
||||||
`.env.example`을 참고하여 `.env.local` 파일을 생성합니다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env.local
|
|
||||||
```
|
|
||||||
|
|
||||||
```env
|
|
||||||
DATABASE_URL=mysql://user:pass@mariadb:3306/mathgame
|
|
||||||
REDIS_URL=redis://redis:6379
|
|
||||||
NEXTAUTH_SECRET=your-secret
|
|
||||||
```
|
|
||||||
|
|
||||||
### 개발 서버 실행
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm dev
|
|
||||||
```
|
|
||||||
|
|
||||||
`http://localhost:3000`에서 확인합니다.
|
|
||||||
|
|
||||||
### 빌드
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm build
|
|
||||||
pnpm start
|
|
||||||
```
|
|
||||||
|
|
||||||
`pnpm build`는 Next.js standalone 산출물에 필요한 정적 파일 복사까지 함께 처리합니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 프로젝트 구조
|
|
||||||
|
|
||||||
```
|
|
||||||
app/ # Next.js App Router 루트
|
|
||||||
(game)/ # 게임 화면 라우트 그룹
|
|
||||||
number-parentheses/ # 숫자 괄호 준비 화면
|
|
||||||
play/ # 숫자 괄호 풀이 화면
|
|
||||||
variable-parentheses/ # 미지수 괄호 준비 화면
|
|
||||||
play/ # 미지수 괄호 풀이 화면
|
|
||||||
api/
|
|
||||||
auth/ # NextAuth.js 핸들러
|
|
||||||
problem/ # 문제 생성 API
|
|
||||||
record/ # 풀이 기록 저장 API
|
|
||||||
layout.tsx
|
|
||||||
page.tsx # 시작 화면 (학습 내용 선택)
|
|
||||||
components/
|
|
||||||
math/ # KaTeX 수식 컴포넌트
|
|
||||||
game/ # 게임 UI 컴포넌트
|
|
||||||
lib/
|
|
||||||
engine/ # 문제 생성 알고리즘 엔진
|
|
||||||
parenthesis-sign.ts # 숫자 괄호 엔진
|
|
||||||
parenthesis-latex.ts # 괄호 식 KaTeX 포맷 및 현재 항 강조 유틸
|
|
||||||
variable-parenthesis.ts # 미지수 괄호 엔진
|
|
||||||
types.ts # 숫자 괄호 타입
|
|
||||||
variable-types.ts # 미지수 괄호 타입
|
|
||||||
input-device.ts # 입력 장치 감지 미디어 쿼리
|
|
||||||
prisma.ts # Prisma Client 싱글턴
|
|
||||||
redis.ts # Redis Client 싱글턴
|
|
||||||
store/
|
|
||||||
gameStore.ts # 숫자 괄호 Zustand 전역 상태
|
|
||||||
variableGameStore.ts # 미지수 괄호 Zustand 전역 상태
|
|
||||||
prisma/
|
|
||||||
schema.prisma # MariaDB 모델 정의
|
|
||||||
.gitea/workflows/deploy.yml # CI/CD 워크플로우
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 다음 확장 방향
|
|
||||||
|
|
||||||
- 분수 괄호, 실수 괄호는 각 문제 생성 엔진이 준비되면 시작 화면에서 활성화합니다.
|
|
||||||
- `{}`, `[]` 괄호 종류는 생성 엔진과 상태/URL 파라미터 연동이 준비된 뒤 준비 화면에서 활성화합니다.
|
|
||||||
- 스피드 퀴즈 모드, 풀이 기록 저장, 로그인 기능은 DB/Redis/NextAuth 연동 후 추가합니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 배포
|
|
||||||
|
|
||||||
- **플랫폼**: Synology NAS + Docker Compose + Nginx Proxy Manager
|
|
||||||
- **CI/CD**: `.gitea/workflows/deploy.yml` — `main` 브랜치 push 시 자동 빌드·배포
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import ParenthesesPreparationClient from "@/components/game/ParenthesesPreparationClient";
|
|
||||||
import { useGameStore } from "@/store/gameStore";
|
|
||||||
|
|
||||||
export default function NumberParenthesesClient() {
|
|
||||||
const selectedProblemCount = useGameStore(
|
|
||||||
(state) => state.selectedProblemCount,
|
|
||||||
);
|
|
||||||
const setProblemCount = useGameStore((state) => state.setProblemCount);
|
|
||||||
const selectedMaxTermCount = useGameStore(
|
|
||||||
(state) => state.selectedMaxTermCount,
|
|
||||||
);
|
|
||||||
const setMaxTermCount = useGameStore((state) => state.setMaxTermCount);
|
|
||||||
const initSession = useGameStore((state) => state.initSession);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ParenthesesPreparationClient
|
|
||||||
eyebrow="숫자 괄호 학습 준비"
|
|
||||||
title="숫자 괄호 풀기"
|
|
||||||
description="정수로 이루어진 괄호식을 풀며 음수 분배와 부호 변화를 연습합니다."
|
|
||||||
notice="현재는 소괄호 숫자 문제만 선택할 수 있습니다."
|
|
||||||
playHref={`/number-parentheses/play?count=${selectedProblemCount}&terms=${selectedMaxTermCount}`}
|
|
||||||
selectedProblemCount={selectedProblemCount}
|
|
||||||
selectedMaxTermCount={selectedMaxTermCount}
|
|
||||||
onProblemCountChange={setProblemCount}
|
|
||||||
onMaxTermCountChange={setMaxTermCount}
|
|
||||||
onStart={() => initSession(selectedProblemCount, selectedMaxTermCount)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import NumberParenthesesClient from "./NumberParenthesesClient";
|
|
||||||
|
|
||||||
export default function NumberParenthesesPage() {
|
|
||||||
return <NumberParenthesesClient />;
|
|
||||||
}
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import type { FormEvent } from "react";
|
|
||||||
import { flushSync } from "react-dom";
|
|
||||||
|
|
||||||
import NumberAnswerForm from "@/components/game/NumberAnswerForm";
|
|
||||||
import type {
|
|
||||||
ActiveAnswerInput,
|
|
||||||
NumberAnswerFormHandle,
|
|
||||||
} from "@/components/game/NumberAnswerForm";
|
|
||||||
import FeedbackPanel from "@/components/game/FeedbackPanel";
|
|
||||||
import ScoreSummary from "@/components/game/ScoreSummary";
|
|
||||||
import StageIndicator from "@/components/game/StageIndicator";
|
|
||||||
import { DESKTOP_INPUT_DEVICE_QUERY } from "@/lib/input-device";
|
|
||||||
import KatexRenderer from "@/components/math/KatexRenderer";
|
|
||||||
import { useGameStore } from "@/store/gameStore";
|
|
||||||
import type { MaxTermCount, ProblemCount } from "@/store/gameStore";
|
|
||||||
import { formatParenthesisExpressionLatex } from "@/lib/engine/parenthesis-latex";
|
|
||||||
|
|
||||||
const REAL_NUMBER_PATTERN = /^-?\d+(?:\.\d+)?$/;
|
|
||||||
|
|
||||||
function parseAnswer(value: string) {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
|
|
||||||
if (!REAL_NUMBER_PATTERN.test(trimmed)) return null;
|
|
||||||
|
|
||||||
return Number(trimmed);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NumberPlayClientProps {
|
|
||||||
problemCount: ProblemCount;
|
|
||||||
maxTermCount: MaxTermCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function NumberPlayClient({
|
|
||||||
problemCount,
|
|
||||||
maxTermCount,
|
|
||||||
}: NumberPlayClientProps) {
|
|
||||||
const session = useGameStore((state) => state.session);
|
|
||||||
const selectedProblemCount = useGameStore(
|
|
||||||
(state) => state.selectedProblemCount,
|
|
||||||
);
|
|
||||||
const setProblemCount = useGameStore((state) => state.setProblemCount);
|
|
||||||
const selectedMaxTermCount = useGameStore(
|
|
||||||
(state) => state.selectedMaxTermCount,
|
|
||||||
);
|
|
||||||
const setMaxTermCount = useGameStore((state) => state.setMaxTermCount);
|
|
||||||
const initSession = useGameStore((state) => state.initSession);
|
|
||||||
const submitAnswer = useGameStore((state) => state.submitAnswer);
|
|
||||||
const nextProblem = useGameStore((state) => state.nextProblem);
|
|
||||||
const resetSession = useGameStore((state) => state.resetSession);
|
|
||||||
|
|
||||||
const problemCardRef = useRef<HTMLDivElement>(null);
|
|
||||||
const answerFormRef = useRef<NumberAnswerFormHandle>(null);
|
|
||||||
|
|
||||||
const [answers, setAnswers] = useState<string[]>(() =>
|
|
||||||
Array.from({ length: maxTermCount }, () => ""),
|
|
||||||
);
|
|
||||||
const [activeInput, setActiveInput] = useState<ActiveAnswerInput>(0);
|
|
||||||
const [inputError, setInputError] = useState("");
|
|
||||||
|
|
||||||
const sessionProblemCount = session?.problems.length ?? null;
|
|
||||||
const currentProblem = session?.problems[session.currentIndex] ?? null;
|
|
||||||
const currentResult = session?.results[session.currentIndex] ?? null;
|
|
||||||
const isComplete = Boolean(
|
|
||||||
session && session.currentIndex >= session.problems.length,
|
|
||||||
);
|
|
||||||
const sessionNeedsInit =
|
|
||||||
sessionProblemCount !== problemCount || session?.termCount !== maxTermCount;
|
|
||||||
const correctCount =
|
|
||||||
session?.results.filter((result) => result?.correct).length ?? 0;
|
|
||||||
const totalCount = session?.problems.length ?? 0;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedProblemCount !== problemCount) {
|
|
||||||
setProblemCount(problemCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedMaxTermCount !== maxTermCount) {
|
|
||||||
setMaxTermCount(maxTermCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sessionNeedsInit) {
|
|
||||||
initSession(problemCount, maxTermCount);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
initSession,
|
|
||||||
maxTermCount,
|
|
||||||
problemCount,
|
|
||||||
selectedMaxTermCount,
|
|
||||||
selectedProblemCount,
|
|
||||||
sessionProblemCount,
|
|
||||||
sessionNeedsInit,
|
|
||||||
setMaxTermCount,
|
|
||||||
setProblemCount,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setAnswers(Array.from({ length: currentProblem?.terms.length ?? maxTermCount }, () => ""));
|
|
||||||
setActiveInput(0);
|
|
||||||
setInputError("");
|
|
||||||
}, [currentProblem?.key, currentProblem?.terms.length, maxTermCount]);
|
|
||||||
|
|
||||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
if (currentResult || !currentProblem) return;
|
|
||||||
|
|
||||||
const parsedAnswers = answers.map(parseAnswer);
|
|
||||||
|
|
||||||
if (parsedAnswers.some((answer) => answer === null)) {
|
|
||||||
setInputError("모든 항을 실수로 입력하세요.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setInputError("");
|
|
||||||
submitAnswer({ terms: parsedAnswers as number[] });
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAnswerChange(index: ActiveAnswerInput, value: string) {
|
|
||||||
setAnswers((currentAnswers) =>
|
|
||||||
currentAnswers.map((answer, answerIndex) =>
|
|
||||||
answerIndex === index ? value : answer,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleNextProblem() {
|
|
||||||
flushSync(() => {
|
|
||||||
nextProblem();
|
|
||||||
});
|
|
||||||
answerFormRef.current?.focusInput();
|
|
||||||
}
|
|
||||||
|
|
||||||
const scrollKatexToViewportTop = useCallback(() => {
|
|
||||||
if (window.matchMedia(DESKTOP_INPUT_DEVICE_QUERY).matches) return;
|
|
||||||
|
|
||||||
function alignKatex() {
|
|
||||||
const katexElement =
|
|
||||||
problemCardRef.current?.querySelector<HTMLElement>("span.katex");
|
|
||||||
|
|
||||||
if (!katexElement) return;
|
|
||||||
|
|
||||||
const rect = katexElement.getBoundingClientRect();
|
|
||||||
const scrollElement =
|
|
||||||
document.scrollingElement ?? document.documentElement;
|
|
||||||
const elementDocumentTop = window.scrollY + rect.top;
|
|
||||||
const maxScrollTop = Math.max(
|
|
||||||
0,
|
|
||||||
scrollElement.scrollHeight - window.innerHeight,
|
|
||||||
);
|
|
||||||
const scrollTop = Math.min(
|
|
||||||
maxScrollTop,
|
|
||||||
Math.max(0, elementDocumentTop),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (Math.abs(window.scrollY - scrollTop) < 4) return;
|
|
||||||
|
|
||||||
scrollElement.scrollTop = scrollTop;
|
|
||||||
document.body.scrollTop = scrollTop;
|
|
||||||
window.scrollTo(0, scrollTop);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.requestAnimationFrame(() => {
|
|
||||||
window.requestAnimationFrame(alignKatex);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!session || sessionNeedsInit) {
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-screen items-center bg-slate-50 px-6 py-12 text-slate-950">
|
|
||||||
<section className="mx-auto w-full max-w-3xl">
|
|
||||||
<p className="text-lg font-semibold text-slate-700">
|
|
||||||
문제를 준비하고 있습니다.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isComplete) {
|
|
||||||
return (
|
|
||||||
<ScoreSummary
|
|
||||||
correctCount={correctCount}
|
|
||||||
totalCount={totalCount}
|
|
||||||
problems={session.problems}
|
|
||||||
results={session.results}
|
|
||||||
onRestart={resetSession}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentProblem) {
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-screen items-center bg-slate-50 px-6 py-12 text-slate-950">
|
|
||||||
<section className="mx-auto w-full max-w-3xl">
|
|
||||||
<p className="text-lg font-semibold text-slate-700">
|
|
||||||
현재 문제를 불러올 수 없습니다.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitted = currentResult !== null;
|
|
||||||
const isLastProblem = session.currentIndex === session.problems.length - 1;
|
|
||||||
const problemLatex = submitted
|
|
||||||
? currentProblem.latexBefore
|
|
||||||
: formatParenthesisExpressionLatex({
|
|
||||||
multiplier: currentProblem.multiplier,
|
|
||||||
terms: currentProblem.terms,
|
|
||||||
operators: currentProblem.operators,
|
|
||||||
formatTermLatex: (term) => `${term}`,
|
|
||||||
activeTermIndex: activeInput,
|
|
||||||
});
|
|
||||||
const pendingAnswerLabels = currentProblem.terms.map((term, index) => {
|
|
||||||
if (index === 0) return String(term);
|
|
||||||
|
|
||||||
return String(
|
|
||||||
currentProblem.operators[index - 1] === "-" ? -term : term,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-slate-50 px-6 py-10 text-slate-950">
|
|
||||||
<section className="mx-auto flex w-full max-w-3xl flex-col gap-8">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm font-semibold text-emerald-700">
|
|
||||||
괄호 음수 분배
|
|
||||||
</p>
|
|
||||||
<h1 className="text-3xl font-bold tracking-normal sm:text-4xl">
|
|
||||||
문제 풀이
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href="/number-parentheses"
|
|
||||||
onClick={resetSession}
|
|
||||||
className="inline-flex h-10 items-center justify-center rounded-md bg-red-700 px-4 text-sm font-semibold text-white transition hover:bg-red-800 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
|
|
||||||
>
|
|
||||||
포기
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
ref={problemCardRef}
|
|
||||||
className="rounded-md bg-white px-6 pt-6 pb-4 shadow-sm ring-1 ring-slate-200 sm:p-8"
|
|
||||||
>
|
|
||||||
<div className="text-3xl font-semibold text-slate-950 sm:text-4xl">
|
|
||||||
<KatexRenderer latex={problemLatex} displayMode />
|
|
||||||
</div>
|
|
||||||
<div className="mt-6">
|
|
||||||
<StageIndicator
|
|
||||||
currentIndex={session.currentIndex}
|
|
||||||
results={session.results}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!submitted ? (
|
|
||||||
<NumberAnswerForm
|
|
||||||
ref={answerFormRef}
|
|
||||||
answers={answers}
|
|
||||||
activeInput={activeInput}
|
|
||||||
error={inputError}
|
|
||||||
pendingAnswerLabels={pendingAnswerLabels}
|
|
||||||
onActiveInputChange={setActiveInput}
|
|
||||||
onAnswerChange={handleAnswerChange}
|
|
||||||
onInputFocus={scrollKatexToViewportTop}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{currentResult ? (
|
|
||||||
<FeedbackPanel
|
|
||||||
result={currentResult}
|
|
||||||
problem={currentProblem}
|
|
||||||
isLastProblem={isLastProblem}
|
|
||||||
onNext={handleNextProblem}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import NumberPlayClient from "./NumberPlayClient";
|
|
||||||
import type { MaxTermCount, ProblemCount } from "@/store/gameStore";
|
|
||||||
|
|
||||||
interface NumberPlayPageProps {
|
|
||||||
searchParams?: {
|
|
||||||
count?: string | string[];
|
|
||||||
terms?: string | string[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseProblemCount(count?: string | string[]): ProblemCount {
|
|
||||||
if (Array.isArray(count)) {
|
|
||||||
return parseProblemCount(count[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return count === "5" ? 5 : 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseMaxTermCount(terms?: string | string[]): MaxTermCount {
|
|
||||||
if (Array.isArray(terms)) {
|
|
||||||
return parseMaxTermCount(terms[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (terms === "3") return 3;
|
|
||||||
if (terms === "4") return 4;
|
|
||||||
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function NumberPlayPage({ searchParams }: NumberPlayPageProps) {
|
|
||||||
return (
|
|
||||||
<NumberPlayClient
|
|
||||||
problemCount={parseProblemCount(searchParams?.count)}
|
|
||||||
maxTermCount={parseMaxTermCount(searchParams?.terms)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import ParenthesesPreparationClient from "@/components/game/ParenthesesPreparationClient";
|
|
||||||
import { useVariableGameStore } from "@/store/variableGameStore";
|
|
||||||
|
|
||||||
export default function VariableParenthesesClient() {
|
|
||||||
const selectedProblemCount = useVariableGameStore(
|
|
||||||
(state) => state.selectedProblemCount,
|
|
||||||
);
|
|
||||||
const setProblemCount = useVariableGameStore(
|
|
||||||
(state) => state.setProblemCount,
|
|
||||||
);
|
|
||||||
const selectedMaxTermCount = useVariableGameStore(
|
|
||||||
(state) => state.selectedMaxTermCount,
|
|
||||||
);
|
|
||||||
const setMaxTermCount = useVariableGameStore(
|
|
||||||
(state) => state.setMaxTermCount,
|
|
||||||
);
|
|
||||||
const initSession = useVariableGameStore((state) => state.initSession);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ParenthesesPreparationClient
|
|
||||||
eyebrow="미지수 괄호 학습 준비"
|
|
||||||
title="미지수 괄호 풀기"
|
|
||||||
description="괄호 안의 한 항에 미지수 x가 들어간 식을 풀며 부호 변화를 연습합니다."
|
|
||||||
notice="현재는 소괄호와 미지수 x 문제만 선택할 수 있습니다."
|
|
||||||
playHref={`/variable-parentheses/play?count=${selectedProblemCount}&terms=${selectedMaxTermCount}`}
|
|
||||||
selectedProblemCount={selectedProblemCount}
|
|
||||||
selectedMaxTermCount={selectedMaxTermCount}
|
|
||||||
onProblemCountChange={setProblemCount}
|
|
||||||
onMaxTermCountChange={setMaxTermCount}
|
|
||||||
onStart={() => initSession(selectedProblemCount, selectedMaxTermCount)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import VariableParenthesesClient from "./VariableParenthesesClient";
|
|
||||||
|
|
||||||
export default function VariableParenthesesPage() {
|
|
||||||
return <VariableParenthesesClient />;
|
|
||||||
}
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { flushSync } from "react-dom";
|
|
||||||
|
|
||||||
import StageIndicator from "@/components/game/StageIndicator";
|
|
||||||
import VariableChoiceAnswerForm from "@/components/game/VariableChoiceAnswerForm";
|
|
||||||
import VariableFeedbackPanel from "@/components/game/VariableFeedbackPanel";
|
|
||||||
import VariableScoreSummary from "@/components/game/VariableScoreSummary";
|
|
||||||
import KatexRenderer from "@/components/math/KatexRenderer";
|
|
||||||
import { formatVariableTermLatex } from "@/lib/engine/variable-parenthesis";
|
|
||||||
import { formatParenthesisExpressionLatex } from "@/lib/engine/parenthesis-latex";
|
|
||||||
import {
|
|
||||||
useVariableGameStore,
|
|
||||||
type VariableMaxTermCount,
|
|
||||||
type VariableProblemCount,
|
|
||||||
} from "@/store/variableGameStore";
|
|
||||||
|
|
||||||
interface VariablePlayClientProps {
|
|
||||||
problemCount: VariableProblemCount;
|
|
||||||
maxTermCount: VariableMaxTermCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function VariablePlayClient({
|
|
||||||
problemCount,
|
|
||||||
maxTermCount,
|
|
||||||
}: VariablePlayClientProps) {
|
|
||||||
const session = useVariableGameStore((state) => state.session);
|
|
||||||
const selectedProblemCount = useVariableGameStore(
|
|
||||||
(state) => state.selectedProblemCount,
|
|
||||||
);
|
|
||||||
const setProblemCount = useVariableGameStore(
|
|
||||||
(state) => state.setProblemCount,
|
|
||||||
);
|
|
||||||
const selectedMaxTermCount = useVariableGameStore(
|
|
||||||
(state) => state.selectedMaxTermCount,
|
|
||||||
);
|
|
||||||
const setMaxTermCount = useVariableGameStore(
|
|
||||||
(state) => state.setMaxTermCount,
|
|
||||||
);
|
|
||||||
const initSession = useVariableGameStore((state) => state.initSession);
|
|
||||||
const submitAnswer = useVariableGameStore((state) => state.submitAnswer);
|
|
||||||
const nextProblem = useVariableGameStore((state) => state.nextProblem);
|
|
||||||
const resetSession = useVariableGameStore((state) => state.resetSession);
|
|
||||||
|
|
||||||
const [selectedChoiceIds, setSelectedChoiceIds] = useState<(string | null)[]>(
|
|
||||||
() => Array.from({ length: maxTermCount }, () => null),
|
|
||||||
);
|
|
||||||
const [activeTermIndex, setActiveTermIndex] = useState(0);
|
|
||||||
|
|
||||||
const sessionProblemCount = session?.problems.length ?? null;
|
|
||||||
const currentProblem = session?.problems[session.currentIndex] ?? null;
|
|
||||||
const currentResult = session?.results[session.currentIndex] ?? null;
|
|
||||||
const isComplete = Boolean(
|
|
||||||
session && session.currentIndex >= session.problems.length,
|
|
||||||
);
|
|
||||||
const sessionNeedsInit =
|
|
||||||
sessionProblemCount !== problemCount || session?.termCount !== maxTermCount;
|
|
||||||
const correctCount =
|
|
||||||
session?.results.filter((result) => result?.correct).length ?? 0;
|
|
||||||
const totalCount = session?.problems.length ?? 0;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedProblemCount !== problemCount) {
|
|
||||||
setProblemCount(problemCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedMaxTermCount !== maxTermCount) {
|
|
||||||
setMaxTermCount(maxTermCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sessionNeedsInit) {
|
|
||||||
initSession(problemCount, maxTermCount);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
initSession,
|
|
||||||
maxTermCount,
|
|
||||||
problemCount,
|
|
||||||
selectedMaxTermCount,
|
|
||||||
selectedProblemCount,
|
|
||||||
sessionProblemCount,
|
|
||||||
sessionNeedsInit,
|
|
||||||
setMaxTermCount,
|
|
||||||
setProblemCount,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSelectedChoiceIds(
|
|
||||||
Array.from({ length: currentProblem?.terms.length ?? maxTermCount }, () => null),
|
|
||||||
);
|
|
||||||
setActiveTermIndex(0);
|
|
||||||
}, [currentProblem?.key, currentProblem?.terms.length, maxTermCount]);
|
|
||||||
|
|
||||||
function handleSelectChoice(termIndex: number, choiceId: string) {
|
|
||||||
const nextSelectedChoiceIds = [...selectedChoiceIds];
|
|
||||||
nextSelectedChoiceIds[termIndex] = choiceId;
|
|
||||||
|
|
||||||
setSelectedChoiceIds(nextSelectedChoiceIds);
|
|
||||||
|
|
||||||
if (termIndex < nextSelectedChoiceIds.length - 1) {
|
|
||||||
setActiveTermIndex(termIndex + 1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentResult || !currentProblem) return;
|
|
||||||
if (
|
|
||||||
nextSelectedChoiceIds.some((selectedChoiceId) => selectedChoiceId === null)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
submitAnswer({ selectedChoiceIds: nextSelectedChoiceIds });
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleNextProblem() {
|
|
||||||
flushSync(() => {
|
|
||||||
nextProblem();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session || sessionNeedsInit) {
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-screen items-center bg-slate-50 px-6 py-12 text-slate-950">
|
|
||||||
<section className="mx-auto w-full max-w-3xl">
|
|
||||||
<p className="text-lg font-semibold text-slate-700">
|
|
||||||
문제를 준비하고 있습니다.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isComplete) {
|
|
||||||
return (
|
|
||||||
<VariableScoreSummary
|
|
||||||
correctCount={correctCount}
|
|
||||||
totalCount={totalCount}
|
|
||||||
problems={session.problems}
|
|
||||||
results={session.results}
|
|
||||||
onRestart={resetSession}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentProblem) {
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-screen items-center bg-slate-50 px-6 py-12 text-slate-950">
|
|
||||||
<section className="mx-auto w-full max-w-3xl">
|
|
||||||
<p className="text-lg font-semibold text-slate-700">
|
|
||||||
현재 문제를 불러올 수 없습니다.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitted = currentResult !== null;
|
|
||||||
const isLastProblem = session.currentIndex === session.problems.length - 1;
|
|
||||||
const problemLatex = submitted
|
|
||||||
? currentProblem.latexBefore
|
|
||||||
: formatParenthesisExpressionLatex({
|
|
||||||
multiplier: currentProblem.multiplier,
|
|
||||||
terms: currentProblem.terms,
|
|
||||||
operators: currentProblem.operators,
|
|
||||||
formatTermLatex: formatVariableTermLatex,
|
|
||||||
activeTermIndex,
|
|
||||||
});
|
|
||||||
const pendingChoiceLabels = currentProblem.terms.map((term, index) =>
|
|
||||||
formatVariableTermLatex({
|
|
||||||
...term,
|
|
||||||
coefficient:
|
|
||||||
index > 0 && currentProblem.operators[index - 1] === "-"
|
|
||||||
? -term.coefficient
|
|
||||||
: term.coefficient,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-slate-50 px-6 py-10 text-slate-950">
|
|
||||||
<section className="mx-auto flex w-full max-w-3xl flex-col gap-8">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm font-semibold text-emerald-700">
|
|
||||||
미지수 괄호 풀기
|
|
||||||
</p>
|
|
||||||
<h1 className="text-3xl font-bold tracking-normal sm:text-4xl">
|
|
||||||
문제 풀이
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href="/variable-parentheses"
|
|
||||||
onClick={resetSession}
|
|
||||||
className="inline-flex h-10 items-center justify-center rounded-md bg-red-700 px-4 text-sm font-semibold text-white transition hover:bg-red-800 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
|
|
||||||
>
|
|
||||||
포기
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-md bg-white px-6 pt-6 pb-4 shadow-sm ring-1 ring-slate-200 sm:p-8">
|
|
||||||
<div className="text-3xl font-semibold text-slate-950 sm:text-4xl">
|
|
||||||
<KatexRenderer latex={problemLatex} displayMode />
|
|
||||||
</div>
|
|
||||||
<div className="mt-6">
|
|
||||||
<StageIndicator
|
|
||||||
currentIndex={session.currentIndex}
|
|
||||||
results={session.results}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!submitted ? (
|
|
||||||
<VariableChoiceAnswerForm
|
|
||||||
choices={currentProblem.choices}
|
|
||||||
selectedChoiceIds={selectedChoiceIds}
|
|
||||||
activeTermIndex={activeTermIndex}
|
|
||||||
pendingChoiceLabels={pendingChoiceLabels}
|
|
||||||
onSelectChoice={handleSelectChoice}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{currentResult ? (
|
|
||||||
<VariableFeedbackPanel
|
|
||||||
result={currentResult}
|
|
||||||
problem={currentProblem}
|
|
||||||
isLastProblem={isLastProblem}
|
|
||||||
onNext={handleNextProblem}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import VariablePlayClient from "./VariablePlayClient";
|
|
||||||
import type {
|
|
||||||
VariableMaxTermCount,
|
|
||||||
VariableProblemCount,
|
|
||||||
} from "@/store/variableGameStore";
|
|
||||||
|
|
||||||
interface VariablePlayPageProps {
|
|
||||||
searchParams?: {
|
|
||||||
count?: string | string[];
|
|
||||||
terms?: string | string[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseProblemCount(count?: string | string[]): VariableProblemCount {
|
|
||||||
if (Array.isArray(count)) {
|
|
||||||
return parseProblemCount(count[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return count === "5" ? 5 : 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseMaxTermCount(terms?: string | string[]): VariableMaxTermCount {
|
|
||||||
if (Array.isArray(terms)) {
|
|
||||||
return parseMaxTermCount(terms[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (terms === "3") return 3;
|
|
||||||
if (terms === "4") return 4;
|
|
||||||
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function VariablePlayPage({
|
|
||||||
searchParams,
|
|
||||||
}: VariablePlayPageProps) {
|
|
||||||
return (
|
|
||||||
<VariablePlayClient
|
|
||||||
problemCount={parseProblemCount(searchParams?.count)}
|
|
||||||
maxTermCount={parseMaxTermCount(searchParams?.terms)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
Binary file not shown.
Binary file not shown.
@@ -1,27 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--background: #ffffff;
|
|
||||||
--foreground: #171717;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--background: #0a0a0a;
|
|
||||||
--foreground: #ededed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
color: var(--foreground);
|
|
||||||
background: var(--background);
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer utilities {
|
|
||||||
.text-balance {
|
|
||||||
text-wrap: balance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import localFont from "next/font/local";
|
|
||||||
import "katex/dist/katex.min.css";
|
|
||||||
import "./globals.css";
|
|
||||||
|
|
||||||
const geistSans = localFont({
|
|
||||||
src: "./fonts/GeistVF.woff",
|
|
||||||
variable: "--font-geist-sans",
|
|
||||||
weight: "100 900",
|
|
||||||
});
|
|
||||||
const geistMono = localFont({
|
|
||||||
src: "./fonts/GeistMonoVF.woff",
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
weight: "100 900",
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "괄호 음수 분배 연습",
|
|
||||||
description: "중학교 2학년 수학 실수 유형 반복 연습",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RootLayout({
|
|
||||||
children,
|
|
||||||
}: Readonly<{
|
|
||||||
children: React.ReactNode;
|
|
||||||
}>) {
|
|
||||||
return (
|
|
||||||
<html lang="ko">
|
|
||||||
<body
|
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
type Subject =
|
|
||||||
| {
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
href: string;
|
|
||||||
disabled: false;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
disabled: true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const subjects: Subject[] = [
|
|
||||||
{
|
|
||||||
label: "숫자 괄호 풀기",
|
|
||||||
description: "정수로 이루어진 괄호식을 풀며 부호 변화를 연습합니다.",
|
|
||||||
href: "/number-parentheses",
|
|
||||||
disabled: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "분수 괄호 풀기",
|
|
||||||
description: "분수가 들어간 괄호식은 이후 단계에서 추가됩니다.",
|
|
||||||
disabled: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "실수 괄호 풀기",
|
|
||||||
description: "실수가 들어간 괄호식은 이후 단계에서 추가됩니다.",
|
|
||||||
disabled: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "미지수 괄호 풀기",
|
|
||||||
description: "괄호 안에 미지수 x가 들어간 식의 부호 변화를 연습합니다.",
|
|
||||||
href: "/variable-parentheses",
|
|
||||||
disabled: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-screen items-center bg-slate-50 px-6 py-12 text-slate-950">
|
|
||||||
<section className="mx-auto flex w-full max-w-3xl flex-col gap-8">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-sm font-semibold text-emerald-700">
|
|
||||||
중학교 2학년 수학 - 학습 내용 선택
|
|
||||||
</p>
|
|
||||||
<h1 className="text-4xl font-bold tracking-normal sm:text-5xl">
|
|
||||||
오늘 연습할 괄호 유형을 고르세요
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-2xl text-lg leading-8 text-slate-700">
|
|
||||||
괄호 앞의 음수를 분배할 때 부호가 어떻게 바뀌는지 학습
|
|
||||||
내용별로 차근차근 연습합니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
{subjects.map((subject) => {
|
|
||||||
if (!subject.disabled) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={subject.label}
|
|
||||||
href={subject.href}
|
|
||||||
className="flex min-h-32 flex-col justify-between rounded-md bg-white p-5 text-left shadow-sm ring-1 ring-slate-200 transition hover:-translate-y-0.5 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
|
|
||||||
>
|
|
||||||
<span className="text-xl font-bold text-slate-950">
|
|
||||||
{subject.label}
|
|
||||||
</span>
|
|
||||||
<span className="mt-4 text-sm leading-6 text-slate-600">
|
|
||||||
{subject.description}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={subject.label}
|
|
||||||
type="button"
|
|
||||||
disabled
|
|
||||||
className="flex min-h-32 cursor-not-allowed flex-col justify-between rounded-md bg-slate-100 p-5 text-left text-slate-400 ring-1 ring-slate-200"
|
|
||||||
>
|
|
||||||
<span className="text-xl font-bold">{subject.label}</span>
|
|
||||||
<span className="mt-4 text-sm leading-6">
|
|
||||||
{subject.description}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-l-4 border-emerald-700 pl-4 text-sm leading-6 text-slate-600">
|
|
||||||
<p>지금은 숫자 괄호와 미지수 괄호 학습을 사용할 수 있습니다.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
import KatexRenderer from "@/components/math/KatexRenderer";
|
|
||||||
|
|
||||||
interface AnswerResultLineProps {
|
|
||||||
expressionLatex: string;
|
|
||||||
userAnswerLatex: string;
|
|
||||||
correctAnswerLatex: string;
|
|
||||||
correct: boolean;
|
|
||||||
prefix?: ReactNode;
|
|
||||||
userAnswerContent?: ReactNode;
|
|
||||||
correctAnswerContent?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AnswerResultLine({
|
|
||||||
expressionLatex,
|
|
||||||
userAnswerLatex,
|
|
||||||
correctAnswerLatex,
|
|
||||||
correct,
|
|
||||||
prefix,
|
|
||||||
userAnswerContent,
|
|
||||||
correctAnswerContent,
|
|
||||||
}: AnswerResultLineProps) {
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
className={`flex flex-wrap items-center gap-x-2 gap-y-1 rounded-md px-4 py-3 text-lg font-semibold ring-1 ring-inset ${
|
|
||||||
correct
|
|
||||||
? "bg-emerald-100 text-emerald-950 ring-emerald-200"
|
|
||||||
: "bg-red-100 text-red-950 ring-red-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{prefix}
|
|
||||||
<KatexRenderer latex={expressionLatex} output="mathml" />
|
|
||||||
<span aria-hidden="true">=</span>
|
|
||||||
{userAnswerContent ?? (
|
|
||||||
<span className={correct ? undefined : "text-red-700"}>
|
|
||||||
<KatexRenderer latex={userAnswerLatex} output="mathml" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{correct ? (
|
|
||||||
<span
|
|
||||||
aria-label="정답"
|
|
||||||
role="img"
|
|
||||||
className="inline-flex h-7 items-center text-2xl leading-none text-emerald-700"
|
|
||||||
>
|
|
||||||
○
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
aria-label="오답"
|
|
||||||
role="img"
|
|
||||||
className="mr-2 inline-flex h-7 items-center text-2xl leading-none text-red-700"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</span>
|
|
||||||
<span className="text-slate-700">
|
|
||||||
(
|
|
||||||
{correctAnswerContent ?? (
|
|
||||||
<span className="font-bold">
|
|
||||||
<KatexRenderer latex={correctAnswerLatex} output="mathml" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
)
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
import AnswerResultLine from "@/components/game/AnswerResultLine";
|
|
||||||
import type { GradeResult, ParenthesisSignProblem } from "@/lib/engine/types";
|
|
||||||
|
|
||||||
interface FeedbackPanelProps {
|
|
||||||
result: GradeResult;
|
|
||||||
problem: ParenthesisSignProblem;
|
|
||||||
isLastProblem: boolean;
|
|
||||||
onNext: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSignedNumber(value: number) {
|
|
||||||
return value < 0 ? `(${value})` : `${value}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FeedbackPanel({
|
|
||||||
result,
|
|
||||||
problem,
|
|
||||||
isLastProblem,
|
|
||||||
onNext,
|
|
||||||
}: FeedbackPanelProps) {
|
|
||||||
const nextButtonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
const signedTerms = problem.terms.map((term, index) => {
|
|
||||||
if (index === 0) return term;
|
|
||||||
|
|
||||||
return problem.operators[index - 1] === "-" ? -term : term;
|
|
||||||
});
|
|
||||||
const feedbackItems = signedTerms.map((term, index) => ({
|
|
||||||
formulaLatex: `${formatSignedNumber(
|
|
||||||
problem.multiplier,
|
|
||||||
)}\\times ${formatSignedNumber(term)}`,
|
|
||||||
userAnswer: result.userAnswer[index],
|
|
||||||
correctAnswer: result.correctAnswer[index],
|
|
||||||
correct: result.userAnswer[index] === result.correctAnswer[index],
|
|
||||||
}));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
nextButtonRef.current?.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`rounded-md p-6 shadow-sm ring-1 ${
|
|
||||||
result.correct
|
|
||||||
? "bg-emerald-50 text-emerald-950 ring-emerald-200"
|
|
||||||
: "bg-red-50 text-red-950 ring-red-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<p className="text-lg font-bold">
|
|
||||||
{result.correct ? "정답입니다." : "오답입니다."}
|
|
||||||
</p>
|
|
||||||
<ul className="mt-5 space-y-3">
|
|
||||||
{feedbackItems.map((item, index) => (
|
|
||||||
<AnswerResultLine
|
|
||||||
key={index}
|
|
||||||
expressionLatex={item.formulaLatex}
|
|
||||||
userAnswerLatex={`${item.userAnswer}`}
|
|
||||||
correctAnswerLatex={`${item.correctAnswer}`}
|
|
||||||
correct={item.correct}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<button
|
|
||||||
ref={nextButtonRef}
|
|
||||||
type="button"
|
|
||||||
onClick={onNext}
|
|
||||||
className="mt-5 inline-flex h-11 items-center justify-center rounded-md bg-slate-950 px-5 text-base font-semibold text-white transition hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2"
|
|
||||||
>
|
|
||||||
{isLastProblem ? "결과 보기" : "다음 문제"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,337 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
forwardRef,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useId,
|
|
||||||
useImperativeHandle,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import type { FocusEvent, FormEvent } from "react";
|
|
||||||
|
|
||||||
import { DESKTOP_INPUT_DEVICE_QUERY } from "@/lib/input-device";
|
|
||||||
import KatexRenderer from "@/components/math/KatexRenderer";
|
|
||||||
|
|
||||||
export type ActiveAnswerInput = number;
|
|
||||||
|
|
||||||
const ANSWER_INPUT_PATTERN = /^-?\d*(?:\.\d*)?$/;
|
|
||||||
const KEYPAD_KEYS = [
|
|
||||||
"1",
|
|
||||||
"2",
|
|
||||||
"3",
|
|
||||||
"4",
|
|
||||||
"5",
|
|
||||||
"6",
|
|
||||||
"7",
|
|
||||||
"8",
|
|
||||||
"9",
|
|
||||||
"+/-",
|
|
||||||
"0",
|
|
||||||
];
|
|
||||||
|
|
||||||
export interface NumberAnswerFormHandle {
|
|
||||||
focusInput: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NumberAnswerFormProps {
|
|
||||||
answers: string[];
|
|
||||||
activeInput: ActiveAnswerInput;
|
|
||||||
error: string;
|
|
||||||
pendingAnswerLabels: string[];
|
|
||||||
onAnswerChange: (index: ActiveAnswerInput, value: string) => void;
|
|
||||||
onActiveInputChange: (input: ActiveAnswerInput) => void;
|
|
||||||
onInputBlur?: () => void;
|
|
||||||
onInputFocus?: () => void;
|
|
||||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NumberAnswerForm = forwardRef<
|
|
||||||
NumberAnswerFormHandle,
|
|
||||||
NumberAnswerFormProps
|
|
||||||
>(function NumberAnswerForm(
|
|
||||||
{
|
|
||||||
answers,
|
|
||||||
activeInput,
|
|
||||||
error,
|
|
||||||
pendingAnswerLabels,
|
|
||||||
onAnswerChange,
|
|
||||||
onActiveInputChange,
|
|
||||||
onInputBlur,
|
|
||||||
onInputFocus,
|
|
||||||
onSubmit,
|
|
||||||
},
|
|
||||||
ref,
|
|
||||||
) {
|
|
||||||
const inputId = useId();
|
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const blurTimeoutRef = useRef<number | null>(null);
|
|
||||||
const [keypadOpen, setKeypadOpen] = useState(false);
|
|
||||||
const [desktopInputDevice, setDesktopInputDevice] = useState(false);
|
|
||||||
|
|
||||||
const activeAnswer = answers[activeInput] ?? "";
|
|
||||||
const activeLabel = `${activeInput + 1}항`;
|
|
||||||
const activeDescription = `${activeLabel}의 답을 입력하세요.`;
|
|
||||||
const lastInputIndex = answers.length - 1;
|
|
||||||
const activeEnterKeyHint = activeInput < lastInputIndex ? "next" : "done";
|
|
||||||
|
|
||||||
function updateAnswer(value: string) {
|
|
||||||
if (!ANSWER_INPUT_PATTERN.test(value)) return;
|
|
||||||
|
|
||||||
onAnswerChange(activeInput, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleInputFocus() {
|
|
||||||
if (blurTimeoutRef.current !== null) {
|
|
||||||
window.clearTimeout(blurTimeoutRef.current);
|
|
||||||
blurTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
setKeypadOpen(true);
|
|
||||||
onInputFocus?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleInputBlur(event: FocusEvent<HTMLInputElement>) {
|
|
||||||
const nextFocusedElement = event.relatedTarget;
|
|
||||||
|
|
||||||
if (
|
|
||||||
nextFocusedElement instanceof Node &&
|
|
||||||
formRef.current?.contains(nextFocusedElement)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
blurTimeoutRef.current = window.setTimeout(() => {
|
|
||||||
blurTimeoutRef.current = null;
|
|
||||||
|
|
||||||
if (formRef.current?.contains(document.activeElement)) {
|
|
||||||
setKeypadOpen(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setKeypadOpen(false);
|
|
||||||
onInputBlur?.();
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const focusInput = useCallback(() => {
|
|
||||||
const input = inputRef.current;
|
|
||||||
|
|
||||||
if (!input) return;
|
|
||||||
|
|
||||||
const wasFocused = document.activeElement === input;
|
|
||||||
|
|
||||||
input.focus({ preventScroll: true });
|
|
||||||
|
|
||||||
if (wasFocused) {
|
|
||||||
onInputFocus?.();
|
|
||||||
}
|
|
||||||
}, [onInputFocus]);
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({ focusInput }), [focusInput]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const mediaQuery = window.matchMedia(DESKTOP_INPUT_DEVICE_QUERY);
|
|
||||||
const updateInputDevice = () => {
|
|
||||||
setDesktopInputDevice(mediaQuery.matches);
|
|
||||||
};
|
|
||||||
|
|
||||||
updateInputDevice();
|
|
||||||
mediaQuery.addEventListener("change", updateInputDevice);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (blurTimeoutRef.current !== null) {
|
|
||||||
window.clearTimeout(blurTimeoutRef.current);
|
|
||||||
}
|
|
||||||
mediaQuery.removeEventListener("change", updateInputDevice);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
function toggleMinus() {
|
|
||||||
onAnswerChange(
|
|
||||||
activeInput,
|
|
||||||
activeAnswer.startsWith("-") ? activeAnswer.slice(1) : `-${activeAnswer}`,
|
|
||||||
);
|
|
||||||
focusInput();
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendKeypadValue(value: string) {
|
|
||||||
updateAnswer(`${activeAnswer}${value}`);
|
|
||||||
focusInput();
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteLastCharacter() {
|
|
||||||
updateAnswer(activeAnswer.slice(0, -1));
|
|
||||||
focusInput();
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveToInput(input: ActiveAnswerInput, reset = false) {
|
|
||||||
onActiveInputChange(input);
|
|
||||||
if (reset) {
|
|
||||||
onAnswerChange(input, "");
|
|
||||||
}
|
|
||||||
focusInput();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEnter() {
|
|
||||||
if (activeInput < lastInputIndex) {
|
|
||||||
moveToInput(activeInput + 1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
formRef.current?.requestSubmit();
|
|
||||||
focusInput();
|
|
||||||
}
|
|
||||||
|
|
||||||
const answerButtons = answers.map((value, index) => ({
|
|
||||||
index,
|
|
||||||
label: `${index + 1}항`,
|
|
||||||
value,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
ref={formRef}
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
className="rounded-md bg-white p-6 shadow-sm ring-1 ring-slate-200 sm:p-8"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<label
|
|
||||||
htmlFor={inputId}
|
|
||||||
className="text-sm font-semibold text-slate-500"
|
|
||||||
>
|
|
||||||
{activeDescription}
|
|
||||||
</label>
|
|
||||||
<ol
|
|
||||||
className="flex min-w-40 items-center gap-1"
|
|
||||||
aria-label="입력 항 진행 상태"
|
|
||||||
>
|
|
||||||
{answerButtons.map((button) => {
|
|
||||||
const selected = activeInput === button.index;
|
|
||||||
const completed = button.value !== "";
|
|
||||||
const displayValue =
|
|
||||||
button.value || pendingAnswerLabels[button.index];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
key={button.index}
|
|
||||||
className="min-w-0 flex-1"
|
|
||||||
aria-label={`${button.label} ${
|
|
||||||
completed
|
|
||||||
? `입력값 ${button.value}`
|
|
||||||
: `원래 항 ${displayValue}, 미입력`
|
|
||||||
}${selected ? ", 현재 입력 중" : ""}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`block truncate text-center text-xs font-bold ${
|
|
||||||
selected || completed ? "text-emerald-800" : "text-slate-500"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<KatexRenderer latex={displayValue} />
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={`mt-1 block h-1.5 rounded-full ${
|
|
||||||
completed
|
|
||||||
? "bg-emerald-700"
|
|
||||||
: selected
|
|
||||||
? "bg-emerald-300 ring-1 ring-emerald-700"
|
|
||||||
: "bg-slate-200"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input
|
|
||||||
id={inputId}
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={activeAnswer}
|
|
||||||
onFocus={handleInputFocus}
|
|
||||||
onBlur={handleInputBlur}
|
|
||||||
onChange={(event) => updateAnswer(event.target.value)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter") {
|
|
||||||
event.preventDefault();
|
|
||||||
handleEnter();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
autoComplete="off"
|
|
||||||
enterKeyHint={activeEnterKeyHint}
|
|
||||||
inputMode="none"
|
|
||||||
pattern="-?[0-9]*(\.[0-9]*)?"
|
|
||||||
className="h-12 w-full min-w-0 rounded-md border border-slate-300 bg-white px-3 text-lg font-semibold text-slate-950 outline-none transition focus:border-emerald-600 focus:ring-2 focus:ring-emerald-200 sm:px-4"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{keypadOpen && !desktopInputDevice ? (
|
|
||||||
<div
|
|
||||||
className="grid grid-cols-3 gap-2"
|
|
||||||
role="group"
|
|
||||||
aria-label={`${activeLabel} 숫자 입력 키패드`}
|
|
||||||
>
|
|
||||||
{KEYPAD_KEYS.map((key) => (
|
|
||||||
<button
|
|
||||||
key={key}
|
|
||||||
type="button"
|
|
||||||
onPointerDown={(event) => event.preventDefault()}
|
|
||||||
onClick={() =>
|
|
||||||
key === "+/-" ? toggleMinus() : appendKeypadValue(key)
|
|
||||||
}
|
|
||||||
aria-label={
|
|
||||||
key === "+/-"
|
|
||||||
? `${activeLabel} 양수 음수 기호 전환`
|
|
||||||
: `${activeLabel} 숫자 ${key} 입력`
|
|
||||||
}
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-md border border-slate-300 bg-white text-lg font-semibold text-slate-900 shadow-sm transition hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
|
|
||||||
>
|
|
||||||
{key}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onPointerDown={(event) => event.preventDefault()}
|
|
||||||
onClick={() => appendKeypadValue(".")}
|
|
||||||
aria-label={`${activeLabel} 소수점 입력`}
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-md border border-slate-300 bg-white text-lg font-semibold text-slate-900 shadow-sm transition hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
|
|
||||||
>
|
|
||||||
.
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onPointerDown={(event) => event.preventDefault()}
|
|
||||||
onClick={deleteLastCharacter}
|
|
||||||
aria-label={`${activeLabel} 마지막 글자 삭제`}
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-md border border-slate-300 bg-white text-xl font-semibold text-slate-800 shadow-sm transition hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
|
|
||||||
>
|
|
||||||
⌫
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onPointerDown={(event) => event.preventDefault()}
|
|
||||||
onClick={handleEnter}
|
|
||||||
aria-label={
|
|
||||||
activeInput < lastInputIndex
|
|
||||||
? "현재 항 입력 완료 후 다음 항으로 이동"
|
|
||||||
: "마지막 항 입력 완료 후 제출"
|
|
||||||
}
|
|
||||||
className="col-span-2 inline-flex h-12 items-center justify-center rounded-md bg-emerald-700 text-base font-semibold text-white shadow-sm transition hover:bg-emerald-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
|
|
||||||
>
|
|
||||||
↵
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="mt-3 text-sm font-semibold text-red-700">{error}</p>
|
|
||||||
) : null}
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default NumberAnswerForm;
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
export type PreparationProblemCount = 5 | 10;
|
|
||||||
export type PreparationMaxTermCount = 2 | 3 | 4;
|
|
||||||
|
|
||||||
interface ParenthesesPreparationClientProps {
|
|
||||||
eyebrow: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
notice: string;
|
|
||||||
playHref: string;
|
|
||||||
selectedProblemCount: PreparationProblemCount;
|
|
||||||
selectedMaxTermCount: PreparationMaxTermCount;
|
|
||||||
onProblemCountChange: (count: PreparationProblemCount) => void;
|
|
||||||
onMaxTermCountChange: (count: PreparationMaxTermCount) => void;
|
|
||||||
onStart: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const problemCounts: PreparationProblemCount[] = [5, 10];
|
|
||||||
const maxTermCounts: PreparationMaxTermCount[] = [2, 3, 4];
|
|
||||||
|
|
||||||
const buttonGroupClassName =
|
|
||||||
"inline-flex gap-1 rounded-md border border-slate-300 bg-white p-1 shadow-sm";
|
|
||||||
|
|
||||||
function optionButtonClassName({
|
|
||||||
selected,
|
|
||||||
disabled = false,
|
|
||||||
}: {
|
|
||||||
selected: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
}) {
|
|
||||||
return `h-10 min-w-16 rounded px-4 text-base font-semibold transition focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 ${
|
|
||||||
selected ? "bg-emerald-700 text-white" : "text-slate-700 hover:bg-slate-100"
|
|
||||||
} ${
|
|
||||||
disabled
|
|
||||||
? "cursor-not-allowed bg-slate-100 text-slate-400 hover:bg-slate-100"
|
|
||||||
: ""
|
|
||||||
}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bracketKinds = [
|
|
||||||
{
|
|
||||||
label: "( )",
|
|
||||||
name: "소괄호",
|
|
||||||
selected: true,
|
|
||||||
disabled: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "{ }",
|
|
||||||
name: "중괄호",
|
|
||||||
selected: false,
|
|
||||||
disabled: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "[ ]",
|
|
||||||
name: "대괄호",
|
|
||||||
selected: false,
|
|
||||||
disabled: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function ParenthesesPreparationClient({
|
|
||||||
eyebrow,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
notice,
|
|
||||||
playHref,
|
|
||||||
selectedProblemCount,
|
|
||||||
selectedMaxTermCount,
|
|
||||||
onProblemCountChange,
|
|
||||||
onMaxTermCountChange,
|
|
||||||
onStart,
|
|
||||||
}: ParenthesesPreparationClientProps) {
|
|
||||||
const startLinkRef = useRef<HTMLAnchorElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
startLinkRef.current?.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-screen items-center bg-slate-50 px-6 py-12 text-slate-950">
|
|
||||||
<section className="mx-auto flex w-full max-w-3xl flex-col gap-8">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-sm font-semibold text-emerald-700">{eyebrow}</p>
|
|
||||||
<h1 className="text-4xl font-bold tracking-normal sm:text-5xl">
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-2xl text-lg leading-8 text-slate-700">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<fieldset className="space-y-3">
|
|
||||||
<legend className="text-sm font-semibold text-slate-700">
|
|
||||||
문제 수
|
|
||||||
</legend>
|
|
||||||
<div
|
|
||||||
className={buttonGroupClassName}
|
|
||||||
role="group"
|
|
||||||
aria-label="문제 수 선택"
|
|
||||||
>
|
|
||||||
{problemCounts.map((count) => {
|
|
||||||
const selected = selectedProblemCount === count;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={count}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onProblemCountChange(count)}
|
|
||||||
aria-pressed={selected}
|
|
||||||
className={optionButtonClassName({ selected })}
|
|
||||||
>
|
|
||||||
{count}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-6 sm:flex-row sm:items-start">
|
|
||||||
<fieldset className="space-y-3">
|
|
||||||
<legend className="text-sm font-semibold text-slate-700">
|
|
||||||
괄호 종류
|
|
||||||
</legend>
|
|
||||||
<div
|
|
||||||
className={buttonGroupClassName}
|
|
||||||
role="group"
|
|
||||||
aria-label="괄호 종류 선택"
|
|
||||||
>
|
|
||||||
{bracketKinds.map((bracketKind) => (
|
|
||||||
<button
|
|
||||||
key={bracketKind.label}
|
|
||||||
type="button"
|
|
||||||
disabled={bracketKind.disabled}
|
|
||||||
aria-pressed={bracketKind.selected}
|
|
||||||
aria-label={`${bracketKind.name} ${
|
|
||||||
bracketKind.disabled ? "준비 중" : "선택됨"
|
|
||||||
}`}
|
|
||||||
className={optionButtonClassName({
|
|
||||||
selected: bracketKind.selected,
|
|
||||||
disabled: bracketKind.disabled,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{bracketKind.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset className="space-y-3">
|
|
||||||
<legend className="text-sm font-semibold text-slate-700">
|
|
||||||
괄호 안 최대 항 수
|
|
||||||
</legend>
|
|
||||||
<div
|
|
||||||
className={buttonGroupClassName}
|
|
||||||
role="group"
|
|
||||||
aria-label="괄호 안 최대 항 수 선택"
|
|
||||||
>
|
|
||||||
{maxTermCounts.map((count) => {
|
|
||||||
const selected = selectedMaxTermCount === count;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={count}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onMaxTermCountChange(count)}
|
|
||||||
aria-pressed={selected}
|
|
||||||
className={optionButtonClassName({ selected })}
|
|
||||||
>
|
|
||||||
{count}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
<Link
|
|
||||||
ref={startLinkRef}
|
|
||||||
href={playHref}
|
|
||||||
onClick={onStart}
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-md bg-emerald-700 px-6 text-base font-semibold text-white transition hover:bg-emerald-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
|
|
||||||
>
|
|
||||||
연습 시작
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-md border border-slate-300 bg-white px-6 text-base font-semibold text-slate-700 transition hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
|
|
||||||
>
|
|
||||||
뒤로가기
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-l-4 border-emerald-700 pl-4 text-sm leading-6 text-slate-600">
|
|
||||||
<p>{notice}</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
import AnswerResultLine from "@/components/game/AnswerResultLine";
|
|
||||||
import KatexRenderer from "@/components/math/KatexRenderer";
|
|
||||||
import type { GradeResult, ParenthesisSignProblem } from "@/lib/engine/types";
|
|
||||||
|
|
||||||
interface ScoreSummaryProps {
|
|
||||||
correctCount: number;
|
|
||||||
totalCount: number;
|
|
||||||
problems: ParenthesisSignProblem[];
|
|
||||||
results: (GradeResult | null)[];
|
|
||||||
onRestart: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAnswerLatex(terms: number[]) {
|
|
||||||
return terms
|
|
||||||
.map((term, index) => {
|
|
||||||
if (index === 0) return `${term}`;
|
|
||||||
|
|
||||||
const operator = term < 0 ? "-" : "+";
|
|
||||||
return `${operator} ${Math.abs(term)}`;
|
|
||||||
})
|
|
||||||
.join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderUserAnswerPair(result: GradeResult) {
|
|
||||||
return (
|
|
||||||
<span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
||||||
{result.userAnswer.map((term, index) => {
|
|
||||||
const operator = term < 0 ? "-" : "+";
|
|
||||||
const correct = term === result.correctAnswer[index];
|
|
||||||
const displayLatex =
|
|
||||||
index === 0 ? `${term}` : `${operator} ${Math.abs(term)}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span key={index} className={correct ? undefined : "text-red-700"}>
|
|
||||||
<KatexRenderer latex={displayLatex} output="mathml" />
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderCorrectAnswerPair(result: GradeResult) {
|
|
||||||
return (
|
|
||||||
<span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
||||||
{result.correctAnswer.map((term, index) => {
|
|
||||||
const operator = term < 0 ? "-" : "+";
|
|
||||||
const correct = result.userAnswer[index] === term;
|
|
||||||
const displayLatex =
|
|
||||||
index === 0 ? `${term}` : `${operator} ${Math.abs(term)}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
key={index}
|
|
||||||
className={correct ? undefined : "font-bold text-blue-700"}
|
|
||||||
>
|
|
||||||
<KatexRenderer latex={displayLatex} output="mathml" />
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ScoreSummary({
|
|
||||||
correctCount,
|
|
||||||
totalCount,
|
|
||||||
problems,
|
|
||||||
results,
|
|
||||||
onRestart,
|
|
||||||
}: ScoreSummaryProps) {
|
|
||||||
const restartButtonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
const solvedItems = problems
|
|
||||||
.map((problem, index) => ({
|
|
||||||
problem,
|
|
||||||
result: results[index],
|
|
||||||
}))
|
|
||||||
.filter(
|
|
||||||
(item): item is { problem: ParenthesisSignProblem; result: GradeResult } =>
|
|
||||||
item.result !== null,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
restartButtonRef.current?.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-screen items-center bg-slate-50 px-6 py-12 text-slate-950">
|
|
||||||
<section className="mx-auto flex w-full max-w-3xl flex-col gap-8">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p className="text-sm font-semibold text-emerald-700">
|
|
||||||
괄호 음수 분배
|
|
||||||
</p>
|
|
||||||
<h1 className="text-4xl font-bold tracking-normal sm:text-5xl">
|
|
||||||
풀이 결과
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl font-semibold text-slate-800">
|
|
||||||
{correctCount} / {totalCount} 정답
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ol className="space-y-3">
|
|
||||||
{solvedItems.map(({ problem, result }, index) => {
|
|
||||||
const userAnswerLatex = formatAnswerLatex(result.userAnswer);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AnswerResultLine
|
|
||||||
key={problem.key}
|
|
||||||
expressionLatex={problem.latexBefore}
|
|
||||||
userAnswerLatex={userAnswerLatex}
|
|
||||||
correctAnswerLatex={problem.latexAfter}
|
|
||||||
correct={result.correct}
|
|
||||||
userAnswerContent={renderUserAnswerPair(result)}
|
|
||||||
correctAnswerContent={renderCorrectAnswerPair(result)}
|
|
||||||
prefix={
|
|
||||||
<span className="mr-1 text-sm font-bold text-slate-500">
|
|
||||||
{index + 1}.
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
<button
|
|
||||||
ref={restartButtonRef}
|
|
||||||
type="button"
|
|
||||||
onClick={onRestart}
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-md bg-emerald-700 px-6 text-base font-semibold text-white transition hover:bg-emerald-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
|
|
||||||
>
|
|
||||||
다시 시작
|
|
||||||
</button>
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-md border border-slate-300 px-6 text-base font-semibold text-slate-800 transition hover:bg-white focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
|
|
||||||
>
|
|
||||||
처음으로
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
interface StageResult {
|
|
||||||
correct: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StageIndicatorProps {
|
|
||||||
currentIndex: number;
|
|
||||||
results: (StageResult | null)[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function StageIndicator({
|
|
||||||
currentIndex,
|
|
||||||
results,
|
|
||||||
}: StageIndicatorProps) {
|
|
||||||
const completedCount = results.filter((result) => result !== null).length;
|
|
||||||
const correctCount = results.filter((result) => result?.correct).length;
|
|
||||||
const incorrectCount = results.filter((result) => result?.correct === false)
|
|
||||||
.length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full">
|
|
||||||
<p className="sr-only">
|
|
||||||
총 {results.length}문제 중 {completedCount}문제 완료, 정답{" "}
|
|
||||||
{correctCount}개, 오답 {incorrectCount}개, 현재 {currentIndex + 1}번
|
|
||||||
문제
|
|
||||||
</p>
|
|
||||||
<ol
|
|
||||||
className="flex w-full items-center gap-1"
|
|
||||||
aria-label="문제 풀이 진행 단계"
|
|
||||||
>
|
|
||||||
{results.map((result, index) => {
|
|
||||||
const stageNumber = index + 1;
|
|
||||||
const isCurrent = index === currentIndex;
|
|
||||||
const isCompleted = result !== null;
|
|
||||||
|
|
||||||
let label = `${stageNumber}번 남은 단계`;
|
|
||||||
let className =
|
|
||||||
"block h-2 w-full rounded-full transition";
|
|
||||||
|
|
||||||
if (isCompleted) {
|
|
||||||
label = result.correct
|
|
||||||
? `${stageNumber}번 완료, 정답`
|
|
||||||
: `${stageNumber}번 완료, 오답`;
|
|
||||||
className += result.correct
|
|
||||||
? " bg-emerald-700"
|
|
||||||
: " bg-red-700";
|
|
||||||
} else if (isCurrent) {
|
|
||||||
label = `${stageNumber}번 현재 단계`;
|
|
||||||
className += " bg-emerald-300 ring-2 ring-emerald-700";
|
|
||||||
} else {
|
|
||||||
className += " bg-slate-200";
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li key={stageNumber} className="min-w-0 flex-1">
|
|
||||||
<span className={className} aria-label={label} title={label} />
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
import KatexRenderer from "@/components/math/KatexRenderer";
|
|
||||||
import type { VariableTermChoice } from "@/lib/engine/variable-types";
|
|
||||||
|
|
||||||
interface VariableChoiceAnswerFormProps {
|
|
||||||
choices: VariableTermChoice[][];
|
|
||||||
selectedChoiceIds: (string | null)[];
|
|
||||||
activeTermIndex: number;
|
|
||||||
pendingChoiceLabels: string[];
|
|
||||||
onSelectChoice: (termIndex: number, choiceId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function VariableChoiceAnswerForm({
|
|
||||||
choices,
|
|
||||||
selectedChoiceIds,
|
|
||||||
activeTermIndex,
|
|
||||||
pendingChoiceLabels,
|
|
||||||
onSelectChoice,
|
|
||||||
}: VariableChoiceAnswerFormProps) {
|
|
||||||
const firstChoiceRef = useRef<HTMLButtonElement>(null);
|
|
||||||
const activeChoices = [...choices[activeTermIndex]].sort(
|
|
||||||
(a, b) => a.coefficient - b.coefficient,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
firstChoiceRef.current?.focus();
|
|
||||||
}, [activeTermIndex]);
|
|
||||||
|
|
||||||
function getSelectedChoice(index: number) {
|
|
||||||
const selectedChoiceId = selectedChoiceIds[index];
|
|
||||||
|
|
||||||
if (!selectedChoiceId) return null;
|
|
||||||
|
|
||||||
return choices[index].find((choice) => choice.id === selectedChoiceId) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-md bg-white p-6 shadow-sm ring-1 ring-slate-200">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-semibold text-slate-500">
|
|
||||||
{activeTermIndex + 1}항을 고르세요.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<ol
|
|
||||||
className="flex min-w-40 items-center gap-1"
|
|
||||||
aria-label="입력 항 진행 상태"
|
|
||||||
>
|
|
||||||
{selectedChoiceIds.map((choiceId, index) => {
|
|
||||||
const selected = activeTermIndex === index;
|
|
||||||
const selectedChoice = getSelectedChoice(index);
|
|
||||||
const completed = choiceId !== null && selectedChoice !== null;
|
|
||||||
const statusLabel =
|
|
||||||
completed && selectedChoice
|
|
||||||
? `선택값 ${selectedChoice.latex}`
|
|
||||||
: `원래 항 ${pendingChoiceLabels[index]}, 미선택`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
key={index}
|
|
||||||
className="min-w-0 flex-1"
|
|
||||||
aria-label={`${index + 1}항 ${statusLabel}${
|
|
||||||
selected ? ", 현재 선택 중" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`block truncate text-center text-xs font-bold ${
|
|
||||||
selected || completed ? "text-emerald-800" : "text-slate-500"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{completed ? (
|
|
||||||
<KatexRenderer latex={selectedChoice.latex} />
|
|
||||||
) : (
|
|
||||||
<KatexRenderer latex={pendingChoiceLabels[index]} />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={`mt-1 block h-1.5 rounded-full ${
|
|
||||||
completed
|
|
||||||
? "bg-emerald-700"
|
|
||||||
: selected
|
|
||||||
? "bg-emerald-300 ring-1 ring-emerald-700"
|
|
||||||
: "bg-slate-200"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="mt-5 grid gap-3 sm:grid-cols-2"
|
|
||||||
role="group"
|
|
||||||
aria-label={`${activeTermIndex + 1}항 보기 선택`}
|
|
||||||
>
|
|
||||||
{activeChoices.map((choice, index) => {
|
|
||||||
const selected = selectedChoiceIds[activeTermIndex] === choice.id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={choice.id}
|
|
||||||
ref={index === 0 ? firstChoiceRef : undefined}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSelectChoice(activeTermIndex, choice.id)}
|
|
||||||
aria-pressed={selected}
|
|
||||||
className={`flex h-20 items-center justify-center rounded-md text-3xl font-bold transition focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 ${
|
|
||||||
selected
|
|
||||||
? "bg-emerald-700 text-white"
|
|
||||||
: "bg-slate-50 text-slate-950 ring-1 ring-slate-200 hover:bg-slate-100"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<KatexRenderer latex={choice.latex} />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
import AnswerResultLine from "@/components/game/AnswerResultLine";
|
|
||||||
import type {
|
|
||||||
VariableGradeResult,
|
|
||||||
VariableParenthesisProblem,
|
|
||||||
} from "@/lib/engine/variable-types";
|
|
||||||
import { formatVariableTermLatex } from "@/lib/engine/variable-parenthesis";
|
|
||||||
|
|
||||||
interface VariableFeedbackPanelProps {
|
|
||||||
result: VariableGradeResult;
|
|
||||||
problem: VariableParenthesisProblem;
|
|
||||||
isLastProblem: boolean;
|
|
||||||
onNext: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSignedNumber(value: number) {
|
|
||||||
return value < 0 ? `(${value})` : `${value}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function VariableFeedbackPanel({
|
|
||||||
result,
|
|
||||||
problem,
|
|
||||||
isLastProblem,
|
|
||||||
onNext,
|
|
||||||
}: VariableFeedbackPanelProps) {
|
|
||||||
const nextButtonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
const signedTerms = problem.terms.map((term, index) =>
|
|
||||||
formatVariableTermLatex({
|
|
||||||
...term,
|
|
||||||
coefficient:
|
|
||||||
index > 0 && problem.operators[index - 1] === "-"
|
|
||||||
? -term.coefficient
|
|
||||||
: term.coefficient,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const feedbackItems = signedTerms.map((termLatex, index) => {
|
|
||||||
const userAnswer = result.userAnswer[index];
|
|
||||||
const correctAnswer = result.correctAnswer[index];
|
|
||||||
|
|
||||||
return {
|
|
||||||
formulaLatex: `${formatSignedNumber(problem.multiplier)}\\times ${termLatex}`,
|
|
||||||
userAnswerLatex: userAnswer?.latex ?? "?",
|
|
||||||
correctAnswerLatex: correctAnswer.latex,
|
|
||||||
correct: userAnswer?.coefficient === correctAnswer.coefficient,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
nextButtonRef.current?.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`rounded-md p-6 shadow-sm ring-1 ${
|
|
||||||
result.correct
|
|
||||||
? "bg-emerald-50 text-emerald-950 ring-emerald-200"
|
|
||||||
: "bg-red-50 text-red-950 ring-red-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<p className="text-lg font-bold">
|
|
||||||
{result.correct ? "정답입니다." : "오답입니다."}
|
|
||||||
</p>
|
|
||||||
<ul className="mt-5 space-y-3">
|
|
||||||
{feedbackItems.map((item, index) => (
|
|
||||||
<AnswerResultLine
|
|
||||||
key={index}
|
|
||||||
expressionLatex={item.formulaLatex}
|
|
||||||
userAnswerLatex={item.userAnswerLatex}
|
|
||||||
correctAnswerLatex={item.correctAnswerLatex}
|
|
||||||
correct={item.correct}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<button
|
|
||||||
ref={nextButtonRef}
|
|
||||||
type="button"
|
|
||||||
onClick={onNext}
|
|
||||||
className="mt-5 inline-flex h-11 items-center justify-center rounded-md bg-slate-950 px-5 text-base font-semibold text-white transition hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2"
|
|
||||||
>
|
|
||||||
{isLastProblem ? "결과 보기" : "다음 문제"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
import AnswerResultLine from "@/components/game/AnswerResultLine";
|
|
||||||
import KatexRenderer from "@/components/math/KatexRenderer";
|
|
||||||
import type {
|
|
||||||
DistributedVariableTerm,
|
|
||||||
VariableGradeResult,
|
|
||||||
VariableParenthesisProblem,
|
|
||||||
} from "@/lib/engine/variable-types";
|
|
||||||
import { formatVariableExpressionLatex } from "@/lib/engine/variable-parenthesis";
|
|
||||||
|
|
||||||
interface VariableScoreSummaryProps {
|
|
||||||
correctCount: number;
|
|
||||||
totalCount: number;
|
|
||||||
problems: VariableParenthesisProblem[];
|
|
||||||
results: (VariableGradeResult | null)[];
|
|
||||||
onRestart: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTermPair(
|
|
||||||
terms: (DistributedVariableTerm | null)[],
|
|
||||||
comparisonTerms: (DistributedVariableTerm | null)[],
|
|
||||||
mode: "user" | "correct",
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
||||||
{terms.map((term, index) => {
|
|
||||||
if (!term) {
|
|
||||||
return (
|
|
||||||
<span key={index} className="text-red-700">
|
|
||||||
?
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const correct = term.coefficient === comparisonTerms[index]?.coefficient;
|
|
||||||
const className =
|
|
||||||
mode === "user"
|
|
||||||
? correct
|
|
||||||
? undefined
|
|
||||||
: "text-red-700"
|
|
||||||
: correct
|
|
||||||
? undefined
|
|
||||||
: "font-bold text-blue-700";
|
|
||||||
const operator = term.coefficient < 0 ? "-" : "+";
|
|
||||||
const displayLatex =
|
|
||||||
index === 0 ? term.latex : `${operator} ${term.latex.replace(/^-/, "")}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span key={index} className={className}>
|
|
||||||
<KatexRenderer latex={displayLatex} output="mathml" />
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function VariableScoreSummary({
|
|
||||||
correctCount,
|
|
||||||
totalCount,
|
|
||||||
problems,
|
|
||||||
results,
|
|
||||||
onRestart,
|
|
||||||
}: VariableScoreSummaryProps) {
|
|
||||||
const restartButtonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
const solvedItems = problems
|
|
||||||
.map((problem, index) => ({
|
|
||||||
problem,
|
|
||||||
result: results[index],
|
|
||||||
}))
|
|
||||||
.filter(
|
|
||||||
(
|
|
||||||
item,
|
|
||||||
): item is {
|
|
||||||
problem: VariableParenthesisProblem;
|
|
||||||
result: VariableGradeResult;
|
|
||||||
} => item.result !== null,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
restartButtonRef.current?.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-screen items-center bg-slate-50 px-6 py-12 text-slate-950">
|
|
||||||
<section className="mx-auto flex w-full max-w-3xl flex-col gap-8">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p className="text-sm font-semibold text-emerald-700">
|
|
||||||
미지수 괄호 풀기
|
|
||||||
</p>
|
|
||||||
<h1 className="text-4xl font-bold tracking-normal sm:text-5xl">
|
|
||||||
풀이 결과
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl font-semibold text-slate-800">
|
|
||||||
{correctCount} / {totalCount} 정답
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ol className="space-y-3">
|
|
||||||
{solvedItems.map(({ problem, result }, index) => (
|
|
||||||
<AnswerResultLine
|
|
||||||
key={problem.key}
|
|
||||||
expressionLatex={problem.latexBefore}
|
|
||||||
userAnswerLatex={formatVariableExpressionLatex([
|
|
||||||
...result.correctAnswer.map(
|
|
||||||
(term, termIndex) => result.userAnswer[termIndex] ?? term,
|
|
||||||
),
|
|
||||||
])}
|
|
||||||
correctAnswerLatex={problem.latexAfter}
|
|
||||||
correct={result.correct}
|
|
||||||
userAnswerContent={renderTermPair(
|
|
||||||
result.userAnswer,
|
|
||||||
result.correctAnswer,
|
|
||||||
"user",
|
|
||||||
)}
|
|
||||||
correctAnswerContent={renderTermPair(
|
|
||||||
result.correctAnswer,
|
|
||||||
result.userAnswer,
|
|
||||||
"correct",
|
|
||||||
)}
|
|
||||||
prefix={
|
|
||||||
<span className="mr-1 text-sm font-bold text-slate-500">
|
|
||||||
{index + 1}.
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
<button
|
|
||||||
ref={restartButtonRef}
|
|
||||||
type="button"
|
|
||||||
onClick={onRestart}
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-md bg-emerald-700 px-6 text-base font-semibold text-white transition hover:bg-emerald-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
|
|
||||||
>
|
|
||||||
다시 시작
|
|
||||||
</button>
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-md border border-slate-300 px-6 text-base font-semibold text-slate-800 transition hover:bg-white focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
|
|
||||||
>
|
|
||||||
처음으로
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { renderToString } from "katex";
|
|
||||||
|
|
||||||
interface KatexRendererProps {
|
|
||||||
latex: string;
|
|
||||||
displayMode?: boolean;
|
|
||||||
output?: "htmlAndMathml" | "mathml";
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function KatexRenderer({
|
|
||||||
latex,
|
|
||||||
displayMode = false,
|
|
||||||
output = "htmlAndMathml",
|
|
||||||
}: KatexRendererProps) {
|
|
||||||
const html = renderToString(latex, {
|
|
||||||
displayMode,
|
|
||||||
output,
|
|
||||||
throwOnError: false,
|
|
||||||
});
|
|
||||||
const Tag = displayMode ? "div" : "span";
|
|
||||||
|
|
||||||
return <Tag dangerouslySetInnerHTML={{ __html: html }} />;
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
services:
|
|
||||||
math-quiz:
|
|
||||||
container_name: math-quiz
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
image: math-quiz:latest
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file:
|
|
||||||
- .env.production
|
|
||||||
environment:
|
|
||||||
NODE_ENV: production
|
|
||||||
PORT: 3000
|
|
||||||
HOSTNAME: 0.0.0.0
|
|
||||||
networks:
|
|
||||||
- proxy-network
|
|
||||||
|
|
||||||
networks:
|
|
||||||
proxy-network:
|
|
||||||
external: true
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
# Synology NAS Docker Compose 배포
|
|
||||||
|
|
||||||
이 문서는 현재 프로젝트를 Synology NAS에서 `math-quiz` 컨테이너로 실행하고, Nginx Proxy Manager(NPM)에서 `math-quiz.jisangs.com`으로 연결하는 절차입니다.
|
|
||||||
|
|
||||||
## 전제
|
|
||||||
|
|
||||||
- NAS에 Docker 또는 Container Manager가 설치되어 있어야 합니다.
|
|
||||||
- NPM 컨테이너가 80/443 포트를 사용하고 있어야 합니다.
|
|
||||||
- `math-quiz.jisangs.com` DNS가 NAS로 연결되어 있어야 합니다.
|
|
||||||
- NPM 컨테이너와 `math-quiz` 컨테이너가 같은 Docker 네트워크에 있어야 합니다.
|
|
||||||
|
|
||||||
## 1. NPM 공용 네트워크 준비
|
|
||||||
|
|
||||||
NAS SSH에서 한 번만 실행합니다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker network create proxy-network
|
|
||||||
```
|
|
||||||
|
|
||||||
이미 같은 이름의 네트워크가 있으면 에러가 나도 괜찮습니다.
|
|
||||||
|
|
||||||
NPM 컨테이너가 이미 실행 중이라면 같은 네트워크에 붙입니다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker network connect proxy-network <npm-container-name>
|
|
||||||
```
|
|
||||||
|
|
||||||
예를 들어 NPM 컨테이너 이름이 `nginx-proxy-manager`라면:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker network connect proxy-network nginx-proxy-manager
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. 프로젝트 파일 업로드
|
|
||||||
|
|
||||||
NAS에 배포 디렉터리를 만듭니다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p /volume1/docker/math-quiz
|
|
||||||
```
|
|
||||||
|
|
||||||
로컬 작업 폴더에서 NAS로 프로젝트를 복사합니다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rsync -av --delete \
|
|
||||||
--exclude .git \
|
|
||||||
--exclude node_modules \
|
|
||||||
--exclude .next \
|
|
||||||
--exclude .pnpm-store \
|
|
||||||
./ <nas-user>@<nas-host>:/volume1/docker/math-quiz/
|
|
||||||
```
|
|
||||||
|
|
||||||
Git을 NAS에서 직접 사용할 수 있다면 아래 방식도 가능합니다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /volume1/docker
|
|
||||||
git clone <repo-url> math-quiz
|
|
||||||
```
|
|
||||||
|
|
||||||
이미 clone 되어 있다면:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /volume1/docker/math-quiz
|
|
||||||
git pull
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. 운영 환경변수 작성
|
|
||||||
|
|
||||||
NAS의 `/volume1/docker/math-quiz/.env.production` 파일을 만듭니다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /volume1/docker/math-quiz
|
|
||||||
cp .env.production.example .env.production
|
|
||||||
vi .env.production
|
|
||||||
```
|
|
||||||
|
|
||||||
현재 구현은 DB/Redis/NextAuth를 아직 사용하지 않지만, 이후 기능 추가를 위해 키는 준비해 둡니다.
|
|
||||||
|
|
||||||
```env
|
|
||||||
DATABASE_URL=
|
|
||||||
REDIS_URL=
|
|
||||||
NEXTAUTH_SECRET=
|
|
||||||
NEXTAUTH_URL=https://math-quiz.jisangs.com
|
|
||||||
```
|
|
||||||
|
|
||||||
`NEXTAUTH_SECRET`을 사용할 때는 아래처럼 생성할 수 있습니다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
openssl rand -base64 32
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Docker 이미지 빌드 및 실행
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /volume1/docker/math-quiz
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
상태 확인:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose ps
|
|
||||||
docker logs -f math-quiz
|
|
||||||
```
|
|
||||||
|
|
||||||
정상 실행되면 컨테이너 내부에서 Next.js 서버가 `3000` 포트로 떠 있습니다. 외부 80/443은 NPM이 담당하므로 `docker-compose.yml`에는 호스트 포트 노출을 두지 않았습니다.
|
|
||||||
|
|
||||||
## 5. Nginx Proxy Manager 설정
|
|
||||||
|
|
||||||
NPM 관리자 화면에서 Proxy Host를 추가합니다.
|
|
||||||
|
|
||||||
- Domain Names: `math-quiz.jisangs.com`
|
|
||||||
- Scheme: `http`
|
|
||||||
- Forward Hostname / IP: `math-quiz`
|
|
||||||
- Forward Port: `3000`
|
|
||||||
- Websockets Support: 필요 시 활성화
|
|
||||||
- SSL: Let's Encrypt 인증서 발급
|
|
||||||
- Force SSL: 활성화
|
|
||||||
|
|
||||||
NPM 컨테이너와 `math-quiz` 컨테이너가 `proxy-network` 네트워크를 공유해야 `Forward Hostname`에 `math-quiz`를 사용할 수 있습니다.
|
|
||||||
|
|
||||||
## 6. 업데이트 반영
|
|
||||||
|
|
||||||
로컬에서 변경한 파일을 다시 rsync 하거나 NAS에서 `git pull` 후 재빌드합니다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /volume1/docker/math-quiz
|
|
||||||
docker compose up -d --build
|
|
||||||
docker image prune -f
|
|
||||||
```
|
|
||||||
|
|
||||||
로그 확인:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker logs -f math-quiz
|
|
||||||
```
|
|
||||||
|
|
||||||
## 참고: 네트워크 이름이 다를 때
|
|
||||||
|
|
||||||
NPM에서 이미 쓰는 Docker 네트워크 이름이 있다면 `docker-compose.yml`의 `proxy-network`를 해당 네트워크 이름으로 바꿉니다.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
networks:
|
|
||||||
proxy-network:
|
|
||||||
external: true
|
|
||||||
```
|
|
||||||
|
|
||||||
위 블록의 `proxy-network` 이름과 서비스의 `networks` 값을 같은 이름으로 맞추면 됩니다.
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
Here is Claude's plan:
|
|
||||||
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
|
|
||||||
이터레이션 1: 괄호 음수 분배 문제 풀이 기능
|
|
||||||
|
|
||||||
Context
|
|
||||||
|
|
||||||
완전 초기 상태(코드 없음)에서 바이브 코딩 방식으로 시작. DB·인증·Redis 없이 프론트엔드와 문제 생성
|
|
||||||
엔진만으로 첫 기능을 동작 가능한 상태까지 만든다.
|
|
||||||
|
|
||||||
---
|
|
||||||
단계별 접근법 (바이브 코딩)
|
|
||||||
|
|
||||||
각 단계가 독립적으로 실행·확인 가능한 단위로 나뉜다. 다음 단계로 넘어가기 전에 해당 단계가 정상
|
|
||||||
동작하는지 함께 확인한다.
|
|
||||||
|
|
||||||
┌──────┬──────────────────────────────┬────────────────────────────────────┐
|
|
||||||
│ 단계 │ 내용 │ 완료 기준 │
|
|
||||||
├──────┼──────────────────────────────┼────────────────────────────────────┤
|
|
||||||
│ 1 │ 프로젝트 초기화 │ pnpm dev 실행 시 Next.js 기본 화면 │
|
|
||||||
├──────┼──────────────────────────────┼────────────────────────────────────┤
|
|
||||||
│ 2 │ 문제 생성 엔진 + 단위 테스트 │ pnpm test 통과 │
|
|
||||||
├──────┼──────────────────────────────┼────────────────────────────────────┤
|
|
||||||
│ 3 │ KaTeX 수식 렌더링 컴포넌트 │ 브라우저에서 수식 정상 표시 │
|
|
||||||
├──────┼──────────────────────────────┼────────────────────────────────────┤
|
|
||||||
│ 4 │ Zustand 게임 상태 │ store에서 문제 생성·채점 동작 │
|
|
||||||
├──────┼──────────────────────────────┼────────────────────────────────────┤
|
|
||||||
│ 5 │ 게임 UI 조립 │ 문제 풀이 전체 흐름 플레이 가능 │
|
|
||||||
└──────┴──────────────────────────────┴────────────────────────────────────┘
|
|
||||||
|
|
||||||
---
|
|
||||||
1단계: 프로젝트 초기화
|
|
||||||
|
|
||||||
pnpm create next-app@14 . --typescript --tailwind --eslint --app --no-src-dir --import-alias "@/*"
|
|
||||||
pnpm add zustand katex react-katex framer-motion clsx tailwind-merge
|
|
||||||
pnpm add -D @types/katex vitest @vitejs/plugin-react jsdom @testing-library/react
|
|
||||||
|
|
||||||
app/layout.tsx에 추가:
|
|
||||||
import 'katex/dist/katex.min.css'
|
|
||||||
|
|
||||||
---
|
|
||||||
2단계: 문제 생성 엔진
|
|
||||||
|
|
||||||
lib/engine/types.ts
|
|
||||||
|
|
||||||
interface ParenthesisSignProblem {
|
|
||||||
id: string
|
|
||||||
multiplier: number // 항상 음수 (-1 ~ -9)
|
|
||||||
a: number // 괄호 안 첫째 항 (1 ~ 9)
|
|
||||||
operator: '+' | '-'
|
|
||||||
b: number // 괄호 안 둘째 항 (1 ~ 9)
|
|
||||||
distributedA: number // multiplier * a
|
|
||||||
distributedB: number // multiplier * (op==='-' ? -b : b)
|
|
||||||
latexBefore: string // 문제 수식
|
|
||||||
latexAfter: string // 정답 수식
|
|
||||||
}
|
|
||||||
interface UserAnswer { a: number; b: number }
|
|
||||||
interface GradeResult { correct: boolean; correctAnswer: [number, number] }
|
|
||||||
|
|
||||||
lib/engine/parenthesis-sign.ts
|
|
||||||
|
|
||||||
핵심 로직:
|
|
||||||
- multiplier: -9 ~ -1 (음수 강제)
|
|
||||||
- a, b: 1 ~ 9, a ≠ b
|
|
||||||
- distributedB 계산: operator === '-' 이면 multiplier * (-b), 아니면 multiplier * b
|
|
||||||
- KaTeX 빌드: -2(3 - 6) → 정답: -6 + 12
|
|
||||||
|
|
||||||
함수 목록:
|
|
||||||
- generateProblem(): ParenthesisSignProblem
|
|
||||||
- generateSession(count: number): ParenthesisSignProblem[] (중복 방지)
|
|
||||||
- gradeAnswer(problem, answer): GradeResult
|
|
||||||
|
|
||||||
테스트 파일: lib/engine/parenthesis-sign.test.ts
|
|
||||||
|
|
||||||
---
|
|
||||||
3단계: 수식 렌더링 컴포넌트
|
|
||||||
|
|
||||||
components/math/KatexRenderer.tsx ('use client')
|
|
||||||
|
|
||||||
- props: latex: string, displayMode?: boolean
|
|
||||||
- react-katex의 InlineMath / BlockMath 래핑
|
|
||||||
|
|
||||||
components/math/ExpressionDisplay.tsx
|
|
||||||
|
|
||||||
- props: problem, showAnswer?: boolean
|
|
||||||
- showAnswer=false: latexBefore 표시
|
|
||||||
- showAnswer=true: latexBefore → latexAfter (Framer Motion fade-in)
|
|
||||||
|
|
||||||
---
|
|
||||||
4단계: Zustand Store
|
|
||||||
|
|
||||||
store/gameStore.ts
|
|
||||||
|
|
||||||
interface GameState {
|
|
||||||
session: { problems: ParenthesisSignProblem[]; currentIndex: number; results: (GradeResult|null)[] }
|
|
||||||
| null
|
|
||||||
initSession: (count?: number) => void // 기본 10문제
|
|
||||||
submitAnswer: (answer: UserAnswer) => void
|
|
||||||
nextProblem: () => void
|
|
||||||
resetSession: () => void
|
|
||||||
// 셀렉터
|
|
||||||
currentProblem: () => ParenthesisSignProblem | null
|
|
||||||
currentResult: () => GradeResult | null
|
|
||||||
isComplete: () => boolean
|
|
||||||
score: () => { correct: number; total: number }
|
|
||||||
}
|
|
||||||
|
|
||||||
---
|
|
||||||
5단계: 게임 UI 조립
|
|
||||||
|
|
||||||
게임 컴포넌트
|
|
||||||
|
|
||||||
- components/game/AnswerInput.tsx: 두 개의 숫자 입력 (distributedA, distributedB), Enter 제출 지원
|
|
||||||
- components/game/FeedbackBanner.tsx: 정답(초록)/오답(빨강) + Framer Motion 슬라이드 인
|
|
||||||
- components/game/ProgressBar.tsx: n/total 진행 표시
|
|
||||||
- components/game/ScoreSummary.tsx: 최종 점수 + 다시 시작 버튼
|
|
||||||
|
|
||||||
페이지
|
|
||||||
|
|
||||||
- app/(game)/play/page.tsx: Server Component 래퍼
|
|
||||||
- app/(game)/play/PlayClient.tsx ('use client'): 상태에 따라 분기 렌더링
|
|
||||||
session===null → initSession()
|
|
||||||
!isComplete() → 문제 풀이 UI
|
|
||||||
isComplete() → ScoreSummary
|
|
||||||
- app/page.tsx: "연습 시작" → /play 이동
|
|
||||||
|
|
||||||
---
|
|
||||||
화면 흐름
|
|
||||||
|
|
||||||
app/page.tsx (시작)
|
|
||||||
→ (game)/play/PlayClient.tsx
|
|
||||||
├─ ProgressBar
|
|
||||||
├─ ExpressionDisplay (문제 수식)
|
|
||||||
├─ AnswerInput (두 항 입력)
|
|
||||||
├─ FeedbackBanner (제출 후)
|
|
||||||
├─ "다음 문제" 버튼
|
|
||||||
└─ [완료] ScoreSummary → "다시 시작"
|
|
||||||
|
|
||||||
---
|
|
||||||
검증 방법
|
|
||||||
|
|
||||||
1. pnpm test → 엔진 단위 테스트 전체 통과
|
|
||||||
2. pnpm dev → 브라우저에서 수식 렌더링 확인
|
|
||||||
3. 10문제 전체 풀이 → 점수 화면 확인
|
|
||||||
4. 오답 입력 → 오답 피드백 및 정답 표시 확인
|
|
||||||
5. "다시 시작" → 새 세션 생성 확인
|
|
||||||
|
|
||||||
---
|
|
||||||
이번 이터레이션에서 제외
|
|
||||||
|
|
||||||
- DB(MariaDB), Redis, NextAuth (2차 이터레이션)
|
|
||||||
- 유형 선택 / 모드 선택 화면 (3차 이터레이션)
|
|
||||||
- 스피드 퀴즈 모드 (4차 이터레이션)
|
|
||||||
- 미지수(x, y, z) 포함 문제 (파라미터 확장 이터레이션)
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
# dev-step-01 계획 검토 및 단계별 진행 제안
|
|
||||||
|
|
||||||
`dev-step-01.md`의 방향은 전반적으로 좋다. 특히 첫 이터레이션에서 DB, 인증, Redis, 유형 선택, 모드 선택을 제외하고 **괄호 앞 음수 분배 문제 풀이 기능만 먼저 만든다**는 판단은 `AGENTS.md`의 프로젝트 방향과 잘 맞는다.
|
|
||||||
|
|
||||||
다만 첫 단계는 조금 더 작게 쪼개는 것이 좋다. 현재 계획의 “1단계: 프로젝트 초기화”는 적절하지만, 바로 Zustand, Framer Motion, react-katex까지 모두 설치하면 첫 확인 지점이 흐려질 수 있다.
|
|
||||||
|
|
||||||
## 추천 첫 단계
|
|
||||||
|
|
||||||
1. Next.js 프로젝트 초기화
|
|
||||||
2. Tailwind 기본 화면 정리
|
|
||||||
3. `/`에서 “괄호 음수 분배 연습 시작” 버튼만 보이게 만들기
|
|
||||||
4. `/play` 빈 페이지까지 라우팅 확인
|
|
||||||
5. `pnpm dev`로 브라우저에서 화면 이동 확인
|
|
||||||
|
|
||||||
첫 단계의 완료 기준은 “앱이 실행되고 시작 화면에서 풀이 화면으로 이동된다” 정도가 적당하다. 아직 문제 생성, KaTeX, 상태 관리는 넣지 않는다.
|
|
||||||
|
|
||||||
## 단계별 진행안
|
|
||||||
|
|
||||||
### 1차 이터레이션: 화면 뼈대
|
|
||||||
|
|
||||||
- `app/page.tsx`
|
|
||||||
- `app/(game)/play/page.tsx`
|
|
||||||
- 시작 버튼에서 `/play`로 이동
|
|
||||||
- 아직 문제 생성 없음
|
|
||||||
- 완료 기준: 브라우저에서 시작 화면에서 풀이 화면으로 이동 확인
|
|
||||||
|
|
||||||
### 2차 이터레이션: 문제 생성 엔진
|
|
||||||
|
|
||||||
- `lib/engine/parenthesis-sign.ts`
|
|
||||||
- `generateProblem`
|
|
||||||
- `gradeAnswer`
|
|
||||||
- Vitest 테스트
|
|
||||||
- UI는 건드리지 않고 순수 알고리즘만 검증
|
|
||||||
- 완료 기준: `pnpm test` 통과
|
|
||||||
|
|
||||||
### 3차 이터레이션: 수식 표시
|
|
||||||
|
|
||||||
- `components/math/KatexRenderer.tsx`
|
|
||||||
- KaTeX CSS import
|
|
||||||
- `/play`에 임시 문제 하나 표시
|
|
||||||
- 완료 기준: 브라우저에서 `-2(3 - 6)` 같은 수식이 KaTeX로 표시됨
|
|
||||||
|
|
||||||
### 4차 이터레이션: 한 문제 풀기
|
|
||||||
|
|
||||||
- 답 입력 UI
|
|
||||||
- 제출 버튼
|
|
||||||
- 채점 결과 표시
|
|
||||||
- 아직 Zustand 없이 `useState`만 사용해도 충분함
|
|
||||||
- 완료 기준: 한 문제를 풀고 정답/오답 피드백 확인
|
|
||||||
|
|
||||||
### 5차 이터레이션: 10문제 세션
|
|
||||||
|
|
||||||
- 이때 Zustand 도입
|
|
||||||
- `initSession`
|
|
||||||
- `submitAnswer`
|
|
||||||
- `nextProblem`
|
|
||||||
- `score`
|
|
||||||
- 완료 기준: 10문제 풀이 후 점수 화면 표시
|
|
||||||
|
|
||||||
### 6차 이터레이션: UX 다듬기
|
|
||||||
|
|
||||||
- ProgressBar
|
|
||||||
- FeedbackBanner
|
|
||||||
- ScoreSummary
|
|
||||||
- Framer Motion은 이 단계에서 도입해도 충분함
|
|
||||||
|
|
||||||
## Claude 계획에서 조정하면 좋은 점
|
|
||||||
|
|
||||||
Claude의 계획에서 가장 조정하면 좋은 부분은 **Zustand와 Framer Motion을 너무 빨리 넣지 않는 것**이다.
|
|
||||||
|
|
||||||
첫 기능의 핵심은 다음 세 가지다.
|
|
||||||
|
|
||||||
1. 문제를 정확히 만든다.
|
|
||||||
2. 문제를 화면에 보여준다.
|
|
||||||
3. 사용자의 답을 채점한다.
|
|
||||||
|
|
||||||
상태 관리와 애니메이션은 이 기본 동작이 확인된 뒤 붙이는 편이 단계별 바이브 코딩에 더 잘 맞는다.
|
|
||||||
|
|
||||||
## 단계별 바이브 코딩 진행 방식
|
|
||||||
|
|
||||||
매 단계마다 아래 순서를 따른다.
|
|
||||||
|
|
||||||
```text
|
|
||||||
1. 이번 단계 목표를 한 문장으로 정한다
|
|
||||||
2. 수정할 파일을 2~4개 이하로 제한한다
|
|
||||||
3. 완료 기준을 명확히 둔다
|
|
||||||
4. 실행해서 직접 확인한다
|
|
||||||
5. 다음 단계로 넘어가기 전에 짧게 회고한다
|
|
||||||
```
|
|
||||||
|
|
||||||
## 다음 요청 예시
|
|
||||||
|
|
||||||
첫 개발 요청은 이렇게 주면 좋다.
|
|
||||||
|
|
||||||
> 1차 이터레이션으로 Next.js 14 프로젝트를 초기화하고, 시작 화면과 `/play` 페이지 이동까지만 구현해줘. AGENTS.md 규칙을 따르고, 아직 문제 생성 엔진이나 DB/Auth/Redis는 만들지 마.
|
|
||||||
|
|
||||||
그 다음 요청은 이렇게 이어갈 수 있다.
|
|
||||||
|
|
||||||
> 2차 이터레이션으로 `lib/engine/`에 괄호 앞 음수 분배 문제 생성 엔진과 Vitest 단위 테스트를 추가해줘. UI는 건드리지 마.
|
|
||||||
|
|
||||||
이런 식으로 “이번 단계에서 하지 않을 것”을 함께 말하면 범위가 잘 고정된다.
|
|
||||||
|
|
||||||
현재 프로젝트는 아직 파일이 거의 없는 초기 상태이므로, 위 방식으로 진행하면 작은 단위로 확인하면서 안정적으로 기능을 쌓아갈 수 있다.
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
# 01. 프로젝트 시작 화면과 라우팅
|
|
||||||
|
|
||||||
## 목표
|
|
||||||
|
|
||||||
Next.js 14 프로젝트를 초기화하고, 시작 화면에서 문제 풀이 화면(`/play`)으로 이동할 수 있는 가장 작은 앱 흐름을 만든다.
|
|
||||||
|
|
||||||
## 이번 단계에서 할 것
|
|
||||||
|
|
||||||
- Next.js 14 App Router 기반 프로젝트를 초기화한다.
|
|
||||||
- TypeScript, Tailwind CSS, ESLint를 함께 설정한다.
|
|
||||||
- 루트 시작 화면(`/`)을 만든다.
|
|
||||||
- 문제 풀이 화면(`/play`)을 만든다.
|
|
||||||
- 시작 화면의 버튼 또는 링크를 통해 `/play`로 이동할 수 있게 한다.
|
|
||||||
- `pnpm dev`로 앱 실행을 확인한다.
|
|
||||||
|
|
||||||
## 이번 단계에서 하지 않을 것
|
|
||||||
|
|
||||||
- 문제 생성 엔진 구현
|
|
||||||
- KaTeX 수식 렌더링
|
|
||||||
- 답 입력 및 채점 UI
|
|
||||||
- Zustand 상태 관리
|
|
||||||
- Framer Motion 애니메이션
|
|
||||||
- DB, Prisma, Redis, NextAuth 설정
|
|
||||||
- 유형 선택 화면
|
|
||||||
- 모드 선택 화면
|
|
||||||
- 테스트 환경 구성
|
|
||||||
|
|
||||||
## 수정 또는 생성 예상 파일
|
|
||||||
|
|
||||||
프로젝트 초기화 후 다음 파일들이 핵심 확인 대상이 된다.
|
|
||||||
|
|
||||||
- `app/layout.tsx`
|
|
||||||
- `app/page.tsx`
|
|
||||||
- `app/(game)/play/page.tsx`
|
|
||||||
- `app/globals.css`
|
|
||||||
- `package.json`
|
|
||||||
- `tailwind.config.ts`
|
|
||||||
- `tsconfig.json`
|
|
||||||
|
|
||||||
## 작업 순서
|
|
||||||
|
|
||||||
1. `pnpm create next-app@14 . --typescript --tailwind --eslint --app --no-src-dir --import-alias "@/*"`로 프로젝트를 초기화한다.
|
|
||||||
2. 생성된 기본 파일 구조를 확인한다.
|
|
||||||
3. `app/page.tsx`를 시작 화면으로 정리한다.
|
|
||||||
4. `app/(game)/play/page.tsx`를 추가해 빈 풀이 화면을 만든다.
|
|
||||||
5. 시작 화면에서 `/play`로 이동하는 링크를 추가한다.
|
|
||||||
6. `pnpm dev`를 실행해 브라우저에서 화면 이동을 확인한다.
|
|
||||||
|
|
||||||
## 화면 흐름
|
|
||||||
|
|
||||||
```text
|
|
||||||
/ 시작 화면
|
|
||||||
- 괄호 음수 분배 연습 시작
|
|
||||||
- 시작 버튼 클릭
|
|
||||||
|
|
||||||
↓
|
|
||||||
|
|
||||||
/play 풀이 화면
|
|
||||||
- 아직 실제 문제는 표시하지 않음
|
|
||||||
- 다음 단계에서 문제 생성 엔진과 연결 예정
|
|
||||||
```
|
|
||||||
|
|
||||||
## 시작 화면 초안
|
|
||||||
|
|
||||||
시작 화면은 기능 소개보다 바로 연습을 시작할 수 있는 간단한 화면으로 만든다.
|
|
||||||
|
|
||||||
- 제목: 괄호 음수 분배 연습
|
|
||||||
- 보조 문구: 괄호 앞의 음수를 분배할 때 부호가 어떻게 바뀌는지 연습한다.
|
|
||||||
- 주요 액션: 연습 시작
|
|
||||||
|
|
||||||
## 풀이 화면 초안
|
|
||||||
|
|
||||||
`/play` 화면은 아직 실제 문제 풀이 기능을 넣지 않는다.
|
|
||||||
|
|
||||||
- 제목: 문제 풀이
|
|
||||||
- 보조 문구: 다음 단계에서 괄호 음수 분배 문제가 표시된다.
|
|
||||||
- 돌아가기 링크: 처음으로
|
|
||||||
|
|
||||||
## 완료 기준
|
|
||||||
|
|
||||||
- `pnpm dev`가 정상 실행된다.
|
|
||||||
- 브라우저에서 `/` 페이지가 열린다.
|
|
||||||
- 시작 화면에서 “연습 시작”을 누르면 `/play`로 이동한다.
|
|
||||||
- `/play`에서 “처음으로”를 누르면 `/`로 돌아온다.
|
|
||||||
- 콘솔에 명백한 런타임 오류가 없다.
|
|
||||||
|
|
||||||
## 확인 명령
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm dev
|
|
||||||
```
|
|
||||||
|
|
||||||
필요하면 초기화 직후 다음 명령으로 정적 검사를 확인한다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm lint
|
|
||||||
```
|
|
||||||
|
|
||||||
## 작업 결과
|
|
||||||
|
|
||||||
- Next.js 14 App Router 프로젝트를 초기화했다.
|
|
||||||
- TypeScript, Tailwind CSS, ESLint 설정을 추가했다.
|
|
||||||
- `/` 시작 화면을 만들었다.
|
|
||||||
- `/play` 풀이 화면을 만들었다.
|
|
||||||
- 시작 화면의 “연습 시작” 링크로 `/play` 이동을 확인했다.
|
|
||||||
- `/play` 화면의 “처음으로” 링크로 `/` 복귀를 확인했다.
|
|
||||||
- `pnpm lint` 통과를 확인했다.
|
|
||||||
- `pnpm dev` 실행 후 브라우저에서 화면 이동을 확인했다.
|
|
||||||
|
|
||||||
## 다음 단계
|
|
||||||
|
|
||||||
`02-parenthesis-sign-engine.md`에서 괄호 앞 음수 분배 문제 생성 엔진을 만든다.
|
|
||||||
|
|
||||||
다음 단계에서는 UI를 확장하지 않고, `lib/engine/` 아래에 순수 TypeScript 함수와 단위 테스트를 먼저 추가한다.
|
|
||||||
@@ -1,358 +0,0 @@
|
|||||||
# 02. 괄호 앞 음수 분배 — 간단한 풀이 화면
|
|
||||||
|
|
||||||
## 계획 평가
|
|
||||||
|
|
||||||
Claude Code가 작성한 기존 계획은 방향이 맞다. `01-project-start.md`에서 만든 `/` -> `/play` 흐름 위에 문제 생성 엔진, KaTeX 표시, 답 입력, 채점, 10문제 세션을 붙이는 순서가 자연스럽다.
|
|
||||||
|
|
||||||
다만 실제 구현 단계로 쓰기에는 몇 가지를 조정하는 편이 좋다.
|
|
||||||
|
|
||||||
- 엔진 단위 테스트만 하는 단계에서는 `@vitejs/plugin-react`, `jsdom`, `@testing-library/react`가 아직 필요하지 않다.
|
|
||||||
- `generateSession()`의 중복 방지는 랜덤 `id`가 아니라 `multiplier/a/operator/b` 조합 기준이어야 한다.
|
|
||||||
- Zustand는 한 문제 풀이가 동작한 뒤 도입한다. 이 순서를 지키면 버그가 엔진, UI, 상태 관리 중 어디에서 생겼는지 분리하기 쉽다.
|
|
||||||
- `PlayClient`에서 세션을 초기화할 때 렌더링 중 `initSession()`을 직접 호출하지 않고 `useEffect`에서 호출해야 한다.
|
|
||||||
- 이번 문서는 “간단한 풀이 화면”이 목표이므로 Framer Motion, 정교한 진행 바, 배너 디자인, E2E는 다음 단계로 넘긴다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 목표
|
|
||||||
|
|
||||||
`/play` 화면에서 **괄호 앞 음수 분배** 문제를 10개 풀고 점수를 확인할 수 있게 만든다.
|
|
||||||
|
|
||||||
이번 단계의 최종 상태는 다음과 같다.
|
|
||||||
|
|
||||||
- 자체 알고리즘으로 괄호 앞 음수 분배 문제를 생성한다.
|
|
||||||
- 문제와 정답 수식을 KaTeX로 렌더링한다.
|
|
||||||
- 사용자가 두 항의 답을 입력하고 채점 결과를 확인한다.
|
|
||||||
- 10문제 풀이가 끝나면 점수 요약을 보여준다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 이번 단계에서 할 것
|
|
||||||
|
|
||||||
- `lib/engine/`에 문제 생성·채점 순수 함수와 Vitest 단위 테스트 추가
|
|
||||||
- `components/math/`에 KaTeX 렌더링 컴포넌트 추가
|
|
||||||
- `/play`에 한 문제 풀이 UI 구현
|
|
||||||
- 한 문제 풀이 확인 후 `store/gameStore.ts`로 10문제 세션 구현
|
|
||||||
- 세션 완료 후 간단한 점수 요약 표시
|
|
||||||
|
|
||||||
## 이번 단계에서 하지 않을 것
|
|
||||||
|
|
||||||
- Framer Motion 애니메이션
|
|
||||||
- 정교한 ProgressBar, FeedbackBanner, ScoreSummary 디자인 컴포넌트화
|
|
||||||
- DB, Prisma, Redis, NextAuth
|
|
||||||
- 유형 선택 화면, 모드 선택 화면
|
|
||||||
- 스피드 퀴즈 모드
|
|
||||||
- 미지수 `x`, `y`, `z`가 포함된 문제
|
|
||||||
- Playwright E2E 테스트
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 의존성
|
|
||||||
|
|
||||||
필요한 시점에 나누어 설치한다.
|
|
||||||
|
|
||||||
### 엔진 테스트
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm add -D vitest
|
|
||||||
```
|
|
||||||
|
|
||||||
### 수식 렌더링과 세션 상태
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm add katex react-katex zustand
|
|
||||||
pnpm add -D @types/katex
|
|
||||||
```
|
|
||||||
|
|
||||||
React 컴포넌트 테스트를 추가하지 않는 한 `@testing-library/react`, `jsdom`, `@vitejs/plugin-react`는 이번 단계에 넣지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 생성 또는 수정 파일
|
|
||||||
|
|
||||||
### 신규 생성
|
|
||||||
|
|
||||||
```text
|
|
||||||
lib/engine/types.ts
|
|
||||||
lib/engine/parenthesis-sign.ts
|
|
||||||
lib/engine/parenthesis-sign.test.ts
|
|
||||||
vitest.config.ts
|
|
||||||
components/math/KatexRenderer.tsx
|
|
||||||
store/gameStore.ts
|
|
||||||
app/(game)/play/PlayClient.tsx
|
|
||||||
```
|
|
||||||
|
|
||||||
### 수정
|
|
||||||
|
|
||||||
```text
|
|
||||||
package.json # test script 추가
|
|
||||||
app/layout.tsx # KaTeX CSS import 추가
|
|
||||||
app/(game)/play/page.tsx # PlayClient 마운트
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 작업 순서
|
|
||||||
|
|
||||||
### 2-A. 문제 생성 엔진
|
|
||||||
|
|
||||||
완료 기준: `pnpm test` 통과
|
|
||||||
|
|
||||||
`lib/engine/types.ts`
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export interface ParenthesisSignProblem {
|
|
||||||
id: string
|
|
||||||
key: string
|
|
||||||
multiplier: number
|
|
||||||
a: number
|
|
||||||
operator: '+' | '-'
|
|
||||||
b: number
|
|
||||||
distributedA: number
|
|
||||||
distributedB: number
|
|
||||||
latexBefore: string
|
|
||||||
latexAfter: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserAnswer {
|
|
||||||
a: number
|
|
||||||
b: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GradeResult {
|
|
||||||
correct: boolean
|
|
||||||
correctAnswer: [number, number]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`lib/engine/parenthesis-sign.ts`에 구현할 함수:
|
|
||||||
|
|
||||||
- `generateProblem(): ParenthesisSignProblem`
|
|
||||||
- `multiplier`: `-9`부터 `-1` 사이 정수
|
|
||||||
- `a`, `b`: `1`부터 `9` 사이 정수
|
|
||||||
- `a !== b`
|
|
||||||
- `operator`: `'+' | '-'`
|
|
||||||
- `distributedA = multiplier * a`
|
|
||||||
- `distributedB = operator === '-' ? multiplier * -b : multiplier * b`
|
|
||||||
- `key`는 `multiplier:a:operator:b` 형식으로 만든다.
|
|
||||||
- `latexBefore`: 예시 `-2(3 - 6)`
|
|
||||||
- `latexAfter`: 예시 `-6 + 12`
|
|
||||||
|
|
||||||
- `generateSession(count = 10): ParenthesisSignProblem[]`
|
|
||||||
- `count`가 `1` 이상인지 확인한다.
|
|
||||||
- 가능한 문제 수는 `9 * 8 * 2 = 144`개이므로 `count > 144`이면 에러를 던진다.
|
|
||||||
- 중복 방지는 `id`가 아니라 `key` 기준으로 한다.
|
|
||||||
|
|
||||||
- `gradeAnswer(problem, answer): GradeResult`
|
|
||||||
- 두 항이 모두 일치할 때만 정답이다.
|
|
||||||
- 입력 순서를 바꾸어 쓴 답은 오답으로 처리한다.
|
|
||||||
|
|
||||||
`lib/engine/parenthesis-sign.test.ts` 검증 항목:
|
|
||||||
|
|
||||||
- `multiplier`는 항상 음수 정수다.
|
|
||||||
- `a`, `b`는 범위 안에 있고 서로 다르다.
|
|
||||||
- `distributedA`, `distributedB` 계산이 `operator`별로 맞다.
|
|
||||||
- `gradeAnswer()`는 정답과 오답을 정확히 구분한다.
|
|
||||||
- `generateSession(10)`은 10개 문제를 만들고 `key` 중복이 없다.
|
|
||||||
- `generateSession(145)`는 에러를 던진다.
|
|
||||||
|
|
||||||
`vitest.config.ts`
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { defineConfig } from 'vitest/config'
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
test: {
|
|
||||||
environment: 'node',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
`package.json`
|
|
||||||
|
|
||||||
```json
|
|
||||||
"test": "vitest run"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2-B. KaTeX 렌더링
|
|
||||||
|
|
||||||
완료 기준: 브라우저에서 문제 수식이 KaTeX로 표시됨
|
|
||||||
|
|
||||||
`app/layout.tsx`에 전역 CSS를 추가한다.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import 'katex/dist/katex.min.css'
|
|
||||||
```
|
|
||||||
|
|
||||||
`components/math/KatexRenderer.tsx`
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { BlockMath, InlineMath } from 'react-katex'
|
|
||||||
|
|
||||||
interface KatexRendererProps {
|
|
||||||
latex: string
|
|
||||||
displayMode?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function KatexRenderer({
|
|
||||||
latex,
|
|
||||||
displayMode = false,
|
|
||||||
}: KatexRendererProps) {
|
|
||||||
return displayMode ? <BlockMath math={latex} /> : <InlineMath math={latex} />
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
이 단계에서는 `/play`에 임시 문제 하나를 표시해서 KaTeX 렌더링만 확인한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2-C. 한 문제 풀이 UI
|
|
||||||
|
|
||||||
완료 기준: 한 문제를 풀고 정답·오답 피드백이 표시됨
|
|
||||||
|
|
||||||
`app/(game)/play/PlayClient.tsx`를 클라이언트 컴포넌트로 만든다.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
'use client'
|
|
||||||
```
|
|
||||||
|
|
||||||
구성:
|
|
||||||
|
|
||||||
- 문제 수식: `KatexRenderer`로 `problem.latexBefore` 표시
|
|
||||||
- 답 입력: 숫자 입력 두 칸
|
|
||||||
- 제출 버튼
|
|
||||||
- Enter 제출
|
|
||||||
- 정답이면 간단한 성공 문구
|
|
||||||
- 오답이면 정답 수식과 정답 두 항 표시
|
|
||||||
- 새 문제 버튼
|
|
||||||
|
|
||||||
로컬 상태:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const [problem, setProblem] = useState<ParenthesisSignProblem>(() => generateProblem())
|
|
||||||
const [answerA, setAnswerA] = useState('')
|
|
||||||
const [answerB, setAnswerB] = useState('')
|
|
||||||
const [result, setResult] = useState<GradeResult | null>(null)
|
|
||||||
```
|
|
||||||
|
|
||||||
입력 처리:
|
|
||||||
|
|
||||||
- 빈 문자열은 제출하지 않는다.
|
|
||||||
- `Number.parseInt(value, 10)` 결과가 `NaN`이면 제출하지 않는다.
|
|
||||||
- 채점 후에는 같은 문제에서 중복 제출하지 않도록 제출 버튼을 비활성화한다.
|
|
||||||
|
|
||||||
이 단계에서는 Zustand를 사용하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2-D. 10문제 세션
|
|
||||||
|
|
||||||
완료 기준: 10문제를 모두 풀면 점수 요약 화면이 표시됨
|
|
||||||
|
|
||||||
`store/gameStore.ts`
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface GameSession {
|
|
||||||
problems: ParenthesisSignProblem[]
|
|
||||||
currentIndex: number
|
|
||||||
results: (GradeResult | null)[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GameState {
|
|
||||||
session: GameSession | null
|
|
||||||
initSession: (count?: number) => void
|
|
||||||
submitAnswer: (answer: UserAnswer) => void
|
|
||||||
nextProblem: () => void
|
|
||||||
resetSession: () => void
|
|
||||||
currentProblem: () => ParenthesisSignProblem | null
|
|
||||||
currentResult: () => GradeResult | null
|
|
||||||
isComplete: () => boolean
|
|
||||||
score: () => { correct: number; total: number }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
상태 규칙:
|
|
||||||
|
|
||||||
- `initSession(10)`은 10개 문제와 `null` 결과 배열을 만든다.
|
|
||||||
- `submitAnswer()`는 현재 문제의 결과가 `null`일 때만 채점한다.
|
|
||||||
- `nextProblem()`은 채점 후에만 다음 문제로 이동한다.
|
|
||||||
- 마지막 문제에서 `nextProblem()`을 호출하면 완료 상태가 된다.
|
|
||||||
- `resetSession()`은 기존 세션을 버리고 새 세션을 시작한다.
|
|
||||||
|
|
||||||
`PlayClient.tsx` 전환:
|
|
||||||
|
|
||||||
- `useEffect`에서 `session === null`이면 `initSession()` 호출
|
|
||||||
- `currentProblem() === null`이면 로딩 또는 완료 화면 표시
|
|
||||||
- 풀이 중에는 현재 문제 번호를 텍스트로 표시한다. 예: `3 / 10`
|
|
||||||
- 세션 완료 후 맞은 개수와 전체 개수를 표시한다.
|
|
||||||
- 다시 시작 버튼은 `resetSession()`을 호출한다.
|
|
||||||
|
|
||||||
`app/(game)/play/page.tsx`
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import PlayClient from './PlayClient'
|
|
||||||
|
|
||||||
export default function PlayPage() {
|
|
||||||
return <PlayClient />
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 화면 흐름
|
|
||||||
|
|
||||||
```text
|
|
||||||
/ 시작 화면
|
|
||||||
- "연습 시작" 클릭
|
|
||||||
|
|
||||||
↓
|
|
||||||
|
|
||||||
/play 풀이 화면
|
|
||||||
├─ 현재 문제 번호
|
|
||||||
├─ 문제 수식 (KaTeX)
|
|
||||||
├─ 답 입력 두 칸
|
|
||||||
├─ 제출 버튼
|
|
||||||
├─ 정답/오답 피드백
|
|
||||||
├─ 다음 문제 버튼
|
|
||||||
└─ 10문제 완료 후 점수 요약
|
|
||||||
- n / 10 정답
|
|
||||||
- 다시 시작
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 완료 기준
|
|
||||||
|
|
||||||
- `pnpm test` 통과
|
|
||||||
- `pnpm lint` 통과
|
|
||||||
- 브라우저에서 `/play` 수식이 KaTeX로 렌더링된다.
|
|
||||||
- 정답 입력 시 정답 피드백이 표시된다.
|
|
||||||
- 오답 입력 시 오답 피드백과 정답이 표시된다.
|
|
||||||
- 10문제를 모두 풀면 점수 요약 화면이 표시된다.
|
|
||||||
- 다시 시작을 누르면 새 10문제 세션이 시작된다.
|
|
||||||
|
|
||||||
## 확인 명령
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm test
|
|
||||||
pnpm lint
|
|
||||||
pnpm dev
|
|
||||||
```
|
|
||||||
|
|
||||||
브라우저 확인은 `pnpm dev` 실행 후 `http://localhost:3000/play`에서 한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 다음 단계
|
|
||||||
|
|
||||||
`03-ux-polish.md`에서 UI 품질을 높인다.
|
|
||||||
|
|
||||||
- 진행률 바
|
|
||||||
- 정답·오답 배너 컴포넌트
|
|
||||||
- 점수 요약 컴포넌트
|
|
||||||
- Framer Motion 기반 전환 애니메이션
|
|
||||||
- 키보드 탐색과 포커스 처리 개선
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
# 03. 학습 내용 선택과 숫자 괄호 준비 화면
|
|
||||||
|
|
||||||
## 계획 평가
|
|
||||||
|
|
||||||
Codex가 작성한 기존 계획은 방향이 맞다. `StartControls.tsx` 이동, `/number-parentheses` 라우트 신설, 첫 화면을 학습 내용 선택으로 전환하는 흐름이 현재 코드 구조와 잘 맞는다.
|
|
||||||
|
|
||||||
다만 실제 코드 상태와 맞추어 조정할 부분이 있다.
|
|
||||||
|
|
||||||
- `PlayClient`는 URL `?count=N`으로 문제 수를 받고, `gameStore`에 `selectedProblemCount`와 `setProblemCount`가 이미 구현되어 있다. 준비 화면에서 이 흐름을 그대로 이어받으면 된다.
|
|
||||||
- 괄호 종류 선택은 이번 단계에서 UI만 추가한다. `()`만 선택 가능하고 `{}`, `[]`는 disabled로 표시한다. 괄호 종류를 Zustand나 URL에 저장하는 것은 실제 문제 생성이 지원할 때로 미룬다.
|
|
||||||
- `app/StartControls.tsx`는 준비 화면으로 내용을 옮긴 뒤 삭제한다.
|
|
||||||
- 문제 풀이 화면(`PlayClient.tsx`, `play/page.tsx`)은 이번 단계에서 수정하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 목표
|
|
||||||
|
|
||||||
앞으로 학습 내용을 계속 추가할 수 있도록 첫 화면을 학습 내용 선택 화면으로 바꾸고, 현재 숫자 괄호 풀이 시작 기능을 별도 준비 화면으로 분리한다.
|
|
||||||
|
|
||||||
이번 단계의 최종 상태는 다음과 같다.
|
|
||||||
|
|
||||||
- `/`에서 학습 내용을 선택한다.
|
|
||||||
- `숫자 괄호`를 선택하면 문제 수와 괄호 종류를 고르는 준비 화면으로 이동한다.
|
|
||||||
- 분수 괄호, 실수 괄호, 미지수 괄호는 버튼만 표시하고 비활성화한다.
|
|
||||||
- `/play` 문제 풀이 화면과 풀이 로직은 현재 동작 그대로 유지한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 이번 단계에서 할 것
|
|
||||||
|
|
||||||
- `app/page.tsx`를 학습 내용 선택 화면으로 교체
|
|
||||||
- `app/(game)/number-parentheses/page.tsx`, `NumberParenthesesClient.tsx` 신규 생성
|
|
||||||
- `StartControls.tsx`의 문제 수 선택과 연습 시작 흐름을 준비 화면으로 이동
|
|
||||||
- 준비 화면에 괄호 종류 버튼 그룹 추가 (`()` 선택 가능, `{}` / `[]` disabled)
|
|
||||||
- `app/StartControls.tsx` 삭제
|
|
||||||
|
|
||||||
## 이번 단계에서 하지 않을 것
|
|
||||||
|
|
||||||
- 분수 괄호, 실수 괄호, 미지수 괄호 문제 생성
|
|
||||||
- `{}`, `[]` 괄호 문제 생성
|
|
||||||
- 괄호 종류를 Zustand나 URL에 저장
|
|
||||||
- 문제 풀이 화면 UI 수정
|
|
||||||
- 새로운 게임 모드 추가
|
|
||||||
- DB, Redis, NextAuth
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 생성 또는 수정 파일
|
|
||||||
|
|
||||||
### 신규 생성
|
|
||||||
|
|
||||||
```text
|
|
||||||
app/(game)/number-parentheses/page.tsx
|
|
||||||
app/(game)/number-parentheses/NumberParenthesesClient.tsx
|
|
||||||
```
|
|
||||||
|
|
||||||
### 수정
|
|
||||||
|
|
||||||
```text
|
|
||||||
app/page.tsx
|
|
||||||
```
|
|
||||||
|
|
||||||
### 삭제
|
|
||||||
|
|
||||||
```text
|
|
||||||
app/StartControls.tsx
|
|
||||||
```
|
|
||||||
|
|
||||||
### 변경 없음
|
|
||||||
|
|
||||||
```text
|
|
||||||
app/(game)/play/page.tsx
|
|
||||||
app/(game)/play/PlayClient.tsx
|
|
||||||
store/gameStore.ts
|
|
||||||
lib/engine/
|
|
||||||
components/
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 작업 순서
|
|
||||||
|
|
||||||
### 3-A. 숫자 괄호 준비 화면 신설
|
|
||||||
|
|
||||||
완료 기준: `/number-parentheses`에서 문제 수를 고르고 연습을 시작할 수 있다.
|
|
||||||
|
|
||||||
`app/(game)/number-parentheses/page.tsx`는 Server Component 래퍼로 만든다.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import NumberParenthesesClient from './NumberParenthesesClient'
|
|
||||||
|
|
||||||
export default function NumberParenthesesPage() {
|
|
||||||
return <NumberParenthesesClient />
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`app/(game)/number-parentheses/NumberParenthesesClient.tsx`에는 `'use client'`를 붙이고, 현재 `StartControls.tsx`의 내용을 기반으로 구성한다.
|
|
||||||
|
|
||||||
**문제 수 버튼 그룹**
|
|
||||||
|
|
||||||
현재 `StartControls.tsx`의 문제 수 선택 로직을 그대로 가져온다.
|
|
||||||
|
|
||||||
- `useGameStore`에서 `selectedProblemCount`, `setProblemCount`, `initSession`을 가져온다.
|
|
||||||
- `5`, `10` 버튼 그룹 (기본값 `10`)
|
|
||||||
- 선택된 문제 수는 `selectedProblemCount`로 표시한다.
|
|
||||||
|
|
||||||
**괄호 종류 버튼 그룹**
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
type BracketKind = 'round' | 'curly' | 'square'
|
|
||||||
|
|
||||||
const brackets: { kind: BracketKind; label: string; disabled: boolean }[] = [
|
|
||||||
{ kind: 'round', label: '( )', disabled: false },
|
|
||||||
{ kind: 'curly', label: '{ }', disabled: true },
|
|
||||||
{ kind: 'square', label: '[ ]', disabled: true },
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
- `round`는 항상 selected 상태로 고정 표시한다.
|
|
||||||
- `curly`, `square`는 disabled 스타일을 적용하고 클릭을 막는다.
|
|
||||||
- 괄호 종류 상태를 useState나 Zustand에 저장하지 않는다. 이번 단계에서는 `()` 하나만 유효하다.
|
|
||||||
|
|
||||||
**연습 시작 버튼**
|
|
||||||
|
|
||||||
현재 `StartControls.tsx`의 Link를 그대로 가져온다.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<Link
|
|
||||||
href={`/play?count=${selectedProblemCount}`}
|
|
||||||
onClick={() => initSession(selectedProblemCount)}
|
|
||||||
>
|
|
||||||
연습 시작
|
|
||||||
</Link>
|
|
||||||
```
|
|
||||||
|
|
||||||
**뒤로 가기 링크**
|
|
||||||
|
|
||||||
준비 화면에서 첫 화면으로 돌아갈 수 있도록 "처음으로" 링크를 추가한다.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<Link href="/">처음으로</Link>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3-B. 첫 화면을 학습 내용 선택으로 교체
|
|
||||||
|
|
||||||
완료 기준: `/`에서 네 가지 학습 내용 버튼이 표시된다.
|
|
||||||
|
|
||||||
`app/page.tsx`를 수정한다.
|
|
||||||
|
|
||||||
- 제목과 안내 문구를 여러 학습 내용을 선택하는 맥락으로 바꾼다.
|
|
||||||
- `StartControls` 호출을 제거한다.
|
|
||||||
- 학습 내용 목록을 렌더링한다.
|
|
||||||
|
|
||||||
버튼 목록:
|
|
||||||
|
|
||||||
| 학습 내용 | 상태 | 이동 |
|
|
||||||
|---|---|---|
|
|
||||||
| 숫자 괄호 | 활성 | `/number-parentheses` |
|
|
||||||
| 분수 괄호 | disabled | — |
|
|
||||||
| 실수 괄호 | disabled | — |
|
|
||||||
| 미지수 괄호 | disabled | — |
|
|
||||||
|
|
||||||
활성 버튼은 `Link`로, disabled 버튼은 `<button disabled>`으로 만든다.
|
|
||||||
클라이언트 기능이 없으므로 `page.tsx` 자체에 인라인으로 구성하고 별도 컴포넌트로 분리하지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3-C. StartControls.tsx 삭제 및 회귀 확인
|
|
||||||
|
|
||||||
완료 기준: `숫자 괄호`부터 결과 화면까지 기존 풀이가 동작한다.
|
|
||||||
|
|
||||||
`app/StartControls.tsx`를 삭제한다.
|
|
||||||
|
|
||||||
삭제 전에 `StartControls`를 참조하는 파일이 없는지 확인한다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -r "StartControls" /Users/jisangs/work/jinaju/git/web/math-quiz/app
|
|
||||||
```
|
|
||||||
|
|
||||||
풀이 흐름을 처음부터 끝까지 수동으로 확인한다.
|
|
||||||
|
|
||||||
1. `/` 접속 → 학습 내용 선택 화면 표시 확인
|
|
||||||
2. `숫자 괄호` 클릭 → `/number-parentheses` 이동 확인
|
|
||||||
3. 문제 수 `5` 선택 → `연습 시작` 클릭 → `/play?count=5` 이동 확인
|
|
||||||
4. 5문제 세션 완료 → 점수 요약 표시 확인
|
|
||||||
5. `다시 시작` 클릭 → 새 세션 확인
|
|
||||||
6. `/number-parentheses`로 돌아와 문제 수 `10` 선택 → `/play?count=10` 이동 확인
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 화면 흐름
|
|
||||||
|
|
||||||
```text
|
|
||||||
/ 학습 내용 선택
|
|
||||||
- [숫자 괄호 풀기] → /number-parentheses
|
|
||||||
- [분수 괄호 풀기] disabled
|
|
||||||
- [실수 괄호 풀기] disabled
|
|
||||||
- [미지수 괄호 풀기] disabled
|
|
||||||
|
|
||||||
↓
|
|
||||||
|
|
||||||
/number-parentheses 숫자 괄호 준비
|
|
||||||
- 문제 수: [5] [10]
|
|
||||||
- 괄호 종류: [( )] [{} disabled] [[] disabled]
|
|
||||||
- [연습 시작] → /play?count=N
|
|
||||||
- [처음으로] → /
|
|
||||||
|
|
||||||
↓
|
|
||||||
|
|
||||||
/play 문제 풀이 (현재 동작 유지)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 완료 기준
|
|
||||||
|
|
||||||
- `/`가 학습 내용 선택 화면으로 표시된다.
|
|
||||||
- `숫자 괄호` 버튼으로 `/number-parentheses`에 진입할 수 있다.
|
|
||||||
- `분수 괄호`, `실수 괄호`, `미지수 괄호`는 disabled 상태다.
|
|
||||||
- 준비 화면에서 문제 수 `5`, `10`을 선택할 수 있다.
|
|
||||||
- 준비 화면에서 `()` 괄호 종류가 선택 상태로 표시된다.
|
|
||||||
- `{}`, `[]` 괄호 종류는 disabled 상태다.
|
|
||||||
- `연습 시작`으로 기존 `/play` 문제 풀이 화면에 진입한다.
|
|
||||||
- `/play`의 문제 풀이 동작은 기존과 동일하다.
|
|
||||||
- `pnpm build` 통과
|
|
||||||
- `pnpm test` 통과
|
|
||||||
|
|
||||||
## 확인 명령
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm build
|
|
||||||
pnpm test
|
|
||||||
pnpm dev
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 다음 단계
|
|
||||||
|
|
||||||
새 학습 내용(분수 괄호, 실수 괄호, 미지수 괄호)을 추가할 때는 다음 순서로 확장한다.
|
|
||||||
|
|
||||||
1. `lib/engine/`에 해당 유형의 문제 생성 함수 추가
|
|
||||||
2. `app/(game)/number-parentheses/NumberParenthesesClient.tsx`의 괄호 종류 버튼에서 disabled 해제
|
|
||||||
3. 괄호 종류를 Zustand나 URL 파라미터로 저장하고 `initSession`에 파라미터 연결
|
|
||||||
@@ -1,413 +0,0 @@
|
|||||||
# 04. 미지수 괄호 학습과 선택지형 풀이 화면
|
|
||||||
|
|
||||||
## 계획 검토
|
|
||||||
|
|
||||||
Codex가 작성한 초안은 방향이 맞다. 엔진 설계, store 분리, 화면 구조가 기존 숫자 괄호 흐름과 일관성 있게 정리되어 있다.
|
|
||||||
|
|
||||||
조정이 필요한 부분:
|
|
||||||
|
|
||||||
- `VariableUserAnswer`의 `selectedChoiceIds`는 보기 id 기반 채점이므로 그대로 유지한다. 보기가 매번 섞이므로 계수 값 자체보다 id로 선택을 추적하는 것이 명확하다.
|
|
||||||
- 사용자가 요청한 "보기 하나는 음수, 보기 하나는 양수" 방식이 Codex 초안과 동일하다. 보기는 정답 계수의 절대값을 기준으로 양수/음수 두 개만 표시한다.
|
|
||||||
- 선택지 순서는 매번 셔플한다.
|
|
||||||
- 둘째 항 선택 후 `정답 확인` 버튼으로 명시적으로 제출한다. 자동 제출은 사용자가 선택을 확인할 여지 없이 넘어가므로 사용하지 않는다.
|
|
||||||
- 준비 화면은 `NumberParenthesesClient`와 구조가 동일하므로 처음에는 별도 파일로 구현한다. 중복이 불편해지면 그때 공통 컴포넌트로 분리한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 목표
|
|
||||||
|
|
||||||
미지수가 포함된 괄호 풀기 문제를 선택지 방식으로 풀 수 있는 학습 흐름을 추가한다.
|
|
||||||
|
|
||||||
이번 단계의 최종 상태:
|
|
||||||
|
|
||||||
- `/`에서 `미지수 괄호 풀기`가 활성 링크로 표시된다.
|
|
||||||
- `/variable-parentheses` 준비 화면에서 문제 수와 괄호 종류를 선택한다.
|
|
||||||
- `/variable-parentheses/play?count=N` 풀이 화면에서 첫째 항, 둘째 항 순서로 보기를 고르고 정답을 확인한다.
|
|
||||||
- 기존 숫자 괄호 풀이 화면은 현재 동작을 유지한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 이번 단계에서 할 것
|
|
||||||
|
|
||||||
- 첫 화면의 `미지수 괄호 풀기` disabled 해제 (`/variable-parentheses` 링크)
|
|
||||||
- 미지수 괄호 학습 준비 화면 추가
|
|
||||||
- 미지수 괄호 문제 생성 엔진 추가
|
|
||||||
- 미지수 괄호 전용 Zustand store 추가
|
|
||||||
- 미지수 괄호 전용 풀이 화면 추가 (선택지형)
|
|
||||||
- 정답·오답 피드백 및 결과 화면 추가
|
|
||||||
- 엔진 단위 테스트 추가
|
|
||||||
|
|
||||||
## 이번 단계에서 하지 않을 것
|
|
||||||
|
|
||||||
- 분수 괄호, 실수 괄호 문제 생성
|
|
||||||
- `{}`, `[]` 괄호 문제 생성
|
|
||||||
- 여러 미지수(`x, y, z`) 지원
|
|
||||||
- 기존 숫자 괄호 `/play` 화면 동작 변경
|
|
||||||
- 사용자가 직접 미지수 답을 타이핑하는 입력 방식
|
|
||||||
- DB, Redis, NextAuth 연동
|
|
||||||
- 스피드 퀴즈 모드
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 문제 생성 규칙
|
|
||||||
|
|
||||||
### 문제 형태
|
|
||||||
|
|
||||||
```text
|
|
||||||
multiplier(a ± b)
|
|
||||||
```
|
|
||||||
|
|
||||||
괄호 안 두 항 중 정확히 하나가 `정수 × x` 항이다.
|
|
||||||
|
|
||||||
예시:
|
|
||||||
|
|
||||||
```text
|
|
||||||
-2(3x - 6) → -6x + 12
|
|
||||||
-5(4 + 7x) → -20 - 35x
|
|
||||||
-3(8x + 2) → -24x - 6
|
|
||||||
```
|
|
||||||
|
|
||||||
### 생성 조건
|
|
||||||
|
|
||||||
| 항목 | 범위 |
|
|
||||||
|---|---|
|
|
||||||
| `multiplier` | `-9`~`-1` 정수 |
|
|
||||||
| 숫자 항 계수 | `1`~`9` 정수 |
|
|
||||||
| 미지수 항 계수 | `1`~`9` 정수 |
|
|
||||||
| 미지수 | `x` 고정 |
|
|
||||||
| 연산자 | `+` 또는 `-` |
|
|
||||||
| 미지수 항 위치 | 첫째 항 또는 둘째 항 (한 문제에 하나만) |
|
|
||||||
|
|
||||||
두 항 계수가 같아도 허용한다 (숫자 괄호와 달리 항의 종류가 다르므로 혼동이 없다).
|
|
||||||
|
|
||||||
### 정답 계산
|
|
||||||
|
|
||||||
- 첫째 항 정답: `multiplier × firstTerm.coefficient` (미지수 항이면 `x` 붙임)
|
|
||||||
- 둘째 항 정답: `multiplier × (operator === '-' ? -secondTerm.coefficient : secondTerm.coefficient)` (미지수 항이면 `x` 붙임)
|
|
||||||
|
|
||||||
### 보기 생성
|
|
||||||
|
|
||||||
정답 계수의 절대값을 기준으로 두 보기를 만든다.
|
|
||||||
|
|
||||||
```text
|
|
||||||
정답: -6x → 보기: -6x, 6x
|
|
||||||
정답: 12 → 보기: -12, 12
|
|
||||||
```
|
|
||||||
|
|
||||||
- 보기 두 개는 항 종류(미지수 항 / 숫자 항)가 같다.
|
|
||||||
- 보기 순서는 매번 셔플한다.
|
|
||||||
- 보기 `id`는 안정적으로 생성한다: `${problem.key}:term-0:negative`, `${problem.key}:term-0:positive`
|
|
||||||
|
|
||||||
### 중복 방지 key
|
|
||||||
|
|
||||||
```text
|
|
||||||
${multiplier}:${firstKind}:${firstCoeff}:${operator}:${secondKind}:${secondCoeff}
|
|
||||||
```
|
|
||||||
|
|
||||||
예: `-2:variable:3:-:constant:6`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 타입 설계
|
|
||||||
|
|
||||||
신규 파일: `lib/engine/variable-types.ts`
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export type VariableTermKind = 'constant' | 'variable'
|
|
||||||
|
|
||||||
export interface VariableParenthesisTerm {
|
|
||||||
kind: VariableTermKind
|
|
||||||
coefficient: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DistributedVariableTerm {
|
|
||||||
kind: VariableTermKind
|
|
||||||
coefficient: number
|
|
||||||
latex: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VariableTermChoice {
|
|
||||||
id: string
|
|
||||||
coefficient: number
|
|
||||||
kind: VariableTermKind
|
|
||||||
latex: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VariableParenthesisProblem {
|
|
||||||
id: string
|
|
||||||
key: string
|
|
||||||
multiplier: number
|
|
||||||
operator: '+' | '-'
|
|
||||||
firstTerm: VariableParenthesisTerm
|
|
||||||
secondTerm: VariableParenthesisTerm
|
|
||||||
correctTerms: [DistributedVariableTerm, DistributedVariableTerm]
|
|
||||||
choices: [VariableTermChoice[], VariableTermChoice[]]
|
|
||||||
latexBefore: string
|
|
||||||
latexAfter: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VariableUserAnswer {
|
|
||||||
selectedChoiceIds: [string, string]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VariableGradeResult {
|
|
||||||
correct: boolean
|
|
||||||
firstCorrect: boolean
|
|
||||||
secondCorrect: boolean
|
|
||||||
userChoiceIds: [string, string]
|
|
||||||
correctTerms: [DistributedVariableTerm, DistributedVariableTerm]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 엔진 설계
|
|
||||||
|
|
||||||
신규 파일: `lib/engine/variable-parenthesis.ts`
|
|
||||||
|
|
||||||
구현 함수:
|
|
||||||
|
|
||||||
- `generateVariableParenthesisSession(count?: number): VariableParenthesisProblem[]`
|
|
||||||
- `gradeVariableParenthesisAnswer(problem, answer): VariableGradeResult`
|
|
||||||
|
|
||||||
내부 헬퍼:
|
|
||||||
|
|
||||||
- `formatVariableTermLatex(kind, coefficient): string`
|
|
||||||
- `kind === 'variable'`: `6x`, `-6x`
|
|
||||||
- `kind === 'constant'`: `6`, `-6`
|
|
||||||
- `makeChoices(termIndex, key, distributedTerm): VariableTermChoice[]`
|
|
||||||
- 양수/음수 두 보기를 만들고 셔플
|
|
||||||
- `createAllVariableSeeds()`: 전체 가능 시드 목록 (최초 1회 생성)
|
|
||||||
- `shuffle()`: 기존 숫자 괄호 엔진과 동일한 방식
|
|
||||||
|
|
||||||
세션 생성 방식은 기존 숫자 괄호 엔진과 동일하게 전체 시드 셔플 후 슬라이스로 중복 방지한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 상태 관리 설계
|
|
||||||
|
|
||||||
신규 파일: `store/variableGameStore.ts`
|
|
||||||
|
|
||||||
기존 `gameStore.ts`와 동일한 구조로 분리한다.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface VariableGameState {
|
|
||||||
session: VariableGameSession | null
|
|
||||||
selectedProblemCount: ProblemCount
|
|
||||||
setProblemCount: (count: ProblemCount) => void
|
|
||||||
initSession: (count?: ProblemCount) => void
|
|
||||||
submitAnswer: (answer: VariableUserAnswer) => void
|
|
||||||
nextProblem: () => void
|
|
||||||
resetSession: () => void
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
주의:
|
|
||||||
|
|
||||||
- selector 안에서 새 객체·배열을 반환하지 않는다.
|
|
||||||
- 현재 선택 중인 보기 상태(`selectedChoiceIds`, `activeTermIndex`)는 `VariablePlayClient` 로컬 state로 관리한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 화면 설계
|
|
||||||
|
|
||||||
### 첫 화면 (`app/page.tsx`)
|
|
||||||
|
|
||||||
- `미지수 괄호 풀기` disabled 해제, `href="/variable-parentheses"` 추가
|
|
||||||
- 분수 괄호, 실수 괄호는 계속 disabled 유지
|
|
||||||
- 안내 문구를 `숫자 괄호`, `미지수 괄호` 두 개가 활성화된 상태로 수정
|
|
||||||
|
|
||||||
### 미지수 괄호 학습 준비 화면
|
|
||||||
|
|
||||||
신규 파일:
|
|
||||||
|
|
||||||
```text
|
|
||||||
app/(game)/variable-parentheses/page.tsx
|
|
||||||
app/(game)/variable-parentheses/VariableParenthesesClient.tsx
|
|
||||||
```
|
|
||||||
|
|
||||||
화면 구성:
|
|
||||||
|
|
||||||
- 문제 수: `5`, `10` 버튼 그룹 (기본값 `10`)
|
|
||||||
- 괄호 종류: `()` selected, `{}` disabled, `[]` disabled
|
|
||||||
- `연습 시작` 버튼 → `/variable-parentheses/play?count=${selectedProblemCount}` 이동, 클릭 시 세션 초기화
|
|
||||||
|
|
||||||
### 미지수 괄호 풀이 화면
|
|
||||||
|
|
||||||
신규 파일:
|
|
||||||
|
|
||||||
```text
|
|
||||||
app/(game)/variable-parentheses/play/page.tsx
|
|
||||||
app/(game)/variable-parentheses/play/VariablePlayClient.tsx
|
|
||||||
components/game/VariableChoiceAnswerForm.tsx
|
|
||||||
components/game/VariableFeedbackPanel.tsx
|
|
||||||
components/game/VariableScoreSummary.tsx
|
|
||||||
```
|
|
||||||
|
|
||||||
풀이 흐름:
|
|
||||||
|
|
||||||
```text
|
|
||||||
문제 표시 (KaTeX 카드)
|
|
||||||
↓
|
|
||||||
첫째 항 보기 2개 표시 → 선택
|
|
||||||
↓
|
|
||||||
둘째 항 보기 2개 표시 → 선택
|
|
||||||
↓
|
|
||||||
[정답 확인] 버튼 활성화 → 클릭
|
|
||||||
↓
|
|
||||||
피드백 표시 → [다음 문제] 또는 [결과 보기]
|
|
||||||
↓
|
|
||||||
최종 결과 화면
|
|
||||||
```
|
|
||||||
|
|
||||||
`VariableChoiceAnswerForm` props 초안:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface VariableChoiceAnswerFormProps {
|
|
||||||
choices: [VariableTermChoice[], VariableTermChoice[]]
|
|
||||||
selectedChoiceIds: [string | null, string | null]
|
|
||||||
activeTermIndex: 0 | 1
|
|
||||||
onSelectChoice: (termIndex: 0 | 1, choiceId: string) => void
|
|
||||||
onSubmit: () => void
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
접근성:
|
|
||||||
|
|
||||||
- 첫째 항 선택 후 둘째 항 첫 번째 보기 버튼에 focus 이동
|
|
||||||
- 두 항 모두 선택되면 `정답 확인` 버튼 활성화
|
|
||||||
- 피드백 표시 시 `다음 문제` 버튼에 focus 이동
|
|
||||||
- 결과 화면 표시 시 `다시 시작` 버튼에 focus 이동
|
|
||||||
|
|
||||||
`VariableFeedbackPanel` 표시 형식:
|
|
||||||
|
|
||||||
```text
|
|
||||||
(-2) × 3x = -6x ○
|
|
||||||
(-2) × (-6) = 12 ○
|
|
||||||
```
|
|
||||||
|
|
||||||
`VariableScoreSummary` 표시 형식:
|
|
||||||
|
|
||||||
```text
|
|
||||||
-2(3x - 6) = -6x + 12 ○
|
|
||||||
```
|
|
||||||
|
|
||||||
기존 `AnswerResultLine` 컴포넌트를 재사용한다. 항 표현이 복잡해지는 경우 `userAnswerContent` / `correctAnswerContent` prop으로 커스텀 렌더링한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 작업 순서
|
|
||||||
|
|
||||||
### 4-A. 미지수 괄호 엔진 추가
|
|
||||||
|
|
||||||
완료 기준: `pnpm test` 통과
|
|
||||||
|
|
||||||
- `lib/engine/variable-types.ts` 타입 정의
|
|
||||||
- `lib/engine/variable-parenthesis.ts` 엔진 구현
|
|
||||||
- `lib/engine/variable-parenthesis.test.ts` 단위 테스트
|
|
||||||
|
|
||||||
테스트 항목:
|
|
||||||
|
|
||||||
- 괄호 안 두 항 중 정확히 하나만 미지수 항이다
|
|
||||||
- 계수는 모두 정수다
|
|
||||||
- 정답 분배 계산이 맞다
|
|
||||||
- 둘째 항이 `-` 뒤에 있을 때 부호가 올바르게 뒤집힌다
|
|
||||||
- 각 항의 보기는 정답 절대값 기준 양수/음수 두 개다
|
|
||||||
- choice id 기준으로 채점이 올바르다
|
|
||||||
- 세션 문제 key가 중복되지 않는다
|
|
||||||
- 기존 숫자 괄호 엔진 테스트가 회귀 없이 통과한다
|
|
||||||
|
|
||||||
### 4-B. 미지수 괄호 store 추가
|
|
||||||
|
|
||||||
완료 기준: 세션 생성 및 진행 가능
|
|
||||||
|
|
||||||
- `store/variableGameStore.ts`
|
|
||||||
|
|
||||||
### 4-C. 준비 화면 추가
|
|
||||||
|
|
||||||
완료 기준: `/variable-parentheses` 접속 시 준비 화면이 표시된다
|
|
||||||
|
|
||||||
- `app/(game)/variable-parentheses/page.tsx`
|
|
||||||
- `app/(game)/variable-parentheses/VariableParenthesesClient.tsx`
|
|
||||||
|
|
||||||
### 4-D. 첫 화면 수정
|
|
||||||
|
|
||||||
완료 기준: `/`에서 `미지수 괄호 풀기`가 활성 링크로 표시된다
|
|
||||||
|
|
||||||
- `app/page.tsx` 수정
|
|
||||||
|
|
||||||
### 4-E. 선택지형 풀이 화면 추가
|
|
||||||
|
|
||||||
완료 기준: `/variable-parentheses/play?count=5`에서 문제를 풀 수 있다
|
|
||||||
|
|
||||||
- `app/(game)/variable-parentheses/play/page.tsx`
|
|
||||||
- `app/(game)/variable-parentheses/play/VariablePlayClient.tsx`
|
|
||||||
- `components/game/VariableChoiceAnswerForm.tsx`
|
|
||||||
|
|
||||||
### 4-F. 피드백 및 결과 화면 추가
|
|
||||||
|
|
||||||
완료 기준: 선택 결과에 따라 피드백과 최종 결과가 표시된다
|
|
||||||
|
|
||||||
- `components/game/VariableFeedbackPanel.tsx`
|
|
||||||
- `components/game/VariableScoreSummary.tsx`
|
|
||||||
|
|
||||||
### 4-G. 회귀 확인
|
|
||||||
|
|
||||||
완료 기준: 숫자 괄호와 미지수 괄호 흐름이 모두 동작한다
|
|
||||||
|
|
||||||
1. `/` → `숫자 괄호 풀기` → 준비 화면 → `/play?count=5` → 풀이 → 결과
|
|
||||||
2. `/` → `미지수 괄호 풀기` → 준비 화면 → `/variable-parentheses/play?count=5` → 풀이 → 결과
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 생성 또는 수정 파일
|
|
||||||
|
|
||||||
### 신규 생성
|
|
||||||
|
|
||||||
```text
|
|
||||||
lib/engine/variable-types.ts
|
|
||||||
lib/engine/variable-parenthesis.ts
|
|
||||||
lib/engine/variable-parenthesis.test.ts
|
|
||||||
store/variableGameStore.ts
|
|
||||||
app/(game)/variable-parentheses/page.tsx
|
|
||||||
app/(game)/variable-parentheses/VariableParenthesesClient.tsx
|
|
||||||
app/(game)/variable-parentheses/play/page.tsx
|
|
||||||
app/(game)/variable-parentheses/play/VariablePlayClient.tsx
|
|
||||||
components/game/VariableChoiceAnswerForm.tsx
|
|
||||||
components/game/VariableFeedbackPanel.tsx
|
|
||||||
components/game/VariableScoreSummary.tsx
|
|
||||||
```
|
|
||||||
|
|
||||||
### 수정
|
|
||||||
|
|
||||||
```text
|
|
||||||
app/page.tsx
|
|
||||||
```
|
|
||||||
|
|
||||||
### 변경 없음
|
|
||||||
|
|
||||||
```text
|
|
||||||
app/(game)/play/page.tsx
|
|
||||||
app/(game)/play/PlayClient.tsx
|
|
||||||
store/gameStore.ts
|
|
||||||
lib/engine/parenthesis-sign.ts
|
|
||||||
lib/engine/parenthesis-sign.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 완료 기준
|
|
||||||
|
|
||||||
- `/`에서 `미지수 괄호 풀기`가 활성화되어 있다
|
|
||||||
- `/variable-parentheses` 준비 화면에서 문제 수 `5`, `10`을 선택할 수 있다
|
|
||||||
- `()`가 selected 상태이고 `{}`, `[]`는 disabled 상태다
|
|
||||||
- `연습 시작`으로 `/variable-parentheses/play?count=N`에 진입한다
|
|
||||||
- 미지수 괄호 문제는 괄호 안 두 항 중 하나만 `정수x` 항을 포함한다
|
|
||||||
- 풀이 화면에서 첫째 항, 둘째 항을 순서대로 보기에서 선택할 수 있다
|
|
||||||
- 각 항의 보기는 정답 절대값 기준 음수/양수 두 개다
|
|
||||||
- 두 항 선택 후 `정답 확인` 버튼으로 제출할 수 있다
|
|
||||||
- 정답·오답 피드백이 기존 색상과 기호 규칙(`○`, `×`)으로 표시된다
|
|
||||||
- 최종 결과 화면이 표시된다
|
|
||||||
- 기존 숫자 괄호 풀이 화면은 현재 동작을 유지한다
|
|
||||||
- `pnpm test` 통과
|
|
||||||
- `pnpm build` 통과
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# 바이브 코딩 진행 문서
|
|
||||||
|
|
||||||
이 폴더는 단계별 바이브 코딩을 진행하면서 생기는 계획, 검토, 회고 문서를 모아두는 곳이다.
|
|
||||||
|
|
||||||
루트 디렉토리에는 `AGENTS.md`, `README.md`, 설정 파일처럼 프로젝트의 공식 입구 역할을 하는 파일만 두고, 작업 과정에서 생기는 문서는 이 폴더 아래에서 관리한다.
|
|
||||||
|
|
||||||
## 현재 목표
|
|
||||||
|
|
||||||
괄호 앞 음수 분배 문제를 웹 화면에서 풀 수 있는 첫 기능을 만든다.
|
|
||||||
|
|
||||||
## 진행 단계
|
|
||||||
|
|
||||||
- [x] [01. 프로젝트 시작 화면과 라우팅](./01-project-start.md)
|
|
||||||
- [ ] 02. 괄호 앞 음수 분배 문제 생성 엔진
|
|
||||||
- [ ] 03. KaTeX 수식 렌더링
|
|
||||||
- [ ] 04. 한 문제 풀이 UI
|
|
||||||
- [ ] 05. 10문제 세션과 점수
|
|
||||||
- [ ] 06. UX 다듬기
|
|
||||||
|
|
||||||
## 참고 문서
|
|
||||||
|
|
||||||
- [Claude 초기 계획](./00-claude-initial-plan.md)
|
|
||||||
- [초기 계획 검토](./00-plan-review.md)
|
|
||||||
|
|
||||||
## 문서 작성 규칙
|
|
||||||
|
|
||||||
새로운 단계 문서는 다음 형식으로 만든다.
|
|
||||||
|
|
||||||
```text
|
|
||||||
NN-short-feature-name.md
|
|
||||||
```
|
|
||||||
|
|
||||||
예시:
|
|
||||||
|
|
||||||
```text
|
|
||||||
01-project-start.md
|
|
||||||
02-parenthesis-sign-engine.md
|
|
||||||
03-katex-rendering.md
|
|
||||||
```
|
|
||||||
|
|
||||||
각 단계 문서는 아래 구조를 기본으로 사용한다.
|
|
||||||
|
|
||||||
```md
|
|
||||||
# NN. 단계 제목
|
|
||||||
|
|
||||||
## 목표
|
|
||||||
|
|
||||||
이번 단계의 목표를 한 문장으로 적는다.
|
|
||||||
|
|
||||||
## 이번 단계에서 할 것
|
|
||||||
|
|
||||||
- 작업 항목
|
|
||||||
|
|
||||||
## 이번 단계에서 하지 않을 것
|
|
||||||
|
|
||||||
- 범위에서 제외할 항목
|
|
||||||
|
|
||||||
## 완료 기준
|
|
||||||
|
|
||||||
- 직접 확인 가능한 기준
|
|
||||||
|
|
||||||
## 작업 결과
|
|
||||||
|
|
||||||
작업 후 기록한다.
|
|
||||||
|
|
||||||
## 다음 단계
|
|
||||||
|
|
||||||
다음에 이어서 할 일을 적는다.
|
|
||||||
```
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
export const ACTIVE_DISTRIBUTION_COLOR = "#f97316";
|
|
||||||
|
|
||||||
function colorLatex(latex: string, color: string) {
|
|
||||||
return `\\textcolor{${color}}{${latex}}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FormatParenthesisExpressionLatexParams<T> {
|
|
||||||
multiplier: number;
|
|
||||||
terms: T[];
|
|
||||||
operators: ("+" | "-")[];
|
|
||||||
formatTermLatex: (term: T) => string;
|
|
||||||
activeTermIndex?: number;
|
|
||||||
activeColor?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatParenthesisExpressionLatex<T>({
|
|
||||||
multiplier,
|
|
||||||
terms,
|
|
||||||
operators,
|
|
||||||
formatTermLatex,
|
|
||||||
activeTermIndex,
|
|
||||||
activeColor = ACTIVE_DISTRIBUTION_COLOR,
|
|
||||||
}: FormatParenthesisExpressionLatexParams<T>) {
|
|
||||||
const hasActiveTerm = activeTermIndex !== undefined;
|
|
||||||
const maybeColor = (latex: string, active: boolean) =>
|
|
||||||
hasActiveTerm && active ? colorLatex(latex, activeColor) : latex;
|
|
||||||
const termLatex = terms
|
|
||||||
.map((term, index) => {
|
|
||||||
const displayTerm = maybeColor(
|
|
||||||
formatTermLatex(term),
|
|
||||||
index === activeTermIndex,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (index === 0) return displayTerm;
|
|
||||||
|
|
||||||
return `${operators[index - 1]} ${displayTerm}`;
|
|
||||||
})
|
|
||||||
.join(" ");
|
|
||||||
|
|
||||||
if (hasActiveTerm) {
|
|
||||||
return `${colorLatex(`${multiplier}`, activeColor)}${colorLatex(
|
|
||||||
"(",
|
|
||||||
activeColor,
|
|
||||||
)}${termLatex}${colorLatex(")", activeColor)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${multiplier}\\left(${termLatex}\\right)`;
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import {
|
|
||||||
generateProblem,
|
|
||||||
generateSession,
|
|
||||||
gradeAnswer,
|
|
||||||
} from "./parenthesis-sign";
|
|
||||||
|
|
||||||
describe("parenthesis sign problem engine", () => {
|
|
||||||
it("generates a problem within the configured ranges", () => {
|
|
||||||
for (let index = 0; index < 100; index += 1) {
|
|
||||||
const problem = generateProblem();
|
|
||||||
|
|
||||||
expect(Number.isInteger(problem.multiplier)).toBe(true);
|
|
||||||
expect(problem.multiplier).toBeGreaterThanOrEqual(-9);
|
|
||||||
expect(problem.multiplier).toBeLessThanOrEqual(-1);
|
|
||||||
expect(problem.terms).toHaveLength(2);
|
|
||||||
expect(problem.operators).toHaveLength(1);
|
|
||||||
expect(problem.terms.every((term) => term >= 1 && term <= 9)).toBe(true);
|
|
||||||
expect(new Set(problem.terms).size).toBe(problem.terms.length);
|
|
||||||
expect(["+", "-"]).toContain(problem.operators[0]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("distributes the multiplier correctly for both operators", () => {
|
|
||||||
for (let index = 0; index < 100; index += 1) {
|
|
||||||
const problem = generateProblem();
|
|
||||||
const signedTerms = problem.terms.map((term, termIndex) => {
|
|
||||||
if (termIndex === 0) return term;
|
|
||||||
|
|
||||||
return problem.operators[termIndex - 1] === "-" ? -term : term;
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(problem.distributedTerms).toEqual(
|
|
||||||
signedTerms.map((term) => problem.multiplier * term),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("grades correct and incorrect answers", () => {
|
|
||||||
const problem = generateProblem();
|
|
||||||
|
|
||||||
const correctResult = gradeAnswer(problem, {
|
|
||||||
terms: problem.distributedTerms,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(correctResult.correct).toBe(true);
|
|
||||||
expect(correctResult.userAnswer).toEqual(problem.distributedTerms);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
gradeAnswer(problem, {
|
|
||||||
terms: [...problem.distributedTerms].reverse(),
|
|
||||||
}).correct,
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("generates a session with unique problem keys", () => {
|
|
||||||
const problems = generateSession(10);
|
|
||||||
const keys = new Set(problems.map((problem) => problem.key));
|
|
||||||
|
|
||||||
expect(problems).toHaveLength(10);
|
|
||||||
expect(keys.size).toBe(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("generates the selected number of terms inside parentheses", () => {
|
|
||||||
const problem = generateProblem(4);
|
|
||||||
|
|
||||||
expect(problem.terms).toHaveLength(4);
|
|
||||||
expect(problem.operators).toHaveLength(3);
|
|
||||||
expect(problem.distributedTerms).toHaveLength(4);
|
|
||||||
expect(
|
|
||||||
gradeAnswer(problem, { terms: problem.distributedTerms }).correct,
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects impossible session sizes", () => {
|
|
||||||
expect(() => generateSession(0)).toThrow();
|
|
||||||
expect(() => generateSession(1297)).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
import type {
|
|
||||||
GradeResult,
|
|
||||||
ParenthesisSignProblem,
|
|
||||||
UserAnswer,
|
|
||||||
} from "./types";
|
|
||||||
import { formatParenthesisExpressionLatex } from "./parenthesis-latex";
|
|
||||||
|
|
||||||
const MIN_MULTIPLIER = -9;
|
|
||||||
const MAX_MULTIPLIER = -1;
|
|
||||||
const MIN_TERM = 1;
|
|
||||||
const MAX_TERM = 9;
|
|
||||||
const MAX_TERM_COUNT = 4;
|
|
||||||
|
|
||||||
export type ParenthesisTermCount = 2 | 3 | 4;
|
|
||||||
|
|
||||||
interface ProblemSeed {
|
|
||||||
multiplier: number;
|
|
||||||
terms: number[];
|
|
||||||
operators: ("+" | "-")[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function randomInt(min: number, max: number) {
|
|
||||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatLatexTerms(terms: number[]) {
|
|
||||||
return terms
|
|
||||||
.map((term, index) => {
|
|
||||||
if (index === 0) return `${term}`;
|
|
||||||
|
|
||||||
const operator = term < 0 ? "-" : "+";
|
|
||||||
return `${operator} ${Math.abs(term)}`;
|
|
||||||
})
|
|
||||||
.join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function createProblem({
|
|
||||||
multiplier,
|
|
||||||
terms,
|
|
||||||
operators,
|
|
||||||
}: ProblemSeed): ParenthesisSignProblem {
|
|
||||||
const signedTerms = terms.map((term, index) => {
|
|
||||||
if (index === 0) return term;
|
|
||||||
|
|
||||||
return operators[index - 1] === "-" ? -term : term;
|
|
||||||
});
|
|
||||||
const distributedTerms = signedTerms.map((term) => multiplier * term);
|
|
||||||
const key = [
|
|
||||||
multiplier,
|
|
||||||
...terms.flatMap((term, index) =>
|
|
||||||
index === 0 ? [`${term}`] : [operators[index - 1], `${term}`],
|
|
||||||
),
|
|
||||||
].join(":");
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `parenthesis-sign:${key}`,
|
|
||||||
key,
|
|
||||||
multiplier,
|
|
||||||
terms,
|
|
||||||
operators,
|
|
||||||
distributedTerms,
|
|
||||||
latexBefore: formatParenthesisExpressionLatex({
|
|
||||||
multiplier,
|
|
||||||
terms,
|
|
||||||
operators,
|
|
||||||
formatTermLatex: (term) => `${term}`,
|
|
||||||
}),
|
|
||||||
latexAfter: formatLatexTerms(distributedTerms),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTermCombinations(termCount: ParenthesisTermCount) {
|
|
||||||
const combinations: number[][] = [];
|
|
||||||
|
|
||||||
function addTerms(nextTerms: number[]) {
|
|
||||||
if (nextTerms.length === termCount) {
|
|
||||||
combinations.push(nextTerms);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let term = MIN_TERM; term <= MAX_TERM; term += 1) {
|
|
||||||
if (nextTerms.includes(term)) continue;
|
|
||||||
|
|
||||||
addTerms([...nextTerms, term]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addTerms([]);
|
|
||||||
|
|
||||||
return combinations;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createOperatorCombinations(termCount: ParenthesisTermCount) {
|
|
||||||
const combinations: ("+" | "-")[][] = [];
|
|
||||||
const operatorCount = termCount - 1;
|
|
||||||
|
|
||||||
function addOperators(nextOperators: ("+" | "-")[]) {
|
|
||||||
if (nextOperators.length === operatorCount) {
|
|
||||||
combinations.push(nextOperators);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
addOperators([...nextOperators, "+"]);
|
|
||||||
addOperators([...nextOperators, "-"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
addOperators([]);
|
|
||||||
|
|
||||||
return combinations;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createAllProblemSeeds(termCount: ParenthesisTermCount) {
|
|
||||||
const seeds: ProblemSeed[] = [];
|
|
||||||
const termCombinations = createTermCombinations(termCount);
|
|
||||||
const operatorCombinations = createOperatorCombinations(termCount);
|
|
||||||
|
|
||||||
for (
|
|
||||||
let multiplier = MIN_MULTIPLIER;
|
|
||||||
multiplier <= MAX_MULTIPLIER;
|
|
||||||
multiplier += 1
|
|
||||||
) {
|
|
||||||
for (const terms of termCombinations) {
|
|
||||||
for (const operators of operatorCombinations) {
|
|
||||||
seeds.push({ multiplier, terms, operators });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return seeds;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ALL_PROBLEM_SEEDS_BY_TERM_COUNT: Record<
|
|
||||||
ParenthesisTermCount,
|
|
||||||
ProblemSeed[]
|
|
||||||
> = {
|
|
||||||
2: createAllProblemSeeds(2),
|
|
||||||
3: createAllProblemSeeds(3),
|
|
||||||
4: createAllProblemSeeds(4),
|
|
||||||
};
|
|
||||||
|
|
||||||
function shuffle<T>(items: T[]) {
|
|
||||||
const shuffled = [...items];
|
|
||||||
|
|
||||||
for (let index = shuffled.length - 1; index > 0; index -= 1) {
|
|
||||||
const targetIndex = randomInt(0, index);
|
|
||||||
const current = shuffled[index];
|
|
||||||
shuffled[index] = shuffled[targetIndex];
|
|
||||||
shuffled[targetIndex] = current;
|
|
||||||
}
|
|
||||||
|
|
||||||
return shuffled;
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertTermCount(termCount: number): asserts termCount is ParenthesisTermCount {
|
|
||||||
if (!Number.isInteger(termCount) || termCount < 2 || termCount > MAX_TERM_COUNT) {
|
|
||||||
throw new Error("termCount must be 2, 3, or 4.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generateProblem(
|
|
||||||
termCount: ParenthesisTermCount = 2,
|
|
||||||
): ParenthesisSignProblem {
|
|
||||||
assertTermCount(termCount);
|
|
||||||
|
|
||||||
const terms: number[] = [];
|
|
||||||
|
|
||||||
while (terms.length < termCount) {
|
|
||||||
const term = randomInt(MIN_TERM, MAX_TERM);
|
|
||||||
|
|
||||||
if (!terms.includes(term)) {
|
|
||||||
terms.push(term);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return createProblem({
|
|
||||||
multiplier: randomInt(MIN_MULTIPLIER, MAX_MULTIPLIER),
|
|
||||||
terms,
|
|
||||||
operators: Array.from({ length: termCount - 1 }, () =>
|
|
||||||
Math.random() < 0.5 ? "+" : "-",
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generateSession(
|
|
||||||
count = 10,
|
|
||||||
termCount: ParenthesisTermCount = 2,
|
|
||||||
): ParenthesisSignProblem[] {
|
|
||||||
if (!Number.isInteger(count) || count < 1) {
|
|
||||||
throw new Error("count must be a positive integer.");
|
|
||||||
}
|
|
||||||
|
|
||||||
assertTermCount(termCount);
|
|
||||||
|
|
||||||
const seeds = ALL_PROBLEM_SEEDS_BY_TERM_COUNT[termCount];
|
|
||||||
|
|
||||||
if (count > seeds.length) {
|
|
||||||
throw new Error(`count cannot exceed ${seeds.length}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return shuffle(seeds).slice(0, count).map(createProblem);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function gradeAnswer(
|
|
||||||
problem: ParenthesisSignProblem,
|
|
||||||
answer: UserAnswer,
|
|
||||||
): GradeResult {
|
|
||||||
return {
|
|
||||||
correct:
|
|
||||||
answer.terms.length === problem.distributedTerms.length &&
|
|
||||||
answer.terms.every((value, index) => value === problem.distributedTerms[index]),
|
|
||||||
userAnswer: answer.terms,
|
|
||||||
correctAnswer: problem.distributedTerms,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
export interface ParenthesisSignProblem {
|
|
||||||
id: string;
|
|
||||||
key: string;
|
|
||||||
multiplier: number;
|
|
||||||
terms: number[];
|
|
||||||
operators: ("+" | "-")[];
|
|
||||||
distributedTerms: number[];
|
|
||||||
latexBefore: string;
|
|
||||||
latexAfter: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserAnswer {
|
|
||||||
terms: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GradeResult {
|
|
||||||
correct: boolean;
|
|
||||||
userAnswer: number[];
|
|
||||||
correctAnswer: number[];
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import {
|
|
||||||
formatVariableExpressionLatex,
|
|
||||||
generateVariableParenthesisProblem,
|
|
||||||
generateVariableParenthesisSession,
|
|
||||||
gradeVariableParenthesisAnswer,
|
|
||||||
} from "./variable-parenthesis";
|
|
||||||
import type { VariableParenthesisProblem } from "./variable-types";
|
|
||||||
|
|
||||||
function findCorrectChoiceIds(problem: VariableParenthesisProblem) {
|
|
||||||
return problem.correctTerms.map((term, index) => {
|
|
||||||
const choices = problem.choices[index];
|
|
||||||
const choice = choices.find(
|
|
||||||
(item) =>
|
|
||||||
item.coefficient === term.coefficient && item.variable === term.variable,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!choice) throw new Error("correct choice not found.");
|
|
||||||
|
|
||||||
return choice.id;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("variable parenthesis engine", () => {
|
|
||||||
it("generates a problem with exactly one variable term inside parentheses", () => {
|
|
||||||
const problem = generateVariableParenthesisProblem();
|
|
||||||
|
|
||||||
expect(problem.terms.filter((term) => term.kind === "variable")).toHaveLength(1);
|
|
||||||
expect(problem.terms.filter((term) => term.kind === "constant")).toHaveLength(1);
|
|
||||||
expect(problem.multiplier).toBeLessThan(0);
|
|
||||||
expect(Number.isInteger(problem.multiplier)).toBe(true);
|
|
||||||
expect(
|
|
||||||
problem.terms.every((term) => term.coefficient >= 1 && term.coefficient <= 9),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("distributes signs correctly for addition and subtraction", () => {
|
|
||||||
const problems = generateVariableParenthesisSession(50);
|
|
||||||
|
|
||||||
for (const problem of problems) {
|
|
||||||
expect(problem.correctTerms.map((term) => term.coefficient)).toEqual(
|
|
||||||
problem.terms.map((term, index) => {
|
|
||||||
const sign = index === 0 || problem.operators[index - 1] === "+" ? 1 : -1;
|
|
||||||
|
|
||||||
return problem.multiplier * sign * term.coefficient;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates positive and negative choices for each term", () => {
|
|
||||||
const problem = generateVariableParenthesisProblem();
|
|
||||||
|
|
||||||
problem.choices.forEach((choices, index) => {
|
|
||||||
const correctTerm = problem.correctTerms[index as 0 | 1];
|
|
||||||
const coefficients = choices.map((choice) => choice.coefficient).sort();
|
|
||||||
|
|
||||||
expect(choices).toHaveLength(2);
|
|
||||||
expect(coefficients).toEqual([
|
|
||||||
-Math.abs(correctTerm.coefficient),
|
|
||||||
Math.abs(correctTerm.coefficient),
|
|
||||||
]);
|
|
||||||
expect(choices.every((choice) => choice.variable === correctTerm.variable))
|
|
||||||
.toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("grades selected choice ids", () => {
|
|
||||||
const problem = generateVariableParenthesisProblem();
|
|
||||||
const correctChoiceIds = findCorrectChoiceIds(problem);
|
|
||||||
const result = gradeVariableParenthesisAnswer(problem, {
|
|
||||||
selectedChoiceIds: correctChoiceIds,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.correct).toBe(true);
|
|
||||||
expect(result.correctAnswer).toEqual(problem.correctTerms);
|
|
||||||
|
|
||||||
const wrongFirstChoice = problem.choices[0].find(
|
|
||||||
(choice) => choice.id !== correctChoiceIds[0],
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(wrongFirstChoice).toBeDefined();
|
|
||||||
expect(
|
|
||||||
gradeVariableParenthesisAnswer(problem, {
|
|
||||||
selectedChoiceIds: [wrongFirstChoice!.id, correctChoiceIds[1]],
|
|
||||||
}).correct,
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("generates sessions without duplicate keys", () => {
|
|
||||||
const problems = generateVariableParenthesisSession(10);
|
|
||||||
const keys = new Set(problems.map((problem) => problem.key));
|
|
||||||
|
|
||||||
expect(problems).toHaveLength(10);
|
|
||||||
expect(keys.size).toBe(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("generates the selected number of variable choices", () => {
|
|
||||||
const problem = generateVariableParenthesisProblem(4);
|
|
||||||
|
|
||||||
expect(problem.terms).toHaveLength(4);
|
|
||||||
expect(problem.operators).toHaveLength(3);
|
|
||||||
expect(problem.correctTerms).toHaveLength(4);
|
|
||||||
expect(problem.choices).toHaveLength(4);
|
|
||||||
expect(problem.terms.filter((term) => term.kind === "variable")).toHaveLength(1);
|
|
||||||
expect(
|
|
||||||
gradeVariableParenthesisAnswer(problem, {
|
|
||||||
selectedChoiceIds: findCorrectChoiceIds(problem),
|
|
||||||
}).correct,
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("formats expressions with variable terms", () => {
|
|
||||||
expect(
|
|
||||||
formatVariableExpressionLatex([
|
|
||||||
{ kind: "variable", coefficient: -6, variable: "x", latex: "-6x" },
|
|
||||||
{ kind: "constant", coefficient: 12, latex: "12" },
|
|
||||||
]),
|
|
||||||
).toBe("-6x + 12");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,358 +0,0 @@
|
|||||||
import type {
|
|
||||||
DistributedVariableTerm,
|
|
||||||
VariableParenthesisProblem,
|
|
||||||
VariableParenthesisTerm,
|
|
||||||
VariableTermChoice,
|
|
||||||
VariableUserAnswer,
|
|
||||||
VariableGradeResult,
|
|
||||||
} from "./variable-types";
|
|
||||||
import { formatParenthesisExpressionLatex } from "./parenthesis-latex";
|
|
||||||
|
|
||||||
const MIN_MULTIPLIER = -9;
|
|
||||||
const MAX_MULTIPLIER = -1;
|
|
||||||
const MIN_COEFFICIENT = 1;
|
|
||||||
const MAX_COEFFICIENT = 9;
|
|
||||||
const MAX_TERM_COUNT = 4;
|
|
||||||
|
|
||||||
export type VariableParenthesisTermCount = 2 | 3 | 4;
|
|
||||||
|
|
||||||
interface VariableProblemSeed {
|
|
||||||
multiplier: number;
|
|
||||||
terms: VariableParenthesisTerm[];
|
|
||||||
operators: ("+" | "-")[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function randomInt(min: number, max: number) {
|
|
||||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shuffle<T>(items: T[]) {
|
|
||||||
const shuffled = [...items];
|
|
||||||
|
|
||||||
for (let index = shuffled.length - 1; index > 0; index -= 1) {
|
|
||||||
const targetIndex = randomInt(0, index);
|
|
||||||
const current = shuffled[index];
|
|
||||||
shuffled[index] = shuffled[targetIndex];
|
|
||||||
shuffled[targetIndex] = current;
|
|
||||||
}
|
|
||||||
|
|
||||||
return shuffled;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createConstantTerm(coefficient: number): VariableParenthesisTerm {
|
|
||||||
return {
|
|
||||||
kind: "constant",
|
|
||||||
coefficient,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createVariableTerm(coefficient: number): VariableParenthesisTerm {
|
|
||||||
return {
|
|
||||||
kind: "variable",
|
|
||||||
coefficient,
|
|
||||||
variable: "x",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createUniqueConstantTerms(
|
|
||||||
baseCoefficient: number,
|
|
||||||
count: number,
|
|
||||||
): VariableParenthesisTerm[] {
|
|
||||||
const used = new Set<number>();
|
|
||||||
const terms: VariableParenthesisTerm[] = [];
|
|
||||||
let candidate = baseCoefficient;
|
|
||||||
|
|
||||||
while (terms.length < count) {
|
|
||||||
const coef = ((candidate - 1) % MAX_COEFFICIENT) + 1;
|
|
||||||
if (!used.has(coef)) {
|
|
||||||
used.add(coef);
|
|
||||||
terms.push(createConstantTerm(coef));
|
|
||||||
}
|
|
||||||
candidate += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return terms;
|
|
||||||
}
|
|
||||||
|
|
||||||
function termKey(term: VariableParenthesisTerm) {
|
|
||||||
return `${term.kind}:${term.coefficient}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatVariableTermLatex(
|
|
||||||
term: Pick<VariableParenthesisTerm, "kind" | "coefficient" | "variable">,
|
|
||||||
) {
|
|
||||||
if (term.kind === "constant") {
|
|
||||||
return `${term.coefficient}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (term.coefficient === 1) {
|
|
||||||
return "x";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (term.coefficient === -1) {
|
|
||||||
return "-x";
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${term.coefficient}x`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatVariableExpressionLatex(
|
|
||||||
terms: DistributedVariableTerm[],
|
|
||||||
) {
|
|
||||||
return terms
|
|
||||||
.map((term, index) => {
|
|
||||||
if (index === 0) return term.latex;
|
|
||||||
|
|
||||||
const operator = term.coefficient < 0 ? "-" : "+";
|
|
||||||
const positiveTerm = {
|
|
||||||
...term,
|
|
||||||
coefficient: Math.abs(term.coefficient),
|
|
||||||
};
|
|
||||||
|
|
||||||
return `${operator} ${formatVariableTermLatex(positiveTerm)}`;
|
|
||||||
})
|
|
||||||
.join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function distributeTerm(
|
|
||||||
multiplier: number,
|
|
||||||
term: VariableParenthesisTerm,
|
|
||||||
sign: 1 | -1,
|
|
||||||
): DistributedVariableTerm {
|
|
||||||
const coefficient = multiplier * sign * term.coefficient;
|
|
||||||
|
|
||||||
return {
|
|
||||||
kind: term.kind,
|
|
||||||
coefficient,
|
|
||||||
variable: term.variable,
|
|
||||||
latex: formatVariableTermLatex({ ...term, coefficient }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createChoice(
|
|
||||||
key: string,
|
|
||||||
termIndex: number,
|
|
||||||
term: DistributedVariableTerm,
|
|
||||||
coefficient: number,
|
|
||||||
): VariableTermChoice {
|
|
||||||
const signKey = coefficient < 0 ? "negative" : "positive";
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `${key}:term-${termIndex}:${signKey}`,
|
|
||||||
coefficient,
|
|
||||||
variable: term.variable,
|
|
||||||
latex: formatVariableTermLatex({ ...term, coefficient }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createChoices(
|
|
||||||
key: string,
|
|
||||||
termIndex: number,
|
|
||||||
term: DistributedVariableTerm,
|
|
||||||
) {
|
|
||||||
const absoluteCoefficient = Math.abs(term.coefficient);
|
|
||||||
const choices: VariableTermChoice[] = [
|
|
||||||
createChoice(key, termIndex, term, -absoluteCoefficient),
|
|
||||||
createChoice(key, termIndex, term, absoluteCoefficient),
|
|
||||||
];
|
|
||||||
|
|
||||||
return shuffle(choices);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createProblem(seed: VariableProblemSeed): VariableParenthesisProblem {
|
|
||||||
const { multiplier, terms, operators } = seed;
|
|
||||||
const key = [
|
|
||||||
multiplier,
|
|
||||||
...terms.flatMap((term, index) =>
|
|
||||||
index === 0 ? [termKey(term)] : [operators[index - 1], termKey(term)],
|
|
||||||
),
|
|
||||||
].join(":");
|
|
||||||
const correctTerms = terms.map((term, index) =>
|
|
||||||
distributeTerm(
|
|
||||||
multiplier,
|
|
||||||
term,
|
|
||||||
index === 0 || operators[index - 1] === "+" ? 1 : -1,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const choices = correctTerms.map((term, index) =>
|
|
||||||
createChoices(key, index, term),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `variable-parenthesis:${key}`,
|
|
||||||
key,
|
|
||||||
multiplier,
|
|
||||||
terms,
|
|
||||||
operators,
|
|
||||||
correctTerms,
|
|
||||||
choices,
|
|
||||||
latexBefore: formatParenthesisExpressionLatex({
|
|
||||||
multiplier,
|
|
||||||
terms,
|
|
||||||
operators,
|
|
||||||
formatTermLatex: formatVariableTermLatex,
|
|
||||||
}),
|
|
||||||
latexAfter: formatVariableExpressionLatex(correctTerms),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createOperatorCombinations(termCount: VariableParenthesisTermCount) {
|
|
||||||
const combinations: ("+" | "-")[][] = [];
|
|
||||||
|
|
||||||
function addOperators(nextOperators: ("+" | "-")[]) {
|
|
||||||
if (nextOperators.length === termCount - 1) {
|
|
||||||
combinations.push(nextOperators);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
addOperators([...nextOperators, "+"]);
|
|
||||||
addOperators([...nextOperators, "-"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
addOperators([]);
|
|
||||||
|
|
||||||
return combinations;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createAllProblemSeeds(termCount: VariableParenthesisTermCount) {
|
|
||||||
const seeds: VariableProblemSeed[] = [];
|
|
||||||
const operatorCombinations = createOperatorCombinations(termCount);
|
|
||||||
|
|
||||||
for (
|
|
||||||
let multiplier = MIN_MULTIPLIER;
|
|
||||||
multiplier <= MAX_MULTIPLIER;
|
|
||||||
multiplier += 1
|
|
||||||
) {
|
|
||||||
for (
|
|
||||||
let constant = MIN_COEFFICIENT;
|
|
||||||
constant <= MAX_COEFFICIENT;
|
|
||||||
constant += 1
|
|
||||||
) {
|
|
||||||
for (
|
|
||||||
let variableCoefficient = MIN_COEFFICIENT;
|
|
||||||
variableCoefficient <= MAX_COEFFICIENT;
|
|
||||||
variableCoefficient += 1
|
|
||||||
) {
|
|
||||||
for (let variableIndex = 0; variableIndex < termCount; variableIndex += 1) {
|
|
||||||
const constantTerms = createUniqueConstantTerms(constant, termCount - 1);
|
|
||||||
let constantTermIndex = 0;
|
|
||||||
const terms = Array.from({ length: termCount }, (_, index) =>
|
|
||||||
index === variableIndex
|
|
||||||
? createVariableTerm(variableCoefficient)
|
|
||||||
: constantTerms[constantTermIndex++],
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const operators of operatorCombinations) {
|
|
||||||
seeds.push({
|
|
||||||
multiplier,
|
|
||||||
terms,
|
|
||||||
operators,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return seeds;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ALL_PROBLEM_SEEDS_BY_TERM_COUNT: Record<
|
|
||||||
VariableParenthesisTermCount,
|
|
||||||
VariableProblemSeed[]
|
|
||||||
> = {
|
|
||||||
2: createAllProblemSeeds(2),
|
|
||||||
3: createAllProblemSeeds(3),
|
|
||||||
4: createAllProblemSeeds(4),
|
|
||||||
};
|
|
||||||
|
|
||||||
function assertTermCount(
|
|
||||||
termCount: number,
|
|
||||||
): asserts termCount is VariableParenthesisTermCount {
|
|
||||||
if (!Number.isInteger(termCount) || termCount < 2 || termCount > MAX_TERM_COUNT) {
|
|
||||||
throw new Error("termCount must be 2, 3, or 4.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generateVariableParenthesisProblem(
|
|
||||||
termCount: VariableParenthesisTermCount = 2,
|
|
||||||
): VariableParenthesisProblem {
|
|
||||||
assertTermCount(termCount);
|
|
||||||
|
|
||||||
const constant = randomInt(MIN_COEFFICIENT, MAX_COEFFICIENT);
|
|
||||||
const variableCoefficient = randomInt(MIN_COEFFICIENT, MAX_COEFFICIENT);
|
|
||||||
const variableIndex = randomInt(0, termCount - 1);
|
|
||||||
|
|
||||||
const constantTerms = createUniqueConstantTerms(constant, termCount - 1);
|
|
||||||
let constantTermIndex = 0;
|
|
||||||
|
|
||||||
return createProblem({
|
|
||||||
multiplier: randomInt(MIN_MULTIPLIER, MAX_MULTIPLIER),
|
|
||||||
terms: Array.from({ length: termCount }, (_, index) =>
|
|
||||||
index === variableIndex
|
|
||||||
? createVariableTerm(variableCoefficient)
|
|
||||||
: constantTerms[constantTermIndex++],
|
|
||||||
),
|
|
||||||
operators: Array.from({ length: termCount - 1 }, () =>
|
|
||||||
Math.random() < 0.5 ? "+" : "-",
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generateVariableParenthesisSession(
|
|
||||||
count = 10,
|
|
||||||
termCount: VariableParenthesisTermCount = 2,
|
|
||||||
): VariableParenthesisProblem[] {
|
|
||||||
if (!Number.isInteger(count) || count < 1) {
|
|
||||||
throw new Error("count must be a positive integer.");
|
|
||||||
}
|
|
||||||
|
|
||||||
assertTermCount(termCount);
|
|
||||||
|
|
||||||
const seeds = ALL_PROBLEM_SEEDS_BY_TERM_COUNT[termCount];
|
|
||||||
|
|
||||||
if (count > seeds.length) {
|
|
||||||
throw new Error(`count cannot exceed ${seeds.length}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return shuffle(seeds).slice(0, count).map(createProblem);
|
|
||||||
}
|
|
||||||
|
|
||||||
function findSelectedChoice(
|
|
||||||
problem: VariableParenthesisProblem,
|
|
||||||
termIndex: number,
|
|
||||||
choiceId: string | null,
|
|
||||||
): DistributedVariableTerm | null {
|
|
||||||
if (!choiceId) return null;
|
|
||||||
|
|
||||||
const choice = problem.choices[termIndex].find((item) => item.id === choiceId);
|
|
||||||
|
|
||||||
if (!choice) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
kind: problem.correctTerms[termIndex].kind,
|
|
||||||
coefficient: choice.coefficient,
|
|
||||||
variable: choice.variable,
|
|
||||||
latex: choice.latex,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function gradeVariableParenthesisAnswer(
|
|
||||||
problem: VariableParenthesisProblem,
|
|
||||||
answer: VariableUserAnswer,
|
|
||||||
): VariableGradeResult {
|
|
||||||
const userAnswer = problem.correctTerms.map((_, index) =>
|
|
||||||
findSelectedChoice(problem, index, answer.selectedChoiceIds[index] ?? null),
|
|
||||||
);
|
|
||||||
const correct =
|
|
||||||
userAnswer.length === problem.correctTerms.length &&
|
|
||||||
userAnswer.every(
|
|
||||||
(term, index) => term?.coefficient === problem.correctTerms[index].coefficient,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
correct,
|
|
||||||
userAnswer,
|
|
||||||
correctAnswer: problem.correctTerms,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
export type VariableTermKind = "constant" | "variable";
|
|
||||||
|
|
||||||
export interface VariableParenthesisTerm {
|
|
||||||
kind: VariableTermKind;
|
|
||||||
coefficient: number;
|
|
||||||
variable?: "x";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DistributedVariableTerm {
|
|
||||||
kind: VariableTermKind;
|
|
||||||
coefficient: number;
|
|
||||||
variable?: "x";
|
|
||||||
latex: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VariableTermChoice {
|
|
||||||
id: string;
|
|
||||||
coefficient: number;
|
|
||||||
variable?: "x";
|
|
||||||
latex: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VariableParenthesisProblem {
|
|
||||||
id: string;
|
|
||||||
key: string;
|
|
||||||
multiplier: number;
|
|
||||||
terms: VariableParenthesisTerm[];
|
|
||||||
operators: ("+" | "-")[];
|
|
||||||
correctTerms: DistributedVariableTerm[];
|
|
||||||
choices: VariableTermChoice[][];
|
|
||||||
latexBefore: string;
|
|
||||||
latexAfter: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VariableUserAnswer {
|
|
||||||
selectedChoiceIds: (string | null)[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VariableGradeResult {
|
|
||||||
correct: boolean;
|
|
||||||
userAnswer: (DistributedVariableTerm | null)[];
|
|
||||||
correctAnswer: DistributedVariableTerm[];
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export const DESKTOP_INPUT_DEVICE_QUERY =
|
|
||||||
"(any-hover: hover) and (any-pointer: fine)";
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
|
||||||
const nextConfig = {
|
|
||||||
output: "standalone",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default nextConfig;
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "math-quiz",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"packageManager": "pnpm@11.3.0",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "next dev",
|
|
||||||
"build": "next build",
|
|
||||||
"postbuild": "mkdir -p .next/standalone/.next && cp -R .next/static .next/standalone/.next/static",
|
|
||||||
"start": "next start",
|
|
||||||
"test": "vitest run",
|
|
||||||
"lint": "next lint"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"katex": "^0.17.0",
|
|
||||||
"next": "14.2.35",
|
|
||||||
"react": "^18",
|
|
||||||
"react-dom": "^18",
|
|
||||||
"zustand": "^5.0.13"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/katex": "^0.16.8",
|
|
||||||
"@types/node": "^20",
|
|
||||||
"@types/react": "^18",
|
|
||||||
"@types/react-dom": "^18",
|
|
||||||
"eslint": "^8",
|
|
||||||
"eslint-config-next": "14.2.35",
|
|
||||||
"postcss": "^8",
|
|
||||||
"tailwindcss": "^3.4.1",
|
|
||||||
"typescript": "^5",
|
|
||||||
"vitest": "^4.1.7"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
-4411
File diff suppressed because it is too large
Load Diff
@@ -1,4 +0,0 @@
|
|||||||
allowBuilds:
|
|
||||||
unrs-resolver: true
|
|
||||||
unrs-resolver@1.12.2: true
|
|
||||||
strictDepBuilds: false
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
/** @type {import('postcss-load-config').Config} */
|
|
||||||
const config = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import { create } from "zustand";
|
|
||||||
|
|
||||||
import {
|
|
||||||
generateSession,
|
|
||||||
gradeAnswer,
|
|
||||||
} from "@/lib/engine/parenthesis-sign";
|
|
||||||
import type { ParenthesisTermCount } from "@/lib/engine/parenthesis-sign";
|
|
||||||
import type {
|
|
||||||
GradeResult,
|
|
||||||
ParenthesisSignProblem,
|
|
||||||
UserAnswer,
|
|
||||||
} from "@/lib/engine/types";
|
|
||||||
|
|
||||||
export interface GameSession {
|
|
||||||
problems: ParenthesisSignProblem[];
|
|
||||||
currentIndex: number;
|
|
||||||
results: (GradeResult | null)[];
|
|
||||||
termCount: ParenthesisTermCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ProblemCount = 5 | 10;
|
|
||||||
export type MaxTermCount = ParenthesisTermCount;
|
|
||||||
|
|
||||||
interface GameState {
|
|
||||||
session: GameSession | null;
|
|
||||||
selectedProblemCount: ProblemCount;
|
|
||||||
selectedMaxTermCount: MaxTermCount;
|
|
||||||
setProblemCount: (count: ProblemCount) => void;
|
|
||||||
setMaxTermCount: (count: MaxTermCount) => void;
|
|
||||||
initSession: (count?: ProblemCount, maxTermCount?: MaxTermCount) => void;
|
|
||||||
submitAnswer: (answer: UserAnswer) => void;
|
|
||||||
nextProblem: () => void;
|
|
||||||
resetSession: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_SESSION_COUNT: ProblemCount = 10;
|
|
||||||
const DEFAULT_MAX_TERM_COUNT: MaxTermCount = 2;
|
|
||||||
|
|
||||||
function createSession(
|
|
||||||
count: ProblemCount = DEFAULT_SESSION_COUNT,
|
|
||||||
maxTermCount: MaxTermCount = DEFAULT_MAX_TERM_COUNT,
|
|
||||||
): GameSession {
|
|
||||||
const problems = generateSession(count, maxTermCount);
|
|
||||||
|
|
||||||
return {
|
|
||||||
problems,
|
|
||||||
currentIndex: 0,
|
|
||||||
results: problems.map(() => null),
|
|
||||||
termCount: maxTermCount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useGameStore = create<GameState>((set, get) => ({
|
|
||||||
session: null,
|
|
||||||
selectedProblemCount: DEFAULT_SESSION_COUNT,
|
|
||||||
selectedMaxTermCount: DEFAULT_MAX_TERM_COUNT,
|
|
||||||
setProblemCount: (count) => {
|
|
||||||
set({ selectedProblemCount: count });
|
|
||||||
},
|
|
||||||
setMaxTermCount: (count) => {
|
|
||||||
set({ selectedMaxTermCount: count });
|
|
||||||
},
|
|
||||||
initSession: (count, maxTermCount) => {
|
|
||||||
const selectedProblemCount = count ?? get().selectedProblemCount;
|
|
||||||
const selectedMaxTermCount = maxTermCount ?? get().selectedMaxTermCount;
|
|
||||||
|
|
||||||
set({ session: createSession(selectedProblemCount, selectedMaxTermCount) });
|
|
||||||
},
|
|
||||||
submitAnswer: (answer) => {
|
|
||||||
const { session } = get();
|
|
||||||
|
|
||||||
if (!session || session.currentIndex >= session.problems.length) return;
|
|
||||||
if (session.results[session.currentIndex] !== null) return;
|
|
||||||
|
|
||||||
const problem = session.problems[session.currentIndex];
|
|
||||||
const result = gradeAnswer(problem, answer);
|
|
||||||
const results = [...session.results];
|
|
||||||
results[session.currentIndex] = result;
|
|
||||||
|
|
||||||
set({
|
|
||||||
session: {
|
|
||||||
...session,
|
|
||||||
results,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
nextProblem: () => {
|
|
||||||
const { session } = get();
|
|
||||||
|
|
||||||
if (!session || session.currentIndex >= session.problems.length) return;
|
|
||||||
if (session.results[session.currentIndex] === null) return;
|
|
||||||
|
|
||||||
set({
|
|
||||||
session: {
|
|
||||||
...session,
|
|
||||||
currentIndex: session.currentIndex + 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
resetSession: () => {
|
|
||||||
set({
|
|
||||||
session: createSession(
|
|
||||||
get().selectedProblemCount,
|
|
||||||
get().selectedMaxTermCount,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import { create } from "zustand";
|
|
||||||
|
|
||||||
import {
|
|
||||||
generateVariableParenthesisSession,
|
|
||||||
gradeVariableParenthesisAnswer,
|
|
||||||
} from "@/lib/engine/variable-parenthesis";
|
|
||||||
import type { VariableParenthesisTermCount } from "@/lib/engine/variable-parenthesis";
|
|
||||||
import type {
|
|
||||||
VariableGradeResult,
|
|
||||||
VariableParenthesisProblem,
|
|
||||||
VariableUserAnswer,
|
|
||||||
} from "@/lib/engine/variable-types";
|
|
||||||
|
|
||||||
export interface VariableGameSession {
|
|
||||||
problems: VariableParenthesisProblem[];
|
|
||||||
currentIndex: number;
|
|
||||||
results: (VariableGradeResult | null)[];
|
|
||||||
termCount: VariableParenthesisTermCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type VariableProblemCount = 5 | 10;
|
|
||||||
export type VariableMaxTermCount = VariableParenthesisTermCount;
|
|
||||||
|
|
||||||
interface VariableGameState {
|
|
||||||
session: VariableGameSession | null;
|
|
||||||
selectedProblemCount: VariableProblemCount;
|
|
||||||
selectedMaxTermCount: VariableMaxTermCount;
|
|
||||||
setProblemCount: (count: VariableProblemCount) => void;
|
|
||||||
setMaxTermCount: (count: VariableMaxTermCount) => void;
|
|
||||||
initSession: (
|
|
||||||
count?: VariableProblemCount,
|
|
||||||
maxTermCount?: VariableMaxTermCount,
|
|
||||||
) => void;
|
|
||||||
submitAnswer: (answer: VariableUserAnswer) => void;
|
|
||||||
nextProblem: () => void;
|
|
||||||
resetSession: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_SESSION_COUNT: VariableProblemCount = 10;
|
|
||||||
const DEFAULT_MAX_TERM_COUNT: VariableMaxTermCount = 2;
|
|
||||||
|
|
||||||
function createSession(
|
|
||||||
count: VariableProblemCount = DEFAULT_SESSION_COUNT,
|
|
||||||
maxTermCount: VariableMaxTermCount = DEFAULT_MAX_TERM_COUNT,
|
|
||||||
): VariableGameSession {
|
|
||||||
const problems = generateVariableParenthesisSession(count, maxTermCount);
|
|
||||||
|
|
||||||
return {
|
|
||||||
problems,
|
|
||||||
currentIndex: 0,
|
|
||||||
results: problems.map(() => null),
|
|
||||||
termCount: maxTermCount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useVariableGameStore = create<VariableGameState>((set, get) => ({
|
|
||||||
session: null,
|
|
||||||
selectedProblemCount: DEFAULT_SESSION_COUNT,
|
|
||||||
selectedMaxTermCount: DEFAULT_MAX_TERM_COUNT,
|
|
||||||
setProblemCount: (count) => {
|
|
||||||
set({ selectedProblemCount: count });
|
|
||||||
},
|
|
||||||
setMaxTermCount: (count) => {
|
|
||||||
set({ selectedMaxTermCount: count });
|
|
||||||
},
|
|
||||||
initSession: (count, maxTermCount) => {
|
|
||||||
const selectedProblemCount = count ?? get().selectedProblemCount;
|
|
||||||
const selectedMaxTermCount = maxTermCount ?? get().selectedMaxTermCount;
|
|
||||||
|
|
||||||
set({ session: createSession(selectedProblemCount, selectedMaxTermCount) });
|
|
||||||
},
|
|
||||||
submitAnswer: (answer) => {
|
|
||||||
const { session } = get();
|
|
||||||
|
|
||||||
if (!session || session.currentIndex >= session.problems.length) return;
|
|
||||||
if (session.results[session.currentIndex] !== null) return;
|
|
||||||
|
|
||||||
const problem = session.problems[session.currentIndex];
|
|
||||||
const result = gradeVariableParenthesisAnswer(problem, answer);
|
|
||||||
const results = [...session.results];
|
|
||||||
results[session.currentIndex] = result;
|
|
||||||
|
|
||||||
set({
|
|
||||||
session: {
|
|
||||||
...session,
|
|
||||||
results,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
nextProblem: () => {
|
|
||||||
const { session } = get();
|
|
||||||
|
|
||||||
if (!session || session.currentIndex >= session.problems.length) return;
|
|
||||||
if (session.results[session.currentIndex] === null) return;
|
|
||||||
|
|
||||||
set({
|
|
||||||
session: {
|
|
||||||
...session,
|
|
||||||
currentIndex: session.currentIndex + 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
resetSession: () => {
|
|
||||||
set({
|
|
||||||
session: createSession(
|
|
||||||
get().selectedProblemCount,
|
|
||||||
get().selectedMaxTermCount,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import type { Config } from "tailwindcss";
|
|
||||||
|
|
||||||
const config: Config = {
|
|
||||||
content: [
|
|
||||||
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
|
||||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
|
||||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
|
||||||
],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
background: "var(--background)",
|
|
||||||
foreground: "var(--foreground)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
};
|
|
||||||
export default config;
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
|
||||||
"allowJs": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"strict": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"module": "esnext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"jsx": "preserve",
|
|
||||||
"incremental": true,
|
|
||||||
"plugins": [
|
|
||||||
{
|
|
||||||
"name": "next"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
||||||
"exclude": ["node_modules"]
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
test: {
|
|
||||||
environment: "node",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user