This post documents the development journey of pools.golf, a real-time golf tournament pool tracker application.

The Vision

After participating in various golf pools with friends over the years, I noticed we were all using spreadsheets to track our picks and standings. It was clunky, error-prone, and lacked the real-time excitement of seeing how our picks were doing during tournament play.

I set out to build something better: a web application that would:

  • Allow users to create groups for tournaments
  • Let players make picks from tiered golfer lists
  • Automatically update standings in real-time during tournaments
  • Support multiple tournament formats

Tech Stack Overview

For this project, I decided on:

  • Frontend: React with Tailwind CSS for styling
  • Backend: Flask (Python) for the API
  • Database: SQLAlchemy with PostgreSQL
  • Authentication: JWT tokens with Google OAuth integration
  • Deployment: Docker containers on a cloud provider

Let’s walk through the key components and challenges encountered along the way.

Backend Architecture

The application’s backend is built with Flask, a lightweight Python web framework that made it easy to create a RESTful API. My initial database schema included models for:

  • Tournaments
  • Golfers
  • Scores
  • Users
  • Pool Groups
  • Pool Entries

Here’s a simplified version of how these models relate to each other:

User → creates/joins → PoolGroup
User → submits → PoolEntry (for a Tournament)
Tournament → has → Golfers (organized in Tiers)
Golfer → has → Scores (for each round of a Tournament)

Data Sourcing

One of the biggest challenges was acquiring real-time golf data. After evaluating several options, I ended up building a custom scraper for ESPN’s tournament pages. The ESPNScraper class fetches leaderboard data and parses it into our database format:

class ESPNScraper:
    def fetch_scores(self):
        """Fetch and parse the leaderboard from ESPN."""
        try:
            response = requests.get(
                self.tournament.espn_url,
                headers=self.HEADERS,
                timeout=10
            )
            
            soup = BeautifulSoup(response.text, 'html.parser')
            
            # Find the leaderboard table
            leaderboard = soup.find('table', class_='Table')
            
            # Convert the table to a pandas DataFrame
            df = pd.read_html(str(leaderboard))[0]
            return self._process_leaderboard(df)
        
        except requests.RequestException as e:
            print(f"Error fetching ESPN data: {e}")
            return None

This scraper runs on a scheduled basis to keep our database updated with the latest scores during active tournaments.

Frontend Design

For the frontend, I wanted a clean, responsive interface that would work well on both desktop and mobile devices. I used React with Tailwind CSS, which allowed for rapid prototyping and a consistent design language throughout the application.

The UI follows a component-based architecture with several key views:

Home Page

The homepage needed to clearly communicate the purpose of the site and provide quick access to active tournaments. I designed it with a hero section, feature highlights, and a current tournament card:

<div className="bg-blue-600 text-white">
  <div className="max-w-7xl mx-auto py-16 px-4 sm:px-6 lg:px-8">
    <h1 className="text-4xl font-bold text-center mb-4">
      Welcome to Golf Pool
    </h1>
    <p className="text-xl text-center text-blue-100 mb-8">
      Join the excitement of professional golf by creating your own player pool
    </p>
    {!isAuthenticated && (
      <div className="text-center">
        <button
          onClick={() => navigate('/login')}
          className="bg-white text-blue-600 px-6 py-3 rounded-lg font-semibold hover:bg-blue-50 transition-colors"
        >
          Join Now
        </button>
      </div>
    )}
  </div>
</div>

Tournament Entry Form

The tournament entry interface was crucial - it needed to clearly present the golfer tiers and allow users to make and review their selections:

{[1, 2, 3, 4].map(tier => (
  <div key={tier} className="bg-white rounded-lg shadow-sm p-6 mb-6">
    <h2 className="text-xl font-bold mb-4">
      Tier {tier} {tier === 4 ? '(Select Two)' : '(Select One)'}
    </h2>
    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
      {tiers[tier]?.map(golfer => (
        <button
          key={golfer.id}
          onClick={() => handleGolferSelect(golfer.id, tier)}
          className={`
            p-4 rounded-lg border transition-all
            ${isGolferSelected(golfer.id, tier)
              ? 'bg-blue-50 border-blue-500 shadow-sm'
              : 'hover:bg-gray-50 border-gray-200'}
          `}
        >
          <div className="font-semibold">{golfer.name}</div>
          <div className="text-sm text-gray-600">
            Odds: {golfer.odds ? `${golfer.odds}:1` : 'N/A'}
          </div>
        </button>
      ))}
    </div>
  </div>
))}

Leaderboard & Pool Standings

The standings page is where the real-time magic happens. It shows not just the tournament leaderboard, but also how each user’s selections are performing:

