Skip to content
Open
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
8 changes: 3 additions & 5 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
@@ -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`로 감싸 데이터가 변경될 때만 재계산되도록 하여 불필요한 렌더링을 방지해야 합니다.
Comment on lines +1 to +3

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Earlier optimization guidance disappears

The new lesson replaces the existing Date.parse guidance rather than adding to it. Reviewers lose the prior optimization rationale.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

175 changes: 84 additions & 91 deletions packages/web/src/app/dashboard/[orgSlug]/settings/members/page.tsx
Original file line number Diff line number Diff line change
@@ -1,139 +1,139 @@
'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 (
<div className="flex items-center gap-2 w-44">
<div className="flex-1 h-1.5 rounded-sm bg-muted overflow-hidden">
<div
className={cn('h-full rounded-sm transition-all', barColor)}
className={cn("h-full rounded-sm transition-all", barColor)}
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-xs tabular-nums text-muted-foreground w-14 text-right shrink-0">
${cost.toFixed(2)}
</span>
</div>
)
);
}

const ROLE_LABEL: Record<OrgRole, string> = {
OWNER: 'Owner',
MANAGER: 'Manager',
MEMBER: 'Member',
VIEWER: 'Viewer',
}
OWNER: "Owner",
MANAGER: "Manager",
MEMBER: "Member",
VIEWER: "Viewer",
};

const ROLE_DESCRIPTION: Record<OrgRole, string> = {
OWNER: '조직 전체 권한 (삭제 포함)',
MANAGER: '멤버·프로젝트 관리, 개인 단위 드릴다운 가능',
MEMBER: '기본 역할. 팀 세션·개인 드릴다운 열람',
VIEWER: '팀 집계만. 개인 식별자·전사·세션 리스트 접근 불가',
}
OWNER: "조직 전체 권한 (삭제 포함)",
MANAGER: "멤버·프로젝트 관리, 개인 단위 드릴다운 가능",
MEMBER: "기본 역할. 팀 세션·개인 드릴다운 열람",
VIEWER: "팀 집계만. 개인 식별자·전사·세션 리스트 접근 불가",
};

function MembersTable({
orgSlug,
members,
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<Record<string, string>>({})
const updateRole = useUpdateMemberRole(orgSlug);
const removeMember = useRemoveMember(orgSlug);
const [errorByUser, setErrorByUser] = useState<Record<string, string>>({});

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 (
<div className="space-y-2">
{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 (
<div
key={m.membershipId}
Expand All @@ -154,18 +154,14 @@ function MembersTable({
<div className="w-40">
<Select
value={m.role}
onValueChange={(v) =>
handleRoleChange(m.userId, v as OrgRole)
}
disabled={
updateRole.isPending || ownerLocked || isLastAdmin
}
onValueChange={(v) => handleRoleChange(m.userId, v as OrgRole)}
disabled={updateRole.isPending || ownerLocked || isLastAdmin}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(canChangeToOwner || m.role === 'OWNER') && (
{(canChangeToOwner || m.role === "OWNER") && (
<SelectItem value="OWNER">Owner</SelectItem>
)}
<SelectItem value="MANAGER">Manager</SelectItem>
Expand All @@ -180,37 +176,34 @@ function MembersTable({
size="sm"
onClick={() => handleRemove(m.userId)}
disabled={
removeMember.isPending ||
isSelf ||
ownerLocked ||
isLastAdmin
removeMember.isPending || isSelf || ownerLocked || isLastAdmin
}
>
제거
</Button>
</div>
)
);
})}
</div>
)
);
}

function MembersPageContent({ orgSlug }: { orgSlug: string }) {
const { data: session } = useSession()
const orgs = useOrgs()
const members = useMembers(orgSlug)
const { data: session } = useSession();
const orgs = useOrgs();
const members = useMembers(orgSlug);

const currentOrg = orgs.data?.orgs.find((o) => o.slug === orgSlug)
const currentRole = (currentOrg?.role ?? 'MEMBER') as OrgRole
const canManage = currentRole === 'OWNER' || currentRole === 'MANAGER'
const currentOrg = orgs.data?.orgs.find((o) => o.slug === orgSlug);
const currentRole = (currentOrg?.role ?? "MEMBER") as OrgRole;
const canManage = currentRole === "OWNER" || currentRole === "MANAGER";

if (orgs.isLoading || members.isLoading) {
return (
<div className="max-w-3xl space-y-4">
<Skeleton className="h-8 w-32" />
<Skeleton className="h-48" />
</div>
)
);
}

if (!canManage) {
Expand All @@ -223,15 +216,15 @@ function MembersPageContent({ orgSlug }: { orgSlug: string }) {
</AlertDescription>
</Alert>
</div>
)
);
}

const membersList = members.data?.members ?? []
const membersList = members.data?.members ?? [];
// session.user에는 email만 있을 수 있어 id는 JWT에서 가져옴. 클라이언트 단에서는
// argosToken을 디코딩하지 않고, 멤버 리스트에서 본인의 email 매칭으로 찾는다.
const selfEmail = session?.user?.email
const selfEmail = session?.user?.email;
const currentUserId =
membersList.find((m) => m.email === selfEmail)?.userId ?? ''
membersList.find((m) => m.email === selfEmail)?.userId ?? "";

return (
<div className="max-w-3xl space-y-6">
Expand Down Expand Up @@ -288,14 +281,14 @@ function MembersPageContent({ orgSlug }: { orgSlug: string }) {
</CardContent>
</Card>
</div>
)
);
}

export default function MembersPage({
params,
}: {
params: Promise<{ orgSlug: string }>
params: Promise<{ orgSlug: string }>;
}) {
const { orgSlug } = use(params)
return <MembersPageContent orgSlug={orgSlug} />
const { orgSlug } = use(params);
return <MembersPageContent orgSlug={orgSlug} />;
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
import Link from 'next/link'
import { Wand2, ArrowUpRight } from 'lucide-react'
import { ChartCard } from '@/components/dashboard/chart-card'
import { StatList, StatListRow } from '@/components/dashboard/stat-list'
import type { WeeklyInsights } from '@/types/reports'
import Link from "next/link";
import { useMemo } from "react";
import { Wand2, ArrowUpRight } from "lucide-react";
import { ChartCard } from "@/components/dashboard/chart-card";
import { StatList, StatListRow } from "@/components/dashboard/stat-list";
import type { WeeklyInsights } from "@/types/reports";

interface DelegationInsightProps {
insight: WeeklyInsights['delegation']
orgSlug: string
insight: WeeklyInsights["delegation"];
orgSlug: string;
}

export function DelegationInsight({ insight, orgSlug }: DelegationInsightProps) {
export function DelegationInsight({
insight,
orgSlug,
}: DelegationInsightProps) {
const maxCount = useMemo(
() => insight.topAgents.reduce((max, a) => Math.max(max, a.callCount), 1),
[insight.topAgents],
);
Comment on lines +17 to +20

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Bounded lists weaken the performance case

The weekly agent list holds at most ten entries; overview users holds five. Neither spread-based maximum reaches argument limits, and memoization adds bookkeeping for these small reductions.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.


if (insight.taskCount === 0) {
return (
<ChartCard
Expand All @@ -25,11 +34,9 @@ export function DelegationInsight({ insight, orgSlug }: DelegationInsightProps)
이번 주 위임 활용 없음
</p>
</ChartCard>
)
);
}

const maxCount = Math.max(1, ...insight.topAgents.map((a) => a.callCount))

return (
<ChartCard
title={
Expand All @@ -56,7 +63,9 @@ export function DelegationInsight({ insight, orgSlug }: DelegationInsightProps)
)}
{insight.sampleSessionIds.length > 0 && (
<div className="pt-2 border-t border-border">
<div className="text-xs text-muted-foreground mb-2">효과적 위임 사례</div>
<div className="text-xs text-muted-foreground mb-2">
효과적 위임 사례
</div>
<div className="flex flex-wrap gap-2">
{insight.sampleSessionIds.map((sid) => (
<Link
Expand All @@ -73,5 +82,5 @@ export function DelegationInsight({ insight, orgSlug }: DelegationInsightProps)
)}
</div>
</ChartCard>
)
);
}
Loading
Loading