Skip to content

Sealed Barcode Availability 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: Tell a merchant, while they are adding a sealed product, whether it will get a scannable barcode โ€” and when it won't, why.

Architecture: SealedProductBarcode gains a batched getBarcodeStatuses that becomes the single implementation of the "usable only if exactly one product holds it" rule; getBarcode is refactored into a thin caller so the rule is never written twice. setDetails annotates each MTG sealed product with a three-state barcodeStatus, and a small SealedBarcodeNote component renders the explanation in both the grid and table views.

Tech Stack: Node.js (CommonJS), Mongoose, React 18, Tailwind 4, retroui, Vitest (ESM tests), @testing-library/react.

Spec: docs/superpowers/specs/2026-07-31-sealed-barcode-availability-design.md

Global Constraints โ€‹

  • Server code is CommonJS; test files are ESM. Tests are co-located as x.test.js / x.test.jsx.
  • Never default an identity parameter โ€” game, shop, uuid (CLAUDE.md ยง5.5). Absent โ†’ throw or Zod reject.
  • Client components come from the retroui barrel (import { Button } from '../components/retroui'), with cn() from lib/utils.js. retroui variant vocabularies diverge between components โ€” Badge, Button and Alert have different variant maps, and a wrong name renders unstyled with no error. Check the target component's own variant map; never copy a variant name from a sibling component.
  • Aggregations put $match before $group; no $sort. sealedproductbarcodes is a plain collection, not time-series, so ยง5.6's carve-out does not apply.
  • Husky pre-commit runs ESLint. Fix lint; never --no-verify.
  • Commit messages: one imperative sentence stating the merchant-visible outcome, sentence case, no trailing period.

Environment note โ€” read before Task 3 โ€‹

client/node_modules is absent in a fresh worktree. Client tests will fail with a module-resolution error until you run:

bash
npm --prefix client install

Do this once before Task 3. Server tests need no such step.

Per-game parity (CLAUDE.md ยง5.1) โ€‹

  • mtg โ€” implemented. setDetails.js:262 is the only branch that returns a non-empty sealedProducts.
  • pokemon โ€” exempt: its branch returns sealedProducts: [] (setDetails.js:378-379). Nothing to annotate.
  • riftbound โ€” exempt: same, no sealed listing in this endpoint.

getBarcodeStatuses is game-scoped, so whenever sealed support lands for either game the annotation comes with it. State this in the PR.

File Structure โ€‹

FileResponsibility
server/models/SealedProductBarcode.jsModify. Add getBarcodeStatuses; refactor getBarcode to call it.
server/models/SealedProductBarcode.test.jsModify. Cover the new static; rewrite the getBarcode tests against the new internals so they still guard sync behavior.
server/routes/setDetails.jsModify. Add + export annotateSealedBarcodeStatus; wire it into the MTG sealed branch.
server/routes/setDetails.sealedBarcode.test.jsCreate. Unit-tests the pure annotator โ€” no Mongo, no router introspection.
client/src/components/SealedBarcodeNote.jsxCreate. Renders the three states. Used by both sealed views.
client/src/components/SealedBarcodeNote.test.jsxCreate. Three render states.
client/src/components/CatalogSetBrowser.jsxModify. Render the note in the grid card and the table row.

Why a separate SealedBarcodeNote component: it is needed in two places (grid card and table row), and CatalogSetBrowser.jsx is already over 1,100 lines with no test file. Extracting a small component gives a testable unit without standing up a test harness for the whole browser, and avoids duplicating the copy in two render paths (ยง5.8).

Why the annotator is a separate exported function: server/routes/api.catalogSealed.test.js tests routes by introspecting router.stack against a real local MongoDB. That is heavy and needs docker compose up -d. A pure annotator can be unit-tested with zero I/O, so the new logic gets fast, dependency-free coverage.


Task 1: getBarcodeStatuses, with getBarcode as a thin caller โ€‹

Files:

  • Modify: server/models/SealedProductBarcode.js
  • Test: server/models/SealedProductBarcode.test.js

Interfaces:

  • Consumes: nothing.

  • Produces:

    • getBarcodeStatuses(game: string, uuids: string[]): Promise<Map<string, {status: 'available'|'shared', barcode: string, sharedBy: number}>> โ€” a uuid absent from the Map has no row (the 'unpublished' state). Throws if game is falsy. Returns an empty Map without querying when uuids is empty.
    • getBarcode(game, uuid) โ€” unchanged signature and behavior.
  • [ ] Step 1: Rewrite the getBarcode tests against the new internals

