diff --git a/PROGRESS.md b/PROGRESS.md index 7dbe43b..89876c0 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -99,7 +99,7 @@ - プレイヤー操作パネルの視覚的改善 - ステータス変更・役職変更UIの改善 -### Phase 3: ビルド環境改善・型安全性向上 (完了) ⭐️ NEW +### Phase 3: ビルド環境改善・型安全性向上 (完了) - [x] **CIビルドエラーの解決** - Google Fontsの外部依存を削除しシステムフォントに変更 - Tailwind CSSクラスの修正(duration-250 → duration-300) @@ -120,12 +120,38 @@ - テストケースの更新(新しいフィールド名に対応) - 環境変数テストの改善 +### Phase 4: 位置履歴・リプレイ・統計機能 (完了) ⭐️ NEW +- [x] **位置履歴記録システム** + - useLocationHistoryカスタムフック実装 + - プレイヤーの位置、速度、方向を時系列で記録 + - ユーザー/ゲームID別のフィルタリング対応 + - 統計計算機能(総移動距離、平均/最高速度、活動時間) + +- [x] **ゲーム統計コンポーネント** + - GameStatsコンポーネント作成 + - プレイヤー別の詳細統計表示 + - リアルタイム統計更新 + - ゲームマスターダッシュボードに統合 + +- [x] **リプレイビューアコンポーネント** + - ReplayViewerコンポーネント実装 + - タイムライン形式の再生UI + - 可変速度再生(1x/2x/4x/8x) + - シーク機能とプログレスバー表示 + - 位置・速度・方向データの可視化 + +- [x] **テスト・品質保証** + - 位置履歴機能の包括的テスト(9テスト) + - 全70テスト成功(100%) + - TypeScript型安全性の徹底 + - ビルド成功確認 + ## 現在の制限事項 ### 未実装機能 - [ ] Push通知システム -- [ ] リプレイ・ログ表示 - [ ] マルチゲームマスター対応 +- [ ] データベーススキーマの作成(location_historyテーブル等) ### 技術的制約 - Firebase設定が必要(.env.local) @@ -135,19 +161,32 @@ ## ファイル構成 ``` -tag-support/ (20ファイル作成) -├── lib/firebase.ts # Firebase設定 +tag-support/ (25ファイル作成) ⭐️ 更新 +├── lib/ +│ ├── firebase.ts # Firebase設定 +│ └── supabase.ts # Supabase設定 ├── types/index.ts # 型定義 ├── hooks/ -│ ├── useAuth.ts # 認証フック -│ └── useLocation.ts # 位置情報フック -├── components/Map.tsx # 地図コンポーネント +│ ├── useAuth.tsx # 認証フック +│ ├── useLocation.ts # 位置情報フック +│ ├── useGame.tsx # ゲーム状態フック +│ ├── useMissions.tsx # ミッションフック +│ ├── useCapture.tsx # 捕獲フック +│ ├── useZones.tsx # ゾーンフック +│ └── useLocationHistory.tsx # 位置履歴フック ⭐️ NEW +├── components/ +│ ├── Map.tsx # 地図コンポーネント +│ ├── GameControls.tsx # ゲーム制御 +│ ├── MissionManager.tsx # ミッション管理 +│ ├── ZoneManager.tsx # ゾーン管理 +│ ├── GameStats.tsx # ゲーム統計 ⭐️ NEW +│ └── ReplayViewer.tsx # リプレイビューア ⭐️ NEW ├── app/ │ ├── layout.tsx # 共通レイアウト │ ├── page.tsx # ランディング │ ├── runner/page.tsx # 逃走者UI │ ├── chaser/page.tsx # 鬼UI -│ └── gamemaster/page.tsx # GM UI +│ └── gamemaster/page.tsx # GM UI(統計・リプレイ統合済み) ⭐️ 更新 └── public/ ├── manifest.json # PWAマニフェスト └── sw.js # Service Worker @@ -156,7 +195,7 @@ tag-support/ (20ファイル作成) ## テスト状況 ### 自動テスト環境 ✅ -- [x] **ユニットテスト (Vitest)** - 61/61テスト成功(100%) ⭐️ 更新 +- [x] **ユニットテスト (Vitest)** - 70/70テスト成功(100%) ⭐️ 更新 - lib/supabase.ts: 4/4テスト成功(環境変数フォールバック対応) - hooks/useLocation.ts: 8/8テスト成功 - hooks/useAuth.tsx: 9/9テスト成功 (Supabaseベースに修正) @@ -164,6 +203,7 @@ tag-support/ (20ファイル作成) - hooks/useMissions.tsx: 13/13テスト成功 (Supabaseベースに修正) - hooks/useCapture.tsx: 8/8テスト成功 - hooks/useZones.tsx: 9/9テスト成功(フィールド名修正対応) + - hooks/useLocationHistory.tsx: 9/9テスト成功 ⭐️ NEW - [x] **テスト品質向上** ⭐️ NEW - Firebase → Supabase への完全移行完了 - 全テストがSupabaseのモックを使用 @@ -179,9 +219,9 @@ tag-support/ (20ファイル作成) - カバレッジレポート、トラブルシューティング追加 ### 動作確認済み -- [x] ローカル開発環境起動 ⭐️ 更新 -- [x] ビルドプロセス(npm run build成功) ⭐️ NEW -- [x] 全テストスイート実行(61/61成功) ⭐️ NEW +- [x] ローカル開発環境起動 +- [x] ビルドプロセス(npm run build成功) +- [x] 全テストスイート実行(70/70成功) ⭐️ 更新 - [ ] Firebase接続テスト - [ ] 認証フロー - [ ] 位置情報取得 @@ -197,22 +237,28 @@ tag-support/ (20ファイル作成) ## 次回セッションでの予定 ### 優先度: 高 -1. **Supabase実環境セットアップ** ⭐️ 更新 - - データベース設定 +1. **Supabaseデータベーススキーマ作成** + - location_historyテーブルの作成 + - インデックス設定(user_id, game_id, timestamp) + - マイグレーションファイル作成 +2. **位置履歴の自動記録機能** + - useLocationフックとの統合 + - 自動的な履歴保存機能 + - パフォーマンス最適化(バッチ処理) +3. **Supabase実環境セットアップ** - セキュリティルール設定 + - RLS (Row Level Security) ポリシー - 実環境での動作確認 -2. **モバイル実機テスト** + +### 優先度: 中 +4. **モバイル実機テスト** - iOS/Android実機でのテスト - PWAインストールテスト - 位置情報精度確認 -3. **Push通知基礎実装** +5. **Push通知基礎実装** - Web Push API統合 - ゲームイベント通知 - -### 優先度: 中 -4. **パフォーマンス最適化** +6. **パフォーマンス最適化** - バンドルサイズ削減 - 地図描画の最適化 -5. **リプレイ・ログ機能** - - 移動履歴の可視化 - - ゲーム統計の表示 \ No newline at end of file + - リプレイデータの効率的な読み込み \ No newline at end of file diff --git a/app/gamemaster/page.tsx b/app/gamemaster/page.tsx index e034f01..a0d1a31 100644 --- a/app/gamemaster/page.tsx +++ b/app/gamemaster/page.tsx @@ -10,6 +10,8 @@ import type { RealtimeChannel } from '@supabase/supabase-js'; import GameControls from '@/components/GameControls'; import MissionManager from '@/components/MissionManager'; import ZoneManager from '@/components/ZoneManager'; +import GameStats from '@/components/GameStats'; +import ReplayViewer from '@/components/ReplayViewer'; const Map = dynamic(() => import('@/components/Map'), { ssr: false }); @@ -180,6 +182,10 @@ export default function GamemasterPage() { + {game && } + + {selectedPlayer && } +

