Skip to content

feat(inbox): add Inbox activity heatmap#2734

Draft
cleo-pleurodon wants to merge 1 commit into
mainfrom
posthog-code/inbox-activity-heatmap
Draft

feat(inbox): add Inbox activity heatmap#2734
cleo-pleurodon wants to merge 1 commit into
mainfrom
posthog-code/inbox-activity-heatmap

Conversation

@cleo-pleurodon

Copy link
Copy Markdown

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:

  • Inbox records are SignalReports — no new "pull request" entity. A PR in the inbox is a report carrying implementation_pr_url, matched by the existing isPullRequestReport predicate.
  • Default metric: "Pull requests" (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).
  • Both metrics bucket reports by created_at. No metric treats status:"ready" as merged/landed — the product model doesn't define ready that way, so the heatmap doesn't either.
  • Pure logic lives in @posthog/core (computeInboxHeatmap, the metric registry, month-label helper) with unit tests; InboxActivityHeatmap is a thin renderer mounted in InboxView on 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?

  • Added packages/core/src/inbox/inboxHeatmap.test.ts (12 cases) covering metric membership, suppressed/deleted exclusion, the ready-is-not-merged rule, created_at bucketing, the Sunday-aligned grid, future-day handling, out-of-window drop, and level thresholds — all passing via Vitest.
  • biome lint clean on all changed files.
  • tsc introduces 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/agent dist in this environment).
  • Not verified in the running Electron app — the dependency build is broken in this environment, so I could not launch it.

Automatic notifications

  • Publish to changelog?
  • Alert Sales and Marketing teams?

Created with PostHog Code from a Slack thread

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
@github-actions

Copy link
Copy Markdown

React Doctor found no issues in the changed files. 🎉

Reviewed by React Doctor for commit 915060e.

@greptile-apps

greptile-apps Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor
Prompt To Fix All With AI
Fix 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

Comment on lines +127 to +129
function addDays(date: Date, days: number): Date {
return new Date(date.getTime() + days * DAY_MS);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 DST-unsafe day arithmeticaddDays 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 parseCreatedDaystartOfLocalDay) 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.

Suggested change
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.

Comment on lines +18 to +21
export interface InboxHeatmapMetricMeta {
key: InboxHeatmapMetric;
/** Short label for the metric toggle. */
label: string;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
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!

Comment on lines +243 to +254
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);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant