From b0abb7635944ff4e9648380cc3dd76f287b47710 Mon Sep 17 00:00:00 2001 From: coji Date: Mon, 30 Mar 2026 20:44:13 +0900 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20=E3=83=81=E3=83=BC=E3=83=A0?= =?UTF-8?q?=E9=81=B8=E6=8A=9E=E3=82=92=E3=82=B0=E3=83=AD=E3=83=BC=E3=83=90?= =?UTF-8?q?=E3=83=AB=E5=8C=96=E3=81=97=20Cookie=20=E3=81=A7=E6=B0=B8?= =?UTF-8?q?=E7=B6=9A=E5=8C=96=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - サイドバーに TeamSwitcher コンポーネントを追加(OrgSwitcher の下に配置) - team-cookie.server.ts でサーバーサイドの Cookie 読み取りを実装 - 7 ページの loader で Cookie をフォールバックとして使用(URL ?team > Cookie) - 各ページの PageHeaderActions から TeamFilter を削除 - listTeams を layout loader に集約し、各ページでの重複呼び出しを排除 Closes #259 Co-Authored-By: Claude Opus 4.6 (1M context) --- app/components/layout/app-sidebar.tsx | 6 + app/components/layout/team-switcher.tsx | 119 ++++++++++++++++++ app/libs/team-cookie.server.ts | 10 ++ app/routes/$orgSlug/_layout.tsx | 23 +++- .../analysis/feedbacks/_index/index.tsx | 13 +- .../$orgSlug/analysis/inventory/index.tsx | 16 +-- .../$orgSlug/analysis/reviews/index.tsx | 21 +--- .../$orgSlug/throughput/deployed/index.tsx | 14 +-- .../$orgSlug/throughput/merged/index.tsx | 14 +-- .../$orgSlug/throughput/ongoing/index.tsx | 15 +-- app/routes/$orgSlug/workload/index.tsx | 14 +-- 11 files changed, 184 insertions(+), 81 deletions(-) create mode 100644 app/components/layout/team-switcher.tsx create mode 100644 app/libs/team-cookie.server.ts diff --git a/app/components/layout/app-sidebar.tsx b/app/components/layout/app-sidebar.tsx index ade8a278..424b93cd 100644 --- a/app/components/layout/app-sidebar.tsx +++ b/app/components/layout/app-sidebar.tsx @@ -10,6 +10,7 @@ import { type MemberRole, isOrgAdmin } from '~/app/libs/member-role' import { NavGroup } from './nav-group' import { NavUser } from './nav-user' import { OrgSwitcher } from './org-switcher' +import { TeamSwitcher } from './team-switcher' interface AppSidebarProps extends React.ComponentProps { user: { @@ -30,6 +31,8 @@ interface AppSidebarProps extends React.ComponentProps { slug: string | null }> memberRole: MemberRole + teams: Array<{ id: string; name: string }> + selectedTeamId: string | null } export function AppSidebar({ @@ -37,6 +40,8 @@ export function AppSidebar({ organization, organizations, memberRole, + teams, + selectedTeamId, ...props }: AppSidebarProps) { const orgSlug = organization.slug ?? organization.id @@ -49,6 +54,7 @@ export function AppSidebar({ + {navGroups.map((group) => ( diff --git a/app/components/layout/team-switcher.tsx b/app/components/layout/team-switcher.tsx new file mode 100644 index 00000000..bfefeb35 --- /dev/null +++ b/app/components/layout/team-switcher.tsx @@ -0,0 +1,119 @@ +import { ChevronsUpDown, UsersIcon } from 'lucide-react' +import { useRevalidator } from 'react-router' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '~/app/components/ui/dropdown-menu' +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from '~/app/components/ui/sidebar' + +interface Team { + id: string + name: string +} + +const COOKIE_MAX_AGE = 2592000 // 30 days + +function setTeamCookie(teamId: string) { + document.cookie = `selected_team=${encodeURIComponent(teamId)}; path=/; max-age=${COOKIE_MAX_AGE}; samesite=lax` +} + +function clearTeamCookie() { + document.cookie = 'selected_team=; path=/; max-age=0; samesite=lax' +} + +export function TeamSwitcher({ + teams, + selectedTeamId, +}: { + teams: Team[] + selectedTeamId: string | null +}) { + const { isMobile } = useSidebar() + const revalidator = useRevalidator() + + if (teams.length === 0) return null + + const selectedTeam = selectedTeamId + ? teams.find((t) => t.id === selectedTeamId) + : null + const displayName = selectedTeam?.name ?? 'All Teams' + + return ( + + + + + +
+ {selectedTeam ? ( + selectedTeam.name.slice(0, 2).toUpperCase() + ) : ( + + )} +
+
+ {displayName} + Team +
+ +
+
+ + + Teams + + { + clearTeamCookie() + revalidator.revalidate() + }} + className="gap-2 p-2" + > +
+ +
+ All Teams + {!selectedTeamId && ( + current + )} +
+ {teams.map((team) => ( + { + setTeamCookie(team.id) + revalidator.revalidate() + }} + className="gap-2 p-2" + > +
+ {team.name.slice(0, 2).toUpperCase()} +
+ {team.name} + {team.id === selectedTeamId && ( + current + )} +
+ ))} +
+
+
+
+ ) +} diff --git a/app/libs/team-cookie.server.ts b/app/libs/team-cookie.server.ts new file mode 100644 index 00000000..ccf12efa --- /dev/null +++ b/app/libs/team-cookie.server.ts @@ -0,0 +1,10 @@ +const COOKIE_NAME = 'selected_team' + +export function getSelectedTeam(request: Request): string | null { + const cookieHeader = request.headers.get('Cookie') ?? '' + const match = cookieHeader + .split('; ') + .find((c) => c.startsWith(`${COOKIE_NAME}=`)) + if (!match) return null + return decodeURIComponent(match.split('=')[1] ?? '') || null +} diff --git a/app/routes/$orgSlug/_layout.tsx b/app/routes/$orgSlug/_layout.tsx index ba329d69..c4791834 100644 --- a/app/routes/$orgSlug/_layout.tsx +++ b/app/routes/$orgSlug/_layout.tsx @@ -5,9 +5,11 @@ import { Main } from '~/app/components/layout/main' import { SidebarProvider } from '~/app/components/ui/sidebar' import { useBreadcrumbs } from '~/app/hooks/use-breadcrumbs' import { getUserOrganizations } from '~/app/libs/auth.server' +import { getSelectedTeam } from '~/app/libs/team-cookie.server' import { cn } from '~/app/libs/utils' import { orgContext, timezoneContext } from '~/app/middleware/context' import { orgMemberMiddleware } from '~/app/middleware/org-member' +import { listTeams } from '~/app/routes/$orgSlug/settings/teams._index/queries.server' import type { Route } from './+types/_layout' export interface RouteHandle { @@ -47,6 +49,13 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { ?.split('=')[1] const defaultOpen = sidebarState !== 'false' + const teams = await listTeams(organization.id) + const selectedTeamCookie = getSelectedTeam(request) + const selectedTeamId = + selectedTeamCookie && teams.some((t) => t.id === selectedTeamCookie) + ? selectedTeamCookie + : null + return { user, organization, @@ -54,11 +63,21 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { organizations, defaultOpen, timezone, + teams, + selectedTeamId, } } export default function OrgLayout({ - loaderData: { user, organization, membership, organizations, defaultOpen }, + loaderData: { + user, + organization, + membership, + organizations, + defaultOpen, + teams, + selectedTeamId, + }, }: Route.ComponentProps) { const { Breadcrumbs } = useBreadcrumbs() const matches = useMatches() @@ -74,6 +93,8 @@ export default function OrgLayout({ organization={organization} organizations={organizations} memberRole={membership.role} + teams={teams} + selectedTeamId={selectedTeamId} />
{ const timezone = context.get(timezoneContext) const url = new URL(request.url) - const teamParam = url.searchParams.get('team') || undefined + const teamParam = + url.searchParams.get('team') ?? getSelectedTeam(request) ?? undefined const periodParam = url.searchParams.get('period') const periodMonths = periodParam === 'all' @@ -80,7 +80,7 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { const sortOrder = (url.searchParams.get('sort_order') as 'asc' | 'desc') || 'desc' - const [feedbackResult, summary, teams] = await Promise.all([ + const [feedbackResult, summary] = await Promise.all([ listFilteredFeedbacks({ organizationId: organization.id, teamId: teamParam, @@ -95,20 +95,18 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { teamId: teamParam, sinceDate, }), - listTeams(organization.id), ]) return { feedbacks: feedbackResult.data, pagination: feedbackResult.pagination, summary, - teams, periodMonths, } } export default function FeedbacksPage({ - loaderData: { feedbacks, pagination, summary, teams, periodMonths }, + loaderData: { feedbacks, pagination, summary, periodMonths }, }: Route.ComponentProps) { const [, setSearchParams] = useSearchParams() const { sort, updateSort } = useDataTableState() @@ -152,7 +150,6 @@ export default function FeedbacksPage({ ))} - diff --git a/app/routes/$orgSlug/analysis/inventory/index.tsx b/app/routes/$orgSlug/analysis/inventory/index.tsx index 1549892d..85074645 100644 --- a/app/routes/$orgSlug/analysis/inventory/index.tsx +++ b/app/routes/$orgSlug/analysis/inventory/index.tsx @@ -6,7 +6,6 @@ import { 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, @@ -18,8 +17,8 @@ import { import { Switch } from '~/app/components/ui/switch' import { calcSinceDate } from '~/app/libs/date-utils' import dayjs from '~/app/libs/dayjs' +import { getSelectedTeam } from '~/app/libs/team-cookie.server' 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' @@ -44,7 +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') || null + const teamParam = + url.searchParams.get('team') ?? getSelectedTeam(request) ?? null const periodParam = url.searchParams.get('period') || null const periodMonths = VALID_PERIODS.includes( Number(periodParam) as (typeof VALID_PERIODS)[number], @@ -59,10 +59,6 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { 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 @@ -82,7 +78,6 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { ) return { - teams, rawRows, sinceDate, now, @@ -97,7 +92,6 @@ export const clientLoader = async ({ serverLoader, }: Route.ClientLoaderArgs) => { const { - teams, rawRows, sinceDate, now, @@ -108,7 +102,6 @@ export const clientLoader = async ({ } = await serverLoader() return { - teams, inventory: aggregateWeeklyOpenPRInventory( rawRows, sinceDate, @@ -137,7 +130,7 @@ export function HydrateFallback() { } export default function InventoryPage({ - loaderData: { teams, inventory, periodMonths, excludeBots, unreviewedOnly }, + loaderData: { inventory, periodMonths, excludeBots, unreviewedOnly }, }: Route.ComponentProps) { const [, setSearchParams] = useSearchParams() @@ -176,7 +169,6 @@ export default function InventoryPage({ ))} - { const timezone = context.get(timezoneContext) const url = new URL(request.url) - const teamParam = url.searchParams.get('team') + const teamParam = url.searchParams.get('team') ?? getSelectedTeam(request) const periodParam = url.searchParams.get('period') const VALID_PERIODS = [1, 3, 6, 12] const periodMonths = @@ -65,8 +64,6 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { const sinceDate = calcSinceDate(periodMonths, timezone) - const teams = await listTeams(organization.id) - const cacheKey = `reviews:${teamParam ?? 'all'}:${periodMonths}` const FIVE_MINUTES = 5 * 60 * 1000 @@ -83,7 +80,6 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { ) return { - teams, queueHistoryRaw, wipCycleRaw, prSizesRaw, @@ -95,19 +91,12 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { export const clientLoader = async ({ serverLoader, }: Route.ClientLoaderArgs) => { - const { - teams, - queueHistoryRaw, - wipCycleRaw, - prSizesRaw, - sinceDate, - periodMonths, - } = await serverLoader() + const { queueHistoryRaw, wipCycleRaw, prSizesRaw, sinceDate, periodMonths } = + await serverLoader() const wipCounts = computeWipCounts(wipCycleRaw) return { - teams, queueTrend: aggregateWeeklyQueueTrend( queueHistoryRaw.filter( (r): r is typeof r & { requestedAt: string } => r.requestedAt !== null, @@ -138,7 +127,6 @@ export function HydrateFallback() { export default function ReviewsPage({ loaderData: { - teams, queueTrend, wipCycle, wipCycleLabeled, @@ -179,7 +167,6 @@ export default function ReviewsPage({ ))} - diff --git a/app/routes/$orgSlug/throughput/deployed/index.tsx b/app/routes/$orgSlug/throughput/deployed/index.tsx index 6f4c8573..43c81c38 100644 --- a/app/routes/$orgSlug/throughput/deployed/index.tsx +++ b/app/routes/$orgSlug/throughput/deployed/index.tsx @@ -3,12 +3,10 @@ import { useSearchParams } from 'react-router' import { AppDataTable } from '~/app/components' import { PageHeader, - PageHeaderActions, PageHeaderDescription, PageHeaderHeading, PageHeaderTitle, } from '~/app/components/layout/page-header' -import { TeamFilter } from '~/app/components/team-filter' import { Stack } from '~/app/components/ui' import { DropdownMenuCheckboxItem, @@ -18,8 +16,8 @@ import WeeklyCalendar from '~/app/components/week-calendar' import { useTimezone } from '~/app/hooks/use-timezone' import { getEndOfWeek, getStartOfWeek, parseDate } from '~/app/libs/date-utils' import dayjs from '~/app/libs/dayjs' +import { getSelectedTeam } from '~/app/libs/team-cookie.server' 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' @@ -43,7 +41,7 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { const url = new URL(request.url) const fromParam = url.searchParams.get('from') const toParam = url.searchParams.get('to') - const teamParam = url.searchParams.get('team') + const teamParam = url.searchParams.get('team') ?? getSelectedTeam(request) const businessDaysOnly = url.searchParams.get('businessDays') !== '0' let from: dayjs.Dayjs @@ -59,8 +57,7 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { const prevFrom = from.subtract(7, 'day') const prevTo = to.subtract(7, 'day') - const [teams, pullRequests, prevPullRequests] = await Promise.all([ - listTeams(organization.id), + const [pullRequests, prevPullRequests] = await Promise.all([ getDeployedPullRequestReport( organization.id, from.utc().toISOString(), @@ -88,7 +85,6 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { objective, ...stats, prev, - teams, businessDaysOnly, } } @@ -102,7 +98,6 @@ export default function DeployedPage({ achievementRate, median, prev, - teams, businessDaysOnly, }, params: { orgSlug }, @@ -123,9 +118,6 @@ export default function DeployedPage({ Pull requests deployed this week with cycle time metrics. - - - { const url = new URL(request.url) const fromParam = url.searchParams.get('from') const toParam = url.searchParams.get('to') - const teamParam = url.searchParams.get('team') + const teamParam = url.searchParams.get('team') ?? getSelectedTeam(request) const businessDaysOnly = url.searchParams.get('businessDays') !== '0' let from: dayjs.Dayjs @@ -59,8 +57,7 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { const prevFrom = from.subtract(7, 'day') const prevTo = to.subtract(7, 'day') - const [teams, pullRequests, prevPullRequests] = await Promise.all([ - listTeams(organization.id), + const [pullRequests, prevPullRequests] = await Promise.all([ getMergedPullRequestReport( organization.id, from.utc().toISOString(), @@ -88,7 +85,6 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { objective, ...stats, prev, - teams, businessDaysOnly, } } @@ -102,7 +98,6 @@ export default function OrganizationIndex({ achievementRate, median, prev, - teams, businessDaysOnly, }, params: { orgSlug }, @@ -123,9 +118,6 @@ export default function OrganizationIndex({ Pull requests merged this week with cycle time metrics. - - - { const { organization } = context.get(orgContext) const url = new URL(request.url) - const teamParam = url.searchParams.get('team') + const teamParam = url.searchParams.get('team') ?? getSelectedTeam(request) const businessDaysOnly = url.searchParams.get('businessDays') !== '0' - const teams = await listTeams(organization.id) - const pullRequests = await getOngoingPullRequestReport( organization.id, null, @@ -56,11 +52,11 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { .filter((v): v is number => v !== null) const median = calcMedian(ages) - return { pullRequests, median, teams, businessDaysOnly } + return { pullRequests, median, businessDaysOnly } } export default function OngoingPage({ - loaderData: { pullRequests, median, teams, businessDaysOnly }, + loaderData: { pullRequests, median, businessDaysOnly }, params: { orgSlug }, }: Route.ComponentProps) { const [, setSearchParams] = useSearchParams() @@ -79,9 +75,6 @@ export default function OngoingPage({ Pull requests currently in progress. - - - { const { organization } = context.get(orgContext) const url = new URL(request.url) - const teamParam = url.searchParams.get('team') + const teamParam = url.searchParams.get('team') ?? getSelectedTeam(request) const teams = await listTeams(organization.id) const selectedTeam = teamParam @@ -39,7 +38,6 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { ]) return { - teams, openPRs, pendingReviews, personalLimit, @@ -49,10 +47,9 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { export const clientLoader = async ({ serverLoader, }: Route.ClientLoaderArgs) => { - const { teams, openPRs, pendingReviews, personalLimit } = await serverLoader() + const { openPRs, pendingReviews, personalLimit } = await serverLoader() return { - teams, teamStacks: aggregateTeamStacks(openPRs, pendingReviews, personalLimit), } } @@ -74,7 +71,7 @@ export function HydrateFallback() { } export default function ReviewStacksPage({ - loaderData: { teams, teamStacks }, + loaderData: { teamStacks }, }: Route.ComponentProps) { return ( @@ -85,9 +82,6 @@ export default function ReviewStacksPage({ Monitor review workload balance across team members. - - - From 233e59cf88177014962f9cb7e4cace1fe94872b0 Mon Sep 17 00:00:00 2001 From: coji Date: Mon, 30 Mar 2026 20:46:41 +0900 Subject: [PATCH 02/11] =?UTF-8?q?fix:=20team-switcher=20=E3=81=AE=20noDocu?= =?UTF-8?q?mentCookie=20lint=20warning=20=E3=82=92=20suppress?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 既存の sidebar.tsx と同じ document.cookie パターンのため biome-ignore で抑制。 Co-Authored-By: Claude Opus 4.6 (1M context) --- app/components/layout/team-switcher.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/components/layout/team-switcher.tsx b/app/components/layout/team-switcher.tsx index bfefeb35..9ef499fc 100644 --- a/app/components/layout/team-switcher.tsx +++ b/app/components/layout/team-switcher.tsx @@ -22,10 +22,12 @@ interface Team { const COOKIE_MAX_AGE = 2592000 // 30 days function setTeamCookie(teamId: string) { + // biome-ignore lint/suspicious/noDocumentCookie: matches sidebar_state cookie pattern in sidebar.tsx document.cookie = `selected_team=${encodeURIComponent(teamId)}; path=/; max-age=${COOKIE_MAX_AGE}; samesite=lax` } function clearTeamCookie() { + // biome-ignore lint/suspicious/noDocumentCookie: matches sidebar_state cookie pattern in sidebar.tsx document.cookie = 'selected_team=; path=/; max-age=0; samesite=lax' } From 68e9b886cdaa638c3d2b395a6968dcdb37855a0d Mon Sep 17 00:00:00 2001 From: coji Date: Mon, 30 Mar 2026 20:47:30 +0900 Subject: [PATCH 03/11] =?UTF-8?q?chore:=20=E6=A4=9C=E8=A8=BC=E6=B8=88?= =?UTF-8?q?=E3=81=BF=20PoC=20=E3=82=B9=E3=82=AF=E3=83=AA=E3=83=97=E3=83=88?= =?UTF-8?q?=E3=82=92=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub App Installation Token の API 検証用スクリプト。 検証完了済みのため削除。lint warning 5件も解消。 Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/poc-repo-add-api.ts | 90 ------------------------------------- 1 file changed, 90 deletions(-) delete mode 100644 scripts/poc-repo-add-api.ts diff --git a/scripts/poc-repo-add-api.ts b/scripts/poc-repo-add-api.ts deleted file mode 100644 index def09c37..00000000 --- a/scripts/poc-repo-add-api.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * PoC: リポジトリ追加画面で使う API が Installation Token で動くか検証 - */ -import { createAppAuth } from '@octokit/auth-app' -import fs from 'node:fs' -import { Octokit } from 'octokit' - -const privateKey = fs.readFileSync( - process.env.GITHUB_APP_PRIVATE_KEY_PATH!, - 'utf-8', -) -const octokit = new Octokit({ - authStrategy: createAppAuth, - auth: { - appId: Number(process.env.GITHUB_APP_ID), - privateKey, - installationId: Number(process.env.GITHUB_APP_INSTALLATION_ID), - }, -}) - -async function main() { - // Test 1: GET /user/repos (現在のリポ追加画面が使ってる API) - console.log('=== GET /user/repos (PAT用、App で動くか?) ===') - try { - const res = await octokit.rest.repos.listForAuthenticatedUser({ - per_page: 5, - affiliation: 'owner,collaborator,organization_member', - }) - console.log('✅ 動いた:', res.data.length, 'repos') - for (const r of res.data) console.log(' ', r.full_name) - } catch (e: any) { - console.log('❌ 失敗:', e.status, e.message?.substring(0, 200)) - } - - // Test 2: GET /installation/repositories (App 用の代替) - console.log('\n=== GET /installation/repositories (App用の代替) ===') - try { - const res = await octokit.rest.apps.listReposAccessibleToInstallation({ - per_page: 100, - }) - console.log('✅ 動いた:', res.data.total_count, 'repos') - for (const r of res.data.repositories.slice(0, 5)) - console.log(' ', r.full_name) - - // owner 抽出(getUniqueOwners の代替) - const owners = [...new Set(res.data.repositories.map((r) => r.owner.login))] - console.log(' → owners:', owners.join(', ')) - } catch (e: any) { - console.log('❌ 失敗:', e.status, e.message?.substring(0, 200)) - } - - // Test 3: Search API (現在のリポ検索) - console.log('\n=== Search API (キーワード検索) ===') - try { - const res = await octokit.rest.search.repos({ - q: 'user:techtalkjp', - per_page: 5, - }) - console.log('✅ 動いた:', res.data.total_count, 'repos') - for (const r of res.data.items.slice(0, 5)) console.log(' ', r.full_name) - } catch (e: any) { - console.log('❌ 失敗:', e.status, e.message?.substring(0, 200)) - } - - // Test 4: Search API でインストール外のリポが見えないか確認 - console.log('\n=== Search API (スコープ外 org の検索) ===') - try { - const res = await octokit.rest.search.repos({ - q: 'user:facebook react', - per_page: 3, - }) - if (res.data.total_count > 0) { - console.log( - '⚠️ スコープ外のリポが見える:', - res.data.total_count, - 'repos', - ) - for (const r of res.data.items.slice(0, 3)) console.log(' ', r.full_name) - console.log( - ' → Search API は Installation Token でもスコープされない。GET /installation/repositories + フィルタが必要', - ) - } else { - console.log('✅ スコープ外のリポは見えない') - } - } catch (e: any) { - console.log('❌ 失敗:', e.status, e.message?.substring(0, 200)) - } -} - -main().catch(console.error) From 197975e025beda61a6cb3caa41e037fac80f498f Mon Sep 17 00:00:00 2001 From: coji Date: Mon, 30 Mar 2026 20:49:53 +0900 Subject: [PATCH 04/11] =?UTF-8?q?refactor:=20TeamSwitcher=20=E3=82=92?= =?UTF-8?q?=E3=82=B3=E3=83=B3=E3=83=91=E3=82=AF=E3=83=88=E3=81=AA=20Select?= =?UTF-8?q?=20=E3=81=AB=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DropdownMenu + SidebarMenuButton の組織スイッチャー風から、 Select + UsersIcon のシンプルな見た目に変更。 サイドバー内での視覚的階層を適切にする。 Co-Authored-By: Claude Opus 4.6 (1M context) --- app/components/layout/team-switcher.tsx | 120 +++++++----------------- 1 file changed, 33 insertions(+), 87 deletions(-) diff --git a/app/components/layout/team-switcher.tsx b/app/components/layout/team-switcher.tsx index 9ef499fc..820c3854 100644 --- a/app/components/layout/team-switcher.tsx +++ b/app/components/layout/team-switcher.tsx @@ -1,18 +1,12 @@ -import { ChevronsUpDown, UsersIcon } from 'lucide-react' +import { UsersIcon } from 'lucide-react' import { useRevalidator } from 'react-router' import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuTrigger, -} from '~/app/components/ui/dropdown-menu' -import { - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - useSidebar, -} from '~/app/components/ui/sidebar' + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '~/app/components/ui/select' interface Team { id: string @@ -38,84 +32,36 @@ export function TeamSwitcher({ teams: Team[] selectedTeamId: string | null }) { - const { isMobile } = useSidebar() const revalidator = useRevalidator() if (teams.length === 0) return null - const selectedTeam = selectedTeamId - ? teams.find((t) => t.id === selectedTeamId) - : null - const displayName = selectedTeam?.name ?? 'All Teams' - return ( - - - - - -
- {selectedTeam ? ( - selectedTeam.name.slice(0, 2).toUpperCase() - ) : ( - - )} -
-
- {displayName} - Team -
- -
-
- - - Teams - - { - clearTeamCookie() - revalidator.revalidate() - }} - className="gap-2 p-2" - > -
- -
- All Teams - {!selectedTeamId && ( - current - )} -
- {teams.map((team) => ( - { - setTeamCookie(team.id) - revalidator.revalidate() - }} - className="gap-2 p-2" - > -
- {team.name.slice(0, 2).toUpperCase()} -
- {team.name} - {team.id === selectedTeamId && ( - current - )} -
- ))} -
-
-
-
+
+ + +
) } From 532084ab0b0e77c6ed3a70a7644905475f9e1e09 Mon Sep 17 00:00:00 2001 From: coji Date: Mon, 30 Mar 2026 20:50:32 +0900 Subject: [PATCH 05/11] =?UTF-8?q?fix:=20TeamSwitcher=20=E3=81=AE=20Select?= =?UTF-8?q?=20=E3=82=92=E6=A8=AA=E5=B9=85=E3=81=84=E3=81=A3=E3=81=B1?= =?UTF-8?q?=E3=81=84=E3=81=AB=E5=BA=83=E3=81=92=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- app/components/layout/team-switcher.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/layout/team-switcher.tsx b/app/components/layout/team-switcher.tsx index 820c3854..1c7a3a71 100644 --- a/app/components/layout/team-switcher.tsx +++ b/app/components/layout/team-switcher.tsx @@ -50,7 +50,7 @@ export function TeamSwitcher({ revalidator.revalidate() }} > - + From a724ed511113e406252c4196c0a638b63abaeec5 Mon Sep 17 00:00:00 2001 From: coji Date: Mon, 30 Mar 2026 20:51:28 +0900 Subject: [PATCH 06/11] =?UTF-8?q?fix:=20TeamSwitcher=20=E3=82=A2=E3=82=A4?= =?UTF-8?q?=E3=82=B3=E3=83=B3=E3=82=92=20SelectTrigger=20=E5=86=85?= =?UTF-8?q?=E3=81=AB=E9=85=8D=E7=BD=AE=E3=81=97=E3=81=A6=E6=8F=83=E3=81=88?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- app/components/layout/team-switcher.tsx | 50 ++++++++++++------------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/app/components/layout/team-switcher.tsx b/app/components/layout/team-switcher.tsx index 1c7a3a71..0f09277c 100644 --- a/app/components/layout/team-switcher.tsx +++ b/app/components/layout/team-switcher.tsx @@ -37,31 +37,29 @@ export function TeamSwitcher({ if (teams.length === 0) return null return ( -
- - -
+ ) } From eb8498aa1abba84068ce951da09763511c0da5c0 Mon Sep 17 00:00:00 2001 From: coji Date: Mon, 30 Mar 2026 20:52:02 +0900 Subject: [PATCH 07/11] =?UTF-8?q?fix:=20TeamSwitcher=20=E3=81=AE=20Chevron?= =?UTF-8?q?Down=20=E3=82=92=E5=8F=B3=E7=AB=AF=E3=81=AB=E6=8F=83=E3=81=88?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- app/components/layout/team-switcher.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/layout/team-switcher.tsx b/app/components/layout/team-switcher.tsx index 0f09277c..1bb392e7 100644 --- a/app/components/layout/team-switcher.tsx +++ b/app/components/layout/team-switcher.tsx @@ -48,7 +48,7 @@ export function TeamSwitcher({ revalidator.revalidate() }} > - + From afa1fa9880e868228d3ab77769408bd78c117498 Mon Sep 17 00:00:00 2001 From: coji Date: Mon, 30 Mar 2026 20:52:50 +0900 Subject: [PATCH 08/11] =?UTF-8?q?fix:=20TeamSwitcher=20=E3=81=AE=E3=83=AC?= =?UTF-8?q?=E3=82=A4=E3=82=A2=E3=82=A6=E3=83=88=E3=82=92=E5=B7=A6=E5=AF=84?= =?UTF-8?q?=E3=81=9B=20+=20ChevronDown=20=E5=8F=B3=E7=AB=AF=E3=81=AB?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- app/components/layout/team-switcher.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/layout/team-switcher.tsx b/app/components/layout/team-switcher.tsx index 1bb392e7..f3e9b38c 100644 --- a/app/components/layout/team-switcher.tsx +++ b/app/components/layout/team-switcher.tsx @@ -48,8 +48,8 @@ export function TeamSwitcher({ revalidator.revalidate() }} > - - + + From ccd9f6144ab1878b14bedde5d881058ed386fc3f Mon Sep 17 00:00:00 2001 From: coji Date: Mon, 30 Mar 2026 20:53:59 +0900 Subject: [PATCH 09/11] =?UTF-8?q?fix:=20TeamSwitcher=20=E3=81=AE=E5=B7=A6?= =?UTF-8?q?=E5=8F=B3=E3=83=91=E3=83=87=E3=82=A3=E3=83=B3=E3=82=B0=E3=82=92?= =?UTF-8?q?=20OrgSwitcher=20=E3=81=A8=E6=8F=83=E3=81=88=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- app/components/layout/team-switcher.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/layout/team-switcher.tsx b/app/components/layout/team-switcher.tsx index f3e9b38c..29a776a5 100644 --- a/app/components/layout/team-switcher.tsx +++ b/app/components/layout/team-switcher.tsx @@ -48,7 +48,7 @@ export function TeamSwitcher({ revalidator.revalidate() }} > - + From ce9fe6d8a85fc9f309c4c3cef0136f85405af163 Mon Sep 17 00:00:00 2001 From: coji Date: Mon, 30 Mar 2026 21:05:09 +0900 Subject: [PATCH 10/11] =?UTF-8?q?refactor:=20team=20=E9=81=B8=E6=8A=9E?= =?UTF-8?q?=E3=82=92=20middleware=20context=20=E3=81=AB=E9=9B=86=E7=B4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cookie 名・max-age を共有定数ファイル (team-cookie.ts) に抽出 - team-cookie.server.ts が共有定数を import - team-switcher.tsx が共有定数を import(stringly-typed 重複を解消) - orgMemberMiddleware で teamContext をセット - URL ?team > Cookie の優先順位で解決 - teams リストで検証済み(stale cookie で空データになるバグを修正) - 7 ページの loader から getSelectedTeam(request) を排除 → context.get(teamContext) に統一 Co-Authored-By: Claude Opus 4.6 (1M context) --- app/components/layout/team-switcher.tsx | 7 +++---- app/libs/team-cookie.server.ts | 7 +++---- app/libs/team-cookie.ts | 2 ++ app/middleware/context.ts | 2 ++ app/middleware/org-member.ts | 15 ++++++++++++++- app/routes/$orgSlug/_layout.tsx | 13 ++++++------- .../$orgSlug/analysis/feedbacks/_index/index.tsx | 10 ++++++---- app/routes/$orgSlug/analysis/inventory/index.tsx | 10 ++++++---- app/routes/$orgSlug/analysis/reviews/index.tsx | 9 ++++++--- app/routes/$orgSlug/throughput/deployed/index.tsx | 9 ++++++--- app/routes/$orgSlug/throughput/merged/index.tsx | 9 ++++++--- app/routes/$orgSlug/throughput/ongoing/index.tsx | 5 ++--- app/routes/$orgSlug/workload/index.tsx | 8 +++----- 13 files changed, 65 insertions(+), 41 deletions(-) create mode 100644 app/libs/team-cookie.ts diff --git a/app/components/layout/team-switcher.tsx b/app/components/layout/team-switcher.tsx index 29a776a5..d45270b5 100644 --- a/app/components/layout/team-switcher.tsx +++ b/app/components/layout/team-switcher.tsx @@ -7,22 +7,21 @@ import { SelectTrigger, SelectValue, } from '~/app/components/ui/select' +import { TEAM_COOKIE_MAX_AGE, TEAM_COOKIE_NAME } from '~/app/libs/team-cookie' interface Team { id: string name: string } -const COOKIE_MAX_AGE = 2592000 // 30 days - function setTeamCookie(teamId: string) { // biome-ignore lint/suspicious/noDocumentCookie: matches sidebar_state cookie pattern in sidebar.tsx - document.cookie = `selected_team=${encodeURIComponent(teamId)}; path=/; max-age=${COOKIE_MAX_AGE}; samesite=lax` + document.cookie = `${TEAM_COOKIE_NAME}=${encodeURIComponent(teamId)}; path=/; max-age=${TEAM_COOKIE_MAX_AGE}; samesite=lax` } function clearTeamCookie() { // biome-ignore lint/suspicious/noDocumentCookie: matches sidebar_state cookie pattern in sidebar.tsx - document.cookie = 'selected_team=; path=/; max-age=0; samesite=lax' + document.cookie = `${TEAM_COOKIE_NAME}=; path=/; max-age=0; samesite=lax` } export function TeamSwitcher({ diff --git a/app/libs/team-cookie.server.ts b/app/libs/team-cookie.server.ts index ccf12efa..061060b8 100644 --- a/app/libs/team-cookie.server.ts +++ b/app/libs/team-cookie.server.ts @@ -1,10 +1,9 @@ -const COOKIE_NAME = 'selected_team' +import { TEAM_COOKIE_NAME } from '~/app/libs/team-cookie' export function getSelectedTeam(request: Request): string | null { const cookieHeader = request.headers.get('Cookie') ?? '' - const match = cookieHeader - .split('; ') - .find((c) => c.startsWith(`${COOKIE_NAME}=`)) + const prefix = `${TEAM_COOKIE_NAME}=` + const match = cookieHeader.split('; ').find((c) => c.startsWith(prefix)) if (!match) return null return decodeURIComponent(match.split('=')[1] ?? '') || null } diff --git a/app/libs/team-cookie.ts b/app/libs/team-cookie.ts new file mode 100644 index 00000000..2f48ced2 --- /dev/null +++ b/app/libs/team-cookie.ts @@ -0,0 +1,2 @@ +export const TEAM_COOKIE_NAME = 'selected_team' +export const TEAM_COOKIE_MAX_AGE = 2592000 // 30 days diff --git a/app/middleware/context.ts b/app/middleware/context.ts index 27f8338f..38ceb98e 100644 --- a/app/middleware/context.ts +++ b/app/middleware/context.ts @@ -3,3 +3,5 @@ import type { OrgContext } from '~/app/libs/auth.server' export const orgContext = createContext() export const timezoneContext = createContext() +/** Resolved team ID from URL ?team or cookie, validated against org's teams list. null = all teams. */ +export const teamContext = createContext() diff --git a/app/middleware/org-member.ts b/app/middleware/org-member.ts index b21b1029..9ef9ae54 100644 --- a/app/middleware/org-member.ts +++ b/app/middleware/org-member.ts @@ -1,7 +1,9 @@ import { requireOrgMember } from '~/app/libs/auth.server' +import { getSelectedTeam } from '~/app/libs/team-cookie.server' import { getOrganizationTimezone } from '~/app/libs/timezone.server' +import { listTeams } from '~/app/routes/$orgSlug/settings/teams._index/queries.server' import type { Route } from '../routes/$orgSlug/+types/_layout' -import { orgContext, timezoneContext } from './context' +import { orgContext, teamContext, timezoneContext } from './context' export const orgMemberMiddleware: Route.MiddlewareFunction = async ( { request, params, context }, @@ -11,5 +13,16 @@ export const orgMemberMiddleware: Route.MiddlewareFunction = async ( context.set(orgContext, org) const timezone = await getOrganizationTimezone(org.organization.id) context.set(timezoneContext, timezone) + + const url = new URL(request.url) + const teams = await listTeams(org.organization.id) + const teamFromUrl = url.searchParams.get('team') + const teamFromCookie = getSelectedTeam(request) + const selectedTeamId = + [teamFromUrl, teamFromCookie].find( + (id) => id && teams.some((t) => t.id === id), + ) ?? null + context.set(teamContext, selectedTeamId) + return next() } diff --git a/app/routes/$orgSlug/_layout.tsx b/app/routes/$orgSlug/_layout.tsx index c4791834..15087f05 100644 --- a/app/routes/$orgSlug/_layout.tsx +++ b/app/routes/$orgSlug/_layout.tsx @@ -5,9 +5,12 @@ import { Main } from '~/app/components/layout/main' import { SidebarProvider } from '~/app/components/ui/sidebar' import { useBreadcrumbs } from '~/app/hooks/use-breadcrumbs' import { getUserOrganizations } from '~/app/libs/auth.server' -import { getSelectedTeam } from '~/app/libs/team-cookie.server' import { cn } from '~/app/libs/utils' -import { orgContext, timezoneContext } from '~/app/middleware/context' +import { + orgContext, + teamContext, + timezoneContext, +} from '~/app/middleware/context' import { orgMemberMiddleware } from '~/app/middleware/org-member' import { listTeams } from '~/app/routes/$orgSlug/settings/teams._index/queries.server' import type { Route } from './+types/_layout' @@ -40,6 +43,7 @@ export const middleware = [orgMemberMiddleware] export const loader = async ({ request, context }: Route.LoaderArgs) => { const { user, organization, membership } = context.get(orgContext) const timezone = context.get(timezoneContext) + const selectedTeamId = context.get(teamContext) const organizations = await getUserOrganizations(user.id) const cookieHeader = request.headers.get('Cookie') ?? '' @@ -50,11 +54,6 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { const defaultOpen = sidebarState !== 'false' const teams = await listTeams(organization.id) - const selectedTeamCookie = getSelectedTeam(request) - const selectedTeamId = - selectedTeamCookie && teams.some((t) => t.id === selectedTeamCookie) - ? selectedTeamCookie - : null return { user, diff --git a/app/routes/$orgSlug/analysis/feedbacks/_index/index.tsx b/app/routes/$orgSlug/analysis/feedbacks/_index/index.tsx index 229ed569..4e3aa17a 100644 --- a/app/routes/$orgSlug/analysis/feedbacks/_index/index.tsx +++ b/app/routes/$orgSlug/analysis/feedbacks/_index/index.tsx @@ -29,8 +29,11 @@ import { TableRow, } from '~/app/components/ui/table' import { calcSinceDate } from '~/app/libs/date-utils' -import { getSelectedTeam } from '~/app/libs/team-cookie.server' -import { orgContext, timezoneContext } from '~/app/middleware/context' +import { + orgContext, + teamContext, + timezoneContext, +} from '~/app/middleware/context' import { DataTablePagination } from './+components/data-table-pagination' import { feedbackColumns } from './+components/feedback-columns' import { FeedbackSummaryCards } from './+components/feedback-summary-cards' @@ -62,8 +65,7 @@ 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') ?? getSelectedTeam(request) ?? undefined + const teamParam = context.get(teamContext) ?? undefined const periodParam = url.searchParams.get('period') const periodMonths = periodParam === 'all' diff --git a/app/routes/$orgSlug/analysis/inventory/index.tsx b/app/routes/$orgSlug/analysis/inventory/index.tsx index 85074645..a4890fe8 100644 --- a/app/routes/$orgSlug/analysis/inventory/index.tsx +++ b/app/routes/$orgSlug/analysis/inventory/index.tsx @@ -17,8 +17,11 @@ import { import { Switch } from '~/app/components/ui/switch' import { calcSinceDate } from '~/app/libs/date-utils' import dayjs from '~/app/libs/dayjs' -import { getSelectedTeam } from '~/app/libs/team-cookie.server' -import { orgContext, timezoneContext } from '~/app/middleware/context' +import { + orgContext, + teamContext, + timezoneContext, +} from '~/app/middleware/context' import { getOrgCachedData } from '~/app/services/cache.server' import { OpenPRInventoryChart } from './+components/open-pr-inventory-chart' import { aggregateWeeklyOpenPRInventory } from './+functions/aggregate' @@ -43,8 +46,7 @@ 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') ?? getSelectedTeam(request) ?? null + const teamParam = context.get(teamContext) const periodParam = url.searchParams.get('period') || null const periodMonths = VALID_PERIODS.includes( Number(periodParam) as (typeof VALID_PERIODS)[number], diff --git a/app/routes/$orgSlug/analysis/reviews/index.tsx b/app/routes/$orgSlug/analysis/reviews/index.tsx index 8548878d..8daeacf8 100644 --- a/app/routes/$orgSlug/analysis/reviews/index.tsx +++ b/app/routes/$orgSlug/analysis/reviews/index.tsx @@ -15,8 +15,11 @@ import { } from '~/app/components/ui/select' import { Stack } from '~/app/components/ui/stack' import { calcSinceDate } from '~/app/libs/date-utils' -import { getSelectedTeam } from '~/app/libs/team-cookie.server' -import { orgContext, timezoneContext } from '~/app/middleware/context' +import { + orgContext, + teamContext, + timezoneContext, +} from '~/app/middleware/context' import { getOrgCachedData } from '~/app/services/cache.server' import { PRSizeChart } from './+components/pr-size-chart' import { QueueTrendChart } from './+components/queue-trend-chart' @@ -52,7 +55,7 @@ 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') ?? getSelectedTeam(request) + const teamParam = context.get(teamContext) const periodParam = url.searchParams.get('period') const VALID_PERIODS = [1, 3, 6, 12] const periodMonths = diff --git a/app/routes/$orgSlug/throughput/deployed/index.tsx b/app/routes/$orgSlug/throughput/deployed/index.tsx index 43c81c38..6a93ba40 100644 --- a/app/routes/$orgSlug/throughput/deployed/index.tsx +++ b/app/routes/$orgSlug/throughput/deployed/index.tsx @@ -16,8 +16,11 @@ import WeeklyCalendar from '~/app/components/week-calendar' import { useTimezone } from '~/app/hooks/use-timezone' import { getEndOfWeek, getStartOfWeek, parseDate } from '~/app/libs/date-utils' import dayjs from '~/app/libs/dayjs' -import { getSelectedTeam } from '~/app/libs/team-cookie.server' -import { orgContext, timezoneContext } from '~/app/middleware/context' +import { + orgContext, + teamContext, + timezoneContext, +} from '~/app/middleware/context' import { DiffBadge } from '../+components/diff-badge' import { StatCard } from '../+components/stat-card' import { calcStats } from '../+functions/calc-stats' @@ -41,7 +44,7 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { const url = new URL(request.url) const fromParam = url.searchParams.get('from') const toParam = url.searchParams.get('to') - const teamParam = url.searchParams.get('team') ?? getSelectedTeam(request) + const teamParam = context.get(teamContext) const businessDaysOnly = url.searchParams.get('businessDays') !== '0' let from: dayjs.Dayjs diff --git a/app/routes/$orgSlug/throughput/merged/index.tsx b/app/routes/$orgSlug/throughput/merged/index.tsx index 9f68a127..e7f0ce54 100644 --- a/app/routes/$orgSlug/throughput/merged/index.tsx +++ b/app/routes/$orgSlug/throughput/merged/index.tsx @@ -16,8 +16,11 @@ import WeeklyCalendar from '~/app/components/week-calendar' import { useTimezone } from '~/app/hooks/use-timezone' import { getEndOfWeek, getStartOfWeek, parseDate } from '~/app/libs/date-utils' import dayjs from '~/app/libs/dayjs' -import { getSelectedTeam } from '~/app/libs/team-cookie.server' -import { orgContext, timezoneContext } from '~/app/middleware/context' +import { + orgContext, + teamContext, + timezoneContext, +} from '~/app/middleware/context' import { DiffBadge } from '../+components/diff-badge' import { StatCard } from '../+components/stat-card' import { calcStats } from '../+functions/calc-stats' @@ -41,7 +44,7 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { const url = new URL(request.url) const fromParam = url.searchParams.get('from') const toParam = url.searchParams.get('to') - const teamParam = url.searchParams.get('team') ?? getSelectedTeam(request) + const teamParam = context.get(teamContext) const businessDaysOnly = url.searchParams.get('businessDays') !== '0' let from: dayjs.Dayjs diff --git a/app/routes/$orgSlug/throughput/ongoing/index.tsx b/app/routes/$orgSlug/throughput/ongoing/index.tsx index 4e6bd038..c42241fb 100644 --- a/app/routes/$orgSlug/throughput/ongoing/index.tsx +++ b/app/routes/$orgSlug/throughput/ongoing/index.tsx @@ -15,8 +15,7 @@ import { import { useTimezone } from '~/app/hooks/use-timezone' import dayjs from '~/app/libs/dayjs' import { median as calcMedian } from '~/app/libs/stats' -import { getSelectedTeam } from '~/app/libs/team-cookie.server' -import { orgContext } from '~/app/middleware/context' +import { orgContext, teamContext } from '~/app/middleware/context' import { StatCard } from '../+components/stat-card' import { createColumns } from './+columns' import { getOngoingPullRequestReport } from './+functions/queries.server' @@ -36,7 +35,7 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { const { organization } = context.get(orgContext) const url = new URL(request.url) - const teamParam = url.searchParams.get('team') ?? getSelectedTeam(request) + const teamParam = context.get(teamContext) const businessDaysOnly = url.searchParams.get('businessDays') !== '0' const pullRequests = await getOngoingPullRequestReport( diff --git a/app/routes/$orgSlug/workload/index.tsx b/app/routes/$orgSlug/workload/index.tsx index 4b4f204f..1b80857c 100644 --- a/app/routes/$orgSlug/workload/index.tsx +++ b/app/routes/$orgSlug/workload/index.tsx @@ -5,8 +5,7 @@ import { PageHeaderTitle, } from '~/app/components/layout/page-header' import { Stack } from '~/app/components/ui/stack' -import { getSelectedTeam } from '~/app/libs/team-cookie.server' -import { orgContext } from '~/app/middleware/context' +import { orgContext, teamContext } from '~/app/middleware/context' import { listTeams } from '~/app/routes/$orgSlug/settings/teams._index/queries.server' import { TeamStacksChart } from './+components/team-stacks-chart' import { @@ -19,11 +18,10 @@ import { } from './+functions/stacks.server' import type { Route } from './+types/index' -export const loader = async ({ request, context }: Route.LoaderArgs) => { +export const loader = async ({ context }: Route.LoaderArgs) => { const { organization } = context.get(orgContext) - const url = new URL(request.url) - const teamParam = url.searchParams.get('team') ?? getSelectedTeam(request) + const teamParam = context.get(teamContext) const teams = await listTeams(organization.id) const selectedTeam = teamParam From 7808419de4f7e0e64d2e14492b2ecfaaf498e147 Mon Sep 17 00:00:00 2001 From: coji Date: Mon, 30 Mar 2026 21:41:53 +0900 Subject: [PATCH 11/11] =?UTF-8?q?fix:=20cookie=20=E3=83=91=E3=83=BC?= =?UTF-8?q?=E3=82=B5=E3=83=BC=E3=81=AE=20=3D=20=E5=88=87=E3=82=8A=E8=A9=B0?= =?UTF-8?q?=E3=82=81=E3=81=A8=20URIError=20=E3=82=AF=E3=83=A9=E3=83=83?= =?UTF-8?q?=E3=82=B7=E3=83=A5=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - split('=')[1] → slice(prefix.length) で = を含む値も正しく取得 - decodeURIComponent を try-catch で囲み malformed 入力で null を返す Co-Authored-By: Claude Opus 4.6 (1M context) --- app/libs/team-cookie.server.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/libs/team-cookie.server.ts b/app/libs/team-cookie.server.ts index 061060b8..abd93191 100644 --- a/app/libs/team-cookie.server.ts +++ b/app/libs/team-cookie.server.ts @@ -5,5 +5,10 @@ export function getSelectedTeam(request: Request): string | null { const prefix = `${TEAM_COOKIE_NAME}=` const match = cookieHeader.split('; ').find((c) => c.startsWith(prefix)) if (!match) return null - return decodeURIComponent(match.split('=')[1] ?? '') || null + const rawValue = match.slice(prefix.length) + try { + return decodeURIComponent(rawValue) || null + } catch { + return null + } }