From 9bf16980cc808f448acfd65ffba5e8bb57553970 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Tue, 11 Aug 2026 23:25:38 -0400 Subject: [PATCH] feat(roster): build org chart from explicit subassignment hierarchy - Resolve parents via subAssignments instead of inferred member overlap - Drop cyclic or dangling subassignment edges - Render members only at their deepest unit; widen unit boxes and pluralize member counts --- src/app/(frontend)/roster/page.tsx | 4 + src/components/frontend/roster/RosterView.tsx | 158 ++++++------------ 2 files changed, 57 insertions(+), 105 deletions(-) diff --git a/src/app/(frontend)/roster/page.tsx b/src/app/(frontend)/roster/page.tsx index 56f1653..3f89653 100644 --- a/src/app/(frontend)/roster/page.tsx +++ b/src/app/(frontend)/roster/page.tsx @@ -27,6 +27,7 @@ export type RosterAssignment = { callsign?: string; leaderId?: number; memberIds: number[]; + subAssignmentIds: number[]; }; export default async function RosterPage() { @@ -111,6 +112,9 @@ export default async function RosterPage() { memberIds: (a.members ?? []) .map((m) => (typeof m === "object" ? m.id : m)) .filter(Boolean) as number[], + subAssignmentIds: (a.subAssignments ?? []) + .map((s) => (typeof s === "object" ? s.id : s)) + .filter(Boolean) as number[], })); return ( diff --git a/src/components/frontend/roster/RosterView.tsx b/src/components/frontend/roster/RosterView.tsx index d1d7425..876bcdb 100644 --- a/src/components/frontend/roster/RosterView.tsx +++ b/src/components/frontend/roster/RosterView.tsx @@ -1,16 +1,10 @@ "use client"; import Link from "next/link"; -import { cn } from "@/lib/utils"; +import { cn, pluralize } from "@/lib/utils"; import type { RosterMember, RosterAssignment } from "@/app/(frontend)/roster/page"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { - NetworkIcon, - Building2Icon, - ShieldIcon, - ListIcon, - CrownIcon, -} from "lucide-react"; +import { NetworkIcon, Building2Icon, ShieldIcon, ListIcon, CrownIcon } from "lucide-react"; const TYPE_ORDER: Record = { division: 0, @@ -29,93 +23,59 @@ const TYPE_LABELS: Record = { type TreeNode = RosterAssignment & { children: TreeNode[]; members: RosterMember[] }; function buildTree(assignments: RosterAssignment[], members: RosterMember[]): TreeNode[] { - // Group assignments by type for parent resolution - const byType = new Map(); - for (const a of assignments) { - const list = byType.get(a.type) ?? []; - list.push(a); - byType.set(a.type, list); - } - - // Sort by type depth so parents are processed before children - const sorted = [...assignments].sort( - (a, b) => (TYPE_ORDER[a.type] ?? 99) - (TYPE_ORDER[b.type] ?? 99), - ); - - // Resolve parent for each assignment - const parentMap = new Map(); - for (const a of sorted) { - const typeIdx = TYPE_ORDER[a.type] ?? 99; - if (typeIdx === 0) { - parentMap.set(a.id, null); - continue; - } - - let found = false; - for (let t = typeIdx - 1; t >= 0; t--) { - const typeName = Object.keys(TYPE_ORDER).find((k) => TYPE_ORDER[k] === t); - const candidates = typeName ? byType.get(typeName) : undefined; - if (!candidates) continue; - - for (const parent of candidates) { - const parentAllIds = new Set( - [...parent.memberIds, parent.leaderId].filter(Boolean), - ); - const thisAllIds = new Set( - [...a.memberIds, a.leaderId].filter(Boolean), - ); - const overlap = [...thisAllIds].some((id) => parentAllIds.has(id)); - if (overlap) { - parentMap.set(a.id, parent.id); - found = true; - break; - } - } - if (found) break; - } - if (!found) { - parentMap.set(a.id, null); - } - } - - // Build tree nodes — each node only holds direct members (not members of children) const nodeMap = new Map(); for (const a of assignments) { - nodeMap.set(a.id, { - ...a, - children: [], - members: members.filter((m) => { - const inThisAssignment = - a.memberIds.includes(m.user.id) || a.leaderId === m.user.id; - const inChildAssignment = assignments.some( - (other) => - parentMap.get(other.id) === a.id && - (other.memberIds.includes(m.user.id) || other.leaderId === m.user.id), - ); - return inThisAssignment && !inChildAssignment; - }), - }); + nodeMap.set(a.id, { ...a, children: [], members: [] }); } - // Wire parent-child relationships - for (const [childId, parentId] of parentMap) { - if (parentId != null) { - const parent = nodeMap.get(parentId); - const child = nodeMap.get(childId); - if (parent && child) parent.children.push(child); + // Wire the explicit hierarchy from subAssignments. Drop any edge that + // would create a cycle (a unit cannot contain itself) or that points at + // an assignment that wasn't fetched. + const childIds = new Set(); + const visited = new Set(); + const inStack = new Set(); + const wire = (node: TreeNode): void => { + if (visited.has(node.id) || inStack.has(node.id)) return; + inStack.add(node.id); + for (const subId of node.subAssignmentIds ?? []) { + const child = nodeMap.get(subId); + if (!child || inStack.has(child.id)) continue; + node.children.push(child); + childIds.add(child.id); + wire(child); } - } + inStack.delete(node.id); + visited.add(node.id); + }; + for (const node of nodeMap.values()) wire(node); - // Collect root nodes (no parent) - const roots: TreeNode[] = []; - for (const [id, parentId] of parentMap) { - if (parentId == null) { - const node = nodeMap.get(id); - if (node) roots.push(node); + const roots = assignments.filter((a) => !childIds.has(a.id)).map((a) => nodeMap.get(a.id)!); + + // Each node only holds the members/leader it claims directly — anyone a + // descendant also claims renders at the deepest unit only. + const descendantClaimed = (node: TreeNode): Set => { + const ids = new Set(); + for (const child of node.children) { + for (const id of [...child.memberIds, child.leaderId]) { + if (id != null) ids.add(id); + } + for (const id of descendantClaimed(child)) ids.add(id); } + return ids; + }; + for (const a of assignments) { + const node = nodeMap.get(a.id)!; + const claimed = descendantClaimed(node); + node.members = members.filter( + (m) => + (a.memberIds.includes(m.user.id) || a.leaderId === m.user.id) && !claimed.has(m.user.id), + ); } - return roots.sort((a, b) => (TYPE_ORDER[a.type] ?? 99) - (TYPE_ORDER[b.type] ?? 99)); + const byOrder = (x: TreeNode, y: TreeNode) => + (TYPE_ORDER[x.type] ?? 99) - (TYPE_ORDER[y.type] ?? 99) || x.name.localeCompare(y.name); + for (const node of nodeMap.values()) node.children.sort(byOrder); + return roots.sort(byOrder); } function collectAllMembers(node: TreeNode): RosterMember[] { @@ -173,9 +133,7 @@ function OrgChartView({ function OrgChartNode({ node, depth }: { node: TreeNode; depth: number }) { const allMembers = collectAllMembers(node); - const leader = node.leaderId - ? allMembers.find((m) => m.user.id === node.leaderId) - : null; + const leader = node.leaderId ? allMembers.find((m) => m.user.id === node.leaderId) : null; const directMembers = node.members.filter((m) => m.user.id !== node.leaderId); return ( @@ -217,17 +175,13 @@ function UnitBox({ directMembers: RosterMember[]; memberCount: number; }) { - const depthBorders = [ - "border-l-blue-600", - "border-l-amber-600", - "border-l-emerald-600", - ]; + const depthBorders = ["border-l-blue-600", "border-l-amber-600", "border-l-emerald-600"]; const borderClass = depthBorders[depth] ?? "border-l-zinc-600"; return (
@@ -243,7 +197,7 @@ function UnitBox({ )} - {memberCount} + {memberCount} {pluralize(memberCount, "member")}
@@ -378,9 +332,7 @@ function ByRankView({ members }: { members: RosterMember[] }) { // ─── Alphabetical ─────────────────────────────────────────────────── function AlphabeticalView({ members }: { members: RosterMember[] }) { - const sorted = [...members].sort((a, b) => - a.user.displayName.localeCompare(b.user.displayName), - ); + const sorted = [...members].sort((a, b) => a.user.displayName.localeCompare(b.user.displayName)); return (
@@ -407,9 +359,7 @@ function MemberCard({ member }: { member: RosterMember }) { /> )}
- - {member.user.displayName} - + {member.user.displayName} {member.rank.abbreviation} · @{member.user.username} @@ -422,9 +372,7 @@ function MemberCard({ member }: { member: RosterMember }) { )} {member.assignmentName && ( - {member.assignmentCallsign - ? `[${member.assignmentCallsign}]` - : member.assignmentName} + {member.assignmentCallsign ? `[${member.assignmentCallsign}]` : member.assignmentName} )}