Skip to content

"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/$match must precede $sort; never rely on allowDiskUse for 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 β€” add getSyncedSetCounts(), refactor getSyncedSetCodes() 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 β€” add onlyIncomplete to setsQuerySchema + catalogSetsQuerySchema (Modify).
  • server/schemas/schemas.test.js β€” schema validation tests (Modify).
  • server/routes/api.js β€” a shared loadSyncCompletion(...) helper; wire GET /api/sets and GET /api/catalog/sets (Modify).
  • client/src/components/SetSelector.jsx β€” checkbox, % badge, drop the /synced-sets fetch (Modify).
  • client/src/components/SetSelector.test.jsx β€” tests (Modify).

Task 1: getSyncedSetCounts() on ShopifyAPI ​

Files:

  • Modify: server/services/shopifyAPI.js (the getSyncedSetCodes() 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 where expected > 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 (default false), 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 the GET /api/sets handler ~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/sets handler

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 800

Confirm: 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 (the GET /api/catalog/sets handler ~line 729; riftbound early-return block ~line 873; shared block ~line 888)

Interfaces:

  • Consumes: loadSyncCompletion (Task 4), computeFullySyncedCodes/syncedPercent (Task 2). code is a projected field here, so exclusion is a post-$project $match (before $sort, Β§5.6), and total is recomputed with a $count pipeline 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":"[^"]*"' | head

Then 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 800

Confirm 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.syncedCount and set.syncedPercent now returned inline by /sets and /catalog/sets.

  • Removes: the separate GET /synced-sets fetch and its syncedSets state.

  • [ ] 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 test exits 0; npm run test:client exits 0.
  • [ ] npm run test:coverage β€” shopifyAPI.js, syncCompletionService.js, catalog.js, SetSelector.jsx each β‰₯70% on all four metrics.
  • [ ] npm run lint exits 0 (no new warnings); npm run build exits 0.
  • [ ] Aggregation order checked: every new/edited pipeline has $match/$project before $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 physical cardCount; 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).
  • [ ] Sync-affecting sanity (from MULTI_GAME_ARCHITECTURE.md Β§7): after a real test sync of one set, that set's syncedPercent moves off 0 and it disappears from the onlyIncomplete list once fully synced.