What I Learned from Refactoring Enduro Stats
A deep dive into transforming a simple Strava API wrapper into a robust analytics platform
What I Learned from Refactoring Enduro Stats
"It works" and "it works well" are two very different things. This is the story of how I transformed a simple Strava API wrapper into a full-fledged analytics platform, and the valuable lessons learned along the way.
๐ฑ View the live application | โก Explore the source code on GitHub
The Journey from Simple to Sophisticated
Every developer has that one project that teaches them more than they bargained for. For me, that was Enduro Stats โ a running analytics app that started as a simple Strava API wrapper and became a masterclass in the gap between "working" and "working well."
My first iteration was straightforward: a learning exercise to pull data from Strava's API, display some basic stats, and deploy it to the web. I gained valuable experience with OAuth flows and API integration, and I was proud of what I'd built โ it worked, it was live, and users could see their running data.
But as many developers learn the hard way, there's a world of difference between a working application and one that's truly ready for real users and real-world usage. After coming back to the project months later, I realized that it needed to evolve into something more robust and valuable.
The Wake-Up Call
The limitations of my original approach became glaringly obvious when I tried to add meaningful analytics โ which was ironic, considering analytics was the whole point. Here's what I found:
// Initial implementation with poor data management
async function getAthleteStats(athleteId: string): Promise<AthleteStats> {
// Fetch from Strava API on every request - inefficient and slow
const activities = await stravaApi.getActivities();
// Calculate everything on the fly without caching
return {
totalDistance: activities.reduce((sum, a) => sum + a.distance, 0),
// No historical data to compare against
// No way to track improvements
// Everything recalculated on every request
};
}
See the improved implementation on GitHub โ
This code represented everything wrong with my initial approach:
- No data persistence
- Repeated API calls
- No historical tracking
- No way to analyze trends
It was time for a complete rethink. The journey from this naive implementation to a proper analytics platform taught me valuable lessons about:
- Authentication and security
- Data modeling and persistence
- State management and caching
- Professional development practices
Here's how I transformed Enduro Stats from a simple API wrapper into a robust analytics platform...
The Limitations Became Clear
When I revisited Enduro Stats after a few months away, the limitations of my original approach became glaringly obvious. The biggest issue? There was no way to add meaningful analytics to the app โ which was ironic, considering that was the whole point of building it.
My original architecture was painfully simple: basic API routes that fetched data from Strava on every request, displayed it, and forgot about it. No persistence, no historical tracking, no ability to analyze trends over time. I was essentially building a very expensive API proxy.
The core question that drove my refactoring decision was: How can I utilize the data I'm already collecting to add real value? But answering that question required solving a fundamental architectural problem โ I needed to persist data in a database.
With these limitations in mind, it was time to rebuild. The first crucial step? Proper authentication.
The Technical Challenge
The features I wanted to implement required a completely different approach:
- Persistent historical data: Users needed access to their running history over time, not just their most recent activities
- Performance zones calculation: Automatically calculate training zones based on recent runs and heart rate data
- TRIMP score tracking: Implement Training Impulse calculations to help users understand training load
- Trend analysis: Show progress over weeks and months, not just individual workout snapshots
Each of these features required storing and processing data over time โ something my original "fetch and display" architecture simply couldn't handle.
The Rebuild Decision
After evaluating my options, I decided to rebuild from scratch rather than trying to retrofit the existing codebase. The original app was missing too many fundamental pieces:
- User authentication: The original version had no concept of individual users
- Data persistence: Everything was ephemeral API calls
- Proper Strava integration: I needed a more robust OAuth flow that could handle user accounts
For the new architecture, I chose:
- Supabase for the database and authentication backend
- React Query for client-side data management and caching
- Proper user flow: Users now create an account, then link their Strava profile to unlock the analytics features
This approach gave me the foundation I needed to build the analytical features that were the whole point of the project in the first place.
The Technical Hurdles
Looking back at my first version, I can see several architectural mistakes that made the codebase difficult to maintain and scale:
OAuth & Authentication Woes
My initial OAuth implementation was naive at best. I was using cookies without proper session handling, which led to all sorts of edge cases and security concerns. The authentication flow was tightly coupled to specific components, making it nearly impossible to reuse or test.
API Call Chaos
The original app suffered from what I now call "API call chaos":
- Multiple redundant API calls triggering constant re-renders
- No caching strategy whatsoever
- API logic scattered across components
- Custom hooks that were tightly coupled to specific components
I had violated the "keep components simple" rule, and it showed. My callback logic was embedded in a custom hook that was essentially married to one component, making it impossible to reuse or test in isolation.
Enter React Query
Learning and implementing React Query was a game-changer. It forced me to:
- Think about data fetching as a separate concern from UI
- Implement proper caching strategies
- Handle loading and error states consistently
- Separate my API logic from my components
The difference was night and day. Instead of components managing their own data fetching (and often fetching the same data multiple times), I now had a centralized way to handle API calls, caching, and state management.
The Data Synchronization Challenge
While React Query solved many of our data fetching problems, it exposed a deeper architectural challenge: data synchronization. As the application grew, managing the flow of data between Strava's API, our database, and the UI became increasingly complex.
Understanding the Data Flow
Here's how data moves through the application:
The Synchronization Problem
My initial approach was naive. I assumed I could simply:
- Fetch data from Strava
- Store it in the database
- Display it to the user
Reality was more complicated:
// Improved sync implementation with proper error handling and types
interface SyncResult {
success: boolean;
updatedActivities: number;
errors?: Error[];
}
async function syncActivities(userId: string): Promise<SyncResult> {
try {
// Get last synced activity for incremental sync
const lastSync = await db.activities
.findFirst({
where: { userId },
orderBy: { start_date: 'desc' }
});
// Fetch only new activities from Strava
const newActivities = await stravaApi.getActivities({
after: lastSync?.start_date ?? 'beginning of time',
per_page: 100
});
// Process and store new activities with conflict resolution
const results = await Promise.allSettled(
newActivities.map(activity =>
syncActivity(activity, userId)
)
);
// Track sync results
const succeeded = results.filter(r => r.status === 'fulfilled');
const failed = results.filter(r => r.status === 'rejected');
return {
success: failed.length === 0,
updatedActivities: succeeded.length,
errors: failed.map(f => (f as PromiseRejectedResult).reason)
};
} catch (error) {
logger.error('Sync failed', { userId, error });
throw error;
}
}
This led to several problems:
-
Data Conflicts:
// What happens when both sources have different data? const stravaActivity = { id: '123', name: 'Morning Run', distance: 5000 }; const localActivity = { id: '123', name: 'Morning Run', // Same distance: 5100, // Different! userNotes: 'Felt great!' // Local only data };
-
Partial Updates:
// How do we handle partial data updates? interface Activity { id: string; name: string; distance: number; userNotes?: string; // Local only stravaKudos?: number; // Strava only localRating?: number; // Local only }
The Solution: Clear Data Ownership
The solution was to clearly define data ownership and synchronization rules:
interface ActivityBase {
id: string;
strava_id: string;
name: string;
distance: number;
start_date: Date;
}
interface StravaActivity extends ActivityBase {
kudos: number;
athlete_id: string;
// Strava-specific fields
}
interface LocalActivity extends ActivityBase {
user_notes?: string;
personal_rating?: number;
// Local-specific fields
// Reference to source data
strava_last_sync: Date;
}
// Clear sync logic
async function syncActivity(stravaActivity: StravaActivity): Promise<LocalActivity> {
const existing = await db.activities.findUnique({
where: { strava_id: stravaActivity.id }
});
if (!existing) {
// New activity - simple create
return db.activities.create({
data: {
...mapStravaToLocal(stravaActivity),
strava_last_sync: new Date()
}
});
}
// Update existing - preserve local data
return db.activities.update({
where: { id: existing.id },
data: {
...mapStravaToLocal(stravaActivity),
// Preserve local-only fields
user_notes: existing.user_notes,
personal_rating: existing.personal_rating,
strava_last_sync: new Date()
}
});
}
Lessons Learned About Data Management
-
Start with a Clear MVP:
- Define core data requirements
- Identify data sources
- Plan for data ownership
- Document sync requirements
-
Schema Design is Critical:
// Good schema design makes sync logic clear interface SyncableEntity { id: string; // Our ID external_id: string; // API ID last_synced: Date; // Sync tracking local_version: number; // Version control }
-
Handle Edge Cases Early:
- Network failures during sync
- Conflict resolution
- Partial updates
- Data validation
-
Monitor and Log:
async function syncWithLogging(activity: StravaActivity) { try { const result = await syncActivity(activity); logger.info('Sync successful', { activity_id: activity.id, changes: computeChanges(activity, result) }); return result; } catch (error) { logger.error('Sync failed', { activity_id: activity.id, error: error.message }); throw error; } }
This experience taught me that data synchronization isn't just about moving data - it's about:
- Understanding data ownership
- Planning for conflicts
- Preserving user modifications
- Maintaining data integrity
- Tracking sync state
Starting with a clear MVP and well-thought-out schemas would have prevented many of these challenges. Now, in new projects, I always begin with a detailed data flow diagram and clear ownership rules before writing any code.
Lessons Learned
This refactoring journey taught me some valuable lessons about building modern web applications:
-
Authentication is a first-class concern: Don't treat it as an afterthought. Proper session handling and OAuth flows are crucial.
-
Separate your concerns: API logic, data fetching, and UI should be distinct layers that can evolve independently.
-
Embrace modern tools: React Query isn't just a nice-to-have โ it's essential for building performant, maintainable applications.
-
Keep components focused: Each component should do one thing well. If it's handling API calls, managing state, AND rendering UI, it's probably doing too much.
These lessons have fundamentally changed how I approach new features and components in the ongoing refactor.
Deep Dive: Authentication Implementation
The first step in transforming Enduro Stats was implementing proper authentication. Without it, we couldn't safely store user data or provide personalized analytics. Here's how we tackled it:
-
User Authentication Flow:
- Implemented email/password authentication using Supabase Auth
- Added social login options for better user experience
- Set up proper session management with refresh tokens
-
Strava OAuth Integration:
- Implemented the full OAuth 2.0 flow with PKCE
- Securely stored refresh tokens for background syncs
- Added token rotation and automatic refresh handling
-
Security Considerations:
- Implemented proper CSRF protection
- Added rate limiting on authentication endpoints
- Set up secure session cookie handling
Building the Foundation: Data Layer
With secure authentication in place, we focused on the core challenge: data persistence and management. Our data layer needed to handle:
-
Activity Data:
- Raw activity data from Strava
- Derived metrics and analytics
- User-specific customizations and notes
-
User Preferences:
- Training zones and thresholds
- Display preferences
- Notification settings
-
Analytics Data:
- Historical performance metrics
- Training load calculations
- Progress tracking
Optimizing the User Experience: React Query
React Query transformed how we handle data on the frontend:
// Custom hook for activity data with React Query
function useAthleteActivities(userId: string) {
return useQuery({
queryKey: ['activities', userId],
queryFn: async () => {
const response = await fetch(`/api/activities/${userId}`);
if (!response.ok) {
throw new Error('Failed to fetch activities');
}
return response.json();
},
staleTime: 5 * 60 * 1000, // Consider data stale after 5 minutes
cacheTime: 30 * 60 * 1000, // Keep in cache for 30 minutes
});
}
// Component using the hook
function ActivityList({ userId }: { userId: string }) {
const { data, isLoading, error } = useAthleteActivities(userId);
if (isLoading) return <ActivitySkeleton />;
if (error) return <ErrorBoundary error={error} />;
return (
<div>
{data.activities.map(activity => (
<ActivityCard
key={activity.id}
activity={activity}
/>
))}
</div>
);
}
View the complete React Query implementation โ
Looking Back and Forward
This refactoring journey transformed not just Enduro Stats, but my approach to development. What started as a simple API wrapper became a lesson in:
- The importance of proper authentication and data security
- The value of well-designed data models and persistence
- The power of modern data management tools like React Query
- The necessity of professional development practices
Most importantly, it taught me that "making it work" is just the beginning. Real-world applications require careful thought about:
- Data synchronization and integrity
- User experience and performance
- Code maintainability and testing
- Professional development workflows
These lessons continue to influence how I approach new projects, always starting with a solid foundation rather than just making it work.
The Road Ahead
While the refactoring journey has significantly improved Enduro Stats, there are still exciting possibilities ahead:
-
Advanced Analytics:
- Machine learning for performance prediction
- Training load optimization
- Race pace recommendations
-
Enhanced Sync:
- Real-time activity updates
- Multi-device synchronization
- Offline support
-
Community Features:
- Activity sharing
- Training plan collaboration
- Group challenges
The foundation we've built through this refactoring makes these features possible, proving that sometimes taking a step back to rebuild is the fastest way forward.
Explore the Code
Interested in diving deeper into the implementation? The complete source code for both the original and refactored versions of Enduro Stats is available on GitHub:
๐ View the full project on GitHub
Key files to explore:
- Authentication implementation - OAuth flow and session management
- Data synchronization logic - Strava API integration and conflict resolution
- React Query hooks - Data fetching and state management
- Database schema - Supabase table definitions and relationships
The project is actively being refactored, so you might see work-in-progress improvements and experiments as I continue to enhance the platform.
What's Next in This Series
This post is the first in a multi-part series documenting my journey of transforming Enduro Stats from a simple API wrapper into a robust analytics platform. Think of it as my public learning log โ a way to capture the challenges, discoveries, and "aha!" moments that come with real-world development.
Coming up in future posts:
๐ "Building Resilient Data Sync" - Deep dive into handling network failures, data conflicts, and building bulletproof background sync processes
โก "Performance Optimization Chronicles" - From slow API calls to sub-second load times: caching strategies, database optimization, and React Query mastery
๐งช "Testing a Complex Data Pipeline" - How to test OAuth flows, API integrations, and data synchronization without losing your sanity
๐จ "UI/UX Decisions That Matter" - Designing intuitive analytics dashboards and handling complex user flows
Each post will include real code examples, the problems I encountered, and honest reflections on what worked (and what didn't). My goal is to document not just the final solutions, but the messy, iterative process of getting there.
Want to follow along? The complete codebase is public, so you can see the evolution in real-time. I'll be sharing updates on new posts and major refactoring milestones.
This is how we learn in public โ one commit, one lesson, one blog post at a time.