👁️
5
fork

Configure Feed

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

refactor to support type with tags for dnd, use new persisted state hook

+87 -145
+11 -5
src/components/deck/DeckSection.tsx
··· 59 59 60 60 // Group and sort cards with memoization 61 61 const groupedCards = useMemo(() => { 62 - if (!cardMap) return new Map([["all", cards]]); 62 + if (!cardMap) return new Map([["all", { cards, forTag: false }]]); 63 63 const lookup = (card: DeckCard) => cardMap.get(card.scryfallId); 64 64 return groupCards(cards, lookup, groupBy); 65 65 }, [cards, cardMap, groupBy]); ··· 71 71 72 72 // Sort cards within each group 73 73 const sortedGroups = useMemo(() => { 74 - if (!cardMap) return groupedCards; 74 + if (!cardMap) 75 + return new Map( 76 + Array.from(groupedCards.entries(), ([k, v]) => [k, v.cards]), 77 + ); 75 78 const lookup = (card: DeckCard) => cardMap.get(card.scryfallId); 76 79 return new Map( 77 80 sortedGroupNames.map((groupName) => { 78 - const groupCards = groupedCards.get(groupName) ?? []; 79 - return [groupName, sortCards(groupCards, lookup, sortBy)]; 81 + const groupCards = groupedCards.get(groupName) ?? { 82 + cards: [], 83 + forTag: false, 84 + }; 85 + return [groupName, sortCards(groupCards.cards, lookup, sortBy)]; 80 86 }), 81 87 ); 82 88 }, [sortedGroupNames, groupedCards, cardMap, sortBy]); ··· 158 164 key={groupName} 159 165 tagName={groupName} 160 166 section={section} 161 - enabled={groupBy === "tag"} 167 + enabled={groupedCards.get(groupName)?.forTag ?? false} 162 168 isDragging={isDragging} 163 169 > 164 170 <div className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1">
+1 -2
src/components/deck/ViewControls.tsx
··· 15 15 } 16 16 17 17 const GROUP_BY_OPTIONS: Array<{ value: GroupBy; label: string }> = [ 18 - { value: "tag", label: "Tag" }, 18 + { value: "typeAndTags", label: "Type & Tags" }, 19 19 { value: "type", label: "Type" }, 20 - { value: "typeAndTags", label: "Type & Tags" }, 21 20 { value: "subtype", label: "Subtype" }, 22 21 { value: "manaValue", label: "Mana Value" }, 23 22 { value: "colorIdentity", label: "Color Identity" },
+26 -53
src/lib/deck-grouping.test.ts
··· 243 243 const cards = [mockDeckCard("creature-1"), mockDeckCard("instant-1")]; 244 244 const groups = groupCards(cards, lookup, "none"); 245 245 expect(groups.size).toBe(1); 246 - expect(groups.get("all")).toHaveLength(2); 247 - }); 248 - 249 - it("groups by tag", () => { 250 - const cards = [ 251 - mockDeckCard("creature-1", { tags: ["aggro", "tribal"] }), 252 - mockDeckCard("instant-1", { tags: ["removal"] }), 253 - mockDeckCard("land-1", { tags: [] }), 254 - ]; 255 - const groups = groupCards(cards, lookup, "tag"); 256 - 257 - expect(groups.size).toBe(4); 258 - expect(groups.get("aggro")).toHaveLength(1); 259 - expect(groups.get("tribal")).toHaveLength(1); 260 - expect(groups.get("removal")).toHaveLength(1); 261 - expect(groups.get("(No Tags)")).toHaveLength(1); 262 - }); 263 - 264 - it("groups by tag with multi-tag cards appearing in each group", () => { 265 - const cards = [mockDeckCard("creature-1", { tags: ["aggro", "tribal"] })]; 266 - const groups = groupCards(cards, lookup, "tag"); 267 - 268 - expect(groups.size).toBe(2); 269 - expect(groups.get("aggro")).toHaveLength(1); 270 - expect(groups.get("tribal")).toHaveLength(1); 271 - // Same card appears in both groups 272 - expect(groups.get("aggro")?.[0]).toBe(cards[0]); 273 - expect(groups.get("tribal")?.[0]).toBe(cards[0]); 246 + expect(groups.get("all")?.cards).toHaveLength(2); 274 247 }); 275 248 276 249 it("groups by type", () => { ··· 283 256 const groups = groupCards(cards, lookup, "type"); 284 257 285 258 expect(groups.size).toBe(4); 286 - expect(groups.get("Creature")).toHaveLength(1); 287 - expect(groups.get("Instant")).toHaveLength(1); 288 - expect(groups.get("Land")).toHaveLength(1); 289 - expect(groups.get("Artifact")).toHaveLength(1); 259 + expect(groups.get("Creature")?.cards).toHaveLength(1); 260 + expect(groups.get("Instant")?.cards).toHaveLength(1); 261 + expect(groups.get("Land")?.cards).toHaveLength(1); 262 + expect(groups.get("Artifact")?.cards).toHaveLength(1); 290 263 }); 291 264 292 265 it("groups by typeAndTags", () => { ··· 298 271 const groups = groupCards(cards, lookup, "typeAndTags"); 299 272 300 273 expect(groups.size).toBe(3); 301 - expect(groups.get("aggro")).toHaveLength(1); // Tagged creature 302 - expect(groups.get("Instant")).toHaveLength(1); // Untagged instant 303 - expect(groups.get("Land")).toHaveLength(1); // Untagged land 274 + expect(groups.get("aggro")?.cards).toHaveLength(1); // Tagged creature 275 + expect(groups.get("Instant")?.cards).toHaveLength(1); // Untagged instant 276 + expect(groups.get("Land")?.cards).toHaveLength(1); // Untagged land 304 277 }); 305 278 306 279 it("groups by subtype", () => { ··· 313 286 const groups = groupCards(cards, lookup, "subtype"); 314 287 315 288 expect(groups.size).toBe(5); 316 - expect(groups.get("Human")).toHaveLength(1); 317 - expect(groups.get("Warrior")).toHaveLength(1); 318 - expect(groups.get("Forest")).toHaveLength(1); 319 - expect(groups.get("Equipment")).toHaveLength(1); 320 - expect(groups.get("(No Subtype)")).toHaveLength(1); // instant has no subtypes 289 + expect(groups.get("Human")?.cards).toHaveLength(1); 290 + expect(groups.get("Warrior")?.cards).toHaveLength(1); 291 + expect(groups.get("Forest")?.cards).toHaveLength(1); 292 + expect(groups.get("Equipment")?.cards).toHaveLength(1); 293 + expect(groups.get("(No Subtype)")?.cards).toHaveLength(1); // instant has no subtypes 321 294 }); 322 295 323 296 it("groups by subtype with multi-subtype cards appearing in each group", () => { 324 297 const cards = [mockDeckCard("creature-1")]; 325 298 const groups = groupCards(cards, lookup, "subtype"); 326 299 327 - expect(groups.get("Human")).toHaveLength(1); 328 - expect(groups.get("Warrior")).toHaveLength(1); 300 + expect(groups.get("Human")?.cards).toHaveLength(1); 301 + expect(groups.get("Warrior")?.cards).toHaveLength(1); 329 302 // Same card appears in both groups 330 - expect(groups.get("Human")?.[0]).toBe(cards[0]); 331 - expect(groups.get("Warrior")?.[0]).toBe(cards[0]); 303 + expect(groups.get("Human")?.cards[0]).toBe(cards[0]); 304 + expect(groups.get("Warrior")?.cards[0]).toBe(cards[0]); 332 305 }); 333 306 334 307 it("groups by manaValue", () => { ··· 341 314 const groups = groupCards(cards, lookup, "manaValue"); 342 315 343 316 expect(groups.size).toBe(4); 344 - expect(groups.get("0")).toHaveLength(1); 345 - expect(groups.get("1")).toHaveLength(1); 346 - expect(groups.get("2")).toHaveLength(1); 347 - expect(groups.get("3")).toHaveLength(1); 317 + expect(groups.get("0")?.cards).toHaveLength(1); 318 + expect(groups.get("1")?.cards).toHaveLength(1); 319 + expect(groups.get("2")?.cards).toHaveLength(1); 320 + expect(groups.get("3")?.cards).toHaveLength(1); 348 321 }); 349 322 350 323 it("groups by colorIdentity", () => { ··· 357 330 const groups = groupCards(cards, lookup, "colorIdentity"); 358 331 359 332 expect(groups.size).toBe(4); 360 - expect(groups.get("White")).toHaveLength(1); 361 - expect(groups.get("Red")).toHaveLength(1); 362 - expect(groups.get("Green")).toHaveLength(1); 363 - expect(groups.get("Colorless")).toHaveLength(1); 333 + expect(groups.get("White")?.cards).toHaveLength(1); 334 + expect(groups.get("Red")?.cards).toHaveLength(1); 335 + expect(groups.get("Green")?.cards).toHaveLength(1); 336 + expect(groups.get("Colorless")?.cards).toHaveLength(1); 364 337 }); 365 338 }); 366 339 ··· 385 358 386 359 it("sorts special groups to end", () => { 387 360 const names = ["Zombie", "(No Tags)", "Human", "(No Subtype)"]; 388 - const sorted = sortGroupNames(names, "tag"); 361 + const sorted = sortGroupNames(names, "subtype"); 389 362 // Special groups (starting with parentheses) come last, sorted alphabetically among themselves 390 363 expect(sorted).toEqual(["Human", "Zombie", "(No Subtype)", "(No Tags)"]); 391 364 });
+34 -35
src/lib/deck-grouping.ts
··· 202 202 /** 203 203 * Group cards by the specified method 204 204 * Returns a Map of group name → cards in that group 205 + * also includes a bool to indicate if the group is based on a user tag 205 206 * 206 207 * Note: Cards with multiple tags will appear in multiple groups 207 208 */ ··· 209 210 cards: DeckCard[], 210 211 cardLookup: CardLookup, 211 212 groupBy: GroupBy, 212 - ): Map<string, DeckCard[]> { 213 - const groups = new Map<string, DeckCard[]>(); 213 + ): Map< 214 + string, 215 + { 216 + cards: DeckCard[]; 217 + forTag: boolean; 218 + } 219 + > { 220 + const groups = new Map< 221 + string, 222 + { 223 + cards: DeckCard[]; 224 + forTag: boolean; 225 + } 226 + >(); 214 227 215 228 switch (groupBy) { 216 229 case "none": { 217 - groups.set("all", cards); 218 - break; 219 - } 220 - 221 - case "tag": { 222 - for (const card of cards) { 223 - if (!card.tags || card.tags.length === 0) { 224 - const group = groups.get("(No Tags)") ?? []; 225 - group.push(card); 226 - groups.set("(No Tags)", group); 227 - } else { 228 - // Add card to each tag group it belongs to 229 - for (const tag of card.tags) { 230 - const group = groups.get(tag) ?? []; 231 - group.push(card); 232 - groups.set(tag, group); 233 - } 234 - } 235 - } 230 + groups.set("all", { cards, forTag: false }); 236 231 break; 237 232 } 238 233 ··· 240 235 for (const card of cards) { 241 236 const cardData = cardLookup(card); 242 237 const type = extractPrimaryType(cardData?.type_line); 243 - const group = groups.get(type) ?? []; 244 - group.push(card); 238 + const group = groups.get(type) ?? { cards: [], forTag: false }; 239 + group.cards.push(card); 245 240 groups.set(type, group); 246 241 } 247 242 break; ··· 253 248 // No tags → group by type 254 249 const cardData = cardLookup(card); 255 250 const type = extractPrimaryType(cardData?.type_line); 256 - const group = groups.get(type) ?? []; 257 - group.push(card); 251 + const group = groups.get(type) ?? { cards: [], forTag: false }; 252 + group.cards.push(card); 258 253 groups.set(type, group); 259 254 } else { 260 255 // Has tags → add to each tag group 261 256 for (const tag of card.tags) { 262 - const group = groups.get(tag) ?? []; 263 - group.push(card); 257 + const group = groups.get(tag) ?? { cards: [], forTag: true }; 258 + group.forTag = true; 259 + group.cards.push(card); 264 260 groups.set(tag, group); 265 261 } 266 262 } ··· 274 270 const subtypes = extractSubtypes(cardData?.type_line); 275 271 276 272 if (subtypes.length === 0) { 277 - const group = groups.get("(No Subtype)") ?? []; 278 - group.push(card); 273 + const group = groups.get("(No Subtype)") ?? { 274 + cards: [], 275 + forTag: false, 276 + }; 277 + group.cards.push(card); 279 278 groups.set("(No Subtype)", group); 280 279 } else { 281 280 // Add card to each subtype group it belongs to 282 281 for (const subtype of subtypes) { 283 - const group = groups.get(subtype) ?? []; 284 - group.push(card); 282 + const group = groups.get(subtype) ?? { cards: [], forTag: false }; 283 + group.cards.push(card); 285 284 groups.set(subtype, group); 286 285 } 287 286 } ··· 293 292 for (const card of cards) { 294 293 const cardData = cardLookup(card); 295 294 const bucket = getManaValueBucket(cardData?.cmc); 296 - const group = groups.get(bucket) ?? []; 297 - group.push(card); 295 + const group = groups.get(bucket) ?? { cards: [], forTag: false }; 296 + group.cards.push(card); 298 297 groups.set(bucket, group); 299 298 } 300 299 break; ··· 304 303 for (const card of cards) { 305 304 const cardData = cardLookup(card); 306 305 const label = getColorIdentityLabel(cardData?.color_identity); 307 - const group = groups.get(label) ?? []; 308 - group.push(card); 306 + const group = groups.get(label) ?? { cards: [], forTag: false }; 307 + group.cards.push(card); 309 308 groups.set(label, group); 310 309 } 311 310 break;
-1
src/lib/deck-types.ts
··· 21 21 */ 22 22 export type ViewStyle = "text" | "grid" | "stacks"; 23 23 export type GroupBy = 24 - | "tag" 25 24 | "type" 26 25 | "typeAndTags" 27 26 | "subtype"
+10 -44
src/routes/deck/$id.tsx
··· 1 1 import { type DragEndEvent, useDndMonitor } from "@dnd-kit/core"; 2 2 import { useQueryClient } from "@tanstack/react-query"; 3 3 import { createFileRoute } from "@tanstack/react-router"; 4 - import { useEffect, useState } from "react"; 4 + import { useState } from "react"; 5 5 import { toast } from "sonner"; 6 6 import { CardDragOverlay } from "@/components/deck/CardDragOverlay"; 7 7 import { CardModal } from "@/components/deck/CardModal"; ··· 26 26 } from "@/lib/deck-types"; 27 27 import { getCardByIdQueryOptions } from "@/lib/queries"; 28 28 import { asScryfallId, type ScryfallId } from "@/lib/scryfall-types"; 29 + import { usePersistedState } from "@/lib/usePersistedState"; 29 30 30 31 // Test deck card IDs (TODO: remove when ATProto persistence is implemented) 31 32 const TEST_CARD_IDS = [ ··· 48 49 }, 49 50 }); 50 51 51 - const VIEW_CONFIG_KEY = "deckbelcher:viewConfig"; 52 - 53 - interface ViewConfig { 54 - groupBy: GroupBy; 55 - sortBy: SortBy; 56 - } 57 - 58 - function loadViewConfig(): ViewConfig { 59 - try { 60 - const stored = localStorage.getItem(VIEW_CONFIG_KEY); 61 - if (stored) { 62 - return JSON.parse(stored); 63 - } 64 - } catch (_e) { 65 - // Ignore parse errors 66 - } 67 - return { groupBy: "tag", sortBy: "name" }; 68 - } 69 - 70 - function saveViewConfig(config: ViewConfig): void { 71 - try { 72 - localStorage.setItem(VIEW_CONFIG_KEY, JSON.stringify(config)); 73 - } catch (_e) { 74 - // Ignore storage errors 75 - } 76 - } 77 - 78 52 function DeckEditorPage() { 79 - // View configuration with localStorage persistence 80 - // Start with defaults to avoid SSR hydration mismatch 81 - const [groupBy, setGroupBy] = useState<GroupBy>("tag"); 82 - const [sortBy, setSortBy] = useState<SortBy>("name"); 83 - 84 - // Load from localStorage after mount (client-only) 85 - useEffect(() => { 86 - const config = loadViewConfig(); 87 - setGroupBy(config.groupBy); 88 - setSortBy(config.sortBy); 89 - }, []); 53 + const [groupBy, setGroupBy] = usePersistedState<GroupBy>( 54 + "deckbelcher:viewConfig:groupBy", 55 + "typeAndTags", 56 + ); 57 + const [sortBy, setSortBy] = usePersistedState<SortBy>( 58 + "deckbelcher:viewConfig:sortBy", 59 + "name", 60 + ); 90 61 91 62 // Initialize deck with some test data 92 63 const [deck, setDeck] = useState<Deck>(() => { ··· 140 111 const [isDragging, setIsDragging] = useState(false); 141 112 142 113 const queryClient = useQueryClient(); 143 - 144 - // Save view config to localStorage when it changes 145 - useEffect(() => { 146 - saveViewConfig({ groupBy, sortBy }); 147 - }, [groupBy, sortBy]); 148 114 149 115 const handleCardHover = (cardId: ScryfallId | null) => { 150 116 // Only update preview if we have a card (persistence - don't clear on null)
+5 -5
src/workers/__tests__/cards.worker.test.ts
··· 13 13 }, 20_000); 14 14 15 15 it("has the same results with and without space", () => { 16 - const resultsA = worker.searchCards("mark") 17 - const resultsB = worker.searchCards("mark ") 16 + const resultsA = worker.searchCards("mark"); 17 + const resultsB = worker.searchCards("mark "); 18 18 19 - expect(resultsA).toEqual(resultsB) 20 - }) 21 - 19 + expect(resultsA).toEqual(resultsB); 20 + }); 21 + 22 22 describe("restrictions", () => { 23 23 it("applies format legality restriction", () => { 24 24 // Search for a card that's banned in some formats