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/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 3248fa73..c4c05c7e 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')}` } @@ -181,7 +174,7 @@ const WeeklyCalendar = ({
+
+ )} - - {!isCurrentWeek && ( - - )} ) } 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/+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/deployed/index.tsx b/app/routes/$orgSlug/throughput/deployed/index.tsx index acc4cca1..779f7948 100644 --- a/app/routes/$orgSlug/throughput/deployed/index.tsx +++ b/app/routes/$orgSlug/throughput/deployed/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, Label, Stack } from '~/app/components/ui' +import { Stack } from '~/app/components/ui' import { DropdownMenuCheckboxItem, DropdownMenuLabel, @@ -22,8 +20,10 @@ 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 { generateMarkdown } from './+functions/generate-markdown' import { getDeployedPullRequestReport } from './+functions/queries.server' import type { Route } from './+types/index' @@ -56,28 +56,38 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { to = getEndOfWeek(undefined, timezone) } - const teams = await listTeams(organization.id) + const prevFrom = from.subtract(7, 'day') + const prevTo = to.subtract(7, 'day') - const pullRequests = await getDeployedPullRequestReport( - organization.id, - from.utc().toISOString(), - to.utc().toISOString(), - objective, - teamParam || undefined, - businessDaysOnly, - ) + const [teams, pullRequests, prevPullRequests] = await Promise.all([ + listTeams(organization.id), + getDeployedPullRequestReport( + organization.id, + from.utc().toISOString(), + to.utc().toISOString(), + objective, + teamParam || undefined, + businessDaysOnly, + ), + getDeployedPullRequestReport( + organization.id, + prevFrom.utc().toISOString(), + prevTo.utc().toISOString(), + objective, + teamParam || undefined, + businessDaysOnly, + ), + ]) - const achievementCount = pullRequests.filter((pr) => pr.achievement).length - const achievementRate = - pullRequests.length > 0 ? (achievementCount / pullRequests.length) * 100 : 0 + const stats = calcStats(pullRequests, (pr) => pr.createAndDeployDiff) + const prev = calcStats(prevPullRequests, (pr) => pr.createAndDeployDiff) return { pullRequests, from: from.toISOString(), - to: to.toISOString(), objective, - achievementCount, - achievementRate, + ...stats, + prev, teams, businessDaysOnly, } @@ -87,10 +97,11 @@ export default function DeployedPage({ loaderData: { pullRequests, from, - to, objective, - achievementCount, + count, achievementRate, + median, + prev, teams, businessDaysOnly, }, @@ -110,59 +121,21 @@ 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 - }) - }} - /> - -
- -
- -
-
Time to Deploy
-
- {'< '} - {objective.toFixed(1)} - d -
-
Achievement
-
- {achievementRate.toFixed(1)} - % ({achievementCount.toLocaleString()}) -
-
-
-
- - Deployed {dayjs(from).tz(timezone).format('M/D')} -{' '} - {dayjs(to).tz(timezone).format('M/D')}: {pullRequests.length} -
+ { + 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} @@ -187,7 +160,44 @@ export default function DeployedPage({ } - /> + > +
+ + `${d >= 0 ? '+' : ''}${d}`} + /> + + + {median !== null && ( + `${d >= 0 ? '+' : ''}${d.toFixed(1)}d`} + invertColor + /> + )} + + + `${d >= 0 ? '+' : ''}${d.toFixed(1)}%`} + /> +
+ Goal {'< '} + {objective.toFixed(1)}d +
+
+
+ ) } 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/merged/index.tsx b/app/routes/$orgSlug/throughput/merged/index.tsx index fd5071df..1ee7a1d3 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, Label, Stack } from '~/app/components/ui' +import { Stack } from '~/app/components/ui' import { DropdownMenuCheckboxItem, DropdownMenuLabel, @@ -22,8 +20,10 @@ 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 { generateMarkdown } from './+functions/generate-markdown' import { getMergedPullRequestReport } from './+functions/queries.server' import type { Route } from './+types/index' @@ -56,28 +56,38 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { to = getEndOfWeek(undefined, timezone) } - const teams = await listTeams(organization.id) + const prevFrom = from.subtract(7, 'day') + const prevTo = to.subtract(7, 'day') - const pullRequests = await getMergedPullRequestReport( - organization.id, - from.utc().toISOString(), - to.utc().toISOString(), - objective, - teamParam || undefined, - businessDaysOnly, - ) + const [teams, pullRequests, prevPullRequests] = await Promise.all([ + listTeams(organization.id), + getMergedPullRequestReport( + organization.id, + from.utc().toISOString(), + to.utc().toISOString(), + objective, + teamParam || undefined, + businessDaysOnly, + ), + getMergedPullRequestReport( + organization.id, + prevFrom.utc().toISOString(), + prevTo.utc().toISOString(), + objective, + teamParam || undefined, + businessDaysOnly, + ), + ]) - const achievementCount = pullRequests.filter((pr) => pr.achievement).length - const achievementRate = - pullRequests.length > 0 ? (achievementCount / pullRequests.length) * 100 : 0 + const stats = calcStats(pullRequests, (pr) => pr.createAndMergeDiff) + const prev = calcStats(prevPullRequests, (pr) => pr.createAndMergeDiff) return { pullRequests, from: from.toISOString(), - to: to.toISOString(), objective, - achievementCount, - achievementRate, + ...stats, + prev, teams, businessDaysOnly, } @@ -87,10 +97,11 @@ export default function OrganizationIndex({ loaderData: { pullRequests, from, - to, objective, - achievementCount, + count, achievementRate, + median, + prev, teams, businessDaysOnly, }, @@ -110,59 +121,21 @@ 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 - }) - }} - /> - -
- -
- -
-
Time to Merge
-
- {'< '} - {objective.toFixed(1)} - d -
-
Achievement
-
- {achievementRate.toFixed(1)} - % ({achievementCount.toLocaleString()}) -
-
-
-
- - Merged {dayjs(from).tz(timezone).format('M/D')} -{' '} - {dayjs(to).tz(timezone).format('M/D')}: {pullRequests.length} -
+ { + 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} @@ -187,7 +160,44 @@ export default function OrganizationIndex({ } - /> + > +
+ + `${d >= 0 ? '+' : ''}${d}`} + /> + + + {median !== null && ( + `${d >= 0 ? '+' : ''}${d.toFixed(1)}d`} + invertColor + /> + )} + + + `${d >= 0 ? '+' : ''}${d.toFixed(1)}%`} + /> +
+ Goal {'< '} + {objective.toFixed(1)}d +
+
+
+ ) } 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}` -} diff --git a/app/routes/$orgSlug/throughput/ongoing/index.tsx b/app/routes/$orgSlug/throughput/ongoing/index.tsx index 8b2e8445..a3a2bcec 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,17 +9,18 @@ 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, } 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 { generateMarkdown } from './+functions/generate-markdown' import { getOngoingPullRequestReport } from './+functions/queries.server' import type { Route } from './+types/index' @@ -51,11 +50,17 @@ 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) + const median = calcMedian(ages) + + 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() @@ -72,24 +77,10 @@ export default function OngoingPage({ - Ongoing pull requests: {pullRequests.length}} columns={columns} data={pullRequests} getRowId={(row) => `${row.repositoryId}:${row.number}`} @@ -113,7 +104,15 @@ export default function OngoingPage({ } - /> + > +
+ + +
+
) }