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
20 changes: 14 additions & 6 deletions src/components/widgets/v2/dashboard-header.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { ReactNode } from "react";

import { IcViewToggle } from "@/components/ic-view-toggle";
import { PeriodSelectorBar } from "@/components/widgets/period-selector-bar";
import { SidebarTrigger } from "@/components/ui/sidebar";
Expand All @@ -8,13 +10,16 @@ export interface DashboardHeaderProps {
subtitle?: string | null;
person: string;
hasReports: boolean;
/** Extra screen-specific controls, rendered before the period selector. */
actions?: ReactNode;
}

export function DashboardHeader({
title,
subtitle,
person,
hasReports,
actions,
}: DashboardHeaderProps) {
const { period, customRange, setPeriod, setCustomRange } = usePeriod();

Expand All @@ -30,12 +35,15 @@ export function DashboardHeader({
</div>
<IcViewToggle person={person} hasReports={hasReports} />
</div>
<PeriodSelectorBar
period={period}
customRange={customRange}
onPeriodChange={setPeriod}
onRangeChange={setCustomRange}
/>
<div className="flex flex-wrap items-center gap-2">
{actions}
<PeriodSelectorBar
period={period}
customRange={customRange}
onPeriodChange={setPeriod}
onRangeChange={setCustomRange}
/>
</div>
</header>
);
}
65 changes: 65 additions & 0 deletions src/lib/insight/identity-tree.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Roster derivation from the identity tree (#1724).
*
* `flattenSubordinates` marks depth-1 reports `is_direct`;
* `scopeRosterToDirectReports` narrows a roster to those entries when the
* "Direct reports only" toggle is on, and passes `null` through so screens
* keep their roster-not-loaded gate.
*/

import { describe, expect, it } from "vitest";

import type { IdentityPerson } from "@/types/insight";
import {
flattenSubordinates,
scopeRosterToDirectReports,
type RosterEntry,
} from "./identity-tree";

function person(
email: string,
subordinates: IdentityPerson[] = [],
): IdentityPerson {
return {
person_id: email,
email,
display_name: email.split("@")[0]!,
subordinates,
} as IdentityPerson;
}

const pivot = person("alice@x.io", [
person("bob@x.io", [person("carol@x.io"), person("dave@x.io")]),
person("erin@x.io"),
]);

describe("flattenSubordinates", () => {
it("marks only depth-1 reports as direct", () => {
const roster = flattenSubordinates(pivot);
expect(roster.map((r) => [r.email, r.is_direct])).toEqual([
["bob@x.io", true],
["carol@x.io", false],
["dave@x.io", false],
["erin@x.io", true],
]);
});
});

describe("scopeRosterToDirectReports", () => {
const roster: RosterEntry[] = flattenSubordinates(pivot);

it("keeps only direct reports when scoping is on", () => {
expect(
scopeRosterToDirectReports(roster, true)?.map((r) => r.email),
).toEqual(["bob@x.io", "erin@x.io"]);
});

it("returns the roster unchanged when scoping is off", () => {
expect(scopeRosterToDirectReports(roster, false)).toBe(roster);
});

it("passes null through regardless of the toggle", () => {
expect(scopeRosterToDirectReports(null, true)).toBeNull();
expect(scopeRosterToDirectReports(null, false)).toBeNull();
});
});
14 changes: 14 additions & 0 deletions src/lib/insight/identity-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,17 @@ export function flattenSubordinates(pivot: IdentityPerson): RosterEntry[] {
walk(pivot, pivot.email, true);
return out;
}

/**
* Narrow a roster to the pivot's direct reports when `directOnly` is set.
*
* `null` passes through unchanged so callers keep their "roster not loaded
* yet" gate regardless of the toggle state.
*/
export function scopeRosterToDirectReports(
roster: RosterEntry[] | null,
directOnly: boolean,
): RosterEntry[] | null {
if (!roster || !directOnly) return roster;
return roster.filter((r) => r.is_direct);
}
189 changes: 189 additions & 0 deletions src/screens/team-view-v2.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/**
* "Direct reports only" scoping on the v2 team dashboard (#1724).
*
* The toggle (default ON, matching the old team view) narrows the roster to
* depth-1 reports before it reaches any query, so members, heatmap bullets,
* legacy sections, and metric collections all scope together. Covers:
* - default render shows direct reports only, with the scope subtitle
* ("Direct reports of X") and the scoped/total count on the toggle.
* - toggling off widens the roster to the full subtree and flips the
* subtitle to "X's department".
* - the scoped roster is what `useTeamMembers` receives — scoping happens
* upstream of the fetch, not as a client-side row filter.
*
* Child widgets are stubbed: this file tests the roster/toggle wiring, not
* widget render rules (those have their own component tests).
*/

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";

import type { RosterEntry } from "@/lib/insight/identity-tree";
import type {
IdentityPerson,
PeriodValue,
TeamMember,
} from "@/types/insight";

vi.mock("@/api/use-catalog", () => ({
useCatalog: () => ({
data: undefined,
isLoading: false,
isError: false,
byId: () => undefined,
byMetricKey: () => undefined,
refetch: () => {},
}),
}));

vi.mock("@/components/ic-view-toggle", () => ({
IcViewToggle: () => null,
}));
vi.mock("@/components/ui/sidebar", () => ({
SidebarTrigger: () => null,
}));
vi.mock("@/components/widgets/period-selector-bar", () => ({
PeriodSelectorBar: () => null,
}));

vi.mock("@/components/widgets/v2/team-members-attention", () => ({
TeamMembersAttention: () => null,
}));
vi.mock("@/components/widgets/v2/members-heatmap", () => ({
MembersHeatmap: ({ members }: { members: TeamMember[] }) => (
<div data-testid="heatmap">{members.map((m) => m.name).join(",")}</div>
),
}));
vi.mock("@/components/widgets/v2/section-card", () => ({
SectionCard: () => null,
}));
vi.mock("@/components/widgets/metric-views/team-metric-group-card", () => ({
TeamMetricGroupCard: () => null,
}));
vi.mock("@/components/widgets/v2/group-drilldown-sheet", () => ({
GroupDrilldownSheet: () => null,
}));

const queryState = {
isPending: false,
isFetching: false,
isError: false,
refetch: () => {},
};

function makeMember(entry: RosterEntry): TeamMember {
return {
person_id: entry.email,
period: "month" as PeriodValue,
name: entry.display_name,
seniority: "",
supervisor_email: entry.supervisor_email,
org_unit_id: null,
tasks_closed: 0,
bugs_fixed: 0,
dev_time_h: null,
prs_merged: null,
build_success_pct: null,
focus_time_pct: null,
ai_tools: [],
ai_loc_share_pct: null,
};
}

const useTeamMembers = vi.fn(
(_teamId: string, roster: RosterEntry[] | null) => ({
...queryState,
data: (roster ?? []).map(makeMember),
}),
);

vi.mock("@/queries/team-view", () => ({
useTeamMembers: (
...args: [string, RosterEntry[] | null, PeriodValue, unknown]
) => useTeamMembers(args[0], args[1]),
useTeamBulletSections: () => ({
...queryState,
data: { bySection: {}, errors: {} },
}),
}));

vi.mock("@/queries/v2/team-extras", () => ({
useTeamMemberBullets: () => ({ ...queryState, data: undefined }),
useTeamMemberBulletsPrevious: () => ({ ...queryState, data: undefined }),
useDeptDistributions: () => ({ ...queryState, data: undefined }),
}));

vi.mock("@/queries/metric-results", () => ({
useMetricCollectionSet: () => new Map(),
}));

function person(
email: string,
name: string,
subordinates: IdentityPerson[] = [],
): IdentityPerson {
return {
person_id: email,
email,
display_name: name,
subordinates,
} as IdentityPerson;
}

// Alice manages Bob and Erin directly; Carol reports to Bob (indirect).
const viewerTree = person("alice@x.io", "Alice", [
person("bob@x.io", "Bob", [person("carol@x.io", "Carol")]),
person("erin@x.io", "Erin"),
]);

vi.mock("@/queries/ic-dashboard", () => ({
useIcPerson: () => ({ ...queryState, data: viewerTree }),
}));

import { TeamViewV2Screen } from "./team-view-v2";

function renderScreen() {
return render(
<TeamViewV2Screen teamId="alice@x.io" viewerEmail="alice@x.io" />,
);
}

describe("TeamViewV2Screen direct-reports scoping", () => {
it("defaults to direct reports only, scoping the roster before the fetch", () => {
renderScreen();

expect(
screen.getByText("Direct reports of Alice · 2 members"),
).toBeInTheDocument();
expect(screen.getByText("Direct reports only")).toBeInTheDocument();
expect(screen.getByText("(2/3)")).toBeInTheDocument();
expect(screen.getByTestId("heatmap")).toHaveTextContent("Bob,Erin");

const lastRoster = useTeamMembers.mock.lastCall?.[1];
expect(lastRoster?.map((r) => r.email)).toEqual([
"bob@x.io",
"erin@x.io",
]);
});

it("widens to the whole department when toggled off", async () => {
const user = userEvent.setup();
renderScreen();

await user.click(screen.getByRole("switch"));

expect(
screen.getByText("Alice's department · 3 members"),
).toBeInTheDocument();
expect(screen.getByText("(3/3)")).toBeInTheDocument();
expect(screen.getByTestId("heatmap")).toHaveTextContent("Bob,Carol,Erin");

const lastRoster = useTeamMembers.mock.lastCall?.[1];
expect(lastRoster?.map((r) => r.email)).toEqual([
"bob@x.io",
"carol@x.io",
"erin@x.io",
]);
});
});
34 changes: 32 additions & 2 deletions src/screens/team-view-v2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ import { TeamMembersAttention } from "@/components/widgets/v2/team-members-atten
import { TeamMetricGroupCard } from "@/components/widgets/metric-views/team-metric-group-card";
import type { TeamMemberRef } from "@/components/widgets/metric-views/team-collection-drilldown";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { useCatalog } from "@/api/use-catalog";
import { usePeriod } from "@/hooks/use-period";
import {
flattenSubordinates,
findIdentityNode,
scopeRosterToDirectReports,
} from "@/lib/insight/identity-tree";
import {
GROUPS,
Expand Down Expand Up @@ -61,6 +63,7 @@ export function TeamViewV2Screen({ teamId, viewerEmail }: TeamViewV2ScreenProps)
const { period, dateRange, setPeriod } = usePeriod();
const { byMetricKey } = useCatalog();
const [openGroup, setOpenGroup] = useState<GroupId | null>(null);
const [directReportsOnly, setDirectReportsOnly] = useState(true);

// Close any open drilldown when the viewed team changes. Render-phase
// reset against the previous id rather than an effect (no cascading commit).
Expand All @@ -79,10 +82,16 @@ export function TeamViewV2Screen({ teamId, viewerEmail }: TeamViewV2ScreenProps)
return null;
}, [viewerTree, teamId]);

const roster = useMemo(
const fullRoster = useMemo(
() => (pivot ? flattenSubordinates(pivot) : null),
[pivot],
);
// Scoping the roster scopes everything downstream — members, heatmap
// bullets, legacy sections, and metric collections all derive from it.
const roster = useMemo(
() => scopeRosterToDirectReports(fullRoster, directReportsOnly),
[fullRoster, directReportsOnly],
);
const teamName = pivot?.display_name ?? teamId;
const teamSize = roster?.length;

Expand Down Expand Up @@ -176,13 +185,34 @@ export function TeamViewV2Screen({ teamId, viewerEmail }: TeamViewV2ScreenProps)
const showFullSpinner =
sectionsPending || membersQ.isPending || (isAllEmpty && isFetching);

const memberCountLabel = `${members.length} member${members.length === 1 ? "" : "s"}`;
const scopeLabel = directReportsOnly
? `Direct reports of ${teamName}`
: `${teamName}'s department`;

return (
<div className="flex flex-col">
<DashboardHeader
title={`Team of ${teamName}`}
subtitle={`${members.length} member${members.length === 1 ? "" : "s"}`}
subtitle={
fullRoster ? `${scopeLabel} · ${memberCountLabel}` : memberCountLabel
}
person={teamId}
hasReports
actions={
fullRoster ? (
<label className="text-foreground flex cursor-pointer items-center gap-2 text-sm select-none">
<Switch
checked={directReportsOnly}
onCheckedChange={setDirectReportsOnly}
/>
<span>Direct reports only</span>
<span className="text-muted-foreground text-xs">
({roster?.length ?? 0}/{fullRoster.length})
</span>
</label>
) : null
}
/>
<main className="flex flex-1 flex-col gap-8 p-4 md:p-6">
{showFullSpinner ? (
Expand Down