From fe3805460637995ae7d4fd3453e969d04ac6d3f8 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Wed, 2 Sep 2026 23:32:57 -0400 Subject: [PATCH] feat(roster): enhance member data with enlistment date and experience points, add seniority view --- src/app/(frontend)/roster/page.tsx | 16 +- src/components/frontend/roster/RosterView.tsx | 141 +++++++++++++++++- 2 files changed, 151 insertions(+), 6 deletions(-) diff --git a/src/app/(frontend)/roster/page.tsx b/src/app/(frontend)/roster/page.tsx index 65a62ec..d568fa2 100644 --- a/src/app/(frontend)/roster/page.tsx +++ b/src/app/(frontend)/roster/page.tsx @@ -13,7 +13,9 @@ export const metadata = { export type RosterMember = { profileId: number; user: Pick; - rank: Pick & { + enlistmentDate: string | null; + xp: number; + rank: Pick & { insigniaUrl?: string | null; }; assignmentName?: string; @@ -46,6 +48,8 @@ export default async function RosterPage() { select: { user: true, rank: true, + dossier: true, + progression: true, }, }), payload.find({ @@ -97,7 +101,15 @@ export default async function RosterPage() { return { profileId: profile.id, user: { id: user.id, displayName: user.displayName, username: user.username }, - rank: { id: rank.id, name: rank.name, abbreviation: rank.abbreviation, insigniaUrl }, + enlistmentDate: profile.dossier?.enlistmentDate ?? null, + xp: profile.progression?.xp ?? 0, + rank: { + id: rank.id, + name: rank.name, + abbreviation: rank.abbreviation, + _order: rank._order, + insigniaUrl, + }, assignmentName, assignmentType, assignmentCallsign, diff --git a/src/components/frontend/roster/RosterView.tsx b/src/components/frontend/roster/RosterView.tsx index 59f07f3..38b3c98 100644 --- a/src/components/frontend/roster/RosterView.tsx +++ b/src/components/frontend/roster/RosterView.tsx @@ -5,7 +5,14 @@ import Link from "next/link"; import { cn, pluralize } from "@/lib/utils"; import type { RosterAssignment, RosterMember } from "@/app/(frontend)/roster/page"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Building2Icon, CrownIcon, ListIcon, NetworkIcon, ShieldIcon } from "lucide-react"; +import { + Building2Icon, + CalendarDaysIcon, + CrownIcon, + ListIcon, + NetworkIcon, + ShieldIcon, +} from "lucide-react"; const RosterDisplayContext = React.createContext<"lead" | "actual">("lead"); @@ -102,7 +109,7 @@ function OrgChartView({ const assignedIds = new Set( assignments.flatMap((a) => [...a.memberIds, a.leaderId]).filter(Boolean), ); - const unassigned = members.filter((m) => !assignedIds.has(m.user.id)); + const unassigned = members.filter((m) => !assignedIds.has(m.user.id)).sort(rosterMemberOrder); return (
@@ -137,7 +144,9 @@ 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 directMembers = node.members.filter((m) => m.user.id !== node.leaderId); + const directMembers = node.members + .filter((m) => m.user.id !== node.leaderId) + .sort(rosterMemberOrder); return (
@@ -346,17 +355,134 @@ function AlphabeticalView({ members }: { members: RosterMember[] }) { ); } +function completedServiceYears(enlistmentDate: string | null): number | null { + if (!enlistmentDate) return null; + + const enlistment = new Date(enlistmentDate); + if (Number.isNaN(enlistment.getTime())) return null; + + const now = new Date(); + let years = now.getUTCFullYear() - enlistment.getUTCFullYear(); + const anniversaryMonth = enlistment.getUTCMonth(); + const anniversaryDay = enlistment.getUTCDate(); + const anniversaryPassed = + now.getUTCMonth() > anniversaryMonth || + (now.getUTCMonth() === anniversaryMonth && now.getUTCDate() >= anniversaryDay); + + if (!anniversaryPassed) years -= 1; + return Math.max(0, years); +} + +function rosterMemberOrder(a: RosterMember, b: RosterMember): number { + const aRankOrder = a.rank._order ?? null; + const bRankOrder = b.rank._order ?? null; + const aMissingRank = aRankOrder === null; + const bMissingRank = bRankOrder === null; + + if (aMissingRank && !bMissingRank) return 1; + if (!aMissingRank && bMissingRank) return -1; + if (aRankOrder !== null && bRankOrder !== null && aRankOrder !== bRankOrder) { + return bRankOrder.localeCompare(aRankOrder); + } + + const aYears = completedServiceYears(a.enlistmentDate); + const bYears = completedServiceYears(b.enlistmentDate); + if (aYears === null && bYears !== null) return 1; + if (aYears !== null && bYears === null) return -1; + if (aYears !== null && bYears !== null && aYears !== bYears) return bYears - aYears; + + if (a.xp !== b.xp) return b.xp - a.xp; + return a.user.displayName.localeCompare(b.user.displayName); +} + +function SeniorityView({ members }: { members: RosterMember[] }) { + const sorted = [...members].sort((a, b) => { + const aTime = a.enlistmentDate ? new Date(a.enlistmentDate).getTime() : Number.NaN; + const bTime = b.enlistmentDate ? new Date(b.enlistmentDate).getTime() : Number.NaN; + const aMissing = Number.isNaN(aTime); + const bMissing = Number.isNaN(bTime); + + if (aMissing && !bMissing) return 1; + if (!aMissing && bMissing) return -1; + if (!aMissing && !bMissing && aTime !== bTime) return aTime - bTime; + return a.user.displayName.localeCompare(b.user.displayName); + }); + const groups = new Map(); + for (const member of sorted) { + const years = completedServiceYears(member.enlistmentDate); + const group = years === null ? "unknown" : years; + groups.set(group, [...(groups.get(group) ?? []), member]); + } + + return ( +
+
+

+ + Seniority by Years of Service +

+

Longest-serving members appear first.

+
+ + {groups.size > 0 ? ( +
+ {[...groups.entries()].map(([years, groupMembers]) => ( +
+
+

+ {years === 0 + ? "Less than a year of service" + : years === "unknown" + ? "Service history unavailable" + : `${years} ${years === 1 ? "year" : "years"} of service`} +

+ + {groupMembers.length} + +
+
+ {groupMembers.map((member, index) => ( +
+ +
+ ))} +
+
+ ))} +
+ ) : ( +

No roster members found.

+ )} +
+ ); +} + // ─── Member Card ──────────────────────────────────────────────────── function MemberCard({ member }: { member: RosterMember }) { const leadOrActual = React.useContext(RosterDisplayContext); const leadLabel = leadOrActual === "actual" ? "Actual" : "Lead"; + const serviceYears = completedServiceYears(member.enlistmentDate); + const serviceLabel = + serviceYears === null + ? null + : serviceYears === 0 + ? "Less than a year" + : `${serviceYears} year${serviceYears === 1 ? "" : "s"}`; return ( + {serviceLabel && ( + + {serviceLabel} of service + + )} {member.rank.insigniaUrl && ( A–Z + + + Years of Service + @@ -431,6 +561,9 @@ export function RosterView({ + + + );