Angular 22, Spring Boot 3 and PostgreSQL, ten functional specifications, and an AI coding assistant kept on a short leash
Most teams still track their time in a spreadsheet that nobody enjoys filling in. I wanted to build the opposite: a small, focused app where logging a day takes seconds and a manager sees the team's week at a glance.
The result is AppTracer, open source under the MIT license:
- Live demo: https://apptracer-demo.onrender.com (demo accounts are listed in the README, data reset every night)
- Code: https://github.com/SalaheddineBns/apptracer
This article is less about the features than about how it was built: an AI coding assistant wrote most of the code, and what kept it on track was a set of specifications with numbered rules and tests named after them. Along the way, a few problems turned out to be more interesting than expected.
What the app does
- Employees log what they worked on by the hour, the whole day, or a range of days, choosing an activity type (project work, meeting, spec reading, code review…).
- Managers get a read-only view of their team's week, member by member.
- Administrators manage accounts, teams, job titles, activity types, projects and the length of a standard day.
- Everyone only sees the projects of their own team.
- The whole interface is available in French and English, works with the keyboard and screen readers, and is usable from 375 px wide.
Start from the rules, not the screens
Before writing any code for a feature, I wrote its functional specification. Each one contains two things (the prefixes come from French: RG for règle de gestion, business rule, and CA for critère d'acceptation, acceptance criterion):
Business rules, each with an ID. For example:
RG-01-06: a time entry cannot overlap another entry of the same user.
Acceptance criteria, written as Given / When / Then with real dates and values:
Given an entry on Monday 5 October from 09:00 to 10:00,
when the same user logs 09:30–10:30 that day,
then the entry is refused with an overlap message.
Ten specifications later, this gives the project a property I now find hard to live without: every rule ID is cited in the code, and every acceptance criterion has a test named after it.
@DisplayName("CA-01-04 : 09:30-10:30 sur une saisie 09:00-10:00 donne 409 RG-01-06 avec la date") (Test names are in French in the code base: the acceptance criterion ID is what links them to the specification.)
When a rule changes, searching for its ID finds the specification, the implementation and the tests in one go. Reviewing a change becomes a question of "does this match RG-xx-yy?" rather than "does this look right?".
The stack
- Front end: Angular 22 with standalone components and signals, PrimeNG and Tailwind CSS.
- Back end: Spring Boot 3 on Java 21, Spring Security, Flyway migrations, PostgreSQL 16.
- Contract first: the OpenAPI contract is exported from the back end, and the TypeScript client used by Angular is generated from it. Nobody edits the client by hand, so the front and back end cannot silently drift apart.
- Authentication: short-lived JWT access tokens, and refresh tokens that can be used only once (each renewal invalidates the previous one).
- Tests: 600+ back-end tests (JUnit 5, Testcontainers on a real PostgreSQL) and 500+ front-end tests (Vitest).
Three problems worth sharing
1. Overlaps under concurrency
The service checks that a new entry does not overlap an existing one. That works, until two requests arrive at the same moment: both read the database, both see no conflict, both save.
The real safety net is in the database: a PostgreSQL exclusion constraint on the entry's time range, per user. Here is the actual migration:
CREATE EXTENSION IF NOT EXISTS btree_gist; ALTER TABLE time_entries ADD COLUMN occupied tsrange GENERATED ALWAYS AS ( CASE WHEN granularity = 'HOUR' THEN tsrange(start_date + start_time, start_date + end_time, '[)') ELSE tsrange(start_date::timestamp, (end_date + 1)::timestamp, '[)') END ) STORED; ALTER TABLE time_entries ADD CONSTRAINT ex_time_entries_no_overlap EXCLUDE USING gist (user_id WITH =, occupied WITH &&) WHERE (deleted_at IS NULL); Two details matter here. btree_gist is needed so that a GiST index can handle the plain equality on user_id. And the '[)' bounds make the end of a range exclusive, so 09:00–10:00 followed by 10:00–11:00 is allowed.
A generated column turns every entry into a time range: an hour slot for HOUR, whole days for DAY and RANGE (weekends included). The WHERE clause means a deleted entry no longer blocks anything.
The service check gives a clear error message in the normal case; the constraint guarantees correctness in the rare one. A dedicated test, CA-01-04 : deux requêtes simultanées, la contrainte SQL donne aussi 409 RG-01-06, fires two simultaneous requests and checks that the constraint answers with the same 409 and the same rule ID as the service check.
2. Durations that must never change after the fact
A "day" is not always 8 hours: the length of a standard day is an admin setting. If an admin changes it from 8 h to 7 h, should last month's entries suddenly be shorter? Clearly not.
So the duration is computed and frozen when the entry is saved, together with the standard day in force at that moment. Changing the setting only affects new entries; history is never rewritten.
3. A test that only failed in CI
One persistence test compared a timestamp in memory with the same timestamp read back from the database. It passed on my Mac and failed on GitHub Actions.
The reason: on Linux, Instant.now() returns nanosecond precision, while on macOS it stops at microseconds. PostgreSQL stores microseconds. On Linux, the value read back was truncated, so the two were not equal.
The fix was to compare with the value as stored, not with the one in memory (truncating with truncatedTo(ChronoUnit.MICROS) before saving works too). It is a small bug, but a good reminder that "works on my machine" can hide in the last three digits of a timestamp.
Deploying for free
The demo runs on free tiers:
- One Docker image: Spring Boot serves the built Angular app itself, so there is a single URL and no CORS setup.
- Render runs the container, and Neon hosts PostgreSQL.
- A GitHub Actions job resets the demo every night: it drops the schema and redeploys, and the app recreates the demo accounts, a team and two weeks of sample entries at start-up.
The only trade-off is the free tier's cold start: the first visit after a quiet period can take about a minute.
How it was built
I wrote the specifications, made the architecture decisions and reviewed every step. An AI coding assistant wrote most of the code from those specifications.
What made this work was not the assistant on its own, but the structure around it: rules with IDs, acceptance criteria with concrete dates, a generated API client, and a test suite that fails loudly when something drifts from the specification. The specifications told the assistant what to build; the tests told me whether it did.
Try it, and contribute
The repository has a contributing guide, issue templates and a few good first issues (end-to-end tests, a one-command Docker setup, extra CI checks). Feedback, issues and pull requests are welcome, and if you find the project useful, a star on GitHub helps other developers find it.
