Turning Raw App Names Into Categories A Manager Can Read

Turning Raw App Names Into Categories A Manager Can Read

September 18, 2026
The logic here never changes. Only the list does — roughly every quarter, forever.

A desktop tracker records which application was in the foreground. What it actually captures is a string: PhpStorm, com.google.Chrome, gnome-terminal, Code.exe. Useful to a machine, useless in a report. Nobody opens a weekly summary hoping to read three hundred process names.

So somewhere between the raw capture and the report there has to be a mapping from application to category. This is a small piece of code that turns out to have a surprising number of ways to go wrong, and one way to go wrong that is not technical at all.

The logic here never changes. Only the list does — roughly every quarter, forever.
The logic here never changes. Only the list does — roughly every quarter, forever.

What a block is before it is a category

It is worth being precise about the row we are categorising, because the granularity decides how much the mapping can ever be worth.

The trackers do not store one row per foreground change. That would be enormous and mostly noise — alt-tabbing between an editor and a browser twelve times in a minute is one activity, not twelve. Instead each tracker accumulates a block: a start time, an end time, the application that held the foreground for the majority of that window, and the tracked time entry it belongs to. A block is typically one to five minutes.

That majority rule is doing quiet work. It means a two-second glance at a chat window does not fragment an hour of coding into thirty rows, and it means the total minutes in a category are approximately right even though each individual block is an approximation. Aggregate accuracy from inexact parts is exactly the trade you want here, because every report built on this data is an aggregate.

The block also carries the organisation id, which every query filters on without exception. That is not specific to this feature — it is how the whole database works — but activity data is the kind that would be most damaging to leak across a tenant boundary, so it is worth naming.

Why it must be data, not a switch statement

The obvious first implementation is a method with a match or a switch in it. It works, it is fast, it is typed, and it lives happily in the codebase for about six weeks.

Then a designer joins and uses a tool nobody had heard of. Then a customer in Chennai runs an editor that is popular there and nowhere else. Then a new IDE ships and half the team switches to it in a fortnight. Each of those is a code change, a review, a deploy and a release note, for what is genuinely a one-line addition to a list.

We added support for Antigravity and a handful of other modern editors in one afternoon last month. With a switch statement that is a pull request. With a data map it is a line in an array, and eventually a row in a table that a super-admin can add without involving engineering at all.

The test is simple. If the thing changes on a schedule rather than because the logic changed, it is data. A mapping from application names to categories changes every time the software industry produces a new tool, which is to say constantly.

The shape we use is deliberately boring: a lowercase, normalised key to a category string.

class ActivityBlock extends Model
{
    protected const APP_CATEGORIES = [
        'phpstorm'      => 'coding',
        'webstorm'      => 'coding',
        'xcode'         => 'coding',
        'visual studio code' => 'coding',
        'antigravity'   => 'coding',
        'sublime text'  => 'coding',
        'iterm'         => 'coding',
        'terminal'      => 'coding',
        'figma'         => 'design',
        'sketch'        => 'design',
        'photoshop'     => 'design',
        'slack'         => 'communication',
        'zoom'          => 'meeting',
        'google meet'   => 'meeting',
        'excel'         => 'documents',
        'word'          => 'documents',
    ];

    public static function categoryFor(?string $app): string
    {
        if (! $app) {
            return 'other';
        }

        $key = self::normalise($app);

        return self::APP_CATEGORIES[$key]
            ?? self::partialMatch($key)
            ?? 'other';
    }
}

Two things about that lookup. The key is normalised before it is used, because the same application arrives with three different spellings depending on the operating system. And the fallback chain ends at other rather than at a guess.

Normalising the name first

The macOS tracker reports a display name, the Electron build on Linux reports a window class, and the WPF build on Windows reports an executable. The same editor can arrive as Visual Studio Code, code and Code.exe in the same organisation on the same afternoon.

private static function normalise(string $app): string
{
    $app = strtolower(trim($app));
    $app = preg_replace('/\.(exe|app)$/', '', $app);       // Code.exe
    $app = preg_replace('/^(com|org|net)\.[a-z0-9]+\./', '', $app);  // bundle ids
    $app = preg_replace('/\s+\d+(\.\d+)*$/', '', $app);   // "Photoshop 2026"
    $app = preg_replace('/\s+/', ' ', $app);

    return $app;
}

The version-number strip is the one that earns its place. Without it, every annual release of a design tool silently becomes an unmapped application, and the “other” bucket grows every January for no reason anybody can explain.

The long tail, and the browser problem

You can map the top forty applications in an afternoon and cover perhaps eighty per cent of the recorded minutes. The rest is a tail that never ends: internal tools, regional software, a Java application whose process is called java, and something a single contractor uses for a single client.

