diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..54c7971 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,107 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a Next.js 14 work checkout tracking application that sends LINE notifications when users check out from work. The app integrates with NextAuth for authentication, LINE Messaging API for notifications, and Vercel AI SDK for generating personalized checkout messages. + +## Development Commands + +### Running the application +```bash +npm run dev # Start development server on localhost:3000 +npm run build # Build production bundle +npm start # Run production server +npm run lint # Run ESLint +``` + +### Testing +```bash +npm run test:e2e # Run all Playwright E2E tests +npm run test:e2e:smoke # Run smoke tests (login spec only, used in pre-push hook) +npm run test:e2e:ui # Run tests with Playwright UI +npm run test:e2e:debug # Run tests in debug mode +npm run test:e2e:headed # Run tests in headed mode +npm run test:e2e:report # Show test report +``` + +### Pre-push Hook +A pre-push git hook (`.githooks/pre-push`) automatically runs smoke E2E tests before pushing. Skip with: +```bash +SKIP_E2E=1 git push +``` + +## Architecture + +### Hybrid Routing +This project uses **both** App Router and Pages Router: +- **App Router** (`src/app/`): Main UI pages, new API routes (`/api/work_records`) +- **Pages Router** (`src/pages/`): Legacy API routes and authentication handlers + +### Key Components + +**Authentication:** +- Uses NextAuth.js with JWT strategy +- Simple password-based authentication (no database) +- Auth configuration: `src/pages/api/[...nextauth].ts` (Pages Router) +- App Router handler: `src/app/api/auth/[...nextauth]/route.ts` (delegates to Pages Router config) + +**API Routes:** +- `src/pages/api/checkout.ts` - Sends LINE push notifications for checkout +- `src/pages/api/vercelAI.ts` - Generates AI-powered checkout messages using Vercel AI SDK +- `src/app/api/work_records/route.ts` - New App Router API with Zod validation + +**Environment-Aware:** +- Uses `isDev()`, `isStg()` from `src/app/utility/checkEnvironment.ts` +- Different LINE tokens/targets for dev/stg/prod environments + +**Presentation Layer:** +- UI components in `src/app/presentation/components/` +- Client-side components use `"use client"` directive +- Main page: `src/app/page.tsx` + +### E2E Testing + +Tests are organized under `e2e/`: +- `e2e/tests/` - Test specs organized by feature (auth, checkout, api) +- `e2e/pages/` - Page Object Model implementations +- `e2e/global-setup.ts` - Authenticates and saves session state +- `e2e/.auth/user.json` - Persisted auth state (gitignored) + +**Test Environment:** +- Uses `.env.test` for test credentials +- `E2E_FAKE_LLM=1` flag disables real AI calls during tests (returns mock messages) +- Authentication persisted via `storageState` to avoid repeated logins + +### Validation + +Uses Zod for schema validation: +- Example: `src/app/api/work_records/validator.ts` defines work record schema +- Use `safeParse()` for validation with error handling + +## Important Patterns + +**AI Message Generation:** +- Vercel AI SDK replaces legacy Gemini integration (see commit history) +- Falls back to static messages if AI generation fails or times out +- Test mode returns fixed dummy messages + +**LINE Integration:** +- Environment-aware token/target selection +- Formats checkout messages with timestamp and optional user text +- Uses axios for LINE Messaging API calls + +**Path Aliases:** +- `@/*` maps to `src/*` (configured in tsconfig.json) + +## Style Guide + +From `.gemini/styleguide.md`: +- 日本語で返答してください (Respond in Japanese) + +## Git Workflow + +- Main branch: `main` +- PR workflow enabled with pr-agent (`.github/workflows/pr-agent.yml`) +- Pre-push hook runs smoke E2E tests automatically diff --git a/e2e/tests/route-search/route-search.spec.ts b/e2e/tests/route-search/route-search.spec.ts new file mode 100644 index 0000000..901ec9c --- /dev/null +++ b/e2e/tests/route-search/route-search.spec.ts @@ -0,0 +1,94 @@ +import { test, expect } from '@playwright/test'; + +test.describe('経路検索機能テスト', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/', { waitUntil: 'domcontentloaded' }); + }); + + test.describe('API呼び出しの検証', () => { + test('トップページアクセス時に経路検索APIが呼び出される', async ({ page }) => { + const routeRequest = await page.waitForResponse( + (response) => response.url().includes('/api/route-search') && response.status() === 200, + ); + const data = await routeRequest.json(); + expect(data).toHaveProperty('route'); + expect(data).toHaveProperty('origin', '阪東橋駅'); + expect(data).toHaveProperty('destination', '綱島駅'); + expect(data).toHaveProperty('timestamp'); + }); + }); + + test.describe('ボタンの状態', () => { + test('読み込み完了後に「経路を表示」ボタンが表示される', async ({ page }) => { + // API応答を待つ + await page.waitForResponse( + (response) => response.url().includes('/api/route-search'), + ); + const button = page.getByRole('button', { name: '経路を表示' }); + await expect(button).toBeVisible(); + await expect(button).toBeEnabled(); + }); + }); + + test.describe('モーダル表示/非表示', () => { + test('「経路を表示」ボタンクリックでモーダルが表示される', async ({ page }) => { + await page.waitForResponse( + (response) => response.url().includes('/api/route-search'), + ); + await page.getByRole('button', { name: '経路を表示' }).click(); + + await expect(page.getByRole('heading', { name: '経路検索結果' })).toBeVisible(); + }); + + test('閉じるボタンでモーダルが非表示になる', async ({ page }) => { + await page.waitForResponse( + (response) => response.url().includes('/api/route-search'), + ); + await page.getByRole('button', { name: '経路を表示' }).click(); + await expect(page.getByRole('heading', { name: '経路検索結果' })).toBeVisible(); + + // 閉じるボタン(×)をクリック + await page.getByRole('button', { name: '\u00d7' }).click(); + await expect(page.getByRole('heading', { name: '経路検索結果' })).not.toBeVisible(); + }); + }); + + test.describe('経路情報の表示内容', () => { + test('E2Eテスト用ダミー経路情報が正しく表示される', async ({ page }) => { + await page.waitForResponse( + (response) => response.url().includes('/api/route-search'), + ); + await page.getByRole('button', { name: '経路を表示' }).click(); + + // E2E_FAKE_LLM=1の場合のダミーデータが表示されることを確認 + const modal = page.locator('[class*="modal"]'); + await expect(modal).toBeVisible(); + await expect(modal.getByText('阪東橋駅')).toBeVisible(); + await expect(modal.getByText('綱島駅')).toBeVisible(); + }); + }); + + test.describe('エラーケース', () => { + test('API失敗時にエラーメッセージが表示される', async ({ page }) => { + // APIをモックして失敗させる + await page.route('**/api/route-search', (route) => { + route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ error: 'Internal Server Error' }), + }); + }); + + await page.goto('/', { waitUntil: 'domcontentloaded' }); + + // エラー状態でもボタンが表示されることを待つ + const button = page.getByRole('button', { name: '経路を表示' }); + await expect(button).toBeVisible({ timeout: 10000 }); + + await button.click(); + + // モーダル内にエラーメッセージが表示される + await expect(page.getByText('経路情報の取得に失敗しました')).toBeVisible(); + }); + }); +}); diff --git a/package-lock.json b/package-lock.json index b848d15..d62e2cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,18 +8,16 @@ "name": "leaving-work", "version": "0.1.0", "dependencies": { - "@google/generative-ai": "^0.24.1", + "@ai-sdk/google": "^2.0.62", "@line/bot-sdk": "^9.2.2", "ai": "^5.0.44", "express": "^4.19.2", "google-home-player": "^2.0.1", "next": "14.1.3", "next-auth": "^4.24.11", - "pnpm": "^10.16.1", "react": "^18", "react-dom": "^18", - "react-markdown": "^10.1.0", - "tsx": "^4.20.5" + "react-markdown": "^10.1.0" }, "devDependencies": { "@playwright/test": "^1.54.2", @@ -33,10 +31,12 @@ "esbuild-css-modules-plugin": "^3.1.4", "eslint": "^8", "eslint-config-next": "14.1.3", + "pnpm": "^10.16.1", "react-router-dom": "^6.22.3", "ts-node": "^10.9.2", "ts-node-dev": "^2.0.0", "tsconfig-paths": "^4.2.0", + "tsx": "^4.20.5", "typescript": "^5" } }, @@ -55,6 +55,48 @@ "zod": "^3.25.76 || ^4" } }, + "node_modules/@ai-sdk/google": { + "version": "2.0.62", + "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-2.0.62.tgz", + "integrity": "sha512-RUpgkG5dWsmkYQsluTdutXakFpyQQ1NvELnQ0KD1VWTNLHWD70fO0FOpOs1cQKeTe7PspcJSii9Zekpaepv6qA==", + "dependencies": { + "@ai-sdk/provider": "2.0.1", + "@ai-sdk/provider-utils": "3.0.22" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/google/node_modules/@ai-sdk/provider": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.1.tgz", + "integrity": "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng==", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/google/node_modules/@ai-sdk/provider-utils": { + "version": "3.0.22", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.22.tgz", + "integrity": "sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw==", + "dependencies": { + "@ai-sdk/provider": "2.0.1", + "@standard-schema/spec": "^1.0.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@ai-sdk/provider": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.0.tgz", @@ -140,6 +182,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "aix" @@ -155,6 +198,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "android" @@ -170,6 +214,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "android" @@ -185,6 +230,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "android" @@ -200,6 +246,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -215,6 +262,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -230,6 +278,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -245,6 +294,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -260,6 +310,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" @@ -275,6 +326,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -290,6 +342,7 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "linux" @@ -305,6 +358,7 @@ "cpu": [ "loong64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -320,6 +374,7 @@ "cpu": [ "mips64el" ], + "dev": true, "optional": true, "os": [ "linux" @@ -335,6 +390,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -350,6 +406,7 @@ "cpu": [ "riscv64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -365,6 +422,7 @@ "cpu": [ "s390x" ], + "dev": true, "optional": true, "os": [ "linux" @@ -380,6 +438,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -395,6 +454,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "netbsd" @@ -410,6 +470,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "netbsd" @@ -425,6 +486,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "openbsd" @@ -440,6 +502,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "openbsd" @@ -455,6 +518,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "sunos" @@ -470,6 +534,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -485,6 +550,7 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "win32" @@ -500,6 +566,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -567,14 +634,6 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/@google/generative-ai": { - "version": "0.24.1", - "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.1.tgz", - "integrity": "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -1023,9 +1082,9 @@ "dev": true }, "node_modules/@standard-schema/spec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" }, "node_modules/@swc/helpers": { "version": "0.5.2", @@ -2801,6 +2860,7 @@ "version": "0.25.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", + "dev": true, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" @@ -3641,6 +3701,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -3743,6 +3804,7 @@ "version": "4.10.1", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", + "dev": true, "dependencies": { "resolve-pkg-maps": "^1.0.0" }, @@ -6294,6 +6356,7 @@ "version": "10.16.1", "resolved": "https://registry.npmjs.org/pnpm/-/pnpm-10.16.1.tgz", "integrity": "sha512-DhVaomKduGcrSehHXaYiaqS96oX9zf3BU1CHSUbU88kfqvZMvcSl0auAAvRz1cP87c0ZeYnPA5D5ut08BGeHBg==", + "dev": true, "bin": { "pnpm": "bin/pnpm.cjs", "pnpx": "bin/pnpx.cjs" @@ -6708,6 +6771,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } @@ -7723,6 +7787,7 @@ "version": "4.20.5", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.5.tgz", "integrity": "sha512-+wKjMNU9w/EaQayHXb7WA7ZaHY6hN8WgfvHNQ3t1PnU91/7O8TcTnIhCDYTZwnt8JsO9IBqZ30Ln1r7pPF52Aw==", + "dev": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" diff --git a/package.json b/package.json index 072b1c9..624d2c9 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "test:e2e:report": "playwright show-report" }, "dependencies": { + "@ai-sdk/google": "^2.0.62", "@line/bot-sdk": "^9.2.2", "ai": "^5.0.44", "express": "^4.19.2", @@ -26,7 +27,6 @@ "react-markdown": "^10.1.0" }, "devDependencies": { - "pnpm": "^10.16.1", "@playwright/test": "^1.54.2", "@types/axios": "^0.14.0", "@types/css-modules": "^1.0.5", @@ -38,12 +38,13 @@ "esbuild-css-modules-plugin": "^3.1.4", "eslint": "^8", "eslint-config-next": "14.1.3", + "pnpm": "^10.16.1", "react-router-dom": "^6.22.3", "ts-node": "^10.9.2", "ts-node-dev": "^2.0.0", "tsconfig-paths": "^4.2.0", - "typescript": "^5", - "tsx": "^4.20.5" + "tsx": "^4.20.5", + "typescript": "^5" }, "sideEffects": false } diff --git a/public/game/index.html b/public/game/index.html deleted file mode 100644 index 9ab23d2..0000000 --- a/public/game/index.html +++ /dev/null @@ -1,1009 +0,0 @@ - - -
- - -経路を検索中...
+ )} + {error &&{error}
} + {routeData && ( +