SportsSuggestions.sys.mjs (10708B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { RealtimeSuggestProvider } from "moz-src:///browser/components/urlbar/private/RealtimeSuggestProvider.sys.mjs"; 6 7 /** 8 * A feature that manages sports realtime suggestions. 9 */ 10 export class SportsSuggestions extends RealtimeSuggestProvider { 11 get realtimeType() { 12 return "sports"; 13 } 14 15 get isSponsored() { 16 return false; 17 } 18 19 get merinoProvider() { 20 return "sports"; 21 } 22 23 getViewTemplateForImage(item, index) { 24 if (itemIcon(item)) { 25 return super.getViewTemplateForImage(item, index); 26 } 27 28 return [ 29 { 30 name: `scheduled-date-chiclet-day-${index}`, 31 tag: "span", 32 classList: ["urlbarView-sports-scheduled-date-chiclet-day"], 33 }, 34 { 35 name: `scheduled-date-chiclet-month-${index}`, 36 tag: "span", 37 classList: ["urlbarView-sports-scheduled-date-chiclet-month"], 38 }, 39 ]; 40 } 41 42 getViewTemplateForDescriptionTop(item, index) { 43 return stringifiedScore(item.home_team.score) && 44 stringifiedScore(item.away_team.score) 45 ? this.#viewTemplateTopWithScores(index) 46 : this.#viewTemplateTopWithoutScores(index); 47 } 48 49 #viewTemplateTopWithScores(index) { 50 return [ 51 { 52 name: `home-team-name-${index}`, 53 tag: "span", 54 }, 55 { 56 name: `home-team-score-${index}`, 57 tag: "span", 58 classList: ["urlbarView-sports-score"], 59 }, 60 { 61 tag: "span", 62 classList: ["urlbarView-realtime-description-separator-dot"], 63 }, 64 { 65 name: `away-team-name-${index}`, 66 tag: "span", 67 }, 68 { 69 name: `away-team-score-${index}`, 70 tag: "span", 71 classList: ["urlbarView-sports-score"], 72 }, 73 ]; 74 } 75 76 #viewTemplateTopWithoutScores(index) { 77 return [ 78 { 79 name: `team-names-${index}`, 80 tag: "span", 81 classList: ["urlbarView-sports-team-names"], 82 }, 83 ]; 84 } 85 86 getViewTemplateForDescriptionBottom(item, index) { 87 return [ 88 { 89 name: `sport-name-${index}`, 90 tag: "span", 91 }, 92 { 93 tag: "span", 94 classList: ["urlbarView-realtime-description-separator-dot"], 95 }, 96 { 97 name: `date-${index}`, 98 tag: "span", 99 }, 100 { 101 tag: "span", 102 classList: ["urlbarView-realtime-description-separator-dot"], 103 }, 104 { 105 name: `status-${index}`, 106 tag: "span", 107 classList: ["urlbarView-sports-status"], 108 }, 109 ]; 110 } 111 112 getViewUpdateForPayloadItem(item, index) { 113 let topUpdate = 114 stringifiedScore(item.home_team.score) && 115 stringifiedScore(item.away_team.score) 116 ? this.#viewUpdateTopWithScores(item, index) 117 : this.#viewUpdateTopWithoutScores(item, index); 118 119 return { 120 ...topUpdate, 121 ...this.#viewUpdateImageAndBottom(item, index), 122 [`item_${index}`]: { 123 attributes: { 124 sport: item.sport, 125 status: item.status_type, 126 }, 127 }, 128 }; 129 } 130 131 #viewUpdateTopWithScores(item, i) { 132 return { 133 [`home-team-name-${i}`]: { 134 textContent: item.home_team.name, 135 }, 136 [`home-team-score-${i}`]: { 137 textContent: stringifiedScore(item.home_team.score), 138 }, 139 [`away-team-name-${i}`]: { 140 textContent: item.away_team.name, 141 }, 142 [`away-team-score-${i}`]: { 143 textContent: stringifiedScore(item.away_team.score), 144 }, 145 }; 146 } 147 148 #viewUpdateTopWithoutScores(item, i) { 149 return { 150 [`team-names-${i}`]: { 151 l10n: { 152 id: "urlbar-result-sports-team-names", 153 args: { 154 homeTeam: item.home_team.name, 155 awayTeam: item.away_team.name, 156 }, 157 }, 158 }, 159 }; 160 } 161 162 #viewUpdateImageAndBottom(item, i) { 163 let date = new Date(item.date); 164 let { zonedNow, zonedDate, daysUntil, isFuture } = 165 SportsSuggestions._parseDate(date); 166 167 let icon = itemIcon(item); 168 let isScheduled = item.status_type == "scheduled"; 169 170 // Create the image update. 171 let imageUpdate; 172 if (icon) { 173 // The image container will contain the icon. 174 imageUpdate = { 175 [`image_container_${i}`]: { 176 attributes: { 177 // Remove the fallback attribute by setting it to null. 178 "is-fallback": null, 179 }, 180 }, 181 [`image_${i}`]: { 182 attributes: { 183 src: icon, 184 }, 185 }, 186 }; 187 } else { 188 // Instead of an icon, the image container will be a date chiclet 189 // containing the item's date as text, with the day above the month. 190 let partsArray = new Intl.DateTimeFormat(undefined, { 191 month: "short", 192 day: "numeric", 193 timeZone: zonedNow.timeZoneId, 194 }).formatToParts(date); 195 let partsMap = Object.fromEntries( 196 partsArray.map(({ type, value }) => [type, value]) 197 ); 198 if (partsMap.day && partsMap.month) { 199 imageUpdate = { 200 [`image_container_${i}`]: { 201 attributes: { 202 "is-fallback": "", 203 }, 204 }, 205 [`scheduled-date-chiclet-day-${i}`]: { 206 textContent: partsMap.day, 207 }, 208 [`scheduled-date-chiclet-month-${i}`]: { 209 textContent: partsMap.month, 210 }, 211 }; 212 } else { 213 // This shouldn't happen. 214 imageUpdate = {}; 215 } 216 } 217 218 // Create the date update. First, format the date. 219 let formattedDate; 220 if (Math.abs(daysUntil) <= 1) { 221 // Relative date: "Today", "Tomorrow", "Yesterday" 222 formattedDate = capitalizeString( 223 new Intl.RelativeTimeFormat(undefined, { 224 numeric: "auto", 225 }).format(daysUntil, "day") 226 ); 227 } else { 228 // Formatted date with some combination of year, month, day, and weekday 229 let opts = { 230 timeZone: zonedNow.timeZoneId, 231 }; 232 if (!isScheduled || icon || !isFuture) { 233 opts.month = "short"; 234 opts.day = "numeric"; 235 if (zonedDate.year != zonedNow.year) { 236 opts.year = "numeric"; 237 } 238 } 239 if (isScheduled && isFuture) { 240 opts.weekday = "short"; 241 } 242 formattedDate = new Intl.DateTimeFormat(undefined, opts).format(date); 243 } 244 245 // Now format the time. 246 let formattedTime; 247 if (isScheduled && daysUntil >= 0) { 248 formattedTime = new Intl.DateTimeFormat(undefined, { 249 hour: "numeric", 250 minute: "numeric", 251 timeZoneName: "short", 252 timeZone: zonedNow.timeZoneId, 253 }).format(date); 254 } 255 256 // Finally, create the date update. 257 let dateUpdate; 258 if (formattedTime) { 259 dateUpdate = { 260 [`date-${i}`]: { 261 l10n: { 262 id: "urlbar-result-sports-game-date-with-time", 263 args: { 264 date: formattedDate, 265 time: formattedTime, 266 }, 267 }, 268 }, 269 }; 270 } else { 271 dateUpdate = { 272 [`date-${i}`]: { 273 textContent: formattedDate, 274 }, 275 }; 276 } 277 278 // Create the status update. Show the status if the game is live or if it 279 // happened earlier today. Otherwise clear the status. 280 let statusUpdate; 281 if (item.status_type == "live") { 282 statusUpdate = { 283 [`status-${i}`]: { 284 l10n: { 285 id: "urlbar-result-sports-status-live", 286 }, 287 }, 288 }; 289 } else if (daysUntil == 0 && item.status_type == "past") { 290 statusUpdate = { 291 [`status-${i}`]: { 292 l10n: { 293 id: "urlbar-result-sports-status-final", 294 }, 295 }, 296 }; 297 } else { 298 statusUpdate = { 299 [`status-${i}`]: { 300 // Clear the status by setting its text to an empty string. 301 textContent: "", 302 }, 303 }; 304 } 305 306 return { 307 ...imageUpdate, 308 ...dateUpdate, 309 ...statusUpdate, 310 [`sport-name-${i}`]: { 311 textContent: item.sport, 312 }, 313 }; 314 } 315 316 /** 317 * Parses a date and returns some info about it. 318 * 319 * This is a static method rather than a helper function internal to this file 320 * so that tests can easily test it. 321 * 322 * @param {Date} date 323 * A `Date` object. 324 * @returns {DateParseResult} 325 * The result. 326 * 327 * @typedef {object} DateParseResult 328 * @property {typeof Temporal.ZonedDateTime} zonedNow 329 * Now as a `ZonedDateTime`. 330 * @property {typeof Temporal.ZonedDateTime} zonedDate 331 * The passed-in date as a `ZonedDateTime`. 332 * @property {boolean} isFuture 333 * Whether the date is in the future. 334 * @property {number} daysUntil 335 * The number of calendar days from today to the date: 336 * If the date is after tomorrow: `Infinity` 337 * If the date is tomorrow: `1` 338 * If the date is today: `0` 339 * If the date is yesterday: `-1` 340 * If the date is before yesterday: `-Infinity` 341 */ 342 static _parseDate(date) { 343 // Find how many days there are from today to the date. 344 let zonedNow = SportsSuggestions._zonedDateTimeISO(); 345 let zonedDate = date.toTemporalInstant().toZonedDateTimeISO(zonedNow); 346 347 let today = zonedNow.startOfDay(); 348 let yesterday = today.subtract({ days: 1 }); 349 let tomorrow = today.add({ days: 1 }); 350 let dayAfterTomorrow = today.add({ days: 2 }); 351 352 let daysUntil; 353 if (Temporal.ZonedDateTime.compare(dayAfterTomorrow, zonedDate) <= 0) { 354 // date is after tomorrow 355 daysUntil = Infinity; 356 } else if (Temporal.ZonedDateTime.compare(tomorrow, zonedDate) <= 0) { 357 // date is tomorrow 358 daysUntil = 1; 359 } else if (Temporal.ZonedDateTime.compare(today, zonedDate) <= 0) { 360 // date is today 361 daysUntil = 0; 362 } else if (Temporal.ZonedDateTime.compare(yesterday, zonedDate) <= 0) { 363 // date is yesterday 364 daysUntil = -1; 365 } else { 366 // date is before yesterday 367 daysUntil = -Infinity; 368 } 369 370 let isFuture = Temporal.ZonedDateTime.compare(zonedNow, zonedDate) < 0; 371 372 return { 373 zonedNow, 374 zonedDate, 375 isFuture, 376 daysUntil, 377 }; 378 } 379 380 // Thin wrapper around `zonedDateTimeISO` so that tests can easily set a mock 381 // "now" date and time. 382 static _zonedDateTimeISO() { 383 return Temporal.Now.zonedDateTimeISO(); 384 } 385 } 386 387 function itemIcon(item) { 388 return item.icon || item.home_team?.icon || item.away_team?.icon; 389 } 390 391 function stringifiedScore(scoreValue) { 392 let s = scoreValue; 393 if (typeof s == "number") { 394 s = String(s); 395 } 396 return typeof s == "string" ? s : ""; 397 } 398 399 function capitalizeString(str) { 400 return str[0].toLocaleUpperCase() + str.substring(1); 401 }