T/T
Back to work

Client work · 2026 · In production

Academia Combate de Gaia

Raça. Humildade. Audácia.

The academy home page: the three values set large in condensed uppercase against a near-black ground, a free trial class button and a link to the timetable beneath them, and the crest to the right.
The public site. The academy's colours are black and white, so hierarchy comes from weight, case and tracking.

What it is

Academia Combate de Gaia is a nonprofit combat sports academy in Portugal. They have around 400 registered members, and up to 200 people train there every day.

I built a platform that runs the whole academy in one place. It has a public website with schedules and news, a member portal for booking classes and paying dues, and an admin panel for the people who run the gym. It has been live since the summer. Monthly charges now go out on their own every night, and members pay by MB Way or Multibanco.

This started as freelance work for two people. I owned the frontend and built the design system, every screen on all three surfaces, and the integration with the data. Another developer handled the backend, and a few months in he stopped working on it, so I picked up the backend and the infrastructure too. Around the same time I needed an internship for a course I was taking, so instead of looking elsewhere I stayed here. Freelance work in April 2026 became my internship in July, which let me work on it full time.

The problem

Before this, the academy was juggling three separate services that didn't talk to each other. One for members, one for booking, one for payments. The same person existed three times over, and the admin had to match them up by hand.

There were two big headaches. First, members couldn't book classes in advance, because the system only checked if they had paid this month, not what their subscription actually covered. Second, the old membership platform owned the academy's domain name.

So the real goal wasn't "build a website." It was moving everything to a system the academy owned.

The decisions

Designing in black and white

The client required the public site to be strictly black and white. No background colors for sections or cards, so hierarchy had to come out of typography: size, weight, and spacing. Apart from the crest, the only color on the public site is gold, silver, and bronze on the competition results page.

The constraint made the design easier, not harder. My one addition was a diagonal seam between sections, which matches the stripe on the academy's team uniforms.

The competition record

The academy had five years of competition results scattered around. I built a simple page that tracks medals by competition and by year.

It became the most popular part of the site. The owner liked it enough to recreate it on the academy's Instagram, and he has talked about painting the records on the gym wall. A plain table of data beat every flashy feature on the site.

The competition record page: a table of championships with the years they span and counts of gold, silver and bronze medals.
The competition record. Apart from the crest, this is the only place on the public site where color appears.

Trusting the code, not the docs

The frontend and backend were supposed to follow a written API contract, and in practice the documentation drifted. A form would send Portuguese field names when the backend expected English, or an endpoint would fail if I sent an explicit null instead of leaving the field out. I stopped trusting the documentation and verified every endpoint against the actual backend code. Next time I'd push for generated types so the two can't drift in the first place.

Testing, and the rules nobody wrote

I set up the test strategy: contract tests between the frontend and backend, and a crawler that checks for broken links and accessibility problems. Zero errors before anything shipped.

A week before launch a new requirement came in, and instead of building it straight away I read the backend to see how the related rules worked. Three rules from the original brief had never been written. Nothing checked whether the mandatory annual membership was paid, there was no time check so you could book a morning class late at night, and weekly class limits couldn't be expressed at all.

They had been missing for months with every test passing, because you can't fail a test for a feature nobody wrote. I fixed all three that day. A green suite proves the code you wrote works. It says nothing about the code you forgot.

The admin panel dashboard in its light theme, showing summary figures, a list of overdue payments and the classes still waiting for attendance to be confirmed.
The admin panel, rebuilt in September. Light is now the default here and in the member portal. The names and figures shown are randomized.

Making it readable

I initially designed the member portal and admin panel with a dark theme, picking colors that looked fine on my own monitor. But the panel gets used at the gym, on a laptop next to a wall of windows, and low-opacity text washes out in daylight. A member also mentioned she couldn't easily read the booking calendar on her phone, and I realized I had relied too much on opacity for hierarchy.

Much of the interface used white text at 40% opacity, which measured at 3.8:1 against the background. That falls short of the 4.5:1 minimum required for body text. The fix was more than just bumping up font sizes. I replaced every loose opacity value with a set of named color tokens, ensuring the faintest text still cleared the accessibility minimum.

I also added a light theme and made it the default for both the portal and the admin panel. It was a good reminder that contrast is measurable, and I should have run those numbers before writing the CSS.

What I'd do differently

I'd walk the brief against real behavior much earlier. Testing the core requirements by hand against the running app would have caught those three missing rules months sooner.

I'd get a browser into the loop sooner. I did a whole pass on mobile issues by reading class names and reasoning about the layout instead of rendering it, and when I finally opened it on my phone neither problem was fixed. Visual work isn't done until you look at it on a real screen.

And I'd plan the data migration alongside the schema instead of after it. You can't write the rules for migrated data before you know what that data looks like.

More work on the home page, or the full history on the CV.