In server/models/SealedProductBarcode.test.js, replace the whole describe('SealedProductBarcode.getBarcode', ...) block with the version below. It mocks find and aggregate (the real I/O after the refactor) rather than findOne/countDocuments, so it still exercises the full path through getBarcodeStatuses and remains a genuine guard on sync behavior.

javascript
describe('SealedProductBarcode.getBarcode', () => {
    /** Mock a lookup that finds `barcode`, held by `sharedCount` products. */
    function mockLookup(barcode, sharedCount = 1) {
        vi.spyOn(SealedProductBarcode, 'find').mockReturnValue({
            select: () => ({
                lean: () => Promise.resolve(barcode === null ? [] : [{ uuid: 'uuid-1', barcode }]),
            }),
        });
        vi.spyOn(SealedProductBarcode, 'aggregate').mockResolvedValue(
            barcode === null ? [] : [{ _id: barcode, n: sharedCount }]
        );
    }

    it('returns the stored barcode when it identifies exactly one product', async () => {
        mockLookup('195166278636', 1);
        await expect(SealedProductBarcode.getBarcode('mtg', 'uuid-1')).resolves.toBe('195166278636');
    });

    it('returns null when no row exists', async () => {
        mockLookup(null);
        await expect(SealedProductBarcode.getBarcode('mtg', 'uuid-1')).resolves.toBeNull();
    });

    // Regression guard on the Task 1 refactor: suppression must survive
    // getBarcode being reimplemented on top of getBarcodeStatuses.
    it('suppresses a barcode shared by more than one product', async () => {
        mockLookup('195166121468', 10);
        await expect(SealedProductBarcode.getBarcode('mtg', 'uuid-1')).resolves.toBeNull();
    });

    it('suppresses a barcode shared by exactly two products', async () => {
        mockLookup('630509997053', 2);
        await expect(SealedProductBarcode.getBarcode('mtg', 'uuid-1')).resolves.toBeNull();
    });

    it('throws when game is missing', async () => {
        await expect(SealedProductBarcode.getBarcode(null, 'uuid-1')).rejects.toThrow(/game is required/);
    });

    it('throws when uuid is missing', async () => {
        await expect(SealedProductBarcode.getBarcode('mtg', null)).rejects.toThrow(/uuid is required/);
    });
});

describe('SealedProductBarcode.getBarcodeStatuses', () => {
    function mockRows(rows, counts) {
        vi.spyOn(SealedProductBarcode, 'find').mockReturnValue({
            select: () => ({ lean: () => Promise.resolve(rows) }),
        });
        vi.spyOn(SealedProductBarcode, 'aggregate').mockResolvedValue(counts);
    }

    it('reports a solely-held barcode as available', async () => {
        mockRows([{ uuid: 'u1', barcode: '195166326399' }], [{ _id: '195166326399', n: 1 }]);
        const statuses = await SealedProductBarcode.getBarcodeStatuses('mtg', ['u1']);
        expect(statuses.get('u1')).toEqual({ status: 'available', barcode: '195166326399', sharedBy: 1 });
    });

    it('reports a multi-holder barcode as shared, with the count', async () => {
        mockRows([{ uuid: 'u1', barcode: '195166313085' }], [{ _id: '195166313085', n: 4 }]);
        const statuses = await SealedProductBarcode.getBarcodeStatuses('mtg', ['u1']);
        expect(statuses.get('u1')).toEqual({ status: 'shared', barcode: '195166313085', sharedBy: 4 });
    });

    // Absence, not a sentinel value: callers must not confuse "no data" with
    // "bad data". A uuid with no row simply is not in the Map.
    it('omits uuids that have no row', async () => {
        mockRows([{ uuid: 'u1', barcode: '195166326399' }], [{ _id: '195166326399', n: 1 }]);
        const statuses = await SealedProductBarcode.getBarcodeStatuses('mtg', ['u1', 'u2']);
        expect(statuses.has('u2')).toBe(false);
        expect(statuses.size).toBe(1);
    });

    it('classifies a mixed batch in one call', async () => {
        mockRows(
            [{ uuid: 'u1', barcode: 'AAA' }, { uuid: 'u2', barcode: 'BBB' }],
            [{ _id: 'AAA', n: 1 }, { _id: 'BBB', n: 3 }]
        );
        const statuses = await SealedProductBarcode.getBarcodeStatuses('mtg', ['u1', 'u2']);
        expect(statuses.get('u1').status).toBe('available');
        expect(statuses.get('u2').status).toBe('shared');
        expect(statuses.get('u2').sharedBy).toBe(3);
    });

    it('returns an empty Map without querying when given no uuids', async () => {
        const find = vi.spyOn(SealedProductBarcode, 'find');
        const statuses = await SealedProductBarcode.getBarcodeStatuses('mtg', []);
        expect(statuses.size).toBe(0);
        expect(find).not.toHaveBeenCalled();
    });

    it('throws when game is missing', async () => {
        await expect(SealedProductBarcode.getBarcodeStatuses(null, ['u1'])).rejects.toThrow(/game is required/);
    });
});
  • [ ] Step 2: Run to verify they fail
