From 14a5f6f678de7f41fd5967baa069614449488679 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Nov 2025 06:17:33 +0000 Subject: [PATCH 1/3] fix: resolve ESLint warnings and TypeScript errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Changes Made ### Code Quality Improvements - Fixed all ESLint warnings in components and pages - Resolved all TypeScript type errors in test files - Removed unused imports and variables - Replaced `any` types with proper type definitions ### Component Fixes - **app/runner/page.tsx**: Removed unused variables and imports, fixed type definitions - **app/chaser/page.tsx**: Added missing dependency to useEffect - **components/GameStats.tsx**: Removed unused import, added proper type interfaces - **components/Map.tsx**: Removed unused import, fixed type assertion - **components/ReplayViewer.tsx**: Removed unused import - **components/ZoneManager.tsx**: Removed unused import ### Test File Fixes - Updated all test mocks to match actual type definitions - Removed non-existent properties (register, logout, updateProfile, accuracy, firebaseUser) - Added correct properties (session, signIn, signOut) - Added non-null assertions for array access in tests - Fixed GeolocationPosition mock to include toJSON method ### Dependency Updates - Removed deprecated @supabase/auth-helpers-nextjs package - Package was not in use and showing deprecation warnings ## Verification ✅ Build: Success ✅ Type Check: No errors ✅ ESLint: No warnings or errors ✅ Tests: 70/70 passing (100%) --- app/chaser/page.tsx | 2 +- app/runner/page.tsx | 12 +++----- components/GameStats.tsx | 27 +++++++++++++---- components/Map.tsx | 3 +- components/ReplayViewer.tsx | 2 +- components/ZoneManager.tsx | 2 +- hooks/__tests__/useCapture.test.tsx | 22 +++++++------- hooks/__tests__/useLocation.test.ts | 5 ++-- hooks/__tests__/useMissions.test.tsx | 10 ++----- hooks/__tests__/useZones.test.tsx | 25 ++++++++-------- package-lock.json | 43 ---------------------------- package.json | 1 - 12 files changed, 57 insertions(+), 97 deletions(-) diff --git a/app/chaser/page.tsx b/app/chaser/page.tsx index 63aebd5..6d05f18 100644 --- a/app/chaser/page.tsx +++ b/app/chaser/page.tsx @@ -78,7 +78,7 @@ export default function ChaserPage() { supabase.removeChannel(channel); } }; - }, [user, location]); + }, [user, location, game?.settings.chaserRadarRange]); useEffect(() => { if (!isTracking) { diff --git a/app/runner/page.tsx b/app/runner/page.tsx index f13e712..6553001 100644 --- a/app/runner/page.tsx +++ b/app/runner/page.tsx @@ -6,8 +6,7 @@ import { useAuth } from '@/hooks/useAuth'; import { useLocation } from '@/hooks/useLocation'; import { useGame } from '@/hooks/useGame'; import { supabase } from '@/lib/supabase'; -import type { User, Mission } from '@/types'; -import type { RealtimeChannel } from '@supabase/supabase-js'; +import type { User } from '@/types'; import MissionManager from '@/components/MissionManager'; const Map = dynamic(() => import('@/components/Map'), { ssr: false }); @@ -17,13 +16,10 @@ export default function RunnerPage() { const { location, isTracking, startTracking } = useLocation(); const { game } = useGame(); const [teammates, setTeammates] = useState([]); - const [missions, setMissions] = useState([]); useEffect(() => { if (!user || user.role !== 'runner' || !user.team) return; - let channel: RealtimeChannel; - const fetchTeammates = async () => { if (!user.team) { setTeammates([]); @@ -43,10 +39,10 @@ export default function RunnerPage() { } if (data) { - const mappedUsers: User[] = data.map((u: any) => ({ + const mappedUsers: User[] = data.map((u: { id: string; nickname: string; role: string; team_id: string | null; status: string; updated_at: string }) => ({ id: u.id, nickname: u.nickname, - role: u.role as any, + role: u.role as 'runner' | 'chaser' | 'gamemaster', team: u.team_id || undefined, status: u.status === 'captured' ? 'captured' : u.status === 'offline' ? 'safe' : 'active', lastUpdated: new Date(u.updated_at), @@ -58,7 +54,7 @@ export default function RunnerPage() { fetchTeammates(); // Subscribe to real-time updates - channel = supabase + const channel = supabase .channel('teammates_changes') .on('postgres_changes', { event: '*', diff --git a/components/GameStats.tsx b/components/GameStats.tsx index ee2e08c..f025323 100644 --- a/components/GameStats.tsx +++ b/components/GameStats.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useEffect } from 'react'; -import { useLocationHistory, type PlayerStats } from '@/hooks/useLocationHistory'; +import type { PlayerStats } from '@/hooks/useLocationHistory'; import { supabase } from '@/lib/supabase'; interface PlayerStatsData { @@ -17,6 +17,21 @@ interface GameStatsProps { isGameMaster?: boolean; } +interface UserRecord { + id: string; + nickname: string; + role: string; + team?: string; +} + +interface LocationHistoryRecord { + user_id: string; + latitude: number; + longitude: number; + timestamp: string; + speed?: number; +} + export default function GameStats({ gameId, isGameMaster = false }: GameStatsProps) { const [playerStats, setPlayerStats] = useState([]); const [loading, setLoading] = useState(true); @@ -37,7 +52,7 @@ export default function GameStats({ gameId, isGameMaster = false }: GameStatsPro if (usersError) throw usersError; if (users) { - const statsPromises = users.map(async (user: any) => { + const statsPromises = users.map(async (user: UserRecord) => { // Fetch location history for each user const { data: historyData, error: historyError } = await supabase .from('location_history') @@ -74,8 +89,8 @@ export default function GameStats({ gameId, isGameMaster = false }: GameStatsPro const speeds: number[] = []; for (let i = 1; i < historyData.length; i++) { - const prev = historyData[i - 1] as any; - const curr = historyData[i] as any; + const prev = historyData[i - 1] as unknown as LocationHistoryRecord; + const curr = historyData[i] as unknown as LocationHistoryRecord; if (!prev || !curr) continue; @@ -91,8 +106,8 @@ export default function GameStats({ gameId, isGameMaster = false }: GameStatsPro } } - const lastEntry = historyData[historyData.length - 1] as any; - const firstEntry = historyData[0] as any; + const lastEntry = historyData[historyData.length - 1] as unknown as LocationHistoryRecord; + const firstEntry = historyData[0] as unknown as LocationHistoryRecord; if (!lastEntry || !firstEntry) { return { diff --git a/components/Map.tsx b/components/Map.tsx index 033c8de..e623ab8 100644 --- a/components/Map.tsx +++ b/components/Map.tsx @@ -1,12 +1,11 @@ 'use client'; -import { useEffect } from 'react'; import L from 'leaflet'; import { MapContainer, TileLayer, Marker, Popup, Circle } from 'react-leaflet'; import type { User, Zone } from '@/types'; import 'leaflet/dist/leaflet.css'; -delete (L.Icon.Default.prototype as any)._getIconUrl; +delete (L.Icon.Default.prototype as unknown as Record)._getIconUrl; L.Icon.Default.mergeOptions({ iconRetinaUrl: '/marker-icon-2x.png', iconUrl: '/marker-icon.png', diff --git a/components/ReplayViewer.tsx b/components/ReplayViewer.tsx index 6b139ea..a8868aa 100644 --- a/components/ReplayViewer.tsx +++ b/components/ReplayViewer.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useEffect, useCallback } from 'react'; -import { useLocationHistory, type LocationHistoryEntry } from '@/hooks/useLocationHistory'; +import { useLocationHistory } from '@/hooks/useLocationHistory'; interface ReplayViewerProps { userId: string; diff --git a/components/ZoneManager.tsx b/components/ZoneManager.tsx index b84925d..d382b7e 100644 --- a/components/ZoneManager.tsx +++ b/components/ZoneManager.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import { useZones } from '@/hooks/useZones'; import { useLocation } from '@/hooks/useLocation'; -import type { Zone, Location } from '@/types'; +import type { Location } from '@/types'; interface ZoneManagerProps { isGameMaster: boolean; diff --git a/hooks/__tests__/useCapture.test.tsx b/hooks/__tests__/useCapture.test.tsx index 648e510..2516e2c 100644 --- a/hooks/__tests__/useCapture.test.tsx +++ b/hooks/__tests__/useCapture.test.tsx @@ -57,18 +57,17 @@ describe('useCapture', () => { // Mock useAuth vi.mocked(useAuth).mockReturnValue({ user: mockChaser, + session: null, loading: false, error: null, - register: vi.fn(), - logout: vi.fn(), - updateProfile: vi.fn(), + signIn: vi.fn(), + signOut: vi.fn(), }); // Mock useLocation vi.mocked(useLocation).mockReturnValue({ location: mockLocation, error: null, - accuracy: 10, isTracking: true, startTracking: vi.fn(), stopTracking: vi.fn(), @@ -142,8 +141,8 @@ describe('useCapture', () => { }); expect(result.current.captures).toHaveLength(1); - expect(result.current.captures[0].id).toBe(mockCapture.id); - expect(result.current.captures[0].chaserId).toBe(mockCapture.chaserId); + expect(result.current.captures[0]!.id).toBe(mockCapture.id); + expect(result.current.captures[0]!.chaserId).toBe(mockCapture.chaserId); }); it('should record a capture successfully', async () => { @@ -208,11 +207,11 @@ describe('useCapture', () => { it('should throw error if non-chaser tries to record capture', async () => { vi.mocked(useAuth).mockReturnValue({ user: mockRunner, + session: null, loading: false, error: null, - register: vi.fn(), - logout: vi.fn(), - updateProfile: vi.fn(), + signIn: vi.fn(), + signOut: vi.fn(), }); const mockSelect = vi.fn().mockReturnThis(); @@ -249,7 +248,6 @@ describe('useCapture', () => { vi.mocked(useLocation).mockReturnValue({ location: null, error: null, - accuracy: 0, isTracking: false, startTracking: vi.fn(), stopTracking: vi.fn(), @@ -348,7 +346,7 @@ describe('useCapture', () => { }); expect(nearbyRunners).toHaveLength(1); - expect(nearbyRunners[0].id).toBe(nearbyRunner.id); + expect(nearbyRunners![0]!.id).toBe(nearbyRunner.id); }); it('should handle real-time capture updates', async () => { @@ -399,7 +397,7 @@ describe('useCapture', () => { expect(result.current.captures).toHaveLength(1); }); - expect(result.current.captures[0].id).toBe('capture-2'); + expect(result.current.captures[0]!.id).toBe('capture-2'); }); it('should throw error when used outside CaptureProvider', () => { diff --git a/hooks/__tests__/useLocation.test.ts b/hooks/__tests__/useLocation.test.ts index ba31f08..820010b 100644 --- a/hooks/__tests__/useLocation.test.ts +++ b/hooks/__tests__/useLocation.test.ts @@ -34,7 +34,7 @@ describe('useLocation', () => { // Mock useAuth hook vi.mocked(useAuth).mockReturnValue({ user: { id: 'test-user-id', nickname: 'Test User', role: 'runner', status: 'active', lastUpdated: new Date() }, - firebaseUser: null, + session: null, loading: false, error: null, signIn: vi.fn(), @@ -55,7 +55,6 @@ describe('useLocation', () => { }); it('should set error when geolocation is not supported', () => { - // @ts-expect-error - intentionally setting to undefined Object.defineProperty(global.navigator, 'geolocation', { writable: true, value: undefined, @@ -105,8 +104,10 @@ describe('useLocation', () => { altitudeAccuracy: null, heading: null, speed: null, + toJSON: () => ({}), }, timestamp: Date.now(), + toJSON: () => ({}), }; // Call the position callback synchronously diff --git a/hooks/__tests__/useMissions.test.tsx b/hooks/__tests__/useMissions.test.tsx index e650e60..38ba531 100644 --- a/hooks/__tests__/useMissions.test.tsx +++ b/hooks/__tests__/useMissions.test.tsx @@ -77,7 +77,6 @@ describe('useMissions', () => { vi.mocked(useLocation).mockReturnValue({ location: null, error: null, - accuracy: 0, isTracking: false, startTracking: vi.fn(), stopTracking: vi.fn(), @@ -353,7 +352,6 @@ describe('useMissions', () => { vi.mocked(useLocation).mockReturnValue({ location: mockLocation, error: null, - accuracy: 10, isTracking: true, startTracking: vi.fn(), stopTracking: vi.fn(), @@ -408,7 +406,6 @@ describe('useMissions', () => { vi.mocked(useLocation).mockReturnValue({ location: mockLocation, error: null, - accuracy: 10, isTracking: true, startTracking: vi.fn(), stopTracking: vi.fn(), @@ -461,7 +458,6 @@ describe('useMissions', () => { vi.mocked(useLocation).mockReturnValue({ location: mockLocation, error: null, - accuracy: 10, isTracking: true, startTracking: vi.fn(), stopTracking: vi.fn(), @@ -545,9 +541,9 @@ describe('useMissions', () => { await waitFor(() => { expect(result.current.loading).toBe(false); expect(result.current.missions).toHaveLength(3); - expect(result.current.missions[0].type).toBe('area'); - expect(result.current.missions[1].type).toBe('escape'); - expect(result.current.missions[2].type).toBe('rescue'); + expect(result.current.missions[0]!.type).toBe('area'); + expect(result.current.missions[1]!.type).toBe('escape'); + expect(result.current.missions[2]!.type).toBe('rescue'); }); }); }); diff --git a/hooks/__tests__/useZones.test.tsx b/hooks/__tests__/useZones.test.tsx index 554b7ab..5926267 100644 --- a/hooks/__tests__/useZones.test.tsx +++ b/hooks/__tests__/useZones.test.tsx @@ -64,18 +64,17 @@ describe('useZones', () => { // Mock useAuth vi.mocked(useAuth).mockReturnValue({ user: mockRunner, + session: null, loading: false, error: null, - register: vi.fn(), - logout: vi.fn(), - updateProfile: vi.fn(), + signIn: vi.fn(), + signOut: vi.fn(), }); // Mock useLocation vi.mocked(useLocation).mockReturnValue({ location: mockLocation, error: null, - accuracy: 10, isTracking: true, startTracking: vi.fn(), stopTracking: vi.fn(), @@ -165,8 +164,8 @@ describe('useZones', () => { expect(result.current.safeZones).toHaveLength(1); expect(result.current.restrictedZones).toHaveLength(1); - expect(result.current.safeZones[0].id).toBe(mockSafeZone.id); - expect(result.current.restrictedZones[0].id).toBe(mockRestrictedZone.id); + expect(result.current.safeZones[0]!.id).toBe(mockSafeZone.id); + expect(result.current.restrictedZones[0]!.id).toBe(mockRestrictedZone.id); }); it('should create a zone as gamemaster', async () => { @@ -174,9 +173,9 @@ describe('useZones', () => { user: mockGameMaster, loading: false, error: null, - register: vi.fn(), - logout: vi.fn(), - updateProfile: vi.fn(), + session: null, + signIn: vi.fn(), + signOut: vi.fn(), }); const mockSelect = vi.fn().mockReturnThis(); @@ -265,9 +264,9 @@ describe('useZones', () => { user: mockGameMaster, loading: false, error: null, - register: vi.fn(), - logout: vi.fn(), - updateProfile: vi.fn(), + session: null, + signIn: vi.fn(), + signOut: vi.fn(), }); const mockSelect = vi.fn().mockReturnThis(); @@ -458,7 +457,7 @@ describe('useZones', () => { expect(result.current.safeZones).toHaveLength(1); }); - expect(result.current.safeZones[0].id).toBe('zone-3'); + expect(result.current.safeZones[0]!.id).toBe('zone-3'); }); it('should throw error when used outside ZoneProvider', () => { diff --git a/package-lock.json b/package-lock.json index 9af5ffd..14feeb6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,6 @@ "name": "tag-support", "version": "0.1.0", "dependencies": { - "@supabase/auth-helpers-nextjs": "^0.10.0", "@supabase/supabase-js": "^2.45.0", "@types/leaflet": "^1.9.20", "geolib": "^3.3.4", @@ -4532,33 +4531,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@supabase/auth-helpers-nextjs": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@supabase/auth-helpers-nextjs/-/auth-helpers-nextjs-0.10.0.tgz", - "integrity": "sha512-2dfOGsM4yZt0oS4TPiE7bD4vf7EVz7NRz/IJrV6vLg0GP7sMUx8wndv2euLGq4BjN9lUCpu6DG/uCC8j+ylwPg==", - "deprecated": "This package is now deprecated - please use the @supabase/ssr package instead.", - "license": "MIT", - "dependencies": { - "@supabase/auth-helpers-shared": "0.7.0", - "set-cookie-parser": "^2.6.0" - }, - "peerDependencies": { - "@supabase/supabase-js": "^2.39.8" - } - }, - "node_modules/@supabase/auth-helpers-shared": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@supabase/auth-helpers-shared/-/auth-helpers-shared-0.7.0.tgz", - "integrity": "sha512-FBFf2ei2R7QC+B/5wWkthMha8Ca2bWHAndN+syfuEUUfufv4mLcAgBCcgNg5nJR8L0gZfyuaxgubtOc9aW3Cpg==", - "deprecated": "This package is now deprecated - please use the @supabase/ssr package instead.", - "license": "MIT", - "dependencies": { - "jose": "^4.14.4" - }, - "peerDependencies": { - "@supabase/supabase-js": "^2.39.8" - } - }, "node_modules/@supabase/auth-js": { "version": "2.81.1", "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.81.1.tgz", @@ -9620,15 +9592,6 @@ "jiti": "bin/jiti.js" } }, - "node_modules/jose": { - "version": "4.15.9", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", - "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -12198,12 +12161,6 @@ "randombytes": "^2.1.0" } }, - "node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", - "license": "MIT" - }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", diff --git a/package.json b/package.json index ab24f18..07a3c5f 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,6 @@ "format:check": "prettier --check ." }, "dependencies": { - "@supabase/auth-helpers-nextjs": "^0.10.0", "@supabase/supabase-js": "^2.45.0", "@types/leaflet": "^1.9.20", "geolib": "^3.3.4", From e9e7d3bee41621bc2d7db82f331cf682dd6bb06e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Nov 2025 06:21:24 +0000 Subject: [PATCH 2/3] style: apply Prettier code formatting to all files Apply consistent code formatting across the entire codebase using Prettier. This ensures consistent code style and resolves all formatting warnings. Files affected: 40 files - Source files (components, hooks, pages) - Configuration files - Documentation files - Test files --- .eslintrc.json | 6 +- CLAUDE.md | 4 + PROGRESS.md | 20 +- README.md | 8 +- ROADMAP.md | 51 +++- app/chaser/page.tsx | 123 +++++----- app/gamemaster/page.tsx | 206 +++++++++------- app/globals.css | 89 ++++--- app/layout.tsx | 20 +- app/page.tsx | 76 +++--- app/runner/page.tsx | 120 ++++++---- components/GameControls.tsx | 220 ++++++++++-------- components/GameStats.tsx | 123 +++++----- components/Map.tsx | 57 +++-- components/MissionManager.tsx | 176 +++++++------- components/ReplayViewer.tsx | 113 ++++----- components/ZoneManager.tsx | 134 ++++++----- docs/FIREBASE_SETUP.md | 55 +++-- docs/SUPABASE_SETUP.md | 29 +++ docs/TECH_STACK_MIGRATION.md | 98 ++++---- docs/TESTING.md | 32 +-- .../\344\273\225\346\247\230\346\233\270.md" | 18 +- hooks/__tests__/useLocation.test.ts | 26 ++- hooks/__tests__/useMissions.test.tsx | 2 +- hooks/__tests__/useZones.test.tsx | 48 ++-- hooks/useAuth.tsx | 27 +-- hooks/useCapture.tsx | 60 ++--- hooks/useGame.tsx | 46 ++-- hooks/useLocation.ts | 67 +++--- hooks/useLocationHistory.tsx | 92 ++++---- hooks/useMissions.tsx | 103 ++++---- hooks/useZones.tsx | 99 ++++---- lib/firebase.ts | 4 +- lib/supabase.ts | 5 +- next.config.ts | 4 +- postcss.config.js | 2 +- public/manifest.json | 2 +- public/sw.js | 22 +- tailwind.config.js | 7 +- types/index.ts | 2 +- 40 files changed, 1368 insertions(+), 1028 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 3cf9e8c..6d6149a 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,9 +1,5 @@ { - "extends": [ - "next/core-web-vitals", - "plugin:@typescript-eslint/recommended", - "prettier" - ], + "extends": ["next/core-web-vitals", "plugin:@typescript-eslint/recommended", "prettier"], "parser": "@typescript-eslint/parser", "parserOptions": { "ecmaVersion": 2021, diff --git a/CLAUDE.md b/CLAUDE.md index ce1a1fa..b96def3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,10 +19,12 @@ ### 主要コンポーネントクラス #### カード + - `card-mobile`: 基本カード(elevation-2) - `card-elevated`: 強調カード(elevation-4) #### ボタン + - `btn-primary`: 青(主要アクション) - `btn-success`: 緑(開始/成功) - `btn-danger`: 赤(終了/削除) @@ -30,9 +32,11 @@ - `btn-secondary`: アウトライン型(副次的) #### フォーム + - `input-touch`: 48px最小高、2pxボーダー、フォーカス時にシャドウ #### ユーティリティ + - `elevation-1` ~ `elevation-6`: シャドウレベル - `haptic-light/medium/heavy`: タッチフィードバック diff --git a/PROGRESS.md b/PROGRESS.md index 89876c0..33573a1 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -4,11 +4,12 @@ **プロジェクト名**: Tag Support - リアル鬼ごっこサポートアプリ **開始日**: 2025-08-06 -**現在のバージョン**: v0.1.0-alpha +**現在のバージョン**: v0.1.0-alpha ## 完了済み機能 ✅ ### Phase 1: 基礎実装 (完了) + - [x] **プロジェクト初期セットアップ** - Next.js 15 + TypeScript + Tailwind CSS - ESLint無効、Turbopack有効 @@ -47,7 +48,7 @@ - チームメイト位置表示 - ミッション表示エリア - 位置追跡状況表示 - - **鬼画面** (`/chaser`) + - **鬼画面** (`/chaser`) - 200m範囲レーダー - 近距離逃走者表示 - 捕獲ボタン機能 @@ -65,23 +66,27 @@ ## 技術的成果 ### アーキテクチャ + - **フロントエンド**: React Server Components + Client Components適切な分離 - **状態管理**: React Context + Custom Hooks パターン - **データベース**: Firestore リアルタイムリスナー活用 - **地図**: Dynamic Import による SSR対応 ### パフォーマンス + - Leafletマーカー画像の事前配置 - 地図コンポーネントの動的読み込み - Firebase接続の最適化 - CI/CDビルドプロセスの最適化(外部依存削減) ⭐️ NEW ### 設計原則 + - Atomic Design準拠のコンポーネント設計 - TypeScript厳格型チェック - 仕様書要求の拡張性確保 ### Phase 2: 統合・UI改善 (完了) ⭐️ NEW + - [x] **ゲームマスター機能統合** - ZoneManager(エリア管理)をゲームマスターページに統合 - セーフゾーン・立禁エリアの作成・削除機能 @@ -100,6 +105,7 @@ - ステータス変更・役職変更UIの改善 ### Phase 3: ビルド環境改善・型安全性向上 (完了) + - [x] **CIビルドエラーの解決** - Google Fontsの外部依存を削除しシステムフォントに変更 - Tailwind CSSクラスの修正(duration-250 → duration-300) @@ -121,6 +127,7 @@ - 環境変数テストの改善 ### Phase 4: 位置履歴・リプレイ・統計機能 (完了) ⭐️ NEW + - [x] **位置履歴記録システム** - useLocationHistoryカスタムフック実装 - プレイヤーの位置、速度、方向を時系列で記録 @@ -149,11 +156,13 @@ ## 現在の制限事項 ### 未実装機能 + - [ ] Push通知システム - [ ] マルチゲームマスター対応 - [ ] データベーススキーマの作成(location_historyテーブル等) ### 技術的制約 + - Firebase設定が必要(.env.local) - 位置情報許可が必須 - オンライン環境での動作前提 @@ -195,6 +204,7 @@ tag-support/ (25ファイル作成) ⭐️ 更新 ## テスト状況 ### 自動テスト環境 ✅ + - [x] **ユニットテスト (Vitest)** - 70/70テスト成功(100%) ⭐️ 更新 - lib/supabase.ts: 4/4テスト成功(環境変数フォールバック対応) - hooks/useLocation.ts: 8/8テスト成功 @@ -219,6 +229,7 @@ tag-support/ (25ファイル作成) ⭐️ 更新 - カバレッジレポート、トラブルシューティング追加 ### 動作確認済み + - [x] ローカル開発環境起動 - [x] ビルドプロセス(npm run build成功) - [x] 全テストスイート実行(70/70成功) ⭐️ 更新 @@ -229,6 +240,7 @@ tag-support/ (25ファイル作成) ⭐️ 更新 - [ ] 役職別画面遷移 ### 未テスト + - [ ] 複数端末での同期 - [ ] モバイル実機テスト - [ ] PWAインストール @@ -237,6 +249,7 @@ tag-support/ (25ファイル作成) ⭐️ 更新 ## 次回セッションでの予定 ### 優先度: 高 + 1. **Supabaseデータベーススキーマ作成** - location_historyテーブルの作成 - インデックス設定(user_id, game_id, timestamp) @@ -251,6 +264,7 @@ tag-support/ (25ファイル作成) ⭐️ 更新 - 実環境での動作確認 ### 優先度: 中 + 4. **モバイル実機テスト** - iOS/Android実機でのテスト - PWAインストールテスト @@ -261,4 +275,4 @@ tag-support/ (25ファイル作成) ⭐️ 更新 6. **パフォーマンス最適化** - バンドルサイズ削減 - 地図描画の最適化 - - リプレイデータの効率的な読み込み \ No newline at end of file + - リプレイデータの効率的な読み込み diff --git a/README.md b/README.md index 9b38304..69a8fb8 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,16 @@ ## 機能概要 ### 🏃‍♂️ プレイヤー向け機能 + - **逃走者(Runner)**: チームメイト位置の表示、ミッション表示 - **鬼(Chaser)**: 近距離レーダー、捕獲機能 ### 🎮 運営向け機能 + - **ゲームマスター**: 全プレイヤー位置管理、ステータス変更、リアルタイム監視 ### 🗺️ 位置情報機能 + - リアルタイム位置追跡 - Leaflet.jsによる地図表示 - 調整可能な位置精度・更新間隔 @@ -28,6 +31,7 @@ ## 開発環境セットアップ ### 前提条件 + - Node.js 18以上 - npm または yarn @@ -86,11 +90,13 @@ tag-support/ ## 使用方法 ### 1. プレイヤー登録 + 1. アプリにアクセス 2. ニックネーム・役職・チームを選択 3. 位置情報のアクセス許可 ### 2. 各役職での操作 + - **逃走者**: チームメイト位置確認、ミッション進行 - **鬼**: 近くの逃走者を捕獲 - **ゲームマスター**: 全体管理、プレイヤーステータス変更 @@ -104,7 +110,7 @@ tag-support/ ✅ 役職別UI(逃走者・鬼・ゲームマスター) ✅ PWA設定・オフライン対応基礎 ✅ CI/CD (GitHub Actions) -✅ テスト環境 (Vitest + Playwright) +✅ テスト環境 (Vitest + Playwright) ## 今後の実装予定 diff --git a/ROADMAP.md b/ROADMAP.md index 8948c62..94d12fe 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,19 +5,22 @@ **最終目標**: 大学生10名規模での2-3km範囲リアル鬼ごっこ運営支援PWA **MVP目標日**: 未定(Firebase設定次第) -**β版目標日**: 未定 +**β版目標日**: 未定 ## Phase 1: 基礎実装 ✅ (完了) + **期間**: 2025-08-06 **状況**: 完了 ### 完了した機能 + - Next.js + Firebase基盤構築 - 認証・位置情報・地図システム - 役職別UI(逃走者・鬼・GM) - PWA基礎設定 ### 成果物 + - 20個のソースファイル - 完全なTypeScript型システム - 3つの役職別画面 @@ -25,22 +28,26 @@ --- ## Phase 2: 実用化準備 🚧 (次回優先) + **想定期間**: 1-2セッション **目標**: 実際にテストプレイ可能な状態 ### 2.1 Firebase実環境構築 + - [ ] Firebaseプロジェクト作成・設定 - [ ] Authentication有効化 - [ ] Firestore設定・セキュリティルール - [ ] 実環境での動作確認 -### 2.2 モバイル対応最適化 +### 2.2 モバイル対応最適化 + - [ ] レスポンシブデザイン調整 - [ ] タッチ操作最適化 - [ ] PWAインストールテスト - [ ] バッテリー使用量確認 ### 2.3 基本的なゲーム機能 + - [ ] ゲーム開始/終了制御 - [ ] 制限時間表示 - [ ] 基本的な勝利条件判定 @@ -50,18 +57,21 @@ --- ## Phase 3: 通知・ミッション機能 📋 -**想定期間**: 2-3セッション + +**想定期間**: 2-3セッション ### 3.1 Push通知システム + - [ ] Firebase Cloud Messaging設定 - [ ] 通知許可フロー - [ ] 役職別通知ロジック - ゲーム開始/終了通知 - - ミッション完了通知 + - ミッション完了通知 - 捕獲/救出通知 - [ ] バックグラウンド通知テスト ### 3.2 ミッションシステム + - [ ] ミッションデータモデル実装 - [ ] 位置ベース判定ロジック - エリア到達判定 @@ -71,6 +81,7 @@ - [ ] 達成状況リアルタイム同期 ### 3.3 ゲーム進行管理 + - [ ] GM用ミッション作成UI - [ ] 動的ミッション配信 - [ ] 進行状況ダッシュボード @@ -78,21 +89,25 @@ --- ## Phase 4: エリア管理・安全機能 🗺️ + **想定期間**: 2-3セッション ### 4.1 エリア管理システム + - [ ] セーフゾーン設定UI -- [ ] 立禁エリア設定UI +- [ ] 立禁エリア設定UI - [ ] ドラッグ&ドロップでのエリア編集 - [ ] エリア境界での自動警告 ### 4.2 安全機能強化 + - [ ] 危険エリア警告システム - [ ] 緊急停止機能 - [ ] 参加者の安全状況監視 - [ ] SOS機能 ### 4.3 位置情報精度調整 + - [ ] GM用精度設定UI - [ ] 更新間隔動的調整 - [ ] 通信断時の挙動改善 @@ -100,19 +115,23 @@ --- ## Phase 5: 高度なゲーム機能 🎮 + **想定期間**: 3-4セッション ### 5.1 特殊役職システム + - [ ] データモデル拡張 - [ ] 特殊能力定義・実装 - [ ] バランス調整機能 ### 5.2 チーム戦機能 + - [ ] チーム間通信 - [ ] チーム別スコアリング - [ ] 連携ミッション ### 5.3 ゲームモード追加 + - [ ] 複数ゲームモード選択 - [ ] カスタムルール設定 - [ ] トーナメント機能 @@ -120,19 +139,23 @@ --- ## Phase 6: 分析・改善機能 📊 + **想定期間**: 2-3セッション -### 6.1 リプレイシステム +### 6.1 リプレイシステム + - [ ] 移動履歴可視化 - [ ] タイムライン再生 - [ ] ハイライト機能 ### 6.2 ゲーム分析 + - [ ] 統計データ収集 - [ ] プレイヤー行動分析 - [ ] ゲームバランス分析 ### 6.3 改善サイクル + - [ ] フィードバック収集システム - [ ] A/Bテスト基盤 - [ ] パフォーマンス監視 @@ -140,19 +163,23 @@ --- ## Phase 7: 本格運用準備 🚀 + **想定期間**: 2-3セッション ### 7.1 ドメイン・インフラ + - [ ] atok.dev ドメイン設定 - [ ] Cloudflare Pages設定 - [ ] 本番環境構築 ### 7.2 運用機能 + - [ ] 複数GM対応 - [ ] イベント管理システム - [ ] 参加者募集機能 ### 7.3 スケーラビリティ + - [ ] 大規模対応(50-100名) - [ ] WebSocket高頻度同期 - [ ] 負荷テスト・最適化 @@ -162,16 +189,19 @@ ## 技術的マイルストーン ### セキュリティ + - [ ] Firestore セキュリティルール強化 - [ ] 位置情報保護対策 - [ ] 個人情報取扱改善 -### パフォーマンス +### パフォーマンス + - [ ] バンドルサイズ最適化 - [ ] 地図描画パフォーマンス - [ ] バッテリー消費最適化 ### 品質保証 + - [ ] 自動テスト導入 - [ ] E2Eテスト設定 - [ ] CI/CD構築 @@ -179,16 +209,19 @@ ## リスク管理 ### 技術リスク + - **Firebase制限**: 無料枠・API制限対策 - **位置精度**: GPS精度の天候・環境依存 - **バッテリー**: 長時間使用での電池消耗 -### 運用リスク +### 運用リスク + - **安全面**: 屋外活動での事故リスク - **通信**: 電波状況による機能制限 - **参加者**: ルール理解・操作習熟 ### 対策 + - 段階的テスト実施 - フォールバック機能準備 - 十分な事前説明・練習時間確保 @@ -199,4 +232,4 @@ 2. **モバイル実機テスト** 3. **基本ゲーム機能追加** -**目標**: 実際に屋外でテストプレイが可能な状態を目指す \ No newline at end of file +**目標**: 実際に屋外でテストプレイが可能な状態を目指す diff --git a/app/chaser/page.tsx b/app/chaser/page.tsx index 6d05f18..bb1c66d 100644 --- a/app/chaser/page.tsx +++ b/app/chaser/page.tsx @@ -48,7 +48,7 @@ export default function ChaserPage() { if (location) { const radarRange = game?.settings.chaserRadarRange || 200; - const nearby = mappedRunners.filter(runner => { + const nearby = mappedRunners.filter((runner) => { if (!runner.location) return false; const distance = calculateDistance(location, runner.location); return distance <= radarRange; @@ -63,14 +63,18 @@ export default function ChaserPage() { // Subscribe to real-time updates channel = supabase .channel('runners_changes') - .on('postgres_changes', { - event: '*', - schema: 'public', - table: 'users', - filter: 'role=eq.runner', - }, () => { - fetchRunners(); - }) + .on( + 'postgres_changes', + { + event: '*', + schema: 'public', + table: 'users', + filter: 'role=eq.runner', + }, + () => { + fetchRunners(); + } + ) .subscribe(); return () => { @@ -86,17 +90,20 @@ export default function ChaserPage() { } }, [isTracking, startTracking]); - const calculateDistance = (pos1: {lat: number, lng: number}, pos2: {lat: number, lng: number}) => { + const calculateDistance = ( + pos1: { lat: number; lng: number }, + pos2: { lat: number; lng: number } + ) => { const R = 6371e3; - const φ1 = pos1.lat * Math.PI/180; - const φ2 = pos2.lat * Math.PI/180; - const Δφ = (pos2.lat-pos1.lat) * Math.PI/180; - const Δλ = (pos2.lng-pos1.lng) * Math.PI/180; + const φ1 = (pos1.lat * Math.PI) / 180; + const φ2 = (pos2.lat * Math.PI) / 180; + const Δφ = ((pos2.lat - pos1.lat) * Math.PI) / 180; + const Δλ = ((pos2.lng - pos1.lng) * Math.PI) / 180; - const a = Math.sin(Δφ/2) * Math.sin(Δφ/2) + - Math.cos(φ1) * Math.cos(φ2) * - Math.sin(Δλ/2) * Math.sin(Δλ/2); - const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); + const a = + Math.sin(Δφ / 2) * Math.sin(Δφ / 2) + + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; }; @@ -107,10 +114,10 @@ export default function ChaserPage() { try { // Update runner status to captured const updatePayload: Record = { status: 'captured' }; - const { error: runnerError } = await (supabase + const { error: runnerError } = await supabase .from('users') .update(updatePayload as never) - .eq('id', runnerId)); + .eq('id', runnerId); if (runnerError) throw runnerError; @@ -121,22 +128,22 @@ export default function ChaserPage() { }; if (!user || user.role !== 'chaser') { - return
-
-

