Skip to content

Sets Viewer Sync Indicator 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: Replace the sets viewer's ambiguous green checkmark + hidden-at-100% percentage with a single chip in its own aligned column that states "Synced" when a set is complete.

Architecture: Two independent changes. First, syncedPercent on the server floors instead of rounds, so the value 100 means exactly what the "show only not 100% synced" filter means by complete. Second, the client replaces the checkmark and the conditional badge with one pure helper, getSyncChip(set), that returns the chip's label/variant/title (or null), rendered into a new Sync table column.

Tech Stack: React 18, Vite 7, Tailwind 4, the local retroui component library (neo-brutalist Radix wrappers), Vitest.

Global Constraints โ€‹

  • Spec: docs/superpowers/specs/2026-07-26-sets-sync-indicator-design.md. Read it before starting.
  • No Polaris. UI components come from the retroui barrel: import { Badge } from '@/components/retroui'.
  • Badge variants are default | outline | solid | surface only (client/src/components/retroui/Badge.jsx:7). secondary is a Button variant; passing it to Badge silently renders no styling. Never pass a variant outside that list.
  • Server is CommonJS (require/module.exports); test files are ESM (import).
  • Client tests need deps installed in this worktree: run npm --prefix client install once before any test:client run โ€” client/node_modules does not exist in a fresh worktree.
  • Commit messages: one imperative sentence, sentence case, no trailing period, describing the merchant-visible outcome.
  • Pre-commit hook runs ESLint. Fix lint rather than bypassing it; never use --no-verify.

Task 1: Floor the sync percentage so 100 means complete โ€‹

syncedPercent currently rounds, so a set at 99.6% reports 100. Task 2 renders a "Synced" chip at 100, which would contradict the onlyIncomplete filter โ€” that filter uses computeFullySyncedCodes, whose rule is count >= expected. Flooring makes syncedPercent === 100 exactly equivalent to that rule.

Files:

  • Modify: server/services/syncCompletionService.js:19-23
  • Test: server/services/syncCompletionService.test.js:4-24

Interfaces:

  • Consumes: nothing from earlier tasks.

  • Produces: syncedPercent(count, expected) -> number (0โ€“100 integer, unchanged signature). Task 2 relies on the guarantee that the return value is 100 only when count >= expected and expected > 0.

  • [ ] Step 1: Write the failing test

In server/services/syncCompletionService.test.js, change the title of the first test in the syncedPercent block from 'rounds count/expected to a 0-100 integer' to 'floors count/expected to a 0-100 integer' (its three existing assertions are unchanged โ€” 50/100, 1/3, and 110/100 give identical results under floor).

Then add this new test immediately after that first test:

js
    // 100 must mean the same thing here as in computeFullySyncedCodes
    // (count >= expected), or the sets viewer's "Synced" chip contradicts the
    // "show only not 100% synced" filter for a set sitting just under.
    it('reaches 100 only when the count actually meets the expected total', () => {
        expect(syncedPercent(299, 300)).toBe(99);
        expect(syncedPercent(300, 300)).toBe(100);
    });
  • [ ] Step 2: Run the test to verify it fails
bash
npx vitest run server/services/syncCompletionService.test.js

Expected: FAIL โ€” reaches 100 only when the count actually meets the expected total reports expected 100 to be 99 for the syncedPercent(299, 300) assertion, because Math.round(99.67) is 100. Every other test in the file passes.

  • [ ] Step 3: Write the implementation

In server/services/syncCompletionService.js, replace the body of syncedPercent (lines 19-23) with:

js
function syncedPercent(count, expected) {
    const safe = safeCount(count);
    if (!expected || expected <= 0) return safe > 0 ? 100 : 0;
    // Floor, not round: `=== 100` must mean the same thing as
    // computeFullySyncedCodes below (count >= expected). Rounding reported 100
    // at 99.6%, which would show a "Synced" chip on a set the onlyIncomplete
    // filter still returns.
    return Math.min(100, Math.floor((safe / expected) * 100));
}
  • [ ] Step 4: Run the test to verify it passes
bash
npx vitest run server/services/syncCompletionService.test.js

Expected: PASS โ€” all tests in the file green.

  • [ ] Step 5: Run the full server suite and lint
bash
npx vitest run server/services/syncCompletionService.test.js server/routes && npx eslint server/services/syncCompletionService.js

Expected: both exit 0. Nothing else asserts on rounding behavior.

  • [ ] Step 6: Commit
bash
git add server/services/syncCompletionService.js server/services/syncCompletionService.test.js
git commit -m "Report a set as 100% synced only when every card is actually in the store"

Task 2: Show one aligned sync chip per set instead of a checkmark โ€‹

Replaces the CheckCircle2 icon (gated on syncedCount > 0, so it means "started" not "done") and the badge suppressed at 100% with a single chip in a new Sync column. Chip logic lives in an exported pure helper so it is unit-testable without rendering the component โ€” matching how buildSetsRequest and toggleSetSelection are already tested in this file.

