commit 92af680d0f87865773ccf73813976a05f671eff4 parent 0e7d63c4a61723b7347af1e3e87895ed67281456 Author: Drew Willcoxon <adw@mozilla.com> Date: Fri, 7 Nov 2025 21:58:15 +0000 Bug 1997513 - Sports suggestions initial implementation. r=daisuke,fluent-reviewers,desktop-theme-reviewers,urlbar-reviewers,bolsson,dao Depends on D271101 Differential Revision: https://phabricator.services.mozilla.com/D270807 Diffstat:
22 files changed, 3125 insertions(+), 210 deletions(-)
diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js @@ -792,6 +792,13 @@ pref("browser.urlbar.yelpRealtime.minKeywordLength", 0); // Feature gate pref for flight status suggestions in the urlbar. pref("browser.urlbar.flightStatus.featureGate", false); +// Feature gate pref for sports suggestions in the urlbar. +pref("browser.urlbar.sports.featureGate", false); + +// If `browser.urlbar.sports.featureGate` is true, this controls whether sports +// suggestions are turned on. +pref("browser.urlbar.suggest.sports", true); + // Timestamp of the time the user last performed a search via the urlbar // so that experiments can target users who have / have not performed // urlbar searches. diff --git a/browser/components/urlbar/QuickSuggest.sys.mjs b/browser/components/urlbar/QuickSuggest.sys.mjs @@ -170,6 +170,8 @@ const FEATURES = { "moz-src:///browser/components/urlbar/private/MarketSuggestions.sys.mjs", MDNSuggestions: "moz-src:///browser/components/urlbar/private/MDNSuggestions.sys.mjs", + SportsSuggestions: + "moz-src:///browser/components/urlbar/private/SportsSuggestions.sys.mjs", SuggestBackendMerino: "moz-src:///browser/components/urlbar/private/SuggestBackendMerino.sys.mjs", SuggestBackendMl: diff --git a/browser/components/urlbar/UrlbarPrefs.sys.mjs b/browser/components/urlbar/UrlbarPrefs.sys.mjs @@ -452,6 +452,18 @@ const PREF_URLBAR_DEFAULTS = /** @type {PreferenceDefinition[]} */ ([ // If true, top sites may include sponsored ones. ["sponsoredTopSites", false], + // Feature gate pref for realtime sports suggestions in the urlbar. + ["sports.featureGate", false], + + // The minimum prefix length of sports keyword the user must type to trigger + // the suggestion. 0 means the min length should be taken from Nimbus or + // remote settings. + ["sports.minKeywordLength", 0], + + // The number of times the user has clicked the "Show less frequently" command + // for sports suggestions. + ["sports.showLessFrequentlyCount", 0], + // If `browser.urlbar.addons.featureGate` is true, this controls whether // addon suggestions are turned on. ["suggest.addons", true], @@ -517,6 +529,9 @@ const PREF_URLBAR_DEFAULTS = /** @type {PreferenceDefinition[]} */ ([ // Whether results will include search suggestions. ["suggest.searches", false], + // Whether results will include realtime sports suggestions. + ["suggest.sports", true], + // Whether results will include top sites and the view will open on focus. ["suggest.topsites", true], diff --git a/browser/components/urlbar/UrlbarView.sys.mjs b/browser/components/urlbar/UrlbarView.sys.mjs @@ -2340,7 +2340,7 @@ export class UrlbarView { } if (update.l10n) { this.#l10nCache.setElementL10n(node, update.l10n); - } else if (update.textContent) { + } else if (update.hasOwnProperty("textContent")) { lazy.UrlbarUtils.addTextContentWithHighlights( node, update.textContent, diff --git a/browser/components/urlbar/content/enUS-searchFeatures.ftl b/browser/components/urlbar/content/enUS-searchFeatures.ftl @@ -336,3 +336,32 @@ urlbar-result-flight-status-airport = { $city } ({ $code }) # $flightNumber (string) - The flight number. # $airlineName (string) - The airline name. urlbar-result-flight-status-flight-number-with-airline = { $flightNumber }, { $airlineName } + +## These strings are used for sports suggestions in the urlbar. Sports +## suggestions show team names, scores, game times, etc. + +# This string is shown for a scheduled future game. In English, "Team 1 at Team +# 2" means the game is taking place at Team 2's home venue, and we say Team 1 is +# the "away" team and Team 2 is the "home" team. If your language doesn't have a +# similar phrase, use your equivalent of "vs." or even just "and". +# Variables: +# $awayTeam (string) - Name of the visting team. +# $homeTeam (string) - Name of the home team. +urlbar-result-sports-team-names = { $awayTeam } at { $homeTeam } + +# This string is shown when the game is today, in the near future, or in the +# recent past. +# Variables: +# $date (string) - Localized date string, e.g., "Today", "Oct 31" +# $time (string) - Localized time +urlbar-result-sports-game-date-with-time = { $date } at { $time } + +# This status is shown when the game is in progress. +urlbar-result-sports-status-live = Live + +# This status is shown when the game is over. +urlbar-result-sports-status-final = Final + +# This string is shown in the result menu. +urlbar-result-menu-dont-show-sports = + .label = Don’t show sports suggestions diff --git a/browser/components/urlbar/moz.build b/browser/components/urlbar/moz.build @@ -75,6 +75,7 @@ MOZ_SRC_FILES += [ "private/MDNSuggestions.sys.mjs", "private/MLSuggest.sys.mjs", "private/RealtimeSuggestProvider.sys.mjs", + "private/SportsSuggestions.sys.mjs", "private/SuggestBackendMerino.sys.mjs", "private/SuggestBackendMl.sys.mjs", "private/SuggestBackendRust.sys.mjs", diff --git a/browser/components/urlbar/private/FlightStatusSuggestions.sys.mjs b/browser/components/urlbar/private/FlightStatusSuggestions.sys.mjs @@ -24,7 +24,7 @@ export class FlightStatusSuggestions extends RealtimeSuggestProvider { return "flights"; } - getViewTemplateForDescriptionTop(index) { + getViewTemplateForDescriptionTop(_item, index) { return [ { name: `departure_time_${index}`, @@ -53,7 +53,7 @@ export class FlightStatusSuggestions extends RealtimeSuggestProvider { ]; } - getViewTemplateForDescriptionBottom(index) { + getViewTemplateForDescriptionBottom(_item, index) { return [ { name: `departure_date_${index}`, @@ -90,9 +90,9 @@ export class FlightStatusSuggestions extends RealtimeSuggestProvider { ]; } - getViewUpdateForValue(i, v) { + getViewUpdateForPayloadItem(item, index) { let status; - switch (v.status) { + switch (item.status) { case "Scheduled": { status = "ontime"; break; @@ -119,16 +119,16 @@ export class FlightStatusSuggestions extends RealtimeSuggestProvider { let departureTimeZone; let arrivalTime; let arrivalTimeZone; - if (status == "delayed" || !v.delayed) { - departureTime = new Date(v.departure.scheduled_time); - departureTimeZone = getTimeZone(v.departure.scheduled_time); - arrivalTime = new Date(v.arrival.scheduled_time); - arrivalTimeZone = getTimeZone(v.arrival.scheduled_time); + if (status == "delayed" || !item.delayed) { + departureTime = new Date(item.departure.scheduled_time); + departureTimeZone = getTimeZone(item.departure.scheduled_time); + arrivalTime = new Date(item.arrival.scheduled_time); + arrivalTimeZone = getTimeZone(item.arrival.scheduled_time); } else { - departureTime = new Date(v.departure.estimated_time); - departureTimeZone = getTimeZone(v.departure.estimated_time); - arrivalTime = new Date(v.arrival.estimated_time); - arrivalTimeZone = getTimeZone(v.arrival.estimated_time); + departureTime = new Date(item.departure.estimated_time); + departureTimeZone = getTimeZone(item.departure.estimated_time); + arrivalTime = new Date(item.arrival.estimated_time); + arrivalTimeZone = getTimeZone(item.arrival.estimated_time); } let statusL10nId = `urlbar-result-flight-status-status-${status}`; @@ -138,8 +138,8 @@ export class FlightStatusSuggestions extends RealtimeSuggestProvider { departureEstimatedTime: new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "numeric", - timeZone: getTimeZone(v.departure.estimated_time), - }).format(new Date(v.departure.estimated_time)), + timeZone: getTimeZone(item.departure.estimated_time), + }).format(new Date(item.departure.estimated_time)), }; } @@ -147,35 +147,38 @@ export class FlightStatusSuggestions extends RealtimeSuggestProvider { let backgroundImage; if (status == "inflight") { let backgroundImageId = - v.progress_percent == 100 ? 4 : Math.floor(v.progress_percent / 20); + item.progress_percent == 100 + ? 4 + : Math.floor(item.progress_percent / 20); backgroundImage = { style: { - "--airline-color": v.airline.color, + "--airline-color": item.airline.color, }, attributes: { backgroundImageId, - hasForegroundImage: !!v.airline.icon, + hasForegroundImage: !!item.airline.icon, }, }; foregroundImage = { attributes: { - src: v.airline.icon, + src: item.airline.icon, }, }; } else { foregroundImage = { attributes: { src: - v.airline.icon ?? "chrome://browser/skin/urlbar/flight-airline.svg", - fallback: !v.airline.icon, + item.airline.icon ?? + "chrome://browser/skin/urlbar/flight-airline.svg", + fallback: !item.airline.icon, }, }; } let timeLeft; - if (typeof v.time_left_minutes == "number") { - let hours = Math.floor(v.time_left_minutes / 60); - let minutes = v.time_left_minutes % 60; + if (typeof item.time_left_minutes == "number") { + let hours = Math.floor(item.time_left_minutes / 60); + let minutes = item.time_left_minutes % 60; // TODO Bug 1997547: TypeScript support for `Intl.DurationFormat` // @ts-ignore timeLeft = new Intl.DurationFormat(undefined, { @@ -188,21 +191,21 @@ export class FlightStatusSuggestions extends RealtimeSuggestProvider { } return { - [`item_${i}`]: { + [`item_${index}`]: { attributes: { status, }, }, - [`image_container_${i}`]: backgroundImage, - [`image_${i}`]: foregroundImage, - [`departure_time_${i}`]: { + [`image_container_${index}`]: backgroundImage, + [`image_${index}`]: foregroundImage, + [`departure_time_${index}`]: { textContent: new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "numeric", timeZone: departureTimeZone, }).format(departureTime), }, - [`departure_date_${i}`]: { + [`departure_date_${index}`]: { textContent: new Intl.DateTimeFormat(undefined, { month: "long", day: "numeric", @@ -210,51 +213,51 @@ export class FlightStatusSuggestions extends RealtimeSuggestProvider { timeZone: departureTimeZone, }).format(departureTime), }, - [`arrival_time_${i}`]: { + [`arrival_time_${index}`]: { textContent: new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "numeric", timeZone: arrivalTimeZone, }).format(arrivalTime), }, - [`origin_airport_${i}`]: { + [`origin_airport_${index}`]: { l10n: { id: "urlbar-result-flight-status-airport", args: { - city: v.origin.city, - code: v.origin.code, + city: item.origin.city, + code: item.origin.code, }, cacheable: true, excludeArgsFromCacheKey: true, }, }, - [`destination_airport_${i}`]: { + [`destination_airport_${index}`]: { l10n: { id: "urlbar-result-flight-status-airport", args: { - city: v.destination.city, - code: v.destination.code, + city: item.destination.city, + code: item.destination.code, }, cacheable: true, excludeArgsFromCacheKey: true, }, }, - [`flight_number_${i}`]: v.airline.name + [`flight_number_${index}`]: item.airline.name ? { l10n: { id: "urlbar-result-flight-status-flight-number-with-airline", args: { - flightNumber: v.flight_number, - airlineName: v.airline.name, + flightNumber: item.flight_number, + airlineName: item.airline.name, }, cacheable: true, excludeArgsFromCacheKey: !!statusL10nArgs, }, } : { - textContent: v.flight_number, + textContent: item.flight_number, }, - [`status_${i}`]: { + [`status_${index}`]: { l10n: { id: statusL10nId, args: statusL10nArgs, @@ -262,7 +265,7 @@ export class FlightStatusSuggestions extends RealtimeSuggestProvider { excludeArgsFromCacheKey: !!statusL10nArgs, }, }, - [`time_left_${i}`]: timeLeft + [`time_left_${index}`]: timeLeft ? { l10n: { id: "urlbar-result-flight-status-time-left", diff --git a/browser/components/urlbar/private/MarketSuggestions.sys.mjs b/browser/components/urlbar/private/MarketSuggestions.sys.mjs @@ -4,13 +4,6 @@ import { RealtimeSuggestProvider } from "moz-src:///browser/components/urlbar/private/RealtimeSuggestProvider.sys.mjs"; -const lazy = {}; - -ChromeUtils.defineESModuleGetters(lazy, { - UrlbarSearchUtils: - "moz-src:///browser/components/urlbar/UrlbarSearchUtils.sys.mjs", -}); - /** * A feature that supports Market suggestions like stocks, indexes, and funds. */ @@ -27,29 +20,7 @@ export class MarketSuggestions extends RealtimeSuggestProvider { return "polygon"; } - makeMerinoResult(queryContext, suggestion, searchString) { - if (!suggestion.custom_details?.polygon?.values?.length) { - return null; - } - - let engine = lazy.UrlbarSearchUtils.getDefaultEngine( - queryContext.isPrivate - ); - if (!engine) { - return null; - } - - let result = super.makeMerinoResult(queryContext, suggestion, searchString); - if (!result) { - return null; - } - - result.payload.engine = engine.name; - - return result; - } - - getViewTemplateForDescriptionTop(index) { + getViewTemplateForDescriptionTop(_item, index) { return [ { name: `name_${index}`, @@ -67,7 +38,7 @@ export class MarketSuggestions extends RealtimeSuggestProvider { ]; } - getViewTemplateForDescriptionBottom(index) { + getViewTemplateForDescriptionBottom(_item, index) { return [ { name: `todays_change_perc_${index}`, @@ -95,10 +66,10 @@ export class MarketSuggestions extends RealtimeSuggestProvider { ]; } - getViewUpdateForValue(i, v) { + getViewUpdateForPayloadItem(item, index) { let arrowImageUri; let changeDescription; - let changePercent = parseFloat(v.todays_change_perc); + let changePercent = parseFloat(item.todays_change_perc); if (changePercent < 0) { changeDescription = "down"; arrowImageUri = "chrome://browser/skin/urlbar/market-down.svg"; @@ -110,7 +81,7 @@ export class MarketSuggestions extends RealtimeSuggestProvider { arrowImageUri = "chrome://browser/skin/urlbar/market-unchanged.svg"; } - let imageUri = v.image_url; + let imageUri = item.image_url; let isImageAnArrow = false; if (!imageUri) { isImageAnArrow = true; @@ -118,35 +89,35 @@ export class MarketSuggestions extends RealtimeSuggestProvider { } return { - [`item_${i}`]: { + [`item_${index}`]: { attributes: { change: changeDescription, }, }, - [`image_container_${i}`]: { + [`image_container_${index}`]: { attributes: { "is-arrow": isImageAnArrow ? "" : null, }, }, - [`image_${i}`]: { + [`image_${index}`]: { attributes: { src: imageUri, }, }, - [`name_${i}`]: { - textContent: v.name, + [`name_${index}`]: { + textContent: item.name, }, - [`ticker_${i}`]: { - textContent: v.ticker, + [`ticker_${index}`]: { + textContent: item.ticker, }, - [`todays_change_perc_${i}`]: { - textContent: `${v.todays_change_perc}%`, + [`todays_change_perc_${index}`]: { + textContent: `${item.todays_change_perc}%`, }, - [`last_price_${i}`]: { - textContent: v.last_price, + [`last_price_${index}`]: { + textContent: item.last_price, }, - [`exchange_${i}`]: { - textContent: v.exchange, + [`exchange_${index}`]: { + textContent: item.exchange, }, }; } diff --git a/browser/components/urlbar/private/RealtimeSuggestProvider.sys.mjs b/browser/components/urlbar/private/RealtimeSuggestProvider.sys.mjs @@ -10,6 +10,8 @@ ChromeUtils.defineESModuleGetters(lazy, { QuickSuggest: "moz-src:///browser/components/urlbar/QuickSuggest.sys.mjs", UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs", UrlbarResult: "moz-src:///browser/components/urlbar/UrlbarResult.sys.mjs", + UrlbarSearchUtils: + "moz-src:///browser/components/urlbar/UrlbarSearchUtils.sys.mjs", UrlbarUtils: "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs", }); @@ -35,15 +37,15 @@ export class RealtimeSuggestProvider extends SuggestProvider { throw new Error("Trying to access the base class, must be overridden"); } - getViewTemplateForDescriptionTop(_index) { + getViewTemplateForDescriptionTop(_item, _index) { throw new Error("Trying to access the base class, must be overridden"); } - getViewTemplateForDescriptionBottom(_index) { + getViewTemplateForDescriptionBottom(_item, _index) { throw new Error("Trying to access the base class, must be overridden"); } - getViewUpdateForValue(_index, _value) { + getViewUpdateForPayloadItem(_item, _index) { throw new Error("Trying to access the base class, must be overridden"); } @@ -127,6 +129,20 @@ export class RealtimeSuggestProvider extends SuggestProvider { return false; } + /** + * @returns {string} + * The dynamic result type that will be set in the Merino result's payload + * as `result.payload.dynamicType`. Note that "dynamic" here refers to the + * concept of dynamic result types as used in the view and + * `UrlbarUtils.RESULT_TYPE.DYNAMIC`, not Rust dynamic suggestions. + * + * If you override this, make sure the value starts with "realtime-" because + * there are CSS rules that depend on that. + */ + get dynamicResultType() { + return "realtime-" + this.realtimeType; + } + // The following methods can be overridden but hopefully it's not necessary. get rustSuggestionType() { @@ -307,17 +323,54 @@ export class RealtimeSuggestProvider extends SuggestProvider { return null; } - return new lazy.UrlbarResult({ + let values = suggestion.custom_details?.[this.merinoProvider]?.values; + if (!values?.length) { + return null; + } + + let engine; + if (values.some(v => v.query)) { + engine = lazy.UrlbarSearchUtils.getDefaultEngine(queryContext.isPrivate); + if (!engine) { + return null; + } + } + + let result = new lazy.UrlbarResult({ type: lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC, source: lazy.UrlbarUtils.RESULT_SOURCE.SEARCH, isBestMatch: true, hideRowLabel: true, ...additionalOptions, payload: { - ...suggestion.custom_details, - dynamicType: this.realtimeType, + items: values.map((v, i) => this.makePayloadItem(v, i)), + dynamicType: this.dynamicResultType, + engine: engine?.name, }, }); + + return result; + } + + /** + * Returns the object that should be stored as `result.payload.items[i]` for + * the Merino result. The default implementation here returns the + * corresponding value in the suggestion. + * + * It's useful to override this if there's a significant amount of logic + * that's used by the different code paths of the view update. In that case, + * you can override this method, perform the logic, store the results in the + * item, and then your different view update paths can all use it. + * + * @param {object} value + * The value in the suggestion's `values` array. + * @param {number} _index + * The index of the value in the array. + * @returns {object} + * The object that should be stored in `result.payload.items[_index]`. + */ + makePayloadItem(value, _index) { + return value; } makeOptInResult(queryContext, _suggestion) { @@ -381,25 +434,21 @@ export class RealtimeSuggestProvider extends SuggestProvider { } getViewTemplate(result) { - let values = result.payload[this.merinoProvider]?.values; - if (!values) { - return null; - } - - let hasMultipleValues = values.length > 1; + let { items } = result.payload; + let hasMultipleItems = items.length > 1; return { name: "root", overflowable: true, attributes: { - selectable: hasMultipleValues ? null : "", + selectable: hasMultipleItems ? null : "", }, classList: ["urlbarView-realtime-root"], - children: values.map((_v, i) => ({ + children: items.map((item, i) => ({ name: `item_${i}`, tag: "span", classList: ["urlbarView-realtime-item"], attributes: { - selectable: !hasMultipleValues ? null : "", + selectable: !hasMultipleItems ? null : "", }, children: [ // Create an image inside a container so that the image appears inset @@ -412,14 +461,9 @@ export class RealtimeSuggestProvider extends SuggestProvider { name: `image_container_${i}`, tag: "span", classList: ["urlbarView-realtime-image-container"], - children: [ - { - name: `image_${i}`, - tag: "img", - classList: ["urlbarView-realtime-image"], - }, - ], + children: this.getViewTemplateForImage(item, i), }, + { tag: "span", classList: ["urlbarView-realtime-description"], @@ -427,12 +471,12 @@ export class RealtimeSuggestProvider extends SuggestProvider { { tag: "div", classList: ["urlbarView-realtime-description-top"], - children: this.getViewTemplateForDescriptionTop(i), + children: this.getViewTemplateForDescriptionTop(item, i), }, { tag: "div", classList: ["urlbarView-realtime-description-bottom"], - children: this.getViewTemplateForDescriptionBottom(i), + children: this.getViewTemplateForDescriptionBottom(item, i), }, ], }, @@ -441,32 +485,51 @@ export class RealtimeSuggestProvider extends SuggestProvider { }; } + /** + * Returns the view template inside the `image_container`. This default + * implementation creates an `img` element. Override it if you need something + * else. + * + * @param {object} _item + * An item from the `result.payload.items` array. + * @param {number} index + * The index of the item in the array. + * @returns {Array} + * View template for the image, an array of objects. + */ + getViewTemplateForImage(_item, index) { + return [ + { + name: `image_${index}`, + tag: "img", + classList: ["urlbarView-realtime-image"], + }, + ]; + } + getViewUpdate(result) { - let values = result.payload[this.merinoProvider]?.values; - if (!values) { - return null; - } + let { items } = result.payload; let update = { root: { dataset: { - // This `url` or `query` will be used when there's only one value. - url: values[0].url, - query: values[0].query, + // This `url` or `query` will be used when there's only one item. + url: items[0].url, + query: items[0].query, }, }, }; - for (let i = 0; i < values.length; i++) { - let value = values[i]; - Object.assign(update, this.getViewUpdateForValue(i, value)); + for (let i = 0; i < items.length; i++) { + let item = items[i]; + Object.assign(update, this.getViewUpdateForPayloadItem(item, i)); - // These `url` or `query`s will be used when there are multiple values. + // These `url` or `query`s will be used when there are multiple items. let itemName = `item_${i}`; update[itemName] ??= {}; update[itemName].dataset ??= {}; - update[itemName].dataset.url ??= value.url; - update[itemName].dataset.query ??= value.query; + update[itemName].dataset.url ??= item.url; + update[itemName].dataset.query ??= item.query; } return update; diff --git a/browser/components/urlbar/private/SportsSuggestions.sys.mjs b/browser/components/urlbar/private/SportsSuggestions.sys.mjs @@ -0,0 +1,395 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { RealtimeSuggestProvider } from "moz-src:///browser/components/urlbar/private/RealtimeSuggestProvider.sys.mjs"; + +/** + * A feature that manages sports realtime suggestions. + */ +export class SportsSuggestions extends RealtimeSuggestProvider { + get realtimeType() { + return "sports"; + } + + get isSponsored() { + return false; + } + + get merinoProvider() { + return "sports"; + } + + getViewTemplateForImage(item, index) { + if (itemIcon(item)) { + return super.getViewTemplateForImage(item, index); + } + + return [ + { + name: `scheduled-date-chiclet-day-${index}`, + tag: "span", + classList: ["urlbarView-sports-scheduled-date-chiclet-day"], + }, + { + name: `scheduled-date-chiclet-month-${index}`, + tag: "span", + classList: ["urlbarView-sports-scheduled-date-chiclet-month"], + }, + ]; + } + + getViewTemplateForDescriptionTop(item, index) { + return stringifiedScore(item.home_team.score) && + stringifiedScore(item.away_team.score) + ? this.#viewTemplateTopWithScores(index) + : this.#viewTemplateTopWithoutScores(index); + } + + #viewTemplateTopWithScores(index) { + return [ + { + name: `home-team-name-${index}`, + tag: "span", + }, + { + name: `home-team-score-${index}`, + tag: "span", + classList: ["urlbarView-sports-score"], + }, + { + tag: "span", + classList: ["urlbarView-realtime-description-separator-dot"], + }, + { + name: `away-team-name-${index}`, + tag: "span", + }, + { + name: `away-team-score-${index}`, + tag: "span", + classList: ["urlbarView-sports-score"], + }, + ]; + } + + #viewTemplateTopWithoutScores(index) { + return [ + { + name: `team-names-${index}`, + tag: "span", + classList: ["urlbarView-sports-team-names"], + }, + ]; + } + + getViewTemplateForDescriptionBottom(item, index) { + return [ + { + name: `sport-name-${index}`, + tag: "span", + }, + { + tag: "span", + classList: ["urlbarView-realtime-description-separator-dot"], + }, + { + name: `date-${index}`, + tag: "span", + }, + { + tag: "span", + classList: ["urlbarView-realtime-description-separator-dot"], + }, + { + name: `status-${index}`, + tag: "span", + classList: ["urlbarView-sports-status"], + }, + ]; + } + + getViewUpdateForPayloadItem(item, index) { + let topUpdate = + stringifiedScore(item.home_team.score) && + stringifiedScore(item.away_team.score) + ? this.#viewUpdateTopWithScores(item, index) + : this.#viewUpdateTopWithoutScores(item, index); + + return { + ...topUpdate, + ...this.#viewUpdateImageAndBottom(item, index), + }; + } + + #viewUpdateTopWithScores(item, i) { + return { + [`home-team-name-${i}`]: { + textContent: item.home_team.name, + }, + [`home-team-score-${i}`]: { + textContent: stringifiedScore(item.home_team.score), + }, + [`away-team-name-${i}`]: { + textContent: item.away_team.name, + }, + [`away-team-score-${i}`]: { + textContent: stringifiedScore(item.away_team.score), + }, + }; + } + + #viewUpdateTopWithoutScores(item, i) { + return { + [`team-names-${i}`]: { + l10n: { + id: "urlbar-result-sports-team-names", + args: { + homeTeam: item.home_team.name, + awayTeam: item.away_team.name, + }, + }, + }, + }; + } + + #viewUpdateImageAndBottom(item, i) { + let date = new Date(item.date); + let { zonedNow, zonedDate, daysUntil, isFuture } = + SportsSuggestions._parseDate(date); + + let icon = itemIcon(item); + let isScheduled = item.status_type == "scheduled"; + + // Create the image update. + let imageUpdate; + if (icon) { + // The image container will contain the icon. + imageUpdate = { + [`image_container_${i}`]: { + attributes: { + // Remove the date-chiclet attribute by setting it to null. + "is-date-chiclet": null, + }, + }, + [`image_${i}`]: { + attributes: { + src: icon, + }, + }, + }; + } else { + // Instead of an icon, the image container will be a date chiclet + // containing the item's date as text, with the day above the month. + let partsArray = new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + timeZone: zonedNow.timeZoneId, + }).formatToParts(date); + let partsMap = Object.fromEntries( + partsArray.map(({ type, value }) => [type, value]) + ); + if (partsMap.day && partsMap.month) { + imageUpdate = { + [`image_container_${i}`]: { + attributes: { + "is-date-chiclet": "", + }, + }, + [`scheduled-date-chiclet-day-${i}`]: { + textContent: partsMap.day, + }, + [`scheduled-date-chiclet-month-${i}`]: { + textContent: partsMap.month, + }, + }; + } else { + // This shouldn't happen. + imageUpdate = {}; + } + } + + // Create the date update. First, format the date. + let formattedDate; + if (Math.abs(daysUntil) <= 1) { + // Relative date: "Today", "Tomorrow", "Yesterday" + formattedDate = capitalizeString( + new Intl.RelativeTimeFormat(undefined, { + numeric: "auto", + }).format(daysUntil, "day") + ); + } else { + // Formatted date with some combination of year, month, day, and weekday + let opts = { + timeZone: zonedNow.timeZoneId, + }; + if (!isScheduled || icon || !isFuture) { + opts.month = "short"; + opts.day = "numeric"; + if (zonedDate.year != zonedNow.year) { + opts.year = "numeric"; + } + } + if (isScheduled && isFuture) { + opts.weekday = "short"; + } + formattedDate = new Intl.DateTimeFormat(undefined, opts).format(date); + } + + // Now format the time. + let formattedTime; + if (isScheduled && daysUntil >= 0) { + formattedTime = new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "numeric", + timeZoneName: "short", + timeZone: zonedNow.timeZoneId, + }).format(date); + } + + // Finally, create the date update. + let dateUpdate; + if (formattedTime) { + dateUpdate = { + [`date-${i}`]: { + l10n: { + id: "urlbar-result-sports-game-date-with-time", + args: { + date: formattedDate, + time: formattedTime, + }, + }, + }, + }; + } else { + dateUpdate = { + [`date-${i}`]: { + textContent: formattedDate, + }, + }; + } + + // Create the status update. Show the status if the game is live or if it + // happened earlier today. Otherwise clear the status. + let statusUpdate; + if (item.status_type == "live") { + statusUpdate = { + [`status-${i}`]: { + l10n: { + id: "urlbar-result-sports-status-live", + }, + }, + }; + } else if (daysUntil == 0 && item.status_type == "past") { + statusUpdate = { + [`status-${i}`]: { + l10n: { + id: "urlbar-result-sports-status-final", + }, + }, + }; + } else { + statusUpdate = { + [`status-${i}`]: { + // Clear the status by setting its text to an empty string. + textContent: "", + }, + }; + } + + return { + ...imageUpdate, + ...dateUpdate, + ...statusUpdate, + [`sport-name-${i}`]: { + textContent: item.sport, + }, + }; + } + + /** + * Parses a date and returns some info about it. + * + * This is a static method rather than a helper function internal to this file + * so that tests can easily test it. + * + * @param {Date} date + * A `Date` object. + * @returns {DateParseResult} + * The result. + * + * @typedef {object} DateParseResult + * @property {typeof Temporal.ZonedDateTime} zonedNow + * Now as a `ZonedDateTime`. + * @property {typeof Temporal.ZonedDateTime} zonedDate + * The passed-in date as a `ZonedDateTime`. + * @property {boolean} isFuture + * Whether the date is in the future. + * @property {number} daysUntil + * The number of calendar days from today to the date: + * If the date is after tomorrow: `Infinity` + * If the date is tomorrow: `1` + * If the date is today: `0` + * If the date is yesterday: `-1` + * If the date is before yesterday: `-Infinity` + */ + static _parseDate(date) { + // Find how many days there are from today to the date. + let zonedNow = SportsSuggestions._zonedDateTimeISO(); + let zonedDate = date.toTemporalInstant().toZonedDateTimeISO(zonedNow); + + let today = zonedNow.startOfDay(); + let yesterday = today.subtract({ days: 1 }); + let tomorrow = today.add({ days: 1 }); + let dayAfterTomorrow = today.add({ days: 2 }); + + let daysUntil; + if (Temporal.ZonedDateTime.compare(dayAfterTomorrow, zonedDate) <= 0) { + // date is after tomorrow + daysUntil = Infinity; + } else if (Temporal.ZonedDateTime.compare(tomorrow, zonedDate) <= 0) { + // date is tomorrow + daysUntil = 1; + } else if (Temporal.ZonedDateTime.compare(today, zonedDate) <= 0) { + // date is today + daysUntil = 0; + } else if (Temporal.ZonedDateTime.compare(yesterday, zonedDate) <= 0) { + // date is yesterday + daysUntil = -1; + } else { + // date is before yesterday + daysUntil = -Infinity; + } + + let isFuture = Temporal.ZonedDateTime.compare(zonedNow, zonedDate) < 0; + + return { + zonedNow, + zonedDate, + isFuture, + daysUntil, + }; + } + + // Thin wrapper around `zonedDateTimeISO` so that tests can easily set a mock + // "now" date and time. + static _zonedDateTimeISO() { + return Temporal.Now.zonedDateTimeISO(); + } +} + +function itemIcon(item) { + return item.icon || item.home_team?.icon || item.away_team?.icon; +} + +function stringifiedScore(scoreValue) { + let s = scoreValue; + if (typeof s == "number") { + s = String(s); + } + return typeof s == "string" ? s : ""; +} + +function capitalizeString(str) { + return str[0].toLocaleUpperCase() + str.substring(1); +} diff --git a/browser/components/urlbar/private/YelpRealtimeSuggestions.sys.mjs b/browser/components/urlbar/private/YelpRealtimeSuggestions.sys.mjs @@ -32,7 +32,7 @@ export class YelpRealtimeSuggestions extends RealtimeSuggestProvider { }); } - getViewTemplateForDescriptionTop(index) { + getViewTemplateForDescriptionTop(_item, index) { return [ { name: `title_${index}`, @@ -42,7 +42,7 @@ export class YelpRealtimeSuggestions extends RealtimeSuggestProvider { ]; } - getViewTemplateForDescriptionBottom(index) { + getViewTemplateForDescriptionBottom(_item, index) { return [ { name: `address_${index}`, @@ -83,30 +83,30 @@ export class YelpRealtimeSuggestions extends RealtimeSuggestProvider { ]; } - getViewUpdateForValue(i, v) { + getViewUpdateForPayloadItem(item, index) { return { - [`item_${i}`]: { + [`item_${index}`]: { attributes: { - state: v.business_hours[0].is_open_now ? "open" : "closed", + state: item.business_hours[0].is_open_now ? "open" : "closed", }, }, - [`image_${i}`]: { + [`image_${index}`]: { attributes: { - src: v.image_url, + src: item.image_url, }, }, - [`title_${i}`]: { - textContent: v.name, + [`title_${index}`]: { + textContent: item.name, }, - [`address_${i}`]: { - textContent: v.address, + [`address_${index}`]: { + textContent: item.address, }, - [`pricing_${i}`]: { - textContent: v.pricing, + [`pricing_${index}`]: { + textContent: item.pricing, }, - [`business_hours_${i}`]: { + [`business_hours_${index}`]: { l10n: { - id: v.business_hours[0].is_open_now + id: item.business_hours[0].is_open_now ? "urlbar-result-yelp-realtime-business-hours-open" : "urlbar-result-yelp-realtime-business-hours-closed", args: { @@ -121,12 +121,12 @@ export class YelpRealtimeSuggestions extends RealtimeSuggestProvider { excludeArgsFromCacheKey: true, }, }, - [`popularity_${i}`]: { + [`popularity_${index}`]: { l10n: { id: "urlbar-result-yelp-realtime-popularity", args: { - rating: v.rating, - review_count: v.review_count, + rating: item.rating, + review_count: item.review_count, }, cacheable: true, excludeArgsFromCacheKey: true, diff --git a/browser/components/urlbar/tests/quicksuggest/QuickSuggestTestUtils.sys.mjs b/browser/components/urlbar/tests/quicksuggest/QuickSuggestTestUtils.sys.mjs @@ -1596,7 +1596,7 @@ class _QuickSuggestTestUtils { lazy.SearchUtils.TOPIC_SEARCH_SERVICE, (subject, data) => { this.#log( - "setLocales", + "#waitForAllLocaleChanges", "Observed TOPIC_SEARCH_SERVICE with data: " + data ); return data == "engines-reloaded"; @@ -1605,7 +1605,7 @@ class _QuickSuggestTestUtils { new Promise(resolve => { lazy.setTimeout(() => { this.#log( - "setLocales", + "#waitForAllLocaleChanges", "Timed out waiting for TOPIC_SEARCH_SERVICE (not an error)" ); resolve(); diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser.toml b/browser/components/urlbar/tests/quicksuggest/browser/browser.toml @@ -46,6 +46,8 @@ tags = "search-telemetry" ["browser_quicksuggest_realtime_yelp.js"] +["browser_quicksuggest_sports.js"] + ["browser_quicksuggest_yelp.js"] ["browser_telemetry_environment.js"] diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_realtime_optin.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_realtime_optin.js @@ -165,7 +165,7 @@ async function doOptInTest(useKeyboard) { Assert.ok(UrlbarPrefs.get("quicksuggest.online.enabled")); Assert.equal(merinoResult.payload.source, "merino"); Assert.equal(merinoResult.payload.provider, "polygon"); - Assert.equal(merinoResult.payload.dynamicType, "market"); + Assert.equal(merinoResult.payload.dynamicType, "realtime-market"); info("Allow button works"); await UrlbarTestUtils.promisePopupClose(window); diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_sports.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_sports.js @@ -0,0 +1,1747 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests sports suggestions. + +ChromeUtils.defineESModuleGetters(this, { + SportsSuggestions: + "moz-src:///browser/components/urlbar/private/SportsSuggestions.sys.mjs", +}); + +// Trying to avoid timeouts in TV mode, especially on debug Mac. +requestLongerTimeout(3); + +// 2025-10-30 - game status is "past", without icon +const SUGGESTION_VALUE_PAST = { + sport: "Sport 1", + query: "query 1", + date: "2025-10-30T17:00:00Z", + home_team: { + name: "Team 1 Home", + score: 5, + }, + away_team: { + name: "Team 1 Away", + score: 4, + }, + status_type: "past", +}; + +// 2025-10-30 - game status is "past", with icon +const SUGGESTION_VALUE_PAST_ICON = { + ...SUGGESTION_VALUE_PAST, + icon: "https://example.com/sports-icon", +}; + +// 2025-10-30 - game status is "past", without scores +const SUGGESTION_VALUE_PAST_NO_SCORES = { + ...SUGGESTION_VALUE_PAST, + home_team: { + name: "Team 1 Home", + }, + away_team: { + name: "Team 1 Away", + }, +}; + +// 2025-10-31 - game status is "live", without icon +const SUGGESTION_VALUE_LIVE = { + sport: "Sport 2", + query: "query 2", + date: "2025-10-31T17:00:00Z", + home_team: { + name: "Team 2 Home", + score: 1, + }, + away_team: { + name: "Team 2 Away", + score: 0, + }, + status_type: "live", +}; + +// 2025-10-31 - game status is "live", with icon +const SUGGESTION_VALUE_LIVE_ICON = { + ...SUGGESTION_VALUE_LIVE, + icon: "https://example.com/sports-icon", +}; + +// 2025-10-31 - game status is "live", without scores +const SUGGESTION_VALUE_LIVE_NO_SCORES = { + ...SUGGESTION_VALUE_LIVE, + home_team: { + name: "Team 2 Home", + }, + away_team: { + name: "Team 2 Away", + }, +}; + +// 2025-11-01 - game status is "scheduled", without icon +const SUGGESTION_VALUE_SCHEDULED = { + sport: "Sport 3", + query: "query 3", + date: "2025-11-01T17:00:00Z", + home_team: { + name: "Team 3 Home", + score: null, + }, + away_team: { + name: "Team 3 Away", + score: null, + }, + status_type: "scheduled", +}; + +// 2025-11-01 - game status is "scheduled", with icon +const SUGGESTION_VALUE_SCHEDULED_ICON = { + ...SUGGESTION_VALUE_SCHEDULED, + icon: "https://example.com/sports-icon", +}; + +// 2025-11-01 - game status is "scheduled", with icons in the team objects +const SUGGESTION_VALUE_SCHEDULED_ICONS_IN_TEAMS = { + ...SUGGESTION_VALUE_SCHEDULED, + home_team: { + name: "Team 3 Home", + score: null, + icon: "https://example.com/sports-icon-home", + }, + away_team: { + name: "Team 3 Away", + score: null, + icon: "https://example.com/sports-icon-away", + }, +}; + +add_setup(async function () { + await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + }); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + merinoSuggestions: merinoSuggestions([SUGGESTION_VALUE_PAST]), + prefs: [ + ["sports.featureGate", true], + ["suggest.sports", true], + ["suggest.quicksuggest.all", true], + ], + }); + + registerCleanupFunction(() => { + setNow(null); + }); +}); + +add_task(async function manyItems() { + await doTest({ + now: "2025-10-31T14:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([ + SUGGESTION_VALUE_PAST, + SUGGESTION_VALUE_LIVE, + SUGGESTION_VALUE_SCHEDULED, + ]), + expectedItems: [ + { + image: null, + image_container: { + attributes: { + "is-date-chiclet": "", + }, + }, + "scheduled-date-chiclet-day": "30", + "scheduled-date-chiclet-month": "Oct", + "sport-name": "Sport 1", + "home-team-name": "Team 1 Home", + "home-team-score": "5", + "away-team-name": "Team 1 Away", + "away-team-score": "4", + date: "Yesterday", + status: "", + }, + { + image: null, + image_container: { + attributes: { + "is-date-chiclet": "", + }, + }, + "scheduled-date-chiclet-day": "31", + "scheduled-date-chiclet-month": "Oct", + "sport-name": "Sport 2", + "home-team-name": "Team 2 Home", + "home-team-score": "1", + "away-team-name": "Team 2 Away", + "away-team-score": "0", + date: "Today", + status: { + l10n: { + id: "urlbar-result-sports-status-live", + }, + }, + }, + { + image: null, + image_container: { + attributes: { + "is-date-chiclet": "", + }, + }, + "scheduled-date-chiclet-day": "1", + "scheduled-date-chiclet-month": "Nov", + "sport-name": "Sport 3", + "team-names": { + l10n: { + id: "urlbar-result-sports-team-names", + args: { + homeTeam: "Team 3 Home", + awayTeam: "Team 3 Away", + }, + }, + }, + date: { + l10n: { + id: "urlbar-result-sports-game-date-with-time", + args: { + date: "Tomorrow", + time: "1:00 PM GMT-4", + }, + }, + }, + status: "", + }, + ], + }); +}); + +add_task(async function past_noScores() { + await doTest({ + now: "2025-10-31T14:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_PAST_NO_SCORES]), + expectedItems: [ + { + image: null, + image_container: { + attributes: { + "is-date-chiclet": "", + }, + }, + "scheduled-date-chiclet-day": "30", + "scheduled-date-chiclet-month": "Oct", + "sport-name": "Sport 1", + // should use "team-names" UI, not scores + "team-names": { + l10n: { + id: "urlbar-result-sports-team-names", + args: { + homeTeam: "Team 1 Home", + awayTeam: "Team 1 Away", + }, + }, + }, + date: "Yesterday", + status: "", + }, + ], + }); +}); + +add_task(async function live_noScores() { + await doTest({ + now: "2025-10-31T14:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_LIVE_NO_SCORES]), + expectedItems: [ + { + image: null, + image_container: { + attributes: { + "is-date-chiclet": "", + }, + }, + "scheduled-date-chiclet-day": "31", + "scheduled-date-chiclet-month": "Oct", + "sport-name": "Sport 2", + // should use "team-names" UI, not scores + "team-names": { + l10n: { + id: "urlbar-result-sports-team-names", + args: { + homeTeam: "Team 2 Home", + awayTeam: "Team 2 Away", + }, + }, + }, + date: "Today", + status: { + l10n: { + id: "urlbar-result-sports-status-live", + }, + }, + }, + ], + }); +}); + +add_task(async function scheduled_iconsInTeams() { + await doTest({ + now: "2025-10-31T14:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_SCHEDULED_ICONS_IN_TEAMS]), + expectedItems: [ + { + image_container: { + attributes: { + "is-date-chiclet": null, + }, + }, + "scheduled-date-chiclet-day": null, + "scheduled-date-chiclet-month": null, + // home team icon should be used + image: { + attributes: { + src: "https://example.com/sports-icon-home", + }, + }, + "sport-name": "Sport 3", + "team-names": { + l10n: { + id: "urlbar-result-sports-team-names", + args: { + homeTeam: "Team 3 Home", + awayTeam: "Team 3 Away", + }, + }, + }, + date: { + l10n: { + id: "urlbar-result-sports-game-date-with-time", + args: { + date: "Tomorrow", + time: "1:00 PM GMT-4", + }, + }, + }, + status: "", + }, + ], + }); +}); + +/////////////////////////////////////////////////////////////////////////////// +// +// Games with "past" status + +add_task(async function past_lastYear_noIcon() { + await doTest({ + now: "2026-12-01T14:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_PAST]), + expectedItems: [ + { + image: null, + image_container: { + attributes: { + "is-date-chiclet": "", + }, + }, + "scheduled-date-chiclet-day": "30", + "scheduled-date-chiclet-month": "Oct", + "sport-name": "Sport 1", + "home-team-name": "Team 1 Home", + "home-team-score": "5", + "away-team-name": "Team 1 Away", + "away-team-score": "4", + date: "Oct 30, 2025", + status: "", + }, + ], + }); +}); + +add_task(async function past_lastYear_icon() { + await doTest({ + now: "2026-12-01T14:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_PAST_ICON]), + expectedItems: [ + { + image_container: { + attributes: { + "is-date-chiclet": null, + }, + }, + "scheduled-date-chiclet-day": null, + "scheduled-date-chiclet-month": null, + image: { + attributes: { + src: "https://example.com/sports-icon", + }, + }, + "sport-name": "Sport 1", + "home-team-name": "Team 1 Home", + "home-team-score": "5", + "away-team-name": "Team 1 Away", + "away-team-score": "4", + date: "Oct 30, 2025", + status: "", + }, + ], + }); +}); + +add_task(async function past_beforeYesterday_noIcon() { + await doTest({ + now: "2025-12-01T14:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_PAST]), + expectedItems: [ + { + image: null, + image_container: { + attributes: { + "is-date-chiclet": "", + }, + }, + "scheduled-date-chiclet-day": "30", + "scheduled-date-chiclet-month": "Oct", + "sport-name": "Sport 1", + "home-team-name": "Team 1 Home", + "home-team-score": "5", + "away-team-name": "Team 1 Away", + "away-team-score": "4", + date: "Oct 30", + status: "", + }, + ], + }); +}); + +add_task(async function past_beforeYesterday_icon() { + await doTest({ + now: "2025-12-01T14:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_PAST_ICON]), + expectedItems: [ + { + image_container: { + attributes: { + "is-date-chiclet": null, + }, + }, + "scheduled-date-chiclet-day": null, + "scheduled-date-chiclet-month": null, + image: { + attributes: { + src: "https://example.com/sports-icon", + }, + }, + "sport-name": "Sport 1", + "home-team-name": "Team 1 Home", + "home-team-score": "5", + "away-team-name": "Team 1 Away", + "away-team-score": "4", + date: "Oct 30", + status: "", + }, + ], + }); +}); + +add_task(async function past_yesterday_noIcon() { + await doTest({ + now: "2025-10-31T14:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_PAST]), + expectedItems: [ + { + image: null, + image_container: { + attributes: { + "is-date-chiclet": "", + }, + }, + "scheduled-date-chiclet-day": "30", + "scheduled-date-chiclet-month": "Oct", + "sport-name": "Sport 1", + "home-team-name": "Team 1 Home", + "home-team-score": "5", + "away-team-name": "Team 1 Away", + "away-team-score": "4", + date: "Yesterday", + status: "", + }, + ], + }); +}); + +add_task(async function past_yesterday_icon() { + await doTest({ + now: "2025-10-31T14:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_PAST_ICON]), + expectedItems: [ + { + image_container: { + attributes: { + "is-date-chiclet": null, + }, + }, + "scheduled-date-chiclet-day": null, + "scheduled-date-chiclet-month": null, + image: { + attributes: { + src: "https://example.com/sports-icon", + }, + }, + "sport-name": "Sport 1", + "home-team-name": "Team 1 Home", + "home-team-score": "5", + "away-team-name": "Team 1 Away", + "away-team-score": "4", + date: "Yesterday", + status: "", + }, + ], + }); +}); + +add_task(async function past_todayPast_noIcon() { + await doTest({ + now: "2025-10-30T22:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_PAST]), + expectedItems: [ + { + image: null, + image_container: { + attributes: { + "is-date-chiclet": "", + }, + }, + "scheduled-date-chiclet-day": "30", + "scheduled-date-chiclet-month": "Oct", + "sport-name": "Sport 1", + "home-team-name": "Team 1 Home", + "home-team-score": "5", + "away-team-name": "Team 1 Away", + "away-team-score": "4", + date: "Today", + status: { + l10n: { + id: "urlbar-result-sports-status-final", + }, + }, + }, + ], + }); +}); + +add_task(async function past_todayPast_icon() { + await doTest({ + now: "2025-10-30T22:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_PAST_ICON]), + expectedItems: [ + { + image_container: { + attributes: { + "is-date-chiclet": null, + }, + }, + "scheduled-date-chiclet-day": null, + "scheduled-date-chiclet-month": null, + image: { + attributes: { + src: "https://example.com/sports-icon", + }, + }, + "sport-name": "Sport 1", + "home-team-name": "Team 1 Home", + "home-team-score": "5", + "away-team-name": "Team 1 Away", + "away-team-score": "4", + date: "Today", + status: { + l10n: { + id: "urlbar-result-sports-status-final", + }, + }, + }, + ], + }); +}); + +// This shouldn't normally happen but technically it could. +add_task(async function past_todayFuture_noIcon() { + await doTest({ + now: "2025-10-30T09:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_PAST]), + expectedItems: [ + { + image: null, + image_container: { + attributes: { + "is-date-chiclet": "", + }, + }, + "scheduled-date-chiclet-day": "30", + "scheduled-date-chiclet-month": "Oct", + "sport-name": "Sport 1", + "home-team-name": "Team 1 Home", + "home-team-score": "5", + "away-team-name": "Team 1 Away", + "away-team-score": "4", + date: "Today", + status: { + l10n: { + id: "urlbar-result-sports-status-final", + }, + }, + }, + ], + }); +}); + +// This shouldn't normally happen but technically it could. +add_task(async function past_todayFuture_icon() { + await doTest({ + now: "2025-10-30T09:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_PAST_ICON]), + expectedItems: [ + { + image_container: { + attributes: { + "is-date-chiclet": null, + }, + }, + "scheduled-date-chiclet-day": null, + "scheduled-date-chiclet-month": null, + image: { + attributes: { + src: "https://example.com/sports-icon", + }, + }, + "sport-name": "Sport 1", + "home-team-name": "Team 1 Home", + "home-team-score": "5", + "away-team-name": "Team 1 Away", + "away-team-score": "4", + date: "Today", + status: { + l10n: { + id: "urlbar-result-sports-status-final", + }, + }, + }, + ], + }); +}); + +/////////////////////////////////////////////////////////////////////////////// +// +// Games with "live" status + +// This probably shouldn't happen but it could, especially if the game is in a +// different time zone from the user and/or happening around the new year. +add_task(async function live_lastYear_noIcon() { + await doTest({ + now: "2026-12-01T14:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_LIVE]), + expectedItems: [ + { + image: null, + image_container: { + attributes: { + "is-date-chiclet": "", + }, + }, + "scheduled-date-chiclet-day": "31", + "scheduled-date-chiclet-month": "Oct", + "sport-name": "Sport 2", + "home-team-name": "Team 2 Home", + "home-team-score": "1", + "away-team-name": "Team 2 Away", + "away-team-score": "0", + date: "Oct 31, 2025", + status: { + l10n: { + id: "urlbar-result-sports-status-live", + }, + }, + }, + ], + }); +}); + +// This probably shouldn't happen but it could, especially if the game is in a +// different time zone from the user and/or happening around the new year. +add_task(async function live_lastYear_icon() { + await doTest({ + now: "2026-12-01T14:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_LIVE_ICON]), + expectedItems: [ + { + image_container: { + attributes: { + "is-date-chiclet": null, + }, + }, + "scheduled-date-chiclet-day": null, + "scheduled-date-chiclet-month": null, + image: { + attributes: { + src: "https://example.com/sports-icon", + }, + }, + "sport-name": "Sport 2", + "home-team-name": "Team 2 Home", + "home-team-score": "1", + "away-team-name": "Team 2 Away", + "away-team-score": "0", + date: "Oct 31, 2025", + status: { + l10n: { + id: "urlbar-result-sports-status-live", + }, + }, + }, + ], + }); +}); + +// This probably shouldn't happen but technically it could. +add_task(async function live_beforeYesterday_noIcon() { + await doTest({ + now: "2025-12-01T14:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_LIVE]), + expectedItems: [ + { + image: null, + image_container: { + attributes: { + "is-date-chiclet": "", + }, + }, + "scheduled-date-chiclet-day": "31", + "scheduled-date-chiclet-month": "Oct", + "sport-name": "Sport 2", + "home-team-name": "Team 2 Home", + "home-team-score": "1", + "away-team-name": "Team 2 Away", + "away-team-score": "0", + date: "Oct 31", + status: { + l10n: { + id: "urlbar-result-sports-status-live", + }, + }, + }, + ], + }); +}); + +// This probably shouldn't happen but technically it could. +add_task(async function live_beforeYesterday_icon() { + await doTest({ + now: "2025-12-01T14:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_LIVE_ICON]), + expectedItems: [ + { + image_container: { + attributes: { + "is-date-chiclet": null, + }, + }, + "scheduled-date-chiclet-day": null, + "scheduled-date-chiclet-month": null, + image: { + attributes: { + src: "https://example.com/sports-icon", + }, + }, + "sport-name": "Sport 2", + "home-team-name": "Team 2 Home", + "home-team-score": "1", + "away-team-name": "Team 2 Away", + "away-team-score": "0", + date: "Oct 31", + status: { + l10n: { + id: "urlbar-result-sports-status-live", + }, + }, + }, + ], + }); +}); + +// This probably shouldn't happen but it could, especially if the game is in a +// different time zone from the user. +add_task(async function live_yesterday_noIcon() { + await doTest({ + now: "2025-11-01T14:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_LIVE]), + expectedItems: [ + { + image: null, + image_container: { + attributes: { + "is-date-chiclet": "", + }, + }, + "scheduled-date-chiclet-day": "31", + "scheduled-date-chiclet-month": "Oct", + "sport-name": "Sport 2", + "home-team-name": "Team 2 Home", + "home-team-score": "1", + "away-team-name": "Team 2 Away", + "away-team-score": "0", + date: "Yesterday", + status: { + l10n: { + id: "urlbar-result-sports-status-live", + }, + }, + }, + ], + }); +}); + +// This probably shouldn't happen but it could, especially if the game is in a +// different time zone from the user. +add_task(async function live_yesterday_icon() { + await doTest({ + now: "2025-11-01T14:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_LIVE_ICON]), + expectedItems: [ + { + image_container: { + attributes: { + "is-date-chiclet": null, + }, + }, + "scheduled-date-chiclet-day": null, + "scheduled-date-chiclet-month": null, + image: { + attributes: { + src: "https://example.com/sports-icon", + }, + }, + "sport-name": "Sport 2", + "home-team-name": "Team 2 Home", + "home-team-score": "1", + "away-team-name": "Team 2 Away", + "away-team-score": "0", + date: "Yesterday", + status: { + l10n: { + id: "urlbar-result-sports-status-live", + }, + }, + }, + ], + }); +}); + +add_task(async function live_todayPast_noIcon() { + await doTest({ + now: "2025-10-31T14:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_LIVE]), + expectedItems: [ + { + image: null, + image_container: { + attributes: { + "is-date-chiclet": "", + }, + }, + "scheduled-date-chiclet-day": "31", + "scheduled-date-chiclet-month": "Oct", + "sport-name": "Sport 2", + "home-team-name": "Team 2 Home", + "home-team-score": "1", + "away-team-name": "Team 2 Away", + "away-team-score": "0", + date: "Today", + status: { + l10n: { + id: "urlbar-result-sports-status-live", + }, + }, + }, + ], + }); +}); + +add_task(async function live_todayPast_icon() { + await doTest({ + now: "2025-10-31T14:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_LIVE_ICON]), + expectedItems: [ + { + image_container: { + attributes: { + "is-date-chiclet": null, + }, + }, + "scheduled-date-chiclet-day": null, + "scheduled-date-chiclet-month": null, + image: { + attributes: { + src: "https://example.com/sports-icon", + }, + }, + "sport-name": "Sport 2", + "home-team-name": "Team 2 Home", + "home-team-score": "1", + "away-team-name": "Team 2 Away", + "away-team-score": "0", + date: "Today", + status: { + l10n: { + id: "urlbar-result-sports-status-live", + }, + }, + }, + ], + }); +}); + +// This shouldn't normally happen but technically it could. +add_task(async function live_todayFuture_noIcon() { + await doTest({ + now: "2025-10-31T09:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_LIVE]), + expectedItems: [ + { + image: null, + image_container: { + attributes: { + "is-date-chiclet": "", + }, + }, + "scheduled-date-chiclet-day": "31", + "scheduled-date-chiclet-month": "Oct", + "sport-name": "Sport 2", + "home-team-name": "Team 2 Home", + "home-team-score": "1", + "away-team-name": "Team 2 Away", + "away-team-score": "0", + date: "Today", + status: { + l10n: { + id: "urlbar-result-sports-status-live", + }, + }, + }, + ], + }); +}); + +// This shouldn't normally happen but technically it could. +add_task(async function live_todayFuture_icon() { + await doTest({ + now: "2025-10-31T09:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_LIVE_ICON]), + expectedItems: [ + { + image_container: { + attributes: { + "is-date-chiclet": null, + }, + }, + "scheduled-date-chiclet-day": null, + "scheduled-date-chiclet-month": null, + image: { + attributes: { + src: "https://example.com/sports-icon", + }, + }, + "sport-name": "Sport 2", + "home-team-name": "Team 2 Home", + "home-team-score": "1", + "away-team-name": "Team 2 Away", + "away-team-score": "0", + date: "Today", + status: { + l10n: { + id: "urlbar-result-sports-status-live", + }, + }, + }, + ], + }); +}); + +// This shouldn't normally happen but technically it could. +add_task(async function live_tomorrow_noIcon() { + await doTest({ + now: "2025-10-30T09:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_LIVE]), + expectedItems: [ + { + image: null, + image_container: { + attributes: { + "is-date-chiclet": "", + }, + }, + "scheduled-date-chiclet-day": "31", + "scheduled-date-chiclet-month": "Oct", + "sport-name": "Sport 2", + "home-team-name": "Team 2 Home", + "home-team-score": "1", + "away-team-name": "Team 2 Away", + "away-team-score": "0", + date: "Tomorrow", + status: { + l10n: { + id: "urlbar-result-sports-status-live", + }, + }, + }, + ], + }); +}); + +// This shouldn't normally happen but technically it could. +add_task(async function live_tomorrow_icon() { + await doTest({ + now: "2025-10-30T09:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_LIVE_ICON]), + expectedItems: [ + { + image_container: { + attributes: { + "is-date-chiclet": null, + }, + }, + "scheduled-date-chiclet-day": null, + "scheduled-date-chiclet-month": null, + image: { + attributes: { + src: "https://example.com/sports-icon", + }, + }, + "sport-name": "Sport 2", + "home-team-name": "Team 2 Home", + "home-team-score": "1", + "away-team-name": "Team 2 Away", + "away-team-score": "0", + date: "Tomorrow", + status: { + l10n: { + id: "urlbar-result-sports-status-live", + }, + }, + }, + ], + }); +}); + +/////////////////////////////// + +// Games with "scheduled" status + +// This shouldn't normally happen but technically it could. +add_task(async function scheduled_lastYear_noIcon() { + await doTest({ + now: "2026-12-01T12:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_SCHEDULED]), + expectedItems: [ + { + image: null, + image_container: { + attributes: { + "is-date-chiclet": "", + }, + }, + "scheduled-date-chiclet-day": "1", + "scheduled-date-chiclet-month": "Nov", + "sport-name": "Sport 3", + "team-names": { + l10n: { + id: "urlbar-result-sports-team-names", + args: { + homeTeam: "Team 3 Home", + awayTeam: "Team 3 Away", + }, + }, + }, + date: "Nov 1, 2025", + status: "", + }, + ], + }); +}); + +// This shouldn't normally happen but technically it could. +add_task(async function scheduled_lastYear_icon() { + await doTest({ + now: "2026-12-01T12:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_SCHEDULED_ICON]), + expectedItems: [ + { + image_container: { + attributes: { + "is-date-chiclet": null, + }, + }, + "scheduled-date-chiclet-day": null, + "scheduled-date-chiclet-month": null, + image: { + attributes: { + src: "https://example.com/sports-icon", + }, + }, + "sport-name": "Sport 3", + "team-names": { + l10n: { + id: "urlbar-result-sports-team-names", + args: { + homeTeam: "Team 3 Home", + awayTeam: "Team 3 Away", + }, + }, + }, + date: "Nov 1, 2025", + status: "", + }, + ], + }); +}); + +// This shouldn't normally happen but technically it could. +add_task(async function scheduled_beforeYesterday_noIcon() { + await doTest({ + now: "2025-12-01T12:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_SCHEDULED]), + expectedItems: [ + { + image: null, + image_container: { + attributes: { + "is-date-chiclet": "", + }, + }, + "scheduled-date-chiclet-day": "1", + "scheduled-date-chiclet-month": "Nov", + "sport-name": "Sport 3", + "team-names": { + l10n: { + id: "urlbar-result-sports-team-names", + args: { + homeTeam: "Team 3 Home", + awayTeam: "Team 3 Away", + }, + }, + }, + date: "Nov 1", + status: "", + }, + ], + }); +}); + +// This shouldn't normally happen but technically it could. +add_task(async function scheduled_beforeYesterday_icon() { + await doTest({ + now: "2025-12-01T12:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_SCHEDULED_ICON]), + expectedItems: [ + { + image_container: { + attributes: { + "is-date-chiclet": null, + }, + }, + "scheduled-date-chiclet-day": null, + "scheduled-date-chiclet-month": null, + image: { + attributes: { + src: "https://example.com/sports-icon", + }, + }, + "sport-name": "Sport 3", + "team-names": { + l10n: { + id: "urlbar-result-sports-team-names", + args: { + homeTeam: "Team 3 Home", + awayTeam: "Team 3 Away", + }, + }, + }, + date: "Nov 1", + status: "", + }, + ], + }); +}); + +// This shouldn't normally happen but technically it could. +add_task(async function scheduled_yesterday_noIcon() { + await doTest({ + now: "2025-11-02T12:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_SCHEDULED]), + expectedItems: [ + { + image: null, + image_container: { + attributes: { + "is-date-chiclet": "", + }, + }, + "scheduled-date-chiclet-day": "1", + "scheduled-date-chiclet-month": "Nov", + "sport-name": "Sport 3", + "team-names": { + l10n: { + id: "urlbar-result-sports-team-names", + args: { + homeTeam: "Team 3 Home", + awayTeam: "Team 3 Away", + }, + }, + }, + date: "Yesterday", + status: "", + }, + ], + }); +}); + +// This shouldn't normally happen but technically it could. +add_task(async function scheduled_yesterday_icon() { + await doTest({ + now: "2025-11-02T12:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_SCHEDULED_ICON]), + expectedItems: [ + { + image_container: { + attributes: { + "is-date-chiclet": null, + }, + }, + "scheduled-date-chiclet-day": null, + "scheduled-date-chiclet-month": null, + image: { + attributes: { + src: "https://example.com/sports-icon", + }, + }, + "sport-name": "Sport 3", + "team-names": { + l10n: { + id: "urlbar-result-sports-team-names", + args: { + homeTeam: "Team 3 Home", + awayTeam: "Team 3 Away", + }, + }, + }, + date: "Yesterday", + status: "", + }, + ], + }); +}); + +// This shouldn't normally happen but technically it could. +add_task(async function scheduled_todayPast_noIcon() { + await doTest({ + now: "2025-11-01T22:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_SCHEDULED]), + expectedItems: [ + { + image: null, + image_container: { + attributes: { + "is-date-chiclet": "", + }, + }, + "scheduled-date-chiclet-day": "1", + "scheduled-date-chiclet-month": "Nov", + "sport-name": "Sport 3", + "team-names": { + l10n: { + id: "urlbar-result-sports-team-names", + args: { + homeTeam: "Team 3 Home", + awayTeam: "Team 3 Away", + }, + }, + }, + date: { + l10n: { + id: "urlbar-result-sports-game-date-with-time", + args: { + date: "Today", + time: "1:00 PM GMT-4", + }, + }, + }, + status: "", + }, + ], + }); +}); + +// This shouldn't normally happen but technically it could. +add_task(async function scheduled_todayPast_icon() { + await doTest({ + now: "2025-11-01T22:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_SCHEDULED_ICON]), + expectedItems: [ + { + image_container: { + attributes: { + "is-date-chiclet": null, + }, + }, + "scheduled-date-chiclet-day": null, + "scheduled-date-chiclet-month": null, + image: { + attributes: { + src: "https://example.com/sports-icon", + }, + }, + "sport-name": "Sport 3", + "team-names": { + l10n: { + id: "urlbar-result-sports-team-names", + args: { + homeTeam: "Team 3 Home", + awayTeam: "Team 3 Away", + }, + }, + }, + date: { + l10n: { + id: "urlbar-result-sports-game-date-with-time", + args: { + date: "Today", + time: "1:00 PM GMT-4", + }, + }, + }, + status: "", + }, + ], + }); +}); + +add_task(async function scheduled_todayFuture_noIcon() { + await doTest({ + now: "2025-11-01T09:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_SCHEDULED]), + expectedItems: [ + { + image: null, + image_container: { + attributes: { + "is-date-chiclet": "", + }, + }, + "scheduled-date-chiclet-day": "1", + "scheduled-date-chiclet-month": "Nov", + "sport-name": "Sport 3", + "team-names": { + l10n: { + id: "urlbar-result-sports-team-names", + args: { + homeTeam: "Team 3 Home", + awayTeam: "Team 3 Away", + }, + }, + }, + date: { + l10n: { + id: "urlbar-result-sports-game-date-with-time", + args: { + date: "Today", + time: "1:00 PM GMT-4", + }, + }, + }, + status: "", + }, + ], + }); +}); + +add_task(async function scheduled_todayFuture_icon() { + await doTest({ + now: "2025-11-01T09:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_SCHEDULED_ICON]), + expectedItems: [ + { + image_container: { + attributes: { + "is-date-chiclet": null, + }, + }, + "scheduled-date-chiclet-day": null, + "scheduled-date-chiclet-month": null, + image: { + attributes: { + src: "https://example.com/sports-icon", + }, + }, + "sport-name": "Sport 3", + "team-names": { + l10n: { + id: "urlbar-result-sports-team-names", + args: { + homeTeam: "Team 3 Home", + awayTeam: "Team 3 Away", + }, + }, + }, + date: { + l10n: { + id: "urlbar-result-sports-game-date-with-time", + args: { + date: "Today", + time: "1:00 PM GMT-4", + }, + }, + }, + status: "", + }, + ], + }); +}); + +add_task(async function scheduled_tomorrow_noIcon() { + await doTest({ + now: "2025-10-31T14:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_SCHEDULED]), + expectedItems: [ + { + image: null, + image_container: { + attributes: { + "is-date-chiclet": "", + }, + }, + "scheduled-date-chiclet-day": "1", + "scheduled-date-chiclet-month": "Nov", + "sport-name": "Sport 3", + "team-names": { + l10n: { + id: "urlbar-result-sports-team-names", + args: { + homeTeam: "Team 3 Home", + awayTeam: "Team 3 Away", + }, + }, + }, + date: { + l10n: { + id: "urlbar-result-sports-game-date-with-time", + args: { + date: "Tomorrow", + time: "1:00 PM GMT-4", + }, + }, + }, + status: "", + }, + ], + }); +}); + +add_task(async function scheduled_tomorrow_icon() { + await doTest({ + now: "2025-10-31T14:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_SCHEDULED_ICON]), + expectedItems: [ + { + image_container: { + attributes: { + "is-date-chiclet": null, + }, + }, + "scheduled-date-chiclet-day": null, + "scheduled-date-chiclet-month": null, + image: { + attributes: { + src: "https://example.com/sports-icon", + }, + }, + "sport-name": "Sport 3", + "team-names": { + l10n: { + id: "urlbar-result-sports-team-names", + args: { + homeTeam: "Team 3 Home", + awayTeam: "Team 3 Away", + }, + }, + }, + date: { + l10n: { + id: "urlbar-result-sports-game-date-with-time", + args: { + date: "Tomorrow", + time: "1:00 PM GMT-4", + }, + }, + }, + status: "", + }, + ], + }); +}); + +add_task(async function scheduled_afterTomorrow_noIcon() { + await doTest({ + now: [ + // date is same year + "2025-10-01T14:00:00-04:00[-04:00]", + // date is next year, UI should be the same + "2024-10-01T14:00:00-04:00[-04:00]", + ], + suggestions: merinoSuggestions([SUGGESTION_VALUE_SCHEDULED]), + expectedItems: [ + { + image: null, + image_container: { + attributes: { + "is-date-chiclet": "", + }, + }, + "scheduled-date-chiclet-day": "1", + "scheduled-date-chiclet-month": "Nov", + "sport-name": "Sport 3", + "team-names": { + l10n: { + id: "urlbar-result-sports-team-names", + args: { + homeTeam: "Team 3 Home", + awayTeam: "Team 3 Away", + }, + }, + }, + date: "Sat at 1:00 PM GMT-4", + status: "", + }, + ], + }); +}); + +add_task(async function scheduled_afterTomorrow_icon_thisYear() { + await doTest({ + // date and `now` are the same year + now: "2025-10-01T14:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_SCHEDULED_ICON]), + expectedItems: [ + { + image_container: { + attributes: { + "is-date-chiclet": null, + }, + }, + "scheduled-date-chiclet-day": null, + "scheduled-date-chiclet-month": null, + image: { + attributes: { + src: "https://example.com/sports-icon", + }, + }, + "sport-name": "Sport 3", + "team-names": { + l10n: { + id: "urlbar-result-sports-team-names", + args: { + homeTeam: "Team 3 Home", + awayTeam: "Team 3 Away", + }, + }, + }, + // should not include year + date: "Sat, Nov 1 at 1:00 PM GMT-4", + status: "", + }, + ], + }); +}); + +add_task(async function scheduled_afterTomorrow_icon_nextYear() { + await doTest({ + // date is the year after `now` + now: "2024-10-01T14:00:00-04:00[-04:00]", + suggestions: merinoSuggestions([SUGGESTION_VALUE_SCHEDULED_ICON]), + expectedItems: [ + { + image_container: { + attributes: { + "is-date-chiclet": null, + }, + }, + "scheduled-date-chiclet-day": null, + "scheduled-date-chiclet-month": null, + image: { + attributes: { + src: "https://example.com/sports-icon", + }, + }, + "sport-name": "Sport 3", + "team-names": { + l10n: { + id: "urlbar-result-sports-team-names", + args: { + homeTeam: "Team 3 Home", + awayTeam: "Team 3 Away", + }, + }, + }, + // should include year + date: "Sat, Nov 1, 2025 at 1:00 PM GMT-4", + status: "", + }, + ], + }); +}); + +async function doTest({ now, suggestions, expectedItems }) { + let nows = Array.isArray(now) ? now : [now]; + + MerinoTestUtils.server.response.body.suggestions = suggestions; + + for (let n of nows) { + info("Testing with `now`: " + n); + setNow(n); + await doOneTest({ expectedItems }); + } +} + +async function doOneTest({ expectedItems }) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "only match the Merino suggestion", + }); + + let { + result, + element: { row }, + } = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + + // Make sure the row is a sports suggestion. + Assert.equal( + result.providerName, + "UrlbarProviderQuickSuggest", + "Row should be a Suggest result" + ); + Assert.equal( + result.payload.provider, + "sports", + "Row should be a sports result" + ); + + // Check each realtime item in the row. + for (let i = 0; i < expectedItems.length; i++) { + let expectedItem = expectedItems[i]; + + // Check each expected child element in the item. + for (let [childNamePrefix, expectedValue] of Object.entries(expectedItem)) { + let sep = ["image", "image_container"].includes(childNamePrefix) + ? "_" + : "-"; + let childName = `${childNamePrefix}${sep}${i}`; + let child = row.querySelector(`[name=${childName}]`); + + if (expectedValue === null) { + Assert.ok(!child, "Child element should not exist: " + childName); + continue; + } + + Assert.ok(child, "Expected child element should exist: " + childName); + + // textContent + if (typeof expectedValue == "string") { + Assert.equal( + child.textContent, + expectedValue, + "Child element should have expected textContent: " + childName + ); + continue; + } + + // l10n + if (expectedValue.l10n) { + Assert.equal( + child.dataset.l10nId, + expectedValue.l10n.id, + "Child element should have expected l10nId: " + childName + ); + if (expectedValue.l10n.args) { + Assert.deepEqual( + JSON.parse(child.dataset.l10nArgs), + expectedValue.l10n.args, + "Child element should have expected l10nArgs: " + childName + ); + } else { + Assert.ok( + !child.dataset.l10nArgs, + "Child element shouldn't have any l10nArgs: " + childName + ); + } + } + + // attributes + if (expectedValue.attributes) { + for (let [attr, value] of Object.entries(expectedValue.attributes)) { + if (value === null) { + Assert.ok( + !child.hasAttribute(attr), + "Child element should not have attribute: " + + JSON.stringify({ childName, attr }) + ); + } else { + Assert.ok( + child.hasAttribute(attr), + "Child element should have expected attribute: " + + JSON.stringify({ childName, attr }) + ); + Assert.equal( + child.getAttribute(attr), + value, + "Child element attribute should have expected value: " + + JSON.stringify({ childName, attr }) + ); + } + } + } + } + } + + await UrlbarTestUtils.promisePopupClose(window); + gURLBar.handleRevert(); +} + +let gSandbox; +let gDateStub; + +function setNow(dateStr) { + if (!dateStr) { + gSandbox?.restore(); + return; + } + + let global = Cu.getGlobalForObject(SportsSuggestions); + if (!gSandbox) { + gSandbox = sinon.createSandbox(); + gDateStub = gSandbox.stub(SportsSuggestions, "_zonedDateTimeISO"); + } + gDateStub.returns(global.Temporal.ZonedDateTime.from(dateStr)); +} + +function merinoSuggestions(values) { + return [ + { + provider: "sports", + is_sponsored: false, + score: 0.2, + title: "", + custom_details: { + sports: { + values, + }, + }, + }, + ]; +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_flight_status.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_flight_status.js @@ -221,25 +221,23 @@ function merinoResult() { payload: { source: "merino", provider: "flightaware", - dynamicType: "flightStatus", + dynamicType: "realtime-flightStatus", telemetryType: "flights", isSponsored: false, - flightaware: { - values: [ - { - flight_number: "flight", - origin: { - city: "Origin", - code: "O", - }, - destination: { city: "Destination", code: "D" }, - departure_scheduled_time: "2025-09-17T14:05:00Z", - arrival_scheduled_time: "2025-09-17T18:30:00Z", - status: "Scheduled", - url: "https://example.com/A1", + items: [ + { + flight_number: "flight", + origin: { + city: "Origin", + code: "O", }, - ], - }, + destination: { city: "Destination", code: "D" }, + departure_scheduled_time: "2025-09-17T14:05:00Z", + arrival_scheduled_time: "2025-09-17T18:30:00Z", + status: "Scheduled", + url: "https://example.com/A1", + }, + ], }, }; } diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_market.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_market.js @@ -244,19 +244,17 @@ function marketResult() { telemetryType: "market", isSponsored: false, engine: Services.search.defaultEngine.name, - polygon: { - values: [ - { - image_url: "https://example.com/aapl.svg", - query: "AAPL stock", - name: "Apple Inc", - ticker: "AAPL", - todays_change_perc: "-0.54", - last_price: "$181.98 USD", - }, - ], - }, - dynamicType: "market", + items: [ + { + image_url: "https://example.com/aapl.svg", + query: "AAPL stock", + name: "Apple Inc", + ticker: "AAPL", + todays_change_perc: "-0.54", + last_price: "$181.98 USD", + }, + ], + dynamicType: "realtime-market", }, }; } diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_sports.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_sports.js @@ -0,0 +1,601 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Tests sports suggestions and related code. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + SportsSuggestions: + "moz-src:///browser/components/urlbar/private/SportsSuggestions.sys.mjs", +}); + +// 2025-11-01 - game status is "scheduled", without icon +const SUGGESTION_VALUE_SCHEDULED = { + sport: "Sport 3", + query: "query 3", + date: "2025-11-01T17:00:00Z", + home_team: { + name: "Team 3 Home", + score: null, + }, + away_team: { + name: "Team 3 Away", + score: null, + }, + status_type: "scheduled", +}; + +add_setup(async function init() { + await Services.search.init(); + + // Disable search suggestions so we don't hit the network. + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + + // This test deals with `Intl` formating of dates and times, which depends on + // the system locale, and assumes it's en-US. Make sure it's actually en-US. + await QuickSuggestTestUtils.setRegionAndLocale({ + locale: "en-US", + skipSuggestReset: true, + }); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + merinoSuggestions: merinoSuggestions([SUGGESTION_VALUE_SCHEDULED]), + prefs: [ + ["sports.featureGate", true], + ["suggest.sports", true], + ["suggest.quicksuggest.all", true], + ], + }); +}); + +add_task(async function telemetryType() { + Assert.equal( + QuickSuggest.getFeature("SportsSuggestions").getSuggestionTelemetryType({}), + "sports", + "Telemetry type should be as expected" + ); +}); + +// The suggestions should be disabled when the relevant prefs are false. +add_task(async function disabledPrefs() { + setNow("2025-10-31T14:00:00-04:00[-04:00]"); + + let prefs = [ + "quicksuggest.enabled", + "sports.featureGate", + "suggest.sports", + "suggest.quicksuggest.all", + ]; + + for (let pref of prefs) { + info("Testing pref: " + pref); + + // First make sure the suggestion is added. + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + expectedResult([ + { + query: "query 3", + sport: "Sport 3", + status_type: "scheduled", + date: "2025-11-01T17:00:00Z", + home_team: { + name: "Team 3 Home", + score: null, + }, + away_team: { + name: "Team 3 Away", + score: null, + }, + }, + ]), + ], + }); + + // Now disable them. + UrlbarPrefs.set(pref, false); + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + // Revert. + UrlbarPrefs.set(pref, true); + await QuickSuggestTestUtils.forceSync(); + } +}); + +// Main test for `SportsSuggestions._parseDate`. +add_task(async function datesAndTimes() { + // For each test, we'll set `now`, call `_parseDate` with `date`, and check + // the return value against `expected`. + let tests = [ + // date is before this year + { + now: "2025-10-31T12:00:00-07:00[-07:00]", + date: "2013-05-11T04:00:00-07:00", + expected: { + daysUntil: -Infinity, + isFuture: false, + }, + }, + + // date is before yesterday + { + now: [ + "2025-10-31T00:00:00-07:00[-07:00]", + "2025-10-31T23:59:59-07:00[-07:00]", + ], + date: ["2025-10-29T00:00:00-07:00", "2025-10-29T23:59:59-07:00"], + expected: { + daysUntil: -Infinity, + isFuture: false, + }, + }, + + // date is yesterday + { + now: [ + "2025-10-31T00:00:00-07:00[-07:00]", + "2025-10-31T23:59:59-07:00[-07:00]", + ], + date: ["2025-10-30T00:00:00-07:00", "2025-10-30T23:59:59-07:00"], + expected: { + daysUntil: -1, + isFuture: false, + }, + }, + + // date is today (past) + { + now: [ + "2025-10-31T12:00:00-07:00[-07:00]", + "2025-10-31T23:59:59-07:00[-07:00]", + ], + date: ["2025-10-31T00:00:00-07:00", "2025-10-31T11:59:59-07:00"], + expected: { + daysUntil: 0, + isFuture: false, + }, + }, + + // date is today (now) + { + now: "2025-10-31T12:00:00-07:00[-07:00]", + date: "2025-10-31T12:00:00-07:00", + expected: { + daysUntil: 0, + isFuture: false, + }, + }, + + // date is today (future) + { + now: [ + "2025-10-31T00:00:00-07:00[-07:00]", + "2025-10-31T12:00:00-07:00[-07:00]", + ], + date: ["2025-10-31T12:00:01-07:00", "2025-10-31T23:59:59-07:00"], + expected: { + daysUntil: 0, + isFuture: true, + }, + }, + + // date is tomorrow + { + now: [ + "2025-10-31T00:00:00-07:00[-07:00]", + "2025-10-31T23:59:59-07:00[-07:00]", + ], + date: ["2025-11-01T00:00:00-07:00", "2025-11-01T23:59:59-07:00"], + expected: { + daysUntil: 1, + isFuture: true, + }, + }, + + // date is after tomorrow + { + now: [ + "2025-10-31T00:00:00-07:00[-07:00]", + "2025-10-31T23:59:59-07:00[-07:00]", + ], + date: ["2025-11-02T00:00:00-07:00", "2025-11-02T23:59:59-07:00"], + expected: { + daysUntil: Infinity, + isFuture: true, + }, + }, + + // date is after this year + { + now: "2025-10-31T00:00:00-07:00[-07:00]", + date: "3013-05-11T04:00:00-07:00", + expected: { + daysUntil: Infinity, + isFuture: true, + }, + }, + ]; + + for (let { now, date, expected } of tests) { + let nows = typeof now == "string" ? [now] : now; + let dates = typeof date == "string" ? [date] : date; + for (let n of nows) { + let zonedNow = setNow(n); + for (let d of dates) { + Assert.deepEqual( + SportsSuggestions._parseDate(new Date(d)), + { + ...expected, + zonedNow, + zonedDate: new Date(d) + .toTemporalInstant() + .toZonedDateTimeISO(zonedNow), + }, + "datesAndTimes test: " + JSON.stringify({ now: n, date: d }) + ); + } + } + } +}); + +// Tests `SportsSuggestions._parseDate` with dates across time zone changes. +add_task(function timeZoneTransition() { + // This task is based around 2025-11-02, when Daylight Saving Time ends in the + // U.S. On 2025-11-02 at 2:00 am, the time changes to 1:00 am Standard Time. + + let tests = [ + // `now` and `date` both in PDT (daylight saving) + { + now: "2025-10-02T12:00:00-07:00[America/Los_Angeles]", + date: "2025-10-01T00:00:00-07:00", + expected: { + daysUntil: -1, + isFuture: false, + }, + }, + + // `now` in PST, `date` in PDT + { + now: "2025-11-03T00:00:00-08:00[America/Los_Angeles]", + date: "2025-11-01T00:00:00-07:00", + expected: { + daysUntil: -Infinity, + isFuture: false, + }, + }, + { + now: "2025-11-02T12:00:00-08:00[America/Los_Angeles]", + date: "2025-11-01T00:00:00-07:00", + expected: { + daysUntil: -1, + isFuture: false, + }, + }, + { + now: "2025-11-02T01:00:00-08:00[America/Los_Angeles]", + date: "2025-11-01T00:00:00-07:00", + expected: { + daysUntil: -1, + isFuture: false, + }, + }, + { + now: "2025-11-02T23:59:59-08:00[America/Los_Angeles]", + date: "2025-11-01T00:00:00-07:00", + expected: { + daysUntil: -1, + isFuture: false, + }, + }, + { + now: "2025-11-02T01:00:00-08:00[America/Los_Angeles]", + date: "2025-11-02T00:00:00-07:00", + expected: { + daysUntil: 0, + isFuture: false, + }, + }, + { + now: "2025-11-02T01:00:00-08:00[America/Los_Angeles]", + date: "2025-11-02T01:00:00-07:00", + expected: { + daysUntil: 0, + isFuture: false, + }, + }, + + // `now` in PDT, `date` in PST + { + now: "2025-11-02T01:00:00-07:00[America/Los_Angeles]", + date: "2025-11-02T01:00:00-08:00", + expected: { + daysUntil: 0, + isFuture: true, + }, + }, + { + now: "2025-11-02T00:00:00-07:00[America/Los_Angeles]", + date: "2025-11-02T01:00:00-08:00", + expected: { + daysUntil: 0, + isFuture: true, + }, + }, + { + now: "2025-11-01T00:00:00-07:00[America/Los_Angeles]", + date: "2025-11-02T23:59:59-08:00", + expected: { + daysUntil: 1, + isFuture: true, + }, + }, + { + now: "2025-11-01T00:00:00-07:00[America/Los_Angeles]", + date: "2025-11-02T01:00:00-08:00", + expected: { + daysUntil: 1, + isFuture: true, + }, + }, + { + now: "2025-11-01T00:00:00-07:00[America/Los_Angeles]", + date: "2025-11-02T12:00:00-08:00", + expected: { + daysUntil: 1, + isFuture: true, + }, + }, + { + now: "2025-11-01T00:00:00-07:00[America/Los_Angeles]", + date: "2025-11-03T00:00:00-08:00", + expected: { + daysUntil: Infinity, + isFuture: true, + }, + }, + + // `now` and `date` both in PST (standard time) + { + now: "2025-11-11T12:00:00-08:00[America/Los_Angeles]", + date: "2025-11-10T00:00:00-08:00", + expected: { + daysUntil: -1, + isFuture: false, + }, + }, + ]; + + for (let { now, date, expected } of tests) { + let zonedNow = setNow(now); + Assert.deepEqual( + SportsSuggestions._parseDate(new Date(date)), + { + ...expected, + zonedNow, + zonedDate: new Date(date) + .toTemporalInstant() + .toZonedDateTimeISO(zonedNow), + }, + "timeZoneTransition test: " + JSON.stringify({ now, date }) + ); + } +}); + +add_task(async function command_notInterested() { + setNow("2025-10-31T14:00:00-04:00[-04:00]"); + + await doDismissAllTest({ + result: expectedResult([ + { + query: "query 3", + sport: "Sport 3", + status_type: "scheduled", + date: "2025-11-01T17:00:00Z", + home_team: { + name: "Team 3 Home", + score: null, + }, + away_team: { + name: "Team 3 Away", + score: null, + }, + }, + ]), + command: "not_interested", + feature: QuickSuggest.getFeature("SportsSuggestions"), + pref: "suggest.sports", + queries: [{ query: "test" }], + }); +}); + +add_task(async function command_showLessFrequently() { + setNow("2025-10-31T14:00:00-04:00[-04:00]"); + + UrlbarPrefs.clear("sports.showLessFrequentlyCount"); + UrlbarPrefs.clear("sports.minKeywordLength"); + + let cleanUpNimbus = await UrlbarTestUtils.initNimbusFeature({ + realtimeMinKeywordLength: 0, + realtimeShowLessFrequentlyCap: 3, + }); + + let result = expectedResult([ + { + query: "query 3", + sport: "Sport 3", + status_type: "scheduled", + date: "2025-11-01T17:00:00Z", + home_team: { + name: "Team 3 Home", + score: null, + }, + away_team: { + name: "Team 3 Away", + score: null, + }, + }, + ]); + + const testData = [ + { + input: "spo", + before: { + canShowLessFrequently: true, + showLessFrequentlyCount: 0, + minKeywordLength: 0, + }, + after: { + canShowLessFrequently: true, + showLessFrequentlyCount: 1, + minKeywordLength: 4, + }, + }, + { + input: "sport", + before: { + canShowLessFrequently: true, + showLessFrequentlyCount: 1, + minKeywordLength: 4, + }, + after: { + canShowLessFrequently: true, + showLessFrequentlyCount: 2, + minKeywordLength: 6, + }, + }, + { + input: "sports", + before: { + canShowLessFrequently: true, + showLessFrequentlyCount: 2, + minKeywordLength: 6, + }, + after: { + canShowLessFrequently: false, + showLessFrequentlyCount: 3, + minKeywordLength: 7, + }, + }, + ]; + + for (let { input, before, after } of testData) { + let feature = QuickSuggest.getFeature("SportsSuggestions"); + + await check_results({ + context: createContext(input, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [result], + }); + + Assert.equal( + UrlbarPrefs.get("sports.minKeywordLength"), + before.minKeywordLength + ); + Assert.equal(feature.canShowLessFrequently, before.canShowLessFrequently); + Assert.equal( + feature.showLessFrequentlyCount, + before.showLessFrequentlyCount + ); + + triggerCommand({ + result, + feature, + command: "show_less_frequently", + searchString: input, + }); + + Assert.equal( + UrlbarPrefs.get("sports.minKeywordLength"), + after.minKeywordLength + ); + Assert.equal(feature.canShowLessFrequently, after.canShowLessFrequently); + Assert.equal( + feature.showLessFrequentlyCount, + after.showLessFrequentlyCount + ); + + await check_results({ + context: createContext(input, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + } + + await cleanUpNimbus(); + UrlbarPrefs.clear("sports.showLessFrequentlyCount"); + UrlbarPrefs.clear("sports.minKeywordLength"); +}); + +let gSandbox; +let gDateStub; + +function setNow(dateStr) { + if (!dateStr) { + gSandbox?.restore(); + return null; + } + + let global = Cu.getGlobalForObject(SportsSuggestions); + if (!gSandbox) { + gSandbox = sinon.createSandbox(); + gDateStub = gSandbox.stub(SportsSuggestions, "_zonedDateTimeISO"); + } + + let zonedNow = global.Temporal.ZonedDateTime.from(dateStr); + gDateStub.returns(zonedNow); + + return zonedNow; +} + +function merinoSuggestions(values) { + return [ + { + provider: "sports", + is_sponsored: false, + score: 0.2, + title: "", + custom_details: { + sports: { + values, + }, + }, + }, + ]; +} + +function expectedResult(expectedItems) { + return { + type: UrlbarUtils.RESULT_TYPE.DYNAMIC, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + isBestMatch: true, + hideRowLabel: true, + rowIndex: -1, + heuristic: false, + exposureTelemetry: 0, + payload: { + items: expectedItems, + source: "merino", + provider: "sports", + telemetryType: "sports", + isSponsored: false, + engine: Services.search.defaultEngine.name, + dynamicType: "realtime-sports", + }, + }; +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_yelp_realtime.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_yelp_realtime.js @@ -22,6 +22,15 @@ const YELP_MERINO_SINGLE = [ { provider: "yelp", is_sponsored: true, + custom_details: { + yelp: { + values: [ + { + some_value: "foo", + }, + ], + }, + }, }, ]; @@ -476,9 +485,14 @@ function yelpMerinoResult() { payload: { source: "merino", provider: "yelp", - dynamicType: "yelpRealtime", + dynamicType: "realtime-yelpRealtime", telemetryType: "yelpRealtime", isSponsored: true, + items: [ + { + some_value: "foo", + }, + ], }, }; } diff --git a/browser/components/urlbar/tests/quicksuggest/unit/xpcshell.toml b/browser/components/urlbar/tests/quicksuggest/unit/xpcshell.toml @@ -61,6 +61,8 @@ skip-if = ["true"] # Bug 1880214 ["test_quicksuggest_scoreMap.js"] +["test_quicksuggest_sports.js"] + ["test_quicksuggest_topPicks.js"] ["test_quicksuggest_yelp.js"] diff --git a/browser/themes/shared/urlbarView.css b/browser/themes/shared/urlbarView.css @@ -453,7 +453,7 @@ /* The realtime suggestions' style will be broken if the width will be changed by hover. Thus we use the visibility instead of display to keep the element size */ - .urlbarView-row:is([dynamicType="flightStatus"], [dynamicType="market"], [dynamicType="yelpRealtime"]) & { + .urlbarView-row[dynamicType^="realtime-"] > .urlbarView-row-buttons > & { display: unset; visibility: hidden; } @@ -1326,11 +1326,19 @@ .urlbarView-realtime-root { --green-status-color: light-dark(var(--color-green-60), var(--color-green-20)); --red-status-color: light-dark(var(--color-red-70), var(--color-red-40)); + --violet-bg-color: light-dark(var(--color-violet-0), var(--color-violet-80)); + --violet-fg-color: light-dark(var(--color-violet-60), var(--color-violet-10)); + + @media (prefers-contrast) { + --violet-bg-color: var(--urlbar-box-focus-bgcolor); + --violet-fg-color: currentColor; + } align-items: center; /* Realtime suggestions can contain many items, which should always wrap. */ flex-wrap: wrap; + /* Remove the usual inner padding. Each item in the row will have its own. */ padding: 0; @@ -1367,7 +1375,6 @@ width: var(--urlbarView-top-pick-large-icon-box-size); height: var(--urlbarView-top-pick-large-icon-box-size); margin-inline-end: var(--space-medium); - background-color: var(--market-image-bg-color); border: 1px solid transparent; border-radius: var(--border-radius-small); @@ -1395,18 +1402,36 @@ color: var(--urlbarView-secondary-text-color); } - > .urlbarView-realtime-description-top > .urlbarView-realtime-description-separator-dot::before, - > .urlbarView-realtime-description-bottom > .urlbarView-realtime-description-separator-dot::before { - margin-inline: var(--space-xsmall); - content: "·"; - display: inline; + > .urlbarView-realtime-description-top > .urlbarView-realtime-description-separator-dot, + > .urlbarView-realtime-description-top > .urlbarView-realtime-description-separator-dash, + > .urlbarView-realtime-description-bottom > .urlbarView-realtime-description-separator-dot, + > .urlbarView-realtime-description-bottom > .urlbarView-realtime-description-separator-dash { + /* stylelint-disable-next-line max-nesting-depth */ + &::before { + margin-inline: var(--space-xsmall); + display: inline; + } + + /* stylelint-disable-next-line max-nesting-depth */ + &:has(+ span:empty) { + display: none; + } } - > .urlbarView-realtime-description-top > .urlbarView-realtime-description-separator-dash::before, - > .urlbarView-realtime-description-bottom > .urlbarView-realtime-description-separator-dash::before { - margin-inline: var(--space-xsmall); - content: "–"; - display: inline; + > .urlbarView-realtime-description-top > .urlbarView-realtime-description-separator-dot, + > .urlbarView-realtime-description-bottom > .urlbarView-realtime-description-separator-dot { + /* stylelint-disable-next-line max-nesting-depth */ + &::before { + content: "·"; + } + } + + > .urlbarView-realtime-description-top > .urlbarView-realtime-description-separator-dash, + > .urlbarView-realtime-description-bottom > .urlbarView-realtime-description-separator-dash { + /* stylelint-disable-next-line max-nesting-depth */ + &::before { + content: "–"; + } } } } @@ -1414,28 +1439,32 @@ /* Market suggestions specific */ -.urlbarView-row[dynamicType="market"] > .urlbarView-realtime-root > .urlbarView-realtime-item { +.urlbarView-row[dynamicType="realtime-market"] > .urlbarView-realtime-root > .urlbarView-realtime-item { --market-image-bg-color: var(--urlbar-box-focus-bgcolor); --market-image-down-bg-color: light-dark(var(--color-red-0), var(--color-red-90)); --market-image-up-bg-color: light-dark(var(--color-green-0), var(--color-green-90)); - > .urlbarView-realtime-image-container[is-arrow] { - border-color: color-mix(in srgb, currentColor 10%, transparent); + > .urlbarView-realtime-image-container { + background-color: var(--market-image-bg-color); - .urlbarView-realtime-item[change="down"] > & { - fill: var(--red-status-color); - border-color: color-mix(in srgb, var(--red-status-color) 10%, transparent); - background-color: var(--market-image-down-bg-color); - } - .urlbarView-realtime-item[change="up"] > & { - fill: var(--green-status-color); - border-color: color-mix(in srgb, var(--green-status-color) 10%, transparent); - background-color: var(--market-image-up-bg-color); - } + &[is-arrow] { + border-color: color-mix(in srgb, currentColor 10%, transparent); - > .urlbarView-realtime-image { - width: 20px; - height: 20px; + .urlbarView-realtime-item[change="down"] > & { + fill: var(--red-status-color); + border-color: color-mix(in srgb, var(--red-status-color) 10%, transparent); + background-color: var(--market-image-down-bg-color); + } + .urlbarView-realtime-item[change="up"] > & { + fill: var(--green-status-color); + border-color: color-mix(in srgb, var(--green-status-color) 10%, transparent); + background-color: var(--market-image-up-bg-color); + } + + > .urlbarView-realtime-image { + width: 20px; + height: 20px; + } } } @@ -1481,7 +1510,7 @@ /* Yelp realtime suggestions specific */ -.urlbarView-row[dynamicType="yelpRealtime"] > .urlbarView-realtime-root > .urlbarView-realtime-item { +.urlbarView-row[dynamicType="realtime-yelpRealtime"] > .urlbarView-realtime-root > .urlbarView-realtime-item { --star-size: 12px; > .urlbarView-realtime-image-container > .urlbarView-realtime-image { @@ -1534,7 +1563,7 @@ /* Flight status suggestions specific */ -.urlbarView-row[dynamicType="flightStatus"] > .urlbarView-realtime-root > .urlbarView-realtime-item { +.urlbarView-row[dynamicType="realtime-flightStatus"] > .urlbarView-realtime-root > .urlbarView-realtime-item { > .urlbarView-realtime-image-container { --airline-fallback-icon-color: #bac2ca; @@ -1613,9 +1642,41 @@ > .urlbarView-flightStatus-status { color: var(--red-status-color); } + } + } +} - > .urlbarView-realtime-description-separator-dot:has(+ .urlbarView-flightStatus-time-left:empty) { - display: none; +/* Realtime sports suggestions */ + +.urlbarView-row[dynamicType="realtime-sports"] > .urlbarView-realtime-root > .urlbarView-realtime-item { + > .urlbarView-realtime-image-container[is-date-chiclet] { + flex-direction: column; + + background-color: var(--violet-bg-color); + color: var(--violet-fg-color); + border-color: color-mix(in srgb, currentColor 10%, transparent); + + > .urlbarView-sports-scheduled-date-chiclet-day { + font-size: 1.25em; + } + } + + > .urlbarView-realtime-description { + > .urlbarView-realtime-description-top { + > .urlbarView-sports-team-names { + font-weight: var(--font-weight-bold); + } + + > .urlbarView-sports-score { + font-weight: var(--font-weight-bold); + margin-inline-start: var(--space-xsmall); + } + } + + > .urlbarView-realtime-description-bottom { + > .urlbarView-sports-status { + color: var(--green-status-color); + font-weight: var(--font-weight-bold); } } } diff --git a/toolkit/components/nimbus/FeatureManifest.yaml b/toolkit/components/nimbus/FeatureManifest.yaml @@ -613,6 +613,12 @@ urlbar: pref: browser.urlbar.showDebuggingIcons description: >- Whether or not to show debugging badges on urlbar results favicons. + sportsFeatureGate: + type: boolean + fallbackPref: browser.urlbar.sports.featureGate + description: >- + Feature gate that controls whether all aspects of the sports suggestions + feature are exposed to the user. suggestSemanticHistoryMinLength: type: int setPref: