Rework library sync around a JSON document; add card filters

Sync

The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.

Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.

Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.

SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.

Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.

Filters

Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.

Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.

The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.

Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
This commit is contained in:
2026-08-07 22:03:49 -05:00
parent 663af32562
commit 04e9c3cbbb
21 changed files with 3370 additions and 100 deletions

View File

@@ -8,7 +8,16 @@ import {
useRef,
useState,
} from "react";
import { api, CardRef, COLORS, LEGALITY_FORMATS, legalStatusFor, maskToLetters, PreviewCard } from "./api";
import {
api,
CardRef,
COLORS,
LEGALITY_FORMATS,
LibraryFilter,
legalStatusFor,
maskToLetters,
PreviewCard,
} from "./api";
// --- Icons ------------------------------------------------------------
@@ -1386,3 +1395,296 @@ export function ColorToggles({
</span>
);
}
// --- Shared card criteria -------------------------------------------------
/** Ringed planet — a card from another world/property (Universes Beyond). */
const ICON_CROSSOVER = icon(
<>
<circle cx="12" cy="11" r="6" />
<ellipse cx="12" cy="11" rx="11" ry="4" transform="rotate(-20 12 11)" />
</>,
);
/** Monitor — exists only on Arena/MTGO. */
const ICON_SCREEN = icon(
<>
<rect x="3" y="4" width="18" height="12" rx="2" />
<path d="M9 20h6" />
<path d="M12 16v4" />
</>,
);
/** Smiley — the joke sets. */
const ICON_SMILEY = icon(
<>
<circle cx="12" cy="12" r="9" />
<path d="M8.5 14.5a4.5 4.5 0 0 0 7 0" />
<line x1="9" y1="9.5" x2="9.01" y2="9.5" />
<line x1="15" y1="9.5" x2="15.01" y2="9.5" />
</>,
);
/** Crown — can head a Commander deck. */
const ICON_CROWN = icon(
<>
<path d="M3 8l3.5 3L12 5l5.5 6L21 8l-1.5 10h-15z" />
<line x1="5" y1="21" x2="19" y2="21" />
</>,
);
/**
* Card-origin and numeric criteria shared by the Library and Card Search
* filter panels. Kept here rather than duplicated in both views, since they
* must stay in step: a filter that behaves differently between browsing what
* you own and searching everything would be a bug, not a feature.
*
* Numeric fields are strings so the inputs can be empty (meaning "no bound")
* rather than coercing a blank to 0.
*/
export type CardCriteria = {
excludeUb: boolean;
excludeDigital: boolean;
excludeFunny: boolean;
priceMin: string;
priceMax: string;
legalIn: string;
canBeCommander: boolean;
powerMin: string;
powerMax: string;
toughMin: string;
toughMax: string;
};
export const EMPTY_CRITERIA: CardCriteria = {
excludeUb: false,
excludeDigital: false,
excludeFunny: false,
priceMin: "",
priceMax: "",
legalIn: "",
canBeCommander: false,
powerMin: "",
powerMax: "",
toughMin: "",
toughMax: "",
};
export function useCardCriteria(key: string): [CardCriteria, (patch: Partial<CardCriteria>) => void] {
const [value, setValue] = usePersistedState<CardCriteria>(key, EMPTY_CRITERIA);
// Merged over the defaults so a stored object written before a field existed
// doesn't leave it undefined.
const merged = { ...EMPTY_CRITERIA, ...value };
const patch = useCallback(
(p: Partial<CardCriteria>) => setValue({ ...EMPTY_CRITERIA, ...value, ...p }),
[setValue, value],
);
return [merged, patch];
}
const num = (s: string): number | null => (s.trim() === "" ? null : Number(s));
/** Fold criteria into the filter sent to the backend. */
export function applyCardCriteria(c: CardCriteria): Partial<LibraryFilter> {
return {
exclude_universes_beyond: c.excludeUb,
exclude_digital_only: c.excludeDigital,
exclude_funny: c.excludeFunny,
price_min: num(c.priceMin),
price_max: num(c.priceMax),
legal_in: c.legalIn || null,
can_be_commander: c.canBeCommander,
power_min: num(c.powerMin),
power_max: num(c.powerMax),
toughness_min: num(c.toughMin),
toughness_max: num(c.toughMax),
};
}
/**
* Whether these criteria amount to a search in their own right.
*
* The three "exclude" toggles deliberately don't count: they refine a result
* set rather than select one, so ticking "hide Un-sets" on its own shouldn't
* make Card Search pull every card in the database. Choosing a format or a
* power range is a real query, and does.
*/
export function criteriaActive(c: CardCriteria): boolean {
return (
c.canBeCommander ||
c.legalIn !== "" ||
[c.priceMin, c.priceMax, c.powerMin, c.powerMax, c.toughMin, c.toughMax].some(
(v) => v.trim() !== "",
)
);
}
function RangePair({
label,
min,
max,
onMin,
onMax,
step,
prefix,
}: {
label: string;
min: string;
max: string;
onMin: (v: string) => void;
onMax: (v: string) => void;
step?: string;
prefix?: string;
}) {
return (
<div className="field">
<label>{label}</label>
<div className="row">
{prefix && <span className="muted">{prefix}</span>}
<input
type="number"
step={step}
style={{ width: 70 }}
placeholder="min"
value={min}
onChange={(e) => onMin(e.target.value)}
/>
<span className="muted">–</span>
{prefix && <span className="muted">{prefix}</span>}
<input
type="number"
step={step}
style={{ width: 70 }}
placeholder="max"
value={max}
onChange={(e) => onMax(e.target.value)}
/>
</div>
</div>
);
}
export function CardCriteriaFields({
value: c,
onChange,
}: {
value: CardCriteria;
onChange: (patch: Partial<CardCriteria>) => void;
}) {
return (
<>
<div className="field">
<label>Legal in</label>
<select value={c.legalIn} onChange={(e) => onChange({ legalIn: e.target.value })}>
<option value="">Any format</option>
{LEGALITY_FORMATS.map((f) => (
<option key={f.key} value={f.key}>
{f.label}
</option>
))}
</select>
</div>
<RangePair
label="Price (USD)"
prefix="$"
step="0.01"
min={c.priceMin}
max={c.priceMax}
onMin={(v) => onChange({ priceMin: v })}
onMax={(v) => onChange({ priceMax: v })}
/>
<RangePair
label="Power"
min={c.powerMin}
max={c.powerMax}
onMin={(v) => onChange({ powerMin: v })}
onMax={(v) => onChange({ powerMax: v })}
/>
<RangePair
label="Toughness"
min={c.toughMin}
max={c.toughMax}
onMin={(v) => onChange({ toughMin: v })}
onMax={(v) => onChange({ toughMax: v })}
/>
<div className="field">
<label>Quick filters</label>
<div className="icon-toggles">
{/* Exclusions read as "no X" — tinted with the danger colour when
active, so removing things looks different from selecting them. */}
<IconToggle
icon={ICON_CROSSOVER}
label="Hide Universes Beyond"
hint="Cards that only exist as another property — Lord of the Rings, Warhammer 40,000, Marvel. Normal cards reprinted in those sets stay."
exclude
on={c.excludeUb}
onToggle={() => onChange({ excludeUb: !c.excludeUb })}
/>
<IconToggle
icon={ICON_SCREEN}
label="Hide digital-only"
hint="Arena and MTGO cards such as Alchemy rebalances, which don't exist on paper."
exclude
on={c.excludeDigital}
onToggle={() => onChange({ excludeDigital: !c.excludeDigital })}
/>
<IconToggle
icon={ICON_SMILEY}
label="Hide Un-set cards"
hint="Unglued, Unhinged, Unstable and Unfinity joke cards."
exclude
on={c.excludeFunny}
onToggle={() => onChange({ excludeFunny: !c.excludeFunny })}
/>
<span className="icon-toggles-sep" />
<IconToggle
icon={ICON_CROWN}
label="Only cards that can be a commander"
hint="Legendary creatures, plus cards whose rules text says they can be your commander."
on={c.canBeCommander}
onToggle={() => onChange({ canBeCommander: !c.canBeCommander })}
/>
</div>
</div>
</>
);
}
/**
* A single icon filter toggle, styled like [`ColorToggles`]' pips: dim when
* off, lit with a ring when on. `exclude` marks a filter that *removes* cards
* rather than selecting them, which is worth distinguishing visually since
* both live in the same row.
*/
function IconToggle({
icon,
label,
hint,
on,
exclude,
onToggle,
}: {
icon: ReactNode;
label: string;
hint: string;
on: boolean;
exclude?: boolean;
onToggle: () => void;
}) {
return (
<button
type="button"
className={`icon-toggle ${exclude ? "exclude" : ""} ${on ? "on" : ""}`}
// Title carries the full explanation the old inline hint text did — the
// whole point of this row is to stop that prose eating the panel.
title={`${label}\n\n${hint}`}
aria-label={label}
aria-pressed={on}
onClick={onToggle}
>
{icon}
{/* A slash over the glyph when an exclusion is active, so "hidden" is
legible without reading the tooltip. */}
{exclude && on && <span className="icon-toggle-slash" />}
</button>
);
}