{"slug": "building-apptracer-a-spec-driven-time-tracker-with-angular-spring-boot-and-an-ai", "title": "Building AppTracer: a spec-driven time tracker with Angular, Spring Boot and an AI assistant", "summary": "A developer built AppTracer, an open-source MIT-licensed time-tracking app on Angular 22, Spring Boot 3 and PostgreSQL, using an AI coding assistant to write most of the code under a spec-driven workflow. Each of ten functional specifications pairs numbered business rules (RG) with Given/When/Then acceptance criteria (CA), and every rule ID is cited in code and matched by a test named after its criterion. Concurrency safety is enforced by a PostgreSQL exclusion constraint on a generated tsrange column, with a test firing two simultaneous requests to confirm the database returns the same 409 and rule ID as the service check.", "body_md": "*Angular 22, Spring Boot 3 and PostgreSQL, ten functional specifications, and an AI coding assistant kept on a short leash*\n\nMost 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.\n\nThe result is **AppTracer**, open source under the MIT license:\n\nThis 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.\n\nBefore 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):\n\n**Business rules**, each with an ID. For example:\n\nRG-01-06: a time entry cannot overlap another entry of the same user.\n\n**Acceptance criteria**, written as *Given / When / Then* with real dates and values:\n\nGiven an entry on Monday 5 October from 09:00 to 10:00,\n\nwhen the same user logs 09:30–10:30 that day,\n\nthen the entry is refused with an overlap message.\n\nTen 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.**\n\n```\n@DisplayName(\"CA-01-04 : 09:30-10:30 sur une saisie 09:00-10:00 donne 409 RG-01-06 avec la date\")\n```\n\n(Test names are in French in the code base: the acceptance criterion ID is what links them to the specification.)\n\nWhen 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?\".\n\nThe 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.\n\nThe real safety net is in the database: a **PostgreSQL exclusion constraint** on the entry's time range, per user. Here is the actual migration:\n\n```\nCREATE EXTENSION IF NOT EXISTS btree_gist;\n\nALTER TABLE time_entries\n  ADD COLUMN occupied tsrange GENERATED ALWAYS AS (\n    CASE WHEN granularity = 'HOUR'\n      THEN tsrange(start_date + start_time, start_date + end_time, '[)')\n      ELSE tsrange(start_date::timestamp, (end_date + 1)::timestamp, '[)')\n    END\n  ) STORED;\n\nALTER TABLE time_entries\n  ADD CONSTRAINT ex_time_entries_no_overlap\n  EXCLUDE USING gist (user_id WITH =, occupied WITH &&)\n  WHERE (deleted_at IS NULL);\n```\n\nTwo 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.\n\nA 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.\n\nThe 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.\n\nA \"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.\n\nSo 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.\n\nOne 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.\n\nThe 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.\n\nThe 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.\n\nThe demo runs on free tiers:\n\nThe only trade-off is the free tier's cold start: the first visit after a quiet period can take about a minute.\n\nI wrote the specifications, made the architecture decisions and reviewed every step. An AI coding assistant wrote most of the code from those specifications.\n\nWhat 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.\n\nThe 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.", "url": "https://wpnews.pro/news/building-apptracer-a-spec-driven-time-tracker-with-angular-spring-boot-and-an-ai", "canonical_source": "https://dev.to/salah_benkhanous_5c14c350/building-apptracer-a-spec-driven-time-tracker-with-angular-spring-boot-and-an-ai-assistant-3goa", "published_at": "2026-11-13 00:03:34+00:00", "updated_at": "2026-11-13 00:20:00.994384+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "ai-agents"], "entities": ["AppTracer", "Angular", "Spring Boot", "PostgreSQL", "btree_gist"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/building-apptracer-a-spec-driven-time-tracker-with-angular-spring-boot-and-an-ai", "markdown": "https://wpnews.pro/news/building-apptracer-a-spec-driven-time-tracker-with-angular-spring-boot-and-an-ai.md", "text": "https://wpnews.pro/news/building-apptracer-a-spec-driven-time-tracker-with-angular-spring-boot-and-an-ai.txt", "jsonld": "https://wpnews.pro/news/building-apptracer-a-spec-driven-time-tracker-with-angular-spring-boot-and-an-ai.jsonld"}}