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,
+ };
+}