🛠️ Stack
| Layer | Technology |
|---|---|
| Frontend | React, React Router, Mantine UI, TanStack Query |
| Backend | Node.js, Express, PostgreSQL, node-postgres |
| Security | Better-Auth, Zod |
| Testing | Vitest, React Testing Library, Mock Service Worker, Supertest |
| Deployment | Railway |
A workout tracker app I built for my personal use and as a portfolio project. Not vibe-coded. It is the first full-stack app I built since completing The Odin Project's Full Stack JavaScript curriculum in early Jan 2026.
Live Link (Refresh the page if there is an "Application failed to respond" error.)
Core Features
- User authentication
- Workout logging
- Progress charts
Preview
Preview.mp4
The Challenge: When a user deletes a set (e.g., Set 2 of 4), simply removing the row creates a "gap" in the numbering.
The Solution: I implemented a PostgreSQL Transaction that deletes a set and automatically re-indexes the remaining sets in that exercise.
Details
export async function deleteSet(userId: string, setId: string) {
const client = await db.connect();
try {
await client.query('BEGIN');
const infoRes = await client.query(
`SELECT s.exercise_id, s.set_number FROM sets s
JOIN exercises e ON s.exercise_id = e.id
JOIN workouts w ON e.workout_id = w.id
WHERE s.id = $1 AND w.user_id = $2`,
[setId, userId]
);
if (infoRes.rows.length === 0) throw new Error("Set not found or unauthorized");
const { exercise_id, set_number } = infoRes.rows[0];
await client.query('DELETE FROM sets WHERE id = $1', [setId]);
await client.query(
`UPDATE sets
SET set_number = set_number - 1
WHERE exercise_id = $1 AND set_number > $2`,
[exercise_id, set_number]
);
await client.query('COMMIT');
return { success: true };
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}The Challenge: I didn't want to fetch a user's entire workout history in one go because it wastes bandwidth and makes the initial load feel sluggish. On the flip side, I didn't want 50 different child components each firing off their own GET requests, which is inefficient and can result in a jittery UI.
The Solution:
Cursor Pagination + Infinite Scroll
I implemented cursor-based pagination to handle loading a user's workout history. This allows the app to fetch workouts in discrete batches of 10, ensuring the "Infinite Scroll" stays performant even as a user's history grows into the thousands.
// Backend: Cursor Logic using UUID comparison
export async function getAllWorkouts(userId: string, cursor: string | null) {
const limit = 10;
const startingId = (cursor) ? cursor : 'ffffffff-ffff-ffff-ffff-ffffffffffff';
const query = `... WHERE w.user_id = $1 AND w.id < $2 ORDER BY created_at DESC LIMIT $3`;
const { rows } = await db.query(query, [userId, startingId, limit + 1]);
const hasNextPage = rows.length > limit;
const itemsToReturn = hasNextPage ? rows.slice(0, -1) : rows;
const nextCursor = hasNextPage ? itemsToReturn[itemsToReturn.length - 1].id : null;
return { itemsToReturn, nextCursor };
}
// Frontend: React Query Integration
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ['workouts'],
queryFn: ({ pageParam }) => fetch(`/api/workouts?cursor=${encodeURIComponent(pageParam)}`),
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.nextCursor,
});Parent-Level Fetching
To avoid hitting the server for every component, I made strategic "mid-sized" fetches at the parent level. I then passed that data down as props and performed client-side transformations as needed, supplementing with smaller, surgical requests only when necessary.
"Local-First" State
I configured my Queries to have a global staleTime of Infinity. This effectively turned the browser cache into a "Local Database," ensuring the UI only updates when data actually changes after successful mutations like adding a set.
// global QueryClient configuration
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity, // Prevent unnecessary background re-fetches
},
},
});
// Invalidation after a mutation
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['workouts', workoutId] });
}
The Challenge: Users should not be able to access the workout data of others.
The Solution: I implemented a strict ownership verification layer at the database level. By joining every sensitive query with the user_id from the secure session, the database itself acts as a firewall. If a user tries to access an ID that doesn't belong to them, the query simply returns zero rows, preventing unauthorized data leaks or modifications.
Details
// workoutModel.ts
export async function getWorkout(userId: string, workoutId: string) {
const query = 'SELECT * FROM workouts WHERE user_id = $1 AND id = $2';
const { rows } = await db.query(query, [userId, workoutId]);
return rows[0];
};
//workoutController.ts
export async function getWorkout(req: Request, res: Response) {
try {
const { workoutId } = req.params;
const userId = req.user.id;
const workout = await WorkoutModel.getWorkout(userId, workoutId as string);
if (!workout) return res.status(404).json({ message: "Workout not found or unauthorised" });
res.status(200).json(workout);
} catch (error) {
res.status(500).json({ message: "Internal server error" });
}
}The Challenge: Maintaining consistency between frontend UI errors and backend API responses.
The Solution: I used shared Zod schemas. By importing the same validation logic into both the React components and the Express controllers, I ensured the frontend never attempts to send data that the server won't accept.
Details
// 1. Shared Schema
export const WorkoutNameSchema = z.object({
name: z.coerce.string().trim().min(1).transform(titleCase)
});
// 2. Frontend Usage (Mantine Input)
onBlur={(e) => {
const result = WorkoutNameSchema.safeParse({ name: e.currentTarget.value });
if (!result.success) return setNameError(true);
mutation.mutate(result.data.name);
}}
// 3. Backend Usage (Express Controller)
const result = WorkoutNameSchema.safeParse(req.body);
if (!result.success) return res.status(400).json({ message: result.error.issues[0].message });Unit Test Example
describe('SetCard Component', () => {
const defaultProps = {
id: 'current-set-id',
set_number: 1,
reps: 10,
weight_kg: 100,
workout_id: 'workout-123',
prevExerciseSet: {
id: 'prev-set-id',
set_number: 1,
reps: 8,
weight_kg: 95.5,
},
updateSetField: vi.fn(),
deleteSet: vi.fn(),
};
...
it('prevents the user to leave reps empty', async () => {
const user = userEvent.setup();
render(<SetCard {...defaultProps} />);
const repsInput = screen.getByLabelText('Reps');
await user.clear(repsInput);
await user.tab();
expect(repsInput).toBeInvalid();
});
});Integration Test Example
const createTestQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
const mockExercise = {
id: 'current-ex-123',
name: 'Bench Press',
created_at: new Date().toISOString(),
workout_id: 'workout-abc',
sets: [
{ id: 'set-1', set_number: 1, reps: 8, weight_kg: 70 }
]
};
describe('ExerciseCard', () => {
...
it('calls the delete mutation with the right set id when a set delete button is clicked', async () => {
const user = userEvent.setup();
const queryClient = createTestQueryClient();
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
let capturedSetId: string;
server.use(
http.delete('/api/workouts/:workoutId/exercises/:exerciseId/sets/:setId', ({ params }) => {
capturedSetId = params.setId as string;
return HttpResponse.json({ status: 200 });
})
);
render(
<QueryClientProvider client={queryClient}>
<ExerciseCard {...mockExercise} />
</QueryClientProvider>
);
await screen.findByLabelText('Set Number')
await user.click(screen.getByLabelText('Set Number'));
await user.click(screen.getByRole('menuitem', { name: /delete set/i }));
await user.click(screen.getByRole('button', { name: /^delete$/i }));
await waitFor(() => {
expect(capturedSetId).toBe(mockExercise.sets[0].id);
})
expect(invalidateSpy).toHaveBeenCalledWith(expect.objectContaining({ queryKey: ['exercises'] }));
expect(invalidateSpy).toHaveBeenCalledWith(expect.objectContaining({ queryKey: ['workouts', mockExercise.workout_id] }));
});
});