From 45753050422e4843914b848d2a5a5454481a3a3e Mon Sep 17 00:00:00 2001 From: John Clevenger Date: Tue, 12 May 2026 09:05:48 -0700 Subject: [PATCH 1/3] i1251: update WTI scoreboard-page component to support group selection. --- .../scoreboard-page.component.html | 27 +- .../scoreboard-page.component.scss | 30 ++ .../scoreboard-page.component.ts | 272 ++++++++++++++++-- 3 files changed, 307 insertions(+), 22 deletions(-) diff --git a/projects/WTI-UI/src/app/modules/scoreboard/components/scoreboard-page/scoreboard-page.component.html b/projects/WTI-UI/src/app/modules/scoreboard/components/scoreboard-page/scoreboard-page.component.html index 45b78d3d8..82e5d7824 100644 --- a/projects/WTI-UI/src/app/modules/scoreboard/components/scoreboard-page/scoreboard-page.component.html +++ b/projects/WTI-UI/src/app/modules/scoreboard/components/scoreboard-page/scoreboard-page.component.html @@ -1,6 +1,23 @@
+ +
+ +
+ @@ -23,10 +40,14 @@ - - - + + + + + + + diff --git a/projects/WTI-UI/src/app/modules/scoreboard/components/scoreboard-page/scoreboard-page.component.scss b/projects/WTI-UI/src/app/modules/scoreboard/components/scoreboard-page/scoreboard-page.component.scss index f725c9da9..82073eb98 100644 --- a/projects/WTI-UI/src/app/modules/scoreboard/components/scoreboard-page/scoreboard-page.component.scss +++ b/projects/WTI-UI/src/app/modules/scoreboard/components/scoreboard-page/scoreboard-page.component.scss @@ -6,6 +6,36 @@ flex: 1; } +//scoreboard control elements such as group dropdown +.scoreboard-controls { + flex: 0 0 auto; + align-self: flex-start; + margin: 0 0 0.75rem; + padding: 0; +} + +.scoreboard-group-filter { + display: inline-flex; + align-items: center; + gap: 0.5rem; + max-width: 100%; +} + +.scoreboard-group-filter-label { + font-size: 0.9375rem; + font-weight: 600; + color: #333; +} + +.scoreboard-group-select { + width: auto; + max-width: min(28rem, 100%); + min-width: 10rem; + padding: 0.35rem 0.5rem; + border-radius: 5px; + border: 1px solid $border1; +} + .scoreboard-table { td, th { padding: 2px 4px !important; diff --git a/projects/WTI-UI/src/app/modules/scoreboard/components/scoreboard-page/scoreboard-page.component.ts b/projects/WTI-UI/src/app/modules/scoreboard/components/scoreboard-page/scoreboard-page.component.ts index 44052eb66..340cbba3a 100644 --- a/projects/WTI-UI/src/app/modules/scoreboard/components/scoreboard-page/scoreboard-page.component.ts +++ b/projects/WTI-UI/src/app/modules/scoreboard/components/scoreboard-page/scoreboard-page.component.ts @@ -3,7 +3,6 @@ import { IContestService } from 'src/app/modules/core/abstract-services/i-contes import { takeUntil } from 'rxjs/operators'; import { Subject } from 'rxjs'; import { AppTitleService } from 'src/app/modules/core/services/app-title.service'; -import * as Constants from 'src/constants'; import { DEBUG_MODE } from 'src/constants'; import { UiHelperService } from 'src/app/modules/core/services/ui-helper.service'; @@ -13,6 +12,11 @@ interface ProblemHeader { textColor: 'black' | 'white'; url: string; } + +interface ScoreboardGroupOption { + id: string; + displayName: string; +} @Component({ templateUrl: './scoreboard-page.component.html', @@ -22,6 +26,10 @@ export class ScoreboardPageComponent implements OnInit, OnDestroy, DoCheck { private _unsubscribe = new Subject(); teamStandings: any = []; + private fullTeamStandings: any[] = []; + groupOptions: ScoreboardGroupOption[] = []; + selectedGroupId = ''; + numProblems: number = 0; problemDetailHeaders: ProblemHeader[] = []; @@ -73,40 +81,266 @@ export class ScoreboardPageComponent implements OnInit, OnDestroy, DoCheck { private loadStandings(): void { this._contestService.getStandings() .pipe(takeUntil(this._unsubscribe)) - .subscribe((standings: string) => { - //console.log("standings string:"); - //console.log(standings); - this.teamStandings = this.getTeamStandingsArray(standings); + .subscribe((standings: any) => { + // Snapshot all team rows, derive dropdown options from the header groupList (or from rows if the header is unusable), + // reset an invalid filter choice, then derive visible rows and table metadata from the same payload. + const rows = this.getTeamStandingsArray(standings); + this.fullTeamStandings = rows; + this.groupOptions = this.buildGroupDropdownOptions(standings, rows); + // Hide the filter unless two or more groups qualify; clear selection if the chosen group vanished. + if (this.groupOptions.length <= 1) { + this.selectedGroupId = ''; + } else if ( + this.selectedGroupId !== '' && + !this.groupOptions.some(g => g.id === this.selectedGroupId) + ) { + this.selectedGroupId = ''; + } + this.teamStandings = this.getFilteredStandings(rows); this.numProblems = this.getNumProblems(standings); this.problemDetailHeaders = this.getProblemDetailHeaders(standings); }); } + /** + * Update scoreboard standings whenever group dropdown ("filter") changes + */ + onGroupFilterChange(): void { + this.teamStandings = this.getFilteredStandings(this.fullTeamStandings); + } + + /** + * Returns the value shown in the Rank column for one team row, depending on whether a group filter is active. + * + * - No group selected: use contest-wide {@code rank} from the standings payload (PC2 overall placement). + * - Group selected and this row's primary group matches the filter ({@code teamGroupId}): use {@code groupRank} + * when present (within-group rank from PC2 for that primary group). + * - Group selected but the row is included because of another group (e.g. {@code teamGroupIds}) or {@code groupRank} + * is missing: use {@code filteredIndex + 1} for a simple 1-based order in the currently filtered table. + * + * PC2 only emits {@code groupRank} for the team's primary group; it is not reused when the user filters to a + * non-primary group membership. + * + * @param team one element from {@code teamStandings} (filtered or full) + * @param filteredIndex zero-based index in the current {@code teamStandings} array (after group filter) + */ + getDisplayRank(team: any, filteredIndex: number): string | number { + if (this.selectedGroupId === '') { + return team.rank; + } + const primaryMatches = String(team.teamGroupId ?? '') === this.selectedGroupId; + if (primaryMatches) { + const gr = team.groupRank; + if (gr !== undefined && gr !== null && String(gr) !== '') { + return gr; + } + } + return filteredIndex + 1; + } + /** * Pull each teamStanding node out of the received JSON, load it into an array, * and return the array of team standing elements. + * Normalizes each row's problemSummaryInfo to an array so the template NgFor never receives a lone object. + */ + private getTeamStandingsArray(standings: any): any[] { + const contest = standings?.contestStandings; + if (!contest) { + return []; + } + const teams = this.normalizeToArray(contest.teamStanding); + for (const team of teams) { + this.ensureProblemSummaryInfoArray(team); + } + return teams; + } + + /** Repeated XML elements may arrive as one object or an array depending on parser and row count. + * This method ensures the specified "nodes" argument is an iterable array. + */ + private normalizeToArray(nodes: unknown): any[] { + if (nodes == null) { + return []; + } + return Array.isArray(nodes) ? nodes : [nodes]; + } + + /** + * XML-to-JSON may emit a lone problemSummaryInfo object; template NgFor requires an iterable array. + * Missing or null becomes an empty array. + */ + private ensureProblemSummaryInfoArray(team: unknown): void { + if (!team || typeof team !== 'object') { + return; + } + const row = team as Record; + const psi = row.problemSummaryInfo; + if (psi == null) { + row.problemSummaryInfo = []; + } else if (!Array.isArray(psi)) { + row.problemSummaryInfo = [psi]; + } + } + + /** + * Returns an array of options for populating the scoreboard Group dropdown. The preferred source is + * standingsHeader.groupList.group (see DefaultScoringAlgorithm.dumpGroupList) + * with teamCount > 0. Option values use each group's "id" (matches WTI-enriched teamGroupIds / legacy teamGroupId). + * If the header omits usable group rows, falls back to groups inferred from team standings. */ - private getTeamStandingsArray(standings: any) { + private buildGroupDropdownOptions(standings: any, teams: any[]): ScoreboardGroupOption[] { + const fromHeader = this.buildGroupOptionsFromHeader(standings); + if (fromHeader.length > 0) { + return fromHeader; + } + return this.buildGroupOptionsFromTeamRows(teams); + } - const contest = standings.contestStandings ; - //console.log("ContestStandings element:"); - //console.log(contest); + /** Builds an array of Group Options from standingsHeader.groupList, using only entries with teamCount + * strictly greater than zero. + */ + private buildGroupOptionsFromHeader(standings: any): ScoreboardGroupOption[] { + const header = standings?.contestStandings?.standingsHeader; + //if header is missing, the groupList is empty + if (!header) { + return []; + } + //if there is no grouplist (aka groupList) in the header, the group list is empty + const groupList = header.groupList ?? header.grouplist; + if (!groupList || typeof groupList !== 'object') { + return []; + } - const teams = contest.teamStanding ; - //console.log("TeamStandings elements:"); - //console.log(teams); + //get the list of groups + const raw = (groupList as { group?: unknown }).group; - let tempArray: any = [] ; + //ensure we have an ARRAY of group objects + const groups = this.normalizeToArray(raw); - for (let temp of teams) { - tempArray.push(temp); - } + //start with an empty list of group dropdown options + const out: ScoreboardGroupOption[] = []; -// console.log("Individual Team Standings:"); -// console.log(tempArray); + //check each group in the groupList. For each group, safely read id/title/teamCount across JSON quirks; + //keep only groups with a non-empty id and positive team count; assign a readable label. + for (const g of groups) { - return tempArray; + //normalize each item in this group to an object, either what it was to start with or an empty-string Record + const row = g && typeof g === 'object' ? (g as Record) : {}; + + //pull the standard fields (id, title, teamCount, etc.) out of the group row + const id = this.readXmlishField(row, 'id'); + const title = this.readXmlishField(row, 'title'); + const teamCountVal = this.readXmlishField(row, 'teamCount'); + const teamCountAlt = row['teamcount'] ?? row['team_count']; + const tc = this.parsePositiveCount(teamCountVal ?? teamCountAlt); + const stringGroupId = id !== undefined && id !== null && String(id).trim() !== '' + ? String(id).trim() + : ''; + + //skip invalid or empty groups + if (stringGroupId === '' || !(tc > 0)) { + continue; + } + + //If title is present (after trim), use it as label; otherwise use 'Group+stringId' as label + const label = title !== undefined && title !== null && String(title).trim() !== '' + ? String(title).trim() + : `Group ${stringGroupId}`; + + + //insert the stringGroupId and label into the output array + out.push({ id: stringGroupId, displayName: label }); + } + + //return options sorted alphabetically by display name, case-insensitive + return out.sort((a, b) => + a.displayName.localeCompare(b.displayName, undefined, { sensitivity: 'base' }) + ); + } + + /** + * The fields in scoreboard records come from XML converted to JSON. Depending on what XML serializer and + * schema was used, the same conceptual attribute (id, title, teamCount, ...) can appear either + * directly as a property on the object representing the element, e.g. { id: "1", title: "..." }, or + * inside a synthetic child keyed something like @attributes, e.g. { "@attributes": { id: "1", title: "..." } }. + * readXmlishField is a tiny normalization helper: "give me the value for logical field field from this JSON object, + * no matter which of those two layouts we got." + */ + private readXmlishField(obj: Record, field: string): unknown { + // JSON-from-XML sometimes puts attributes on @attributes instead of alongside child nodes. + if (field in obj) { + return obj[field]; + } + const attr = obj['@attributes']; + if (attr && typeof attr === 'object' && field in (attr as object)) { + return (attr as Record)[field]; + } + return undefined; + } + + private parsePositiveCount(value: unknown): number { + if (value === undefined || value === null) { + return NaN; + } + if (typeof value === 'number') { + return Number.isFinite(value) ? value : NaN; + } + const n = parseInt(String(value).trim(), 10); + return Number.isFinite(n) ? n : NaN; + } + + /** Fallback: one row per teamGroupId present on team standings (legacy / missing header). */ + private buildGroupOptionsFromTeamRows(teams: any[]): ScoreboardGroupOption[] { + const idToLabel = new Map(); + for (const row of teams) { + const id = row?.teamGroupId; + const name = row?.teamGroupName; + if (id === undefined || id === null || String(id).trim() === '') { + continue; + } + const sid = String(id); + idToLabel.set(sid, (name !== undefined && name !== null && String(name).trim() !== '') + ? String(name) + : `Group ${sid}`); + } + return Array.from(idToLabel.entries()) + .sort((a, b) => a[1].localeCompare(b[1], undefined, { sensitivity: 'base' })) + .map(([id, displayName]) => ({ id, displayName })); + } + + private getFilteredStandings(allRows: any[]): any[] { + if (this.selectedGroupId === '') { + return allRows.slice(); + } + const sid = this.selectedGroupId; + // WTI puts all group ids on each row; otherwise only PC2 primary teamGroupId exists. + return allRows.filter(row => this.rowBelongsToSelectedGroup(row, sid)); + } + + /** + * Prefer WTI-enriched `teamGroupIds` (all groups the team belongs to). + * Otherwise fall back to PC2's single {@code teamGroupId} (primary group only). + */ + private rowBelongsToSelectedGroup(row: any, selectedId: string): boolean { + const ids = this.getTeamGroupIdsFromRow(row); + if (ids.length > 0) { + return ids.indexOf(selectedId) >= 0; + } + return String(row.teamGroupId ?? '') === selectedId; + } + + private getTeamGroupIdsFromRow(row: any): string[] { + const raw = row?.teamGroupIds; + if (raw == null) { + return []; + } + if (Array.isArray(raw)) { + return raw + .map((x: unknown) => String(x).trim()) + .filter(s => s !== ''); + } + return []; } /** From bd95cc6fc49045035ad9b9bbe87cfbc2da38829a Mon Sep 17 00:00:00 2001 From: John Clevenger Date: Tue, 12 May 2026 09:08:24 -0700 Subject: [PATCH 2/3] i1251: update ContestController to add all team-groups to JSON. --- .../main/controllers/ContestController.java | 135 +++++++++++++++++- 1 file changed, 134 insertions(+), 1 deletion(-) diff --git a/projects/WTI-API/src/main/controllers/ContestController.java b/projects/WTI-API/src/main/controllers/ContestController.java index 4d971d32a..6303e01cb 100644 --- a/projects/WTI-API/src/main/controllers/ContestController.java +++ b/projects/WTI-API/src/main/controllers/ContestController.java @@ -18,6 +18,7 @@ import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; +import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import org.json.XML; @@ -38,9 +39,11 @@ import edu.csus.ecs.pc2.core.StringUtilities; import edu.csus.ecs.pc2.core.exception.IllegalContestState; import edu.csus.ecs.pc2.core.log.Log; +import edu.csus.ecs.pc2.core.model.Account; import edu.csus.ecs.pc2.core.model.ClientId; import edu.csus.ecs.pc2.core.model.ClientType.Type; import edu.csus.ecs.pc2.core.model.ElementId; +import edu.csus.ecs.pc2.core.model.Group; import edu.csus.ecs.pc2.core.model.IInternalContest; import edu.csus.ecs.pc2.core.model.Run; import edu.csus.ecs.pc2.core.scoring.DefaultScoringAlgorithm; @@ -802,7 +805,8 @@ public Response getStandings( // logger.fine("Got the following XML from DSA:"); // logger.fine(xmlStandings); logger.info("Converting DSA XML to JSON"); - currentJSONStandings = this.getJSONStandings(xmlStandings); + currentJSONStandings = this.enrichScoreboardTeamGroups(this.getJSONStandings(xmlStandings), + internalContest); // logger.fine("Got the following JSON standings:"); // logger.fine(currentJSONStandings); } catch (IllegalContestState e) { @@ -871,6 +875,135 @@ private String getJSONStandings(String xmlStandings) throws IOException, JSONExc } + /** + * Adds multi-group membership to each scoreboard row. + *

