Repository: DEFRA/aqie-front-end
Pull request #601: Unhappy paths fix search location
Author: login: ulysses-cognizant
Created: 2025-05-20T15:26:22Z | Merged: 2025-05-20T15:41:28Z



Diff:
diff --git a/src/client/assets/javascripts/components/analytics.js b/src/client/assets/javascripts/components/analytics.js
new file mode 100644
index 00000000..a50e10bc
--- /dev/null
+++ b/src/client/assets/javascripts/components/analytics.js
@@ -0,0 +1,30 @@
+// @ts-nocheck
+export default function loadAnalytics() {
+  if (!window.ga || !window.ga.loaded) {
+    // Load gtm script
+    // Script based on snippet at https://developers.google.com/tag-manager/quickstart
+    // prettier-ignore
+    ;(function (w, d, s, l, i) {
+      w[l] = w[l] || []
+      w[l].push({
+        'gtm.start': new Date().getTime(),
+        event: 'gtm.js'
+      })
+
+      const j = d.createElement(s)
+      const dl = l !== 'dataLayer' ? `&l=${l}` : ''
+
+      j.async = true
+      j.src = 'https://www.googletagmanager.com/gtag/js?id=G-8CMZBTDQBC'
+      document.head.appendChild(j)
+      window.dataLayer = window.dataLayer || []
+      function gtag() {
+        window.dataLayer.push(arguments)
+      }
+      gtag('js', new Date())
+      gtag('config', 'G-8CMZBTDQBC')
+      j.src = `https://www.googletagmanager.com/gtm.js?id=${i}${dl}`
+      document.head.appendChild(j)
+    })(window, document, 'script', 'dataLayer', 'G-8CMZBTDQBC')
+  }
+}
diff --git a/src/client/assets/javascripts/components/analytics.mjs b/src/client/assets/javascripts/components/analytics.mjs
new file mode 100644
index 00000000..e10ead55
--- /dev/null
+++ b/src/client/assets/javascripts/components/analytics.mjs
@@ -0,0 +1,63 @@
+// @ts-nocheck
+export default function loadAnalytics() {
+    if (!window.ga || !window.ga.loaded) {
+      window.dataLayer = window.dataLayer || []
+      if(localStorage.getItem('consentMode') === null){
+        window.dataLayer.push('consent', 'default', {
+          'ad_storage': 'denied',
+          'analytics_storage': 'denied',
+          'personalization_storage': 'denied',
+          'functionality_storage': 'denied',
+          'security_storage': 'denied',
+        })
+      } else {
+        window.dataLayer.push('consent', 'default', JSON.parse(localStorage.getItem('consentMode')))
+      }
+      // prettier-ignore
+      ;(function (w, d, s, l, i) {
+        w[l] = w[l] || []
+        w[l].push({
+          'gtm.start': new Date().getTime(),
+          event: 'gtm.js'
+        })
+        ///
+        const noscript = document.createElement('noscript')
+        const iframe = document.createElement('iframe')
+        iframe.setAttribute("height", "0")
+        iframe.setAttribute("width", "0")
+        iframe.setAttribute("style", "display:none;visibility:hidden")
+        iframe.async = true
+        iframe.src = "https://www.googletagmanager.com/ns.html?id=GTM-PBFV8FNC"
+        noscript.appendChild(iframe)
+        document.body.appendChild(noscript)
+        ///
+        const k = d.createElement(s)
+        k.async = true
+        k.src = "https://www.googletagmanager.com/gtag/js?id=G-8CMZBTDQBC"
+        document.body.appendChild(k)
+        const urlParams = new URLSearchParams(window.location.search);
+        const userId = urlParams.get('userId');
+        const utm_source = urlParams.get('utm_source');
+        const utm_campaign = urlParams.get('utm_campaign');
+        const utm_medium = urlParams.get('utm_medium');
+        window.dataLayer.push('js', new Date())
+        window.dataLayer.push('config', 'G-8CMZBTDQBC',{
+          'user_id': utm_source
+        })
+        window.dataLayer.push({
+          'event': 'airQualityData',
+          'userSeg': userId,
+          'login_status': 'logged in',
+          'utm_source': utm_source,
+          utm_medium: utm_medium,
+          utm_campaign: utm_campaign
+        })
+        ///
+        const f = d.getElementsByTagName(s)[0],
+        j = d.createElement(s), dl = l != 'dataLayer' ? '&l=' + l : ''
+        j.async = true;
+        j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl
+        f.parentNode.insertBefore(j, f)
+      })(window, document, 'script', 'dataLayer', 'GTM-PBFV8FNC')
+    }
+}
diff --git a/src/server/home/controller.js b/src/server/home/controller.js
index 5a76ad83..fabace1e 100644
--- a/src/server/home/controller.js
+++ b/src/server/home/controller.js
@@ -1,12 +1,22 @@
 import { english } from '~/src/server/data/en/en.js'
 import { LANG_CY, LANG_EN } from '../data/constants'
 import { getAirQualitySiteUrl } from '~/src/server/common/helpers/get-site-url'
