From 56dd5eb1e3bc80e11809e7bdfd6e2489b6bca332 Mon Sep 17 00:00:00 2001 From: coji Date: Mon, 16 Mar 2026 14:13:56 +0900 Subject: [PATCH 1/9] improve: week calendar UX and throughput dashboard cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WeeklyCalendar: - Today ボタンをカレンダーポップアップ内の「今週に戻る」に移動 - 週ラベルを常に M/D – M/D 形式に統一(年またぎでも幅安定) - カレンダーのボーダー二重表示を修正 Throughput Merged/Deployed: - Objective の grid 表示を Big Number カード3枚に変更 - 中央値(Median Time to Merge/Deploy)を追加 - テーブルタイトルの冗長な日付・件数表示を削除 Throughput Ongoing: - テーブルタイトルを Big Number カード2枚に変更(件数 + Median Age) Co-Authored-By: Claude Opus 4.6 (1M context) --- app/components/week-calendar.tsx | 37 ++++---- .../$orgSlug/throughput/deployed/index.tsx | 84 +++++++++++-------- .../$orgSlug/throughput/merged/index.tsx | 82 ++++++++++-------- .../$orgSlug/throughput/ongoing/index.tsx | 30 ++++++- 4 files changed, 138 insertions(+), 95 deletions(-) diff --git a/app/components/week-calendar.tsx b/app/components/week-calendar.tsx index 3248fa73..7472fff9 100644 --- a/app/components/week-calendar.tsx +++ b/app/components/week-calendar.tsx @@ -135,10 +135,6 @@ const WeeklyCalendar = ({ navigateTo(dayjs(weekInterval.start).add(7, 'day').toDate()) } - const handleToday = () => { - navigateTo(new Date()) - } - const handleDateSelect = (date: Date | undefined) => { if (date) { navigateTo(date) @@ -156,9 +152,6 @@ const WeeklyCalendar = ({ const formatWeekLabel = () => { const start = dayjs(weekInterval.start) const end = dayjs(weekInterval.end) - if (start.year() !== end.year()) { - return `${start.format('YYYY/M/D')} – ${end.format('YYYY/M/D')}` - } return `${start.format('M/D')} – ${end.format('M/D')}` } @@ -211,8 +204,24 @@ const WeeklyCalendar = ({ weekStartsOn={startDay} locale={ja} components={calendarComponents} - className="rounded-lg border p-3" + className="p-1" /> + {!isCurrentWeek && ( +
+ +
+ )} @@ -226,18 +235,6 @@ const WeeklyCalendar = ({ > - - {!isCurrentWeek && ( - - )} ) } diff --git a/app/routes/$orgSlug/throughput/deployed/index.tsx b/app/routes/$orgSlug/throughput/deployed/index.tsx index acc4cca1..8faea504 100644 --- a/app/routes/$orgSlug/throughput/deployed/index.tsx +++ b/app/routes/$orgSlug/throughput/deployed/index.tsx @@ -11,7 +11,7 @@ import { PageHeaderTitle, } from '~/app/components/layout/page-header' import { TeamFilter } from '~/app/components/team-filter' -import { Button, Label, Stack } from '~/app/components/ui' +import { Button, Stack } from '~/app/components/ui' import { DropdownMenuCheckboxItem, DropdownMenuLabel, @@ -71,13 +71,26 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { const achievementRate = pullRequests.length > 0 ? (achievementCount / pullRequests.length) * 100 : 0 + const deployTimes = pullRequests + .map((pr) => pr.createAndDeployDiff) + .filter((v): v is number => v !== null) + .sort((a, b) => a - b) + const median = + deployTimes.length > 0 + ? deployTimes.length % 2 === 1 + ? deployTimes[Math.floor(deployTimes.length / 2)] + : (deployTimes[deployTimes.length / 2 - 1] + + deployTimes[deployTimes.length / 2]) / + 2 + : null + return { pullRequests, from: from.toISOString(), to: to.toISOString(), objective, - achievementCount, achievementRate, + median, teams, businessDaysOnly, } @@ -87,10 +100,9 @@ export default function DeployedPage({ loaderData: { pullRequests, from, - to, objective, - achievementCount, achievementRate, + median, teams, businessDaysOnly, }, @@ -125,45 +137,43 @@ export default function DeployedPage({ -
- { - setSearchParams((prev) => { - prev.set('from', dayjs(start).format('YYYY-MM-DD')) - prev.set('to', dayjs(start).add(6, 'day').format('YYYY-MM-DD')) - return prev - }) - }} - /> - -
+ { + setSearchParams((prev) => { + prev.set('from', dayjs(start).format('YYYY-MM-DD')) + prev.set('to', dayjs(start).add(6, 'day').format('YYYY-MM-DD')) + return prev + }) + }} + /> -
- -
-
Time to Deploy
-
- {'< '} - {objective.toFixed(1)} - d -
-
Achievement
-
- {achievementRate.toFixed(1)} - % ({achievementCount.toLocaleString()}) -
+
+
+
{pullRequests.length}
+
Deployed
+
+
+
+ {median !== null ? `${median.toFixed(1)}d` : '–'} +
+
+ Median Time to Deploy +
+
+
+
+ {achievementRate.toFixed(1)}% +
+
Achievement
+
+ Goal {'< '} + {objective.toFixed(1)}d
- Deployed {dayjs(from).tz(timezone).format('M/D')} -{' '} - {dayjs(to).tz(timezone).format('M/D')}: {pullRequests.length} -
- } columns={columns} data={pullRequests} getRowId={(row) => `${row.repositoryId}:${row.number}`} diff --git a/app/routes/$orgSlug/throughput/merged/index.tsx b/app/routes/$orgSlug/throughput/merged/index.tsx index fd5071df..351addfb 100644 --- a/app/routes/$orgSlug/throughput/merged/index.tsx +++ b/app/routes/$orgSlug/throughput/merged/index.tsx @@ -11,7 +11,7 @@ import { PageHeaderTitle, } from '~/app/components/layout/page-header' import { TeamFilter } from '~/app/components/team-filter' -import { Button, Label, Stack } from '~/app/components/ui' +import { Button, Stack } from '~/app/components/ui' import { DropdownMenuCheckboxItem, DropdownMenuLabel, @@ -71,6 +71,19 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { const achievementRate = pullRequests.length > 0 ? (achievementCount / pullRequests.length) * 100 : 0 + const mergeTimes = pullRequests + .map((pr) => pr.createAndMergeDiff) + .filter((v): v is number => v !== null) + .sort((a, b) => a - b) + const median = + mergeTimes.length > 0 + ? mergeTimes.length % 2 === 1 + ? mergeTimes[Math.floor(mergeTimes.length / 2)] + : (mergeTimes[mergeTimes.length / 2 - 1] + + mergeTimes[mergeTimes.length / 2]) / + 2 + : null + return { pullRequests, from: from.toISOString(), @@ -78,6 +91,7 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { objective, achievementCount, achievementRate, + median, teams, businessDaysOnly, } @@ -89,8 +103,8 @@ export default function OrganizationIndex({ from, to, objective, - achievementCount, achievementRate, + median, teams, businessDaysOnly, }, @@ -125,45 +139,43 @@ export default function OrganizationIndex({ -
- { - setSearchParams((prev) => { - prev.set('from', dayjs(start).format('YYYY-MM-DD')) - prev.set('to', dayjs(start).add(6, 'day').format('YYYY-MM-DD')) - return prev - }) - }} - /> - -
+ { + setSearchParams((prev) => { + prev.set('from', dayjs(start).format('YYYY-MM-DD')) + prev.set('to', dayjs(start).add(6, 'day').format('YYYY-MM-DD')) + return prev + }) + }} + /> -
- -
-
Time to Merge
-
- {'< '} - {objective.toFixed(1)} - d -
-
Achievement
-
- {achievementRate.toFixed(1)} - % ({achievementCount.toLocaleString()}) -
+
+
+
{pullRequests.length}
+
Merged
+
+
+
+ {median !== null ? `${median.toFixed(1)}d` : '–'} +
+
+ Median Time to Merge +
+
+
+
+ {achievementRate.toFixed(1)}% +
+
Achievement
+
+ Goal {'< '} + {objective.toFixed(1)}d
- Merged {dayjs(from).tz(timezone).format('M/D')} -{' '} - {dayjs(to).tz(timezone).format('M/D')}: {pullRequests.length} -
- } columns={columns} data={pullRequests} getRowId={(row) => `${row.repositoryId}:${row.number}`} diff --git a/app/routes/$orgSlug/throughput/ongoing/index.tsx b/app/routes/$orgSlug/throughput/ongoing/index.tsx index 8b2e8445..3ac74675 100644 --- a/app/routes/$orgSlug/throughput/ongoing/index.tsx +++ b/app/routes/$orgSlug/throughput/ongoing/index.tsx @@ -51,11 +51,23 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { teamParam || undefined, businessDaysOnly, ) - return { pullRequests, teams, businessDaysOnly } + + const ages = pullRequests + .map((pr) => pr.createAndNowDiff) + .filter((v): v is number => v !== null) + .sort((a, b) => a - b) + const median = + ages.length > 0 + ? ages.length % 2 === 1 + ? ages[Math.floor(ages.length / 2)] + : (ages[ages.length / 2 - 1] + ages[ages.length / 2]) / 2 + : null + + return { pullRequests, median, teams, businessDaysOnly } } export default function OngoingPage({ - loaderData: { pullRequests, teams, businessDaysOnly }, + loaderData: { pullRequests, median, teams, businessDaysOnly }, }: Route.ComponentProps) { const [, setSearchParams] = useSearchParams() const timezone = useTimezone() @@ -88,8 +100,20 @@ export default function OngoingPage({ +
+
+
{pullRequests.length}
+
Ongoing
+
+
+
+ {median !== null ? `${median.toFixed(1)}d` : '–'} +
+
Median Age
+
+
+ Ongoing pull requests: {pullRequests.length}
} columns={columns} data={pullRequests} getRowId={(row) => `${row.repositoryId}:${row.number}`} From c5ef0c7fd03f5770c4f4551ce3ed79fa8b511394 Mon Sep 17 00:00:00 2001 From: coji Date: Mon, 16 Mar 2026 14:14:59 +0900 Subject: [PATCH 2/9] chore: remove unused to and achievementCount from loader returns Co-Authored-By: Claude Opus 4.6 (1M context) --- app/routes/$orgSlug/throughput/deployed/index.tsx | 1 - app/routes/$orgSlug/throughput/merged/index.tsx | 3 --- 2 files changed, 4 deletions(-) diff --git a/app/routes/$orgSlug/throughput/deployed/index.tsx b/app/routes/$orgSlug/throughput/deployed/index.tsx index 8faea504..c6ebc6a4 100644 --- a/app/routes/$orgSlug/throughput/deployed/index.tsx +++ b/app/routes/$orgSlug/throughput/deployed/index.tsx @@ -87,7 +87,6 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { return { pullRequests, from: from.toISOString(), - to: to.toISOString(), objective, achievementRate, median, diff --git a/app/routes/$orgSlug/throughput/merged/index.tsx b/app/routes/$orgSlug/throughput/merged/index.tsx index 351addfb..f8ae1fc6 100644 --- a/app/routes/$orgSlug/throughput/merged/index.tsx +++ b/app/routes/$orgSlug/throughput/merged/index.tsx @@ -87,9 +87,7 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { return { pullRequests, from: from.toISOString(), - to: to.toISOString(), objective, - achievementCount, achievementRate, median, teams, @@ -101,7 +99,6 @@ export default function OrganizationIndex({ loaderData: { pullRequests, from, - to, objective, achievementRate, median, From 5852a2160f3784ab55599bcbe6e7619f83b675bc Mon Sep 17 00:00:00 2001 From: coji Date: Mon, 16 Mar 2026 14:21:17 +0900 Subject: [PATCH 3/9] improve: move calendar and cards into AppDataTable layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppDataTable に children スロットを追加し、ツールバーとテーブルの間に コンテンツを差し込めるようにした。 Merged/Deployed: カレンダーを AppDataTable の title に、 カードを children に配置し、カレンダーと Options が同じ行に収まるように。 Co-Authored-By: Claude Opus 4.6 (1M context) --- app/components/AppDataTable.tsx | 4 + .../$orgSlug/throughput/deployed/index.tsx | 75 ++++++++++--------- .../$orgSlug/throughput/merged/index.tsx | 75 ++++++++++--------- 3 files changed, 80 insertions(+), 74 deletions(-) diff --git a/app/components/AppDataTable.tsx b/app/components/AppDataTable.tsx index 52ffceeb..71a4b6a6 100644 --- a/app/components/AppDataTable.tsx +++ b/app/components/AppDataTable.tsx @@ -26,6 +26,8 @@ interface AppDataTableProps { columns: ColumnDef[] data: TData[] optionsChildren?: React.ReactNode + /** Content rendered between the toolbar and the table. */ + children?: React.ReactNode /** Stable key for each row. When provided, row order is frozen until the user re-sorts via column header click. */ getRowId?: (row: TData) => string } @@ -57,6 +59,7 @@ export function AppDataTable({ columns, data, optionsChildren, + children, getRowId, }: AppDataTableProps) { const [sorting, setSorting] = React.useState([]) @@ -118,6 +121,7 @@ export function AppDataTable({ {optionsChildren} + {children}
diff --git a/app/routes/$orgSlug/throughput/deployed/index.tsx b/app/routes/$orgSlug/throughput/deployed/index.tsx index c6ebc6a4..7e141c9d 100644 --- a/app/routes/$orgSlug/throughput/deployed/index.tsx +++ b/app/routes/$orgSlug/throughput/deployed/index.tsx @@ -136,43 +136,19 @@ export default function DeployedPage({ - { - setSearchParams((prev) => { - prev.set('from', dayjs(start).format('YYYY-MM-DD')) - prev.set('to', dayjs(start).add(6, 'day').format('YYYY-MM-DD')) - return prev - }) - }} - /> - -
-
-
{pullRequests.length}
-
Deployed
-
-
-
- {median !== null ? `${median.toFixed(1)}d` : '–'} -
-
- Median Time to Deploy -
-
-
-
- {achievementRate.toFixed(1)}% -
-
Achievement
-
- Goal {'< '} - {objective.toFixed(1)}d -
-
-
- { + setSearchParams((prev) => { + prev.set('from', dayjs(start).format('YYYY-MM-DD')) + prev.set('to', dayjs(start).add(6, 'day').format('YYYY-MM-DD')) + return prev + }) + }} + /> + } columns={columns} data={pullRequests} getRowId={(row) => `${row.repositoryId}:${row.number}`} @@ -196,7 +172,32 @@ export default function DeployedPage({ } - /> + > +
+
+
{pullRequests.length}
+
Deployed
+
+
+
+ {median !== null ? `${median.toFixed(1)}d` : '–'} +
+
+ Median Time to Deploy +
+
+
+
+ {achievementRate.toFixed(1)}% +
+
Achievement
+
+ Goal {'< '} + {objective.toFixed(1)}d +
+
+
+
) } diff --git a/app/routes/$orgSlug/throughput/merged/index.tsx b/app/routes/$orgSlug/throughput/merged/index.tsx index f8ae1fc6..a0bf274d 100644 --- a/app/routes/$orgSlug/throughput/merged/index.tsx +++ b/app/routes/$orgSlug/throughput/merged/index.tsx @@ -136,43 +136,19 @@ export default function OrganizationIndex({ - { - setSearchParams((prev) => { - prev.set('from', dayjs(start).format('YYYY-MM-DD')) - prev.set('to', dayjs(start).add(6, 'day').format('YYYY-MM-DD')) - return prev - }) - }} - /> - -
-
-
{pullRequests.length}
-
Merged
-
-
-
- {median !== null ? `${median.toFixed(1)}d` : '–'} -
-
- Median Time to Merge -
-
-
-
- {achievementRate.toFixed(1)}% -
-
Achievement
-
- Goal {'< '} - {objective.toFixed(1)}d -
-
-
- { + setSearchParams((prev) => { + prev.set('from', dayjs(start).format('YYYY-MM-DD')) + prev.set('to', dayjs(start).add(6, 'day').format('YYYY-MM-DD')) + return prev + }) + }} + /> + } columns={columns} data={pullRequests} getRowId={(row) => `${row.repositoryId}:${row.number}`} @@ -196,7 +172,32 @@ export default function OrganizationIndex({ } - /> + > +
+
+
{pullRequests.length}
+
Merged
+
+
+
+ {median !== null ? `${median.toFixed(1)}d` : '–'} +
+
+ Median Time to Merge +
+
+
+
+ {achievementRate.toFixed(1)}% +
+
Achievement
+
+ Goal {'< '} + {objective.toFixed(1)}d +
+
+
+
) } From 1acd1987f66df19e95b14a9c03a6817ad98dd8d6 Mon Sep 17 00:00:00 2001 From: coji Date: Mon, 16 Mar 2026 14:22:21 +0900 Subject: [PATCH 4/9] improve: move Ongoing cards into AppDataTable children Co-Authored-By: Claude Opus 4.6 (1M context) --- .../$orgSlug/throughput/ongoing/index.tsx | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/app/routes/$orgSlug/throughput/ongoing/index.tsx b/app/routes/$orgSlug/throughput/ongoing/index.tsx index 3ac74675..fca369df 100644 --- a/app/routes/$orgSlug/throughput/ongoing/index.tsx +++ b/app/routes/$orgSlug/throughput/ongoing/index.tsx @@ -100,19 +100,6 @@ export default function OngoingPage({ -
-
-
{pullRequests.length}
-
Ongoing
-
-
-
- {median !== null ? `${median.toFixed(1)}d` : '–'} -
-
Median Age
-
-
- } - /> + > +
+
+
{pullRequests.length}
+
Ongoing
+
+
+
+ {median !== null ? `${median.toFixed(1)}d` : '–'} +
+
Median Age
+
+
+
) } From 0cbf19f5bf176407385824be62a64900aaf2df34 Mon Sep 17 00:00:00 2001 From: coji Date: Mon, 16 Mar 2026 14:24:59 +0900 Subject: [PATCH 5/9] style: use ghost variant for nav buttons and Options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit カレンダーの prev/next ボタンと Options ボタンを ghost に変更し ボーダーのノイズを軽減。日付表示ボタンのみ outline を維持。 Co-Authored-By: Claude Opus 4.6 (1M context) --- app/components/AppDataTableViewOption.tsx | 2 +- app/components/week-calendar.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/components/AppDataTableViewOption.tsx b/app/components/AppDataTableViewOption.tsx index 90a8c93b..e2fde492 100644 --- a/app/components/AppDataTableViewOption.tsx +++ b/app/components/AppDataTableViewOption.tsx @@ -30,7 +30,7 @@ export function AppDataTableViewOptions({ return ( - diff --git a/app/components/week-calendar.tsx b/app/components/week-calendar.tsx index 7472fff9..c4c05c7e 100644 --- a/app/components/week-calendar.tsx +++ b/app/components/week-calendar.tsx @@ -174,7 +174,7 @@ const WeeklyCalendar = ({
diff --git a/app/routes/$orgSlug/throughput/merged/index.tsx b/app/routes/$orgSlug/throughput/merged/index.tsx index b2e0f685..5005acc9 100644 --- a/app/routes/$orgSlug/throughput/merged/index.tsx +++ b/app/routes/$orgSlug/throughput/merged/index.tsx @@ -1,7 +1,5 @@ -import { CopyIcon } from 'lucide-react' import { useMemo } from 'react' import { useSearchParams } from 'react-router' -import { toast } from 'sonner' import { AppDataTable } from '~/app/components' import { PageHeader, @@ -11,7 +9,7 @@ import { PageHeaderTitle, } from '~/app/components/layout/page-header' import { TeamFilter } from '~/app/components/team-filter' -import { Button, Stack } from '~/app/components/ui' +import { Stack } from '~/app/components/ui' import { DropdownMenuCheckboxItem, DropdownMenuLabel, @@ -23,7 +21,6 @@ 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 { createColumns } from './+columns' -import { generateMarkdown } from './+functions/generate-markdown' import { getMergedPullRequestReport } from './+functions/queries.server' import type { Route } from './+types/index' @@ -162,18 +159,6 @@ export default function OrganizationIndex({ - diff --git a/app/routes/$orgSlug/throughput/ongoing/index.tsx b/app/routes/$orgSlug/throughput/ongoing/index.tsx index fca369df..86f9ed58 100644 --- a/app/routes/$orgSlug/throughput/ongoing/index.tsx +++ b/app/routes/$orgSlug/throughput/ongoing/index.tsx @@ -1,7 +1,5 @@ -import { CopyIcon } from 'lucide-react' import { useMemo } from 'react' import { useSearchParams } from 'react-router' -import { toast } from 'sonner' import { AppDataTable } from '~/app/components' import { PageHeader, @@ -11,7 +9,7 @@ import { PageHeaderTitle, } from '~/app/components/layout/page-header' import { TeamFilter } from '~/app/components/team-filter' -import { Button, Stack } from '~/app/components/ui' +import { Stack } from '~/app/components/ui' import { DropdownMenuCheckboxItem, DropdownMenuLabel, @@ -21,7 +19,6 @@ import dayjs from '~/app/libs/dayjs' import { orgContext } from '~/app/middleware/context' import { listTeams } from '~/app/routes/$orgSlug/settings/teams._index/queries.server' import { createColumns } from './+columns' -import { generateMarkdown } from './+functions/generate-markdown' import { getOngoingPullRequestReport } from './+functions/queries.server' import type { Route } from './+types/index' @@ -84,19 +81,6 @@ export default function OngoingPage({ - From 35d6c90dd39870402fd942a4a66b4d36bcca75ee Mon Sep 17 00:00:00 2001 From: coji Date: Mon, 16 Mar 2026 14:40:53 +0900 Subject: [PATCH 8/9] refactor: extract shared components and utilities for throughput pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DiffBadge → throughput/+components/diff-badge.tsx (cn() 使用に修正) - StatCard → throughput/+components/stat-card.tsx - calcStats → throughput/+functions/calc-stats.ts (アクセサ引数で共通化) - median() → app/libs/stats.ts (4箇所の重複を解消、aggregate.ts も統一) Co-Authored-By: Claude Opus 4.6 (1M context) --- app/libs/stats.ts | 12 +++ .../analysis/reviews/+functions/aggregate.ts | 12 +-- .../throughput/+components/diff-badge.tsx | 28 +++++++ .../throughput/+components/stat-card.tsx | 19 +++++ .../throughput/+functions/calc-stats.ts | 16 ++++ .../$orgSlug/throughput/deployed/index.tsx | 80 +++++-------------- .../$orgSlug/throughput/merged/index.tsx | 80 +++++-------------- .../$orgSlug/throughput/ongoing/index.tsx | 25 ++---- 8 files changed, 120 insertions(+), 152 deletions(-) create mode 100644 app/libs/stats.ts create mode 100644 app/routes/$orgSlug/throughput/+components/diff-badge.tsx create mode 100644 app/routes/$orgSlug/throughput/+components/stat-card.tsx create mode 100644 app/routes/$orgSlug/throughput/+functions/calc-stats.ts diff --git a/app/libs/stats.ts b/app/libs/stats.ts new file mode 100644 index 00000000..78d031dc --- /dev/null +++ b/app/libs/stats.ts @@ -0,0 +1,12 @@ +/** + * Calculate the median of a numeric array. + * Returns null for empty arrays. + */ +export function median(values: number[]): number | null { + if (values.length === 0) return null + const sorted = [...values].sort((a, b) => a - b) + const mid = Math.floor(sorted.length / 2) + return sorted.length % 2 === 1 + ? sorted[mid] + : (sorted[mid - 1] + sorted[mid]) / 2 +} diff --git a/app/routes/$orgSlug/analysis/reviews/+functions/aggregate.ts b/app/routes/$orgSlug/analysis/reviews/+functions/aggregate.ts index 057f5828..28fdbf92 100644 --- a/app/routes/$orgSlug/analysis/reviews/+functions/aggregate.ts +++ b/app/routes/$orgSlug/analysis/reviews/+functions/aggregate.ts @@ -3,17 +3,7 @@ * clientLoader で呼び出し、集計済みデータをチャートコンポーネントに渡す。 */ import { getPRComplexity, type PRSizeLabel } from '~/app/libs/pr-classify' - -// --- 共通ユーティリティ --- - -function median(values: number[]): number | null { - if (values.length === 0) return null - const sorted = [...values].sort((a, b) => a - b) - const mid = Math.floor(sorted.length / 2) - return sorted.length % 2 !== 0 - ? sorted[mid] - : (sorted[mid - 1] + sorted[mid]) / 2 -} +import { median } from '~/app/libs/stats' function formatHours(h: number): string { if (h < 1) return `${(h * 60).toFixed(0)}m` diff --git a/app/routes/$orgSlug/throughput/+components/diff-badge.tsx b/app/routes/$orgSlug/throughput/+components/diff-badge.tsx new file mode 100644 index 00000000..bce048d1 --- /dev/null +++ b/app/routes/$orgSlug/throughput/+components/diff-badge.tsx @@ -0,0 +1,28 @@ +import { cn } from '~/app/libs/utils' + +export function DiffBadge({ + value, + prevValue, + format = (v) => `${v >= 0 ? '+' : ''}${v.toFixed(1)}`, + invertColor = false, +}: { + value: number + prevValue: number | null + format?: (diff: number) => string + invertColor?: boolean +}) { + if (prevValue === null) return null + const diff = value - prevValue + if (diff === 0) return null + const isPositive = invertColor ? diff < 0 : diff > 0 + return ( + + {format(diff)} + + ) +} diff --git a/app/routes/$orgSlug/throughput/+components/stat-card.tsx b/app/routes/$orgSlug/throughput/+components/stat-card.tsx new file mode 100644 index 00000000..be0445db --- /dev/null +++ b/app/routes/$orgSlug/throughput/+components/stat-card.tsx @@ -0,0 +1,19 @@ +import type React from 'react' + +export function StatCard({ + value, + label, + children, +}: { + value: string | number + label: string + children?: React.ReactNode +}) { + return ( +
+
{value}
+
{label}
+ {children} +
+ ) +} diff --git a/app/routes/$orgSlug/throughput/+functions/calc-stats.ts b/app/routes/$orgSlug/throughput/+functions/calc-stats.ts new file mode 100644 index 00000000..2b980ba4 --- /dev/null +++ b/app/routes/$orgSlug/throughput/+functions/calc-stats.ts @@ -0,0 +1,16 @@ +import { median } from '~/app/libs/stats' + +export function calcStats( + prs: T[], + getTime: (pr: T) => number | null, +) { + const achievementCount = prs.filter((pr) => pr.achievement).length + const achievementRate = + prs.length > 0 ? (achievementCount / prs.length) * 100 : 0 + const times = prs.map(getTime).filter((v): v is number => v !== null) + return { + count: prs.length, + achievementRate, + median: median(times), + } +} diff --git a/app/routes/$orgSlug/throughput/deployed/index.tsx b/app/routes/$orgSlug/throughput/deployed/index.tsx index 45b0ae39..779f7948 100644 --- a/app/routes/$orgSlug/throughput/deployed/index.tsx +++ b/app/routes/$orgSlug/throughput/deployed/index.tsx @@ -20,6 +20,9 @@ import { getEndOfWeek, getStartOfWeek, parseDate } from '~/app/libs/date-utils' 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 { DiffBadge } from '../+components/diff-badge' +import { StatCard } from '../+components/stat-card' +import { calcStats } from '../+functions/calc-stats' import { createColumns } from './+columns' import { getDeployedPullRequestReport } from './+functions/queries.server' import type { Route } from './+types/index' @@ -76,61 +79,20 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { ), ]) - const calcStats = (prs: typeof pullRequests) => { - const achievementCount = prs.filter((pr) => pr.achievement).length - const achievementRate = - prs.length > 0 ? (achievementCount / prs.length) * 100 : 0 - const times = prs - .map((pr) => pr.createAndDeployDiff) - .filter((v): v is number => v !== null) - .sort((a, b) => a - b) - const median = - times.length > 0 - ? times.length % 2 === 1 - ? times[Math.floor(times.length / 2)] - : (times[times.length / 2 - 1] + times[times.length / 2]) / 2 - : null - return { count: prs.length, achievementRate, median } - } - - const stats = calcStats(pullRequests) - const prevStats = calcStats(prevPullRequests) + const stats = calcStats(pullRequests, (pr) => pr.createAndDeployDiff) + const prev = calcStats(prevPullRequests, (pr) => pr.createAndDeployDiff) return { pullRequests, from: from.toISOString(), objective, ...stats, - prev: prevStats, + prev, teams, businessDaysOnly, } } -function DiffBadge({ - value, - prevValue, - format = (v) => `${v >= 0 ? '+' : ''}${v.toFixed(1)}`, - invertColor = false, -}: { - value: number - prevValue: number | null - format?: (diff: number) => string - invertColor?: boolean -}) { - if (prevValue === null) return null - const diff = value - prevValue - if (diff === 0) return null - const isPositive = invertColor ? diff < 0 : diff > 0 - return ( - - {format(diff)} - - ) -} - export default function DeployedPage({ loaderData: { pullRequests, @@ -200,22 +162,17 @@ export default function DeployedPage({ } >
-
-
{count}
-
Deployed
+ `${d >= 0 ? '+' : ''}${d}`} /> -
-
-
- {median !== null ? `${median.toFixed(1)}d` : '–'} -
-
- Median Time to Deploy -
+ + {median !== null && ( )} -
-
-
- {achievementRate.toFixed(1)}% -
-
Achievement
+ + -
+
diff --git a/app/routes/$orgSlug/throughput/merged/index.tsx b/app/routes/$orgSlug/throughput/merged/index.tsx index 5005acc9..1ee7a1d3 100644 --- a/app/routes/$orgSlug/throughput/merged/index.tsx +++ b/app/routes/$orgSlug/throughput/merged/index.tsx @@ -20,6 +20,9 @@ import { getEndOfWeek, getStartOfWeek, parseDate } from '~/app/libs/date-utils' 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 { DiffBadge } from '../+components/diff-badge' +import { StatCard } from '../+components/stat-card' +import { calcStats } from '../+functions/calc-stats' import { createColumns } from './+columns' import { getMergedPullRequestReport } from './+functions/queries.server' import type { Route } from './+types/index' @@ -76,61 +79,20 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { ), ]) - const calcStats = (prs: typeof pullRequests) => { - const achievementCount = prs.filter((pr) => pr.achievement).length - const achievementRate = - prs.length > 0 ? (achievementCount / prs.length) * 100 : 0 - const times = prs - .map((pr) => pr.createAndMergeDiff) - .filter((v): v is number => v !== null) - .sort((a, b) => a - b) - const median = - times.length > 0 - ? times.length % 2 === 1 - ? times[Math.floor(times.length / 2)] - : (times[times.length / 2 - 1] + times[times.length / 2]) / 2 - : null - return { count: prs.length, achievementRate, median } - } - - const stats = calcStats(pullRequests) - const prevStats = calcStats(prevPullRequests) + const stats = calcStats(pullRequests, (pr) => pr.createAndMergeDiff) + const prev = calcStats(prevPullRequests, (pr) => pr.createAndMergeDiff) return { pullRequests, from: from.toISOString(), objective, ...stats, - prev: prevStats, + prev, teams, businessDaysOnly, } } -function DiffBadge({ - value, - prevValue, - format = (v) => `${v >= 0 ? '+' : ''}${v.toFixed(1)}`, - invertColor = false, -}: { - value: number - prevValue: number | null - format?: (diff: number) => string - invertColor?: boolean -}) { - if (prevValue === null) return null - const diff = value - prevValue - if (diff === 0) return null - const isPositive = invertColor ? diff < 0 : diff > 0 - return ( - - {format(diff)} - - ) -} - export default function OrganizationIndex({ loaderData: { pullRequests, @@ -200,22 +162,17 @@ export default function OrganizationIndex({ } >
-
-
{count}
-
Merged
+ `${d >= 0 ? '+' : ''}${d}`} /> -
-
-
- {median !== null ? `${median.toFixed(1)}d` : '–'} -
-
- Median Time to Merge -
+ + {median !== null && ( )} -
-
-
- {achievementRate.toFixed(1)}% -
-
Achievement
+ + -
+
diff --git a/app/routes/$orgSlug/throughput/ongoing/index.tsx b/app/routes/$orgSlug/throughput/ongoing/index.tsx index 86f9ed58..a3a2bcec 100644 --- a/app/routes/$orgSlug/throughput/ongoing/index.tsx +++ b/app/routes/$orgSlug/throughput/ongoing/index.tsx @@ -16,8 +16,10 @@ import { } from '~/app/components/ui/dropdown-menu' import { useTimezone } from '~/app/hooks/use-timezone' import dayjs from '~/app/libs/dayjs' +import { median as calcMedian } from '~/app/libs/stats' import { orgContext } from '~/app/middleware/context' import { listTeams } from '~/app/routes/$orgSlug/settings/teams._index/queries.server' +import { StatCard } from '../+components/stat-card' import { createColumns } from './+columns' import { getOngoingPullRequestReport } from './+functions/queries.server' import type { Route } from './+types/index' @@ -52,13 +54,7 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { const ages = pullRequests .map((pr) => pr.createAndNowDiff) .filter((v): v is number => v !== null) - .sort((a, b) => a - b) - const median = - ages.length > 0 - ? ages.length % 2 === 1 - ? ages[Math.floor(ages.length / 2)] - : (ages[ages.length / 2 - 1] + ages[ages.length / 2]) / 2 - : null + const median = calcMedian(ages) return { pullRequests, median, teams, businessDaysOnly } } @@ -110,16 +106,11 @@ export default function OngoingPage({ } >
-
-
{pullRequests.length}
-
Ongoing
-
-
-
- {median !== null ? `${median.toFixed(1)}d` : '–'} -
-
Median Age
-
+ +
From 6c4b3edc11076c2b6d056a5797746cf139c9209d Mon Sep 17 00:00:00 2001 From: coji Date: Mon, 16 Mar 2026 14:44:05 +0900 Subject: [PATCH 9/9] chore: remove orphaned generate-markdown files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit コピーボタン削除でどこからも参照されなくなった。 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../deployed/+functions/generate-markdown.ts | 14 -------------- .../merged/+functions/generate-markdown.ts | 13 ------------- .../ongoing/+functions/generate-markdown.ts | 13 ------------- 3 files changed, 40 deletions(-) delete mode 100644 app/routes/$orgSlug/throughput/deployed/+functions/generate-markdown.ts delete mode 100644 app/routes/$orgSlug/throughput/merged/+functions/generate-markdown.ts delete mode 100644 app/routes/$orgSlug/throughput/ongoing/+functions/generate-markdown.ts diff --git a/app/routes/$orgSlug/throughput/deployed/+functions/generate-markdown.ts b/app/routes/$orgSlug/throughput/deployed/+functions/generate-markdown.ts deleted file mode 100644 index 9f0d8b03..00000000 --- a/app/routes/$orgSlug/throughput/deployed/+functions/generate-markdown.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { PullRequest } from '../index' - -export function generateMarkdown(pulls: PullRequest[]) { - const header1 = - '| Author | Repo | No | タイトル | デプロイまで | デプロイ時間 |\n' - const header2 = '| ------ | -- | -- | -------- | ---------- | ---------- |\n' - const body = pulls - .map( - (row) => - `| ${row.authorDisplayName ?? row.author} | ${row.repo} | ${row.number} | [${row.title}](${row.url}) | ${row.createAndDeployDiff?.toFixed(1) ?? '-'}日${!row.achievement ? ' 超過' : ''} | ${row.deployTime?.toFixed(1) ?? '-'}日 |`, - ) - .join('\n') - return `${header1}${header2}${body}` -} diff --git a/app/routes/$orgSlug/throughput/merged/+functions/generate-markdown.ts b/app/routes/$orgSlug/throughput/merged/+functions/generate-markdown.ts deleted file mode 100644 index 35ca39a4..00000000 --- a/app/routes/$orgSlug/throughput/merged/+functions/generate-markdown.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { PullRequest } from '../index' - -export function generateMarkdown(pulls: PullRequest[]) { - const header1 = '| Author | Repo | No | タイトル | マージまで |\n' - const header2 = '| ------ | -- | -- | -------- | ---------- |\n' - const body = pulls - .map( - (row) => - `| ${row.authorDisplayName ?? row.author} | ${row.repo} | ${row.number} | [${row.title}](${row.url}) | ${row.createAndMergeDiff?.toFixed(1)}日 ${!row.achievement ? '超過' : ''} |`, - ) - .join('\n') - return `${header1}${header2}${body}` -} diff --git a/app/routes/$orgSlug/throughput/ongoing/+functions/generate-markdown.ts b/app/routes/$orgSlug/throughput/ongoing/+functions/generate-markdown.ts deleted file mode 100644 index 7d48f9dd..00000000 --- a/app/routes/$orgSlug/throughput/ongoing/+functions/generate-markdown.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { PullRequest } from '../index' - -export function generateMarkdown(pulls: PullRequest[]) { - const header1 = '| Author | Repo | No | タイトル | 期間 |\n' - const header2 = '| ------ | -- | -- | -------- | ---------- |\n' - const body = pulls - .map( - (row) => - `|${row.authorDisplayName ?? row.author}|${row.repo}|${row.number}|[${row.title}](${row.url})|${row.createAndNowDiff?.toFixed(1)}日|`, - ) - .join('\n') - return `${header1}${header2}${body}` -}