1
0
Fork 0

feat(roster): enhance member data with enlistment date and experience points, add seniority view

This commit is contained in:
Jason Fraley 2026-09-02 23:32:57 -04:00
parent 90195286c3
commit fe38054606
2 changed files with 151 additions and 6 deletions

View file

@ -13,7 +13,9 @@ export const metadata = {
export type RosterMember = {
profileId: number;
user: Pick<User, "id" | "displayName" | "username">;
rank: Pick<Rank, "id" | "name" | "abbreviation"> & {
enlistmentDate: string | null;
xp: number;
rank: Pick<Rank, "id" | "name" | "abbreviation" | "_order"> & {
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,

View file

@ -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 (
<div className="flex flex-col gap-4">
@ -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 (
<div className="flex flex-col items-center">
@ -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<number | "unknown", RosterMember[]>();
for (const member of sorted) {
const years = completedServiceYears(member.enlistmentDate);
const group = years === null ? "unknown" : years;
groups.set(group, [...(groups.get(group) ?? []), member]);
}
return (
<div className="flex flex-col gap-4">
<div>
<h3 className="flex items-center gap-2 text-sm font-semibold">
<CalendarDaysIcon className="size-4 text-muted-foreground" />
Seniority by Years of Service
</h3>
<p className="mt-1 text-xs text-muted-foreground">Longest-serving members appear first.</p>
</div>
{groups.size > 0 ? (
<div className="flex flex-col gap-5">
{[...groups.entries()].map(([years, groupMembers]) => (
<section key={years} className="flex flex-col gap-2">
<div className="flex items-baseline gap-2 border-b border-border/60 pb-1">
<h4 className="text-xs font-semibold uppercase tracking-wider text-foreground">
{years === 0
? "Less than a year of service"
: years === "unknown"
? "Service history unavailable"
: `${years} ${years === 1 ? "year" : "years"} of service`}
</h4>
<span className="text-[10px] font-mono text-muted-foreground">
{groupMembers.length}
</span>
</div>
<div className="flex flex-col gap-1">
{groupMembers.map((member, index) => (
<div
key={member.profileId}
className={cn("rounded-md p-0.5", index % 2 === 0 && "bg-muted/20")}
>
<MemberCard member={member} />
</div>
))}
</div>
</section>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No roster members found.</p>
)}
</div>
);
}
// ─── 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 (
<Link
href={`/profile/${member.user.username}`}
className="flex items-center gap-2.5 rounded-md border border-border bg-muted/30 px-3 py-2 hover:bg-accent hover:border-accent-foreground/20 transition-colors"
className="relative flex items-center gap-2.5 rounded-md border border-border bg-muted/30 px-3 py-2 hover:bg-accent hover:border-accent-foreground/20 transition-colors"
>
{serviceLabel && (
<span className="absolute top-1 right-2 text-[9px] leading-none text-muted-foreground/70 font-mono pointer-events-none">
{serviceLabel} of service
</span>
)}
{member.rank.insigniaUrl && (
<img
src={member.rank.insigniaUrl}
@ -417,6 +543,10 @@ export function RosterView({
<ListIcon className="size-3" />
A–Z
</TabsTrigger>
<TabsTrigger value="seniority" className="gap-1.5">
<CalendarDaysIcon className="size-3" />
Years of Service
</TabsTrigger>
</TabsList>
<TabsContent value="org" className="overflow-x-scroll">
@ -431,6 +561,9 @@ export function RosterView({
<TabsContent value="alpha">
<AlphabeticalView members={members} />
</TabsContent>
<TabsContent value="seniority">
<SeniorityView members={members} />
</TabsContent>
</Tabs>
</RosterDisplayContext.Provider>
);