{"id":5960,"date":"2026-08-18T14:30:00","date_gmt":"2026-08-18T12:30:00","guid":{"rendered":"https:\/\/engel-wolf.com\/?p=5960"},"modified":"2026-11-08T21:22:04","modified_gmt":"2026-11-08T19:22:04","slug":"time-tracking-is-hell","status":"publish","type":"post","link":"https:\/\/engel-wolf.com\/?p=5960","title":{"rendered":"Time tracking is hell (I just spend less time there now)"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Time tracking is hell. Especially for developers. At the end of the day Jira wants to know exactly how many hours I spent on each ticket, and my memory wants to know why I&#8217;m asking it.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"720\" height=\"720\" src=\"https:\/\/engel-wolf.com\/wp-content\/uploads\/tech-time-tracking-is-hell-01-time-tracking-is-hell.jpg\" alt=\"Comic: a panicked developer with glasses and beard drags a huge alarm clock across the floor\" class=\"wp-image-5958 sew-ai\" srcset=\"https:\/\/engel-wolf.com\/wp-content\/uploads\/tech-time-tracking-is-hell-01-time-tracking-is-hell.jpg 720w, https:\/\/engel-wolf.com\/wp-content\/uploads\/tech-time-tracking-is-hell-01-time-tracking-is-hell-300x300.jpg 300w, https:\/\/engel-wolf.com\/wp-content\/uploads\/tech-time-tracking-is-hell-01-time-tracking-is-hell-150x150.jpg 150w, https:\/\/engel-wolf.com\/wp-content\/uploads\/tech-time-tracking-is-hell-01-time-tracking-is-hell-100x100.jpg 100w, https:\/\/engel-wolf.com\/wp-content\/uploads\/tech-time-tracking-is-hell-01-time-tracking-is-hell-120x120.jpg 120w\" sizes=\"auto, (max-width: 720px) 100vw, 720px\" \/><figcaption class=\"wp-element-caption\">Time tracking can be hell.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The irony is that my tools already know most of it. GitLab knows when I pushed to which repository and which commits mention which ticket. It knows which merge requests I was assigned to or reviewing. Jira knows which tickets I commented on. The whole day is there as an activity trail. I just never looked at it when filling in hours. So one Monday in July I asked Claude Code to turn that trail into a draft I only have to correct.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The before<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">I&#8217;m lazy about admin routines. I rarely log time daily. Sometimes weekly, sometimes much later. That morning I had set my team a goal of logging something on every working day, and our internal team dashboard (a small Next.js app with Jira-based evaluations) was about to show everybody&#8217;s missing days. Including mine. The first prompt of the session was harmless:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: plain; gutter: false; title: ; notranslate\" title=\"\">\nThis repo contains a tool in the &quot;evaluations&quot; route that allows to check the time tracking.\n\nI set my team a goal to track all workdays of the year\n\nSo we need to introduce to the table in &quot;hours tracking&quot;\n\n1) a calculation of workdays in the time-frame (taken from a clever api that can tell you holidays in Bavaria Germany). Workdays are only Mo, Tu, We, Th, Fr in our company.\n2) Show how many unlogged work-days there were. I want my co-workers to log at least on one ticket per day\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Claude picked a free public-holiday API and filtered it to holidays that are either nationwide or list the Bavarian region code. Then it counted. A few prompts later, after a &#8222;My Calendar&#8220; tab with my GitLab pushes per day, I saw my own number, and the real project started:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: plain; gutter: false; title: ; notranslate\" title=\"\">\nok. now that you see that. I have my gitlab pat in &amp;lt;env file&gt;\n\nCould you do me a favour and make a little proposal\n\nSo I have 102 missed days this year. I basically need a tool to show\n\n1  - headline day, subheadline proposed ticket id\n2 - proposed ticket summary\n3 - commits (number, max time diff between commits)\n4 - slider - hours to log on ticket\n\nThat way, I could have a view with all my unlogged days and create an output file, that allows me to log\n\neach day row should have a (+) icon that allows to add a row by selecting a ticket via dropdown. can we make this at a seperate route &quot;\/ai-time-track&quot;\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">102 days. In July. That is the number that makes you build tools. The prompt worked well because it describes the screen, not the algorithm: a row per day, what&#8217;s in the headline, what&#8217;s in the subheadline, a slider. Point 3, &#8222;max time diff between commits&#8220;, turned out to be the seed of the whole estimation heuristic.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Tightening the rules<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The first version proposed one ticket per day with an hour value nobody could actually book. Two short corrections fixed that:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: plain; gutter: false; title: ; notranslate\" title=\"\">\nsingle commit is at least 30min. slider should be at the proposed time rounded up to 30min slots. we can only do 30min\n<\/pre><\/div>\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: plain; gutter: false; title: ; notranslate\" title=\"\">\nright now we only have one line per day. But, I need one line per day + ticket. Of course each day can have 1 or 10 sub-rows.\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">These are the kind of domain rules an agent cannot know: our time tracking only accepts half-hour slots, and a day is rarely one ticket. Then I got suspicious about where the tickets came from at all and asked a question instead of giving an order:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: plain; gutter: false; title: ; notranslate\" title=\"\">\nHow do you determine all issues in the timeframe? My guess is - check all the user was assigned to + check all the user interacted with + all that were mentioned in commits ids...or what did you do?\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Asking &#8222;what did you do?&#8220; is underrated. The answer showed it only read branch names and the last commit of each push, which missed a lot. The result is the &#8222;scan depth&#8220; switch in the final tool.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The heuristic<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Here is how the estimate actually works. It is deliberately dumb, which is why I trust it.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Collect tickets.<\/strong> GitLab&#8217;s user events API returns my push events. Ticket keys are pulled out of branch names and commit messages with a plain regex, <code>[A-Z][A-Z0-9]+-\\d+<\/code>. &#8222;Fast&#8220; mode reads the branch and the last commit title, &#8222;Deep git&#8220; reads every commit message of a push via the compare endpoint (in parallel, cached), &#8222;Deep git + Jira&#8220; also pulls my Jira comments and changelog entries. Merge requests where I&#8217;m assignee or reviewer are always scanned; their title and description count for the day the MR was last updated.<\/li>\n\n\n\n<li><strong>Estimate the day.<\/strong> Take the time of the first and the last push of the day. The span between them, rounded <em>up<\/em> to 30-minute slots, is the day&#8217;s total. Any day with at least one commit gets at least 30 minutes.<\/li>\n\n\n\n<li><strong>Split across tickets.<\/strong> Each ticket gets a weight: its number of commits that day. Tickets that only show up in Jira or in a merge request get weight 1. The day&#8217;s hours are split by weight, rounded to half hours, minimum 30 minutes per ticket.<\/li>\n\n\n\n<li><strong>Skip what&#8217;s done.<\/strong> Days that already have a worklog get no proposal, so nothing is booked twice.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">In code, the core is just two small functions:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: jscript; title: ; notranslate\" title=\"\">\n\/\/ The day&#039;s total = push span rounded UP to 30-min slots, at least 30 min if there was a commit.\nfunction proposeDayHours(spanMinutes, commitCount) {\n  if (commitCount === 0) return 0;\n  return Math.max(0.5, Math.ceil(spanMinutes \/ 30) * 0.5);\n}\n\n\/\/ Split the day across its tickets, weighted by commit count, every ticket at least 30 min.\nfunction proposePerTicket(weights, dayHours) {\n  const entries = &#x5B;...weights.entries()].sort((a, b) =&gt; b&#x5B;1] - a&#x5B;1]);\n  const total = entries.reduce((s, &#x5B;, w]) =&gt; s + w, 0) || entries.length;\n  return entries.map((&#x5B;key, w]) =&gt; ({\n    key,\n    proposedHours: Math.max(0.5, Math.round((dayHours * w) \/ total \/ 0.5) * 0.5),\n  }));\n}\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Yes, this underestimates days full of meetings and overestimates days where I pushed at 9 and fixed a typo at 18:00. That&#8217;s what the sliders are for. The point is not to know the truth, it&#8217;s to give me a draft that&#8217;s 80 % right so I only touch the other 20 %.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1232\" height=\"928\" src=\"https:\/\/engel-wolf.com\/wp-content\/uploads\/tech-time-tracking-is-hell-02-midjourney-clock-original.jpg\" alt=\"Midjourney original: the same cartoon developer in full figure, wrestling the oversized clock\" class=\"wp-image-5959 sew-ai\" srcset=\"https:\/\/engel-wolf.com\/wp-content\/uploads\/tech-time-tracking-is-hell-02-midjourney-clock-original.jpg 1232w, https:\/\/engel-wolf.com\/wp-content\/uploads\/tech-time-tracking-is-hell-02-midjourney-clock-original-300x226.jpg 300w, https:\/\/engel-wolf.com\/wp-content\/uploads\/tech-time-tracking-is-hell-02-midjourney-clock-original-1024x771.jpg 1024w, https:\/\/engel-wolf.com\/wp-content\/uploads\/tech-time-tracking-is-hell-02-midjourney-clock-original-768x578.jpg 768w, https:\/\/engel-wolf.com\/wp-content\/uploads\/tech-time-tracking-is-hell-02-midjourney-clock-original-398x300.jpg 398w\" sizes=\"auto, (max-width: 1232px) 100vw, 1232px\" \/><figcaption class=\"wp-element-caption\">The Midjourney original, before the title box went on. The clock always wins.<\/figcaption><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\">One click to the worklog, and two errors on the way<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Downloading a CSV was nice. Not having to upload it anywhere was nicer:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: plain; gutter: false; title: ; notranslate\" title=\"\">\ncool can we now push this to the Tempo API? But this needs to happen per currently selected time band. And I need a modal that shows 2 tabs for checking\n\n1. Calendar view with little icon how many hours per each day\n2. Issue view - sum of hours per issue that will get submitted\n\nSubmission should of course happen properly with hours per day\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">The review modal is the important part. Before anything is booked I see the calendar with hours per day and a per-ticket sum. Then the first real submit failed, and then the second one:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: plain; gutter: false; title: ; notranslate\" title=\"\">\nI wanted to submit tempo_log.json and get back &quot;400 {&quot;errors&quot;:{&quot;worker&quot;:&quot;User is invalid&quot;}, ... &quot;reasons&quot;:&#x5B;&quot;VALIDATION_FAILED&quot;]}&quot;\n<\/pre><\/div>\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: plain; gutter: false; title: ; notranslate\" title=\"\">\n&quot;error&quot;: &quot;400 {&quot;errors&quot;:{&quot;TT Activity&quot;:&quot;Work attribute &#039;TT Activity&#039; (_TTActivity_) is required&quot;}, ... }&quot;\n<\/pre><\/div>\n\n\n<h3 class=\"wp-block-heading\">Polish, colleagues and the cloud<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The next morning I found the tool dropped days without any activity, which defeats the purpose:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: plain; gutter: false; title: ; notranslate\" title=\"\">\nthe tool sometimes looses days. if I have zero activity. can it please render all days\n\nin case it was public holiday in bavaria put &quot;public holiday&quot; under &amp;lt;X&gt; hours total\n\nAllow to have a &quot;None&quot; row with 0h. this then needs to get removed from logs\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Now every weekday shows up: holidays marked, already-logged days shown with their hours but no proposal, empty days with a &#8222;None&#8220; row I can fill in from a ticket dropdown.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The last surprise came when we wanted to run it as a shared web app: worklogs must be written with the user&#8217;s own Jira token, not the service token from the server&#8217;s env file. So the tool now asks each user for a Jira and a GitLab token in a modal and keeps them in the browser, while the team-wide evaluations still use the server token.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">From the first prompt to the merge request with per-user tokens took about a day and a half, in between other work. Six commits, roughly 1,300 lines in the page component, which is too many, but it&#8217;s an internal tool.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/engel-wolf.com\/wp-content\/uploads\/image-1.png\" rel=\"highslide\" class=\"highslide\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"1015\" src=\"https:\/\/engel-wolf.com\/wp-content\/uploads\/image-1-1024x1015.png\" alt=\"\" class=\"wp-image-5963 sew-ai\" srcset=\"https:\/\/engel-wolf.com\/wp-content\/uploads\/image-1-1024x1015.png 1024w, https:\/\/engel-wolf.com\/wp-content\/uploads\/image-1-150x150.png 150w, https:\/\/engel-wolf.com\/wp-content\/uploads\/image-1-300x297.png 300w, https:\/\/engel-wolf.com\/wp-content\/uploads\/image-1-100x100.png 100w, https:\/\/engel-wolf.com\/wp-content\/uploads\/image-1-303x300.png 303w, https:\/\/engel-wolf.com\/wp-content\/uploads\/image-1-120x120.png 120w, https:\/\/engel-wolf.com\/wp-content\/uploads\/image-1-768x761.png 768w, https:\/\/engel-wolf.com\/wp-content\/uploads\/image-1.png 1100w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\">Results and takeaways<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">My time tracking went from about ten minutes a day to about one. The bigger win is that I no longer depend on memory: when I catch up a week later, the history is already there. Could it book everything automatically? Probably. I don&#8217;t want it to. An AI-generated proposal plus a quick human review feels like the right balance, and the sliders keep me honest.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Describe the screen you want, not the algorithm. The heuristic emerged from &#8222;max time diff between commits&#8220;.<\/li>\n\n\n\n<li>State the domain rules early: booking granularity, one row per day and ticket, workdays, holidays.<\/li>\n\n\n\n<li>Ask the agent how it computed something. It will tell you, including what it skipped.<\/li>\n\n\n\n<li>Paste raw API errors. They are precise prompts.<\/li>\n\n\n\n<li>Keep a review step before anything writes to a system of record.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Time tracking is still hell. I just spend much less time there.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I had 102 unlogged workdays, so I asked Claude Code to turn my GitLab and Jira activity into proposed worklogs with sliders and a one-click Tempo submit. Here are the prompts, the estimation heuristic and the two API errors on the way. <a href=\"https:\/\/engel-wolf.com\/?p=5960\">Weiterlesen <span class=\"meta-nav\">&rarr;<\/span><\/a><\/p>\n","protected":false},"author":1,"featured_media":5965,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"ngg_post_thumbnail":0,"sew_ai_header":false,"footnotes":""},"categories":[1],"tags":[381],"class_list":["post-5960","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-allgemein","tag-technical"],"_links":{"self":[{"href":"https:\/\/engel-wolf.com\/index.php?rest_route=\/wp\/v2\/posts\/5960","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/engel-wolf.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/engel-wolf.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/engel-wolf.com\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/engel-wolf.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=5960"}],"version-history":[{"count":2,"href":"https:\/\/engel-wolf.com\/index.php?rest_route=\/wp\/v2\/posts\/5960\/revisions"}],"predecessor-version":[{"id":5964,"href":"https:\/\/engel-wolf.com\/index.php?rest_route=\/wp\/v2\/posts\/5960\/revisions\/5964"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/engel-wolf.com\/index.php?rest_route=\/wp\/v2\/media\/5965"}],"wp:attachment":[{"href":"https:\/\/engel-wolf.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=5960"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/engel-wolf.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=5960"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/engel-wolf.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=5960"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}