+ * PC2 standings XML only carries the team's primary group on each {@code teamStanding} node + * ({@code teamGroupId}, {@code teamGroupName}, {@code groupRank}). A team may belong to several groups; + * the UI needs every {@code standingsHeader.groupList.group} {@code id} that should include that team. + *

+ *

+ * For each team row, looks up the {@link Account} and appends a {@code teamGroupIds} array: for each + * {@code group} in the header {@code groupList}, if {@code externalId} matches the group's CMS id and + * the account {@link Account#isGroupMember} for that group, the group's {@code id} string is added. + * Those ids match the scoreboard group filter in the browser. + *

+ *

+ * On any failure, returns the input JSON unchanged. + *

+ * + * @param jsonStandings JSON string from {@link #getJSONStandings(String)} + * @param contest contest providing team accounts and {@link Group} definitions + * @return JSON with {@code teamGroupIds} set on each processed {@code teamStanding}, or the original string if + * enrichment is skipped or throws + */ + private String enrichScoreboardTeamGroups(String jsonStandings, IInternalContest contest) { + if (jsonStandings == null || contest == null) { + return jsonStandings; + } + try { + JSONObject root = new JSONObject(jsonStandings); + + // PC2 XML-to-JSON must expose contestStandings, header, and team rows. If any are missing, + // we cannot align group ids with teams, so return the payload unchanged (client keeps legacy behavior). + // (In other words, the following block of code verifies that all the required elements are found in + // the received jsonStandings.) + if (!root.has("contestStandings")) { + return jsonStandings; + } + JSONObject contestStandings = root.getJSONObject("contestStandings"); + if (!contestStandings.has("standingsHeader") || !contestStandings.has("teamStanding")) { + return jsonStandings; + } + JSONObject standingsHeader = contestStandings.getJSONObject("standingsHeader"); + JSONObject groupListObj = null; + // org.json may preserve "groupList" casing from XML; we also want to accept "grouplist" for defensive parsing. + if (standingsHeader.has("groupList")) { + groupListObj = standingsHeader.getJSONObject("groupList"); + } else if (standingsHeader.has("grouplist")) { + groupListObj = standingsHeader.getJSONObject("grouplist"); + } + // No group metadata means nothing to merge with account membership. + if (groupListObj == null || !groupListObj.has("group")) { + return jsonStandings; + } + + // Header "group" entries use externalId (CMS group id) and id (sequential scoreboard id used in the UI filter). + // Construct a Map CMS id -> PC2 Group so we can call account.isGroupMember(ElementId) for each header row. + JSONArray groupArray = asJsonArray(groupListObj.get("group")); + HashMap groupByCmsId = new HashMap(); + for (Group g : contest.getGroups()) { + groupByCmsId.put(Integer.valueOf(g.getGroupId()), g); + } + + // Walk the list of teamStandings (one teamStanding element per team); for each team, insert into + // the teamStanding an array containing teamGroupIds for the group(s) to which that team belongs. + Object rawTeams = contestStandings.get("teamStanding"); + JSONArray teamArray = asJsonArray(rawTeams); + int defaultSite = contest.getSiteNumber(); + for (int i = 0; i < teamArray.length(); i++) { + JSONObject team = teamArray.getJSONObject(i); + if (!team.has("teamId")) { + //can't do anything with this teamStanding row if it contains no teamId + continue; + } + //get the team number + int teamNum = team.optInt("teamId", -1); + if (teamNum < 0) { + continue; + } + //get the account associated with the team + int siteNum = team.has("teamSiteId") ? team.getInt("teamSiteId") : defaultSite; + ClientId cid = new ClientId(siteNum, Type.TEAM, teamNum); + Account account = contest.getAccount(cid); + if (account == null) { + //can't do anything with this team if there's no account for them + continue; + } + // Collect every scoreboard group "id" from the header for which this account belongs to that group. + JSONArray memberStandingsIds = new JSONArray(); + for (int gi = 0; gi < groupArray.length(); gi++) { + JSONObject groupJson = groupArray.getJSONObject(gi); + if (!groupJson.has("externalId") || !groupJson.has("id")) { + continue; + } + int extId = groupJson.getInt("externalId"); + Group scoredGroup = groupByCmsId.get(Integer.valueOf(extId)); + if (scoredGroup != null && account.isGroupMember(scoredGroup.getElementId())) { + Object idObj = groupJson.get("id"); + String standingsId = idObj == null ? "" : String.valueOf(idObj).trim(); + if (!standingsId.isEmpty()) { + memberStandingsIds.put(standingsId); + } + } + } + //insert the groupIds for this team in the team's teamStanding row + team.put("teamGroupIds", memberStandingsIds); + } + + //return an updated JSON string which now also contains the groupIds in each teamStanding row + return root.toString(); + + } catch (Exception e) { + // Malformed JSON, missing fields, or type mismatches: do not fail the request; the UI can still filter by primary teamGroupId. + logger.warning("ContestController.enrichScoreboardTeamGroups failed while adding teamGroupIds; returning unenriched JSON: " + e.getMessage()); + return jsonStandings; + } + } + + /** Wraps a single JSONObject from XML in a one-element JSONArray so callers can always use indexed loops. */ + private static JSONArray asJsonArray(Object orig) throws JSONException { + if (orig == null) { + return new JSONArray(); + } + if (orig instanceof JSONArray) { + return (JSONArray) orig; + } + JSONArray array = new JSONArray(); + array.put(orig); + return array; + } + /** * Returns the flag indicating whether the cached copy of the contest standings are current (true), * or instead that some event has occured which potentially makes the standings out of date (false). From 5e8fcceab92105ad019e5686a24754f3fdebfdeb Mon Sep 17 00:00:00 2001 From: John Clevenger Date: Tue, 28 Jul 2026 18:15:49 -0700 Subject: [PATCH 3/3] i1251: keep WTI Group dropdown from stealing keyboard focus. --- .../scoreboard-page/scoreboard-page.component.html | 2 +- .../scoreboard-page/scoreboard-page.component.ts | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/projects/WTI-UI/src/app/modules/scoreboard/components/scoreboard-page/scoreboard-page.component.html b/projects/WTI-UI/src/app/modules/scoreboard/components/scoreboard-page/scoreboard-page.component.html index 82e5d7824..aa28f05cb 100644 --- a/projects/WTI-UI/src/app/modules/scoreboard/components/scoreboard-page/scoreboard-page.component.html +++ b/projects/WTI-UI/src/app/modules/scoreboard/components/scoreboard-page/scoreboard-page.component.html @@ -7,7 +7,7 @@ Group
Rank Solved Score
{{ team.rank }}
{{ getDisplayRank(team, i) }} {{ team.teamName }}