- {nonZero.map((item) => (
-
- {item.label}×{item.count}
-
- ))}
+
+ = 0 ? "text-emerald-400" : "text-coral-accent"}`}>
+ {pointChange >= 0 ? "+" : ""}{pointChange.toFixed(2)} pts
+
+ {rankChange > 0 ? (
+ ↑{rankChange} rank
+ ) : rankChange < 0 ? (
+ ↓{Math.abs(rankChange)} rank
+ ) : (
+ — rank
+ )}
);
}
diff --git a/app/components/standings/__tests__/StandingsTable.test.tsx b/app/components/standings/__tests__/StandingsTable.test.tsx
index 46bb86f..7518f55 100644
--- a/app/components/standings/__tests__/StandingsTable.test.tsx
+++ b/app/components/standings/__tests__/StandingsTable.test.tsx
@@ -9,11 +9,6 @@ function renderWithRouter(ui: React.ReactElement) {
return render(
{ui});
}
-/**
- * StandingsTable Component Tests
- * Phase 4.1: Test standings display with tiebreakers and placement breakdown
- * Phase 4.3: Added tests for clickable team links
- */
describe("StandingsTable", () => {
const mockLeagueId = "league-123";
const mockSeasonId = "season-456";
@@ -25,7 +20,7 @@ describe("StandingsTable", () => {
totalPoints: 250.5,
currentRank: 1,
previousRank: 2,
- rankChange: 1, // Moved up
+ rankChange: 1,
placementCounts: {
first: 2,
second: 1,
@@ -45,7 +40,7 @@ describe("StandingsTable", () => {
totalPoints: 245.0,
currentRank: 2,
previousRank: 1,
- rankChange: -1, // Moved down
+ rankChange: -1,
placementCounts: {
first: 1,
second: 2,
@@ -65,7 +60,7 @@ describe("StandingsTable", () => {
totalPoints: 200.0,
currentRank: 3,
previousRank: 3,
- rankChange: 0, // No change
+ rankChange: 0,
placementCounts: {
first: 0,
second: 1,
@@ -135,7 +130,6 @@ describe("StandingsTable", () => {
renderWithRouter(
);
- // Should not have emoji, just the number
const badge = screen.getByText("4");
expect(badge).toBeInTheDocument();
});
@@ -157,80 +151,59 @@ describe("StandingsTable", () => {
it("should not show indicator for no rank change", () => {
renderWithRouter(
);
- // Third team has no change, so no arrow
- const arrows = screen.queryAllByText(/↑|↓/);
- expect(arrows).toHaveLength(2); // Only first two teams
+ // Only first two teams have rank changes; use \d to avoid matching sort-header arrows
+ const arrows = screen.queryAllByText(/[↑↓]\d/);
+ expect(arrows).toHaveLength(2);
});
});
- describe("Placement Breakdown", () => {
- it("should display placement counts", () => {
- const singleTeam: TeamStanding[] = [
- {
- teamId: "team1",
- teamName: "Test Team",
- totalPoints: 250,
- currentRank: 1,
- previousRank: null,
- rankChange: 0,
- placementCounts: {
- first: 2,
- second: 1,
- third: 0,
- fourth: 1,
- fifth: 0,
- sixth: 0,
- seventh: 0,
- eighth: 1,
- },
- participantsRemaining: 0,
- calculatedAt: new Date(),
- },
- ];
+ describe("7-Day Change Column", () => {
+ it("should show dash when sevenDayPointChange is not present", () => {
+ renderWithRouter(
);
- renderWithRouter(
);
-
- // Test Team: 2 firsts, 1 second, 1 fourth, 1 eighth
- expect(screen.getByText("1st×2")).toBeInTheDocument();
- expect(screen.getByText("2nd×1")).toBeInTheDocument();
- expect(screen.getByText("4th×1")).toBeInTheDocument();
- expect(screen.getByText("8th×1")).toBeInTheDocument();
+ // All mock standings lack sevenDayPointChange, so all rows show —
+ const dashes = screen.getAllByText("—");
+ expect(dashes.length).toBeGreaterThanOrEqual(3);
});
- it("should only show non-zero placement counts", () => {
- renderWithRouter(
);
-
- // Should not show "3rd×0" for Champions United
- const placements = screen.queryByText("3rd×0");
- expect(placements).not.toBeInTheDocument();
- });
-
- it("should hide placement breakdown when disabled", () => {
- renderWithRouter(
);
-
- expect(screen.queryByText("1st×2")).not.toBeInTheDocument();
- });
-
- it("should show 'None yet' when no placements", () => {
+ it("should display positive point change in green with + prefix", () => {
const standings: TeamStanding[] = [
- {
- ...mockStandings[0],
- placementCounts: {
- first: 0,
- second: 0,
- third: 0,
- fourth: 0,
- fifth: 0,
- sixth: 0,
- seventh: 0,
- eighth: 0,
- },
- },
+ { ...mockStandings[0], sevenDayPointChange: 12.5 },
];
- renderWithRouter(
);
+ renderWithRouter(
);
- expect(screen.getByText("None yet")).toBeInTheDocument();
+ expect(screen.getByText("+12.50 pts")).toBeInTheDocument();
+ });
+
+ it("should display negative point change with no + prefix", () => {
+ const standings: TeamStanding[] = [
+ { ...mockStandings[0], sevenDayPointChange: -3.25 },
+ ];
+
+ renderWithRouter(
);
+
+ expect(screen.getByText("-3.25 pts")).toBeInTheDocument();
+ });
+
+ it("should show rank change within the 7-day column", () => {
+ const standings: TeamStanding[] = [
+ { ...mockStandings[0], sevenDayPointChange: 10, rankChange: 2 },
+ ];
+
+ renderWithRouter(
);
+
+ expect(screen.getByText("↑2 rank")).toBeInTheDocument();
+ });
+
+ it("should show no rank change indicator when rank is unchanged", () => {
+ const standings: TeamStanding[] = [
+ { ...mockStandings[0], sevenDayPointChange: 5, rankChange: 0 },
+ ];
+
+ renderWithRouter(
);
+
+ expect(screen.getByText("— rank")).toBeInTheDocument();
});
});
@@ -256,23 +229,18 @@ describe("StandingsTable", () => {
expect(screen.getByText("Rank")).toBeInTheDocument();
expect(screen.getByText("Team")).toBeInTheDocument();
expect(screen.getByText("Points")).toBeInTheDocument();
- expect(screen.getByText("Placements")).toBeInTheDocument();
+ expect(screen.getByText("7-Day Change")).toBeInTheDocument();
expect(screen.getByText("Remaining")).toBeInTheDocument();
});
- it("should render teams in order", () => {
+ it("should render teams in order by rank", () => {
const { container } = renderWithRouter(
);
const rows = container.querySelectorAll("tbody tr");
expect(rows).toHaveLength(3);
- // First row should be Champions United (rank 1)
expect(rows[0].textContent).toContain("Champions United");
-
- // Second row should be Second Place Squad (rank 2)
expect(rows[1].textContent).toContain("Second Place Squad");
-
- // Third row should be Bronze Warriors (rank 3)
expect(rows[2].textContent).toContain("Bronze Warriors");
});
});
@@ -280,30 +248,16 @@ describe("StandingsTable", () => {
describe("Tied Ranks", () => {
it("should handle teams with same rank", () => {
const tiedStandings: TeamStanding[] = [
- {
- ...mockStandings[0],
- currentRank: 1,
- teamName: "Team A",
- },
- {
- ...mockStandings[1],
- currentRank: 1,
- teamName: "Team B",
- },
- {
- ...mockStandings[2],
- currentRank: 3, // Skips 2
- teamName: "Team C",
- },
+ { ...mockStandings[0], currentRank: 1, teamName: "Team A" },
+ { ...mockStandings[1], currentRank: 1, teamName: "Team B" },
+ { ...mockStandings[2], currentRank: 3, teamName: "Team C" },
];
renderWithRouter(
);
- // Both teams should show rank 1
const firstPlaceBadges = screen.getAllByText(/🏆.*1st/);
expect(firstPlaceBadges).toHaveLength(2);
- // Third team should show rank 3 (not 2)
expect(screen.getByText(/🥉.*3rd/)).toBeInTheDocument();
});
});
@@ -320,13 +274,5 @@ describe("StandingsTable", () => {
expect(downArrow).toBeInTheDocument();
expect(downArrow?.getAttribute("title")).toContain("Down 1 place");
});
-
- it("should have title attributes for placement counts", () => {
- const { container } = renderWithRouter(
);
-
- const firstPlace = container.querySelector('[title*="1st place"]');
- expect(firstPlace).toBeInTheDocument();
- expect(firstPlace?.getAttribute("title")).toContain("2 1st place finishes");
- });
});
});
diff --git a/app/hooks/useSortableData.ts b/app/hooks/useSortableData.ts
new file mode 100644
index 0000000..9f75c3a
--- /dev/null
+++ b/app/hooks/useSortableData.ts
@@ -0,0 +1,74 @@
+import { useState, useMemo } from "react";
+
+export type SortDirection = "asc" | "desc";
+
+export interface SortConfig
{
+ key: keyof T | null;
+ direction: SortDirection;
+}
+
+type Comparators = Partial number>>;
+
+/**
+ * Generic hook for client-side sortable table data.
+ *
+ * @param data - The array of items to sort
+ * @param defaultKey - The column key to sort by initially (null = no sort)
+ * @param defaultDirection - Initial sort direction
+ * @param comparators - Optional per-key custom comparators. Receives both items and the
+ * current direction so the comparator can handle ascending/descending internally
+ * (return positive to sort a after b).
+ */
+export function useSortableData(
+ data: T[],
+ defaultKey: keyof T | null = null,
+ defaultDirection: SortDirection = "asc",
+ comparators?: Comparators
+): {
+ sortedData: T[];
+ sortConfig: SortConfig;
+ requestSort: (key: keyof T) => void;
+} {
+ const [sortConfig, setSortConfig] = useState>({
+ key: defaultKey,
+ direction: defaultDirection,
+ });
+
+ const sortedData = useMemo(() => {
+ if (!sortConfig.key) return data;
+
+ const key = sortConfig.key;
+ const dir = sortConfig.direction;
+
+ return [...data].sort((a, b) => {
+ // Use custom comparator if provided
+ if (comparators?.[key]) {
+ return comparators[key]!(a, b, dir);
+ }
+
+ const aVal = a[key];
+ const bVal = b[key];
+
+ if (aVal === null || aVal === undefined) return 1;
+ if (bVal === null || bVal === undefined) return -1;
+
+ let cmp = 0;
+ if (typeof aVal === "string" && typeof bVal === "string") {
+ cmp = aVal.localeCompare(bVal);
+ } else {
+ cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
+ }
+
+ return dir === "asc" ? cmp : -cmp;
+ });
+ }, [data, sortConfig, comparators]);
+
+ const requestSort = (key: keyof T) => {
+ setSortConfig((prev) => ({
+ key,
+ direction: prev.key === key && prev.direction === "asc" ? "desc" : "asc",
+ }));
+ };
+
+ return { sortedData, sortConfig, requestSort };
+}
diff --git a/app/models/standings.ts b/app/models/standings.ts
index abf5104..ca1e633 100644
--- a/app/models/standings.ts
+++ b/app/models/standings.ts
@@ -309,20 +309,30 @@ export async function getSevenDayStandingsChange(
),
});
- // Create a map of team -> old rank
+ // Create a map of team -> old rank and old points
const oldRanks = new Map();
+ const oldPoints = new Map();
for (const snapshot of snapshots) {
oldRanks.set(snapshot.teamId, snapshot.rank);
+ const snapshotPoints = snapshot.actualPoints
+ ? parseFloat(snapshot.actualPoints)
+ : parseFloat(snapshot.totalPoints);
+ oldPoints.set(snapshot.teamId, snapshotPoints);
}
// Add 7-day changes to current standings
- return current.map((standing) => ({
- ...standing,
- sevenDayRankChange: oldRanks.has(standing.teamId)
- ? (oldRanks.get(standing.teamId) ?? 0) - standing.currentRank
- : 0,
- sevenDayOldRank: oldRanks.get(standing.teamId) || null,
- }));
+ return current.map((standing) => {
+ const currentPoints = standing.actualPoints ?? standing.totalPoints;
+ const oldPoint = oldPoints.get(standing.teamId);
+ return {
+ ...standing,
+ sevenDayRankChange: oldRanks.has(standing.teamId)
+ ? (oldRanks.get(standing.teamId) ?? 0) - standing.currentRank
+ : 0,
+ sevenDayOldRank: oldRanks.get(standing.teamId) || null,
+ sevenDayPointChange: oldPoint !== undefined ? currentPoints - oldPoint : 0,
+ };
+ });
}
/**
diff --git a/app/routes/leagues/$leagueId.standings.$seasonId.tsx b/app/routes/leagues/$leagueId.standings.$seasonId.tsx
index 91c1a12..09abf2c 100644
--- a/app/routes/leagues/$leagueId.standings.$seasonId.tsx
+++ b/app/routes/leagues/$leagueId.standings.$seasonId.tsx
@@ -157,16 +157,6 @@ export default function LeagueStandings() {