Files:

  • Modify: client/src/components/SetSelector.jsx (line 4 import; new helper after line 90; header row line 324-327; row cells line 356-371)
  • Test: client/src/components/SetSelector.test.jsx

Interfaces:

  • Consumes: syncedPercent from Task 1 โ€” specifically, that set.syncedPercent === 100 implies the set is genuinely complete.

  • Produces: getSyncChip(set) -> { label: string, variant: 'solid' | 'outline', title: string } | null, a named export of client/src/components/SetSelector.jsx. Returns null when the set has never been synced.

  • [ ] Step 1: Install client dependencies

client/node_modules is absent in a fresh worktree, so client tests cannot run without this.

bash
npm --prefix client install

Expected: completes without error. Skip only if client/node_modules already exists.

  • [ ] Step 2: Write the failing tests

In client/src/components/SetSelector.test.jsx, change the import on line 2 to include the new helper:

js
import { getRowSelectionState, toggleSetSelection, buildSetsRequest, getSyncChip } from './SetSelector';

Then append this block to the end of the file:

js
describe('getSyncChip', () => {
  it('states "Synced" on a solid chip when every card is in the store', () => {
    expect(getSyncChip({ syncedCount: 261, syncedPercent: 100, cardCount: 261 })).toEqual({
      label: 'Synced',
      variant: 'solid',
      title: '261 of 261 cards synced (approximate)',
    });
  });

  it('shows the percentage on an outline chip while a set is partial', () => {
    expect(getSyncChip({ syncedCount: 190, syncedPercent: 63, cardCount: 303 })).toEqual({
      label: '63%',
      variant: 'outline',
      title: '190 of 303 cards synced (approximate)',
    });
  });

  it('renders no chip at all for a set that was never synced', () => {
    expect(getSyncChip({ syncedCount: 0, syncedPercent: 0, cardCount: 281 })).toBeNull();
    expect(getSyncChip({})).toBeNull();
  });

  // "No chip" must mean strictly never synced, so a barely-started set still
  // gets a chip even though its percentage floors to zero.
  it('still chips a barely-started set whose percentage floors to 0', () => {
    expect(getSyncChip({ syncedCount: 1, syncedPercent: 0, cardCount: 500 })).toEqual({
      label: '0%',
      variant: 'outline',
      title: '1 of 500 cards synced (approximate)',
    });
  });

  it('omits the denominator when the catalog card count is unknown', () => {
    expect(getSyncChip({ syncedCount: 12, syncedPercent: 100 }).title).toBe(
      '12 cards synced (approximate)'
    );
  });

  // Regression guard for the original bug: the chip shipped with
  // variant="secondary", which is a Button variant. cva contributes no classes
  // for an unknown value and defaultVariants only apply when the prop is
  // undefined, so the badge rendered completely unstyled. Mirrors the variant
  // list in components/retroui/Badge.jsx โ€” update both together.
  it('only uses variants that Badge actually defines', () => {
    const BADGE_VARIANTS = ['default', 'outline', 'solid', 'surface'];
    const sets = [
      { syncedCount: 261, syncedPercent: 100, cardCount: 261 },
      { syncedCount: 190, syncedPercent: 63, cardCount: 303 },
      { syncedCount: 1, syncedPercent: 0, cardCount: 500 },
    ];
    for (const set of sets) {
      expect(BADGE_VARIANTS).toContain(getSyncChip(set).variant);
    }
  });
});
  • [ ] Step 3: Run the tests to verify they fail
bash
npx vitest run --config vitest.client.config.js client/src/components/SetSelector.test.jsx

Expected: FAIL โ€” every test in the new getSyncChip block errors with getSyncChip is not a function. The four pre-existing tests still pass.

  • [ ] Step 4: Add the helper

In client/src/components/SetSelector.jsx, insert this after the buildSetsRequest function (which ends at line 90, just before function SetSelector(...) on line 93):

js
// The sets viewer's single sync marker: one chip, never a checkmark plus a
// badge. Returning null means strictly "never synced" โ€” a barely-started set
// whose percentage floors to 0 still gets a chip, so "no chip" is unambiguous.
// The 100 boundary is the server's: syncedPercent floors, so it reads 100 only
// when syncedCount >= cardCount โ€” the same predicate computeFullySyncedCodes
// uses for the onlyIncomplete filter (server/services/syncCompletionService.js).
export function getSyncChip(set) {
  const count = set?.syncedCount || 0;
  if (count <= 0) return null;

  const percent = set?.syncedPercent || 0;
  const expected = set?.cardCount || 0;
  // The ratio is approximate by design โ€” variants-mode syncs, promos and tokens
  // skew numerator against denominator โ€” so the tooltip names the real counts.
  const title = expected > 0
    ? `${count.toLocaleString()} of ${expected.toLocaleString()} cards synced (approximate)`
    : `${count.toLocaleString()} cards synced (approximate)`;

  return percent >= 100
    ? { label: 'Synced', variant: 'solid', title }
    : { label: `${percent}%`, variant: 'outline', title };
}
  • [ ] Step 5: Run the tests to verify they pass