アクセス拒否

-

鬼の権限が必要です

+ return ( +
+
+

アクセス拒否

+

鬼の権限が必要です

+
-
; + ); } - const mapCenter: [number, number] = location - ? [location.lat, location.lng] - : [35.5522, 139.7797]; + const mapCenter: [number, number] = location ? [location.lat, location.lng] : [35.5522, 139.7797]; return ( -
-
-
+
+
+

👹 鬼

{user.nickname}

@@ -144,24 +151,32 @@ export default function ChaserPage() {

捕獲数: {user.captureCount || 0}人

{game && ( -

ゲーム: { - game.status === 'waiting' ? '待機中' : - game.status === 'active' ? '進行中' : - game.status === 'paused' ? '一時停止' : - game.status === 'finished' ? '終了' : game.status - }

+

+ ゲーム:{' '} + {game.status === 'waiting' + ? '待機中' + : game.status === 'active' + ? '進行中' + : game.status === 'paused' + ? '一時停止' + : game.status === 'finished' + ? '終了' + : game.status} +

)} {isTracking &&

📍 位置追跡中

}
{game && game.status === 'active' && game.startTime && ( -
-

ゲーム開始から {Math.floor((Date.now() - game.startTime.getTime()) / 60000)} 分経過

+
+

+ ゲーム開始から {Math.floor((Date.now() - game.startTime.getTime()) / 60000)} 分経過 +

)}
-
+
-
-

📡 近くの逃走者

+
+

📡 近くの逃走者

{nearbyRunners.length === 0 ? ( -

レーダー範囲内に逃走者なし

+

レーダー範囲内に逃走者なし

) : ( - nearbyRunners.map(runner => ( -
+ nearbyRunners.map((runner) => ( +
-

{runner.nickname}

+

{runner.nickname}

チーム {runner.team}

{runner.status === 'active' && ( )} {runner.status === 'captured' && ( - 捕獲済み + 捕獲済み )}
)) )}
- -
+ +

- レーダー範囲: {game?.settings.chaserRadarRange || 200}m | 総逃走者数: {allRunners.length}人 + レーダー範囲: {game?.settings.chaserRadarRange || 200}m | 総逃走者数:{' '} + {allRunners.length}人

); -} \ No newline at end of file +} diff --git a/app/gamemaster/page.tsx b/app/gamemaster/page.tsx index a0d1a31..6bab47d 100644 --- a/app/gamemaster/page.tsx +++ b/app/gamemaster/page.tsx @@ -57,13 +57,17 @@ export default function GamemasterPage() { // Subscribe to real-time updates channel = supabase .channel('all_players_changes') - .on('postgres_changes', { - event: '*', - schema: 'public', - table: 'users', - }, () => { - fetchPlayers(); - }) + .on( + 'postgres_changes', + { + event: '*', + schema: 'public', + table: 'users', + }, + () => { + fetchPlayers(); + } + ) .subscribe(); return () => { @@ -76,13 +80,14 @@ export default function GamemasterPage() { const updatePlayerStatus = async (playerId: string, newStatus: string) => { try { const updatePayload: Record = { - status: newStatus === 'captured' ? 'captured' : newStatus === 'offline' ? 'offline' : 'active', - updated_at: new Date().toISOString() + status: + newStatus === 'captured' ? 'captured' : newStatus === 'offline' ? 'offline' : 'active', + updated_at: new Date().toISOString(), }; - const { error } = await (supabase + const { error } = await supabase .from('users') .update(updatePayload as never) - .eq('id', playerId)); + .eq('id', playerId); if (error) throw error; } catch (error) { @@ -94,16 +99,16 @@ export default function GamemasterPage() { try { const updates: Record = { role: newRole, - updated_at: new Date().toISOString() + updated_at: new Date().toISOString(), }; if (newTeam) { updates.team_id = newTeam; } - const { error } = await (supabase + const { error } = await supabase .from('users') .update(updates as never) - .eq('id', playerId)); + .eq('id', playerId); if (error) throw error; } catch (error) { @@ -112,61 +117,75 @@ export default function GamemasterPage() { }; if (!user || user.role !== 'gamemaster') { - return
-
-

アクセス拒否

-

ゲームマスターの権限が必要です

+ return ( +
+
+

アクセス拒否

+

ゲームマスターの権限が必要です

+
-
; + ); } - const runners = allPlayers.filter(p => p.role === 'runner'); - const chasers = allPlayers.filter(p => p.role === 'chaser'); - const activePlayers = allPlayers.filter(p => p.status === 'active'); - const capturedPlayers = allPlayers.filter(p => p.status === 'captured'); + const runners = allPlayers.filter((p) => p.role === 'runner'); + const chasers = allPlayers.filter((p) => p.role === 'chaser'); + const activePlayers = allPlayers.filter((p) => p.status === 'active'); + const capturedPlayers = allPlayers.filter((p) => p.status === 'captured'); - const mapCenter: [number, number] = allPlayers.find(p => p.location) - ? [allPlayers.find(p => p.location)!.location!.lat, allPlayers.find(p => p.location)!.location!.lng] + const mapCenter: [number, number] = allPlayers.find((p) => p.location) + ? [ + allPlayers.find((p) => p.location)!.location!.lat, + allPlayers.find((p) => p.location)!.location!.lng, + ] : [35.5522, 139.7797]; return ( -
-
-
+
+
+

🎮 ゲームマスター

{user.nickname}

-
+
- +
-

+

📊 ゲーム統計

-
+

🏃 逃走者

合計 {runners.length}人

-

逃走中 {runners.filter(r => r.status === 'active').length}人

+

+ 逃走中 {runners.filter((r) => r.status === 'active').length}人 +

-
+

👹 鬼

合計 {chasers.length}人

-

捕獲数 {chasers.reduce((sum, c) => sum + (c.captureCount || 0), 0)}人

+

+ 捕獲数 {chasers.reduce((sum, c) => sum + (c.captureCount || 0), 0)}人 +

{game && ( -
+
ゲーム状態: - { - game.status === 'waiting' ? '待機中' : - game.status === 'active' ? '進行中' : - game.status === 'paused' ? '一時停止' : - game.status === 'finished' ? '終了' : game.status - } + + {game.status === 'waiting' + ? '待機中' + : game.status === 'active' + ? '進行中' + : game.status === 'paused' + ? '一時停止' + : game.status === 'finished' + ? '終了' + : game.status} +
{game.startTime && game.status === 'active' && (
@@ -184,58 +203,71 @@ export default function GamemasterPage() { {game && } - {selectedPlayer && } + {selectedPlayer && ( + + )}
-

+

👥 アクティブプレイヤー - + {activePlayers.length}

-
- {activePlayers.map(player => ( +
+ {activePlayers.map((player) => (
setSelectedPlayer(player)} > -
- {player.nickname} - +
+ {player.nickname} + {player.role === 'runner' ? '🏃 逃走者' : '👹 鬼'}
-

チーム {player.team}

+

チーム {player.team}

))}
-

+

🚨 捕獲されたプレイヤー - + {capturedPlayers.length}

-
+
{capturedPlayers.length === 0 ? ( -

捕獲されたプレイヤーはいません

+

+ 捕獲されたプレイヤーはいません +

) : ( - capturedPlayers.map(player => ( -
-
- {player.nickname} + capturedPlayers.map((player) => ( +
+
+ {player.nickname} @@ -248,46 +280,56 @@ export default function GamemasterPage() { {selectedPlayer && (
-

+

⚙️ プレイヤー操作

-
-

選択中: {selectedPlayer.nickname}

+
+

+ 選択中: {selectedPlayer.nickname} +

- +
- {['active', 'captured', 'rescued', 'safe'].map(status => ( + {['active', 'captured', 'rescued', 'safe'].map((status) => ( ))}
- +
- {['runner', 'chaser'].map(role => ( + {['runner', 'chaser'].map((role) => (
- +
); -} \ No newline at end of file +} diff --git a/app/globals.css b/app/globals.css index 6a77228..1a5bd10 100644 --- a/app/globals.css +++ b/app/globals.css @@ -36,11 +36,11 @@ --md-outline: #cbd5e1; /* 8dp Grid System */ - --spacing-1: 0.5rem; /* 8px */ - --spacing-2: 1rem; /* 16px */ - --spacing-3: 1.5rem; /* 24px */ - --spacing-4: 2rem; /* 32px */ - --spacing-6: 3rem; /* 48px */ + --spacing-1: 0.5rem; /* 8px */ + --spacing-2: 1rem; /* 16px */ + --spacing-3: 1.5rem; /* 24px */ + --spacing-4: 2rem; /* 32px */ + --spacing-6: 3rem; /* 48px */ } /* Mobile optimizations */ @@ -59,23 +59,40 @@ -moz-text-size-adjust: 100%; text-size-adjust: 100%; /* Material Design typography */ - font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-family: + 'Roboto', + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; color: var(--md-on-surface); } /* Material Design Typography Scale */ - h1 { @apply text-3xl font-normal tracking-normal; } - h2 { @apply text-2xl font-normal tracking-normal; } - h3 { @apply text-xl font-medium tracking-normal; } - h4 { @apply text-lg font-medium tracking-normal; } - h5 { @apply text-base font-medium tracking-normal; } - h6 { @apply text-sm font-medium tracking-wider; } + h1 { + @apply text-3xl font-normal tracking-normal; + } + h2 { + @apply text-2xl font-normal tracking-normal; + } + h3 { + @apply text-xl font-medium tracking-normal; + } + h4 { + @apply text-lg font-medium tracking-normal; + } + h5 { + @apply text-base font-medium tracking-normal; + } + h6 { + @apply text-sm font-medium tracking-wider; + } } @layer components { /* Material Design Elevated Button */ .btn-touch { - @apply px-6 py-3 min-h-[48px] text-base font-medium rounded-lg; + @apply min-h-[48px] rounded-lg px-6 py-3 text-base font-medium; @apply transition-all duration-200 ease-out; @apply focus:outline-none focus:ring-4 focus:ring-offset-0; @apply relative overflow-hidden; @@ -165,7 +182,7 @@ /* Secondary Button - Outlined Style */ .btn-secondary { - @apply btn-touch bg-white border-2; + @apply btn-touch border-2 bg-white; border-color: var(--md-outline); color: var(--md-primary); @apply focus:ring-gray-500/30; @@ -182,7 +199,7 @@ /* Material Design Text Fields */ .input-touch { - @apply px-4 py-3 min-h-[48px] text-base rounded-lg border-2; + @apply min-h-[48px] rounded-lg border-2 px-4 py-3 text-base; @apply transition-all duration-200 ease-out; border-color: var(--md-outline); background-color: var(--md-surface); @@ -201,7 +218,7 @@ /* Material Design Cards */ .card-mobile { background-color: var(--md-surface); - @apply rounded-2xl p-4 space-y-3; + @apply space-y-3 rounded-2xl p-4; box-shadow: var(--shadow-2); @apply transition-shadow duration-200; } @@ -218,22 +235,22 @@ /* Material Design Chips/Badges */ .status-active { - @apply bg-green-100 text-green-800 px-3 py-1.5 rounded-full text-sm font-medium; + @apply rounded-full bg-green-100 px-3 py-1.5 text-sm font-medium text-green-800; @apply inline-flex items-center gap-1; } .status-inactive { - @apply bg-slate-100 text-slate-700 px-3 py-1.5 rounded-full text-sm font-medium; + @apply rounded-full bg-slate-100 px-3 py-1.5 text-sm font-medium text-slate-700; @apply inline-flex items-center gap-1; } .status-captured { - @apply bg-red-100 text-red-800 px-3 py-1.5 rounded-full text-sm font-medium; + @apply rounded-full bg-red-100 px-3 py-1.5 text-sm font-medium text-red-800; @apply inline-flex items-center gap-1; } .status-safe { - @apply bg-blue-100 text-blue-800 px-3 py-1.5 rounded-full text-sm font-medium; + @apply rounded-full bg-blue-100 px-3 py-1.5 text-sm font-medium text-blue-800; @apply inline-flex items-center gap-1; } } @@ -304,12 +321,24 @@ } /* Material Design Elevation helpers */ - .elevation-0 { box-shadow: none; } - .elevation-1 { box-shadow: var(--shadow-1); } - .elevation-2 { box-shadow: var(--shadow-2); } - .elevation-3 { box-shadow: var(--shadow-3); } - .elevation-4 { box-shadow: var(--shadow-4); } - .elevation-6 { box-shadow: var(--shadow-6); } + .elevation-0 { + box-shadow: none; + } + .elevation-1 { + box-shadow: var(--shadow-1); + } + .elevation-2 { + box-shadow: var(--shadow-2); + } + .elevation-3 { + box-shadow: var(--shadow-3); + } + .elevation-4 { + box-shadow: var(--shadow-4); + } + .elevation-6 { + box-shadow: var(--shadow-6); + } } /* Leaflet map mobile optimizations - Material Design準拠 */ @@ -357,15 +386,15 @@ } .custom-marker.runner { - background-color: #3B82F6; + background-color: #3b82f6; } .custom-marker.chaser { - background-color: #EF4444; + background-color: #ef4444; } .custom-marker.gamemaster { - background-color: #10B981; + background-color: #10b981; } /* PWA specific styles */ @@ -419,4 +448,4 @@ .haptic-heavy:active { transform: none !important; } -} \ No newline at end of file +} diff --git a/app/layout.tsx b/app/layout.tsx index c13daec..28f43ed 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,13 +1,13 @@ -import type { Metadata } from "next"; -import "./globals.css"; -import { AuthProvider } from "@/hooks/useAuth"; -import { GameProvider } from "@/hooks/useGame"; -import { MissionProvider } from "@/hooks/useMissions"; +import type { Metadata } from 'next'; +import './globals.css'; +import { AuthProvider } from '@/hooks/useAuth'; +import { GameProvider } from '@/hooks/useGame'; +import { MissionProvider } from '@/hooks/useMissions'; export const metadata: Metadata = { - title: "Tag Support - Real-time Tag Game", - description: "PWA for supporting real-time tag games with location tracking", - manifest: "/manifest.json", + title: 'Tag Support - Real-time Tag Game', + description: 'PWA for supporting real-time tag games with location tracking', + manifest: '/manifest.json', }; export default function RootLayout({ @@ -20,9 +20,7 @@ export default function RootLayout({ - - {children} - + {children} diff --git a/app/page.tsx b/app/page.tsx index f9cdd8d..c914707 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -20,7 +20,7 @@ export default function Home() { setIsSubmitting(true); try { await signIn(nickname.trim(), role, role === 'gamemaster' ? undefined : team); - + switch (role) { case 'runner': router.push('/runner'); @@ -43,10 +43,10 @@ export default function Home() { if (loading) { return ( -
+
-
-

読み込み中...

+
+

読み込み中...

); @@ -54,33 +54,37 @@ export default function Home() { if (user) { return ( -
-
-
-
+
+
+
+
👋
-

おかえりなさい!

+

おかえりなさい!

-
-
-

ニックネーム

+
+
+

ニックネーム

{user.nickname}

-
-

役職

+
+

役職

- {user.role === 'runner' ? '🏃 逃走者' : - user.role === 'chaser' ? '👹 鬼' : - user.role === 'gamemaster' ? '🎮 ゲームマスター' : user.role} + {user.role === 'runner' + ? '🏃 逃走者' + : user.role === 'chaser' + ? '👹 鬼' + : user.role === 'gamemaster' + ? '🎮 ゲームマスター' + : user.role}

{user.team && ( -
-

チーム

+
+

チーム

チーム {user.team}

)} @@ -100,7 +104,7 @@ export default function Home() { break; } }} - className="w-full btn-primary" + className="btn-primary w-full" > ゲームに参加 @@ -110,26 +114,24 @@ export default function Home() { } return ( -
-
-
-
+
+
+
+
🏃‍♂️
-

リアル鬼ごっこ

+

リアル鬼ごっこ

リアルタイム鬼ごっこサポートアプリ

- + setNickname(e.target.value)} - className="w-full input-touch" + className="input-touch w-full" placeholder="ニックネームを入力" required disabled={isSubmitting} @@ -137,13 +139,11 @@ export default function Home() {
- + setTeam(e.target.value)} - className="w-full input-touch" + className="input-touch w-full" disabled={isSubmitting} > @@ -174,13 +172,13 @@ export default function Home() { -
+
📍 位置情報の許可をお忘れなく! diff --git a/app/runner/page.tsx b/app/runner/page.tsx index 6553001..f25376b 100644 --- a/app/runner/page.tsx +++ b/app/runner/page.tsx @@ -39,14 +39,24 @@ export default function RunnerPage() { } if (data) { - const mappedUsers: User[] = data.map((u: { id: string; nickname: string; role: string; team_id: string | null; status: string; updated_at: string }) => ({ - id: u.id, - nickname: u.nickname, - role: u.role as 'runner' | 'chaser' | 'gamemaster', - team: u.team_id || undefined, - status: u.status === 'captured' ? 'captured' : u.status === 'offline' ? 'safe' : 'active', - lastUpdated: new Date(u.updated_at), - })); + const mappedUsers: User[] = data.map( + (u: { + id: string; + nickname: string; + role: string; + team_id: string | null; + status: string; + updated_at: string; + }) => ({ + id: u.id, + nickname: u.nickname, + role: u.role as 'runner' | 'chaser' | 'gamemaster', + team: u.team_id || undefined, + status: + u.status === 'captured' ? 'captured' : u.status === 'offline' ? 'safe' : 'active', + lastUpdated: new Date(u.updated_at), + }) + ); setTeammates(mappedUsers); } }; @@ -56,14 +66,18 @@ export default function RunnerPage() { // Subscribe to real-time updates const channel = supabase .channel('teammates_changes') - .on('postgres_changes', { - event: '*', - schema: 'public', - table: 'users', - filter: `team_id=eq.${user.team}`, - }, () => { - fetchTeammates(); - }) + .on( + 'postgres_changes', + { + event: '*', + schema: 'public', + table: 'users', + filter: `team_id=eq.${user.team}`, + }, + () => { + fetchTeammates(); + } + ) .subscribe(); return () => { @@ -80,52 +94,68 @@ export default function RunnerPage() { }, [isTracking, startTracking]); if (!user || user.role !== 'runner') { - return
-
-

アクセス拒否

-

逃走者の権限が必要です

+ return ( +
+
+

アクセス拒否

+

逃走者の権限が必要です

+
-
; + ); } - const mapCenter: [number, number] = location - ? [location.lat, location.lng] - : [35.5522, 139.7797]; + const mapCenter: [number, number] = location ? [location.lat, location.lng] : [35.5522, 139.7797]; return ( -
-
-
+
+
+

🏃 逃走者

-

{user.nickname} - チーム {user.team}

+

+ {user.nickname} - チーム {user.team} +

-

状態: { - user.status === 'active' ? '逃走中' : - user.status === 'captured' ? '捕獲済み' : - user.status === 'rescued' ? '救出済み' : - user.status === 'safe' ? '安全' : user.status - }

+

+ 状態:{' '} + {user.status === 'active' + ? '逃走中' + : user.status === 'captured' + ? '捕獲済み' + : user.status === 'rescued' + ? '救出済み' + : user.status === 'safe' + ? '安全' + : user.status} +

{game && ( -

ゲーム: { - game.status === 'waiting' ? '待機中' : - game.status === 'active' ? '進行中' : - game.status === 'paused' ? '一時停止' : - game.status === 'finished' ? '終了' : game.status - }

+

+ ゲーム:{' '} + {game.status === 'waiting' + ? '待機中' + : game.status === 'active' + ? '進行中' + : game.status === 'paused' + ? '一時停止' + : game.status === 'finished' + ? '終了' + : game.status} +

)} {isTracking &&

📍 位置追跡中

}
{game && game.status === 'active' && game.startTime && ( -
-

ゲーム開始から {Math.floor((Date.now() - game.startTime.getTime()) / 60000)} 分経過

+
+

+ ゲーム開始から {Math.floor((Date.now() - game.startTime.getTime()) / 60000)} 分経過 +

)}
-
+
-
+
); -} \ No newline at end of file +} diff --git a/components/GameControls.tsx b/components/GameControls.tsx index 68272c6..59e034d 100644 --- a/components/GameControls.tsx +++ b/components/GameControls.tsx @@ -17,57 +17,70 @@ export default function GameControls({ isGameMaster }: GameControlsProps) { locationAccuracy: 20, // meters safeZones: [], restrictedZones: [], - chaserRadarRange: 200 // meters + chaserRadarRange: 200, // meters }); if (!isGameMaster) { return (
-
-
+
+
🎮
-

ゲーム状態

+

ゲーム状態

{game ? (
-
-
- 状態 - - {game.status === 'waiting' ? '⏳ 待機中' : - game.status === 'active' ? '▶️ 進行中' : - game.status === 'paused' ? '⏸️ 一時停止' : - game.status === 'finished' ? '✓ 終了' : game.status} +
+
+ 状態 + + {game.status === 'waiting' + ? '⏳ 待機中' + : game.status === 'active' + ? '▶️ 進行中' + : game.status === 'paused' + ? '⏸️ 一時停止' + : game.status === 'finished' + ? '✓ 終了' + : game.status}
-
+
制限時間 {game.duration}分
{game.startTime && game.status === 'active' && ( -
+
開始時刻 - {game.startTime.toLocaleTimeString()} + + {game.startTime.toLocaleTimeString()} +
)}
) : ( -
-
+
+
🎮
-

アクティブなゲームがありません

+

アクティブなゲームがありません

)}
@@ -99,15 +112,15 @@ export default function GameControls({ isGameMaster }: GameControlsProps) { return (
-
-
+
+
🎮
-

ゲーム制御

+

ゲーム制御

{error && ( -
+
⚠️ {error}
@@ -115,8 +128,8 @@ export default function GameControls({ isGameMaster }: GameControlsProps) { {!game ? (
-
-

+
+

🆕 新しいゲームを作成

@@ -124,23 +137,27 @@ export default function GameControls({ isGameMaster }: GameControlsProps) {
- + setDuration(Number(e.target.value))} - className="w-full input-touch" + className="input-touch w-full" min="5" max="180" />
- + setSettings({ ...settings, chaserRadarRange: Number(e.target.value) })} - className="w-full input-touch" + onChange={(e) => + setSettings({ ...settings, chaserRadarRange: Number(e.target.value) }) + } + className="input-touch w-full" min="50" max="500" />
-

) : (
-
-
- +
+
+ 🎯 現在のゲーム - - {game.status === 'waiting' ? '⏳ 待機中' : - game.status === 'active' ? '▶️ 進行中' : - game.status === 'paused' ? '⏸️ 一時停止' : - game.status === 'finished' ? '✓ 終了' : game.status} + + {game.status === 'waiting' + ? '⏳ 待機中' + : game.status === 'active' + ? '▶️ 進行中' + : game.status === 'paused' + ? '⏸️ 一時停止' + : game.status === 'finished' + ? '✓ 終了' + : game.status}
-
-

制限時間

+
+

制限時間

{game.duration}分

-
-

更新間隔

-

{formatTime(game.settings.locationUpdateInterval)}

+
+

更新間隔

+

+ {formatTime(game.settings.locationUpdateInterval)} +

-
-

レーダー範囲

-

{game.settings.chaserRadarRange || 200}m

+
+

レーダー範囲

+

+ {game.settings.chaserRadarRange || 200}m +

{game.startTime && ( -
-

開始時刻

-

{game.startTime.toLocaleTimeString()}

+
+

開始時刻

+

+ {game.startTime.toLocaleTimeString()} +

)}
@@ -212,26 +247,17 @@ export default function GameControls({ isGameMaster }: GameControlsProps) {
{game.status === 'waiting' && ( - )} {game.status === 'active' && ( <> - - @@ -239,37 +265,32 @@ export default function GameControls({ isGameMaster }: GameControlsProps) { {game.status === 'paused' && ( <> - - )}
- {showSettings && ( -
+
- + setSettings({ ...settings, chaserRadarRange: Number(e.target.value) })} - className="w-full input-touch" + onChange={(e) => + setSettings({ ...settings, chaserRadarRange: Number(e.target.value) }) + } + className="input-touch w-full" min="50" max="500" />
-
@@ -302,4 +324,4 @@ export default function GameControls({ isGameMaster }: GameControlsProps) { )}
); -} \ No newline at end of file +} diff --git a/components/GameStats.tsx b/components/GameStats.tsx index f025323..0b69850 100644 --- a/components/GameStats.tsx +++ b/components/GameStats.tsx @@ -106,7 +106,9 @@ export default function GameStats({ gameId, isGameMaster = false }: GameStatsPro } } - const lastEntry = historyData[historyData.length - 1] as unknown as LocationHistoryRecord; + const lastEntry = historyData[ + historyData.length - 1 + ] as unknown as LocationHistoryRecord; const firstEntry = historyData[0] as unknown as LocationHistoryRecord; if (!lastEntry || !firstEntry) { @@ -119,12 +121,11 @@ export default function GameStats({ gameId, isGameMaster = false }: GameStatsPro }; } - const duration = new Date(lastEntry.timestamp).getTime() - - new Date(firstEntry.timestamp).getTime(); + const duration = + new Date(lastEntry.timestamp).getTime() - new Date(firstEntry.timestamp).getTime(); - const averageSpeed = speeds.length > 0 - ? speeds.reduce((sum, s) => sum + s, 0) / speeds.length - : 0; + const averageSpeed = + speeds.length > 0 ? speeds.reduce((sum, s) => sum + s, 0) / speeds.length : 0; return { userId: user.id, @@ -158,16 +159,19 @@ export default function GameStats({ gameId, isGameMaster = false }: GameStatsPro fetchPlayerStats(); }, [gameId]); - const calculateDistance = (pos1: { lat: number; lng: number }, pos2: { lat: number; lng: number }): number => { + const calculateDistance = ( + pos1: { lat: number; lng: number }, + pos2: { lat: number; lng: number } + ): number => { const R = 6371e3; - const φ1 = pos1.lat * Math.PI / 180; - const φ2 = pos2.lat * Math.PI / 180; - const Δφ = (pos2.lat - pos1.lat) * Math.PI / 180; - const Δλ = (pos2.lng - pos1.lng) * Math.PI / 180; - - const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) + - Math.cos(φ1) * Math.cos(φ2) * - Math.sin(Δλ / 2) * Math.sin(Δλ / 2); + const φ1 = (pos1.lat * Math.PI) / 180; + const φ2 = (pos2.lat * Math.PI) / 180; + const Δφ = ((pos2.lat - pos1.lat) * Math.PI) / 180; + const Δλ = ((pos2.lng - pos1.lng) * Math.PI) / 180; + + const a = + Math.sin(Δφ / 2) * Math.sin(Δφ / 2) + + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; @@ -201,19 +205,27 @@ export default function GameStats({ gameId, isGameMaster = false }: GameStatsPro const getRoleEmoji = (role: string): string => { switch (role) { - case 'runner': return '🏃'; - case 'chaser': return '👹'; - case 'gamemaster': return '🎮'; - default: return '👤'; + case 'runner': + return '🏃'; + case 'chaser': + return '👹'; + case 'gamemaster': + return '🎮'; + default: + return '👤'; } }; const getRoleLabel = (role: string): string => { switch (role) { - case 'runner': return '逃走者'; - case 'chaser': return '鬼'; - case 'gamemaster': return 'GM'; - default: return role; + case 'runner': + return '逃走者'; + case 'chaser': + return '鬼'; + case 'gamemaster': + return 'GM'; + default: + return role; } }; @@ -223,56 +235,56 @@ export default function GameStats({ gameId, isGameMaster = false }: GameStatsPro return (
-
-
+
+
📊
-

ゲーム統計

+

ゲーム統計

{loading ? ( -
-
+
+
-

統計を読み込み中...

+

統計を読み込み中...

) : playerStats.length === 0 ? ( -
-
+
+
📊
-

統計データがありません

+

統計データがありません

) : (
{/* Summary stats */} -
-
-

総プレイヤー数

+
+
+

総プレイヤー数

{playerStats.length}

-
-

アクティブ

+
+

アクティブ

- {playerStats.filter(p => p.stats !== null).length} + {playerStats.filter((p) => p.stats !== null).length}

{/* Individual player stats */}
-

プレイヤー別統計

+

プレイヤー別統計

{playerStats.map((player) => (
-
+
{getRoleEmoji(player.role)}
-

{player.nickname}

+

{player.nickname}

{getRoleLabel(player.role)} {player.team && ` • チーム${player.team}`} @@ -280,8 +292,10 @@ export default function GameStats({ gameId, isGameMaster = false }: GameStatsPro

@@ -289,21 +303,21 @@ export default function GameStats({ gameId, isGameMaster = false }: GameStatsPro {player.stats ? ( <> -
-
-

移動距離

+
+
+

移動距離

{formatDistance(player.stats.totalDistance)}

-
-

平均速度

+
+

平均速度

{formatSpeed(player.stats.averageSpeed)}

-
-

最高速度

+
+

最高速度

{formatSpeed(player.stats.maxSpeed)}

@@ -311,7 +325,7 @@ export default function GameStats({ gameId, isGameMaster = false }: GameStatsPro
{selectedPlayer === player.userId && ( -
+
活動時間: @@ -323,7 +337,8 @@ export default function GameStats({ gameId, isGameMaster = false }: GameStatsPro
最終位置: - {player.stats.lastLocation.lat.toFixed(6)}, {player.stats.lastLocation.lng.toFixed(6)} + {player.stats.lastLocation.lat.toFixed(6)},{' '} + {player.stats.lastLocation.lng.toFixed(6)}
)} @@ -332,7 +347,7 @@ export default function GameStats({ gameId, isGameMaster = false }: GameStatsPro )} ) : ( -
+

データなし

)} diff --git a/components/Map.tsx b/components/Map.tsx index e623ab8..89daa2c 100644 --- a/components/Map.tsx +++ b/components/Map.tsx @@ -20,12 +20,12 @@ interface MapProps { zones?: Zone[]; } -export default function Map({ - center, - zoom = 15, +export default function Map({ + center, + zoom = 15, currentUser, visibleUsers = [], - zones = [] + zones = [], }: MapProps) { const getMarkerIcon = (role: string) => { const color = role === 'chaser' ? 'red' : role === 'runner' ? 'blue' : 'green'; @@ -45,17 +45,17 @@ export default function Map({ }; return ( - - + {currentUser?.location && ( )} - {visibleUsers.map((user) => - user.location && ( - - -
-

{user.nickname}

-

Role: {user.role}

-

Status: {user.status}

- {user.captureCount !== undefined && ( -

Captures: {user.captureCount}

- )} -
-
-
- ) + {visibleUsers.map( + (user) => + user.location && ( + + +
+

{user.nickname}

+

Role: {user.role}

+

Status: {user.status}

+ {user.captureCount !== undefined &&

Captures: {user.captureCount}

} +
+
+
+ ) )} {zones.map((zone) => ( @@ -113,4 +112,4 @@ export default function Map({ ))}
); -} \ No newline at end of file +} diff --git a/components/MissionManager.tsx b/components/MissionManager.tsx index 6ba5b96..e26b98b 100644 --- a/components/MissionManager.tsx +++ b/components/MissionManager.tsx @@ -21,7 +21,7 @@ export default function MissionManager({ isGameMaster, userMissions }: MissionMa lat: '', lng: '', radius: 50, - duration: 300 // 5 minutes + duration: 300, // 5 minutes }); const handleCreateMission = async (e: React.FormEvent) => { @@ -29,11 +29,14 @@ export default function MissionManager({ isGameMaster, userMissions }: MissionMa if (!newMission.title || !newMission.description) return; try { - const targetLocation: Location | undefined = newMission.lat && newMission.lng ? { - lat: parseFloat(newMission.lat), - lng: parseFloat(newMission.lng), - timestamp: new Date() - } : undefined; + const targetLocation: Location | undefined = + newMission.lat && newMission.lng + ? { + lat: parseFloat(newMission.lat), + lng: parseFloat(newMission.lng), + timestamp: new Date(), + } + : undefined; await createMission( newMission.title, @@ -52,7 +55,7 @@ export default function MissionManager({ isGameMaster, userMissions }: MissionMa lat: '', lng: '', radius: 50, - duration: 300 + duration: 300, }); setShowCreateForm(false); } catch (error) { @@ -70,7 +73,7 @@ export default function MissionManager({ isGameMaster, userMissions }: MissionMa const handleDeleteMission = async (missionId: string) => { if (!confirm('Are you sure you want to delete this mission?')) return; - + try { await deleteMission(missionId); } catch (error) { @@ -83,19 +86,19 @@ export default function MissionManager({ isGameMaster, userMissions }: MissionMa if (isGameMaster) { return (
-
+
-
+
🎯
-

ミッション管理

+

ミッション管理

{error && ( -
+
⚠️ {error}
)} {showCreateForm && ( -
+
- + setNewMission({ ...newMission, title: e.target.value })} - className="w-full input-touch" + className="input-touch w-full" placeholder="ミッションのタイトル" required />
- +