GitHub Personal Stats GitHub

Coding activity

How long the work took, what wrote it, and which models did — as a card or a chart.

Coding Activity

Every card above is drawn from your GitHub profile, which knows what you pushed and nothing about how it came to be. This is the other half: how long the work took, what wrote it, and which models did. It is read from records your own machine already keeps, so it needs no account with anybody and no service running in the cloud.

It comes out in two shapes. A card, for a README that is mostly pictures:

An activity card comparing the last thirty days with all time, ranked by language

And a chart, for a README that is mostly words:

LINES BY LANGUAGE

Total        +119,979   lines, 99.94% by an agent

Rust          +35,995   #########################   30.00 %
Markdown      +28,134   ####################-----   23.44 %
Go            +21,941   ###############----------   18.28 %
TypeScript    +13,046   #########----------------   10.87 %
Python         +9,025   ######-------------------    7.52 %
Zig            +6,926   #####--------------------    5.77 %

Both read the same collected record through the same fold, so they can sit on one page without disagreeing. Nothing here is fetched: the card is the only one in this project that renders with no token, no saved profile and no network at all.

Every figure on this page comes from a sample record rather than anybody's real one, the same way the other cards are drawn from examples/showcase.json. Rebuild all of it with python3 scripts/render-activity-samples.py.

The card

github-personal-stats generate --card activity \
  --activity-record ~/.local/state/github-personal-stats/record \
  --width 900 --height auto \
  --output profile/activity.svg

Two spans are named on the left, the recent one leading. Under each is how much of that span recorded anything at all, which is not decoration: a span reaching further back than your record does would otherwise read as an idle stretch rather than an unobserved one.

On the right, a bar is the recent window and the mark across it is where the longer span put the same language. A language you have picked up lately reads as a bar past its own mark, and one you have left behind as a bar short of it. The line underneath is the time no language could be put to, which on a real record is most of it — see How time is measured.

The card fits a tile as well as a header. Below 440px it stacks, and when two spans are too long to share a line it gives them one each, because 619 hrs 25 mins needs a quarter more room than 9 hrs 2 mins:

--width 900 --theme light
Activity card, light

A row of a dashboard

--width 900 --theme dark
Activity card, dark

The same card on a dark surface

--width 275
Activity card at tile width

Spans stacked, names measured to fit

The chart

Update a marked README section:

<!--START_SECTION:activity-->
<!--END_SECTION:activity-->
github-personal-stats update-readme --section activity --target README.md

Print the same chart to a terminal instead, which is the quickest way to try a configuration:

github-personal-stats chart --activity-record <your record>

With nothing configured you get the period covered, then what was written, who wrote it, and what wrote it:

From: 24 May 2026 - To: 27 August 2026

LINES BY LANGUAGE

Total        +119,979   lines, 99.94% by an agent

Rust          +35,995   #########################   30.00 %
Markdown      +28,134   ####################-----   23.44 %
Go            +21,941   ###############----------   18.28 %
TypeScript    +13,046   #########----------------   10.87 %
Python         +9,025   ######-------------------    7.52 %
Zig            +6,926   #####--------------------    5.77 %

LINES BY AUTHOR

Total          +119,979   lines, last 30 days

agent          +119,917   #########################   99.94 %
unattributed        +62   -------------------------    0.05 %

LINES BY MODEL

Total           +119,917   lines, 99.94% by an agent

claude-opus-5    +38,780   #########################   32.33 %
gpt-5.6-sol      +32,270   #####################----   26.91 %
gpt-5.5          +25,082   ################---------   20.91 %
grok-4.6         +15,492   ##########---------------   12.91 %
unnamed           +8,293   #####--------------------    6.91 %

# agent    = unattributed    - rest

The first line is the period your record actually holds evidence for, which is the one thing none of the figures below can tell you. A month is a month, but a month out of nine years of history and a month out of a fortnight's mean different things, and a total that says all time means whatever your record happens to reach back to.

The dates follow what is being counted, not the record's outer edge. Sources keep the three figures for different lengths of time — durations come from transcripts that survive for months, line counts from an editor database that holds a few weeks — so a block of lines opens at the first day with lines rather than at the first day with anything. Where a chart holds several blocks the line covers all of them, which is why a chart pairing hours collected here with hours imported from a tracker you used for years opens at the earliest day any of them knows about. Turn it off with --activity-dates off.

