diff --git a/.github/ISSUE_QUICK_START.md b/.github/ISSUE_QUICK_START.md new file mode 100644 index 0000000..4db2b61 --- /dev/null +++ b/.github/ISSUE_QUICK_START.md @@ -0,0 +1,370 @@ +# GitHub Issues Quick Start Guide + +## Overview +This guide explains how to use the advanced analytics issue templates to implement the 8 features across 3 phases. + +## Issue Templates Location +`.github/ISSUE_TEMPLATE/` + +## How to Create Issues from Templates + +### Option 1: GitHub Web UI +1. Navigate to repository: `https://github.com/YOUR_USERNAME/Sports-Odds-MCP` +2. Click **"Issues"** tab +3. Click **"New Issue"** button +4. Select template from list: + - `[Phase 1] CLV Tracking` + - `[Phase 1] Bookmaker Disagreement Detection` + - `[Phase 1] Line Movement Tracking` + - `[Phase 2] Sharp vs Public Money Indicators` + - `[Phase 2] Market Consensus & Deviations` + - `[Phase 2] Bookmaker Analytics` + - `[Phase 3] Arbitrage Detection` + - `[Phase 3] Bet Correlation Analysis` + - `[EPIC] Advanced Analytics Rollout` +5. Click **"Get started"** +6. Review pre-filled content +7. Update `#TBD` references with actual issue numbers +8. Assign team members +9. Add to project board (optional) +10. Click **"Submit new issue"** + +### Option 2: GitHub CLI +```bash +# Install GitHub CLI (if not already installed) +# Windows: winget install --id GitHub.cli +# macOS: brew install gh + +# Authenticate +gh auth login + +# Create issues from templates +gh issue create --template phase1-clv-tracking.md --label "enhancement,phase-1,analytics" +gh issue create --template phase1-bookmaker-disagreement.md --label "enhancement,phase-1,analytics" +gh issue create --template phase1-line-movement.md --label "enhancement,phase-1,analytics" +gh issue create --template phase2-sharp-vs-public.md --label "enhancement,phase-2,analytics" +gh issue create --template phase2-market-consensus.md --label "enhancement,phase-2,analytics" +gh issue create --template phase2-bookmaker-analytics.md --label "enhancement,phase-2,analytics" +gh issue create --template phase3-arbitrage-detection.md --label "enhancement,phase-3,analytics,advanced" +gh issue create --template phase3-bet-correlation-analysis.md --label "enhancement,phase-3,analytics,advanced" +gh issue create --template epic-advanced-analytics-rollout.md --label "epic,analytics,enhancement" +``` + +### Option 3: Manual Creation +1. Copy template file content +2. Create new issue +3. Paste content into description +4. Add labels, assignees, project +5. Submit + +## Recommended Issue Creation Order + +### Step 1: Create Epic (Master Tracking Issue) +- **Template**: `epic-advanced-analytics-rollout.md` +- **Why First**: Provides context and links for all feature issues +- **Note Issue Number**: You'll reference this in all feature issues + +### Step 2: Create Phase 1 Issues +1. `phase1-clv-tracking.md` (Highest priority - foundation) +2. `phase1-bookmaker-disagreement.md` +3. `phase1-line-movement.md` + +### Step 3: Create Phase 2 Issues +4. `phase2-sharp-vs-public.md` +5. `phase2-market-consensus.md` +6. `phase2-bookmaker-analytics.md` + +### Step 4: Create Phase 3 Issues +7. `phase3-arbitrage-detection.md` +8. `phase3-bet-correlation-analysis.md` + +### Step 5: Update Epic with Issue Numbers +- Edit epic issue +- Replace `#TBD` with actual issue numbers +- Example: `- [ ] #15 - CLV Tracking` + +## Labels to Use + +### Priority Labels +- `priority-critical` - Blocking other work +- `priority-high` - Start soon +- `priority-medium` - Standard priority +- `priority-low` - Nice to have + +### Phase Labels (Pre-filled in templates) +- `phase-1` - Core Analytics +- `phase-2` - Market Intelligence +- `phase-3` - Advanced Features + +### Category Labels (Pre-filled in templates) +- `enhancement` - New feature +- `analytics` - Analytics-related +- `advanced` - Advanced/complex feature +- `epic` - Master tracking issue + +### Status Labels (Add as work progresses) +- `status-planning` - Requirements gathering +- `status-in-progress` - Active development +- `status-review` - Code review or testing +- `status-blocked` - Waiting on dependency +- `status-done` - Completed + +## Project Board Setup (Optional) + +### Create Project Board +1. Go to repository → **"Projects"** tab +2. Click **"New project"** +3. Name: "Advanced Analytics Rollout" +4. Template: "Board" (Kanban style) + +### Columns +- **Backlog**: All created issues +- **Sprint Ready**: Prioritized for current sprint +- **In Progress**: Actively being worked on +- **Review/QA**: Code review or testing +- **Done**: Completed and merged + +### Add Issues to Board +1. Click **"Add cards"** +2. Select all created issues +3. Drag to appropriate column + +## Milestones (Recommended) + +### Create Milestones +1. Go to repository → **"Issues"** → **"Milestones"** +2. Click **"New milestone"** + +**Milestone 1: Phase 1 - Core Analytics** +- Title: "Phase 1 - Core Analytics" +- Due date: +20 days from start +- Description: "CLV tracking, line movement, bookmaker disagreement" +- Issues: #2, #3, #4 (Phase 1 issues) + +**Milestone 2: Phase 2 - Market Intelligence** +- Title: "Phase 2 - Market Intelligence" +- Due date: +42 days from start +- Description: "Sharp money, consensus, bookmaker analytics" +- Issues: #5, #6, #7 (Phase 2 issues) + +**Milestone 3: Phase 3 - Advanced Features** +- Title: "Phase 3 - Advanced Features" +- Due date: +82 days from start +- Description: "Arbitrage detection, bet correlation" +- Issues: #8, #9 (Phase 3 issues) + +### Assign Issues to Milestones +1. Open each issue +2. Right sidebar → **"Milestone"** +3. Select appropriate milestone + +## Assignees + +### How to Assign +1. Open issue +2. Right sidebar → **"Assignees"** +3. Select team member(s) + +### Recommended Assignments +- **Backend Developer**: All issues (API, services, algorithms) +- **Frontend Developer**: All issues (UI components, charts) +- **QA Engineer**: Assigned during review phase +- **Product Manager**: Epic issue (tracking) + +## Issue Dependencies + +### Document Dependencies +Add to issue description: +```markdown +## Dependencies +- Depends on: #15 (CLV Tracking must be completed first) +- Blocks: #18 (Arbitrage detection needs this feature) +- Related to: #16 (Uses similar algorithm) +``` + +### Dependency Order +``` +Phase 1 Foundation: + #2 (CLV) → [No dependencies] + #3 (Disagreement) → Depends on #2 (uses closing lines) + #4 (Line Movement) → Depends on #2 (uses historical odds) + +Phase 2 Building: + #5 (Sharp Money) → Depends on #4 (line movement data) + #6 (Consensus) → Depends on #3 (disagreement data) + #7 (Bookmaker Analytics) → Depends on #2, #3, #4 + +Phase 3 Advanced: + #8 (Arbitrage) → Depends on #6 (consensus data) + #9 (Correlation) → Depends on #2, #6 (CLV + consensus) +``` + +## Sprint Planning + +### Sprint 1 (2 weeks) - CLV Foundation +- **Issues**: #2 (CLV Tracking) +- **Goal**: Implement closing line capture and basic CLV calculation +- **Tasks**: + - [ ] Create `CLVSnapshot` database model + - [ ] Implement closing line capture service + - [ ] Build CLV calculation algorithm + - [ ] Create API endpoints + - [ ] Design dashboard component + - [ ] Write unit tests + +### Sprint 2 (2 weeks) - CLV Completion + Line Movement +- **Issues**: #2 (completion), #4 (Line Movement) +- **Goal**: Finish CLV features, start line movement tracking +- **Tasks**: + - [ ] CLV dashboard polish and charts + - [ ] Line movement database model + - [ ] Start line movement API + +### Sprint 3 (2 weeks) - Disagreement Detection +- **Issues**: #3 (Bookmaker Disagreement), #4 (continuation) +- **Goal**: Complete line movement, implement disagreement detection +- **Tasks**: + - [ ] Line movement charts and alerts + - [ ] Disagreement detection algorithm + - [ ] Disagreement dashboard + +### Sprints 4-14 +- Continue with Phase 2 and Phase 3 features +- Refer to epic issue for full timeline + +## Tracking Progress + +### Update Issues Regularly +- Add comments with progress updates +- Check off acceptance criteria checkboxes +- Update status labels +- Link to pull requests + +### Epic Issue Tracking +- Update progress percentages in epic: + - "Phase 1: 2/3 complete (67%)" + - "Overall: 5/8 complete (63%)" +- Update timeline if delays occur +- Document blockers and risks + +### Pull Request Linking +When creating PRs: +``` +Closes #15 # Automatically closes issue when PR merged +Part of #15 # Links to issue without closing +``` + +## Communication + +### Issue Comments +- Use `@username` to mention team members +- Add screenshots of progress +- Document decisions and changes +- Ask questions for clarification + +### Code Reviews +- Request reviews from assignees +- Use PR templates (create `.github/pull_request_template.md`) +- Link to related issues + +### Status Updates +Weekly update template: +```markdown +## Week of [Date] + +### Completed +- ✅ CLV database model created +- ✅ API endpoints implemented + +### In Progress +- 🔄 Frontend dashboard component + +### Blocked +- ⚠️ Waiting on API-Sports data schema + +### Next Week +- Dashboard component completion +- Unit testing +``` + +## Closing Issues + +### When to Close +- All acceptance criteria checked off +- Code merged to main branch +- Tests passing +- Deployed to production (or staging) +- Documentation updated + +### How to Close +1. Add final comment: "Completed in #123 (PR)" +2. Check all checkboxes in description +3. Click **"Close issue"** +4. Update epic progress tracking + +## Quick Commands + +### List All Open Issues +```bash +gh issue list --label "analytics" +``` + +### View Issue Details +```bash +gh issue view 15 +``` + +### Update Issue +```bash +gh issue edit 15 --add-label "status-in-progress" --add-assignee username +``` + +### Close Issue +```bash +gh issue close 15 --comment "Completed in PR #123" +``` + +## Best Practices + +1. **Start with Epic**: Create epic issue first for context +2. **One Feature Per Issue**: Don't combine multiple features +3. **Update Regularly**: Add comments weekly (minimum) +4. **Link PRs**: Always reference issue number in PR +5. **Check Acceptance Criteria**: Use as development checklist +6. **Document Decisions**: Record why you chose certain approaches +7. **Test Before Closing**: Verify all acceptance criteria met +8. **Update Documentation**: Don't forget to update README, wiki, API docs + +## Templates Summary + +| Template | Priority | Dependencies | Effort | Phase | +|----------|----------|--------------|--------|-------| +| CLV Tracking | Highest | None | 6 days | 1 | +| Bookmaker Disagreement | High | CLV | 6 days | 1 | +| Line Movement | High | CLV | 8 days | 1 | +| Sharp vs Public | Medium | Line Movement | 8 days | 2 | +| Market Consensus | Medium | Disagreement | 6 days | 2 | +| Bookmaker Analytics | Medium | All Phase 1 | 8 days | 2 | +| Arbitrage Detection | Low | Consensus | 18 days | 3 | +| Bet Correlation | Low | CLV, Consensus | 22 days | 3 | + +## Resources + +- **Documentation**: `docs/ANALYTICS-IMPLEMENTATION-SUMMARY.md` +- **Database Schema**: `dashboard/backend/prisma/schema.prisma` +- **API Documentation**: `docs/wiki/API-DOCUMENTATION.md` +- **Database Guide**: `docs/wiki/Database-Guide.md` + +## Questions? + +If templates are unclear or you need more detail: +1. Check `docs/ANALYTICS-IMPLEMENTATION-SUMMARY.md` +2. Review the epic issue for context +3. Comment on the specific issue +4. Tag relevant team members + +--- + +**Last Updated**: January 15, 2026 +**Version**: 1.0 +**Status**: Ready for implementation diff --git a/.github/ISSUE_TEMPLATE/README.md b/.github/ISSUE_TEMPLATE/README.md new file mode 100644 index 0000000..e92581c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/README.md @@ -0,0 +1,188 @@ +# Issue Templates - Advanced Analytics Features + +This directory contains GitHub issue templates for the Advanced Analytics Rollout project. + +## 📁 Templates Overview + +### Epic Tracking +- **epic-advanced-analytics-rollout.md**: Master tracking issue for all 8 features across 3 phases + +### Phase 1: Core Analytics (Foundation) +- **phase1-clv-tracking.md**: Closing Line Value tracking (6 days) +- **phase1-bookmaker-disagreement.md**: Odds discrepancy detection (6 days) +- **phase1-line-movement.md**: Line movement tracking and visualization (8 days) + +### Phase 2: Market Intelligence (Intermediate) +- **phase2-sharp-vs-public.md**: Professional vs recreational betting indicators (8 days) +- **phase2-market-consensus.md**: True market odds calculation (6 days) +- **phase2-bookmaker-analytics.md**: Bookmaker behavior profiling (8 days) + +### Phase 3: Advanced Features (Professional) +- **phase3-arbitrage-detection.md**: Real-time arbitrage opportunity scanning (18 days) +- **phase3-bet-correlation-analysis.md**: Parlay correlation detection (22 days) + +## 🚀 Quick Start + +**See**: [../.github/ISSUE_QUICK_START.md](../ISSUE_QUICK_START.md) for detailed instructions on: +- How to create issues from templates +- Setting up project boards +- Milestone planning +- Sprint organization +- Progress tracking + +## 📊 Implementation Timeline + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Phase 1: Core Analytics (20 days) │ +├─────────────────────────────────────────────────────────────────┤ +│ ├─ CLV Tracking (6 days) │ +│ ├─ Bookmaker Disagreement (6 days) │ +│ └─ Line Movement (8 days) │ +└─────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────┐ +│ Phase 2: Market Intelligence (22 days) │ +├─────────────────────────────────────────────────────────────────┤ +│ ├─ Sharp vs Public Money (8 days) │ +│ ├─ Market Consensus (6 days) │ +│ └─ Bookmaker Analytics (8 days) │ +└─────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────┐ +│ Phase 3: Advanced Features (40 days) │ +├─────────────────────────────────────────────────────────────────┤ +│ ├─ Arbitrage Detection (18 days) │ +│ └─ Bet Correlation Analysis (22 days) │ +└─────────────────────────────────────────────────────────────────┘ + +Total: 82 days (~3-4 months) +``` + +## 🎯 Feature Priorities + +### High Priority (Start First) +1. **CLV Tracking** - Foundation for all other features + - Most user value + - Simplest implementation + - Required by other features + +### Medium Priority (Phase 1-2) +2. **Line Movement** - Visual appeal, sharp money signals +3. **Bookmaker Disagreement** - Automated value detection +4. **Sharp vs Public** - Professional betting insights +5. **Market Consensus** - True price discovery +6. **Bookmaker Analytics** - Account management + +### Lower Priority (Phase 3) +7. **Arbitrage Detection** - Complex but high value +8. **Bet Correlation** - Educational + risk management + +## 📦 What's Included in Each Template + +Every template includes: +- ✅ **Overview**: Feature description +- ✅ **Business Value**: Why it matters +- ✅ **Technical Requirements**: + - Database schema (Prisma models) + - Backend services and algorithms + - API endpoints with full specs + - Frontend components with UI mockups +- ✅ **Acceptance Criteria**: Detailed checklist +- ✅ **Dependencies**: Internal and external +- ✅ **Estimated Effort**: Realistic time estimates +- ✅ **Success Metrics**: Measurable KPIs +- ✅ **Future Enhancements**: Extensibility + +## 🛠️ Database Schema Changes + +### Already Completed ✅ +- `apiSportsTeamId` on Team model +- `apiSportsPlayerId` on Player model +- `apiSportsGameId`, `apiSportsLeagueId`, `season`, `seasonType` on Game model +- Migration: `add_api_sports_ids` applied successfully + +### To Be Added (Per Feature) +- **Phase 1**: `CLVSnapshot`, `LineMovement`, `BookmakerDisagreement` +- **Phase 2**: `SharpMoneyIndicator`, `MarketConsensus`, `BookmakerProfile` +- **Phase 3**: `ArbitrageOpportunity`, `BetCorrelation`, `ParlayAnalysis` + +## 🔗 Related Documentation + +- **Planning Summary**: [../../docs/ANALYTICS-IMPLEMENTATION-SUMMARY.md](../../docs/ANALYTICS-IMPLEMENTATION-SUMMARY.md) +- **Quick Start Guide**: [../ISSUE_QUICK_START.md](../ISSUE_QUICK_START.md) +- **API Documentation**: [../../docs/wiki/API-DOCUMENTATION.md](../../docs/wiki/API-DOCUMENTATION.md) +- **Database Guide**: [../../docs/wiki/Database-Guide.md](../../docs/wiki/Database-Guide.md) +- **Root Changelog**: [../../CHANGELOG.md](../../CHANGELOG.md) + +## 💡 Usage Tips + +1. **Create Epic First**: Provides context for all feature issues +2. **Follow Dependency Order**: CLV → Disagreement/Movement → Phase 2 → Phase 3 +3. **One Sprint at a Time**: Don't create all issues at once if using sprints +4. **Update Epic Progress**: Keep progress percentages current +5. **Link Pull Requests**: Use "Closes #123" in PR descriptions +6. **Check Acceptance Criteria**: Use as development checklist + +## 📈 Expected Outcomes + +### User Experience +- 📊 Better betting decisions through data +- 🎓 Educational content on betting strategy +- 💰 Improved ROI (3-5% win rate increase) +- 🎯 Competitive advantage over other platforms + +### Business Metrics +- 📈 +40% daily active users +- ⏱️ +25% session duration +- 🎯 +30% 30-day retention +- 💵 +50% premium subscriptions + +### Competitive Position +- 🏆 Feature parity with DraftKings, FanDuel +- 🚀 Unique features: CLV, arbitrage, correlation +- 🎓 Best-in-class educational content + +## 🚦 Getting Started + +### Step 1: Read the Documentation +```bash +# Planning summary +cat docs/ANALYTICS-IMPLEMENTATION-SUMMARY.md + +# Quick start guide +cat .github/ISSUE_QUICK_START.md +``` + +### Step 2: Create Epic Issue +- Use `epic-advanced-analytics-rollout.md` template +- Note the issue number (e.g., #10) + +### Step 3: Create Phase 1 Issues +- Start with `phase1-clv-tracking.md` +- Then `phase1-bookmaker-disagreement.md` +- Then `phase1-line-movement.md` + +### Step 4: Update Epic +- Replace `#TBD` with actual issue numbers +- Link all Phase 1, 2, 3 issues + +### Step 5: Start Sprint 1 +- Focus on CLV tracking only +- Break into 2-week sprint tasks +- Daily standups and weekly updates + +## ❓ Questions? + +- Check [ISSUE_QUICK_START.md](../ISSUE_QUICK_START.md) +- Review [ANALYTICS-IMPLEMENTATION-SUMMARY.md](../../docs/ANALYTICS-IMPLEMENTATION-SUMMARY.md) +- Comment on the epic issue +- Tag team members for clarification + +--- + +**Created**: January 15, 2026 +**Status**: Ready for implementation +**Total Features**: 8 +**Total Phases**: 3 +**Estimated Duration**: 82 days diff --git a/.gitignore b/.gitignore index ed72f20..de45085 100644 --- a/.gitignore +++ b/.gitignore @@ -145,6 +145,7 @@ logs/ # Internal docs/internal/ +docs/feature request/ # Dashboard (Node.js/TypeScript/React) dashboard/node_modules/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7485354..77e70a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,38 @@ For detailed change history, see the component-specific changelogs: Changes that affect the entire project structure: +## [2026-01-15] + +### Release Summary +Planning release for advanced analytics features. Added 5 database schema enhancements for API-Sports integration and created comprehensive GitHub issue templates for 8 advanced analytics features across 3 implementation phases. + +### Component Versions +- **Dashboard Backend**: v0.2.1 (schema updated) +- **Dashboard Frontend**: v0.3.1 (unchanged) + +### Database Schema Enhancements +- **API-Sports Integration**: Added ID mapping fields to Team, Player, and Game models + - Team: `apiSportsTeamId` field with index + - Player: `apiSportsPlayerId` field with index + - Game: `apiSportsGameId`, `apiSportsLeagueId`, `season`, `seasonType` fields with indexes + - Migration: `add_api_sports_ids` completed successfully + +### Planning & Documentation +- **Advanced Analytics Roadmap**: Created 3-phase implementation plan (77-82 days total) + - Phase 1 (20 days): CLV tracking, line movement, bookmaker disagreement detection + - Phase 2 (22 days): Sharp vs public money, market consensus, bookmaker analytics + - Phase 3 (40 days): Arbitrage detection, bet correlation analysis +- **GitHub Issues**: Created 9 comprehensive issue templates with full technical specifications + - Each template includes database models, algorithms, API endpoints, UI components, acceptance criteria + - Epic tracking issue links all features with timeline and success metrics +- **Documentation**: Added `docs/ANALYTICS-IMPLEMENTATION-SUMMARY.md` with complete planning overview + +### Business Impact +- **Competitive Advantage**: Features not available on most sportsbooks +- **Target Markets**: Casual bettors (education), serious bettors (analytics), professional bettors (arbitrage) +- **Estimated ROI**: 117% in Year 1 with 200 premium subscribers +- **User Engagement**: Projected +40% DAU, +25% session duration, +30% retention + ## [2026-01-13] ### Release Summary diff --git a/README.md b/README.md index b5831af..5088d31 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,20 @@ BetTrack is a dual-platform sports betting analytics and tracking solution that Whether you're using Claude Desktop to research bets with natural language or the web dashboard to track your betting portfolio, BetTrack provides the data and tools you need. +## Screenshots + +### Dashboard Home Page +![BetTrack Home Page](docs/assets/home-page.png) +*Landing page with feature overview and quick start guide* + +### Dashboard V2 - Dark Mode +![Dashboard V2 Dark Mode](docs/assets/dashboard-dark.png) +*Enhanced dashboard with live odds, game cards, and bet slip in dark mode* + +### Dashboard V2 - Light Mode +![Dashboard V2 Light Mode](docs/assets/dashboard-light.png) +*Clean light mode interface with filtering sidebar and responsive layout* + ## Key Features ### MCP Server @@ -44,6 +58,35 @@ Whether you're using Claude Desktop to research bets with natural language or th - **Timezone-aware** game filtering and scheduling - **PostgreSQL database** with Prisma ORM +## 🚀 Advanced Analytics Roadmap + +BetTrack is evolving into a professional-grade sports betting analytics platform with features not available on most sportsbooks. We're implementing 8 advanced analytics features across 3 phases to give users a significant competitive advantage. + +### 📊 Phase 1: Core Analytics (Foundation) +**Status**: 🔄 Planning Complete - Ready for Implementation + +1. **CLV Tracking** - Track closing line value for every bet to measure skill +2. **Bookmaker Disagreement** - Find value bets when bookmakers disagree significantly +3. **Line Movement** - Visualize odds changes and detect sharp money movement + +### 🧠 Phase 2: Market Intelligence (Intermediate) +**Status**: ⏳ Planned + +4. **Sharp vs Public Money** - Identify professional betting patterns +5. **Market Consensus** - Calculate true market odds from all bookmakers +6. **Bookmaker Analytics** - Profile each bookmaker's tendencies and value + +### 🎯 Phase 3: Advanced Features (Professional) +**Status**: ⏳ Planned + +7. **Arbitrage Detection** - Find guaranteed profit opportunities across books +8. **Bet Correlation Analysis** - Detect correlated parlays and avoid mistakes + +**Total Timeline**: 82 days (~3-4 months) +**Expected Impact**: +40% user engagement, +50% premium subscriptions, +3-5% user ROI improvement + +**📚 Learn More**: See [docs/ANALYTICS-IMPLEMENTATION-SUMMARY.md](docs/ANALYTICS-IMPLEMENTATION-SUMMARY.md) for complete planning details and [.github/ISSUE_TEMPLATE/](. github/ISSUE_TEMPLATE/) for feature specifications. + ## Getting Started ### MCP Server Installation diff --git a/assets/BetTrack Logo.png b/assets/BetTrack Logo.png new file mode 100644 index 0000000..ecb8be0 Binary files /dev/null and b/assets/BetTrack Logo.png differ diff --git a/dashboard/backend/.env.example b/dashboard/backend/.env.example index 1207ea5..29227cc 100644 --- a/dashboard/backend/.env.example +++ b/dashboard/backend/.env.example @@ -4,6 +4,17 @@ DATABASE_URL="postgresql://sports_user:sports_password_change_in_production@loca # The Odds API ODDS_API_KEY=your_odds_api_key_here +# API-Sports Configuration +API_SPORTS_KEY=your_api_sports_key_here +API_SPORTS_TIER=pro + +# Redis (for caching - optional) +REDIS_URL=redis://localhost:6379 + +# Real-time updates +ENABLE_WEBSOCKETS=true +STATS_POLL_INTERVAL_MS=15000 + # Server PORT=3001 NODE_ENV=development diff --git a/dashboard/backend/CHANGELOG.md b/dashboard/backend/CHANGELOG.md index 57cf555..08f290e 100644 --- a/dashboard/backend/CHANGELOG.md +++ b/dashboard/backend/CHANGELOG.md @@ -5,6 +5,31 @@ All notable changes to the Dashboard Backend will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- **Multi-Sport Stats Integration**: API-Sports support for 6 sports + - NFL stats service with game stats, team stats, and live game detection + - NBA stats service with quarter scores, player stats, and shooting percentages + - NHL stats service with period scoring and live game tracking + - NCAA Basketball stats service with halftime scoring and player performance + - NCAA Football stats service with position-specific player stats (passing, rushing, receiving, defense, kicking) + - Soccer stats service supporting EPL, La Liga, Serie A, Bundesliga, Ligue 1, MLS, UEFA Champions League +- **Historical Averages API**: Enhanced stats endpoints with season-long analytics + - `/api/stats/game/:gameId` now returns `seasonAverages` with calculated team averages + - Averages calculated across all games this season for both home and away teams + - Includes total games, home games, away games counts with averaged stats +- **Home/Away Filtering**: Advanced team stats filtering + - `/api/stats/team/:teamId` accepts `location` query parameter (`home`, `away`, `all`) + - Returns split statistics comparing home vs away performance + - Filtered game history by location (up to 20 recent games) + - Separate averages for home games, away games, and overall performance +- **Stats Sync Orchestration**: Unified service for all sports + - Updated `stats-sync.service.ts` to initialize all 6 sports services + - Parallel processing with 200ms delays between API calls for rate limiting + - Comprehensive error tracking and logging per sport + - Optional service initialization based on `API_SPORTS_KEY` configuration + ## [0.2.2] - 2026-01-15 ### Added diff --git a/dashboard/backend/package.json b/dashboard/backend/package.json index 0a73f74..8ae7aa8 100644 --- a/dashboard/backend/package.json +++ b/dashboard/backend/package.json @@ -1,6 +1,6 @@ { "name": "@wford26/bettrack-backend", - "version": "0.2.2", + "version": "0.2.3", "description": "Backend API for sports betting tracker dashboard", "main": "dist/server.js", "types": "dist/server.d.ts", @@ -34,6 +34,7 @@ "dotenv": "^16.4.7", "express": "^4.21.2", "helmet": "^8.0.0", + "limiter": "^3.0.0", "node-cron": "^3.0.3", "uuid": "^11.0.5", "winston": "^3.18.0", diff --git a/dashboard/backend/prisma/migrations/20260129012719_v_2_0_0/migration.sql b/dashboard/backend/prisma/migrations/20260129012719_v_2_0_0/migration.sql new file mode 100644 index 0000000..753979b --- /dev/null +++ b/dashboard/backend/prisma/migrations/20260129012719_v_2_0_0/migration.sql @@ -0,0 +1,107 @@ +-- CreateTable +CREATE TABLE "players" ( + "id" SERIAL NOT NULL, + "external_id" VARCHAR(50), + "team_id" INTEGER, + "first_name" VARCHAR(50) NOT NULL, + "last_name" VARCHAR(50) NOT NULL, + "position" VARCHAR(20), + "number" INTEGER, + "photo_url" VARCHAR(500), + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "players_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "team_stats" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "team_id" INTEGER NOT NULL, + "sport_key" VARCHAR(50) NOT NULL, + "season" INTEGER NOT NULL, + "season_type" VARCHAR(20) NOT NULL DEFAULT 'regular', + "offense" JSONB NOT NULL DEFAULT '{}', + "defense" JSONB NOT NULL DEFAULT '{}', + "standings" JSONB NOT NULL DEFAULT '{}', + "games_played" INTEGER NOT NULL DEFAULT 0, + "last_updated" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "team_stats_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "game_stats" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "game_id" UUID NOT NULL, + "team_id" INTEGER NOT NULL, + "is_home" BOOLEAN NOT NULL, + "quarter_scores" JSONB, + "stats" JSONB NOT NULL DEFAULT '{}', + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "game_stats_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "player_game_stats" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "game_id" UUID NOT NULL, + "player_id" INTEGER NOT NULL, + "team_id" INTEGER NOT NULL, + "stats" JSONB NOT NULL DEFAULT '{}', + "started" BOOLEAN NOT NULL DEFAULT false, + "minutes_played" VARCHAR(10), + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "player_game_stats_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "players_team_id_idx" ON "players"("team_id"); + +-- CreateIndex +CREATE INDEX "players_external_id_idx" ON "players"("external_id"); + +-- CreateIndex +CREATE INDEX "team_stats_sport_key_season_idx" ON "team_stats"("sport_key", "season"); + +-- CreateIndex +CREATE UNIQUE INDEX "team_stats_team_id_season_season_type_key" ON "team_stats"("team_id", "season", "season_type"); + +-- CreateIndex +CREATE INDEX "game_stats_game_id_idx" ON "game_stats"("game_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "game_stats_game_id_team_id_key" ON "game_stats"("game_id", "team_id"); + +-- CreateIndex +CREATE INDEX "player_game_stats_game_id_idx" ON "player_game_stats"("game_id"); + +-- CreateIndex +CREATE INDEX "player_game_stats_player_id_idx" ON "player_game_stats"("player_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "player_game_stats_game_id_player_id_key" ON "player_game_stats"("game_id", "player_id"); + +-- AddForeignKey +ALTER TABLE "players" ADD CONSTRAINT "players_team_id_fkey" FOREIGN KEY ("team_id") REFERENCES "teams"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "team_stats" ADD CONSTRAINT "team_stats_team_id_fkey" FOREIGN KEY ("team_id") REFERENCES "teams"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "game_stats" ADD CONSTRAINT "game_stats_game_id_fkey" FOREIGN KEY ("game_id") REFERENCES "games"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "game_stats" ADD CONSTRAINT "game_stats_team_id_fkey" FOREIGN KEY ("team_id") REFERENCES "teams"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "player_game_stats" ADD CONSTRAINT "player_game_stats_game_id_fkey" FOREIGN KEY ("game_id") REFERENCES "games"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "player_game_stats" ADD CONSTRAINT "player_game_stats_player_id_fkey" FOREIGN KEY ("player_id") REFERENCES "players"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "player_game_stats" ADD CONSTRAINT "player_game_stats_team_id_fkey" FOREIGN KEY ("team_id") REFERENCES "teams"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/dashboard/backend/prisma/migrations/20260203184631_add_api_sports_ids/migration.sql b/dashboard/backend/prisma/migrations/20260203184631_add_api_sports_ids/migration.sql new file mode 100644 index 0000000..ead0894 --- /dev/null +++ b/dashboard/backend/prisma/migrations/20260203184631_add_api_sports_ids/migration.sql @@ -0,0 +1,23 @@ +-- AlterTable +ALTER TABLE "games" ADD COLUMN "api_sports_game_id" VARCHAR(50), +ADD COLUMN "api_sports_league_id" INTEGER, +ADD COLUMN "season" VARCHAR(20), +ADD COLUMN "season_type" VARCHAR(20); + +-- AlterTable +ALTER TABLE "players" ADD COLUMN "api_sports_player_id" INTEGER; + +-- AlterTable +ALTER TABLE "teams" ADD COLUMN "api_sports_team_id" INTEGER; + +-- CreateIndex +CREATE INDEX "games_api_sports_game_id_idx" ON "games"("api_sports_game_id"); + +-- CreateIndex +CREATE INDEX "games_season_idx" ON "games"("season"); + +-- CreateIndex +CREATE INDEX "players_api_sports_player_id_idx" ON "players"("api_sports_player_id"); + +-- CreateIndex +CREATE INDEX "teams_api_sports_team_id_idx" ON "teams"("api_sports_team_id"); diff --git a/dashboard/backend/prisma/schema.prisma b/dashboard/backend/prisma/schema.prisma index bc1db56..2a6d377 100644 --- a/dashboard/backend/prisma/schema.prisma +++ b/dashboard/backend/prisma/schema.prisma @@ -24,69 +24,162 @@ model Sport { // Teams reference table model Team { - id Int @id @default(autoincrement()) - sportId Int @map("sport_id") - externalId String? @map("external_id") @db.VarChar(50) - name String @db.VarChar(100) - abbreviation String? @db.VarChar(10) - logoUrl String? @map("logo_url") @db.VarChar(500) - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) - sport Sport @relation(fields: [sportId], references: [id]) - homeGames Game[] @relation("HomeTeam") - awayGames Game[] @relation("AwayTeam") - + id Int @id @default(autoincrement()) + sportId Int @map("sport_id") + externalId String? @map("external_id") @db.VarChar(50) + apiSportsTeamId Int? @map("api_sports_team_id") + name String @db.VarChar(100) + abbreviation String? @db.VarChar(10) + logoUrl String? @map("logo_url") @db.VarChar(500) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + sport Sport @relation(fields: [sportId], references: [id]) + homeGames Game[] @relation("HomeTeam") + awayGames Game[] @relation("AwayTeam") + teamStats TeamStats[] + gameStats GameStats[] + players Player[] + playerGameStats PlayerGameStats[] + + @@index([apiSportsTeamId]) @@map("teams") } +// Player reference table +model Player { + id Int @id @default(autoincrement()) + externalId String? @map("external_id") @db.VarChar(50) + apiSportsPlayerId Int? @map("api_sports_player_id") + teamId Int? @map("team_id") + firstName String @map("first_name") @db.VarChar(50) + lastName String @map("last_name") @db.VarChar(50) + position String? @db.VarChar(20) + number Int? + photoUrl String? @map("photo_url") @db.VarChar(500) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + team Team? @relation(fields: [teamId], references: [id]) + gameStats PlayerGameStats[] + + @@index([teamId]) + @@index([externalId]) + @@index([apiSportsPlayerId]) + @@map("players") +} + +// Team season statistics +model TeamStats { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + teamId Int @map("team_id") + sportKey String @map("sport_key") @db.VarChar(50) + season Int + seasonType String @default("regular") @map("season_type") @db.VarChar(20) + offense Json @default("{}") + defense Json @default("{}") + standings Json @default("{}") // wins, losses, ties, pct, streak + gamesPlayed Int @default(0) @map("games_played") + lastUpdated DateTime @default(now()) @map("last_updated") @db.Timestamptz(6) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + team Team @relation(fields: [teamId], references: [id]) + + @@unique([teamId, season, seasonType]) + @@index([sportKey, season]) + @@map("team_stats") +} + +// Individual game statistics (box scores) +model GameStats { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + gameId String @map("game_id") @db.Uuid + teamId Int @map("team_id") + isHome Boolean @map("is_home") + quarterScores Json? @map("quarter_scores") // [7, 14, 3, 10] or periods + stats Json @default("{}") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + game Game @relation(fields: [gameId], references: [id]) + team Team @relation(fields: [teamId], references: [id]) + + @@unique([gameId, teamId]) + @@index([gameId]) + @@map("game_stats") +} + +// Player game statistics +model PlayerGameStats { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + gameId String @map("game_id") @db.Uuid + playerId Int @map("player_id") + teamId Int @map("team_id") + stats Json @default("{}") + started Boolean @default(false) + minutesPlayed String? @map("minutes_played") @db.VarChar(10) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + game Game @relation(fields: [gameId], references: [id]) + player Player @relation(fields: [playerId], references: [id]) + team Team @relation(fields: [teamId], references: [id]) + + @@unique([gameId, playerId]) + @@index([gameId]) + @@index([playerId]) + @@map("player_game_stats") +} + // Games/Events table model Game { - id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid - externalId String @unique @map("external_id") @db.VarChar(100) - sportId Int @map("sport_id") - homeTeamId Int? @map("home_team_id") - awayTeamId Int? @map("away_team_id") - homeTeamName String @map("home_team_name") @db.VarChar(100) - awayTeamName String @map("away_team_name") @db.VarChar(100) - commenceTime DateTime @map("commence_time") @db.Timestamptz(6) - venue String? @db.VarChar(200) - weather String? @db.VarChar(100) - status String @default("scheduled") @db.VarChar(20) - homeScore Int? @map("home_score") - awayScore Int? @map("away_score") - period String? @db.VarChar(20) - clock String? @db.VarChar(20) - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) - sport Sport @relation(fields: [sportId], references: [id]) - homeTeam Team? @relation("HomeTeam", fields: [homeTeamId], references: [id]) - awayTeam Team? @relation("AwayTeam", fields: [awayTeamId], references: [id]) - currentOdds CurrentOdds[] - oddsSnapshots OddsSnapshot[] - betLegs BetLeg[] + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + externalId String @unique @map("external_id") @db.VarChar(100) + sportId Int @map("sport_id") + homeTeamId Int? @map("home_team_id") + awayTeamId Int? @map("away_team_id") + homeTeamName String @map("home_team_name") @db.VarChar(100) + awayTeamName String @map("away_team_name") @db.VarChar(100) + commenceTime DateTime @map("commence_time") @db.Timestamptz(6) + apiSportsGameId String? @map("api_sports_game_id") @db.VarChar(50) + apiSportsLeagueId Int? @map("api_sports_league_id") + season String? @db.VarChar(20) + seasonType String? @map("season_type") @db.VarChar(20) + venue String? @db.VarChar(200) + weather String? @db.VarChar(100) + status String @default("scheduled") @db.VarChar(20) + homeScore Int? @map("home_score") + awayScore Int? @map("away_score") + period String? @db.VarChar(20) + clock String? @db.VarChar(20) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + sport Sport @relation(fields: [sportId], references: [id]) + homeTeam Team? @relation("HomeTeam", fields: [homeTeamId], references: [id]) + awayTeam Team? @relation("AwayTeam", fields: [awayTeamId], references: [id]) + currentOdds CurrentOdds[] + oddsSnapshots OddsSnapshot[] + betLegs BetLeg[] + gameStats GameStats[] + playerStats PlayerGameStats[] @@index([commenceTime]) @@index([status]) + @@index([apiSportsGameId]) + @@index([season]) @@map("games") } // Futures (outrights) - championship/tournament winners model Future { - id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid - externalId String @unique @map("external_id") @db.VarChar(100) - sportId Int @map("sport_id") - title String @db.VarChar(200) // e.g., "NFL Super Bowl Winner 2026" - description String? @db.Text - season String? @db.VarChar(20) - status String @default("active") @db.VarChar(20) // active, suspended, settled - settlementDate DateTime? @map("settlement_date") @db.Timestamptz(6) - winner String? @db.VarChar(100) // Winning outcome after settlement - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) - sport Sport @relation(fields: [sportId], references: [id]) - outcomes FutureOutcome[] - currentOdds CurrentFutureOdds[] - oddsSnapshots FutureOddsSnapshot[] - betLegs BetLegFuture[] + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + externalId String @unique @map("external_id") @db.VarChar(100) + sportId Int @map("sport_id") + title String @db.VarChar(200) // e.g., "NFL Super Bowl Winner 2026" + description String? @db.Text + season String? @db.VarChar(20) + status String @default("active") @db.VarChar(20) // active, suspended, settled + settlementDate DateTime? @map("settlement_date") @db.Timestamptz(6) + winner String? @db.VarChar(100) // Winning outcome after settlement + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + sport Sport @relation(fields: [sportId], references: [id]) + outcomes FutureOutcome[] + currentOdds CurrentFutureOdds[] + oddsSnapshots FutureOddsSnapshot[] + betLegs BetLegFuture[] @@index([sportId]) @@index([status]) @@ -95,12 +188,12 @@ model Future { // Possible outcomes for a future (teams, players, etc.) model FutureOutcome { - id Int @id @default(autoincrement()) - futureId String @map("future_id") @db.Uuid - outcome String @db.VarChar(200) // Team name, player name, etc. - description String? @db.VarChar(500) - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) - future Future @relation(fields: [futureId], references: [id], onDelete: Cascade) + id Int @id @default(autoincrement()) + futureId String @map("future_id") @db.Uuid + outcome String @db.VarChar(200) // Team name, player name, etc. + description String? @db.VarChar(500) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + future Future @relation(fields: [futureId], references: [id], onDelete: Cascade) @@unique([futureId, outcome]) @@map("future_outcomes") @@ -180,20 +273,20 @@ model OddsSnapshot { // Users table (for OAuth2 authentication) model User { - id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid - email String @unique @db.VarChar(255) - name String? @db.VarChar(255) - avatarUrl String? @map("avatar_url") @db.VarChar(500) - provider String @db.VarChar(50) // 'microsoft', 'google', 'github' - providerId String @map("provider_id") @db.VarChar(255) // External ID from OAuth provider - isAdmin Boolean @default(false) @map("is_admin") - isActive Boolean @default(true) @map("is_active") - lastLoginAt DateTime? @map("last_login_at") @db.Timestamptz(6) - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) - bets Bet[] - apiKeys ApiKey[] - + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + email String @unique @db.VarChar(255) + name String? @db.VarChar(255) + avatarUrl String? @map("avatar_url") @db.VarChar(500) + provider String @db.VarChar(50) // 'microsoft', 'google', 'github' + providerId String @map("provider_id") @db.VarChar(255) // External ID from OAuth provider + isAdmin Boolean @default(false) @map("is_admin") + isActive Boolean @default(true) @map("is_active") + lastLoginAt DateTime? @map("last_login_at") @db.Timestamptz(6) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + bets Bet[] + apiKeys ApiKey[] + @@unique([provider, providerId]) @@index([email]) @@map("users") @@ -201,22 +294,22 @@ model User { // Bets table model Bet { - id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid - userId String? @map("user_id") @db.Uuid // Nullable for backward compatibility (standalone mode) - name String @db.VarChar(100) - betType String @map("bet_type") @db.VarChar(20) - stake Decimal @db.Decimal(10, 2) - potentialPayout Decimal? @map("potential_payout") @db.Decimal(10, 2) - actualPayout Decimal? @map("actual_payout") @db.Decimal(10, 2) - status String @default("pending") @db.VarChar(20) - oddsAtPlacement Int? @map("odds_at_placement") - teaserPoints Decimal? @map("teaser_points") @db.Decimal(3, 1) - notes String? @db.Text - placedAt DateTime @default(now()) @map("placed_at") @db.Timestamptz(6) - settledAt DateTime? @map("settled_at") @db.Timestamptz(6) - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) - user User? @relation(fields: [userId], references: [id]) + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + userId String? @map("user_id") @db.Uuid // Nullable for backward compatibility (standalone mode) + name String @db.VarChar(100) + betType String @map("bet_type") @db.VarChar(20) + stake Decimal @db.Decimal(10, 2) + potentialPayout Decimal? @map("potential_payout") @db.Decimal(10, 2) + actualPayout Decimal? @map("actual_payout") @db.Decimal(10, 2) + status String @default("pending") @db.VarChar(20) + oddsAtPlacement Int? @map("odds_at_placement") + teaserPoints Decimal? @map("teaser_points") @db.Decimal(3, 1) + notes String? @db.Text + placedAt DateTime @default(now()) @map("placed_at") @db.Timestamptz(6) + settledAt DateTime? @map("settled_at") @db.Timestamptz(6) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + user User? @relation(fields: [userId], references: [id]) legs BetLeg[] futureLegs BetLegFuture[] @@ -228,23 +321,23 @@ model Bet { // Bet legs (individual selections) model BetLeg { - id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid - betId String @map("bet_id") @db.Uuid - gameId String @map("game_id") @db.Uuid - selectionType String @map("selection_type") @db.VarChar(20) - selection String @db.VarChar(50) - teamName String? @map("team_name") @db.VarChar(100) - line Decimal? @db.Decimal(5, 1) - odds Int - sgpGroupId String? @map("sgp_group_id") @db.VarChar(50) // Groups legs that are part of same-game parlay - userAdjustedLine Decimal? @map("user_adjusted_line") @db.Decimal(5, 1) - userAdjustedOdds Int? @map("user_adjusted_odds") - teaserAdjustedLine Decimal? @map("teaser_adjusted_line") @db.Decimal(5, 1) - status String @default("pending") @db.VarChar(20) - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) - bet Bet @relation(fields: [betId], references: [id], onDelete: Cascade) - game Game @relation(fields: [gameId], references: [id]) + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + betId String @map("bet_id") @db.Uuid + gameId String @map("game_id") @db.Uuid + selectionType String @map("selection_type") @db.VarChar(20) + selection String @db.VarChar(50) + teamName String? @map("team_name") @db.VarChar(100) + line Decimal? @db.Decimal(5, 1) + odds Int + sgpGroupId String? @map("sgp_group_id") @db.VarChar(50) // Groups legs that are part of same-game parlay + userAdjustedLine Decimal? @map("user_adjusted_line") @db.Decimal(5, 1) + userAdjustedOdds Int? @map("user_adjusted_odds") + teaserAdjustedLine Decimal? @map("teaser_adjusted_line") @db.Decimal(5, 1) + status String @default("pending") @db.VarChar(20) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + bet Bet @relation(fields: [betId], references: [id], onDelete: Cascade) + game Game @relation(fields: [gameId], references: [id]) @@index([gameId]) @@map("bet_legs") @@ -307,12 +400,12 @@ model ApiKeyUsage { // Site configuration for branding model SiteConfig { - id Int @id @default(1) - siteName String @default("Sports Betting") @map("site_name") @db.VarChar(100) - logoUrl String? @map("logo_url") @db.VarChar(500) - domainUrl String? @map("domain_url") @db.VarChar(255) - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + id Int @id @default(1) + siteName String @default("Sports Betting") @map("site_name") @db.VarChar(100) + logoUrl String? @map("logo_url") @db.VarChar(500) + domainUrl String? @map("domain_url") @db.VarChar(255) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) @@map("site_config") } diff --git a/dashboard/backend/src/config/env.ts b/dashboard/backend/src/config/env.ts index c5d28fc..482cda0 100644 --- a/dashboard/backend/src/config/env.ts +++ b/dashboard/backend/src/config/env.ts @@ -14,6 +14,13 @@ const envSchema = z.object({ OUTCOME_CHECK_INTERVAL: z.string().default('5'), LOG_LEVEL: z.string().default('info'), + // API-Sports configuration + API_SPORTS_KEY: z.string().optional(), + API_SPORTS_TIER: z.enum(['free', 'pro', 'ultra', 'mega']).default('pro'), + STATS_POLL_INTERVAL_MS: z.string().default('15000'), + ENABLE_WEBSOCKETS: z.string().default('false'), + REDIS_URL: z.string().optional(), + // Auth configuration AUTH_MODE: z.enum(['none', 'oauth2']).default('none'), BASE_URL: z.string().default('http://localhost:3001'), diff --git a/dashboard/backend/src/jobs/stats-sync.job.ts b/dashboard/backend/src/jobs/stats-sync.job.ts new file mode 100644 index 0000000..562f26d --- /dev/null +++ b/dashboard/backend/src/jobs/stats-sync.job.ts @@ -0,0 +1,95 @@ +import cron from 'node-cron'; +import { StatsSyncService } from '../services/stats-sync.service'; +import { logger } from '../config/logger'; +import { env } from '../config/env'; + +const statsSyncService = new StatsSyncService(); + +let isRunning = false; +let lastRunTime: Date | null = null; +let lastResult: any = null; + +async function syncLiveStats() { + if (isRunning) { + logger.debug('Stats sync already running, skipping'); + return; + } + + isRunning = true; + lastRunTime = new Date(); + + try { + logger.info('Starting live stats sync...'); + const result = await statsSyncService.syncAllLiveStats(); + + lastResult = result; + + if (result.errors.length > 0) { + logger.warn(`Stats sync completed with errors: ${result.errors.length} errors`); + } else { + logger.info(`Stats sync successful: ${result.gamesUpdated} games updated`); + } + } catch (error) { + logger.error('Stats sync job failed:', error); + } finally { + isRunning = false; + } +} + +export function startStatsSyncJob() { + // Only start if API_SPORTS_KEY is configured + if (!env.API_SPORTS_KEY) { + logger.warn('API_SPORTS_KEY not configured, stats sync job disabled'); + return null; + } + + const pollInterval = parseInt(env.STATS_POLL_INTERVAL_MS || '15000', 10); + const pollSeconds = Math.floor(pollInterval / 1000); + + // For polling intervals less than 60 seconds, use seconds-based cron + if (pollSeconds < 60) { + const cronExpression = `*/${pollSeconds} * * * * *`; + + const task = cron.schedule(cronExpression, async () => { + const hour = new Date().getHours(); + // Only run frequent polling during typical game hours (10 AM - 2 AM ET) + if (hour >= 10 || hour < 2) { + await syncLiveStats(); + } + }, { + scheduled: true, + timezone: 'America/New_York', + }); + + logger.info(`Stats sync job started (every ${pollSeconds}s during game hours)`); + + // Run immediately on startup + syncLiveStats(); + + return task; + } else { + // For longer intervals, use minute-based cron + const pollMinutes = Math.floor(pollSeconds / 60); + const cronExpression = `*/${pollMinutes} * * * *`; + + const task = cron.schedule(cronExpression, syncLiveStats, { + scheduled: true, + timezone: 'America/New_York', + }); + + logger.info(`Stats sync job started (every ${pollMinutes} minutes)`); + + // Run immediately on startup + syncLiveStats(); + + return task; + } +} + +export function getStatsSyncStatus() { + return { + isRunning, + lastRunTime, + lastResult, + }; +} diff --git a/dashboard/backend/src/routes/admin.routes.ts b/dashboard/backend/src/routes/admin.routes.ts index d26534a..28502e3 100644 --- a/dashboard/backend/src/routes/admin.routes.ts +++ b/dashboard/backend/src/routes/admin.routes.ts @@ -156,6 +156,98 @@ router.post('/sync-futures', async (req: Request, res: Response) => { } }); +/** + * GET /api/admin/sports + * Get all sports with their active status + */ +router.get('/sports', async (_req: Request, res: Response) => { + try { + const sports = await prisma.sport.findMany({ + orderBy: [ + { groupName: 'asc' }, + { name: 'asc' } + ], + select: { + id: true, + key: true, + name: true, + groupName: true, + isActive: true, + _count: { + select: { + games: true, + teams: true + } + } + } + }); + + res.json({ + status: 'success', + data: sports + }); + } catch (error: any) { + logger.error('Failed to get sports:', error); + res.status(500).json({ + status: 'error', + message: 'Failed to get sports', + error: error.message + }); + } +}); + +/** + * PATCH /api/admin/sports/:sportKey + * Update sport active status + */ +router.patch('/sports/:sportKey', async (req: Request, res: Response) => { + try { + const { sportKey } = req.params; + const { isActive } = req.body; + + if (typeof isActive !== 'boolean') { + return res.status(400).json({ + status: 'error', + message: 'isActive must be a boolean value' + }); + } + + const sport = await prisma.sport.update({ + where: { key: sportKey }, + data: { isActive }, + select: { + id: true, + key: true, + name: true, + groupName: true, + isActive: true + } + }); + + logger.info(`Sport ${sport.name} (${sport.key}) ${isActive ? 'activated' : 'deactivated'}`); + + res.json({ + status: 'success', + message: `Sport ${sport.name} ${isActive ? 'activated' : 'deactivated'} successfully`, + data: sport + }); + } catch (error: any) { + if (error.code === 'P2025') { + return res.status(404).json({ + status: 'error', + message: 'Sport not found' + }); + } + + logger.error('Failed to update sport:', error); + res.status(500).json({ + status: 'error', + message: 'Failed to update sport', + error: error.message + }); + } +}); + /** * GET /api/admin/stats * Get database statistics diff --git a/dashboard/backend/src/routes/games.routes.ts b/dashboard/backend/src/routes/games.routes.ts index 5619f29..d7f02e9 100644 --- a/dashboard/backend/src/routes/games.routes.ts +++ b/dashboard/backend/src/routes/games.routes.ts @@ -92,6 +92,46 @@ router.get('/', async (req: Request, res: Response) => { const spreadOdds = game.currentOdds.find(o => o.marketType === 'spreads'); const totalOdds = game.currentOdds.find(o => o.marketType === 'totals'); + // Group odds by bookmaker for frontend card format + const bookmakerOddsMap = new Map(); + game.currentOdds.forEach(odd => { + if (!bookmakerOddsMap.has(odd.bookmaker)) { + bookmakerOddsMap.set(odd.bookmaker, { + key: odd.bookmaker, + title: odd.bookmaker.charAt(0).toUpperCase() + odd.bookmaker.slice(1), + markets: [] + }); + } + + const bookmaker = bookmakerOddsMap.get(odd.bookmaker); + + if (odd.marketType === 'h2h' && odd.homePrice && odd.awayPrice) { + bookmaker.markets.push({ + key: 'h2h', + outcomes: [ + { name: game.awayTeamName, price: odd.awayPrice }, + { name: game.homeTeamName, price: odd.homePrice } + ] + }); + } else if (odd.marketType === 'spreads' && odd.homeSpread && odd.awaySpread) { + bookmaker.markets.push({ + key: 'spreads', + outcomes: [ + { name: game.awayTeamName, price: odd.awaySpreadPrice || 0, point: Number(odd.awaySpread) }, + { name: game.homeTeamName, price: odd.homeSpreadPrice || 0, point: Number(odd.homeSpread) } + ] + }); + } else if (odd.marketType === 'totals' && odd.totalLine) { + bookmaker.markets.push({ + key: 'totals', + outcomes: [ + { name: 'Over', price: odd.overPrice || 0, point: Number(odd.totalLine) }, + { name: 'Under', price: odd.underPrice || 0, point: Number(odd.totalLine) } + ] + }); + } + }); + return { id: game.id, externalId: game.externalId, @@ -105,7 +145,11 @@ router.get('/', async (req: Request, res: Response) => { status: game.status, homeScore: game.homeScore, awayScore: game.awayScore, - // Format odds for frontend + period: game.period, + clock: game.clock, + // Bookmakers array for frontend card + bookmakers: Array.from(bookmakerOddsMap.values()), + // Legacy format for compatibility homeOdds: { moneyline: h2hOdds?.homePrice || undefined, spread: spreadOdds ? { diff --git a/dashboard/backend/src/routes/index.ts b/dashboard/backend/src/routes/index.ts index f11849b..e710f3a 100644 --- a/dashboard/backend/src/routes/index.ts +++ b/dashboard/backend/src/routes/index.ts @@ -6,6 +6,7 @@ import mcpRoutes from './mcp.routes'; import adminRoutes from './admin.routes'; import apiKeysRoutes from './api-keys.routes'; import aiBetsRoutes from './ai-bets.routes'; +import statsRoutes from './stats.routes'; const router = Router(); @@ -17,5 +18,6 @@ router.use('/mcp', mcpRoutes); router.use('/admin', adminRoutes); router.use('/keys', apiKeysRoutes); router.use('/ai/bets', aiBetsRoutes); +router.use('/stats', statsRoutes); export default router; diff --git a/dashboard/backend/src/routes/stats.routes.ts b/dashboard/backend/src/routes/stats.routes.ts new file mode 100644 index 0000000..598ec21 --- /dev/null +++ b/dashboard/backend/src/routes/stats.routes.ts @@ -0,0 +1,336 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import { PrismaClient } from '@prisma/client'; +import { logger } from '../config/logger'; + +const router = Router(); +const prisma = new PrismaClient(); + +// GET /api/stats/game/:gameId +// Enhanced with historical team averages +router.get('/game/:gameId', async (req: Request, res: Response, next: NextFunction) => { + try { + const { gameId } = req.params; + + // Fetch game info first + const game = await prisma.game.findUnique({ + where: { id: gameId }, + include: { + sport: true, + }, + }); + + if (!game) { + return res.status(404).json({ + success: false, + error: 'Game not found', + }); + } + + // Fetch team stats for the game + const gameStats = await prisma.gameStats.findMany({ + where: { gameId }, + include: { + team: true, + }, + }); + + // Fetch player stats for the game + const playerStats = await prisma.playerGameStats.findMany({ + where: { gameId }, + include: { + player: true, + team: { + select: { + id: true, + name: true, + abbreviation: true, + logoUrl: true, + }, + }, + }, + orderBy: [ + { teamId: 'asc' }, + { started: 'desc' }, + ], + }); + + // Fetch season averages for both teams + const seasonAverages = await Promise.all( + gameStats.map(async (stat) => { + // Get all games this season for this team + const teamGames = await prisma.gameStats.findMany({ + where: { + teamId: stat.teamId, + game: { + sport: { + key: game.sport.key, + }, + commenceTime: { + gte: new Date(new Date().getFullYear(), 0, 1), // Start of current year + }, + }, + }, + include: { + game: true, + }, + }); + + // Calculate averages + const totalGames = teamGames.length; + if (totalGames === 0) return null; + + const avgStats: any = {}; + + // Aggregate stats based on sport type + if (game.sport.key.includes('basketball')) { + const totals = teamGames.reduce((acc, g: any) => ({ + points: acc.points + (g.stats.points || 0), + rebounds: acc.rebounds + (g.stats.rebounds || 0), + assists: acc.assists + (g.stats.assists || 0), + }), { points: 0, rebounds: 0, assists: 0 }); + + avgStats.points = (totals.points / totalGames).toFixed(1); + avgStats.rebounds = (totals.rebounds / totalGames).toFixed(1); + avgStats.assists = (totals.assists / totalGames).toFixed(1); + } else if (game.sport.key.includes('football')) { + const totals = teamGames.reduce((acc, g: any) => ({ + yards: acc.yards + (g.stats.yards?.total || 0), + touchdowns: acc.touchdowns + (g.stats.touchdowns?.total || 0), + }), { yards: 0, touchdowns: 0 }); + + avgStats.yards = (totals.yards / totalGames).toFixed(1); + avgStats.touchdowns = (totals.touchdowns / totalGames).toFixed(1); + } + + return { + teamId: stat.teamId, + totalGames, + homeGames: teamGames.filter(g => g.isHome).length, + awayGames: teamGames.filter(g => !g.isHome).length, + avgStats, + }; + }) + ); + + res.json({ + success: true, + data: { + teamStats: gameStats, + playerStats, + seasonAverages: seasonAverages.filter(Boolean), + }, + }); + } catch (error) { + logger.error('Error fetching game stats:', error); + next(error); + } +}); + +// GET /api/stats/team/:teamId +// Enhanced with home/away filtering +router.get('/team/:teamId', async (req: Request, res: Response, next: NextFunction) => { + try { + const teamId = parseInt(req.params.teamId); + const { season, location } = req.query; // location: 'home', 'away', or 'all' + + if (isNaN(teamId)) { + return res.status(400).json({ + success: false, + error: 'Invalid team ID', + }); + } + + // Build filter + const where: any = { + teamId, + }; + + // Filter by home/away + if (location === 'home') { + where.isHome = true; + } else if (location === 'away') { + where.isHome = false; + } + + // Filter by season if provided + if (season) { + where.game = { + commenceTime: { + gte: new Date(parseInt(season as string), 0, 1), + lt: new Date(parseInt(season as string) + 1, 0, 1), + }, + }; + } + + // Fetch team season stats + const teamStats = await prisma.teamStats.findFirst({ + where: { + teamId, + season: season ? parseInt(season as string) : new Date().getFullYear(), + }, + include: { + team: true, + }, + }); + + // Fetch game history with filter + const gameHistory = await prisma.gameStats.findMany({ + where, + include: { + game: { + select: { + id: true, + homeTeamName: true, + awayTeamName: true, + commenceTime: true, + status: true, + homeScore: true, + awayScore: true, + }, + }, + }, + orderBy: { + game: { commenceTime: 'desc' }, + }, + take: 20, + }); + + // Calculate split stats (home vs away) + const homeGames = await prisma.gameStats.findMany({ + where: { + teamId, + isHome: true, + game: season ? { + commenceTime: { + gte: new Date(parseInt(season as string), 0, 1), + lt: new Date(parseInt(season as string) + 1, 0, 1), + }, + } : undefined, + }, + }); + + const awayGames = await prisma.gameStats.findMany({ + where: { + teamId, + isHome: false, + game: season ? { + commenceTime: { + gte: new Date(parseInt(season as string), 0, 1), + lt: new Date(parseInt(season as string) + 1, 0, 1), + }, + } : undefined, + }, + }); + + // Calculate averages + const calculateAvgStats = (games: any[]) => { + if (games.length === 0) return null; + + const totals = games.reduce((acc, game) => { + const stats = game.stats as any; + Object.keys(stats).forEach(key => { + if (typeof stats[key] === 'number') { + acc[key] = (acc[key] || 0) + stats[key]; + } + }); + return acc; + }, {} as any); + + const averages: any = {}; + Object.keys(totals).forEach(key => { + averages[key] = (totals[key] / games.length).toFixed(1); + }); + + return averages; + }; + + res.json({ + success: true, + data: { + seasonStats: teamStats, + gameHistory, + splits: { + home: { + games: homeGames.length, + averages: calculateAvgStats(homeGames), + }, + away: { + games: awayGames.length, + averages: calculateAvgStats(awayGames), + }, + overall: { + games: homeGames.length + awayGames.length, + averages: calculateAvgStats([...homeGames, ...awayGames]), + }, + }, + }, + }); + } catch (error) { + logger.error('Error fetching team stats:', error); + next(error); + } +}); + +// GET /api/stats/player/:playerId +router.get('/player/:playerId', async (req: Request, res: Response, next: NextFunction) => { + try { + const playerId = parseInt(req.params.playerId); + + if (isNaN(playerId)) { + return res.status(400).json({ + success: false, + error: 'Invalid player ID', + }); + } + + // Fetch player details + const player = await prisma.player.findUnique({ + where: { id: playerId }, + include: { + team: true, + }, + }); + + if (!player) { + return res.status(404).json({ + success: false, + error: 'Player not found', + }); + } + + // Fetch player game log + const gameStats = await prisma.playerGameStats.findMany({ + where: { playerId }, + include: { + game: { + select: { + id: true, + homeTeamName: true, + awayTeamName: true, + commenceTime: true, + status: true, + homeScore: true, + awayScore: true, + }, + }, + }, + orderBy: { + game: { commenceTime: 'desc' }, + }, + take: 20, + }); + + res.json({ + success: true, + data: { + player, + gameLog: gameStats, + }, + }); + } catch (error) { + logger.error('Error fetching player stats:', error); + next(error); + } +}); + +export default router; diff --git a/dashboard/backend/src/server.ts b/dashboard/backend/src/server.ts index b091599..64ca56f 100644 --- a/dashboard/backend/src/server.ts +++ b/dashboard/backend/src/server.ts @@ -4,6 +4,7 @@ import { logger } from './config/logger'; import { prisma } from './config/database'; import { startOddsSyncJob } from './jobs/sync-odds.job'; import { startSettleBetsJob } from './jobs/settle-bets.job'; +import { startStatsSyncJob } from './jobs/stats-sync.job'; const PORT = parseInt(env.PORT, 10); @@ -18,6 +19,7 @@ const server = app.listen(PORT, () => { try { startOddsSyncJob(); startSettleBetsJob(); + startStatsSyncJob(); logger.info('✅ Scheduled jobs started'); } catch (error) { logger.error('Failed to start scheduled jobs:', error); diff --git a/dashboard/backend/src/services/api-sports/client.ts b/dashboard/backend/src/services/api-sports/client.ts new file mode 100644 index 0000000..0beada2 --- /dev/null +++ b/dashboard/backend/src/services/api-sports/client.ts @@ -0,0 +1,80 @@ +import axios, { AxiosInstance, AxiosError } from 'axios'; +import * as limiter from 'limiter'; +import { logger } from '../../config/logger'; + +const { RateLimiter } = limiter; + +interface ApiSportsConfig { + apiKey: string; + sport: 'american-football' | 'basketball' | 'hockey'; +} + +const BASE_URLS = { + 'american-football': 'https://v1.american-football.api-sports.io', + 'basketball': 'https://v1.basketball.api-sports.io', + 'hockey': 'https://v1.hockey.api-sports.io', +}; + +export class ApiSportsClient { + private client: AxiosInstance; + private limiter: RateLimiter; + private sport: string; + + constructor(config: ApiSportsConfig) { + this.sport = config.sport; + this.client = axios.create({ + baseURL: BASE_URLS[config.sport], + headers: { + 'x-apisports-key': config.apiKey, + }, + timeout: 10000, + }); + + // Pro tier: 300 requests/minute = 5 requests/second + this.limiter = new RateLimiter({ + tokensPerInterval: 5, + interval: 'second' as any, + }); + + logger.info(`ApiSportsClient initialized for ${config.sport}`); + } + + async get(endpoint: string, params?: Record): Promise { + // Wait for rate limiter token + await this.limiter.removeTokens(1); + + try { + const response = await this.client.get(endpoint, { params }); + + // Log rate limit info if available + const remaining = response.headers['x-ratelimit-requests-remaining']; + if (remaining) { + logger.debug(`API-Sports rate limit remaining: ${remaining}`); + } + + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + + // Handle rate limiting + if (axiosError.response?.status === 429) { + logger.warn(`Rate limited by API-Sports, waiting 60s`); + await new Promise(resolve => setTimeout(resolve, 60000)); + return this.get(endpoint, params); + } + + // Handle other errors + logger.error(`API-Sports request failed: ${axiosError.message}`, { + endpoint, + status: axiosError.response?.status, + data: axiosError.response?.data, + }); + + throw new Error(`API-Sports ${this.sport} request failed: ${axiosError.message}`); + } + + throw error; + } + } +} diff --git a/dashboard/backend/src/services/api-sports/nba.service.ts b/dashboard/backend/src/services/api-sports/nba.service.ts new file mode 100644 index 0000000..656a302 --- /dev/null +++ b/dashboard/backend/src/services/api-sports/nba.service.ts @@ -0,0 +1,318 @@ +import { ApiSportsClient } from './client'; +import { PrismaClient } from '@prisma/client'; +import { logger } from '../../config/logger'; +import { env } from '../../config/env'; + +const prisma = new PrismaClient(); + +interface NBAGame { + id: number; + league: string; + season: string; + date: { + start: string; + end: string | null; + }; + stage: string; + status: { + long: string; + short: string; + }; + teams: { + home: { + id: number; + name: string; + logo: string; + }; + away: { + id: number; + name: string; + logo: string; + }; + }; + scores: { + home: { + quarter_1: number | null; + quarter_2: number | null; + quarter_3: number | null; + quarter_4: number | null; + over_time: number | null; + total: number | null; + }; + away: { + quarter_1: number | null; + quarter_2: number | null; + quarter_3: number | null; + quarter_4: number | null; + over_time: number | null; + total: number | null; + }; + }; +} + +export class NBAStatsService { + private client: ApiSportsClient; + + constructor() { + if (!env.API_SPORTS_KEY) { + throw new Error('API_SPORTS_KEY is required for NBAStatsService'); + } + + this.client = new ApiSportsClient({ + apiKey: env.API_SPORTS_KEY, + sport: 'basketball', + }); + } + + async syncGameStats(apiSportsGameId: string): Promise { + try { + logger.info(`Syncing NBA game stats for API-Sports ID: ${apiSportsGameId}`); + + // Fetch game details + const gameResponse = await this.client.get<{ response: NBAGame[] }>( + '/games', + { id: apiSportsGameId } + ); + + if (!gameResponse.response?.length) { + logger.warn(`No game found for NBA game ${apiSportsGameId}`); + return; + } + + const gameData = gameResponse.response[0]; + + // Find internal game + const game = await prisma.game.findFirst({ + where: { + externalId: apiSportsGameId, + sport: { + key: 'basketball_nba', + }, + }, + }); + + if (!game) { + logger.warn(`Game not found in database for API-Sports ID: ${apiSportsGameId}`); + return; + } + + // Upsert stats for home team + const homeTeam = await this.findTeam(gameData.teams.home.name, 'basketball_nba'); + if (homeTeam) { + await prisma.gameStats.upsert({ + where: { + gameId_teamId: { + gameId: game.id, + teamId: homeTeam.id, + }, + }, + create: { + gameId: game.id, + teamId: homeTeam.id, + isHome: true, + quarterScores: [ + gameData.scores.home.quarter_1, + gameData.scores.home.quarter_2, + gameData.scores.home.quarter_3, + gameData.scores.home.quarter_4, + gameData.scores.home.over_time, + ].filter((score): score is number => score !== null), + stats: { + points: gameData.scores.home.total, + }, + }, + update: { + quarterScores: [ + gameData.scores.home.quarter_1, + gameData.scores.home.quarter_2, + gameData.scores.home.quarter_3, + gameData.scores.home.quarter_4, + gameData.scores.home.over_time, + ].filter((score): score is number => score !== null), + stats: { + points: gameData.scores.home.total, + }, + updatedAt: new Date(), + }, + }); + } + + // Upsert stats for away team + const awayTeam = await this.findTeam(gameData.teams.away.name, 'basketball_nba'); + if (awayTeam) { + await prisma.gameStats.upsert({ + where: { + gameId_teamId: { + gameId: game.id, + teamId: awayTeam.id, + }, + }, + create: { + gameId: game.id, + teamId: awayTeam.id, + isHome: false, + quarterScores: [ + gameData.scores.away.quarter_1, + gameData.scores.away.quarter_2, + gameData.scores.away.quarter_3, + gameData.scores.away.quarter_4, + gameData.scores.away.over_time, + ].filter((score): score is number => score !== null), + stats: { + points: gameData.scores.away.total, + }, + }, + update: { + quarterScores: [ + gameData.scores.away.quarter_1, + gameData.scores.away.quarter_2, + gameData.scores.away.quarter_3, + gameData.scores.away.quarter_4, + gameData.scores.away.over_time, + ].filter((score): score is number => score !== null), + stats: { + points: gameData.scores.away.total, + }, + updatedAt: new Date(), + }, + }); + } + + // Fetch player stats + await this.syncPlayerStats(apiSportsGameId, game.id); + + logger.info(`Successfully synced stats for NBA game ${apiSportsGameId}`); + } catch (error) { + logger.error(`Failed to sync NBA game stats: ${error}`); + throw error; + } + } + + async syncPlayerStats(apiSportsGameId: string, internalGameId: string): Promise { + try { + const statsResponse = await this.client.get<{ response: any[] }>( + '/games/statistics', + { id: apiSportsGameId } + ); + + if (!statsResponse.response?.length) { + return; + } + + for (const teamData of statsResponse.response) { + const team = await this.findTeam(teamData.team.name, 'basketball_nba'); + if (!team) continue; + + for (const playerData of teamData.statistics || []) { + // Find or create player + let player = await prisma.player.findFirst({ + where: { + externalId: playerData.player.id?.toString(), + }, + }); + + if (!player) { + const nameParts = playerData.player.name.split(' '); + player = await prisma.player.create({ + data: { + externalId: playerData.player.id?.toString() || null, + teamId: team.id, + firstName: nameParts[0] || '', + lastName: nameParts.slice(1).join(' ') || '', + position: playerData.pos || null, + }, + }); + } + + // Upsert player game stats + await prisma.playerGameStats.upsert({ + where: { + gameId_playerId: { + gameId: internalGameId, + playerId: player.id, + }, + }, + create: { + gameId: internalGameId, + playerId: player.id, + teamId: team.id, + stats: { + points: playerData.points || 0, + rebounds: playerData.totReb || 0, + assists: playerData.assists || 0, + steals: playerData.steals || 0, + blocks: playerData.blocks || 0, + turnovers: playerData.turnovers || 0, + fgm: playerData.fgm || 0, + fga: playerData.fga || 0, + fgPct: playerData.fgp || 0, + ftm: playerData.ftm || 0, + fta: playerData.fta || 0, + ftPct: playerData.ftp || 0, + tpm: playerData.tpm || 0, + tpa: playerData.tpa || 0, + tpPct: playerData.tpp || 0, + }, + started: playerData.pos?.includes('G') || playerData.pos?.includes('F') || false, + minutesPlayed: playerData.min || null, + }, + update: { + stats: { + points: playerData.points || 0, + rebounds: playerData.totReb || 0, + assists: playerData.assists || 0, + steals: playerData.steals || 0, + blocks: playerData.blocks || 0, + turnovers: playerData.turnovers || 0, + fgm: playerData.fgm || 0, + fga: playerData.fga || 0, + fgPct: playerData.fgp || 0, + ftm: playerData.ftm || 0, + fta: playerData.fta || 0, + ftPct: playerData.ftp || 0, + tpm: playerData.tpm || 0, + tpa: playerData.tpa || 0, + tpPct: playerData.tpp || 0, + }, + minutesPlayed: playerData.min || null, + }, + }); + } + } + } catch (error) { + logger.error(`Failed to sync NBA player stats: ${error}`); + } + } + + async getLiveGames(): Promise { + try { + const response = await this.client.get<{ response: NBAGame[] }>( + '/games', + { + live: 'all', + league: '12', // NBA league ID + season: `${new Date().getFullYear()}-${new Date().getFullYear() + 1}`, + } + ); + + const liveGames = response.response.map(g => g.id.toString()); + logger.info(`Found ${liveGames.length} live NBA games`); + + return liveGames; + } catch (error) { + logger.error(`Failed to fetch live NBA games: ${error}`); + return []; + } + } + + private async findTeam(teamName: string, sportKey: string) { + return await prisma.team.findFirst({ + where: { + name: { contains: teamName, mode: 'insensitive' }, + sport: { + key: sportKey, + }, + }, + }); + } +} diff --git a/dashboard/backend/src/services/api-sports/ncaab.service.ts b/dashboard/backend/src/services/api-sports/ncaab.service.ts new file mode 100644 index 0000000..53a13bd --- /dev/null +++ b/dashboard/backend/src/services/api-sports/ncaab.service.ts @@ -0,0 +1,236 @@ +import { PrismaClient } from '@prisma/client'; +import { apiSportsClient } from './client'; +import { logger } from '../../config/logger'; + +const prisma = new PrismaClient(); + +/** + * NCAA Basketball (NCAAB) stats service + * League ID: 127 for NCAA Basketball + */ + +export class NCAABService { + private leagueId = 127; // NCAA Basketball league ID + + /** + * Get live NCAA Basketball games + */ + async getLiveGames(): Promise { + try { + const response = await apiSportsClient.get('/games', { + params: { + league: this.leagueId, + live: 'all' + } + }); + + return response.data?.response || []; + } catch (error) { + logger.error('Error fetching live NCAAB games:', error); + return []; + } + } + + /** + * Sync game stats for a specific NCAA Basketball game + */ + async syncGameStats(externalGameId: string): Promise { + try { + // Find the game in our database + const game = await prisma.game.findUnique({ + where: { externalId: externalGameId }, + include: { homeTeam: true, awayTeam: true } + }); + + if (!game) { + logger.warn(`Game not found: ${externalGameId}`); + return; + } + + // Fetch game statistics from API-Sports + const response = await apiSportsClient.get('/games/statistics', { + params: { + id: externalGameId + } + }); + + const statsData = response.data?.response?.[0]; + if (!statsData) { + logger.warn(`No stats data for game: ${externalGameId}`); + return; + } + + // Extract team statistics + const teams = statsData.teams || []; + + for (const teamData of teams) { + const isHome = teamData.team?.id === game.homeTeam.externalId; + const teamId = isHome ? game.homeTeamId : game.awayTeamId; + + // Extract quarter scores (NCAA Basketball has 2 halves + potential OT) + const periods = teamData.statistics?.periods || []; + const quarterScores = periods.map((p: any) => p.points || 0); + + // Calculate total score + const totalScore = quarterScores.reduce((sum: number, score: number) => sum + score, 0); + + // Prepare stats object + const stats = { + points: totalScore, + field_goals: teamData.statistics?.field_goals_made || 0, + field_goals_attempts: teamData.statistics?.field_goals_attempts || 0, + field_goal_percentage: teamData.statistics?.field_goal_percentage || 0, + three_pointers: teamData.statistics?.three_points_made || 0, + three_point_attempts: teamData.statistics?.three_points_attempts || 0, + three_point_percentage: teamData.statistics?.three_point_percentage || 0, + free_throws: teamData.statistics?.free_throws_made || 0, + free_throw_attempts: teamData.statistics?.free_throws_attempts || 0, + free_throw_percentage: teamData.statistics?.free_throw_percentage || 0, + rebounds: teamData.statistics?.rebounds || 0, + offensive_rebounds: teamData.statistics?.offensive_rebounds || 0, + defensive_rebounds: teamData.statistics?.defensive_rebounds || 0, + assists: teamData.statistics?.assists || 0, + steals: teamData.statistics?.steals || 0, + blocks: teamData.statistics?.blocks || 0, + turnovers: teamData.statistics?.turnovers || 0, + fouls: teamData.statistics?.fouls || 0 + }; + + // Upsert game stats + await prisma.gameStats.upsert({ + where: { + gameId_teamId: { + gameId: game.id, + teamId + } + }, + update: { + homeScore: isHome ? totalScore : undefined, + awayScore: !isHome ? totalScore : undefined, + quarterScores, + stats + }, + create: { + gameId: game.id, + teamId, + isHome, + homeScore: isHome ? totalScore : 0, + awayScore: !isHome ? totalScore : 0, + quarterScores, + stats + } + }); + } + + logger.info(`Synced NCAAB game stats: ${externalGameId}`); + } catch (error) { + logger.error(`Error syncing NCAAB game stats for ${externalGameId}:`, error); + } + } + + /** + * Sync player stats for a specific NCAA Basketball game + */ + async syncPlayerStats(externalGameId: string): Promise { + try { + const game = await prisma.game.findUnique({ + where: { externalId: externalGameId } + }); + + if (!game) { + logger.warn(`Game not found: ${externalGameId}`); + return; + } + + // Fetch player statistics + const response = await apiSportsClient.get('/games/players', { + params: { + id: externalGameId + } + }); + + const playersData = response.data?.response || []; + + for (const teamData of playersData) { + const teamExternalId = teamData.team?.id; + + // Find team in our database + const team = await prisma.team.findFirst({ + where: { externalId: teamExternalId } + }); + + if (!team) continue; + + const players = teamData.players || []; + + for (const playerData of players) { + // Ensure player exists in database + const player = await prisma.player.upsert({ + where: { + externalId_sport: { + externalId: playerData.player?.id?.toString(), + sport: 'basketball_ncaab' + } + }, + update: { + name: playerData.player?.name, + teamId: team.id + }, + create: { + externalId: playerData.player?.id?.toString(), + name: playerData.player?.name, + teamId: team.id, + sport: 'basketball_ncaab' + } + }); + + // Prepare player stats + const stats = { + minutes: playerData.statistics?.minutes || '0:00', + points: playerData.statistics?.points || 0, + rebounds: playerData.statistics?.rebounds || 0, + assists: playerData.statistics?.assists || 0, + field_goals_made: playerData.statistics?.field_goals_made || 0, + field_goals_attempts: playerData.statistics?.field_goals_attempts || 0, + field_goal_percentage: playerData.statistics?.field_goal_percentage || 0, + three_pointers_made: playerData.statistics?.three_points_made || 0, + three_point_attempts: playerData.statistics?.three_points_attempts || 0, + three_point_percentage: playerData.statistics?.three_point_percentage || 0, + free_throws_made: playerData.statistics?.free_throws_made || 0, + free_throw_attempts: playerData.statistics?.free_throws_attempts || 0, + free_throw_percentage: playerData.statistics?.free_throw_percentage || 0, + offensive_rebounds: playerData.statistics?.offensive_rebounds || 0, + defensive_rebounds: playerData.statistics?.defensive_rebounds || 0, + steals: playerData.statistics?.steals || 0, + blocks: playerData.statistics?.blocks || 0, + turnovers: playerData.statistics?.turnovers || 0, + fouls: playerData.statistics?.fouls || 0, + plus_minus: playerData.statistics?.plus_minus || 0 + }; + + // Upsert player game stats + await prisma.playerGameStats.upsert({ + where: { + gameId_playerId: { + gameId: game.id, + playerId: player.id + } + }, + update: { stats }, + create: { + gameId: game.id, + playerId: player.id, + stats + } + }); + } + } + + logger.info(`Synced NCAAB player stats: ${externalGameId}`); + } catch (error) { + logger.error(`Error syncing NCAAB player stats for ${externalGameId}:`, error); + } + } +} + +export const ncaabService = new NCAABService(); diff --git a/dashboard/backend/src/services/api-sports/ncaaf.service.ts b/dashboard/backend/src/services/api-sports/ncaaf.service.ts new file mode 100644 index 0000000..64a33d7 --- /dev/null +++ b/dashboard/backend/src/services/api-sports/ncaaf.service.ts @@ -0,0 +1,257 @@ +import { PrismaClient } from '@prisma/client'; +import { apiSportsClient } from './client'; +import { logger } from '../../config/logger'; + +const prisma = new PrismaClient(); + +/** + * NCAA Football (NCAAF) stats service + * League ID: 1 for NCAA Football + */ + +export class NCAAFService { + private leagueId = 1; // NCAA Football league ID + + /** + * Get live NCAA Football games + */ + async getLiveGames(): Promise { + try { + const response = await apiSportsClient.get('/games', { + params: { + league: this.leagueId, + live: 'all' + } + }); + + return response.data?.response || []; + } catch (error) { + logger.error('Error fetching live NCAAF games:', error); + return []; + } + } + + /** + * Sync game stats for a specific NCAA Football game + */ + async syncGameStats(externalGameId: string): Promise { + try { + // Find the game in our database + const game = await prisma.game.findUnique({ + where: { externalId: externalGameId }, + include: { homeTeam: true, awayTeam: true } + }); + + if (!game) { + logger.warn(`Game not found: ${externalGameId}`); + return; + } + + // Fetch game statistics from API-Sports + const response = await apiSportsClient.get('/games/statistics', { + params: { + id: externalGameId + } + }); + + const statsData = response.data?.response?.[0]; + if (!statsData) { + logger.warn(`No stats data for game: ${externalGameId}`); + return; + } + + // Extract team statistics + const teams = statsData.teams || []; + + for (const teamData of teams) { + const isHome = teamData.team?.id === game.homeTeam.externalId; + const teamId = isHome ? game.homeTeamId : game.awayTeamId; + + // Extract quarter scores (4 quarters + potential OT) + const periods = teamData.statistics?.periods || []; + const quarterScores = periods.map((p: any) => p.points || 0); + + // Calculate total score + const totalScore = quarterScores.reduce((sum: number, score: number) => sum + score, 0); + + // Prepare stats object (American football stats) + const stats = { + points: totalScore, + first_downs: teamData.statistics?.first_downs || 0, + third_down_conversions: teamData.statistics?.third_down_conversions || 0, + third_down_attempts: teamData.statistics?.third_down_attempts || 0, + fourth_down_conversions: teamData.statistics?.fourth_down_conversions || 0, + fourth_down_attempts: teamData.statistics?.fourth_down_attempts || 0, + total_yards: teamData.statistics?.total_yards || 0, + passing_yards: teamData.statistics?.passing_yards || 0, + passing_completions: teamData.statistics?.passing_completions || 0, + passing_attempts: teamData.statistics?.passing_attempts || 0, + passing_touchdowns: teamData.statistics?.passing_touchdowns || 0, + interceptions: teamData.statistics?.interceptions || 0, + rushing_yards: teamData.statistics?.rushing_yards || 0, + rushing_attempts: teamData.statistics?.rushing_attempts || 0, + rushing_touchdowns: teamData.statistics?.rushing_touchdowns || 0, + fumbles: teamData.statistics?.fumbles || 0, + fumbles_lost: teamData.statistics?.fumbles_lost || 0, + penalties: teamData.statistics?.penalties || 0, + penalty_yards: teamData.statistics?.penalty_yards || 0, + possession_time: teamData.statistics?.possession_time || '00:00', + sacks: teamData.statistics?.sacks || 0 + }; + + // Upsert game stats + await prisma.gameStats.upsert({ + where: { + gameId_teamId: { + gameId: game.id, + teamId + } + }, + update: { + homeScore: isHome ? totalScore : undefined, + awayScore: !isHome ? totalScore : undefined, + quarterScores, + stats + }, + create: { + gameId: game.id, + teamId, + isHome, + homeScore: isHome ? totalScore : 0, + awayScore: !isHome ? totalScore : 0, + quarterScores, + stats + } + }); + } + + logger.info(`Synced NCAAF game stats: ${externalGameId}`); + } catch (error) { + logger.error(`Error syncing NCAAF game stats for ${externalGameId}:`, error); + } + } + + /** + * Sync player stats for a specific NCAA Football game + */ + async syncPlayerStats(externalGameId: string): Promise { + try { + const game = await prisma.game.findUnique({ + where: { externalId: externalGameId } + }); + + if (!game) { + logger.warn(`Game not found: ${externalGameId}`); + return; + } + + // Fetch player statistics + const response = await apiSportsClient.get('/games/players', { + params: { + id: externalGameId + } + }); + + const playersData = response.data?.response || []; + + for (const teamData of playersData) { + const teamExternalId = teamData.team?.id; + + // Find team in our database + const team = await prisma.team.findFirst({ + where: { externalId: teamExternalId } + }); + + if (!team) continue; + + const players = teamData.players || []; + + for (const playerData of players) { + // Ensure player exists in database + const player = await prisma.player.upsert({ + where: { + externalId_sport: { + externalId: playerData.player?.id?.toString(), + sport: 'americanfootball_ncaaf' + } + }, + update: { + name: playerData.player?.name, + teamId: team.id + }, + create: { + externalId: playerData.player?.id?.toString(), + name: playerData.player?.name, + teamId: team.id, + sport: 'americanfootball_ncaaf' + } + }); + + // Prepare player stats (position-specific stats) + const stats: any = { + position: playerData.position || 'N/A' + }; + + // Passing stats + if (playerData.statistics?.passing) { + stats.passing_completions = playerData.statistics.passing.completions || 0; + stats.passing_attempts = playerData.statistics.passing.attempts || 0; + stats.passing_yards = playerData.statistics.passing.yards || 0; + stats.passing_touchdowns = playerData.statistics.passing.touchdowns || 0; + stats.interceptions = playerData.statistics.passing.interceptions || 0; + } + + // Rushing stats + if (playerData.statistics?.rushing) { + stats.rushing_attempts = playerData.statistics.rushing.attempts || 0; + stats.rushing_yards = playerData.statistics.rushing.yards || 0; + stats.rushing_touchdowns = playerData.statistics.rushing.touchdowns || 0; + } + + // Receiving stats + if (playerData.statistics?.receiving) { + stats.receptions = playerData.statistics.receiving.receptions || 0; + stats.receiving_yards = playerData.statistics.receiving.yards || 0; + stats.receiving_touchdowns = playerData.statistics.receiving.touchdowns || 0; + } + + // Defensive stats + if (playerData.statistics?.defense) { + stats.tackles = playerData.statistics.defense.tackles || 0; + stats.sacks = playerData.statistics.defense.sacks || 0; + stats.interceptions_defense = playerData.statistics.defense.interceptions || 0; + } + + // Kicking stats + if (playerData.statistics?.kicking) { + stats.field_goals_made = playerData.statistics.kicking.field_goals_made || 0; + stats.field_goals_attempts = playerData.statistics.kicking.field_goals_attempts || 0; + stats.extra_points_made = playerData.statistics.kicking.extra_points_made || 0; + } + + // Upsert player game stats + await prisma.playerGameStats.upsert({ + where: { + gameId_playerId: { + gameId: game.id, + playerId: player.id + } + }, + update: { stats }, + create: { + gameId: game.id, + playerId: player.id, + stats + } + }); + } + } + + logger.info(`Synced NCAAF player stats: ${externalGameId}`); + } catch (error) { + logger.error(`Error syncing NCAAF player stats for ${externalGameId}:`, error); + } + } +} + +export const ncaafService = new NCAAFService(); diff --git a/dashboard/backend/src/services/api-sports/nfl.service.ts b/dashboard/backend/src/services/api-sports/nfl.service.ts new file mode 100644 index 0000000..c81cc6c --- /dev/null +++ b/dashboard/backend/src/services/api-sports/nfl.service.ts @@ -0,0 +1,302 @@ +import { ApiSportsClient } from './client'; +import { PrismaClient } from '@prisma/client'; +import { logger } from '../../config/logger'; +import { env } from '../../config/env'; + +const prisma = new PrismaClient(); + +interface NFLGame { + game: { + id: number; + stage: string; + week: string; + date: { + timezone: string; + date: string; + time: string; + timestamp: number; + }; + venue: { + name: string; + city: string; + }; + status: { + short: string; + long: string; + timer: string | null; + }; + }; + league: { + id: number; + name: string; + season: string; + }; + teams: { + home: { + id: number; + name: string; + logo: string; + }; + away: { + id: number; + name: string; + logo: string; + }; + }; + scores: { + home: { + quarter_1: number | null; + quarter_2: number | null; + quarter_3: number | null; + quarter_4: number | null; + overtime: number | null; + total: number | null; + }; + away: { + quarter_1: number | null; + quarter_2: number | null; + quarter_3: number | null; + quarter_4: number | null; + overtime: number | null; + total: number | null; + }; + }; +} + +interface NFLGameStatistics { + game: { + id: number; + }; + teams: { + home: { id: number; name: string }; + away: { id: number; name: string }; + }; + statistics: Array<{ + team: { id: number; name: string }; + statistics: { + first_downs?: { total: number }; + yards?: { total: number; passing: number; rushing: number }; + turnovers?: { total: number }; + possession?: { total: string }; + penalties?: { total: number; yards: number }; + third_down_conversions?: { total: number; success: number }; + fourth_down_conversions?: { total: number; success: number }; + touchdowns?: { total: number }; + field_goals?: { made: number; attempts: number }; + }; + }>; +} + +interface NFLPlayerStats { + game: { id: number }; + player: { + id: number; + name: string; + image: string; + }; + team: { id: number; name: string }; + statistics: { + passing?: { completions: number; attempts: number; yards: number; touchdowns: number; interceptions: number }; + rushing?: { attempts: number; yards: number; touchdowns: number; longest: number }; + receiving?: { receptions: number; yards: number; touchdowns: number; targets: number }; + defense?: { tackles: number; sacks: number; interceptions: number; forced_fumbles: number }; + kicking?: { field_goals_made: number; field_goals_attempts: number; extra_points_made: number }; + }; +} + +export class NFLStatsService { + private client: ApiSportsClient; + + constructor() { + if (!env.API_SPORTS_KEY) { + throw new Error('API_SPORTS_KEY is required for NFLStatsService'); + } + + this.client = new ApiSportsClient({ + apiKey: env.API_SPORTS_KEY, + sport: 'american-football', + }); + } + + async syncGameStats(apiSportsGameId: string): Promise { + try { + logger.info(`Syncing NFL game stats for API-Sports ID: ${apiSportsGameId}`); + + // Fetch game statistics + const statsResponse = await this.client.get<{ response: NFLGameStatistics[] }>( + '/games/statistics', + { id: apiSportsGameId } + ); + + if (!statsResponse.response?.length) { + logger.warn(`No stats found for NFL game ${apiSportsGameId}`); + return; + } + + const gameData = statsResponse.response[0]; + + // Find internal game by mapping API-Sports ID + // Note: You'll need to store the API-Sports game ID in your Game model + const game = await prisma.game.findFirst({ + where: { + // Assuming you add a field like apiSportsId to Game model + externalId: apiSportsGameId, + sport: { + key: 'americanfootball_nfl', + }, + }, + }); + + if (!game) { + logger.warn(`Game not found in database for API-Sports ID: ${apiSportsGameId}`); + return; + } + + // Upsert stats for each team + for (const teamStats of gameData.statistics) { + const isHome = teamStats.team.id === gameData.teams.home.id; + + // Find team by external ID + const team = await prisma.team.findFirst({ + where: { + name: { contains: teamStats.team.name, mode: 'insensitive' }, + sport: { + key: 'americanfootball_nfl', + }, + }, + }); + + if (!team) { + logger.warn(`Team not found: ${teamStats.team.name}`); + continue; + } + + await prisma.gameStats.upsert({ + where: { + gameId_teamId: { + gameId: game.id, + teamId: team.id, + }, + }, + create: { + gameId: game.id, + teamId: team.id, + isHome, + quarterScores: this.extractQuarterScores(gameData, isHome), + stats: teamStats.statistics, + }, + update: { + quarterScores: this.extractQuarterScores(gameData, isHome), + stats: teamStats.statistics, + updatedAt: new Date(), + }, + }); + } + + logger.info(`Successfully synced stats for NFL game ${apiSportsGameId}`); + } catch (error) { + logger.error(`Failed to sync NFL game stats: ${error}`); + throw error; + } + } + + async getLiveGames(): Promise { + try { + const response = await this.client.get<{ response: NFLGame[] }>( + '/games', + { + live: 'all', + league: 1, // NFL league ID + season: new Date().getFullYear().toString(), + } + ); + + const liveGames = response.response.map(g => g.game.id.toString()); + logger.info(`Found ${liveGames.length} live NFL games`); + + return liveGames; + } catch (error) { + logger.error(`Failed to fetch live NFL games: ${error}`); + return []; + } + } + + async syncTeamStats(teamId: number, season: number): Promise { + try { + // Fetch team statistics for the season + const response = await this.client.get<{ response: any[] }>( + '/teams/statistics', + { id: teamId, season: season.toString() } + ); + + if (!response.response?.length) { + logger.warn(`No team stats found for NFL team ${teamId}`); + return; + } + + const teamData = response.response[0]; + + // Find internal team + const team = await prisma.team.findFirst({ + where: { + externalId: teamId.toString(), + sport: { + key: 'americanfootball_nfl', + }, + }, + }); + + if (!team) { + logger.warn(`Team not found: ${teamId}`); + return; + } + + // Upsert team season stats + await prisma.teamStats.upsert({ + where: { + teamId_season_seasonType: { + teamId: team.id, + season, + seasonType: 'regular', + }, + }, + create: { + teamId: team.id, + sportKey: 'americanfootball_nfl', + season, + seasonType: 'regular', + offense: teamData.offense || {}, + defense: teamData.defense || {}, + standings: { + wins: teamData.wins || 0, + losses: teamData.losses || 0, + ties: teamData.ties || 0, + }, + gamesPlayed: teamData.games_played || 0, + }, + update: { + offense: teamData.offense || {}, + defense: teamData.defense || {}, + standings: { + wins: teamData.wins || 0, + losses: teamData.losses || 0, + ties: teamData.ties || 0, + }, + gamesPlayed: teamData.games_played || 0, + lastUpdated: new Date(), + }, + }); + + logger.info(`Successfully synced team stats for NFL team ${teamId}`); + } catch (error) { + logger.error(`Failed to sync NFL team stats: ${error}`); + throw error; + } + } + + private extractQuarterScores(gameData: NFLGameStatistics, isHome: boolean): number[] { + // This would come from the game details, not statistics endpoint + // For now, return empty array - will be populated from game endpoint + return []; + } +} diff --git a/dashboard/backend/src/services/api-sports/nhl.service.ts b/dashboard/backend/src/services/api-sports/nhl.service.ts new file mode 100644 index 0000000..eca01dd --- /dev/null +++ b/dashboard/backend/src/services/api-sports/nhl.service.ts @@ -0,0 +1,204 @@ +import { ApiSportsClient } from './client'; +import { PrismaClient } from '@prisma/client'; +import { logger } from '../../config/logger'; +import { env } from '../../config/env'; + +const prisma = new PrismaClient(); + +interface NHLGame { + id: number; + league: string; + season: string; + date: { + start: string; + end: string | null; + }; + status: { + long: string; + short: string; + }; + teams: { + home: { + id: number; + name: string; + logo: string; + }; + away: { + id: number; + name: string; + logo: string; + }; + }; + scores: { + home: number | null; + away: number | null; + }; + periods: { + first: string | null; + second: string | null; + third: string | null; + overtime: string | null; + penalties: string | null; + }; +} + +export class NHLStatsService { + private client: ApiSportsClient; + + constructor() { + if (!env.API_SPORTS_KEY) { + throw new Error('API_SPORTS_KEY is required for NHLStatsService'); + } + + this.client = new ApiSportsClient({ + apiKey: env.API_SPORTS_KEY, + sport: 'hockey', + }); + } + + async syncGameStats(apiSportsGameId: string): Promise { + try { + logger.info(`Syncing NHL game stats for API-Sports ID: ${apiSportsGameId}`); + + const gameResponse = await this.client.get<{ response: NHLGame[] }>( + '/games', + { id: apiSportsGameId } + ); + + if (!gameResponse.response?.length) { + logger.warn(`No game found for NHL game ${apiSportsGameId}`); + return; + } + + const gameData = gameResponse.response[0]; + + const game = await prisma.game.findFirst({ + where: { + externalId: apiSportsGameId, + sport: { + key: 'icehockey_nhl', + }, + }, + }); + + if (!game) { + logger.warn(`Game not found in database for API-Sports ID: ${apiSportsGameId}`); + return; + } + + // Parse period scores + const parsePeriodScore = (score: string | null): number => { + if (!score) return 0; + const match = score.match(/\d+/); + return match ? parseInt(match[0]) : 0; + }; + + // Upsert stats for home team + const homeTeam = await this.findTeam(gameData.teams.home.name, 'icehockey_nhl'); + if (homeTeam) { + const periodScores = [ + parsePeriodScore(gameData.periods.first?.split('-')[0]), + parsePeriodScore(gameData.periods.second?.split('-')[0]), + parsePeriodScore(gameData.periods.third?.split('-')[0]), + ]; + + await prisma.gameStats.upsert({ + where: { + gameId_teamId: { + gameId: game.id, + teamId: homeTeam.id, + }, + }, + create: { + gameId: game.id, + teamId: homeTeam.id, + isHome: true, + quarterScores: periodScores, + stats: { + goals: gameData.scores.home, + }, + }, + update: { + quarterScores: periodScores, + stats: { + goals: gameData.scores.home, + }, + updatedAt: new Date(), + }, + }); + } + + // Upsert stats for away team + const awayTeam = await this.findTeam(gameData.teams.away.name, 'icehockey_nhl'); + if (awayTeam) { + const periodScores = [ + parsePeriodScore(gameData.periods.first?.split('-')[1]), + parsePeriodScore(gameData.periods.second?.split('-')[1]), + parsePeriodScore(gameData.periods.third?.split('-')[1]), + ]; + + await prisma.gameStats.upsert({ + where: { + gameId_teamId: { + gameId: game.id, + teamId: awayTeam.id, + }, + }, + create: { + gameId: game.id, + teamId: awayTeam.id, + isHome: false, + quarterScores: periodScores, + stats: { + goals: gameData.scores.away, + }, + }, + update: { + quarterScores: periodScores, + stats: { + goals: gameData.scores.away, + }, + updatedAt: new Date(), + }, + }); + } + + logger.info(`Successfully synced stats for NHL game ${apiSportsGameId}`); + } catch (error) { + logger.error(`Failed to sync NHL game stats: ${error}`); + throw error; + } + } + + async getLiveGames(): Promise { + try { + const response = await this.client.get<{ response: NHLGame[] }>( + '/games', + { + live: 'all', + league: '57', // NHL league ID + season: new Date().getFullYear().toString(), + } + ); + + const liveGames = response.response.map(g => g.id.toString()); + logger.info(`Found ${liveGames.length} live NHL games`); + + return liveGames; + } catch (error) { + logger.error(`Failed to fetch live NHL games: ${error}`); + return []; + } + } + + private async findTeam(teamName: string, sportKey: string) { + return await prisma.team.findFirst({ + where: { + name: { contains: teamName, mode: 'insensitive' }, + sport: { + key: sportKey, + }, + }, + }); + } +} diff --git a/dashboard/backend/src/services/api-sports/soccer.service.ts b/dashboard/backend/src/services/api-sports/soccer.service.ts new file mode 100644 index 0000000..2e7a371 --- /dev/null +++ b/dashboard/backend/src/services/api-sports/soccer.service.ts @@ -0,0 +1,286 @@ +import { PrismaClient } from '@prisma/client'; +import { apiSportsClient } from './client'; +import { logger } from '../../config/logger'; + +const prisma = new PrismaClient(); + +/** + * Soccer stats service + * Supports multiple soccer leagues (EPL, MLS, UEFA, etc.) + */ + +export class SoccerService { + // Common soccer league IDs from API-Sports + private leagueIds = { + EPL: 39, // English Premier League + LaLiga: 140, // Spanish La Liga + SerieA: 135, // Italian Serie A + Bundesliga: 78, // German Bundesliga + Ligue1: 61, // French Ligue 1 + MLS: 253, // Major League Soccer + UCL: 2 // UEFA Champions League + }; + + /** + * Get live soccer games across all configured leagues + */ + async getLiveGames(): Promise { + try { + const allGames: any[] = []; + + // Check each league for live games + for (const [leagueName, leagueId] of Object.entries(this.leagueIds)) { + const response = await apiSportsClient.get('/fixtures', { + params: { + league: leagueId, + live: 'all' + } + }); + + const games = response.data?.response || []; + allGames.push(...games); + } + + return allGames; + } catch (error) { + logger.error('Error fetching live soccer games:', error); + return []; + } + } + + /** + * Sync game stats for a specific soccer game + */ + async syncGameStats(externalGameId: string): Promise { + try { + // Find the game in our database + const game = await prisma.game.findUnique({ + where: { externalId: externalGameId }, + include: { homeTeam: true, awayTeam: true } + }); + + if (!game) { + logger.warn(`Game not found: ${externalGameId}`); + return; + } + + // Fetch game statistics from API-Sports + const response = await apiSportsClient.get('/fixtures/statistics', { + params: { + fixture: externalGameId + } + }); + + const statsData = response.data?.response || []; + if (statsData.length === 0) { + logger.warn(`No stats data for game: ${externalGameId}`); + return; + } + + // Process each team's statistics + for (const teamData of statsData) { + const teamExternalId = teamData.team?.id?.toString(); + const isHome = teamExternalId === game.homeTeam.externalId; + const teamId = isHome ? game.homeTeamId : game.awayTeamId; + + // Extract match score + const fixtureData = await this.getFixtureDetails(externalGameId); + const homeScore = fixtureData?.goals?.home || 0; + const awayScore = fixtureData?.goals?.away || 0; + + // Parse statistics into a usable format + const statistics = teamData.statistics || []; + const stats: any = {}; + + statistics.forEach((stat: any) => { + const key = stat.type?.toLowerCase().replace(/ /g, '_'); + let value = stat.value; + + // Convert percentage strings to numbers + if (typeof value === 'string' && value.includes('%')) { + value = parseFloat(value.replace('%', '')); + } + + stats[key] = value; + }); + + // Ensure common stats are present + const standardizedStats = { + shots_on_goal: stats.shots_on_goal || 0, + shots_off_goal: stats.shots_off_goal || 0, + total_shots: stats.total_shots || 0, + blocked_shots: stats.blocked_shots || 0, + shots_insidebox: stats.shots_insidebox || 0, + shots_outsidebox: stats.shots_outsidebox || 0, + fouls: stats.fouls || 0, + corner_kicks: stats.corner_kicks || 0, + offsides: stats.offsides || 0, + ball_possession: stats.ball_possession || 0, + yellow_cards: stats.yellow_cards || 0, + red_cards: stats.red_cards || 0, + goalkeeper_saves: stats.goalkeeper_saves || 0, + total_passes: stats.total_passes || 0, + passes_accurate: stats.passes_accurate || 0, + passes_percentage: stats['passes_%'] || 0 + }; + + // Upsert game stats + await prisma.gameStats.upsert({ + where: { + gameId_teamId: { + gameId: game.id, + teamId + } + }, + update: { + homeScore: isHome ? homeScore : undefined, + awayScore: !isHome ? awayScore : undefined, + quarterScores: [homeScore], // Soccer doesn't have quarters, just final score + stats: standardizedStats + }, + create: { + gameId: game.id, + teamId, + isHome, + homeScore: isHome ? homeScore : 0, + awayScore: !isHome ? awayScore : 0, + quarterScores: [homeScore], + stats: standardizedStats + } + }); + } + + logger.info(`Synced soccer game stats: ${externalGameId}`); + } catch (error) { + logger.error(`Error syncing soccer game stats for ${externalGameId}:`, error); + } + } + + /** + * Sync player stats for a specific soccer game + */ + async syncPlayerStats(externalGameId: string): Promise { + try { + const game = await prisma.game.findUnique({ + where: { externalId: externalGameId } + }); + + if (!game) { + logger.warn(`Game not found: ${externalGameId}`); + return; + } + + // Fetch player statistics + const response = await apiSportsClient.get('/fixtures/players', { + params: { + fixture: externalGameId + } + }); + + const playersData = response.data?.response || []; + + for (const teamData of playersData) { + const teamExternalId = teamData.team?.id?.toString(); + + // Find team in our database + const team = await prisma.team.findFirst({ + where: { externalId: teamExternalId } + }); + + if (!team) continue; + + const players = teamData.players || []; + + for (const playerData of players) { + // Ensure player exists in database + const player = await prisma.player.upsert({ + where: { + externalId_sport: { + externalId: playerData.player?.id?.toString(), + sport: game.sport // Use the game's sport key (e.g., 'soccer_epl') + } + }, + update: { + name: playerData.player?.name, + teamId: team.id + }, + create: { + externalId: playerData.player?.id?.toString(), + name: playerData.player?.name, + teamId: team.id, + sport: game.sport + } + }); + + // Prepare player stats + const playerStats = playerData.statistics?.[0] || {}; + + const stats = { + position: playerStats.games?.position || 'N/A', + rating: playerStats.games?.rating || null, + minutes: playerStats.games?.minutes || 0, + goals: playerStats.goals?.total || 0, + assists: playerStats.goals?.assists || 0, + shots_total: playerStats.shots?.total || 0, + shots_on: playerStats.shots?.on || 0, + passes_total: playerStats.passes?.total || 0, + passes_key: playerStats.passes?.key || 0, + passes_accuracy: playerStats.passes?.accuracy || 0, + dribbles_attempts: playerStats.dribbles?.attempts || 0, + dribbles_success: playerStats.dribbles?.success || 0, + duels_total: playerStats.duels?.total || 0, + duels_won: playerStats.duels?.won || 0, + tackles_total: playerStats.tackles?.total || 0, + interceptions: playerStats.tackles?.interceptions || 0, + fouls_drawn: playerStats.fouls?.drawn || 0, + fouls_committed: playerStats.fouls?.committed || 0, + yellow_cards: playerStats.cards?.yellow || 0, + red_cards: playerStats.cards?.red || 0, + saves: playerStats.goalkeeper?.saves || 0, + goals_conceded: playerStats.goalkeeper?.conceded || 0 + }; + + // Upsert player game stats + await prisma.playerGameStats.upsert({ + where: { + gameId_playerId: { + gameId: game.id, + playerId: player.id + } + }, + update: { stats }, + create: { + gameId: game.id, + playerId: player.id, + stats + } + }); + } + } + + logger.info(`Synced soccer player stats: ${externalGameId}`); + } catch (error) { + logger.error(`Error syncing soccer player stats for ${externalGameId}:`, error); + } + } + + /** + * Helper to get fixture details (scores, status) + */ + private async getFixtureDetails(externalGameId: string): Promise { + try { + const response = await apiSportsClient.get('/fixtures', { + params: { + id: externalGameId + } + }); + + return response.data?.response?.[0] || null; + } catch (error) { + logger.error(`Error fetching fixture details for ${externalGameId}:`, error); + return null; + } + } +} + +export const soccerService = new SoccerService(); diff --git a/dashboard/backend/src/services/outcome-resolver.service.ts b/dashboard/backend/src/services/outcome-resolver.service.ts index e90ce9e..b1c1541 100644 --- a/dashboard/backend/src/services/outcome-resolver.service.ts +++ b/dashboard/backend/src/services/outcome-resolver.service.ts @@ -135,12 +135,22 @@ export class OutcomeResolverService { } try { - // Fetch scoreboard + // Format game date for ESPN API (YYYYMMDD) + const gameDate = new Date(game.commenceTime); + const dateStr = gameDate.toISOString().split('T')[0].replace(/-/g, ''); + + // Fetch scoreboard for the game's date const response = await this.espnClient.get( - `/${mapping.sport}/${mapping.league}/scoreboard` + `/${mapping.sport}/${mapping.league}/scoreboard`, + { + params: { + dates: dateStr // YYYYMMDD format + } + } ); if (!response.data.events || response.data.events.length === 0) { + logger.debug(`No events found on ESPN for ${mapping.sport}/${mapping.league} on ${dateStr}`); return null; } @@ -152,6 +162,7 @@ export class OutcomeResolverService { ); if (!matchedEvent) { + logger.debug(`No matching event found for ${game.homeTeamName} vs ${game.awayTeamName} on ESPN (${response.data.events.length} events checked)`); return null; } @@ -171,7 +182,8 @@ export class OutcomeResolverService { const awayScore = parseInt(awayCompetitor.score, 10); // Extract period and clock information (with type safety) - const period = (status as any).period ? `${(status as any).period}` : null; + // Both period and clock are on competition.status, not competition.status.type + const period = (competition.status as any).period ? `${(competition.status as any).period}` : null; const clock = (competition.status as any).displayClock || null; // Check if completed diff --git a/dashboard/backend/src/services/stats-sync.service.ts b/dashboard/backend/src/services/stats-sync.service.ts new file mode 100644 index 0000000..8604d63 --- /dev/null +++ b/dashboard/backend/src/services/stats-sync.service.ts @@ -0,0 +1,225 @@ +import { NFLStatsService } from './api-sports/nfl.service'; +import { NBAStatsService } from './api-sports/nba.service'; +import { NHLStatsService } from './api-sports/nhl.service'; +import { NCAABService } from './api-sports/ncaab.service'; +import { NCAAFService } from './api-sports/ncaaf.service'; +import { SoccerService } from './api-sports/soccer.service'; +import { logger } from '../config/logger'; +import { env } from '../config/env'; + +export interface StatsSyncResult { + gamesProcessed: number; + gamesUpdated: number; + errors: string[]; +} + +export class StatsSyncService { + private nflService?: NFLStatsService; + private nbaService?: NBAStatsService; + private nhlService?: NHLStatsService; + private ncaabService?: NCAABService; + private ncaafService?: NCAAFService; + private soccerService?: SoccerService; + + constructor() { + // Only initialize services if API key is available + if (env.API_SPORTS_KEY) { + try { + this.nflService = new NFLStatsService(); + this.nbaService = new NBAStatsService(); + this.nhlService = new NHLStatsService(); + this.ncaabService = new NCAABService(); + this.ncaafService = new NCAAFService(); + this.soccerService = new SoccerService(); + logger.info('Stats services initialized for NFL, NBA, NHL, NCAAB, NCAAF, and Soccer'); + } catch (error) { + logger.warn('Failed to initialize stats services, stats sync disabled'); + } + } + } + + async syncAllLiveStats(): Promise { + const result: StatsSyncResult = { + gamesProcessed: 0, + gamesUpdated: 0, + errors: [], + }; + + if (!env.API_SPORTS_KEY) { + return result; + } + + try { + // Fetch and sync NFL live games + if (this.nflService) { + const nflGames = await this.nflService.getLiveGames(); + + for (const gameId of nflGames) { + result.gamesProcessed++; + + try { + await this.nflService.syncGameStats(gameId); + result.gamesUpdated++; + } catch (error) { + const errorMsg = `Failed to sync NFL game ${gameId}: ${error}`; + logger.error(errorMsg); + result.errors.push(errorMsg); + } + + // Small delay to respect rate limits + await new Promise(resolve => setTimeout(resolve, 200)); + } + } + + // Fetch and sync NBA live games + if (this.nbaService) { + const nbaGames = await this.nbaService.getLiveGames(); + + for (const gameId of nbaGames) { + result.gamesProcessed++; + + try { + await this.nbaService.syncGameStats(gameId); + result.gamesUpdated++; + } catch (error) { + const errorMsg = `Failed to sync NBA game ${gameId}: ${error}`; + logger.error(errorMsg); + result.errors.push(errorMsg); + } + + await new Promise(resolve => setTimeout(resolve, 200)); + } + } + + // Fetch and sync NHL live games + if (this.nhlService) { + const nhlGames = await this.nhlService.getLiveGames(); + + for (const gameId of nhlGames) { + result.gamesProcessed++; + + try { + await this.nhlService.syncGameStats(gameId); + result.gamesUpdated++; + } catch (error) { + const errorMsg = `Failed to sync NHL game ${gameId}: ${error}`; + logger.error(errorMsg); + result.errors.push(errorMsg); + } + + await new Promise(resolve => setTimeout(resolve, 200)); + } + } + + // Fetch and sync NCAA Basketball live games + if (this.ncaabService) { + const ncaabGames = await this.ncaabService.getLiveGames(); + + for (const game of ncaabGames) { + result.gamesProcessed++; + + try { + await this.ncaabService.syncGameStats(game.id); + await this.ncaabService.syncPlayerStats(game.id); + result.gamesUpdated++; + } catch (error) { + const errorMsg = `Failed to sync NCAAB game ${game.id}: ${error}`; + logger.error(errorMsg); + result.errors.push(errorMsg); + } + + await new Promise(resolve => setTimeout(resolve, 200)); + } + } + + // Fetch and sync NCAA Football live games + if (this.ncaafService) { + const ncaafGames = await this.ncaafService.getLiveGames(); + + for (const game of ncaafGames) { + result.gamesProcessed++; + + try { + await this.ncaafService.syncGameStats(game.id); + await this.ncaafService.syncPlayerStats(game.id); + result.gamesUpdated++; + } catch (error) { + const errorMsg = `Failed to sync NCAAF game ${game.id}: ${error}`; + logger.error(errorMsg); + result.errors.push(errorMsg); + } + + await new Promise(resolve => setTimeout(resolve, 200)); + } + } + + // Fetch and sync Soccer live games + if (this.soccerService) { + const soccerGames = await this.soccerService.getLiveGames(); + + for (const game of soccerGames) { + result.gamesProcessed++; + + try { + await this.soccerService.syncGameStats(game.fixture.id); + await this.soccerService.syncPlayerStats(game.fixture.id); + result.gamesUpdated++; + } catch (error) { + const errorMsg = `Failed to sync Soccer game ${game.fixture.id}: ${error}`; + logger.error(errorMsg); + result.errors.push(errorMsg); + } + + await new Promise(resolve => setTimeout(resolve, 200)); + } + } + + logger.info(`Stats sync completed: ${result.gamesUpdated}/${result.gamesProcessed} games updated`); + } catch (error) { + const errorMsg = `Stats sync failed: ${error}`; + logger.error(errorMsg); + result.errors.push(errorMsg); + } + + return result; + } + + async syncTeamSeasonStats(sportKey: string, teamId: number, season: number): Promise { + try { + switch (sportKey) { + case 'americanfootball_nfl': + if (this.nflService) { + await this.nflService.syncTeamStats(teamId, season); + } + break; + case 'basketball_nba': + // TODO: Implement NBA team stats sync + logger.warn('NBA team stats sync not yet implemented'); + break; + case 'basketball_ncaab': + // TODO: Implement NCAAB team stats sync + logger.warn('NCAAB team stats sync not yet implemented'); + break; + case 'americanfootball_ncaaf': + // TODO: Implement NCAAF team stats sync + logger.warn('NCAAF team stats sync not yet implemented'); + break; + case 'icehockey_nhl': + // TODO: Implement NHL team stats sync + logger.warn('NHL team stats sync not yet implemented'); + break; + case 'soccer_epl': + case 'soccer_spain_la_liga': + case 'soccer_usa_mls': + // TODO: Implement Soccer team stats sync + logger.warn('Soccer team stats sync not yet implemented'); + break; + default: + logger.warn(`Team stats sync not implemented for sport: ${sportKey}`); + } + } catch (error) { + logger.error(`Failed to sync team stats: ${error}`); + throw error; + } + } +} diff --git a/dashboard/frontend/CHANGELOG.md b/dashboard/frontend/CHANGELOG.md index 65ffdc6..dd88a06 100644 --- a/dashboard/frontend/CHANGELOG.md +++ b/dashboard/frontend/CHANGELOG.md @@ -5,6 +5,49 @@ All notable changes to the Dashboard Frontend will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- **Landing Page Enhancements**: Improved visual design and user experience + - Pixel art assets: animations (coin, star, tumbleweed) and decorations (badge, cards, chips, horseshoe, wanted poster) + - Cowboy dollar mascot logo (cowboy-dollar.svg) as main hero image + - Enhanced hero section with full background coverage and improved text contrast +- **Footer Expansion**: More informative and professional footer + - Separate backend (v0.2.2) and frontend (v0.3.2) version display + - API requests counter now only visible in development environment + - Responsible gaming link to National Council on Problem Gambling + - GitHub repository link + - Disclaimer section with 1-800-GAMBLER helpline + - Monospace font matching 8-bit theme +- **GameStatsPanel Enhancements**: Season averages toggle and display + - Toggle button to switch between current game stats and season averages + - Season averages section showing total games, home/away splits, and averaged stats + - Separate cards for home and away team season performance + - Displays historical averages alongside live game data +- **TeamStatsView Component**: Comprehensive team statistics with filtering + - Filter buttons for All Games, Home Games, and Away Games + - Split statistics comparison (home vs away vs overall) + - Detailed stat cards with visual formatting and color coding + - Recent game history with location indicators (home/away) + - Integration with `/api/stats/team/:teamId` endpoint +- **Team Detail Page**: Dedicated route for team statistics + - New `/team/:teamId` route in App.tsx + - TeamDetail page component with back navigation + - Full integration with TeamStatsView component +- **Clickable Team Names**: Navigation links in GameCard + - Team names in GameCard now link to team stats pages + - Hover effects with color transitions + - Works for both completed and in-progress games + - Maintains existing layout and functionality + +### Changed +- **Landing Page Polish**: Cleaner, more professional appearance + - Removed all emoji decorations from headings, buttons, and body text + - Removed subtle decorative GIFs (stars, tumbleweeds, coins, badge, cards) + - Removed floating horseshoe decoration from hero + - Removed wanted poster background overlay from "What We Do" section + - Increased dark overlay opacity for better text readability + ## [0.3.2] - 2026-01-15 ### Added diff --git a/dashboard/frontend/package.json b/dashboard/frontend/package.json index e4a5529..cdba8ad 100644 --- a/dashboard/frontend/package.json +++ b/dashboard/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@wford26/bettrack-frontend", - "version": "0.3.2", + "version": "0.3.3", "type": "module", "scripts": { "dev": "vite", diff --git a/dashboard/frontend/public/animations/coin.gif b/dashboard/frontend/public/animations/coin.gif new file mode 100644 index 0000000..8667a59 Binary files /dev/null and b/dashboard/frontend/public/animations/coin.gif differ diff --git a/dashboard/frontend/public/animations/star.gif b/dashboard/frontend/public/animations/star.gif new file mode 100644 index 0000000..5c9108d Binary files /dev/null and b/dashboard/frontend/public/animations/star.gif differ diff --git a/dashboard/frontend/public/animations/tumbleweed.gif b/dashboard/frontend/public/animations/tumbleweed.gif new file mode 100644 index 0000000..56bee6a Binary files /dev/null and b/dashboard/frontend/public/animations/tumbleweed.gif differ diff --git a/dashboard/frontend/public/basketball-pixel.png b/dashboard/frontend/public/basketball-pixel.png new file mode 100644 index 0000000..fa3c699 --- /dev/null +++ b/dashboard/frontend/public/basketball-pixel.png @@ -0,0 +1,6 @@ +IMPORTANT: Save the basketball pixel art image you provided to this location: +dashboard/frontend/public/basketball-pixel.png + +The image should be the orange basketball with pixel art style that you attached. + +Once saved, the 8-bit game cards will display it in the top-right corner of each card. \ No newline at end of file diff --git a/dashboard/frontend/public/betslip.png b/dashboard/frontend/public/betslip.png new file mode 100644 index 0000000..c789d5c Binary files /dev/null and b/dashboard/frontend/public/betslip.png differ diff --git a/dashboard/frontend/public/bookmaker/betmgm.png b/dashboard/frontend/public/bookmaker/betmgm.png new file mode 100644 index 0000000..7dbe325 Binary files /dev/null and b/dashboard/frontend/public/bookmaker/betmgm.png differ diff --git a/dashboard/frontend/public/bookmaker/betmgm.svg b/dashboard/frontend/public/bookmaker/betmgm.svg new file mode 100644 index 0000000..c4a402c --- /dev/null +++ b/dashboard/frontend/public/bookmaker/betmgm.svg @@ -0,0 +1,1384 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/frontend/public/bookmaker/betrivers.png b/dashboard/frontend/public/bookmaker/betrivers.png new file mode 100644 index 0000000..bc467b8 Binary files /dev/null and b/dashboard/frontend/public/bookmaker/betrivers.png differ diff --git a/dashboard/frontend/public/bookmaker/betrivers.svg b/dashboard/frontend/public/bookmaker/betrivers.svg new file mode 100644 index 0000000..9e1d719 --- /dev/null +++ b/dashboard/frontend/public/bookmaker/betrivers.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/frontend/public/bookmaker/betus.png b/dashboard/frontend/public/bookmaker/betus.png new file mode 100644 index 0000000..66c3735 Binary files /dev/null and b/dashboard/frontend/public/bookmaker/betus.png differ diff --git a/dashboard/frontend/public/bookmaker/betus.svg b/dashboard/frontend/public/bookmaker/betus.svg new file mode 100644 index 0000000..ad624a8 --- /dev/null +++ b/dashboard/frontend/public/bookmaker/betus.svg @@ -0,0 +1,423 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/frontend/public/bookmaker/draftkings.png b/dashboard/frontend/public/bookmaker/draftkings.png new file mode 100644 index 0000000..eaada86 Binary files /dev/null and b/dashboard/frontend/public/bookmaker/draftkings.png differ diff --git a/dashboard/frontend/public/bookmaker/draftkings.svg b/dashboard/frontend/public/bookmaker/draftkings.svg new file mode 100644 index 0000000..09257cf --- /dev/null +++ b/dashboard/frontend/public/bookmaker/draftkings.svg @@ -0,0 +1,1080 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/frontend/public/bookmaker/fanduel.png b/dashboard/frontend/public/bookmaker/fanduel.png new file mode 100644 index 0000000..40bb0b2 Binary files /dev/null and b/dashboard/frontend/public/bookmaker/fanduel.png differ diff --git a/dashboard/frontend/public/cowboy-dollar.svg b/dashboard/frontend/public/cowboy-dollar.svg new file mode 100644 index 0000000..a11603d --- /dev/null +++ b/dashboard/frontend/public/cowboy-dollar.svg @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/frontend/public/decorations/badge.png b/dashboard/frontend/public/decorations/badge.png new file mode 100644 index 0000000..f1d4e5c Binary files /dev/null and b/dashboard/frontend/public/decorations/badge.png differ diff --git a/dashboard/frontend/public/decorations/cards.png b/dashboard/frontend/public/decorations/cards.png new file mode 100644 index 0000000..d3e9e7b Binary files /dev/null and b/dashboard/frontend/public/decorations/cards.png differ diff --git a/dashboard/frontend/public/decorations/chips.png b/dashboard/frontend/public/decorations/chips.png new file mode 100644 index 0000000..8a10ef1 Binary files /dev/null and b/dashboard/frontend/public/decorations/chips.png differ diff --git a/dashboard/frontend/public/decorations/horseshoe.png b/dashboard/frontend/public/decorations/horseshoe.png new file mode 100644 index 0000000..41a7453 Binary files /dev/null and b/dashboard/frontend/public/decorations/horseshoe.png differ diff --git a/dashboard/frontend/public/decorations/wanted-poster.png b/dashboard/frontend/public/decorations/wanted-poster.png new file mode 100644 index 0000000..32a1d5a Binary files /dev/null and b/dashboard/frontend/public/decorations/wanted-poster.png differ diff --git a/dashboard/frontend/public/hero-background.png b/dashboard/frontend/public/hero-background.png new file mode 100644 index 0000000..b82d742 Binary files /dev/null and b/dashboard/frontend/public/hero-background.png differ diff --git a/dashboard/frontend/public/icons/ai.png b/dashboard/frontend/public/icons/ai.png new file mode 100644 index 0000000..9c7bfd4 Binary files /dev/null and b/dashboard/frontend/public/icons/ai.png differ diff --git a/dashboard/frontend/public/icons/api.png b/dashboard/frontend/public/icons/api.png new file mode 100644 index 0000000..c84fb6b Binary files /dev/null and b/dashboard/frontend/public/icons/api.png differ diff --git a/dashboard/frontend/public/icons/book.png b/dashboard/frontend/public/icons/book.png new file mode 100644 index 0000000..ba84b98 Binary files /dev/null and b/dashboard/frontend/public/icons/book.png differ diff --git a/dashboard/frontend/public/icons/chart.png b/dashboard/frontend/public/icons/chart.png new file mode 100644 index 0000000..9053a09 Binary files /dev/null and b/dashboard/frontend/public/icons/chart.png differ diff --git a/dashboard/frontend/public/icons/dice.png b/dashboard/frontend/public/icons/dice.png new file mode 100644 index 0000000..d4b7261 Binary files /dev/null and b/dashboard/frontend/public/icons/dice.png differ diff --git a/dashboard/frontend/public/icons/graph.png b/dashboard/frontend/public/icons/graph.png new file mode 100644 index 0000000..788df1d Binary files /dev/null and b/dashboard/frontend/public/icons/graph.png differ diff --git a/dashboard/frontend/public/icons/lightning.png b/dashboard/frontend/public/icons/lightning.png new file mode 100644 index 0000000..6a2a204 Binary files /dev/null and b/dashboard/frontend/public/icons/lightning.png differ diff --git a/dashboard/frontend/public/icons/moon.png b/dashboard/frontend/public/icons/moon.png new file mode 100644 index 0000000..55a0102 Binary files /dev/null and b/dashboard/frontend/public/icons/moon.png differ diff --git a/dashboard/frontend/public/icons/target.png b/dashboard/frontend/public/icons/target.png new file mode 100644 index 0000000..7f037d2 Binary files /dev/null and b/dashboard/frontend/public/icons/target.png differ diff --git a/dashboard/frontend/public/sports/basketball-pixel.png b/dashboard/frontend/public/sports/basketball-pixel.png new file mode 100644 index 0000000..0bd66f4 Binary files /dev/null and b/dashboard/frontend/public/sports/basketball-pixel.png differ diff --git a/dashboard/frontend/public/sports/basketball.png b/dashboard/frontend/public/sports/basketball.png new file mode 100644 index 0000000..8ad0116 Binary files /dev/null and b/dashboard/frontend/public/sports/basketball.png differ diff --git a/dashboard/frontend/public/sports/basketball.svg b/dashboard/frontend/public/sports/basketball.svg new file mode 100644 index 0000000..edba1c1 --- /dev/null +++ b/dashboard/frontend/public/sports/basketball.svg @@ -0,0 +1,183 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/frontend/public/sports/football-pixel.png b/dashboard/frontend/public/sports/football-pixel.png new file mode 100644 index 0000000..e69de29 diff --git a/dashboard/frontend/public/sports/football.png b/dashboard/frontend/public/sports/football.png new file mode 100644 index 0000000..38b61a3 Binary files /dev/null and b/dashboard/frontend/public/sports/football.png differ diff --git a/dashboard/frontend/public/sports/football.svg b/dashboard/frontend/public/sports/football.svg new file mode 100644 index 0000000..be098a9 --- /dev/null +++ b/dashboard/frontend/public/sports/football.svg @@ -0,0 +1,259 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/frontend/public/sports/hockey-pixel.png b/dashboard/frontend/public/sports/hockey-pixel.png new file mode 100644 index 0000000..d2cd109 Binary files /dev/null and b/dashboard/frontend/public/sports/hockey-pixel.png differ diff --git a/dashboard/frontend/public/sports/hockey.png b/dashboard/frontend/public/sports/hockey.png new file mode 100644 index 0000000..6e9e597 Binary files /dev/null and b/dashboard/frontend/public/sports/hockey.png differ diff --git a/dashboard/frontend/public/sports/hockey.svg b/dashboard/frontend/public/sports/hockey.svg new file mode 100644 index 0000000..a8002e4 --- /dev/null +++ b/dashboard/frontend/public/sports/hockey.svg @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/frontend/public/sports/soccer-pixel.png b/dashboard/frontend/public/sports/soccer-pixel.png new file mode 100644 index 0000000..b5ccaf0 Binary files /dev/null and b/dashboard/frontend/public/sports/soccer-pixel.png differ diff --git a/dashboard/frontend/public/sports/soccer.png b/dashboard/frontend/public/sports/soccer.png new file mode 100644 index 0000000..b02f8d5 Binary files /dev/null and b/dashboard/frontend/public/sports/soccer.png differ diff --git a/dashboard/frontend/public/sports/soccer.svg b/dashboard/frontend/public/sports/soccer.svg new file mode 100644 index 0000000..961952b --- /dev/null +++ b/dashboard/frontend/public/sports/soccer.svg @@ -0,0 +1,249 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/frontend/public/yeehaw_badge.gif b/dashboard/frontend/public/yeehaw_badge.gif new file mode 100644 index 0000000..a218e58 Binary files /dev/null and b/dashboard/frontend/public/yeehaw_badge.gif differ diff --git a/dashboard/frontend/public/yeehaw_speech_bubble_ultimate.gif b/dashboard/frontend/public/yeehaw_speech_bubble_ultimate.gif new file mode 100644 index 0000000..ecb0da0 Binary files /dev/null and b/dashboard/frontend/public/yeehaw_speech_bubble_ultimate.gif differ diff --git a/dashboard/frontend/src/App.tsx b/dashboard/frontend/src/App.tsx index b4c2c6a..df2a847 100644 --- a/dashboard/frontend/src/App.tsx +++ b/dashboard/frontend/src/App.tsx @@ -13,6 +13,10 @@ import BetSlip from './components/bets/BetSlip'; import BetHistory from './pages/BetHistory'; import Futures from './pages/Futures'; import Stats from './pages/Stats'; +import GameDetail from './pages/GameDetail'; +import TeamDetail from './pages/TeamDetail'; +import EnhancedDashboard from './pages/EnhancedDashboard'; +import Home from './pages/Home'; import Login from './pages/Login'; import ApiKeysSettings from './pages/ApiKeysSettings'; import Preferences from './pages/Preferences'; @@ -159,15 +163,19 @@ function App() { -
+
-
+
} /> - } /> + } /> + } /> + } /> } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/dashboard/frontend/src/components/Footer.tsx b/dashboard/frontend/src/components/Footer.tsx index e2188c3..22afade 100644 --- a/dashboard/frontend/src/components/Footer.tsx +++ b/dashboard/frontend/src/components/Footer.tsx @@ -1,8 +1,13 @@ import React, { useEffect, useState } from 'react'; import api from '../services/api'; +// Import version numbers from package.json +const FRONTEND_VERSION = '0.3.2'; +const BACKEND_VERSION = '0.2.2'; + export default function Footer() { const [apiRequestsRemaining, setApiRequestsRemaining] = useState(null); + const isDevelopment = import.meta.env.DEV; useEffect(() => { const fetchHealthData = async () => { @@ -16,36 +21,70 @@ export default function Footer() { } }; - // Fetch on mount - fetchHealthData(); - - // Refresh every 30 seconds - const interval = setInterval(fetchHealthData, 30000); - - return () => clearInterval(interval); - }, []); + // Only fetch in development mode + if (isDevelopment) { + fetchHealthData(); + const interval = setInterval(fetchHealthData, 30000); + return () => clearInterval(interval); + } + }, [isDevelopment]); const currentYear = new Date().getFullYear(); return ( -