Appearance
"Not 100% Synced" Set Filter Implementation Plan β
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Let merchants filter the Sets list to only sets that are not 100% synced, and show an approximate "% synced" per set, across all three games.
Architecture: Sync completion is derived from Shopify smart-collection product counts (there is no per-store sync record in Mongo). A shared getSyncedSetCounts() reads each set collection's productsCount; a pure syncCompletionService compares those counts to each set's catalog card count. GET /api/sets (MTG) and GET /api/catalog/sets (Pokemon, Riftbound) enrich each set with syncedCount/syncedPercent and, when onlyIncomplete=true, exclude fully-synced sets in the aggregation. The client adds a checkbox and a % badge, replacing its separate /synced-sets fetch.
Tech Stack: Node.js/Express (CommonJS), Mongoose aggregation, Shopify Admin GraphQL, Zod, Vitest (ESM tests), React 18.
Global Constraints β
- CommonJS in
server/; test files use ESM (import). - Aggregations:
$project/$matchmust precede$sort; never rely onallowDiskUsefor correctness (Β§5.6). - Never uppercase non-MTG set identifiers. MTG codes are uppercase; Pokemon/Riftbound set identifiers are case-preserving slugs. Uppercasing them breaks the catalogβcollection join and silently blanks the data (Β§5.3/Β§5.4). Verify the exact metafield-condition casing against a real smart-collection document before trusting the join.
- Per-game parity (Β§5.1): any per-game field/behavior is mirrored across mtg/pokemon/riftbound or explicitly exempted, and the PR summary names all three.
- Server enforces; the client checkbox is a UX mirror and must reference the server rule in a comment (Β§5.12).
- Best-effort degradation: if the Shopify collection query fails, the filter is a no-op and badges hide β never 500.
- Commit messages: one imperative sentence, merchant-visible outcome, sentence case, no trailing period. End every commit with:
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> - Design reference:
docs/superpowers/specs/2026-07-23-sync-visibility-design.md.
File Structure β
server/services/shopifyAPI.jsβ addgetSyncedSetCounts(), refactorgetSyncedSetCodes()to share a private rule-fetch (Modify).server/services/shopifyAPI.test.jsβ tests for the new method (Modify).server/services/syncCompletionService.jsβ pure completion math (Create).server/services/syncCompletionService.test.jsβ unit tests (Create).server/services/syncedSetCountsCache.jsβ per-shop counts cache (Create).server/queues/processors/syncProcessor.jsβ invalidate the new cache alongside the existing one (Modify, ~line 266).server/schemas/catalog.jsβ addonlyIncompletetosetsQuerySchema+catalogSetsQuerySchema(Modify).server/schemas/schemas.test.jsβ schema validation tests (Modify).server/routes/api.jsβ a sharedloadSyncCompletion(...)helper; wireGET /api/setsandGET /api/catalog/sets(Modify).client/src/components/SetSelector.jsxβ checkbox,%badge, drop the/synced-setsfetch (Modify).client/src/components/SetSelector.test.jsxβ tests (Modify).
Task 1: getSyncedSetCounts() on ShopifyAPI β
Files:
- Modify:
server/services/shopifyAPI.js(thegetSyncedSetCodes()method, ~line 2967) - Modify:
server/services/shopifyAPI.test.js
Interfaces:
Produces:
async getSyncedSetCounts() β { [setCode]: productCount }(case-preserving keys, summed if a code appears in multiple rules).Preserves:
async getSyncedSetCodes() β string[](uppercased, deduped β unchanged output).[ ] Step 1: Write the failing test
Add to server/services/shopifyAPI.test.js (mirrors the existing api.graphQL = graphQLSpy stub pattern):
js
describe('ShopifyAPI.getSyncedSetCounts', () => {
let api;
beforeEach(() => {
api = new ShopifyAPI('test-shop.myshopify.com', 'token');
});
it('maps each set_code metafield rule to its collection product count, preserving case', async () => {
api.graphQL = vi.fn().mockResolvedValue({
collections: {
edges: [
{ cursor: 'c1', node: { productsCount: { count: 120 }, ruleSet: { rules: [
{ column: 'PRODUCT_METAFIELD_DEFINITION', condition: 'BLB' }] } } },
{ cursor: 'c2', node: { productsCount: { count: 68 }, ruleSet: { rules: [
{ column: 'PRODUCT_METAFIELD_DEFINITION', condition: 'sv3pt5' }] } } },
{ cursor: 'c3', node: { productsCount: { count: 5 }, ruleSet: { rules: [
{ column: 'TAG', condition: 'ignore-me' }] } } },
],
pageInfo: { hasNextPage: false },
},
});
const counts = await api.getSyncedSetCounts();
expect(counts).toEqual({ BLB: 120, sv3pt5: 68 });
expect(counts).not.toHaveProperty('ignore-me');
});
it('getSyncedSetCodes still returns uppercased, deduped codes', async () => {
api.graphQL = vi.fn().mockResolvedValue({
collections: {
edges: [{ cursor: 'c1', node: { productsCount: { count: 1 }, ruleSet: { rules: [
{ column: 'PRODUCT_METAFIELD_DEFINITION', condition: 'sv3pt5' }] } } }],
pageInfo: { hasNextPage: false },
},
});
expect(await api.getSyncedSetCodes()).toEqual(['SV3PT5']);
});
});- [ ] Step 2: Run the test to verify it fails
Run: npm test -- server/services/shopifyAPI.test.js Expected: FAIL β getSyncedSetCounts is not a function.
- [ ] Step 3: Refactor to share a private fetch, add the new method
In server/services/shopifyAPI.js, replace the existing getSyncedSetCodes() method with the following three methods (the GraphQL query gains productsCount { count }):
js
// Fetches every smart-collection rule that targets the set_code metafield,
// paired with that collection's product count. Shared by getSyncedSetCodes
// (which uppercases for MTG back-compat) and getSyncedSetCounts (which keeps
// the raw case β Pokemon/Riftbound slugs must not be uppercased, Β§5.3).
async _fetchSmartCollectionSetRules() {
const out = [];
let cursor = null;
let hasNextPage = true;
const query = `
query getSmartCollections($first: Int!, $after: String) {
collections(first: $first, after: $after, query: "collection_type:smart") {
edges {
cursor
node {
productsCount { count }
ruleSet { rules { column condition } }
}
}
pageInfo { hasNextPage }
}
}
`;
while (hasNextPage) {
const data = await this.graphQL(query, { first: 250, after: cursor });
const edges = data.collections.edges;
for (const edge of edges) {
const productCount = edge.node.productsCount?.count || 0;
const rules = edge.node.ruleSet?.rules || [];
for (const rule of rules) {
if (rule.column === 'PRODUCT_METAFIELD_DEFINITION' && rule.condition) {
out.push({ condition: rule.condition, productCount });
}
}
cursor = edge.cursor;
}
hasNextPage = data.collections.pageInfo.hasNextPage;
}
return out;
}
// Set codes the merchant has started syncing (has a collection for).
// Uppercased + deduped β unchanged contract for the /synced-sets consumer.
async getSyncedSetCodes() {
const rules = await this._fetchSmartCollectionSetRules();
return [...new Set(rules.map((r) => r.condition.toUpperCase()))];
}
// Per-set synced product count, keyed by the raw metafield condition
// (case-preserving, so keys join to the catalog `code` for every game).
async getSyncedSetCounts() {
const counts = {};
for (const { condition, productCount } of await this._fetchSmartCollectionSetRules()) {
counts[condition] = (counts[condition] || 0) + productCount;
}
return counts;
}- [ ] Step 4: Run the test to verify it passes
Run: npm test -- server/services/shopifyAPI.test.js Expected: PASS.
- [ ] Step 5: Commit
bash
git add server/services/shopifyAPI.js server/services/shopifyAPI.test.js
git commit -m "Read per-set synced product counts from Shopify collections"Task 2: Completion math + counts cache β
Files:
- Create:
server/services/syncCompletionService.js - Create:
server/services/syncCompletionService.test.js - Create:
server/services/syncedSetCountsCache.js
Interfaces:
Produces:
syncedPercent(count, expected) β number(0β100, integer).Produces:
computeFullySyncedCodes(countsByCode, expectedByCode) β string[]β codes whereexpected > 0 && count >= expected.Produces:
syncedSetCountsCache = { get(shop), set(shop, counts), invalidate(shop), TTL_MS }.[ ] Step 1: Write the failing test
Create server/services/syncCompletionService.test.js:
js
import { describe, it, expect } from 'vitest';
import { syncedPercent, computeFullySyncedCodes } from './syncCompletionService.js';
describe('syncedPercent', () => {
it('rounds count/expected to a 0-100 integer', () => {
expect(syncedPercent(50, 100)).toBe(50);
expect(syncedPercent(1, 3)).toBe(33);
});
it('caps at 100 when a collection has extra products (promos/tokens)', () => {
expect(syncedPercent(110, 100)).toBe(100);
});
it('treats an unknown/zero expected as 100 when synced, else 0', () => {
expect(syncedPercent(5, 0)).toBe(100);
expect(syncedPercent(0, 0)).toBe(0);
});
});
describe('computeFullySyncedCodes', () => {
it('returns only codes whose count meets or exceeds a positive expected', () => {
const counts = { BLB: 300, MH3: 100, OTJ: 0 };
const expected = { BLB: 300, MH3: 250, OTJ: 200 };
expect(computeFullySyncedCodes(counts, expected).sort()).toEqual(['BLB']);
});
it('never marks a code fully synced when its expected is unknown or zero', () => {
expect(computeFullySyncedCodes({ X: 10 }, {})).toEqual([]);
expect(computeFullySyncedCodes({ X: 10 }, { X: 0 })).toEqual([]);
});
});- [ ] Step 2: Run the test to verify it fails
Run: npm test -- server/services/syncCompletionService.test.js Expected: FAIL β module not found.
- [ ] Step 3: Implement the service and cache
Create server/services/syncCompletionService.js:
js
/**
* Approximate set-sync completion, derived from Shopify smart-collection
* product counts vs. the catalog's expected card count. Approximate by design:
* variants-mode syncs, promos, and tokens can skew the ratio (see
* docs/superpowers/specs/2026-07-23-sync-visibility-design.md). Pure functions
* only β all I/O lives in the routes and shopifyAPI.
*/
'use strict';
function syncedPercent(count, expected) {
if (!expected || expected <= 0) return count > 0 ? 100 : 0;
return Math.min(100, Math.round((count / expected) * 100));
}
// Codes considered 100% synced: a positive expected count that the Shopify
// collection product count meets or exceeds. Never-synced codes (count 0) and
// codes with unknown expected are deliberately excluded β they are "incomplete".
function computeFullySyncedCodes(countsByCode, expectedByCode) {
const full = [];
for (const [code, count] of Object.entries(countsByCode)) {
const expected = expectedByCode[code];
if (expected > 0 && count >= expected) full.push(code);
}
return full;
}
module.exports = { syncedPercent, computeFullySyncedCodes };Create server/services/syncedSetCountsCache.js (mirrors syncedSetsCache.js; stores the {code:count} object):
js
/**
* In-memory cache of per-set synced product counts (from Shopify smart
* collections), keyed by shop, 5-minute TTL. Sibling of syncedSetsCache;
* both are invalidated together when a sync creates a new collection.
*/
const TTL_MS = 5 * 60 * 1000;
const cache = new Map();
function get(shop) {
const entry = cache.get(shop);
if (!entry) return null;
if (Date.now() - entry.cachedAt > TTL_MS) {
cache.delete(shop);
return null;
}
return entry.counts;
}
function set(shop, counts) {
cache.set(shop, { counts, cachedAt: Date.now() });
}
function invalidate(shop) {
cache.delete(shop);
}
module.exports = { get, set, invalidate, TTL_MS };- [ ] Step 4: Run the test to verify it passes
Run: npm test -- server/services/syncCompletionService.test.js Expected: PASS.
- [ ] Step 5: Invalidate the new cache alongside the old one
In server/queues/processors/syncProcessor.js at ~line 266, directly after the existing:
js
require('../../services/syncedSetsCache').invalidate(shop);add:
js
require('../../services/syncedSetCountsCache').invalidate(shop);- [ ] Step 6: Commit
bash
git add server/services/syncCompletionService.js server/services/syncCompletionService.test.js server/services/syncedSetCountsCache.js server/queues/processors/syncProcessor.js
git commit -m "Add sync-completion math and a per-shop synced-count cache"Task 3: onlyIncomplete query param β
Files:
- Modify:
server/schemas/catalog.js(setsQuerySchema~line 146,catalogSetsQuerySchema~line 99) - Modify:
server/schemas/schemas.test.js
Interfaces:
Produces: both schemas accept optional
onlyIncomplete: boolean(defaultfalse), coerced from the"true"/"false"query string.[ ] Step 1: Write the failing test
Add to server/schemas/schemas.test.js (match the file's existing import of the schemas):
js
describe('onlyIncomplete param', () => {
it('coerces "true" to boolean true on setsQuerySchema', () => {
const parsed = setsQuerySchema.parse({ onlyIncomplete: 'true' });
expect(parsed.onlyIncomplete).toBe(true);
});
it('defaults onlyIncomplete to false on catalogSetsQuerySchema', () => {
const parsed = catalogSetsQuerySchema.parse({ game: 'pokemon' });
expect(parsed.onlyIncomplete).toBe(false);
});
});If setsQuerySchema/catalogSetsQuerySchema are not already imported at the top of schemas.test.js, add them to the existing require('./catalog') (or equivalent) import.
- [ ] Step 2: Run the test to verify it fails
Run: npm test -- server/schemas/schemas.test.js Expected: FAIL β parsed.onlyIncomplete is undefined.
- [ ] Step 3: Add the field to both schemas
In server/schemas/catalog.js, define a reusable coercer after sortDirectionSchema (~line 88):
js
// Coerce the "true"/"false" query string to a boolean; defaults to false.
const onlyIncompleteSchema = z
.enum(['true', 'false'])
.optional()
.default('false')
.transform((v) => v === 'true');Add onlyIncomplete: onlyIncompleteSchema, to both catalogSetsQuerySchema (the object at ~line 99) and setsQuerySchema (~line 146).
- [ ] Step 4: Run the test to verify it passes
Run: npm test -- server/schemas/schemas.test.js Expected: PASS.
- [ ] Step 5: Commit
bash
git add server/schemas/catalog.js server/schemas/schemas.test.js
git commit -m "Accept an onlyIncomplete filter param on the set list endpoints"Task 4: A shared loadSyncCompletion helper + wire GET /api/sets (MTG) β
Files:
- Modify:
server/routes/api.js(add a helper near the other set routes; edit theGET /api/setshandler ~line 1631)
Interfaces:
Produces (helper):
async loadSyncCompletion(req) β { syncedCounts: {code:count} }β best-effort; returns{ syncedCounts: {} }when unauthenticated or on failure.Consumes:
shopifyAPI.getSyncedSetCounts()(Task 1),syncedSetCountsCache(Task 2),computeFullySyncedCodes/syncedPercent(Task 2).[ ] Step 1: Add the shared helper
In server/routes/api.js, add near the GET /api/synced-sets route (~line 1704):
js
/**
* Best-effort per-shop synced product counts, cached 5 min (syncedSetCountsCache).
* Returns { syncedCounts: {} } when there's no authenticated shop or the Shopify
* call fails β callers then skip the incomplete-filter and badges (never 500).
*/
async function loadSyncCompletion(req) {
if (!req.shop) return { syncedCounts: {} };
const countsCache = require('../services/syncedSetCountsCache');
let syncedCounts = countsCache.get(req.shop);
if (!syncedCounts) {
try {
const ShopifyAPI = require('../services/shopifyAPI');
const shopifyAPI = new ShopifyAPI(req.shop, req.accessToken);
syncedCounts = await shopifyAPI.getSyncedSetCounts();
countsCache.set(req.shop, syncedCounts);
} catch (err) {
logger.warn('Failed to load synced set counts', { shop: req.shop, error: err.message });
syncedCounts = {};
}
}
return { syncedCounts };
}- [ ] Step 2: Wire the MTG
/api/setshandler
In GET /api/sets (~line 1631), make these edits:
(a) Read the new param near the other query parsing (after the codes line ~1636):
js
const onlyIncomplete = req.query.onlyIncomplete === true || req.query.onlyIncomplete === 'true';(b) After the filter object is built and before const total = ... (~line 1662), load counts and, when filtering, exclude fully-synced codes. MTG's code lives at data.code, so the exclusion goes straight into the base $match β keeping countDocuments(filter) accurate:
js
const { syncedCounts } = await loadSyncCompletion(req);
if (onlyIncomplete && Object.keys(syncedCounts).length > 0) {
const syncedCodes = Object.keys(syncedCounts);
// Expected count = the same physical card count the list projects,
// so the badge denominator and the filter threshold agree.
const expectedDocs = await SetModel.aggregate([
{ $match: { 'data.code': { $in: syncedCodes } } },
{ $project: { code: '$data.code', cardCount: physicalCardCountExpr } }
]);
const expectedByCode = Object.fromEntries(expectedDocs.map((d) => [d.code, d.cardCount]));
const { computeFullySyncedCodes } = require('../services/syncCompletionService');
const fullySynced = computeFullySyncedCodes(syncedCounts, expectedByCode);
if (fullySynced.length > 0) {
filter['data.code'] = { ...(filter['data.code'] || {}), $nin: fullySynced };
}
}(c) Enrich the response. Replace the final res.json({ sets, pagination: {...} }) with:
js
const { syncedPercent } = require('../services/syncCompletionService');
const enrichedSets = sets.map((s) => {
const syncedCount = syncedCounts[s.code] || 0;
return { ...s, syncedCount, syncedPercent: syncedPercent(syncedCount, s.cardCount) };
});
res.json({
sets: enrichedSets,
pagination: { page, limit, total, pages: Math.ceil(total / limit) }
});- [ ] Step 3: Verify server suite + lint
Run: npm test -- server and npm run lint Expected: existing tests PASS; lint exits 0. (Route wiring is verified end-to-end in Step 4; the underlying logic is unit-tested in Tasks 1β2.)
- [ ] Step 4: Manual end-to-end check (dev)
With the dev server running:
bash
curl -s "http://localhost:3000/api/sets?shop=$DEV_BYPASS_SHOP&limit=5" | head -c 800
curl -s "http://localhost:3000/api/sets?shop=$DEV_BYPASS_SHOP&limit=5&onlyIncomplete=true" | head -c 800Confirm: every set has syncedCount/syncedPercent; the onlyIncomplete=true response omits any set whose syncedPercent was 100. Paste what you observe. (If the dev store has synced nothing, all sets return and all show 0% β that is correct.)
- [ ] Step 5: Commit
bash
git add server/routes/api.js
git commit -m "Filter MTG sets by sync completion and surface a synced percentage"Task 5: Wire GET /api/catalog/sets (Pokemon + Riftbound) β
Files:
- Modify:
server/routes/api.js(theGET /api/catalog/setshandler ~line 729; riftbound early-return block ~line 873; shared block ~line 888)
Interfaces:
Consumes:
loadSyncCompletion(Task 4),computeFullySyncedCodes/syncedPercent(Task 2).codeis a projected field here, so exclusion is a post-$project$match(before$sort, Β§5.6), andtotalis recomputed with a$countpipeline when filtering.[ ] Step 1: Read the param and load counts
In the GET /api/catalog/sets handler, after game is resolved and before the per-game branches build Model/projection, add:
js
const onlyIncomplete = req.query.onlyIncomplete === true || req.query.onlyIncomplete === 'true';
const { syncedCounts } = await loadSyncCompletion(req);- [ ] Step 2: Add a small in-handler helper for the exclusion
Still inside the handler (after syncedCounts is loaded), define a closure that both branches reuse. It runs the game's own projection (plus any pre-stages, e.g. riftbound's lookup) over just the synced codes to get expected counts, then returns the fully-synced list:
js
// preStages = stages that must run before `projection` can reference its
// derived fields (riftbound builds _cardCount via a $lookup + $addFields).
const computeFullySyncedForCatalog = async (preStages) => {
const syncedCodes = Object.keys(syncedCounts);
if (!onlyIncomplete || syncedCodes.length === 0) return [];
const expectedDocs = await Model.aggregate([
...preStages,
{ $project: { code: projection.code, cardCount: projection.cardCount } },
{ $match: { code: { $in: syncedCodes } } }
]).allowDiskUse(true);
const expectedByCode = Object.fromEntries(expectedDocs.map((d) => [d.code, d.cardCount]));
const { computeFullySyncedCodes } = require('../services/syncCompletionService');
return computeFullySyncedCodes(syncedCounts, expectedByCode);
};
const { syncedPercent } = require('../services/syncCompletionService');
const enrichCatalogSets = (sets) => sets.map((s) => {
const syncedCount = syncedCounts[s.code] || 0;
return { ...s, syncedCount, syncedPercent: syncedPercent(syncedCount, s.cardCount) };
});- [ ] Step 3: Apply it in the riftbound early-return block
In the riftbound path (the Model.aggregate([...]) at ~line 873 that uses lookupStage, addFieldsStage, { $project: projection }), change the block to exclude fully-synced sets after the $project and recompute total when filtering:
js
const fullySynced = await computeFullySyncedForCatalog([lookupStage, addFieldsStage]);
const excludeStage = fullySynced.length > 0
? [{ $match: { code: { $nin: fullySynced } } }]
: [];
let total = await Model.countDocuments(filter);
if (excludeStage.length > 0) {
const countResult = await Model.aggregate([
{ $match: filter }, lookupStage, addFieldsStage,
{ $project: projection }, ...excludeStage, { $count: 'total' }
]).allowDiskUse(true);
total = countResult[0]?.total || 0;
}
const sets = await Model.aggregate([
{ $match: filter },
lookupStage,
addFieldsStage,
{ $project: projection },
...excludeStage,
{ $sort: sort },
{ $skip: skip },
{ $limit: limit }
]).allowDiskUse(true);
return res.json({
sets: enrichCatalogSets(sets),
pagination: { page, limit, total, pages: Math.ceil(total / limit) }
});(Replace the existing riftbound total/sets/return res.json(...) lines β note the original computed total earlier in the branch; move/consolidate it as shown so the count reflects the exclusion.)
- [ ] Step 4: Apply it in the shared (Pokemon / MTG-via-catalog) block
Replace the shared const total = await Model.countDocuments(filter); and the Model.aggregate([...]) at ~lines 888β908 with:
js
const fullySynced = await computeFullySyncedForCatalog([]);
const excludeStage = fullySynced.length > 0
? [{ $match: { code: { $nin: fullySynced } } }]
: [];
let total = await Model.countDocuments(filter);
if (excludeStage.length > 0) {
const countResult = await Model.aggregate([
{ $match: filter }, { $project: projection }, ...excludeStage, { $count: 'total' }
]).allowDiskUse(true);
total = countResult[0]?.total || 0;
}
const sets = await Model.aggregate([
{ $match: filter },
{ $project: projection },
...excludeStage,
{ $sort: sort },
{ $skip: skip },
{ $limit: limit }
]).allowDiskUse(true);
res.json({
sets: enrichCatalogSets(sets),
pagination: { page, limit, total, pages: Math.ceil(total / limit) }
});- [ ] Step 5: Verify the join casing against a real document (Β§5.3/Β§5.4)
Before trusting the Pokemon/Riftbound join, confirm the metafield-condition casing matches the catalog code. Query one real synced store (or the dev store) and compare:
bash
# Pokemon: the catalog code the client filters on
curl -s "http://localhost:3000/api/catalog/sets?shop=$DEV_BYPASS_SHOP&game=pokemon&limit=3" | grep -o '"code":"[^"]*"' | headThen inspect one Pokemon smart collection's rule condition in Shopify admin (or log the raw getSyncedSetCounts() keys). Assert they are string-equal (same case). If they differ, the join is broken β fix the casing at the source (do not uppercase). Record the compared values in the PR description.
- [ ] Step 6: Verify server suite + lint, then manual check
Run: npm test -- server and npm run lint (expect PASS / exit 0), then:
bash
curl -s "http://localhost:3000/api/catalog/sets?shop=$DEV_BYPASS_SHOP&game=pokemon&limit=5&onlyIncomplete=true" | head -c 800Confirm each set carries syncedCount/syncedPercent and fully-synced Pokemon sets are excluded. Paste what you observe.
- [ ] Step 7: Commit
bash
git add server/routes/api.js
git commit -m "Filter Pokemon and Riftbound sets by sync completion"Task 6: Client β checkbox, % badge, consolidate synced-sets β
Files:
- Modify:
client/src/components/SetSelector.jsx - Modify:
client/src/components/SetSelector.test.jsx
Interfaces:
Consumes:
set.syncedCountandset.syncedPercentnow returned inline by/setsand/catalog/sets.Removes: the separate
GET /synced-setsfetch and itssyncedSetsstate.[ ] Step 1: Write the failing test
Add to client/src/components/SetSelector.test.jsx a test that the new checkbox sends onlyIncomplete and that a % badge renders. Match the file's existing render/mock style (it already mocks ../utils/api). Example assertion for the request param:
jsx
it('requests onlyIncomplete when the "not fully synced" box is checked', async () => {
// Arrange: render with api.get mocked to capture params (follow the existing
// mock setup at the top of this file).
// Act: click the "Show only not 100% synced" checkbox.
// Assert: the most recent api.get('/sets', ...) call included params.onlyIncomplete === true.
const lastCall = api.get.mock.calls.at(-1);
expect(lastCall[1].params.onlyIncomplete).toBe(true);
});(Write the arrange/act lines concretely against the existing test harness in this file β it already renders <SetSelector> and stubs api.get; reuse that setup rather than inventing a new one.)
- [ ] Step 2: Run the test to verify it fails
Run: npm run test:client -- src/components/SetSelector.test.jsx Expected: FAIL β no such checkbox / param not sent.
- [ ] Step 3: Add state + wire the request param
In client/src/components/SetSelector.jsx:
(a) Add state near the other useState declarations (~line 88):
jsx
const [onlyIncomplete, setOnlyIncomplete] = useState(false);(b) Re-run the load when it changes β add onlyIncomplete to the debounced effect deps (~line 116):
jsx
}, [queryValue, activeShop, sortedColumn, sortDirection, showSelectedOnly, onlyIncomplete, game]);(c) In loadSets, pass the param (after the params object is built, ~line 136):
jsx
// Server enforces the filter (see GET /api/sets & /api/catalog/sets,
// onlyIncomplete). This is a UX mirror only.
if (onlyIncomplete) {
params.onlyIncomplete = true;
}- [ ] Step 4: Replace the /synced-sets fetch with inline data
Remove the syncedSets state (~line 89) and the useEffect that fetches /synced-sets (~lines 95β106). Derive the green check inline in the row render: replace syncedSets.has(set.code) (~line 357) with set.syncedCount > 0.
- [ ] Step 5: Add the checkbox and the % badge
(a) In the filter controls row (near the "Show selected only" label, ~line 281), add a sibling checkbox:
jsx
<label className={`flex items-center gap-2 px-3 py-2 border-2 rounded-lg cursor-pointer transition-colors ${
onlyIncomplete ? 'border-green-600 bg-green-900/20 text-green-400' : 'border-border hover:bg-muted'
}`}>
<Checkbox checked={onlyIncomplete} onCheckedChange={setOnlyIncomplete} />
<span className="text-sm font-medium whitespace-nowrap">Show only not 100% synced</span>
</label>(b) In the set-name cell, next to the synced check (~line 356β366), show the percentage when partially synced:
jsx
{set.syncedCount > 0 && set.syncedPercent < 100 && (
<Badge variant="secondary" className="text-xs">{set.syncedPercent}%</Badge>
)}- [ ] Step 6: Run client tests + build
Run: npm run test:client -- src/components/SetSelector.test.jsx then npm run build Expected: PASS; build exits 0.
- [ ] Step 7: Commit
bash
git add client/src/components/SetSelector.jsx client/src/components/SetSelector.test.jsx
git commit -m "Add a not-fully-synced set filter with a synced-percentage badge"Final verification (before opening the PR) β
- [ ]
npm testexits 0;npm run test:clientexits 0. - [ ]
npm run test:coverageβshopifyAPI.js,syncCompletionService.js,catalog.js,SetSelector.jsxeach β₯70% on all four metrics. - [ ]
npm run lintexits 0 (no new warnings);npm run buildexits 0. - [ ] Aggregation order checked: every new/edited pipeline has
$match/$projectbefore$sort(Β§5.6). - [ ] Casing verified (Task 5 Step 5): Pokemon/Riftbound collection conditions are string-equal to catalog codes β recorded in the PR.
- [ ] Parity statement for the PR (Β§5.1):
- mtg β mirrored via
GET /api/sets; expected = projected physicalcardCount; codes uppercase. - pokemon β mirrored via
GET /api/catalog/sets; expected =PokemonSet.cardCount; codes = slug (not uppercased). - riftbound β mirrored via
GET /api/catalog/sets; expected = lookup-derived_cardCount; codes = slug. Few/no synced collections today, so most sets read as incomplete (expected).
- mtg β mirrored via
- [ ] Sync-affecting sanity (from
MULTI_GAME_ARCHITECTURE.mdΒ§7): after a real test sync of one set, that set'ssyncedPercentmoves off 0 and it disappears from theonlyIncompletelist once fully synced.
