From 1bc9ffa5bbb6151c23dcdd8ad97a4cf505422d51 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:42:30 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9A=A1=20Bolt:=20=EB=B0=B0=EC=97=B4=20?= =?UTF-8?q?=EC=B5=9C=EB=8C=93=EA=B0=92=20=EA=B3=84=EC=82=B0=20=EC=8B=9C=20?= =?UTF-8?q?reduce=20=EB=B0=8F=20useMemo=20=EC=A0=81=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=EC=84=B1=EB=8A=A5=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .jules/bolt.md | 8 +- .../[orgSlug]/settings/members/page.tsx | 175 +++++++++--------- .../dashboard/reports/delegation-insight.tsx | 35 ++-- .../components/dashboard/top-users-list.tsx | 34 ++-- 4 files changed, 128 insertions(+), 124 deletions(-) diff --git a/.jules/bolt.md b/.jules/bolt.md index 57daf471..2cc4464a 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -1,5 +1,3 @@ -## 2026-08-11 - Use `Date.parse` for timestamp primitives - -**Learning:** `Date.parse(value)` returns the timestamp primitive directly, while `new Date(value).getTime()` also constructs a `Date` object. Both use the same ECMAScript string-parsing semantics for these call sites. - -**Action:** In frequently executed paths that only need a timestamp primitive, prefer `Date.parse(value)`. Treat the allocation reduction as a bounded micro-optimization unless a committed benchmark establishes a larger runtime effect. +## 2025-02-19 - 배열 최댓값 계산 시 Math.max와 스프레드 연산자 사용으로 인한 성능 및 콜 스택 이슈 해결 +**Learning:** React 컴포넌트(`DelegationInsight`, `MembersPage`, `TopUsersList` 등)에서 리스트 렌더링 시 배열의 최댓값을 구하기 위해 `Math.max(...array)`를 사용하면 배열 크기가 클 때 'Maximum call stack size exceeded' 에러가 발생할 수 있습니다. 또한 매 렌더링 주기마다 무거운 동기적 계산을 반복하여 성능 저하를 초래합니다. +**Action:** 항상 `Math.max(...array)` 대신 `.reduce((max, item) => Math.max(max, item), 0)`를 사용하여 모든 크기의 데이터셋을 안전하게 처리해야 합니다. React 컴포넌트에서 이러한 파생 값을 계산할 때는 `useMemo`로 감싸 데이터가 변경될 때만 재계산되도록 하여 불필요한 렌더링을 방지해야 합니다. diff --git a/packages/web/src/app/dashboard/[orgSlug]/settings/members/page.tsx b/packages/web/src/app/dashboard/[orgSlug]/settings/members/page.tsx index 5d6ef680..2e08bb6f 100644 --- a/packages/web/src/app/dashboard/[orgSlug]/settings/members/page.tsx +++ b/packages/web/src/app/dashboard/[orgSlug]/settings/members/page.tsx @@ -1,49 +1,49 @@ -'use client' +"use client"; -import { use, useState } from 'react' -import { useSession } from 'next-auth/react' +import { use, useMemo, useState } from "react"; +import { useSession } from "next-auth/react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, -} from '@/components/ui/card' -import { Button } from '@/components/ui/button' -import { Skeleton } from '@/components/ui/skeleton' -import { Alert, AlertDescription } from '@/components/ui/alert' +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Alert, AlertDescription } from "@/components/ui/alert"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from '@/components/ui/select' +} from "@/components/ui/select"; import { useMembers, useRemoveMember, useUpdateMemberRole, type MemberListItem, -} from '@/hooks/use-members' -import { useOrgs } from '@/hooks/use-orgs' -import type { OrgRole } from '@argos/shared' -import { ApiError } from '@/lib/api-client' -import { cn } from '@/lib/utils' +} from "@/hooks/use-members"; +import { useOrgs } from "@/hooks/use-orgs"; +import type { OrgRole } from "@argos/shared"; +import { ApiError } from "@/lib/api-client"; +import { cn } from "@/lib/utils"; function CostBar({ cost, maxCost }: { cost: number; maxCost: number }) { - const pct = maxCost > 0 ? Math.round((cost / maxCost) * 100) : 0 + const pct = maxCost > 0 ? Math.round((cost / maxCost) * 100) : 0; const barColor = pct >= 67 - ? 'bg-destructive' + ? "bg-destructive" : pct >= 34 - ? 'bg-[var(--color-chart-4)]' - : 'bg-[var(--color-chart-3)]' + ? "bg-[var(--color-chart-4)]" + : "bg-[var(--color-chart-3)]"; return (
@@ -51,22 +51,22 @@ function CostBar({ cost, maxCost }: { cost: number; maxCost: number }) { ${cost.toFixed(2)}
- ) + ); } const ROLE_LABEL: Record = { - OWNER: 'Owner', - MANAGER: 'Manager', - MEMBER: 'Member', - VIEWER: 'Viewer', -} + OWNER: "Owner", + MANAGER: "Manager", + MEMBER: "Member", + VIEWER: "Viewer", +}; const ROLE_DESCRIPTION: Record = { - OWNER: '조직 전체 권한 (삭제 포함)', - MANAGER: '멤버·프로젝트 관리, 개인 단위 드릴다운 가능', - MEMBER: '기본 역할. 팀 세션·개인 드릴다운 열람', - VIEWER: '팀 집계만. 개인 식별자·전사·세션 리스트 접근 불가', -} + OWNER: "조직 전체 권한 (삭제 포함)", + MANAGER: "멤버·프로젝트 관리, 개인 단위 드릴다운 가능", + MEMBER: "기본 역할. 팀 세션·개인 드릴다운 열람", + VIEWER: "팀 집계만. 개인 식별자·전사·세션 리스트 접근 불가", +}; function MembersTable({ orgSlug, @@ -74,66 +74,66 @@ function MembersTable({ currentRole, currentUserId, }: { - orgSlug: string - members: MemberListItem[] - currentRole: OrgRole - currentUserId: string + orgSlug: string; + members: MemberListItem[]; + currentRole: OrgRole; + currentUserId: string; }) { - const updateRole = useUpdateMemberRole(orgSlug) - const removeMember = useRemoveMember(orgSlug) - const [errorByUser, setErrorByUser] = useState>({}) + const updateRole = useUpdateMemberRole(orgSlug); + const removeMember = useRemoveMember(orgSlug); + const [errorByUser, setErrorByUser] = useState>({}); - const maxCost = Math.max(...members.map((m) => m.sevenDayCostUsd), 0) - const canChangeToOwner = currentRole === 'OWNER' + const maxCost = useMemo( + () => members.reduce((max, m) => Math.max(max, m.sevenDayCostUsd), 0), + [members], + ); + const canChangeToOwner = currentRole === "OWNER"; const adminCount = members.filter( - (m) => m.role === 'OWNER' || m.role === 'MANAGER' - ).length + (m) => m.role === "OWNER" || m.role === "MANAGER", + ).length; const handleRoleChange = async (userId: string, role: OrgRole) => { - setErrorByUser((prev) => ({ ...prev, [userId]: '' })) + setErrorByUser((prev) => ({ ...prev, [userId]: "" })); try { - await updateRole.mutateAsync({ userId, role }) + await updateRole.mutateAsync({ userId, role }); } catch (err) { const msg = err instanceof ApiError ? err.status === 400 - ? '최소 1명의 Manager가 필요합니다.' + ? "최소 1명의 Manager가 필요합니다." : err.status === 403 - ? '이 변경 권한이 없습니다.' - : '변경 실패' - : '변경 실패' - setErrorByUser((prev) => ({ ...prev, [userId]: msg })) + ? "이 변경 권한이 없습니다." + : "변경 실패" + : "변경 실패"; + setErrorByUser((prev) => ({ ...prev, [userId]: msg })); } - } + }; const handleRemove = async (userId: string) => { - if ( - !confirm('이 멤버를 조직에서 제거합니다. 계속하시겠습니까?') - ) - return - setErrorByUser((prev) => ({ ...prev, [userId]: '' })) + if (!confirm("이 멤버를 조직에서 제거합니다. 계속하시겠습니까?")) return; + setErrorByUser((prev) => ({ ...prev, [userId]: "" })); try { - await removeMember.mutateAsync(userId) + await removeMember.mutateAsync(userId); } catch (err) { const msg = err instanceof ApiError ? err.status === 400 - ? '최소 1명의 Manager가 필요합니다.' + ? "최소 1명의 Manager가 필요합니다." : err.status === 403 - ? '이 멤버를 제거할 권한이 없습니다.' - : '제거 실패' - : '제거 실패' - setErrorByUser((prev) => ({ ...prev, [userId]: msg })) + ? "이 멤버를 제거할 권한이 없습니다." + : "제거 실패" + : "제거 실패"; + setErrorByUser((prev) => ({ ...prev, [userId]: msg })); } - } + }; return (
{members.map((m) => { - const isSelf = m.userId === currentUserId + const isSelf = m.userId === currentUserId; const isLastAdmin = - (m.role === 'OWNER' || m.role === 'MANAGER') && adminCount <= 1 - const ownerLocked = m.role === 'OWNER' && !canChangeToOwner + (m.role === "OWNER" || m.role === "MANAGER") && adminCount <= 1; + const ownerLocked = m.role === "OWNER" && !canChangeToOwner; return (