From 6b07bdfdd98aefde2e6a03f8dab9c7eec7d2e038 Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Thu, 23 Oct 2025 13:58:30 -0700 Subject: [PATCH 1/3] Fix smart invite participant sourcing --- src/utils/inviteSuggestions.js | 111 ++++++++++++++++++++++++++++++++- 1 file changed, 110 insertions(+), 1 deletion(-) diff --git a/src/utils/inviteSuggestions.js b/src/utils/inviteSuggestions.js index d58ad5e8..c8d882ed 100644 --- a/src/utils/inviteSuggestions.js +++ b/src/utils/inviteSuggestions.js @@ -31,6 +31,29 @@ const readValue = (subject, key) => { return subject[key]; }; +const isPlainIdKey = (key) => typeof key === "string" && key === "id"; + +const participantIdentityPaths = candidateIdKeys.filter((key) => !isPlainIdKey(key)); + +const looksLikeParticipantRecord = (candidate) => { + if (!candidate || typeof candidate !== "object") { + return false; + } + return participantIdentityPaths.some((key) => { + const value = readValue(candidate, key); + if (value === undefined || value === null) { + return false; + } + if (typeof value === "string") { + return value.trim().length > 0; + } + if (typeof value === "number") { + return Number.isFinite(value); + } + return true; + }); +}; + const parseNumericId = (value) => { if (value === null || value === undefined) return null; if (typeof value === "number") { @@ -57,6 +80,90 @@ const extractParticipantId = (participant) => { return null; }; +const participantCollectionPaths = [ + ["participants"], + ["match", "participants"], + ["matchParticipants"], + ["match", "matchParticipants"], + ["match_participants"], + ["match", "match_participants"], +]; + +const nestedParticipantKeys = [ + "data", + "items", + "results", + "list", + "values", + "records", + "rows", + "edges", + "nodes", +]; + +const collectParticipantsFromSource = (source, participants, visited) => { + if (source === null || source === undefined) { + return; + } + + const isObjectLike = typeof source === "object"; + if (isObjectLike) { + if (visited.has(source)) { + return; + } + visited.add(source); + } + + if (Array.isArray(source)) { + source.forEach((item) => { + if (!item) return; + if ( + typeof item === "object" && + item !== null && + Object.prototype.hasOwnProperty.call(item, "node") + ) { + collectParticipantsFromSource(item.node, participants, visited); + return; + } + collectParticipantsFromSource(item, participants, visited); + }); + return; + } + + if (!isObjectLike) { + return; + } + + if (looksLikeParticipantRecord(source)) { + participants.push(source); + return; + } + + nestedParticipantKeys.forEach((key) => { + if (Object.prototype.hasOwnProperty.call(source, key)) { + collectParticipantsFromSource(source[key], participants, visited); + } + }); +}; + +const collectMatchParticipants = (match) => { + if (!match || typeof match !== "object") { + return []; + } + + const participants = []; + const visited = new Set(); + + participantCollectionPaths.forEach((path) => { + const value = readValue(match, path); + if (value !== undefined && value !== null) { + collectParticipantsFromSource(value, participants, visited); + } + }); + + return participants; +}; + const buildParticipantName = (participant, fallbackId) => { if (!participant || typeof participant !== "object") { return fallbackId ? `Player ${fallbackId}` : "Unknown player"; @@ -141,7 +248,9 @@ export const buildRecentPartnerSuggestions = ({ matches.forEach((match) => { const { timestamp, iso } = extractMatchMoment(match); - const participants = uniqueActiveParticipants(match?.participants); + const participants = uniqueActiveParticipants( + collectMatchParticipants(match), + ); participants.forEach((participant) => { if ( memberMatchesParticipant( From 503603d5e3f6c26a0e59b7e38376cd0402f3d008 Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Thu, 23 Oct 2025 15:21:19 -0700 Subject: [PATCH 2/3] Refine invite suggestion participant parsing --- src/utils/inviteSuggestions.js | 248 ++++++++++++++++++++++++++------- 1 file changed, 197 insertions(+), 51 deletions(-) diff --git a/src/utils/inviteSuggestions.js b/src/utils/inviteSuggestions.js index c8d882ed..9e273a75 100644 --- a/src/utils/inviteSuggestions.js +++ b/src/utils/inviteSuggestions.js @@ -35,25 +35,6 @@ const isPlainIdKey = (key) => typeof key === "string" && key === "id"; const participantIdentityPaths = candidateIdKeys.filter((key) => !isPlainIdKey(key)); -const looksLikeParticipantRecord = (candidate) => { - if (!candidate || typeof candidate !== "object") { - return false; - } - return participantIdentityPaths.some((key) => { - const value = readValue(candidate, key); - if (value === undefined || value === null) { - return false; - } - if (typeof value === "string") { - return value.trim().length > 0; - } - if (typeof value === "number") { - return Number.isFinite(value); - } - return true; - }); -}; - const parseNumericId = (value) => { if (value === null || value === undefined) return null; if (typeof value === "number") { @@ -70,6 +51,10 @@ const parseNumericId = (value) => { }; const extractParticipantId = (participant) => { + if (!participant || typeof participant !== "object") { + return null; + } + for (const key of candidateIdKeys) { const candidate = readValue(participant, key); const parsed = parseNumericId(candidate); @@ -77,9 +62,187 @@ const extractParticipantId = (participant) => { return parsed; } } + return null; }; +const participantContextKeys = [ + "participant_status", + "participantStatus", + "status", + "status_reason", + "statusReason", + "role", + "is_active", + "active", + "joined_at", + "joinedAt", + "checked_in_at", + "checkedInAt", + "invited_at", + "invitedAt", + "left_at", + "leftAt", + "removed_at", + "removedAt", + "cancelled_at", + "cancelledAt", + "canceled_at", + "canceledAt", + "declined_at", + "declinedAt", + "withdrawn_at", + "withdrawnAt", + "profile", + "player", + "invitee", + "user", + "member", + "team", + "identity", + "identity_id", + "identityId", + "identity_ids", + "identityIds", + "full_name", + "fullName", + "display_name", + "displayName", +]; + +const plainIdSupplementalKeys = [ + "participant_status", + "participantStatus", + "status_reason", + "statusReason", + "role", + "profile", + "player", + "invitee", + "user", + "member", + "team", + "identity", + "identity_id", + "identityId", + "identity_ids", + "identityIds", + "full_name", + "fullName", + "display_name", + "displayName", + "joined_at", + "joinedAt", + "checked_in_at", + "checkedInAt", + "invited_at", + "invitedAt", + "left_at", + "leftAt", + "removed_at", + "removedAt", + "cancelled_at", + "cancelledAt", + "canceled_at", + "canceledAt", + "declined_at", + "declinedAt", + "withdrawn_at", + "withdrawnAt", +]; + +const participantStatusValues = new Set([ + "accepted", + "accept", + "active", + "attend", + "attending", + "checked-in", + "checked_in", + "checkedin", + "confirm", + "confirmed", + "confirmed_player", + "confirmed_substitute", + "joining", + "joined", + "participant", + "participating", + "pending", + "playing", + "registered", + "reserve", + "standby", + "sub", + "substitute", + "waitlist", + "waitlisted", + "alternate", + "alternate_player", + "available", + "invited", +]); + +const looksLikeParticipantRecord = (candidate) => { + if (!candidate || typeof candidate !== "object" || candidate instanceof Date) { + return false; + } + + const participantId = extractParticipantId(candidate); + if (participantId === null) { + return false; + } + + const hasParticipantSpecificIdentity = participantIdentityPaths.some((key) => { + const value = readValue(candidate, key); + if (value === undefined || value === null) { + return false; + } + if (typeof value === "string") { + return value.trim().length > 0; + } + if (typeof value === "number") { + return Number.isFinite(value); + } + return true; + }); + + const hasPlainId = Object.prototype.hasOwnProperty.call(candidate, "id"); + + if (!hasParticipantSpecificIdentity && !hasPlainId) { + return false; + } + + const hasContext = participantContextKeys.some((key) => + Object.prototype.hasOwnProperty.call(candidate, key), + ); + + if (!hasContext) { + return false; + } + + if (hasPlainId && !hasParticipantSpecificIdentity) { + const hasSupplemental = plainIdSupplementalKeys.some((key) => + Object.prototype.hasOwnProperty.call(candidate, key), + ); + + let hasParticipantStatus = false; + if (!hasSupplemental && Object.prototype.hasOwnProperty.call(candidate, "status")) { + const statusValue = candidate.status; + if (statusValue !== undefined && statusValue !== null) { + const normalized = statusValue.toString().trim().toLowerCase(); + hasParticipantStatus = participantStatusValues.has(normalized); + } + } + + if (!hasSupplemental && !hasParticipantStatus) { + return false; + } + } + + return true; +}; + const participantCollectionPaths = [ ["participants"], ["match", "participants"], @@ -89,40 +252,23 @@ const participantCollectionPaths = [ ["match", "match_participants"], ]; -const nestedParticipantKeys = [ - "data", - "items", - "results", - "list", - "values", - "records", - "rows", - "edges", - "nodes", -]; - const collectParticipantsFromSource = (source, participants, visited) => { if (source === null || source === undefined) { return; } - const isObjectLike = typeof source === "object"; - if (isObjectLike) { - if (visited.has(source)) { - return; - } - visited.add(source); + if (typeof source !== "object" || source instanceof Date) { + return; + } + + if (visited.has(source)) { + return; } + visited.add(source); if (Array.isArray(source)) { source.forEach((item) => { - if (!item) return; - if ( - typeof item === "object" && - item !== null && - Object.prototype.hasOwnProperty.call(item, "node") - ) { - collectParticipantsFromSource(item.node, participants, visited); + if (item === undefined || item === null) { return; } collectParticipantsFromSource(item, participants, visited); @@ -130,19 +276,19 @@ const collectParticipantsFromSource = (source, participants, visited) => { return; } - if (!isObjectLike) { - return; - } - if (looksLikeParticipantRecord(source)) { participants.push(source); return; } - nestedParticipantKeys.forEach((key) => { - if (Object.prototype.hasOwnProperty.call(source, key)) { - collectParticipantsFromSource(source[key], participants, visited); + Object.values(source).forEach((value) => { + if (value === undefined || value === null) { + return; + } + if (typeof value !== "object") { + return; } + collectParticipantsFromSource(value, participants, visited); }); }; From d65093b6b61a690bb6bdd8b4e919221ced87fc7e Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Thu, 23 Oct 2025 15:33:26 -0700 Subject: [PATCH 3/3] Relax suggestion participant filtering --- src/utils/inviteSuggestions.js | 134 ++++++++++++++++++++++++++++++++- 1 file changed, 131 insertions(+), 3 deletions(-) diff --git a/src/utils/inviteSuggestions.js b/src/utils/inviteSuggestions.js index 9e273a75..bef1d682 100644 --- a/src/utils/inviteSuggestions.js +++ b/src/utils/inviteSuggestions.js @@ -1,4 +1,4 @@ -import { uniqueActiveParticipants } from "./participants"; +import { uniqueParticipants } from "./participants"; import { memberMatchesParticipant } from "./memberIdentity"; const candidateIdKeys = [ @@ -310,6 +310,134 @@ const collectMatchParticipants = (match) => { return participants; }; +const departureKeys = [ + "left_at", + "leftAt", + "removed_at", + "removedAt", + "cancelled_at", + "cancelledAt", + "canceled_at", + "canceledAt", + "declined_at", + "declinedAt", + "withdrawn_at", + "withdrawnAt", +]; + +const statusKeys = [ + "status", + "participant_status", + "participantStatus", + "status_reason", + "statusReason", + "response", + "rsvp_status", + "rsvpStatus", +]; + +const normalizeStatus = (value) => { + if (value === undefined || value === null) { + return ""; + } + const normalized = value.toString().trim().toLowerCase(); + if (!normalized) { + return ""; + } + return normalized + .replace(/[_-]+/g, " ") + .replace(/\s{2,}/g, " ") + .trim(); +}; + +const inactiveStatusValues = new Set([ + "left", + "removed", + "cancelled", + "canceled", + "declined", + "rejected", + "withdrawn", + "expired", + "no show", + "did not show", + "did not attend", + "not attending", + "not coming", + "no longer attending", + "no longer playing", + "banned", + "blocked", + "suspended", + "kicked", + "booted", + "removed by host", + "removed by captain", + "cancelled by host", + "canceled by host", + "cancelled by captain", + "canceled by captain", +]); + +const inactiveStatusList = Array.from(inactiveStatusValues); + +const hasMeaningfulValue = (subject, key) => { + if (!subject || typeof subject !== "object") { + return false; + } + if (!Object.prototype.hasOwnProperty.call(subject, key)) { + return false; + } + const value = subject[key]; + if (value === undefined || value === null) { + return false; + } + if (typeof value === "string") { + return value.trim().length > 0; + } + if (typeof value === "number") { + return Number.isFinite(value); + } + if (value instanceof Date) { + return !Number.isNaN(value.getTime()); + } + return true; +}; + +const hasDepartureMetadata = (participant) => + departureKeys.some((key) => hasMeaningfulValue(participant, key)); + +const hasInactiveStatus = (participant) => + statusKeys.some((key) => { + const raw = readValue(participant, key); + const normalized = normalizeStatus(raw); + if (!normalized) { + return false; + } + if (inactiveStatusValues.has(normalized)) { + return true; + } + return inactiveStatusList.some((inactive) => + normalized.includes(inactive), + ); + }); + +const isEligibleForSuggestions = (participant) => { + if (!participant || typeof participant !== "object") { + return false; + } + if (participant.is_active === false || participant.active === false) { + return false; + } + if (hasDepartureMetadata(participant)) { + return false; + } + if (hasInactiveStatus(participant)) { + return false; + } + return true; +}; + const buildParticipantName = (participant, fallbackId) => { if (!participant || typeof participant !== "object") { return fallbackId ? `Player ${fallbackId}` : "Unknown player"; @@ -394,9 +522,9 @@ export const buildRecentPartnerSuggestions = ({ matches.forEach((match) => { const { timestamp, iso } = extractMatchMoment(match); - const participants = uniqueActiveParticipants( + const participants = uniqueParticipants( collectMatchParticipants(match), - ); + ).filter(isEligibleForSuggestions); participants.forEach((participant) => { if ( memberMatchesParticipant(