An honest unknown is worth more than a confident guess, and it is much easier to defend.
An honest unknown is worth more than a confident guess, and it is much easier to defend.

There are two honest strategies for the tail and one dishonest one.

  • Leave it as “other” and review it monthly. A query that lists the top unmapped applications by total minutes across all organisations takes five seconds to write and tells you exactly what to add next. This is the strategy that works.
  • Let organisations map their own. An override table keyed on organisation and application. Useful for internal tools, which nobody but that customer will ever see.
  • Guess from the name. Anything containing “studio” is coding, anything containing “player” is entertainment. This is the dishonest one, and it produces confidently wrong categories that are far more damaging than an honest “other”.

The browser is its own problem and it deserves saying loudly: a browser is not one activity. Chrome is a code review tool, a documentation reader, a design tool, a support desk, a video call and a shopping site, often within the same ten minutes. Any category you assign to a browser is wrong for most of the time it is open.

We could resolve this by capturing page URLs, and we deliberately do not — a URL is a document name, a customer name, a medical search. So the browser stays uncategorised, and the report says so rather than pretending. In practice this is fine: the sentence “three hours in a browser” is genuinely informative to a manager, and the manager knows their own team well enough to interpret it.

The monthly review query is worth having in a saved file rather than in somebody’s history:

SELECT app_name, COUNT(*) AS blocks, SUM(duration) / 3600 AS hours
FROM activity_blocks
WHERE category = 'other'
  AND created_at > DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY app_name
ORDER BY hours DESC
LIMIT 40;

Order by hours rather than by count. An application that appears in two hundred one-minute blocks matters less than one that holds forty hours, and counting rows gets that backwards.

Keep the raw name forever

This is the single most important decision in the whole feature, and it costs one column.

Store the raw application name on every row, permanently, alongside the category. Never overwrite it, never normalise it away, never drop it once the category has been derived.

The reason is that a category is an opinion and opinions change. Today a terminal is “coding”; next year you may decide terminals deserve their own category, or that a particular tool was misfiled from the start. If you kept the raw name, that is a backfill. If you did not, the information is gone and no amount of care will bring it back.

A derived value you can recompute is a cache. A derived value you cannot recompute is your only copy, and you have deleted the original. The column that stores the raw application name is the cheapest insurance in the schema.

It also gives you an audit trail. When somebody asks why Tuesday afternoon is filed as design, the answer is a specific string that was captured at a specific moment, not an inference three layers removed from anything real.

Backfilling when the map changes

Every time you add an application to the map, existing rows are wrong. Somebody has been using that editor for five weeks and all of it is sitting in “other”. Leaving it there means historic reports and current reports disagree, which is the fastest way for a manager to stop trusting the feature.

So each map change comes with a backfill. Ours are usually small — the Antigravity addition moved 749 blocks from “other” to “coding” — but the code has to assume they will not be.

749 rows would survive one UPDATE. Write it as though it were four million, because one day it will be.
749 rows would survive one UPDATE. Write it as though it were four million, because one day it will be.
class RecategoriseActivityBlocks extends Command
{
    protected $signature = 'activity:recategorise {--dry-run}';

    public function handle(): int
    {
        $changed = 0;

        ActivityBlock::query()
            ->whereNotNull('app_name')
            ->orderBy('id')
            ->chunkById(1000, function ($blocks) use (&$changed) {
                foreach ($blocks as $block) {
                    $fresh = ActivityBlock::categoryFor($block->app_name);

                    if ($fresh === $block->category) {
                        continue;
                    }

                    $this->line("{$block->app_name}: {$block->category} -> {$fresh}");

                    if (! $this->option('dry-run')) {
                        $block->updateQuietly(['category' => $fresh]);
                    }

                    $changed++;
                }
            });

        $this->info("{$changed} blocks recategorised");

        return self::SUCCESS;
    }
}

Four details in there are not decoration:

  • chunkById, not chunk. Ordinary chunking pages with an offset, and an offset moves underneath you when the rows you are reading are also being written. Keyset pagination on the primary key does not.
  • A dry run that prints every transition. Reading fifty lines of “Antigravity: other → coding” before you commit anything catches a bad map entry in seconds.
  • updateQuietly. This is a data correction, not a business event. Firing model observers for a million rows will send notifications, invalidate caches and, in the worst case, dispatch a job per row.
  • A count at the end. A backfill that silently did nothing looks exactly like one that worked. The number is the proof.

Run the dry run on production data and the real command on production data, in that order, and nowhere else. A backfill verified only against a seeded development database tells you about your seeder rather than about your customers.

The one that needs a transaction, and the one that does not

