Today I spent time debugging and fixing the Fitbit integration on laverty.io, then built out the first visualization of that data.
Here’s what happened and what I learned.
The problem: sync was failing
After successfully connecting my Fitbit account via OAuth, clicking “Sync Fitbit Data” would fail with a generic error message on the frontend.
Looking at the production logs via wrangler pages deployment tail, I could see the real issue:
(error) Fitbit sync error: Error: Failed to fetch Fitbit weight data
The sync endpoint was throwing an error when trying to fetch weight data from Fitbit’s API.
Finding the root cause
I opened up the sync endpoint code at apps/web/src/pages/api/fitbit/sync.ts and found the problem immediately.
The code was constructing API URLs like this:
let url = `https://api.fitbit.com/1/user/-/${dataType}/date/${start}/${end}.json`;
if (dataType === 'sleep') {
url = `https://api.fitbit.com/1.2/user/-/sleep/date/${start}/${end}.json`;
}
This meant:
- Weight endpoint:
/1/user/-/weight/date/{start}/{end}.json - Heart rate endpoint:
/1/user/-/heartrate/date/{start}/{end}.json
Both were wrong.
I checked Fitbit’s official Web API documentation and found the correct endpoint formats:
- Weight:
/1/user/-/body/log/weight/date/{start}/{end}.json - Heart rate:
/1/user/-/activities/heart/date/{start}/{end}.json
The weight endpoint was missing /body/log/, and the heart rate endpoint was missing /activities/ and using the wrong resource name entirely.
The fix
I replaced the conditional logic with a proper switch statement to make the endpoint construction explicit:
let url: string;
switch (dataType) {
case 'weight':
url = `https://api.fitbit.com/1/user/-/body/log/weight/date/${start}/${end}.json`;
break;
case 'heartrate':
url = `https://api.fitbit.com/1/user/-/activities/heart/date/${start}/${end}.json`;
break;
case 'sleep':
url = `https://api.fitbit.com/1.2/user/-/sleep/date/${start}/${end}.json`;
break;
default:
throw new Error(`Unknown data type: ${dataType}`);
}
This made the code:
- more explicit
- easier to maintain
- harder to break accidentally
After pushing the fix, syncing worked immediately. The endpoint successfully pulled 30 days of weight, heart rate, and sleep data from Fitbit and stored it in the D1 database.
Building the data table
With the sync working, I wanted to actually see the data.
The heatmap page at /fitcypher/heatmap was just a placeholder, so I decided to turn it into a simple data table.
Creating the API endpoint
First, I created a new endpoint at apps/web/src/pages/api/fitness/data.ts:
export const GET: APIRoute = async (context) => {
// Check authentication
const sessionCookie = context.cookies.get('__session')?.value;
if (!sessionCookie) {
return new Response(JSON.stringify({ error: 'Not authenticated' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
// Get user ID from header
const userId = context.request.headers.get('X-User-Id');
if (!userId) {
return new Response(JSON.stringify({
error: 'User ID not provided'
}), { status: 400 });
}
const db = context.locals.runtime.env.DB;
// Fetch fitness data ordered by date descending
const results = await db.prepare(
`SELECT date, weight_kg, heart_rate_bpm, sleep_minutes, synced_at
FROM fitness_data
WHERE user_id = ?
ORDER BY date DESC`
).bind(userId).all();
return new Response(JSON.stringify({
success: true,
data: results.results || []
}), { status: 200 });
};
This endpoint:
- requires authentication via Clerk
- takes the user ID from a request header
- queries the D1 database for that user’s fitness data
- returns it ordered by date (newest first)
Building the frontend table
Next, I updated the heatmap page to fetch and display this data.
The key parts:
Authentication using Clerk:
async function getClerkUserId(): Promise<string | null> {
if (clerkUserId) return clerkUserId;
// Wait for Clerk to load
for (let i = 0; i < 50; i++) {
const user = (window as any).Clerk?.user;
if (user?.id) {
clerkUserId = user.id;
return clerkUserId;
}
await new Promise(resolve => setTimeout(resolve, 100));
}
return null;
}
This pattern waits for Clerk to initialize on the page, then retrieves the current user’s ID.
Fetching and displaying the data:
const userId = await getClerkUserId();
const response = await fetch('/api/fitness/data', {
headers: { 'X-User-Id': userId }
});
const result = await response.json();
const data = result.data || [];
// Build table HTML dynamically
let tableHTML = `
<table class="fitness-table">
<thead>
<tr>
<th>Date</th>
<th>Sleep (hours)</th>
<th>Weight (kg)</th>
<th>Heart Rate (bpm)</th>
</tr>
</thead>
<tbody>
`;
data.forEach((row: any) => {
const sleepHours = row.sleep_minutes ? (row.sleep_minutes / 60).toFixed(1) : '-';
const weight = row.weight_kg ? row.weight_kg.toFixed(1) : '-';
const heartRate = row.heart_rate_bpm || '-';
tableHTML += `
<tr>
<td>${row.date}</td>
<td>${sleepHours}</td>
<td>${weight}</td>
<td>${heartRate}</td>
</tr>
`;
});
tableHTML += `</tbody></table>`;
container.innerHTML = tableHTML;
The table:
- converts sleep from minutes to hours (e.g., 450 minutes → 7.5 hours)
- displays weight to one decimal place
- shows heart rate as-is
- handles missing values gracefully with ”-“
What’s working now
After deploying these changes:
✅ Fitbit sync pulls weight, heart rate, and sleep data correctly ✅ Data is stored in the D1 database with proper user association ✅ The heatmap page displays a clean table of all synced data ✅ Newest entries appear at the top ✅ Missing data points show as ”-” instead of breaking the UI
What this enables
Now that the data pipeline is working:
- syncing happens reliably
- data is stored in a queryable format
- visualization is straightforward
From here, I can:
- add charts and graphs
- build trend analysis
- create custom metrics
- experiment with different visualizations
The table is intentionally simple — it’s a foundation, not a finished product.
Lessons from debugging
1. Check the API documentation first
I assumed the endpoint paths were correct, but they weren’t. A quick look at Fitbit’s docs would have saved debugging time.
2. Explicit is better than clever
The switch statement is longer than the original conditional logic, but it’s much clearer what each endpoint should be.
3. Logs are essential
Without wrangler pages deployment tail, I would have been guessing at what went wrong. Production logs showed the exact error immediately.
4. Test in production
Local development worked fine because I hadn’t tried syncing yet. The bug only appeared when hitting Fitbit’s real API in production.
Next steps
The fitness tracking system is now functional, but there’s room to expand:
- add more Fitbit metrics (steps, active minutes, calories)
- build actual heatmap visualizations (not just a table)
- create weekly/monthly summaries
- add goal tracking
- implement data export
For now, though, the core integration works, and the data is accessible.
More updates to come.
Comments (...)
Please sign in to leave a comment.
Loading comments...