👁️
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

even more overkill title based sorting

+245 -28
+20 -28
src/components/DeckPreview.tsx
··· 5 5 import { CardSpread } from "@/components/CardSpread"; 6 6 import { ClientDate } from "@/components/ClientDate"; 7 7 import { asRkey, type Rkey } from "@/lib/atproto-client"; 8 + import { 9 + getDeckNameWords, 10 + isNonCreatureLand, 11 + textMatchesDeckTitle, 12 + } from "@/lib/deck-preview-utils"; 8 13 import type { Deck } from "@/lib/deck-types"; 9 14 import { didDocumentQueryOptions, extractHandle } from "@/lib/did-to-handle"; 10 15 import { formatDisplayName } from "@/lib/format-utils"; ··· 56 61 return parts.join(" · "); 57 62 } 58 63 59 - function isNonCreatureLand(typeLine: string | undefined): boolean { 60 - if (!typeLine) return false; 61 - const lower = typeLine.toLowerCase(); 62 - return lower.includes("land") && !lower.includes("creature"); 63 - } 64 - 65 - function getDeckNameWords(name: string): string[] { 66 - // Extract meaningful words (3+ chars, lowercased) 67 - return name 68 - .toLowerCase() 69 - .split(/\s+/) 70 - .filter((w) => w.length >= 3); 71 - } 72 - 73 - function cardNameMatchesDeckTitle( 74 - cardName: string | undefined, 75 - deckWords: string[], 76 - ): boolean { 77 - if (!cardName || deckWords.length === 0) return false; 78 - const lower = cardName.toLowerCase(); 79 - return deckWords.some((word) => lower.includes(word)); 80 - } 81 - 82 64 export function DeckPreview({ 83 65 did, 84 66 rkey, ··· 144 126 .sort((a, b) => { 145 127 const qtyDiff = b.deckCard.quantity - a.deckCard.quantity; 146 128 if (qtyDiff !== 0) return qtyDiff; 147 - // Tiebreak: prefer cards whose name matches deck title (sort to end = on top) 148 - const aMatches = cardNameMatchesDeckTitle(a.card?.name, deckWords); 149 - const bMatches = cardNameMatchesDeckTitle(b.card?.name, deckWords); 150 - if (aMatches && !bMatches) return 1; 151 - if (bMatches && !aMatches) return -1; 129 + // Tiebreak 1: prefer cards whose name matches deck title 130 + const aNameMatch = textMatchesDeckTitle(a.card?.name, deckWords); 131 + const bNameMatch = textMatchesDeckTitle(b.card?.name, deckWords); 132 + if (aNameMatch && !bNameMatch) return 1; 133 + if (bNameMatch && !aNameMatch) return -1; 134 + // Tiebreak 2: prefer cards whose type line matches deck title 135 + const aTypeMatch = textMatchesDeckTitle(a.card?.type_line, deckWords); 136 + const bTypeMatch = textMatchesDeckTitle(b.card?.type_line, deckWords); 137 + if (aTypeMatch && !bTypeMatch) return 1; 138 + if (bTypeMatch && !aTypeMatch) return -1; 139 + // Tiebreak 3: prefer cards whose oracle text matches deck title 140 + const aTextMatch = textMatchesDeckTitle(a.card?.oracle_text, deckWords); 141 + const bTextMatch = textMatchesDeckTitle(b.card?.oracle_text, deckWords); 142 + if (aTextMatch && !bTextMatch) return 1; 143 + if (bTextMatch && !aTextMatch) return -1; 152 144 return 0; 153 145 }) 154 146 .slice(0, 3)
+143
src/lib/deck-preview-utils.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { 3 + getDeckNameWords, 4 + getSingularForms, 5 + isNonCreatureLand, 6 + textMatchesDeckTitle, 7 + } from "./deck-preview-utils"; 8 + 9 + describe("getSingularForms", () => { 10 + describe("irregular plurals", () => { 11 + it.each([ 12 + ["geese", ["geese", "goose"]], 13 + ["teeth", ["teeth", "tooth"]], 14 + ["feet", ["feet", "foot"]], 15 + ["men", ["men", "man"]], 16 + ["mice", ["mice", "mouse"]], 17 + ["children", ["children", "child"]], 18 + ["oxen", ["oxen", "ox"]], 19 + ["people", ["people", "person"]], 20 + ])("%s -> %j", (input, expected) => { 21 + expect(getSingularForms(input)).toEqual(expected); 22 + }); 23 + }); 24 + 25 + describe("typal deck names", () => { 26 + it.each([ 27 + // -s plurals (most common) 28 + ["goblins", ["goblins", "goblin"]], 29 + ["spirits", ["spirits", "spirit"]], 30 + ["humans", ["humans", "human"]], 31 + ["wizards", ["wizards", "wizard"]], 32 + ["knights", ["knights", "knight"]], 33 + ["angels", ["angels", "angel"]], 34 + ["dragons", ["dragons", "dragon"]], 35 + ["slivers", ["slivers", "sliver"]], 36 + ["rogues", ["rogues", "rogue"]], 37 + ["warriors", ["warriors", "warrior"]], 38 + ["clerics", ["clerics", "cleric"]], 39 + ["shamans", ["shamans", "shaman"]], 40 + ["soldiers", ["soldiers", "soldier"]], 41 + ["bogles", ["bogles", "bogle"]], 42 + ["vampires", ["vampires", "vampire"]], 43 + ["horses", ["horses", "horse"]], 44 + // -ves -> -f and -fe plurals 45 + ["elves", ["elves", "elf", "elfe"]], 46 + ["wolves", ["wolves", "wolf", "wolfe"]], 47 + ["werewolves", ["werewolves", "werewolf", "werewolfe"]], 48 + ["knives", ["knives", "knif", "knife"]], 49 + // -ies plurals (both -y and -ie forms) 50 + ["zombies", ["zombies", "zomby", "zombie"]], 51 + ["faeries", ["faeries", "faery", "faerie"]], 52 + ["pixies", ["pixies", "pixy", "pixie"]], 53 + ["allies", ["allies", "ally", "allie"]], 54 + // -xes/-ches/-shes/-sses/-zzes plurals (strip -es) 55 + ["boxes", ["boxes", "box"]], 56 + ["matches", ["matches", "match"]], 57 + ["dishes", ["dishes", "dish"]], 58 + ["passes", ["passes", "pass"]], 59 + ["buzzes", ["buzzes", "buzz"]], 60 + // Words that don't need singularization 61 + ["merfolk", ["merfolk"]], 62 + ["equipment", ["equipment"]], 63 + ])("%s -> %j", (input, expected) => { 64 + expect(getSingularForms(input)).toEqual(expected); 65 + }); 66 + }); 67 + 68 + describe("edge cases", () => { 69 + it.each([ 70 + ["as", ["as", "a"]], 71 + ["is", ["is", "i"]], 72 + ["s", ["s"]], 73 + ["es", ["es", "e"]], 74 + ["ves", ["ves", "ve"]], 75 + ["ies", ["ies", "ie"]], 76 + ])("%s -> %j", (input, expected) => { 77 + expect(getSingularForms(input)).toEqual(expected); 78 + }); 79 + }); 80 + }); 81 + 82 + describe("getDeckNameWords", () => { 83 + it.each([ 84 + ["Selesnya Elves", ["selesnya", "elves", "elf", "elfe"]], 85 + ["Mono-Green Elves", ["mono-green", "elves", "elf", "elfe"]], 86 + ["Bant Spirits", ["bant", "spirits", "spirit"]], 87 + ["Selesnya Bogles", ["selesnya", "bogles", "bogle"]], 88 + ["Simic Merfolk", ["simic", "merfolk"]], 89 + ["Azorius Faeries", ["azorius", "azoriu", "faeries", "faery", "faerie"]], 90 + ["GOBLINS", ["goblins", "goblin"]], 91 + ])("%s -> %j", (input, expected) => { 92 + expect(getDeckNameWords(input)).toEqual(expected); 93 + }); 94 + 95 + it("filters words shorter than 3 chars", () => { 96 + const words = getDeckNameWords("UW Spirits"); 97 + expect(words).not.toContain("uw"); 98 + expect(words).toContain("spirits"); 99 + }); 100 + }); 101 + 102 + describe("textMatchesDeckTitle", () => { 103 + it.each([ 104 + ["Slippery Bogle", "Selesnya Bogles", true], 105 + ["Gladecover Scout", "Selesnya Bogles", false], 106 + ["Llanowar Elves", "Mono-Green Elves", true], 107 + ["Heritage Druid", "Mono-Green Elves", false], 108 + ["Whenever another Elf enters...", "Mono-Green Elves", true], 109 + ["GOBLIN GUIDE", "goblins", true], 110 + ["Gilded Goose", "Food Geese", true], 111 + ["Tireless Tracker", "Food Geese", false], 112 + ["Cheeky House-Mouse", "Boros Mice", true], 113 + ["Manifold Mouse", "Boros Mice", true], 114 + ])("'%s' matches deck '%s' -> %s", (text, deckName, expected) => { 115 + const deckWords = getDeckNameWords(deckName); 116 + expect(textMatchesDeckTitle(text, deckWords)).toBe(expected); 117 + }); 118 + 119 + it("handles empty/undefined inputs", () => { 120 + const deckWords = getDeckNameWords("Elves"); 121 + expect(textMatchesDeckTitle(undefined, deckWords)).toBe(false); 122 + expect(textMatchesDeckTitle("", deckWords)).toBe(false); 123 + expect(textMatchesDeckTitle("Llanowar Elves", [])).toBe(false); 124 + }); 125 + }); 126 + 127 + describe("isNonCreatureLand", () => { 128 + it.each([ 129 + ["Basic Land — Forest", true], 130 + ["Basic Land — Island", true], 131 + ["Land", true], 132 + ["Land — Gate", true], 133 + ["Legendary Land", true], 134 + ["Land Creature — Elemental", false], 135 + ["Creature Land", false], 136 + ["Creature — Elf", false], 137 + ["Instant", false], 138 + ["Enchantment", false], 139 + [undefined, false], 140 + ])("isNonCreatureLand(%j) -> %s", (input, expected) => { 141 + expect(isNonCreatureLand(input)).toBe(expected); 142 + }); 143 + });
+82
src/lib/deck-preview-utils.ts
··· 1 + /** 2 + * Irregular plurals relevant to MTG typal deck names. 3 + * These can't be derived algorithmically. 4 + */ 5 + const IRREGULAR_PLURALS: Record<string, string[]> = { 6 + geese: ["goose"], 7 + teeth: ["tooth"], 8 + feet: ["foot"], 9 + men: ["man"], 10 + mice: ["mouse"], 11 + children: ["child"], 12 + oxen: ["ox"], 13 + people: ["person"], 14 + }; 15 + 16 + /** 17 + * Get possible singular forms of a word for matching deck names to card names. 18 + * Handles common English plural patterns used in MTG typal deck names. 19 + */ 20 + export function getSingularForms(word: string): string[] { 21 + const forms = [word]; 22 + 23 + // Check irregular plurals first 24 + const irregular = IRREGULAR_PLURALS[word]; 25 + if (irregular) { 26 + forms.push(...irregular); 27 + return forms; 28 + } 29 + 30 + if (word.endsWith("ies") && word.length > 3) { 31 + forms.push(`${word.slice(0, -3)}y`); // pixies -> pixy 32 + forms.push(word.slice(0, -1)); // faeries -> faerie 33 + } else if (word.endsWith("ves") && word.length > 3) { 34 + forms.push(`${word.slice(0, -3)}f`); // elves -> elf 35 + forms.push(`${word.slice(0, -3)}fe`); // knives -> knife 36 + } else if ( 37 + word.length > 2 && 38 + (word.endsWith("xes") || 39 + word.endsWith("sses") || 40 + word.endsWith("ches") || 41 + word.endsWith("shes") || 42 + word.endsWith("zzes")) 43 + ) { 44 + forms.push(word.slice(0, -2)); // boxes -> box, passes -> pass 45 + } else if (word.endsWith("s") && word.length > 1) { 46 + forms.push(word.slice(0, -1)); // bogles -> bogle, goblins -> goblin 47 + } 48 + return forms; 49 + } 50 + 51 + /** 52 + * Extract meaningful words from a deck name for matching against card names/text. 53 + * Returns lowercase words (3+ chars) plus their possible singular forms. 54 + */ 55 + export function getDeckNameWords(name: string): string[] { 56 + return name 57 + .toLowerCase() 58 + .split(/\s+/) 59 + .filter((w) => w.length >= 3) 60 + .flatMap(getSingularForms); 61 + } 62 + 63 + /** 64 + * Check if text contains any of the deck title words. 65 + */ 66 + export function textMatchesDeckTitle( 67 + text: string | undefined, 68 + deckWords: string[], 69 + ): boolean { 70 + if (!text || deckWords.length === 0) return false; 71 + const lower = text.toLowerCase(); 72 + return deckWords.some((word) => lower.includes(word)); 73 + } 74 + 75 + /** 76 + * Check if a type line represents a non-creature land. 77 + */ 78 + export function isNonCreatureLand(typeLine: string | undefined): boolean { 79 + if (!typeLine) return false; 80 + const lower = typeLine.toLowerCase(); 81 + return lower.includes("land") && !lower.includes("creature"); 82 + }