A recategorisation does not need a transaction around the whole run. Each row is independently correct after it is updated, and a run that stops halfway has simply done part of the work — you run it again. Wrapping four million updates in one transaction turns a resumable job into an all-or-nothing one that holds locks for an hour.

The exception is a change that moves rows between categories in a way that must be consistent to be meaningful, such as splitting one category into two while a report is being generated. That is rare, and the simpler answer is to run it at 2am rather than to reach for a long transaction.

Testing a mapping

The mapping itself needs about a dozen tests and they are all the same shape: a raw string in, an expected category out. They are dull and they catch a specific class of mistake, which is a normalisation change that quietly breaks an existing key.

public function test_it_normalises_platform_spellings(): void
{
    $variants = ['Visual Studio Code', 'code', 'Code.exe', 'com.microsoft.VSCode'];

    foreach ($variants as $raw) {
        $this->assertSame('coding', ActivityBlock::categoryFor($raw), $raw);
    }
}

public function test_unknown_apps_are_other_not_guessed(): void
{
    $this->assertSame('other', ActivityBlock::categoryFor('SomeInternalTool'));
    $this->assertSame('other', ActivityBlock::categoryFor(null));
}

The second test is the one people leave out, and it is the one that stops a well-meaning partial matcher from deciding that an unknown tool with “studio” in its name must be a code editor.

Where the categories are used

The categorised data drives two things in Happy Tracker and deliberately no more.

The first is the daily activity summary: a strip showing how a day broke down — four hours in editors, ninety minutes in meetings, an hour in a browser, forty minutes uncategorised. It sits next to the tracked hours and the idle time, which we covered separately in active time versus idle time.

The second is the project-level view: across everyone who worked on a project this sprint, what shape did the work have. A project that is eighty per cent meetings is a real finding, and it is a finding about the project rather than about a person.

What we do not do is show a category breakdown per person, ranked. The same numbers arranged as a league table become a different product with a different effect on a team, and the arrangement is the whole difference. A project breakdown invites the question “why is this project so meeting-heavy?”; a ranked personal breakdown invites the question “why is Ram at the bottom?”, which the data cannot answer and was never collected to answer.

There is also a practical reason the project view is more useful. Categories are most informative when they are summed over many people and many days, because the errors in individual blocks cancel out. A single person’s single afternoon contains too much rounding, too many browser minutes and too much “other” to support a firm conclusion. The larger the aggregate, the more the data is actually telling you something.

For both, the aggregation is precomputed nightly rather than calculated on page load. Grouping a quarter of activity blocks by category at request time is the query that makes a reports page slow, and it gets slower every month the customer stays.

The thing we refuse to build

Every few months somebody suggests turning this into a productivity score. Weight the categories, sum them up, show a percentage per person per day. It is an obvious product idea, several competitors ship it, and it is the one feature request in this area that we say no to.

The moment a number is scored, people optimise the number, and the honest data is gone.
The moment a number is scored, people optimise the number, and the honest data is gone.

There are three reasons, and none of them is squeamishness.

  • The mapping cannot support it. A score claims that an hour in an editor is worth more than an hour in a notebook application. Sometimes the notebook hour is where the architecture got decided and the editor hour was renaming variables. The data has no way to tell, so the score is invented precision.
  • It changes the behaviour it measures. As soon as a number is visible and attributed to a person, people optimise it. You get editors left open, mouse-jigglers, and a report that describes compliance with a metric rather than work. You have lost the honest data and gained a number.
  • Description survives a conversation; judgement does not. “Tuesday was four hours in an editor and two in meetings” is a fact both people in the room can discuss. “Tuesday scored 62%” is an accusation that has to be defended by the software, which cannot defend it.

So the product describes the day and stops. Categories are shown, totals are shown, the manager supplies the judgement. That is not a limitation we are working around; it is the line, and it is written down so that it survives the next time somebody asks.

What to check in your own implementation

  1. Is the mapping in a file somebody can edit without a deploy? If adding an application needs a release, it will not get added, and your “other” bucket will grow quietly for a year.
  2. Do you still have the raw application name on every row? If not, stop and add the column today. Every hour you wait is more data you cannot recategorise.
  3. What are your top twenty unmapped applications by hours? Run that query now. In most systems the answer includes two or three things that should obviously have been mapped from the beginning.
  4. What does your browser resolve to? If it is anything except “browser” or “unknown”, your categories are less accurate than they look.
  5. Is the aggregation precomputed? If a report groups raw blocks at request time, put the date in your calendar for when it becomes slow, because it will.

The whole feature is one lookup table, one normaliser, one chunked command and a nightly aggregate. What makes it worth the care is that it is the layer where raw capture becomes something a human being reads, and everything that is wrong or dishonest at that layer is wrong or dishonest in every report built on top of it.