bash
npx vitest run server/models/SealedProductBarcode.test.js

Expected: FAIL โ€” SealedProductBarcode.getBarcodeStatuses is not a function, plus the rewritten getBarcode tests failing because the old implementation calls findOne, which is no longer mocked.

  • [ ] Step 3: Write the implementation

In server/models/SealedProductBarcode.js, replace the entire existing getBarcode static with the two statics below. Keep the existing block comment above it โ€” it documents the suppression rule and stays accurate; move it so it sits above getBarcodeStatuses.

javascript
/**
 * Resolve barcode status for many sealed products at once.
 *
 * This is the single implementation of the suppression rule described above;
 * getBarcode is a thin caller so the rule is never written twice (ยง5.8).
 *
 * A uuid absent from the returned Map has no row at all โ€” the "unpublished"
 * state. Absence rather than a sentinel entry, so a caller cannot confuse
 * "we have no data" with "we have data and it is unusable".
 *
 * Two queries regardless of batch size: one find for the rows, one aggregation
 * counting holders of each distinct barcode found. Both are served by the
 * existing {game, uuid} and {game, barcode} indexes.
 *
 * @param {string} game - Game identifier, never defaulted (CLAUDE.md ยง5.5)
 * @param {string[]} uuids - Sealed product catalog uuids
 * @returns {Promise<Map<string, {status: string, barcode: string, sharedBy: number}>>}
 */
sealedProductBarcodeSchema.statics.getBarcodeStatuses = async function(game, uuids) {
    if (!game) {
        throw new Error('game is required to resolve a sealed barcode');
    }

    const wanted = Array.isArray(uuids) ? uuids.filter(Boolean) : [];
    const statuses = new Map();
    if (wanted.length === 0) return statuses;

    const rows = await this.find({ game, uuid: { $in: wanted } }).select('uuid barcode').lean();
    if (rows.length === 0) return statuses;

    const barcodes = [...new Set(rows.map(r => r.barcode))];
    const counts = await this.aggregate([
        { $match: { game, barcode: { $in: barcodes } } },
        { $group: { _id: '$barcode', n: { $sum: 1 } } }
    ]);
    const holders = new Map(counts.map(c => [c._id, c.n]));

    for (const row of rows) {
        const sharedBy = holders.get(row.barcode) || 1;
        statuses.set(row.uuid, {
            status: sharedBy > 1 ? 'shared' : 'available',
            barcode: row.barcode,
            sharedBy
        });
    }

    return statuses;
};

/**
 * Resolve the manufacturer barcode for one sealed product.
 *
 * @param {string} game - Game identifier, never defaulted (CLAUDE.md ยง5.5)
 * @param {string} uuid - Sealed product catalog uuid
 * @returns {Promise<string|null>} The UPC, or null when unknown or ambiguous
 */
sealedProductBarcodeSchema.statics.getBarcode = async function(game, uuid) {
    if (!game) {
        throw new Error('game is required to resolve a sealed barcode');
    }
    if (!uuid) {
        throw new Error('uuid is required to resolve a sealed barcode');
    }

    const statuses = await this.getBarcodeStatuses(game, [uuid]);
    const entry = statuses.get(uuid);
    return entry && entry.status === 'available' ? entry.barcode : null;
};
  • [ ] Step 4: Run to verify they pass
bash
npx vitest run server/models/SealedProductBarcode.test.js

