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 + }) + }} + /> + + + + + + + ) +}