From bc6c33d924128f7ec84a2a2767dcdfd1aeb02d7e Mon Sep 17 00:00:00 2001 From: Karoline Tufte Lien Date: Wed, 27 May 2026 16:31:13 +0200 Subject: [PATCH 1/3] fix: coerce thematic period values to numbers [DHIS2-21037] Analytics returns row values as strings, and computed data items (indicators, calculations) serialize whole numbers with a trailing ".0" (e.g. "16082.0"). The single-map path already parseFloats the value, but the timeline / split-by-period path (getValuesByPeriod) kept the raw string, so popups and hover labels rendered the trailing decimal for values that should display as integers. Coerce the period value with parseFloat to mirror getValueById. This is lossless (single-map display and the GeoJSON export already use numbers) and additionally routes timeline value filtering through the numeric filter instead of substring matching. AI Assisted Co-Authored-By: Claude Opus 4.7 (1M context) --- src/loaders/__tests__/thematicLoader.spec.js | 58 ++++++++++++++++++++ src/loaders/thematicLoader.js | 6 +- 2 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 src/loaders/__tests__/thematicLoader.spec.js diff --git a/src/loaders/__tests__/thematicLoader.spec.js b/src/loaders/__tests__/thematicLoader.spec.js new file mode 100644 index 000000000..62e84f291 --- /dev/null +++ b/src/loaders/__tests__/thematicLoader.spec.js @@ -0,0 +1,58 @@ +import { getValuesByPeriod, getValueById } from '../thematicLoader.js' + +const headers = [{ name: 'pe' }, { name: 'ou' }, { name: 'value' }] + +describe('thematicLoader value extraction', () => { + // Analytics returns row values as strings, and computed items (indicators, + // calculations) serialize whole numbers with a trailing ".0" (e.g. "16082.0"). + // Both extractors must yield numbers so integers render without decimals. + // See DHIS2-21037. + + test('getValueById coerces string values to numbers', () => { + const data = { + headers, + rows: [ + ['202501', 'ou1', '16082.0'], + ['202501', 'ou2', '118.13'], + ], + } + + expect(getValueById(data)).toEqual({ + ou1: 16082, + ou2: 118.13, + }) + }) + + test('getValuesByPeriod coerces string values to numbers per period', () => { + const data = { + headers, + rows: [ + ['202501', 'ou1', '16082.0'], + ['202501', 'ou2', '118.13'], + ['202502', 'ou1', '238.0'], + ], + } + + expect(getValuesByPeriod(data)).toEqual({ + 202501: { + ou1: { value: 16082 }, + ou2: { value: 118.13 }, + }, + 202502: { + ou1: { value: 238 }, + }, + }) + }) + + test('getValuesByPeriod and getValueById agree on the same value', () => { + const data = { + headers, + rows: [['202501', 'ou1', '238.0']], + } + + const byId = getValueById(data) + const byPeriod = getValuesByPeriod(data) + + expect(byPeriod['202501'].ou1.value).toBe(byId.ou1) + }) +}) diff --git a/src/loaders/thematicLoader.js b/src/loaders/thematicLoader.js index 860570a70..3fa91fe27 100644 --- a/src/loaders/thematicLoader.js +++ b/src/loaders/thematicLoader.js @@ -345,7 +345,7 @@ const getPeriodsFromMetaData = ({ dimensions, items }) => } }) -const getValuesByPeriod = (data) => { +export const getValuesByPeriod = (data) => { const { headers, rows } = data const periodIndex = findIndex(['name', 'pe'], headers) const ouIndex = findIndex(['name', 'ou'], headers) @@ -355,14 +355,14 @@ const getValuesByPeriod = (data) => { const period = row[periodIndex] const periodObj = (obj[period] = obj[period] || {}) periodObj[row[ouIndex]] = { - value: row[valueIndex], + value: parseFloat(row[valueIndex]), } return obj }, {}) } // Returns an object mapping org. units and values -const getValueById = (data) => { +export const getValueById = (data) => { const { headers, rows } = data const ouIndex = findIndex(['name', 'ou'], headers) const valueIndex = findIndex(['name', 'value'], headers) From 6a829dffd078e644b4fcd842b3985de0800d1791 Mon Sep 17 00:00:00 2001 From: Karoline Tufte Lien Date: Thu, 28 May 2026 15:19:44 +0200 Subject: [PATCH 2/3] refactor: prefer Number.parseFloat over global parseFloat Sonar prefers the static Number.parseFloat. Same function (per spec Number.parseFloat === parseFloat), no behavior change. AI Assisted Co-Authored-By: Claude Opus 4.7 (1M context) --- src/loaders/thematicLoader.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/loaders/thematicLoader.js b/src/loaders/thematicLoader.js index 3fa91fe27..b00e435ea 100644 --- a/src/loaders/thematicLoader.js +++ b/src/loaders/thematicLoader.js @@ -355,7 +355,7 @@ export const getValuesByPeriod = (data) => { const period = row[periodIndex] const periodObj = (obj[period] = obj[period] || {}) periodObj[row[ouIndex]] = { - value: parseFloat(row[valueIndex]), + value: Number.parseFloat(row[valueIndex]), } return obj }, {}) @@ -368,7 +368,7 @@ export const getValueById = (data) => { const valueIndex = findIndex(['name', 'value'], headers) return rows.reduce((obj, row) => { - obj[row[ouIndex]] = parseFloat(row[valueIndex]) + obj[row[ouIndex]] = Number.parseFloat(row[valueIndex]) return obj }, {}) } @@ -378,7 +378,7 @@ const getOrderedValues = (data) => { const { headers, rows } = data const valueIndex = findIndex(['name', 'value'], headers) - return rows.map((row) => parseFloat(row[valueIndex])).sort((a, b) => a - b) + return rows.map((row) => Number.parseFloat(row[valueIndex])).sort((a, b) => a - b) } // Load features and data values from api From 4d424c9c6deea8409a223bcb98cfeb07df1da9cc Mon Sep 17 00:00:00 2001 From: Karoline Tufte Lien Date: Thu, 28 May 2026 15:38:08 +0200 Subject: [PATCH 3/3] style: wrap long line per prettier The Number.parseFloat rename pushed getOrderedValues over the 80-column print width; prettier breaks the chain across lines. AI Assisted Co-Authored-By: Claude Opus 4.7 (1M context) --- src/loaders/thematicLoader.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/loaders/thematicLoader.js b/src/loaders/thematicLoader.js index b00e435ea..5854f4eaa 100644 --- a/src/loaders/thematicLoader.js +++ b/src/loaders/thematicLoader.js @@ -378,7 +378,9 @@ const getOrderedValues = (data) => { const { headers, rows } = data const valueIndex = findIndex(['name', 'value'], headers) - return rows.map((row) => Number.parseFloat(row[valueIndex])).sort((a, b) => a - b) + return rows + .map((row) => Number.parseFloat(row[valueIndex])) + .sort((a, b) => a - b) } // Load features and data values from api