Expected: PASS โ€” 7 getBarcodeStatuses tests + 6 getBarcode tests + the pre-existing schema tests.

  • [ ] Step 5: Verify the sync path still works end to end

buildSealedSyncPayload calls getBarcode. Confirm its tests still pass unchanged โ€” they must, since the signature and behavior did not change:

bash
npx vitest run server/routes/sealedProducts.buildSyncPayload.test.js

Expected: PASS, with no edits to that file.

  • [ ] Step 6: Commit
bash
git add server/models/SealedProductBarcode.js server/models/SealedProductBarcode.test.js
git commit -m "Report why a sealed barcode is unavailable, not just that it is"

Task 2: Annotate sealed products in setDetails โ€‹

Files:

  • Modify: server/routes/setDetails.js (MTG sealed branch at :259-270; export at the file end)
  • Test: server/routes/setDetails.sealedBarcode.test.js (create)

Interfaces:

  • Consumes: SealedProductBarcode.getBarcodeStatuses(game, uuids) from Task 1.
  • Produces: annotateSealedBarcodeStatus(products, statuses) exported from setDetails.js, returning a new array where each product gains barcodeStatus: 'available'|'shared'|'unpublished' and, only when shared, barcodeSharedBy: number.

Note the existing export is module.exports = { handleGetSetDetails }; โ€” add to that object, do not replace it.

  • [ ] Step 1: Write the failing test

Create server/routes/setDetails.sealedBarcode.test.js:

javascript
/**
 * Unit tests for the sealed barcode annotation in setDetails.
 *
 * Deliberately tests the pure annotator rather than the route: the route tests
 * in api.catalogSealed.test.js introspect router.stack against a real local
 * MongoDB, which is slow and needs docker compose. The mapping logic needs
 * neither.
 */

import { describe, it, expect } from 'vitest';
import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);
const { annotateSealedBarcodeStatus } = require('./setDetails.js');

const products = [
    { uuid: 'u1', name: 'The Hobbit Play Booster Box' },
    { uuid: 'u2', name: 'Wakanda Forever (CE)' },
    { uuid: 'u3', name: 'Some Bundle' },
];

describe('annotateSealedBarcodeStatus', () => {
    it('marks a solely-held barcode available', () => {
        const statuses = new Map([['u1', { status: 'available', barcode: '195166326399', sharedBy: 1 }]]);
        const [first] = annotateSealedBarcodeStatus([products[0]], statuses);
        expect(first.barcodeStatus).toBe('available');
        expect(first.barcodeSharedBy).toBeUndefined();
    });

    it('marks a shared barcode and carries the count', () => {
        const statuses = new Map([['u2', { status: 'shared', barcode: '195166313085', sharedBy: 4 }]]);
        const [first] = annotateSealedBarcodeStatus([products[1]], statuses);
        expect(first.barcodeStatus).toBe('shared');
        expect(first.barcodeSharedBy).toBe(4);
    });

    it('marks a uuid absent from the Map as unpublished', () => {
        const [first] = annotateSealedBarcodeStatus([products[2]], new Map());
        expect(first.barcodeStatus).toBe('unpublished');
        expect(first.barcodeSharedBy).toBeUndefined();
    });

    // The shared UPC is deliberately not exposed: showing it invites a merchant
    // to paste it into Shopify, giving several products one barcode and
    // breaking the POS lookup this whole feature protects.
    it('never leaks the barcode value itself', () => {
        const statuses = new Map([['u2', { status: 'shared', barcode: '195166313085', sharedBy: 4 }]]);
        const [first] = annotateSealedBarcodeStatus([products[1]], statuses);
        expect(JSON.stringify(first)).not.toContain('195166313085');
    });

    it('preserves the original product fields', () => {
        const statuses = new Map([['u1', { status: 'available', barcode: 'X', sharedBy: 1 }]]);
        const [first] = annotateSealedBarcodeStatus([products[0]], statuses);
        expect(first.name).toBe('The Hobbit Play Booster Box');
        expect(first.uuid).toBe('u1');
    });

    it('does not mutate its input (ยง5.11)', () => {
        const input = [{ uuid: 'u1', name: 'x' }];
        annotateSealedBarcodeStatus(input, new Map());
        expect(input[0].barcodeStatus).toBeUndefined();
    });

    it('handles an empty product list', () => {
        expect(annotateSealedBarcodeStatus([], new Map())).toEqual([]);
    });
});
  • [ ] Step 2: Run to verify it fails
