<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Salah Benkhanous</title>
    <description>The latest articles on DEV Community by Salah Benkhanous (@salah_benkhanous_5c14c350).</description>
    <link>https://dev.to/salah_benkhanous_5c14c350</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3764104%2F9e228ec6-31e9-417b-8ea5-bcd48246ab0b.png</url>
      <title>DEV Community: Salah Benkhanous</title>
      <link>https://dev.to/salah_benkhanous_5c14c350</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/salah_benkhanous_5c14c350"/>
    <language>en</language>
    <item>
      <title>Building AppTracer: a spec-driven time tracker with Angular, Spring Boot and an AI assistant</title>
      <dc:creator>Salah Benkhanous</dc:creator>
      <pubDate>Fri, 13 Nov 2026 00:03:34 +0000</pubDate>
      <link>https://dev.to/salah_benkhanous_5c14c350/building-apptracer-a-spec-driven-time-tracker-with-angular-spring-boot-and-an-ai-assistant-3goa</link>
      <guid>https://dev.to/salah_benkhanous_5c14c350/building-apptracer-a-spec-driven-time-tracker-with-angular-spring-boot-and-an-ai-assistant-3goa</guid>
      <description>&lt;p&gt;&lt;em&gt;Angular 22, Spring Boot 3 and PostgreSQL, ten functional specifications, and an AI coding assistant kept on a short leash&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1o1n8ftp4j8c54iyf0az.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1o1n8ftp4j8c54iyf0az.gif" alt="AppTracer: logging time and viewing the team's week" width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The result is &lt;strong&gt;AppTracer&lt;/strong&gt;, open source under the MIT license:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://apptracer-demo.onrender.com" rel="noopener noreferrer"&gt;https://apptracer-demo.onrender.com&lt;/a&gt; (demo accounts are listed in the README, data reset every night)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code:&lt;/strong&gt; &lt;a href="https://github.com/SalaheddineBns/apptracer" rel="noopener noreferrer"&gt;https://github.com/SalaheddineBns/apptracer&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This article is less about the features than about &lt;em&gt;how&lt;/em&gt; 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the app does
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Employees&lt;/strong&gt; 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…).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Managers&lt;/strong&gt; get a read-only view of their team's week, member by member.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Administrators&lt;/strong&gt; manage accounts, teams, job titles, activity types, projects and the length of a standard day.&lt;/li&gt;
&lt;li&gt;Everyone only sees &lt;strong&gt;the projects of their own team&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The whole interface is available in &lt;strong&gt;French and English&lt;/strong&gt;, works with the keyboard and screen readers, and is usable from 375 px wide.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Start from the rules, not the screens
&lt;/h2&gt;

&lt;p&gt;Before writing any code for a feature, I wrote its functional specification. Each one contains two things (the prefixes come from French: &lt;strong&gt;RG&lt;/strong&gt; for &lt;em&gt;règle de gestion&lt;/em&gt;, business rule, and &lt;strong&gt;CA&lt;/strong&gt; for &lt;em&gt;critère d'acceptation&lt;/em&gt;, acceptance criterion):&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Business rules&lt;/strong&gt;, each with an ID. For example:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;RG-01-06: a time entry cannot overlap another entry of the same user.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Acceptance criteria&lt;/strong&gt;, written as &lt;em&gt;Given / When / Then&lt;/em&gt; with real dates and values:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Given an entry on Monday 5 October from 09:00 to 10:00,&lt;br&gt;
when the same user logs 09:30–10:30 that day,&lt;br&gt;
then the entry is refused with an overlap message.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Ten specifications later, this gives the project a property I now find hard to live without: &lt;strong&gt;every rule ID is cited in the code, and every acceptance criterion has a test named after it.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@DisplayName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"CA-01-04 : 09:30-10:30 sur une saisie 09:00-10:00 donne 409 RG-01-06 avec la date"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Test names are in French in the code base: the acceptance criterion ID is what links them to the specification.)&lt;/p&gt;

&lt;p&gt;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?".&lt;/p&gt;




