1
0
Fork 0

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
This commit is contained in:
Jason Fraley 2026-08-11 23:25:38 -04:00
parent d4ff6ba1a1
commit 9bf16980cc
2 changed files with 57 additions and 105 deletions

View file

@ -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 (

View file

@ -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<string, number> = {
division: 0,
@ -29,93 +23,59 @@ const TYPE_LABELS: Record<string, string> = {
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<string, RosterAssignment[]>();
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<number, number | null>();
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<number, TreeNode>();
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<number>();
const visited = new Set<number>();
const inStack = new Set<number>();
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<number> => {
const ids = new Set<number>();
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 (
<div
className={cn(
"border rounded-lg bg-muted/20 border-l-4 p-3 w-full min-w-56 max-w-xs",
"border rounded-lg bg-muted/20 border-l-4 p-3 w-full min-w-56 max-w-lg",
borderClass,
)}
>
@ -243,7 +197,7 @@ function UnitBox({
</span>
)}
<span className="text-[10px] text-muted-foreground ml-auto shrink-0">
{memberCount}
{memberCount} {pluralize(memberCount, "member")}
</span>
</div>
@ -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 (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-1.5">
@ -407,9 +359,7 @@ function MemberCard({ member }: { member: RosterMember }) {
/>
)}
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm font-medium truncate">
{member.user.displayName}
</span>
<span className="text-sm font-medium truncate">{member.user.displayName}</span>
<span className="text-[10px] text-muted-foreground font-mono truncate">
{member.rank.abbreviation} · @{member.user.username}
</span>
@ -422,9 +372,7 @@ function MemberCard({ member }: { member: RosterMember }) {
)}
{member.assignmentName && (
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded font-mono hidden sm:block">
{member.assignmentCallsign
? `[${member.assignmentCallsign}]`
: member.assignmentName}
{member.assignmentCallsign ? `[${member.assignmentCallsign}]` : member.assignmentName}
</span>
)}
</div>