bash
npx vitest run --config vitest.client.config.js client/src/components/SetSelector.test.jsx

Expected: PASS โ€” all tests green, including the four pre-existing ones.

  • [ ] Step 6: Commit the helper
bash
git add client/src/components/SetSelector.jsx client/src/components/SetSelector.test.jsx
git commit -m "Add a single sync-state chip helper for the sets viewer"
  • [ ] Step 7: Add the Sync column header

In client/src/components/SetSelector.jsx, in the <thead> block, insert a plain <th> between the "Set Name" and "Code" headers (currently lines 324-325) so the header row reads:

jsx
                <SortableHeader column="name">Set Name</SortableHeader>
                <th className="px-4 py-3 text-left font-semibold border-b-2 border-black">
                  Sync
                </th>
                <SortableHeader column="code">Code</SortableHeader>
                <SortableHeader column="releaseDate">Release Date</SortableHeader>
                <SortableHeader column="cardCount">Card Count</SortableHeader>

It is deliberately not a SortableHeader: syncedPercent is attached after the DB query from a per-shop Shopify counts map, so server-side sorting would need a separate pipeline, and the existing "Show only not 100% synced" checkbox already covers that need. The className matches SortableHeader minus the cursor/hover affordances.

  • [ ] Step 8: Render the chip and delete the checkmark

Still in client/src/components/SetSelector.jsx, inside the sets.map((set, index) => { body, add this alongside the other per-row constants (next to const isSelected = isRowSelected(set, index);, currently line 336):

jsx
                const syncChip = getSyncChip(set);

Then replace the whole set-name <td> (currently lines 356-371) and add the new cell after it, so the two cells read:

jsx
                  <td className="px-4 py-3 whitespace-nowrap">{set.name}</td>
                  <td className="px-4 py-3 whitespace-nowrap">
                    {syncChip && (
                      <Badge variant={syncChip.variant} size="sm" title={syncChip.title}>
                        {syncChip.label}
                      </Badge>
                    )}
                  </td>

This removes the CheckCircle2 element, its wrapping <div className="flex items-center gap-1.5">, and the set.syncedCount > 0 && set.syncedPercent < 100 badge.

  • [ ] Step 9: Drop the now-unused lucide import

Delete line 4 of client/src/components/SetSelector.jsx:

js
import { CheckCircle2 } from 'lucide-react';

It is the only lucide-react usage in the file, so the import must go or ESLint's unused-vars rule fails the pre-commit hook.

  • [ ] Step 10: Verify the client suite, lint, and build
bash
npm run test:client && npx eslint client/src/components/SetSelector.jsx && npm run build

Expected: all three exit 0. The build confirms no dangling reference to the removed import.

  • [ ] Step 11: Confirm the rendering in the running app

Start the app and open the sets viewer for a store with a mix of synced and unsynced sets:

bash
npm run dev

Confirm by eye: the Sync column sits between Set Name and Code with every chip left-aligned at the same position; a fully synced set shows a solid black Synced chip; a partial set shows an outlined percentage; a never-synced set shows an empty cell; hovering a chip shows the N of M cards synced (approximate) tooltip; and no green checkmark remains anywhere in the list.

  • [ ] Step 12: Commit
bash
git add client/src/components/SetSelector.jsx
git commit -m "Show one aligned sync chip per set so a finished set reads as Synced"

Definition of Done (ยง7) โ€‹

  • [ ] npm test exits 0.

  • [ ] npm run test:client exits 0.

  • [ ] npm run lint exits 0 with no new warnings.

  • [ ] npm run build exits 0.

  • [ ] Step 11's visual confirmation was actually performed, not inferred.

  • [ ] Per-game parity (ยง5.1): no per-game code is touched โ€” SetSelector is the single renderer for MTG (/sets) and for Pokemon and Riftbound (/catalog/sets), and both endpoints already attach syncedCount/syncedPercent via the same loadSyncCompletion path. mtg, pokemon, and riftbound are all covered by the shared change; none is exempt.

  • [ ] No new persisted fields (ยง5.2 not applicable) and no aggregation changes (ยง5.6 not applicable).

  • [ ] Grep proof for the original defect: grep -rn 'CheckCircle2' client/src returns nothing, and every Badge variant= in client/src/components/SetSelector.jsx names one of default | outline | solid | surface.

    Note: the two Release Date badges (coming-soon and Preview) carried the same invalid-variant defect and were out of the original spec's scope. Fixing them in this branch was approved on 2026-07-26 and is recorded in ยง3.5 of the design. That is why this check covers all Badge variants in the file rather than only variant="secondary".