Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build-number.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
12
13
2 changes: 2 additions & 0 deletions docs/break-buyer-research-2026-08-11.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,8 @@ Each proposal states the problem, the evidence, the change, acceptance criteria,

**Change.** Where a sheet carries `balanceColors`, surface a named omission stating that per-colour counts on that sheet are not derivable, and treat the slot's card count as a range rather than a point value. Where a sheet does not carry the flag, per-card weights are usable directly and confidence stays higher.

**Superseded (2026-09).** The omission told the buyer about a gap they could do nothing about, while the simulator drew the sheet as if unbalanced — which understates every mono colour's floor, because a modelled opening could miss a colour that a real pack cannot. Sampling now applies the one guarantee the flag does carry: the sheet spends its first five picks on one card of each mono colour, and draws the remainder by its own printed weights. No stronger per-colour distribution is claimed, and nothing is excluded — colourless and land commons still come through the free picks. The omission now fires only when the resolved sheet has lost a whole colour and the guarantee therefore cannot be honoured.

**Acceptance.** No code path infers a colour split from an unbalanced-unknown sheet. The omission is named specifically enough for a reviewer to act on.

**Constraint.** This is the CLAUDE.md rule applied literally: never silently infer sheet weights, emit a named omission and lower confidence.
Expand Down
5 changes: 4 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ export function App({ releaseContext = runtimeReleaseContext }: { releaseContext
// Every mode - including "home" - gets its own hash, so a real (non-SPA)
// navigation back to this URL resolves to the same mode instead of
// falling through to the buyer-workspace default. See route-mode.ts.
history.replaceState(null, "", `${location.pathname}${location.search}${hashForMode(next)}`);
// Keep whatever the history entry already carries (the buyer workspace
// marks its own break URLs there) so leaving and returning does not turn
// this browser's own link into someone else's shared break.
history.replaceState(history.state, "", `${location.pathname}${location.search}${hashForMode(next)}`);
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
if (next === "home") window.setTimeout(() => document.querySelector<HTMLElement>("[data-home-focus]")?.focus({ preventScroll: true }), 220);
};
Expand Down
4 changes: 3 additions & 1 deletion src/analytics.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
export type AnalyticsEvent =
| "persona_selected" | "builder_opened" | "product_selected" | "builder_abandoned"
| "calculation_completed" | "decision_eligibility" | "draft_resumed"
| "buyer_setup_copied";
| "buyer_setup_copied" | "price_refresh_requested" | "break_link_shared";

const ALLOWED: Record<AnalyticsEvent, readonly string[]> = {
persona_selected: ["mode", "viewportClass"], builder_opened: ["mode"],
product_selected: ["mode", "productCount"], builder_abandoned: ["mode", "durationBucket"],
calculation_completed: ["mode", "productCount", "status", "durationBucket"],
decision_eligibility: ["mode", "eligibility"], draft_resumed: ["mode", "productCount"],
buyer_setup_copied: ["mode", "productCount"],
price_refresh_requested: ["mode", "productCount"],
break_link_shared: ["mode", "productCount"],
};

export function analyticsPayload(
Expand Down
143 changes: 143 additions & 0 deletions src/buyer-actionable-notices.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { createElement } from "react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { BuyerView, ContributorRows, type PriceRefreshState } from "./features/buyer/BuyerDetails";
import { useOutcomeSimulation } from "./features/buyer/BuyerVisuals";
import { createAuction } from "./domain/auction";
import { calculateBreak } from "./domain/valuation";
import { DEFAULT_BUYER_COSTS } from "./domain/bid-ceiling";
import type { BreakAnalysis } from "./data/evaluate";
import type { DecisionEligibility, SlotValuation } from "./domain/types";

const valuation = calculateBreak({
prices: [
{ id: "w", set: "TST", collectorNumber: "1", name: "White", slot: "W", nonfoil: 10, foil: null },
{ id: "u", set: "TST", collectorNumber: "2", name: "Blue", slot: "U", nonfoil: 20, foil: null },
],
draws: [
{ set: "TST", collectorNumber: "1", copies: 1, foil: false, source: "fixed" },
{ set: "TST", collectorNumber: "2", copies: 1, foil: false, source: "fixed" },
],
threshold: 2,
});

const analysis: BreakAnalysis = {
valuation,
outcomeModel: { complete: true, packs: [], fixed: [{ id: "w", slot: "W", value: 10 }] },
outcomeOmissions: [],
};

const staleEligibility: DecisionEligibility = {
status: "stale",
blockerCount: 1,
affectedGroups: [],
observedAt: new Date(Date.now() - 9 * 60 * 60 * 1000).toISOString(),
ageMs: 9 * 60 * 60 * 1000,
freshnessThresholdMs: 6 * 60 * 60 * 1000,
resolvedOnlyAvailable: true,
reason: "stale-price-snapshot",
};

function Decision({
eligibility,
priceRefresh = "idle",
onRefreshPrices,
}: {
eligibility?: DecisionEligibility;
priceRefresh?: PriceRefreshState;
onRefreshPrices?: () => void;
}) {
const auction = createAuction();
const simulation = useOutcomeSimulation(analysis, auction.remaining, undefined);
return createElement(BuyerView, {
analysis,
eligibility,
auction,
selectedSlots: [],
costs: DEFAULT_BUYER_COSTS,
simulation,
priceRefresh,
onRefreshPrices,
});
}

describe("stale prices are an action, not an announcement", () => {
it("offers a refresh control instead of a read-only age notice", async () => {
const refresh = vi.fn();
render(createElement(Decision, { eligibility: staleEligibility, onRefreshPrices: refresh }));

const control = await screen.findByRole("button", { name: /Prices over 6 hours old/ });
fireEvent.click(control);
expect(refresh).toHaveBeenCalledTimes(1);
});

it("reports each phase, then the real answer, in the picker's own words", async () => {
const { rerender } = render(createElement(Decision, {
eligibility: staleEligibility, onRefreshPrices: () => {}, priceRefresh: "searching",
}));
expect(await screen.findByRole("button", { name: /Searching/ })).toBeDisabled();

rerender(createElement(Decision, {
eligibility: staleEligibility, onRefreshPrices: () => {}, priceRefresh: "updating",
}));
expect(screen.getByRole("button", { name: /Updating/ })).toHaveAttribute("aria-busy", "true");

// Nothing newer exists is an answer, not a silence.
rerender(createElement(Decision, {
eligibility: staleEligibility, onRefreshPrices: () => {}, priceRefresh: "stale",
}));
await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("No newer prices are published yet"));
expect(screen.getByRole("button", { name: /No newer data/ })).toBeEnabled();

// A refused refresh keeps the estimate and offers the retry.
rerender(createElement(Decision, {
eligibility: staleEligibility, onRefreshPrices: () => {}, priceRefresh: "error",
}));
expect(screen.getByRole("status")).toHaveTextContent("keeps the prices it already had");
expect(screen.getByRole("button", { name: /Retry/ })).toBeEnabled();
});

it("keeps the control visible after a refresh makes the estimate fresh", async () => {
// The answer to "did that work?" must outlive the condition that prompted
// it; swapping straight back to a plain label loses the result.
render(createElement(Decision, { onRefreshPrices: () => {}, priceRefresh: "updated" }));
expect(await screen.findByRole("button", { name: /Updated/ })).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent("Newer prices are in this estimate");
});

it("keeps a plain label when the estimate is fresh", async () => {
render(createElement(Decision, { onRefreshPrices: () => {} }));
await waitFor(() => expect(screen.getByRole("region", { name: "Bid decision" })).toBeInTheDocument());
expect(screen.queryByRole("button", { name: /Prices over 6 hours old/ })).not.toBeInTheDocument();
});
});

describe("ranked card column help", () => {
const slot = {
...valuation.slots.find((row) => row.id === "W")!,
} as SlotValuation;

it("explains each column as its own paragraph, with the term set apart", () => {
render(createElement(ContributorRows, { slot, onInspect: () => {} }));

fireEvent.click(screen.getByRole("button", { name: "What Chance and Adds mean" }));
const paragraphs = screen.getByRole("tooltip").querySelectorAll(".tip-paragraph");

expect(paragraphs).toHaveLength(2);
expect(paragraphs[0].querySelector("b")).toHaveTextContent("Chance");
expect(paragraphs[1].querySelector("b")).toHaveTextContent("Adds");
expect(paragraphs[1]).toHaveTextContent("average number of copies opened");
});

it("gives every ranked row a chance cell and an adds cell", () => {
const { container } = render(createElement(ContributorRows, { slot, onInspect: () => {} }));
const row = container.querySelector(".contributor-card")!;

// Four cells, in the order the four column headers name them. A row with
// fewer wraps its last cell onto its own line and reads as an empty
// Chance column.
expect(row.children).toHaveLength(4);
expect(row.querySelector(".pull-odds")).not.toBeEmptyDOMElement();
expect(row.querySelector(".ev-contribution")).not.toBeEmptyDOMElement();
});
});
72 changes: 72 additions & 0 deletions src/buyer-share-link.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { createElement } from "react";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { BuyerWorkspace } from "./features/buyer/BuyerWorkspace";
import { decodeBuyerShare } from "./domain/share-url";
import { decodeLegacySearch } from "./domain/legacy";

const SHARED = "/ColorBreak/?b=MH2.collector-pack.1~EOE.play-pack.2&m=random&s=&r=WUBRGMCL&f=1&t=2#buyer";

// The workspace's data layer is not under test here; a link must carry the
// break whether or not the price snapshot answers.
beforeEach(() => { vi.stubGlobal("fetch", () => Promise.reject(new Error("offline"))); });

function mount() {
return render(createElement(BuyerWorkspace, { exit: () => {}, startFresh: false, startReady: false }));
}

describe("a break travels in its own link", () => {
it("keeps the break in the address bar so the visible URL is the shareable one", async () => {
history.replaceState(null, "", SHARED);
mount();

// Historically the query was stripped on arrival, so the address bar a
// recipient forwarded carried no break at all.
await waitFor(() => expect(decodeLegacySearch(location.search)).toHaveLength(2));
expect(decodeBuyerShare(location.search).assignmentMode).toBe("random");
expect(location.hash).toBe("#buyer");
});

it("marks its own URL so a reload is not announced as someone else's break", async () => {
history.replaceState(null, "", SHARED);
mount();
await waitFor(() => expect(history.state).toMatchObject({ colorbreakOwn: true }));

expect(screen.getByLabelText("Shared calculation details")).toBeInTheDocument();

// Reload: same URL, but this browser wrote it, so it is this buyer's own
// working break rather than an incoming shared calculation.
cleanup();
mount();
await waitFor(() => expect(screen.queryByLabelText("Shared calculation details")).not.toBeInTheDocument());
});

it("shares through the platform sheet when one exists", async () => {
history.replaceState(null, "", SHARED);
const share = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "share", { configurable: true, value: share });
mount();

await act(async () => { fireEvent.click(screen.getByLabelText("Share this break")); });

expect(share).toHaveBeenCalledTimes(1);
expect(decodeLegacySearch(new URL(share.mock.calls[0][0].url).search)).toHaveLength(2);
Reflect.deleteProperty(navigator, "share");
});

it("falls back to the clipboard, and to a readable link when that is refused", async () => {
history.replaceState(null, "", SHARED);
Reflect.deleteProperty(navigator, "share");
let copied = "";
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText: async (value: string) => { copied = value; } },
});
mount();

await act(async () => { fireEvent.click(screen.getByLabelText("Share this break")); });

expect(decodeLegacySearch(new URL(copied).search)).toHaveLength(2);
expect(screen.getByLabelText("Break link")).toHaveValue(copied);
});
});
2 changes: 1 addition & 1 deletion src/data/scryfall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ export async function loadPrices(request: PriceLoadRequest): Promise<PriceLoadRe
const message = status === "available"
? `Exact-printing prices loaded from the ${source === "snapshot" ? "published snapshot" : source}.`
: status === "stale"
? "Latest published snapshot is older than six hours; reload to check publication status."
? "Latest published snapshot is older than six hours; refreshing checks for a newer publication."
: status === "partial"
? "Some exact-printing prices could not be loaded; values are a lower bound."
: "Exact-printing prices are temporarily unavailable; product contents remain intact.";
Expand Down
70 changes: 67 additions & 3 deletions src/features/buyer/BuyerDetails.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { AnswerValue, AnswerNote, AnswerGraphic } from "../shared/Answer";
import { useEffect, useMemo, useState } from "react";
import type { CSSProperties } from "react";
import { Search, ShieldAlert } from "lucide-react";
import { RefreshCw, Search, ShieldAlert } from "lucide-react";
import type { BreakAnalysis } from "../../data/evaluate";
import type { PriceRefreshPhase, PriceRefreshResult } from "../../data/scryfall";
import { bidCeiling } from "../../domain/bid-ceiling";
import type { BuyerCosts } from "../../domain/bid-ceiling";
import { decisionEligibility } from "../../domain/valuation";
Expand Down Expand Up @@ -168,7 +169,10 @@ function CardThumbnail({ row }: { row: Contributor }) {

const CONTRIBUTOR_PAGE = 10;

const CONTRIBUTOR_COLUMN_HELP = "Chance: how often at least one copy of this exact card version turns up when this break is opened. Adds: how much that card contributes to the colour's average value, which is its price multiplied by the average number of copies opened.";
const CONTRIBUTOR_COLUMN_HELP = [
"Chance: how often at least one copy of this exact card version turns up when this break is opened.",
"Adds: how much that card contributes to the colour's average value, which is its price multiplied by the average number of copies opened.",
].join("\n\n");

/**
* The ranked card list is long — hundreds of printings in a big break — so it
Expand Down Expand Up @@ -508,6 +512,46 @@ export function LargeBreakView({
);
}

/**
* The buyer's refresh speaks the same vocabulary as the product picker's, so
* one word means one thing across the app. Phases are transient; the rest are
* the answer the buyer asked for and stay on screen until the break changes.
*/
export type PriceRefreshState = "idle" | PriceRefreshPhase | PriceRefreshResult | "error";

const PRICE_REFRESH_BUSY: PriceRefreshState[] = ["searching", "updating"];

const PRICE_REFRESH_LABEL: Record<PriceRefreshState, string> = {
idle: "Prices over 6 hours old · Refresh",
searching: "Searching…",
updating: "Updating…",
updated: "Updated",
current: "Up to date",
stale: "No newer data",
partial: "Partial update",
error: "Retry",
};

const PRICE_REFRESH_DETAIL: Record<PriceRefreshState, string> = {
idle: "Check the latest published prices and update this estimate.",
searching: "Checking the price publication.",
updating: "Loading newer prices.",
updated: "Newer prices loaded. Tap to check again.",
current: "This estimate already uses the latest publication. Tap to check again.",
stale: "Checked the latest publication; newer prices are not available yet. Tap to check again.",
partial: "Some new prices could not load. Existing prices fill those gaps. Tap to retry.",
error: "Refresh failed. Your existing estimates are kept. Tap to retry.",
};

const PRICE_REFRESH_ANSWER: Record<PriceRefreshState, string> = {
idle: "", searching: "", updating: "",
updated: "Newer prices are in this estimate.",
current: "This estimate already used the latest published prices.",
stale: "No newer prices are published yet. The estimate still uses the latest published snapshot.",
partial: "Some prices could not be refreshed. The estimate keeps the prices it already had for those.",
error: "The price publication could not be checked. The estimate keeps the prices it already had.",
};

export function BuyerView({
analysis,
eligibility: assessedEligibility,
Expand All @@ -516,6 +560,8 @@ export function BuyerView({
breakLabel,
costs,
simulation,
priceRefresh = "idle",
onRefreshPrices,
}: {
analysis: BreakAnalysis;
eligibility?: DecisionEligibility;
Expand All @@ -526,6 +572,8 @@ export function BuyerView({
simulation: OutcomeSimulation;
onChooseReady?: () => void;
onUseManualCap?: () => void;
priceRefresh?: PriceRefreshState;
onRefreshPrices?: () => void;
}) {
const result = analysis.valuation;
const eligibility = assessedEligibility ?? decisionEligibility(result);
Expand All @@ -552,7 +600,22 @@ export function BuyerView({
<section className="bid-live-decision" aria-label="Bid decision">
<div className="decision-kicker">
<span title={decisionKicker}>{decisionKicker}</span>
<span className={`decision-evidence evidence-${result.status}`}>{eligibility.status === "eligible" ? "Fresh estimate" : eligibility.status === "stale" ? "Prices over 6 hours old" : result.status}</span>
{(eligibility.status === "stale" || priceRefresh !== "idle") && onRefreshPrices
// Stale prices are something the buyer can act on, so the label is
// the control that acts on it rather than a notice they can only
// read. It stays put after the check so the answer is legible.
? <button
type="button"
className={`decision-evidence evidence-${result.status} refresh-prices`}
onClick={onRefreshPrices}
disabled={PRICE_REFRESH_BUSY.includes(priceRefresh)}
aria-busy={PRICE_REFRESH_BUSY.includes(priceRefresh)}
title={PRICE_REFRESH_DETAIL[priceRefresh]}
>
<RefreshCw aria-hidden="true" className={PRICE_REFRESH_BUSY.includes(priceRefresh) ? "spinning" : undefined} />
{PRICE_REFRESH_LABEL[priceRefresh]}
</button>
: <span className={`decision-evidence evidence-${result.status}`}>{eligibility.status === "eligible" ? "Fresh estimate" : eligibility.status === "stale" ? "Prices over 6 hours old" : result.status}</span>}
</div>
<div className="verdict-head">
<div className="verdict-decision">
Expand All @@ -568,6 +631,7 @@ export function BuyerView({
<span>My {selectedSlots.length === 1 ? "slot" : "slots"}: {selectedSlots.map((id) => SLOT_NAMES[id]).join(", ")}</span>
<b><AnswerValue value={ownedValue} /></b>
</p>}
{PRICE_REFRESH_ANSWER[priceRefresh] && <p className="price-refresh-answer" role="status">{PRICE_REFRESH_ANSWER[priceRefresh]}</p>}
<AnswerGraphic detail={simulation.result?.sampleCount === 0 ? "MIN and MAX use available pack rules. The typical result is still being refined; missing data can change the limits." : "MIN and MAX are the smallest and largest values possible for one remaining spot under the current pack rules and prices. Missing data can change these limits."}><OutcomeRange summary={distribution} compact /></AnswerGraphic>
{simulation.busy && <p className="simulation-state" role="status" aria-live="polite">Checking more possible openings…</p>}
{simulation.error && <CompactWarning title="Pull ranges unavailable" summary="The non-simulation value remains visible." className="inline-warning"><p role="alert">{simulation.error}</p><button type="button" className="quiet" onClick={simulation.retry}>Retry pull ranges</button></CompactWarning>}
Expand Down
Loading