evol-npm-pulse 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ # These are supported funding model platforms
2
+
3
+ github: evoluteur
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Olivier Giulieri
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,40 @@
1
+ # npm-Pulse
2
+
3
+ One page to see how all your npm packages are doing: downloads over the last week, month, or year, a weekly sparkline for each package, the trend against the previous month, and the GitHub stars that go with it.
4
+
5
+ - [See the packages](https://evoluteur.github.io/npm-pulse/)
6
+ - [See the charts](https://evoluteur.github.io/npm-pulse/chart.html)
7
+
8
+ ![npm Pulse](npm-pulse.png)
9
+
10
+ ![npm Pulse charts](npm-pulse-charts.png)
11
+
12
+ Everything is computed in the browser from three public APIs - no backend, no build step, no library:
13
+
14
+ - the [npm registry search API](https://github.com/npm/registry/blob/main/docs/REGISTRY-API.md) for the list of packages published by a maintainer,
15
+ - the [npm download counts API](https://github.com/npm/registry/blob/main/docs/download-counts.md) for a year of daily downloads (fetched in a single bulk call),
16
+ - the [GitHub API](https://docs.github.com/en/rest) for stars and forks.
17
+
18
+ The sparklines are 52 weekly buckets drawn as plain SVG, and the charts page shows total downloads month by month plus the ranking of the most downloaded packages.
19
+
20
+ Downloads are recomputed once a day by npm, so the answers are cached in `localStorage` for 6 hours. Use the "Refresh" link to fetch them again.
21
+
22
+ ## Use it for your own packages
23
+
24
+ Change the `user` value (at the top of the [/js/npm-data.js](https://github.com/evoluteur/npm-pulse/blob/main/js/npm-data.js) file) to see your packages instead of mine:
25
+
26
+ ```js
27
+ const user = "evoluteur"; // npm maintainer whose packages are shown
28
+ const ghUser = "evoluteur"; // GitHub user (for stars and forks)
29
+ const cacheMinutes = 360; // npm recomputes its stats once a day
30
+ ```
31
+
32
+ Then open `index.html` - there is nothing to install and nothing to build.
33
+
34
+ GitHub allows 60 anonymous API calls per hour. When that limit is reached the stars simply do not show, and everything else keeps working.
35
+
36
+ npm-Pulse is open source at [GitHub](https://github.com/evoluteur/npm-pulse) with MIT license.
37
+
38
+ For more ways to look at your projects check out my other projects [GitHub-Projects-Cards](https://github.com/evoluteur/github-projects-cards) ([demo](https://evoluteur.github.io/github-projects-cards/)) to display your repositories as cards, and [Meet-the-Fans](https://github.com/evoluteur/meet-the-fans) ([demo](https://evoluteur.github.io/meet-the-fans/)) to visualize the network graph of your repositories, followers, stargazers, and forks.
39
+
40
+ Copyright (c) 2026 [Olivier Giulieri](https://evoluteur.github.io/).
package/chart.html ADDED
@@ -0,0 +1,87 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <script
5
+ async
6
+ src="https://www.googletagmanager.com/gtag/js?id=G-063933E3C2"
7
+ ></script>
8
+ <script>
9
+ window.dataLayer = window.dataLayer || [];
10
+ function gtag() {
11
+ dataLayer.push(arguments);
12
+ }
13
+ gtag("js", new Date());
14
+ gtag("config", "G-063933E3C2");
15
+ </script>
16
+ <title>npm Pulse Charts - Downloads month by month</title>
17
+ <link
18
+ rel="canonical"
19
+ href="https://evoluteur.github.io/npm-pulse/chart.html"
20
+ />
21
+ <meta charset="utf-8" />
22
+ <meta http-equiv="content-language" content="en" />
23
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
24
+ <meta name="theme-color" content="#0288d1" />
25
+ <link rel="icon" type="image/png" href="favicon.png" />
26
+ <link
27
+ rel="stylesheet"
28
+ href="https://fonts.googleapis.com/css?family=Marcellus"
29
+ />
30
+ <link href="css/me.css" rel="stylesheet" />
31
+ <link href="css/pulse.css" rel="stylesheet" />
32
+ <script src="js/npm-data.js"></script>
33
+ <script src="js/chart.js"></script>
34
+ <meta
35
+ name="keywords"
36
+ content="npm, downloads, chart, monthly, statistics, packages, ranking, trends, svg"
37
+ />
38
+ <meta
39
+ name="description"
40
+ content="Total npm downloads month by month across all the packages of a maintainer, and the ranking of the most downloaded ones."
41
+ />
42
+ <meta name="robots" content="index, follow" />
43
+ <meta name="author" content="Olivier Giulieri" />
44
+ <meta property="og:type" content="website" />
45
+ <meta property="og:site_name" content="Evoluteur" />
46
+ <meta property="og:title" content="npm Pulse Charts" />
47
+ <meta
48
+ property="og:description"
49
+ content="Total npm downloads month by month across all the packages of a maintainer, and the ranking of the most downloaded ones."
50
+ />
51
+ <meta
52
+ property="og:url"
53
+ content="https://evoluteur.github.io/npm-pulse/chart.html"
54
+ />
55
+ </head>
56
+
57
+ <body onload="setupChartPage()">
58
+ <div class="nav">
59
+ <a href="https://evoluteur.github.io/">Evoluteur</a> &gt;
60
+ <a href="index.html">npm Pulse</a> &gt; Charts
61
+ </div>
62
+
63
+ <section class="gpc">
64
+ <h1 id="title">Loading...</h1>
65
+ <div id="charts"></div>
66
+ </section>
67
+
68
+ <footer>
69
+ <p>
70
+ Charts are drawn in plain SVG from the
71
+ <a
72
+ href="https://github.com/npm/registry/blob/main/docs/download-counts.md"
73
+ >npm registry API</a
74
+ >, no charting library.
75
+ </p>
76
+ <p>
77
+ <a href="index.html">Back to the packages</a>
78
+ </p>
79
+ <p>
80
+ &#169; 2026
81
+ <a href="https://evoluteur.github.io/" target="_blank"
82
+ >Olivier Giulieri</a
83
+ >
84
+ </p>
85
+ </footer>
86
+ </body>
87
+ </html>
package/css/me.css ADDED
@@ -0,0 +1,76 @@
1
+ /*
2
+ https://evoluteur.github.io/
3
+ (c) 2026 Olivier Giulieri
4
+ */
5
+
6
+ html {
7
+ margin: 0;
8
+ padding: 0;
9
+ scroll-behavior: smooth;
10
+ background: #0288d1 url("spirals.png");
11
+ }
12
+ body {
13
+ margin: 0;
14
+ padding: 0;
15
+ font-family: "Marcellus", serif;
16
+ font-size: 20px;
17
+ font-weight: 300;
18
+ line-height: 1.4em;
19
+ color: white;
20
+ overflow-x: hidden;
21
+ }
22
+ h1,
23
+ h2 {
24
+ color: #ffcc80;
25
+ }
26
+ h1 {
27
+ font-size: 2.5rem;
28
+ }
29
+ a,
30
+ a:visited {
31
+ color: #ffb74d;
32
+ text-decoration: none;
33
+ font-weight: 400;
34
+ /* background-color: #2b88d2; */
35
+ }
36
+ a:active,
37
+ a:hover {
38
+ color: #ffcc80;
39
+ text-decoration: underline;
40
+ font-weight: 400;
41
+ }
42
+ footer {
43
+ color: #bdcff1;
44
+ margin: 50px 20px 10px 20px;
45
+ > a {
46
+ white-space: nowrap;
47
+ }
48
+ &:hover {
49
+ color: white;
50
+ }
51
+ }
52
+
53
+ .nav {
54
+ color: white;
55
+ background-color: #1a1a1a;
56
+ padding: 8px 10px;
57
+ > a {
58
+ color: #92cddc;
59
+ background-color: #1a1a1a !important;
60
+ text-decoration: none;
61
+ &:hover {
62
+ color: #ff9900;
63
+ }
64
+ }
65
+ }
66
+ .gpc {
67
+ padding: 0 20px;
68
+ }
69
+ @media print {
70
+ body {
71
+ color: #1a1a1a !important;
72
+ }
73
+ @page {
74
+ margin: 1cm 1cm;
75
+ }
76
+ }
package/css/pulse.css ADDED
@@ -0,0 +1,207 @@
1
+ /*
2
+ npm-pulse
3
+ https://evoluteur.github.io/npm-pulse/
4
+ (c) 2026 Olivier Giulieri
5
+ */
6
+
7
+ #title > a {
8
+ text-transform: none;
9
+ }
10
+ .totals {
11
+ font-size: 1.2rem;
12
+ white-space: nowrap;
13
+ margin-left: 20px;
14
+ > span {
15
+ margin-right: 20px;
16
+ }
17
+ }
18
+ .filters {
19
+ display: flex;
20
+ flex-wrap: wrap;
21
+ gap: 20px;
22
+ align-items: baseline;
23
+ margin-bottom: 20px;
24
+ label::after {
25
+ content: ":";
26
+ }
27
+ input,
28
+ select {
29
+ font-size: 16px;
30
+ padding: 5px 10px;
31
+ margin-left: 5px;
32
+ }
33
+ .tools {
34
+ white-space: nowrap;
35
+ }
36
+ }
37
+ #summary {
38
+ font-size: 0.9em;
39
+ color: #bbdefb;
40
+ }
41
+
42
+ .projects {
43
+ display: flex;
44
+ flex-wrap: wrap;
45
+ gap: 16px;
46
+ font-size: 0.9em;
47
+ > .project-card {
48
+ display: flex;
49
+ flex-direction: column;
50
+ justify-content: space-between;
51
+ flex-grow: 1;
52
+ background-color: #5594f4;
53
+ width: 300px;
54
+ max-width: 100%;
55
+ border: 3px solid #0288d1;
56
+ border-radius: 10px;
57
+ padding: 5px 20px 15px;
58
+ .desc {
59
+ padding: 10px 0;
60
+ }
61
+ .p-links > a {
62
+ display: inline-block;
63
+ margin-top: 5px;
64
+ }
65
+ }
66
+ }
67
+ .pcard-title {
68
+ display: flex;
69
+ gap: 10px;
70
+ align-items: baseline;
71
+ min-height: 56px;
72
+ margin-bottom: 5px;
73
+ > span:nth-child(1) {
74
+ flex-grow: 1;
75
+ overflow-wrap: anywhere;
76
+ }
77
+ > .dls {
78
+ font-size: 1.1rem;
79
+ color: white;
80
+ white-space: nowrap;
81
+ }
82
+ }
83
+ .pcard-foot {
84
+ display: flex;
85
+ flex-wrap: wrap;
86
+ gap: 10px;
87
+ justify-content: space-between;
88
+ align-items: baseline;
89
+ margin-top: 12px;
90
+ font-size: 0.85em;
91
+ color: #dbe9ff;
92
+ }
93
+ .lang {
94
+ margin-right: 10px;
95
+ padding: 3px 10px;
96
+ border-radius: 10px;
97
+ border: solid 1px white;
98
+ white-space: nowrap;
99
+ }
100
+ .trend {
101
+ white-space: nowrap;
102
+ &.up {
103
+ color: #b9f6ca;
104
+ }
105
+ &.down {
106
+ color: #ffcdd2;
107
+ }
108
+ }
109
+ .crud-icon {
110
+ position: relative;
111
+ display: inline-block;
112
+ height: 16px;
113
+ width: 16px;
114
+ top: 2px;
115
+ svg {
116
+ fill: currentColor;
117
+ height: 16px;
118
+ width: 16px;
119
+ }
120
+ }
121
+
122
+ /* sparklines: 52 weekly buckets, no axis, no library */
123
+ .spark {
124
+ display: block;
125
+ width: 100%;
126
+ height: 46px;
127
+ overflow: visible;
128
+ > polygon {
129
+ fill: rgba(255, 255, 255, 0.22);
130
+ }
131
+ > polyline {
132
+ fill: none;
133
+ stroke: #ffcc80;
134
+ stroke-width: 1.5;
135
+ stroke-linejoin: round;
136
+ vector-effect: non-scaling-stroke;
137
+ }
138
+ > circle {
139
+ fill: #ffcc80;
140
+ vector-effect: non-scaling-stroke;
141
+ }
142
+ }
143
+ .spark-empty {
144
+ height: 46px;
145
+ display: flex;
146
+ align-items: center;
147
+ font-size: 0.85em;
148
+ color: #dbe9ff;
149
+ }
150
+
151
+ .noresults {
152
+ margin: 20px 0;
153
+ padding: 30px;
154
+ }
155
+
156
+ /* charts page */
157
+ .chart-block {
158
+ margin: 0 0 50px 0;
159
+ > h2 {
160
+ margin-bottom: 5px;
161
+ }
162
+ > .chart-note {
163
+ font-size: 0.85em;
164
+ color: #bbdefb;
165
+ margin: 0 0 15px 0;
166
+ }
167
+ }
168
+ .chart {
169
+ width: 100%;
170
+ height: auto;
171
+ overflow: visible;
172
+ .grid {
173
+ stroke: rgba(255, 255, 255, 0.25);
174
+ stroke-width: 1;
175
+ }
176
+ .bar {
177
+ fill: #ffcc80;
178
+ }
179
+ .bar.partial {
180
+ fill: rgba(255, 204, 128, 0.6);
181
+ stroke: #ffcc80;
182
+ stroke-dasharray: 3 3;
183
+ }
184
+ .axis {
185
+ fill: white;
186
+ font-size: 12px;
187
+ font-family: "Marcellus", serif;
188
+ }
189
+ .axis.value {
190
+ fill: #dbe9ff;
191
+ }
192
+ .bar-label {
193
+ fill: white;
194
+ font-size: 13px;
195
+ font-family: "Marcellus", serif;
196
+ }
197
+ }
198
+
199
+ @media print {
200
+ .filters,
201
+ .nav {
202
+ display: none;
203
+ }
204
+ .projects > .project-card {
205
+ break-inside: avoid;
206
+ }
207
+ }
Binary file
package/favicon.png ADDED
Binary file
package/index.html ADDED
@@ -0,0 +1,151 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <script
5
+ async
6
+ src="https://www.googletagmanager.com/gtag/js?id=G-063933E3C2"
7
+ ></script>
8
+ <script>
9
+ window.dataLayer = window.dataLayer || [];
10
+ function gtag() {
11
+ dataLayer.push(arguments);
12
+ }
13
+ gtag("js", new Date());
14
+ gtag("config", "G-063933E3C2");
15
+ </script>
16
+ <title>npm Pulse - Downloads and trends for your npm packages</title>
17
+ <link rel="canonical" href="https://evoluteur.github.io/npm-pulse/" />
18
+ <meta charset="utf-8" />
19
+ <meta http-equiv="content-language" content="en" />
20
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
21
+ <meta name="theme-color" content="#0288d1" />
22
+ <link rel="icon" type="image/png" href="favicon.png" />
23
+ <link
24
+ rel="stylesheet"
25
+ href="https://fonts.googleapis.com/css?family=Marcellus"
26
+ />
27
+ <link href="css/me.css" rel="stylesheet" />
28
+ <link href="css/pulse.css" rel="stylesheet" />
29
+ <script src="js/npm-data.js"></script>
30
+ <script src="js/pulse.js"></script>
31
+ <meta
32
+ name="keywords"
33
+ content="npm, downloads, packages, statistics, stats, dashboard, sparkline, trends, registry, open source, portfolio, github, stars"
34
+ />
35
+ <meta
36
+ name="description"
37
+ content="One page dashboard for all the npm packages of a maintainer: downloads over the last week, month, and year, weekly sparklines, trends, and GitHub stars."
38
+ />
39
+ <meta name="robots" content="index, follow" />
40
+ <meta name="author" content="Olivier Giulieri" />
41
+
42
+ <meta property="og:type" content="website" />
43
+ <meta property="og:site_name" content="Evoluteur" />
44
+ <meta property="og:title" content="npm Pulse" />
45
+ <meta
46
+ property="og:description"
47
+ content="One page dashboard for all the npm packages of a maintainer: downloads over the last week, month, and year, weekly sparklines, trends, and GitHub stars."
48
+ />
49
+ <meta property="og:url" content="https://evoluteur.github.io/npm-pulse/" />
50
+ <meta
51
+ property="og:image"
52
+ content="https://raw.githubusercontent.com/evoluteur/npm-pulse/main/npm-pulse.png"
53
+ />
54
+ <meta name="twitter:card" content="summary_large_image" />
55
+ <meta name="twitter:title" content="npm Pulse" />
56
+ <meta
57
+ name="twitter:description"
58
+ content="Downloads, sparklines, and trends for all your npm packages on a single page."
59
+ />
60
+ <meta
61
+ name="twitter:image"
62
+ content="https://raw.githubusercontent.com/evoluteur/npm-pulse/main/npm-pulse.png"
63
+ />
64
+
65
+ <script type="application/ld+json">
66
+ {
67
+ "@context": "https://schema.org",
68
+ "@type": "SoftwareSourceCode",
69
+ "name": "npm-pulse",
70
+ "description": "One page dashboard for all the npm packages of a maintainer: downloads, weekly sparklines, trends, and GitHub stars.",
71
+ "url": "https://evoluteur.github.io/npm-pulse/",
72
+ "codeRepository": "https://github.com/evoluteur/npm-pulse",
73
+ "license": "https://github.com/evoluteur/npm-pulse/blob/main/LICENSE",
74
+ "programmingLanguage": "JavaScript",
75
+ "author": {
76
+ "@type": "Person",
77
+ "name": "Olivier Giulieri",
78
+ "url": "https://evoluteur.github.io/"
79
+ }
80
+ }
81
+ </script>
82
+ </head>
83
+
84
+ <body onload="setupPulsePage()">
85
+ <div class="nav">
86
+ <a href="https://evoluteur.github.io/">Evoluteur</a> &gt; npm Pulse
87
+ </div>
88
+
89
+ <section class="gpc">
90
+ <h1 id="title">Loading...</h1>
91
+
92
+ <div class="filters">
93
+ <div>
94
+ <label for="filter">Find</label>
95
+ <input type="text" onkeyup="filter(this.value)" id="filter" />
96
+ </div>
97
+ <div>
98
+ <label for="periodPicker">Period</label>
99
+ <select onchange="setPeriod(this.value)" id="periodPicker">
100
+ <option value="last7">Last 7 days</option>
101
+ <option value="last30" selected>Last 30 days</option>
102
+ <option value="last365">Last 12 months</option>
103
+ </select>
104
+ </div>
105
+ <div>
106
+ <label for="sortPicker">Sort by</label>
107
+ <select onchange="sort(this.value)" id="sortPicker">
108
+ <option value="downloads" selected>Downloads</option>
109
+ <option value="name">Name</option>
110
+ <option value="stars">Stars</option>
111
+ <option value="date">Published</option>
112
+ <option value="trend">Trend</option>
113
+ </select>
114
+ </div>
115
+ <span id="summary"></span>
116
+ <span class="tools">
117
+ <a href="chart.html">Charts</a>
118
+ <!-- <a href="#" onclick="clearCache(); return false;">Refresh</a> -->
119
+ </span>
120
+ </div>
121
+
122
+ <div id="packages" class="projects"></div>
123
+ </section>
124
+
125
+ <footer>
126
+ <p>
127
+ <a href="https://github.com/evoluteur/npm-pulse">npm-Pulse</a> is open
128
+ source at GitHub with MIT license. Downloads come from the
129
+ <a
130
+ href="https://github.com/npm/registry/blob/main/docs/download-counts.md"
131
+ >npm registry API</a
132
+ >, stars from the
133
+ <a href="https://docs.github.com/en/rest">GitHub API</a>.
134
+ </p>
135
+
136
+ <p>
137
+ For more ways to look at your projects check out
138
+ <a href="https://evoluteur.github.io/github-projects-cards/"
139
+ >GitHub-Projects-Cards</a
140
+ >
141
+ and
142
+ <a href="https://evoluteur.github.io/meet-the-fans/">Meet-the-Fans</a>.
143
+ </p>
144
+
145
+ &#169; 2026
146
+ <a href="https://evoluteur.github.io/" target="_blank"
147
+ >Olivier Giulieri</a
148
+ >
149
+ </footer>
150
+ </body>
151
+ </html>
package/js/chart.js ADDED
@@ -0,0 +1,134 @@
1
+ /*
2
+ npm-pulse - charts
3
+ https://evoluteur.github.io/npm-pulse/
4
+ (c) 2026 Olivier Giulieri
5
+ */
6
+
7
+ const topCount = 12; // number of packages in the ranking
8
+
9
+ // round a maximum up to a readable value (1200 -> 2000, 43 -> 50)
10
+ const niceMax = (value) => {
11
+ if (value <= 0) return 1;
12
+ const power = Math.pow(10, Math.floor(Math.log10(value)));
13
+ const steps = [1, 1.5, 2, 2.5, 3, 4, 5, 7.5, 10];
14
+ const step = steps.find((s) => value <= s * power) || 10;
15
+ return step * power;
16
+ };
17
+
18
+ const gridHTML = (max, x0, x1, y0, y1, ticks) => {
19
+ let html = "";
20
+ for (let i = 0; i <= ticks; i++) {
21
+ const y = y1 - ((y1 - y0) * i) / ticks;
22
+ html += `<line class="grid" x1="${x0}" y1="${y}" x2="${x1}" y2="${y}"></line>
23
+ <text class="axis value" x="${x0 - 10}" y="${
24
+ y + 4
25
+ }" text-anchor="end">${fNum(Math.round((max * i) / ticks))}</text>`;
26
+ }
27
+ return html;
28
+ };
29
+
30
+ // downloads per month, across every package
31
+ const monthsChart = (months) => {
32
+ const w = 900;
33
+ const h = 340;
34
+ const left = 90;
35
+ const right = 20;
36
+ const top = 20;
37
+ const bottom = 50;
38
+ const max = niceMax(Math.max(...months.map((m) => m.downloads), 1));
39
+ const iw = w - left - right;
40
+ const step = iw / months.length;
41
+ const barW = Math.min(step * 0.62, 60);
42
+ const bars = months
43
+ .map((m, i) => {
44
+ const height = ((h - top - bottom) * m.downloads) / max;
45
+ const x = left + i * step + (step - barW) / 2;
46
+ const y = h - bottom - height;
47
+ const partial = i === months.length - 1;
48
+ return `<rect class="bar${partial ? " partial" : ""}" x="${x.toFixed(
49
+ 1
50
+ )}" y="${y.toFixed(1)}" width="${barW.toFixed(1)}" height="${height.toFixed(
51
+ 1
52
+ )}"><title>${m.month}: ${fNum(m.downloads)} downloads${
53
+ partial ? " so far" : ""
54
+ }</title></rect>
55
+ <text class="axis" x="${(x + barW / 2).toFixed(1)}" y="${
56
+ h - bottom + 20
57
+ }" text-anchor="middle">${fMonth(m.month)}</text>`;
58
+ })
59
+ .join("");
60
+ return `<svg class="chart" viewBox="0 0 ${w} ${h}" role="img"
61
+ aria-label="Total npm downloads per month over the last year">
62
+ ${gridHTML(max, left, w - right, top, h - bottom, 4)}
63
+ ${bars}
64
+ </svg>`;
65
+ };
66
+
67
+ // the most downloaded packages of the last 12 months
68
+ const topChart = (top) => {
69
+ const rowH = 32;
70
+ const w = 900;
71
+ const left = 230;
72
+ const right = 90;
73
+ const h = top.length * rowH + 10;
74
+ const max = Math.max(...top.map((p) => p.last365), 1);
75
+ const iw = w - left - right;
76
+ const rows = top
77
+ .map((p, i) => {
78
+ const y = i * rowH + 5;
79
+ const barW = (iw * p.last365) / max;
80
+ return `<text class="bar-label" x="${left - 12}" y="${
81
+ y + rowH / 2
82
+ }" text-anchor="end" dominant-baseline="middle">${escapeHtml(
83
+ p.name
84
+ )}</text>
85
+ <rect class="bar" x="${left}" y="${y + 4}" width="${barW.toFixed(
86
+ 1
87
+ )}" height="${rowH - 12}" rx="3"><title>${escapeHtml(p.name)}: ${fNum(
88
+ p.last365
89
+ )} downloads</title></rect>
90
+ <text class="axis value" x="${(left + barW + 10).toFixed(1)}" y="${
91
+ y + rowH / 2
92
+ }" dominant-baseline="middle">${fNum(p.last365)}</text>`;
93
+ })
94
+ .join("");
95
+ return `<svg class="chart" viewBox="0 0 ${w} ${h}" role="img"
96
+ aria-label="Most downloaded packages over the last 12 months">${rows}</svg>`;
97
+ };
98
+
99
+ const setupChartPage = async () => {
100
+ try {
101
+ await loadData();
102
+ } catch (e) {
103
+ document.getElementById("title").innerHTML = "npm";
104
+ document.getElementById("charts").innerHTML =
105
+ `<div class="noresults">Could not reach the npm registry: ${escapeHtml(
106
+ e.message
107
+ )}</div>`;
108
+ return;
109
+ }
110
+ document.getElementById("title").innerHTML = `<a href="https://www.npmjs.com/~${user}"
111
+ target="npm-user">${escapeHtml(user)}</a> downloads`;
112
+
113
+ const months = monthlyTotals();
114
+ const top = packages
115
+ .slice()
116
+ .sort((a, b) => b.last365 - a.last365)
117
+ .slice(0, topCount)
118
+ .filter((p) => p.last365);
119
+
120
+ document.getElementById("charts").innerHTML = `
121
+ <div class="chart-block">
122
+ <h2>${fNum(totals.last365)} downloads over the last 12 months</h2>
123
+ <p class="chart-note">
124
+ All ${totals.packages} packages together, month by month. The last bar
125
+ covers the current month only up to yesterday.
126
+ </p>
127
+ ${monthsChart(months)}
128
+ </div>
129
+ <div class="chart-block">
130
+ <h2>Most downloaded packages</h2>
131
+ <p class="chart-note">Downloads over the last 12 months.</p>
132
+ ${topChart(top)}
133
+ </div>`;
134
+ };
package/js/npm-data.js ADDED
@@ -0,0 +1,249 @@
1
+ /*
2
+ npm-pulse - shared data layer
3
+ https://evoluteur.github.io/npm-pulse/
4
+ (c) 2026 Olivier Giulieri
5
+ */
6
+
7
+ // --- config options ---------------------------------------------------
8
+ const user = "evoluteur"; // npm maintainer whose packages are shown
9
+ const ghUser = "evoluteur"; // GitHub user (for stars and forks)
10
+ const cacheMinutes = 360; // npm recomputes its stats once a day, no need to refetch often
11
+ // ----------------------------------------------------------------------
12
+
13
+ const npmURL = "https://www.npmjs.com/package/";
14
+ const ghURL = `https://github.com/${ghUser}`;
15
+ const cacheKey = "npm-pulse-data";
16
+
17
+ let packages = []; // all packages, enriched with downloads and GitHub stats
18
+ let totals = {};
19
+
20
+ const periods = {
21
+ last7: "last 7 days",
22
+ last30: "last 30 days",
23
+ last365: "last 12 months",
24
+ };
25
+
26
+ // --- formatting -------------------------------------------------------
27
+
28
+ const numFormat = new Intl.NumberFormat();
29
+ const fNum = (n) => numFormat.format(n || 0);
30
+
31
+ const fDate = (d) => (d ? new Date(d).toDateString().substring(4) : "");
32
+
33
+ const fMonth = (ym) => {
34
+ const [y, m] = ym.split("-");
35
+ return new Date(y, m - 1, 1).toLocaleString(undefined, { month: "short" });
36
+ };
37
+
38
+ const escapeHtml = (txt) =>
39
+ String(txt)
40
+ .replace(/&/g, "&amp;")
41
+ .replace(/</g, "&lt;")
42
+ .replace(/>/g, "&gt;")
43
+ .replace(/"/g, "&quot;")
44
+ .replace(/'/g, "&#039;");
45
+
46
+ // --- fetching ---------------------------------------------------------
47
+
48
+ const fetchJSON = async (url) => {
49
+ const response = await fetch(url);
50
+ if (!response.ok) {
51
+ throw new Error(`${response.status} on ${url}`);
52
+ }
53
+ return response.json();
54
+ };
55
+
56
+ // all packages published by the maintainer
57
+ const fetchPackages = async () => {
58
+ const data = await fetchJSON(
59
+ `https://registry.npmjs.org/-/v1/search?text=maintainer:${user}&size=250`
60
+ );
61
+ return (data.objects || []).map((o) => o.package);
62
+ };
63
+
64
+ // a year of daily downloads, in bulk (the API takes up to 128 packages per call)
65
+ const fetchDownloads = async (names) => {
66
+ const chunks = [];
67
+ for (let i = 0; i < names.length; i += 100) {
68
+ chunks.push(names.slice(i, i + 100));
69
+ }
70
+ const answers = await Promise.all(
71
+ chunks.map((chunk) =>
72
+ fetchJSON(
73
+ `https://api.npmjs.org/downloads/range/last-year/${chunk.join(",")}`
74
+ ).catch(() => null)
75
+ )
76
+ );
77
+ const all = {};
78
+ answers.forEach((answer) => {
79
+ if (!answer) return;
80
+ // asking for a single package answers with one object rather than a map
81
+ const map = answer.package ? { [answer.package]: answer } : answer;
82
+ Object.keys(map).forEach((name) => {
83
+ if (map[name]) {
84
+ all[name] = map[name].downloads || [];
85
+ }
86
+ });
87
+ });
88
+ return all;
89
+ };
90
+
91
+ // stars and forks are a bonus: GitHub throttles anonymous calls at 60/hour
92
+ const fetchRepos = async () => {
93
+ try {
94
+ const list = await fetchJSON(
95
+ `https://api.github.com/users/${ghUser}/repos?per_page=100`
96
+ );
97
+ const map = {};
98
+ list.forEach((r) => {
99
+ map[r.name.toLowerCase()] = {
100
+ name: r.name,
101
+ stars: r.stargazers_count,
102
+ forks: r.forks_count,
103
+ language: r.language,
104
+ homepage: r.homepage,
105
+ };
106
+ });
107
+ return map;
108
+ } catch (e) {
109
+ return {};
110
+ }
111
+ };
112
+
113
+ // --- deriving ---------------------------------------------------------
114
+
115
+ // "git+https://github.com/evoluteur/healing-frequencies.git" -> "healing-frequencies"
116
+ const repoOf = (pkg) => {
117
+ const url = (pkg.links || {}).repository;
118
+ if (!url) return "";
119
+ const match = url
120
+ .replace(/\.git$/, "")
121
+ .replace(/\/$/, "")
122
+ .match(/github\.com[/:]([^/]+)\/([^/]+)$/);
123
+ return match ? match[2] : "";
124
+ };
125
+
126
+ const sumDays = (days, from, to) =>
127
+ days.slice(from, to).reduce((total, d) => total + (d.downloads || 0), 0);
128
+
129
+ // 52 weekly buckets, oldest first, for the sparklines
130
+ const weeklyBuckets = (days) => {
131
+ const weeks = [];
132
+ const year = days.slice(-364);
133
+ for (let i = 0; i < year.length; i += 7) {
134
+ weeks.push(sumDays(year, i, i + 7));
135
+ }
136
+ return weeks;
137
+ };
138
+
139
+ const build = (pkgs, downloads, repos) => {
140
+ packages = pkgs.map((pkg) => {
141
+ const days = downloads[pkg.name] || [];
142
+ const repoName = repoOf(pkg);
143
+ const repo = repos[repoName.toLowerCase()];
144
+ const last30 = sumDays(days, -30);
145
+ const previous30 = sumDays(days, -60, -30);
146
+ return {
147
+ name: pkg.name,
148
+ version: pkg.version || "",
149
+ description: pkg.description || "",
150
+ date: pkg.date,
151
+ repo: repoName,
152
+ homepage: (pkg.links || {}).homepage || (repo && repo.homepage) || "",
153
+ language: repo ? repo.language : "",
154
+ stars: repo ? repo.stars : 0,
155
+ forks: repo ? repo.forks : 0,
156
+ days,
157
+ weeks: weeklyBuckets(days),
158
+ last7: sumDays(days, -7),
159
+ last30,
160
+ last365: sumDays(days, -365),
161
+ // growth of the last 30 days over the 30 days before them
162
+ trend: previous30
163
+ ? Math.round(((last30 - previous30) / previous30) * 100)
164
+ : null,
165
+ };
166
+ });
167
+ const add = (field) => packages.reduce((t, p) => t + p[field], 0);
168
+ totals = {
169
+ packages: packages.length,
170
+ last7: add("last7"),
171
+ last30: add("last30"),
172
+ last365: add("last365"),
173
+ stars: add("stars"),
174
+ forks: add("forks"),
175
+ };
176
+ return packages;
177
+ };
178
+
179
+ // total downloads per month, across all packages, oldest first
180
+ const monthlyTotals = () => {
181
+ const months = {};
182
+ packages.forEach((p) =>
183
+ p.days.forEach((d) => {
184
+ const month = d.day.substring(0, 7);
185
+ months[month] = (months[month] || 0) + (d.downloads || 0);
186
+ })
187
+ );
188
+ const keys = Object.keys(months).sort();
189
+ // the first month is only partially covered by a "last-year" range
190
+ keys.shift();
191
+ return keys.map((month) => ({ month, downloads: months[month] }));
192
+ };
193
+
194
+ // --- caching ----------------------------------------------------------
195
+
196
+ const readCache = () => {
197
+ try {
198
+ const cache = JSON.parse(localStorage.getItem(cacheKey));
199
+ if (!cache || cache.user !== user) return null;
200
+ if (Date.now() - cache.time > cacheMinutes * 60000) return null;
201
+ return cache.data;
202
+ } catch (e) {
203
+ return null;
204
+ }
205
+ };
206
+
207
+ const writeCache = (data) => {
208
+ try {
209
+ localStorage.setItem(cacheKey, JSON.stringify({ user, time: Date.now(), data }));
210
+ } catch (e) {
211
+ // a year of daily downloads can outgrow the quota, the page works without the cache
212
+ }
213
+ };
214
+
215
+ const clearCache = () => {
216
+ try {
217
+ localStorage.removeItem(cacheKey);
218
+ } catch (e) {}
219
+ location.reload();
220
+ };
221
+
222
+ // --- entry point ------------------------------------------------------
223
+
224
+ const loadData = async () => {
225
+ const cached = readCache();
226
+ if (cached) {
227
+ build(cached.pkgs, cached.downloads, cached.repos);
228
+ return { cached: true, time: cached.time };
229
+ }
230
+ const pkgs = await fetchPackages();
231
+ const names = pkgs.map((p) => p.name);
232
+ const [downloads, repos] = await Promise.all([
233
+ fetchDownloads(names),
234
+ fetchRepos(),
235
+ ]);
236
+ build(pkgs, downloads, repos);
237
+ writeCache({ pkgs, downloads, repos, time: Date.now() });
238
+ return { cached: false, time: Date.now() };
239
+ };
240
+
241
+ // --- icons ------------------------------------------------------------
242
+
243
+ const icoStar = `<span class="crud-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z"></path></svg></span>`;
244
+
245
+ const icoDownload = `<span class="crud-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z"></path></svg></span>`;
246
+
247
+ const icoBox = `<span class="crud-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12,2L2,7V17L12,22L22,17V7L12,2M12,4.2L19.2,7.8L12,11.4L4.8,7.8L12,4.2M4,9.4L11,12.9V19.6L4,16.1V9.4M13,19.6V12.9L20,9.4V16.1L13,19.6Z"></path></svg></span>`;
248
+
249
+ const icoChart = `<span class="crud-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M22,21H2V3H4V19H6V10H10V19H12V6H16V19H18V14H22V21Z"></path></svg></span>`;
package/js/pulse.js ADDED
@@ -0,0 +1,186 @@
1
+ /*
2
+ npm-pulse - package cards
3
+ https://evoluteur.github.io/npm-pulse/
4
+ (c) 2026 Olivier Giulieri
5
+ */
6
+
7
+ let selection = [];
8
+ let period = localStorage.getItem("npm-pulse-period") || "last30";
9
+ let sortField = localStorage.getItem("npm-pulse-sort") || "downloads";
10
+ let searchString = "";
11
+
12
+ // --- sparkline --------------------------------------------------------
13
+
14
+ // 52 weeks of downloads drawn as an area and a line, no library, no axis
15
+ const sparkHTML = (pkg) => {
16
+ const weeks = pkg.weeks;
17
+ if (weeks.length < 2 || !pkg.last365) {
18
+ return `<div class="spark-empty">no downloads reported yet</div>`;
19
+ }
20
+ const w = 300;
21
+ const h = 46;
22
+ const max = Math.max(...weeks, 1);
23
+ const points = weeks.map((v, i) => {
24
+ const x = (i / (weeks.length - 1)) * w;
25
+ const y = h - 3 - (v / max) * (h - 8);
26
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
27
+ });
28
+ const peak = weeks.indexOf(max);
29
+ const peakX = ((peak / (weeks.length - 1)) * w).toFixed(1);
30
+ const peakY = (h - 3 - (h - 8)).toFixed(1);
31
+ return `<svg class="spark" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none"
32
+ role="img" aria-label="Weekly downloads over the last 12 months, peak of ${fNum(
33
+ max
34
+ )}">
35
+ <polygon points="0,${h} ${points.join(" ")} ${w},${h}"></polygon>
36
+ <polyline points="${points.join(" ")}"></polyline>
37
+ <circle cx="${peakX}" cy="${peakY}" r="2.5"></circle>
38
+ </svg>`;
39
+ };
40
+
41
+ // --- cards ------------------------------------------------------------
42
+
43
+ const trendHTML = (pkg) => {
44
+ if (pkg.trend === null || !pkg.last30) return "";
45
+ const up = pkg.trend >= 0;
46
+ return `<span class="trend ${up ? "up" : "down"}" title="${
47
+ up ? "up" : "down"
48
+ } ${Math.abs(pkg.trend)}% over the previous 30 days">${up ? "▲" : "▼"} ${Math.abs(
49
+ pkg.trend
50
+ )}%</span>`;
51
+ };
52
+
53
+ const linksHTML = (pkg) => {
54
+ const n = pkg.name;
55
+ const links = [`<a href="${npmURL}${n}" target="npm-${n}">npm</a>`];
56
+ if (pkg.repo) {
57
+ links.push(`<a href="${ghURL}/${pkg.repo}" target="code-${n}">Code</a>`);
58
+ }
59
+ if (pkg.homepage) {
60
+ links.push(`<a href="${pkg.homepage}" target="demo-${n}">Demo</a>`);
61
+ }
62
+ links.push(
63
+ `<a href="https://npm-stat.com/charts.html?package=${n}" target="stat-${n}">Charts</a>`
64
+ );
65
+ const lang = pkg.language
66
+ ? `<span class="lang">${escapeHtml(pkg.language)}</span>`
67
+ : "";
68
+ return lang + links.join(" - ");
69
+ };
70
+
71
+ const statsHTML = (pkg) => {
72
+ const bits = [];
73
+ if (pkg.stars) {
74
+ bits.push(
75
+ `<a href="${ghURL}/${pkg.repo}/stargazers" target="stars-${pkg.name}" aria-label="stars">${icoStar} ${pkg.stars}</a>`
76
+ );
77
+ }
78
+ return bits.join(" ");
79
+ };
80
+
81
+ const cardHTML = (pkg) => `<div class="project-card">
82
+ <div>
83
+ <h2 class="pcard-title">
84
+ <span><a href="${npmURL}${pkg.name}" target="npm-${pkg.name}">${escapeHtml(
85
+ pkg.name
86
+ )}</a></span>
87
+ <span class="dls" title="downloads ${periods[period]}">${icoDownload} ${fNum(
88
+ pkg[period]
89
+ )}</span>
90
+ </h2>
91
+ ${sparkHTML(pkg)}
92
+ <div class="desc">${escapeHtml(pkg.description || pkg.name)}</div>
93
+ <div class="p-links">${linksHTML(pkg)}</div>
94
+ </div>
95
+ <div class="pcard-foot">
96
+ <span>v${escapeHtml(pkg.version)} - ${fDate(pkg.date)}</span>
97
+ <span class="stats">${trendHTML(pkg)} ${statsHTML(pkg)}</span>
98
+ </div>
99
+ </div>`;
100
+
101
+ // --- filtering and sorting --------------------------------------------
102
+
103
+ const applySort = () => {
104
+ const field = sortField === "downloads" ? period : sortField;
105
+ const alpha = field === "name";
106
+ const way = alpha ? 1 : -1;
107
+ selection.sort((a, b) => {
108
+ const av = a[field] === null ? -Infinity : a[field];
109
+ const bv = b[field] === null ? -Infinity : b[field];
110
+ if (av === bv) return a.name > b.name ? 1 : -1;
111
+ return way * (av > bv ? 1 : -1);
112
+ });
113
+ };
114
+
115
+ const applyFilter = () => {
116
+ const s = searchString.toLowerCase();
117
+ selection = s
118
+ ? packages.filter(
119
+ (p) =>
120
+ p.name.toLowerCase().includes(s) ||
121
+ p.description.toLowerCase().includes(s)
122
+ )
123
+ : packages.slice();
124
+ };
125
+
126
+ const refresh = () => {
127
+ applyFilter();
128
+ applySort();
129
+ const html = selection.map(cardHTML).join("");
130
+ document.getElementById("packages").innerHTML =
131
+ html ||
132
+ `<div class="noresults">No results. The search criteria is too restrictive.</div>`;
133
+ const shown = selection.length;
134
+ const downloads = selection.reduce((t, p) => t + p[period], 0);
135
+ document.getElementById("summary").innerHTML = `${shown} package${
136
+ shown === 1 ? "" : "s"
137
+ } - ${fNum(downloads)} downloads ${periods[period]}`;
138
+ };
139
+
140
+ const filter = (value) => {
141
+ searchString = value;
142
+ refresh();
143
+ };
144
+
145
+ const sort = (value) => {
146
+ sortField = value;
147
+ localStorage.setItem("npm-pulse-sort", value);
148
+ refresh();
149
+ };
150
+
151
+ const setPeriod = (value) => {
152
+ period = value;
153
+ localStorage.setItem("npm-pulse-period", value);
154
+ refresh();
155
+ };
156
+
157
+ // --- page -------------------------------------------------------------
158
+
159
+ const titleHTML = () => `<a href="https://www.npmjs.com/~${user}" target="npm-user">${escapeHtml(
160
+ user
161
+ )}</a> on npm
162
+ <span class="totals">
163
+ <span title="packages">${icoBox} ${totals.packages}</span>
164
+ <span title="downloads in the last 12 months">${icoDownload} ${fNum(
165
+ totals.last365
166
+ )}</span>
167
+ ${totals.stars ? `<span title="stars on GitHub">${icoStar} ${totals.stars}</span>` : ""}
168
+ </span>`;
169
+
170
+ const setupPulsePage = async () => {
171
+ document.getElementById("sortPicker").value = sortField;
172
+ document.getElementById("periodPicker").value = period;
173
+ try {
174
+ await loadData();
175
+ } catch (e) {
176
+ document.getElementById("title").innerHTML = "npm";
177
+ document.getElementById(
178
+ "packages"
179
+ ).innerHTML = `<div class="noresults">Could not reach the npm registry: ${escapeHtml(
180
+ e.message
181
+ )}</div>`;
182
+ return;
183
+ }
184
+ document.getElementById("title").innerHTML = titleHTML();
185
+ refresh();
186
+ };
Binary file
package/npm-pulse.png ADDED
Binary file
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "evol-npm-pulse",
3
+ "version": "1.0.0",
4
+ "description": "One page dashboard for all your npm packages: downloads over the last week, month, and year, weekly sparklines, trends, and GitHub stars.",
5
+ "repository": "github:evoluteur/npm-pulse",
6
+ "homepage": "https://evoluteur.github.io/npm-pulse/",
7
+ "copyright": "(c) 2026 Olivier Giulieri",
8
+ "author": "Olivier Giulieri (https://evoluteur.github.io/)",
9
+ "license": "MIT",
10
+ "keywords": [
11
+ "npm",
12
+ "downloads",
13
+ "download-counts",
14
+ "packages",
15
+ "registry",
16
+ "stats",
17
+ "statistics",
18
+ "dashboard",
19
+ "sparkline",
20
+ "trends",
21
+ "chart",
22
+ "svg",
23
+ "portfolio",
24
+ "github",
25
+ "stars",
26
+ "open-source",
27
+ "javascript",
28
+ "vanilla-js"
29
+ ]
30
+ }
package/robots.txt ADDED
@@ -0,0 +1,4 @@
1
+ User-agent: *
2
+ Allow: /
3
+
4
+ Sitemap: https://evoluteur.github.io/npm-pulse/sitemap.xml
package/sitemap.xml ADDED
@@ -0,0 +1,9 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
3
+ <url>
4
+ <loc>https://evoluteur.github.io/npm-pulse/</loc>
5
+ </url>
6
+ <url>
7
+ <loc>https://evoluteur.github.io/npm-pulse/chart.html</loc>
8
+ </url>
9
+ </urlset>