diff --git a/MPCAutofill/cardpicker/attribute_tags.py b/MPCAutofill/cardpicker/attribute_tags.py new file mode 100644 index 000000000..c243d78d2 --- /dev/null +++ b/MPCAutofill/cardpicker/attribute_tags.py @@ -0,0 +1,60 @@ +""" +Tags backing the "What's That Card?" attribute chips (see docs/features/printing-tags.md, +questionFeed section) that aren't already part of the bracket-text taxonomy in +`cardpicker.default_tags`. "Full Art", "Borderless", "Showcase", and "Extended" are reused +as-is from `DEFAULT_TAGS` - this module only seeds the ones with no existing row: the border- +color and frame-era exclusion groups, plus "Etched" (a `frame_effects` value with no bracket- +text precedent). + +Same idempotent-seed-command-not-migration pattern as `default_tags.py`/`sensitive_tags.py` - +see either's header comment for why. +""" + +from typing import Optional + +from cardpicker.models import Tag + +# (name, display_name). None of these are sensitive - always TagModerationClass.STANDARD +# (the Tag model default), unlike sensitive_tags.py's seed list. +ATTRIBUTE_TAGS: list[tuple[str, Optional[str]]] = [ + ("Etched", None), + ("Black Border", None), + ("White Border", None), + ("Silver Border", None), + ("Old Border", None), + ("Modern Border", None), + ("Future Frame", None), +] + +# The full chip-tag taxonomy the questionFeed confidence overlay computes net-polarity for - +# the four reused tags above plus the seven seeded here. Kept here (not duplicated in +# question_feed.py) since this module owns the "what are the attribute chip tags" question. +ATTRIBUTE_CHIP_TAG_NAMES: list[str] = [ + "Full Art", + "Borderless", + "Showcase", + "Extended", + "Etched", + "Black Border", + "White Border", + "Silver Border", + "Old Border", + "Modern Border", + "Future Frame", +] + + +def seed_attribute_tags() -> dict[str, int]: + """Idempotent - safe to re-run. Creates any tag that doesn't exist yet; never overwrites + an existing row (mirrors seed_sensitive_tags's "never overwrite a manual edit" contract, + minus the moderation_class upgrade path - these were never sensitive).""" + + created = 0 + for name, display_name in ATTRIBUTE_TAGS: + _tag, was_created = Tag.objects.get_or_create(name=name, defaults={"aliases": [], "display_name": display_name}) + if was_created: + created += 1 + return {"created": created} + + +__all__ = ["seed_attribute_tags", "ATTRIBUTE_TAGS", "ATTRIBUTE_CHIP_TAG_NAMES"] diff --git a/MPCAutofill/cardpicker/management/commands/seed_attribute_tags.py b/MPCAutofill/cardpicker/management/commands/seed_attribute_tags.py new file mode 100644 index 000000000..6460fc6af --- /dev/null +++ b/MPCAutofill/cardpicker/management/commands/seed_attribute_tags.py @@ -0,0 +1,16 @@ +from typing import Any + +from django.core.management.base import BaseCommand + +from cardpicker.attribute_tags import seed_attribute_tags + + +class Command(BaseCommand): + help = ( + 'Seeds the attribute-chip tag taxonomy for the "What\'s That Card?" questionFeed ' + "(Etched, Black/White/Silver Border, Old/Modern Border, Future Frame). Safe to re-run." + ) + + def handle(self, *args: Any, **kwargs: Any) -> None: + stats = seed_attribute_tags() + print(f"Attribute tags: {stats['created']} created.") diff --git a/MPCAutofill/cardpicker/models.py b/MPCAutofill/cardpicker/models.py index 7a20f5099..3e7b21da1 100755 --- a/MPCAutofill/cardpicker/models.py +++ b/MPCAutofill/cardpicker/models.py @@ -114,6 +114,7 @@ def serialise_as_printing_candidate(self) -> PrintingCandidate: `serialise()`'s embedded-in-a-resolved-Card shape needs. """ metadata = getattr(self, "printing_metadata", None) + frame_effects = metadata.frame_effects if metadata is not None else [] return PrintingCandidate( identifier=str(self.identifier), canonicalId=str(self.canonical_id), @@ -126,6 +127,13 @@ def serialise_as_printing_candidate(self) -> PrintingCandidate: fullArt=metadata.full_art if metadata is not None else False, isBorderless=metadata.border_color == "borderless" if metadata is not None else False, frame=metadata.frame if metadata is not None else "", + borderColor=metadata.border_color if metadata is not None else "", + # curated subset of the `frame_effects` list with a dedicated attribute chip - see + # cardpicker.attribute_tags / docs/features/printing-tags.md's questionFeed section + # for why these three and not the (more numerous) rest of the field's values. + isShowcase="showcase" in frame_effects, + isExtendedArt="extendedart" in frame_effects, + isEtched="etched" in frame_effects, releasedAt=metadata.released_at.isoformat() if metadata is not None and metadata.released_at else None, ) diff --git a/MPCAutofill/cardpicker/question_feed.py b/MPCAutofill/cardpicker/question_feed.py new file mode 100644 index 000000000..f80bd1b0e --- /dev/null +++ b/MPCAutofill/cardpicker/question_feed.py @@ -0,0 +1,240 @@ +""" +Backs `GET 2/questionFeed/` - the unified single-question feed that replaces the three +printing/artist/tag tabs (see docs/features/printing-tags.md's questionFeed section and +journal/2026-07-14-queue-question-feed-design.md for the full design writeup this +implements). Deliberately a "dumb ranked union" per spec: four fixed-order tiers, first +non-empty match wins, no cross-tier scoring/ML. + +Tier 1 (confirm_suggestion) is large relative to the others at current volume (28,112 cards +- the full AI deductive-vote backfill, confirmed via a live query during design) - a voter +working only this feed will not reach tiers 2-4 until tier 1 is exhausted. Flagged as a known +v1 property, not silently accepted - see the design doc's "Starvation risk" section for the +concrete consequence and the planned v2 fix (interleaved/weighted union, out of scope here). +""" + +from typing import Optional + +from django.db.models import Count + +from cardpicker.artist_consensus import get_contested_artist_card_ids +from cardpicker.attribute_tags import ATTRIBUTE_CHIP_TAG_NAMES +from cardpicker.models import ( + ArtistVoteStatus, + Card, + CardTagVote, + PrintingTagStatus, + Tag, + TagVoteStatus, + VoteSource, +) +from cardpicker.moderation import is_moderator +from cardpicker.printing_candidates import get_ranked_printing_candidates +from cardpicker.printing_consensus import get_contested_card_ids +from cardpicker.schema_types import QuestionFeedItem, TypeEnum +from cardpicker.tag_consensus import ( + get_pending_approval_queue_pairs, + get_tag_net_polarity, + get_tag_review_queue_pairs, +) + + +def _tag_confidence(card: Card) -> dict[str, float]: + """netPolarity for every attribute-chip tag against `card`, for the chip fill overlay - + always the full fixed set (not just tags with votes), so an unvoted chip predictably reads + as 0.0 (neutral) rather than being absent from the payload.""" + tags_by_name = {tag.name: tag for tag in Tag.objects.filter(name__in=ATTRIBUTE_CHIP_TAG_NAMES)} + return {name: get_tag_net_polarity(card, tag) for name, tag in tags_by_name.items()} + + +def _confirm_suggestion_item(card: Card) -> Optional[QuestionFeedItem]: + ai_vote = ( + card.printing_tags.filter(source=VoteSource.AI, is_no_match=False) + .select_related("printing__expansion", "printing__printing_metadata", "printing__artist") + .first() + ) + if ai_vote is None or ai_vote.printing is None: + return None + candidates = get_ranked_printing_candidates(card, card.name) + return QuestionFeedItem( + type=TypeEnum.confirmsuggestion, + card=card.serialise(), + suggestedPrinting=ai_vote.printing.serialise_as_printing_candidate(), + candidates=[candidate.serialise_as_printing_candidate() for candidate in candidates], + tagConfidence=_tag_confidence(card), + ) + + +def _identify_printing_item(card: Card) -> QuestionFeedItem: + candidates = get_ranked_printing_candidates(card, card.name) + return QuestionFeedItem( + type=TypeEnum.identifyprinting, + card=card.serialise(), + candidates=[candidate.serialise_as_printing_candidate() for candidate in candidates], + tagConfidence=_tag_confidence(card), + ) + + +def _artist_item(card: Card) -> QuestionFeedItem: + serialised = card.serialise() + confidently_known_artist_name = ( + serialised.canonicalArtist.name + if serialised.canonicalArtist is not None and not serialised.canonicalArtistIsFromVoteOnly + else None + ) + return QuestionFeedItem( + type=TypeEnum.artist, card=serialised, confidentlyKnownArtistName=confidently_known_artist_name + ) + + +def _tag_item(card: Card, tag_name: str) -> QuestionFeedItem: + return QuestionFeedItem(type=TypeEnum.tag, card=card.serialise(), tagName=tag_name) + + +def _moderation_item(card: Card, tag_name: str, report_count: int, excerpts: list[str]) -> QuestionFeedItem: + return QuestionFeedItem( + type=TypeEnum.moderation, + card=card.serialise(), + tagName=tag_name, + reportCount=report_count, + reportExcerpts=excerpts, + ) + + +def _tier_1_confirm_suggestion(anonymous_id: str) -> Optional[QuestionFeedItem]: + cards = ( + Card.objects.filter(printing_tag_status=PrintingTagStatus.UNRESOLVED, printing_tags__source=VoteSource.AI) + .exclude(printing_tags__source__in=[VoteSource.USER, VoteSource.ADMIN, VoteSource.FEDERATED]) + .exclude(printing_tags__anonymous_id=anonymous_id) + .distinct() + .order_by("date_created") + ) + for card in cards.iterator(): + item = _confirm_suggestion_item(card) + if item is not None: + return item + return None + + +def _tier_2_contested(anonymous_id: str) -> Optional[QuestionFeedItem]: + printing_card = ( + Card.objects.filter(printing_tag_status=PrintingTagStatus.UNRESOLVED, pk__in=get_contested_card_ids()) + .exclude(printing_tags__anonymous_id=anonymous_id) + .order_by("-date_created") + .first() + ) + if printing_card is not None: + return _identify_printing_item(printing_card) + + artist_card = ( + Card.objects.filter(artist_vote_status=ArtistVoteStatus.CONTESTED, pk__in=get_contested_artist_card_ids()) + .exclude(artist_votes__anonymous_id=anonymous_id) + .order_by("-date_created") + .first() + ) + if artist_card is not None: + return _artist_item(artist_card) + + for card_id, tag_name in get_tag_review_queue_pairs(): + # scoped to (card, tag, anonymous_id), not just (card, anonymous_id) - a voter who + # already answered a *different* tag on this card (there are ~11 attribute-chip tags + # per card) must still see this tag if they haven't answered it yet. A card-level + # exclude here would silently hide every other still-open tag on a card the moment + # this voter touches any one tag on it. + if CardTagVote.objects.filter(card_id=card_id, tag__name=tag_name, anonymous_id=anonymous_id).exists(): + continue + card = Card.objects.get(pk=card_id) + status = card.tag_vote_statuses.get(tag_name) + if status == TagVoteStatus.CONTESTED: + return _tag_item(card, tag_name) + return None + + +def _tier_3_moderation(user: object) -> Optional[QuestionFeedItem]: + """Same (card, tag) selection, report-count, and excerpt logic as `post_moderation_queue` + (views.py), narrowed to just the single highest-priority pair - see that view for why + reasons are matched via `REPORT_REASON_TO_TAG_NAME` and excerpts are capped at 3.""" + if not is_moderator(user): # type: ignore[arg-type] # is_moderator accepts AbstractUser | AnonymousUser + return None + from cardpicker.models import CardReport + from cardpicker.sensitive_tags import REPORT_REASON_TO_TAG_NAME + + pairs = get_pending_approval_queue_pairs() + if not pairs: + return None + card_id, tag_name = pairs[0] + card = Card.objects.get(pk=card_id) + reasons = [reason for reason, name in REPORT_REASON_TO_TAG_NAME.items() if name == tag_name] + reports = CardReport.objects.filter(card_id=card_id, reason__in=reasons) + excerpts = [row["text"] for row in reports.exclude(text="").order_by("-created_at").values("text")[:3]] + return _moderation_item(card, tag_name, report_count=reports.count(), excerpts=excerpts) + + +def _tier_4_fresh(anonymous_id: str) -> Optional[QuestionFeedItem]: + # A card with one AI-sourced vote plus one *agreeing* human vote (weight 1.5 at default + # settings - still short of PRINTING_TAG_MIN_VOTES=2) is exactly as close to resolving as + # a card can get without being resolved outright, yet it's excluded from tier 1 (any human + # vote moves a card out of tier 1's "AI-only" pool) and isn't contested (agreeing votes, + # not conflicting, so tier 2's contested check doesn't catch it either) - it lands here, + # in tier 4, with zero votes and 28,112 genuinely-untouched cards. `-vote_count` surfaces + # these "one vote from resolving" cards first within this tier, a small, concrete answer + # to "prioritize whichever question is closest to actually resolving" without building a + # full scoring system (out of scope - see this module's docstring). + printing_card = ( + Card.objects.filter(printing_tag_status=PrintingTagStatus.UNRESOLVED) + .exclude(pk__in=get_contested_card_ids()) + .exclude(printing_tags__anonymous_id=anonymous_id) + .annotate(vote_count=Count("printing_tags", distinct=True)) + .order_by("-vote_count", "-date_created") + .first() + ) + if printing_card is not None: + return _identify_printing_item(printing_card) + + artist_card = ( + Card.objects.filter(artist_vote_status=ArtistVoteStatus.UNRESOLVED) + .exclude(artist_votes__anonymous_id=anonymous_id) + .order_by("-date_created") + .first() + ) + if artist_card is not None: + return _artist_item(artist_card) + + for card_id, tag_name in get_tag_review_queue_pairs(): + # see tier 2's identical comment above - scoped to (card, tag, anonymous_id) + if CardTagVote.objects.filter(card_id=card_id, tag__name=tag_name, anonymous_id=anonymous_id).exists(): + continue + card = Card.objects.get(pk=card_id) + status = card.tag_vote_statuses.get(tag_name) + if status == TagVoteStatus.UNRESOLVED: + return _tag_item(card, tag_name) + return None + + +def get_next_question_feed_item(anonymous_id: str, user: object) -> Optional[QuestionFeedItem]: + """The dumb ranked union itself - first non-None tier wins, in priority order.""" + return ( + _tier_1_confirm_suggestion(anonymous_id) + or _tier_2_contested(anonymous_id) + or _tier_3_moderation(user) + or _tier_4_fresh(anonymous_id) + ) + + +def get_remaining_estimate(user: object) -> int: + """ + Best-effort total across tiers, for "Still need N cards" messaging - NOT per-voter (doesn't + account for own-vote exclusion, which is comparatively cheap to skip here since this is + advisory copy, not a candidate set). Moderator-only tier only counted for moderators, same + visibility rule as the feed itself. + """ + estimate = ( + Card.objects.filter(printing_tag_status=PrintingTagStatus.UNRESOLVED).count() + + Card.objects.filter(artist_vote_status__in=[ArtistVoteStatus.UNRESOLVED, ArtistVoteStatus.CONTESTED]).count() + + len(get_tag_review_queue_pairs()) + ) + if is_moderator(user): # type: ignore[arg-type] + estimate += len(get_pending_approval_queue_pairs()) + return estimate + + +__all__ = ["get_next_question_feed_item", "get_remaining_estimate"] diff --git a/MPCAutofill/cardpicker/schema_types.py b/MPCAutofill/cardpicker/schema_types.py index a01af62cd..c257993f7 100644 --- a/MPCAutofill/cardpicker/schema_types.py +++ b/MPCAutofill/cardpicker/schema_types.py @@ -18,6 +18,11 @@ def from_str(x: Any) -> str: return x +def from_bool(x: Any) -> bool: + assert isinstance(x, bool) + return x + + def from_none(x: Any) -> Any: assert x is None return x @@ -32,24 +37,14 @@ def from_union(fs, x): assert False -def from_list(f: Callable[[Any], T], x: Any) -> List[T]: - assert isinstance(x, list) - return [f(y) for y in x] - - -def to_class(c: Type[T], x: Any) -> dict: - assert isinstance(x, c) - return cast(Any, x).to_dict() - - def from_int(x: Any) -> int: assert isinstance(x, int) and not isinstance(x, bool) return x -def from_bool(x: Any) -> bool: - assert isinstance(x, bool) - return x +def from_list(f: Callable[[Any], T], x: Any) -> List[T]: + assert isinstance(x, list) + return [f(y) for y in x] def to_enum(c: Type[EnumT], x: Any) -> EnumT: @@ -57,6 +52,11 @@ def to_enum(c: Type[EnumT], x: Any) -> EnumT: return x.value +def to_class(c: Type[T], x: Any) -> dict: + assert isinstance(x, c) + return cast(Any, x).to_dict() + + def from_dict(f: Callable[[Any], T], x: Any) -> Dict[str, T]: assert isinstance(x, dict) return {k: f(v) for (k, v) in x.items()} @@ -76,22 +76,81 @@ class Game(str, Enum): MTG = "MTG" -class ArtistCandidatesRequest(BaseModel): +class PrintingCandidate(BaseModel): + artist: str + borderColor: str + canonicalId: str + collectorNumber: str + expansionCode: str + expansionName: str + frame: str + fullArt: bool identifier: str - query: Optional[str] = None + isBorderless: bool + isEtched: bool + isExtendedArt: bool + isShowcase: bool + mediumThumbnailUrl: str + smallThumbnailUrl: str + releasedAt: Optional[str] = None @staticmethod - def from_dict(obj: Any) -> "ArtistCandidatesRequest": + def from_dict(obj: Any) -> "PrintingCandidate": assert isinstance(obj, dict) + artist = from_str(obj.get("artist")) + borderColor = from_str(obj.get("borderColor")) + canonicalId = from_str(obj.get("canonicalId")) + collectorNumber = from_str(obj.get("collectorNumber")) + expansionCode = from_str(obj.get("expansionCode")) + expansionName = from_str(obj.get("expansionName")) + frame = from_str(obj.get("frame")) + fullArt = from_bool(obj.get("fullArt")) identifier = from_str(obj.get("identifier")) - query = from_union([from_none, from_str], obj.get("query")) - return ArtistCandidatesRequest(identifier, query) + isBorderless = from_bool(obj.get("isBorderless")) + isEtched = from_bool(obj.get("isEtched")) + isExtendedArt = from_bool(obj.get("isExtendedArt")) + isShowcase = from_bool(obj.get("isShowcase")) + mediumThumbnailUrl = from_str(obj.get("mediumThumbnailUrl")) + smallThumbnailUrl = from_str(obj.get("smallThumbnailUrl")) + releasedAt = from_union([from_none, from_str], obj.get("releasedAt")) + return PrintingCandidate( + artist, + borderColor, + canonicalId, + collectorNumber, + expansionCode, + expansionName, + frame, + fullArt, + identifier, + isBorderless, + isEtched, + isExtendedArt, + isShowcase, + mediumThumbnailUrl, + smallThumbnailUrl, + releasedAt, + ) def to_dict(self) -> dict: result: dict = {} + result["artist"] = from_str(self.artist) + result["borderColor"] = from_str(self.borderColor) + result["canonicalId"] = from_str(self.canonicalId) + result["collectorNumber"] = from_str(self.collectorNumber) + result["expansionCode"] = from_str(self.expansionCode) + result["expansionName"] = from_str(self.expansionName) + result["frame"] = from_str(self.frame) + result["fullArt"] = from_bool(self.fullArt) result["identifier"] = from_str(self.identifier) - if self.query is not None: - result["query"] = from_union([from_none, from_str], self.query) + result["isBorderless"] = from_bool(self.isBorderless) + result["isEtched"] = from_bool(self.isEtched) + result["isExtendedArt"] = from_bool(self.isExtendedArt) + result["isShowcase"] = from_bool(self.isShowcase) + result["mediumThumbnailUrl"] = from_str(self.mediumThumbnailUrl) + result["smallThumbnailUrl"] = from_str(self.smallThumbnailUrl) + if self.releasedAt is not None: + result["releasedAt"] = from_union([from_none, from_str], self.releasedAt) return result @@ -110,6 +169,325 @@ def to_dict(self) -> dict: return result +class CanonicalCardClass(BaseModel): + collectorNumber: str + expansionCode: str + expansionName: str + identifier: str + mediumThumbnailUrl: str + smallThumbnailUrl: str + artist: Optional[str] = None + canonicalId: Optional[str] = None + + @staticmethod + def from_dict(obj: Any) -> "CanonicalCardClass": + assert isinstance(obj, dict) + collectorNumber = from_str(obj.get("collectorNumber")) + expansionCode = from_str(obj.get("expansionCode")) + expansionName = from_str(obj.get("expansionName")) + identifier = from_str(obj.get("identifier")) + mediumThumbnailUrl = from_str(obj.get("mediumThumbnailUrl")) + smallThumbnailUrl = from_str(obj.get("smallThumbnailUrl")) + artist = from_union([from_str, from_none], obj.get("artist")) + canonicalId = from_union([from_str, from_none], obj.get("canonicalId")) + return CanonicalCardClass( + collectorNumber, + expansionCode, + expansionName, + identifier, + mediumThumbnailUrl, + smallThumbnailUrl, + artist, + canonicalId, + ) + + def to_dict(self) -> dict: + result: dict = {} + result["collectorNumber"] = from_str(self.collectorNumber) + result["expansionCode"] = from_str(self.expansionCode) + result["expansionName"] = from_str(self.expansionName) + result["identifier"] = from_str(self.identifier) + result["mediumThumbnailUrl"] = from_str(self.mediumThumbnailUrl) + result["smallThumbnailUrl"] = from_str(self.smallThumbnailUrl) + if self.artist is not None: + result["artist"] = from_union([from_str, from_none], self.artist) + if self.canonicalId is not None: + result["canonicalId"] = from_union([from_str, from_none], self.canonicalId) + return result + + +class CardType(str, Enum): + CARD = "CARD" + CARDBACK = "CARDBACK" + TOKEN = "TOKEN" + + +class PrintingTagStatus(str, Enum): + """Community printing-tag vote consensus status for this card. Only RESOLVED cards have a + community-confirmed printing behind canonicalCard (via inferred_canonical_card) - used by + the frontend to show a 'matched by community tags' indicator and is otherwise + informational. + """ + + nomatch = "no_match" + resolved = "resolved" + unresolved = "unresolved" + + +class SourceType(str, Enum): + AWSS3 = "AWS S3" + GoogleDrive = "Google Drive" + LocalFile = "Local File" + + +class Card(BaseModel): + cardType: CardType + dateCreated: str + """Created date - formatted by backend""" + + dateModified: str + """Modified date - formatted by backend""" + + dpi: int + extension: str + identifier: str + language: str + mediumThumbnailUrl: str + name: str + printingTagStatus: PrintingTagStatus + """Community printing-tag vote consensus status for this card. Only RESOLVED cards have a + community-confirmed printing behind canonicalCard (via inferred_canonical_card) - used by + the frontend to show a 'matched by community tags' indicator and is otherwise + informational. + """ + priority: int + searchq: str + size: int + smallThumbnailUrl: str + source: str + sourceId: int + sourceName: str + sourceVerbose: str + tags: List[str] + canonicalArtist: Optional[CanonicalArtistClass] = None + canonicalArtistIsFromVoteOnly: Optional[bool] = None + """True only when canonicalArtist was supplied by artist-vote consensus alone, with no + confirmed indexing match or resolved printing backing it - lets the frontend distinguish + a confidently-known artist from a vote-derived one (e.g. for the ArtistVotePicker + 'wrong?' affordance) without needing to know serialise()'s fallback chain itself. + """ + canonicalArtistSource: Optional[str] = None + """Which rung of the artist fallback chain actually supplied canonicalArtist - + debug/introspection field, not load-bearing for any current frontend logic. + """ + canonicalCard: Optional[CanonicalCardClass] = None + sourceExternalLink: Optional[str] = None + sourceType: Optional[SourceType] = None + + @staticmethod + def from_dict(obj: Any) -> "Card": + assert isinstance(obj, dict) + cardType = CardType(obj.get("cardType")) + dateCreated = from_str(obj.get("dateCreated")) + dateModified = from_str(obj.get("dateModified")) + dpi = from_int(obj.get("dpi")) + extension = from_str(obj.get("extension")) + identifier = from_str(obj.get("identifier")) + language = from_str(obj.get("language")) + mediumThumbnailUrl = from_str(obj.get("mediumThumbnailUrl")) + name = from_str(obj.get("name")) + printingTagStatus = PrintingTagStatus(obj.get("printingTagStatus")) + priority = from_int(obj.get("priority")) + searchq = from_str(obj.get("searchq")) + size = from_int(obj.get("size")) + smallThumbnailUrl = from_str(obj.get("smallThumbnailUrl")) + source = from_str(obj.get("source")) + sourceId = from_int(obj.get("sourceId")) + sourceName = from_str(obj.get("sourceName")) + sourceVerbose = from_str(obj.get("sourceVerbose")) + tags = from_list(from_str, obj.get("tags")) + canonicalArtist = from_union([from_none, CanonicalArtistClass.from_dict], obj.get("canonicalArtist")) + canonicalArtistIsFromVoteOnly = from_union([from_bool, from_none], obj.get("canonicalArtistIsFromVoteOnly")) + canonicalArtistSource = from_union([from_none, from_str], obj.get("canonicalArtistSource")) + canonicalCard = from_union([from_none, CanonicalCardClass.from_dict], obj.get("canonicalCard")) + sourceExternalLink = from_union([from_str, from_none], obj.get("sourceExternalLink")) + sourceType = from_union([SourceType, from_none], obj.get("sourceType")) + return Card( + cardType, + dateCreated, + dateModified, + dpi, + extension, + identifier, + language, + mediumThumbnailUrl, + name, + printingTagStatus, + priority, + searchq, + size, + smallThumbnailUrl, + source, + sourceId, + sourceName, + sourceVerbose, + tags, + canonicalArtist, + canonicalArtistIsFromVoteOnly, + canonicalArtistSource, + canonicalCard, + sourceExternalLink, + sourceType, + ) + + def to_dict(self) -> dict: + result: dict = {} + result["cardType"] = to_enum(CardType, self.cardType) + result["dateCreated"] = from_str(self.dateCreated) + result["dateModified"] = from_str(self.dateModified) + result["dpi"] = from_int(self.dpi) + result["extension"] = from_str(self.extension) + result["identifier"] = from_str(self.identifier) + result["language"] = from_str(self.language) + result["mediumThumbnailUrl"] = from_str(self.mediumThumbnailUrl) + result["name"] = from_str(self.name) + result["printingTagStatus"] = to_enum(PrintingTagStatus, self.printingTagStatus) + result["priority"] = from_int(self.priority) + result["searchq"] = from_str(self.searchq) + result["size"] = from_int(self.size) + result["smallThumbnailUrl"] = from_str(self.smallThumbnailUrl) + result["source"] = from_str(self.source) + result["sourceId"] = from_int(self.sourceId) + result["sourceName"] = from_str(self.sourceName) + result["sourceVerbose"] = from_str(self.sourceVerbose) + result["tags"] = from_list(from_str, self.tags) + if self.canonicalArtist is not None: + result["canonicalArtist"] = from_union( + [from_none, lambda x: to_class(CanonicalArtistClass, x)], self.canonicalArtist + ) + if self.canonicalArtistIsFromVoteOnly is not None: + result["canonicalArtistIsFromVoteOnly"] = from_union( + [from_bool, from_none], self.canonicalArtistIsFromVoteOnly + ) + if self.canonicalArtistSource is not None: + result["canonicalArtistSource"] = from_union([from_none, from_str], self.canonicalArtistSource) + if self.canonicalCard is not None: + result["canonicalCard"] = from_union( + [from_none, lambda x: to_class(CanonicalCardClass, x)], self.canonicalCard + ) + if self.sourceExternalLink is not None: + result["sourceExternalLink"] = from_union([from_str, from_none], self.sourceExternalLink) + if self.sourceType is not None: + result["sourceType"] = from_union([lambda x: to_enum(SourceType, x), from_none], self.sourceType) + return result + + +class TypeEnum(str, Enum): + artist = "artist" + confirmsuggestion = "confirm_suggestion" + identifyprinting = "identify_printing" + moderation = "moderation" + tag = "tag" + + +class QuestionFeedItem(BaseModel): + card: Card + type: TypeEnum + candidates: Optional[List[PrintingCandidate]] = None + confidentlyKnownArtistName: Optional[str] = None + reportCount: Optional[int] = None + reportExcerpts: Optional[List[str]] = None + suggestedPrinting: Optional[PrintingCandidate] = None + tagConfidence: Optional[Dict[str, float]] = None + tagName: Optional[str] = None + + @staticmethod + def from_dict(obj: Any) -> "QuestionFeedItem": + assert isinstance(obj, dict) + card = Card.from_dict(obj.get("card")) + type = TypeEnum(obj.get("type")) + candidates = from_union([lambda x: from_list(PrintingCandidate.from_dict, x), from_none], obj.get("candidates")) + confidentlyKnownArtistName = from_union([from_none, from_str], obj.get("confidentlyKnownArtistName")) + reportCount = from_union([from_int, from_none], obj.get("reportCount")) + reportExcerpts = from_union([lambda x: from_list(from_str, x), from_none], obj.get("reportExcerpts")) + suggestedPrinting = from_union([PrintingCandidate.from_dict, from_none], obj.get("suggestedPrinting")) + tagConfidence = from_union([lambda x: from_dict(from_float, x), from_none], obj.get("tagConfidence")) + tagName = from_union([from_str, from_none], obj.get("tagName")) + return QuestionFeedItem( + card, + type, + candidates, + confidentlyKnownArtistName, + reportCount, + reportExcerpts, + suggestedPrinting, + tagConfidence, + tagName, + ) + + def to_dict(self) -> dict: + result: dict = {} + result["card"] = to_class(Card, self.card) + result["type"] = to_enum(TypeEnum, self.type) + if self.candidates is not None: + result["candidates"] = from_union( + [lambda x: from_list(lambda x: to_class(PrintingCandidate, x), x), from_none], self.candidates + ) + if self.confidentlyKnownArtistName is not None: + result["confidentlyKnownArtistName"] = from_union([from_none, from_str], self.confidentlyKnownArtistName) + if self.reportCount is not None: + result["reportCount"] = from_union([from_int, from_none], self.reportCount) + if self.reportExcerpts is not None: + result["reportExcerpts"] = from_union([lambda x: from_list(from_str, x), from_none], self.reportExcerpts) + if self.suggestedPrinting is not None: + result["suggestedPrinting"] = from_union( + [lambda x: to_class(PrintingCandidate, x), from_none], self.suggestedPrinting + ) + if self.tagConfidence is not None: + result["tagConfidence"] = from_union([lambda x: from_dict(to_float, x), from_none], self.tagConfidence) + if self.tagName is not None: + result["tagName"] = from_union([from_str, from_none], self.tagName) + return result + + +class QuestionFeedResponse(BaseModel): + remainingEstimate: int + item: Optional[QuestionFeedItem] = None + + @staticmethod + def from_dict(obj: Any) -> "QuestionFeedResponse": + assert isinstance(obj, dict) + remainingEstimate = from_int(obj.get("remainingEstimate")) + item = from_union([QuestionFeedItem.from_dict, from_none], obj.get("item")) + return QuestionFeedResponse(remainingEstimate, item) + + def to_dict(self) -> dict: + result: dict = {} + result["remainingEstimate"] = from_int(self.remainingEstimate) + if self.item is not None: + result["item"] = from_union([lambda x: to_class(QuestionFeedItem, x), from_none], self.item) + return result + + +class ArtistCandidatesRequest(BaseModel): + identifier: str + query: Optional[str] = None + + @staticmethod + def from_dict(obj: Any) -> "ArtistCandidatesRequest": + assert isinstance(obj, dict) + identifier = from_str(obj.get("identifier")) + query = from_union([from_none, from_str], obj.get("query")) + return ArtistCandidatesRequest(identifier, query) + + def to_dict(self) -> dict: + result: dict = {} + result["identifier"] = from_str(self.identifier) + if self.query is not None: + result["query"] = from_union([from_none, from_str], self.query) + return result + + class ArtistCandidatesResponse(BaseModel): results: List[Optional[CanonicalArtistClass]] @@ -292,274 +670,61 @@ class SearchSettings(BaseModel): @staticmethod def from_dict(obj: Any) -> "SearchSettings": assert isinstance(obj, dict) - filterSettings = FilterSettings.from_dict(obj.get("filterSettings")) - searchTypeSettings = SearchTypeSettings.from_dict(obj.get("searchTypeSettings")) - sourceSettings = SourceSettings.from_dict(obj.get("sourceSettings")) - return SearchSettings(filterSettings, searchTypeSettings, sourceSettings) - - def to_dict(self) -> dict: - result: dict = {} - result["filterSettings"] = to_class(FilterSettings, self.filterSettings) - result["searchTypeSettings"] = to_class(SearchTypeSettings, self.searchTypeSettings) - result["sourceSettings"] = to_class(SourceSettings, self.sourceSettings) - return result - - -class CardbacksRequest(BaseModel): - searchSettings: SearchSettings - - @staticmethod - def from_dict(obj: Any) -> "CardbacksRequest": - assert isinstance(obj, dict) - searchSettings = SearchSettings.from_dict(obj.get("searchSettings")) - return CardbacksRequest(searchSettings) - - def to_dict(self) -> dict: - result: dict = {} - result["searchSettings"] = to_class(SearchSettings, self.searchSettings) - return result - - -class CardbacksResponse(BaseModel): - cardbacks: List[str] - - @staticmethod - def from_dict(obj: Any) -> "CardbacksResponse": - assert isinstance(obj, dict) - cardbacks = from_list(from_str, obj.get("cardbacks")) - return CardbacksResponse(cardbacks) - - def to_dict(self) -> dict: - result: dict = {} - result["cardbacks"] = from_list(from_str, self.cardbacks) - return result - - -class CardsRequest(BaseModel): - cardIdentifiers: List[str] - - @staticmethod - def from_dict(obj: Any) -> "CardsRequest": - assert isinstance(obj, dict) - cardIdentifiers = from_list(from_str, obj.get("cardIdentifiers")) - return CardsRequest(cardIdentifiers) - - def to_dict(self) -> dict: - result: dict = {} - result["cardIdentifiers"] = from_list(from_str, self.cardIdentifiers) - return result - - -class CanonicalCardClass(BaseModel): - collectorNumber: str - expansionCode: str - expansionName: str - identifier: str - mediumThumbnailUrl: str - smallThumbnailUrl: str - artist: Optional[str] = None - canonicalId: Optional[str] = None - - @staticmethod - def from_dict(obj: Any) -> "CanonicalCardClass": - assert isinstance(obj, dict) - collectorNumber = from_str(obj.get("collectorNumber")) - expansionCode = from_str(obj.get("expansionCode")) - expansionName = from_str(obj.get("expansionName")) - identifier = from_str(obj.get("identifier")) - mediumThumbnailUrl = from_str(obj.get("mediumThumbnailUrl")) - smallThumbnailUrl = from_str(obj.get("smallThumbnailUrl")) - artist = from_union([from_str, from_none], obj.get("artist")) - canonicalId = from_union([from_str, from_none], obj.get("canonicalId")) - return CanonicalCardClass( - collectorNumber, - expansionCode, - expansionName, - identifier, - mediumThumbnailUrl, - smallThumbnailUrl, - artist, - canonicalId, - ) - - def to_dict(self) -> dict: - result: dict = {} - result["collectorNumber"] = from_str(self.collectorNumber) - result["expansionCode"] = from_str(self.expansionCode) - result["expansionName"] = from_str(self.expansionName) - result["identifier"] = from_str(self.identifier) - result["mediumThumbnailUrl"] = from_str(self.mediumThumbnailUrl) - result["smallThumbnailUrl"] = from_str(self.smallThumbnailUrl) - if self.artist is not None: - result["artist"] = from_union([from_str, from_none], self.artist) - if self.canonicalId is not None: - result["canonicalId"] = from_union([from_str, from_none], self.canonicalId) - return result + filterSettings = FilterSettings.from_dict(obj.get("filterSettings")) + searchTypeSettings = SearchTypeSettings.from_dict(obj.get("searchTypeSettings")) + sourceSettings = SourceSettings.from_dict(obj.get("sourceSettings")) + return SearchSettings(filterSettings, searchTypeSettings, sourceSettings) + def to_dict(self) -> dict: + result: dict = {} + result["filterSettings"] = to_class(FilterSettings, self.filterSettings) + result["searchTypeSettings"] = to_class(SearchTypeSettings, self.searchTypeSettings) + result["sourceSettings"] = to_class(SourceSettings, self.sourceSettings) + return result -class CardType(str, Enum): - CARD = "CARD" - CARDBACK = "CARDBACK" - TOKEN = "TOKEN" +class CardbacksRequest(BaseModel): + searchSettings: SearchSettings -class PrintingTagStatus(str, Enum): - """Community printing-tag vote consensus status for this card. Only RESOLVED cards have a - community-confirmed printing behind canonicalCard (via inferred_canonical_card) - used by - the frontend to show a 'matched by community tags' indicator and is otherwise - informational. - """ + @staticmethod + def from_dict(obj: Any) -> "CardbacksRequest": + assert isinstance(obj, dict) + searchSettings = SearchSettings.from_dict(obj.get("searchSettings")) + return CardbacksRequest(searchSettings) - nomatch = "no_match" - resolved = "resolved" - unresolved = "unresolved" + def to_dict(self) -> dict: + result: dict = {} + result["searchSettings"] = to_class(SearchSettings, self.searchSettings) + return result -class SourceType(str, Enum): - AWSS3 = "AWS S3" - GoogleDrive = "Google Drive" - LocalFile = "Local File" +class CardbacksResponse(BaseModel): + cardbacks: List[str] + @staticmethod + def from_dict(obj: Any) -> "CardbacksResponse": + assert isinstance(obj, dict) + cardbacks = from_list(from_str, obj.get("cardbacks")) + return CardbacksResponse(cardbacks) -class Card(BaseModel): - cardType: CardType - dateCreated: str - """Created date - formatted by backend""" + def to_dict(self) -> dict: + result: dict = {} + result["cardbacks"] = from_list(from_str, self.cardbacks) + return result - dateModified: str - """Modified date - formatted by backend""" - dpi: int - extension: str - identifier: str - language: str - mediumThumbnailUrl: str - name: str - printingTagStatus: PrintingTagStatus - """Community printing-tag vote consensus status for this card. Only RESOLVED cards have a - community-confirmed printing behind canonicalCard (via inferred_canonical_card) - used by - the frontend to show a 'matched by community tags' indicator and is otherwise - informational. - """ - priority: int - searchq: str - size: int - smallThumbnailUrl: str - source: str - sourceId: int - sourceName: str - sourceVerbose: str - tags: List[str] - canonicalArtist: Optional[CanonicalArtistClass] = None - canonicalArtistIsFromVoteOnly: Optional[bool] = None - """True only when canonicalArtist was supplied by artist-vote consensus alone, with no - confirmed indexing match or resolved printing backing it - lets the frontend distinguish - a confidently-known artist from a vote-derived one (e.g. for the ArtistVotePicker - 'wrong?' affordance) without needing to know serialise()'s fallback chain itself. - """ - canonicalArtistSource: Optional[str] = None - """Which rung of the artist fallback chain actually supplied canonicalArtist - - debug/introspection field, not load-bearing for any current frontend logic. - """ - canonicalCard: Optional[CanonicalCardClass] = None - sourceExternalLink: Optional[str] = None - sourceType: Optional[SourceType] = None +class CardsRequest(BaseModel): + cardIdentifiers: List[str] @staticmethod - def from_dict(obj: Any) -> "Card": + def from_dict(obj: Any) -> "CardsRequest": assert isinstance(obj, dict) - cardType = CardType(obj.get("cardType")) - dateCreated = from_str(obj.get("dateCreated")) - dateModified = from_str(obj.get("dateModified")) - dpi = from_int(obj.get("dpi")) - extension = from_str(obj.get("extension")) - identifier = from_str(obj.get("identifier")) - language = from_str(obj.get("language")) - mediumThumbnailUrl = from_str(obj.get("mediumThumbnailUrl")) - name = from_str(obj.get("name")) - printingTagStatus = PrintingTagStatus(obj.get("printingTagStatus")) - priority = from_int(obj.get("priority")) - searchq = from_str(obj.get("searchq")) - size = from_int(obj.get("size")) - smallThumbnailUrl = from_str(obj.get("smallThumbnailUrl")) - source = from_str(obj.get("source")) - sourceId = from_int(obj.get("sourceId")) - sourceName = from_str(obj.get("sourceName")) - sourceVerbose = from_str(obj.get("sourceVerbose")) - tags = from_list(from_str, obj.get("tags")) - canonicalArtist = from_union([from_none, CanonicalArtistClass.from_dict], obj.get("canonicalArtist")) - canonicalArtistIsFromVoteOnly = from_union([from_bool, from_none], obj.get("canonicalArtistIsFromVoteOnly")) - canonicalArtistSource = from_union([from_none, from_str], obj.get("canonicalArtistSource")) - canonicalCard = from_union([from_none, CanonicalCardClass.from_dict], obj.get("canonicalCard")) - sourceExternalLink = from_union([from_str, from_none], obj.get("sourceExternalLink")) - sourceType = from_union([SourceType, from_none], obj.get("sourceType")) - return Card( - cardType, - dateCreated, - dateModified, - dpi, - extension, - identifier, - language, - mediumThumbnailUrl, - name, - printingTagStatus, - priority, - searchq, - size, - smallThumbnailUrl, - source, - sourceId, - sourceName, - sourceVerbose, - tags, - canonicalArtist, - canonicalArtistIsFromVoteOnly, - canonicalArtistSource, - canonicalCard, - sourceExternalLink, - sourceType, - ) + cardIdentifiers = from_list(from_str, obj.get("cardIdentifiers")) + return CardsRequest(cardIdentifiers) def to_dict(self) -> dict: result: dict = {} - result["cardType"] = to_enum(CardType, self.cardType) - result["dateCreated"] = from_str(self.dateCreated) - result["dateModified"] = from_str(self.dateModified) - result["dpi"] = from_int(self.dpi) - result["extension"] = from_str(self.extension) - result["identifier"] = from_str(self.identifier) - result["language"] = from_str(self.language) - result["mediumThumbnailUrl"] = from_str(self.mediumThumbnailUrl) - result["name"] = from_str(self.name) - result["printingTagStatus"] = to_enum(PrintingTagStatus, self.printingTagStatus) - result["priority"] = from_int(self.priority) - result["searchq"] = from_str(self.searchq) - result["size"] = from_int(self.size) - result["smallThumbnailUrl"] = from_str(self.smallThumbnailUrl) - result["source"] = from_str(self.source) - result["sourceId"] = from_int(self.sourceId) - result["sourceName"] = from_str(self.sourceName) - result["sourceVerbose"] = from_str(self.sourceVerbose) - result["tags"] = from_list(from_str, self.tags) - if self.canonicalArtist is not None: - result["canonicalArtist"] = from_union( - [from_none, lambda x: to_class(CanonicalArtistClass, x)], self.canonicalArtist - ) - if self.canonicalArtistIsFromVoteOnly is not None: - result["canonicalArtistIsFromVoteOnly"] = from_union( - [from_bool, from_none], self.canonicalArtistIsFromVoteOnly - ) - if self.canonicalArtistSource is not None: - result["canonicalArtistSource"] = from_union([from_none, from_str], self.canonicalArtistSource) - if self.canonicalCard is not None: - result["canonicalCard"] = from_union( - [from_none, lambda x: to_class(CanonicalCardClass, x)], self.canonicalCard - ) - if self.sourceExternalLink is not None: - result["sourceExternalLink"] = from_union([from_str, from_none], self.sourceExternalLink) - if self.sourceType is not None: - result["sourceType"] = from_union([lambda x: to_enum(SourceType, x), from_none], self.sourceType) + result["cardIdentifiers"] = from_list(from_str, self.cardIdentifiers) return result @@ -1238,68 +1403,6 @@ def to_dict(self) -> dict: return result -class PrintingCandidate(BaseModel): - artist: str - canonicalId: str - collectorNumber: str - expansionCode: str - expansionName: str - frame: str - fullArt: bool - identifier: str - isBorderless: bool - mediumThumbnailUrl: str - smallThumbnailUrl: str - releasedAt: Optional[str] = None - - @staticmethod - def from_dict(obj: Any) -> "PrintingCandidate": - assert isinstance(obj, dict) - artist = from_str(obj.get("artist")) - canonicalId = from_str(obj.get("canonicalId")) - collectorNumber = from_str(obj.get("collectorNumber")) - expansionCode = from_str(obj.get("expansionCode")) - expansionName = from_str(obj.get("expansionName")) - frame = from_str(obj.get("frame")) - fullArt = from_bool(obj.get("fullArt")) - identifier = from_str(obj.get("identifier")) - isBorderless = from_bool(obj.get("isBorderless")) - mediumThumbnailUrl = from_str(obj.get("mediumThumbnailUrl")) - smallThumbnailUrl = from_str(obj.get("smallThumbnailUrl")) - releasedAt = from_union([from_none, from_str], obj.get("releasedAt")) - return PrintingCandidate( - artist, - canonicalId, - collectorNumber, - expansionCode, - expansionName, - frame, - fullArt, - identifier, - isBorderless, - mediumThumbnailUrl, - smallThumbnailUrl, - releasedAt, - ) - - def to_dict(self) -> dict: - result: dict = {} - result["artist"] = from_str(self.artist) - result["canonicalId"] = from_str(self.canonicalId) - result["collectorNumber"] = from_str(self.collectorNumber) - result["expansionCode"] = from_str(self.expansionCode) - result["expansionName"] = from_str(self.expansionName) - result["frame"] = from_str(self.frame) - result["fullArt"] = from_bool(self.fullArt) - result["identifier"] = from_str(self.identifier) - result["isBorderless"] = from_bool(self.isBorderless) - result["mediumThumbnailUrl"] = from_str(self.mediumThumbnailUrl) - result["smallThumbnailUrl"] = from_str(self.smallThumbnailUrl) - if self.releasedAt is not None: - result["releasedAt"] = from_union([from_none, from_str], self.releasedAt) - return result - - class PrintingCandidatesResponse(BaseModel): results: List[PrintingCandidate] @@ -1622,6 +1725,7 @@ def to_dict(self) -> dict: class TagConsensusEntry(BaseModel): + netPolarity: float tagName: str tally: List[TagVoteTallyEntry] resolvedPolarity: Optional[int] = None @@ -1629,17 +1733,19 @@ class TagConsensusEntry(BaseModel): @staticmethod def from_dict(obj: Any) -> "TagConsensusEntry": assert isinstance(obj, dict) + netPolarity = from_float(obj.get("netPolarity")) tagName = from_str(obj.get("tagName")) tally = from_list(TagVoteTallyEntry.from_dict, obj.get("tally")) - resolvedPolarity = from_union([from_none, from_int], obj.get("resolvedPolarity")) - return TagConsensusEntry(tagName, tally, resolvedPolarity) + resolvedPolarity = from_union([from_int, from_none], obj.get("resolvedPolarity")) + return TagConsensusEntry(netPolarity, tagName, tally, resolvedPolarity) def to_dict(self) -> dict: result: dict = {} + result["netPolarity"] = to_float(self.netPolarity) result["tagName"] = from_str(self.tagName) result["tally"] = from_list(lambda x: to_class(TagVoteTallyEntry, x), self.tally) if self.resolvedPolarity is not None: - result["resolvedPolarity"] = from_union([from_none, from_int], self.resolvedPolarity) + result["resolvedPolarity"] = from_union([from_int, from_none], self.resolvedPolarity) return result @@ -1945,6 +2051,22 @@ def PrintingTagStatustodict(x: PrintingTagStatus) -> Any: return to_enum(PrintingTagStatus, x) +def QuestionFeedItemfromdict(s: Any) -> QuestionFeedItem: + return QuestionFeedItem.from_dict(s) + + +def QuestionFeedItemtodict(x: QuestionFeedItem) -> Any: + return to_class(QuestionFeedItem, x) + + +def QuestionFeedResponsefromdict(s: Any) -> QuestionFeedResponse: + return QuestionFeedResponse.from_dict(s) + + +def QuestionFeedResponsetodict(x: QuestionFeedResponse) -> Any: + return to_class(QuestionFeedResponse, x) + + def SearchQueryfromdict(s: Any) -> SearchQuery: return SearchQuery.from_dict(s) diff --git a/MPCAutofill/cardpicker/tag_consensus.py b/MPCAutofill/cardpicker/tag_consensus.py index c4a716623..0ec3c7a2b 100644 --- a/MPCAutofill/cardpicker/tag_consensus.py +++ b/MPCAutofill/cardpicker/tag_consensus.py @@ -170,6 +170,28 @@ def get_tag_vote_tally(card: Card, tag: Tag) -> list[TagVoteTallyEntry]: ) +def get_tag_net_polarity(card: Card, tag: Tag) -> float: + """ + Weighted net polarity for (card, tag), normalized to [-1, 1] - the confidence-fill scalar + for the questionFeed attribute chips (see docs/features/printing-tags.md). 0.0 for no + votes (neutral gray, no signal either way) or a perfectly tied weighted split; sign gives + the fill color (positive/green vs. negative/red), magnitude gives the fill intensity. + + Deliberately separate from `resolve_tag`/`resolve_weighted_consensus`, which collapse to a + categorical winner-or-None verdict and apply min_weight/min_share/privileged gates that + have no analogue for a continuous confidence display - this is the same underlying + weighted-sum math `get_tag_review_queue_pairs` already computes inline for its own + ordering, just normalized and exposed as its own function instead of staying buried there. + """ + total_weight = 0.0 + net = 0.0 + for source, polarity in card.tag_votes.filter(tag=tag).values_list("source", "polarity"): + weight = _SOURCE_WEIGHTS[source] + total_weight += weight + net += polarity * weight + return net / total_weight if total_weight > 0 else 0.0 + + def get_resolved_tag_overlay(card_ids: Iterable[int]) -> dict[int, dict[str, int]]: """ Batched version of `resolve_tag`, computed for every (card, tag) pair with at least one @@ -334,6 +356,7 @@ def sort_key(pair: tuple[int, str]) -> tuple[int, bool, dt.datetime, int]: "resolve_tag", "resolve_and_persist_tag_votes", "get_tag_vote_tally", + "get_tag_net_polarity", "get_resolved_tag_overlay", "get_contested_tag_pairs", "get_tag_review_queue_pairs", diff --git a/MPCAutofill/cardpicker/tests/test_attribute_tags.py b/MPCAutofill/cardpicker/tests/test_attribute_tags.py new file mode 100644 index 000000000..4b2e3f298 --- /dev/null +++ b/MPCAutofill/cardpicker/tests/test_attribute_tags.py @@ -0,0 +1,42 @@ +from cardpicker.attribute_tags import ( + ATTRIBUTE_CHIP_TAG_NAMES, + ATTRIBUTE_TAGS, + seed_attribute_tags, +) +from cardpicker.models import Tag + + +class TestSeedAttributeTags: + def test_creates_every_configured_tag(self, db): + stats = seed_attribute_tags() + assert stats["created"] == len(ATTRIBUTE_TAGS) + for name, _display_name in ATTRIBUTE_TAGS: + assert Tag.objects.filter(name=name).exists() + + def test_idempotent(self, db): + seed_attribute_tags() + second = seed_attribute_tags() + assert second["created"] == 0 + assert Tag.objects.filter(name__in=[name for name, _ in ATTRIBUTE_TAGS]).count() == len(ATTRIBUTE_TAGS) + + def test_never_overwrites_a_manual_edit(self, db): + seed_attribute_tags() + tag = Tag.objects.get(name="Etched") + tag.display_name = "Manually Renamed" + tag.save() + + seed_attribute_tags() + + tag.refresh_from_db() + assert tag.display_name == "Manually Renamed" + + def test_chip_tag_names_are_all_seedable_or_already_in_default_tags(self, db): + from cardpicker.default_tags import DEFAULT_TAGS, seed_default_tags + + seed_default_tags() + seed_attribute_tags() + default_names = {name for name, _aliases, _display_name in DEFAULT_TAGS} + attribute_names = {name for name, _display_name in ATTRIBUTE_TAGS} + assert set(ATTRIBUTE_CHIP_TAG_NAMES) <= default_names | attribute_names + for name in ATTRIBUTE_CHIP_TAG_NAMES: + assert Tag.objects.filter(name=name).exists(), f"{name!r} is not seeded by either seed command" diff --git a/MPCAutofill/cardpicker/tests/test_printing_tags_views.py b/MPCAutofill/cardpicker/tests/test_printing_tags_views.py index 96d62abde..39e5b710e 100644 --- a/MPCAutofill/cardpicker/tests/test_printing_tags_views.py +++ b/MPCAutofill/cardpicker/tests/test_printing_tags_views.py @@ -132,6 +132,7 @@ def test_candidate_shape_includes_printing_metadata_fields(self, client, django_ assert result["fullArt"] is True assert result["isBorderless"] is True assert result["frame"] == "1997" + assert result["borderColor"] == "borderless" assert result["artist"] == printing.artist.name def test_candidate_with_non_borderless_border_color(self, client, django_settings): @@ -147,6 +148,24 @@ def test_candidate_with_non_borderless_border_color(self, client, django_setting [result] = response.json()["results"] assert result["isBorderless"] is False + assert result["borderColor"] == "black" + + def test_candidate_frame_effect_booleans(self, client, django_settings): + card = CardFactory(name="Brainstorm") + printing = CanonicalCardFactory(name="Brainstorm") + CanonicalPrintingMetadataFactory(canonical_card=printing, frame_effects=["showcase", "etched", "legendary"]) + + response = client.post( + reverse(views.post_printing_candidates), + {"identifier": card.identifier}, + content_type="application/json", + ) + + [result] = response.json()["results"] + assert result["isShowcase"] is True + assert result["isEtched"] is True + # "legendary" is deliberately not a chip - see cardpicker.attribute_tags + assert result["isExtendedArt"] is False def test_candidate_without_printing_metadata_uses_defaults(self, client, django_settings): card = CardFactory(name="Brainstorm") @@ -162,6 +181,10 @@ def test_candidate_without_printing_metadata_uses_defaults(self, client, django_ assert result["fullArt"] is False assert result["isBorderless"] is False assert result["frame"] == "" + assert result["borderColor"] == "" + assert result["isShowcase"] is False + assert result["isExtendedArt"] is False + assert result["isEtched"] is False assert result["releasedAt"] is None diff --git a/MPCAutofill/cardpicker/tests/test_question_feed.py b/MPCAutofill/cardpicker/tests/test_question_feed.py new file mode 100644 index 000000000..5480e44fe --- /dev/null +++ b/MPCAutofill/cardpicker/tests/test_question_feed.py @@ -0,0 +1,255 @@ +import pytest + +from django.contrib.auth.models import AnonymousUser +from django.urls import reverse + +from cardpicker import views +from cardpicker.models import ( + ArtistVoteStatus, + PrintingTagStatus, + TagModerationClass, + TagVoteStatus, + VotePolarity, + VoteSource, +) +from cardpicker.question_feed import get_next_question_feed_item, get_remaining_estimate +from cardpicker.tag_consensus import resolve_and_persist_tag_votes +from cardpicker.tests.factories import ( + CanonicalArtistFactory, + CanonicalCardFactory, + CanonicalExpansionFactory, + CardFactory, + CardPrintingTagFactory, + CardTagVoteFactory, + SourceFactory, + TagFactory, +) + +# see test_printing_consensus.py for why this capture-and-restore fixture exists +_SHARED_FACTORIES = [ + CardFactory, + SourceFactory, + CanonicalArtistFactory, + CanonicalExpansionFactory, + CanonicalCardFactory, +] + + +@pytest.fixture(autouse=True) +def _preserve_shared_factory_sequences(): + before = {f: f._meta.next_sequence() for f in _SHARED_FACTORIES} + for f, n in before.items(): + f.reset_sequence(n, force=True) + yield + for f, n in before.items(): + f.reset_sequence(n, force=True) + + +def make_ai_suggested_card(anonymous_id: str = "ai-bot") -> tuple: + card = CardFactory(printing_tag_status=PrintingTagStatus.UNRESOLVED) + printing = CanonicalCardFactory() + CardPrintingTagFactory(card=card, printing=printing, source=VoteSource.AI, anonymous_id=anonymous_id) + return card, printing + + +def make_pending_pair(tag_name: str = "sensitive-tag") -> tuple: + # printing/artist already resolved so this card is *only* a tier-3 candidate - isolates + # moderation-tier tests from tiers 2/4, which would otherwise also match this card via its + # (irrelevant, default-unresolved) printing/artist status + card = CardFactory( + tags=[], printing_tag_status=PrintingTagStatus.RESOLVED, artist_vote_status=ArtistVoteStatus.RESOLVED + ) + tag = TagFactory(name=tag_name, moderation_class=TagModerationClass.SENSITIVE) + for index in range(2): + CardTagVoteFactory(card=card, tag=tag, polarity=VotePolarity.APPLY, anonymous_id=f"crowd-{index}") + resolve_and_persist_tag_votes(card) + card.refresh_from_db() + return card, tag + + +class TestGetNextQuestionFeedItem: + def test_no_data_returns_none(self, db): + assert get_next_question_feed_item("anon-1", AnonymousUser()) is None + + def test_tier_1_returns_confirm_suggestion_with_the_ai_suggested_printing(self, db): + card, printing = make_ai_suggested_card() + + item = get_next_question_feed_item("anon-1", AnonymousUser()) + + assert item is not None + assert item.type.value == "confirm_suggestion" + assert item.card.identifier == card.identifier + assert item.suggestedPrinting.identifier == str(printing.identifier) + + def test_tier_1_excludes_cards_this_voter_already_voted_on(self, db): + make_ai_suggested_card(anonymous_id="ai-bot") + # the only tier-1 candidate has this same anonymous_id's own vote already + card = CardFactory(printing_tag_status=PrintingTagStatus.UNRESOLVED) + printing = CanonicalCardFactory() + CardPrintingTagFactory(card=card, printing=printing, source=VoteSource.AI, anonymous_id="ai-bot") + CardPrintingTagFactory(card=card, printing=printing, source=VoteSource.USER, anonymous_id="anon-1") + + item = get_next_question_feed_item("anon-1", AnonymousUser()) + + # falls through past the excluded tier-1 card - no other data exists, so None + assert item is None or item.card.identifier != card.identifier + + def test_a_second_voters_own_exclusion_does_not_affect_a_first_voter(self, db): + card, _ = make_ai_suggested_card() + + item_for_second_voter = get_next_question_feed_item("anon-2", AnonymousUser()) + + assert item_for_second_voter is not None + assert item_for_second_voter.card.identifier == card.identifier + + def test_tier_2_contested_printing_wins_over_tier_4_fresh_unresolved(self, db): + # tier 4 candidate: a plain unresolved card with no votes at all + CardFactory(printing_tag_status=PrintingTagStatus.UNRESOLVED) + # tier 2 candidate: a contested card (two different printings voted for) + contested_card = CardFactory(printing_tag_status=PrintingTagStatus.UNRESOLVED) + CardPrintingTagFactory(card=contested_card, printing=CanonicalCardFactory(), source=VoteSource.USER) + CardPrintingTagFactory(card=contested_card, printing=CanonicalCardFactory(), source=VoteSource.USER) + + item = get_next_question_feed_item("anon-1", AnonymousUser()) + + assert item is not None + assert item.type.value == "identify_printing" + assert item.card.identifier == contested_card.identifier + + def test_tier_4_fresh_unresolved_printing_when_nothing_higher_priority_exists(self, db): + card = CardFactory(printing_tag_status=PrintingTagStatus.UNRESOLVED) + + item = get_next_question_feed_item("anon-1", AnonymousUser()) + + assert item is not None + assert item.type.value == "identify_printing" + assert item.card.identifier == card.identifier + + def test_tier_4_prioritizes_a_card_one_vote_from_resolving_over_a_totally_fresh_one(self, db): + # zero votes at all - the common case, 28k+ of these exist at once + CardFactory(printing_tag_status=PrintingTagStatus.UNRESOLVED) + # one AI vote + one *agreeing* human vote (weight 1.5 < PRINTING_TAG_MIN_VOTES=2, so + # not yet resolved) - excluded from tier 1 (has a human vote) and not contested + # (agreeing, not conflicting), so it falls through to tier 4 same as a fresh card, + # but is one vote closer to actually resolving than one with zero votes. + almost_resolved = CardFactory(printing_tag_status=PrintingTagStatus.UNRESOLVED) + printing = CanonicalCardFactory() + CardPrintingTagFactory(card=almost_resolved, printing=printing, source=VoteSource.AI) + CardPrintingTagFactory(card=almost_resolved, printing=printing, source=VoteSource.USER) + + item = get_next_question_feed_item("anon-1", AnonymousUser()) + + assert item is not None + assert item.card.identifier == almost_resolved.identifier + + def test_tier_4_artist_when_no_printing_candidates_remain(self, db): + card = CardFactory( + printing_tag_status=PrintingTagStatus.RESOLVED, artist_vote_status=ArtistVoteStatus.UNRESOLVED + ) + + item = get_next_question_feed_item("anon-1", AnonymousUser()) + + assert item is not None + assert item.type.value == "artist" + assert item.card.identifier == card.identifier + + def test_moderation_tier_hidden_from_non_moderators(self, db): + make_pending_pair() + + item = get_next_question_feed_item("anon-1", AnonymousUser()) + + # no printing/artist/tag data exists other than the pending-approval pair, which a + # non-moderator must never see + assert item is None + + def test_moderation_tier_visible_to_moderators(self, db, moderator_user): + card, tag = make_pending_pair(tag_name="NSFW") + + item = get_next_question_feed_item("anon-1", moderator_user) + + assert item is not None + assert item.type.value == "moderation" + assert item.card.identifier == card.identifier + assert item.tagName == tag.name + + def test_own_vote_exclusion_is_scoped_to_the_specific_tag_not_the_whole_card(self, db): + """A voter who already answered one contested tag on a card must still be served a + *different* still-open contested tag on the same card - own-vote exclusion must not + be card-level (regression test for a bug caught in review before this shipped).""" + card = CardFactory(printing_tag_status=PrintingTagStatus.RESOLVED, artist_vote_status=ArtistVoteStatus.RESOLVED) + tag_a = TagFactory(name="Full Art") + tag_b = TagFactory(name="Etched") + for tag in (tag_a, tag_b): + CardTagVoteFactory(card=card, tag=tag, polarity=VotePolarity.APPLY, anonymous_id="crowd-1") + CardTagVoteFactory(card=card, tag=tag, polarity=VotePolarity.NOT_APPLICABLE, anonymous_id="crowd-2") + resolve_and_persist_tag_votes(card) + card.refresh_from_db() + assert card.tag_vote_statuses[tag_a.name] == TagVoteStatus.CONTESTED + assert card.tag_vote_statuses[tag_b.name] == TagVoteStatus.CONTESTED + # this voter already answered tag_a, but not tag_b + CardTagVoteFactory(card=card, tag=tag_a, polarity=VotePolarity.APPLY, anonymous_id="anon-1") + + item = get_next_question_feed_item("anon-1", AnonymousUser()) + + assert item is not None + assert item.type.value == "tag" + assert item.card.identifier == card.identifier + assert item.tagName == tag_b.name + + def test_moderation_tier_ranks_below_tier_2_contested(self, db, moderator_user): + make_pending_pair() + contested_card = CardFactory(printing_tag_status=PrintingTagStatus.UNRESOLVED) + CardPrintingTagFactory(card=contested_card, printing=CanonicalCardFactory(), source=VoteSource.USER) + CardPrintingTagFactory(card=contested_card, printing=CanonicalCardFactory(), source=VoteSource.USER) + + item = get_next_question_feed_item("anon-1", moderator_user) + + assert item is not None + assert item.type.value == "identify_printing" + + +class TestGetRemainingEstimate: + def test_is_non_negative(self, db): + assert get_remaining_estimate(AnonymousUser()) >= 0 + + def test_counts_unresolved_printing_cards(self, db): + before = get_remaining_estimate(AnonymousUser()) + # artist_vote_status=RESOLVED so this card contributes to only the printing count - + # a fresh CardFactory() defaults artist_vote_status to UNRESOLVED too, which would + # otherwise add 1 to the artist-tier count as well and make this assertion brittle + CardFactory(printing_tag_status=PrintingTagStatus.UNRESOLVED, artist_vote_status=ArtistVoteStatus.RESOLVED) + after = get_remaining_estimate(AnonymousUser()) + assert after == before + 1 + + def test_moderation_pairs_only_counted_for_moderators(self, db, moderator_user): + make_pending_pair() + assert get_remaining_estimate(AnonymousUser()) == 0 + assert get_remaining_estimate(moderator_user) == 1 + + +class TestGetQuestionFeedView: + def test_missing_anonymous_id_is_a_bad_request(self, client, django_settings): + response = client.get(reverse(views.get_question_feed)) + assert response.status_code == 400 + + def test_returns_null_item_when_caught_up(self, client, django_settings): + response = client.get(reverse(views.get_question_feed), {"anonymousId": "anon-1"}) + assert response.status_code == 200 + assert response.json()["item"] is None + assert response.json()["remainingEstimate"] == 0 + + def test_returns_the_next_item(self, client, django_settings): + card, _ = make_ai_suggested_card() + response = client.get(reverse(views.get_question_feed), {"anonymousId": "anon-1"}) + assert response.status_code == 200 + assert response.json()["item"]["card"]["identifier"] == card.identifier + + def test_moderation_item_requires_moderator_session(self, client, django_settings, moderator_user): + make_pending_pair() + + anonymous_response = client.get(reverse(views.get_question_feed), {"anonymousId": "anon-1"}) + assert anonymous_response.json()["item"] is None + + client.force_login(moderator_user) + moderator_response = client.get(reverse(views.get_question_feed), {"anonymousId": "anon-1"}) + assert moderator_response.json()["item"]["type"] == "moderation" diff --git a/MPCAutofill/cardpicker/tests/test_tag_votes.py b/MPCAutofill/cardpicker/tests/test_tag_votes.py index f2e09c5a8..ade00b2ab 100644 --- a/MPCAutofill/cardpicker/tests/test_tag_votes.py +++ b/MPCAutofill/cardpicker/tests/test_tag_votes.py @@ -8,6 +8,7 @@ from cardpicker.tag_consensus import ( get_contested_tag_pairs, get_resolved_tag_overlay, + get_tag_net_polarity, get_tag_review_queue_pairs, get_tag_vote_tally, resolve_and_persist_tag_votes, @@ -332,6 +333,40 @@ def test_returns_an_entry_for_every_seeded_tag(self, client, django_settings): assert all(entry["resolvedPolarity"] is None for entry in body["tags"]) +class TestGetTagNetPolarity: + def test_no_votes_is_zero(self, db): + card = CardFactory() + tag = TagFactory() + assert get_tag_net_polarity(card, tag) == 0.0 + + def test_unanimous_positive_is_one(self, db): + card = CardFactory() + tag = TagFactory() + CardTagVoteFactory(card=card, tag=tag, polarity=VotePolarity.APPLY, source=VoteSource.USER) + CardTagVoteFactory(card=card, tag=tag, polarity=VotePolarity.APPLY, source=VoteSource.USER) + assert get_tag_net_polarity(card, tag) == 1.0 + + def test_unanimous_negative_is_negative_one(self, db): + card = CardFactory() + tag = TagFactory() + CardTagVoteFactory(card=card, tag=tag, polarity=VotePolarity.NOT_APPLICABLE, source=VoteSource.USER) + assert get_tag_net_polarity(card, tag) == -1.0 + + def test_even_split_by_weight_is_zero(self, db): + card = CardFactory() + tag = TagFactory() + CardTagVoteFactory(card=card, tag=tag, polarity=VotePolarity.APPLY, source=VoteSource.USER) + CardTagVoteFactory(card=card, tag=tag, polarity=VotePolarity.NOT_APPLICABLE, source=VoteSource.USER) + assert get_tag_net_polarity(card, tag) == 0.0 + + def test_votes_on_a_different_tag_are_not_counted(self, db): + card = CardFactory() + tag_a = TagFactory() + tag_b = TagFactory() + CardTagVoteFactory(card=card, tag=tag_a, polarity=VotePolarity.APPLY, source=VoteSource.USER) + assert get_tag_net_polarity(card, tag_b) == 0.0 + + class TestPostSubmitTagVote: def test_unknown_card_identifier_is_a_bad_request(self, client, django_settings): response = client.post( @@ -411,6 +446,58 @@ def test_a_vote_on_one_tag_does_not_clear_a_vote_on_another_tag_by_the_same_pers assert CardTagVote.objects.filter(card=card, anonymous_id="anon-1").count() == 2 + def test_retraction_deletes_the_vote_and_unresolves_consensus(self, client, django_settings): + card = CardFactory(tags=[]) + tag = TagFactory(name="Borderless") + client.post( + reverse(views.post_submit_tag_vote), + {"identifier": card.identifier, "tagName": tag.name, "polarity": 1, "anonymousId": "anon-1"}, + content_type="application/json", + ) + assert CardTagVote.objects.filter(card=card, tag=tag, anonymous_id="anon-1").count() == 1 + + response = client.post( + reverse(views.post_submit_tag_vote), + {"identifier": card.identifier, "tagName": tag.name, "polarity": 0, "anonymousId": "anon-1"}, + content_type="application/json", + ) + + assert response.status_code == 200 + assert CardTagVote.objects.filter(card=card, tag=tag, anonymous_id="anon-1").count() == 0 + assert response.json()["resolvedPolarity"] is None + card.refresh_from_db() + assert card.tags == [] + + def test_retracting_a_vote_that_was_never_cast_is_a_no_op(self, client, django_settings): + card = CardFactory() + tag = TagFactory() + response = client.post( + reverse(views.post_submit_tag_vote), + {"identifier": card.identifier, "tagName": tag.name, "polarity": 0, "anonymousId": "anon-1"}, + content_type="application/json", + ) + assert response.status_code == 200 + assert CardTagVote.objects.filter(card=card, tag=tag).count() == 0 + + def test_retraction_only_removes_this_anonymous_ids_own_vote(self, client, django_settings): + card = CardFactory() + tag = TagFactory() + CardTagVoteFactory(card=card, tag=tag, anonymous_id="anon-other", polarity=1) + client.post( + reverse(views.post_submit_tag_vote), + {"identifier": card.identifier, "tagName": tag.name, "polarity": 1, "anonymousId": "anon-1"}, + content_type="application/json", + ) + + client.post( + reverse(views.post_submit_tag_vote), + {"identifier": card.identifier, "tagName": tag.name, "polarity": 0, "anonymousId": "anon-1"}, + content_type="application/json", + ) + + assert CardTagVote.objects.filter(card=card, tag=tag, anonymous_id="anon-1").count() == 0 + assert CardTagVote.objects.filter(card=card, tag=tag, anonymous_id="anon-other").count() == 1 + def test_rate_limited_after_exceeding_the_configured_rate(self, client, django_settings, settings): settings.PRINTING_TAG_SUBMISSION_RATE = "1/m" card = CardFactory() diff --git a/MPCAutofill/cardpicker/urls.py b/MPCAutofill/cardpicker/urls.py index 0d31b23e1..b345bee83 100755 --- a/MPCAutofill/cardpicker/urls.py +++ b/MPCAutofill/cardpicker/urls.py @@ -33,6 +33,7 @@ path("2/submitTagVote/", views.post_submit_tag_vote), path("2/voteQueue/", views.post_vote_queue), path("2/moderationQueue/", views.post_moderation_queue), + path("2/questionFeed/", views.get_question_feed), path("2/reportCard/", views.post_report_card), path("2/whoami/", views.get_whoami), ] diff --git a/MPCAutofill/cardpicker/views.py b/MPCAutofill/cardpicker/views.py index da604d543..3c2b1a393 100644 --- a/MPCAutofill/cardpicker/views.py +++ b/MPCAutofill/cardpicker/views.py @@ -76,6 +76,7 @@ resolve_and_persist_printing, resolve_printing, ) +from cardpicker.question_feed import get_next_question_feed_item, get_remaining_estimate from cardpicker.schema_types import ( ArtistCandidatesRequest, ArtistCandidatesResponse, @@ -122,6 +123,7 @@ PrintingConsensusRequest, PrintingConsensusResponse, PrintingTagQueueResponse, + QuestionFeedResponse, ReportCardRequest, ReportCardResponse, SampleCardsResponse, @@ -157,6 +159,7 @@ from cardpicker.sources.source_types import SourceTypeChoices from cardpicker.tag_consensus import ( get_pending_approval_queue_pairs, + get_tag_net_polarity, get_tag_review_queue_pairs, get_tag_vote_tally, resolve_and_persist_tag_votes, @@ -1012,6 +1015,7 @@ def _build_tag_consensus_entry(card: Card, tag: Tag) -> TagConsensusEntry: # a sensitive tag awaiting privileged approval reads as unresolved to the public # consensus surface - the pending state is a moderation-queue concern, not a voter one resolvedPolarity=None if isinstance(resolved, _PendingPrivileged) else resolved, + netPolarity=get_tag_net_polarity(card, tag), tally=[ TagVoteTallyEntry(polarity=entry["polarity"], count=entry["count"]) for entry in get_tag_vote_tally(card, tag) @@ -1066,8 +1070,10 @@ def post_submit_tag_vote(request: HttpRequest) -> HttpResponse: tag = Tag.objects.get(name=req.tagName) except Tag.DoesNotExist: raise BadRequestException(f"No tag found with name {req.tagName!r}.") - if req.polarity not in (VotePolarity.APPLY, VotePolarity.NOT_APPLICABLE): - raise BadRequestException(f"Invalid polarity {req.polarity!r} - must be 1 (apply) or -1 (not applicable).") + if req.polarity not in (VotePolarity.APPLY, VotePolarity.NOT_APPLICABLE, RETRACT_POLARITY): + raise BadRequestException( + f"Invalid polarity {req.polarity!r} - must be 1 (apply), -1 (not applicable), or 0 (retract)." + ) _cast_tag_vote_and_resolve( card=card, tag=tag, anonymous_id=req.anonymousId, polarity=req.polarity, user=_requesting_user(request) @@ -1075,6 +1081,16 @@ def post_submit_tag_vote(request: HttpRequest) -> HttpResponse: return JsonResponse(_build_tag_consensus_entry(card, tag).model_dump()) +# Sentinel accepted by post_submit_tag_vote/_cast_tag_vote_and_resolve alongside the two real +# VotePolarity values - never persisted (VotePolarity.choices is unchanged), it means "delete +# my existing vote on this (card, tag) if I have one" - the untouched-with-no-votes state a +# tri-state attribute chip cycles back to. See docs/features/printing-tags.md's questionFeed +# section for why this didn't exist before the attribute-chip UI needed it: every prior tag +# voter (QueueTagQuestion, PrintingConfirmStrip, NoMatchReasonStrip) only ever asks apply-or- +# not-applicable, with no UI path back to "no opinion" once tapped. +RETRACT_POLARITY = 0 + + def _cast_tag_vote_and_resolve(card: Card, tag: Tag, anonymous_id: str, polarity: int, user: Optional[User]) -> None: """ The one write path for a tag vote - shared verbatim between `post_submit_tag_vote` and @@ -1082,14 +1098,17 @@ def _cast_tag_vote_and_resolve(card: Card, tag: Tag, anonymous_id: str, polarity the two entry points can never drift on how a vote lands or when consensus recomputes. """ with transaction.atomic(): - # `user` sits in defaults deliberately: the row reflects the *latest* submission from - # this (card, tag, anonymous_id), so a later unauthenticated re-vote clears it. - CardTagVote.objects.update_or_create( - card=card, - tag=tag, - anonymous_id=anonymous_id, - defaults={"polarity": polarity, "source": VoteSource.USER, "user": user}, - ) + if polarity == RETRACT_POLARITY: + CardTagVote.objects.filter(card=card, tag=tag, anonymous_id=anonymous_id).delete() + else: + # `user` sits in defaults deliberately: the row reflects the *latest* submission + # from this (card, tag, anonymous_id), so a later unauthenticated re-vote clears it. + CardTagVote.objects.update_or_create( + card=card, + tag=tag, + anonymous_id=anonymous_id, + defaults={"polarity": polarity, "source": VoteSource.USER, "user": user}, + ) resolve_and_persist_tag_votes(card) @@ -1218,6 +1237,28 @@ def post_vote_queue(request: HttpRequest) -> HttpResponse: return JsonResponse(VoteQueueResponse(hits=paginator.count, pages=paginator.num_pages, items=items).model_dump()) +@csrf_exempt +@ErrorWrappers.to_json +def get_question_feed(request: HttpRequest) -> HttpResponse: + """ + The unified "What's That Card?" question feed (see cardpicker.question_feed and + docs/features/printing-tags.md) - one question at a time rather than a paginated batch, + since (unlike printingTagQueue/voteQueue) which question comes next depends on what this + same voter has already answered, evaluated fresh on every call. + """ + + if request.method != "GET": + raise BadRequestException("Expected GET request.") + + anonymous_id = request.GET.get("anonymousId") + if not anonymous_id: + raise BadRequestException("Missing required anonymousId query parameter.") + + item = get_next_question_feed_item(anonymous_id, request.user) + remaining_estimate = get_remaining_estimate(request.user) + return JsonResponse(QuestionFeedResponse(item=item, remainingEstimate=remaining_estimate).model_dump()) + + @csrf_exempt @reject_untrusted_origin @require_moderator diff --git a/docs/features/moderation.md b/docs/features/moderation.md index 82bd595b7..d3bfe1a29 100644 --- a/docs/features/moderation.md +++ b/docs/features/moderation.md @@ -223,7 +223,7 @@ for low-res / incorrect-info yet). `MPCAutofill/settings.py`. - Frontend: `features/reporting/ReportCardPanel.tsx`, `features/moderation/AuthWidget.tsx` + `ModerationQueue.tsx`, - `features/filters/MatureContentFilter.tsx`, `pages/printingQueue.tsx`, + `features/filters/MatureContentFilter.tsx`, `pages/whatsthat.tsx`, `store/api.ts` (whoami query + credentialed fetches). - Tests: `cardpicker/tests/test_moderation_gate.py`, `test_moderation_views.py`, `test_sensitive_tags.py`; diff --git a/docs/features/printing-tags.md b/docs/features/printing-tags.md index e252d0835..805df1b68 100644 --- a/docs/features/printing-tags.md +++ b/docs/features/printing-tags.md @@ -74,6 +74,15 @@ because `import_canonical_card_data` was silently hanging for its full ## Frontend: vote-queue UI ("What's That Card?") +**Superseded by Stage 7 (unified question feed) below** — `PrintingTagQueue.tsx`, +`GenericVoteQueue.tsx`, `PrintingConfirmStrip.tsx`, and `ModerationQueue.tsx` +(the tab switcher and its four tab bodies) were deleted as part of that +change; their mechanics (starburst, sticky panel, reveal animation, +candidate grid) live on, extracted into `cardPanel.tsx` and reused by the +new `QuestionFeed.tsx`. This section is kept as the historical record of +how those mechanics were originally built — still accurate for that, just +not for "what renders today." + `PrintingTagQueue.tsx` (standalone queue page) and `PrintingTagPicker.tsx` (embedded picker in `CardDetailedViewModal.tsx`) present candidate printings for a card with a themed starburst background, animated flicker, @@ -537,7 +546,125 @@ The sensitive-tag moderation layer ([[moderation.md]]) builds directly on this system: a third seeded taxonomy (`seed_sensitive_tags` — NSFW/low-res/ incorrect-info/appropriate-bleed, same command-not-migration convention as the two above), a privileged-approval gate in `resolve_weighted_consensus`, -and a moderator-only queue tab beside the ones described here. +and a moderator-only review surface (originally its own tab; folded into +the unified question feed's `moderation` question type by Stage 7 below). + +## Stage 7: unified question feed (queue redesign) + +Replaces the printing/artist/tag/moderation tab switcher with a single +`GET 2/questionFeed/`-driven stream: one question at a time, typed +(`confirm_suggestion` | `identify_printing` | `artist` | `tag` | +`moderation`), each with a `payload` shaped per type. A "dumb ranked +union" v1 — four fixed-priority tiers, first non-empty tier wins, no +cross-tier scoring. Full design rationale (chip taxonomy data grounding, +layout tradeoffs, exact tier queries) lives in +`journal/2026-07-14-queue-question-feed-design.md` (gitignored, local +only) — this section captures the durable facts a future reader needs +without that file. + +**Priority tiers**: (1) `confirm_suggestion` — cards with an unresolved +AI-sourced printing vote and no human printing vote yet (28,112 cards at +last count — the full deductive-backfill set from Stage 6); (2) contested +printing/artist/tag pairs, existing per-kind ordering reused verbatim; (3) +`moderation` — pending-approval sensitive tags +(`get_pending_approval_queue_pairs`, unchanged from the moderation layer), +gated on `is_moderator(request.user)` and simply never queried for a +non-moderator request; (4) fresh unresolved. **Own-vote exclusion**: every +tier excludes cards/pairs this exact `anonymous_id` has already voted on +(scoped to `(card, tag)`, not just `card` — a card can carry ~11 +independent attribute-chip votes), so a single vote that doesn't itself +resolve consensus doesn't re-serve the same question forever. + +**Starvation risk, not silently accepted**: at current volume, a voter +working only this feed will not see a single contested/moderation item +until all 28,112 tier-1 questions are exhausted. Flagged as a known v1 +property; an interleaved/weighted union is the likely v2 fix, out of scope +here (matches the "ML/scoring schedulers beyond the ranked union" +exclusion from this stage's own brief). + +**Attribute chips** (`frontend/src/features/attributeChips/`): tri-state +per chip (untouched → positive → negative → untouched, cycling on tap), +fill color/intensity renders the tag's weighted net polarity (a new +`netPolarity` field on `TagConsensusEntry`, computed by +`tag_consensus.get_tag_net_polarity` — the same weighted-sum math +`get_tag_review_queue_pairs` already computed inline for its own ordering, +now exposed as its own function). Chip taxonomy (11 tags total, +`cardpicker/attribute_tags.py` + `frontend/.../attributeChips.ts`, +kept in lockstep by tag name): standalone toggles Full Art / Borderless / +Showcase / Extended (Art) / Etched, plus two **exclusion groups** — Border +Color (Black/White/Silver) and Frame Style (Old/Modern/Future, bucketing +Scryfall's four raw frame years into three) — encoded as one frontend +constant (`EXCLUSION_GROUPS`) with a comment, per spec. A positive tap on +one exclusion-group chip renders siblings implied-negative (dimmed) and +drives live candidate filtering, but casts no vote on those siblings — +only the frontend styling/filtering is group-aware, the vote write path +never is. Chip set is deliberately narrower than "every value +`CanonicalPrintingMetadata` stores" — `promo_types` is excluded entirely +(mostly production/marketing provenance, not visually identifiable from a +card image) and `frame_effects` is limited to the three values common +enough (849–4165 occurrences at census time) to read as a distinct visual +treatment to a non-expert; `legendary`/`inverted` had higher raw counts +but were excluded as a judgment call (card-type marker and one narrow +product line, respectively, not general printing-identification signal). + +**Retraction**: `CardTagVote` previously only supported apply/not- +applicable (`update_or_create`, no delete path) — the tri-state chip's +untouched-cycle-back needed a real "un-vote." Minimal addition: +`post_submit_tag_vote` now also accepts `polarity=0` as a retract +sentinel (never persisted — `VotePolarity`'s two real choices are +unchanged), which deletes the existing `CardTagVote` row instead of +upserting. + +**Auto-tag on selection**: picking a printing candidate casts the +existing printing vote plus one positive `CardTagVote` per _standalone_ +attribute the candidate itself carries true (not the exclusion groups — +border/frame aren't auto-derivable from a boolean flag the same way). +`PrintingConfirmStrip` (Stage 4) is fully redundant under this — both +attributes it used to manually confirm (Full Art, Borderless) are now +auto-cast — and was deleted rather than kept as dead code. + +**No-match gating**: the "No match" candidate is disabled (visually and +functionally) until at least one chip has an explicit (non-untouched) +state, per spec — "describe what you see first." + +**Layout**: the starburst/sticky subject-card panel (with its surrounding +chips) renders LEFT and the candidate grid RIGHT on desktop, in plain +JSX/DOM order — the spec's original brief called for the opposite +(candidates left, card right, via a CSS `order` flip so mobile stacking +still worked); changed to this arrangement per direct follow-up +instruction. Mobile stacks in the same DOM order (card+chips first/top, +grid second/below) with no extra CSS needed. + +**A latent bug this stage's chips exposed, not introduced**: `CardPanel` +has always used `z-index: -1` (see `cardPanel.tsx`, unchanged since the +original `PrintingTagQueue.tsx`) so the starburst bleeding out from +behind it doesn't paint over the page heading above. That negative +z-index was never actually _contained_ to CardPanel's own column — with +no positioned ancestor between it and the page root, it escapes all the +way up, which happens to be harmless as long as nothing _inside_ +CardPanel needs to be clicked (the original component only ever showed a +static image there). This stage is the first time CardPanel hosts real +interactive content (the attribute chips), and the escape turned out to +make CardPanel's entire subtree - chips included - unclickable at the +browser's hit-testing layer: `elementFromPoint` at a chip's own screen +coordinates resolved to its grandparent `Col`, not the chip, even though +the chip visually renders exactly there. Caught via a real +intercepted-click failure in Playwright (multiple false leads chased +first - CSS `order`, dev-server staleness, duplicate mounts - before +isolating it with `elementFromPoint` diagnostics and a bisection between +`z-index: -1` and a throwaway positive value). Fixed by giving the `Col` +wrapping `CardPanel` its own local stacking context: `position: relative` +_and_ an explicit non-`auto` `z-index` (`0`) together - `position: relative` alone does not establish one, a distinction that cost a full +extra round of "fixed, then still broken" before landing on the working +combination. + +**Server deployment step**: `manage.py seed_attribute_tags` must run once +before this feature is live (idempotent, same pattern as +`seed_sensitive_tags` — see [[moderation.md]]'s checklist). Without it, +the six non-default-taxonomy chips (Etched, Black/White/Silver Border, +Old/Modern Border, Future Frame) 400 on tap; `Full Art`/`Borderless`/ +`Showcase`/`Extended` already work since they're seeded by the existing +`seed_default_tags`. ## Key files @@ -553,25 +680,49 @@ and a moderator-only queue tab beside the ones described here. `cardpicker/management/commands/seed_no_match_reason_tags.py` (Stage 4, display_name seeding Stage 5), `cardpicker/deductive_backfill.py` + management command - `deductive_backfill_printing_tags` (Stage 6) -- Frontend: `frontend/src/features/printingTags/` (`PrintingTagQueue.tsx`, - `PrintingTagPicker.tsx`, `starburstShape.ts`, `useStickyTop`), + `deductive_backfill_printing_tags` (Stage 6), + `cardpicker/question_feed.py`, `cardpicker/attribute_tags.py` + + management command `seed_attribute_tags` (Stage 7) +- Frontend: `frontend/src/features/printingTags/` (`PrintingTagPicker.tsx`, + `starburstShape.ts`, `cardPanel.tsx` — the extracted sticky/starburst/ + reveal/candidate-grid mechanics, Stage 7), `frontend/src/features/filters/ResolvedAttributeFilter.tsx` (Stage 3), `frontend/src/common/processing.ts::getPrintingMatchLabel` (Stage 3), `frontend/src/features/attributeVoting/` (`ChipCard.tsx`, - `NoMatchReasonStrip.tsx`, `PrintingConfirmStrip.tsx` — Stage 4), - `frontend/src/common/tagDisplayNames.ts` (Stage 5) + `NoMatchReasonStrip.tsx` — Stage 4; `QueueTagQuestion.tsx`, + `ArtistVotePicker.tsx` — reused directly by Stage 7), + `frontend/src/common/tagDisplayNames.ts` (Stage 5), + `frontend/src/features/attributeChips/`, `frontend/src/features/ questionFeed/QuestionFeed.tsx`, `frontend/src/pages/whatsthat.tsx` + (renamed from `printingQueue.tsx` — Stage 7) - `docs/upstreaming/vote-system.md`, `docs/federation-v1.md` (`name` vs. `display_name` interchange-key note, Stage 5) ## Known gaps +- The Stage 7 layout (starburst/card/chip-ring composition) was hand-tuned + via iterative screenshot review, not built against a real design system - + owner has flagged that this needs a proper pass with the `/dataviz` skill + in the future rather than further ad hoc CSS tuning. - `CanonicalCard.image_hash` is bootstrapped to `0` for every row (`--skip-image-hash`); real perceptual-hash-based matching isn't implemented yet. - Client-side (Orama) search has no Stage 3 parity — see above. - Upstreaming this feature is deprioritized — see [[../infrastructure.md]]'s Upstreaming section. +- Tier-1 `confirm_suggestion` volume (28,112) is confirmed via a direct + live query, not the _live-usage_ starvation impact - whether it actually + swamps tiers 2-4 in practice (vs. just in raw candidate-set size) is a + server follow-up. +- `netPolarity`'s optimistic client-side update (set to the tapped + direction's extreme immediately, reconciled with the server's real + value once the response lands) isn't linear in vote count once AI/admin + weights are involved - can't fully verify the two never visibly diverge + against MSW mocks alone. +- Border Color's v1 chip set omits gold/yellow `border_color` values, and + the frame_effects chip set omits `legendary`/`inverted` despite higher + raw counts than the chips that made the cut - both flagged as judgment + calls in Stage 7 above, worth revisiting with real moderator/voter + feedback. - Stage numbering: Stage 4 (no-match reason tags, merged as PR #12), Stage 5 (tag identity/presentation decoupling via `Tag.display_name`, merged as PR #14), and Stage 6 (this document's current stage — diff --git a/docs/lessons.md b/docs/lessons.md index 686514a21..4483f7067 100644 --- a/docs/lessons.md +++ b/docs/lessons.md @@ -108,6 +108,26 @@ establishing a scroll container. Verify by scripting an actual scroll and measuring `getBoundingClientRect()` at multiple offsets — a static screenshot at one scroll position won't reveal a broken sticky context. +**(3) A negative z-index on that sticky element (per (1) above) is a ticking +time bomb once anything inside it needs to be clickable.** An uncontained +`z-index: -1` escapes all the way up to whatever ancestor DOES establish a +stacking context — which can be many levels up, or the document root — and +can make the sticky element's _entire subtree_, descendants included, +unclickable at the browser's hit-testing layer (`elementFromPoint` at a +descendant's own on-screen coordinates resolves to a grandparent instead), +even though everything still paints exactly where expected and looks +completely normal in a screenshot. This is silent as long as the sticky +element only ever shows static content — the bug was latent in this +codebase's own starburst card panel for months before an unrelated feature +added the first interactive control inside it. Fix: give the sticky +element's _own parent_ a real, local stacking context — `position: relative` +**and** an explicit non-`auto` `z-index` (e.g. `0`) together. +`position: relative` alone does not establish one; that gap alone is worth +budgeting a full extra "fixed, still broken" round for. Diagnose via +`document.elementFromPoint(x, y)` at the target's own +`getBoundingClientRect()` center, not via CSS inspection or screenshots — +a screenshot cannot distinguish "renders here" from "is hit-testable here." + ## A new wrapper placed around an existing effect can silently fight that effect's own CSS When component B is later wrapped around component A, check whether B's own @@ -205,3 +225,21 @@ regardless of how the request is phrased ("data migration" in a task spec should be read as "a repeatable seeding step," not literally `migrations.RunPython`, when the target table has DB-wide list-all consumers). + +## A call-count-based MSW mock breaks under React 18 Strict Mode's dev-time double-invoke + +A Playwright mock like "return item X on the first `GET`, then a +caught-up/empty response on every call after" is a trap in this codebase +(`reactStrictMode: true` in `next.config.js`): Strict Mode double-invokes +effects on mount in dev (mount → cleanup → mount again), so a fetch effect +fires twice before the app "really" settles. A naive counter-based mock +hands its one real item to the _first_ (thrown-away) invocation and the +empty response to the second (kept) one — the UI never shows the item at +all, and the resulting test failure (a locator that never appears) looks +identical to a real rendering/interception bug, not a mock-design one. +Symptom to watch for: a Playwright test times out waiting for content +that a Jest/RTL test covering the identical interaction passes for +instantly — Jest doesn't run Strict Mode's double-invoke the same way a +real browser mount does. Fix: make the mock's "have I served the real +item yet" state track a genuine domain event the flow itself causes +(e.g. a specific vote being submitted), not a raw request count. diff --git a/frontend/src/common/schema_types.ts b/frontend/src/common/schema_types.ts index bb5ccad98..72928f052 100644 --- a/frontend/src/common/schema_types.ts +++ b/frontend/src/common/schema_types.ts @@ -2,7 +2,7 @@ // To parse this data: // -// import { Convert, ArtistVoteTallyEntry, Campaign, CanonicalArtist, CanonicalCard, Card, CardType, FilterSettings, Game, ImportSite, Language, ModerationQueueItem, NewCardsFirstPage, PrintingCandidate, PrintingTagStatus, SearchQuery, SearchSettings, SearchTypeSettings, SortBy, Source, SourceContribution, SourceSettings, SourceType, Supporter, SupporterTier, Tag, TagConsensusEntry, TagVoteTallyEntry, VoteQueueItem, VoteTallyEntry, ArtistCandidatesRequest, ArtistCandidatesResponse, ArtistConsensusRequest, ArtistConsensusResponse, CardbacksRequest, CardbacksResponse, CardsRequest, CardsResponse, ContributionsResponse, DFCPairsResponse, EditorSearchRequest, EditorSearchResponse, ErrorResponse, ExploreSearchRequest, ExploreSearchResponse, ImportSiteDecklistRequest, ImportSiteDecklistResponse, ImportSitesResponse, InfoResponse, LanguagesResponse, ModerationQueueRequest, ModerationQueueResponse, NewCardsFirstPagesResponse, NewCardsPageResponse, OldEditorSearchRequest, OldEditorSearchResponse, PatreonResponse, PrintingCandidatesRequest, PrintingCandidatesResponse, PrintingConsensusRequest, PrintingConsensusResponse, PrintingTagQueueResponse, ReportCardRequest, ReportCardResponse, SampleCardsResponse, SearchEngineHealthResponse, SourcesResponse, SubmitArtistVoteRequest, SubmitPrintingTagRequest, SubmitTagVoteRequest, TagConsensusRequest, TagConsensusResponse, TagsResponse, VoteQueueRequest, VoteQueueResponse, WhoamiResponse } from "./file"; +// import { Convert, ArtistVoteTallyEntry, Campaign, CanonicalArtist, CanonicalCard, Card, CardType, FilterSettings, Game, ImportSite, Language, ModerationQueueItem, NewCardsFirstPage, PrintingCandidate, PrintingTagStatus, QuestionFeedItem, QuestionFeedResponse, SearchQuery, SearchSettings, SearchTypeSettings, SortBy, Source, SourceContribution, SourceSettings, SourceType, Supporter, SupporterTier, Tag, TagConsensusEntry, TagVoteTallyEntry, VoteQueueItem, VoteTallyEntry, ArtistCandidatesRequest, ArtistCandidatesResponse, ArtistConsensusRequest, ArtistConsensusResponse, CardbacksRequest, CardbacksResponse, CardsRequest, CardsResponse, ContributionsResponse, DFCPairsResponse, EditorSearchRequest, EditorSearchResponse, ErrorResponse, ExploreSearchRequest, ExploreSearchResponse, ImportSiteDecklistRequest, ImportSiteDecklistResponse, ImportSitesResponse, InfoResponse, LanguagesResponse, ModerationQueueRequest, ModerationQueueResponse, NewCardsFirstPagesResponse, NewCardsPageResponse, OldEditorSearchRequest, OldEditorSearchResponse, PatreonResponse, PrintingCandidatesRequest, PrintingCandidatesResponse, PrintingConsensusRequest, PrintingConsensusResponse, PrintingTagQueueResponse, ReportCardRequest, ReportCardResponse, SampleCardsResponse, SearchEngineHealthResponse, SourcesResponse, SubmitArtistVoteRequest, SubmitPrintingTagRequest, SubmitTagVoteRequest, TagConsensusRequest, TagConsensusResponse, TagsResponse, VoteQueueRequest, VoteQueueResponse, WhoamiResponse } from "./file"; // // const artistVoteTallyEntry = Convert.toArtistVoteTallyEntry(json); // const campaign = Convert.toCampaign(json); @@ -18,6 +18,8 @@ // const newCardsFirstPage = Convert.toNewCardsFirstPage(json); // const printingCandidate = Convert.toPrintingCandidate(json); // const printingTagStatus = Convert.toPrintingTagStatus(json); +// const questionFeedItem = Convert.toQuestionFeedItem(json); +// const questionFeedResponse = Convert.toQuestionFeedResponse(json); // const searchQuery = Convert.toSearchQuery(json); // const searchSettings = Convert.toSearchSettings(json); // const searchTypeSettings = Convert.toSearchTypeSettings(json); @@ -88,19 +90,148 @@ export enum Game { Mtg = "MTG", } -export interface ArtistCandidatesRequest { +export interface QuestionFeedResponse { + item?: QuestionFeedItem; + remainingEstimate: number; +} + +export interface QuestionFeedItem { + candidates?: PrintingCandidate[]; + card: Card; + confidentlyKnownArtistName?: null | string; + reportCount?: number; + reportExcerpts?: string[]; + suggestedPrinting?: PrintingCandidate; + tagConfidence?: { [key: string]: number }; + tagName?: string; + type: Type; +} + +export interface PrintingCandidate { + artist: string; + borderColor: string; + canonicalId: string; + collectorNumber: string; + expansionCode: string; + expansionName: string; + frame: string; + fullArt: boolean; identifier: string; - query?: null | string; + isBorderless: boolean; + isEtched: boolean; + isExtendedArt: boolean; + isShowcase: boolean; + mediumThumbnailUrl: string; + releasedAt?: null | string; + smallThumbnailUrl: string; } -export interface ArtistCandidatesResponse { - results: Array; +export interface Card { + canonicalArtist?: CanonicalArtist | null; + /** + * True only when canonicalArtist was supplied by artist-vote consensus alone, with no + * confirmed indexing match or resolved printing backing it - lets the frontend distinguish + * a confidently-known artist from a vote-derived one (e.g. for the ArtistVotePicker + * 'wrong?' affordance) without needing to know serialise()'s fallback chain itself. + */ + canonicalArtistIsFromVoteOnly?: boolean; + /** + * Which rung of the artist fallback chain actually supplied canonicalArtist - + * debug/introspection field, not load-bearing for any current frontend logic. + */ + canonicalArtistSource?: null | string; + canonicalCard?: CanonicalCard | null; + cardType: CardType; + /** + * Created date - formatted by backend + */ + dateCreated: string; + /** + * Modified date - formatted by backend + */ + dateModified: string; + dpi: number; + extension: string; + identifier: string; + language: string; + mediumThumbnailUrl: string; + name: string; + /** + * Community printing-tag vote consensus status for this card. Only RESOLVED cards have a + * community-confirmed printing behind canonicalCard (via inferred_canonical_card) - used by + * the frontend to show a 'matched by community tags' indicator and is otherwise + * informational. + */ + printingTagStatus: PrintingTagStatus; + priority: number; + searchq: string; + size: number; + smallThumbnailUrl: string; + source: string; + sourceExternalLink?: string; + sourceId: number; + sourceName: string; + sourceType?: SourceType; + sourceVerbose: string; + tags: string[]; } export interface CanonicalArtist { name: string; } +export interface CanonicalCard { + artist?: string; + canonicalId?: string; + collectorNumber: string; + expansionCode: string; + expansionName: string; + identifier: string; + mediumThumbnailUrl: string; + smallThumbnailUrl: string; +} + +export enum CardType { + Card = "CARD", + Cardback = "CARDBACK", + Token = "TOKEN", +} + +/** + * Community printing-tag vote consensus status for this card. Only RESOLVED cards have a + * community-confirmed printing behind canonicalCard (via inferred_canonical_card) - used by + * the frontend to show a 'matched by community tags' indicator and is otherwise + * informational. + */ +export enum PrintingTagStatus { + NoMatch = "no_match", + Resolved = "resolved", + Unresolved = "unresolved", +} + +export enum SourceType { + AwsS3 = "AWS S3", + GoogleDrive = "Google Drive", + LocalFile = "Local File", +} + +export enum Type { + Artist = "artist", + ConfirmSuggestion = "confirm_suggestion", + IdentifyPrinting = "identify_printing", + Moderation = "moderation", + Tag = "tag", +} + +export interface ArtistCandidatesRequest { + identifier: string; + query?: null | string; +} + +export interface ArtistCandidatesResponse { + results: Array; +} + export interface ArtistConsensusRequest { identifier: string; } @@ -196,91 +327,6 @@ export interface CardsResponse { results: { [key: string]: Card }; } -export interface Card { - canonicalArtist?: CanonicalArtist | null; - /** - * True only when canonicalArtist was supplied by artist-vote consensus alone, with no - * confirmed indexing match or resolved printing backing it - lets the frontend distinguish - * a confidently-known artist from a vote-derived one (e.g. for the ArtistVotePicker - * 'wrong?' affordance) without needing to know serialise()'s fallback chain itself. - */ - canonicalArtistIsFromVoteOnly?: boolean; - /** - * Which rung of the artist fallback chain actually supplied canonicalArtist - - * debug/introspection field, not load-bearing for any current frontend logic. - */ - canonicalArtistSource?: null | string; - canonicalCard?: CanonicalCard | null; - cardType: CardType; - /** - * Created date - formatted by backend - */ - dateCreated: string; - /** - * Modified date - formatted by backend - */ - dateModified: string; - dpi: number; - extension: string; - identifier: string; - language: string; - mediumThumbnailUrl: string; - name: string; - /** - * Community printing-tag vote consensus status for this card. Only RESOLVED cards have a - * community-confirmed printing behind canonicalCard (via inferred_canonical_card) - used by - * the frontend to show a 'matched by community tags' indicator and is otherwise - * informational. - */ - printingTagStatus: PrintingTagStatus; - priority: number; - searchq: string; - size: number; - smallThumbnailUrl: string; - source: string; - sourceExternalLink?: string; - sourceId: number; - sourceName: string; - sourceType?: SourceType; - sourceVerbose: string; - tags: string[]; -} - -export interface CanonicalCard { - artist?: string; - canonicalId?: string; - collectorNumber: string; - expansionCode: string; - expansionName: string; - identifier: string; - mediumThumbnailUrl: string; - smallThumbnailUrl: string; -} - -export enum CardType { - Card = "CARD", - Cardback = "CARDBACK", - Token = "TOKEN", -} - -/** - * Community printing-tag vote consensus status for this card. Only RESOLVED cards have a - * community-confirmed printing behind canonicalCard (via inferred_canonical_card) - used by - * the frontend to show a 'matched by community tags' indicator and is otherwise - * informational. - */ -export enum PrintingTagStatus { - NoMatch = "no_match", - Resolved = "resolved", - Unresolved = "unresolved", -} - -export enum SourceType { - AwsS3 = "AWS S3", - GoogleDrive = "Google Drive", - LocalFile = "Local File", -} - export interface ContributionsResponse { cardCountByType: { [key: string]: number }; sources: SourceContribution[]; @@ -477,21 +523,6 @@ export interface PrintingCandidatesResponse { results: PrintingCandidate[]; } -export interface PrintingCandidate { - artist: string; - canonicalId: string; - collectorNumber: string; - expansionCode: string; - expansionName: string; - frame: string; - fullArt: boolean; - identifier: string; - isBorderless: boolean; - mediumThumbnailUrl: string; - releasedAt?: null | string; - smallThumbnailUrl: string; -} - export interface PrintingConsensusRequest { identifier: string; } @@ -584,6 +615,7 @@ export interface TagConsensusResponse { } export interface TagConsensusEntry { + netPolarity: number; resolvedPolarity?: number | null; tagName: string; tally: TagVoteTallyEntry[]; @@ -768,6 +800,24 @@ export class Convert { return JSON.stringify(uncast(value, r("PrintingTagStatus")), null, 2); } + public static toQuestionFeedItem(json: string): QuestionFeedItem { + return cast(JSON.parse(json), r("QuestionFeedItem")); + } + + public static questionFeedItemToJson(value: QuestionFeedItem): string { + return JSON.stringify(uncast(value, r("QuestionFeedItem")), null, 2); + } + + public static toQuestionFeedResponse(json: string): QuestionFeedResponse { + return cast(JSON.parse(json), r("QuestionFeedResponse")); + } + + public static questionFeedResponseToJson( + value: QuestionFeedResponse + ): string { + return JSON.stringify(uncast(value, r("QuestionFeedResponse")), null, 2); + } + public static toSearchQuery(json: string): SearchQuery { return cast(JSON.parse(json), r("SearchQuery")); } @@ -1576,6 +1626,140 @@ function r(name: string) { } const typeMap: any = { + QuestionFeedResponse: o( + [ + { json: "item", js: "item", typ: u(undefined, r("QuestionFeedItem")) }, + { json: "remainingEstimate", js: "remainingEstimate", typ: 0 }, + ], + false + ), + QuestionFeedItem: o( + [ + { + json: "candidates", + js: "candidates", + typ: u(undefined, a(r("PrintingCandidate"))), + }, + { json: "card", js: "card", typ: r("Card") }, + { + json: "confidentlyKnownArtistName", + js: "confidentlyKnownArtistName", + typ: u(undefined, u(null, "")), + }, + { json: "reportCount", js: "reportCount", typ: u(undefined, 0) }, + { + json: "reportExcerpts", + js: "reportExcerpts", + typ: u(undefined, a("")), + }, + { + json: "suggestedPrinting", + js: "suggestedPrinting", + typ: u(undefined, r("PrintingCandidate")), + }, + { + json: "tagConfidence", + js: "tagConfidence", + typ: u(undefined, m(3.14)), + }, + { json: "tagName", js: "tagName", typ: u(undefined, "") }, + { json: "type", js: "type", typ: r("Type") }, + ], + false + ), + PrintingCandidate: o( + [ + { json: "artist", js: "artist", typ: "" }, + { json: "borderColor", js: "borderColor", typ: "" }, + { json: "canonicalId", js: "canonicalId", typ: "" }, + { json: "collectorNumber", js: "collectorNumber", typ: "" }, + { json: "expansionCode", js: "expansionCode", typ: "" }, + { json: "expansionName", js: "expansionName", typ: "" }, + { json: "frame", js: "frame", typ: "" }, + { json: "fullArt", js: "fullArt", typ: true }, + { json: "identifier", js: "identifier", typ: "" }, + { json: "isBorderless", js: "isBorderless", typ: true }, + { json: "isEtched", js: "isEtched", typ: true }, + { json: "isExtendedArt", js: "isExtendedArt", typ: true }, + { json: "isShowcase", js: "isShowcase", typ: true }, + { json: "mediumThumbnailUrl", js: "mediumThumbnailUrl", typ: "" }, + { json: "releasedAt", js: "releasedAt", typ: u(undefined, u(null, "")) }, + { json: "smallThumbnailUrl", js: "smallThumbnailUrl", typ: "" }, + ], + false + ), + Card: o( + [ + { + json: "canonicalArtist", + js: "canonicalArtist", + typ: u(undefined, u(r("CanonicalArtist"), null)), + }, + { + json: "canonicalArtistIsFromVoteOnly", + js: "canonicalArtistIsFromVoteOnly", + typ: u(undefined, true), + }, + { + json: "canonicalArtistSource", + js: "canonicalArtistSource", + typ: u(undefined, u(null, "")), + }, + { + json: "canonicalCard", + js: "canonicalCard", + typ: u(undefined, u(r("CanonicalCard"), null)), + }, + { json: "cardType", js: "cardType", typ: r("CardType") }, + { json: "dateCreated", js: "dateCreated", typ: "" }, + { json: "dateModified", js: "dateModified", typ: "" }, + { json: "dpi", js: "dpi", typ: 0 }, + { json: "extension", js: "extension", typ: "" }, + { json: "identifier", js: "identifier", typ: "" }, + { json: "language", js: "language", typ: "" }, + { json: "mediumThumbnailUrl", js: "mediumThumbnailUrl", typ: "" }, + { json: "name", js: "name", typ: "" }, + { + json: "printingTagStatus", + js: "printingTagStatus", + typ: r("PrintingTagStatus"), + }, + { json: "priority", js: "priority", typ: 0 }, + { json: "searchq", js: "searchq", typ: "" }, + { json: "size", js: "size", typ: 0 }, + { json: "smallThumbnailUrl", js: "smallThumbnailUrl", typ: "" }, + { json: "source", js: "source", typ: "" }, + { + json: "sourceExternalLink", + js: "sourceExternalLink", + typ: u(undefined, ""), + }, + { json: "sourceId", js: "sourceId", typ: 0 }, + { json: "sourceName", js: "sourceName", typ: "" }, + { + json: "sourceType", + js: "sourceType", + typ: u(undefined, r("SourceType")), + }, + { json: "sourceVerbose", js: "sourceVerbose", typ: "" }, + { json: "tags", js: "tags", typ: a("") }, + ], + false + ), + CanonicalArtist: o([{ json: "name", js: "name", typ: "" }], false), + CanonicalCard: o( + [ + { json: "artist", js: "artist", typ: u(undefined, "") }, + { json: "canonicalId", js: "canonicalId", typ: u(undefined, "") }, + { json: "collectorNumber", js: "collectorNumber", typ: "" }, + { json: "expansionCode", js: "expansionCode", typ: "" }, + { json: "expansionName", js: "expansionName", typ: "" }, + { json: "identifier", js: "identifier", typ: "" }, + { json: "mediumThumbnailUrl", js: "mediumThumbnailUrl", typ: "" }, + { json: "smallThumbnailUrl", js: "smallThumbnailUrl", typ: "" }, + ], + false + ), ArtistCandidatesRequest: o( [ { json: "identifier", js: "identifier", typ: "" }, @@ -1587,7 +1771,6 @@ const typeMap: any = { [{ json: "results", js: "results", typ: a(u(r("CanonicalArtist"), null)) }], false ), - CanonicalArtist: o([{ json: "name", js: "name", typ: "" }], false), ArtistConsensusRequest: o( [{ json: "identifier", js: "identifier", typ: "" }], false @@ -1682,77 +1865,6 @@ const typeMap: any = { [{ json: "results", js: "results", typ: m(r("Card")) }], false ), - Card: o( - [ - { - json: "canonicalArtist", - js: "canonicalArtist", - typ: u(undefined, u(r("CanonicalArtist"), null)), - }, - { - json: "canonicalArtistIsFromVoteOnly", - js: "canonicalArtistIsFromVoteOnly", - typ: u(undefined, true), - }, - { - json: "canonicalArtistSource", - js: "canonicalArtistSource", - typ: u(undefined, u(null, "")), - }, - { - json: "canonicalCard", - js: "canonicalCard", - typ: u(undefined, u(r("CanonicalCard"), null)), - }, - { json: "cardType", js: "cardType", typ: r("CardType") }, - { json: "dateCreated", js: "dateCreated", typ: "" }, - { json: "dateModified", js: "dateModified", typ: "" }, - { json: "dpi", js: "dpi", typ: 0 }, - { json: "extension", js: "extension", typ: "" }, - { json: "identifier", js: "identifier", typ: "" }, - { json: "language", js: "language", typ: "" }, - { json: "mediumThumbnailUrl", js: "mediumThumbnailUrl", typ: "" }, - { json: "name", js: "name", typ: "" }, - { - json: "printingTagStatus", - js: "printingTagStatus", - typ: r("PrintingTagStatus"), - }, - { json: "priority", js: "priority", typ: 0 }, - { json: "searchq", js: "searchq", typ: "" }, - { json: "size", js: "size", typ: 0 }, - { json: "smallThumbnailUrl", js: "smallThumbnailUrl", typ: "" }, - { json: "source", js: "source", typ: "" }, - { - json: "sourceExternalLink", - js: "sourceExternalLink", - typ: u(undefined, ""), - }, - { json: "sourceId", js: "sourceId", typ: 0 }, - { json: "sourceName", js: "sourceName", typ: "" }, - { - json: "sourceType", - js: "sourceType", - typ: u(undefined, r("SourceType")), - }, - { json: "sourceVerbose", js: "sourceVerbose", typ: "" }, - { json: "tags", js: "tags", typ: a("") }, - ], - false - ), - CanonicalCard: o( - [ - { json: "artist", js: "artist", typ: u(undefined, "") }, - { json: "canonicalId", js: "canonicalId", typ: u(undefined, "") }, - { json: "collectorNumber", js: "collectorNumber", typ: "" }, - { json: "expansionCode", js: "expansionCode", typ: "" }, - { json: "expansionName", js: "expansionName", typ: "" }, - { json: "identifier", js: "identifier", typ: "" }, - { json: "mediumThumbnailUrl", js: "mediumThumbnailUrl", typ: "" }, - { json: "smallThumbnailUrl", js: "smallThumbnailUrl", typ: "" }, - ], - false - ), ContributionsResponse: o( [ { json: "cardCountByType", js: "cardCountByType", typ: m(0) }, @@ -1980,23 +2092,6 @@ const typeMap: any = { [{ json: "results", js: "results", typ: a(r("PrintingCandidate")) }], false ), - PrintingCandidate: o( - [ - { json: "artist", js: "artist", typ: "" }, - { json: "canonicalId", js: "canonicalId", typ: "" }, - { json: "collectorNumber", js: "collectorNumber", typ: "" }, - { json: "expansionCode", js: "expansionCode", typ: "" }, - { json: "expansionName", js: "expansionName", typ: "" }, - { json: "frame", js: "frame", typ: "" }, - { json: "fullArt", js: "fullArt", typ: true }, - { json: "identifier", js: "identifier", typ: "" }, - { json: "isBorderless", js: "isBorderless", typ: true }, - { json: "mediumThumbnailUrl", js: "mediumThumbnailUrl", typ: "" }, - { json: "releasedAt", js: "releasedAt", typ: u(undefined, u(null, "")) }, - { json: "smallThumbnailUrl", js: "smallThumbnailUrl", typ: "" }, - ], - false - ), PrintingConsensusRequest: o( [{ json: "identifier", js: "identifier", typ: "" }], false @@ -2110,6 +2205,7 @@ const typeMap: any = { ), TagConsensusEntry: o( [ + { json: "netPolarity", js: "netPolarity", typ: 3.14 }, { json: "resolvedPolarity", js: "resolvedPolarity", @@ -2203,6 +2299,13 @@ const typeMap: any = { CardType: ["CARD", "CARDBACK", "TOKEN"], PrintingTagStatus: ["no_match", "resolved", "unresolved"], SourceType: ["AWS S3", "Google Drive", "Local File"], + Type: [ + "artist", + "confirm_suggestion", + "identify_printing", + "moderation", + "tag", + ], SortBy: [ "dateCreatedAscending", "dateCreatedDescending", diff --git a/frontend/src/common/test-constants.ts b/frontend/src/common/test-constants.ts index 1a9be1897..dcbd163f6 100644 --- a/frontend/src/common/test-constants.ts +++ b/frontend/src/common/test-constants.ts @@ -522,6 +522,10 @@ export const printingCandidate1: PrintingCandidate = { fullArt: false, isBorderless: false, frame: "2015", + borderColor: "black", + isShowcase: false, + isExtendedArt: false, + isEtched: false, releasedAt: "2020-01-01", }; @@ -537,6 +541,10 @@ export const printingCandidate2: PrintingCandidate = { fullArt: true, isBorderless: true, frame: "2003", + borderColor: "borderless", + isShowcase: true, + isExtendedArt: false, + isEtched: false, releasedAt: "2010-06-15", }; diff --git a/frontend/src/features/attributeChips/AttributeChipPanel.test.tsx b/frontend/src/features/attributeChips/AttributeChipPanel.test.tsx new file mode 100644 index 000000000..ac1b04fc6 --- /dev/null +++ b/frontend/src/features/attributeChips/AttributeChipPanel.test.tsx @@ -0,0 +1,129 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import React from "react"; +import { Provider } from "react-redux"; + +import { localBackendURL } from "@/common/test-constants"; +import { server } from "@/mocks/server"; +import { setupStore } from "@/store/store"; + +import { AttributeChipPanel, initialChipStates } from "./AttributeChipPanel"; + +function buildRoute(path: string): string { + return `${localBackendURL}/${path}`; +} + +// a thin controlled wrapper - mirrors how QuestionFeed.tsx actually owns chipStates, so the +// component under test exercises the same "lifted state" contract its real caller relies on +function Wrapper({ onSubmitted }: { onSubmitted?: (tagName: string) => void }) { + const [states, setStates] = React.useState(initialChipStates()); + return ( + + card} + /> + + ); +} + +describe("AttributeChipPanel", () => { + it("cycles a chip untouched -> positive -> negative -> untouched, casting one vote per tap", async () => { + server.use( + http.post(buildRoute("2/submitTagVote/"), async ({ request }) => { + const body = (await request.json()) as { + tagName: string; + polarity: number; + }; + return HttpResponse.json( + { + tagName: body.tagName, + resolvedPolarity: body.polarity === 0 ? null : body.polarity, + netPolarity: body.polarity, + tally: [], + }, + { status: 200 } + ); + }) + ); + render(); + + const chip = screen.getByTestId("attribute-chip-Full Art"); + expect(chip.getAttribute("data-chip-state")).toBe("untouched"); + + // each click's optimistic state update lands synchronously, but the button stays + // `disabled` (submitting) until the mocked request's promise resolves - wait for it to + // re-enable before firing the next click, or a click on a still-disabled button is a + // silent no-op in jsdom. + const waitForSettled = async (expectedState: string) => { + await waitFor(() => { + const el = screen.getByTestId("attribute-chip-Full Art"); + expect(el.getAttribute("data-chip-state")).toBe(expectedState); + expect(el).not.toBeDisabled(); + }); + }; + + fireEvent.click(chip); + await waitForSettled("positive"); + + fireEvent.click(screen.getByTestId("attribute-chip-Full Art")); + await waitForSettled("negative"); + + fireEvent.click(screen.getByTestId("attribute-chip-Full Art")); + await waitForSettled("untouched"); + }); + + it("tapping one exclusion-group chip does not cast a vote on its siblings", async () => { + const submittedTagNames: string[] = []; + server.use( + http.post(buildRoute("2/submitTagVote/"), async ({ request }) => { + const body = (await request.json()) as { + tagName: string; + polarity: number; + }; + submittedTagNames.push(body.tagName); + return HttpResponse.json( + { + tagName: body.tagName, + resolvedPolarity: null, + netPolarity: 1, + tally: [], + }, + { status: 200 } + ); + }) + ); + render(); + + fireEvent.click(screen.getByTestId("attribute-chip-Black Border")); + await waitFor(() => expect(submittedTagNames).toEqual(["Black Border"])); + + // sibling should render implied-negative (dimmed) without ever being submitted + expect(submittedTagNames).not.toContain("White Border"); + expect(submittedTagNames).not.toContain("Silver Border"); + const sibling = screen.getByTestId("attribute-chip-White Border"); + expect(sibling.getAttribute("data-chip-state")).toBe("untouched"); + }); + + it("reverts the explicit state on a failed submit", async () => { + server.use( + http.post(buildRoute("2/submitTagVote/"), () => + HttpResponse.json({ name: "Error", message: "failed" }, { status: 500 }) + ) + ); + render(); + + fireEvent.click(screen.getByTestId("attribute-chip-Full Art")); + await waitFor(() => + expect( + screen + .getByTestId("attribute-chip-Full Art") + .getAttribute("data-chip-state") + ).toBe("untouched") + ); + }); +}); diff --git a/frontend/src/features/attributeChips/AttributeChipPanel.tsx b/frontend/src/features/attributeChips/AttributeChipPanel.tsx new file mode 100644 index 000000000..3199bab51 --- /dev/null +++ b/frontend/src/features/attributeChips/AttributeChipPanel.tsx @@ -0,0 +1,264 @@ +/** + * Tri-state attribute chips surrounding the subject card in the unified question feed (see + * QuestionFeed.tsx and docs/features/printing-tags.md's questionFeed section). Each chip + * cycles untouched -> positive -> negative -> untouched on tap, casting a real CardTagVote + * each time (including the retraction on cycling back to untouched - see + * cardpicker.views.RETRACT_POLARITY). Fill color/intensity renders the tag's current + * weighted net polarity (confidence), independent of - though usually correlated with - this + * voter's own explicit state; exclusion-group siblings of an explicitly-positive chip render + * a separate "implied-negative" dimmed style without casting a vote of their own. + */ + +import styled from "@emotion/styled"; +import React, { useState } from "react"; + +import { getOrCreateAnonymousId } from "@/common/cookies"; +import { useTagDisplayName } from "@/common/tagDisplayNames"; +import { useAppDispatch } from "@/common/types"; +import { + ALL_ATTRIBUTE_CHIPS, + CHIP_POLARITY, + ChipVoteState, + EXCLUSION_GROUPS, + findExclusionGroup, + nextChipState, + STANDALONE_CHIPS, +} from "@/features/attributeChips/attributeChips"; +import { APISubmitTagVote } from "@/store/api"; +import { setNotification } from "@/store/slices/toastsSlice"; + +const POSITIVE_RGB = "40, 167, 69"; // bootstrap "success" green +const NEGATIVE_RGB = "220, 53, 69"; // bootstrap "danger" red + +// alpha floor keeps a chip with a real but weak vote (netPolarity near 0) visibly distinct +// from a genuinely untouched, zero-signal chip - both would otherwise render identically at +// alpha 0, losing the "some signal exists, it's just weak" information entirely. +function confidenceFill(netPolarity: number): string { + if (netPolarity === 0) return "transparent"; + const rgb = netPolarity > 0 ? POSITIVE_RGB : NEGATIVE_RGB; + const alpha = 0.15 + Math.min(Math.abs(netPolarity), 1) * 0.55; + return `rgba(${rgb}, ${alpha})`; +} + +const Chip = styled.button<{ fill: string; impliedNegative: boolean }>` + border: 2px solid rgba(0, 0, 0, 0.25); + border-radius: 0.5rem; + background-color: ${(props) => props.fill}; + opacity: ${(props) => (props.impliedNegative ? 0.45 : 1)}; + color: inherit; + padding: 0.35rem 0.6rem; + font-size: 0.85rem; + white-space: nowrap; + + &:disabled { + opacity: 0.5; + } +`; + +const ChipRow = styled.div` + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + justify-content: center; +`; + +const ChipColumn = styled.div` + display: flex; + flex-direction: column; + gap: 0.4rem; + align-items: stretch; +`; + +// A 3x3 grid with the card slot dead center and chips forming a ring around it - "top" holds +// the standalone toggles, "left"/"right" hold the two exclusion groups (arbitrarily assigned; +// nothing about a group is inherently left- or right-handed). Empty grid-template-columns +// cells (corners, bottom) collapse via `auto` sizing rather than reserving dead space. +const ChipRing = styled.div` + display: grid; + grid-template-areas: + ". top ." + "left card right" + ". . ."; + grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-rows: auto auto auto; + gap: 0.6rem; + align-items: center; + justify-items: center; +`; + +const TopArea = styled(ChipRow)` + grid-area: top; +`; + +const LeftArea = styled(ChipColumn)` + grid-area: left; +`; + +const RightArea = styled(ChipColumn)` + grid-area: right; +`; + +// position: relative so an absolutely-positioned burst rendered as part of `cardSlot` (see +// QuestionFeed.tsx) sizes and centers itself against the card's own box specifically, not +// this whole ring (which includes the flanking chip columns and would make the burst far +// larger, and off-center, than intended - see docs/features/printing-tags.md's Stage 7). +const CardArea = styled.div` + grid-area: card; + width: 100%; + position: relative; +`; + +interface AttributeChipPanelProps { + backendURL: string; + cardIdentifier: string; + /** tagName -> weighted net polarity in [-1, 1], from the questionFeed payload. */ + tagConfidence: Record; + /** Controlled explicit vote state per tagName - lifted to the parent since candidate + * filtering (QuestionFeed.tsx) needs to read the same state. */ + chipStates: Record; + onChipStatesChange: (next: Record) => void; + /** The card image/reveal-overlay/caption, rendered dead center with chips forming a ring + * around it - passed in rather than owned here so QuestionFeed.tsx keeps sole ownership of + * the reveal-animation state machine (revealed/onAnimationEnd) that slot's contents depend on. */ + cardSlot: React.ReactNode; +} + +export function AttributeChipPanel({ + backendURL, + cardIdentifier, + tagConfidence, + chipStates, + onChipStatesChange, + cardSlot, +}: AttributeChipPanelProps) { + const dispatch = useAppDispatch(); + const getTagDisplayName = useTagDisplayName(); + const [submittingTagName, setSubmittingTagName] = useState( + null + ); + const [confidence, setConfidence] = + useState>(tagConfidence); + + React.useEffect(() => { + setConfidence(tagConfidence); + }, [tagConfidence]); + + const tap = (tagName: string) => { + const previousState = chipStates[tagName] ?? "untouched"; + const previousConfidence = confidence[tagName] ?? 0; + const nextState = nextChipState(previousState); + const polarity = CHIP_POLARITY[nextState]; + + // optimistic: nudge the fill toward the tapped direction immediately, and update the + // explicit state right away - both get reconciled with the server response below + onChipStatesChange({ ...chipStates, [tagName]: nextState }); + setConfidence((previous) => ({ + ...previous, + [tagName]: polarity === 0 ? 0 : polarity, + })); + setSubmittingTagName(tagName); + + APISubmitTagVote( + backendURL, + cardIdentifier, + getOrCreateAnonymousId(), + tagName, + polarity + ) + .then((response) => { + setConfidence((previous) => ({ + ...previous, + [tagName]: response.netPolarity, + })); + }) + .catch(() => { + // revert both the explicit state and the optimistic fill on failure + onChipStatesChange({ ...chipStates, [tagName]: previousState }); + setConfidence((previous) => ({ + ...previous, + [tagName]: previousConfidence, + })); + dispatch( + setNotification([ + Math.random().toString(), + { + name: "Vote failed", + message: + "Something went wrong submitting your tag - please try again.", + level: "error", + }, + ]) + ); + }) + .finally(() => setSubmittingTagName(null)); + }; + + const renderChip = (tagName: string, label: string) => { + const explicitState = chipStates[tagName] ?? "untouched"; + const group = findExclusionGroup(tagName); + const impliedNegative = + explicitState === "untouched" && + group != null && + group.chips.some( + (sibling) => + sibling.tagName !== tagName && + (chipStates[sibling.tagName] ?? "untouched") === "positive" + ); + return ( + tap(tagName)} + data-testid={`attribute-chip-${tagName}`} + data-chip-state={explicitState} + title={ + explicitState === "positive" + ? "Yes" + : explicitState === "negative" + ? "No" + : "Tap to describe what you see" + } + > + {getTagDisplayName(label)} + + ); + }; + + // EXCLUSION_GROUPS[0] (Border Color) renders left, [1] (Frame Style) renders right - an + // arbitrary but fixed assignment, not a semantic left/right meaning for either group. + const [leftGroup, rightGroup] = EXCLUSION_GROUPS; + + return ( + + + {STANDALONE_CHIPS.map((chip) => renderChip(chip.tagName, chip.label))} + + {leftGroup != null && ( + + {leftGroup.chips.map((chip) => renderChip(chip.tagName, chip.label))} + + )} + {cardSlot} + {rightGroup != null && ( + + {rightGroup.chips.map((chip) => renderChip(chip.tagName, chip.label))} + + )} + + ); +} + +export function initialChipStates(): Record { + return Object.fromEntries( + ALL_ATTRIBUTE_CHIPS.map((chip) => [chip.tagName, "untouched"]) + ); +} + +export function hasAnyExplicitChip( + chipStates: Record +): boolean { + return Object.values(chipStates).some((state) => state !== "untouched"); +} diff --git a/frontend/src/features/attributeChips/attributeChips.test.ts b/frontend/src/features/attributeChips/attributeChips.test.ts new file mode 100644 index 000000000..32ee2decf --- /dev/null +++ b/frontend/src/features/attributeChips/attributeChips.test.ts @@ -0,0 +1,85 @@ +import { + printingCandidate1, + printingCandidate2, +} from "@/common/test-constants"; + +import { + ALL_ATTRIBUTE_CHIPS, + filterCandidatesByChipStates, + findExclusionGroup, + nextChipState, +} from "./attributeChips"; + +describe("nextChipState", () => { + it("cycles untouched -> positive -> negative -> untouched", () => { + expect(nextChipState("untouched")).toBe("positive"); + expect(nextChipState("positive")).toBe("negative"); + expect(nextChipState("negative")).toBe("untouched"); + }); +}); + +describe("findExclusionGroup", () => { + it("finds the group a border-color chip belongs to", () => { + expect(findExclusionGroup("Black Border")?.id).toBe("borderColor"); + }); + + it("finds the group a frame-style chip belongs to", () => { + expect(findExclusionGroup("Old Border")?.id).toBe("frameStyle"); + }); + + it("returns undefined for a standalone chip", () => { + expect(findExclusionGroup("Full Art")).toBeUndefined(); + }); +}); + +describe("ALL_ATTRIBUTE_CHIPS", () => { + it("has no duplicate tagNames", () => { + const names = ALL_ATTRIBUTE_CHIPS.map((chip) => chip.tagName); + expect(new Set(names).size).toBe(names.length); + }); +}); + +describe("filterCandidatesByChipStates", () => { + // printingCandidate1: fullArt=false, isBorderless=false, isShowcase=false, borderColor="black" + // printingCandidate2: fullArt=true, isBorderless=true, isShowcase=true, borderColor="borderless" + const candidates = [printingCandidate1, printingCandidate2]; + + it("returns every candidate when no chip is explicit", () => { + expect(filterCandidatesByChipStates(candidates, {})).toEqual(candidates); + }); + + it("a positive standalone chip keeps only matching candidates", () => { + const result = filterCandidatesByChipStates(candidates, { + "Full Art": "positive", + }); + expect(result).toEqual([printingCandidate2]); + }); + + it("a negative standalone chip drops matching candidates", () => { + const result = filterCandidatesByChipStates(candidates, { + "Full Art": "negative", + }); + expect(result).toEqual([printingCandidate1]); + }); + + it("a positive exclusion-group chip naturally excludes sibling values with no extra logic", () => { + // printingCandidate1 is black-bordered, printingCandidate2 is borderless (not in this group) + const result = filterCandidatesByChipStates(candidates, { + "Black Border": "positive", + }); + expect(result).toEqual([printingCandidate1]); + }); + + it("combines multiple active chips with AND semantics", () => { + const result = filterCandidatesByChipStates(candidates, { + "Full Art": "positive", + Borderless: "positive", + }); + expect(result).toEqual([printingCandidate2]); + const noMatch = filterCandidatesByChipStates(candidates, { + "Full Art": "positive", + Borderless: "negative", // contradictory - candidate2 is both fullArt and borderless + }); + expect(noMatch).toEqual([]); + }); +}); diff --git a/frontend/src/features/attributeChips/attributeChips.ts b/frontend/src/features/attributeChips/attributeChips.ts new file mode 100644 index 000000000..35c66a7a9 --- /dev/null +++ b/frontend/src/features/attributeChips/attributeChips.ts @@ -0,0 +1,175 @@ +/** + * The attribute-chip taxonomy for the "What's That Card?" question feed (see + * docs/features/printing-tags.md's questionFeed section and + * journal/2026-07-14-queue-question-feed-design.md for the full grounding/data-census this + * set is derived from - don't add a chip here without checking that doc first, the taxonomy + * is deliberately NOT "every value CanonicalPrintingMetadata happens to contain"). + * + * Every `tagName` below must already exist as a seeded `Tag` row for a tap to actually work + * (400s otherwise) - "Full Art"/"Borderless"/"Showcase"/"Extended" come from + * cardpicker.default_tags (already seeded in production); the rest come from + * cardpicker.attribute_tags.ATTRIBUTE_TAGS (seed_attribute_tags command, run once as part of + * this feature's own deploy step, same pattern as seed_sensitive_tags). + */ + +import { PrintingCandidate } from "@/common/schema_types"; + +export interface AttributeChipDef { + /** The backend Tag.name this chip votes on - see cardpicker.attribute_tags. */ + tagName: string; + label: string; + /** Whether a given candidate visibly has this attribute - drives auto-tag-on-selection + * (standalone chips only - see QuestionFeed.tsx) and live candidate filtering. */ + matches: (candidate: PrintingCandidate) => boolean; +} + +export interface ExclusionGroup { + id: string; + label: string; + chips: AttributeChipDef[]; +} + +// EXCLUSION GROUPS: a card has exactly one border_color / one frame value, so within a +// group a positive tap on one chip drives live filtering + renders implied-negative styling +// on its siblings - but casts NO vote on those siblings, only on the one actually tapped +// (spec requirement: "votes are only explicit taps"). Filtering itself needs no special +// group-awareness beyond this - see filterCandidatesByChipStates in QuestionFeed.tsx: an +// explicit positive on one member already excludes every candidate whose border_color/frame +// doesn't match, which naturally excludes the group's other values with no extra logic. +export const BORDER_COLOR_GROUP: ExclusionGroup = { + id: "borderColor", + label: "Border Color", + chips: [ + { + tagName: "Black Border", + label: "Black Border", + matches: (candidate) => candidate.borderColor === "black", + }, + { + tagName: "White Border", + label: "White Border", + matches: (candidate) => candidate.borderColor === "white", + }, + { + tagName: "Silver Border", + label: "Silver Border", + matches: (candidate) => candidate.borderColor === "silver", + }, + ], +}; + +// Bucketed 1993+1997 -> "Old Border" and 2003+2015 -> "Modern Border" rather than exposing +// all four raw Scryfall frame years as separate chips - the finer distinctions are hard for +// a non-expert to reliably tell apart at a glance. See the design doc for the full rationale. +export const FRAME_STYLE_GROUP: ExclusionGroup = { + id: "frameStyle", + label: "Frame Style", + chips: [ + { + tagName: "Old Border", + label: "Old Border", + matches: (candidate) => + candidate.frame === "1993" || candidate.frame === "1997", + }, + { + tagName: "Modern Border", + label: "Modern Border", + matches: (candidate) => + candidate.frame === "2003" || candidate.frame === "2015", + }, + { + tagName: "Future Frame", + label: "Future Frame", + matches: (candidate) => candidate.frame === "future", + }, + ], +}; + +export const EXCLUSION_GROUPS: ExclusionGroup[] = [ + BORDER_COLOR_GROUP, + FRAME_STYLE_GROUP, +]; + +// Independent toggles - not mutually exclusive with each other or with the exclusion groups +// above (a card can be simultaneously Full Art, Showcase, and black-bordered). +export const STANDALONE_CHIPS: AttributeChipDef[] = [ + { + tagName: "Full Art", + label: "Full Art", + matches: (candidate) => candidate.fullArt, + }, + { + tagName: "Borderless", + label: "Borderless", + matches: (candidate) => candidate.isBorderless, + }, + { + tagName: "Showcase", + label: "Showcase", + matches: (candidate) => candidate.isShowcase, + }, + { + tagName: "Extended", + label: "Extended Art", + matches: (candidate) => candidate.isExtendedArt, + }, + { + tagName: "Etched", + label: "Etched", + matches: (candidate) => candidate.isEtched, + }, +]; + +export const ALL_ATTRIBUTE_CHIPS: AttributeChipDef[] = [ + ...STANDALONE_CHIPS, + ...EXCLUSION_GROUPS.flatMap((group) => group.chips), +]; + +export type ChipVoteState = "untouched" | "positive" | "negative"; + +export const CHIP_POLARITY: Record = { + untouched: 0, // retract sentinel - see cardpicker.views.RETRACT_POLARITY + positive: 1, + negative: -1, +}; + +/** untouched -> positive -> negative -> untouched, per spec's tri-state tap cycle. */ +export function nextChipState(current: ChipVoteState): ChipVoteState { + if (current === "untouched") return "positive"; + if (current === "positive") return "negative"; + return "untouched"; +} + +/** The group (if any) a given tagName belongs to - used to compute implied-negative styling. */ +export function findExclusionGroup( + tagName: string +): ExclusionGroup | undefined { + return EXCLUSION_GROUPS.find((group) => + group.chips.some((chip) => chip.tagName === tagName) + ); +} + +/** + * Filters candidates against the current explicit chip vote states: a positive chip drops + * any candidate that doesn't match it, a negative chip drops any candidate that does. Implied- + * negative (exclusion-group sibling) styling never contributes an extra filter condition on + * its own - see the exclusion-group comment above for why that's unnecessary. + */ +export function filterCandidatesByChipStates( + candidates: T[], + chipStates: Record +): T[] { + const activeChips = ALL_ATTRIBUTE_CHIPS.filter( + (chip) => (chipStates[chip.tagName] ?? "untouched") !== "untouched" + ); + if (activeChips.length === 0) { + return candidates; + } + return candidates.filter((candidate) => + activeChips.every((chip) => { + const state = chipStates[chip.tagName] ?? "untouched"; + const isMatch = chip.matches(candidate); + return state === "positive" ? isMatch : !isMatch; + }) + ); +} diff --git a/frontend/src/features/attributeVoting/ChipCard.tsx b/frontend/src/features/attributeVoting/ChipCard.tsx index 743d8e6c0..5fee08924 100644 --- a/frontend/src/features/attributeVoting/ChipCard.tsx +++ b/frontend/src/features/attributeVoting/ChipCard.tsx @@ -1,11 +1,11 @@ /** * Small tap-target "card" chip, styled with the same blue (#4d8ddf) used for the "resolved - * consensus" highlight and the "No match" placeholder over in PrintingTagQueue.tsx's - * candidate grid (see CandidateButton/ArtPlaceholder there) - reused here so the post-vote - * follow-up strips (NoMatchReasonStrip, PrintingConfirmStrip) read as the same visual - * language as the picker a user just interacted with, rather than introducing a second - * unrelated chip style. Deliberately lighter than CandidateButton: no starburst/hover-zoom, - * since these chips are small and numerous rather than one large focal candidate. + * consensus" highlight and the "No match" placeholder over in cardPanel.tsx's shared + * candidate grid mechanics (see CandidateButton/ArtPlaceholder there) - reused here so the + * post-vote follow-up strip (NoMatchReasonStrip) reads as the same visual language as the + * picker a user just interacted with, rather than introducing a second unrelated chip style. + * Deliberately lighter than CandidateButton: no starburst/hover-zoom, since these chips are + * small and numerous rather than one large focal candidate. */ import styled from "@emotion/styled"; diff --git a/frontend/src/features/attributeVoting/GenericVoteQueue.tsx b/frontend/src/features/attributeVoting/GenericVoteQueue.tsx deleted file mode 100644 index 537b418a3..000000000 --- a/frontend/src/features/attributeVoting/GenericVoteQueue.tsx +++ /dev/null @@ -1,178 +0,0 @@ -/** - * Shared queue shell for the artist and tag modes of the "What's That Card?" vote - * queue, driven by the generalized `2/voteQueue/` endpoint. Printing mode is deliberately NOT - * folded into this - it keeps using PrintingTagQueue.tsx and `2/printingTagQueue/` completely - * unchanged (its own data source/shape, and its exact existing behavior is a hard constraint), - * so this component only ever needs to handle the two new kinds, which do share one response - * shape (`VoteQueueItem[]`) and can reasonably share one fetch/pagination/flavor-text - * implementation instead of duplicating it twice. - */ - -import React, { useEffect, useState } from "react"; -import Button from "react-bootstrap/Button"; -import Col from "react-bootstrap/Col"; -import Row from "react-bootstrap/Row"; - -import { Kind, VoteQueueItem } from "@/common/schema_types"; -import { useAppSelector } from "@/common/types"; -import { Spinner } from "@/components/Spinner"; -import { ArtistVotePicker } from "@/features/attributeVoting/ArtistVotePicker"; -import { QueueTagQuestion } from "@/features/attributeVoting/QueueTagQuestion"; -import { APIGetVoteQueue } from "@/store/api"; -import { selectRemoteBackendURL } from "@/store/slices/backendSlice"; - -const FLAVOR_TEXT = [ - "Your spark ignites! On to the next mystery.", - "A collector's eye for detail - nicely done!", - "The multiverse is a little better catalogued because of you.", - "Sharper than a Sphinx's riddle. Next card incoming!", - "That's the stuff legends are made of. Keep going!", - "Another printing pinned down. Onward!", - "You've got a good spark for this. Next!", - "Precisely the kind of insight the Multiverse needs.", - "Well spotted. Here comes another.", - "Your knowledge of the planes grows ever stronger.", -]; - -function randomFlavorText(): string { - return FLAVOR_TEXT[Math.floor(Math.random() * FLAVOR_TEXT.length)]; -} - -interface GenericVoteQueueProps { - kind: typeof Kind.Artist | typeof Kind.Tag; - label: string; -} - -export function GenericVoteQueue({ kind, label }: GenericVoteQueueProps) { - const backendURL = useAppSelector(selectRemoteBackendURL); - - const [queueItems, setQueueItems] = useState>([]); - const [currentIndex, setCurrentIndex] = useState(0); - const [page, setPage] = useState(1); - const [pages, setPages] = useState(1); - const [hits, setHits] = useState(0); - const [loadingQueue, setLoadingQueue] = useState(true); - const [flavorText, setFlavorText] = useState(null); - const fetchedPagesRef = React.useRef>(new Set()); - - const currentItem = queueItems[currentIndex] ?? null; - const queueExhausted = - !loadingQueue && currentIndex >= queueItems.length && page >= pages; - - // reset the locally-held queue whenever the kind changes (switching tabs) - useEffect(() => { - setQueueItems([]); - setCurrentIndex(0); - setPage(1); - setPages(1); - setHits(0); - fetchedPagesRef.current = new Set(); - }, [kind]); - - useEffect(() => { - if (backendURL == null) { - return; - } - if (currentIndex < queueItems.length) { - return; - } - if (queueItems.length > 0 && page >= pages) { - return; - } - const nextPage = queueItems.length === 0 ? 1 : page + 1; - if (fetchedPagesRef.current.has(nextPage)) { - return; - } - fetchedPagesRef.current.add(nextPage); - setLoadingQueue(true); - APIGetVoteQueue(backendURL, kind, nextPage) - .then((response) => { - setQueueItems((previous) => [...previous, ...response.items]); - setHits(response.hits); - setPages(response.pages); - setPage(nextPage); - }) - .catch(() => undefined) - .finally(() => setLoadingQueue(false)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [backendURL, kind, currentIndex, queueItems.length, page, pages]); - - const advance = () => { - setFlavorText(randomFlavorText()); - setCurrentIndex((previous) => previous + 1); - }; - - if (queueExhausted) { - return ( -
-

