feat(inbox): add Inbox activity heatmap#2734
Conversation
Adds a GitHub-contribution-style heatmap to the Inbox surface that visualizes Responder output over the last year. Data model is validated against existing types and helpers: - Inbox records are SignalReports; there is no separate PR entity. A PR is a report carrying implementation_pr_url, matched by the existing isPullRequestReport predicate. - Metric membership reuses isPullRequestReport / isExcludedFromInbox, so the heatmap stays aligned with the inbox tab counts. - No metric treats status:"ready" as merged/landed; reports are bucketed by created_at only. Pure logic + thresholds live in @posthog/core (computeInboxHeatmap, metric registry) with unit tests; the UI is a thin renderer mounted in InboxView. Generated-By: PostHog Code Task-Id: 26bc34e7-9520-4168-8789-f4ae2d077f28
|
React Doctor found no issues in the changed files. 🎉 Reviewed by React Doctor for commit |
Prompt To Fix All With AIFix the following 3 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 3
packages/core/src/inbox/inboxHeatmap.ts:127-129
**DST-unsafe day arithmetic** — `addDays` shifts by a fixed 86 400 000 ms. On a fall-back transition night the "day" is 25 hours, so `midnight + 86 400 000 ms` lands at 23:00 local time the same calendar date. `inboxHeatmapDayKey` then returns a duplicate key for that day and the next calendar day never appears in the grid at all. Reports created on that skipped day are counted in `countsByDay` (via `parseCreatedDay`→`startOfLocalDay`) but never matched to a grid cell, so their counts are silently dropped from `totalCount`. Use calendar arithmetic instead — `Date(y, m, d + n)` handles month/year overflow and is DST-safe.
```suggestion
function addDays(date: Date, days: number): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days);
}
```
### Issue 2 of 3
packages/core/src/inbox/inboxHeatmap.ts:18-21
The `key` field in `InboxHeatmapMetricMeta` stores the same value as the `Record` key used to look it up, violating OnceAndOnlyOnce. Callers already know the key (they passed it to index into `INBOX_HEATMAP_METRICS`), and the field is not referenced anywhere in the component — the `SegmentedControl.Item` values are hardcoded string literals, not `meta.key`. Removing it also lets the interface drop the redundant field without any downstream breakage.
```suggestion
export interface InboxHeatmapMetricMeta {
/** Short label for the metric toggle. */
label: string;
```
### Issue 3 of 3
packages/core/src/inbox/inboxHeatmap.test.ts:243-254
The `includes` predicate tests in `INBOX_HEATMAP_METRICS` are written as two independent `expect` calls rather than a parameterised table. Per the repo's convention ("We always prefer parameterised tests") these should use `it.each` so new metrics get coverage automatically.
```suggestion
it.each([
[
"pull_requests",
fakeReport({ implementation_pr_url: "https://gh/pr/1" }),
true,
],
[
"pull_requests",
fakeReport({ implementation_pr_url: null }),
false,
],
[
"reports_created",
fakeReport({ status: "suppressed" }),
false,
],
[
"reports_created",
fakeReport({ status: "ready" }),
true,
],
] as const)(
"metric %s includes predicate returns %s for given report",
(metricKey, report, expected) => {
expect(INBOX_HEATMAP_METRICS[metricKey].includes(report)).toBe(expected);
},
);
```
Reviews (1): Last reviewed commit: "feat(inbox): add Inbox activity heatmap" | Re-trigger Greptile |
| function addDays(date: Date, days: number): Date { | ||
| return new Date(date.getTime() + days * DAY_MS); | ||
| } |
There was a problem hiding this comment.
DST-unsafe day arithmetic —
addDays shifts by a fixed 86 400 000 ms. On a fall-back transition night the "day" is 25 hours, so midnight + 86 400 000 ms lands at 23:00 local time the same calendar date. inboxHeatmapDayKey then returns a duplicate key for that day and the next calendar day never appears in the grid at all. Reports created on that skipped day are counted in countsByDay (via parseCreatedDay→startOfLocalDay) but never matched to a grid cell, so their counts are silently dropped from totalCount. Use calendar arithmetic instead — Date(y, m, d + n) handles month/year overflow and is DST-safe.
| function addDays(date: Date, days: number): Date { | |
| return new Date(date.getTime() + days * DAY_MS); | |
| } | |
| function addDays(date: Date, days: number): Date { | |
| return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days); | |
| } |
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/core/src/inbox/inboxHeatmap.ts
Line: 127-129
Comment:
**DST-unsafe day arithmetic** — `addDays` shifts by a fixed 86 400 000 ms. On a fall-back transition night the "day" is 25 hours, so `midnight + 86 400 000 ms` lands at 23:00 local time the same calendar date. `inboxHeatmapDayKey` then returns a duplicate key for that day and the next calendar day never appears in the grid at all. Reports created on that skipped day are counted in `countsByDay` (via `parseCreatedDay`→`startOfLocalDay`) but never matched to a grid cell, so their counts are silently dropped from `totalCount`. Use calendar arithmetic instead — `Date(y, m, d + n)` handles month/year overflow and is DST-safe.
```suggestion
function addDays(date: Date, days: number): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days);
}
```
How can I resolve this? If you propose a fix, please make it concise.| export interface InboxHeatmapMetricMeta { | ||
| key: InboxHeatmapMetric; | ||
| /** Short label for the metric toggle. */ | ||
| label: string; |
There was a problem hiding this comment.
The
key field in InboxHeatmapMetricMeta stores the same value as the Record key used to look it up, violating OnceAndOnlyOnce. Callers already know the key (they passed it to index into INBOX_HEATMAP_METRICS), and the field is not referenced anywhere in the component — the SegmentedControl.Item values are hardcoded string literals, not meta.key. Removing it also lets the interface drop the redundant field without any downstream breakage.
| export interface InboxHeatmapMetricMeta { | |
| key: InboxHeatmapMetric; | |
| /** Short label for the metric toggle. */ | |
| label: string; | |
| export interface InboxHeatmapMetricMeta { | |
| /** Short label for the metric toggle. */ | |
| label: string; |
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/core/src/inbox/inboxHeatmap.ts
Line: 18-21
Comment:
The `key` field in `InboxHeatmapMetricMeta` stores the same value as the `Record` key used to look it up, violating OnceAndOnlyOnce. Callers already know the key (they passed it to index into `INBOX_HEATMAP_METRICS`), and the field is not referenced anywhere in the component — the `SegmentedControl.Item` values are hardcoded string literals, not `meta.key`. Removing it also lets the interface drop the redundant field without any downstream breakage.
```suggestion
export interface InboxHeatmapMetricMeta {
/** Short label for the metric toggle. */
label: string;
```
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| it("each metric's includes predicate is exposed", () => { | ||
| expect( | ||
| INBOX_HEATMAP_METRICS.pull_requests.includes( | ||
| fakeReport({ implementation_pr_url: "https://gh/pr/1" }), | ||
| ), | ||
| ).toBe(true); | ||
| expect( | ||
| INBOX_HEATMAP_METRICS.reports_created.includes( | ||
| fakeReport({ status: "suppressed" }), | ||
| ), | ||
| ).toBe(false); | ||
| }); |
There was a problem hiding this comment.
The
includes predicate tests in INBOX_HEATMAP_METRICS are written as two independent expect calls rather than a parameterised table. Per the repo's convention ("We always prefer parameterised tests") these should use it.each so new metrics get coverage automatically.
| it("each metric's includes predicate is exposed", () => { | |
| expect( | |
| INBOX_HEATMAP_METRICS.pull_requests.includes( | |
| fakeReport({ implementation_pr_url: "https://gh/pr/1" }), | |
| ), | |
| ).toBe(true); | |
| expect( | |
| INBOX_HEATMAP_METRICS.reports_created.includes( | |
| fakeReport({ status: "suppressed" }), | |
| ), | |
| ).toBe(false); | |
| }); | |
| it.each([ | |
| [ | |
| "pull_requests", | |
| fakeReport({ implementation_pr_url: "https://gh/pr/1" }), | |
| true, | |
| ], | |
| [ | |
| "pull_requests", | |
| fakeReport({ implementation_pr_url: null }), | |
| false, | |
| ], | |
| [ | |
| "reports_created", | |
| fakeReport({ status: "suppressed" }), | |
| false, | |
| ], | |
| [ | |
| "reports_created", | |
| fakeReport({ status: "ready" }), | |
| true, | |
| ], | |
| ] as const)( | |
| "metric %s includes predicate returns %s for given report", | |
| (metricKey, report, expected) => { | |
| expect(INBOX_HEATMAP_METRICS[metricKey].includes(report)).toBe(expected); | |
| }, | |
| ); |
Context Used: Do not attempt to comment on incorrect alphabetica... (source)
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/core/src/inbox/inboxHeatmap.test.ts
Line: 243-254
Comment:
The `includes` predicate tests in `INBOX_HEATMAP_METRICS` are written as two independent `expect` calls rather than a parameterised table. Per the repo's convention ("We always prefer parameterised tests") these should use `it.each` so new metrics get coverage automatically.
```suggestion
it.each([
[
"pull_requests",
fakeReport({ implementation_pr_url: "https://gh/pr/1" }),
true,
],
[
"pull_requests",
fakeReport({ implementation_pr_url: null }),
false,
],
[
"reports_created",
fakeReport({ status: "suppressed" }),
false,
],
[
"reports_created",
fakeReport({ status: "ready" }),
true,
],
] as const)(
"metric %s includes predicate returns %s for given report",
(metricKey, report, expected) => {
expect(INBOX_HEATMAP_METRICS[metricKey].includes(report)).toBe(expected);
},
);
```
**Context Used:** Do not attempt to comment on incorrect alphabetica... ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Problem
The Inbox shows individual Responder reports but nothing that conveys, at a glance, the cumulative value the agent has created for the team. There was no visual summary of activity over time.
This adds a GitHub-contribution-style heatmap to the Inbox surface so a viewer can immediately see how much the Responder has shipped and when.
Changes
The task asked to validate the data model first, so the implementation is built strictly on existing types and helpers:
SignalReports — no new "pull request" entity. A PR in the inbox is a report carryingimplementation_pr_url, matched by the existingisPullRequestReportpredicate.isPullRequestReport) — the clearest signal of value created, since each one is a code change the Responder drafted. A single heatmap with a second selectable metric, "Reports" (!isExcludedFromInbox).created_at. No metric treatsstatus:"ready"as merged/landed — the product model doesn't definereadythat way, so the heatmap doesn't either.@posthog/core(computeInboxHeatmap, the metric registry, month-label helper) with unit tests;InboxActivityHeatmapis a thin renderer mounted inInboxViewon list views.The heatmap reflects loaded reports and pulls a bounded number of pages so the year window is populated without unbounded fetching.
How did you test this?
packages/core/src/inbox/inboxHeatmap.test.ts(12 cases) covering metric membership, suppressed/deleted exclusion, theready-is-not-merged rule,created_atbucketing, the Sunday-aligned grid, future-day handling, out-of-window drop, and level thresholds — all passing via Vitest.biome lintclean on all changed files.tscintroduces zero new errors in the changed files (verified by stashing the change: the UI package reports the same pre-existing error count with and without it; the remaining errors come from an unbuildable@posthog/agentdist in this environment).Automatic notifications
Created with PostHog Code from a Slack thread