fix: pay-by countdown reads as human time delta, not UTC wall-clock
Replaces the previous "2026-05-14 11:00 UTC" deadline strings and
the secondary "23h 14m 8s remaining" pill with a single in-place
prose delta on both offer and auction pages.
Server (no-JS fallback): ago.human(deadline, future_tense="in {}")
renders "in 23 hours, 14 minutes". Reaches for the same precision
the buyer cares about, in their reading style, without a wall-clock
string to mentally subtract from. Same library Russell Ballestrini
wrote — public domain, already a dep.
Client (JS-enhanced): offer.js / auction.js rewrite the same
<strong data-pay-deadline="..."> element once per second with a
prose delta computed locally ("in 23 hours, 14 minutes, 8 seconds").
The previous fmtRemaining returned compact "23h 14m 8s" which read
as code — switched to prose to match the server fallback.
Auction page also drops the dual element (separate countdown chip +
human deadline span); JS rewrites in place so the markup is half
the size.
Test test_accepted_offer_renders_pay_countdown_for_buyer locks in
the new markup: a `data-pay-deadline="…">in N units` regex match.
This commit is contained in:
parent
61cda404e9
commit
e77784cd61
7 changed files with 93 additions and 64 deletions
|
|
@ -22,17 +22,25 @@
|
|||
const bidForm = document.getElementById("auction-bid-form");
|
||||
const buyNowForm = document.getElementById("auction-buy-now-form");
|
||||
|
||||
/* fmtRemaining — prose human delta, matches ago.human() server-side.
|
||||
* Top two non-zero units, so the line stays short on small viewports.
|
||||
*/
|
||||
function fmtRemaining(ms) {
|
||||
if (ms <= 0) return "ended";
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const days = Math.floor(totalSeconds / 86400);
|
||||
const hours = Math.floor((totalSeconds % 86400) / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (days > 0) return `${days}d ${hours}h ${minutes}m`;
|
||||
if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
|
||||
if (minutes > 0) return `${minutes}m ${seconds}s`;
|
||||
return `${seconds}s`;
|
||||
const units = [
|
||||
{ name: "day", value: Math.floor(totalSeconds / 86400) },
|
||||
{ name: "hour", value: Math.floor((totalSeconds % 86400) / 3600) },
|
||||
{ name: "minute", value: Math.floor((totalSeconds % 3600) / 60) },
|
||||
{ name: "second", value: totalSeconds % 60 },
|
||||
];
|
||||
const parts = [];
|
||||
for (let i = 0; i < units.length && parts.length < 2; i++) {
|
||||
const u = units[i];
|
||||
if (u.value === 0 && parts.length === 0 && i < units.length - 1) continue;
|
||||
parts.push(u.value + " " + u.name + (u.value === 1 ? "" : "s"));
|
||||
}
|
||||
return "in " + parts.join(", ");
|
||||
}
|
||||
|
||||
function tickCountdown() {
|
||||
|
|
@ -114,29 +122,19 @@
|
|||
});
|
||||
}
|
||||
|
||||
// Pay-by countdown (post-end): same fmtRemaining; written into
|
||||
// #auction-pay-countdown if present. The human deadline string is
|
||||
// also resolved locally from the same data attribute so the user
|
||||
// sees a consistent wall-clock + countdown in their own timezone.
|
||||
const payCountdownEl = document.getElementById("auction-pay-countdown");
|
||||
const payDeadlineHumanEl = document.getElementById(
|
||||
"auction-pay-deadline-human"
|
||||
);
|
||||
|
||||
/* Pay-by countdown (post-end). Rewrites any [data-pay-deadline]
|
||||
* element on the page in place — the server pre-fills the same
|
||||
* element with ago.human(), and we refine to second precision. */
|
||||
function tickPayCountdown() {
|
||||
if (!payCountdownEl) return;
|
||||
const raw = payCountdownEl.getAttribute("data-pay-deadline");
|
||||
if (!raw) return;
|
||||
const deadline = parseInt(raw, 10);
|
||||
if (isNaN(deadline)) return;
|
||||
payCountdownEl.textContent = fmtRemaining(deadline - Date.now());
|
||||
if (payDeadlineHumanEl && payDeadlineHumanEl.textContent === "computing…") {
|
||||
try {
|
||||
payDeadlineHumanEl.textContent = new Date(deadline).toLocaleString();
|
||||
} catch (e) {
|
||||
/* leave the placeholder */
|
||||
}
|
||||
}
|
||||
const nodes = document.querySelectorAll("[data-pay-deadline]");
|
||||
if (!nodes.length) return;
|
||||
const now = Date.now();
|
||||
nodes.forEach(function (node) {
|
||||
const raw = node.getAttribute("data-pay-deadline");
|
||||
const deadline = parseInt(raw, 10);
|
||||
if (isNaN(deadline)) return;
|
||||
node.textContent = fmtRemaining(deadline - now);
|
||||
});
|
||||
}
|
||||
|
||||
// Countdown ticks locally every second.
|
||||
|
|
|
|||
|
|
@ -126,19 +126,31 @@
|
|||
* elapses the offer auto-expires server-side; we reflect that
|
||||
* locally as "expired — refresh to update."
|
||||
*/
|
||||
/* fmtRemaining — prose human delta, matches ago.human() server-side.
|
||||
* The top two non-zero units make the cut so the line stays short
|
||||
* even on small phones (`in 1 day, 5 hours`, not `in 1d 5h 30m 2s`).
|
||||
*/
|
||||
function fmtRemaining(ms) {
|
||||
if (ms <= 0) return "expired";
|
||||
var totalSeconds = Math.floor(ms / 1000);
|
||||
var days = Math.floor(totalSeconds / 86400);
|
||||
var hours = Math.floor((totalSeconds % 86400) / 3600);
|
||||
var minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
var seconds = totalSeconds % 60;
|
||||
if (days > 0) return days + "d " + hours + "h " + minutes + "m";
|
||||
if (hours > 0) return hours + "h " + minutes + "m " + seconds + "s";
|
||||
if (minutes > 0) return minutes + "m " + seconds + "s";
|
||||
return seconds + "s";
|
||||
var units = [
|
||||
{ name: "day", value: Math.floor(totalSeconds / 86400) },
|
||||
{ name: "hour", value: Math.floor((totalSeconds % 86400) / 3600) },
|
||||
{ name: "minute", value: Math.floor((totalSeconds % 3600) / 60) },
|
||||
{ name: "second", value: totalSeconds % 60 },
|
||||
];
|
||||
var parts = [];
|
||||
for (var i = 0; i < units.length && parts.length < 2; i++) {
|
||||
var u = units[i];
|
||||
if (u.value === 0 && parts.length === 0 && i < units.length - 1) continue;
|
||||
parts.push(u.value + " " + u.name + (u.value === 1 ? "" : "s"));
|
||||
}
|
||||
return "in " + parts.join(", ");
|
||||
}
|
||||
|
||||
/* Tick each [data-pay-deadline] element directly — JS rewrites the
|
||||
* same <strong> the server pre-filled with ago.human(). The two
|
||||
* disagree by at most one second after each tick. */
|
||||
function tickPayCountdown() {
|
||||
var nodes = document.querySelectorAll("[data-pay-deadline]");
|
||||
if (!nodes.length) return;
|
||||
|
|
@ -147,9 +159,7 @@
|
|||
var raw = node.getAttribute("data-pay-deadline");
|
||||
var deadline = parseInt(raw, 10);
|
||||
if (isNaN(deadline)) return;
|
||||
var value = node.querySelector(".offer-pay-countdown-value");
|
||||
if (!value) return;
|
||||
value.textContent = fmtRemaining(deadline - now);
|
||||
node.textContent = fmtRemaining(deadline - now);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,9 +44,7 @@
|
|||
<section class="well auction-winner-pay">
|
||||
<h3 class="type-title">You won this auction!</h3>
|
||||
<p>
|
||||
Complete payment by <strong id="auction-pay-deadline-human">computing…</strong>
|
||||
<span class="js-only">· <span id="auction-pay-countdown" data-pay-deadline="{{ payment_deadline_timestamp }}">computing…</span> remaining</span>,
|
||||
or the auction will be released to the next-highest bidder.
|
||||
Complete payment <strong data-pay-deadline="{{ payment_deadline_timestamp }}">{{ payment_deadline_human }}</strong>, or the auction will be released to the next-highest bidder.
|
||||
</p>
|
||||
<form method="post" action="/a/{{ id }}/checkout" class="auction-checkout-form">
|
||||
<input type="submit" class="mps-submit mps-button-green" value="Pay ${{ '%.2f'|format(current_high) }} now" />
|
||||
|
|
|
|||
|
|
@ -107,11 +107,7 @@
|
|||
<input type="submit" class="mps-submit mps-button-green" value="Pay ${{ "%.2f"|format(current_amount) }} now" />
|
||||
</form>
|
||||
{% if pay_deadline_human %}
|
||||
<p class="offer-pay-deadline-note">You have until <strong>{{ pay_deadline_human }}</strong> to complete payment, after which this offer auto-expires.
|
||||
{% if pay_deadline_timestamp_ms %}
|
||||
<span class="offer-pay-countdown js-only" data-pay-deadline="{{ pay_deadline_timestamp_ms }}">· <span class="offer-pay-countdown-value">computing…</span> remaining</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="offer-pay-deadline-note">Payment due <strong{% if pay_deadline_timestamp_ms %} data-pay-deadline="{{ pay_deadline_timestamp_ms }}"{% endif %}>{{ pay_deadline_human }}</strong>, after which this offer auto-expires.</p>
|
||||
{% endif %}
|
||||
<form method="post" action="/o/{{ id }}/cancel" class="offer-cancel-form" onsubmit="return confirm('Cancel this accepted offer? You will not be able to pay this amount again unless the seller accepts a new offer.');">
|
||||
<button type="submit" class="cart-remove-link">Cancel this offer</button>
|
||||
|
|
@ -122,11 +118,7 @@
|
|||
<h3 class="type-title">Awaiting payment</h3>
|
||||
<p>You accepted <a href="/profile/{{ buyer_handle }}?shop={{ shop_id }}">{{ buyer_name }}</a>’s offer at <strong>${{ "%.2f"|format(current_amount) }}</strong>. We emailed them a one-time checkout link — this offer can be redeemed only once.</p>
|
||||
{% if pay_deadline_human %}
|
||||
<p class="offer-await-deadline-note">Buyer must pay by <strong>{{ pay_deadline_human }}</strong>; the offer auto-expires after that.
|
||||
{% if pay_deadline_timestamp_ms %}
|
||||
<span class="offer-pay-countdown js-only" data-pay-deadline="{{ pay_deadline_timestamp_ms }}">· <span class="offer-pay-countdown-value">computing…</span> remaining</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="offer-await-deadline-note">Buyer must pay <strong{% if pay_deadline_timestamp_ms %} data-pay-deadline="{{ pay_deadline_timestamp_ms }}"{% endif %}>{{ pay_deadline_human }}</strong>; the offer auto-expires after that.</p>
|
||||
{% endif %}
|
||||
<p class="offer-await-share-note">If they need it again, share this same page:</p>
|
||||
<div class="offer-pay-link-row">
|
||||
|
|
|
|||
|
|
@ -6933,20 +6933,26 @@ class TestOfferCheckout(_AuthenticatedBase):
|
|||
self.assertNotIn(f"/o/{offer_id}/checkout", body)
|
||||
|
||||
def test_accepted_offer_renders_pay_countdown_for_buyer(self):
|
||||
"""The buyer's accepted-offer view exposes a [data-pay-deadline]
|
||||
countdown element with an absolute ms timestamp so offer.js can
|
||||
tick a live "time remaining to pay" indicator. Without JS, the
|
||||
existing static "You have until <date>" text still renders; the
|
||||
countdown element is .js-only.
|
||||
"""The buyer's accepted-offer view shows the pay-by deadline as
|
||||
an `ago.human()` delta ("in 23 hours, 59 minutes") in a
|
||||
[data-pay-deadline] element. offer.js refines that to second
|
||||
precision client-side; without JS the server-rendered prose
|
||||
from ago is the fallback.
|
||||
"""
|
||||
offer_id = self._accepted_offer()
|
||||
self.testapp.get("/log-out")
|
||||
self.log_in_user(self.user2_creds)
|
||||
body = self.testapp.get(f"/o/{offer_id}", status=200).body.decode()
|
||||
self.assertIn("Pay $75.00 now", body)
|
||||
self.assertIn("offer-pay-countdown", body)
|
||||
self.assertIn("Payment due", body)
|
||||
self.assertIn("data-pay-deadline=\"", body)
|
||||
self.assertIn("offer-pay-countdown-value", body)
|
||||
# ago.human(future) starts with "in " — proves the server is
|
||||
# rendering the prose form, not a UTC wall-clock string.
|
||||
self.assertIn("data-pay-deadline=\"", body)
|
||||
self.assertRegex(
|
||||
body,
|
||||
r'data-pay-deadline="\d+">in \d+ \w+',
|
||||
)
|
||||
|
||||
def test_buyer_can_cancel_accepted_offer(self):
|
||||
"""Buyer back-out path: POST /o/{id}/cancel flips ACCEPTED →
|
||||
|
|
|
|||
|
|
@ -100,10 +100,28 @@ def _serialize_auction(auction, request):
|
|||
and auction.winner == request.user
|
||||
),
|
||||
"payment_deadline_timestamp": auction.payment_deadline_timestamp,
|
||||
"payment_deadline_human": _human_delta(auction.payment_deadline_timestamp),
|
||||
"is_settled": auction.state == AUCTION_STATE_SETTLED,
|
||||
}
|
||||
|
||||
|
||||
def _human_delta(ms_timestamp):
|
||||
"""Render an absolute ms timestamp as a relative human delta
|
||||
("in 1 day, 5 hours" / "expired 2 minutes ago") for no-JS callers.
|
||||
JS refines to second precision client-side.
|
||||
"""
|
||||
if ms_timestamp is None:
|
||||
return None
|
||||
import ago
|
||||
from datetime import datetime, timezone
|
||||
dt = datetime.fromtimestamp(ms_timestamp / 1000, tz=timezone.utc)
|
||||
return ago.human(
|
||||
dt, precision=2,
|
||||
past_tense="expired {} ago",
|
||||
future_tense="in {}",
|
||||
)
|
||||
|
||||
|
||||
@view_config(route_name="auction_page", renderer="auction.j2")
|
||||
def auction_page(request):
|
||||
auction = get_auction_by_id(
|
||||
|
|
|
|||
|
|
@ -241,10 +241,17 @@ def offer_page(request):
|
|||
|
||||
deadline = offer.acceptance_pay_deadline_ms
|
||||
if deadline is not None:
|
||||
import ago
|
||||
from datetime import datetime, timezone
|
||||
ctx["pay_deadline_human"] = datetime.fromtimestamp(
|
||||
deadline / 1000, tz=timezone.utc
|
||||
).strftime("%Y-%m-%d %H:%M UTC")
|
||||
deadline_dt = datetime.fromtimestamp(deadline / 1000, tz=timezone.utc)
|
||||
# Human delta form ("in 23 hours, 13 minutes") instead of a UTC
|
||||
# wall-clock string. JS refines this to second precision on
|
||||
# capable browsers; this is the no-JS fallback.
|
||||
ctx["pay_deadline_human"] = ago.human(
|
||||
deadline_dt, precision=2,
|
||||
past_tense="expired {} ago",
|
||||
future_tense="in {}",
|
||||
)
|
||||
ctx["pay_deadline_timestamp_ms"] = deadline
|
||||
else:
|
||||
ctx["pay_deadline_human"] = None
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue