Skip to content

Commit b5e893c

Browse files
committed
feat: filter collection by purchase price, added date, and multiple sets
Add paid:, added:, is:paid and is:unpaid collection search predicates, a multi-set picker in the filter modal, and shift+click range selection in the collection grid.
1 parent a36f9e3 commit b5e893c

14 files changed

Lines changed: 558 additions & 60 deletions

File tree

‎assets/react/src/components/card-tile.tsx‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,8 @@ export function CardTile({
120120
name: ReactNode
121121
onSelect?: () => void
122122
onToggleForTrade?: () => void
123-
onToggleSelected?: () => void
123+
// selectRange is true for shift+click, letting the owner select a span of tiles.
124+
onToggleSelected?: (selectRange?: boolean) => void
124125
primaryActionLabel?: string
125126
primaryActionRole?: "button" | "link"
126127
price?: ReactNode
@@ -203,20 +204,26 @@ export function CardTile({
203204
if (isInteractiveClickTarget(event.target)) return
204205

205206
if (selectionClickActive) {
206-
onToggleSelected?.()
207+
onToggleSelected?.(event.shiftKey)
207208
return
208209
}
209210

210211
onSelect?.()
211212
}
212213

214+
// Shift+mousedown would otherwise extend the page's text selection across
215+
// every tile in the range being selected.
216+
function handleMouseDown(event: MouseEvent<HTMLDivElement>) {
217+
if (event.shiftKey && selectionClickActive) event.preventDefault()
218+
}
219+
213220
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
214221
if (event.key !== "Enter" && event.key !== " ") return
215222
if (isInteractiveClickTarget(event.target)) return
216223

217224
if (selectionClickActive) {
218225
event.preventDefault()
219-
onToggleSelected?.()
226+
onToggleSelected?.(event.shiftKey)
220227
return
221228
}
222229

@@ -243,6 +250,7 @@ export function CardTile({
243250
onBlur={handleBlur}
244251
onClick={hasPrimaryAction ? handleClick : undefined}
245252
onKeyDown={handleKeyDown}
253+
onMouseDown={handleMouseDown}
246254
onPointerCancel={mobileHover.onPointerCancel}
247255
onPointerDown={mobileHover.onPointerDown}
248256
onPointerMove={mobileHover.onPointerMove}
@@ -318,10 +326,13 @@ export function CardTile({
318326
aria-pressed={selected}
319327
onClick={(event) => {
320328
event.stopPropagation()
321-
onToggleSelected?.()
329+
onToggleSelected?.(event.shiftKey)
322330
}}
323331
onKeyDown={(event) => event.stopPropagation()}
324-
onMouseDown={(event) => event.stopPropagation()}
332+
onMouseDown={(event) => {
333+
event.stopPropagation()
334+
if (event.shiftKey) event.preventDefault()
335+
}}
325336
>
326337
{selected ? <CheckSquare /> : <Square />}
327338
</CardTileOverlayButton>

‎assets/react/src/lib/collection-filters.ts‎

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,18 @@ export type CollectionFilterState = {
1616
manaValueOperator: ComparisonOperator
1717
manaValue: string
1818
rarities: RarityFilter[]
19-
set: string
19+
sets: string[]
2020
collectorOperator: ComparisonOperator
2121
collectorNumber: string
2222
language: string
2323
finish: FinishFilter
2424
allocation: AllocationFilter
2525
quantityOperator: ComparisonOperator
2626
quantity: string
27+
purchasePriceMin: string
28+
purchasePriceMax: string
29+
addedFrom: string
30+
addedTo: string
2731
priceOperator: ComparisonOperator
2832
priceUsd: string
2933
dateOperator: ComparisonOperator
@@ -50,14 +54,18 @@ export const EMPTY_COLLECTION_FILTERS: CollectionFilterState = {
5054
manaValueOperator: "=",
5155
manaValue: "",
5256
rarities: [],
53-
set: "",
57+
sets: [],
5458
collectorOperator: "=",
5559
collectorNumber: "",
5660
language: "",
5761
finish: "any",
5862
allocation: "any",
5963
quantityOperator: ">=",
6064
quantity: "",
65+
purchasePriceMin: "",
66+
purchasePriceMax: "",
67+
addedFrom: "",
68+
addedTo: "",
6169
priceOperator: ">=",
6270
priceUsd: "",
6371
dateOperator: ">=",
@@ -75,12 +83,16 @@ export function buildCollectionFilterQuery(filters: CollectionFilterState) {
7583
colorPredicate("id", filters.identityOperator, filters.identity),
7684
comparisonPredicate("mv", filters.manaValueOperator, filters.manaValue),
7785
rarityPredicate(filters.rarities),
78-
textPredicate("set", filters.set),
86+
setPredicate(filters.sets),
7987
comparisonPredicate("number", filters.collectorOperator, filters.collectorNumber),
8088
textPredicate("lang", filters.language),
8189
filters.finish === "any" ? "" : `is:${filters.finish}`,
8290
filters.allocation === "any" ? "" : `is:${filters.allocation}`,
8391
comparisonPredicate("qty", filters.quantityOperator, filters.quantity),
92+
comparisonPredicate("paid", ">=", filters.purchasePriceMin),
93+
comparisonPredicate("paid", "<=", filters.purchasePriceMax),
94+
comparisonPredicate("added", ">=", filters.addedFrom),
95+
comparisonPredicate("added", "<=", filters.addedTo),
8496
comparisonPredicate("usd", filters.priceOperator, filters.priceUsd),
8597
comparisonPredicate("date", filters.dateOperator, filters.releasedDate),
8698
comparisonPredicate("year", filters.yearOperator, filters.releasedYear),
@@ -106,12 +118,14 @@ export function countActiveCollectionFilters(filters: CollectionFilterState) {
106118
filters.identity.length,
107119
filters.manaValue.trim(),
108120
filters.rarities.length,
109-
filters.set.trim(),
121+
filters.sets.length,
110122
filters.collectorNumber.trim(),
111123
filters.language.trim(),
112124
filters.finish !== "any",
113125
filters.allocation !== "any",
114126
filters.quantity.trim(),
127+
filters.purchasePriceMin.trim() || filters.purchasePriceMax.trim(),
128+
filters.addedFrom.trim() || filters.addedTo.trim(),
115129
filters.priceUsd.trim(),
116130
filters.releasedDate.trim(),
117131
filters.releasedYear.trim(),
@@ -124,6 +138,7 @@ export function cloneCollectionFilters(filters: CollectionFilterState): Collecti
124138
colors: [...filters.colors],
125139
identity: [...filters.identity],
126140
rarities: [...filters.rarities],
141+
sets: [...filters.sets],
127142
}
128143
}
129144

@@ -152,7 +167,7 @@ export function encodeCollectionFilters(filters: CollectionFilterState) {
152167
? filters.manaValueOperator
153168
: undefined,
154169
rarities: filters.rarities.length ? filters.rarities : undefined,
155-
set: trimmedValue(filters.set),
170+
sets: filters.sets.length ? filters.sets : undefined,
156171
collectorNumber: trimmedValue(filters.collectorNumber),
157172
collectorOperator:
158173
filters.collectorNumber.trim() &&
@@ -169,6 +184,10 @@ export function encodeCollectionFilters(filters: CollectionFilterState) {
169184
filters.quantityOperator !== EMPTY_COLLECTION_FILTERS.quantityOperator
170185
? filters.quantityOperator
171186
: undefined,
187+
purchasePriceMin: trimmedValue(filters.purchasePriceMin),
188+
purchasePriceMax: trimmedValue(filters.purchasePriceMax),
189+
addedFrom: trimmedValue(filters.addedFrom),
190+
addedTo: trimmedValue(filters.addedTo),
172191
priceUsd: trimmedValue(filters.priceUsd),
173192
priceOperator:
174193
filters.priceUsd.trim() && filters.priceOperator !== EMPTY_COLLECTION_FILTERS.priceOperator
@@ -222,7 +241,10 @@ export function decodeCollectionFilters(value: unknown): CollectionFilterState {
222241
filters.manaValueOperator,
223242
)
224243
filters.rarities = filteredValues(decoded.rarities, RARITY_FILTERS)
225-
filters.set = stringValue(decoded.set)
244+
// Older saved filters stored a single `set` string.
245+
filters.sets = uniqueSetValues(
246+
Array.isArray(decoded.sets) ? decoded.sets : [stringValue(decoded.set)],
247+
)
226248
filters.collectorNumber = stringValue(decoded.collectorNumber)
227249
filters.collectorOperator = operatorValue(
228250
decoded.collectorOperator,
@@ -238,6 +260,10 @@ export function decodeCollectionFilters(value: unknown): CollectionFilterState {
238260
COMPARISON_OPERATORS,
239261
filters.quantityOperator,
240262
)
263+
filters.purchasePriceMin = stringValue(decoded.purchasePriceMin)
264+
filters.purchasePriceMax = stringValue(decoded.purchasePriceMax)
265+
filters.addedFrom = stringValue(decoded.addedFrom)
266+
filters.addedTo = stringValue(decoded.addedTo)
241267
filters.priceUsd = stringValue(decoded.priceUsd)
242268
filters.priceOperator = operatorValue(
243269
decoded.priceOperator,
@@ -304,6 +330,27 @@ function colorPredicate(field: "c" | "id", operator: ColorOperator, colors: Mana
304330
return `${field}${operator}${colors.join("")}`
305331
}
306332

333+
/** Trims, drops blanks, and removes case-insensitive duplicates, keeping the first spelling. */
334+
export function uniqueSetValues(values: readonly unknown[]) {
335+
const seen = new Set<string>()
336+
const sets: string[] = []
337+
for (const value of values) {
338+
const set = stringValue(value)
339+
const key = set.toLowerCase()
340+
if (!set || seen.has(key)) continue
341+
seen.add(key)
342+
sets.push(set)
343+
}
344+
return sets
345+
}
346+
347+
function setPredicate(sets: string[]) {
348+
const terms = sets.map((set) => textPredicate("set", set)).filter(Boolean)
349+
if (!terms.length) return ""
350+
351+
return terms.length === 1 ? terms[0] : `(${terms.join(" or ")})`
352+
}
353+
307354
function rarityPredicate(rarities: RarityFilter[]) {
308355
if (!rarities.length) return ""
309356

‎assets/react/src/pages/cards/card-catalog-page.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export function CardCatalogPage({
8888
)}
8989

9090
<CollectionFilterModal
91+
copyFilters={false}
9192
filters={search.structuredFilters}
9293
open={search.isFilterModalOpen}
9394
onApply={search.applyFilters}

‎assets/react/src/pages/collection/filter-modal.tsx‎

Lines changed: 123 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,16 @@ import { createEmptyCollectionFilters } from "./storage"
4242
import { SetCombobox } from "./set-combobox"
4343

4444
export function CollectionFilterModal({
45+
copyFilters = true,
4546
filters,
4647
onApply,
4748
onClear,
4849
onClose,
4950
open,
5051
}: {
52+
// Purchase price and added date describe owned copies; the card catalog
53+
// searches cards, not copies, so it hides them.
54+
copyFilters?: boolean
5155
filters: CollectionFilterState
5256
onApply: (filters: CollectionFilterState) => void
5357
onClear: () => void
@@ -131,8 +135,8 @@ export function CollectionFilterModal({
131135
/>
132136
</FilterSection>
133137

134-
<FilterSection label="Set" syntax="set:lea">
135-
<SetCombobox value={draft.set} onValueChange={(value) => update("set", value)} />
138+
<FilterSection label="Sets" syntax="(set:lea or set:leb)">
139+
<SetCombobox values={draft.sets} onValuesChange={(sets) => update("sets", sets)} />
136140
</FilterSection>
137141

138142
<FilterSection label="Finish" syntax="is:foil">
@@ -170,6 +174,62 @@ export function CollectionFilterModal({
170174
/>
171175
</FilterSection>
172176

177+
{copyFilters ? (
178+
<>
179+
<FilterSection label="Purchase price" syntax="paid>=1 paid<=20">
180+
<RangeFilterControl
181+
fromLabel="Minimum paid"
182+
inputMode="decimal"
183+
from={draft.purchasePriceMin}
184+
placeholder={["Min $", "Max $"]}
185+
to={draft.purchasePriceMax}
186+
toLabel="Maximum paid"
187+
onFromChange={(value) => update("purchasePriceMin", value)}
188+
onToChange={(value) => update("purchasePriceMax", value)}
189+
/>
190+
</FilterSection>
191+
192+
<FilterSection label="Added" syntax="added>=2026-09-01">
193+
<div className="grid gap-3">
194+
<RangeFilterControl
195+
fromLabel="Added on or after"
196+
from={draft.addedFrom}
197+
to={draft.addedTo}
198+
toLabel="Added on or before"
199+
type="date"
200+
onFromChange={(value) => update("addedFrom", value)}
201+
onToChange={(value) => update("addedTo", value)}
202+
/>
203+
<div className="flex flex-wrap gap-2">
204+
{ADDED_DATE_PRESETS.map((preset) => {
205+
const from = preset.from()
206+
const active = draft.addedFrom === from && !draft.addedTo
207+
208+
return (
209+
<button
210+
key={preset.label}
211+
type="button"
212+
aria-pressed={active}
213+
className={cn(
214+
"btn btn-outline btn-sm",
215+
active
216+
? "border-primary bg-primary/15 text-primary"
217+
: "text-base-content/75",
218+
)}
219+
onClick={() =>
220+
setDraft((current) => ({ ...current, addedFrom: from, addedTo: "" }))
221+
}
222+
>
223+
{preset.label}
224+
</button>
225+
)
226+
})}
227+
</div>
228+
</div>
229+
</FilterSection>
230+
</>
231+
) : null}
232+
173233
<details className="group">
174234
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 bg-base-200/40 px-5 py-4 marker:hidden">
175235
<span>
@@ -439,6 +499,67 @@ function ComparisonFilterControl({
439499
)
440500
}
441501

502+
// Presets resolve to a fixed start date when chosen, so saved filters keep
503+
// meaning the same window rather than silently sliding forward.
504+
const ADDED_DATE_PRESETS = [
505+
{ label: "Last 7 days", from: () => localDateDaysAgo(7) },
506+
{ label: "Last 30 days", from: () => localDateDaysAgo(30) },
507+
{ label: "Last 90 days", from: () => localDateDaysAgo(90) },
508+
{ label: "This year", from: () => `${new Date().getFullYear()}-01-01` },
509+
]
510+
511+
function localDateDaysAgo(days: number) {
512+
const date = new Date()
513+
date.setDate(date.getDate() - days)
514+
const month = String(date.getMonth() + 1).padStart(2, "0")
515+
const day = String(date.getDate()).padStart(2, "0")
516+
return `${date.getFullYear()}-${month}-${day}`
517+
}
518+
519+
function RangeFilterControl({
520+
from,
521+
fromLabel,
522+
inputMode,
523+
onFromChange,
524+
onToChange,
525+
placeholder = ["From", "To"],
526+
to,
527+
toLabel,
528+
type = "text",
529+
}: {
530+
from: string
531+
fromLabel: string
532+
inputMode?: React.HTMLAttributes<HTMLInputElement>["inputMode"]
533+
onFromChange: (value: string) => void
534+
onToChange: (value: string) => void
535+
placeholder?: [string, string]
536+
to: string
537+
toLabel: string
538+
type?: string
539+
}) {
540+
return (
541+
<div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2">
542+
<Input
543+
aria-label={fromLabel}
544+
inputMode={inputMode}
545+
type={type}
546+
value={from}
547+
onChange={(event) => onFromChange(event.target.value)}
548+
placeholder={placeholder[0]}
549+
/>
550+
<span className="text-sm text-base-content/50">to</span>
551+
<Input
552+
aria-label={toLabel}
553+
inputMode={inputMode}
554+
type={type}
555+
value={to}
556+
onChange={(event) => onToChange(event.target.value)}
557+
placeholder={placeholder[1]}
558+
/>
559+
</div>
560+
)
561+
}
562+
442563
function ColorFilterControl({
443564
onOperatorChange,
444565
onSelectedChange,

0 commit comments

Comments
 (0)