static/js/auction.js (progressive enhancement; page works without JS):
- Live countdown clock that ticks every 1s using data-end-timestamp
attribute pre-rendered by auction.j2
- /a/{id}.json poll every 5s for state changes (current high,
end_timestamp updates from soft-close, terminal state)
- AJAX bid form submit with X-Requested-With header; updates UI
without full page reload, shows flash with "winning" or "outbid by
proxy" message; on terminal state disables bid + buy-now buttons
static/css/common.css — token-only auction & offer components:
- .auction-stats grid + .auction-stat label/value
- .auction-state-* color per state (Draft/Scheduled/Active/Ended/
Settled/Cancelled)
- .offer-state-* color per state (Pending/Accepted/Countered/
Declined/Expired/Withdrawn/Paid)
- .auction-bid-form grid layout
- .auction-flash success/error banner
- .offer-events / .offer-event audit timeline
styleguide.j2 entries (live previewable at /styleguide):
- Auction state badges (all 6 states)
- Auction countdown stat block
- Bid form (with min and proxy ceiling)
- Offer state badges (all 7 states)
- Make Offer entry-point (collapsed details)
Functional tests:
- /styleguide renders the new components
- /static/js/auction.js is served (200, contains auction-page hook)
- auction.j2 references /static/js/auction.js
Total: 943 tests pass (was 940 + 3).
Adds three email types and wires them into the bid + offer flows:
- AUCTION_OUTBID — sent to the previous high bidder when their bid
is beaten (auction_bid view, after place_bid succeeds and is_winning
flips to a new bidder)
- OFFER_RECEIVED — sent to all shop owners when a new pending offer
arrives (offer_open view, only when offer queues; auto-accept and
auto-decline use different paths)
- OFFER_ACCEPTED — sent to the buyer when the seller (or buyer
themselves) accepts the current amount (open_offer auto-accept lane;
offer_accept view explicit accept)
Email failures are caught and logged; the bid/offer state is already
persisted by the time we attempt to send, so a misconfigured SMTP
relay does not break the user flow.
Tick-driven emails (auction won when tick.tick ends an auction; offer
expired when tick.tick expires an offer) are deferred — they require
a request context for URL building, and tick scripts run from cron
without one. A future commit can either wire a request-less email
sender or queue the events for the next view that runs.
Templates added to lib/mail_messages.py:
- AUCTION_OUTBID_TEXT / AUCTION_OUTBID_HTML
- OFFER_RECEIVED_TEXT / OFFER_RECEIVED_HTML
- OFFER_ACCEPTED_TEXT / OFFER_ACCEPTED_HTML
Send helpers in lib/mail.py:
- send_auction_outbid_email(request, to, auction)
- send_offer_received_email(request, to, offer)
- send_offer_accepted_email(request, to, offer)
Tests:
- 3 unit (test_models.py) — verify each helper builds the right
subject/body/url with mocked send_pyramid_email
- 1 functional — POST /o/{id}/accept calls send_offer_accepted_email
with the buyer's email
Total: 940 tests pass (was 936 + 4).
Without these scripts, auctions stay ACTIVE forever and offers never
expire. Cron runs them on a schedule to drive the state machines.
lib/auction_tick.py:
- Pure functions transition_scheduled_to_active and transition_active_to_ended
- tick(dbsession) orchestrator scans:
- SCHEDULED + start_timestamp <= now → ACTIVE
- ACTIVE + end_timestamp <= now → ENDED (winner from is_winning bid;
payment_deadline_timestamp set to end + 48h)
lib/offer_tick.py:
- tick(dbsession) scans non-terminal offers (PENDING / COUNTERED) past
expires_timestamp and calls lib/offer.expire_offer on each, which
flips state to EXPIRED and writes the OFFER_EVENT_EXPIRE audit row
Both ticks are idempotent — running twice on already-transitioned rows
is a no-op.
Cron entry points:
- scripts/auction_tick.py — every minute (* * * * *) recommended;
60s default soft-close window means 1-min granularity is fine
- scripts/offer_tick.py — every 15 minutes; offers expire at hour
granularity so coarse polling is enough
Tests:
- 2 unit (test_models.py): pure transition_scheduled_to_active /
transition_active_to_ended for state, timestamp, and missing-data branches
- 4 integration auction: SCHEDULED→ACTIVE on start passing, ACTIVE→
ENDED with winner recorded, ACTIVE→ENDED with no bids leaves winner
None, idempotent
- 4 integration offer: PENDING past expires → EXPIRED, live offer not
touched, terminal offer not touched, idempotent
Total: 936 tests pass (was 926 + 10).
Closes the payment loop for both auction and offer modes. After this
commit, an auction winner can pay the agreed bid amount; an offer-
accepted buyer can pay the agreed offer amount. Cart total computation
uses the auction's winning_bid or offer.current_amount when an
association is present.
New tables (idempotent migration):
- mps_cart_auction (cart_id ↔ auction_id) — cart total uses winning bid
- mps_cart_offer (cart_id ↔ offer_id) — cart total uses agreed amount
Cart.auction_offer_override_in_cents property returns the override
amount when either association is set; None otherwise. total_price_in_cents
short-circuits to the override + handling + gift-card-purchases when an
override is present.
Routes:
- POST /a/{auction_id}/checkout winner only; auction must be ENDED;
builds a fresh cart, adds product, creates MpsCartAuction
- POST /o/{offer_id}/checkout buyer only; offer must be ACCEPTED;
builds a fresh cart, adds product, creates MpsCartOffer
Both checkouts redirect to /cart on success or back to /a/{id} or
/o/{id} on rejection (with flash message).
Settle hook in views/cart.py: after invoice is paid, _finalize_auction_offer_state
flips auction.state=SETTLED (recording winner_user_id + winning_bid_id)
and calls lib/offer.mark_paid which transitions offer ACCEPTED → PAID
and writes the OFFER_EVENT_PAY audit row.
Tests:
- 4 integration: cart-auction + cart-offer associations persist + cascade
- 3 integration: cart total override (no association → list price;
with auction → winning bid; with offer → agreed amount)
- 3 functional auction checkout: winner builds cart with override,
non-winner blocked, active auction blocked
- 3 functional offer checkout: buyer builds cart with override,
non-buyer blocked, pending offer blocked
Total: 926 tests pass (was 913 + 13).
Shop owner-facing:
- shop_settings.j2: new "Make an Offer" form section (form_section=offer-settings)
with offer_enabled, auto_accept_threshold_pct, auto_decline_threshold_pct,
offer_min, offer_expiration_hours, offer_max_rounds,
offer_min_buyer_account_age_hours
- views/shop.py: form_section=offer-settings handler with input clamping
(decline forced strictly below accept; numeric inputs bounded)
- product_edit.j2: pricing_mode radio (5 options: fixed, auction, auction+
buy_now, offer, offer+buy_now), allow_offers radio (inherit/yes/no)
appearing only when product is in offer mode, link to live auction
page when one exists
- views/product.py: handles pricing_mode change; flipping into auction
mode (1 or 2) creates a draft MpsAuction with start_price seeded
from product.price_in_cents and default bid_increment / soft_close
from lib/auction defaults
Buyer-facing:
- product.j2: "View live auction" link when pricing_mode is auction;
"Make an offer" details/form when offers_allowed AND user authenticated
AND user not in shop owners; "Log in to make an offer" CTA for anon;
Add To Cart only renders when is_buy_now_allowed (modes 0, 2, 4)
Tests (9 new, all passing):
- TestOfferSettingsForm: enable+set thresholds (round-trip), decline
clamped below accept, blank offer_min clears the floor
- TestPricingModeFormSection: flip to auction creates draft auction,
flip to offer leaves auction None, allow_offers override (yes/no/
inherit), invalid pricing_mode value ignored, Make Offer button
renders when eligible, Make Offer hidden for shop owner
Total: 913 tests pass (was 904 + 9).
Routes (registered before shop_slug-style catch-alls so .json suffix is
not shadowed):
- GET /a/{auction_id}.json live state for poll
- POST /a/{auction_id}/bid place a bid (login required)
- POST /a/{auction_id}/buy-now end auction at buy_now price (mode 2)
- POST /a/{auction_id}/watch toggle watcher
- GET /a/{auction_id} live page (templates/auction.j2)
views/auction.py:
- _serialize_auction shapes the same dict for both template ctx and JSON
- _user_is_seller checks request.user against auction.shop.owners — pure
validate_bid in lib/auction does not have visibility into ownership,
so the view enforces "no self-bidding" with HTTP 403
- auction_bid parses dollar input, converts to cents, calls place_bid,
returns either {ok, bid_amount, is_winning, auction_state} or {error}
- auction_buy_now places a bid at buy_now price, sets state to ENDED,
records winner_user_id and winning_bid_id
- auction_watch toggles MpsAuctionWatcher row
templates/auction.j2:
- Live page extending base.j2 with countdown placeholder, current high,
bid form (amount + optional max_proxy), buy-now form when configured,
watch toggle
- Hides bid form when seller views own auction or anon visitor (with
log-in CTA)
Functional tests (11): page renders for anon, 404 unknown id, JSON
state, seller cannot bid (403), anon cannot bid (redirect), buyer
places first bid, bid below increment rejected (400), invalid amount
rejected (400), watch toggle round-trip, buy-now 404 when not offered,
buy-now ends auction and records winner.
Cart integration deferred to commit 5 (bundling with offer cart
integration since both add a non-list-price line item pattern).
Total: 892 tests pass (was 881 + 11).
Pure functions:
- validate_bid: state must be ACTIVE; first bid >= start_price; subsequent
bids >= current_high + bid_increment; positive integer; max_proxy >= amount
- is_within_soft_close: now_ms inside (end - soft_close_seconds*1000, end]
- extended_end_timestamp: now_ms + soft_close_seconds*1000
- resolve_proxy: eBay-style — higher proxy wins; loser auto-bids defending
bidder up to min(loser_proxy + increment, winner_proxy); ties go to
the existing top (first-in wins)
Orchestrator place_bid:
- writes the new bid, marks the prior winning bid is_winning=False with
outbid_timestamp, applies proxy resolution to choose visible amounts,
applies soft-close to extend end_timestamp when bid lands in window,
bumps auction.updated_timestamp
BidRejected exception carries reason in .args[0].
Tests:
- 16 unit tests: validate_bid matrix (state, start_price, increment,
proxy >= amount), soft-close window math, 6 proxy resolution edge
cases (no proxy, defending proxy auto-increments, breaking through
top proxy, tie tie-break, capped at ceiling)
- 8 integration tests: first bid wins, increment floor, outbid marks
prior bid is_winning=False, exactly-one-winner invariant, proxy
defending bidder auto-increments, soft-close fires only inside window,
inactive auction rejects
Self-bid (bidder == seller) blocking is the view layer's responsibility —
the pure validate_bid does not have visibility into seller identity.
Total: 859 tests pass (was 835 + 24).
Karaoke (MPS-18) and torrent (MPS-19) are broken in production. Adding
two global feature flags off by default so neither feature surfaces in
UI or accepts route traffic until they're fixed.
Pattern mirrors app.features.popout_player.enabled — reified request
properties (request.karaoke_enabled, request.torrent_enabled) read from
ini settings. Templates wrap UI in {% if %}, views return HTTPNotFound
on form sections + routes, view contexts blank out feature-specific
keys when flag off so SPA navigation does not try to render them.
test.ini sets both flags True so existing feature tests keep working.
TestKillSwitches builds a fresh app with both False and verifies the
off path: form_section POSTs return 404, settings page omits sections,
karaoke route 404s, landing page omits karaoke marketing copy.
GET /s/{shop_id}/torrent-backfill-status is shadowed by an earlier
shop_slug catch-all route in production — pre-existing routing defect
that MPS-19 needs to fix when it lands.
MPS-18: diagnose and fix broken karaoke vocal isolation pipeline.
MPS-19: diagnose and fix broken torrent / magnet link distribution.
MPS-20: propose eBay-style auction house mode (bidding, reserve,
soft-close, proxy, buy-now).
MPS-21: propose make-an-offer mode (counter/accept/decline/expire
state machine, auto-accept and auto-decline thresholds).
Each ticket carries proposal, full file list, models, state machine,
GTM plan, and unit/integration/functional test requirements.
architecture.md ticket index extended with MPS-17 (was missing) plus
the four new tickets.
Mobile previously placed product-right (price, download, Up Next)
as the 2nd section, right after images — a different reading flow
from desktop and cinema modes, which keep description + comments
above/alongside product-right.
Unified order for every mode and viewport:
1. images (sticky video/cover on mobile + desktop watch)
2. description
3. comments
4. product-right (price, download, Up Next)
Desktop normal: column 1 = images → description → comments (stacked),
column 2 = product-right (spans all rows on the right).
Desktop cinema: row 1 = images full-width, row 2 = content (description
+ comments stack) on the left, product-right on the right.
Mobile normal: all four stacked single-column in that order. Cinema
stays a no-op below 800px; the classes exist but match no rules and
the page falls through to the consistent mobile watch-mode layout.
CLAUDE.md mobile layout section updated to match.
Mobile (<800px) never really needed a different cinema layout — the
normal watch-mode mobile rules already stack images, purchase,
description, and comments in a single column with full-viewport-width
media. Cinema was re-ordering those sections (putting purchase AFTER
description/comments) and creating an inconsistency between cinema
and normal modes on the same device.
Fix: gate every cinema layout rule on @media (min-width: 800px). Below
that the .cinema-mode.cinema-wide classes exist but match no layout
rules, and the page falls through to normal mobile watch-mode.
JS: isViewportWideEnoughForCinema() uses the same 800px boundary to
decide whether to relocate the hamburger+Edit taskbar into the
sidebar. A debounced resize listener re-runs applyCinemaMode() so
the layout flips cleanly when the viewport crosses the boundary.
Three polish tweaks for cinema mode in narrow desktop panels:
1. 2-column layout kicks in at >=800px instead of >=960px. Cinema's
video already fills the full width, so the content | purchase
split below it doesn't need a typical desktop-wide viewport —
it works fine on smaller panels. This removes the 800-960px dead
zone where cinema-wide was still single-column stacking.
2. Comments get 24px margin-top + 20px padding-top + a top border so
they read as their own section instead of running continuous into
the description above and the sidebar controls below.
3. Stacked sidebar buttons (hamburger, Edit, Download) now have an
8px margin-bottom between each so they don't look shoulder-to-
shoulder. Inherits evenly from the task-bar grid gap.
Hamburger, Edit, and Download now all render as the same component
in the cinema sidebar — full column width, same padding (14px 16px),
same font size, same line height. mps-button-small's narrow min-width
was making Edit look like a leftover chip next to the full-width
hamburger and Download buttons; overridden in the cinema sidebar
scope only.
Task-bar grid gap bumped to 8px so hamburger + Edit don't touch
each other, and their nested padding zeroed so spacing lives in
the outer grid gap rather than in mixed inline padding.
Portrait and square media stay in the normal watch layout even when
the Cinema toggle is on — tall phone videos no longer get stretched
into a skinny column on a wide screen. Only wider-than-square media
triggers the layout adjustment that makes wide/ultrawide content
fill the horizontal viewport.
Two-class gate:
.cinema-mode — user intent (from the toggle)
.cinema-wide — runtime state (current media aspect > 1)
CSS layout rules now require BOTH classes. When cinema is on but
media is portrait, .cinema-mode is on and .cinema-wide is off, and
the selectors don't match — normal watch layout applies.
Aspect detection reads video.videoWidth/videoHeight or
img.naturalWidth/naturalHeight. Unknown dimensions (metadata not
yet loaded) defaults to not wide; a loadedmetadata / img.load
listener re-invokes applyCinemaMode once real dimensions are known.
Taskbar relocation also gated on cinema-wide — portrait doesn't
steal the hamburger + Edit from their normal home.
Two cinema-mode polishes:
1. Top task bar (hamburger / shop name / Edit button) relocates into
.product-right above the .well on cinema-on. Cached original parent
+ next sibling at init lets us put it back on cinema-off. Moved
within the same DOM subtree that survives SPA nav so it persists
across product changes. CSS stacks its children vertically inside
the sidebar instead of the horizontal strip shape up top.
2. The 'click image to open in new window' wrapper link on static
products (images, PDFs) is redundant in cinema mode since media
already fills the viewport. pointer-events:none disables the
click without removing the anchor from the DOM.
Previously purchase (ring sidebar) spanned grid rows 2 and 3. When
Up Next was tall and description was short, grid distributed the
spanning column's height across both rows — description sat at top
of a stretched row 2 with hundreds of pixels of empty space before
comments.
New structure: wrap description + comments in .cinema-content-stack.
Outside cinema mode the wrapper is display:contents (transparent,
zero effect). In cinema mode it becomes a single grid cell containing
description + comments as an internal stack. Main grid is now just
two rows: images (full width) then content | purchase.
Row 2 height = max(content column, purchase column). If purchase is
taller, content stack still starts at top, and description + comments
stay glued together at the top of the column — comments is reachable
without scrolling past a dead zone.
Cinema rules previously lived only inside @media (min-width: 960px).
Between 800px and 960px the rules silently vanished and section.two-column
fell back to natural block flow — description, comments, download,
and Up Next stacked chaotically while the video floated elsewhere.
Split-screen viewports and tablet widths hit this gap.
Base cinema rules now sit outside any media query:
narrow: single column — video > description > comments > purchase
>= 960px: 2fr 1fr — video full-width top, description+comments
left column, product-right (ring+download) right column
Video sizing (width:100% + max-height:92vh + object-fit:contain)
applies at all sizes.
Cinema toggle kept flowing onto its own row because ring-header-controls
was 3-col. Bumped to 4-col so row 1 fits Fresh / Reverse / Autoplay /
Cinema together.
Nav buttons (Prev / Random / Next) now use explicit grid-columns —
Next spans cols 3→end — so there's no empty fourth column on the
right edge. Karaoke button keeps its full-row span via grid-column: 1/-1.
Cinema grid now mirrors the non-watch desktop layout below the video:
row 1: video (full viewport width)
row 2: description | product-right (price + download + Up Next)
row 3: comments | product-right (continues, spans 2 rows)
Previously description took full width and purchase/comments
shared row 3. Fox wants the ring visible alongside the description
so viewers see Up Next while reading, and comments stacked below
description in the same column.
Two paths to stale client state, both closed:
1. HTML cache — content.py and product.py now send Cache-Control:
no-store, must-revalidate on responses. Browsers were holding
onto rendered sidebar HTML from before the pocket fix deployed,
producing phantom 'this didn't work' reports.
2. localStorage cache — shop.json_discovery_ring + GIT_HASH are
hashed into a short cache_version token, stamped on every page
(<meta name='mps-cache-version'>) and every watch_json response.
watch.js stores it in localStorage; on page load and every SPA
nav, mismatch triggers removal of watchRing, watchRingPosition,
watchRingHistory, watchRingLoops, watchQueue before anything
reads them.
watch_json error responses (404 no media, 403 not public) also
carry cache_version so clients can flush even when the target
product can't be played.
Tests:
- test_models.py TestCacheVersion: 6 unit tests (stability, ring
content change, ring order change, empty ring, none shop,
GIT_HASH flip via patch).
- test_functional.py: 3 functional tests (content page sends
no-store + meta tag, watch_json error carries cache_version,
cache_version shifts after reforge).
All 370 model+integration tests + 8 new functional tests green.
Adds validate_discovery_ring(shop) in models/shop.py that returns a
dict diagnosing four ring topology defects:
- duplicates: IDs appearing more than once in ring (greedy-walk bug)
- orphans: public products missing from ring (added after reforge)
- stale: ring IDs no longer public/present (deleted or unlisted
after reforge — the 'pocket' condition we just patched)
- length_mismatch: ring_length != public_count
Wired into reforge_discovery_ring_async — anomalies log a warning
after each background reforge, making silent drift visible.
New route /s/{shop_id}/ring/health.json exposes the validator to
shop mods (403 for anon and non-editor users, 404 for missing shop).
Tests across all three layers:
- Unit (test_models.py, 7 tests): mocked shop.products, each
anomaly class verified in isolation.
- Integration (test_integration.py, 4 tests): real shop + products
+ reforge, simulates visibility changes and late additions,
confirms reforge heals the ring.
- Functional (test_functional.py, 5 tests): auth required, mod
ownership enforced, 404 on unknown shop, real-world stale
detection through the HTTP endpoint.