Skip to content

darreleng/workout-app

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

133 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

WorkoutLogger

🛠️ 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

Technical Challenges & Solutions

1. Maintaining Logical Order in Relational Data

image

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();
	}
}

2. Data Architecture & State Management

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] });
}

3. Data Security

image

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" });
    }
}

4. Unified Validation & Single Source of Truth

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 });

Tests

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] }));
    });
});

About

Log your workouts and track your progress

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages