diff --git a/packages/core/src/inbox/inboxHeatmap.test.ts b/packages/core/src/inbox/inboxHeatmap.test.ts new file mode 100644 index 000000000..3831a4177 --- /dev/null +++ b/packages/core/src/inbox/inboxHeatmap.test.ts @@ -0,0 +1,275 @@ +import type { SignalReport } from "@posthog/shared/types"; +import { describe, expect, it } from "vitest"; +import { + computeInboxHeatmap, + DEFAULT_INBOX_HEATMAP_METRIC, + INBOX_HEATMAP_METRICS, + inboxHeatmapDayKey, + inboxHeatmapMonthLabels, +} from "./inboxHeatmap"; + +// Midday-UTC timestamps so the local calendar day is stable across the test +// runner's timezone (any offset from -12h..+14h stays on the same day). +function fakeReport(overrides: Partial = {}): SignalReport { + return { + id: "r1", + title: "Test report", + summary: "Summary", + status: "ready", + total_weight: 1, + signal_count: 1, + created_at: "2026-06-05T12:00:00Z", + updated_at: "2026-06-05T12:00:00Z", + artefact_count: 0, + priority: null, + actionability: null, + is_suggested_reviewer: false, + source_products: [], + implementation_pr_url: null, + ...overrides, + }; +} + +const NOW = new Date("2026-06-17T12:00:00Z"); + +function dayCount( + heatmap: ReturnType, + dayKey: string, +): number { + for (const week of heatmap.weeks) { + for (const day of week.days) { + if (day.dayKey === dayKey) return day.count; + } + } + return -1; // day not present in the grid +} + +describe("computeInboxHeatmap", () => { + it("counts only pull-request reports for the pull_requests metric", () => { + const reports = [ + fakeReport({ id: "pr1", implementation_pr_url: "https://gh/pr/1" }), + fakeReport({ id: "pr2", implementation_pr_url: "https://gh/pr/2" }), + fakeReport({ id: "noPr", implementation_pr_url: null }), + ]; + + const heatmap = computeInboxHeatmap({ + reports, + metric: "pull_requests", + now: NOW, + }); + + expect(heatmap.totalCount).toBe(2); + expect(dayCount(heatmap, inboxHeatmapDayKey(new Date(2026, 5, 5)))).toBe(2); + }); + + it("excludes suppressed and deleted reports from both metrics", () => { + const reports = [ + fakeReport({ id: "ok", implementation_pr_url: "https://gh/pr/1" }), + fakeReport({ + id: "suppressed", + status: "suppressed", + implementation_pr_url: "https://gh/pr/2", + }), + fakeReport({ id: "deleted", status: "deleted" }), + ]; + + const pulls = computeInboxHeatmap({ + reports, + metric: "pull_requests", + now: NOW, + }); + const created = computeInboxHeatmap({ + reports, + metric: "reports_created", + now: NOW, + }); + + expect(pulls.totalCount).toBe(1); + // reports_created counts the one non-excluded report (the PR one); the + // suppressed and deleted reports are out of the inbox entirely. + expect(created.totalCount).toBe(1); + }); + + it("does not treat status:ready as a merge/landed signal — a ready report with no PR is not counted by pull_requests", () => { + const reports = [ + fakeReport({ status: "ready", implementation_pr_url: null }), + ]; + + const pulls = computeInboxHeatmap({ + reports, + metric: "pull_requests", + now: NOW, + }); + + expect(pulls.totalCount).toBe(0); + }); + + it("buckets reports by created_at day, not updated_at", () => { + const reports = [ + fakeReport({ + id: "pr", + implementation_pr_url: "https://gh/pr/1", + created_at: "2026-06-10T12:00:00Z", + updated_at: "2026-06-16T12:00:00Z", + }), + ]; + + const heatmap = computeInboxHeatmap({ + reports, + metric: "pull_requests", + now: NOW, + }); + + expect(dayCount(heatmap, inboxHeatmapDayKey(new Date(2026, 5, 10)))).toBe( + 1, + ); + expect(dayCount(heatmap, inboxHeatmapDayKey(new Date(2026, 5, 16)))).toBe( + 0, + ); + }); + + it("renders a Sunday-aligned grid of the requested number of weeks", () => { + const heatmap = computeInboxHeatmap({ + reports: [], + metric: "pull_requests", + now: NOW, + weeks: 53, + }); + + expect(heatmap.weeks).toHaveLength(53); + for (const week of heatmap.weeks) { + expect(week.days).toHaveLength(7); + expect(week.days[0]?.date.getDay()).toBe(0); // Sunday + } + // Last column contains today. + const lastWeek = heatmap.weeks[heatmap.weeks.length - 1]; + const todayKey = inboxHeatmapDayKey(new Date(2026, 5, 17)); + expect(lastWeek?.days.some((d) => d.dayKey === todayKey)).toBe(true); + }); + + it("marks days after today as future and never counts them", () => { + // 2026-06-17 is a Wednesday, so Thu–Sat of the last column are in the future. + const heatmap = computeInboxHeatmap({ + reports: [], + metric: "pull_requests", + now: NOW, + }); + const lastWeek = heatmap.weeks[heatmap.weeks.length - 1]; + const futureDays = lastWeek?.days.filter((d) => d.isFuture) ?? []; + expect(futureDays).toHaveLength(3); + for (const day of futureDays) { + expect(day.count).toBe(0); + expect(day.level).toBe(0); + } + }); + + it("drops reports created before the rendered window", () => { + const reports = [ + fakeReport({ + id: "ancient", + implementation_pr_url: "https://gh/pr/1", + created_at: "2024-01-01T12:00:00Z", + }), + ]; + + const heatmap = computeInboxHeatmap({ + reports, + metric: "pull_requests", + now: NOW, + weeks: 53, + }); + + expect(heatmap.totalCount).toBe(0); + }); + + it("assigns the busiest day level 4 and lighter days lower levels", () => { + const reports = [ + // 3 PRs on Jun 10 (busiest), 1 PR on Jun 5. + ...Array.from({ length: 3 }, (_, i) => + fakeReport({ + id: `busy${i}`, + implementation_pr_url: `https://gh/pr/busy${i}`, + created_at: "2026-06-10T12:00:00Z", + }), + ), + fakeReport({ + id: "light", + implementation_pr_url: "https://gh/pr/light", + created_at: "2026-06-05T12:00:00Z", + }), + ]; + + const heatmap = computeInboxHeatmap({ + reports, + metric: "pull_requests", + now: NOW, + }); + + expect(heatmap.maxCount).toBe(3); + expect(heatmap.activeDays).toBe(2); + expect(heatmap.totalCount).toBe(4); + + const findLevel = (date: Date) => { + const key = inboxHeatmapDayKey(date); + for (const week of heatmap.weeks) { + for (const day of week.days) if (day.dayKey === key) return day.level; + } + return -1; + }; + expect(findLevel(new Date(2026, 5, 10))).toBe(4); + expect(findLevel(new Date(2026, 5, 5))).toBe(2); // ceil(1/3 * 4) = 2 + }); + + it("reports zero activity cleanly for an empty inbox", () => { + const heatmap = computeInboxHeatmap({ + reports: [], + metric: DEFAULT_INBOX_HEATMAP_METRIC, + now: NOW, + }); + expect(heatmap.totalCount).toBe(0); + expect(heatmap.activeDays).toBe(0); + expect(heatmap.maxCount).toBe(0); + for (const week of heatmap.weeks) { + for (const day of week.days) expect(day.level).toBe(0); + } + }); +}); + +describe("INBOX_HEATMAP_METRICS", () => { + it("defaults to pull requests", () => { + expect(DEFAULT_INBOX_HEATMAP_METRIC).toBe("pull_requests"); + }); + + 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); + }); +}); + +describe("inboxHeatmapMonthLabels", () => { + it("emits one label per month boundary in column order", () => { + const heatmap = computeInboxHeatmap({ + reports: [], + metric: "pull_requests", + now: NOW, + weeks: 53, + }); + const labels = inboxHeatmapMonthLabels(heatmap, "en-US"); + + expect(labels.length).toBeGreaterThan(0); + // Week indices strictly increasing. + for (let i = 1; i < labels.length; i++) { + expect(labels[i]?.weekIndex).toBeGreaterThan(labels[i - 1]?.weekIndex); + } + // The final label should be the current month (June). + expect(labels[labels.length - 1]?.label).toBe("Jun"); + }); +}); diff --git a/packages/core/src/inbox/inboxHeatmap.ts b/packages/core/src/inbox/inboxHeatmap.ts new file mode 100644 index 000000000..8438af378 --- /dev/null +++ b/packages/core/src/inbox/inboxHeatmap.ts @@ -0,0 +1,246 @@ +import type { SignalReport } from "@posthog/shared/types"; +import { isExcludedFromInbox, isPullRequestReport } from "./reportMembership"; + +/** + * Inbox activity heatmap — a GitHub-contribution-style grid of daily Responder + * output, used to show the value the inbox has produced over the last year. + * + * Everything here is pure: it counts `SignalReport`s (the inbox unit) by their + * `created_at` day using the existing membership predicates. There is no + * separate "pull request" entity — a PR in the inbox is a report carrying + * `implementation_pr_url`, matched by `isPullRequestReport`. Nothing here reads + * `status` as a completion/merge signal: `ready` only means the run finished, + * not that a PR landed, so the metrics deliberately avoid that interpretation. + */ + +export type InboxHeatmapMetric = "pull_requests" | "reports_created"; + +export interface InboxHeatmapMetricMeta { + key: InboxHeatmapMetric; + /** Short label for the metric toggle. */ + label: string; + /** Tooltip noun, e.g. "1 pull request". */ + unitSingular: string; + /** Tooltip noun, e.g. "3 pull requests". */ + unitPlural: string; + /** One-line caption describing what is counted and the date basis. */ + description: string; + /** + * Membership test. A report is tallied on its `created_at` day when this + * returns true. Built only from existing inbox membership helpers so the + * heatmap stays aligned with the tab counts. + */ + includes: (report: SignalReport) => boolean; +} + +export const INBOX_HEATMAP_METRICS: Record< + InboxHeatmapMetric, + InboxHeatmapMetricMeta +> = { + pull_requests: { + key: "pull_requests", + label: "Pull requests", + unitSingular: "pull request", + unitPlural: "pull requests", + description: + "Reports where the Responder opened a pull request, by the day the report was created.", + includes: isPullRequestReport, + }, + reports_created: { + key: "reports_created", + label: "Reports", + unitSingular: "report", + unitPlural: "reports", + description: "Reports surfaced into the inbox, by the day they were created.", + includes: (report) => !isExcludedFromInbox(report), + }, +}; + +/** + * Default metric. Pull requests are the clearest signal of value created — each + * one is a code change the Responder drafted — and `isPullRequestReport` is an + * exact, existing membership rule, so the count is accurate. + */ +export const DEFAULT_INBOX_HEATMAP_METRIC: InboxHeatmapMetric = "pull_requests"; + +export type InboxHeatmapLevel = 0 | 1 | 2 | 3 | 4; + +export interface InboxHeatmapDay { + /** Local calendar day this cell represents (midnight, local time). */ + date: Date; + /** Local `YYYY-MM-DD` key. */ + dayKey: string; + /** Matching reports created on this day. */ + count: number; + /** GitHub-style intensity bucket: 0 (none) … 4 (busiest). */ + level: InboxHeatmapLevel; + /** True for grid cells in the future (the tail of the current week). */ + isFuture: boolean; +} + +export interface InboxHeatmapWeek { + /** Always 7 cells, Sunday → Saturday. */ + days: InboxHeatmapDay[]; +} + +export interface InboxHeatmap { + metric: InboxHeatmapMetric; + /** Week columns, oldest → newest. */ + weeks: InboxHeatmapWeek[]; + /** Total matching reports inside the rendered window. */ + totalCount: number; + /** Days inside the window with at least one matching report. */ + activeDays: number; + /** Busiest single day inside the window. */ + maxCount: number; + /** First in-range (real) day rendered. */ + rangeStart: Date; + /** Last in-range day rendered (the reference "today"). */ + rangeEnd: Date; +} + +export interface ComputeInboxHeatmapOptions { + reports: SignalReport[]; + metric: InboxHeatmapMetric; + /** Reference "today"; the grid ends on the week containing this date. */ + now: Date; + /** Number of week columns to render. Default 53 (~1 year, like GitHub). */ + weeks?: number; +} + +const DAY_MS = 24 * 60 * 60 * 1000; +const DEFAULT_WEEKS = 53; + +function pad2(value: number): string { + return value < 10 ? `0${value}` : String(value); +} + +/** Local `YYYY-MM-DD` key for a date. */ +export function inboxHeatmapDayKey(date: Date): string { + return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`; +} + +function startOfLocalDay(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); +} + +function addDays(date: Date, days: number): Date { + return new Date(date.getTime() + days * DAY_MS); +} + +function parseCreatedDay(value: string | null | undefined): Date | null { + if (!value) return null; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : startOfLocalDay(parsed); +} + +/** Quartile bucket of `count` relative to the window's busiest day. */ +function levelForCount(count: number, maxCount: number): InboxHeatmapLevel { + if (count <= 0 || maxCount <= 0) return 0; + const bucket = Math.ceil((count / maxCount) * 4); + return Math.min(4, Math.max(1, bucket)) as InboxHeatmapLevel; +} + +export function computeInboxHeatmap({ + reports, + metric, + now, + weeks = DEFAULT_WEEKS, +}: ComputeInboxHeatmapOptions): InboxHeatmap { + const weeksCount = Math.max(1, Math.floor(weeks)); + const { includes } = INBOX_HEATMAP_METRICS[metric]; + + // 1. Tally matching reports by their local created-at day. + const countsByDay = new Map(); + for (const report of reports) { + if (!includes(report)) continue; + const created = parseCreatedDay(report.created_at); + if (!created) continue; + const key = inboxHeatmapDayKey(created); + countsByDay.set(key, (countsByDay.get(key) ?? 0) + 1); + } + + // 2. Window: `weeksCount` Sunday-started columns ending on the week of `now`. + const today = startOfLocalDay(now); + const currentWeekSunday = addDays(today, -today.getDay()); + const firstSunday = addDays(currentWeekSunday, -(weeksCount - 1) * 7); + const todayMs = today.getTime(); + + // 3. First pass over in-range days to find the busiest one for leveling. + let maxCount = 0; + let totalCount = 0; + let activeDays = 0; + const lastInRangeMs = todayMs; + for (let i = 0; i < weeksCount * 7; i++) { + const date = addDays(firstSunday, i); + if (date.getTime() > lastInRangeMs) continue; // future tail + const count = countsByDay.get(inboxHeatmapDayKey(date)) ?? 0; + if (count > 0) { + activeDays += 1; + totalCount += count; + if (count > maxCount) maxCount = count; + } + } + + // 4. Build the grid. + const heatmapWeeks: InboxHeatmapWeek[] = []; + for (let w = 0; w < weeksCount; w++) { + const days: InboxHeatmapDay[] = []; + for (let d = 0; d < 7; d++) { + const date = addDays(firstSunday, w * 7 + d); + const isFuture = date.getTime() > todayMs; + const dayKey = inboxHeatmapDayKey(date); + const count = isFuture ? 0 : (countsByDay.get(dayKey) ?? 0); + days.push({ + date, + dayKey, + count, + level: isFuture ? 0 : levelForCount(count, maxCount), + isFuture, + }); + } + heatmapWeeks.push({ days }); + } + + return { + metric, + weeks: heatmapWeeks, + totalCount, + activeDays, + maxCount, + rangeStart: firstSunday, + rangeEnd: today, + }; +} + +export interface InboxHeatmapMonthLabel { + /** Index into `heatmap.weeks` where this month's first column sits. */ + weekIndex: number; + /** Short month name, e.g. "Jun". */ + label: string; +} + +/** + * Month labels for the top axis: one per month boundary, placed on the first + * week column whose Sunday falls in a new month. Mirrors GitHub's sparse axis. + */ +export function inboxHeatmapMonthLabels( + heatmap: InboxHeatmap, + locale?: string, +): InboxHeatmapMonthLabel[] { + const labels: InboxHeatmapMonthLabel[] = []; + let lastMonth = -1; + heatmap.weeks.forEach((week, weekIndex) => { + const firstDay = week.days[0]?.date; + if (!firstDay) return; + const month = firstDay.getMonth(); + if (month !== lastMonth) { + lastMonth = month; + labels.push({ + weekIndex, + label: firstDay.toLocaleDateString(locale, { month: "short" }), + }); + } + }); + return labels; +} diff --git a/packages/ui/src/features/inbox/components/InboxActivityHeatmap.tsx b/packages/ui/src/features/inbox/components/InboxActivityHeatmap.tsx new file mode 100644 index 000000000..da66b25d0 --- /dev/null +++ b/packages/ui/src/features/inbox/components/InboxActivityHeatmap.tsx @@ -0,0 +1,216 @@ +import { + computeInboxHeatmap, + DEFAULT_INBOX_HEATMAP_METRIC, + INBOX_HEATMAP_METRICS, + type InboxHeatmapDay, + type InboxHeatmapLevel, + type InboxHeatmapMetric, + inboxHeatmapMonthLabels, +} from "@posthog/core/inbox/inboxHeatmap"; +import { cn, Skeleton } from "@posthog/quill"; +import { useInboxAllReports } from "@posthog/ui/features/inbox/hooks/useInboxAllReports"; +import { Flex, SegmentedControl, Text } from "@radix-ui/themes"; +import { useEffect, useMemo, useState } from "react"; + +// The list is an infinite query that loads a page at a time. Pull a bounded +// number of pages so the year-long grid is populated without unbounded fetching +// on huge inboxes; older reports beyond this cap simply fall off the left edge. +const MAX_HEATMAP_PAGES = 10; + +const LEVEL_CLASS: Record = { + 0: "bg-(--gray-4)", + 1: "bg-(--accent-5)", + 2: "bg-(--accent-7)", + 3: "bg-(--accent-9)", + 4: "bg-(--accent-11)", +}; + +const WEEKDAY_ROWS = [ + { key: "sun", label: "" }, + { key: "mon", label: "Mon" }, + { key: "tue", label: "" }, + { key: "wed", label: "Wed" }, + { key: "thu", label: "" }, + { key: "fri", label: "Fri" }, + { key: "sat", label: "" }, +]; + +/** + * GitHub-contribution-style heatmap of Responder output on the Inbox surface. + * Each square is one day; intensity is the number of matching `SignalReport`s + * created that day. The default metric (pull requests) shows the code changes + * the Responder has shipped over the last year — a compact picture of the value + * the inbox has produced. Metric membership reuses the inbox helpers, and no + * metric treats `status: "ready"` as a merge/landed signal. + */ +export function InboxActivityHeatmap() { + const [metric, setMetric] = useState( + DEFAULT_INBOX_HEATMAP_METRIC, + ); + // Stamp "today" once per mount so the grid window is stable across renders. + const [now] = useState(() => new Date()); + + // Project-wide, unfiltered: the heatmap reflects all Responder activity, not + // the active scope/search. Same query key as the Runs tab, so React Query + // dedupes the fetch. + const { + allReports, + isLoading, + data, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = useInboxAllReports({ ignoreScope: true, ignoreFilters: true }); + + const pagesLoaded = data?.pages.length ?? 0; + useEffect(() => { + if (hasNextPage && !isFetchingNextPage && pagesLoaded < MAX_HEATMAP_PAGES) { + fetchNextPage(); + } + }, [hasNextPage, isFetchingNextPage, pagesLoaded, fetchNextPage]); + + const heatmap = useMemo( + () => computeInboxHeatmap({ reports: allReports, metric, now }), + [allReports, metric, now], + ); + const monthLabels = useMemo( + () => inboxHeatmapMonthLabels(heatmap), + [heatmap], + ); + const monthByWeek = useMemo(() => { + const map = new Map(); + for (const { weekIndex, label } of monthLabels) map.set(weekIndex, label); + return map; + }, [monthLabels]); + + const meta = INBOX_HEATMAP_METRICS[metric]; + const summary = `${heatmap.totalCount.toLocaleString()} ${ + heatmap.totalCount === 1 ? meta.unitSingular : meta.unitPlural + } in the last year`; + + if (isLoading && allReports.length === 0) { + return ( +
+ +
+ ); + } + + return ( +
+
+ + + + Activity + + + {summary} + + + setMetric(value as InboxHeatmapMetric)} + aria-label="Heatmap metric" + > + + {INBOX_HEATMAP_METRICS.pull_requests.label} + + + {INBOX_HEATMAP_METRICS.reports_created.label} + + + + +
+
+ {/* Month axis, aligned to the week columns below. */} +
+ {heatmap.weeks.map((week, weekIndex) => ( +
+ {monthByWeek.has(weekIndex) && ( + + {monthByWeek.get(weekIndex)} + + )} +
+ ))} +
+ +
+ {/* Weekday axis. */} +
+ {WEEKDAY_ROWS.map((row) => ( +
+ {row.label} +
+ ))} +
+ + {heatmap.weeks.map((week, weekIndex) => ( +
+ {week.days.map((day) => ( + + ))} +
+ ))} +
+
+
+ + + + {meta.description} + + + Less + {([0, 1, 2, 3, 4] as InboxHeatmapLevel[]).map((level) => ( +
+ ))} + More + + +
+
+ ); +} + +function HeatmapCell({ + day, + meta, +}: { + day: InboxHeatmapDay; + meta: (typeof INBOX_HEATMAP_METRICS)[InboxHeatmapMetric]; +}) { + if (day.isFuture) { + return
; + } + const dateLabel = day.date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }); + const unit = day.count === 1 ? meta.unitSingular : meta.unitPlural; + return ( +
+ ); +} diff --git a/packages/ui/src/features/inbox/components/InboxView.tsx b/packages/ui/src/features/inbox/components/InboxView.tsx index 957f0dcf9..a8f5f1032 100644 --- a/packages/ui/src/features/inbox/components/InboxView.tsx +++ b/packages/ui/src/features/inbox/components/InboxView.tsx @@ -1,5 +1,6 @@ import { EnvelopeSimpleIcon } from "@phosphor-icons/react"; import { isInboxDetailPath } from "@posthog/core/inbox/reportMembership"; +import { InboxActivityHeatmap } from "@posthog/ui/features/inbox/components/InboxActivityHeatmap"; import { InboxPageHeader } from "@posthog/ui/features/inbox/components/InboxPageHeader"; import { useInboxAllReports } from "@posthog/ui/features/inbox/hooks/useInboxAllReports"; import { resetReportOpenTrackerHistory } from "@posthog/ui/features/inbox/hooks/useReportOpenTracker"; @@ -48,6 +49,7 @@ export function InboxView() { {!isDetailView && }
+ {!isDetailView && }