- You're all caught up - no cards left to tag right now! -

- {flavorText != null && ( -

- {flavorText} -

- )} -
- ); - } - - return ( -
-

- Still need {label}: {hits} card{hits !== 1 && "s"} -

- {flavorText != null && ( -

- {flavorText} -

- )} - {currentItem == null || backendURL == null ? ( -
- -
- ) : ( -
- - - {currentItem.card.name} -
{currentItem.card.name}
- - - {kind === Kind.Tag && currentItem.tagName != null ? ( - - ) : ( - <> - -
- -
- - )} - -
-
- )} -
- ); -} diff --git a/frontend/src/features/attributeVoting/PrintingConfirmStrip.tsx b/frontend/src/features/attributeVoting/PrintingConfirmStrip.tsx deleted file mode 100644 index 9a52bf8f8..000000000 --- a/frontend/src/features/attributeVoting/PrintingConfirmStrip.tsx +++ /dev/null @@ -1,138 +0,0 @@ -/** - * "Confirm what you see" follow-up shown in PrintingTagQueue.tsx immediately after a vote - * resolves a card's printing. Two chips - Full art / Borderless - pre-filled (highlighted) - * from the resolved candidate's own metadata (already present on the PrintingCandidate - * payload as `fullArt`/`isBorderless`), so the chip's resting state previews the vote a tap - * would cast. Tapping a chip confirms that preview by casting one CardTagVote for the - * existing "Full Art"/"Borderless" tags (seeded by cardpicker.default_tags, not new tags) - * with polarity matching the previewed state; Skip/Continue moves on without voting. - * Deliberately reuses the existing Full Art/Borderless taxonomy rather than minting new - * tags - this strip is just a fast, pre-filled way to cast the same votes TagVotePicker - * already supports. Chip labels are the seeded `display_name` for each tag (useTagDisplayName), - * not hardcoded text. - */ - -import React, { useState } from "react"; -import Button from "react-bootstrap/Button"; -import Col from "react-bootstrap/Col"; -import Row from "react-bootstrap/Row"; - -import { getOrCreateAnonymousId } from "@/common/cookies"; -import { PrintingCandidate } from "@/common/schema_types"; -import { useTagDisplayName } from "@/common/tagDisplayNames"; -import { useAppDispatch } from "@/common/types"; -import { ChipCard } from "@/features/attributeVoting/ChipCard"; -import { APISubmitTagVote } from "@/store/api"; -import { setNotification } from "@/store/slices/toastsSlice"; - -const APPLY = 1; -const NOT_APPLICABLE = -1; - -interface ConfirmToggle { - tagName: string; - previewValue: boolean; -} - -interface PrintingConfirmStripProps { - backendURL: string; - cardIdentifier: string; - candidate: PrintingCandidate; - /** Called once the user has confirmed both toggles (or skipped). */ - onDone: () => void; -} - -export function PrintingConfirmStrip({ - backendURL, - cardIdentifier, - candidate, - onDone, -}: PrintingConfirmStripProps) { - const dispatch = useAppDispatch(); - const getTagDisplayName = useTagDisplayName(); - const [confirmedTagNames, setConfirmedTagNames] = useState>( - new Set() - ); - const [submittingTagName, setSubmittingTagName] = useState( - null - ); - - const toggles: ConfirmToggle[] = [ - { tagName: "Full Art", previewValue: candidate.fullArt }, - { tagName: "Borderless", previewValue: candidate.isBorderless }, - ]; - - const confirm = (toggle: ConfirmToggle) => { - setSubmittingTagName(toggle.tagName); - APISubmitTagVote( - backendURL, - cardIdentifier, - getOrCreateAnonymousId(), - toggle.tagName, - toggle.previewValue ? APPLY : NOT_APPLICABLE - ) - .then(() => - setConfirmedTagNames((previous) => - new Set(previous).add(toggle.tagName) - ) - ) - .catch(() => - dispatch( - setNotification([ - Math.random().toString(), - { - name: "Vote failed", - message: - "Something went wrong submitting your vote - please try again.", - level: "error", - }, - ]) - ) - ) - .finally(() => setSubmittingTagName(null)); - }; - - return ( -
-
Confirm what you see
- - {toggles.map((toggle) => ( - - confirm(toggle)} - data-testid={`printing-confirm-${toggle.tagName - .toLowerCase() - .replace(" ", "-")}`} - /> - - ))} - -
- - -
-
- ); -} diff --git a/frontend/src/features/attributeVoting/QueueTagQuestion.tsx b/frontend/src/features/attributeVoting/QueueTagQuestion.tsx index 13ef88349..311e9a733 100644 --- a/frontend/src/features/attributeVoting/QueueTagQuestion.tsx +++ b/frontend/src/features/attributeVoting/QueueTagQuestion.tsx @@ -21,6 +21,11 @@ interface QueueTagQuestionProps { tagName: string; /** Called once the user has answered (apply/not applicable submitted successfully) or skipped. */ onAnswered: () => void; + /** "include" attaches the moderator session cookie, making the vote privileged at + * resolution time - used by the question feed's "moderation" question type (see + * QuestionFeed.tsx), which reuses this exact component rather than forking a moderator-only + * variant. Defaults to "same-origin" - unchanged behavior for every pre-existing caller. */ + credentials?: RequestCredentials; } const APPLY = 1; @@ -31,6 +36,7 @@ export function QueueTagQuestion({ cardIdentifier, tagName, onAnswered, + credentials = "same-origin", }: QueueTagQuestionProps) { const dispatch = useAppDispatch(); const getTagDisplayName = useTagDisplayName(); @@ -43,7 +49,8 @@ export function QueueTagQuestion({ cardIdentifier, getOrCreateAnonymousId(), tagName, - polarity + polarity, + credentials ) .then(() => onAnswered()) .catch(() => diff --git a/frontend/src/features/moderation/ModerationQueue.tsx b/frontend/src/features/moderation/ModerationQueue.tsx deleted file mode 100644 index 9f9153c83..000000000 --- a/frontend/src/features/moderation/ModerationQueue.tsx +++ /dev/null @@ -1,207 +0,0 @@ -/** - * The moderator-only review queue (docs/features/moderation.md): (card, sensitive-tag) - * pairs awaiting a privileged co-sign, most-reported first. Mirrors GenericVoteQueue's - * item/advance/lazy-pagination mechanics but is typed to the moderation endpoint's richer - * item shape (report count + excerpts) and its actions are Approve/Reject - which are - * ordinary submitTagVote calls sent with credentials so the backend records this - * moderator's user on the vote and the pair resolves through the normal consensus pass. - * - * Distinct `moderation-queue*` testids rather than reusing "vote-queue" - see - * docs/features/printing-tags.md's testid-collision lesson. - * - * The tab that mounts this is already gated on whoami, but hidden is not secured - the - * backend 403s non-moderators, and this component renders that defensively too. - */ - -import React, { useEffect, useState } from "react"; -import Badge from "react-bootstrap/Badge"; -import Button from "react-bootstrap/Button"; -import Col from "react-bootstrap/Col"; -import Row from "react-bootstrap/Row"; - -import { getOrCreateAnonymousId } from "@/common/cookies"; -import { ModerationQueueItem } from "@/common/schema_types"; -import { useTagDisplayName } from "@/common/tagDisplayNames"; -import { useAppDispatch, useAppSelector } from "@/common/types"; -import { Spinner } from "@/components/Spinner"; -import { APIGetModerationQueue, APISubmitTagVote } from "@/store/api"; -import { selectRemoteBackendURL } from "@/store/slices/backendSlice"; -import { setNotification } from "@/store/slices/toastsSlice"; - -export function ModerationQueue() { - const dispatch = useAppDispatch(); - const backendURL = useAppSelector(selectRemoteBackendURL); - const getTagDisplayName = useTagDisplayName(); - - const [queueItems, setQueueItems] = useState>([]); - const [currentIndex, setCurrentIndex] = useState(0); - const [page, setPage] = useState(1); - const [pages, setPages] = useState(1); - const [hits, setHits] = useState(0); - const [loadingQueue, setLoadingQueue] = useState(true); - const [forbidden, setForbidden] = useState(false); - const [submitting, setSubmitting] = useState(false); - const fetchedPagesRef = React.useRef>(new Set()); - - const currentItem = queueItems[currentIndex] ?? null; - const queueExhausted = - !loadingQueue && currentIndex >= queueItems.length && page >= pages; - - useEffect(() => { - if (backendURL == null || forbidden) { - return; - } - if (currentIndex < queueItems.length) { - return; - } - if (queueItems.length > 0 && page >= pages) { - return; - } - const nextPage = queueItems.length === 0 ? 1 : page + 1; - if (fetchedPagesRef.current.has(nextPage)) { - return; - } - fetchedPagesRef.current.add(nextPage); - setLoadingQueue(true); - APIGetModerationQueue(backendURL, nextPage) - .then((response) => { - setQueueItems((previous) => [...previous, ...response.items]); - setHits(response.hits); - setPages(response.pages); - setPage(nextPage); - }) - .catch((error) => { - if (error?.status === 403) { - setForbidden(true); - } - }) - .finally(() => setLoadingQueue(false)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [backendURL, currentIndex, queueItems.length, page, pages, forbidden]); - - const advance = () => setCurrentIndex((previous) => previous + 1); - - const castModeratorVote = async (polarity: 1 | -1) => { - if (backendURL == null || currentItem == null) { - return; - } - setSubmitting(true); - try { - await APISubmitTagVote( - backendURL, - currentItem.card.identifier, - getOrCreateAnonymousId(), - currentItem.tagName, - polarity, - "include" // attach the moderator session so this vote is privileged - ); - advance(); - } catch (error: any) { - dispatch( - setNotification([ - Math.random().toString(), - { - name: error?.name ?? "Vote failed", - message: - error?.message ?? "Something went wrong - please try again.", - level: "error", - }, - ]) - ); - } finally { - setSubmitting(false); - } - }; - - if (forbidden) { - return ( -

- You need moderator access to review this queue. -

- ); - } - - if (queueExhausted) { - return ( -
-

- Nothing awaiting approval - the queue is clear! -

-
- ); - } - - return ( -
-

- Awaiting approval: {hits} item{hits !== 1 && "s"} -

- {currentItem == null || backendURL == null ? ( -
- -
- ) : ( -
- - - {currentItem.card.name} -
{currentItem.card.name}
- - -

- Should this card carry the tag{" "} - {getTagDisplayName(currentItem.tagName)}? -

-

- - {currentItem.reportCount} report - {currentItem.reportCount !== 1 && "s"} - -

- {currentItem.reportExcerpts.length > 0 && ( -
    - {currentItem.reportExcerpts.map((excerpt, index) => ( -
  • “{excerpt}”
  • - ))} -
- )} -
- - - -
- -
-
- )} -
- ); -} diff --git a/frontend/src/features/printingTags/PrintingTagQueue.tsx b/frontend/src/features/printingTags/PrintingTagQueue.tsx deleted file mode 100644 index 2f0a4ce98..000000000 --- a/frontend/src/features/printingTags/PrintingTagQueue.tsx +++ /dev/null @@ -1,738 +0,0 @@ -/** - * "What's That Card?" - a single-card-at-a-time queue for tagging which real-world - * Scryfall printing a card image depicts. Walks through cards returned by - * `2/printingTagQueue/` (which defaults to surfacing contested cards - conflicting votes - * already cast - first, since those are the highest-value cards for a human to weigh in - * on), one at a time, with the card's own image next to a grid of candidate Scryfall - * renders for comparison. Deliberately standalone rather than sharing fetch/submit logic - * with PrintingTagPicker.tsx (the quick-tag row used elsewhere): that component is already - * shipped and covered by its own tests, and the two data-fetching flows differ enough - * (auto-advance, skip, batched pagination) that factoring out a shared hook would add - * indirection for both call sites rather than removing real duplication. - */ - -import { keyframes } from "@emotion/react"; -import styled from "@emotion/styled"; -import React, { useEffect, useState } from "react"; -import Button from "react-bootstrap/Button"; -import Col from "react-bootstrap/Col"; -import Row from "react-bootstrap/Row"; - -import { getPrintingCandidateDataAttributes } from "@/common/cardDom"; -import { getOrCreateAnonymousId } from "@/common/cookies"; -import { - PrintingCandidate, - PrintingConsensusResponse, -} from "@/common/schema_types"; -import { CardDocument, useAppDispatch, useAppSelector } from "@/common/types"; -import { SetIcon } from "@/components/SetIcon"; -import { Spinner } from "@/components/Spinner"; -import { AttributeVotingPanel } from "@/features/attributeVoting/AttributeVotingPanel"; -import { NoMatchReasonStrip } from "@/features/attributeVoting/NoMatchReasonStrip"; -import { PrintingConfirmStrip } from "@/features/attributeVoting/PrintingConfirmStrip"; -import { - STARBURST_INNER_COLOR, - STARBURST_INNER_FRAMES, - STARBURST_OUTER_COLOR, - STARBURST_OUTER_FRAMES, - STARBURST_VIEWBOX, -} from "@/features/printingTags/starburstShape"; -import { - APIGetPrintingCandidates, - APIGetPrintingConsensus, - APIGetPrintingTagQueue, - APISubmitPrintingTag, -} from "@/store/api"; -import { selectRemoteBackendURL } from "@/store/slices/backendSlice"; -import { setNotification } from "@/store/slices/toastsSlice"; - -// Silhouette-reveal: the card starts as a black silhouette with a "?" in -// the middle, holds for a beat, then fades to reveal the real art. The Scryfall candidate -// list is deliberately not rendered until this finishes (see `revealed` state below) - the -// whole point is to test recognition before handing over the answer options. -const revealAnimation = keyframes` - 0% { opacity: 1; } - 55% { opacity: 1; } - 100% { opacity: 0; } -`; - -const RevealWrapper = styled.div` - position: relative; - overflow: hidden; -`; - -// Same blue as ArtPlaceholder below (and the starburst itself) rather than a plain black -// box, so the "mystery card" reveal reads as one consistent visual language with the -// candidate grid's own "?" placeholders instead of a mismatched black flash. Black text -// (matching the page-wide font colour) checked against this blue: contrast ratio ~6.2:1, -// clearly better than the white it replaced (~3.4:1). -const RevealOverlay = styled.div` - position: absolute; - inset: 0; - background: ${STARBURST_OUTER_COLOR}; - color: black; - display: flex; - align-items: center; - justify-content: center; - font-size: 4rem; - font-weight: bold; - animation: ${revealAnimation} 1.8s ease-in forwards; - pointer-events: none; -`; - -// The card, and the starburst behind it, stay glued to the viewport as the page scrolls -// (position: sticky) rather than scrolling away with the rest of the page. "top" is set via -// inline style (see useStickyTop below) to wherever the panel naturally rendered when it -// first mounted, rather than a fixed offset - so it pins at its own original location on -// the page and never visibly jumps to a different spot once scrolling starts, it just stops -// moving exactly where it already was. -// -// z-index: -1 here (not just on BurstSvg) is deliberate and easy to get backwards: a sticky -// element always establishes its own stacking context, and *any* positioned descendant - -// even at the default z-index: auto - paints in front of plain, non-positioned in-flow -// siblings (the CSS spec's stacking order puts positioned content ahead of ordinary flow -// content, independent of DOM order or z-index value). Left at the default, that meant the -// whole panel - including the burst bleeding out of it - painted on top of the "What's That -// Card?" heading and the candidate grid's plain text/borders, hiding them. Pushing -// CardPanel itself to a negative stack level is what actually fixes that (giving BurstSvg -// alone a negative z-index only reorders it against its own siblings *inside* CardPanel, -// it can't reach past the sticky boundary). The two columns never overlap horizontally at -// any breakpoint this page uses (side-by-side on desktop, stacked full-width on mobile), so -// this can't accidentally bury the actual card art behind the candidate grid - only the -// burst's intentional bleed into that space is affected. -const CardPanel = styled.div` - position: sticky; - top: 0; - z-index: -1; -`; - -// Measures how far the panel naturally sits below the top of its scrolling ancestor (see -// ContentContainer in Layout.tsx - the app's content area is a fixed-position, internally -// scrolling box, not the normal document body) right after it mounts, and uses that as the -// sticky "top" offset. Re-measures whenever the subject card changes (a new card can nudge -// the layout by a few px - e.g. flavor text length), so each card pins at wherever it -// actually rendered rather than an offset carried over from a previous card. Runs in a -// plain useEffect, not useLayoutEffect - the measured value only matters once the user -// scrolls far enough for sticky to engage, so there's nothing to flash before it settles, -// and useLayoutEffect warns during Next's static export (no DOM on the server). -function useStickyTop(deps: React.DependencyList): { - ref: React.RefObject; - top: number | null; -} { - const ref = React.useRef(null); - const [top, setTop] = useState(null); - - useEffect(() => { - const panel = ref.current; - if (panel == null) { - return; - } - let scrollParent: HTMLElement | null = panel.parentElement; - while ( - scrollParent != null && - !["scroll", "auto"].includes( - window.getComputedStyle(scrollParent).overflowY - ) - ) { - scrollParent = scrollParent.parentElement; - } - if (scrollParent == null) { - return; - } - const panelRect = panel.getBoundingClientRect(); - const scrollRect = scrollParent.getBoundingClientRect(); - setTop(panelRect.top - scrollRect.top + scrollParent.scrollTop); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, deps); - - return { ref, top }; -} - -// Sized and centred purely with CSS (percentage width + aspect-ratio, both relative to -// CardPanel's own box) rather than a JS measurement - it scales naturally with the card's -// own responsive width at every breakpoint, and travels with CardPanel automatically under -// sticky scrolling with no extra code. -const BurstSvg = styled.svg` - position: absolute; - top: 50%; - left: 50%; - width: 340%; - aspect-ratio: 1; - transform: translate(-50%, -50%); - z-index: -1; - pointer-events: none; -`; - -const STARBURST_FRAME_INTERVAL_MS = 150; - -// Cycles through the precomputed jagged frames (see starburstShape.ts) to reproduce the -// reference gif's flicker. Always starts at frame 0 and only starts advancing inside -// useEffect (client-only, post-mount), so server-rendered and first-client-render markup -// stay identical - no hydration mismatch. Skips animating entirely under -// prefers-reduced-motion. -function useStarburstFrame(frameCount: number): number { - const [frame, setFrame] = useState(0); - - useEffect(() => { - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { - return; - } - const id = setInterval(() => { - setFrame((previous) => (previous + 1) % frameCount); - }, STARBURST_FRAME_INTERVAL_MS); - return () => clearInterval(id); - }, [frameCount]); - - return frame; -} - -// Zooms the thumbnail in on hover, rather than the whole button, so the border/label stay -// put and only the artwork itself grows. Deliberately left uncropped (no overflow: hidden) -// so the enlarged art is fully visible rather than cut off at the original box edge - -// raised above its siblings on hover so it doesn't render underneath the neighbouring grid -// cells it now overlaps. -const ZoomableThumbnail = styled.div` - position: relative; - z-index: 0; - - img { - transition: transform 0.15s ease-out; - } - - &:hover { - z-index: 2; - } - - &:hover img { - transform: scale(1.6); - } -`; - -const FLAVOR_TEXT = [ - "Your spark ignites! On to the next mystery.", - "A collector's eye for detail - nicely done!", - "The multiverse is a little better catalogued because of you.", - "Sharper than a Sphinx's riddle. Next card incoming!", - "That's the stuff legends are made of. Keep going!", - "Another printing pinned down. Onward!", - "You've got a good spark for this. Next!", - "Precisely the kind of insight the Multiverse needs.", - "Well spotted. Here comes another.", - "Your knowledge of the planes grows ever stronger.", -]; - -function randomFlavorText(): string { - return FLAVOR_TEXT[Math.floor(Math.random() * FLAVOR_TEXT.length)]; -} - -// Real Magic card ratio (63mm x 88mm), matching the print-ready `.ratio-7x5` convention -// already used elsewhere (custom.css) - reserves each thumbnail's box up front via CSS -// alone, so an image resolving its intrinsic size late over the network can't reflow the -// page (the starburst is centred on the card's own box - see CardPanel - so any unreserved -// reflow here would visibly resize the burst along with it). -const CARD_ASPECT_RATIO = "63 / 88"; - -// Shared "mystery card" backdrop for every Scryfall art box in the candidate grid - reuses -// the starburst's own blue so it reads as one consistent visual language against the orange -// background rather than a mismatched placeholder colour. Candidates render their real -// artwork on top of this (so a slow-loading image transitions from a blue "?" card into the -// real art instead of a blank flash), and it's also the entire visual for the "No match" -// option, which has no real artwork to show at all - replacing the old black -// "Card Not Found :(" placeholder image. -const ArtPlaceholder = styled.div` - position: relative; - width: 100%; - aspect-ratio: ${CARD_ASPECT_RATIO}; - background: ${STARBURST_OUTER_COLOR}; - /* Deliberately no overflow: hidden here - object-fit: cover below already keeps the image - contained within this box on its own (it crops the underlying image content to fit, - it doesn't make the element itself overflow), and clipping at this level was - silently re-breaking ZoomableThumbnail's hover-zoom (added in a previous round - specifically *without* overflow: hidden, so the enlarged art could pop out uncropped) - - since ArtPlaceholder wraps ZoomableThumbnail, its own overflow: hidden clipped the zoom - right back down to this box's edge, reading as a hard rectangular cut through the - enlarged artwork. */ - - &::before { - content: "?"; - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - color: rgba(0, 0, 0, 0.5); - font-size: 3rem; - font-weight: bold; - } - - img { - position: relative; - z-index: 1; - width: 100%; - height: 100%; - object-fit: cover; - } -`; - -// Bootstrap's `outline-secondary` border doesn't scale with the hover-zoomed thumbnail -// inside it (see ZoomableThumbnail) - it stays put as a stationary frame while the art -// visibly grows past it, breaking the effect - so it's dropped entirely (`border-0`, -// applied at each call site below) and this component only needs to own the "highlighted" -// look. Bootstrap's green `success` variant clashed with the page's blue "mystery" motif -// established elsewhere (ArtPlaceholder, RevealOverlay, the starburst itself), so "this is -// the resolved consensus pick" is now a solid fill in that same blue instead - there's no -// built-in Bootstrap variant in this exact shade, hence the custom class rather than -// swapping to `variant="primary"`. Black text (matching the page-wide font colour) checked -// against it: ~6.2:1 contrast, clearly better than white's ~3.4:1; the artist line below it -// is Bootstrap's `.text-muted` grey, which nearly disappeared against this blue, so it's -// darkened to translucent black specifically inside `.highlighted` (needs `!important` - -// Bootstrap's own text-color utilities are declared `!important`, so nothing else can win -// against it). -// -// Bootstrap's own `.btn-outline-secondary:hover` background (a flat grey) was still -// showing through around the card on hover, which read as a mismatched grey frame against -// the page's blue theme. Per direct request, that hover highlight is now a scaled-down copy -// of the page's own starburst (HoverBurst below) instead of a flat colour - `position: -// relative` + `z-index: 0` here gives HoverBurst's `z-index: -1` a local stacking context -// to sit behind ArtPlaceholder/the text without leaking out to sit behind this button's -// *siblings* in the grid too (the same mechanism as CardPanel/BurstSvg on the page-level -// starburst - see the comment there for the underlying CSS stacking rule). -const CandidateButton = styled(Button)` - position: relative; - z-index: 0; - overflow: visible; - - &:hover, - &:focus { - background-color: transparent !important; - } - - &:hover .hover-burst { - opacity: 1; - transform: translate(-50%, -50%) scale(1); - } - - &.highlighted { - background-color: ${STARBURST_OUTER_COLOR}; - color: #000000; - } - - &.highlighted .text-muted { - color: rgba(0, 0, 0, 0.65) !important; - } -`; - -// A smaller copy of the same starburst geometry, driven by the same shared -// `starburstFrame` state as the page-level burst (see useStarburstFrame below) rather than -// a frame of its own, so a zoomed card's highlight visibly flickers/moves in lockstep with -// the big one on the left instead of holding still - every instance ticks over together -// regardless of which card is actually hovered, since only the hovered one is visible -// (opacity 0 otherwise) and re-rendering a handful of invisible polygons every frame is -// cheap. Centred on and scaled up from the button's own box, the same way the page-level -// burst is centred on the subject card. Faded/scaled in via CSS on CandidateButton's -// `:hover` above rather than JS state, so nothing needs to track which card is hovered. -const HoverBurst = styled.svg` - position: absolute; - top: 50%; - left: 50%; - width: 331.2%; - aspect-ratio: 1; - transform: translate(-50%, -50%) scale(0.75); - opacity: 0; - transition: opacity 0.18s ease-out, transform 0.18s ease-out; - pointer-events: none; - z-index: -1; -`; - -export function PrintingTagQueue() { - const dispatch = useAppDispatch(); - const backendURL = useAppSelector(selectRemoteBackendURL); - const starburstFrame = useStarburstFrame(STARBURST_OUTER_FRAMES.length); - - const [queueCards, setQueueCards] = useState>([]); - const [currentIndex, setCurrentIndex] = useState(0); - const [page, setPage] = useState(1); - const [pages, setPages] = useState(1); - const [hits, setHits] = useState(0); - const [loadingQueue, setLoadingQueue] = useState(true); - - const [candidates, setCandidates] = useState>([]); - const [consensus, setConsensus] = useState( - null - ); - const [loadingCard, setLoadingCard] = useState(false); - const [submitting, setSubmitting] = useState(false); - const [flavorText, setFlavorText] = useState(null); - const [revealed, setRevealed] = useState(false); - // whether the user has taken a printing-tag action (vote or no-match) on the *current* - // card this session - the attribute-voting follow-up panel is step 2 after that action, - // not shown alongside the initial printing picker on a card the user hasn't touched yet. - const [votedThisCard, setVotedThisCard] = useState(false); - // which follow-up to show below the candidate grid once votedThisCard is true - tracks - // the *submitted* vote's own isNoMatch flag directly (not consensus.isNoMatch, which - // reflects the aggregate resolved outcome and can lag a single vote), so a no-match tap - // always gets the reason strip even before/without flipping the card's consensus. - const [lastVoteWasNoMatch, setLastVoteWasNoMatch] = useState(false); - // guards the fetch effect below against React 18 Strict Mode's dev-time double-invoke, - // which would otherwise append the same page's cards twice - const fetchedPagesRef = React.useRef>(new Set()); - - const currentCard = queueCards[currentIndex] ?? null; - const queueExhausted = - !loadingQueue && currentIndex >= queueCards.length && page >= pages; - const { ref: cardPanelRef, top: stickyTop } = useStickyTop([ - currentCard?.identifier, - ]); - - // fetch the next backend page once the locally-held batch runs out - never refetches - // page 1, so a card the user already skipped/voted on this session won't reappear - useEffect(() => { - if (backendURL == null) { - return; - } - if (currentIndex < queueCards.length) { - return; // still have locally-held cards to work through - } - if (queueCards.length > 0 && page >= pages) { - return; // already fetched every available page - nothing more to ask for - } - const nextPage = queueCards.length === 0 ? 1 : page + 1; - if (fetchedPagesRef.current.has(nextPage)) { - return; - } - fetchedPagesRef.current.add(nextPage); - setLoadingQueue(true); - APIGetPrintingTagQueue(backendURL, nextPage) - .then((response) => { - setQueueCards((previous) => [...previous, ...response.cards]); - setHits(response.hits); - setPages(response.pages); - setPage(nextPage); - }) - .catch(() => undefined) - .finally(() => setLoadingQueue(false)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [backendURL, currentIndex, queueCards.length, page, pages]); - - // reset the reveal animation and attribute-voting step for each new card - useEffect(() => { - setRevealed(false); - setVotedThisCard(false); - setLastVoteWasNoMatch(false); - }, [currentCard?.identifier]); - - useEffect(() => { - if (backendURL == null || currentCard == null) { - setCandidates([]); - setConsensus(null); - return; - } - setLoadingCard(true); - setConsensus(null); - Promise.all([ - APIGetPrintingCandidates(backendURL, currentCard.identifier), - APIGetPrintingConsensus(backendURL, currentCard.identifier), - ]) - .then(([candidatesResponse, consensusResponse]) => { - setCandidates(candidatesResponse.results); - setConsensus(consensusResponse); - }) - .catch(() => { - setCandidates([]); - setConsensus(null); - }) - .finally(() => setLoadingCard(false)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [backendURL, currentCard?.identifier]); - - const advance = () => { - setFlavorText(randomFlavorText()); - setCurrentIndex((previous) => previous + 1); - }; - - const skip = () => advance(); - - const submit = ( - printingIdentifier: string | undefined, - isNoMatch: boolean - ) => { - if (backendURL == null || currentCard == null) { - return; - } - setSubmitting(true); - APISubmitPrintingTag( - backendURL, - currentCard.identifier, - getOrCreateAnonymousId(), - printingIdentifier, - isNoMatch - ) - .then((response) => { - setConsensus(response); - setVotedThisCard(true); - setLastVoteWasNoMatch(isNoMatch); - // No immediate advance here even if this vote resolved the printing (e.g. it broke - // a tie) - PrintingConfirmStrip gets a brief, skippable dwell below to confirm - // full-art/borderless before the queue moves on. - }) - .catch(() => - dispatch( - setNotification([ - Math.random().toString(), - { - name: "Vote failed", - message: - "Something went wrong submitting your vote - please try again.", - level: "error", - }, - ]) - ) - ) - .finally(() => setSubmitting(false)); - }; - - if (queueExhausted) { - return ( -
-

- You're all caught up - no cards left to tag right now! -

- {flavorText != null && ( -

- {flavorText} -

- )} -
- ); - } - - return ( -
-

- Still need a printing tagged: {hits} card{hits !== 1 && "s"} -

- {flavorText != null && ( -

- {flavorText} -

- )} - {currentCard == null ? ( -
- -
- ) : ( -
- - - - - - - - - {currentCard.name} - {!revealed && ( - setRevealed(true)} - > - ? - - )} - -
{currentCard.name}
-
- - - {!revealed || loadingCard ? ( -
- -
- ) : ( - <> -
- {consensus?.resolvedPrinting != null && ( - - Current consensus:{" "} - {" "} - {consensus.resolvedPrinting.expansionCode.toUpperCase()}{" "} - {consensus.resolvedPrinting.collectorNumber} - - )} - {consensus != null && - consensus.resolvedPrinting == null && - consensus.isNoMatch && ( - Current consensus: no matching printing - )} - {consensus != null && - consensus.resolvedPrinting == null && - !consensus.isNoMatch && ( - - Not yet resolved - {consensus.voteTally.length > 0 ? " - contested" : ""} - - )} -
- - - submit(undefined, true)} - > - - - - - -
No match
-
- - {candidates.map((candidate) => ( - - submit(candidate.identifier, false)} - {...getPrintingCandidateDataAttributes( - currentCard.name, - candidate - )} - > - - - - - - - {`${candidate.expansionCode} - - -
- {" "} - {candidate.expansionCode.toUpperCase()}{" "} - {candidate.collectorNumber} -
-
- {candidate.artist} -
-
- - ))} -
- {votedThisCard && - consensus?.resolvedPrinting != null && - backendURL != null && ( -
-
- -
- )} - {votedThisCard && - consensus?.resolvedPrinting == null && - lastVoteWasNoMatch && - backendURL != null && ( -
-
- -
- )} - {votedThisCard && - consensus?.resolvedPrinting == null && - !lastVoteWasNoMatch && - backendURL != null && ( -
-
- -
- )} -
- - {votedThisCard && - consensus?.resolvedPrinting == null && - !lastVoteWasNoMatch && ( - - )} -
- - )} - -
-
- )} -
- ); -} diff --git a/frontend/src/features/printingTags/cardPanel.tsx b/frontend/src/features/printingTags/cardPanel.tsx new file mode 100644 index 000000000..66e4c30f6 --- /dev/null +++ b/frontend/src/features/printingTags/cardPanel.tsx @@ -0,0 +1,320 @@ +/** + * Shared visual mechanics behind the "What's That Card?" subject-card panel: the sticky + * starburst-backed card, the silhouette-reveal animation, the candidate-grid "mystery card" + * placeholder/hover-zoom/hover-burst, and the flavor-text pool. Originally lived inline in + * PrintingTagQueue.tsx (the single-card printing-tag queue); extracted verbatim so the + * unified question feed (QuestionFeed.tsx) can reuse the exact same mechanics rather than + * re-implementing them - see docs/features/printing-tags.md's questionFeed section and + * journal/2026-07-14-queue-question-feed-design.md for why this is a re-composition, not a + * rewrite. Every comment below is unchanged from its original call site. + */ + +import { keyframes } from "@emotion/react"; +import styled from "@emotion/styled"; +import React, { useEffect, useState } from "react"; +import Button from "react-bootstrap/Button"; + +import { + STARBURST_OUTER_COLOR, + STARBURST_OUTER_FRAMES, +} from "@/features/printingTags/starburstShape"; + +// Silhouette-reveal: the card starts as a black silhouette with a "?" in +// the middle, holds for a beat, then fades to reveal the real art. The Scryfall candidate +// list is deliberately not rendered until this finishes (see `revealed` state below) - the +// whole point is to test recognition before handing over the answer options. +export const revealAnimation = keyframes` + 0% { opacity: 1; } + 55% { opacity: 1; } + 100% { opacity: 0; } +`; + +export const RevealWrapper = styled.div` + position: relative; + overflow: hidden; +`; + +// Same blue as ArtPlaceholder below (and the starburst itself) rather than a plain black +// box, so the "mystery card" reveal reads as one consistent visual language with the +// candidate grid's own "?" placeholders instead of a mismatched black flash. Black text +// (matching the page-wide font colour) checked against this blue: contrast ratio ~6.2:1, +// clearly better than the white it replaced (~3.4:1). +export const RevealOverlay = styled.div` + position: absolute; + inset: 0; + background: ${STARBURST_OUTER_COLOR}; + color: black; + display: flex; + align-items: center; + justify-content: center; + font-size: 4rem; + font-weight: bold; + animation: ${revealAnimation} 1.8s ease-in forwards; + pointer-events: none; +`; + +// The card, and the starburst behind it, stay glued to the viewport as the page scrolls +// (position: sticky) rather than scrolling away with the rest of the page. "top" is set via +// inline style (see useStickyTop below) to wherever the panel naturally rendered when it +// first mounted, rather than a fixed offset - so it pins at its own original location on +// the page and never visibly jumps to a different spot once scrolling starts, it just stops +// moving exactly where it already was. +// +// z-index: -1 here (not just on BurstSvg) is deliberate and easy to get backwards: a sticky +// element always establishes its own stacking context, and *any* positioned descendant - +// even at the default z-index: auto - paints in front of plain, non-positioned in-flow +// siblings (the CSS spec's stacking order puts positioned content ahead of ordinary flow +// content, independent of DOM order or z-index value). Left at the default, that meant the +// whole panel - including the burst bleeding out of it - painted on top of the "What's That +// Card?" heading and the candidate grid's plain text/borders, hiding them. Pushing +// CardPanel itself to a negative stack level is what actually fixes that (giving BurstSvg +// alone a negative z-index only reorders it against its own siblings *inside* CardPanel, +// it can't reach past the sticky boundary). The two columns never overlap horizontally at +// any breakpoint this page uses (side-by-side on desktop, stacked full-width on mobile), so +// this can't accidentally bury the actual card art behind the candidate grid - only the +// burst's intentional bleed into that space is affected. +export const CardPanel = styled.div` + position: sticky; + top: 0; + z-index: -1; +`; + +// Measures how far the panel naturally sits below the top of its scrolling ancestor (see +// ContentContainer in Layout.tsx - the app's content area is a fixed-position, internally +// scrolling box, not the normal document body) right after it mounts, and uses that as the +// sticky "top" offset. Re-measures whenever the subject card changes (a new card can nudge +// the layout by a few px - e.g. flavor text length), so each card pins at wherever it +// actually rendered rather than an offset carried over from a previous card. Runs in a +// plain useEffect, not useLayoutEffect - the measured value only matters once the user +// scrolls far enough for sticky to engage, so there's nothing to flash before it settles, +// and useLayoutEffect warns during Next's static export (no DOM on the server). +export function useStickyTop(deps: React.DependencyList): { + ref: React.RefObject; + top: number | null; +} { + const ref = React.useRef(null); + const [top, setTop] = useState(null); + + useEffect(() => { + const panel = ref.current; + if (panel == null) { + return; + } + let scrollParent: HTMLElement | null = panel.parentElement; + while ( + scrollParent != null && + !["scroll", "auto"].includes( + window.getComputedStyle(scrollParent).overflowY + ) + ) { + scrollParent = scrollParent.parentElement; + } + if (scrollParent == null) { + return; + } + const panelRect = panel.getBoundingClientRect(); + const scrollRect = scrollParent.getBoundingClientRect(); + setTop(panelRect.top - scrollRect.top + scrollParent.scrollTop); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + + return { ref, top }; +} + +// Sized and centred purely with CSS (percentage width + aspect-ratio, both relative to +// CardPanel's own box) rather than a JS measurement - it scales naturally with the card's +// own responsive width at every breakpoint, and travels with CardPanel automatically under +// sticky scrolling with no extra code. +export const BurstSvg = styled.svg` + position: absolute; + top: 50%; + left: 50%; + width: 140%; + aspect-ratio: 1; + transform: translate(-50%, -50%); + z-index: -1; + pointer-events: none; +`; + +const STARBURST_FRAME_INTERVAL_MS = 150; + +// Cycles through the precomputed jagged frames (see starburstShape.ts) to reproduce the +// reference gif's flicker. Always starts at frame 0 and only starts advancing inside +// useEffect (client-only, post-mount), so server-rendered and first-client-render markup +// stay identical - no hydration mismatch. Skips animating entirely under +// prefers-reduced-motion. +export function useStarburstFrame( + frameCount: number = STARBURST_OUTER_FRAMES.length +): number { + const [frame, setFrame] = useState(0); + + useEffect(() => { + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + return; + } + const id = setInterval(() => { + setFrame((previous) => (previous + 1) % frameCount); + }, STARBURST_FRAME_INTERVAL_MS); + return () => clearInterval(id); + }, [frameCount]); + + return frame; +} + +// Zooms the thumbnail in on hover, rather than the whole button, so the border/label stay +// put and only the artwork itself grows. Deliberately left uncropped (no overflow: hidden) +// so the enlarged art is fully visible rather than cut off at the original box edge - +// raised above its siblings on hover so it doesn't render underneath the neighbouring grid +// cells it now overlaps. +export const ZoomableThumbnail = styled.div` + position: relative; + z-index: 0; + + img { + transition: transform 0.15s ease-out; + } + + &:hover { + z-index: 2; + } + + &:hover img { + transform: scale(1.6); + } +`; + +const FLAVOR_TEXT = [ + "Your spark ignites! On to the next mystery.", + "A collector's eye for detail - nicely done!", + "The multiverse is a little better catalogued because of you.", + "Sharper than a Sphinx's riddle. Next card incoming!", + "That's the stuff legends are made of. Keep going!", + "Another printing pinned down. Onward!", + "You've got a good spark for this. Next!", + "Precisely the kind of insight the Multiverse needs.", + "Well spotted. Here comes another.", + "Your knowledge of the planes grows ever stronger.", +]; + +export function randomFlavorText(): string { + return FLAVOR_TEXT[Math.floor(Math.random() * FLAVOR_TEXT.length)]; +} + +// Real Magic card ratio (63mm x 88mm), matching the print-ready `.ratio-7x5` convention +// already used elsewhere (custom.css) - reserves each thumbnail's box up front via CSS +// alone, so an image resolving its intrinsic size late over the network can't reflow the +// page (the starburst is centred on the card's own box - see CardPanel - so any unreserved +// reflow here would visibly resize the burst along with it). +export const CARD_ASPECT_RATIO = "63 / 88"; + +// Shared "mystery card" backdrop for every Scryfall art box in the candidate grid - reuses +// the starburst's own blue so it reads as one consistent visual language against the orange +// background rather than a mismatched placeholder colour. Candidates render their real +// artwork on top of this (so a slow-loading image transitions from a blue "?" card into the +// real art instead of a blank flash), and it's also the entire visual for the "No match" +// option, which has no real artwork to show at all - replacing the old black +// "Card Not Found :(" placeholder image. +export const ArtPlaceholder = styled.div` + position: relative; + width: 100%; + aspect-ratio: ${CARD_ASPECT_RATIO}; + background: ${STARBURST_OUTER_COLOR}; + /* Deliberately no overflow: hidden here - object-fit: cover below already keeps the image + contained within this box on its own (it crops the underlying image content to fit, + it doesn't make the element itself overflow), and clipping at this level was + silently re-breaking ZoomableThumbnail's hover-zoom (added in a previous round + specifically *without* overflow: hidden, so the enlarged art could pop out uncropped) - + since ArtPlaceholder wraps ZoomableThumbnail, its own overflow: hidden clipped the zoom + right back down to this box's edge, reading as a hard rectangular cut through the + enlarged artwork. */ + + &::before { + content: "?"; + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: rgba(0, 0, 0, 0.5); + font-size: 3rem; + font-weight: bold; + } + + img { + position: relative; + z-index: 1; + width: 100%; + height: 100%; + object-fit: cover; + } +`; + +// Bootstrap's `outline-secondary` border doesn't scale with the hover-zoomed thumbnail +// inside it (see ZoomableThumbnail) - it stays put as a stationary frame while the art +// visibly grows past it, breaking the effect - so it's dropped entirely (`border-0`, +// applied at each call site below) and this component only needs to own the "highlighted" +// look. Bootstrap's green `success` variant clashed with the page's blue "mystery" motif +// established elsewhere (ArtPlaceholder, RevealOverlay, the starburst itself), so "this is +// the resolved consensus pick" is now a solid fill in that same blue instead - there's no +// built-in Bootstrap variant in this exact shade, hence the custom class rather than +// swapping to `variant="primary"`. Black text (matching the page-wide font colour) checked +// against it: ~6.2:1 contrast, clearly better than white's ~3.4:1; the artist line below it +// is Bootstrap's `.text-muted` grey, which nearly disappeared against this blue, so it's +// darkened to translucent black specifically inside `.highlighted` (needs `!important` - +// Bootstrap's own text-color utilities are declared `!important`, so nothing else can win +// against it). +// +// Bootstrap's own `.btn-outline-secondary:hover` background (a flat grey) was still +// showing through around the card on hover, which read as a mismatched grey frame against +// the page's blue theme. Per direct request, that hover highlight is now a scaled-down copy +// of the page's own starburst (HoverBurst below) instead of a flat colour - `position: +// relative` + `z-index: 0` here gives HoverBurst's `z-index: -1` a local stacking context +// to sit behind ArtPlaceholder/the text without leaking out to sit behind this button's +// *siblings* in the grid too (the same mechanism as CardPanel/BurstSvg on the page-level +// starburst - see the comment there for the underlying CSS stacking rule). +export const CandidateButton = styled(Button)` + position: relative; + z-index: 0; + overflow: visible; + + &:hover, + &:focus { + background-color: transparent !important; + } + + &:hover .hover-burst { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } + + &.highlighted { + background-color: ${STARBURST_OUTER_COLOR}; + color: #000000; + } + + &.highlighted .text-muted { + color: rgba(0, 0, 0, 0.65) !important; + } +`; + +// A smaller copy of the same starburst geometry, driven by the same shared +// `starburstFrame` state as the page-level burst (see useStarburstFrame below) rather than +// a frame of its own, so a zoomed card's highlight visibly flickers/moves in lockstep with +// the big one on the left instead of holding still - every instance ticks over together +// regardless of which card is actually hovered, since only the hovered one is visible +// (opacity 0 otherwise) and re-rendering a handful of invisible polygons every frame is +// cheap. Centred on and scaled up from the button's own box, the same way the page-level +// burst is centred on the subject card. Faded/scaled in via CSS on CandidateButton's +// `:hover` above rather than JS state, so nothing needs to track which card is hovered. +export const HoverBurst = styled.svg` + position: absolute; + top: 50%; + left: 50%; + width: 331.2%; + aspect-ratio: 1; + transform: translate(-50%, -50%) scale(0.75); + opacity: 0; + transition: opacity 0.18s ease-out, transform 0.18s ease-out; + pointer-events: none; + z-index: -1; +`; diff --git a/frontend/src/features/printingTags/starburstShape.ts b/frontend/src/features/printingTags/starburstShape.ts index d98c73bfb..f8920e5ee 100644 --- a/frontend/src/features/printingTags/starburstShape.ts +++ b/frontend/src/features/printingTags/starburstShape.ts @@ -1,6 +1,6 @@ /** * Procedurally generates the jagged "explosion" starburst silhouette used behind the - * Vote queue (see printingQueue.tsx) - alternating spike-tip/valley vertices around + * Vote queue (see cardPanel.tsx) - alternating spike-tip/valley vertices around * a circle, with the tip radius heavily randomized per spike so the outline reads as an * irregular burst rather than a uniform star (matching the reference clip-art starburst * this was modeled on, e.g. https://i.sstatic.net/xpRS9.gif). Computed once at module load @@ -60,7 +60,7 @@ export const STARBURST_INNER_COLOR = "#ffffff"; // The reference gif isn't a single static shape - it flickers between several jagged // point-sets (a classic hand-drawn "explosion" vibration), so each layer precomputes a // handful of frames up front (still fully deterministic/seeded) rather than one fixed -// shape. See useStarburstFrame in printingQueue.tsx for the interval that cycles through +// shape. See useStarburstFrame in cardPanel.tsx for the interval that cycles through // these client-side, after the (hydration-safe) first paint always shows frame 0. const FRAME_COUNT = 5; diff --git a/frontend/src/features/questionFeed/QuestionFeed.test.tsx b/frontend/src/features/questionFeed/QuestionFeed.test.tsx new file mode 100644 index 000000000..fe4861132 --- /dev/null +++ b/frontend/src/features/questionFeed/QuestionFeed.test.tsx @@ -0,0 +1,232 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import React from "react"; +import { Provider } from "react-redux"; + +import { localBackend, localBackendURL } from "@/common/test-constants"; +import { submitTagVoteResolvesToApply, tagsNoResults } from "@/mocks/handlers"; +import { server } from "@/mocks/server"; +import { setupStore } from "@/store/store"; + +import { QuestionFeed } from "./QuestionFeed"; + +function buildRoute(path: string): string { + return `${localBackendURL}/${path}`; +} + +function renderFeed() { + server.use(tagsNoResults); + const store = setupStore({ backend: localBackend }); + render( + + + + ); + return store; +} + +// jsdom never actually runs the CSS reveal animation (see cardPanel.tsx's revealAnimation), +// so RevealOverlay's onAnimationEnd handler - the only thing that flips `revealed` to true - +// never fires on its own the way it would in a real browser (Playwright covers that path). +// Manually dispatching the native event it listens for unblocks the candidate grid/chips for +// every test below, same as a real animation completing. +async function revealCard() { + const overlay = await screen.findByTestId("question-feed-reveal-overlay"); + fireEvent.animationEnd(overlay); +} + +const identifyPrintingItem = { + type: "identify_printing", + card: { + identifier: "card-1", + name: "Some Card", + mediumThumbnailUrl: "https://example.com/card1.png", + smallThumbnailUrl: "https://example.com/card1-small.png", + }, + candidates: [ + { + identifier: "printing-1", + canonicalId: "canonical-1", + expansionCode: "abc", + expansionName: "A Big Cardset", + collectorNumber: "1", + artist: "Some Artist", + smallThumbnailUrl: "https://example.com/small1.png", + mediumThumbnailUrl: "https://example.com/medium1.png", + fullArt: false, + isBorderless: false, + frame: "2015", + borderColor: "black", + isShowcase: false, + isExtendedArt: false, + isEtched: false, + }, + { + identifier: "printing-2", + canonicalId: "canonical-1", + expansionCode: "xyz", + expansionName: "Another Cardset", + collectorNumber: "42", + artist: "Another Artist", + smallThumbnailUrl: "https://example.com/small2.png", + mediumThumbnailUrl: "https://example.com/medium2.png", + fullArt: true, + isBorderless: true, + frame: "2003", + borderColor: "borderless", + isShowcase: true, + isExtendedArt: false, + isEtched: false, + }, + ], + tagConfidence: {}, +}; + +function questionFeedOnce() { + return http.get(buildRoute("2/questionFeed/"), () => + HttpResponse.json( + { item: identifyPrintingItem, remainingEstimate: 1 }, + { status: 200 } + ) + ); +} + +describe("QuestionFeed", () => { + it("disables the No match button until a chip is explicitly set, then enables it", async () => { + server.use(questionFeedOnce()); + server.use(submitTagVoteResolvesToApply); + renderFeed(); + await revealCard(); + + const noMatchButton = await screen.findByTestId("question-feed-no-match"); + expect(noMatchButton).toBeDisabled(); + + fireEvent.click(screen.getByTestId("attribute-chip-Full Art")); + await waitFor(() => expect(noMatchButton).not.toBeDisabled()); + }); + + it("clicking No match while disabled never calls submitPrintingTag", async () => { + server.use(questionFeedOnce()); + let submitCalled = false; + server.use( + http.post(buildRoute("2/submitPrintingTag/"), () => { + submitCalled = true; + return HttpResponse.json( + { resolvedPrinting: null, isNoMatch: true, voteTally: [] }, + { status: 200 } + ); + }) + ); + renderFeed(); + await revealCard(); + + const noMatchButton = await screen.findByTestId("question-feed-no-match"); + fireEvent.click(noMatchButton); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(submitCalled).toBe(false); + }); + + it("selecting a candidate auto-casts positive CardTagVotes for its own standalone attributes only", async () => { + // first GET serves the item, every subsequent GET (post-advance) reports caught-up - a + // second `server.use` for the same route would just override the first one outright + // (MSW's handler stack is LIFO), so both states have to live in one handler. + let feedFetchCount = 0; + server.use( + http.get(buildRoute("2/questionFeed/"), () => { + feedFetchCount += 1; + return feedFetchCount === 1 + ? HttpResponse.json( + { item: identifyPrintingItem, remainingEstimate: 1 }, + { status: 200 } + ) + : HttpResponse.json({ remainingEstimate: 0 }, { status: 200 }); + }) + ); + server.use( + http.post(buildRoute("2/submitPrintingTag/"), () => + HttpResponse.json( + { resolvedPrinting: null, isNoMatch: false, voteTally: [] }, + { status: 200 } + ) + ) + ); + const autoTagCalls: Array<{ tagName: string; polarity: number }> = []; + server.use( + http.post(buildRoute("2/submitTagVote/"), async ({ request }) => { + const body = (await request.json()) as { + tagName: string; + polarity: number; + }; + autoTagCalls.push({ tagName: body.tagName, polarity: body.polarity }); + return HttpResponse.json( + { + tagName: body.tagName, + resolvedPolarity: body.polarity, + netPolarity: body.polarity, + tally: [], + }, + { status: 200 } + ); + }) + ); + renderFeed(); + await revealCard(); + + const candidateButton = await screen.findByAltText("xyz 42"); + fireEvent.click(candidateButton); + + await waitFor(() => + expect(autoTagCalls.map((call) => call.tagName).sort()).toEqual( + ["Borderless", "Full Art", "Showcase"].sort() + ) + ); + expect(autoTagCalls.every((call) => call.polarity === 1)).toBe(true); + }); + + it("selecting a candidate with no true attributes casts zero auto-tag votes", async () => { + let feedFetchCount = 0; + server.use( + http.get(buildRoute("2/questionFeed/"), () => { + feedFetchCount += 1; + return feedFetchCount === 1 + ? HttpResponse.json( + { item: identifyPrintingItem, remainingEstimate: 1 }, + { status: 200 } + ) + : HttpResponse.json({ remainingEstimate: 0 }, { status: 200 }); + }) + ); + server.use( + http.post(buildRoute("2/submitPrintingTag/"), () => + HttpResponse.json( + { resolvedPrinting: null, isNoMatch: false, voteTally: [] }, + { status: 200 } + ) + ) + ); + let autoTagCallCount = 0; + server.use( + http.post(buildRoute("2/submitTagVote/"), () => { + autoTagCallCount += 1; + return HttpResponse.json( + { tagName: "x", resolvedPolarity: 1, netPolarity: 1, tally: [] }, + { status: 200 } + ); + }) + ); + renderFeed(); + await revealCard(); + + const candidateButton = await screen.findByAltText("abc 1"); + fireEvent.click(candidateButton); + + await waitFor(() => + expect( + screen.getByText( + "You're all caught up - no cards left to work on right now!" + ) + ).toBeDefined() + ); + expect(autoTagCallCount).toBe(0); + }); +}); diff --git a/frontend/src/features/questionFeed/QuestionFeed.tsx b/frontend/src/features/questionFeed/QuestionFeed.tsx new file mode 100644 index 000000000..86e04a625 --- /dev/null +++ b/frontend/src/features/questionFeed/QuestionFeed.tsx @@ -0,0 +1,535 @@ +/** + * The unified "What's That Card?" question feed - replaces the old printing/artist/tag tab + * switcher (PrintingTagQueue.tsx + GenericVoteQueue.tsx, both deleted alongside this file) + * with a single `GET 2/questionFeed/`-driven stream of one question at a time, typed per + * cardpicker.question_feed's four-tier ranked union. See docs/features/printing-tags.md's + * questionFeed section and journal/2026-07-14-queue-question-feed-design.md for the full + * design writeup (chip taxonomy grounding, layout rationale, starvation-risk tradeoff). + * + * Re-composition, not a rewrite: the sticky starburst card panel, reveal animation, and + * candidate-grid mechanics are the exact same code as the old PrintingTagQueue, now shared + * via cardPanel.tsx. ArtistVotePicker and QueueTagQuestion are reused directly for their + * question types, unforked. + */ + +import React, { useEffect, useState } from "react"; +import Badge from "react-bootstrap/Badge"; +import Button from "react-bootstrap/Button"; +import Col from "react-bootstrap/Col"; +import Row from "react-bootstrap/Row"; + +import { getPrintingCandidateDataAttributes } from "@/common/cardDom"; +import { getOrCreateAnonymousId } from "@/common/cookies"; +import { PrintingCandidate, QuestionFeedItem } from "@/common/schema_types"; +import { useTagDisplayName } from "@/common/tagDisplayNames"; +import { useAppDispatch, useAppSelector } from "@/common/types"; +import { SetIcon } from "@/components/SetIcon"; +import { Spinner } from "@/components/Spinner"; +import { + AttributeChipPanel, + hasAnyExplicitChip, + initialChipStates, +} from "@/features/attributeChips/AttributeChipPanel"; +import { + ChipVoteState, + filterCandidatesByChipStates, + STANDALONE_CHIPS, +} from "@/features/attributeChips/attributeChips"; +import { ArtistVotePicker } from "@/features/attributeVoting/ArtistVotePicker"; +import { NoMatchReasonStrip } from "@/features/attributeVoting/NoMatchReasonStrip"; +import { QueueTagQuestion } from "@/features/attributeVoting/QueueTagQuestion"; +import { + ArtPlaceholder, + BurstSvg, + CandidateButton, + CARD_ASPECT_RATIO, + CardPanel, + HoverBurst, + randomFlavorText, + RevealOverlay, + RevealWrapper, + useStarburstFrame, + useStickyTop, + ZoomableThumbnail, +} from "@/features/printingTags/cardPanel"; +import { + STARBURST_INNER_COLOR, + STARBURST_INNER_FRAMES, + STARBURST_OUTER_COLOR, + STARBURST_OUTER_FRAMES, + STARBURST_VIEWBOX, +} from "@/features/printingTags/starburstShape"; +import { + APIGetQuestionFeed, + APISubmitPrintingTag, + APISubmitTagVote, +} from "@/store/api"; +import { selectRemoteBackendURL } from "@/store/slices/backendSlice"; +import { setNotification } from "@/store/slices/toastsSlice"; + +type FollowUp = "none" | "no-match-reason"; + +export function QuestionFeed() { + const dispatch = useAppDispatch(); + const backendURL = useAppSelector(selectRemoteBackendURL); + const getTagDisplayName = useTagDisplayName(); + const starburstFrame = useStarburstFrame(); + + const [item, setItem] = useState(null); + const [remainingEstimate, setRemainingEstimate] = useState(0); + const [loading, setLoading] = useState(true); + const [caughtUp, setCaughtUp] = useState(false); + const [flavorText, setFlavorText] = useState(null); + const [revealed, setRevealed] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [chipStates, setChipStates] = useState>( + initialChipStates() + ); + const [followUp, setFollowUp] = useState("none"); + const [fetchToken, setFetchToken] = useState(0); + + const { ref: cardPanelRef, top: stickyTop } = useStickyTop([ + item?.card.identifier, + item?.type, + ]); + + const fetchNext = () => setFetchToken((previous) => previous + 1); + + useEffect(() => { + if (backendURL == null) { + return; + } + setLoading(true); + // "include" (not the default "same-origin") so a moderator's session cookie always + // attaches - the backend only surfaces tier-3 moderation questions when it can see who's + // asking (is_moderator(request.user)); an anonymous request just sends no cookie, same as + // APIReportCard's unconditional "include" elsewhere. + APIGetQuestionFeed(backendURL, getOrCreateAnonymousId(), "include") + .then((response) => { + setItem(response.item ?? null); + setRemainingEstimate(response.remainingEstimate); + setCaughtUp(response.item == null); + }) + .catch(() => { + setItem(null); + setCaughtUp(true); + }) + .finally(() => setLoading(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [backendURL, fetchToken]); + + // reset per-question local state whenever a new item lands + useEffect(() => { + setRevealed(false); + setChipStates(initialChipStates()); + setFollowUp("none"); + }, [item?.card.identifier, item?.type]); + + const advance = () => { + setFlavorText(randomFlavorText()); + fetchNext(); + }; + + const reportVoteFailed = () => + dispatch( + setNotification([ + Math.random().toString(), + { + name: "Vote failed", + message: + "Something went wrong submitting your vote - please try again.", + level: "error", + }, + ]) + ); + + // Selecting a candidate casts the printing vote plus one positive CardTagVote per + // standalone attribute the candidate itself carries true - see the design doc's "Auto-tag + // on selection" section for why this only covers the standalone chips (border/frame + // exclusion groups aren't auto-derivable in v1). PrintingConfirmStrip is deliberately not + // rendered anywhere in this flow - everything it used to manually confirm is now auto-cast + // here instead. + const selectCandidate = ( + candidate: PrintingCandidate | undefined, + isNoMatch: boolean + ) => { + if (backendURL == null || item == null) { + return; + } + setSubmitting(true); + const anonymousId = getOrCreateAnonymousId(); + APISubmitPrintingTag( + backendURL, + item.card.identifier, + anonymousId, + candidate?.identifier, + isNoMatch + ) + .then(() => { + if (candidate != null) { + const autoTagChips = STANDALONE_CHIPS.filter((chip) => + chip.matches(candidate) + ); + Promise.all( + autoTagChips.map((chip) => + APISubmitTagVote( + backendURL, + item.card.identifier, + anonymousId, + chip.tagName, + 1 + ) + ) + ).catch(() => undefined); // best-effort - a failed auto-tag shouldn't block advancing + } + if (isNoMatch) { + setFollowUp("no-match-reason"); + } else { + advance(); + } + }) + .catch(reportVoteFailed) + .finally(() => setSubmitting(false)); + }; + + const skip = () => advance(); + + if (loading && item == null) { + return ( +
+ +
+ ); + } + + if (caughtUp || item == null || backendURL == null) { + return ( +
+

+ You're all caught up - no cards left to work on right now! +

+ {flavorText != null && ( +

+ {flavorText} +

+ )} +
+ ); + } + + const isCandidateType = + item.type === "confirm_suggestion" || item.type === "identify_printing"; + const allCandidates = item.candidates ?? []; + const visibleCandidates = filterCandidatesByChipStates( + allCandidates, + chipStates + ); + const hiddenCount = allCandidates.length - visibleCandidates.length; + const noMatchDisabled = !hasAnyExplicitChip(chipStates); + + // BurstSvg renders alongside (not inside) RevealWrapper deliberately - RevealWrapper has + // overflow: hidden (it clips the silhouette-reveal animation to the card's own box), which + // would also clip the burst's intentional bleed if it were a descendant instead of a + // sibling. Both size themselves against whichever positioned ancestor contains them - + // AttributeChipPanel's CardArea now, so the burst centers on and scales with the card's own + // rendered width specifically, not the wider ring (card + flanking chip columns) around it. + const cardImage = ( + <> + + + + + + {item.card.name} + {!revealed && ( + setRevealed(true)} + > + ? + + )} + +
{item.card.name}
+ + ); + + // The card renders dead center with chips forming a ring around it (AttributeChipPanel's + // ChipRing grid) rather than stacked above it - the starburst behind the whole assembly is + // purely decorative (pointer-events: none throughout), so it never competes with any of + // this for clicks regardless of how it visually bleeds. + const cardPanel = ( + + {/* cardPanel is only ever rendered from the isCandidateType branch below - the + artist/tag/moderation branch renders its own plain image directly, uninvolved with + chips or the starburst. BurstSvg now lives inside `cardImage` itself (see above), + not here, so it sizes against the card's own box rather than this whole ring. */} + + + ); + + return ( +
+

+ Still need help with: {remainingEstimate} card + {remainingEstimate !== 1 && "s"} +

+ {flavorText != null && ( +

+ {flavorText} +

+ )} +
+ + {isCandidateType ? ( + <> + + {!revealed ? ( +
+ +
+ ) : ( + <> + {item.type === "confirm_suggestion" && + item.suggestedPrinting != null && ( +

+ Is it this one?{" "} + {" "} + {item.suggestedPrinting.expansionCode.toUpperCase()}{" "} + {item.suggestedPrinting.collectorNumber} +

+ )} + {hiddenCount > 0 && ( +

+ {hiddenCount} hidden by your tags -{" "} + { + event.preventDefault(); + setChipStates(initialChipStates()); + }} + > + clear + +

+ )} + + + selectCandidate(undefined, true)} + data-testid="question-feed-no-match" + > + + + + + +
No match
+
+ + {visibleCandidates.map((candidate) => ( + + selectCandidate(candidate, false)} + {...getPrintingCandidateDataAttributes( + item.card.name, + candidate + )} + > + + + + + + + {`${candidate.expansionCode} + + +
+ {" "} + {candidate.expansionCode.toUpperCase()}{" "} + {candidate.collectorNumber} +
+
+ {candidate.artist} +
+
+ + ))} +
+ {followUp === "no-match-reason" && ( +
+
+ +
+ )} +
+ +
+ + )} + + {/* position + a non-auto z-index together give this column its own local + stacking context, containing CardPanel's z-index: -1 (see cardPanel.tsx) so + it can't escape and render the whole panel - chips included - unclickable + behind this sibling column at the hit-testing layer. position: relative + alone does NOT establish a stacking context - see + docs/features/printing-tags.md's Stage 7 section for the full story. */} + + {cardPanel} + + + ) : ( + <> + + {item.type === "artist" && ( + <> +
Who's the artist?
+ +
+ +
+ + )} + {item.type === "tag" && item.tagName != null && ( + + )} + {item.type === "moderation" && item.tagName != null && ( + <> +

+ Should this card carry the tag{" "} + {getTagDisplayName(item.tagName)}? +

+

+ + {item.reportCount ?? 0} report + {(item.reportCount ?? 0) !== 1 && "s"} + +

+ {(item.reportExcerpts ?? []).length > 0 && ( +
    + {(item.reportExcerpts ?? []).map((excerpt, index) => ( +
  • “{excerpt}”
  • + ))} +
+ )} + + + )} + + + {item.card.name} +
{item.card.name}
+ + + )} +
+
+
+ ); +} diff --git a/frontend/src/features/ui/Navbar.tsx b/frontend/src/features/ui/Navbar.tsx index da56ad75c..bd83fa270 100644 --- a/frontend/src/features/ui/Navbar.tsx +++ b/frontend/src/features/ui/Navbar.tsx @@ -119,9 +119,9 @@ export default function ProjectNavbar() { {remoteBackendConfigured && ( What's That Card? diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts index 478fdf15a..458fcb500 100644 --- a/frontend/src/mocks/handlers.ts +++ b/frontend/src/mocks/handlers.ts @@ -802,8 +802,9 @@ export const submitPrintingTagResolvesToPrintingCandidate1 = http.post( ) ); -// printingCandidate2 (unlike printingCandidate1) has fullArt/isBorderless both true - used to -// exercise PrintingConfirmStrip's pre-fill-from-candidate-metadata behaviour in both states. +// printingCandidate2 (unlike printingCandidate1) has fullArt/isBorderless/isShowcase all true +// - used to exercise QuestionFeed's auto-tag-on-selection behaviour (see attributeChips.ts's +// STANDALONE_CHIPS) across both states. export const submitPrintingTagResolvesToPrintingCandidate2 = http.post( buildRoute("2/submitPrintingTag/"), () => @@ -891,6 +892,115 @@ export const voteQueueNoResults = http.post(buildRoute("2/voteQueue/"), () => //# endregion +//# region question feed + +export const questionFeedConfirmSuggestion = http.get( + buildRoute("2/questionFeed/"), + () => + HttpResponse.json( + { + item: { + type: "confirm_suggestion", + card: cardDocument1, + suggestedPrinting: printingCandidate1, + candidates: [printingCandidate1, printingCandidate2], + tagConfidence: { "Full Art": 0, Borderless: 0 }, + }, + remainingEstimate: 5, + }, + { status: 200 } + ) +); + +export const questionFeedIdentifyPrinting = http.get( + buildRoute("2/questionFeed/"), + () => + HttpResponse.json( + { + item: { + type: "identify_printing", + card: cardDocument1, + candidates: [printingCandidate1, printingCandidate2], + tagConfidence: { "Full Art": 0, Borderless: 0.6 }, + }, + remainingEstimate: 3, + }, + { status: 200 } + ) +); + +export const questionFeedArtist = http.get(buildRoute("2/questionFeed/"), () => + HttpResponse.json( + { + item: { + type: "artist", + card: cardDocument8, + confidentlyKnownArtistName: null, + }, + remainingEstimate: 2, + }, + { status: 200 } + ) +); + +// cardDocument8 has a confidently-known canonicalArtist (Alpha Artist) - this mock exercises +// ArtistVotePicker's collapsed pre-filled state (see its own "wrong?" affordance) via real +// questionFeed-shaped data, distinct from questionFeedArtist's plain-picker (unresolved) case. +export const questionFeedArtistConfidentlyKnown = http.get( + buildRoute("2/questionFeed/"), + () => + HttpResponse.json( + { + item: { + type: "artist", + card: cardDocument8, + confidentlyKnownArtistName: "Alpha Artist", + }, + remainingEstimate: 2, + }, + { status: 200 } + ) +); + +export const questionFeedTag = http.get(buildRoute("2/questionFeed/"), () => + HttpResponse.json( + { + item: { + type: "tag", + card: cardDocument9, + tagName: "Borderless", + }, + remainingEstimate: 1, + }, + { status: 200 } + ) +); + +export const questionFeedModeration = http.get( + buildRoute("2/questionFeed/"), + () => + HttpResponse.json( + { + item: { + type: "moderation", + card: cardDocument9, + tagName: "NSFW", + reportCount: 2, + reportExcerpts: ["too spicy"], + }, + remainingEstimate: 1, + }, + { status: 200 } + ) +); + +export const questionFeedCaughtUp = http.get( + buildRoute("2/questionFeed/"), + () => HttpResponse.json({ remainingEstimate: 0 }, { status: 200 }) +); + +//# endregion + //# region attribute voting export const artistCandidatesTwoResults = http.post( @@ -930,8 +1040,18 @@ export const tagConsensusTwoUnresolvedTags = http.post( HttpResponse.json( { tags: [ - { tagName: "Borderless", resolvedPolarity: null, tally: [] }, - { tagName: "Extended", resolvedPolarity: null, tally: [] }, + { + tagName: "Borderless", + resolvedPolarity: null, + netPolarity: 0, + tally: [], + }, + { + tagName: "Extended", + resolvedPolarity: null, + netPolarity: 0, + tally: [], + }, ], }, { status: 200 } @@ -945,6 +1065,7 @@ export const submitTagVoteResolvesToApply = http.post( { tagName: "Borderless", resolvedPolarity: 1, + netPolarity: 1, tally: [{ polarity: 1, count: 1 }], }, { status: 200 } diff --git a/frontend/src/pages/printingQueue.tsx b/frontend/src/pages/whatsthat.tsx similarity index 50% rename from frontend/src/pages/printingQueue.tsx rename to frontend/src/pages/whatsthat.tsx index 8122562f0..167ac7032 100644 --- a/frontend/src/pages/printingQueue.tsx +++ b/frontend/src/pages/whatsthat.tsx @@ -1,29 +1,21 @@ import styled from "@emotion/styled"; import Head from "next/head"; -import React, { useState } from "react"; -import Nav from "react-bootstrap/Nav"; -import Tab from "react-bootstrap/Tab"; +import React from "react"; import { ContentMaxWidth, ProjectName } from "@/common/constants"; -import { Kind } from "@/common/schema_types"; import { NoBackendDefault } from "@/components/NoBackendDefault"; -import { GenericVoteQueue } from "@/features/attributeVoting/GenericVoteQueue"; import { AuthWidget } from "@/features/moderation/AuthWidget"; -import { ModerationQueue } from "@/features/moderation/ModerationQueue"; -import { PrintingTagQueue } from "@/features/printingTags/PrintingTagQueue"; import { STARBURST_BACKGROUND_COLOR } from "@/features/printingTags/starburstShape"; +import { QuestionFeed } from "@/features/questionFeed/QuestionFeed"; import Footer from "@/features/ui/Footer"; import { ProjectContainer } from "@/features/ui/Layout"; -import { useGetWhoamiQuery } from "@/store/api"; import { useProjectName, useRemoteBackendConfigured, } from "@/store/slices/backendSlice"; -type VoteQueueTab = "printing" | "artist" | "tag" | "moderation"; - // Radiating starburst behind the game itself - a jagged -// "explosion" burst (see starburstShape.ts, rendered inside PrintingTagQueue.tsx alongside +// "explosion" burst (see starburstShape.ts, rendered inside QuestionFeed.tsx alongside // the subject card so the two stay glued together under position: sticky as the page // scrolls) built from two overlapping SVG polygons rather than a static image, so it scales // to any container size with no asset to host/maintain. Full-bleed to the viewport edges @@ -35,7 +27,7 @@ type VoteQueueTab = "printing" | "artist" | "tag" | "moderation"; const StarburstBackground = styled.div` position: relative; /* Deliberately clip-path, not overflow: hidden - the card+burst panel inside this uses - position: sticky (see CardPanel in PrintingTagQueue.tsx), and an overflow value other + position: sticky (see CardPanel in cardPanel.tsx), and an overflow value other than visible on ANY ancestor of a sticky element - even one that never actually scrolls - silently breaks its stickiness (a well-documented CSS gotcha: it changes what the sticky element's nearest scrolling ancestor resolves to). clip-path clips the @@ -60,7 +52,7 @@ const StarburstBackground = styled.div` `; // Sits above the sticky card panel's stacking context (see CardPanel in -// PrintingTagQueue.tsx) so the burst bleeding out from behind the card doesn't cover this +// cardPanel.tsx) so the burst bleeding out from behind the card doesn't cover this // intro text, at the initial (unscrolled) position where they visually overlap. const StarburstContent = styled.div` position: relative; @@ -70,73 +62,31 @@ const StarburstContent = styled.div` padding: 0 1.5rem; `; +// The starburst+card assembly anchors to the right of the page (see QuestionFeed.tsx's +// column order), so the intro copy above it reads right-to-left too, keeping the whole +// header visually aligned with what sits below it rather than starting from the opposite edge. +const IntroText = styled.div` + text-align: right; +`; + function PrintingQueueOrDefault() { const remoteBackendConfigured = useRemoteBackendConfigured(); - const [activeTab, setActiveTab] = useState("printing"); - // gating the tab is presentation only - the backend 403s non-moderators regardless - const whoami = useGetWhoamiQuery(); - const isModerator = whoami.data?.moderator === true; return remoteBackendConfigured ? ( <> -

What's That Card?

-

- Test your Magic: the Gathering knowledge! One card at a time, help - identify which real-world printing, artist, or descriptor tag each - card image depicts - contested cards come first, since they need - your eyes the most. -

+ +

What's That Card?

+

+ Test your Magic: the Gathering knowledge! One card at a time, help + identify which real-world printing, artist, or descriptor tag each + card image depicts - contested and AI-suggested cards come first, + since they need your eyes the most. +

+
- { - if (key) setActiveTab(key as VoteQueueTab); - }} - > - - - {/* mountOnEnter: each mode's queue does its own fetching/pagination on mount - - no need to pay that cost for tabs the user hasn't visited yet. Printing - mode's own component/behavior is completely unchanged from before this tab - switcher existed. unmountOnExit on the two new tabs (not printing, to avoid - touching its existing behavior at all): without it, react-bootstrap keeps an - already-visited pane's DOM mounted (just hidden) rather than removing it, - which both leaves an inactive tab's queue silently polling for more pages in - the background and produces duplicate data-testid="vote-queue" elements in - the DOM at once. */} - - - - - - - - - - {isModerator && ( - - - - )} - - +