&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Front end:&lt;/strong&gt; Angular 22 with standalone components and signals, PrimeNG and Tailwind CSS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Back end:&lt;/strong&gt; Spring Boot 3 on Java 21, Spring Security, Flyway migrations, PostgreSQL 16.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contract first:&lt;/strong&gt; the OpenAPI contract is exported from the back end, and the TypeScript client used by Angular is &lt;em&gt;generated&lt;/em&gt; from it. Nobody edits the client by hand, so the front and back end cannot silently drift apart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authentication:&lt;/strong&gt; short-lived JWT access tokens, and refresh tokens that can be used only once (each renewal invalidates the previous one).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tests:&lt;/strong&gt; 600+ back-end tests (JUnit 5, Testcontainers on a real PostgreSQL) and 500+ front-end tests (Vitest).&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Three problems worth sharing
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Overlaps under concurrency
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The real safety net is in the database: a &lt;strong&gt;PostgreSQL exclusion constraint&lt;/strong&gt; on the entry's time range, per user. Here is the actual migration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="no"&gt;EXTENSION&lt;/span&gt; &lt;span class="k"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;btree_gist&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;time_entries&lt;/span&gt;
  &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;occupied&lt;/span&gt; &lt;span class="n"&gt;tsrange&lt;/span&gt; &lt;span class="no"&gt;GENERATED&lt;/span&gt; &lt;span class="no"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;granularity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'HOUR'&lt;/span&gt;
      &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="nf"&gt;tsrange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start_date&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;start_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start_date&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;end_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'[)'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="nf"&gt;tsrange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start_date&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;end_date&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'[)'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;END&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="no"&gt;STORED&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;time_entries&lt;/span&gt;
  &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;ex_time_entries_no_overlap&lt;/span&gt;
  &lt;span class="no"&gt;EXCLUDE&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;gist&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;occupied&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deleted_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter here. &lt;code&gt;btree_gist&lt;/code&gt; is needed so that a GiST index can handle the plain equality on &lt;code&gt;user_id&lt;/code&gt;. And the &lt;code&gt;'[)'&lt;/code&gt; bounds make the end of a range exclusive, so 09:00–10:00 followed by 10:00–11:00 is allowed.&lt;/p&gt;

&lt;p&gt;A generated column turns every entry into a time range: an hour slot for HOUR, whole days for DAY and RANGE (weekends included). The &lt;code&gt;WHERE&lt;/code&gt; clause means a deleted entry no longer blocks anything.&lt;/p&gt;

&lt;p&gt;The service check gives a clear error message in the normal case; the constraint guarantees correctness in the rare one. A dedicated test, &lt;em&gt;CA-01-04 : deux requêtes simultanées, la contrainte SQL donne aussi 409 RG-01-06&lt;/em&gt;, fires two simultaneous requests and checks that the constraint answers with the same 409 and the same rule ID as the service check.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Durations that must never change after the fact
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So the duration is &lt;strong&gt;computed and frozen when the entry is saved&lt;/strong&gt;, together with the standard day in force at that moment. Changing the setting only affects new entries; history is never rewritten.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. A test that only failed in CI
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The reason: on Linux, &lt;code&gt;Instant.now()&lt;/code&gt; returns &lt;strong&gt;nanosecond&lt;/strong&gt; precision, while on macOS it stops at microseconds. PostgreSQL stores &lt;strong&gt;microseconds&lt;/strong&gt;. On Linux, the value read back was truncated, so the two were not equal.&lt;/p&gt;

&lt;p&gt;The fix was to compare with the value as stored, not with the one in memory (truncating with &lt;code&gt;truncatedTo(ChronoUnit.MICROS)&lt;/code&gt; 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Deploying for free
&lt;/h2&gt;

&lt;p&gt;The demo runs on free tiers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One Docker image:&lt;/strong&gt; Spring Boot serves the built Angular app itself, so there is a single URL and no CORS setup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Render&lt;/strong&gt; runs the container, and &lt;strong&gt;Neon&lt;/strong&gt; hosts PostgreSQL.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;GitHub Actions&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The only trade-off is the free tier's cold start: the first visit after a quiet period can take about a minute.&lt;/p&gt;




&lt;h2&gt;
  
  
  How it was built
&lt;/h2&gt;

&lt;p&gt;I wrote the specifications, made the architecture decisions and reviewed every step. An AI coding assistant wrote most of the code from those specifications.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try it, and contribute
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Demo:&lt;/strong&gt; &lt;a href="https://apptracer-demo.onrender.com" rel="noopener noreferrer"&gt;https://apptracer-demo.onrender.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code:&lt;/strong&gt; &lt;a href="https://github.com/SalaheddineBns/apptracer" rel="noopener noreferrer"&gt;https://github.com/SalaheddineBns/apptracer&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The repository has a contributing guide, issue templates and a few &lt;strong&gt;good first issues&lt;/strong&gt; (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.&lt;/p&gt;

</description>
      <category>angular</category>
      <category>springboot</category>
      <category>java</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