👥 diff --git a/components/GameStats.tsx b/components/GameStats.tsx new file mode 100644 index 0000000..ee2e08c --- /dev/null +++ b/components/GameStats.tsx @@ -0,0 +1,331 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useLocationHistory, type PlayerStats } from '@/hooks/useLocationHistory'; +import { supabase } from '@/lib/supabase'; + +interface PlayerStatsData { + userId: string; + nickname: string; + role: string; + team?: string; + stats: PlayerStats | null; +} + +interface GameStatsProps { + gameId?: string; + isGameMaster?: boolean; +} + +export default function GameStats({ gameId, isGameMaster = false }: GameStatsProps) { + const [playerStats, setPlayerStats] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedPlayer, setSelectedPlayer] = useState(null); + + useEffect(() => { + if (!gameId) return; + + const fetchPlayerStats = async () => { + try { + setLoading(true); + + // Fetch all players + const { data: users, error: usersError } = await supabase + .from('users') + .select('id, nickname, role, team'); + + if (usersError) throw usersError; + + if (users) { + const statsPromises = users.map(async (user: any) => { + // Fetch location history for each user + const { data: historyData, error: historyError } = await supabase + .from('location_history') + .select('*') + .eq('user_id', user.id) + .eq('game_id', gameId) + .order('timestamp', { ascending: true }); + + if (historyError) { + console.error('Error fetching history for user:', user.id, historyError); + return { + userId: user.id, + nickname: user.nickname, + role: user.role, + team: user.team, + stats: null, + }; + } + + // Calculate stats + if (!historyData || historyData.length === 0) { + return { + userId: user.id, + nickname: user.nickname, + role: user.role, + team: user.team, + stats: null, + }; + } + + // Calculate distance and stats + let totalDistance = 0; + let maxSpeed = 0; + const speeds: number[] = []; + + for (let i = 1; i < historyData.length; i++) { + const prev = historyData[i - 1] as any; + const curr = historyData[i] as any; + + if (!prev || !curr) continue; + + const distance = calculateDistance( + { lat: prev.latitude, lng: prev.longitude }, + { lat: curr.latitude, lng: curr.longitude } + ); + totalDistance += distance; + + if (curr.speed) { + speeds.push(curr.speed); + maxSpeed = Math.max(maxSpeed, curr.speed); + } + } + + const lastEntry = historyData[historyData.length - 1] as any; + const firstEntry = historyData[0] as any; + + if (!lastEntry || !firstEntry) { + return { + userId: user.id, + nickname: user.nickname, + role: user.role, + team: user.team, + stats: null, + }; + } + + 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; + + return { + userId: user.id, + nickname: user.nickname, + role: user.role, + team: user.team, + stats: { + totalDistance, + averageSpeed, + maxSpeed, + duration, + lastLocation: { + lat: lastEntry.latitude, + lng: lastEntry.longitude, + timestamp: new Date(lastEntry.timestamp), + }, + }, + }; + }); + + const allStats = await Promise.all(statsPromises); + setPlayerStats(allStats); + } + } catch (err) { + console.error('Error fetching player stats:', err); + } finally { + setLoading(false); + } + }; + + fetchPlayerStats(); + }, [gameId]); + + 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 c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return R * c; + }; + + const formatDistance = (meters: number): string => { + if (meters < 1000) { + return `${Math.round(meters)}m`; + } + return `${(meters / 1000).toFixed(2)}km`; + }; + + const formatSpeed = (metersPerSecond: number): string => { + const kmh = metersPerSecond * 3.6; + return `${kmh.toFixed(1)}km/h`; + }; + + const formatDuration = (milliseconds: number): string => { + const seconds = Math.floor(milliseconds / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + return `${hours}時間${minutes % 60}分`; + } else if (minutes > 0) { + return `${minutes}分${seconds % 60}秒`; + } else { + return `${seconds}秒`; + } + }; + + const getRoleEmoji = (role: string): string => { + switch (role) { + 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; + } + }; + + if (!isGameMaster) { + return null; + } + + return ( +
+
+
+ 📊 +
+

ゲーム統計

+
+ + {loading ? ( +
+
+ +
+

統計を読み込み中...

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

統計データがありません

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

総プレイヤー数

+

{playerStats.length}

+
+
+

アクティブ

+

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

+
+
+ + {/* Individual player stats */} +
+

プレイヤー別統計

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

{player.nickname}

+

+ {getRoleLabel(player.role)} + {player.team && ` • チーム${player.team}`} +

+
+
+ +
+ + {player.stats ? ( + <> +
+
+

移動距離

+

+ {formatDistance(player.stats.totalDistance)} +

+
+
+

平均速度

+

+ {formatSpeed(player.stats.averageSpeed)} +

+
+
+

最高速度

+

+ {formatSpeed(player.stats.maxSpeed)} +

+
+
+ + {selectedPlayer === player.userId && ( +
+
+
+ 活動時間: + + {formatDuration(player.stats.duration)} + +
+ {player.stats.lastLocation && ( +
+ 最終位置: + + {player.stats.lastLocation.lat.toFixed(6)}, {player.stats.lastLocation.lng.toFixed(6)} + +
+ )} +
+
+ )} + + ) : ( +
+

データなし

+
+ )} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/components/ReplayViewer.tsx b/components/ReplayViewer.tsx new file mode 100644 index 0000000..6b139ea --- /dev/null +++ b/components/ReplayViewer.tsx @@ -0,0 +1,247 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { useLocationHistory, type LocationHistoryEntry } from '@/hooks/useLocationHistory'; + +interface ReplayViewerProps { + userId: string; + gameId?: string; + isGameMaster?: boolean; +} + +export default function ReplayViewer({ userId, gameId, isGameMaster = false }: ReplayViewerProps) { + const { history, loading, fetchHistory } = useLocationHistory({ userId, gameId }); + const [isPlaying, setIsPlaying] = useState(false); + const [currentIndex, setCurrentIndex] = useState(0); + const [playbackSpeed, setPlaybackSpeed] = useState(1); // 1x, 2x, 4x + + useEffect(() => { + fetchHistory(); + }, [fetchHistory]); + + useEffect(() => { + if (!isPlaying || history.length === 0) return; + + const interval = setInterval(() => { + setCurrentIndex((prev) => { + if (prev >= history.length - 1) { + setIsPlaying(false); + return prev; + } + return prev + 1; + }); + }, 1000 / playbackSpeed); + + return () => clearInterval(interval); + }, [isPlaying, history.length, playbackSpeed]); + + const handlePlay = useCallback(() => { + if (currentIndex >= history.length - 1) { + setCurrentIndex(0); + } + setIsPlaying(true); + }, [currentIndex, history.length]); + + const handlePause = useCallback(() => { + setIsPlaying(false); + }, []); + + const handleReset = useCallback(() => { + setIsPlaying(false); + setCurrentIndex(0); + }, []); + + const handleSeek = useCallback((index: number) => { + setCurrentIndex(index); + setIsPlaying(false); + }, []); + + const formatTime = (date: Date): string => { + return date.toLocaleTimeString('ja-JP', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + }; + + const currentEntry = history[currentIndex]; + const progress = history.length > 0 ? (currentIndex / (history.length - 1)) * 100 : 0; + + if (!isGameMaster) { + return null; + } + + return ( +
+
+
+ 🎬 +
+

リプレイビューア

+
+ + {loading ? ( +
+
+ +
+

履歴を読み込み中...

+
+ ) : history.length === 0 ? ( +
+
+ 🎬 +
+

位置履歴データがありません

+
+ ) : ( +
+ {/* Current position info */} + {currentEntry && ( +
+
+
+

時刻

+

+ {formatTime(currentEntry.timestamp)} +

+
+
+

位置

+

+ {currentEntry.location.lat.toFixed(6)}, {currentEntry.location.lng.toFixed(6)} +

+
+ {currentEntry.speed !== undefined && ( +
+

速度

+

+ {(currentEntry.speed * 3.6).toFixed(1)} km/h +

+
+ )} + {currentEntry.heading !== undefined && ( +
+

方向

+

+ {currentEntry.heading.toFixed(0)}° +

+
+ )} +
+
+ )} + + {/* Progress bar */} +
+
+ {currentIndex + 1} / {history.length} + {Math.round(progress)}% +
+
+
+
+ {/* Seek bar */} + handleSeek(Number(e.target.value))} + className="w-full h-2 bg-transparent cursor-pointer appearance-none" + style={{ + WebkitAppearance: 'none', + background: 'transparent', + }} + /> +
+ + {/* Playback controls */} +
+
+ + {isPlaying ? ( + + ) : ( + + )} +
+ + {/* Playback speed */} +
+ 再生速度: +
+ {[1, 2, 4, 8].map((speed) => ( + + ))} +
+
+
+ + {/* Timeline */} +
+

タイムライン

+
+ {history.slice(Math.max(0, currentIndex - 2), Math.min(history.length, currentIndex + 3)).map((entry, idx) => { + const actualIdx = Math.max(0, currentIndex - 2) + idx; + const isCurrent = actualIdx === currentIndex; + return ( + + ); + })} +
+
+
+ )} +
+ ); +} diff --git a/hooks/__tests__/useLocationHistory.test.tsx b/hooks/__tests__/useLocationHistory.test.tsx new file mode 100644 index 0000000..0510975 --- /dev/null +++ b/hooks/__tests__/useLocationHistory.test.tsx @@ -0,0 +1,169 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { useLocationHistory } from '../useLocationHistory'; +import { supabase } from '@/lib/supabase'; + +// Mock Supabase +vi.mock('@/lib/supabase', () => ({ + supabase: { + from: vi.fn(() => ({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + eq: vi.fn(() => ({ + order: vi.fn(() => ({ + data: [], + error: null, + })), + })), + order: vi.fn(() => ({ + data: [], + error: null, + })), + })), + order: vi.fn(() => ({ + data: [], + error: null, + })), + })), + insert: vi.fn(() => ({ + data: null, + error: null, + })), + delete: vi.fn(() => ({ + eq: vi.fn(() => ({ + eq: vi.fn(() => ({ + data: null, + error: null, + })), + data: null, + error: null, + })), + })), + })), + }, +})); + +describe('useLocationHistory', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should initialize with empty history', () => { + const { result } = renderHook(() => useLocationHistory()); + + expect(result.current.history).toEqual([]); + expect(result.current.stats).toBeNull(); + expect(result.current.loading).toBe(false); + }); + + it('should provide fetchHistory function', () => { + const { result } = renderHook(() => useLocationHistory({ userId: 'user1', gameId: 'game1' })); + + expect(result.current.loading).toBe(false); + expect(typeof result.current.fetchHistory).toBe('function'); + }); + + it('should calculate distance between two points', () => { + const { result } = renderHook(() => useLocationHistory()); + + const distance = result.current.calculateDistance( + { lat: 35.6895, lng: 139.6917, timestamp: new Date() }, + { lat: 35.6896, lng: 139.6918, timestamp: new Date() } + ); + + expect(distance).toBeGreaterThan(0); + expect(distance).toBeLessThan(20); // Should be a small distance in meters + }); + + it('should record a location', async () => { + const mockInsert = vi.fn(() => Promise.resolve({ data: null, error: null })); + + vi.mocked(supabase.from).mockReturnValue({ + insert: mockInsert, + } as any); + + const { result } = renderHook(() => useLocationHistory({ userId: 'user1' })); + + await result.current.recordLocation( + { lat: 35.6895, lng: 139.6917, timestamp: new Date() }, + 1.5, + 90 + ); + + await waitFor(() => { + expect(mockInsert).toHaveBeenCalled(); + }); + }); + + it('should throw error when recording without userId', async () => { + const { result } = renderHook(() => useLocationHistory()); + + await expect( + result.current.recordLocation({ lat: 35.6895, lng: 139.6917, timestamp: new Date() }) + ).rejects.toThrow('User ID is required'); + }); + + it('should clear history', async () => { + const { result } = renderHook(() => useLocationHistory({ userId: 'user1' })); + + const mockDelete = vi.fn(() => Promise.resolve({ data: null, error: null })); + + vi.mocked(supabase.from).mockReturnValue({ + delete: vi.fn(() => ({ + eq: vi.fn(() => ({ + eq: vi.fn(mockDelete), + })), + })), + } as any); + + await result.current.clearHistory(); + + expect(result.current.history).toEqual([]); + expect(result.current.stats).toBeNull(); + }); + + it('should handle fetch errors gracefully', async () => { + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + order: vi.fn(() => Promise.resolve({ data: null, error: new Error('Network error') })), + })), + order: vi.fn(() => Promise.resolve({ data: null, error: new Error('Network error') })), + })), + } as any); + + const { result } = renderHook(() => useLocationHistory({ userId: 'user1' })); + + await result.current.fetchHistory(); + + await waitFor(() => { + expect(result.current.error).toBeTruthy(); + }); + }); + + it('should initialize stats as null', () => { + const { result } = renderHook(() => useLocationHistory({ userId: 'user1', gameId: 'game1' })); + + expect(result.current.stats).toBeNull(); + expect(result.current.history).toEqual([]); + }); + + it('should auto-fetch when autoTrack is enabled', async () => { + const mockSelect = vi.fn(() => ({ + eq: vi.fn(() => ({ + order: vi.fn(() => Promise.resolve({ data: [], error: null })), + })), + order: vi.fn(() => Promise.resolve({ data: [], error: null })), + })); + + vi.mocked(supabase.from).mockReturnValue({ + select: mockSelect, + } as any); + + renderHook(() => useLocationHistory({ userId: 'user1', autoTrack: true })); + + await waitFor(() => { + expect(mockSelect).toHaveBeenCalled(); + }); + }); +}); diff --git a/hooks/useLocationHistory.tsx b/hooks/useLocationHistory.tsx new file mode 100644 index 0000000..6bc29e7 --- /dev/null +++ b/hooks/useLocationHistory.tsx @@ -0,0 +1,223 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { supabase } from '@/lib/supabase'; +import type { Location } from '@/types'; + +export interface LocationHistoryEntry { + id: string; + userId: string; + location: Location; + timestamp: Date; + speed?: number; // meters per second + heading?: number; // degrees +} + +export interface PlayerStats { + totalDistance: number; // meters + averageSpeed: number; // meters per second + maxSpeed: number; // meters per second + duration: number; // milliseconds + lastLocation?: Location; +} + +interface UseLocationHistoryOptions { + gameId?: string; + userId?: string; + autoTrack?: boolean; +} + +export function useLocationHistory(options: UseLocationHistoryOptions = {}) { + const { gameId, userId, autoTrack = false } = options; + const [history, setHistory] = useState([]); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Fetch location history + const fetchHistory = useCallback(async () => { + if (!userId && !gameId) return; + + try { + setLoading(true); + setError(null); + + let query = supabase + .from('location_history') + .select('*') + .order('timestamp', { ascending: true }); + + if (userId) { + query = query.eq('user_id', userId); + } + if (gameId) { + query = query.eq('game_id', gameId); + } + + const { data, error: fetchError } = await query; + + if (fetchError) throw fetchError; + + if (data) { + const entries: LocationHistoryEntry[] = data.map((entry: any) => ({ + id: entry.id, + userId: entry.user_id, + location: { + lat: entry.latitude, + lng: entry.longitude, + timestamp: new Date(entry.timestamp), + }, + timestamp: new Date(entry.timestamp), + speed: entry.speed || undefined, + heading: entry.heading || undefined, + })); + + setHistory(entries); + calculateStats(entries); + } + } catch (err) { + console.error('Error fetching location history:', err); + setError(err instanceof Error ? err.message : 'Failed to fetch history'); + } finally { + setLoading(false); + } + }, [userId, gameId]); + + // Calculate statistics from history + const calculateStats = useCallback((entries: LocationHistoryEntry[]) => { + if (entries.length === 0) { + setStats(null); + return; + } + + let totalDistance = 0; + let maxSpeed = 0; + const speeds: number[] = []; + + for (let i = 1; i < entries.length; i++) { + const prev = entries[i - 1]; + const curr = entries[i]; + + if (!prev || !curr) continue; + + const distance = calculateDistance(prev.location, curr.location); + totalDistance += distance; + + if (curr.speed !== undefined) { + speeds.push(curr.speed); + maxSpeed = Math.max(maxSpeed, curr.speed); + } + } + + const lastEntry = entries[entries.length - 1]; + const firstEntry = entries[0]; + const duration = lastEntry && firstEntry + ? lastEntry.timestamp.getTime() - firstEntry.timestamp.getTime() + : 0; + + const averageSpeed = speeds.length > 0 + ? speeds.reduce((sum, s) => sum + s, 0) / speeds.length + : 0; + + setStats({ + totalDistance, + averageSpeed, + maxSpeed, + duration, + lastLocation: lastEntry?.location, + }); + }, []); + + // Record a location entry + const recordLocation = useCallback(async (location: Location, speed?: number, heading?: number) => { + if (!userId) { + throw new Error('User ID is required to record location'); + } + + try { + const insertPayload: Record = { + user_id: userId, + game_id: gameId || null, + latitude: location.lat, + longitude: location.lng, + timestamp: new Date().toISOString(), + speed: speed || null, + heading: heading || null, + }; + + const { error: insertError } = await supabase + .from('location_history') + .insert(insertPayload as never); + + if (insertError) throw insertError; + + // Optionally refetch to update local state + if (autoTrack) { + await fetchHistory(); + } + } catch (err) { + console.error('Error recording location:', err); + throw err; + } + }, [userId, gameId, autoTrack, fetchHistory]); + + // Calculate distance between two points (Haversine formula) + const calculateDistance = (pos1: Location, pos2: Location): number => { + const R = 6371e3; // Earth's radius in meters + 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; // Distance in meters + }; + + // Clear history + const clearHistory = useCallback(async () => { + if (!userId) return; + + try { + let deleteQuery = supabase + .from('location_history') + .delete() + .eq('user_id', userId); + + if (gameId) { + deleteQuery = deleteQuery.eq('game_id', gameId); + } + + const { error: deleteError } = await deleteQuery; + + if (deleteError) throw deleteError; + + setHistory([]); + setStats(null); + } catch (err) { + console.error('Error clearing history:', err); + throw err; + } + }, [userId, gameId]); + + // Initial fetch + useEffect(() => { + if (autoTrack) { + fetchHistory(); + } + }, [autoTrack, fetchHistory]); + + return { + history, + stats, + loading, + error, + fetchHistory, + recordLocation, + clearHistory, + calculateDistance, + }; +}