@verifyhash/iso-duration 0.1.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +153 -0
  3. package/index.js +293 -0
  4. package/package.json +37 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 <OWNER-NAME PLACEHOLDER>
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,153 @@
1
+ # iso-duration
2
+
3
+ <!-- publish-prep -->
4
+ > **TODO (owner): pick the final npm name/scope before publishing.**
5
+ > The npm package name is **not** finalized by this project. Convention used here:
6
+ > `package.json` keeps the working slug `iso-duration` as a placeholder — replace it
7
+ > (and the `npm install` line below) with the real published name/scope before
8
+ > running `npm publish`.
9
+
10
+ A tiny, **zero-dependency, zero-network** Node.js library for **ISO 8601
11
+ durations** — the `P3Y6M4DT12H30M5S` strings that show up in JSON Schema
12
+ (`format: duration`), OpenAPI, iCalendar (`RRULE`/`DURATION`), GitHub Actions
13
+ timeouts, Kubernetes manifests, and countless "retry after / cache for / repeat
14
+ every" API fields.
15
+
16
+ It does four things and nothing else:
17
+
18
+ - **`parse(str)`** — string → duration object
19
+ - **`format(obj)`** — duration object → canonical string (round-trips `parse`)
20
+ - **`addToDate` / `subtractFromDate`** — calendar-correct `Date` arithmetic
21
+ - **`humanize(obj)`** — `"3 hours 30 minutes"`
22
+
23
+ No dependencies, no install step, no I/O. Just `index.js` (CommonJS) and pure
24
+ functions. Drop it into any project.
25
+
26
+ ## Who it's for
27
+
28
+ JavaScript/Node developers who receive or emit ISO 8601 durations from APIs,
29
+ schemas, or schedules and want to parse, normalize, do date math on, or display
30
+ them — without pulling in Luxon/moment/date-fns just for the duration bits.
31
+
32
+ ## The duration object
33
+
34
+ Every function speaks the same plain object. All keys are optional; missing
35
+ numeric keys are `0`, and `negative` defaults to `false`:
36
+
37
+ ```js
38
+ { years, months, weeks, days, hours, minutes, seconds, negative }
39
+ ```
40
+
41
+ ## Usage
42
+
43
+ ```js
44
+ const iso = require('iso-duration'); // or require('./index.js')
45
+
46
+ iso.parse('P3Y6M4DT12H30M5S');
47
+ // { years: 3, months: 6, weeks: 0, days: 4,
48
+ // hours: 12, minutes: 30, seconds: 5, negative: false }
49
+
50
+ iso.parse('-PT1H30M'); // { ..., hours: 1, minutes: 30, negative: true }
51
+ iso.parse('P2W'); // { ..., weeks: 2 }
52
+ iso.parse('PT0.5H'); // { ..., hours: 0.5 } (fractional final component)
53
+
54
+ iso.format({ hours: 3, minutes: 30 }); // 'PT3H30M'
55
+ iso.format({}); // 'PT0S'
56
+ iso.format({ weeks: 2 }); // 'P2W'
57
+ iso.format({ hours: 1, negative: true }); // '-PT1H'
58
+
59
+ iso.humanize({ hours: 3, minutes: 30 }); // '3 hours 30 minutes'
60
+ iso.humanize({ hours: 1 }); // '1 hour'
61
+ iso.humanize({}); // '0 seconds'
62
+
63
+ const start = new Date('2020-01-31T00:00:00Z');
64
+ iso.addToDate(start, { months: 1 }); // 2020-02-29T00:00:00.000Z
65
+ start.toISOString(); // unchanged — input is never mutated
66
+ iso.subtractFromDate(start, { days: 10 }); // 2020-01-21T00:00:00.000Z
67
+ ```
68
+
69
+ ## Two design choices to know about
70
+
71
+ Libraries genuinely disagree on these two points, so they are spelled out here
72
+ rather than left to surprise you.
73
+
74
+ ### 1. Weeks normalization
75
+
76
+ ISO 8601 treats `W` (weeks) as mutually exclusive with the other date fields —
77
+ `P2W` is legal, `P1W3D` technically is not. This library is lenient on **input**
78
+ (it will parse `P1W3D` and keep `weeks` and `days` separate) but opinionated on
79
+ **output**:
80
+
81
+ - `format()` emits `P2W` **only when weeks is the sole non-zero component.**
82
+ - In every other case weeks are folded into days at **1 week = 7 days**
83
+ (e.g. `format({ weeks: 1, days: 1 })` → `'P8D'`).
84
+
85
+ This keeps the canonical string valid ISO and guarantees `format` round-trips
86
+ through `parse`: `format(parse(s)) === s` for any canonical `s`, and repeated
87
+ `format(parse(...))` is stable. In date arithmetic, weeks are always treated as
88
+ 7 elapsed days.
89
+
90
+ ### 2. Month (and year) overflow clamps
91
+
92
+ Adding months and years is calendar arithmetic, and calendars have ragged month
93
+ lengths. When the target month is shorter than the source day-of-month, this
94
+ library **clamps to the last day of the target month** — the same rule Luxon,
95
+ Temporal, and date-fns use:
96
+
97
+ - `Jan 31 + P1M` → **Feb 29** in a leap year, **Feb 28** otherwise.
98
+ - `Mar 31 − P1M` → **Feb 29 / Feb 28** (subtraction clamps the same way).
99
+
100
+ The arithmetic order is: **years/months are applied first as whole calendar
101
+ steps (with clamping), then weeks/days/hours/minutes/seconds are added as a flat
102
+ elapsed-milliseconds offset.** Because the calendar step happens first, results
103
+ are deterministic and independent of your machine's timezone (all math is done
104
+ in UTC) and the input `Date` is never mutated — a new `Date` is returned.
105
+
106
+ **Honest limits.** Because day/time components are applied as elapsed
107
+ milliseconds, they do **not** track daylight-saving wall-clock shifts — that is
108
+ correct for UTC/absolute instants but means "+1 day" is always exactly 24 hours,
109
+ not "same local time tomorrow." Fractional **years or months** (rare, e.g.
110
+ `P0.5M`) can't be expressed as an exact number of calendar days, so their
111
+ fractional part is approximated using the mean Gregorian month (30.436875 days);
112
+ integer years/months — the normal case — are always exact.
113
+
114
+ ## Errors
115
+
116
+ `parse()` throws a descriptive `Error` on malformed input, including: an empty
117
+ string, a missing `P`, a `T` separator with no time components (`"PT"`), a bare
118
+ `"P"`, out-of-order fields (`"P3M6Y"`), and time letters outside the `T` block
119
+ (`"P5S"`).
120
+
121
+ ## Running the tests
122
+
123
+ One command, no install, no network:
124
+
125
+ ```
126
+ node test/index.test.js
127
+ ```
128
+
129
+ It prints one line per test and exits non-zero if any fail. Coverage includes
130
+ parse/format round-trips, `P2W` weeks, `PT0S` and negatives, fractional seconds,
131
+ Jan-31 + 1-month month-overflow arithmetic, the no-mutation guarantee, humanize
132
+ pluralization, and malformed-input throw cases.
133
+
134
+ ## License
135
+
136
+ MIT.
137
+
138
+ ## Install
139
+
140
+ > Placeholder name: the `iso-duration` below is the working slug, not a finalized
141
+ > npm name — see the owner TODO near the top of this README.
142
+
143
+ ```sh
144
+ npm install iso-duration
145
+ ```
146
+
147
+ ```js
148
+ const iso = require('iso-duration');
149
+
150
+ iso.parse('P3Y6M4DT12H30M5S'); // { years:3, months:6, days:4, hours:12, minutes:30, seconds:5, ... }
151
+ iso.format({ hours: 3, minutes: 30 }); // 'PT3H30M'
152
+ iso.humanize({ hours: 1, minutes: 30 }); // '1 hour 30 minutes'
153
+ ```
package/index.js ADDED
@@ -0,0 +1,293 @@
1
+ 'use strict';
2
+
3
+ /*
4
+ * iso-duration — zero-dependency ISO 8601 duration parse / format /
5
+ * arithmetic / humanize for Node.js.
6
+ *
7
+ * A "duration object" is a plain object with any of these numeric keys plus a
8
+ * boolean `negative` flag:
9
+ *
10
+ * { years, months, weeks, days, hours, minutes, seconds, negative }
11
+ *
12
+ * All keys are optional; missing numeric keys are treated as 0 and `negative`
13
+ * defaults to false. Every function here is pure — no I/O, no network, no
14
+ * mutation of its arguments.
15
+ */
16
+
17
+ var UNIT_KEYS = ['years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds'];
18
+
19
+ // Average calendar month / year, used ONLY for the rare case of a *fractional*
20
+ // year or month in date arithmetic (see addToDate). 30.436875 days is the mean
21
+ // Gregorian month (365.2425 / 12).
22
+ var MS_PER_DAY = 86400000;
23
+ var AVG_MONTH_MS = 30.436875 * MS_PER_DAY;
24
+ var AVG_YEAR_MS = 365.2425 * MS_PER_DAY;
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // parse
28
+ // ---------------------------------------------------------------------------
29
+
30
+ // P[n]Y[n]M[n]W[n]D [ T[n]H[n]M[n]S ]. A leading '-' (or '+') sets the sign.
31
+ // Numbers may carry a fractional part with '.' or ',' as the decimal mark.
32
+ var NUM = '(\\d+(?:[.,]\\d+)?)';
33
+ var DUR_RE = new RegExp(
34
+ '^([+-])?P' +
35
+ '(?:' + NUM + 'Y)?' +
36
+ '(?:' + NUM + 'M)?' +
37
+ '(?:' + NUM + 'W)?' +
38
+ '(?:' + NUM + 'D)?' +
39
+ '(T' +
40
+ '(?:' + NUM + 'H)?' +
41
+ '(?:' + NUM + 'M)?' +
42
+ '(?:' + NUM + 'S)?' +
43
+ ')?$'
44
+ );
45
+
46
+ function toNumber(raw) {
47
+ if (raw === undefined) return 0;
48
+ return parseFloat(raw.replace(',', '.'));
49
+ }
50
+
51
+ /**
52
+ * Parse an ISO 8601 duration string into a duration object.
53
+ * Throws an Error with a clear message on malformed input.
54
+ */
55
+ function parse(str) {
56
+ if (typeof str !== 'string') {
57
+ throw new Error('iso-duration.parse: expected a string, got ' + typeof str);
58
+ }
59
+ var s = str.trim();
60
+ if (s === '') {
61
+ throw new Error('iso-duration.parse: empty string is not a valid duration');
62
+ }
63
+ var m = DUR_RE.exec(s);
64
+ if (!m) {
65
+ throw new Error(
66
+ 'iso-duration.parse: "' + str + '" is not a valid ISO 8601 duration'
67
+ );
68
+ }
69
+
70
+ var sign = m[1];
71
+ var years = m[2];
72
+ var months = m[3];
73
+ var weeks = m[4];
74
+ var days = m[5];
75
+ var timeBlock = m[6]; // the literal 'T...' block, or undefined
76
+ var hours = m[7];
77
+ var minutes = m[8];
78
+ var seconds = m[9];
79
+
80
+ var hasDate =
81
+ years !== undefined ||
82
+ months !== undefined ||
83
+ weeks !== undefined ||
84
+ days !== undefined;
85
+ var hasTime =
86
+ hours !== undefined || minutes !== undefined || seconds !== undefined;
87
+
88
+ // A 'T' designator was present but no time components followed it.
89
+ if (timeBlock !== undefined && !hasTime) {
90
+ throw new Error(
91
+ 'iso-duration.parse: "' + str + '" has a "T" separator but no time components'
92
+ );
93
+ }
94
+ // "P" (or "-P") alone — no components at all.
95
+ if (!hasDate && !hasTime) {
96
+ throw new Error(
97
+ 'iso-duration.parse: "' + str + '" has no duration components'
98
+ );
99
+ }
100
+
101
+ return {
102
+ years: toNumber(years),
103
+ months: toNumber(months),
104
+ weeks: toNumber(weeks),
105
+ days: toNumber(days),
106
+ hours: toNumber(hours),
107
+ minutes: toNumber(minutes),
108
+ seconds: toNumber(seconds),
109
+ negative: sign === '-'
110
+ };
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // format
115
+ // ---------------------------------------------------------------------------
116
+
117
+ function num(obj, key) {
118
+ var v = obj[key];
119
+ return typeof v === 'number' && !isNaN(v) ? v : 0;
120
+ }
121
+
122
+ // Render a number without a trailing ".0" and without locale grouping.
123
+ function fmtNum(n) {
124
+ return String(n);
125
+ }
126
+
127
+ /**
128
+ * Format a duration object into a canonical ISO 8601 string.
129
+ *
130
+ * Rules:
131
+ * - Zero components are omitted.
132
+ * - An all-zero duration formats as "PT0S".
133
+ * - A negative duration gets a leading "-".
134
+ * - Weeks are emitted as "P2W" ONLY when weeks is the sole non-zero
135
+ * component; otherwise weeks are folded into days (1 week = 7 days) so the
136
+ * output round-trips through parse().
137
+ */
138
+ function format(obj) {
139
+ if (obj == null || typeof obj !== 'object') {
140
+ throw new Error('iso-duration.format: expected a duration object');
141
+ }
142
+
143
+ var years = num(obj, 'years');
144
+ var months = num(obj, 'months');
145
+ var weeks = num(obj, 'weeks');
146
+ var days = num(obj, 'days');
147
+ var hours = num(obj, 'hours');
148
+ var minutes = num(obj, 'minutes');
149
+ var seconds = num(obj, 'seconds');
150
+
151
+ var weeksIsSole =
152
+ weeks !== 0 &&
153
+ years === 0 &&
154
+ months === 0 &&
155
+ days === 0 &&
156
+ hours === 0 &&
157
+ minutes === 0 &&
158
+ seconds === 0;
159
+
160
+ var out = obj.negative ? '-P' : 'P';
161
+
162
+ if (weeksIsSole) {
163
+ return out + fmtNum(weeks) + 'W';
164
+ }
165
+
166
+ // Fold weeks into days for every other case.
167
+ days = days + weeks * 7;
168
+
169
+ if (years !== 0) out += fmtNum(years) + 'Y';
170
+ if (months !== 0) out += fmtNum(months) + 'M';
171
+ if (days !== 0) out += fmtNum(days) + 'D';
172
+
173
+ if (hours !== 0 || minutes !== 0 || seconds !== 0) {
174
+ out += 'T';
175
+ if (hours !== 0) out += fmtNum(hours) + 'H';
176
+ if (minutes !== 0) out += fmtNum(minutes) + 'M';
177
+ if (seconds !== 0) out += fmtNum(seconds) + 'S';
178
+ }
179
+
180
+ // All-zero duration.
181
+ if (out === 'P' || out === '-P') {
182
+ return 'PT0S';
183
+ }
184
+ return out;
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // date arithmetic
189
+ // ---------------------------------------------------------------------------
190
+
191
+ function daysInMonth(year, monthIndex) {
192
+ // monthIndex 0-11; day 0 of next month = last day of this month.
193
+ return new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
194
+ }
195
+
196
+ // Add an integer number of calendar months (may be negative), clamping the
197
+ // day-of-month on overflow (Jan 31 + 1 month -> Feb 28/29).
198
+ function addWholeMonths(d, months) {
199
+ if (months === 0) return;
200
+ var day = d.getUTCDate();
201
+ d.setUTCDate(1);
202
+ d.setUTCMonth(d.getUTCMonth() + months);
203
+ var dim = daysInMonth(d.getUTCFullYear(), d.getUTCMonth());
204
+ d.setUTCDate(Math.min(day, dim));
205
+ }
206
+
207
+ // direction: +1 for addToDate, -1 for subtractFromDate.
208
+ function applyDuration(date, obj, direction) {
209
+ if (!(date instanceof Date) || isNaN(date.getTime())) {
210
+ throw new Error('iso-duration: first argument must be a valid Date');
211
+ }
212
+ if (obj == null || typeof obj !== 'object') {
213
+ throw new Error('iso-duration: second argument must be a duration object');
214
+ }
215
+
216
+ var sign = (obj.negative ? -1 : 1) * direction;
217
+
218
+ var years = num(obj, 'years');
219
+ var months = num(obj, 'months');
220
+
221
+ var totalMonths = sign * (years * 12 + months);
222
+ var wholeMonths = totalMonths < 0 ? Math.ceil(totalMonths) : Math.floor(totalMonths);
223
+ var fracMonths = totalMonths - wholeMonths;
224
+
225
+ // 1) Calendar-correct year/month step first (with day clamping).
226
+ var result = new Date(date.getTime());
227
+ addWholeMonths(result, wholeMonths);
228
+
229
+ // 2) Everything else as elapsed milliseconds. Any fractional month/year
230
+ // remainder is approximated with the mean Gregorian month length.
231
+ var elapsedMs =
232
+ sign *
233
+ (num(obj, 'weeks') * 7 * MS_PER_DAY +
234
+ num(obj, 'days') * MS_PER_DAY +
235
+ num(obj, 'hours') * 3600000 +
236
+ num(obj, 'minutes') * 60000 +
237
+ num(obj, 'seconds') * 1000) +
238
+ fracMonths * AVG_MONTH_MS;
239
+
240
+ return new Date(result.getTime() + elapsedMs);
241
+ }
242
+
243
+ function addToDate(date, obj) {
244
+ return applyDuration(date, obj, 1);
245
+ }
246
+
247
+ function subtractFromDate(date, obj) {
248
+ return applyDuration(date, obj, -1);
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // humanize
253
+ // ---------------------------------------------------------------------------
254
+
255
+ var UNIT_LABELS = {
256
+ years: 'year',
257
+ months: 'month',
258
+ weeks: 'week',
259
+ days: 'day',
260
+ hours: 'hour',
261
+ minutes: 'minute',
262
+ seconds: 'second'
263
+ };
264
+
265
+ /**
266
+ * Render a duration object as an English phrase, e.g. "3 hours 30 minutes".
267
+ * Zero units are omitted; an all-zero duration returns "0 seconds".
268
+ * The sign is not rendered — inspect obj.negative for direction.
269
+ */
270
+ function humanize(obj) {
271
+ if (obj == null || typeof obj !== 'object') {
272
+ throw new Error('iso-duration.humanize: expected a duration object');
273
+ }
274
+ var parts = [];
275
+ for (var i = 0; i < UNIT_KEYS.length; i++) {
276
+ var key = UNIT_KEYS[i];
277
+ var v = num(obj, key);
278
+ if (v === 0) continue;
279
+ var label = UNIT_LABELS[key];
280
+ var mag = Math.abs(v);
281
+ parts.push(mag + ' ' + label + (mag === 1 ? '' : 's'));
282
+ }
283
+ if (parts.length === 0) return '0 seconds';
284
+ return parts.join(' ');
285
+ }
286
+
287
+ module.exports = {
288
+ parse: parse,
289
+ format: format,
290
+ addToDate: addToDate,
291
+ subtractFromDate: subtractFromDate,
292
+ humanize: humanize
293
+ };
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@verifyhash/iso-duration",
3
+ "version": "0.1.0",
4
+ "description": "Zero-dependency ISO 8601 duration parse, format, calendar-correct date arithmetic, and English humanize for Node.js.",
5
+ "keywords": [
6
+ "iso8601",
7
+ "iso-8601",
8
+ "duration",
9
+ "parse",
10
+ "format",
11
+ "period",
12
+ "pt1h30m",
13
+ "humanize",
14
+ "date-arithmetic"
15
+ ],
16
+ "main": "index.js",
17
+ "scripts": {
18
+ "test": "node test/index.test.js"
19
+ },
20
+ "license": "MIT",
21
+ "files": [
22
+ "index.js",
23
+ "README.md"
24
+ ],
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/verifyhash/libs.git",
31
+ "directory": "iso-duration"
32
+ },
33
+ "homepage": "https://github.com/verifyhash/libs/tree/main/iso-duration#readme",
34
+ "bugs": {
35
+ "url": "https://github.com/verifyhash/libs/issues"
36
+ }
37
+ }