brackt/app/hooks/useSortableData.ts
Chris Parsons 8c5389909d
Redesign standings page with sortable table, 7-day change, and chart repositioned (#205)
* Redesign standings page with sortable table, 7-day change, and chart repositioned

- Move point progression chart below the standings table
- Replace separate Points/Projected/Placement columns with:
  - Single stacked "actual / projected" Points column (shared PointsDisplay component)
  - "7-Day Change" column showing points gained + rank change over past 7 days
- Remove Placement breakdown column
- Sort ties alphabetically by team name (rank sort only)
- All columns are sortable via new reusable useSortableData hook
- Add sevenDayPointChange to TeamStandingWithChange type and model

https://claude.ai/code/session_01CuCKFVYbpsKSQoFDcTYfY7

* Code review fixes: module-level comparators, correct type on SortableHead, handle negative point change, remove redundant spread

- Move comparators object to module level (closes over nothing, no useMemo needed)
- Use SortConfig<StandingRow> on SortableHead instead of inline duplicate type
- SevenDayChange: handle negative pointChange with correct sign and color
- Remove redundant sevenDayPointChange explicit assignment (already in ...standing spread)

https://claude.ai/code/session_01CuCKFVYbpsKSQoFDcTYfY7

* Update StandingsTable tests to match redesigned component

- Remove showPlacementBreakdown prop (no longer exists on component)
- Replace Placement Breakdown test suite with 7-Day Change column tests
- Update header assertion: "Placements" → "7-Day Change"
- Add tests for positive/negative point change display and rank change indicators
- Remove accessibility test for placement title attributes

https://claude.ai/code/session_01CuCKFVYbpsKSQoFDcTYfY7

* Fix movement indicator arrow count assertion to exclude sort header arrows

queryAllByText(/↑|↓/) was matching the active sort column's ↑ indicator.
Narrow to /[↑↓]\d/ so only movement indicators (↑1, ↓1) are counted.

https://claude.ai/code/session_01CuCKFVYbpsKSQoFDcTYfY7

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-22 11:05:13 -07:00

74 lines
2.1 KiB
TypeScript

import { useState, useMemo } from "react";
export type SortDirection = "asc" | "desc";
export interface SortConfig<T> {
key: keyof T | null;
direction: SortDirection;
}
type Comparators<T> = Partial<Record<keyof T, (a: T, b: T, direction: SortDirection) => 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<T>(
data: T[],
defaultKey: keyof T | null = null,
defaultDirection: SortDirection = "asc",
comparators?: Comparators<T>
): {
sortedData: T[];
sortConfig: SortConfig<T>;
requestSort: (key: keyof T) => void;
} {
const [sortConfig, setSortConfig] = useState<SortConfig<T>>({
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 };
}