-
Notifications
You must be signed in to change notification settings - Fork 2
feat: add division cutoff rank with leaderboard line #384
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -74,6 +74,7 @@ interface CompetitionLeaderboardTableProps { | |
| }> | ||
| selectedEventId: string | null // null = overall view | ||
| scoringAlgorithm: ScoringAlgorithm | ||
| cutoffRank: number | null | ||
| } | ||
|
|
||
| function getRankIcon(rank: number) { | ||
|
|
@@ -426,6 +427,7 @@ export function CompetitionLeaderboardTable({ | |
| events, | ||
| selectedEventId, | ||
| scoringAlgorithm, | ||
| cutoffRank, | ||
| }: CompetitionLeaderboardTableProps) { | ||
| // Compute the correct default sort column based on view mode | ||
| const defaultSortColumn = selectedEventId ? "eventRank" : "overallRank" | ||
|
|
@@ -825,14 +827,26 @@ export function CompetitionLeaderboardTable({ | |
| </div> | ||
| ) : ( | ||
| <div> | ||
| {tableData.map((entry) => ( | ||
| <MobileLeaderboardRow | ||
| key={entry.registrationId} | ||
| entry={entry} | ||
| events={events} | ||
| scoringAlgorithm={scoringAlgorithm} | ||
| /> | ||
| ))} | ||
| {tableData.map((entry, idx) => { | ||
| const nextEntry = tableData[idx + 1] | ||
| const showCutoff = | ||
| cutoffRank != null && | ||
| !selectedEventId && | ||
| entry.overallRank <= cutoffRank && | ||
| (!nextEntry || nextEntry.overallRank > cutoffRank) | ||
| return ( | ||
| <Fragment key={entry.registrationId}> | ||
| <MobileLeaderboardRow | ||
| entry={entry} | ||
| events={events} | ||
| scoringAlgorithm={scoringAlgorithm} | ||
| /> | ||
| {showCutoff && ( | ||
| <div className="h-[3px] bg-orange-500" /> | ||
| )} | ||
| </Fragment> | ||
| ) | ||
| })} | ||
| </div> | ||
| )} | ||
| </div> | ||
|
|
@@ -885,18 +899,37 @@ export function CompetitionLeaderboardTable({ | |
| </TableCell> | ||
| </TableRow> | ||
| ) : ( | ||
| table.getRowModel().rows.map((row) => ( | ||
| <TableRow key={row.id} className="table-row"> | ||
| {row.getVisibleCells().map((cell) => ( | ||
| <TableCell key={cell.id} className="table-cell"> | ||
| {flexRender( | ||
| cell.column.columnDef.cell, | ||
| cell.getContext(), | ||
| )} | ||
| </TableCell> | ||
| ))} | ||
| </TableRow> | ||
| )) | ||
| table.getRowModel().rows.map((row, rowIdx) => { | ||
| const entry = row.original | ||
| const rows = table.getRowModel().rows | ||
| const nextRow = rows[rowIdx + 1] | ||
| const showCutoff = | ||
| cutoffRank != null && | ||
| !selectedEventId && | ||
| entry.overallRank <= cutoffRank && | ||
| (!nextRow || nextRow.original.overallRank > cutoffRank) | ||
| return ( | ||
| <Fragment key={row.id}> | ||
| <TableRow className="table-row"> | ||
| {row.getVisibleCells().map((cell) => ( | ||
| <TableCell key={cell.id} className="table-cell"> | ||
| {flexRender( | ||
| cell.column.columnDef.cell, | ||
| cell.getContext(), | ||
| )} | ||
| </TableCell> | ||
| ))} | ||
| </TableRow> | ||
| {showCutoff && ( | ||
| <tr> | ||
| <td colSpan={columns.length} className="p-0"> | ||
| <div className="h-[3px] bg-orange-500" /> | ||
| </td> | ||
| </tr> | ||
| )} | ||
|
Comment on lines
+902
to
+929
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hide or recompute the cutoff when the table is re-sorted. This boundary check runs against the current sorted row model, so as soon as the user sorts overall view by athlete/event or flips rank descending, the orange line can land in the middle of the table or disappear entirely. The cutoff marker should only render in ascending 🤖 Prompt for AI Agents |
||
| </Fragment> | ||
| ) | ||
| }) | ||
| )} | ||
| </TableBody> | ||
| </Table> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -38,13 +38,16 @@ export interface OrganizerDivisionItemProps { | |
| description: string | null | ||
| maxSpots: number | null | ||
| defaultMaxSpots: number | null | ||
| cutoffRank: number | null | ||
| defaultCutoffRank: number | null | ||
| index: number | ||
| registrationCount: number | ||
| isOnly: boolean | ||
| instanceId: symbol | ||
| onLabelSave: (value: string) => void | ||
| onDescriptionSave: (value: string | null) => void | ||
| onMaxSpotsSave: (value: number | null) => void | ||
| onCutoffRankSave: (value: number | null) => void | ||
| onRemove: () => void | ||
| onDrop: (sourceIndex: number, targetIndex: number) => void | ||
| } | ||
|
|
@@ -55,13 +58,16 @@ export function OrganizerDivisionItem({ | |
| description, | ||
| maxSpots, | ||
| defaultMaxSpots, | ||
| cutoffRank, | ||
| defaultCutoffRank, | ||
| index, | ||
| registrationCount, | ||
| isOnly, | ||
| instanceId, | ||
| onLabelSave, | ||
| onDescriptionSave, | ||
| onMaxSpotsSave, | ||
| onCutoffRankSave, | ||
| onRemove, | ||
| onDrop, | ||
| }: OrganizerDivisionItemProps) { | ||
|
|
@@ -74,6 +80,7 @@ export function OrganizerDivisionItem({ | |
| const labelRef = useRef(label) | ||
| const [localDescription, setLocalDescription] = useState(description ?? "") | ||
| const [localMaxSpots, setLocalMaxSpots] = useState(maxSpots?.toString() ?? "") | ||
| const [localCutoffRank, setLocalCutoffRank] = useState(cutoffRank?.toString() ?? "") | ||
| const [isExpanded, setIsExpanded] = useState(false) | ||
|
|
||
| // Sync local state when prop changes (e.g., after server update) | ||
|
|
@@ -90,6 +97,10 @@ export function OrganizerDivisionItem({ | |
| setLocalMaxSpots(maxSpots?.toString() ?? "") | ||
| }, [maxSpots]) | ||
|
|
||
| useEffect(() => { | ||
| setLocalCutoffRank(cutoffRank?.toString() ?? "") | ||
| }, [cutoffRank]) | ||
|
|
||
| const canDelete = registrationCount === 0 && !isOnly | ||
|
|
||
| useEffect(() => { | ||
|
|
@@ -318,6 +329,48 @@ export function OrganizerDivisionItem({ | |
| : "Leave blank for unlimited"} | ||
| </span> | ||
| </div> | ||
| <div className="flex items-center gap-3"> | ||
| <label | ||
| htmlFor={`cutoffRank-${id}`} | ||
| className="text-sm text-muted-foreground whitespace-nowrap" | ||
| > | ||
| Cutoff rank: | ||
| </label> | ||
| <Input | ||
| id={`cutoffRank-${id}`} | ||
| type="number" | ||
| min={1} | ||
| value={localCutoffRank} | ||
| onChange={(e) => setLocalCutoffRank(e.target.value)} | ||
| onBlur={() => { | ||
| const newVal = | ||
| localCutoffRank.trim() === "" | ||
| ? null | ||
| : parseInt(localCutoffRank, 10) | ||
| if (newVal !== cutoffRank) { | ||
| if ( | ||
| newVal !== null && | ||
| (Number.isNaN(newVal) || newVal < 1) | ||
| ) { | ||
| setLocalCutoffRank(cutoffRank?.toString() ?? "") | ||
| return | ||
| } | ||
| onCutoffRankSave(newVal) | ||
| } | ||
|
Comment on lines
+345
to
+359
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
node - <<'NODE'
for (const raw of ["1.5", "2.9", "1e2"]) {
console.log({
raw,
parsedInt: parseInt(raw, 10),
numeric: Number(raw),
isInteger: Number.isInteger(Number(raw)),
})
}
NODERepository: wodsmith/thewodapp Length of output: 243 🏁 Script executed: cat -n apps/wodsmith-start/src/components/divisions/organizer-division-item.tsx | sed -n '330,370p'Repository: wodsmith/thewodapp Length of output: 1949 🏁 Script executed: rg -B 20 "localCutoffRank.trim" apps/wodsmith-start/src/components/divisions/organizer-division-item.tsxRepository: wodsmith/thewodapp Length of output: 890 🏁 Script executed: rg "cutoffRank" apps/wodsmith-start/src/components/divisions/organizer-division-item.tsx -A 2 -B 2Repository: wodsmith/thewodapp Length of output: 1533 This blur handler silently truncates decimal input from the number field. The const newVal =
localCutoffRank.trim() === ""
? null
: Number(localCutoffRank)
if (newVal !== cutoffRank) {
if (
newVal !== null &&
(Number.isNaN(newVal) || !Number.isInteger(newVal) || newVal < 1)
) {
setLocalCutoffRank(cutoffRank?.toString() ?? "")
return
}
onCutoffRankSave(newVal)
}🤖 Prompt for AI Agents |
||
| }} | ||
| placeholder={ | ||
| defaultCutoffRank | ||
| ? `${defaultCutoffRank} (default)` | ||
| : "No cutoff" | ||
| } | ||
| className="w-32 text-sm" | ||
| /> | ||
| <span className="text-xs text-muted-foreground"> | ||
| {defaultCutoffRank | ||
| ? "Leave blank to use competition default" | ||
| : "Leave blank for no cutoff line"} | ||
| </span> | ||
| </div> | ||
| <Textarea | ||
| value={localDescription} | ||
| onChange={(e) => setLocalDescription(e.target.value)} | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -38,6 +38,7 @@ import { | |||||
| switchCompetitionScalingGroupFn, | ||||||
| updateCompetitionDivisionFn, | ||||||
| updateDivisionCapacityFn, | ||||||
| updateDivisionCutoffFn, | ||||||
| updateDivisionDescriptionFn, | ||||||
| } from "@/server-fns/competition-divisions-fns" | ||||||
| import { OrganizerDivisionItem } from "./organizer-division-item" | ||||||
|
|
@@ -51,6 +52,7 @@ interface Division { | |||||
| description: string | null | ||||||
| feeCents: number | null | ||||||
| maxSpots: number | null | ||||||
| cutoffRank: number | null | ||||||
| } | ||||||
|
|
||||||
| interface ScalingGroupWithLevels { | ||||||
|
|
@@ -74,6 +76,7 @@ interface OrganizerDivisionManagerProps { | |||||
| scalingGroupTitle: string | null | ||||||
| scalingGroups: ScalingGroupWithLevels[] | ||||||
| defaultMaxSpotsPerDivision: number | null | ||||||
| defaultCutoffRank: number | null | ||||||
| } | ||||||
|
|
||||||
| export function OrganizerDivisionManager({ | ||||||
|
|
@@ -84,6 +87,7 @@ export function OrganizerDivisionManager({ | |||||
| scalingGroupTitle, | ||||||
| scalingGroups, | ||||||
| defaultMaxSpotsPerDivision, | ||||||
| defaultCutoffRank, | ||||||
| }: OrganizerDivisionManagerProps) { | ||||||
| const router = useRouter() | ||||||
| const [divisions, setDivisions] = useState(initialDivisions) | ||||||
|
|
@@ -255,6 +259,43 @@ export function OrganizerDivisionManager({ | |||||
| } | ||||||
| } | ||||||
|
|
||||||
| const handleCutoffRankSave = async ( | ||||||
| divisionId: string, | ||||||
| newCutoffRank: number | null, | ||||||
| ) => { | ||||||
| const original = initialDivisions.find((d) => d.id === divisionId) | ||||||
| if (original && original.cutoffRank === newCutoffRank) return | ||||||
|
|
||||||
| setDivisions((prev) => | ||||||
| prev.map((d) => | ||||||
| d.id === divisionId ? { ...d, cutoffRank: newCutoffRank } : d, | ||||||
| ), | ||||||
| ) | ||||||
|
|
||||||
| try { | ||||||
| await updateDivisionCutoffFn({ | ||||||
| data: { | ||||||
| teamId, | ||||||
| competitionId, | ||||||
| divisionId, | ||||||
| cutoffRank: newCutoffRank, | ||||||
| }, | ||||||
| }) | ||||||
| toast.success("Division cutoff updated") | ||||||
| } catch (error) { | ||||||
| toast.error( | ||||||
| error instanceof Error ? error.message : "Failed to update cutoff", | ||||||
| ) | ||||||
| setDivisions((prev) => | ||||||
| prev.map((d) => | ||||||
| d.id === divisionId | ||||||
| ? { ...d, cutoffRank: original?.cutoffRank ?? newCutoffRank } | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Rollback uses Prompt for AI agents
Suggested change
|
||||||
| : d, | ||||||
| ), | ||||||
| ) | ||||||
| } | ||||||
|
Comment on lines
+262
to
+296
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Rollback uses a stale/null-hostile source value. This handler captures 🤖 Prompt for AI Agents |
||||||
| } | ||||||
|
|
||||||
| const handleRemove = async (divisionId: string) => { | ||||||
| try { | ||||||
| await deleteCompetitionDivisionFn({ | ||||||
|
|
@@ -421,6 +462,8 @@ export function OrganizerDivisionManager({ | |||||
| description={division.description} | ||||||
| maxSpots={division.maxSpots} | ||||||
| defaultMaxSpots={defaultMaxSpotsPerDivision} | ||||||
| cutoffRank={division.cutoffRank} | ||||||
| defaultCutoffRank={defaultCutoffRank} | ||||||
| index={index} | ||||||
| registrationCount={division.registrationCount} | ||||||
| isOnly={divisions.length === 1} | ||||||
|
|
@@ -432,6 +475,9 @@ export function OrganizerDivisionManager({ | |||||
| onMaxSpotsSave={(spots) => | ||||||
| handleMaxSpotsSave(division.id, spots) | ||||||
| } | ||||||
| onCutoffRankSave={(rank) => | ||||||
| handleCutoffRankSave(division.id, rank) | ||||||
| } | ||||||
| onRemove={() => handleRemove(division.id)} | ||||||
| onDrop={handleDrop} | ||||||
| /> | ||||||
|
|
||||||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Cutoff line placement is incorrect when the leaderboard is sorted by anything other than overall-rank ascending.
Prompt for AI agents