<table className="w-full border-collapse text-sm">
  <thead>
    <tr className="line-height-[19px] text-[13px] bg-gray-100">
      <th className="border text-center min-w-[40px]">Rank</th>
      <th className="border text-center min-w-[120px]">Player</th>
      <th className="border text-center min-w-[190px]">Golfer 1</th>
      <th className="border text-center min-w-[190px]">Golfer 2</th>
      <th className="border text-center min-w-[190px]">Golfer 3</th>
      <th className="border text-center min-w-[190px]">Golfer 4</th>
      <th className="border text-center min-w-[190px]">Golfer 5</th>
      <th className="border text-center min-w-[50px]">Score</th>
      <th className="border text-center min-w-[50px]">Round</th>
    </tr>
  </thead>
  <tbody>
    {standings.map((entry) => (
      <tr key={entry.rank} className="hover:bg-gray-50">
        <td className="border text-center font-semibold">{entry.rank}</td>
        <td className="border text-center">
          <div className="font-medium">{entry.player.name}</div>
        </td>
        {/* Golfer selections with scores */}
        {entry.selections.map((golfer, index) => (
          <td key={index} className="p-0 border">
            <GolferScore golfer={golfer} />
          </td>
        ))}
        <td className="p-0 border text-center font-semibold">
          {entry.totalScore > 0 ? `+${entry.totalScore}` : entry.totalScore}
        </td>
        <td className="p-0 border text-center">
          {entry.round || '--'}
        </td>
      </tr>
    ))}
  </tbody>
</table>

Group Functionality

One of the most important features was the ability for users to create and join private groups. This allows friends or colleagues to run their own private pools.

The group management includes:

  • Creating private groups with invite codes
  • Adding tournaments to groups
  • Managing group members
  • Group-specific tournament entry and standings
const PoolGroups = () => {
  // State management for groups, loading status, etc.
  
  const handleCreateGroup = async (e) => {
    e.preventDefault();
    try {
      const response = await fetch(
        `${import.meta.env.VITE_API_BASE_URL}/api/pool-groups`,
        {
          method: 'POST',
          headers: {
            ...getAuthHeader(),
            'Content-Type': 'application/json'
          },
          body: JSON.stringify(formData)
        }
      );
      
      // Handle response
    } catch (err) {
      setError(err.message);
    }
  };
  
  // Render group cards, creation form, etc.
}

Authentication & Security

Security was a priority from day one. I implemented:

  • JWT token-based authentication
  • Google OAuth integration for easy sign-in
  • Passkey support for modern authentication
  • Role-based authorization for group management

The authentication flow works like this:

// In LoginForm.jsx
const handleGoogleLogin = () => {
  window.location.href = `${import.meta.env.VITE_API_BASE_URL}/auth/google`;
};

// In AuthCallback.jsx
useEffect(() => {
  const token = searchParams.get('token');
  const redirect = searchParams.get('redirect') || '/dashboard';

  if (token) {
    // Store the token
    localStorage.setItem('auth_token', token);
    
    // Redirect to the specified page or dashboard
    navigate(redirect, { replace: true });
  } else {
    setError('No authentication token received');
  }
}, [searchParams, navigate]);

Challenges & Solutions

Real-time Score Updates

One of the biggest challenges was keeping score data fresh without overwhelming the ESPN source with requests. I solved this with:

  1. A scheduled scraper that runs at appropriate intervals
  2. A caching layer to store recent results
  3. Client-side polling with exponential backoff during tournament play

Cut Line Handling

In golf tournaments, players who don’t make the “cut” after the first two rounds don’t play the weekend rounds. This created a scoring challenge - how do we handle cut players in the pool standings?

The solution was to assign cut players a penalty score based on the cut line:

# Handle cut players
if current_round > 2:
    if 3 not in rounds and 4 not in rounds:
        if cut_line:
            scores = scores + [
                Score(round=3, score=tournament.par + cut_line, status='CUT', thru='18'),
                Score(round=4, score=tournament.par + cut_line, status='CUT', thru='18')
            ]

Mobile Responsiveness

Ensuring the app worked well on mobile was crucial, as many users check standings on their phones during tournaments. Tailwind CSS made this easier with its mobile-first approach:

<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
  {/* Each card is full width on mobile, two columns on tablet, three on desktop */}
  {upcomingTournaments.map(tournament => (
    <div key={tournament.id} className="bg-white rounded-lg shadow-md overflow-hidden">
      {/* Card content */}
    </div>
  ))}
</div>

What’s Next

The application continues to evolve with new features planned:

  1. Tournament History - Comprehensive statistics and results from past tournaments
  2. Advanced Scoring Options - Support for different pool formats (e.g., majors-only, season-long)
  3. Push Notifications - Real-time alerts for significant leaderboard changes
  4. Admin Dashboard - Better tools for managing tournaments and tiers

Lessons Learned

Building this application taught me several valuable lessons:

  1. Start with core functionality - Focus on the essential features first and iterate
  2. Test with real users early - Getting feedback from actual golf fans was invaluable
  3. Plan for scale - Designing the database schema with growth in mind saved refactoring later
  4. Balance automation and manual control - Sometimes manual data entry is more reliable than automated scraping

Conclusion

Creating the Golf Pool Tracker has been a rewarding journey that combined my passion for golf with web development. The application has already enhanced tournament watching for hundreds of users, making golf pools more engaging and accessible.

I’m excited to continue improving the platform and adding new features based on user feedback. If you’d like to try it out, visit pools.golf and create your first pool!


This post will be updated as development continues with more technical details and lessons learned.