bash
npx vitest run server/routes/setDetails.sealedBarcode.test.js

Expected: FAIL โ€” annotateSealedBarcodeStatus is not a function.

  • [ ] Step 3: Write the annotator

Add to server/routes/setDetails.js, above handleGetSetDetails:

javascript
/**
 * Attach barcode availability to sealed products for the catalog UI.
 *
 * Three states, because "never will have one" and "might get one later" call
 * for different merchant action โ€” relabel now, versus check back. The barcode
 * VALUE is deliberately never included: surfacing a shared UPC invites a
 * merchant to enter it manually, which would give several products the same
 * barcode and break the POS lookup the suppression exists to protect.
 *
 * @param {Array<object>} products - Mapped sealed products
 * @param {Map<string, {status: string, sharedBy: number}>} statuses - from
 *   SealedProductBarcode.getBarcodeStatuses; a missing uuid means no row
 * @returns {Array<object>} New array; inputs are not mutated (ยง5.11)
 */
function annotateSealedBarcodeStatus(products, statuses) {
    return products.map(product => {
        const entry = statuses.get(product.uuid);
        if (!entry) {
            return { ...product, barcodeStatus: 'unpublished' };
        }
        if (entry.status === 'shared') {
            return { ...product, barcodeStatus: 'shared', barcodeSharedBy: entry.sharedBy };
        }
        return { ...product, barcodeStatus: 'available' };
    });
}

Change the export at the end of the file from:

javascript
module.exports = { handleGetSetDetails };

to:

javascript
module.exports = { handleGetSetDetails, annotateSealedBarcodeStatus };
  • [ ] Step 4: Run to verify it passes
bash
npx vitest run server/routes/setDetails.sealedBarcode.test.js

Expected: PASS โ€” 7 tests.

  • [ ] Step 5: Wire it into the MTG sealed branch

Add the model require alongside the others near the top of server/routes/setDetails.js:

javascript
const SealedProductBarcode = require('../models/SealedProductBarcode');

Replace the sealed block at :259-270:

javascript
            // Include sealed products (usually small list, no pagination needed)
            if (include.includes('sealed')) {
                const sealedProducts = data.sealedProduct || [];
                response.sealedProducts = sealedProducts.map(product => ({
                    uuid: product.uuid,
                    name: product.name,
                    category: product.category,
                    subtype: product.subtype,
                    releaseDate: product.releaseDate,
                    purchaseUrls: product.purchaseUrls,
                    identifiers: product.identifiers
                }));
            }

with:

javascript
            // Include sealed products (usually small list, no pagination needed)
            if (include.includes('sealed')) {
                const sealedProducts = data.sealedProduct || [];
                const mapped = sealedProducts.map(product => ({
                    uuid: product.uuid,
                    name: product.name,
                    category: product.category,
                    subtype: product.subtype,
                    releaseDate: product.releaseDate,
                    purchaseUrls: product.purchaseUrls,
                    identifiers: product.identifiers
                }));

                // One batched lookup for the whole page โ€” a set can list ~30
                // sealed products, and per-product resolution would be ~60
                // queries per page load.
                const barcodeStatuses = await SealedProductBarcode.getBarcodeStatuses(
                    game,
                    mapped.map(p => p.uuid)
                );
                response.sealedProducts = annotateSealedBarcodeStatus(mapped, barcodeStatuses);
            }

game is already in scope in this branch โ€” it is the value the surrounding if (game === 'mtg') tested. Do not introduce a literal.

  • [ ] Step 6: Verify nothing regressed
bash
npx vitest run server/routes/

Expected: PASS. If api.catalogSealed.test.js errors on a MongoDB connection, that is the pre-existing local-Mongo dependency, not this change โ€” start it with docker compose up -d and re-run to confirm.

  • [ ] Step 7: Commit
bash
git add server/routes/setDetails.js server/routes/setDetails.sealedBarcode.test.js
git commit -m "Tell the catalog whether a sealed product has a scannable barcode"

Task 3: SealedBarcodeNote component โ€‹

Files:

  • Create: client/src/components/SealedBarcodeNote.jsx
  • Test: client/src/components/SealedBarcodeNote.test.jsx

Interfaces:

  • Consumes: the barcodeStatus / barcodeSharedBy fields produced in Task 2.

  • Produces: default-exported SealedBarcodeNote({ status, sharedBy }). Renders null for 'available', undefined, or any unrecognised value.

  • [ ] Step 1: Install client dependencies

