diff --git a/app/components/layout/nav-config.ts b/app/components/layout/nav-config.ts
index a45e86bb..2850bb51 100644
--- a/app/components/layout/nav-config.ts
+++ b/app/components/layout/nav-config.ts
@@ -1,4 +1,5 @@
import {
+ ChartAreaIcon,
CircleDotIcon,
FunnelIcon,
GitMergeIcon,
@@ -50,6 +51,11 @@ export function getNavConfig(orgSlug: string): NavGroupProps[] {
url: href('/:orgSlug/analysis/reviews', { orgSlug }),
icon: FunnelIcon,
},
+ {
+ title: 'Inventory',
+ url: href('/:orgSlug/analysis/inventory', { orgSlug }),
+ icon: ChartAreaIcon,
+ },
{
title: 'Feedbacks',
url: href('/:orgSlug/analysis/feedbacks', { orgSlug }),
diff --git a/app/routes/$orgSlug/analysis/inventory/+components/open-pr-inventory-chart.tsx b/app/routes/$orgSlug/analysis/inventory/+components/open-pr-inventory-chart.tsx
new file mode 100644
index 00000000..512ffb0f
--- /dev/null
+++ b/app/routes/$orgSlug/analysis/inventory/+components/open-pr-inventory-chart.tsx
@@ -0,0 +1,105 @@
+import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '~/app/components/ui/card'
+import {
+ type ChartConfig,
+ ChartContainer,
+ ChartLegend,
+ ChartLegendContent,
+ ChartTooltip,
+ ChartTooltipContent,
+} from '~/app/components/ui/chart'
+import { useTimezone } from '~/app/hooks/use-timezone'
+import dayjs from '~/app/libs/dayjs'
+
+import type { OpenPRInventoryAggregation } from '../+functions/aggregate'
+
+// Colors aligned with Review Stacks age thresholds
+const chartConfig = {
+ daysUnder1: { label: '< 1d', color: '#3b82f6' }, // blue-500
+ days1to3: { label: '1-3d', color: '#10b981' }, // emerald-500
+ days3to7: { label: '3-7d', color: '#f59e0b' }, // amber-500
+ days7to14: { label: '7-14d', color: '#ef4444' }, // red-500
+ days14to30: { label: '14-30d', color: '#a855f7' }, // purple-500
+ days31Plus: { label: '31d+', color: '#262626' }, // neutral-800
+} satisfies ChartConfig
+
+export function OpenPRInventoryChart({
+ data,
+}: {
+ data: OpenPRInventoryAggregation
+}) {
+ const timezone = useTimezone()
+ const { weeks } = data
+
+ const hasAnyTotal = weeks.some((w) => w.total > 0)
+
+ if (weeks.length === 0 || !hasAnyTotal) {
+ return (
+
+
+ Open PR inventory
+
+ No open pull requests match the selected filters for this period.
+
+
+
+ )
+ }
+
+ return (
+
+
+ Open PR inventory
+
+ Stacked open PR counts by age (days open), measured at each week end
+ (or now for the current week). Shows where review backlog builds up.
+
+
+
+
+
+
+
+
+ {
+ const snapshotAt = payload?.[0]?.payload?.snapshotAt as
+ | string
+ | undefined
+ return snapshotAt
+ ? `Week ending ${dayjs.utc(snapshotAt).tz(timezone).format('YYYY-MM-DD')}`
+ : ''
+ }}
+ />
+ }
+ />
+ }
+ />
+ {Object.keys(chartConfig).map((key) => (
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/app/routes/$orgSlug/analysis/inventory/+functions/aggregate.test.ts b/app/routes/$orgSlug/analysis/inventory/+functions/aggregate.test.ts
new file mode 100644
index 00000000..300f02cb
--- /dev/null
+++ b/app/routes/$orgSlug/analysis/inventory/+functions/aggregate.test.ts
@@ -0,0 +1,199 @@
+import { describe, expect, test } from 'vitest'
+
+import dayjs from '~/app/libs/dayjs'
+
+import {
+ aggregateWeeklyOpenPRInventory,
+ isOpenAtSnapshot,
+ type OpenPRInventoryRawRow,
+} from './aggregate'
+
+const baseRow = (
+ overrides: Partial,
+): OpenPRInventoryRawRow => ({
+ repositoryId: 'repo-1',
+ number: 1,
+ pullRequestCreatedAt: '2024-06-01T00:00:00.000Z',
+ mergedAt: null,
+ closedAt: null,
+ firstReviewedAt: null,
+ ...overrides,
+})
+
+describe('isOpenAtSnapshot', () => {
+ test('mergedAt/closedAt exactly at snapshotAt are not open', () => {
+ const snap = '2024-06-09T12:00:00.000Z'
+ expect(
+ isOpenAtSnapshot(
+ baseRow({
+ pullRequestCreatedAt: '2024-06-01T00:00:00.000Z',
+ mergedAt: snap,
+ closedAt: null,
+ }),
+ snap,
+ ),
+ ).toBe(false)
+ expect(
+ isOpenAtSnapshot(
+ baseRow({
+ pullRequestCreatedAt: '2024-06-01T00:00:00.000Z',
+ mergedAt: null,
+ closedAt: snap,
+ }),
+ snap,
+ ),
+ ).toBe(false)
+ })
+})
+
+describe('aggregateWeeklyOpenPRInventory', () => {
+ const tz = 'UTC'
+
+ const allBucketKeys = [
+ 'daysUnder1',
+ 'days1to3',
+ 'days3to7',
+ 'days7to14',
+ 'days14to30',
+ 'days31Plus',
+ ] as const
+
+ test('counts only PRs open at each week snapshot', () => {
+ const snapshotSunday = '2024-06-09T23:59:59.999Z'
+ const sinceDate = '2024-06-01T00:00:00.000Z'
+ const now = snapshotSunday
+
+ const rows: OpenPRInventoryRawRow[] = [
+ baseRow({
+ number: 1,
+ pullRequestCreatedAt: '2024-06-08T12:00:00.000Z',
+ mergedAt: null,
+ closedAt: null,
+ }),
+ baseRow({
+ number: 2,
+ pullRequestCreatedAt: '2024-06-01T00:00:00.000Z',
+ mergedAt: '2024-06-05T00:00:00.000Z',
+ closedAt: null,
+ }),
+ ]
+
+ const { weeks } = aggregateWeeklyOpenPRInventory(rows, sinceDate, now, tz)
+ const w = weeks.find((x) => x.snapshotAt === snapshotSunday)
+ expect(w?.total).toBe(1)
+ expect(w?.days1to3).toBe(1)
+ })
+
+ test('bucket boundaries (age days)', () => {
+ const snapshotSunday = '2024-06-09T23:59:59.999Z'
+ const sinceDate = '2024-05-01T00:00:00.000Z'
+ const now = snapshotSunday
+
+ const cases: {
+ age: number
+ key: (typeof allBucketKeys)[number]
+ }[] = [
+ { age: 0, key: 'daysUnder1' },
+ { age: 1, key: 'days1to3' },
+ { age: 2, key: 'days1to3' },
+ { age: 3, key: 'days3to7' },
+ { age: 6, key: 'days3to7' },
+ { age: 7, key: 'days7to14' },
+ { age: 13, key: 'days7to14' },
+ { age: 14, key: 'days14to30' },
+ { age: 29, key: 'days14to30' },
+ { age: 30, key: 'days31Plus' },
+ { age: 31, key: 'days31Plus' },
+ ]
+
+ for (const { age, key } of cases) {
+ const created = dayjs
+ .utc(snapshotSunday)
+ .tz(tz)
+ .subtract(age, 'day')
+ .toISOString()
+ const { weeks } = aggregateWeeklyOpenPRInventory(
+ [baseRow({ number: age, pullRequestCreatedAt: created })],
+ sinceDate,
+ now,
+ tz,
+ )
+ const w = weeks.find((x) => x.snapshotAt === snapshotSunday)
+ expect(w, `age ${age}`).toBeDefined()
+ expect(w?.total, `age ${age}`).toBe(1)
+ expect(w?.[key], `age ${age} → ${key}`).toBe(1)
+ const bucketSum = allBucketKeys.reduce((sum, k) => sum + (w?.[k] ?? 0), 0)
+ expect(bucketSum, `age ${age} single bucket`).toBe(1)
+ }
+ })
+
+ test('current incomplete week uses now as snapshot and counts at now', () => {
+ const sinceDate = '2024-06-03T00:00:00.000Z'
+ const now = '2024-06-05T15:00:00.000Z'
+ const rows: OpenPRInventoryRawRow[] = [
+ baseRow({
+ pullRequestCreatedAt: '2024-06-03T10:00:00.000Z',
+ mergedAt: null,
+ closedAt: null,
+ }),
+ ]
+
+ const { weeks } = aggregateWeeklyOpenPRInventory(rows, sinceDate, now, tz)
+ expect(weeks).toHaveLength(1)
+ expect(weeks[0]?.snapshotAt).toBe(now)
+ expect(weeks[0]?.total).toBe(1)
+ })
+
+ test('empty rows still yields weeks with zeros', () => {
+ const sinceDate = '2024-06-01T00:00:00.000Z'
+ const now = '2024-06-15T12:00:00.000Z'
+ const { weeks } = aggregateWeeklyOpenPRInventory([], sinceDate, now, tz)
+ expect(weeks.length).toBeGreaterThan(0)
+ for (const w of weeks) {
+ expect(w.total).toBe(0)
+ }
+ })
+
+ test('unreviewedOnly filters by snapshot-time review status', () => {
+ const sinceDate = '2024-06-01T00:00:00.000Z'
+ const now = '2024-06-16T23:59:59.999Z'
+ const rows: OpenPRInventoryRawRow[] = [
+ baseRow({
+ number: 1,
+ pullRequestCreatedAt: '2024-06-03T10:00:00.000Z',
+ firstReviewedAt: '2024-06-10T12:00:00.000Z',
+ }),
+ baseRow({
+ number: 2,
+ pullRequestCreatedAt: '2024-06-05T10:00:00.000Z',
+ firstReviewedAt: null,
+ }),
+ ]
+
+ const result = aggregateWeeklyOpenPRInventory(
+ rows,
+ sinceDate,
+ now,
+ tz,
+ true,
+ )
+
+ // Week of Jun 3 (snapshot Jun 9): PR#1 not yet reviewed → counted, PR#2 counted → total 2
+ const week1 = result.weeks.find((w) => w.weekLabel === '06/03')
+ expect(week1?.total).toBe(2)
+
+ // Week of Jun 10 (snapshot Jun 16): PR#1 reviewed on Jun 10 ≤ snapshot → excluded, PR#2 counted → total 1
+ const week2 = result.weeks.find((w) => w.weekLabel === '06/10')
+ expect(week2?.total).toBe(1)
+ })
+
+ test('sinceDate after now yields empty weeks', () => {
+ const { weeks } = aggregateWeeklyOpenPRInventory(
+ [],
+ '2025-01-01T00:00:00.000Z',
+ '2024-01-01T00:00:00.000Z',
+ tz,
+ )
+ expect(weeks).toEqual([])
+ })
+})
diff --git a/app/routes/$orgSlug/analysis/inventory/+functions/aggregate.ts b/app/routes/$orgSlug/analysis/inventory/+functions/aggregate.ts
new file mode 100644
index 00000000..26ec953f
--- /dev/null
+++ b/app/routes/$orgSlug/analysis/inventory/+functions/aggregate.ts
@@ -0,0 +1,137 @@
+import dayjs from '~/app/libs/dayjs'
+
+export interface OpenPRInventoryRawRow {
+ repositoryId: string
+ number: number
+ pullRequestCreatedAt: string
+ mergedAt: string | null
+ closedAt: string | null
+ firstReviewedAt: string | null
+}
+
+export interface InventoryWeekPoint {
+ weekLabel: string
+ snapshotAt: string
+ daysUnder1: number
+ days1to3: number
+ days3to7: number
+ days7to14: number
+ days14to30: number
+ days31Plus: number
+ total: number
+}
+
+export interface OpenPRInventoryAggregation {
+ weeks: InventoryWeekPoint[]
+}
+
+function startOfWeekMonday(d: dayjs.Dayjs): dayjs.Dayjs {
+ const day = d.day()
+ const diffToMonday = day === 0 ? -6 : 1 - day
+ return d.startOf('day').add(diffToMonday, 'day')
+}
+
+export function isOpenAtSnapshot(
+ row: OpenPRInventoryRawRow,
+ snapshotAt: string,
+): boolean {
+ if (row.pullRequestCreatedAt > snapshotAt) return false
+ if (row.mergedAt !== null && row.mergedAt <= snapshotAt) return false
+ if (row.closedAt !== null && row.closedAt <= snapshotAt) return false
+ return true
+}
+
+function enumerateWeekStarts(
+ sinceDate: string,
+ now: string,
+ timezone: string,
+): dayjs.Dayjs[] {
+ const since = dayjs.utc(sinceDate).tz(timezone)
+ const nowTz = dayjs.utc(now).tz(timezone)
+
+ if (since.isAfter(nowTz)) {
+ return []
+ }
+
+ const firstMonday = startOfWeekMonday(since)
+ const lastMonday = startOfWeekMonday(nowTz)
+
+ const weeks: dayjs.Dayjs[] = []
+ let cursor = firstMonday
+ while (cursor.isBefore(lastMonday) || cursor.isSame(lastMonday, 'day')) {
+ weeks.push(cursor)
+ cursor = cursor.add(7, 'day')
+ }
+ return weeks
+}
+
+function getSnapshotAtForWeek(
+ weekStart: dayjs.Dayjs,
+ now: string,
+ timezone: string,
+): string {
+ const nowTz = dayjs.utc(now).tz(timezone)
+ const thisWeekMonday = startOfWeekMonday(nowTz)
+ if (weekStart.isSame(thisWeekMonday, 'day')) {
+ return now
+ }
+ return weekStart.add(6, 'day').endOf('day').utc().toISOString()
+}
+
+function addToBucket(point: InventoryWeekPoint, ageDays: number): void {
+ point.total++
+ if (ageDays < 1) point.daysUnder1++
+ else if (ageDays < 3) point.days1to3++
+ else if (ageDays < 7) point.days3to7++
+ else if (ageDays < 14) point.days7to14++
+ else if (ageDays < 30) point.days14to30++
+ else point.days31Plus++
+}
+
+export function aggregateWeeklyOpenPRInventory(
+ rows: OpenPRInventoryRawRow[],
+ sinceDate: string,
+ now: string,
+ timezone: string,
+ unreviewedOnly = false,
+): OpenPRInventoryAggregation {
+ const weekStarts = enumerateWeekStarts(sinceDate, now, timezone)
+
+ return {
+ weeks: weekStarts.map((weekStart) => {
+ const snapshotAt = getSnapshotAtForWeek(weekStart, now, timezone)
+ const point: InventoryWeekPoint = {
+ weekLabel: weekStart.format('MM/DD'),
+ snapshotAt,
+ daysUnder1: 0,
+ days1to3: 0,
+ days3to7: 0,
+ days7to14: 0,
+ days14to30: 0,
+ days31Plus: 0,
+ total: 0,
+ }
+
+ const snapshotDayjs = dayjs.utc(snapshotAt)
+ for (const row of rows) {
+ if (!isOpenAtSnapshot(row, snapshotAt)) continue
+ // unreviewedOnly: skip if PR was already reviewed before this snapshot
+ if (
+ unreviewedOnly &&
+ row.firstReviewedAt !== null &&
+ row.firstReviewedAt <= snapshotAt
+ )
+ continue
+
+ const ageDays = snapshotDayjs.diff(
+ dayjs.utc(row.pullRequestCreatedAt),
+ 'day',
+ )
+
+ addToBucket(point, ageDays)
+ }
+
+ return point
+ }),
+ }
+}
diff --git a/app/routes/$orgSlug/analysis/inventory/+functions/queries.server.ts b/app/routes/$orgSlug/analysis/inventory/+functions/queries.server.ts
new file mode 100644
index 00000000..c0b8c5c3
--- /dev/null
+++ b/app/routes/$orgSlug/analysis/inventory/+functions/queries.server.ts
@@ -0,0 +1,55 @@
+import { excludeBots } from '~/app/libs/tenant-query.server'
+import { getTenantDb } from '~/app/services/tenant-db.server'
+import type { OrganizationId } from '~/app/types/organization'
+
+import type { OpenPRInventoryRawRow } from './aggregate'
+
+export const getOpenPRInventoryRawData = (
+ organizationId: OrganizationId,
+ sinceDate: string,
+ now: string,
+ teamId?: string | null,
+ excludeBotAuthors = true,
+): Promise => {
+ const tenantDb = getTenantDb(organizationId)
+
+ return tenantDb
+ .selectFrom('pullRequests')
+ .innerJoin('repositories', 'pullRequests.repositoryId', 'repositories.id')
+ .where('pullRequests.pullRequestCreatedAt', '<=', now)
+ .where(({ or, eb }) =>
+ or([
+ eb('pullRequests.mergedAt', 'is', null),
+ eb('pullRequests.mergedAt', '>=', sinceDate),
+ ]),
+ )
+ .where(({ or, eb }) =>
+ or([
+ eb('pullRequests.closedAt', 'is', null),
+ eb('pullRequests.closedAt', '>=', sinceDate),
+ ]),
+ )
+ .$if(teamId != null, (qb) =>
+ qb.where('repositories.teamId', '=', teamId as string),
+ )
+ .$if(excludeBotAuthors, (qb) =>
+ qb
+ .leftJoin('companyGithubUsers', (join) =>
+ join.onRef(
+ (eb) => eb.fn('lower', ['pullRequests.author']),
+ '=',
+ (eb) => eb.fn('lower', ['companyGithubUsers.login']),
+ ),
+ )
+ .where(excludeBots),
+ )
+ .select([
+ 'pullRequests.repositoryId',
+ 'pullRequests.number',
+ 'pullRequests.pullRequestCreatedAt',
+ 'pullRequests.mergedAt',
+ 'pullRequests.closedAt',
+ 'pullRequests.firstReviewedAt',
+ ])
+ .execute()
+}
diff --git a/app/routes/$orgSlug/analysis/inventory/index.tsx b/app/routes/$orgSlug/analysis/inventory/index.tsx
new file mode 100644
index 00000000..bc33f245
--- /dev/null
+++ b/app/routes/$orgSlug/analysis/inventory/index.tsx
@@ -0,0 +1,225 @@
+import { useSearchParams } from 'react-router'
+import {
+ PageHeader,
+ PageHeaderActions,
+ PageHeaderDescription,
+ PageHeaderHeading,
+ PageHeaderTitle,
+} from '~/app/components/layout/page-header'
+import { TeamFilter } from '~/app/components/team-filter'
+import { HStack, Label, Stack } from '~/app/components/ui'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '~/app/components/ui/select'
+import { Switch } from '~/app/components/ui/switch'
+import dayjs from '~/app/libs/dayjs'
+import { orgContext, timezoneContext } from '~/app/middleware/context'
+import { listTeams } from '~/app/routes/$orgSlug/settings/teams._index/queries.server'
+import { getOrgCachedData } from '~/app/services/cache.server'
+import { OpenPRInventoryChart } from './+components/open-pr-inventory-chart'
+import { aggregateWeeklyOpenPRInventory } from './+functions/aggregate'
+import { getOpenPRInventoryRawData } from './+functions/queries.server'
+import type { Route } from './+types/index'
+
+export const handle = {
+ breadcrumb: () => ({ label: 'Inventory' }),
+}
+
+const PERIOD_OPTIONS = [
+ { value: '1', label: '1 month' },
+ { value: '3', label: '3 months' },
+ { value: '6', label: '6 months' },
+ { value: '12', label: '1 year' },
+] as const
+
+const VALID_PERIODS = [1, 3, 6, 12] as const
+
+export const loader = async ({ request, context }: Route.LoaderArgs) => {
+ const { organization } = context.get(orgContext)
+ const timezone = context.get(timezoneContext)
+
+ const url = new URL(request.url)
+ const teamParam = url.searchParams.get('team') || null
+ const periodParam = url.searchParams.get('period') || null
+ const periodMonths = VALID_PERIODS.includes(
+ Number(periodParam) as (typeof VALID_PERIODS)[number],
+ )
+ ? Number(periodParam)
+ : 6
+
+ const excludeBots = url.searchParams.get('excludeBots') !== '0'
+ const unreviewedOnly = url.searchParams.get('unreviewedOnly') === '1'
+
+ const sinceDate = dayjs
+ .utc()
+ .tz(timezone)
+ .subtract(periodMonths, 'month')
+ .startOf('day')
+ .utc()
+ .toISOString()
+
+ const now = dayjs.utc().toISOString()
+
+ const teams = await listTeams(organization.id)
+
+ // unreviewedOnly はクライアント集計で判定するため、キャッシュキーには含めない
+ // (同じ raw データから unreviewedOnly ON/OFF 両方の集計が可能)
+ const cacheKey = `inventory:${teamParam ?? 'all'}:${periodMonths}:${excludeBots ? 'exclude-bots' : 'include-bots'}`
+
+ const FIVE_MINUTES = 5 * 60 * 1000
+
+ const rawRows = await getOrgCachedData(
+ organization.id,
+ cacheKey,
+ () =>
+ getOpenPRInventoryRawData(
+ organization.id,
+ sinceDate,
+ now,
+ teamParam,
+ excludeBots,
+ ),
+ FIVE_MINUTES,
+ )
+
+ return {
+ teams,
+ rawRows,
+ sinceDate,
+ now,
+ timezone,
+ periodMonths,
+ excludeBots,
+ unreviewedOnly,
+ }
+}
+
+export const clientLoader = async ({
+ serverLoader,
+}: Route.ClientLoaderArgs) => {
+ const {
+ teams,
+ rawRows,
+ sinceDate,
+ now,
+ timezone,
+ periodMonths,
+ excludeBots,
+ unreviewedOnly,
+ } = await serverLoader()
+
+ return {
+ teams,
+ inventory: aggregateWeeklyOpenPRInventory(
+ rawRows,
+ sinceDate,
+ now,
+ timezone,
+ unreviewedOnly,
+ ),
+ periodMonths,
+ excludeBots,
+ unreviewedOnly,
+ }
+}
+clientLoader.hydrate = true as const
+
+export function HydrateFallback() {
+ return (
+
+
+
+ Open PR Inventory
+ Loading...
+
+
+
+ )
+}
+
+export default function InventoryPage({
+ loaderData: { teams, inventory, periodMonths, excludeBots, unreviewedOnly },
+}: Route.ComponentProps) {
+ const [, setSearchParams] = useSearchParams()
+
+ return (
+
+
+
+ Open PR Inventory
+
+ Weekly snapshots of open PR backlog by age—see how review inventory
+ stacks up across time.
+
+
+
+
+
+
+
+ {
+ setSearchParams((prev) => {
+ if (checked) {
+ prev.delete('excludeBots')
+ } else {
+ prev.set('excludeBots', '0')
+ }
+ return prev
+ })
+ }}
+ />
+
+
+
+ {
+ setSearchParams((prev) => {
+ if (checked) {
+ prev.set('unreviewedOnly', '1')
+ } else {
+ prev.delete('unreviewedOnly')
+ }
+ return prev
+ })
+ }}
+ />
+
+
+
+
+
+
+ )
+}