Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/components/layout/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Sidebar> {
user: {
Expand All @@ -30,13 +31,17 @@ interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
slug: string | null
}>
memberRole: MemberRole
teams: Array<{ id: string; name: string }>
selectedTeamId: string | null
}

export function AppSidebar({
user,
organization,
organizations,
memberRole,
teams,
selectedTeamId,
...props
}: AppSidebarProps) {
const orgSlug = organization.slug ?? organization.id
Expand All @@ -49,6 +54,7 @@ export function AppSidebar({
<Sidebar collapsible="icon" variant="floating" {...props}>
<SidebarHeader>
<OrgSwitcher currentOrg={organization} organizations={organizations} />
<TeamSwitcher teams={teams} selectedTeamId={selectedTeamId} />
</SidebarHeader>
<SidebarContent>
{navGroups.map((group) => (
Expand Down
64 changes: 64 additions & 0 deletions app/components/layout/team-switcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { UsersIcon } from 'lucide-react'
import { useRevalidator } from 'react-router'
import {
Select,
SelectContent,
SelectItem,
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
}

function setTeamCookie(teamId: string) {
// biome-ignore lint/suspicious/noDocumentCookie: matches sidebar_state cookie pattern in sidebar.tsx
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 = `${TEAM_COOKIE_NAME}=; path=/; max-age=0; samesite=lax`
}

export function TeamSwitcher({
teams,
selectedTeamId,
}: {
teams: Team[]
selectedTeamId: string | null
}) {
const revalidator = useRevalidator()

if (teams.length === 0) return null

return (
<Select
value={selectedTeamId ?? '__all__'}
onValueChange={(value) => {
if (value === '__all__') {
clearTeamCookie()
} else {
setTeamCookie(value)
}
revalidator.revalidate()
}}
>
<SelectTrigger className="h-8 w-full justify-start border-none bg-transparent px-2 text-xs shadow-none group-data-[collapsible=icon]:hidden [&>svg:last-child]:ml-auto">
<UsersIcon className="text-muted-foreground size-4 shrink-0" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All Teams</SelectItem>
{teams.map((team) => (
<SelectItem key={team.id} value={team.id}>
{team.name}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
14 changes: 14 additions & 0 deletions app/libs/team-cookie.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { TEAM_COOKIE_NAME } from '~/app/libs/team-cookie'

export function getSelectedTeam(request: Request): string | null {
const cookieHeader = request.headers.get('Cookie') ?? ''
const prefix = `${TEAM_COOKIE_NAME}=`
const match = cookieHeader.split('; ').find((c) => c.startsWith(prefix))
if (!match) return null
const rawValue = match.slice(prefix.length)
try {
return decodeURIComponent(rawValue) || null
} catch {
return null
}
}
2 changes: 2 additions & 0 deletions app/libs/team-cookie.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const TEAM_COOKIE_NAME = 'selected_team'
export const TEAM_COOKIE_MAX_AGE = 2592000 // 30 days
2 changes: 2 additions & 0 deletions app/middleware/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ import type { OrgContext } from '~/app/libs/auth.server'

export const orgContext = createContext<OrgContext>()
export const timezoneContext = createContext<string>()
/** Resolved team ID from URL ?team or cookie, validated against org's teams list. null = all teams. */
export const teamContext = createContext<string | null>()
15 changes: 14 additions & 1 deletion app/middleware/org-member.ts
Original file line number Diff line number Diff line change
@@ -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 },
Expand All @@ -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()
}
24 changes: 22 additions & 2 deletions app/routes/$orgSlug/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@ import { SidebarProvider } from '~/app/components/ui/sidebar'
import { useBreadcrumbs } from '~/app/hooks/use-breadcrumbs'
import { getUserOrganizations } from '~/app/libs/auth.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'

export interface RouteHandle {
Expand Down Expand Up @@ -38,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') ?? ''
Expand All @@ -47,18 +53,30 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => {
?.split('=')[1]
const defaultOpen = sidebarState !== 'false'

const teams = await listTeams(organization.id)

return {
user,
organization,
membership,
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()
Expand All @@ -74,6 +92,8 @@ export default function OrgLayout({
organization={organization}
organizations={organizations}
memberRole={membership.role}
teams={teams}
selectedTeamId={selectedTeamId}
/>
<div
id="content"
Expand Down
17 changes: 8 additions & 9 deletions app/routes/$orgSlug/analysis/feedbacks/_index/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
PageHeaderHeading,
PageHeaderTitle,
} from '~/app/components/layout/page-header'
import { TeamFilter } from '~/app/components/team-filter'
import {
Select,
SelectContent,
Expand All @@ -30,8 +29,11 @@ import {
TableRow,
} from '~/app/components/ui/table'
import { calcSinceDate } from '~/app/libs/date-utils'
import { orgContext, timezoneContext } from '~/app/middleware/context'
import { listTeams } from '~/app/routes/$orgSlug/settings/teams._index/queries.server'
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'
Expand Down Expand Up @@ -63,7 +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') || undefined
const teamParam = context.get(teamContext) ?? undefined
const periodParam = url.searchParams.get('period')
const periodMonths =
periodParam === 'all'
Expand All @@ -80,7 +82,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,
Expand All @@ -95,20 +97,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()
Expand Down Expand Up @@ -152,7 +152,6 @@ export default function FeedbacksPage({
))}
</SelectContent>
</Select>
<TeamFilter teams={teams} />
</PageHeaderActions>
</PageHeader>

Expand Down
20 changes: 7 additions & 13 deletions app/routes/$orgSlug/analysis/inventory/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -18,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 { orgContext, timezoneContext } from '~/app/middleware/context'
import { listTeams } from '~/app/routes/$orgSlug/settings/teams._index/queries.server'
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'
Expand All @@ -44,7 +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') || 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],
Expand All @@ -59,10 +61,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
Expand All @@ -82,7 +80,6 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => {
)

return {
teams,
rawRows,
sinceDate,
now,
Expand All @@ -97,7 +94,6 @@ export const clientLoader = async ({
serverLoader,
}: Route.ClientLoaderArgs) => {
const {
teams,
rawRows,
sinceDate,
now,
Expand All @@ -108,7 +104,6 @@ export const clientLoader = async ({
} = await serverLoader()

return {
teams,
inventory: aggregateWeeklyOpenPRInventory(
rawRows,
sinceDate,
Expand Down Expand Up @@ -137,7 +132,7 @@ export function HydrateFallback() {
}

export default function InventoryPage({
loaderData: { teams, inventory, periodMonths, excludeBots, unreviewedOnly },
loaderData: { inventory, periodMonths, excludeBots, unreviewedOnly },
}: Route.ComponentProps) {
const [, setSearchParams] = useSearchParams()

Expand Down Expand Up @@ -176,7 +171,6 @@ export default function InventoryPage({
))}
</SelectContent>
</Select>
<TeamFilter teams={teams} />
<HStack className="rounded-md border px-3 py-2">
<Label htmlFor="exclude-bots">Exclude bots</Label>
<Switch
Expand Down
Loading
Loading