Your Mac already logged your week: in branch names, commits and agent prompts. Here's how to turn that into hours you can defend.
Key findings
- Over two weeks, my coding activity gave 61 hours of estimated working time, against 120.5 hours of first-to-last span. Billing the span would have roughly doubled the hours.
- Four rules turn timestamps into billable hours: each point owns the time until the next, gaps over 15 minutes are idle, agent work only counts when you're there, and totals are rounded to the quarter hour.
- Branch names, session titles and commit messages assign the hours to tickets, and the same timeline writes the standup.
It's 4:40 on a Friday. The timesheet is due at five. The client wants hours per ticket, per day, and you're staring at Tuesday.
Tuesday. You know you fixed the login bug, because you remember the relief. You know there was a call about the export. Was the call before or after lunch? Did the bug take two hours or five? You open your calendar, then your Git log, then Slack, and slowly reconstruct a day you lived through four days ago. Then you round everything to the nearest hour, add a bit for "investigation", and hope nobody asks.
If you're a freelance developer or contractor who bills by the hour, you know this ritual. It's twenty minutes of unpaid archaeology every week, and the result is a guess wearing a spreadsheet costume.
The odd thing is that the evidence was there all along. Nobody was reading it.
You already left a trail
Think about what a developer's Mac records in a normal day, without anyone asking it to:
- Branch names.
fix/ABC-142-login-timeoutsays which ticket you were on, and Git's reflog says when you switched to it. - Commit messages. Each has a timestamp, and many name a ticket.
- Agent sessions. If you work with Claude Code or Codex, every prompt you typed is in a transcript with a timestamp, in the folder of the project you were in.
- Your shell history. Every
npm test, everygit push, timestamped.
Put those on one timeline and Tuesday stops being a mystery. The question is how to turn a pile of timestamps into billable hours, without a time tracking app and without lying in either direction.
The naive answer is wrong by half
The first thing I tried was the obvious one: take the first and last activity of each day and call the gap "time worked". Start at 9:10, last commit at 6:40: nine and a half hours.
It isn't, of course. A span like that includes lunch, a dentist appointment, forty minutes reading about a framework you'll never use, and the stretch where your coding agent ran a long refactor while you made coffee.
So I measured it. I took two weeks of my own Claude Code activity and estimated my working time with the rules I'll describe below, then compared it with the first-to-last span for each day:
Two weeks of my own Claude Code activity. The grey bar is first-to-last activity; the green bar is the estimate.
61 hours estimated, against 120.5 hours of first-to-last span. Most days, the span was about double. If I'd billed the span, I'd have been charging for my lunch breaks. If I'd billed from memory, I honestly don't know which way I'd have been off, and that's the problem.
(Two caveats, so you can read that chart fairly. It only uses Claude Code activity, so terminal work outside an agent and Git activity aren't in it. And thinking, reading and meetings leave no trace on a Mac at all. The estimate is a floor for keyboard time, not a full picture of a working day.)
Four rules that turn timestamps into hours
After a lot of back and forth, I ended up with four rules. They're simple enough to explain to a client, which matters more than being clever.
Four rules for estimating billable hours from coding activity
| Rule | Value | Why |
|---|---|---|
| Each point of activity owns the time until the next | Up to 15 minutes | Covers reading a diff or thinking through a bug |
| A longer gap is idle | Over 15 minutes: the point gets 5 minutes | Keeps lunch and meetings off the timesheet |
| Agent work counts only when you're there | Within 15 minutes of your own input | An agent running alone isn't your time (724 events left out in my data) |
| Round and label it | Nearest quarter hour, marked as an estimate | Honest about the uncertainty; corrections stay yours |
1. Each moment of activity owns the time until the next one
If I typed a prompt at 10:00 and ran the tests at 10:07, the prompt owns those seven minutes. Activity is a series of points, and the time between them belongs to the point before.
2. A gap of more than 15 minutes is idle
If the next point is more than 15 minutes away, I wasn't working in between, or at least I can't show that I was. The point before the gap gets a flat 5 minutes instead of the whole gap. That one rule is what keeps lunch, meetings and long stares out the window off the timesheet.
Why 15 minutes? It's long enough to cover reading a diff or thinking through a bug, and short enough that a coffee break doesn't count. You could argue for 10 or 20. What matters is picking one rule and applying it the same way every day.
3. Your agent's work only counts when you're there
This one matters a lot once you work with coding agents. An agent can run for an hour on its own while you're at lunch. Its replies and commands are all timestamped, and a naive tracker would happily bill that hour.
So agent activity only counts if it's within 15 minutes of something you did: a prompt you typed, a command you ran. An agent working alone for two hours doesn't fill your timesheet. In my two weeks of data, 724 agent events were left out for exactly that reason.
4. Round to the quarter hour, and say it's an estimate
Minutes look precise, and they aren't. Rounding to the nearest quarter hour is honest about the uncertainty, and it's what most clients expect anyway. Every number should say where it came from: estimated from your activity until you change it, corrected by you after.
That last part is the most important design decision. A timesheet built from activity is a first draft, not a stopwatch. You know about the 30-minute call that left no trace. The tool should make it easy to add that row and fix a cell, and it should never overwrite your correction when it recalculates.
Which ticket gets the time?
Hours per day is half the job. Clients usually want hours per ticket, which raises the next question: which ticket was each point for?
Most of the time the answer is sitting in the evidence:
- A point on branch
fix/ABC-142-login-timeoutis ABC-142. - An agent session titled "ABC-150 export to CSV" is ABC-150.
- A commit that says "ABC-142: handle expired tokens" claims the unlabelled work just before it, back to the previous commit or the last 15-minute pause.
- A terminal command has no branch of its own, so it borrows the branch of the nearest activity in the same project, within 15 minutes. A
git pushbetween two prompts on ABC-142 counts for ABC-142.
Work on main, or on a branch that's been alive for more than 30 days, goes to the project rather than to a ticket, because a long-lived branch isn't a work item.
One more lesson: don't hard-code ticket formats. Teams use ABC-123, INC0012345, #1234 and stranger things. It works better to look for IDs that keep coming back in your own branches and commits, and ask you to confirm which ones are real.
When a client asks how you got that number
The day a client questions an invoice is the day a memory-based timesheet falls apart. "I think it was about five hours" isn't an answer anyone wants to give.
An activity-based timesheet gives you a better one. Every hour traces back to timestamps: the commits on that ticket's branch, the prompts in that session, the test runs in between. You can open a day and show what happened, in order. You can explain the rule in one sentence: gaps over 15 minutes don't count. And you can point at the cells you corrected by hand and say why.
Two things make that conversation go well:
- Be upfront that it's an estimate. Say so on the invoice. A number that claims precision invites an argument; a number with a stated method invites trust.
- Keep your corrections visible. Adding a row for a 45-minute call is normal. Quietly inflating a day is not. When the tool marks which numbers you changed, you can show both.
It also cuts the other way. In my data, the estimate was lower than the span every day. If anything, activity-based hours protect the client from paying for your lunch, and that's an easy thing to say out loud.
The standup falls out of the same data
Once your activity is on one timeline, the daily standup is almost free. "What did you do yesterday?" is your commits, the branches you started and the merges you landed. "What's next?" is the open loops: failing tests, unpushed commits, a branch waiting for review. "Blockers" is whatever's still red.
A plain template does this well with no AI at all:
*Yesterday* • ABC-142: handle expired tokens; retry on 401 • started ABC-150-export-csv *Today* • Finish ABC-150 and open the PR *Blockers* • None If you do let an AI tidy it up, give it the facts and nothing else, and tell it plainly not to invent work, numbers, people or tickets. An AI that guesses what you did yesterday is worse than no standup at all.
Try it on your own week
You can check the gap between your span and your real working time right now. This script reads your Claude Code transcripts (timestamps and folder names only, never message text) and applies the rules above: the 15-minute idle gap, the 5-minute credit, and the rule that agent activity only counts near something you typed.
"""Estimate your hours per project per day from Claude Code transcripts. Uses the same rules as L2Cache's timesheet: each moment of activity owns the time until the next one; a gap over 15 minutes is idle and earns 5 minutes; agent activity counts only within 15 minutes of something you typed. Reads timestamps and folder names only, never message text. """ import functools import glob import json import os import sys from collections import defaultdict from datetime import datetime, timedelta IDLE_GAP = 15 * 60 CREDIT = 5 * 60 DAYS = 14 def is_your_prompt(row): # Compaction summaries are written as user messages but nobody typed them. if row.get("type") != "user" or row.get("isMeta") or row.get("isCompactSummary"): return False content = (row.get("message") or {}).get("content") if isinstance(content, str): return True return isinstance(content, list) and not any( isinstance(b, dict) and b.get("type") == "tool_result" for b in content) @functools.lru_cache(maxsize=None) def project_name(folder): """The repository a folder belongs to (its nearest parent with a .git), like L2Cache's projects.""" path = folder while path and path != os.path.dirname(path): if os.path.exists(os.path.join(path, ".git")): return os.path.basename(path) path = os.path.dirname(path) return os.path.basename(folder) or "?" def points(): since = datetime.now().astimezone() - timedelta(days=DAYS) for path in glob.glob(os.path.expanduser("~/.claude/projects/*/*.jsonl")): for line in open(path): try: row = json.loads(line) when = datetime.fromisoformat(row["timestamp"].replace("Z", "+00:00")).astimezone() except (json.JSONDecodeError, KeyError, ValueError): continue if when < since or row.get("type") not in ("user", "assistant"): continue project = project_name(row.get("cwd") or "") yield when, project, is_your_prompt(row) def main(): pts = sorted(points()) anchors = [w for w, _, you in pts if you] present = [] j = 0 for when, project, you in pts: while j + 1 < len(anchors) and anchors[j + 1] <= when: j += 1 near = any(0 <= k < len(anchors) and abs((anchors[k] - when).total_seconds()) <= IDLE_GAP for k in (j, j + 1)) if you or near: present.append((when, project)) hours = defaultdict(float) for i, (when, project) in enumerate(present): gap = (present[i + 1][0] - when).total_seconds() if i + 1 < len(present) else IDLE_GAP + 1 hours[(when.date(), project)] += gap if gap <= IDLE_GAP else CREDIT alone = sum(1 for _, _, you in pts) - len(present) # First to last activity each day: the "I was at it from 9 to 6" number. first, last = {}, {} for when, _ in present: first.setdefault(when.date(), when) last[when.date()] = when days = sorted({d for d, _ in hours}) projects = sorted({p for _, p in hours}) names = {p: (f"Project {chr(65 + i)}" if "--mask" in sys.argv else p) for i, p in enumerate(projects)} q = lambda s: round(s / 900) / 4 print(f"ESTIMATED HOURS, LAST {DAYS} DAYS (from Claude Code activity)") print(f"{'day':<12}" + "".join(f"{names[p][:12]:>13}" for p in projects) + f"{'total':>9}{'span':>9}") for d in days: row = [q(hours.get((d, p), 0)) for p in projects] print(f"{d:%a %b %d} " + "".join(f"{h:>13.2f}" for h in row) + f"{sum(row):>9.2f}{q((last[d] - first[d]).total_seconds()):>9.2f}") total = sum(q(hours.get((d, p), 0)) for d in days for p in projects) span = sum(q((last[d] - first[d]).total_seconds()) for d in days) print(f"\nEstimated time: {total:.2f} h over {len(days)} days. First-to-last span: {span:.2f} h.") print(f"Agent activity left out because you weren't there (15+ min from your last input): {alone:,} events.") if __name__ == "__main__": main() Save it as hours_check.py and run python3 hours_check.py. Add --mask to replace project names with "Project A, B, C" if you want to share the output. Here's mine:
ESTIMATED HOURS, LAST 14 DAYS (from Claude Code activity) day Project A Project B Project C total span Thu Oct 29 4.50 0.00 0.00 4.50 8.75 Fri Oct 30 5.25 0.00 0.00 5.25 11.00 Sat Oct 31 3.75 0.00 0.00 3.75 8.00 Sun Nov 01 4.50 0.00 0.00 4.50 9.00 Mon Nov 02 6.75 0.00 0.00 6.75 12.00 Tue Nov 03 5.50 0.00 0.00 5.50 10.00 Wed Nov 04 2.00 0.00 0.00 2.00 3.75 Thu Nov 05 3.75 0.00 0.00 3.75 8.25 Fri Nov 06 3.75 1.50 0.00 5.25 11.50 Sat Nov 07 0.00 5.00 0.00 5.00 10.75 Sun Nov 08 5.50 0.75 0.00 6.25 12.00 Mon Nov 09 2.00 0.00 0.00 2.00 3.25 Tue Nov 10 4.50 0.00 0.00 4.50 8.75 Wed Nov 11 1.75 0.25 0.00 2.00 3.50 Estimated time: 61.00 h over 14 days. First-to-last span: 120.50 h. Agent activity left out because you weren't there (15+ min from your last input): 724 events. Today's row is a partial day. The rest is a fair picture of two weeks: never more than about seven hours of activity in a day, even on days with twelve hours between the first and last of it.
What I built
I built these rules into L2Cache, the Mac app I make. Its Worklog learns your ticket formats from your own branches and commits (and asks you to confirm them), estimates each day from Git, agent sessions and terminal commands, and lets you correct any cell or add a row for a call. Copy as Table pastes straight into a client's spreadsheet, Export CSV covers the rest, and the standup is one click away on the Today page. Everything stays on your Mac; it doesn't connect to Jira or anything else.
A week's timesheet built from activity, with one cell corrected by hand.
But the rules matter more than the tool. Even if you keep filling in timesheets by hand, run the script once. Seeing your real working hours next to the hours you thought you worked changes how you estimate, how you quote, and how you feel about that Friday deadline.
The takeaway
Your timesheet shouldn't come from memory. It should come from evidence you already have, with an honest rule for the gaps and room for you to correct it.
Frequently asked questions
Can I build a timesheet from my git commits?
Partly. Commits give timestamps and often ticket IDs, but they're sparse. Adding agent prompts, branch switches and terminal commands fills the gaps, and an idle rule (gaps over 15 minutes don't count) keeps breaks out.
How do freelance developers track billable hours without a timer?
Estimate them from activity that's already recorded, apply one stated rule for gaps, round to the quarter hour, and correct the estimate by hand for calls and other work that leaves no trace.
Is first-to-last activity a fair measure of hours worked?
No. In my two weeks it was about double the activity-based estimate (120.5 h against 61 h), because it includes lunch, meetings and time an agent ran on its own.
How do I explain an activity-based estimate to a client?
State the method in one sentence (gaps over 15 minutes don't count), show the timestamps behind any day they ask about, and keep your manual corrections visible.
For the full details of how the estimate works, including ticket format learning, linking branches and exports: Your hours, already counted: a timesheet and standup from your coding activity. And for the morning side of the same data: where you left off, every morning.
Originally published at l2cache.app.




