From 90549c1c29f8262a39a4e9caf77702649bf6862c Mon Sep 17 00:00:00 2001 From: coji Date: Mon, 30 Mar 2026 14:12:14 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20Open=20PR=20=E6=BB=9E=E7=95=99?= =?UTF-8?q?=E6=97=A5=E6=95=B0=E5=88=86=E5=B8=83=E3=83=81=E3=83=A3=E3=83=BC?= =?UTF-8?q?=E3=83=88=20(#253)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Analysis セクションに Inventory ページを追加。 各週末時点の open PR を滞留日数バケット (0-3d / 4-7d / 8-14d / 15-30d / 31d+) で 積み上げ面グラフ表示し、在庫の推移を可視化する。 - チーム / bot 除外 / レビュー未着手フィルタ - unreviewedOnly はスナップショット時点で判定(DB フィルタではなくクライアント集計) - sinceDate を組織タイムゾーン基準で算出 - period デフォルト 6 ヶ月(all は初版では除外) Co-Authored-By: Claude Opus 4.6 (1M context) --- app/components/layout/nav-config.ts | 6 + .../+components/open-pr-inventory-chart.tsx | 128 ++++++++++ .../inventory/+functions/aggregate.test.ts | 194 +++++++++++++++ .../inventory/+functions/aggregate.ts | 133 +++++++++++ .../inventory/+functions/queries.server.ts | 52 ++++ .../$orgSlug/analysis/inventory/index.tsx | 225 ++++++++++++++++++ 6 files changed, 738 insertions(+) create mode 100644 app/routes/$orgSlug/analysis/inventory/+components/open-pr-inventory-chart.tsx create mode 100644 app/routes/$orgSlug/analysis/inventory/+functions/aggregate.test.ts create mode 100644 app/routes/$orgSlug/analysis/inventory/+functions/aggregate.ts create mode 100644 app/routes/$orgSlug/analysis/inventory/+functions/queries.server.ts create mode 100644 app/routes/$orgSlug/analysis/inventory/index.tsx 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..f1100fd0 --- /dev/null +++ b/app/routes/$orgSlug/analysis/inventory/+components/open-pr-inventory-chart.tsx @@ -0,0 +1,128 @@ +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' + +const chartConfig = { + days0to3: { label: '0-3 days', color: 'var(--color-chart-2)' }, + days4to7: { label: '4-7 days', color: 'var(--color-chart-5)' }, + days8to14: { label: '8-14 days', color: 'var(--color-chart-1)' }, + days15to30: { label: '15-30 days', color: 'var(--color-chart-4)' }, + days31Plus: { label: '31+ days', color: 'var(--color-chart-3)' }, +} 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')}` + : '' + }} + /> + } + /> + } + /> + + + + + + + + + + ) +} 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..83dc46cd --- /dev/null +++ b/app/routes/$orgSlug/analysis/inventory/+functions/aggregate.test.ts @@ -0,0 +1,194 @@ +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' + + 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?.days0to3).toBe(1) + }) + + test('bucket boundaries (age days)', () => { + const snapshotSunday = '2024-06-09T23:59:59.999Z' + const sinceDate = '2024-06-01T00:00:00.000Z' + const now = snapshotSunday + + const cases: { + age: number + key: 'days0to3' | 'days4to7' | 'days8to14' | 'days15to30' | 'days31Plus' + }[] = [ + { age: 3, key: 'days0to3' }, + { age: 4, key: 'days4to7' }, + { age: 7, key: 'days4to7' }, + { age: 8, key: 'days8to14' }, + { age: 14, key: 'days8to14' }, + { age: 15, key: 'days15to30' }, + { age: 30, key: 'days15to30' }, + { 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} bucket`).toBe(1) + expect( + (w?.days0to3 ?? 0) + + (w?.days4to7 ?? 0) + + (w?.days8to14 ?? 0) + + (w?.days15to30 ?? 0) + + (w?.days31Plus ?? 0), + ).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' + // PR created Jun 3, reviewed on Jun 10 + const rows: OpenPRInventoryRawRow[] = [ + baseRow({ + number: 1, + pullRequestCreatedAt: '2024-06-03T10:00:00.000Z', + firstReviewedAt: '2024-06-10T12:00:00.000Z', + }), + // PR created Jun 5, never reviewed + baseRow({ + number: 2, + pullRequestCreatedAt: '2024-06-05T10:00:00.000Z', + firstReviewedAt: null, + }), + ] + + const result = aggregateWeeklyOpenPRInventory( + rows, + sinceDate, + now, + tz, + true, // unreviewedOnly + ) + + // 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..2a057e98 --- /dev/null +++ b/app/routes/$orgSlug/analysis/inventory/+functions/aggregate.ts @@ -0,0 +1,133 @@ +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 + days0to3: number + days4to7: number + days8to14: number + days15to30: 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 <= 3) point.days0to3++ + else if (ageDays <= 7) point.days4to7++ + else if (ageDays <= 14) point.days8to14++ + else if (ageDays <= 30) point.days15to30++ + 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, + days0to3: 0, + days4to7: 0, + days8to14: 0, + days15to30: 0, + days31Plus: 0, + total: 0, + } + + 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 = dayjs + .utc(snapshotAt) + .tz(timezone) + .diff(dayjs.utc(row.pullRequestCreatedAt).tz(timezone), '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..23d97089 --- /dev/null +++ b/app/routes/$orgSlug/analysis/inventory/+functions/queries.server.ts @@ -0,0 +1,52 @@ +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') + .leftJoin('companyGithubUsers', (join) => + join.onRef( + (eb) => eb.fn('lower', ['pullRequests.author']), + '=', + (eb) => eb.fn('lower', ['companyGithubUsers.login']), + ), + ) + .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.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..eed32b6b --- /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') + const periodParam = url.searchParams.get('period') + 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 + }) + }} + /> + + + + + + + ) +} From 7a4e481aecf156924419f44251a2d356ece2035a Mon Sep 17 00:00:00 2001 From: coji Date: Mon, 30 Mar 2026 14:34:36 +0900 Subject: [PATCH 2/5] =?UTF-8?q?refactor:=20inventory=20=E3=83=81=E3=83=A3?= =?UTF-8?q?=E3=83=BC=E3=83=88=E3=81=AE=E3=82=B3=E3=83=BC=E3=83=89=E5=93=81?= =?UTF-8?q?=E8=B3=AA=E3=83=BB=E5=8A=B9=E7=8E=87=E6=94=B9=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Area コンポーネントを chartConfig から動的生成(5重複を解消) - ageDays 計算から冗長な .tz() を除去(diff は絶対時間差で tz 不要) - snapshotAt の dayjs オブジェクトをループ外に巻き上げ(週あたり 1 回の生成に) - LEFT JOIN companyGithubUsers を excludeBots=true 時のみに限定 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../+components/open-pr-inventory-chart.tsx | 45 +++++-------------- .../inventory/+functions/aggregate.ts | 9 ++-- .../inventory/+functions/queries.server.ts | 19 ++++---- 3 files changed, 26 insertions(+), 47 deletions(-) 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 index f1100fd0..e6a62ecf 100644 --- a/app/routes/$orgSlug/analysis/inventory/+components/open-pr-inventory-chart.tsx +++ b/app/routes/$orgSlug/analysis/inventory/+components/open-pr-inventory-chart.tsx @@ -85,41 +85,16 @@ export function OpenPRInventoryChart({ } /> - - - - - + {Object.keys(chartConfig).map((key) => ( + + ))} diff --git a/app/routes/$orgSlug/analysis/inventory/+functions/aggregate.ts b/app/routes/$orgSlug/analysis/inventory/+functions/aggregate.ts index 2a057e98..efd19608 100644 --- a/app/routes/$orgSlug/analysis/inventory/+functions/aggregate.ts +++ b/app/routes/$orgSlug/analysis/inventory/+functions/aggregate.ts @@ -109,6 +109,7 @@ export function aggregateWeeklyOpenPRInventory( 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 @@ -119,10 +120,10 @@ export function aggregateWeeklyOpenPRInventory( ) continue - const ageDays = dayjs - .utc(snapshotAt) - .tz(timezone) - .diff(dayjs.utc(row.pullRequestCreatedAt).tz(timezone), 'day') + const ageDays = snapshotDayjs.diff( + dayjs.utc(row.pullRequestCreatedAt), + 'day', + ) addToBucket(point, ageDays) } diff --git a/app/routes/$orgSlug/analysis/inventory/+functions/queries.server.ts b/app/routes/$orgSlug/analysis/inventory/+functions/queries.server.ts index 23d97089..c0b8c5c3 100644 --- a/app/routes/$orgSlug/analysis/inventory/+functions/queries.server.ts +++ b/app/routes/$orgSlug/analysis/inventory/+functions/queries.server.ts @@ -16,13 +16,6 @@ export const getOpenPRInventoryRawData = ( return tenantDb .selectFrom('pullRequests') .innerJoin('repositories', 'pullRequests.repositoryId', 'repositories.id') - .leftJoin('companyGithubUsers', (join) => - join.onRef( - (eb) => eb.fn('lower', ['pullRequests.author']), - '=', - (eb) => eb.fn('lower', ['companyGithubUsers.login']), - ), - ) .where('pullRequests.pullRequestCreatedAt', '<=', now) .where(({ or, eb }) => or([ @@ -39,7 +32,17 @@ export const getOpenPRInventoryRawData = ( .$if(teamId != null, (qb) => qb.where('repositories.teamId', '=', teamId as string), ) - .$if(excludeBotAuthors, (qb) => qb.where(excludeBots)) + .$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', From 8d87794cfa5f1c376157dd55ae0d160c238af88f Mon Sep 17 00:00:00 2001 From: coji Date: Mon, 30 Mar 2026 15:20:03 +0900 Subject: [PATCH 3/5] =?UTF-8?q?refactor:=20inventory=20=E3=83=81=E3=83=A3?= =?UTF-8?q?=E3=83=BC=E3=83=88=E3=81=AE=E8=89=B2=E3=81=A8=E3=83=90=E3=82=B1?= =?UTF-8?q?=E3=83=83=E3=83=88=E3=82=92=20Review=20Stacks=20=E3=81=AB?= =?UTF-8?q?=E7=B5=B1=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6段階: <1d (緑) / 1-3d (青) / 3-7d (黄) / 7-14d (赤) / 14-30d (紫) / 31d+ (黒) Review Stacks の AGE_THRESHOLDS と同じ境界・色を使い、 画面間で「同じ色 = 同じ深刻度」を直感的に伝える。 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../+components/open-pr-inventory-chart.tsx | 12 +++-- .../inventory/+functions/aggregate.test.ts | 47 ++++++++++--------- .../inventory/+functions/aggregate.ts | 27 ++++++----- 3 files changed, 48 insertions(+), 38 deletions(-) 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 index e6a62ecf..9ce72f08 100644 --- a/app/routes/$orgSlug/analysis/inventory/+components/open-pr-inventory-chart.tsx +++ b/app/routes/$orgSlug/analysis/inventory/+components/open-pr-inventory-chart.tsx @@ -19,12 +19,14 @@ import dayjs from '~/app/libs/dayjs' import type { OpenPRInventoryAggregation } from '../+functions/aggregate' +// Colors aligned with Review Stacks age thresholds const chartConfig = { - days0to3: { label: '0-3 days', color: 'var(--color-chart-2)' }, - days4to7: { label: '4-7 days', color: 'var(--color-chart-5)' }, - days8to14: { label: '8-14 days', color: 'var(--color-chart-1)' }, - days15to30: { label: '15-30 days', color: 'var(--color-chart-4)' }, - days31Plus: { label: '31+ days', color: 'var(--color-chart-3)' }, + daysUnder1: { label: '< 1d', color: '#10b981' }, // emerald-500 + days1to3: { label: '1-3d', color: '#3b82f6' }, // blue-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({ diff --git a/app/routes/$orgSlug/analysis/inventory/+functions/aggregate.test.ts b/app/routes/$orgSlug/analysis/inventory/+functions/aggregate.test.ts index 83dc46cd..300f02cb 100644 --- a/app/routes/$orgSlug/analysis/inventory/+functions/aggregate.test.ts +++ b/app/routes/$orgSlug/analysis/inventory/+functions/aggregate.test.ts @@ -49,6 +49,15 @@ describe('isOpenAtSnapshot', () => { 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' @@ -72,25 +81,28 @@ describe('aggregateWeeklyOpenPRInventory', () => { const { weeks } = aggregateWeeklyOpenPRInventory(rows, sinceDate, now, tz) const w = weeks.find((x) => x.snapshotAt === snapshotSunday) expect(w?.total).toBe(1) - expect(w?.days0to3).toBe(1) + expect(w?.days1to3).toBe(1) }) test('bucket boundaries (age days)', () => { const snapshotSunday = '2024-06-09T23:59:59.999Z' - const sinceDate = '2024-06-01T00:00:00.000Z' + const sinceDate = '2024-05-01T00:00:00.000Z' const now = snapshotSunday const cases: { age: number - key: 'days0to3' | 'days4to7' | 'days8to14' | 'days15to30' | 'days31Plus' + key: (typeof allBucketKeys)[number] }[] = [ - { age: 3, key: 'days0to3' }, - { age: 4, key: 'days4to7' }, - { age: 7, key: 'days4to7' }, - { age: 8, key: 'days8to14' }, - { age: 14, key: 'days8to14' }, - { age: 15, key: 'days15to30' }, - { age: 30, key: 'days15to30' }, + { 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' }, ] @@ -109,14 +121,9 @@ describe('aggregateWeeklyOpenPRInventory', () => { 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} bucket`).toBe(1) - expect( - (w?.days0to3 ?? 0) + - (w?.days4to7 ?? 0) + - (w?.days8to14 ?? 0) + - (w?.days15to30 ?? 0) + - (w?.days31Plus ?? 0), - ).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) } }) @@ -150,14 +157,12 @@ describe('aggregateWeeklyOpenPRInventory', () => { test('unreviewedOnly filters by snapshot-time review status', () => { const sinceDate = '2024-06-01T00:00:00.000Z' const now = '2024-06-16T23:59:59.999Z' - // PR created Jun 3, reviewed on Jun 10 const rows: OpenPRInventoryRawRow[] = [ baseRow({ number: 1, pullRequestCreatedAt: '2024-06-03T10:00:00.000Z', firstReviewedAt: '2024-06-10T12:00:00.000Z', }), - // PR created Jun 5, never reviewed baseRow({ number: 2, pullRequestCreatedAt: '2024-06-05T10:00:00.000Z', @@ -170,7 +175,7 @@ describe('aggregateWeeklyOpenPRInventory', () => { sinceDate, now, tz, - true, // unreviewedOnly + true, ) // Week of Jun 3 (snapshot Jun 9): PR#1 not yet reviewed → counted, PR#2 counted → total 2 diff --git a/app/routes/$orgSlug/analysis/inventory/+functions/aggregate.ts b/app/routes/$orgSlug/analysis/inventory/+functions/aggregate.ts index efd19608..26ec953f 100644 --- a/app/routes/$orgSlug/analysis/inventory/+functions/aggregate.ts +++ b/app/routes/$orgSlug/analysis/inventory/+functions/aggregate.ts @@ -12,10 +12,11 @@ export interface OpenPRInventoryRawRow { export interface InventoryWeekPoint { weekLabel: string snapshotAt: string - days0to3: number - days4to7: number - days8to14: number - days15to30: number + daysUnder1: number + days1to3: number + days3to7: number + days7to14: number + days14to30: number days31Plus: number total: number } @@ -79,10 +80,11 @@ function getSnapshotAtForWeek( function addToBucket(point: InventoryWeekPoint, ageDays: number): void { point.total++ - if (ageDays <= 3) point.days0to3++ - else if (ageDays <= 7) point.days4to7++ - else if (ageDays <= 14) point.days8to14++ - else if (ageDays <= 30) point.days15to30++ + 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++ } @@ -101,10 +103,11 @@ export function aggregateWeeklyOpenPRInventory( const point: InventoryWeekPoint = { weekLabel: weekStart.format('MM/DD'), snapshotAt, - days0to3: 0, - days4to7: 0, - days8to14: 0, - days15to30: 0, + daysUnder1: 0, + days1to3: 0, + days3to7: 0, + days7to14: 0, + days14to30: 0, days31Plus: 0, total: 0, } From bfbefaec56b0cc7f98f430111cf7eb9275324e55 Mon Sep 17 00:00:00 2001 From: coji Date: Mon, 30 Mar 2026 15:21:41 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20inventory=20=E3=83=81=E3=83=A3?= =?UTF-8?q?=E3=83=BC=E3=83=88=E3=81=AE=E8=89=B2=E9=A0=86=E3=82=92=E9=9D=92?= =?UTF-8?q?=E2=86=92=E7=B7=91=E2=86=92=E9=BB=84=E2=86=92=E8=B5=A4=E2=86=92?= =?UTF-8?q?=E7=B4=AB=E2=86=92=E9=BB=92=E3=81=AB=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../inventory/+components/open-pr-inventory-chart.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 9ce72f08..512ffb0f 100644 --- a/app/routes/$orgSlug/analysis/inventory/+components/open-pr-inventory-chart.tsx +++ b/app/routes/$orgSlug/analysis/inventory/+components/open-pr-inventory-chart.tsx @@ -21,8 +21,8 @@ import type { OpenPRInventoryAggregation } from '../+functions/aggregate' // Colors aligned with Review Stacks age thresholds const chartConfig = { - daysUnder1: { label: '< 1d', color: '#10b981' }, // emerald-500 - days1to3: { label: '1-3d', color: '#3b82f6' }, // blue-500 + 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 From f003a488ad653baf627a30a430a455bcbe9a1744 Mon Sep 17 00:00:00 2001 From: coji Date: Mon, 30 Mar 2026 15:23:47 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20searchParams=20=E3=81=AE=E7=A9=BA?= =?UTF-8?q?=E6=96=87=E5=AD=97=E5=88=97=E3=82=92=20null=20=E3=81=AB?= =?UTF-8?q?=E6=AD=A3=E8=A6=8F=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit 指摘対応: ?team= のような空文字列パラメータが フィルタ条件に渡されて結果が空になる問題を防止 Co-Authored-By: Claude Opus 4.6 (1M context) --- app/routes/$orgSlug/analysis/inventory/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/routes/$orgSlug/analysis/inventory/index.tsx b/app/routes/$orgSlug/analysis/inventory/index.tsx index eed32b6b..bc33f245 100644 --- a/app/routes/$orgSlug/analysis/inventory/index.tsx +++ b/app/routes/$orgSlug/analysis/inventory/index.tsx @@ -43,8 +43,8 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { const timezone = context.get(timezoneContext) const url = new URL(request.url) - const teamParam = url.searchParams.get('team') - const periodParam = url.searchParams.get('period') + 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], )