Both spans end today by default. To report a period that has already closed, name the day they end on:

--activity-as-of 2026-06-30 --activity-windows 30,90

The remaining samples in this chapter are single blocks, shown without the dates line.

What the figures mean

Three different things can be counted, they are measured by different means, and they are not interchangeable. Which one a chart leads with is worth choosing deliberately.

ValueWhat it countsKnows the languageKnows who wrote it
linesLines the editor watched appearYesYes
timeWall clock while an agent was workingPartlyYes
tokensTokens the agents were billed forNoNo

lines is the default because a line is counted rather than inferred. The editor records a row per line as it appears, which is why it can say what language the line was in and which model produced it.

It is also why the figures are additions only, written +119,979 with nothing after them. A line that was deleted stops having a row, so there is nothing left to count and no removal can be reported. That absence is what the source can see rather than a gap waiting to be filled, and reporting removals honestly would mean watching each edit as it happens, which is the editor plugin's job.

unattributed means what it says and no more: no request accounts for these lines. It is not a count of what you typed, and it deliberately does not claim to be. A formatter reformatting a file, a shell command writing one, a terminal agent editing outside the editor and a person typing all land here identically, because the editor recorded that the lines appeared and nothing recorded what produced them. Only a plugin watching each edit as it happens can honestly say a person typed something.

For the same reason, moments where unattributed lines appear across more than a handful of files at once are left out of the count altogether. An edit happens to a file; lines turning up in a hundred files inside one second is the tracker taking inventory of a workspace it has just been pointed at, or a formatter sweeping a tree. Those lines were on disk long before that second, so counting them would credit a month with work done over years, and attribute it to nobody in particular. On one real record the distinction was not marginal: the largest sweep put 47,804 lines across 135 files in a single second, while no other unattributed moment reached beyond four files and no generated edit reached beyond fourteen. Left in, it would have reported 9.75% of a month as not written by an agent, and made one language look a third hand-written.

How time is measured

Time is the weakest of the figures and the one worth understanding before quoting it.

There is no clock running. What exists is a set of moments — instants at which something was observed happening — and time is the space between consecutive moments, counted when the gap is shorter than the idle timeout (five minutes by default, --idle-timeout). A gap longer than that ends the stretch and is not counted at all. So the figure is only as good as how densely those moments were observed.

Every source's moments go into one timeline before any of this is counted, rather than each source being totalled on its own and the totals added. An afternoon in which an agent worked in your editor while another ran in a terminal is one afternoon, and summing per source would bill it twice.

Reading the terminal agents is what makes the figure defensible. Measured over one month of a real record:

Minutes with observed activity
Editor only8,214
Terminal agents only13,150
Together21,364

More work happened outside the editor's view than inside it. That changes not just the total but its quality — how much of the figure is observation and how much is filling in silence:

Counted seconds coming from gaps ofShare
Under 30 seconds79%
30 seconds to 2 minutes9%
2 to 5 minutes12%

With the editor alone, over half the total came from gaps above a minute, which is interpolation. With both sources, four fifths of it comes from gaps under half a minute, which is very nearly direct observation. Tightening the idle timeout from five minutes to thirty seconds now changes the monthly total by less than a fifth, which is the useful way to say that the figure is being set by evidence rather than by the length of the silences.

The remaining limitation is that most of those hours cannot be attributed to a language. The terminal agents report when they were working without reporting what they were working on; roughly one in eight of their moments names a file at all, and a file named at one instant says nothing about the minutes around it. So a block of hours by language ranks only the hours it can place, and says how many it could not:

TIME BY LANGUAGE

Total        73 hrs 32 mins   last 30 days, 119 hrs 58 mins not placed to a language

Rust         22 hrs  1 mins   #########################   29.95 %
Markdown     17 hrs 15 mins   ####################-----   23.45 %
Go           13 hrs 26 mins   ###############----------   18.28 %
TypeScript    8 hrs  0 mins   #########----------------   10.88 %
Python        5 hrs 32 mins   ######-------------------    7.53 %
Zig           4 hrs 14 mins   #####--------------------    5.77 %

