From eef43587d52839245920086f453a08707fcc3090 Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Thu, 23 Oct 2025 07:21:02 -0700 Subject: [PATCH 1/4] feat: surface low occupancy alerts and invite suggestions --- src/TennisMatchApp.jsx | 163 ++++++++++-- src/components/InviteScreen.jsx | 451 +++++++++++++++++++++++++++++++- src/utils/matchAlerts.js | 113 ++++++++ 3 files changed, 697 insertions(+), 30 deletions(-) create mode 100644 src/utils/matchAlerts.js diff --git a/src/TennisMatchApp.jsx b/src/TennisMatchApp.jsx index 4c4bea1b..0682ee8f 100644 --- a/src/TennisMatchApp.jsx +++ b/src/TennisMatchApp.jsx @@ -91,6 +91,7 @@ import { import { getMatchPrivacy } from "./utils/matchPrivacy"; import { getAvatarInitials, getAvatarUrlFromPlayer } from "./utils/avatar"; import { buildRecentPartnerSuggestions } from "./utils/inviteSuggestions"; +import { evaluateLowOccupancyAlert } from "./utils/matchAlerts"; const DEFAULT_SKILL_LEVEL = "2.5 - Beginner"; @@ -634,6 +635,8 @@ const TennisMatchApp = () => { notes: "", hostId: null, hostName: "", + status: "upcoming", + lowOccupancy: null, }); const [matches, setMatches] = useState([]); @@ -1467,6 +1470,26 @@ const TennisMatchApp = () => { return null; } + const playerLimitValue = (() => { + if (Number.isFinite(limitFromCapacity) && limitFromCapacity > 0) { + return limitFromCapacity; + } + const raw = m.player_limit; + const numeric = + typeof raw === "string" ? Number.parseInt(raw, 10) : raw; + return Number.isFinite(numeric) ? numeric : null; + })(); + + const lowOccupancyAlert = isHost + ? evaluateLowOccupancyAlert({ + status: m.status || "upcoming", + startDateTime: m.start_date_time, + playerLimit: playerLimitValue, + activeParticipants, + dedupedInvitees: invitees, + }) + : null; + return { id: matchId, type: isHost ? "hosted" : isJoined ? "joined" : "available", @@ -1503,15 +1526,7 @@ const TennisMatchApp = () => { notes: m.notes, invitees: m.invitees || [], participants: m.participants || [], - playerLimit: (() => { - if (Number.isFinite(limitFromCapacity) && limitFromCapacity > 0) { - return limitFromCapacity; - } - const raw = m.player_limit; - const numeric = - typeof raw === "string" ? Number.parseInt(raw, 10) : raw; - return Number.isFinite(numeric) ? numeric : null; - })(), + playerLimit: playerLimitValue, occupied, spotsAvailable: (() => { if (Number.isFinite(openFromCapacity)) { @@ -1528,6 +1543,7 @@ const TennisMatchApp = () => { })(), capacity: capacityInfo, isInvited, + lowOccupancyAlert, }; }).filter(Boolean); if (activeFilter === "draft") { @@ -1675,6 +1691,7 @@ const TennisMatchApp = () => { : match.invitees || []; const validParticipants = uniqueActiveParticipants(participantsSource); + const dedupedInvitees = uniqueInvitees(inviteesSource); const participantIds = validParticipants .map((p) => Number(p.player_id)) .filter((id) => Number.isFinite(id) && id > 0); @@ -1753,6 +1770,14 @@ const TennisMatchApp = () => { Number.isFinite(limitFromCapacity) && limitFromCapacity > 0 ? limitFromCapacity : fallbackPlayerLimit ?? prev.playerCount; + const status = match.status || prev.status || "upcoming"; + const lowOccupancyData = evaluateLowOccupancyAlert({ + status, + startDateTime: match.start_date_time || prev.dateTime, + playerLimit: playerCount, + activeParticipants: validParticipants, + dedupedInvitees, + }); return { ...prev, type: @@ -1764,18 +1789,20 @@ const TennisMatchApp = () => { format: match.match_format || prev.format || "", playerCount, occupied, - dateTime: match.start_date_time || prev.dateTime, - location: match.location_text || prev.location, - latitude: match.latitude ?? prev.latitude, - longitude: match.longitude ?? prev.longitude, - mapUrl: buildMapsUrl( - match.latitude, - match.longitude, - match.location_text, - ), - notes: match.notes || "", - hostId: computedHostId ?? prev.hostId, - hostName: computedHostName || prev.hostName || "", + dateTime: match.start_date_time || prev.dateTime, + location: match.location_text || prev.location, + latitude: match.latitude ?? prev.latitude, + longitude: match.longitude ?? prev.longitude, + mapUrl: buildMapsUrl( + match.latitude, + match.longitude, + match.location_text, + ), + notes: match.notes || "", + hostId: computedHostId ?? prev.hostId, + hostName: computedHostName || prev.hostName || "", + status, + lowOccupancy: lowOccupancyData, }; }); setInviteMatchId((prev) => @@ -2590,6 +2617,58 @@ const TennisMatchApp = () => { ? "Tap for directions" : "Location details coming soon"; + const lowOccupancy = match.lowOccupancyAlert; + const showLowOccupancy = Boolean(isHosted && lowOccupancy); + + const severityStyles = { + urgent: { + container: "border-red-200 bg-red-50 text-red-700", + badge: "bg-red-500 text-white", + button: + "bg-red-500 text-white hover:bg-red-600", + }, + warning: { + container: "border-amber-200 bg-amber-50 text-amber-700", + badge: "bg-amber-500 text-white", + button: + "bg-amber-500 text-white hover:bg-amber-600", + }, + soon: { + container: "border-blue-200 bg-blue-50 text-blue-700", + badge: "bg-blue-500 text-white", + button: + "bg-blue-500 text-white hover:bg-blue-600", + }, + }; + + const formatTimeUntil = (hours) => { + if (!Number.isFinite(hours) || hours <= 0) { + return "less than an hour"; + } + if (hours < 1) { + const minutes = Math.max(Math.round(hours * 60), 1); + return `${minutes} minute${minutes === 1 ? "" : "s"}`; + } + if (hours < 24) { + const rounded = Math.round(hours); + return `${rounded} hour${rounded === 1 ? "" : "s"}`; + } + const days = Math.round(hours / 24); + return `${days} day${days === 1 ? "" : "s"}`; + }; + + const severityKey = lowOccupancy?.severity ?? "warning"; + const severityTokens = severityStyles[severityKey] || severityStyles.warning; + const severityLabel = + severityKey === "urgent" + ? "Act now" + : severityKey === "warning" + ? "Needs attention" + : "Monitor"; + const timeUntilLabel = lowOccupancy + ? formatTimeUntil(lowOccupancy.hoursUntil) + : ""; + return (
{ )}
+ {showLowOccupancy && ( +
+
+
+ + Low occupancy alert + + {severityLabel} + +
+ + Confirmed {lowOccupancy.participantCount}/{lowOccupancy.playerLimit} + +
+

+ Need {lowOccupancy.openSpots} more player + {lowOccupancy.openSpots === 1 ? "" : "s"} before the match starts in {timeUntilLabel}. {" "} + {lowOccupancy.inviteCoverage > 0 + ? `Only ${lowOccupancy.inviteCoverage} invite${ + lowOccupancy.inviteCoverage === 1 ? "" : "s" + } currently out.` + : "No outstanding invites are out yet."} +

+
+ + + Short {lowOccupancy.shortfall} invite + {lowOccupancy.shortfall === 1 ? "" : "s"} to fill every spot + +
+
+ )} +
diff --git a/src/components/InviteScreen.jsx b/src/components/InviteScreen.jsx index de0bb149..a88a4794 100644 --- a/src/components/InviteScreen.jsx +++ b/src/components/InviteScreen.jsx @@ -10,6 +10,7 @@ import { X, Send, Sparkles, + AlertCircle, } from "lucide-react"; import { getMatch, @@ -21,13 +22,19 @@ import { listMatches, } from "../services/matches"; import { ARCHIVE_FILTER_VALUE, isMatchArchivedError } from "../utils/archive"; -import { idsMatch, uniqueActiveParticipants } from "../utils/participants"; +import { + idsMatch, + uniqueActiveParticipants, + uniqueInvitees, + countUniqueMatchOccupants, +} from "../utils/participants"; import { collectMemberIds, memberMatchesAnyId, memberMatchesParticipant, } from "../utils/memberIdentity"; import { buildRecentPartnerSuggestions } from "../utils/inviteSuggestions"; +import { evaluateLowOccupancyAlert } from "../utils/matchAlerts"; const InviteScreen = ({ matchId, @@ -53,11 +60,15 @@ const InviteScreen = ({ const [participants, setParticipants] = useState([]); const [participantsLoading, setParticipantsLoading] = useState(false); const [participantsError, setParticipantsError] = useState(""); + const [invitees, setInvitees] = useState([]); const [hostId, setHostId] = useState(null); const [isArchived, setIsArchived] = useState(false); const [suggestedPlayers, setSuggestedPlayers] = useState([]); const [suggestionsLoading, setSuggestionsLoading] = useState(false); const [suggestionsError, setSuggestionsError] = useState(""); + const [smartRecommendations, setSmartRecommendations] = useState([]); + const [smartRecommendationsLoading, setSmartRecommendationsLoading] = useState(false); + const [smartRecommendationsError, setSmartRecommendationsError] = useState(""); const matchType = typeof matchData?.type === "string" ? matchData.type.toLowerCase() : ""; @@ -68,6 +79,205 @@ const InviteScreen = ({ [currentUser], ); + const formatTimeUntil = (hours) => { + if (!Number.isFinite(hours) || hours <= 0) { + return "less than an hour"; + } + if (hours < 1) { + const minutes = Math.max(Math.round(hours * 60), 1); + return `${minutes} minute${minutes === 1 ? "" : "s"}`; + } + if (hours < 24) { + const rounded = Math.round(hours); + return `${rounded} hour${rounded === 1 ? "" : "s"}`; + } + const days = Math.round(hours / 24); + return `${days} day${days === 1 ? "" : "s"}`; + }; + + const lowOccupancy = matchData?.lowOccupancy ?? null; + const lowOccupancyStyles = { + urgent: { + container: "border-red-200 bg-red-50 text-red-700", + badge: "bg-red-500 text-white", + button: "bg-red-500 text-white hover:bg-red-600", + }, + warning: { + container: "border-amber-200 bg-amber-50 text-amber-700", + badge: "bg-amber-500 text-white", + button: "bg-amber-500 text-white hover:bg-amber-600", + }, + soon: { + container: "border-blue-200 bg-blue-50 text-blue-700", + badge: "bg-blue-500 text-white", + button: "bg-blue-500 text-white hover:bg-blue-600", + }, + }; + const lowOccupancySeverity = lowOccupancy?.severity ?? "warning"; + const lowOccupancyTokens = + lowOccupancyStyles[lowOccupancySeverity] || lowOccupancyStyles.warning; + const lowOccupancyLabel = + lowOccupancySeverity === "urgent" + ? "Act now" + : lowOccupancySeverity === "warning" + ? "Needs attention" + : "Monitor"; + const lowOccupancyTimeLabel = lowOccupancy + ? formatTimeUntil(lowOccupancy.hoursUntil) + : ""; + const outstandingShortfall = lowOccupancy?.shortfall ?? 0; + const outstandingInvites = lowOccupancy?.inviteCoverage ?? 0; + + const buildSmartSearchTerms = useCallback(() => { + const terms = new Set(); + const skill = matchData?.skillLevel; + if (typeof skill === "string") { + const trimmed = skill.trim(); + if (trimmed && trimmed.toLowerCase() !== "any level") { + terms.add(trimmed); + const rating = trimmed.split(/[\s-]/)[0]; + if (rating) terms.add(rating); + } + } + const format = matchData?.format; + if (typeof format === "string") { + const trimmed = format.trim(); + if (trimmed) terms.add(trimmed); + } + const locationText = matchData?.location; + if (typeof locationText === "string") { + const primary = locationText.split(",")[0].trim(); + if (primary.length >= 3) terms.add(primary); + } + terms.add(""); + return Array.from(terms); + }, [matchData?.format, matchData?.location, matchData?.skillLevel]); + + const recalcLowOccupancy = useCallback( + (nextParticipants = participants, nextInvitees = invitees) => { + setMatchData((prev) => { + if (!prev) return prev; + const status = prev.status || "upcoming"; + const lowOccupancyData = evaluateLowOccupancyAlert({ + status, + startDateTime: prev.dateTime, + playerLimit: prev.playerCount, + activeParticipants: nextParticipants, + dedupedInvitees: nextInvitees, + }); + const occupiedCount = countUniqueMatchOccupants( + nextParticipants, + nextInvitees, + ); + return { + ...prev, + occupied: occupiedCount, + lowOccupancy: lowOccupancyData, + }; + }); + }, + [invitees, participants, setMatchData], + ); + + const fetchSmartRecommendations = useCallback( + async (aliveCheck = () => true) => { + if (!lowOccupancy || outstandingShortfall <= 0) { + if (aliveCheck()) { + setSmartRecommendations([]); + setSmartRecommendationsError(""); + setSmartRecommendationsLoading(false); + } + return; + } + + const desired = Math.max(outstandingShortfall + 2, 4); + const perPage = Math.max(desired * 2, 12); + const searchTerms = buildSmartSearchTerms(); + const blockedIds = new Set( + existingPlayerIds instanceof Set + ? existingPlayerIds + : Array.isArray(existingPlayerIds) + ? existingPlayerIds + : [], + ); + participants.forEach((participant) => { + const pid = Number(participant.player_id); + if (Number.isFinite(pid) && pid > 0) { + blockedIds.add(pid); + } + }); + invitees.forEach((invite) => { + const pid = Number(invite.invitee_id || invite.player_id || invite.id); + if (Number.isFinite(pid) && pid > 0) { + blockedIds.add(pid); + } + }); + if (currentUser?.id) { + const uid = Number(currentUser.id); + if (Number.isFinite(uid) && uid > 0) { + blockedIds.add(uid); + } + } + + setSmartRecommendationsLoading(true); + setSmartRecommendationsError(""); + + const collected = []; + const seen = new Set(); + let lastError = null; + + for (const term of searchTerms) { + if (!aliveCheck()) return; + try { + const data = await searchPlayers({ + search: term, + page: 1, + perPage, + }); + const players = Array.isArray(data?.players) ? data.players : []; + players.forEach((player) => { + const pid = Number(player.user_id ?? player.id); + if (!Number.isFinite(pid) || pid <= 0) return; + if (blockedIds.has(pid) || seen.has(pid)) return; + seen.add(pid); + collected.push({ ...player, user_id: pid }); + }); + if (collected.length >= desired) break; + } catch (error) { + console.error("Failed to load smart invite recommendations", error); + lastError = error; + } + } + + if (!aliveCheck()) return; + + if (collected.length === 0) { + setSmartRecommendations([]); + setSmartRecommendationsError( + lastError + ? "We couldn't load recommendations right now. Try refreshing." + : "No ready substitutes found yet. Try searching manually or refreshing soon.", + ); + } else { + setSmartRecommendations(collected.slice(0, desired)); + setSmartRecommendationsError(""); + } + + if (aliveCheck()) { + setSmartRecommendationsLoading(false); + } + }, + [ + buildSmartSearchTerms, + currentUser, + existingPlayerIds, + invitees, + lowOccupancy, + outstandingShortfall, + participants, + ], + ); + const fetchSuggestedPlayers = useCallback( async (aliveCheck = () => true) => { if (!aliveCheck()) return; @@ -158,16 +368,62 @@ const InviteScreen = ({ setIsArchived(archived); if (archived) { setParticipants([]); + setInvitees([]); setParticipantsError("This match has been archived. Invites are read-only."); onToast("This match has been archived. Invites are read-only.", "error"); return; } - setParticipants(uniqueActiveParticipants(data.participants)); + const participantList = uniqueActiveParticipants(data.participants); + const inviteList = uniqueInvitees( + Array.isArray(data.invitees) + ? data.invitees + : Array.isArray(data.match?.invitees) + ? data.match.invitees + : [], + ); + setParticipants(participantList); + setInvitees(inviteList); setHostId(data.match?.host_id ?? null); + setExistingPlayerIds(() => { + const ids = new Set(); + participantList.forEach((participant) => { + const pid = Number(participant.player_id); + if (Number.isFinite(pid) && pid > 0) ids.add(pid); + }); + inviteList.forEach((invite) => { + const pid = Number( + invite.invitee_id ?? invite.player_id ?? invite.id, + ); + if (Number.isFinite(pid) && pid > 0) ids.add(pid); + }); + return ids; + }); + setMatchData((prev) => { + if (!prev) return prev; + const status = data.match?.status || prev.status || "upcoming"; + const lowOccupancyData = evaluateLowOccupancyAlert({ + status, + startDateTime: data.match?.start_date_time || prev.dateTime, + playerLimit: prev.playerCount, + activeParticipants: participantList, + dedupedInvitees: inviteList, + }); + const occupiedCount = countUniqueMatchOccupants( + participantList, + inviteList, + ); + return { + ...prev, + status, + occupied: occupiedCount, + lowOccupancy: lowOccupancyData, + }; + }); } catch (error) { console.error(error); if (!alive) return; setParticipants([]); + setInvitees([]); if (isMatchArchivedError(error)) { setIsArchived(true); setParticipantsError("This match has been archived. Invites are read-only."); @@ -184,6 +440,22 @@ const InviteScreen = ({ }; }, [matchId, onToast]); + useEffect(() => { + let alive = true; + if (isArchived) { + setSmartRecommendations([]); + setSmartRecommendationsError(""); + setSmartRecommendationsLoading(false); + return () => { + alive = false; + }; + } + fetchSmartRecommendations(() => alive); + return () => { + alive = false; + }; + }, [fetchSmartRecommendations, isArchived, lowOccupancy?.shortfall]); + useEffect(() => { const shouldSearch = (!isPrivateMatch && (searchTerm === "" || searchTerm.length >= 2)) || @@ -268,6 +540,75 @@ const InviteScreen = ({ [setSelectedPlayers], ); + const filteredSmartRecommendations = useMemo(() => { + const blocked = + existingPlayerIds instanceof Set + ? new Set(existingPlayerIds) + : new Set(existingPlayerIds || []); + const selectedIds = new Set( + Array.from(selectedPlayers.keys()).map((id) => Number(id)), + ); + if (currentUser?.id) { + const uid = Number(currentUser.id); + if (Number.isFinite(uid) && uid > 0) { + blocked.add(uid); + } + } + return smartRecommendations.filter((player) => { + const pid = Number(player.user_id); + if (!Number.isFinite(pid) || pid <= 0) return false; + if (blocked.has(pid)) return false; + if (selectedIds.has(pid)) return false; + return true; + }); + }, [smartRecommendations, existingPlayerIds, selectedPlayers, currentUser]); + + const recommendationLimit = Math.max( + Math.min(outstandingShortfall + 2, 6), + Math.min(filteredSmartRecommendations.length, 3), + ); + + const displayedSmartRecommendations = useMemo( + () => filteredSmartRecommendations.slice(0, recommendationLimit), + [filteredSmartRecommendations, recommendationLimit], + ); + + const handleAddSmartPlayer = useCallback( + (player) => { + const pid = Number(player.user_id); + if (!Number.isFinite(pid) || pid <= 0) return; + setSelectedPlayers((prev) => { + if (prev.has(pid)) return prev; + const next = new Map(prev); + next.set(pid, { ...player, user_id: pid }); + return next; + }); + }, + [setSelectedPlayers], + ); + + const handleAddAllSmartPlayers = useCallback(() => { + if (displayedSmartRecommendations.length === 0) return; + let added = 0; + setSelectedPlayers((prev) => { + const next = new Map(prev); + displayedSmartRecommendations.forEach((player) => { + const pid = Number(player.user_id); + if (!Number.isFinite(pid) || pid <= 0) return; + if (next.has(pid)) return; + next.set(pid, { ...player, user_id: pid }); + added += 1; + }); + return next; + }); + if (added > 0) { + onToast?.( + `Added ${added} recommended substitute${added === 1 ? "" : "s"} to your invite list.`, + "success", + ); + } + }, [displayedSmartRecommendations, onToast, setSelectedPlayers]); + const participantIsHost = (participant) => { if (!participant) return false; if (hostId) { @@ -312,18 +653,16 @@ const InviteScreen = ({ } try { await removeParticipant(matchId, playerId); - setParticipants((prev) => - prev.filter((p) => !idsMatch(p.player_id, playerId)), - ); + setParticipants((prev) => { + const next = prev.filter((p) => !idsMatch(p.player_id, playerId)); + recalcLowOccupancy(next, invitees); + return next; + }); setExistingPlayerIds((prev) => { const next = new Set([...prev]); next.delete(playerId); return next; }); - setMatchData((prev) => ({ - ...prev, - occupied: Math.max((prev.occupied || 1) - 1, 0), - })); onToast("Participant removed"); } catch (err) { if (isMatchArchivedError(err)) { @@ -410,6 +749,100 @@ const InviteScreen = ({ Need {matchData.playerCount - matchData.occupied} more{" "} {matchData.playerCount - matchData.occupied === 1 ? "player" : "players"}

+ {lowOccupancy && !isArchived && ( +
+
+
+ + Low occupancy alert + + {lowOccupancyLabel} + +
+ +
+

+ Need {lowOccupancy.openSpots} more player + {lowOccupancy.openSpots === 1 ? "" : "s"} before the match starts in {lowOccupancyTimeLabel}. {" "} + {outstandingInvites > 0 + ? `Only ${outstandingInvites} outstanding invite${ + outstandingInvites === 1 ? "" : "s" + } are out right now.` + : "No outstanding invites are out yet."} +

+
+ {smartRecommendationsLoading ? ( +

Finding ready-to-play substitutes…

+ ) : smartRecommendationsError ? ( +
+ {smartRecommendationsError} +
+ ) : displayedSmartRecommendations.length > 0 ? ( + <> +
+ Smart substitutes + +
+
+ {displayedSmartRecommendations.map((player) => { + const name = player.full_name || "Unknown player"; + const skill = player.skill_level || player.ntrp || ""; + const pid = Number(player.user_id); + return ( +
+
+
+

{name}

+ {skill && ( +

NTRP {skill}

+ )} + {player.home_court && ( +

+ {player.home_court} +

+ )} +
+ +
+
+ ); + })} +
+ + ) : ( +

+ We'll surface nearby substitutes as soon as we spot good matches. Try searching manually in the meantime. +

+ )} +
+
+ )} {isArchived && (
ARCHIVED diff --git a/src/utils/matchAlerts.js b/src/utils/matchAlerts.js new file mode 100644 index 00000000..06961002 --- /dev/null +++ b/src/utils/matchAlerts.js @@ -0,0 +1,113 @@ +import { + uniqueActiveParticipants, + uniqueInvitees, + dedupeByIdentity, +} from "./participants"; + +const DEFAULT_LOOKAHEAD_HOURS = 48; +const URGENT_THRESHOLD_HOURS = 12; +const WARNING_THRESHOLD_HOURS = 24; + +const toDate = (value) => { + if (!value) return null; + if (value instanceof Date && !Number.isNaN(value.getTime())) { + return value; + } + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +}; + +const toFiniteNumber = (value) => { + if (typeof value === "number") { + return Number.isFinite(value) ? value : null; + } + if (typeof value === "string") { + const numeric = Number.parseFloat(value); + return Number.isFinite(numeric) ? numeric : null; + } + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : null; +}; + +export const evaluateLowOccupancyAlert = ({ + status, + playerLimit, + startDateTime, + participants, + invitees, + activeParticipants, + dedupedInvitees, + now = new Date(), + lookaheadHours = DEFAULT_LOOKAHEAD_HOURS, +} = {}) => { + const normalizedStatus = + typeof status === "string" ? status.trim().toLowerCase() : ""; + if (normalizedStatus && normalizedStatus !== "upcoming") { + return null; + } + + const limit = toFiniteNumber(playerLimit); + if (!Number.isFinite(limit) || limit <= 0) { + return null; + } + + const matchDate = toDate(startDateTime); + if (!matchDate) return null; + + const hoursUntil = (matchDate.getTime() - now.getTime()) / (1000 * 60 * 60); + if (!Number.isFinite(hoursUntil) || hoursUntil < 0) { + return null; + } + if (hoursUntil > lookaheadHours) { + return null; + } + + const activeList = Array.isArray(activeParticipants) + ? uniqueActiveParticipants(activeParticipants) + : uniqueActiveParticipants(participants); + const inviteList = Array.isArray(dedupedInvitees) + ? uniqueInvitees(dedupedInvitees) + : uniqueInvitees(invitees); + + const activeCount = activeList.length; + const combinedUnique = dedupeByIdentity([...activeList, ...inviteList]); + const combinedCount = combinedUnique.length; + const inviteCoverage = Math.max(combinedCount - activeCount, 0); + const openSpots = Math.max(limit - activeCount, 0); + + if (openSpots <= 0) { + return null; + } + + if (inviteCoverage >= openSpots) { + return null; + } + + const shortfall = Math.max(openSpots - inviteCoverage, 0); + if (shortfall <= 0) { + return null; + } + + const severity = + hoursUntil <= URGENT_THRESHOLD_HOURS + ? "urgent" + : hoursUntil <= WARNING_THRESHOLD_HOURS + ? "warning" + : "soon"; + + return { + severity, + openSpots, + inviteCoverage, + shortfall, + participantCount: activeCount, + inviteeCount: inviteList.length, + combinedPotential: combinedCount, + hoursUntil, + matchTime: matchDate.toISOString(), + playerLimit: limit, + lookaheadHours, + }; +}; + +export default evaluateLowOccupancyAlert; From 2fae91471d9d21d11fd8df620fdb3ea584dbc666 Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Thu, 23 Oct 2025 07:34:34 -0700 Subject: [PATCH 2/4] Improve smart invite recommendations --- src/components/InviteScreen.jsx | 140 ++++++++++++++++++++++++-------- 1 file changed, 106 insertions(+), 34 deletions(-) diff --git a/src/components/InviteScreen.jsx b/src/components/InviteScreen.jsx index a88a4794..d67cccb6 100644 --- a/src/components/InviteScreen.jsx +++ b/src/components/InviteScreen.jsx @@ -191,7 +191,7 @@ const InviteScreen = ({ } const desired = Math.max(outstandingShortfall + 2, 4); - const perPage = Math.max(desired * 2, 12); + const pageSize = Math.max(desired * 2, 12); const searchTerms = buildSmartSearchTerms(); const blockedIds = new Set( existingPlayerIds instanceof Set @@ -219,52 +219,123 @@ const InviteScreen = ({ } } + if (!aliveCheck()) return; + setSmartRecommendationsLoading(true); setSmartRecommendationsError(""); const collected = []; - const seen = new Set(); + const seen = new Set(blockedIds); let lastError = null; - for (const term of searchTerms) { - if (!aliveCheck()) return; - try { - const data = await searchPlayers({ - search: term, - page: 1, - perPage, - }); - const players = Array.isArray(data?.players) ? data.players : []; - players.forEach((player) => { - const pid = Number(player.user_id ?? player.id); - if (!Number.isFinite(pid) || pid <= 0) return; - if (blockedIds.has(pid) || seen.has(pid)) return; - seen.add(pid); - collected.push({ ...player, user_id: pid }); - }); + const tryAddPlayer = (player) => { + const pid = Number(player?.user_id ?? player?.id); + if (!Number.isFinite(pid) || pid <= 0) return; + if (blockedIds.has(pid) || seen.has(pid)) return; + seen.add(pid); + collected.push({ ...player, user_id: pid }); + }; + + const fetchTerm = async (term) => { + let pageNumber = 1; + let hasMore = true; + while (hasMore && collected.length < desired) { + if (!aliveCheck()) { + return; + } + try { + const data = await searchPlayers({ + search: term, + page: pageNumber, + perPage: pageSize, + }); + const players = Array.isArray(data?.players) ? data.players : []; + players.forEach(tryAddPlayer); + const pagination = data?.pagination; + if (pagination) { + const total = Number(pagination.total); + const per = Number( + pagination.perPage ?? pagination.per_page ?? pageSize, + ); + const totalPages = + Number.isFinite(total) && Number.isFinite(per) && per > 0 + ? Math.max(Math.ceil(total / per), 1) + : null; + hasMore = + totalPages !== null + ? pageNumber < totalPages + : players.length === pageSize; + } else { + hasMore = players.length === pageSize; + } + pageNumber += 1; + } catch (error) { + console.error( + "Failed to load smart invite recommendations", + error, + ); + lastError = error; + hasMore = false; + } + } + }; + + try { + for (const term of searchTerms) { + if (!aliveCheck()) { + return; + } + await fetchTerm(term); if (collected.length >= desired) break; - } catch (error) { - console.error("Failed to load smart invite recommendations", error); - lastError = error; } - } - if (!aliveCheck()) return; + if (aliveCheck() && collected.length < desired) { + try { + const data = await listMatches("my", { perPage: 25 }); + const matches = Array.isArray(data?.matches) ? data.matches : []; + const fallbackPlayers = buildRecentPartnerSuggestions({ + matches, + currentUser, + memberIdentities, + }); + fallbackPlayers.forEach(tryAddPlayer); + } catch (error) { + console.error( + "Failed to load fallback smart invite recommendations", + error, + ); + if (!lastError) { + lastError = error; + } + } + } - if (collected.length === 0) { + if (!aliveCheck()) { + return; + } + + if (collected.length === 0) { + setSmartRecommendations([]); + setSmartRecommendationsError( + lastError + ? "We couldn't load recommendations right now. Try refreshing." + : "No ready substitutes found yet. Try searching manually or refreshing soon.", + ); + } else { + setSmartRecommendations(collected.slice(0, desired)); + setSmartRecommendationsError(""); + } + } catch (error) { + console.error("Failed to load smart invite recommendations", error); + if (!aliveCheck()) return; setSmartRecommendations([]); setSmartRecommendationsError( - lastError - ? "We couldn't load recommendations right now. Try refreshing." - : "No ready substitutes found yet. Try searching manually or refreshing soon.", + "We couldn't load recommendations right now. Try refreshing.", ); - } else { - setSmartRecommendations(collected.slice(0, desired)); - setSmartRecommendationsError(""); - } - - if (aliveCheck()) { - setSmartRecommendationsLoading(false); + } finally { + if (aliveCheck()) { + setSmartRecommendationsLoading(false); + } } }, [ @@ -273,6 +344,7 @@ const InviteScreen = ({ existingPlayerIds, invitees, lowOccupancy, + memberIdentities, outstandingShortfall, participants, ], From caf83e8ba7890fbde09783711a924064d8cd8218 Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Thu, 23 Oct 2025 07:45:16 -0700 Subject: [PATCH 3/4] Broaden low occupancy detection --- src/utils/matchAlerts.js | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/utils/matchAlerts.js b/src/utils/matchAlerts.js index 06961002..80b9f677 100644 --- a/src/utils/matchAlerts.js +++ b/src/utils/matchAlerts.js @@ -4,9 +4,30 @@ import { dedupeByIdentity, } from "./participants"; -const DEFAULT_LOOKAHEAD_HOURS = 48; +const DEFAULT_LOOKAHEAD_HOURS = 24 * 7; // one week lookahead by default const URGENT_THRESHOLD_HOURS = 12; const WARNING_THRESHOLD_HOURS = 24; +const DRAFT_STATUS = "draft"; +const FINALIZED_STATUS_KEYWORDS = [ + "archive", + "cancel", + "complete", + "finish", + "final", + "closed", + "past", + "expired", +]; + +const isInactiveMatchStatus = (status) => { + if (!status) return false; + const normalized = status.toString().trim().toLowerCase(); + if (!normalized) return false; + if (normalized === DRAFT_STATUS) return true; + return FINALIZED_STATUS_KEYWORDS.some((keyword) => + normalized.includes(keyword), + ); +}; const toDate = (value) => { if (!value) return null; @@ -42,7 +63,7 @@ export const evaluateLowOccupancyAlert = ({ } = {}) => { const normalizedStatus = typeof status === "string" ? status.trim().toLowerCase() : ""; - if (normalizedStatus && normalizedStatus !== "upcoming") { + if (isInactiveMatchStatus(normalizedStatus)) { return null; } From 6e21d9b0e5a24fad77d24def7bea0cbecc622746 Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Thu, 23 Oct 2025 07:56:40 -0700 Subject: [PATCH 4/4] Refine inactive status detection for alerts --- src/utils/matchAlerts.js | 39 +++++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/src/utils/matchAlerts.js b/src/utils/matchAlerts.js index 80b9f677..28c44710 100644 --- a/src/utils/matchAlerts.js +++ b/src/utils/matchAlerts.js @@ -8,25 +8,42 @@ const DEFAULT_LOOKAHEAD_HOURS = 24 * 7; // one week lookahead by default const URGENT_THRESHOLD_HOURS = 12; const WARNING_THRESHOLD_HOURS = 24; const DRAFT_STATUS = "draft"; -const FINALIZED_STATUS_KEYWORDS = [ +const INACTIVE_STATUS_TOKENS = new Set([ "archive", + "archived", "cancel", + "canceled", + "cancelled", "complete", + "completed", "finish", + "finished", "final", + "finalized", + "finalised", "closed", "past", "expired", -]; +]); + +const getStatusTokens = (status) => { + if (!status) return []; + return status + .toString() + .trim() + .toLowerCase() + .split(/[^a-z0-9]+/) + .map((token) => token.trim()) + .filter(Boolean); +}; const isInactiveMatchStatus = (status) => { - if (!status) return false; - const normalized = status.toString().trim().toLowerCase(); - if (!normalized) return false; - if (normalized === DRAFT_STATUS) return true; - return FINALIZED_STATUS_KEYWORDS.some((keyword) => - normalized.includes(keyword), - ); + const tokens = getStatusTokens(status); + if (tokens.length === 0) return false; + if (tokens.includes(DRAFT_STATUS)) { + return true; + } + return tokens.some((token) => INACTIVE_STATUS_TOKENS.has(token)); }; const toDate = (value) => { @@ -61,9 +78,7 @@ export const evaluateLowOccupancyAlert = ({ now = new Date(), lookaheadHours = DEFAULT_LOOKAHEAD_HOURS, } = {}) => { - const normalizedStatus = - typeof status === "string" ? status.trim().toLowerCase() : ""; - if (isInactiveMatchStatus(normalizedStatus)) { + if (isInactiveMatchStatus(status)) { return null; }