A fresh worktree has no client/node_modules, and every client test fails without it.

bash
npm --prefix client install
  • [ ] Step 2: Write the failing test

Create client/src/components/SealedBarcodeNote.test.jsx:

jsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import SealedBarcodeNote from './SealedBarcodeNote';

describe('SealedBarcodeNote', () => {
    // The happy path is the majority of rows โ€” a badge on every one would be
    // noise, so silence is the correct render.
    it('renders nothing when a barcode is available', () => {
        const { container } = render(<SealedBarcodeNote status="available" />);
        expect(container).toBeEmptyDOMElement();
    });

    it('renders nothing when status is missing', () => {
        const { container } = render(<SealedBarcodeNote />);
        expect(container).toBeEmptyDOMElement();
    });

    it('explains a shared manufacturer code and how many products share it', () => {
        render(<SealedBarcodeNote status="shared" sharedBy={4} />);
        expect(screen.getByText(/No scannable barcode/i)).toBeInTheDocument();
        expect(screen.getByText(/all 4 products in this line/i)).toBeInTheDocument();
    });

    it('says a barcode may arrive later when none is published', () => {
        render(<SealedBarcodeNote status="unpublished" />);
        expect(screen.getByText(/No barcode published yet/i)).toBeInTheDocument();
        expect(screen.getByText(/future data update/i)).toBeInTheDocument();
    });

    // The two messages must stay distinguishable: one means "print your own
    // labels", the other means "check back".
    it('uses different wording for shared versus unpublished', () => {
        const shared = render(<SealedBarcodeNote status="shared" sharedBy={2} />).container.textContent;
        const unpublished = render(<SealedBarcodeNote status="unpublished" />).container.textContent;
        expect(shared).not.toBe(unpublished);
    });

    it('never displays a raw barcode value', () => {
        const { container } = render(<SealedBarcodeNote status="shared" sharedBy={4} />);
        expect(container.textContent).not.toMatch(/[0-9]{12,13}/);
    });
});
  • [ ] Step 3: Run to verify it fails
bash
npm run test:client -- SealedBarcodeNote

Expected: FAIL โ€” cannot resolve ./SealedBarcodeNote.

  • [ ] Step 4: Write the component

Create client/src/components/SealedBarcodeNote.jsx:

jsx
import { Info } from 'lucide-react';

/**
 * Explains why a sealed product will not get a scannable barcode.
 *
 * Renders nothing in the common case. Shared and unpublished are kept distinct
 * because they call for different merchant action: a shared manufacturer code
 * is permanent (print your own labels), while an unpublished one may arrive in
 * a later data update (check back).
 *
 * The barcode value is never shown โ€” see server/routes/setDetails.js.
 */
export default function SealedBarcodeNote({ status, sharedBy }) {
    if (status !== 'shared' && status !== 'unpublished') return null;

    const count = Number(sharedBy) || 2;
    const message = status === 'shared'
        ? `No scannable barcode โ€” the manufacturer publishes one code for all ${count} product${count === 1 ? '' : 's'} in this line.`
        : 'No barcode published yet โ€” may appear in a future data update.';

    return (
        <p className="flex items-start gap-1 text-xs text-muted-foreground">
            <Info className="h-3 w-3 mt-0.5 shrink-0" aria-hidden="true" />
            <span>{message}</span>
        </p>
    );
}

No retroui component is used here, so the diverging-variant hazard does not apply. lucide-react is already a client dependency โ€” CatalogSetBrowser.jsx:6 imports TrendingUp and Package from it.

  • [ ] Step 5: Run to verify it passes
bash
npm run test:client -- SealedBarcodeNote

Expected: PASS โ€” 6 tests.

  • [ ] Step 6: Commit
bash
git add client/src/components/SealedBarcodeNote.jsx client/src/components/SealedBarcodeNote.test.jsx
git commit -m "Explain in plain words why a sealed product has no barcode"

Task 4: Render the note in both sealed views โ€‹

Files:

  • Modify: client/src/components/CatalogSetBrowser.jsx (import; table row at :1082-1104; SealedProductCard info section)

Interfaces:

  • Consumes: SealedBarcodeNote (Task 3) and the barcodeStatus / barcodeSharedBy fields (Task 2).
  • Produces: no new exports.

