1/** 2 * Explorer Dynamic Min Stay â empty-state decision logic. 3 * 4 * Pure functions extracted so the logic is testable in Node 5 * (tests/js/edms-empty.test.mjs), same contract as psn-money.js: 6 * UMD-ish wrapper, ES5 syntax, dependency-free. Query parsing rides on 7 * URL/URLSearchParams (baseline in every browser that runs the 8 * Explorer, and in Node) â hand-rolled regex parsing diverged from 9 * canonical parsing on duplicate params and on `?checkin=` sequences 10 * embedded raw inside another parameter's value. 11 * 12 * The question this module answers: given the localized min-stay 13 * aggregate and the checkin/checkout a visitor arrived with in the URL, 14 * is a zero-results Explorer search GUARANTEED to be a min-stay 15 * failure? The aggregate holds MIN(min_stay) across the inventory per 16 * date, so "searched nights < aggregate[checkin]" means no property 17 * accepts that stay length on that date â the only case where the 18 * empty state may claim a min-stay cause instead of the generic text. 19 */ 20(function (root, factory) { 21 if (typeof module === 'object' && module.exports) { 22 module.exports = factory(); 23 } else { 24 root.hrtkEdmsEmpty = factory(); 25 } 26})(typeof self !== 'undefined' ? self : this, function () { 27 28 var ISO_RE = /^\d{4}-\d{2}-\d{2}$/; 29 var DAY_MS = 86400000; 30 31 // Query keys PHP would fold into the canonical date params while 32 // URLSearchParams keys them separately: bracketed array variants 33 // (checkout[]=x becomes $_GET['checkout'] as an array) and 34 // NUL-truncated names (checkin%00x becomes $_GET['checkin'], 35 // last-wins â verified against parse_str). Their presence means the 36 // server-side reading is unknowable. 37 var SMUGGLED_KEY_RE = /^(checkin|checkout)[\[\u0000]/; 38 39 /** 40 * First value of a query parameter from a search string; '' when 41 * absent. Canonical URLSearchParams semantics, not regex. 42 */ 43 function getParam(search, name) { 44 return new URLSearchParams(search || '').get(name) || ''; 45 } 46 47 /** 48 * UTC midnight for a strict YYYY-MM-DD string; NaN when the string 49 * is malformed or names an impossible date (2026-02-31 would 50 * silently roll over to March in Date arithmetic â reject it). 51 */ 52 function utcMidnight(iso) { 53 if (typeof iso !== 'string' || !ISO_RE.test(iso)) { 54 return NaN; 55 } 56 var y = parseInt(iso.slice(0, 4), 10); 57 var mo = parseInt(iso.slice(5, 7), 10); 58 var d = parseInt(iso.slice(8, 10), 10); 59 var ms = Date.UTC(y, mo - 1, d); 60 var check = new Date(ms); 61 if (check.getUTCFullYear() !== y || check.getUTCMonth() !== mo - 1 || check.getUTCDate() !== d) { 62 return NaN; 63 } 64 return ms; 65 } 66 67 /** 68 * Whole nights between two ISO dates via UTC math (immune to DST 69 * transitions in the viewer's timezone). NaN on invalid input. 70 */ 71 function nightsBetween(checkin, checkout) { 72 var a = utcMidnight(checkin); 73 var b = utcMidnight(checkout); 74 if (isNaN(a) || isNaN(b)) { 75 return NaN; 76 } 77 return Math.round((b - a) / DAY_MS); 78 } 79 80 /** ISO date n days after the given ISO date; null on invalid input. */ 81 function addDays(iso, n) { 82 var ms = utcMidnight(iso); 83 if (isNaN(ms) || typeof n !== 'number' || isNaN(n)) { 84 return null; 85 } 86 var d = new Date(ms + n * DAY_MS); 87 var mo = String(d.getUTCMonth() + 1); 88 var day = String(d.getUTCDate()); 89 if (mo.length < 2) { 90 mo = '0' + mo; 91 } 92 if (day.length < 2) { 93 day = '0' + day; 94 } 95 return d.getUTCFullYear() + '-' + mo + '-' + day; 96 } 97 98 /** 99 * {checkin, checkout} from a location.search string when EXACTLY ONE 100 * of each is present and well-formed; null otherwise. Duplicates are 101 * rejected outright: PHP resolves duplicate params last-wins while 102 * URLSearchParams.get() is first-wins, so a duplicated date param 103 * means we cannot know which value the search actually used â an 104 * uncertain case, and uncertain cases make no claim. 105 */ 106 function parseSearch(search) { 107 var sp = new URLSearchParams(search || ''); 108 var smuggled = false; 109 sp.forEach(function (value, key) { 110 if (SMUGGLED_KEY_RE.test(key)) { 111 smuggled = true; 112 } 113 }); 114 if (smuggled) { 115 return null; 116 } 117 var checkins = sp.getAll('checkin'); 118 var checkouts = sp.getAll('checkout'); 119 if (checkins.length !== 1 || checkouts.length !== 1) { 120 return null; 121 } 122 if (isNaN(utcMidnight(checkins[0])) || isNaN(utcMidnight(checkouts[0]))) { 123 return null; 124 } 125 return { checkin: checkins[0], checkout: checkouts[0] }; 126 } 127 128 /** 129 * Strict integer reading of an aggregate value. localized JSON may 130 * legitimately stringify ints ("2"), but anything fractional, 131 * suffixed, or non-scalar is malformed data and must yield NaN so 132 * the caller makes no claim ("2junk" and "2.9" both parseInt to 2). 133 */ 134 function toMinStay(v) { 135 if (typeof v === 'number') { 136 return isFinite(v) && Math.floor(v) === v ? v : NaN; 137 } 138 if (typeof v === 'string' && /^\d+$/.test(v)) { 139 return parseInt(v, 10); 140 } 141 return NaN; 142 } 143 144 /** 145 * Decide whether a zero-results search for [checkin, checkout) is 146 * guaranteed to be a min-stay failure. 147 * 148 * `uncertain` is the REQUIRED certainty contract: an object mapping 149 * dates whose aggregate value is fleet-incomplete (some available 150 * property carried no known minimum â Aggregator::compute_uncertain). 151 * It is null when the last aggregation could not certify complete 152 * data, and this function FAILS CLOSED on anything but an object: 153 * without certified data, a min-stay claim could misattribute an 154 * empty result that another filter caused while an 155 * invisible-to-the-aggregate property accepts the short stay. 156 * 157 * @return null when we cannot make the claim (no certainty data, 158 * uncertain checkin date, no aggregate entry, invalid range, 159 * or the stay already meets the minimum â the empty result 160 * then has some other cause and the generic message must 161 * stand). Otherwise { nights, required, suggestedCheckout }. 162 */ 163 function evaluate(aggregate, checkin, checkout, uncertain) { 164 if (!aggregate || typeof aggregate !== 'object') { 165 return null; 166 } 167 if (!uncertain || typeof uncertain !== 'object') { 168 return null; 169 } 170 var nights = nightsBetween(checkin, checkout); 171 if (isNaN(nights) || nights < 1) { 172 return null; 173 } 174 if (Object.prototype.hasOwnProperty.call(uncertain, checkin)) { 175 return null; 176 } 177 if (!Object.prototype.hasOwnProperty.call(aggregate, checkin)) { 178 return null; 179 } 180 var required = toMinStay(aggregate[checkin]); 181 if (isNaN(required) || required <= 0 || required > 100) { 182 return null; 183 } 184 if (nights >= required) { 185 return null; 186 } 187 return { 188 nights: nights, 189 required: required, 190 suggestedCheckout: addDays(checkin, required), 191 }; 192 } 193 194 /** 195 * Rewrite the checkout parameter in a full (absolute) URL, 196 * preserving every other parameter and any #fragment. Appends when 197 * absent; collapses duplicate checkout params (searchParams.set
198 * semantics), so the resubmitted search is unambiguous. 199 */ 200 function withCheckout(href, checkout) { 201 var u = new URL(href); 202 // Belt-and-braces: strip smuggled date-key variants (bracketed or 203 // NUL-truncated) so the retried search can't inherit a key 204 // parseSearch would reject. 205 var stale = []; 206 u.searchParams.forEach(function (value, key) { 207 if (SMUGGLED_KEY_RE.test(key)) { 208 stale.push(key); 209 } 210 }); 211 stale.forEach(function (key) { 212 u.searchParams.delete(key); 213 }); 214 u.searchParams.set('checkout', checkout); 215 return u.toString(); 216 } 217 218 return { 219 getParam: getParam, 220 utcMidnight: utcMidnight, 221 nightsBetween: nightsBetween, 222 addDays: addDays, 223 toMinStay: toMinStay, 224 parseSearch: parseSearch, 225 evaluate: evaluate, 226 withCheckout: withCheckout, 227 }; 228});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.