+import { createLogger } from '~/src/server/common/helpers/logging/logger'
+
+// Create a logger instance
+const logger = createLogger()
 
 const handleHomeRequest = (request, h, content = english) => {
   const { query } = request
   const { home, footerTxt, phaseBanner, backlink, cookieBanner } = content
   const metaSiteUrl = getAirQualitySiteUrl(request)
-
+  // Check if request.yar and its set method exist before calling it ''
+  if (request.yar && typeof request.yar.set === 'function') {
+    request.yar.set('locationType', '') // ''
+  } else {
+    // Optionally log a warning or handle the missing session gracefully ''
+    logger.warn('Session (yar) is not available on the request object') // ''
+  }
   if (query.lang === LANG_CY) {
     return h.redirect(LANG_CY)
   }
diff --git a/src/server/home/cy/controller.js b/src/server/home/cy/controller.js
index bd86ffcf..88879258 100644
--- a/src/server/home/cy/controller.js
+++ b/src/server/home/cy/controller.js
@@ -1,13 +1,23 @@
 import { welsh } from '~/src/server/data/cy/cy.js'
 import { LANG_CY, LANG_EN } from '~/src/server/data/constants'
 import { getAirQualitySiteUrl } from '~/src/server/common/helpers/get-site-url'
+import { createLogger } from '~/src/server/common/helpers/logging/logger'
+
+// Create a logger instance
+const logger = createLogger()
 
 // Define the handler function
 const handleHomeRequest = (request, h, content = welsh) => {
   const { query } = request
   const { home, footerTxt, phaseBanner, backlink, cookieBanner } = content
   const metaSiteUrl = getAirQualitySiteUrl(request)
-
+  // Check if request.yar and its set method exist before calling it ''
+  if (request.yar && typeof request.yar.set === 'function') {
+    request.yar.set('locationType', '') // ''
+  } else {
+    // Optionally log a warning or handle the missing session gracefully ''
+    logger.warn('Session (yar) is not available on the request object') // ''
+  }
   // Redirect to the English version if the language is 'en'
   if (query.lang === LANG_EN) {
     return h.redirect(`/?lang=en`)
diff --git a/src/server/locations/helpers/get-nearest.location.test.js b/src/server/locations/helpers/get-nearest.location.test.js
new file mode 100644
index 00000000..eae1fa24
--- /dev/null
+++ b/src/server/locations/helpers/get-nearest.location.test.js
@@ -0,0 +1,150 @@
+import * as geolib from 'geolib'
+import moment from 'moment-timezone'
+import {
+  getNearLocation,
+  convertPointToLonLat,
+  coordinatesTotal,
+  pointsInRange
+} from '~/src/server/locations/helpers/location-util.js'
+// import { getPollutantLevel } from '~/src/server/locations/helpers/pollutant-level-calculation'
+// import { getPollutantLevelCy } from '~/src/server/locations/helpers/cy/pollutant-level-calculation'
+import { getAirQuality } from '~/src/server/data/en/air-quality.js'
+import { getAirQualityCy } from '~/src/server/data/cy/air-quality.js'
+import { LANG_CY, LANG_EN } from '~/src/server/data/constants'
+import { createLogger } from '~/src/server/common/helpers/logging/logger'
+import { getNearestLocation } from '~/src/server/locations/helpers/get-nearest-location'
+
+jest.mock('geolib')
+jest.mock('moment-timezone')
+jest.mock('~/src/server/locations/helpers/location-util.js')
+jest.mock('~/src/server/locations/helpers/pollutant-level-calculation')
+jest.mock('~/src/server/locations/helpers/cy/pollutant-level-calculation')
+jest.mock('~/src/server/data/en/air-quality.js')
+jest.mock('~/src/server/data/cy/air-quality.js')
+jest.mock('~/src/server/common/helpers/logging/logger')
+
+describe.skip('getNearestLocation', () => {
+  let matches, forecasts, measurements, location, index, lang
+
+  beforeEach(() => {
+    matches = [
+      {
+        /* mock data */
+      }
+    ]
+    forecasts = [
+      {
+        /* mock data */
+      }
+    ]
+    measurements = [
+      {
+        /* mock data */
+      }
+    ]
+    location = {
+      /* mock data */
+    }
+    index = 0
+    lang = LANG_EN
+
+    convertPointToLonLat.mockReturnValue({ lat: 51.5074, lon: -0.1278 })
+    coordinatesTotal.mockReturnValue([
+      { latitude: 51.5074, longitude: -0.1278 }
+    ])
+    getNearLocation.mockReturnValue([
+      {
+        /* mock data */
+      }
+    ])
+    geolib.orderByDistance.mockReturnValue([
+      { latitude: 51.5074, longitude: -0.1278 }
+    ])
+    pointsInRange.mockReturnValue(true)
+    moment.tz.mockReturnValue({ format: jest.fn().mockReturnValue('Mon') })
+    getAirQuality.mockReturnValue({
+      /* mock data */
+    })
+    getAirQualityCy.mockReturnValue({
+      /* mock data */
+    })
+    createLogger.mockReturnValue({ info: jest.fn() })
+  })
+
+  it.skip('should return nearest location data for English language', () => {
+    const result = getNearestLocation(
+      matches,
+      forecasts,
+      measurements,
+      location,
+      index,
+      lang
+    )
+    expect(result).toHaveProperty('forecastNum')
+    expect(result).toHaveProperty('nearestLocationsRange')
+    expect(result).toHaveProperty('airQuality')
+    expect(result).toHaveProperty('latlon')
+  })
+
+  it.skip('should return nearest location data for Welsh language', () => {
+    lang = LANG_CY
+    const result = getNearestLocation(
+      matches,
+      forecasts,
+      measurements,
+      location,
+      index,
+      lang
+    )
+    expect(result).toHaveProperty('forecastNum')
+    expect(result).toHaveProperty('nearestLocationsRange')
+    expect(result).toHaveProperty('airQuality')
+    expect(result).toHaveProperty('latlon')
+  })
+
+  it('should handle empty matches array', () => {
+    matches = []
+    const result = getNearestLocation(
+      matches,
+      forecasts,
+      measurements,
+      location,
+      index,
+      lang
+    )
+    expect(result).toEqual({
+      forecastNum: 0,
+      nearestLocationsRange: [],
+      airQuality: undefined,
+      latlon: {}
+    })
+  })
+
+  it('should filter and sort nearest locations correctly', () => {
+    measurements = [
+      {
+        location: { coordinates: [51.5074, -0.1278] },
+        pollutants: {
+          NO2: { value: 50, time: { date: '2025-01-01T12:00:00Z' } }
+        },
+        area: 'Area 1',
+        areaType: 'Type 1',
+        name: 'Location 1',
+        updated: '2025-01-01T12:00:00Z'
+      }
+    ]
+    const result = getNearestLocation(
+      matches,
+      forecasts,
+      measurements,
+      location,
+      index,
+      lang
+    )
+    expect(result.nearestLocationsRange).toHaveLength(1)
+    expect(result.nearestLocationsRange[0]).toHaveProperty(
+      'pollutants.NO2.value',
+      50
+    )
+  })
+})
diff --git a/src/server/locations/helpers/handle-error-helpers.js b/src/server/locations/helpers/handle-error-helpers.js
index 49f077bf..7423b321 100644
--- a/src/server/locations/helpers/handle-error-helpers.js
+++ b/src/server/locations/helpers/handle-error-helpers.js
@@ -24,17 +24,43 @@ import {
 /**
  * Handles the case where search terms are not provided.
  */
-const handleNoSearchTerms = (request, h, lang, payload) => {
+const REFERER_PATH_INDEX = 3 // '' Index for the path segment in the referer header
+
+const getRefererPath = (request) => {
+  // '' Extracts the path segment from the referer header.
+  const tempString = request?.headers?.referer?.split('/')[REFERER_PATH_INDEX]
+  return tempString?.split('?')[0]
+}
+
+const getLocationTypeAndName = (request, payload) => {
+  // '' Retrieves locationType and locationNameOrPostcode from payload or session.
   const locationType =
     request.payload?.locationType || request.yar.get('locationType')
   const locationNameOrPostcode =
     getLocationNameOrPostcode(locationType, payload) ||
     request.yar.get('locationNameOrPostcode')
+  return { locationType, locationNameOrPostcode }
+}
 
-  if (!locationType || !locationNameOrPostcode) {
-    return handleMissingLocation(request, h, lang)
-  }
+const setSessionValues = (
+  request,
+  locationType,
+  locationNameOrPostcode,
+  airQuality
+) => {
+  // '' Sets session values for locationType, locationNameOrPostcode, and airQuality.
+  request.yar.set('locationType', locationType)
+  request.yar.set('locationNameOrPostcode', locationNameOrPostcode)
+  request.yar.set('airQuality', airQuality)
+}
 
+const handleNoSearchTerms = (request, h, lang, payload) => {
+  // '' Main handler for missing search terms.
+  const str = getRefererPath(request)
+  let { locationType, locationNameOrPostcode } = getLocationTypeAndName(
+    request,
+    payload
+  )
   const airQuality = getAirQuality(
     payload?.aq,
     AIR_QUALITY_THRESHOLD_1,
@@ -42,9 +68,21 @@ const handleNoSearchTerms = (request, h, lang, payload) => {
     AIR_QUALITY_THRESHOLD_3,
     AIR_QUALITY_THRESHOLD_4
   )
-  request.yar.set('locationType', locationType)
-  request.yar.set('locationNameOrPostcode', locationNameOrPostcode)
-  request.yar.set('airQuality', airQuality)
+
+  if (
+    !locationType &&
+    str !== 'search-location' &&
+    str !== 'chwilio-lleoliad'
+  ) {
+    locationType = request.yar.get('locationType')
+    locationNameOrPostcode = request.yar.get('locationNameOrPostcode')
+  } else {
+    setSessionValues(request, locationType, locationNameOrPostcode, airQuality)
+  }
+
+  if (!locationType && !locationNameOrPostcode) {
+    return handleMissingLocation(request, h, lang)
+  }
 
   const userLocation = formatPostcode(locationNameOrPostcode.toUpperCase())
 
diff --git a/src/server/locations/middleware.js b/src/server/locations/middleware.js
index 78884760..7366e60d 100644
--- a/src/server/locations/middleware.js
+++ b/src/server/locations/middleware.js
@@ -7,10 +7,8 @@ import {
   getLanguageDates
 } from '~/src/server/locations/helpers/middleware-helpers'
 import { LANG_EN, LOCATION_TYPE_UK } from '~/src/server/data/constants'
-import {
-  handleErrorInputAndRedirect,
-  handleUKLocationType
-} from '~/src/server/locations/helpers/extra-middleware-helpers'
+import { handleUKLocationType } from '~/src/server/locations/helpers/extra-middleware-helpers'
+import { handleErrorInputAndRedirect } from '~/src/server/locations/helpers/error-input-and-redirect'
 import { getMonth } from '~/src/server/locations/helpers/location-type-util'
 import * as airQualityData from '~/src/server/data/en/air-quality.js'
 


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR refines error-handling paths for location searches, updates session initialization in home controllers, and introduces analytics loading scripts.

- Adjusted middleware imports to point to the new error-redirect helper.
- Extended the “no search terms” handler to parse referrer paths and manage session values.
- Added session-existence guards in both English and Welsh home controllers and supplied analytics components for consent-based GTM loading.

### Reviewed Changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.

<details>
<summary>Show a summary per file</summary>

| File                                                              | Description                                                        |
| ----------------------------------------------------------------- | ------------------------------------------------------------------ |
| src/server/locations/middleware.js                                | Updated import paths for `handleErrorInputAndRedirect`.            |
| src/server/locations/helpers/handle-error-helpers.js              | Broke out referer parsing, session utilities, and restructured missing-search handler. |
| src/server/locations/helpers/get-nearest.location.test.js         | Introduced (currently skipped) tests for `getNearestLocation`.     |
| src/server/home/cy/controller.js                                  | Added session guard with logging in the Welsh home controller.     |
| src/server/home/controller.js                                     | Added session guard with logging in the English home controller.   |
| src/client/assets/javascripts/components/analytics.mjs            | Added GTM loader with consent-mode checks and dataLayer pushes.    |
| src/client/assets/javascripts/components/analytics.js             | Added simpler GTM loader for legacy environments.                 |
</details>



<details>
<summary>Comments suppressed due to low confidence (2)</summary>

**src/server/locations/helpers/handle-error-helpers.js:59**
* The variable name 'str' is ambiguous; consider renaming it to 'refererPath' for clarity.
```
const str = getRefererPath(request)
```
**src/server/locations/helpers/get-nearest.location.test.js:26**
* Skipping the entire test suite prevents these tests from executing; remove `.skip` to ensure coverage of key behaviors.
```
describe.skip('getNearestLocation', () => {
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T15:29:24Z

Review comments:
diff_hunk: @@ -22,29 +22,67 @@ import {
   AIR_QUALITY_THRESHOLD_4
 } from '~/src/server/data/constants'
 /**
- * Handles the case where search terms are not provided.
+ * '' Handles the case where search terms are not provided.
path: src/server/locations/helpers/handle-error-helpers.js
user: login: Copilot
body: [nitpick] Remove the stray '' prefix in this comment to avoid confusion and improve readability.
```suggestion
 * Handles the case where search terms are not provided.
```
created_at: 2025-05-20T15:29:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -22,29 +22,67 @@ import {
   AIR_QUALITY_THRESHOLD_4
 } from '~/src/server/data/constants'
 /**
- * Handles the case where search terms are not provided.
+ * '' Handles the case where search terms are not provided.
  */
-const handleNoSearchTerms = (request, h, lang, payload) => {
+const REFERER_PATH_INDEX = 3 // '' Index for the path segment in the referer header
+
+const getRefererPath = (request) => {
+  // '' Extracts the path segment from the referer header.
+  const tempString = request?.headers?.referer?.split('/')[REFERER_PATH_INDEX]
+  return tempString?.split('?')[0]
+}
+
+const getLocationTypeAndName = (request, payload) => {
+  // '' Retrieves locationType and locationNameOrPostcode from payload or session.
   const locationType =
     request.payload?.locationType || request.yar.get('locationType')
   const locationNameOrPostcode =
     getLocationNameOrPostcode(locationType, payload) ||
     request.yar.get('locationNameOrPostcode')
+  return { locationType, locationNameOrPostcode }
+}
 
-  if (!locationType || !locationNameOrPostcode) {
-    return handleMissingLocation(request, h, lang)
-  }
+const setSessionValues = (
+  request,
+  locationType,
+  locationNameOrPostcode,
+  airQuality
+) => {
+  // '' Sets session values for locationType, locationNameOrPostcode, and airQuality.
+  request.yar.set('locationType', locationType)
+  request.yar.set('locationNameOrPostcode', locationNameOrPostcode)
+  request.yar.set('airQuality', airQuality)
+}
 
+const handleNoSearchTerms = (request, h, lang, payload) => {
+  // '' Main handler for missing search terms.
+  const str = getRefererPath(request)
+  let { locationType, locationNameOrPostcode } = getLocationTypeAndName(
+    request,
+    payload
+  )
   const airQuality = getAirQuality(
     payload?.aq,
     AIR_QUALITY_THRESHOLD_1,
     AIR_QUALITY_THRESHOLD_2,
     AIR_QUALITY_THRESHOLD_3,
     AIR_QUALITY_THRESHOLD_4
   )
-  request.yar.set('locationType', locationType)
-  request.yar.set('locationNameOrPostcode', locationNameOrPostcode)
-  request.yar.set('airQuality', airQuality)
+
+  if (
+    !locationType &&
+    str !== 'search-location' &&
+    str !== 'chwilio-lleoliad'
path: src/server/locations/helpers/handle-error-helpers.js
user: login: Copilot
body: Extract these hardcoded path segments into named constants to avoid magic strings and improve maintainability.
```suggestion
    str !== SEARCH_LOCATION_PATH &&
    str !== CHWILIO_LLEOLIAD_PATH
```
created_at: 2025-05-20T15:29:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,13 +1,23 @@
 import { welsh } from '~/src/server/data/cy/cy.js'
 import { LANG_CY, LANG_EN } from '~/src/server/data/constants'
 import { getAirQualitySiteUrl } from '~/src/server/common/helpers/get-site-url'
+import { createLogger } from '~/src/server/common/helpers/logging/logger'
+
+// Create a logger instance
+const logger = createLogger()
 
 // Define the handler function
 const handleHomeRequest = (request, h, content = welsh) => {
   const { query } = request
   const { home, footerTxt, phaseBanner, backlink, cookieBanner } = content
   const metaSiteUrl = getAirQualitySiteUrl(request)
-
+  // Check if request.yar and its set method exist before calling it ''
+  if (request.yar && typeof request.yar.set === 'function') {
+    request.yar.set('locationType', '') // ''
path: src/server/home/cy/controller.js
user: login: Copilot
body: This session-initialization logic is duplicated in the English controller; consider extracting it into a shared helper for DRYness.
created_at: 2025-05-20T15:29:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,63 @@
+// @ts-nocheck
+export default function loadAnalytics() {
+    if (!window.ga || !window.ga.loaded) {
+      window.dataLayer = window.dataLayer || []
+      if(localStorage.getItem('consentMode') === null){
+        window.dataLayer.push('consent', 'default', {
path: src/client/assets/javascripts/components/analytics.mjs
user: login: Copilot
body: [nitpick] Recommended GTM syntax uses a single object payload (e.g., `{ event: 'consent', command: 'default', ... }`) rather than positional arguments; consider updating to match the official snippet.
```suggestion
        window.dataLayer.push({
          event: 'consent',
          command: 'default',
```
created_at: 2025-05-20T15:29:24Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-05-20T15:41:00Z
body: ## [![Quality Gate Failed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-failed-20px.png 'Quality Gate Failed')](https://sonarcloud.io/dashboard?id=DEFRA_aqie-front-end&pullRequest=601) **Quality Gate failed**  
Failed conditions  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/failed-16px.png '') [60.1% Coverage on New Code](https://sonarcloud.io/component_measures?id=DEFRA_aqie-front-end&pullRequest=601&metric=new_coverage&view=list) (required ≥ 90%)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=DEFRA_aqie-front-end&pullRequest=601)


author_association: NONE