Both views must show it. A merchant who prefers table view would otherwise never see the explanation.

  • [ ] Step 1: Add the import

At the top of client/src/components/CatalogSetBrowser.jsx, alongside the other local component imports:

javascript
import SealedBarcodeNote from './SealedBarcodeNote';
  • [ ] Step 2: Render it in the grid card

In SealedProductCard, inside the {/* Product Info */} block, immediately after the release-date paragraph:

jsx
          <p className="text-xs text-muted-foreground">
            {formatShortDate(product.releaseDate)}
          </p>

          <SealedBarcodeNote status={product.barcodeStatus} sharedBy={product.barcodeSharedBy} />
  • [ ] Step 3: Render it in the table row

In the table body, replace the name cell:

jsx
                  <td className="px-2 sm:px-4 py-2 sm:py-3">{product.name}</td>

with:

jsx
                  <td className="px-2 sm:px-4 py-2 sm:py-3">
                    <div className="space-y-1">
                      <span>{product.name}</span>
                      <SealedBarcodeNote status={product.barcodeStatus} sharedBy={product.barcodeSharedBy} />
                    </div>
                  </td>

It goes in the name cell rather than a new column so the table keeps its current column count and responsive hidden sm:table-cell breakpoints.

  • [ ] Step 4: Verify the client builds and tests pass
bash
npm run test:client

Expected: PASS, no regressions.

bash
npm run build

Expected: exit 0.

  • [ ] Step 5: Commit
bash
git add client/src/components/CatalogSetBrowser.jsx
git commit -m "Show barcode availability on sealed products in both catalog views"

Task 5: Full verification โ€‹

  • [ ] Step 1: Server suite
bash
npm test

Expected: exit 0, zero failures.

  • [ ] Step 2: Client suite
bash
npm run test:client

Expected: exit 0, zero failures.

  • [ ] Step 3: Lint
bash
npm run lint

Expected: exit 0. Compare the warning count against the pre-change baseline; it must be unchanged. To confirm the diff itself is clean:

bash
npx eslint server/models/SealedProductBarcode.js server/routes/setDetails.js client/src/components/SealedBarcodeNote.jsx client/src/components/CatalogSetBrowser.jsx -f json | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const r=JSON.parse(s);let w=0,e=0;for(const f of r){w+=f.warningCount;e+=f.errorCount;}console.log('errors:',e,'warnings:',w);})"

Expected: errors: 0 warnings: 0.

  • [ ] Step 4: Build
bash
npm run build

Expected: exit 0.

  • [ ] Step 5: Per-file coverage
bash
npm run test:coverage

Read the per-file table by hand for SealedProductBarcode.js and setDetails.js; each must be โ‰ฅ70% on statements, branches, functions and lines. The configured thresholds are a global ratchet set just under measured totals โ€” they fail on regression but do not enforce the per-file bar, so the printed numbers are the only signal.

  • [ ] Step 6: Confirm the barcode value never reaches the client
bash
git diff main... -- server/routes/setDetails.js | grep -n "barcode:" || echo "clean โ€” no barcode value in the response shape"

Expected: no barcode: field added to the response. Only barcodeStatus and barcodeSharedBy.

  • [ ] Step 7: Confirm no identity defaults (ยง5.5)
bash
git diff main... -- server/ | grep -nE "\|\| 'mtg'|\?\? 'mtg'|= 'mtg'" || echo "NONE (clean)"

Expected: no output.


PR description checklist โ€‹

  • Parity (ยง5.1): mtg implemented; pokemon and riftbound exempt โ€” both return sealedProducts: [] from this endpoint, so there is nothing to annotate. getBarcodeStatuses is game-scoped for when that changes.
  • getBarcode behavior is unchanged; the refactor is guarded by tests that mock the real I/O (find/aggregate) rather than the new method, so they still exercise the full path.
  • No new persisted fields, so ยง5.2 does not apply. No new endpoint, so no Zod schema is needed โ€” the change is additive to an existing response.
  • The shared UPC value is deliberately not returned to the client, asserted by a test.
  • Server and client ship together (ยง5.9); the client renders nothing when barcodeStatus is absent, so deploy ordering does not matter.

Follow-on work (not this PR) โ€‹

  • A Shopify admin block on the product page, if merchants still ask after seeing this at add time.
  • A per-SKU barcode source for the suppressed 36% โ€” GCI-DB was investigated and needs a commercial conversation, not an integration.