Repository: badges/shields (js)

Issue:
Display decimals when over 1 million
:clipboard: **Description**

Once downloads hit 1 million, the resolution of downloads is simply an integer, e.g. 1M. Pretty big milestone for some projects, but getting to display 2M might take just as long if not longer. What about showing a single decimal digit, e.g. 1.2M which still fits within limits. Perhaps this drops off after 10M. Same would go for billions, etc.

Pull request description:
Display one decimal for metrics smaller than 10
Replaces #3534 and closes #3509. Has made things a bit more complex but hopefully delivers the desired functionality.

Patch:
diff --git a/services/text-formatters.js b/services/text-formatters.js
index c6768a1c3b..e932633009 100644
--- a/services/text-formatters.js
+++ b/services/text-formatters.js
@@ -56,9 +56,17 @@ function metric(n) {
   for (let i = metricPrefix.length - 1; i >= 0; i--) {
     const limit = metricPower[i]
     if (n >= limit) {
-      n = Math.round(n / limit)
-      if (n < 1000) {
-        return `${n}${metricPrefix[i]}`
+      const scaledN = n / limit
+      if (scaledN < 10) {
+        // For "small" numbers, display one decimal digit unless it is 0.
+        const oneDecimalN = scaledN.toFixed(1)
+        if (oneDecimalN.charAt(oneDecimalN.length - 1) !== '0') {
+          return `${oneDecimalN}${metricPrefix[i]}`
+        }
+      }
+      const roundedN = Math.round(scaledN)
+      if (roundedN < 1000) {
+        return `${roundedN}${metricPrefix[i]}`
       } else {
         return `1${metricPrefix[i + 1]}`
       }
diff --git a/services/text-formatters.spec.js b/services/text-formatters.spec.js
index a9e79f39ff..0326cfe87b 100644
--- a/services/text-formatters.spec.js
+++ b/services/text-formatters.spec.js
@@ -42,14 +42,24 @@ describe('Text formatters', function() {
   test(metric, () => {
     given(999).expect('999')
     given(1000).expect('1k')
+    given(1100).expect('1.1k')
+    given(10100).expect('10k')
     given(999499).expect('999k')
     given(999500).expect('1M')
-    given(1578896212).expect('2G')
-    given(80000000000000).expect('80T')
+    given(1100000).expect('1.1M')
+    given(1578896212).expect('1.6G')
+    given(20000000000).expect('20G')
+    given(15788962120).expect('16G')
+    given(9949999999999).expect('9.9T')
+    given(9950000000001).expect('10T')
     given(4000000000000001).expect('4P')
+    given(4200000000000001).expect('4.2P')
+    given(7100700010058000200).expect('7.1E')
     given(71007000100580002000).expect('71E')
     given(1000000000000000000000).expect('1Z')
-    given(2222222222222222222222222).expect('2Y')
+    given(1100000000000000000000).expect('1.1Z')
+    given(2222222222222222222222222).expect('2.2Y')
+    given(22222222222222222222222222).expect('22Y')
   })
 
   test(omitv, () => {


Test patch:
diff --git a/services/discourse/discourse.tester.js b/services/discourse/discourse.tester.js
index b5b6ab4c55..8f3d114d54 100644
--- a/services/discourse/discourse.tester.js
+++ b/services/discourse/discourse.tester.js
@@ -97,28 +97,36 @@ t.create('Topics')
   .get('/https/meta.discourse.org/topics.json')
   .expectBadge({
     label: 'discourse',
-    message: Joi.string().regex(/^[0-9]+[kMGTPEZY]? topics$/),
+    message: Joi.string().regex(
+      /^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) topics$/
+    ),
   })
 
 t.create('Posts')
   .get('/https/meta.discourse.org/posts.json')
   .expectBadge({
     label: 'discourse',
-    message: Joi.string().regex(/^[0-9]+[kMGTPEZY]? posts$/),
+    message: Joi.string().regex(
+      /^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) posts$/
+    ),
   })
 
 t.create('Users')
   .get('/https/meta.discourse.org/users.json')
   .expectBadge({
     label: 'discourse',
-    message: Joi.string().regex(/^[0-9]+[kMGTPEZY]? users$/),
+    message: Joi.string().regex(
+      /^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) users$/
+    ),
   })
 
 t.create('Likes')
   .get('/https/meta.discourse.org/likes.json')
   .expectBadge({
     label: 'discourse',
-    message: Joi.string().regex(/^[0-9]+[kMGTPEZY]? likes$/),
+    message: Joi.string().regex(
+      /^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) likes$/
+    ),
   })
 
 t.create('Status')
diff --git a/services/github/github-commits-since.tester.js b/services/github/github-commits-since.tester.js
index 4bcc23d123..6a4be7122d 100644
--- a/services/github/github-commits-since.tester.js
+++ b/services/github/github-commits-since.tester.js
@@ -1,13 +1,14 @@
 'use strict'
 
 const Joi = require('@hapi/joi')
+const { isMetric } = require('../test-validators')
 const t = (module.exports = require('../tester').createServiceTester())
 
 t.create('Commits since')
   .get('/badges/shields/a0663d8da53fb712472c02665e6ff7547ba945b7.json')
   .expectBadge({
     label: Joi.string().regex(/^(commits since){1}[\s\S]+$/),
-    message: Joi.string().regex(/^\w+$/),
+    message: isMetric,
     color: 'blue',
   })
 
@@ -15,21 +16,21 @@ t.create('Commits since (branch)')
   .get('/badges/shields/60be4859585650e8c2b87669e3a39d98ca084e98/gh-pages.json')
   .expectBadge({
     label: Joi.string().regex(/^(commits since){1}[\s\S]+$/),
-    message: Joi.string().regex(/^\w+$/),
+    message: isMetric,
   })
 
 t.create('Commits since by latest release')
   .get('/microsoft/typescript/latest.json')
   .expectBadge({
     label: Joi.string().regex(/^(commits since){1}[\s\S]+$/),
-    message: Joi.string().regex(/^\d+\w?$/),
+    message: isMetric,
   })
 
 t.create('Commits since by latest release (branch)')
   .get('/microsoft/typescript/latest/master.json')
   .expectBadge({
     label: Joi.string().regex(/^(commits since){1}[\s\S]+$/),
-    message: Joi.string().regex(/^\d+\w?$/),
+    message: isMetric,
   })
 
 t.create('Commits since (version not found)')
diff --git a/services/github/github-downloads.tester.js b/services/github/github-downloads.tester.js
index 9f741ad5f8..14c69ffac8 100644
--- a/services/github/github-downloads.tester.js
+++ b/services/github/github-downloads.tester.js
@@ -40,21 +40,27 @@ t.create('downloads for specific asset without slash')
   .get('/downloads/atom/atom/v0.190.0/atom-amd64.deb.json')
   .expectBadge({
     label: 'downloads@v0.190.0',
-    message: Joi.string().regex(/^[0-9]+[kMGTPEZY]? \[atom-amd64\.deb\]$/),
+    message: Joi.string().regex(
+      /^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) \[atom-amd64\.deb\]$/
+    ),
   })
 
 t.create('downloads for specific asset from latest release')
   .get('/downloads/atom/atom/latest/atom-amd64.deb.json')
   .expectBadge({
     label: 'downloads@latest',
-    message: Joi.string().regex(/^[0-9]+[kMGTPEZY]? \[atom-amd64\.deb\]$/),
+    message: Joi.string().regex(
+      /^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) \[atom-amd64\.deb\]$/
+    ),
   })
 
 t.create('downloads-pre for specific asset from latest release')
   .get('/downloads-pre/atom/atom/latest/atom-amd64.deb.json')
   .expectBadge({
     label: 'downloads@latest',
-    message: Joi.string().regex(/^[0-9]+[kMGTPEZY]? \[atom-amd64\.deb\]$/),
+    message: Joi.string().regex(
+      /^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) \[atom-amd64\.deb\]$/
+    ),
   })
 
 t.create('downloads for release with slash')
@@ -66,7 +72,7 @@ t.create('downloads for specific asset with slash')
   .expectBadge({
     label: 'downloads@stable/v2.2.8',
     message: Joi.string().regex(
-      /^[0-9]+[kMGTPEZY]? \[dban-2\.2\.8_i586\.iso\]$/
+      /^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) \[dban-2\.2\.8_i586\.iso\]$/
     ),
   })
 
