diff --git a/renderer/app.js b/renderer/app.js
index 0ffc338..e31b893 100644
--- a/renderer/app.js
+++ b/renderer/app.js
@@ -347,6 +347,12 @@ function startDateLabel (dateStr) {
return `Free from ${start.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}`
}
+// "Dec 17" for a SALES day (a date-only string parses as UTC midnight, so print it in UTC:
+// in the Americas the local date would be the day before). The year shows unless it is this year's.
+function saleDateLabel (d, now) {
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC', year: d.getUTCFullYear() === now.getFullYear() ? undefined : 'numeric' })
+}
+
function fmtChecked (ts) {
if (!ts) return ''
const s = Math.floor((Date.now() - ts) / 1000)
@@ -1401,7 +1407,7 @@ function renderSalesCalendar () {
banner.innerHTML = `
✅
`
}
- const fmtDate = d => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: d.getFullYear() === now.getFullYear() ? undefined : 'numeric' })
+ const fmtDate = d => saleDateLabel(d, now)
SALES.forEach(s => {
const start = new Date(s.start)
const end = new Date(s.end)
diff --git a/tools/preview/selfcheck.mjs b/tools/preview/selfcheck.mjs
index 3325e0c..80394f0 100644
--- a/tools/preview/selfcheck.mjs
+++ b/tools/preview/selfcheck.mjs
@@ -35,7 +35,7 @@ vm.runInContext([
between('const targetKey', '\nfunction wishlistTargetTag'),
between('const nextIndex', '\n// Moves focus between'),
between('const fmt$ =', '\n// CSP forbids inline onerror'),
- 'globalThis.helpers = { SALES, state, filteredSorted, storeKeyFor, escHtml, updateRefreshAge, mergeUiPrefs, dealShops, parseClaimed, pruneClaimed, toggleClaimed, claimedLast, normalizeTitle, hiddenTitle, searchRank, parseSeen, newIds, mergeSeen, wishlistKey, seenIds, salesSeenIds, PAGE_SIZE, pageSlice, nextTheme, targetKey, targetPrice, targetHit, parseTarget, protonTier, protonText, protonTag, nextIndex, fmt$, salePriceDescription, dataSig, timeLeft, endsFirst, endDateLabel, startDateLabel, fmtChecked, gameStoreName, srcIcon, wishlistLowestTag, saleKey, updateAfterCheck, BASE_THEMES, SALE_THEMES }',
+ 'globalThis.helpers = { SALES, state, filteredSorted, storeKeyFor, escHtml, updateRefreshAge, mergeUiPrefs, dealShops, parseClaimed, pruneClaimed, toggleClaimed, claimedLast, normalizeTitle, hiddenTitle, searchRank, parseSeen, newIds, mergeSeen, wishlistKey, seenIds, salesSeenIds, PAGE_SIZE, pageSlice, nextTheme, targetKey, targetPrice, targetHit, parseTarget, protonTier, protonText, protonTag, nextIndex, fmt$, salePriceDescription, dataSig, timeLeft, endsFirst, endDateLabel, startDateLabel, saleDateLabel, fmtChecked, gameStoreName, srcIcon, wishlistLowestTag, saleKey, updateAfterCheck, BASE_THEMES, SALE_THEMES }',
].join('\n'), context)
const h = context.helpers
@@ -240,6 +240,14 @@ assert.equal(h.startDateLabel('2020-01-01T00:00:00Z'), 'Free now')
assert.equal(h.startDateLabel('2030-01-01T00:00:00Z').startsWith('Free from '), true)
assert.equal(h.fmtChecked(0), '')
+// The sales calendar prints the listed day in every timezone (UTC midnight is the day before west of Greenwich)
+const tz = process.env.TZ
+for (const zone of ['America/Los_Angeles', 'Pacific/Auckland', 'UTC']) {
+ process.env.TZ = zone
+ assert.equal(h.saleDateLabel(new Date('2026-12-17'), new Date('2026-10-18T12:00:00Z')), 'Dec 17', zone)
+ assert.equal(h.saleDateLabel(new Date('2027-01-04'), new Date('2026-10-18T12:00:00Z')), 'Jan 4, 2027', zone)
+}
+if (tz === undefined) delete process.env.TZ; else process.env.TZ = tz
assert.equal(h.fmtChecked(Date.now()), 'updated just now')
assert.match(h.fmtChecked(Date.now() - 30000), /^updated 3[01]s ago$/) // the clock may tick between the two reads
assert.equal(h.fmtChecked(Date.now() - 5 * 60000), 'updated 5m ago')