That is also why hours are an option on a block rather than the thing a chart leads with. Any breakdown will state the hours behind its figures on request, with time=on:

LINES BY LANGUAGE

Total        +119,979   lines, 99.94% by an agent

Rust          +35,995   #########################   30.00 %   22 hrs  1 mins
Markdown      +28,134   ####################-----   23.44 %   17 hrs 15 mins
Go            +21,941   ###############----------   18.28 %   13 hrs 26 mins
TypeScript    +13,046   #########----------------   10.87 %    8 hrs  0 mins

Choosing what the chart says

A chart is a list of blocks separated by semicolons. Each block is written value/dimension with optional ,setting=value pairs:

--activity-blocks 'lines/languages,limit=8,time=on;lines/authors;tokens/models'

Values are lines, time and tokens. Dimensions are languages, models, authors and windows. Not every pairing was measured — nothing records a token against a language or an author — and a block that asks for a pairing nothing measured says nothing recorded rather than inventing a number.

SettingDoes
limit=8Rows at most, largest first
time=onAdds the hours behind each row
authors=onAdds the agent's share of each row, as a number beside the bar
split=offDraws each bar whole instead of dividing it by author
title=…Replaces the heading
measure=…Reads a named measure of time rather than the chart's

Every bar is already divided by author, so its shape says how much of a language an agent wrote. authors=on writes the number beside it, for when the difference between rows is too small to read off twenty-five characters of glyph:

LINES BY LANGUAGE

Total        +119,979   lines, 99.94% by an agent

Rust          +35,995   #########################   30.00 %    99.93% agent
Markdown      +28,134   ####################-----   23.44 %    99.92% agent
Go            +21,941   ###############----------   18.28 %    99.91% agent
TypeScript    +13,046   #########----------------   10.87 %   100.00% agent
Python         +9,025   ######-------------------    7.52 %   100.00% agent
Zig            +6,926   #####--------------------    5.77 %   100.00% agent

# agent    = unattributed    - rest

On a block of hours the same setting writes 99.89% agent lines, naming the figure it counted, because a percentage next to a duration would otherwise be read as a share of the hours.

A measure belongs to a block rather than to the whole chart, because the interesting charts hold more than one. Hours an agent spent changing code and hours imported from another tracker are different quantities covering overlapping periods: they can sit side by side but must never be added, and a chart with a single measure could only ever show one of them.

--activity-blocks 'time/windows;time/windows,measure=imported'
TIME BY SPAN

Longest        619 hrs 25 mins   spans overlap; each reads as a share of this

Last 30 days   193 hrs 30 mins   ########-----------------    31.24 %
All time       619 hrs 25 mins   #########################   100.00 %

IMPORTED TIME BY SPAN

Longest        266 hrs 21 mins   spans overlap; each reads as a share of this

Last 30 days     0 hrs  0 mins   -------------------------     0.00 %
All time       266 hrs 21 mins   #########################   100.00 %

The imported measure in that sample stops a month ago, which is what a tracker you have moved away from looks like. Adding the two totals would claim 886 hours of a period that only holds 619.

Two spans are compared, and both are configurable — a day count or all:

--activity-windows 30,90

A span is counted back from today, where today is the day your record is labelling, not the day it is in UTC. Days are labelled where the work happened, so east of Greenwich a record starts a new day hours before UTC does; anchored on the UTC day, the first hours of every local morning would be dropped as if they were in the future. --activity-as-of 2026-06-30 moves the anchor to any day you name, which is how a chart reports last quarter rather than the last thirty days.

Choosing how it looks

--activity-columns name,value,bar,share,aside
--activity-bar '#=-'
--activity-bar-width 25
--activity-bar-basis largest
--activity-dates on

Columns are drawn in the order given, and a column that no row fills is not drawn at all. --activity-bar takes two or three characters: the agent's share, the share nothing watched an agent write, and the remainder. --activity-bar-basis decides whether a bar's length is measured against the largest row or against the block's total. --activity-dates off drops the opening line of dates, which is worth doing when the chart sits under a heading that already says which period it covers.

Everything is padded to a monospace grid, so the chart belongs in a fenced block. update-readme writes one for you.