diff --git a/services/github/github-followers.tester.js b/services/github/github-followers.tester.js
index 852d29c79c..8ad5d8dbb2 100644
--- a/services/github/github-followers.tester.js
+++ b/services/github/github-followers.tester.js
@@ -1,13 +1,13 @@
 'use strict'
 
-const Joi = require('@hapi/joi')
+const { isMetric } = require('../test-validators')
 const t = (module.exports = require('../tester').createServiceTester())
 
 t.create('Followers')
   .get('/webcaetano.json')
   .expectBadge({
     label: 'followers',
-    message: Joi.string().regex(/^\w+$/),
+    message: isMetric,
   })
 
 t.create('Followers (user not found)')
diff --git a/services/github/github-issues.tester.js b/services/github/github-issues.tester.js
index 556eb10cfc..5e88f6d124 100644
--- a/services/github/github-issues.tester.js
+++ b/services/github/github-issues.tester.js
@@ -8,14 +8,16 @@ t.create('GitHub closed pull requests')
   .get('/issues-pr-closed/badges/shields.json')
   .expectBadge({
     label: 'pull requests',
-    message: Joi.string().regex(/^[0-9]+[kMGTPEZY]? closed$/),
+    message: Joi.string().regex(
+      /^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) closed$/
+    ),
   })
 
 t.create('GitHub closed pull requests raw')
   .get('/issues-pr-closed-raw/badges/shields.json')
   .expectBadge({
     label: 'closed pull requests',
-    message: Joi.string().regex(/^\w+?$/),
+    message: isMetric,
   })
 
 t.create('GitHub pull requests')
@@ -36,14 +38,16 @@ t.create('GitHub closed issues')
   .get('/issues-closed/badges/shields.json')
   .expectBadge({
     label: 'issues',
-    message: Joi.string().regex(/^[0-9]+[kMGTPEZY]? closed$/),
+    message: Joi.string().regex(
+      /^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) closed$/
+    ),
   })
 
 t.create('GitHub closed issues raw')
   .get('/issues-closed-raw/badges/shields.json')
   .expectBadge({
     label: 'closed issues',
-    message: Joi.string().regex(/^\w+\+?$/),
+    message: isMetric,
   })
 
 t.create('GitHub open issues')
diff --git a/services/github/github-stars.tester.js b/services/github/github-stars.tester.js
index 92ad07afef..96bb123407 100644
--- a/services/github/github-stars.tester.js
+++ b/services/github/github-stars.tester.js
@@ -1,13 +1,13 @@
 'use strict'
 
-const Joi = require('@hapi/joi')
+const { isMetric } = require('../test-validators')
 const t = (module.exports = require('../tester').createServiceTester())
 
 t.create('Stars')
   .get('/badges/shields.json')
   .expectBadge({
     label: 'stars',
-    message: Joi.string().regex(/^\w+$/),
+    message: isMetric,
     link: [
       'https://github.com/badges/shields',
       'https://github.com/badges/shields/stargazers',
diff --git a/services/test-validators.js b/services/test-validators.js
index ed6519a41c..d25ecf87ce 100644
--- a/services/test-validators.js
+++ b/services/test-validators.js
@@ -62,12 +62,14 @@ const isStarRating = withRegex(
 )
 
 // Required to be > 0, because accepting zero masks many problems.
-const isMetric = withRegex(/^[1-9][0-9]*[kMGTPEZY]?$/)
+const isMetric = withRegex(/^([1-9][0-9]*[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY])$/)
 
-const isMetricOpenIssues = withRegex(/^[1-9][0-9]*[kMGTPEZY]? open$/)
+const isMetricOpenIssues = withRegex(
+  /^([1-9][0-9]*[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) open$/
+)
 
 const isMetricOverTimePeriod = withRegex(
-  /^[1-9][0-9]*[kMGTPEZY]?\/(year|month|four weeks|week|day)$/
+  /^([1-9][0-9]*[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY])\/(year|month|four weeks|week|day)$/
 )
 
 const isIntegerPercentage = withRegex(/^[1-9][0-9]?%|^100%|^0%$/)
diff --git a/services/wordpress/wordpress-downloads.tester.js b/services/wordpress/wordpress-downloads.tester.js
index 45d5c62054..1bdc79b20b 100644
--- a/services/wordpress/wordpress-downloads.tester.js
+++ b/services/wordpress/wordpress-downloads.tester.js
@@ -1,6 +1,5 @@
 'use strict'
 
-const Joi = require('@hapi/joi')
 const { ServiceTester } = require('../tester')
 const { isMetric, isMetricOverTimePeriod } = require('../test-validators')
 
@@ -20,7 +19,7 @@ t.create('Plugin Downloads - Active')
   .get('/plugin/installs/akismet.json')
   .expectBadge({
     label: 'active installs',
-    message: Joi.string().regex(/^[1-9][0-9]*[kMGTPEZY]\+?$/),
+    message: isMetric,
   })
 
 t.create('Plugin Downloads - Day')
@@ -54,7 +53,7 @@ t.create('Theme Downloads - Active')
   .get('/theme/installs/twentyseventeen.json')
   .expectBadge({
     label: 'active installs',
-    message: Joi.string().regex(/^[1-9][0-9]*[kMGTPEZY]\+?$/),
+    message: isMetric,
   })
 
 t.create('Plugin Downloads - Total | Not Found')